Refactor workflow agent and routing components for improved functionality
- Introduce WorkflowAgentStage to manage agent stage configurations and enhance interaction with the workflow engine. - Implement WorkflowEdgeEvaluator for priority-aware edge evaluation, improving routing decisions based on conditions and user turns. - Update WorkflowBrain to handle user turns and routing more effectively, ensuring agents cannot have only one default path. - Enhance CallEndCoordinator to track speech events and manage call termination based on queued speech. - Add new models and output handling for workflow interactions, improving clarity and maintainability. - Update tests to validate the new routing logic and agent behavior under various scenarios.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
你编写具有可维护性、高可读性、可扩展性、模块化的的代码,尽量不去对pipecat框架本身修改
|
||||
以MVP构建为目标
|
||||
你编写具有可维护性、高可读性、可扩展性、模块化的的代码
|
||||
适合CS的本科学生阅读修改
|
||||
尽量不去对pipecat框架本身修改
|
||||
以MVP构建为目标
|
||||
编写代码之前先用易于理解的语言说清楚思路
|
||||
|
||||
界面设计要参考 frontend/DESIGN.md
|
||||
@@ -55,6 +55,10 @@ class CallEndPort(Protocol):
|
||||
|
||||
def arm_after_speech(self) -> None: ...
|
||||
|
||||
def track_speech(self) -> None: ...
|
||||
|
||||
async def arm_after_tracked_speech(self) -> None: ...
|
||||
|
||||
async def finish_after_current_speech(self, *, has_text: bool) -> None: ...
|
||||
|
||||
async def finish(self) -> None: ...
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
@@ -19,26 +20,26 @@ from pipecat.frames.frames import (
|
||||
LLMRunFrame,
|
||||
LLMUpdateSettingsFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
from pipecat.services.settings import LLMSettings
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||
from services.knowledge import search as search_knowledge
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||
from services.workflow.agent import WorkflowAgentStage
|
||||
from services.workflow.models import (
|
||||
RouteStatus,
|
||||
WorkflowRuntimeState,
|
||||
WorkflowStatus,
|
||||
)
|
||||
from services.workflow.output import WorkflowOutput
|
||||
from services.workflow.routing import WorkflowEdgeEvaluator
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
from services.workflow_router import STAY_ON_CURRENT_NODE, WorkflowLLMRouter
|
||||
from services.workflow_router import WorkflowLLMRouter
|
||||
|
||||
|
||||
MAX_AUTOMATIC_HOPS = 50
|
||||
AGENT_STAGE_INSTRUCTION = (
|
||||
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
|
||||
"不要自行解释、模拟或宣布节点切换。"
|
||||
)
|
||||
|
||||
|
||||
class WorkflowBrain(BaseBrain):
|
||||
@@ -69,11 +70,18 @@ class WorkflowBrain(BaseBrain):
|
||||
self._runtime: BrainRuntime | None = None
|
||||
self._manager: FlowManager | None = None
|
||||
self._router = WorkflowLLMRouter(cfg or AssistantConfig(type="workflow"))
|
||||
self._edge_evaluator = WorkflowEdgeEvaluator(
|
||||
self._engine,
|
||||
self._store,
|
||||
self._router_for_node,
|
||||
)
|
||||
self._state = WorkflowRuntimeState(current_node_id=self._engine.start_id)
|
||||
self._turn_lock = asyncio.Lock()
|
||||
self._output: WorkflowOutput | None = None
|
||||
self._agent_stage: WorkflowAgentStage | None = None
|
||||
self._ended = False
|
||||
self._greeting_context_message: dict[str, str] | None = None
|
||||
self._startup_waiting_for_greeting = False
|
||||
self._client_ready = False
|
||||
self._pending_visible_speech_events: list[dict[str, Any]] = []
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
return self._engine.greeting(self._store) or cfg.greeting
|
||||
@@ -95,10 +103,23 @@ class WorkflowBrain(BaseBrain):
|
||||
self._tools = ToolExecutor(self._store)
|
||||
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
||||
self._router = WorkflowLLMRouter(cfg)
|
||||
self._edge_evaluator = WorkflowEdgeEvaluator(
|
||||
self._engine,
|
||||
self._store,
|
||||
self._router_for_node,
|
||||
)
|
||||
self._state = WorkflowRuntimeState(current_node_id=self._engine.start_id)
|
||||
self._turn_lock = asyncio.Lock()
|
||||
self._output = WorkflowOutput(self._store, runtime)
|
||||
self._agent_stage = WorkflowAgentStage(
|
||||
cfg=cfg,
|
||||
engine=self._engine,
|
||||
store=self._store,
|
||||
runtime=runtime,
|
||||
)
|
||||
self._ended = False
|
||||
self._greeting_context_message = None
|
||||
self._startup_waiting_for_greeting = False
|
||||
self._client_ready = False
|
||||
self._pending_visible_speech_events = []
|
||||
self._manager = FlowManager(
|
||||
worker=runtime.worker,
|
||||
llm=runtime.llm,
|
||||
@@ -118,6 +139,7 @@ class WorkflowBrain(BaseBrain):
|
||||
return message
|
||||
|
||||
async def on_connected(self, *, greeting_pending: bool = False) -> None:
|
||||
self._state.enter(self._engine.start_id, WorkflowStatus.STARTING)
|
||||
await self._emit_node_active(self._engine.start_id)
|
||||
await self._emit_variables(
|
||||
reason="initialized",
|
||||
@@ -139,6 +161,7 @@ class WorkflowBrain(BaseBrain):
|
||||
|
||||
node_config = await self._initial_node_config()
|
||||
await self._manager.initialize(node_config)
|
||||
await self._after_node_activated(node_config)
|
||||
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
||||
|
||||
async def on_greeting_finished(self) -> None:
|
||||
@@ -152,17 +175,28 @@ class WorkflowBrain(BaseBrain):
|
||||
|
||||
node_config = await self._initial_node_config()
|
||||
if node_config.get("name") == self._engine.start_id:
|
||||
self._state.enter(self._engine.start_id, WorkflowStatus.WAITING_USER)
|
||||
return
|
||||
await manager.set_node_from_config(node_config)
|
||||
await self._after_node_activated(node_config)
|
||||
logger.info(f"Start 开场白结束,进入节点: {manager.current_node}")
|
||||
|
||||
async def _initial_node_config(self) -> NodeConfig:
|
||||
"""Resolve the immediate path from Start without evaluating LLM edges."""
|
||||
# Start LLM conditions need an actual user turn. Expression-only and
|
||||
# default-only starts may still advance immediately at connection time.
|
||||
edge = await self._select_edge(
|
||||
self._engine.start_id,
|
||||
evaluate_llm=False,
|
||||
"""Only a default-only Start advances before the first user turn."""
|
||||
outgoing = self._engine.outgoing(self._engine.start_id)
|
||||
has_condition = any(
|
||||
self._engine.edge_mode(edge) != "always" for edge in outgoing
|
||||
)
|
||||
if has_condition:
|
||||
self._state.enter(self._engine.start_id, WorkflowStatus.WAITING_USER)
|
||||
return self._passive_node_config(self._engine.start_id)
|
||||
edge = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in outgoing
|
||||
if self._engine.edge_mode(candidate) == "always"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return (
|
||||
await self._follow_edge(edge)
|
||||
@@ -172,18 +206,14 @@ class WorkflowBrain(BaseBrain):
|
||||
|
||||
async def on_client_ready(self) -> None:
|
||||
"""Replay state that may have been emitted before WebRTC data was ready."""
|
||||
self._client_ready = True
|
||||
pending_speech_events = self._pending_visible_speech_events
|
||||
self._pending_visible_speech_events = []
|
||||
for message in pending_speech_events:
|
||||
await self._require_runtime().queue_frame(
|
||||
OutputTransportMessageUrgentFrame(message=message)
|
||||
)
|
||||
await self._require_output().mark_client_ready()
|
||||
current_node = (
|
||||
str(self._manager.current_node)
|
||||
if self._manager and self._manager.current_node
|
||||
else self._engine.start_id
|
||||
else self._state.current_node_id
|
||||
)
|
||||
if current_node != self._state.current_node_id:
|
||||
self._state.current_node_id = current_node
|
||||
await self._emit_node_active(current_node)
|
||||
await self._emit_variables(
|
||||
reason="client_ready",
|
||||
@@ -198,114 +228,69 @@ class WorkflowBrain(BaseBrain):
|
||||
"""Route a complete user turn before the active stage may reply."""
|
||||
if not content or self._ended:
|
||||
return True
|
||||
async with self._turn_lock:
|
||||
return await self._handle_user_turn_end(content)
|
||||
|
||||
async def _handle_user_turn_end(self, content: str) -> bool:
|
||||
"""Serialized implementation so one user turn cannot transition twice."""
|
||||
self.record_user_message(content)
|
||||
self._state.begin_user_turn(content)
|
||||
manager = self._require_manager()
|
||||
current = manager.current_node
|
||||
current = self._state.current_node_id
|
||||
if not current:
|
||||
return True
|
||||
|
||||
edge = await self._select_edge(current)
|
||||
self._state.status = WorkflowStatus.ROUTING
|
||||
decision = await self._edge_evaluator.evaluate(current)
|
||||
if decision.status == RouteStatus.ERROR:
|
||||
await self._require_output().emit_error(
|
||||
decision.error or "工作流路由失败",
|
||||
node_id=current,
|
||||
code="workflow_routing_error",
|
||||
)
|
||||
return await self._continue_current_node_after_no_transition(current)
|
||||
|
||||
if edge and manager.current_node == current:
|
||||
if decision.edge and manager.current_node == current:
|
||||
next_config = await self._follow_edge(
|
||||
edge,
|
||||
decision.edge,
|
||||
triggering_user_text=content,
|
||||
)
|
||||
await manager.set_node_from_config(next_config)
|
||||
next_node = str(next_config.get("name") or "")
|
||||
if (
|
||||
self._engine.node_type(next_node) == "agent"
|
||||
and self._engine.data(next_node).get("entryMode", "wait_user")
|
||||
== "wait_user"
|
||||
):
|
||||
await self._require_runtime().queue_frame(LLMRunFrame())
|
||||
await self._after_node_activated(
|
||||
next_config,
|
||||
triggering_user_text=content,
|
||||
)
|
||||
return True
|
||||
|
||||
if self._engine.node_type(current) != "agent":
|
||||
# Start/Action/Handoff have no conversational LLM of their own.
|
||||
# Keep waiting so a later user turn may satisfy another condition.
|
||||
return await self._continue_current_node_after_no_transition(current)
|
||||
|
||||
async def _continue_current_node_after_no_transition(
|
||||
self,
|
||||
node_id: str,
|
||||
) -> bool:
|
||||
if self._engine.node_type(node_id) != "agent":
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
return True
|
||||
|
||||
# The incoming LLMContextFrame is intentionally suppressed by the
|
||||
# pipeline router. Queue prompt refresh + inference in this order so
|
||||
# this user turn is answered with the current Agent's latest variables.
|
||||
await self._refresh_agent_prompt(current)
|
||||
await self._refresh_agent_prompt(node_id)
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||
await self._require_runtime().queue_frame(LLMRunFrame())
|
||||
return True
|
||||
|
||||
async def _select_edge(
|
||||
self,
|
||||
node_id: str,
|
||||
*,
|
||||
evaluate_llm: bool = True,
|
||||
) -> dict | None:
|
||||
"""Resolve conditional paths by priority, then use the default path."""
|
||||
expression_edge = self._engine.deterministic_edge(
|
||||
node_id,
|
||||
self._store,
|
||||
include_default=False,
|
||||
)
|
||||
outgoing = self._engine.outgoing(node_id)
|
||||
all_llm_edges = [
|
||||
candidate
|
||||
for candidate in outgoing
|
||||
if self._engine.edge_mode(candidate) == "llm"
|
||||
]
|
||||
llm_edges = all_llm_edges
|
||||
default_edge = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in outgoing
|
||||
if self._engine.edge_mode(candidate) == "always"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# A matching expression is a deterministic priority boundary. Only LLM
|
||||
# conditions before it may win; later conditions must not bypass it.
|
||||
if expression_edge:
|
||||
expression_index = outgoing.index(expression_edge)
|
||||
llm_edges = [
|
||||
candidate
|
||||
for candidate in llm_edges
|
||||
if outgoing.index(candidate) < expression_index
|
||||
]
|
||||
|
||||
if not evaluate_llm:
|
||||
if expression_edge and not llm_edges:
|
||||
return expression_edge
|
||||
if all_llm_edges:
|
||||
return None
|
||||
return default_edge
|
||||
|
||||
if not llm_edges:
|
||||
return expression_edge or default_edge
|
||||
|
||||
selected = await self._router_for_node(node_id).select_edge(
|
||||
node_name=self._engine.name(node_id),
|
||||
node_prompt=self._engine.routing_prompt(node_id, self._store),
|
||||
edges=llm_edges,
|
||||
history=self._store.history,
|
||||
variables={
|
||||
key: value
|
||||
for key, value in self._store.values.items()
|
||||
if not key.startswith("system__")
|
||||
},
|
||||
edge_name=self._engine.edge_fn_name,
|
||||
edge_description=self._engine.edge_description,
|
||||
)
|
||||
if selected == STAY_ON_CURRENT_NODE:
|
||||
return expression_edge or default_edge
|
||||
if not selected:
|
||||
return expression_edge
|
||||
return next(
|
||||
(
|
||||
candidate
|
||||
for candidate in llm_edges
|
||||
if self._engine.edge_fn_name(candidate) == selected
|
||||
),
|
||||
None,
|
||||
)
|
||||
"""Compatibility helper used by automatic-node traversal and tests."""
|
||||
decision = await self._edge_evaluator.evaluate(node_id)
|
||||
if decision.status == RouteStatus.ERROR:
|
||||
await self._require_output().emit_error(
|
||||
decision.error or "工作流路由失败",
|
||||
node_id=node_id,
|
||||
code="workflow_routing_error",
|
||||
)
|
||||
return None
|
||||
return decision.edge
|
||||
|
||||
async def on_assistant_text_end(
|
||||
self,
|
||||
@@ -316,88 +301,29 @@ class WorkflowBrain(BaseBrain):
|
||||
if not content or interrupted or self._ended:
|
||||
return
|
||||
self._store.record("agent", content, completed_agent_turn=True)
|
||||
self._state.consume_user_turn()
|
||||
if self._engine.node_type(self._state.current_node_id) == "agent":
|
||||
self._state.status = WorkflowStatus.WAITING_USER
|
||||
|
||||
async def _refresh_agent_prompt(self, node_id: str) -> None:
|
||||
runtime = self._require_runtime()
|
||||
await runtime.queue_frame(
|
||||
LLMUpdateSettingsFrame(
|
||||
delta=LLMSettings(
|
||||
system_instruction=self._agent_role_message(node_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
await self._require_agent_stage().refresh_prompt(node_id)
|
||||
|
||||
def _agent_role_message(self, node_id: str) -> str:
|
||||
"""Build one provider-compatible system instruction for an Agent stage."""
|
||||
stage_prompt = self._engine.prompt_for(node_id, self._store)
|
||||
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
|
||||
return self._require_agent_stage().role_message(node_id)
|
||||
|
||||
def _router_for_node(self, node_id: str) -> WorkflowLLMRouter:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
resource_id = stage.llm_resource_id
|
||||
cfg = self._cfg
|
||||
resource = cfg.workflow_model_resources.get(resource_id) if cfg else None
|
||||
if not cfg or not resource:
|
||||
if self._agent_stage is None:
|
||||
return self._router
|
||||
from services.pipecat.service_factory import config_with_resource
|
||||
|
||||
return WorkflowLLMRouter(config_with_resource(cfg, resource))
|
||||
return self._agent_stage.router_for_node(node_id, self._router)
|
||||
|
||||
async def _apply_agent_stage(self, node_id: str) -> None:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
await self._emit_node_active(node_id)
|
||||
if self._runtime and self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(True)
|
||||
runtime = self._require_runtime()
|
||||
if runtime.apply_turn_config:
|
||||
await runtime.apply_turn_config(
|
||||
stage.enable_interrupt,
|
||||
stage.turn_config,
|
||||
)
|
||||
if runtime.switch_services:
|
||||
await runtime.switch_services(
|
||||
stage.llm_resource_id or None,
|
||||
stage.asr_resource_id or None,
|
||||
stage.tts_resource_id or None,
|
||||
)
|
||||
if runtime.set_knowledge_scope:
|
||||
runtime.set_knowledge_scope(
|
||||
{
|
||||
"knowledge_base_id": stage.knowledge_base_id,
|
||||
"mode": stage.knowledge_mode,
|
||||
"top_n": stage.knowledge_top_n,
|
||||
"score_threshold": stage.knowledge_score_threshold,
|
||||
}
|
||||
)
|
||||
await self._require_agent_stage().apply(node_id)
|
||||
|
||||
def _agent_config(
|
||||
self,
|
||||
node_id: str,
|
||||
leading_messages: list[dict[str, str]] | None = None,
|
||||
) -> NodeConfig:
|
||||
data = self._engine.data(node_id)
|
||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||
fixed_reply_messages: list[dict[str, str]] = (
|
||||
[{"role": "assistant", "content": entry_speech}]
|
||||
if entry_mode == "fixed_speech" and entry_speech
|
||||
else []
|
||||
)
|
||||
strategy = (
|
||||
ContextStrategy.RESET
|
||||
if data.get("contextPolicy") == "fresh"
|
||||
else ContextStrategy.APPEND
|
||||
)
|
||||
greeting_messages = (
|
||||
[deepcopy(self._greeting_context_message)]
|
||||
if strategy == ContextStrategy.RESET and self._greeting_context_message
|
||||
else []
|
||||
)
|
||||
task_messages = [
|
||||
*greeting_messages,
|
||||
*(leading_messages or []),
|
||||
*fixed_reply_messages,
|
||||
]
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
functions: list[FlowsFunctionSchema] = []
|
||||
for tool_id in stage.tool_ids:
|
||||
@@ -407,36 +333,49 @@ class WorkflowBrain(BaseBrain):
|
||||
knowledge_function = self._knowledge_function(node_id)
|
||||
if knowledge_function:
|
||||
functions.append(knowledge_function)
|
||||
config: NodeConfig = {
|
||||
"name": node_id,
|
||||
"role_message": self._agent_role_message(node_id),
|
||||
# Flows writes task_messages into the Pipecat LLM context. The
|
||||
# pre-action below is responsible only for display, persistence,
|
||||
# dynamic conversation history, and TTS playback.
|
||||
"task_messages": task_messages,
|
||||
"functions": functions,
|
||||
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||
"respond_immediately": entry_mode == "generate",
|
||||
}
|
||||
if entry_mode == "fixed_speech":
|
||||
config["pre_actions"] = [
|
||||
{
|
||||
"type": "workflow_fixed_speech",
|
||||
"text": entry_speech,
|
||||
"node_id": node_id,
|
||||
"handler": self._play_fixed_speech,
|
||||
}
|
||||
]
|
||||
return config
|
||||
|
||||
async def _play_fixed_speech(self, action: dict, _flow_manager: FlowManager) -> None:
|
||||
"""Play and persist Agent entry speech without creating an LLM turn."""
|
||||
await self._queue_visible_speech(
|
||||
str(action.get("text") or ""),
|
||||
source="workflow-fixed-reply",
|
||||
node_id=str(action.get("node_id") or "") or None,
|
||||
return self._require_agent_stage().node_config(
|
||||
node_id,
|
||||
functions=functions,
|
||||
greeting_context_message=self._greeting_context_message,
|
||||
leading_messages=leading_messages,
|
||||
)
|
||||
|
||||
async def _after_node_activated(
|
||||
self,
|
||||
node_config: NodeConfig,
|
||||
*,
|
||||
triggering_user_text: str = "",
|
||||
) -> None:
|
||||
"""Publish activation and perform exactly one Agent entry behavior."""
|
||||
node_id = str(node_config.get("name") or "")
|
||||
node_type = self._engine.node_type(node_id)
|
||||
if node_type != "agent":
|
||||
if node_type == "start":
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
return
|
||||
|
||||
await self._emit_node_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||
if entry_mode == "fixed_speech":
|
||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||
await self._queue_visible_speech(
|
||||
entry_speech,
|
||||
source="workflow-fixed-reply",
|
||||
node_id=node_id,
|
||||
)
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
self._state.consume_user_turn()
|
||||
return
|
||||
|
||||
should_run = entry_mode == "generate" or bool(triggering_user_text)
|
||||
if should_run:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||
await self._require_runtime().queue_frame(LLMRunFrame())
|
||||
return
|
||||
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
|
||||
async def _queue_visible_speech(
|
||||
self,
|
||||
text: str,
|
||||
@@ -444,27 +383,11 @@ class WorkflowBrain(BaseBrain):
|
||||
source: str = "workflow-speech",
|
||||
node_id: str | None = None,
|
||||
) -> None:
|
||||
"""Show and persist fixed workflow speech before sending it to TTS."""
|
||||
content = text.strip()
|
||||
if not content:
|
||||
return
|
||||
self._store.record("agent", content)
|
||||
runtime = self._require_runtime()
|
||||
transcript_message = {
|
||||
"type": "transcript",
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"timestamp": time_now_iso8601(),
|
||||
"source": source,
|
||||
**({"nodeId": node_id} if node_id else {}),
|
||||
}
|
||||
if self._client_ready:
|
||||
await runtime.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(message=transcript_message)
|
||||
)
|
||||
else:
|
||||
self._pending_visible_speech_events.append(transcript_message)
|
||||
await runtime.queue_frame(TTSSpeakFrame(content, append_to_context=False))
|
||||
await self._require_output().speak(
|
||||
text,
|
||||
source=source,
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
def _passive_node_config(
|
||||
self,
|
||||
@@ -486,10 +409,19 @@ class WorkflowBrain(BaseBrain):
|
||||
self._tools.register_secrets(tool)
|
||||
|
||||
async def handler(args, _flow_manager):
|
||||
transition_id = self._state.transition_id
|
||||
try:
|
||||
result = await self._tools.execute(tool, dict(args or {}))
|
||||
except ToolExecutionError as exc:
|
||||
return {"status": "error", "message": str(exc)}
|
||||
if (
|
||||
self._state.current_node_id != node_id
|
||||
or self._state.transition_id != transition_id
|
||||
):
|
||||
return {
|
||||
"status": "stale",
|
||||
"message": "工具完成时当前 Agent 已经切换,结果不再触发路由。",
|
||||
}
|
||||
updated_variables = list(result.get("updated_variables") or [])
|
||||
if updated_variables:
|
||||
await self._emit_variables(
|
||||
@@ -504,7 +436,22 @@ class WorkflowBrain(BaseBrain):
|
||||
include_default=False,
|
||||
)
|
||||
if edge:
|
||||
return result, await self._follow_edge(edge)
|
||||
next_config = await self._follow_edge(
|
||||
edge,
|
||||
triggering_user_text=(
|
||||
self._state.pending_user_turn.text
|
||||
if self._state.pending_user_turn
|
||||
else ""
|
||||
),
|
||||
)
|
||||
return result, self._flow_managed_transition_config(
|
||||
next_config,
|
||||
triggering_user_text=(
|
||||
self._state.pending_user_turn.text
|
||||
if self._state.pending_user_turn
|
||||
else ""
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
return FlowsFunctionSchema(
|
||||
@@ -516,6 +463,65 @@ class WorkflowBrain(BaseBrain):
|
||||
cancel_on_interruption=True,
|
||||
)
|
||||
|
||||
def _flow_managed_transition_config(
|
||||
self,
|
||||
node_config: NodeConfig,
|
||||
*,
|
||||
triggering_user_text: str,
|
||||
) -> NodeConfig:
|
||||
"""Let FlowManager finish a tool result before activating its target.
|
||||
|
||||
Function-returned transitions are the one place where FlowManager must
|
||||
schedule the LLM run. Its pre-action only updates our explicit runtime
|
||||
state; it never performs a second LLM run.
|
||||
"""
|
||||
node_id = str(node_config.get("name") or "")
|
||||
if self._engine.node_type(node_id) != "agent":
|
||||
return node_config
|
||||
entry_mode = str(
|
||||
self._engine.data(node_id).get("entryMode") or "wait_user"
|
||||
)
|
||||
should_run = entry_mode == "generate" or bool(triggering_user_text)
|
||||
configured = dict(node_config)
|
||||
configured["respond_immediately"] = (
|
||||
should_run and entry_mode != "fixed_speech"
|
||||
)
|
||||
configured["pre_actions"] = [
|
||||
{
|
||||
"type": "workflow_function_transition_entry",
|
||||
"node_id": node_id,
|
||||
"entry_mode": entry_mode,
|
||||
"should_run": should_run,
|
||||
"handler": self._activate_from_flow_transition,
|
||||
}
|
||||
]
|
||||
return configured
|
||||
|
||||
async def _activate_from_flow_transition(
|
||||
self,
|
||||
action: dict,
|
||||
_flow_manager: FlowManager,
|
||||
) -> None:
|
||||
"""Apply visible entry state without manually queueing an LLM run."""
|
||||
node_id = str(action.get("node_id") or "")
|
||||
await self._emit_node_active(node_id)
|
||||
entry_mode = str(action.get("entry_mode") or "wait_user")
|
||||
if entry_mode == "fixed_speech":
|
||||
entry_speech = self._store.render(
|
||||
str(self._engine.data(node_id).get("entrySpeech") or "")
|
||||
)
|
||||
await self._queue_visible_speech(
|
||||
entry_speech,
|
||||
source="workflow-fixed-reply",
|
||||
node_id=node_id,
|
||||
)
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
self._state.consume_user_turn()
|
||||
elif action.get("should_run"):
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||
else:
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
|
||||
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
knowledge_id = str(stage.knowledge_base_id or "")
|
||||
@@ -562,6 +568,7 @@ class WorkflowBrain(BaseBrain):
|
||||
*,
|
||||
triggering_user_text: str = "",
|
||||
) -> NodeConfig:
|
||||
self._state.begin_transition()
|
||||
leading_messages: list[dict[str, str]] = []
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
@@ -589,7 +596,8 @@ class WorkflowBrain(BaseBrain):
|
||||
triggering_user_text: str = "",
|
||||
) -> NodeConfig:
|
||||
context_messages = list(leading_messages or [])
|
||||
for _ in range(MAX_AUTOMATIC_HOPS):
|
||||
for hop in range(MAX_AUTOMATIC_HOPS):
|
||||
self._state.automatic_hops = hop
|
||||
node_type = self._engine.node_type(node_id)
|
||||
if node_type == "agent":
|
||||
await self._apply_agent_stage(node_id)
|
||||
@@ -611,6 +619,7 @@ class WorkflowBrain(BaseBrain):
|
||||
elif node_type == "handoff":
|
||||
await self._enter_handoff(node_id)
|
||||
elif node_type == "start":
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
await self._emit_node_active(node_id)
|
||||
else:
|
||||
raise RuntimeError(f"工作流指向未知节点:{node_id}")
|
||||
@@ -619,6 +628,7 @@ class WorkflowBrain(BaseBrain):
|
||||
edge = await self._select_edge(node_id)
|
||||
if not edge:
|
||||
return self._passive_node_config(node_id, context_messages)
|
||||
self._state.begin_transition()
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
content = self._store.render(speech).strip()
|
||||
@@ -636,6 +646,7 @@ class WorkflowBrain(BaseBrain):
|
||||
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||
|
||||
async def _enter_action(self, node_id: str) -> None:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
||||
await self._emit_node_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
tool_id = str(data.get("toolId") or "")
|
||||
@@ -665,6 +676,7 @@ class WorkflowBrain(BaseBrain):
|
||||
self._store.values["system__last_action_error"] = str(exc)[:2048]
|
||||
|
||||
async def _enter_handoff(self, node_id: str) -> None:
|
||||
self._state.enter(node_id, WorkflowStatus.HANDOFF)
|
||||
await self._emit_node_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
message = self._store.render(str(data.get("message") or ""))
|
||||
@@ -685,6 +697,8 @@ class WorkflowBrain(BaseBrain):
|
||||
|
||||
async def _enter_end(self, node_id: str) -> None:
|
||||
self._ended = True
|
||||
self._state.enter(node_id, WorkflowStatus.ENDED)
|
||||
self._state.finish()
|
||||
await self._emit_node_active(node_id)
|
||||
runtime = self._require_runtime()
|
||||
if runtime.set_knowledge_scope:
|
||||
@@ -705,27 +719,17 @@ class WorkflowBrain(BaseBrain):
|
||||
return
|
||||
runtime.call_end.begin("workflow_completed")
|
||||
if message:
|
||||
runtime.call_end.arm_after_speech()
|
||||
await self._queue_visible_speech(message)
|
||||
arm_tracked = getattr(runtime.call_end, "arm_after_tracked_speech", None)
|
||||
if callable(arm_tracked):
|
||||
await arm_tracked()
|
||||
elif message:
|
||||
runtime.call_end.arm_after_speech()
|
||||
else:
|
||||
await runtime.call_end.finish()
|
||||
|
||||
async def _emit_node_active(self, node_id: str | None) -> None:
|
||||
if node_id:
|
||||
await self._require_runtime().queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={"type": "node-active", "nodeId": node_id}
|
||||
)
|
||||
)
|
||||
|
||||
def _public_variables(self) -> dict[str, str | int | float | bool]:
|
||||
"""Return the browser-safe part of this session's variable state."""
|
||||
return {
|
||||
name: value
|
||||
for name, value in self._store.values.items()
|
||||
if not name.startswith(("system__", "secret__"))
|
||||
and isinstance(value, (str, int, float, bool))
|
||||
}
|
||||
await self._require_output().emit_node_active(node_id)
|
||||
|
||||
async def _emit_variables(
|
||||
self,
|
||||
@@ -735,21 +739,10 @@ class WorkflowBrain(BaseBrain):
|
||||
changed: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Publish a safe snapshot so Workflow debug mirrors runtime state."""
|
||||
message: dict[str, Any] = {
|
||||
"type": "workflow-variables",
|
||||
"reason": reason,
|
||||
"variables": self._public_variables(),
|
||||
}
|
||||
if node_id:
|
||||
message["nodeId"] = node_id
|
||||
if changed:
|
||||
message["changed"] = [
|
||||
name
|
||||
for name in changed
|
||||
if not name.startswith(("system__", "secret__"))
|
||||
]
|
||||
await self._require_runtime().queue_frame(
|
||||
OutputTransportMessageUrgentFrame(message=message)
|
||||
await self._require_output().emit_variables(
|
||||
reason=reason,
|
||||
node_id=node_id,
|
||||
changed=changed,
|
||||
)
|
||||
|
||||
def _require_runtime(self) -> BrainRuntime:
|
||||
@@ -761,3 +754,20 @@ class WorkflowBrain(BaseBrain):
|
||||
if self._manager is None:
|
||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||
return self._manager
|
||||
|
||||
def _require_output(self) -> WorkflowOutput:
|
||||
if self._output is None:
|
||||
# A few focused unit tests bind BrainRuntime directly. Lazily create
|
||||
# the output adapter so those tests exercise the same production path.
|
||||
self._output = WorkflowOutput(self._store, self._require_runtime())
|
||||
return self._output
|
||||
|
||||
def _require_agent_stage(self) -> WorkflowAgentStage:
|
||||
if self._agent_stage is None:
|
||||
self._agent_stage = WorkflowAgentStage(
|
||||
cfg=self._cfg or AssistantConfig(type="workflow"),
|
||||
engine=self._engine,
|
||||
store=self._store,
|
||||
runtime=self._require_runtime(),
|
||||
)
|
||||
return self._agent_stage
|
||||
|
||||
@@ -73,7 +73,7 @@ NODE_SPECS: list[dict[str, Any]] = [
|
||||
"icon": "Zap",
|
||||
"accent": "peach",
|
||||
"addable": True,
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 0},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Action"},
|
||||
],
|
||||
@@ -352,18 +352,19 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
||||
if mode == "expression":
|
||||
expression_errors = _validate_expression(data.get("expression"))
|
||||
errors.extend(f"边 {edge_id}:{item}" for item in expression_errors)
|
||||
try:
|
||||
priority = int(data.get("priority", 10))
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"边 {edge_id} 的优先级必须是整数")
|
||||
priority = 10
|
||||
if priority in priorities[source_id]:
|
||||
errors.append(f"节点 {source_id} 的出边优先级不能重复:{priority}")
|
||||
priorities[source_id].add(priority)
|
||||
if mode == "always":
|
||||
always_counts[source_id] += 1
|
||||
if always_counts[source_id] > 1:
|
||||
errors.append(f"节点 {source_id} 最多只能有一条默认路径")
|
||||
else:
|
||||
try:
|
||||
priority = int(data.get("priority", 10))
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"边 {edge_id} 的优先级必须是整数")
|
||||
priority = 10
|
||||
if priority in priorities[source_id]:
|
||||
errors.append(f"节点 {source_id} 的条件边优先级不能重复:{priority}")
|
||||
priorities[source_id].add(priority)
|
||||
incoming[target_id] += 1
|
||||
outgoing[source_id] += 1
|
||||
adj[source_id].append(target_id)
|
||||
@@ -387,6 +388,15 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
||||
errors.append(f"节点 {node_id} 的{label}不能少于 {lo}")
|
||||
if hi is not None and actual > hi:
|
||||
errors.append(f"节点 {node_id} 的{label}不能多于 {hi}")
|
||||
if (
|
||||
node.get("type") == "agent"
|
||||
and outgoing[node_id] == 1
|
||||
and always_counts[node_id] == 1
|
||||
):
|
||||
errors.append(
|
||||
f"Agent 节点 {node_id} 不能只有默认路径;"
|
||||
"请改为条件路径,或删除该路径以持续对话"
|
||||
)
|
||||
start_id = next(
|
||||
(node_id for node_id, node in node_by_id.items() if node.get("type") == "start"),
|
||||
None,
|
||||
|
||||
@@ -18,6 +18,8 @@ class CallEndCoordinator:
|
||||
self._armed = False
|
||||
self._speaking = False
|
||||
self._response_speech_started = False
|
||||
self._tracked_speeches = 0
|
||||
self._finish_after_tracked_speech = False
|
||||
self._finished = False
|
||||
self._reason = "completed"
|
||||
|
||||
@@ -37,6 +39,16 @@ class CallEndCoordinator:
|
||||
"""Wait for the next observed bot speech to finish."""
|
||||
self._armed = True
|
||||
|
||||
def track_speech(self) -> None:
|
||||
"""Register one fixed utterance before its TTSSpeakFrame is queued."""
|
||||
self._tracked_speeches += 1
|
||||
|
||||
async def arm_after_tracked_speech(self) -> None:
|
||||
"""Finish after every already queued fixed utterance has played."""
|
||||
self._finish_after_tracked_speech = True
|
||||
if self._tracked_speeches == 0:
|
||||
await self.finish()
|
||||
|
||||
async def finish_after_current_speech(self, *, has_text: bool) -> None:
|
||||
"""Finish now if speech is absent/done, otherwise wait for its stop."""
|
||||
if not has_text:
|
||||
@@ -59,7 +71,15 @@ class CallEndCoordinator:
|
||||
self._response_speech_started = True
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking:
|
||||
self._speaking = False
|
||||
if self._armed:
|
||||
if self._tracked_speeches > 0:
|
||||
self._tracked_speeches -= 1
|
||||
if (
|
||||
self._finish_after_tracked_speech
|
||||
and self._tracked_speeches == 0
|
||||
):
|
||||
logger.info("所有工作流结束语播报完毕,挂断通话")
|
||||
await self.finish()
|
||||
elif self._armed:
|
||||
logger.info("结束语播报完毕,挂断通话")
|
||||
await self.finish()
|
||||
|
||||
|
||||
19
backend/services/workflow/__init__.py
Normal file
19
backend/services/workflow/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Small, explicit building blocks for the local Workflow runtime."""
|
||||
|
||||
from services.workflow.models import (
|
||||
EdgeEvaluation,
|
||||
LLMRouteResult,
|
||||
RouteStatus,
|
||||
UserTurn,
|
||||
WorkflowRuntimeState,
|
||||
WorkflowStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EdgeEvaluation",
|
||||
"LLMRouteResult",
|
||||
"RouteStatus",
|
||||
"UserTurn",
|
||||
"WorkflowRuntimeState",
|
||||
"WorkflowStatus",
|
||||
]
|
||||
134
backend/services/workflow/agent.py
Normal file
134
backend/services/workflow/agent.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Resolve and apply one Workflow Agent's complete stage configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from models import AssistantConfig
|
||||
from pipecat.flows import ContextStrategy, ContextStrategyConfig, NodeConfig
|
||||
from pipecat.frames.frames import LLMUpdateSettingsFrame
|
||||
from pipecat.services.settings import LLMSettings
|
||||
|
||||
from services.brains.base import BrainRuntime
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
from services.workflow_router import WorkflowLLMRouter
|
||||
|
||||
|
||||
AGENT_STAGE_INSTRUCTION = (
|
||||
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
|
||||
"不要自行解释、模拟或宣布节点切换。"
|
||||
)
|
||||
|
||||
|
||||
class WorkflowAgentStage:
|
||||
"""Translate graph-level Agent data into Pipecat Flows configuration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cfg: AssistantConfig,
|
||||
engine: WorkflowEngine,
|
||||
store: DynamicVariableStore,
|
||||
runtime: BrainRuntime,
|
||||
) -> None:
|
||||
self._cfg = cfg
|
||||
self._engine = engine
|
||||
self._store = store
|
||||
self._runtime = runtime
|
||||
|
||||
def role_message(self, node_id: str) -> str:
|
||||
stage_prompt = self._engine.prompt_for(node_id, self._store)
|
||||
return (
|
||||
f"{stage_prompt}\n\n[工作流执行规则]\n"
|
||||
f"{AGENT_STAGE_INSTRUCTION}"
|
||||
)
|
||||
|
||||
async def refresh_prompt(self, node_id: str) -> None:
|
||||
await self._runtime.queue_frame(
|
||||
LLMUpdateSettingsFrame(
|
||||
delta=LLMSettings(
|
||||
system_instruction=self.role_message(node_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def router_for_node(
|
||||
self,
|
||||
node_id: str,
|
||||
default_router: WorkflowLLMRouter,
|
||||
) -> WorkflowLLMRouter:
|
||||
resource_id = self._engine.agent_stage_config(node_id).llm_resource_id
|
||||
resource = self._cfg.workflow_model_resources.get(resource_id)
|
||||
if not resource:
|
||||
return default_router
|
||||
from services.pipecat.service_factory import config_with_resource
|
||||
|
||||
return WorkflowLLMRouter(config_with_resource(self._cfg, resource))
|
||||
|
||||
async def apply(self, node_id: str) -> None:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
if self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(True)
|
||||
if self._runtime.apply_turn_config:
|
||||
await self._runtime.apply_turn_config(
|
||||
stage.enable_interrupt,
|
||||
stage.turn_config,
|
||||
)
|
||||
if self._runtime.switch_services:
|
||||
await self._runtime.switch_services(
|
||||
stage.llm_resource_id or None,
|
||||
stage.asr_resource_id or None,
|
||||
stage.tts_resource_id or None,
|
||||
)
|
||||
if self._runtime.set_knowledge_scope:
|
||||
self._runtime.set_knowledge_scope(
|
||||
{
|
||||
"knowledge_base_id": stage.knowledge_base_id,
|
||||
"mode": stage.knowledge_mode,
|
||||
"top_n": stage.knowledge_top_n,
|
||||
"score_threshold": stage.knowledge_score_threshold,
|
||||
}
|
||||
)
|
||||
|
||||
def node_config(
|
||||
self,
|
||||
node_id: str,
|
||||
*,
|
||||
functions: list,
|
||||
greeting_context_message: dict[str, str] | None,
|
||||
leading_messages: list[dict[str, str]] | None = None,
|
||||
) -> NodeConfig:
|
||||
data = self._engine.data(node_id)
|
||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||
strategy = (
|
||||
ContextStrategy.RESET
|
||||
if data.get("contextPolicy") == "fresh"
|
||||
else ContextStrategy.APPEND
|
||||
)
|
||||
greeting_messages = (
|
||||
[deepcopy(greeting_context_message)]
|
||||
if strategy == ContextStrategy.RESET and greeting_context_message
|
||||
else []
|
||||
)
|
||||
fixed_reply_messages = (
|
||||
[{"role": "assistant", "content": entry_speech}]
|
||||
if entry_mode == "fixed_speech" and entry_speech
|
||||
else []
|
||||
)
|
||||
return {
|
||||
"name": node_id,
|
||||
"role_message": self.role_message(node_id),
|
||||
"task_messages": [
|
||||
*greeting_messages,
|
||||
*(leading_messages or []),
|
||||
*fixed_reply_messages,
|
||||
],
|
||||
"functions": functions,
|
||||
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||
# Direct node activations let WorkflowRuntime decide whether to run
|
||||
# the LLM. A Pipecat function transition may explicitly override
|
||||
# this because FlowManager must coordinate the tool result first.
|
||||
"respond_immediately": False,
|
||||
}
|
||||
92
backend/services/workflow/models.py
Normal file
92
backend/services/workflow/models.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Readable runtime values shared by Workflow orchestration modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class WorkflowStatus(StrEnum):
|
||||
"""The small set of states useful to operators and future debug tooling."""
|
||||
|
||||
STARTING = "starting"
|
||||
WAITING_USER = "waiting_user"
|
||||
ROUTING = "routing"
|
||||
RUNNING_AGENT = "running_agent"
|
||||
RUNNING_ACTION = "running_action"
|
||||
HANDOFF = "handoff"
|
||||
ENDED = "ended"
|
||||
|
||||
|
||||
class RouteStatus(StrEnum):
|
||||
"""A routing error is deliberately different from a valid no-match."""
|
||||
|
||||
MATCHED = "matched"
|
||||
NO_MATCH = "no_match"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserTurn:
|
||||
"""One committed user turn that may cross automatic Workflow nodes."""
|
||||
|
||||
id: int
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowRuntimeState:
|
||||
"""Mutable per-call state; graph definitions remain immutable."""
|
||||
|
||||
current_node_id: str
|
||||
status: WorkflowStatus = WorkflowStatus.STARTING
|
||||
pending_user_turn: UserTurn | None = None
|
||||
transition_id: int = 0
|
||||
automatic_hops: int = 0
|
||||
ended: bool = False
|
||||
_next_turn_id: int = 1
|
||||
|
||||
def begin_user_turn(self, text: str) -> UserTurn:
|
||||
turn = UserTurn(id=self._next_turn_id, text=text)
|
||||
self._next_turn_id += 1
|
||||
self.pending_user_turn = turn
|
||||
self.automatic_hops = 0
|
||||
return turn
|
||||
|
||||
def enter(self, node_id: str, status: WorkflowStatus) -> None:
|
||||
self.current_node_id = node_id
|
||||
self.status = status
|
||||
|
||||
def begin_transition(self) -> int:
|
||||
self.transition_id += 1
|
||||
return self.transition_id
|
||||
|
||||
def consume_user_turn(self) -> UserTurn | None:
|
||||
turn = self.pending_user_turn
|
||||
self.pending_user_turn = None
|
||||
return turn
|
||||
|
||||
def finish(self) -> None:
|
||||
self.ended = True
|
||||
self.status = WorkflowStatus.ENDED
|
||||
self.pending_user_turn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMRouteResult:
|
||||
"""Control-plane result returned by the small routing LLM."""
|
||||
|
||||
status: RouteStatus
|
||||
function_name: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EdgeEvaluation:
|
||||
"""Final graph-level decision after expression, LLM and default handling."""
|
||||
|
||||
status: RouteStatus
|
||||
edge: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
|
||||
119
backend/services/workflow/output.py
Normal file
119
backend/services/workflow/output.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Client-visible Workflow output and fixed speech in one place."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from services.brains.base import BrainRuntime
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
|
||||
|
||||
class WorkflowOutput:
|
||||
"""Publish debug events and fixed speech without duplicating persistence."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: DynamicVariableStore,
|
||||
runtime: BrainRuntime,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._runtime = runtime
|
||||
self._client_ready = False
|
||||
self._pending_transcripts: list[dict[str, Any]] = []
|
||||
|
||||
async def mark_client_ready(self) -> None:
|
||||
self._client_ready = True
|
||||
pending = self._pending_transcripts
|
||||
self._pending_transcripts = []
|
||||
for message in pending:
|
||||
await self.emit(message)
|
||||
|
||||
async def speak(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
source: str,
|
||||
node_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record, display and synthesize one Workflow-owned utterance."""
|
||||
content = text.strip()
|
||||
if not content:
|
||||
return
|
||||
self._store.record("agent", content)
|
||||
transcript = {
|
||||
"type": "transcript",
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"timestamp": time_now_iso8601(),
|
||||
"source": source,
|
||||
**({"nodeId": node_id} if node_id else {}),
|
||||
}
|
||||
if self._client_ready:
|
||||
await self.emit(transcript)
|
||||
else:
|
||||
self._pending_transcripts.append(transcript)
|
||||
|
||||
track_speech = getattr(self._runtime.call_end, "track_speech", None)
|
||||
if callable(track_speech):
|
||||
track_speech()
|
||||
await self._runtime.queue_frame(
|
||||
TTSSpeakFrame(content, append_to_context=False)
|
||||
)
|
||||
|
||||
async def emit_node_active(self, node_id: str | None) -> None:
|
||||
if node_id:
|
||||
await self.emit({"type": "node-active", "nodeId": node_id})
|
||||
|
||||
async def emit_variables(
|
||||
self,
|
||||
*,
|
||||
reason: str,
|
||||
node_id: str | None,
|
||||
changed: list[str] | None = None,
|
||||
) -> None:
|
||||
message: dict[str, Any] = {
|
||||
"type": "workflow-variables",
|
||||
"reason": reason,
|
||||
"variables": self.public_variables(),
|
||||
}
|
||||
if node_id:
|
||||
message["nodeId"] = node_id
|
||||
if changed:
|
||||
message["changed"] = [
|
||||
name
|
||||
for name in changed
|
||||
if not name.startswith(("system__", "secret__"))
|
||||
]
|
||||
await self.emit(message)
|
||||
|
||||
async def emit_error(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
node_id: str | None,
|
||||
code: str = "workflow_runtime_error",
|
||||
) -> None:
|
||||
payload: dict[str, Any] = {
|
||||
"type": "workflow-error",
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
if node_id:
|
||||
payload["nodeId"] = node_id
|
||||
await self.emit(payload)
|
||||
|
||||
def public_variables(self) -> dict[str, str | int | float | bool]:
|
||||
return {
|
||||
name: value
|
||||
for name, value in self._store.values.items()
|
||||
if not name.startswith(("system__", "secret__"))
|
||||
and isinstance(value, (str, int, float, bool))
|
||||
}
|
||||
|
||||
async def emit(self, message: dict[str, Any]) -> None:
|
||||
await self._runtime.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(message=message)
|
||||
)
|
||||
97
backend/services/workflow/routing.py
Normal file
97
backend/services/workflow/routing.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Priority-aware Workflow edge evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.workflow.models import EdgeEvaluation, RouteStatus
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
from services.workflow_router import WorkflowLLMRouter
|
||||
|
||||
|
||||
class WorkflowEdgeEvaluator:
|
||||
"""Evaluate one node's paths without changing runtime state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: WorkflowEngine,
|
||||
store: DynamicVariableStore,
|
||||
router_for_node: Callable[[str], WorkflowLLMRouter],
|
||||
) -> None:
|
||||
self._engine = engine
|
||||
self._store = store
|
||||
self._router_for_node = router_for_node
|
||||
|
||||
async def evaluate(self, node_id: str) -> EdgeEvaluation:
|
||||
"""Select the first matching conditional path, then the default path."""
|
||||
outgoing = self._engine.outgoing(node_id)
|
||||
expression_edge = self._engine.deterministic_edge(
|
||||
node_id,
|
||||
self._store,
|
||||
include_default=False,
|
||||
)
|
||||
default_edge = next(
|
||||
(
|
||||
edge
|
||||
for edge in outgoing
|
||||
if self._engine.edge_mode(edge) == "always"
|
||||
),
|
||||
None,
|
||||
)
|
||||
llm_edges = [
|
||||
edge for edge in outgoing if self._engine.edge_mode(edge) == "llm"
|
||||
]
|
||||
|
||||
# A matching expression is a priority boundary. A later LLM condition
|
||||
# cannot bypass it, while an earlier LLM condition still gets one chance.
|
||||
if expression_edge:
|
||||
expression_index = outgoing.index(expression_edge)
|
||||
llm_edges = [
|
||||
edge
|
||||
for edge in llm_edges
|
||||
if outgoing.index(edge) < expression_index
|
||||
]
|
||||
|
||||
if not llm_edges:
|
||||
return self._matched_or_no_match(expression_edge or default_edge)
|
||||
|
||||
result = await self._router_for_node(node_id).select_edge(
|
||||
node_name=self._engine.name(node_id),
|
||||
node_prompt=self._engine.routing_prompt(node_id, self._store),
|
||||
edges=llm_edges,
|
||||
history=self._store.history,
|
||||
variables={
|
||||
key: value
|
||||
for key, value in self._store.values.items()
|
||||
if not key.startswith(("system__", "secret__"))
|
||||
},
|
||||
edge_name=self._engine.edge_fn_name,
|
||||
edge_description=self._engine.edge_description,
|
||||
)
|
||||
if result.status == RouteStatus.ERROR:
|
||||
return EdgeEvaluation(status=RouteStatus.ERROR, error=result.error)
|
||||
if result.status == RouteStatus.NO_MATCH:
|
||||
return self._matched_or_no_match(expression_edge or default_edge)
|
||||
|
||||
selected = next(
|
||||
(
|
||||
edge
|
||||
for edge in llm_edges
|
||||
if self._engine.edge_fn_name(edge) == result.function_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
return EdgeEvaluation(
|
||||
status=RouteStatus.ERROR,
|
||||
error="路由模型返回了不属于当前节点的连接",
|
||||
)
|
||||
return EdgeEvaluation(status=RouteStatus.MATCHED, edge=selected)
|
||||
|
||||
@staticmethod
|
||||
def _matched_or_no_match(edge: dict | None) -> EdgeEvaluation:
|
||||
if edge is None:
|
||||
return EdgeEvaluation(status=RouteStatus.NO_MATCH)
|
||||
return EdgeEvaluation(status=RouteStatus.MATCHED, edge=edge)
|
||||
|
||||
@@ -177,6 +177,11 @@ class WorkflowEngine:
|
||||
try:
|
||||
if operator == "exists":
|
||||
matched = exists if expected is not False else not exists
|
||||
elif not exists:
|
||||
# Missing values are never equal, unequal, greater or
|
||||
# contained. Use the explicit ``exists`` operator when the
|
||||
# distinction between missing and present is important.
|
||||
matched = False
|
||||
elif operator == "eq":
|
||||
matched = actual == expected
|
||||
elif operator == "neq":
|
||||
|
||||
@@ -15,6 +15,8 @@ from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from services.workflow.models import LLMRouteResult, RouteStatus
|
||||
|
||||
|
||||
STAY_ON_CURRENT_NODE = "workflow_stay_on_current_node"
|
||||
# Compatibility alias for callers saved before all source nodes supported LLM edges.
|
||||
@@ -38,10 +40,10 @@ class WorkflowLLMRouter:
|
||||
variables: dict[str, Any],
|
||||
edge_name: Callable[[dict[str, Any]], str],
|
||||
edge_description: Callable[[dict[str, Any]], str],
|
||||
) -> str | None:
|
||||
"""Return an edge function name, STAY, or None when routing failed."""
|
||||
) -> LLMRouteResult:
|
||||
"""Return a typed match, no-match or technical error."""
|
||||
if not edges:
|
||||
return STAY_ON_CURRENT_NODE
|
||||
return LLMRouteResult(status=RouteStatus.NO_MATCH)
|
||||
|
||||
names = {edge_name(edge) for edge in edges}
|
||||
stay_name = STAY_ON_CURRENT_NODE
|
||||
@@ -116,16 +118,28 @@ class WorkflowLLMRouter:
|
||||
tool_calls = response.choices[0].message.tool_calls or []
|
||||
if not tool_calls:
|
||||
logger.warning("Workflow 路由 LLM 未返回函数调用,留在当前节点")
|
||||
return STAY_ON_CURRENT_NODE
|
||||
return LLMRouteResult(
|
||||
status=RouteStatus.ERROR,
|
||||
error="路由模型没有返回函数调用",
|
||||
)
|
||||
selected = str(tool_calls[0].function.name or "")
|
||||
if selected == stay_name:
|
||||
return STAY_ON_CURRENT_NODE
|
||||
return LLMRouteResult(status=RouteStatus.NO_MATCH)
|
||||
if selected not in names:
|
||||
logger.warning(f"Workflow 路由 LLM 返回未知函数:{selected}")
|
||||
return STAY_ON_CURRENT_NODE
|
||||
return selected
|
||||
return LLMRouteResult(
|
||||
status=RouteStatus.ERROR,
|
||||
error=f"路由模型返回未知函数:{selected}",
|
||||
)
|
||||
return LLMRouteResult(
|
||||
status=RouteStatus.MATCHED,
|
||||
function_name=selected,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - routing failure must not end the call
|
||||
logger.warning(f"Workflow LLM 边判断失败,留在当前节点:{exc}")
|
||||
return None
|
||||
return LLMRouteResult(
|
||||
status=RouteStatus.ERROR,
|
||||
error=f"大模型路由失败:{type(exc).__name__}",
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@@ -28,7 +28,7 @@ from services.brains.dify_llm import (
|
||||
)
|
||||
from services.brains.workflow_brain import WorkflowBrain
|
||||
from services.runtime_variables import prepare_dynamic_config
|
||||
from services.workflow_router import STAY_ON_CURRENT_NODE
|
||||
from services.workflow.models import LLMRouteResult, RouteStatus
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
@@ -47,6 +47,7 @@ class FakeCallEnd:
|
||||
self.finished = False
|
||||
self.response_started = False
|
||||
self.waited_for_text: bool | None = None
|
||||
self.tracked_speeches = 0
|
||||
|
||||
def begin(self, reason: str) -> None:
|
||||
self.ending = True
|
||||
@@ -58,6 +59,14 @@ class FakeCallEnd:
|
||||
def arm_after_speech(self) -> None:
|
||||
self.armed = True
|
||||
|
||||
def track_speech(self) -> None:
|
||||
self.tracked_speeches += 1
|
||||
|
||||
async def arm_after_tracked_speech(self) -> None:
|
||||
self.armed = True
|
||||
if self.tracked_speeches == 0:
|
||||
await self.finish()
|
||||
|
||||
async def finish_after_current_speech(self, *, has_text: bool) -> None:
|
||||
self.waited_for_text = has_text
|
||||
if has_text:
|
||||
@@ -824,7 +833,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def select_edge(self, **_kwargs):
|
||||
self.calls += 1
|
||||
return "goto_eat"
|
||||
return LLMRouteResult(
|
||||
status=RouteStatus.MATCHED,
|
||||
function_name="goto_eat",
|
||||
)
|
||||
|
||||
manager = FakeManager()
|
||||
router = FakeRouter()
|
||||
@@ -847,6 +859,45 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
||||
self.assertIn("我想吃饭", brain._store.values["system__conversation_history"])
|
||||
|
||||
async def test_start_expression_condition_also_waits_for_user_turn(self):
|
||||
brain = WorkflowBrain(
|
||||
{
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{"id": "agent", "type": "agent", "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "route",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {
|
||||
"mode": "expression",
|
||||
"priority": 10,
|
||||
"expression": {
|
||||
"combinator": "and",
|
||||
"rules": [
|
||||
{
|
||||
"variable": "route",
|
||||
"operator": "eq",
|
||||
"value": "agent",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
brain._store.values["route"] = "agent"
|
||||
|
||||
config = await brain._initial_node_config()
|
||||
|
||||
self.assertEqual(config["name"], "start")
|
||||
self.assertEqual(brain._state.status.value, "waiting_user")
|
||||
|
||||
async def test_automatic_node_can_follow_llm_condition(self):
|
||||
brain = WorkflowBrain(
|
||||
{
|
||||
@@ -896,7 +947,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
class FakeRouter:
|
||||
async def select_edge(self, **kwargs):
|
||||
self.node_name = kwargs["node_name"]
|
||||
return "goto_to_agent"
|
||||
return LLMRouteResult(
|
||||
status=RouteStatus.MATCHED,
|
||||
function_name="goto_to_agent",
|
||||
)
|
||||
|
||||
router = FakeRouter()
|
||||
brain._router = router
|
||||
@@ -961,7 +1015,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
class FakeRouter:
|
||||
def __init__(self):
|
||||
self.result = STAY_ON_CURRENT_NODE
|
||||
self.result = LLMRouteResult(status=RouteStatus.NO_MATCH)
|
||||
self.edge_ids = []
|
||||
|
||||
async def select_edge(self, **kwargs):
|
||||
@@ -975,7 +1029,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(router.edge_ids, ["llm"])
|
||||
self.assertEqual(selected["id"], "expression")
|
||||
|
||||
router.result = "goto_llm"
|
||||
router.result = LLMRouteResult(
|
||||
status=RouteStatus.MATCHED,
|
||||
function_name="goto_llm",
|
||||
)
|
||||
selected = await brain._select_edge("agent")
|
||||
self.assertEqual(selected["id"], "llm")
|
||||
|
||||
@@ -1183,21 +1240,19 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
brain._engine.data("agent")["entryMode"] = "generate"
|
||||
generate_config = brain._agent_config("agent")
|
||||
self.assertTrue(generate_config["respond_immediately"])
|
||||
self.assertFalse(generate_config["respond_immediately"])
|
||||
worker.frames.clear()
|
||||
await brain._manager.set_node_from_config(generate_config)
|
||||
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||
await brain._after_node_activated(generate_config)
|
||||
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
||||
|
||||
brain._engine.data("agent").update(
|
||||
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
|
||||
)
|
||||
fixed_config = brain._agent_config("agent")
|
||||
self.assertFalse(fixed_config["respond_immediately"])
|
||||
self.assertEqual(
|
||||
fixed_config["pre_actions"][0]["type"],
|
||||
"workflow_fixed_speech",
|
||||
)
|
||||
self.assertEqual(fixed_config["pre_actions"][0]["text"], "您好,王先生")
|
||||
self.assertNotIn("pre_actions", fixed_config)
|
||||
self.assertEqual(
|
||||
fixed_config["task_messages"],
|
||||
[
|
||||
@@ -1216,10 +1271,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
{"role": "assistant", "content": "您好,王先生"},
|
||||
],
|
||||
)
|
||||
self.assertEqual(fixed_config["pre_actions"][0]["node_id"], "agent")
|
||||
worker.frames.clear()
|
||||
queued.clear()
|
||||
await brain._manager.set_node_from_config(fixed_config)
|
||||
await brain._after_node_activated(fixed_config)
|
||||
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||
context_updates = [
|
||||
@@ -1263,7 +1318,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
class FakeRouter:
|
||||
async def select_edge(self, **_kwargs):
|
||||
return "goto_finish"
|
||||
return LLMRouteResult(
|
||||
status=RouteStatus.MATCHED,
|
||||
function_name="goto_finish",
|
||||
)
|
||||
|
||||
brain._router = FakeRouter()
|
||||
handled = await brain.on_user_turn_end("我的需求已经说完了")
|
||||
|
||||
@@ -44,6 +44,20 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(self.reasons, ["tool_only"])
|
||||
|
||||
async def test_workflow_end_waits_for_every_queued_fixed_speech(self):
|
||||
self.coordinator.track_speech()
|
||||
self.coordinator.track_speech()
|
||||
self.coordinator.begin("workflow_completed")
|
||||
await self.coordinator.arm_after_tracked_speech()
|
||||
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
self.assertEqual(self.reasons, [])
|
||||
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
self.assertEqual(self.reasons, ["workflow_completed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -5,6 +5,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from models import AssistantConfig
|
||||
from services.workflow.models import RouteStatus
|
||||
from services.workflow_router import WorkflowLLMRouter
|
||||
|
||||
|
||||
@@ -62,7 +63,8 @@ class WorkflowLLMRouterTest(unittest.IsolatedAsyncioTestCase):
|
||||
edge_description=lambda _edge: "用户已经回答姓名",
|
||||
)
|
||||
|
||||
self.assertEqual(selected, "goto_age")
|
||||
self.assertEqual(selected.status, RouteStatus.MATCHED)
|
||||
self.assertEqual(selected.function_name, "goto_age")
|
||||
self.assertEqual(requests[0]["tool_choice"], "required")
|
||||
self.assertEqual(
|
||||
[message["role"] for message in requests[0]["messages"]],
|
||||
|
||||
80
backend/tests/test_workflow_routing.py
Normal file
80
backend/tests/test_workflow_routing.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.workflow.models import LLMRouteResult, RouteStatus
|
||||
from services.workflow.routing import WorkflowEdgeEvaluator
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
def routing_graph() -> dict:
|
||||
return {
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{"id": "agent", "type": "agent", "data": {"name": "Agent"}},
|
||||
{"id": "matched", "type": "end", "data": {}},
|
||||
{"id": "fallback", "type": "end", "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "condition",
|
||||
"source": "agent",
|
||||
"target": "matched",
|
||||
"data": {
|
||||
"mode": "llm",
|
||||
"priority": 10,
|
||||
"condition": "用户要求结束",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "default",
|
||||
"source": "agent",
|
||||
"target": "fallback",
|
||||
"data": {"mode": "always", "priority": 10},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class WorkflowEdgeEvaluatorTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_router_error_does_not_take_default_path(self):
|
||||
class ErrorRouter:
|
||||
async def select_edge(self, **_kwargs):
|
||||
return LLMRouteResult(
|
||||
status=RouteStatus.ERROR,
|
||||
error="provider unavailable",
|
||||
)
|
||||
|
||||
evaluator = WorkflowEdgeEvaluator(
|
||||
WorkflowEngine(routing_graph()),
|
||||
DynamicVariableStore({}),
|
||||
lambda _node_id: ErrorRouter(),
|
||||
)
|
||||
|
||||
decision = await evaluator.evaluate("agent")
|
||||
|
||||
self.assertEqual(decision.status, RouteStatus.ERROR)
|
||||
self.assertIsNone(decision.edge)
|
||||
|
||||
async def test_explicit_no_match_takes_default_path(self):
|
||||
class NoMatchRouter:
|
||||
async def select_edge(self, **_kwargs):
|
||||
return LLMRouteResult(status=RouteStatus.NO_MATCH)
|
||||
|
||||
evaluator = WorkflowEdgeEvaluator(
|
||||
WorkflowEngine(routing_graph()),
|
||||
DynamicVariableStore({}),
|
||||
lambda _node_id: NoMatchRouter(),
|
||||
)
|
||||
|
||||
decision = await evaluator.evaluate("agent")
|
||||
|
||||
self.assertEqual(decision.status, RouteStatus.MATCHED)
|
||||
self.assertEqual(decision.edge["id"], "default")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -211,7 +211,7 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
"smart_turn",
|
||||
)
|
||||
|
||||
def test_start_agent_and_handoff_may_have_no_outgoing_edge(self):
|
||||
def test_start_agent_action_and_handoff_may_have_no_outgoing_edge(self):
|
||||
terminal_graphs = [
|
||||
{
|
||||
"specVersion": 3,
|
||||
@@ -277,13 +277,47 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
}
|
||||
],
|
||||
}
|
||||
self.assertEqual(validate_graph(action_without_exit), [])
|
||||
|
||||
def test_agent_cannot_have_only_one_default_path(self):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{"id": "agent", "type": "agent", "data": {}},
|
||||
{"id": "end", "type": "end", "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
},
|
||||
{
|
||||
"id": "leave",
|
||||
"source": "agent",
|
||||
"target": "end",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
self.assertTrue(
|
||||
any(
|
||||
"action 的出边不能少于 1" in error
|
||||
for error in validate_graph(action_without_exit)
|
||||
"Agent 节点 agent 不能只有默认路径" in error
|
||||
for error in validate_graph(graph)
|
||||
)
|
||||
)
|
||||
|
||||
graph["edges"][1]["data"] = {
|
||||
"mode": "llm",
|
||||
"priority": 10,
|
||||
"condition": "当前阶段已经完成",
|
||||
}
|
||||
self.assertEqual(validate_graph(graph), [])
|
||||
|
||||
def test_all_source_nodes_allow_multiple_conditional_paths(self):
|
||||
expression = {
|
||||
"combinator": "and",
|
||||
@@ -448,6 +482,41 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
self.assertNotIn("服务 王先生", custom_prompt)
|
||||
self.assertIn("处理订单", custom_prompt)
|
||||
|
||||
def test_missing_variable_only_matches_explicit_exists_rule(self):
|
||||
engine = WorkflowEngine(valid_graph())
|
||||
values = {}
|
||||
|
||||
self.assertFalse(
|
||||
engine.expression_matches(
|
||||
{
|
||||
"combinator": "and",
|
||||
"rules": [
|
||||
{
|
||||
"variable": "order_status",
|
||||
"operator": "neq",
|
||||
"value": "paid",
|
||||
}
|
||||
],
|
||||
},
|
||||
values,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
engine.expression_matches(
|
||||
{
|
||||
"combinator": "and",
|
||||
"rules": [
|
||||
{
|
||||
"variable": "order_status",
|
||||
"operator": "exists",
|
||||
"value": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
values,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { WorkflowSettings } from "@/components/workflow/types";
|
||||
import { defaultGraph, type WorkflowGraph } from "@/components/workflow/specs";
|
||||
import type { DynamicVariableDefinition } from "@/lib/api";
|
||||
import { defaultTurnConfig } from "@/lib/turn-config";
|
||||
|
||||
function initialWorkflowSettings(): WorkflowSettings {
|
||||
const graph = defaultGraph();
|
||||
type WorkflowPanel =
|
||||
| { type: "debug" }
|
||||
| { type: "settings" }
|
||||
| { type: "node"; nodeId: string }
|
||||
| { type: "edge"; edgeId: string }
|
||||
| null;
|
||||
|
||||
function settingsFromGraph(graph: WorkflowGraph): WorkflowSettings {
|
||||
return {
|
||||
globalPrompt: graph.settings.globalPrompt,
|
||||
llm: graph.settings.defaultLlmResourceId,
|
||||
@@ -21,8 +26,8 @@ function initialWorkflowSettings(): WorkflowSettings {
|
||||
topN: graph.settings.knowledgeTopN,
|
||||
scoreThreshold: graph.settings.knowledgeScoreThreshold,
|
||||
},
|
||||
allowInterrupt: true,
|
||||
turnConfig: defaultTurnConfig(),
|
||||
allowInterrupt: graph.settings.enableInterrupt,
|
||||
turnConfig: graph.settings.turnConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,19 +37,67 @@ export function useWorkflowEditorState() {
|
||||
const [workflowGraph, setWorkflowGraph] = useState<WorkflowGraph>(() =>
|
||||
defaultGraph(),
|
||||
);
|
||||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>(
|
||||
initialWorkflowSettings,
|
||||
const workflowSettings = useMemo(
|
||||
() => settingsFromGraph(workflowGraph),
|
||||
[workflowGraph],
|
||||
);
|
||||
const setWorkflowSettings = useCallback((settings: WorkflowSettings) => {
|
||||
setWorkflowGraph((graph) => ({
|
||||
...graph,
|
||||
settings: {
|
||||
globalPrompt: settings.globalPrompt,
|
||||
defaultLlmResourceId: settings.llm ?? "",
|
||||
defaultAsrResourceId: settings.asr ?? "",
|
||||
defaultTtsResourceId: settings.tts ?? "",
|
||||
toolIds: settings.toolIds,
|
||||
knowledgeBaseId: settings.knowledgeBaseId,
|
||||
knowledgeMode: settings.knowledgeRetrievalConfig.mode,
|
||||
knowledgeTopN: settings.knowledgeRetrievalConfig.topN,
|
||||
knowledgeScoreThreshold:
|
||||
settings.knowledgeRetrievalConfig.scoreThreshold,
|
||||
enableInterrupt: settings.allowInterrupt,
|
||||
turnConfig: settings.turnConfig,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
|
||||
useState<Record<string, DynamicVariableDefinition>>({});
|
||||
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
|
||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
||||
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [workflowPanel, setWorkflowPanel] = useState<WorkflowPanel>(null);
|
||||
const workflowDebugOpen = workflowPanel?.type === "debug";
|
||||
const workflowSettingsOpen = workflowPanel?.type === "settings";
|
||||
const workflowEditingNodeId =
|
||||
workflowPanel?.type === "node" ? workflowPanel.nodeId : null;
|
||||
const workflowEditingEdgeId =
|
||||
workflowPanel?.type === "edge" ? workflowPanel.edgeId : null;
|
||||
|
||||
const setWorkflowDebugOpen = useCallback((open: boolean) => {
|
||||
setWorkflowPanel((panel) =>
|
||||
open ? { type: "debug" } : panel?.type === "debug" ? null : panel,
|
||||
);
|
||||
}, []);
|
||||
const setWorkflowSettingsOpen = useCallback((open: boolean) => {
|
||||
setWorkflowPanel((panel) =>
|
||||
open ? { type: "settings" } : panel?.type === "settings" ? null : panel,
|
||||
);
|
||||
}, []);
|
||||
const setWorkflowEditingNodeId = useCallback((nodeId: string | null) => {
|
||||
setWorkflowPanel((panel) =>
|
||||
nodeId
|
||||
? { type: "node", nodeId }
|
||||
: panel?.type === "node"
|
||||
? null
|
||||
: panel,
|
||||
);
|
||||
}, []);
|
||||
const setWorkflowEditingEdgeId = useCallback((edgeId: string | null) => {
|
||||
setWorkflowPanel((panel) =>
|
||||
edgeId
|
||||
? { type: "edge", edgeId }
|
||||
: panel?.type === "edge"
|
||||
? null
|
||||
: panel,
|
||||
);
|
||||
}, []);
|
||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
|
||||
|
||||
@@ -65,6 +118,7 @@ export function useWorkflowEditorState() {
|
||||
setWorkflowEditingNodeId,
|
||||
workflowEditingEdgeId,
|
||||
setWorkflowEditingEdgeId,
|
||||
workflowPanel,
|
||||
activeNodeId,
|
||||
setActiveNodeId,
|
||||
dynamicVariablesOpen,
|
||||
|
||||
116
frontend/src/components/workflow/AddNodeDialog.tsx
Normal file
116
frontend/src/components/workflow/AddNodeDialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { accentVar, type RuntimeNodeSpec } from "./specs";
|
||||
|
||||
export function AddNodeDialog({
|
||||
open,
|
||||
connectingFromSource,
|
||||
specs,
|
||||
canAdd,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
connectingFromSource: boolean;
|
||||
specs: RuntimeNodeSpec[];
|
||||
canAdd: (spec: RuntimeNodeSpec) => boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (spec: RuntimeNodeSpec) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
|
||||
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<Plus size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="caption-label text-muted-soft">节点目录</div>
|
||||
<DialogTitle className="font-display display-sm mt-1 text-ink">
|
||||
添加节点
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-2 leading-6">
|
||||
{connectingFromSource
|
||||
? "选择节点类型,将在当前节点下方创建并自动连接。"
|
||||
: "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
|
||||
{specs.length === 0 && (
|
||||
<p className="rounded-2xl border border-dashed border-hairline-strong bg-card px-4 py-8 text-center text-sm text-muted-soft">
|
||||
暂无可添加的节点类型。
|
||||
</p>
|
||||
)}
|
||||
{specs.map((spec) => {
|
||||
const Icon = spec.icon;
|
||||
const allowed = canAdd(spec);
|
||||
return (
|
||||
<button
|
||||
key={spec.type}
|
||||
type="button"
|
||||
disabled={!allowed}
|
||||
className="group relative flex items-start gap-4 overflow-hidden rounded-2xl border border-hairline bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-hairline-strong hover:shadow-md disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:border-hairline disabled:hover:shadow-sm"
|
||||
onClick={() => onSelect(spec)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-5 right-5 top-0 h-px"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent, var(${accentVar(spec.accent)}), transparent)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground transition-transform group-hover:scale-105"
|
||||
style={{
|
||||
background: `color-mix(in srgb, var(${accentVar(spec.accent)}) 28%, var(--surface-strong))`,
|
||||
}}
|
||||
>
|
||||
<Icon size={17} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<span>{spec.displayName}</span>
|
||||
{!allowed && (
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
|
||||
已添加
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{spec.description}
|
||||
</div>
|
||||
</div>
|
||||
<Plus
|
||||
size={15}
|
||||
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { Braces, Plus, Settings2, X } from "lucide-react";
|
||||
import { Braces, Plus, Settings2 } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -26,13 +26,6 @@ import {
|
||||
} from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -40,6 +33,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import { AddNodeDialog } from "./AddNodeDialog";
|
||||
import { edgeTypes } from "./ConditionEdge";
|
||||
import { hasDefaultPath, newEdgeData } from "./edge-rules";
|
||||
import {
|
||||
@@ -52,8 +46,8 @@ import { nodeTypes } from "./GenericNode";
|
||||
import { EdgeSettingsPanel } from "./panels/EdgeSettingsPanel";
|
||||
import { GlobalSettingsPanel } from "./panels/GlobalSettingsPanel";
|
||||
import { NodeSettingsPanel } from "./panels/NodeSettingsPanel";
|
||||
import { WorkflowSidePanel } from "./WorkflowSidePanel";
|
||||
import {
|
||||
accentVar,
|
||||
defaultGraph,
|
||||
type NodeSpecMap,
|
||||
type RuntimeNodeSpec,
|
||||
@@ -195,7 +189,9 @@ export function WorkflowCanvas({
|
||||
settings.tts,
|
||||
settings.toolIds,
|
||||
settings.knowledgeBaseId,
|
||||
settings.knowledgeRetrievalConfig,
|
||||
settings.knowledgeRetrievalConfig.mode,
|
||||
settings.knowledgeRetrievalConfig.topN,
|
||||
settings.knowledgeRetrievalConfig.scoreThreshold,
|
||||
settings.allowInterrupt,
|
||||
settings.turnConfig,
|
||||
]);
|
||||
@@ -203,6 +199,9 @@ export function WorkflowCanvas({
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!connection.source || !connection.target) return;
|
||||
const sourceType = nodes.find(
|
||||
(node) => node.id === connection.source,
|
||||
)?.type;
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
{
|
||||
@@ -210,13 +209,13 @@ export function WorkflowCanvas({
|
||||
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
|
||||
type: "condition",
|
||||
animated: true,
|
||||
data: newEdgeData(eds, connection.source),
|
||||
data: newEdgeData(eds, connection.source, sourceType),
|
||||
},
|
||||
eds,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setEdges],
|
||||
[nodes, setEdges],
|
||||
);
|
||||
|
||||
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
|
||||
@@ -277,7 +276,7 @@ export function WorkflowCanvas({
|
||||
target: id,
|
||||
type: "condition",
|
||||
animated: true,
|
||||
data: newEdgeData(currentEdges, source.id),
|
||||
data: newEdgeData(currentEdges, source.id, source.type),
|
||||
},
|
||||
currentEdges,
|
||||
);
|
||||
@@ -577,9 +576,11 @@ export function WorkflowCanvas({
|
||||
</ReactFlow>
|
||||
</section>
|
||||
|
||||
{/* 添加节点弹窗 */}
|
||||
<Dialog
|
||||
<AddNodeDialog
|
||||
open={addOpen}
|
||||
connectingFromSource={Boolean(addSourceId)}
|
||||
specs={addableSpecs}
|
||||
canAdd={canAddSpec}
|
||||
onOpenChange={(open) => {
|
||||
setAddOpen(open);
|
||||
if (!open) {
|
||||
@@ -587,131 +588,43 @@ export function WorkflowCanvas({
|
||||
setAddPosition(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
|
||||
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<Plus size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="caption-label text-muted-soft">
|
||||
节点目录
|
||||
</div>
|
||||
<DialogTitle className="font-display display-sm mt-1 text-ink">
|
||||
添加节点
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-2 leading-6">
|
||||
{addSourceId
|
||||
? "选择节点类型,将在当前节点下方创建并自动连接。"
|
||||
: "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
|
||||
{addableSpecs.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-hairline-strong bg-card px-4 py-8 text-center text-sm text-muted-soft">
|
||||
暂无可添加的节点类型。
|
||||
</p>
|
||||
) : null}
|
||||
{addableSpecs.map((spec) => {
|
||||
const Icon = spec.icon;
|
||||
const canAdd = canAddSpec(spec);
|
||||
return (
|
||||
<button
|
||||
key={spec.type}
|
||||
type="button"
|
||||
disabled={!canAdd}
|
||||
className="group relative flex items-start gap-4 overflow-hidden rounded-2xl border border-hairline bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-hairline-strong hover:shadow-md disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:border-hairline disabled:hover:shadow-sm"
|
||||
onClick={() => addNode(spec)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-5 right-5 top-0 h-px"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent, var(${accentVar(spec.accent)}), transparent)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground transition-transform group-hover:scale-105"
|
||||
style={{
|
||||
background: `color-mix(in srgb, var(${accentVar(spec.accent)}) 28%, var(--surface-strong))`,
|
||||
}}
|
||||
>
|
||||
<Icon size={17} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<span>{spec.displayName}</span>
|
||||
{!canAdd && (
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
|
||||
已添加
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{spec.description}
|
||||
</div>
|
||||
</div>
|
||||
<Plus
|
||||
size={15}
|
||||
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
onSelect={addNode}
|
||||
/>
|
||||
|
||||
{(debugOpen ||
|
||||
settingsOpen ||
|
||||
(editingNode && editingSpec) ||
|
||||
editingEdge) && (
|
||||
<aside className="absolute inset-y-0 right-0 z-40 flex w-1/2 flex-col overflow-hidden rounded-r-2xl border-l border-hairline bg-card shadow-2xl">
|
||||
<WorkflowSidePanel
|
||||
title={
|
||||
debugOpen
|
||||
? undefined
|
||||
: settingsOpen
|
||||
? "工作流设置"
|
||||
: editingEdge
|
||||
? "编辑连接条件"
|
||||
: `编辑${editingSpec?.displayName ?? "节点"}`
|
||||
}
|
||||
closeLabel={
|
||||
settingsOpen
|
||||
? "关闭工作流设置"
|
||||
: editingEdge
|
||||
? "关闭边编辑"
|
||||
: "关闭节点编辑"
|
||||
}
|
||||
onClose={
|
||||
debugOpen
|
||||
? undefined
|
||||
: () => {
|
||||
if (settingsOpen) setSettingsOpen(false);
|
||||
else if (editingEdge) onEditingEdgeIdChange(null);
|
||||
else onEditingNodeIdChange(null);
|
||||
}
|
||||
}
|
||||
>
|
||||
{debugOpen ? (
|
||||
debugPanel
|
||||
) : settingsOpen ||
|
||||
(editingNode && editingSpec) ||
|
||||
editingEdge ? (
|
||||
<>
|
||||
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
settingsOpen
|
||||
? "关闭工作流设置"
|
||||
: editingEdge
|
||||
? "关闭边编辑"
|
||||
: "关闭节点编辑"
|
||||
}
|
||||
title="关闭"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||
onClick={() => {
|
||||
if (settingsOpen) setSettingsOpen(false);
|
||||
else if (editingEdge) onEditingEdgeIdChange(null);
|
||||
else onEditingNodeIdChange(null);
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
{settingsOpen
|
||||
? "工作流设置"
|
||||
: editingEdge
|
||||
? "编辑连接条件"
|
||||
: `编辑${editingSpec?.displayName ?? "节点"}`}
|
||||
</h2>
|
||||
</div>
|
||||
) : (
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto bg-canvas-soft px-4 pb-5 pt-4">
|
||||
{settingsOpen ? (
|
||||
<GlobalSettingsPanel
|
||||
@@ -741,6 +654,16 @@ export function WorkflowCanvas({
|
||||
<EdgeSettingsPanel
|
||||
key={editingEdge.id}
|
||||
edge={editingEdge}
|
||||
sourceType={
|
||||
nodes.find(
|
||||
(node) => node.id === editingEdge.source,
|
||||
)?.type as string | undefined
|
||||
}
|
||||
isOnlyOutgoing={
|
||||
edges.filter(
|
||||
(edge) => edge.source === editingEdge.source,
|
||||
).length === 1
|
||||
}
|
||||
hasOtherDefaultPath={hasDefaultPath(
|
||||
edges,
|
||||
editingEdge.source,
|
||||
@@ -752,9 +675,8 @@ export function WorkflowCanvas({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</aside>
|
||||
)}
|
||||
</WorkflowSidePanel>
|
||||
)}
|
||||
</div>
|
||||
</EdgeActionContext.Provider>
|
||||
|
||||
38
frontend/src/components/workflow/WorkflowSidePanel.tsx
Normal file
38
frontend/src/components/workflow/WorkflowSidePanel.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function WorkflowSidePanel({
|
||||
title,
|
||||
closeLabel,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
closeLabel?: string;
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<aside className="absolute inset-y-0 right-0 z-40 flex w-1/2 flex-col overflow-hidden rounded-r-2xl border-l border-hairline bg-card shadow-2xl">
|
||||
{title && onClose && (
|
||||
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={closeLabel ?? "关闭面板"}
|
||||
title="关闭"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -31,11 +31,19 @@ export function nextEdgePriority(edges: Edge[], sourceId: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* The first path from a node is deterministic. Further paths start as an
|
||||
* incomplete LLM condition so the graph never silently gains two defaults.
|
||||
* Agent nodes need a real condition before they can leave; a lone default path
|
||||
* would consume the next user turn without letting the Agent answer it.
|
||||
* Automatic nodes may use a default as their first deterministic path.
|
||||
*/
|
||||
export function newEdgeData(edges: Edge[], sourceId: string): WorkflowEdgeData {
|
||||
export function newEdgeData(
|
||||
edges: Edge[],
|
||||
sourceId: string,
|
||||
sourceType?: string,
|
||||
): WorkflowEdgeData {
|
||||
const priority = nextEdgePriority(edges, sourceId);
|
||||
if (sourceType === "agent" && !hasDefaultPath(edges, sourceId)) {
|
||||
return { mode: "llm", priority, condition: "" };
|
||||
}
|
||||
return hasDefaultPath(edges, sourceId)
|
||||
? { mode: "llm", priority, condition: "" }
|
||||
: { mode: "always", priority };
|
||||
|
||||
@@ -27,10 +27,14 @@ import type { ExpressionRule, WorkflowEdgeData } from "../specs";
|
||||
|
||||
export function EdgeSettingsPanel({
|
||||
edge,
|
||||
sourceType,
|
||||
isOnlyOutgoing,
|
||||
hasOtherDefaultPath,
|
||||
onChange,
|
||||
}: {
|
||||
edge: Edge;
|
||||
sourceType?: string;
|
||||
isOnlyOutgoing: boolean;
|
||||
hasOtherDefaultPath: boolean;
|
||||
onChange: (patch: WorkflowEdgeData) => void;
|
||||
}) {
|
||||
@@ -109,7 +113,10 @@ export function EdgeSettingsPanel({
|
||||
{
|
||||
value: "always",
|
||||
label: "默认路径",
|
||||
disabled: mode !== "always" && hasOtherDefaultPath,
|
||||
disabled:
|
||||
mode !== "always" &&
|
||||
(hasOtherDefaultPath ||
|
||||
(sourceType === "agent" && isOnlyOutgoing)),
|
||||
},
|
||||
{ value: "llm", label: "大模型判断" },
|
||||
{ value: "expression", label: "表达式" },
|
||||
@@ -127,24 +134,31 @@ export function EdgeSettingsPanel({
|
||||
当前节点已经有一条默认路径;其它出边需要使用大模型判断或表达式。
|
||||
</span>
|
||||
)}
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
||||
优先级
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(event) => {
|
||||
const nextPriority = Number(event.target.value) || 0;
|
||||
setPriority(nextPriority);
|
||||
publish({ nextPriority });
|
||||
}}
|
||||
className="border-hairline-strong bg-background text-foreground"
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||
条件路径按数字从小到大判断,默认路径始终作为兜底。
|
||||
{sourceType === "agent" && isOnlyOutgoing && mode === "always" && (
|
||||
<span className="-mt-1 block text-xs text-destructive">
|
||||
Agent 不能只有默认路径;请改为条件路径,或删除连接以持续对话。
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{mode !== "always" && (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
||||
优先级
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(event) => {
|
||||
const nextPriority = Number(event.target.value) || 0;
|
||||
setPriority(nextPriority);
|
||||
publish({ nextPriority });
|
||||
}}
|
||||
className="border-hairline-strong bg-background text-foreground"
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||
条件路径按数字从小到大判断;默认路径不参与优先级。
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
|
||||
Reference in New Issue
Block a user