Implements the stage 1–5 program from #38174 (3.1 dropped per request). Backend (api/) - New POST /workflow-generate/suggestions: short, workspace-grounded example instructions generated on the tenant default model; soft-fails to {suggestions: []}. - New POST /workflow-generate/stream: SSE plan-first generation that emits the planner result (plan) then the built graph (result), reusing the existing planner/builder/validate stages — the streamed result provably equals the blocking /workflow-generate return. - mode:"auto" on both generate endpoints; a lightweight classifier resolves the concrete mode before planning and it is echoed back as `mode`. Frontend (web/) - Remove the low-value "Ideal output" field from the modal. - Replace the static example chips with AI-generated, workspace-grounded suggestions (cached per session per mode, ↻ refresh, silent static fallback). - Plan-first streaming: show the planner outline as it lands, then the graph (replaces the guessed phase timer) via an isolated SSE consumer that reuses the existing cookie-auth/CSRF/abort setup without touching handleStream. - Review stage: planner metadata header (icon + name), an actionable error panel (Regenerate / Install tools / affected node), a /refine diff summary, and a post-apply nudge toward cmd+k /refine. - Entry & input: ⌘/Ctrl+Enter to generate, autofocus, palette inline capture (`/create workflow <text>`), an "Auto" submenu option, and last-instruction resume across opens. i18n keys added across all 23 locales. New tests cover the suggestions component (fetch → fallback → refresh), the graph diff util, and the updated /create command. Refs #38174 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
7.6 KiB
Python
192 lines
7.6 KiB
Python
"""
|
|
Workflow generator service.
|
|
|
|
Thin facade over ``core.workflow.generator.WorkflowGenerator`` that owns the
|
|
model-manager / model-instance plumbing. Controllers call this; the pure
|
|
domain class never touches the model registry directly.
|
|
|
|
Pattern mirrors ``LLMGenerator.generate_rule_config`` — see
|
|
``core/llm_generator/llm_generator.py`` — but lives in ``services/`` because
|
|
the generator output is consumed at the application layer (sync_draft_workflow,
|
|
createApp) rather than from inside another workflow.
|
|
"""
|
|
|
|
import logging
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
from core.app.app_config.entities import ModelConfig
|
|
from core.llm_generator.llm_generator import LLMGenerator
|
|
from core.model_manager import ModelInstance, ModelManager
|
|
from core.workflow.generator import WorkflowGenerator
|
|
from core.workflow.generator.tool_catalogue import build_tool_catalogue, format_tool_catalogue, installed_tool_keys
|
|
from core.workflow.generator.types import (
|
|
WorkflowGenerateResultDict,
|
|
WorkflowGenerationMode,
|
|
WorkflowGenerationModeRequest,
|
|
)
|
|
from graphon.model_runtime.entities.model_entities import ModelType
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WorkflowGeneratorService:
|
|
"""
|
|
Coordinates model resolution with the workflow generator domain logic.
|
|
|
|
Single public method (``generate_workflow_graph``) keeps the surface area
|
|
minimal — the cmd+k `/create` flow is the only caller today.
|
|
"""
|
|
|
|
@classmethod
|
|
def generate_workflow_graph(
|
|
cls,
|
|
*,
|
|
tenant_id: str,
|
|
mode: WorkflowGenerationModeRequest,
|
|
instruction: str,
|
|
model_config: ModelConfig,
|
|
ideal_output: str = "",
|
|
current_graph: dict[str, Any] | None = None,
|
|
) -> WorkflowGenerateResultDict:
|
|
"""
|
|
Resolve a model instance for the tenant and run the generator.
|
|
|
|
``mode`` accepts the ``"auto"`` sentinel — when set, the instruction is
|
|
classified into a concrete ``workflow`` / ``advanced-chat`` mode (one
|
|
tiny LLM call) before planning so the rest of the pipeline runs against
|
|
a concrete mode. The resolved mode is echoed back under the result's
|
|
``mode`` key.
|
|
|
|
``current_graph`` is the existing draft graph for the cmd+k `/refine`
|
|
flow — when present the generator refines it instead of creating a new
|
|
graph from scratch. ``None`` is the `/create` path.
|
|
|
|
Errors from the LLM call (auth, quota, invoke) propagate so the
|
|
controller can map them to existing HTTP error envelopes (same
|
|
envelope as ``/rule-generate``).
|
|
"""
|
|
resolved_mode = cls._resolve_mode(
|
|
tenant_id=tenant_id, mode=mode, instruction=instruction, model_config=model_config
|
|
)
|
|
model_instance, model_parameters, tool_catalogue_text, installed_tools = cls._resolve_generation_context(
|
|
tenant_id=tenant_id, model_config=model_config
|
|
)
|
|
|
|
return WorkflowGenerator.generate_workflow_graph(
|
|
model_instance=model_instance,
|
|
model_parameters=model_parameters,
|
|
provider=model_config.provider,
|
|
model_name=model_config.name,
|
|
model_mode=model_config.mode.value,
|
|
mode=resolved_mode,
|
|
instruction=instruction,
|
|
ideal_output=ideal_output,
|
|
tool_catalogue_text=tool_catalogue_text,
|
|
installed_tools=installed_tools,
|
|
current_graph=current_graph,
|
|
)
|
|
|
|
@classmethod
|
|
def generate_workflow_graph_stream(
|
|
cls,
|
|
*,
|
|
tenant_id: str,
|
|
mode: WorkflowGenerationModeRequest,
|
|
instruction: str,
|
|
model_config: ModelConfig,
|
|
ideal_output: str = "",
|
|
current_graph: dict[str, Any] | None = None,
|
|
) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
"""
|
|
Streaming sibling of ``generate_workflow_graph``.
|
|
|
|
Resolves the same model instance / tool catalogue / concrete mode, then
|
|
delegates to ``WorkflowGenerator.generate_workflow_graph_stream`` and
|
|
yields its ``(event_name, payload)`` tuples through to the controller's
|
|
SSE writer. Provider-init / invoke errors raised while resolving the
|
|
model instance propagate to the caller (the controller emits them as a
|
|
single ``result`` SSE event).
|
|
"""
|
|
resolved_mode = cls._resolve_mode(
|
|
tenant_id=tenant_id, mode=mode, instruction=instruction, model_config=model_config
|
|
)
|
|
model_instance, model_parameters, tool_catalogue_text, installed_tools = cls._resolve_generation_context(
|
|
tenant_id=tenant_id, model_config=model_config
|
|
)
|
|
|
|
yield from WorkflowGenerator.generate_workflow_graph_stream(
|
|
model_instance=model_instance,
|
|
model_parameters=model_parameters,
|
|
provider=model_config.provider,
|
|
model_name=model_config.name,
|
|
model_mode=model_config.mode.value,
|
|
mode=resolved_mode,
|
|
instruction=instruction,
|
|
ideal_output=ideal_output,
|
|
tool_catalogue_text=tool_catalogue_text,
|
|
installed_tools=installed_tools,
|
|
current_graph=current_graph,
|
|
)
|
|
|
|
@classmethod
|
|
def _resolve_mode(
|
|
cls,
|
|
*,
|
|
tenant_id: str,
|
|
mode: WorkflowGenerationModeRequest,
|
|
instruction: str,
|
|
model_config: ModelConfig,
|
|
) -> WorkflowGenerationMode:
|
|
"""Resolve the request mode into a concrete generation mode.
|
|
|
|
``"auto"`` triggers a one-word LLM classification using the model the
|
|
user already picked; everything else passes through unchanged. The
|
|
classifier never raises (defaults to ``advanced-chat``), so ``auto``
|
|
never blocks generation.
|
|
"""
|
|
if mode == "auto":
|
|
return LLMGenerator.classify_workflow_mode(
|
|
tenant_id=tenant_id, instruction=instruction, model_config=model_config
|
|
)
|
|
return mode
|
|
|
|
@classmethod
|
|
def _resolve_generation_context(
|
|
cls,
|
|
*,
|
|
tenant_id: str,
|
|
model_config: ModelConfig,
|
|
) -> tuple[ModelInstance, dict[str, Any], str, set[tuple[str, str]] | None]:
|
|
"""Resolve the model instance, completion params, and tool catalogue.
|
|
|
|
Build the installed-tool catalogue for this tenant so the planner /
|
|
builder can pick concrete tools instead of inventing names, AND so the
|
|
runner's validator can reject hallucinated tool names BEFORE the user
|
|
clicks Apply. A failure here (plugin daemon unreachable, etc.) must not
|
|
block generation — log and fall back to the no-tool path, which also
|
|
disables tool validation in the runner (``None`` sentinel rather than
|
|
empty set, so we don't reject every tool node just because we couldn't
|
|
enumerate the catalogue).
|
|
"""
|
|
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
|
model_instance = model_manager.get_model_instance(
|
|
tenant_id=tenant_id,
|
|
model_type=ModelType.LLM,
|
|
provider=model_config.provider,
|
|
model=model_config.name,
|
|
)
|
|
|
|
model_parameters: dict[str, Any] = dict(model_config.completion_params or {})
|
|
|
|
tool_catalogue_text = ""
|
|
installed_tools: set[tuple[str, str]] | None = None
|
|
try:
|
|
entries = build_tool_catalogue(tenant_id)
|
|
tool_catalogue_text = format_tool_catalogue(entries)
|
|
installed_tools = installed_tool_keys(entries)
|
|
except Exception:
|
|
logger.exception("Workflow generator: failed to build tool catalogue for tenant %s", tenant_id)
|
|
|
|
return model_instance, model_parameters, tool_catalogue_text, installed_tools
|