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的本科学生阅读修改
|
适合CS的本科学生阅读修改
|
||||||
|
尽量不去对pipecat框架本身修改
|
||||||
|
以MVP构建为目标
|
||||||
编写代码之前先用易于理解的语言说清楚思路
|
编写代码之前先用易于理解的语言说清楚思路
|
||||||
|
|
||||||
界面设计要参考 frontend/DESIGN.md
|
界面设计要参考 frontend/DESIGN.md
|
||||||
@@ -55,6 +55,10 @@ class CallEndPort(Protocol):
|
|||||||
|
|
||||||
def arm_after_speech(self) -> None: ...
|
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_after_current_speech(self, *, has_text: bool) -> None: ...
|
||||||
|
|
||||||
async def finish(self) -> None: ...
|
async def finish(self) -> None: ...
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -19,26 +20,26 @@ from pipecat.frames.frames import (
|
|||||||
LLMRunFrame,
|
LLMRunFrame,
|
||||||
LLMUpdateSettingsFrame,
|
LLMUpdateSettingsFrame,
|
||||||
OutputTransportMessageUrgentFrame,
|
OutputTransportMessageUrgentFrame,
|
||||||
TTSSpeakFrame,
|
|
||||||
)
|
)
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
from pipecat.processors.frame_processor import FrameProcessor
|
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.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||||
from services.knowledge import search as search_knowledge
|
from services.knowledge import search as search_knowledge
|
||||||
from services.runtime_variables import DynamicVariableStore
|
from services.runtime_variables import DynamicVariableStore
|
||||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
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_engine import WorkflowEngine
|
||||||
from services.workflow_router import STAY_ON_CURRENT_NODE, WorkflowLLMRouter
|
from services.workflow_router import WorkflowLLMRouter
|
||||||
|
|
||||||
|
|
||||||
MAX_AUTOMATIC_HOPS = 50
|
MAX_AUTOMATIC_HOPS = 50
|
||||||
AGENT_STAGE_INSTRUCTION = (
|
|
||||||
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
|
|
||||||
"不要自行解释、模拟或宣布节点切换。"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowBrain(BaseBrain):
|
class WorkflowBrain(BaseBrain):
|
||||||
@@ -69,11 +70,18 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._runtime: BrainRuntime | None = None
|
self._runtime: BrainRuntime | None = None
|
||||||
self._manager: FlowManager | None = None
|
self._manager: FlowManager | None = None
|
||||||
self._router = WorkflowLLMRouter(cfg or AssistantConfig(type="workflow"))
|
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._ended = False
|
||||||
self._greeting_context_message: dict[str, str] | None = None
|
self._greeting_context_message: dict[str, str] | None = None
|
||||||
self._startup_waiting_for_greeting = False
|
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:
|
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||||
return self._engine.greeting(self._store) or cfg.greeting
|
return self._engine.greeting(self._store) or cfg.greeting
|
||||||
@@ -95,10 +103,23 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._tools = ToolExecutor(self._store)
|
self._tools = ToolExecutor(self._store)
|
||||||
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
||||||
self._router = WorkflowLLMRouter(cfg)
|
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._greeting_context_message = None
|
||||||
self._startup_waiting_for_greeting = False
|
self._startup_waiting_for_greeting = False
|
||||||
self._client_ready = False
|
|
||||||
self._pending_visible_speech_events = []
|
|
||||||
self._manager = FlowManager(
|
self._manager = FlowManager(
|
||||||
worker=runtime.worker,
|
worker=runtime.worker,
|
||||||
llm=runtime.llm,
|
llm=runtime.llm,
|
||||||
@@ -118,6 +139,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
return message
|
return message
|
||||||
|
|
||||||
async def on_connected(self, *, greeting_pending: bool = False) -> None:
|
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_node_active(self._engine.start_id)
|
||||||
await self._emit_variables(
|
await self._emit_variables(
|
||||||
reason="initialized",
|
reason="initialized",
|
||||||
@@ -139,6 +161,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
|
|
||||||
node_config = await self._initial_node_config()
|
node_config = await self._initial_node_config()
|
||||||
await self._manager.initialize(node_config)
|
await self._manager.initialize(node_config)
|
||||||
|
await self._after_node_activated(node_config)
|
||||||
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
||||||
|
|
||||||
async def on_greeting_finished(self) -> None:
|
async def on_greeting_finished(self) -> None:
|
||||||
@@ -152,17 +175,28 @@ class WorkflowBrain(BaseBrain):
|
|||||||
|
|
||||||
node_config = await self._initial_node_config()
|
node_config = await self._initial_node_config()
|
||||||
if node_config.get("name") == self._engine.start_id:
|
if node_config.get("name") == self._engine.start_id:
|
||||||
|
self._state.enter(self._engine.start_id, WorkflowStatus.WAITING_USER)
|
||||||
return
|
return
|
||||||
await manager.set_node_from_config(node_config)
|
await manager.set_node_from_config(node_config)
|
||||||
|
await self._after_node_activated(node_config)
|
||||||
logger.info(f"Start 开场白结束,进入节点: {manager.current_node}")
|
logger.info(f"Start 开场白结束,进入节点: {manager.current_node}")
|
||||||
|
|
||||||
async def _initial_node_config(self) -> NodeConfig:
|
async def _initial_node_config(self) -> NodeConfig:
|
||||||
"""Resolve the immediate path from Start without evaluating LLM edges."""
|
"""Only a default-only Start advances before the first user turn."""
|
||||||
# Start LLM conditions need an actual user turn. Expression-only and
|
outgoing = self._engine.outgoing(self._engine.start_id)
|
||||||
# default-only starts may still advance immediately at connection time.
|
has_condition = any(
|
||||||
edge = await self._select_edge(
|
self._engine.edge_mode(edge) != "always" for edge in outgoing
|
||||||
self._engine.start_id,
|
)
|
||||||
evaluate_llm=False,
|
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 (
|
return (
|
||||||
await self._follow_edge(edge)
|
await self._follow_edge(edge)
|
||||||
@@ -172,18 +206,14 @@ class WorkflowBrain(BaseBrain):
|
|||||||
|
|
||||||
async def on_client_ready(self) -> None:
|
async def on_client_ready(self) -> None:
|
||||||
"""Replay state that may have been emitted before WebRTC data was ready."""
|
"""Replay state that may have been emitted before WebRTC data was ready."""
|
||||||
self._client_ready = True
|
await self._require_output().mark_client_ready()
|
||||||
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)
|
|
||||||
)
|
|
||||||
current_node = (
|
current_node = (
|
||||||
str(self._manager.current_node)
|
str(self._manager.current_node)
|
||||||
if self._manager and 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_node_active(current_node)
|
||||||
await self._emit_variables(
|
await self._emit_variables(
|
||||||
reason="client_ready",
|
reason="client_ready",
|
||||||
@@ -198,114 +228,69 @@ class WorkflowBrain(BaseBrain):
|
|||||||
"""Route a complete user turn before the active stage may reply."""
|
"""Route a complete user turn before the active stage may reply."""
|
||||||
if not content or self._ended:
|
if not content or self._ended:
|
||||||
return True
|
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.record_user_message(content)
|
||||||
|
self._state.begin_user_turn(content)
|
||||||
manager = self._require_manager()
|
manager = self._require_manager()
|
||||||
current = manager.current_node
|
current = self._state.current_node_id
|
||||||
if not current:
|
if not current:
|
||||||
return True
|
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(
|
next_config = await self._follow_edge(
|
||||||
edge,
|
decision.edge,
|
||||||
triggering_user_text=content,
|
triggering_user_text=content,
|
||||||
)
|
)
|
||||||
await manager.set_node_from_config(next_config)
|
await manager.set_node_from_config(next_config)
|
||||||
next_node = str(next_config.get("name") or "")
|
await self._after_node_activated(
|
||||||
if (
|
next_config,
|
||||||
self._engine.node_type(next_node) == "agent"
|
triggering_user_text=content,
|
||||||
and self._engine.data(next_node).get("entryMode", "wait_user")
|
)
|
||||||
== "wait_user"
|
|
||||||
):
|
|
||||||
await self._require_runtime().queue_frame(LLMRunFrame())
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self._engine.node_type(current) != "agent":
|
return await self._continue_current_node_after_no_transition(current)
|
||||||
# Start/Action/Handoff have no conversational LLM of their own.
|
|
||||||
# Keep waiting so a later user turn may satisfy another condition.
|
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
|
return True
|
||||||
|
|
||||||
# The incoming LLMContextFrame is intentionally suppressed by the
|
await self._refresh_agent_prompt(node_id)
|
||||||
# pipeline router. Queue prompt refresh + inference in this order so
|
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||||
# this user turn is answered with the current Agent's latest variables.
|
|
||||||
await self._refresh_agent_prompt(current)
|
|
||||||
await self._require_runtime().queue_frame(LLMRunFrame())
|
await self._require_runtime().queue_frame(LLMRunFrame())
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _select_edge(
|
async def _select_edge(
|
||||||
self,
|
self,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
*,
|
|
||||||
evaluate_llm: bool = True,
|
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Resolve conditional paths by priority, then use the default path."""
|
"""Compatibility helper used by automatic-node traversal and tests."""
|
||||||
expression_edge = self._engine.deterministic_edge(
|
decision = await self._edge_evaluator.evaluate(node_id)
|
||||||
node_id,
|
if decision.status == RouteStatus.ERROR:
|
||||||
self._store,
|
await self._require_output().emit_error(
|
||||||
include_default=False,
|
decision.error or "工作流路由失败",
|
||||||
)
|
node_id=node_id,
|
||||||
outgoing = self._engine.outgoing(node_id)
|
code="workflow_routing_error",
|
||||||
all_llm_edges = [
|
)
|
||||||
candidate
|
return None
|
||||||
for candidate in outgoing
|
return decision.edge
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_assistant_text_end(
|
async def on_assistant_text_end(
|
||||||
self,
|
self,
|
||||||
@@ -316,88 +301,29 @@ class WorkflowBrain(BaseBrain):
|
|||||||
if not content or interrupted or self._ended:
|
if not content or interrupted or self._ended:
|
||||||
return
|
return
|
||||||
self._store.record("agent", content, completed_agent_turn=True)
|
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:
|
async def _refresh_agent_prompt(self, node_id: str) -> None:
|
||||||
runtime = self._require_runtime()
|
await self._require_agent_stage().refresh_prompt(node_id)
|
||||||
await runtime.queue_frame(
|
|
||||||
LLMUpdateSettingsFrame(
|
|
||||||
delta=LLMSettings(
|
|
||||||
system_instruction=self._agent_role_message(node_id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _agent_role_message(self, node_id: str) -> str:
|
def _agent_role_message(self, node_id: str) -> str:
|
||||||
"""Build one provider-compatible system instruction for an Agent stage."""
|
return self._require_agent_stage().role_message(node_id)
|
||||||
stage_prompt = self._engine.prompt_for(node_id, self._store)
|
|
||||||
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
|
|
||||||
|
|
||||||
def _router_for_node(self, node_id: str) -> WorkflowLLMRouter:
|
def _router_for_node(self, node_id: str) -> WorkflowLLMRouter:
|
||||||
stage = self._engine.agent_stage_config(node_id)
|
if self._agent_stage is None:
|
||||||
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:
|
|
||||||
return self._router
|
return self._router
|
||||||
from services.pipecat.service_factory import config_with_resource
|
return self._agent_stage.router_for_node(node_id, self._router)
|
||||||
|
|
||||||
return WorkflowLLMRouter(config_with_resource(cfg, resource))
|
|
||||||
|
|
||||||
async def _apply_agent_stage(self, node_id: str) -> None:
|
async def _apply_agent_stage(self, node_id: str) -> None:
|
||||||
stage = self._engine.agent_stage_config(node_id)
|
await self._require_agent_stage().apply(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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def _agent_config(
|
def _agent_config(
|
||||||
self,
|
self,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
leading_messages: list[dict[str, str]] | None = None,
|
leading_messages: list[dict[str, str]] | None = None,
|
||||||
) -> NodeConfig:
|
) -> 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)
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
functions: list[FlowsFunctionSchema] = []
|
functions: list[FlowsFunctionSchema] = []
|
||||||
for tool_id in stage.tool_ids:
|
for tool_id in stage.tool_ids:
|
||||||
@@ -407,36 +333,49 @@ class WorkflowBrain(BaseBrain):
|
|||||||
knowledge_function = self._knowledge_function(node_id)
|
knowledge_function = self._knowledge_function(node_id)
|
||||||
if knowledge_function:
|
if knowledge_function:
|
||||||
functions.append(knowledge_function)
|
functions.append(knowledge_function)
|
||||||
config: NodeConfig = {
|
return self._require_agent_stage().node_config(
|
||||||
"name": node_id,
|
node_id,
|
||||||
"role_message": self._agent_role_message(node_id),
|
functions=functions,
|
||||||
# Flows writes task_messages into the Pipecat LLM context. The
|
greeting_context_message=self._greeting_context_message,
|
||||||
# pre-action below is responsible only for display, persistence,
|
leading_messages=leading_messages,
|
||||||
# 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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
async def _queue_visible_speech(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -444,27 +383,11 @@ class WorkflowBrain(BaseBrain):
|
|||||||
source: str = "workflow-speech",
|
source: str = "workflow-speech",
|
||||||
node_id: str | None = None,
|
node_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Show and persist fixed workflow speech before sending it to TTS."""
|
await self._require_output().speak(
|
||||||
content = text.strip()
|
text,
|
||||||
if not content:
|
source=source,
|
||||||
return
|
node_id=node_id,
|
||||||
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))
|
|
||||||
|
|
||||||
def _passive_node_config(
|
def _passive_node_config(
|
||||||
self,
|
self,
|
||||||
@@ -486,10 +409,19 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._tools.register_secrets(tool)
|
self._tools.register_secrets(tool)
|
||||||
|
|
||||||
async def handler(args, _flow_manager):
|
async def handler(args, _flow_manager):
|
||||||
|
transition_id = self._state.transition_id
|
||||||
try:
|
try:
|
||||||
result = await self._tools.execute(tool, dict(args or {}))
|
result = await self._tools.execute(tool, dict(args or {}))
|
||||||
except ToolExecutionError as exc:
|
except ToolExecutionError as exc:
|
||||||
return {"status": "error", "message": str(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 [])
|
updated_variables = list(result.get("updated_variables") or [])
|
||||||
if updated_variables:
|
if updated_variables:
|
||||||
await self._emit_variables(
|
await self._emit_variables(
|
||||||
@@ -504,7 +436,22 @@ class WorkflowBrain(BaseBrain):
|
|||||||
include_default=False,
|
include_default=False,
|
||||||
)
|
)
|
||||||
if edge:
|
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 result
|
||||||
|
|
||||||
return FlowsFunctionSchema(
|
return FlowsFunctionSchema(
|
||||||
@@ -516,6 +463,65 @@ class WorkflowBrain(BaseBrain):
|
|||||||
cancel_on_interruption=True,
|
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:
|
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
||||||
stage = self._engine.agent_stage_config(node_id)
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
knowledge_id = str(stage.knowledge_base_id or "")
|
knowledge_id = str(stage.knowledge_base_id or "")
|
||||||
@@ -562,6 +568,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
*,
|
*,
|
||||||
triggering_user_text: str = "",
|
triggering_user_text: str = "",
|
||||||
) -> NodeConfig:
|
) -> NodeConfig:
|
||||||
|
self._state.begin_transition()
|
||||||
leading_messages: list[dict[str, str]] = []
|
leading_messages: list[dict[str, str]] = []
|
||||||
speech = self._engine.edge_transition_speech(edge)
|
speech = self._engine.edge_transition_speech(edge)
|
||||||
if speech:
|
if speech:
|
||||||
@@ -589,7 +596,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
triggering_user_text: str = "",
|
triggering_user_text: str = "",
|
||||||
) -> NodeConfig:
|
) -> NodeConfig:
|
||||||
context_messages = list(leading_messages or [])
|
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)
|
node_type = self._engine.node_type(node_id)
|
||||||
if node_type == "agent":
|
if node_type == "agent":
|
||||||
await self._apply_agent_stage(node_id)
|
await self._apply_agent_stage(node_id)
|
||||||
@@ -611,6 +619,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
elif node_type == "handoff":
|
elif node_type == "handoff":
|
||||||
await self._enter_handoff(node_id)
|
await self._enter_handoff(node_id)
|
||||||
elif node_type == "start":
|
elif node_type == "start":
|
||||||
|
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"工作流指向未知节点:{node_id}")
|
raise RuntimeError(f"工作流指向未知节点:{node_id}")
|
||||||
@@ -619,6 +628,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
edge = await self._select_edge(node_id)
|
edge = await self._select_edge(node_id)
|
||||||
if not edge:
|
if not edge:
|
||||||
return self._passive_node_config(node_id, context_messages)
|
return self._passive_node_config(node_id, context_messages)
|
||||||
|
self._state.begin_transition()
|
||||||
speech = self._engine.edge_transition_speech(edge)
|
speech = self._engine.edge_transition_speech(edge)
|
||||||
if speech:
|
if speech:
|
||||||
content = self._store.render(speech).strip()
|
content = self._store.render(speech).strip()
|
||||||
@@ -636,6 +646,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||||
|
|
||||||
async def _enter_action(self, node_id: str) -> None:
|
async def _enter_action(self, node_id: str) -> None:
|
||||||
|
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
data = self._engine.data(node_id)
|
data = self._engine.data(node_id)
|
||||||
tool_id = str(data.get("toolId") or "")
|
tool_id = str(data.get("toolId") or "")
|
||||||
@@ -665,6 +676,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._store.values["system__last_action_error"] = str(exc)[:2048]
|
self._store.values["system__last_action_error"] = str(exc)[:2048]
|
||||||
|
|
||||||
async def _enter_handoff(self, node_id: str) -> None:
|
async def _enter_handoff(self, node_id: str) -> None:
|
||||||
|
self._state.enter(node_id, WorkflowStatus.HANDOFF)
|
||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
data = self._engine.data(node_id)
|
data = self._engine.data(node_id)
|
||||||
message = self._store.render(str(data.get("message") or ""))
|
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:
|
async def _enter_end(self, node_id: str) -> None:
|
||||||
self._ended = True
|
self._ended = True
|
||||||
|
self._state.enter(node_id, WorkflowStatus.ENDED)
|
||||||
|
self._state.finish()
|
||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
if runtime.set_knowledge_scope:
|
if runtime.set_knowledge_scope:
|
||||||
@@ -705,27 +719,17 @@ class WorkflowBrain(BaseBrain):
|
|||||||
return
|
return
|
||||||
runtime.call_end.begin("workflow_completed")
|
runtime.call_end.begin("workflow_completed")
|
||||||
if message:
|
if message:
|
||||||
runtime.call_end.arm_after_speech()
|
|
||||||
await self._queue_visible_speech(message)
|
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:
|
else:
|
||||||
await runtime.call_end.finish()
|
await runtime.call_end.finish()
|
||||||
|
|
||||||
async def _emit_node_active(self, node_id: str | None) -> None:
|
async def _emit_node_active(self, node_id: str | None) -> None:
|
||||||
if node_id:
|
await self._require_output().emit_node_active(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))
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _emit_variables(
|
async def _emit_variables(
|
||||||
self,
|
self,
|
||||||
@@ -735,21 +739,10 @@ class WorkflowBrain(BaseBrain):
|
|||||||
changed: list[str] | None = None,
|
changed: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Publish a safe snapshot so Workflow debug mirrors runtime state."""
|
"""Publish a safe snapshot so Workflow debug mirrors runtime state."""
|
||||||
message: dict[str, Any] = {
|
await self._require_output().emit_variables(
|
||||||
"type": "workflow-variables",
|
reason=reason,
|
||||||
"reason": reason,
|
node_id=node_id,
|
||||||
"variables": self._public_variables(),
|
changed=changed,
|
||||||
}
|
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _require_runtime(self) -> BrainRuntime:
|
def _require_runtime(self) -> BrainRuntime:
|
||||||
@@ -761,3 +754,20 @@ class WorkflowBrain(BaseBrain):
|
|||||||
if self._manager is None:
|
if self._manager is None:
|
||||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||||
return self._manager
|
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",
|
"icon": "Zap",
|
||||||
"accent": "peach",
|
"accent": "peach",
|
||||||
"addable": True,
|
"addable": True,
|
||||||
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
"constraints": {"minIncoming": 1, "minOutgoing": 0},
|
||||||
"fields": [
|
"fields": [
|
||||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Action"},
|
{"key": "name", "label": "节点名称", "type": "text", "default": "Action"},
|
||||||
],
|
],
|
||||||
@@ -352,18 +352,19 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
|||||||
if mode == "expression":
|
if mode == "expression":
|
||||||
expression_errors = _validate_expression(data.get("expression"))
|
expression_errors = _validate_expression(data.get("expression"))
|
||||||
errors.extend(f"边 {edge_id}:{item}" for item in expression_errors)
|
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":
|
if mode == "always":
|
||||||
always_counts[source_id] += 1
|
always_counts[source_id] += 1
|
||||||
if always_counts[source_id] > 1:
|
if always_counts[source_id] > 1:
|
||||||
errors.append(f"节点 {source_id} 最多只能有一条默认路径")
|
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
|
incoming[target_id] += 1
|
||||||
outgoing[source_id] += 1
|
outgoing[source_id] += 1
|
||||||
adj[source_id].append(target_id)
|
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}")
|
errors.append(f"节点 {node_id} 的{label}不能少于 {lo}")
|
||||||
if hi is not None and actual > hi:
|
if hi is not None and actual > hi:
|
||||||
errors.append(f"节点 {node_id} 的{label}不能多于 {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(
|
start_id = next(
|
||||||
(node_id for node_id, node in node_by_id.items() if node.get("type") == "start"),
|
(node_id for node_id, node in node_by_id.items() if node.get("type") == "start"),
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class CallEndCoordinator:
|
|||||||
self._armed = False
|
self._armed = False
|
||||||
self._speaking = False
|
self._speaking = False
|
||||||
self._response_speech_started = False
|
self._response_speech_started = False
|
||||||
|
self._tracked_speeches = 0
|
||||||
|
self._finish_after_tracked_speech = False
|
||||||
self._finished = False
|
self._finished = False
|
||||||
self._reason = "completed"
|
self._reason = "completed"
|
||||||
|
|
||||||
@@ -37,6 +39,16 @@ class CallEndCoordinator:
|
|||||||
"""Wait for the next observed bot speech to finish."""
|
"""Wait for the next observed bot speech to finish."""
|
||||||
self._armed = True
|
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:
|
async def finish_after_current_speech(self, *, has_text: bool) -> None:
|
||||||
"""Finish now if speech is absent/done, otherwise wait for its stop."""
|
"""Finish now if speech is absent/done, otherwise wait for its stop."""
|
||||||
if not has_text:
|
if not has_text:
|
||||||
@@ -59,7 +71,15 @@ class CallEndCoordinator:
|
|||||||
self._response_speech_started = True
|
self._response_speech_started = True
|
||||||
elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking:
|
elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking:
|
||||||
self._speaking = False
|
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("结束语播报完毕,挂断通话")
|
logger.info("结束语播报完毕,挂断通话")
|
||||||
await self.finish()
|
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:
|
try:
|
||||||
if operator == "exists":
|
if operator == "exists":
|
||||||
matched = exists if expected is not False else not 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":
|
elif operator == "eq":
|
||||||
matched = actual == expected
|
matched = actual == expected
|
||||||
elif operator == "neq":
|
elif operator == "neq":
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from loguru import logger
|
|||||||
from models import AssistantConfig
|
from models import AssistantConfig
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from services.workflow.models import LLMRouteResult, RouteStatus
|
||||||
|
|
||||||
|
|
||||||
STAY_ON_CURRENT_NODE = "workflow_stay_on_current_node"
|
STAY_ON_CURRENT_NODE = "workflow_stay_on_current_node"
|
||||||
# Compatibility alias for callers saved before all source nodes supported LLM edges.
|
# Compatibility alias for callers saved before all source nodes supported LLM edges.
|
||||||
@@ -38,10 +40,10 @@ class WorkflowLLMRouter:
|
|||||||
variables: dict[str, Any],
|
variables: dict[str, Any],
|
||||||
edge_name: Callable[[dict[str, Any]], str],
|
edge_name: Callable[[dict[str, Any]], str],
|
||||||
edge_description: Callable[[dict[str, Any]], str],
|
edge_description: Callable[[dict[str, Any]], str],
|
||||||
) -> str | None:
|
) -> LLMRouteResult:
|
||||||
"""Return an edge function name, STAY, or None when routing failed."""
|
"""Return a typed match, no-match or technical error."""
|
||||||
if not edges:
|
if not edges:
|
||||||
return STAY_ON_CURRENT_NODE
|
return LLMRouteResult(status=RouteStatus.NO_MATCH)
|
||||||
|
|
||||||
names = {edge_name(edge) for edge in edges}
|
names = {edge_name(edge) for edge in edges}
|
||||||
stay_name = STAY_ON_CURRENT_NODE
|
stay_name = STAY_ON_CURRENT_NODE
|
||||||
@@ -116,16 +118,28 @@ class WorkflowLLMRouter:
|
|||||||
tool_calls = response.choices[0].message.tool_calls or []
|
tool_calls = response.choices[0].message.tool_calls or []
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
logger.warning("Workflow 路由 LLM 未返回函数调用,留在当前节点")
|
logger.warning("Workflow 路由 LLM 未返回函数调用,留在当前节点")
|
||||||
return STAY_ON_CURRENT_NODE
|
return LLMRouteResult(
|
||||||
|
status=RouteStatus.ERROR,
|
||||||
|
error="路由模型没有返回函数调用",
|
||||||
|
)
|
||||||
selected = str(tool_calls[0].function.name or "")
|
selected = str(tool_calls[0].function.name or "")
|
||||||
if selected == stay_name:
|
if selected == stay_name:
|
||||||
return STAY_ON_CURRENT_NODE
|
return LLMRouteResult(status=RouteStatus.NO_MATCH)
|
||||||
if selected not in names:
|
if selected not in names:
|
||||||
logger.warning(f"Workflow 路由 LLM 返回未知函数:{selected}")
|
logger.warning(f"Workflow 路由 LLM 返回未知函数:{selected}")
|
||||||
return STAY_ON_CURRENT_NODE
|
return LLMRouteResult(
|
||||||
return selected
|
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
|
except Exception as exc: # noqa: BLE001 - routing failure must not end the call
|
||||||
logger.warning(f"Workflow LLM 边判断失败,留在当前节点:{exc}")
|
logger.warning(f"Workflow LLM 边判断失败,留在当前节点:{exc}")
|
||||||
return None
|
return LLMRouteResult(
|
||||||
|
status=RouteStatus.ERROR,
|
||||||
|
error=f"大模型路由失败:{type(exc).__name__}",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from services.brains.dify_llm import (
|
|||||||
)
|
)
|
||||||
from services.brains.workflow_brain import WorkflowBrain
|
from services.brains.workflow_brain import WorkflowBrain
|
||||||
from services.runtime_variables import prepare_dynamic_config
|
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:
|
class FakeLLM:
|
||||||
@@ -47,6 +47,7 @@ class FakeCallEnd:
|
|||||||
self.finished = False
|
self.finished = False
|
||||||
self.response_started = False
|
self.response_started = False
|
||||||
self.waited_for_text: bool | None = None
|
self.waited_for_text: bool | None = None
|
||||||
|
self.tracked_speeches = 0
|
||||||
|
|
||||||
def begin(self, reason: str) -> None:
|
def begin(self, reason: str) -> None:
|
||||||
self.ending = True
|
self.ending = True
|
||||||
@@ -58,6 +59,14 @@ class FakeCallEnd:
|
|||||||
def arm_after_speech(self) -> None:
|
def arm_after_speech(self) -> None:
|
||||||
self.armed = True
|
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:
|
async def finish_after_current_speech(self, *, has_text: bool) -> None:
|
||||||
self.waited_for_text = has_text
|
self.waited_for_text = has_text
|
||||||
if has_text:
|
if has_text:
|
||||||
@@ -824,7 +833,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
async def select_edge(self, **_kwargs):
|
async def select_edge(self, **_kwargs):
|
||||||
self.calls += 1
|
self.calls += 1
|
||||||
return "goto_eat"
|
return LLMRouteResult(
|
||||||
|
status=RouteStatus.MATCHED,
|
||||||
|
function_name="goto_eat",
|
||||||
|
)
|
||||||
|
|
||||||
manager = FakeManager()
|
manager = FakeManager()
|
||||||
router = FakeRouter()
|
router = FakeRouter()
|
||||||
@@ -847,6 +859,45 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
||||||
self.assertIn("我想吃饭", brain._store.values["system__conversation_history"])
|
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):
|
async def test_automatic_node_can_follow_llm_condition(self):
|
||||||
brain = WorkflowBrain(
|
brain = WorkflowBrain(
|
||||||
{
|
{
|
||||||
@@ -896,7 +947,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
class FakeRouter:
|
class FakeRouter:
|
||||||
async def select_edge(self, **kwargs):
|
async def select_edge(self, **kwargs):
|
||||||
self.node_name = kwargs["node_name"]
|
self.node_name = kwargs["node_name"]
|
||||||
return "goto_to_agent"
|
return LLMRouteResult(
|
||||||
|
status=RouteStatus.MATCHED,
|
||||||
|
function_name="goto_to_agent",
|
||||||
|
)
|
||||||
|
|
||||||
router = FakeRouter()
|
router = FakeRouter()
|
||||||
brain._router = router
|
brain._router = router
|
||||||
@@ -961,7 +1015,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
class FakeRouter:
|
class FakeRouter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.result = STAY_ON_CURRENT_NODE
|
self.result = LLMRouteResult(status=RouteStatus.NO_MATCH)
|
||||||
self.edge_ids = []
|
self.edge_ids = []
|
||||||
|
|
||||||
async def select_edge(self, **kwargs):
|
async def select_edge(self, **kwargs):
|
||||||
@@ -975,7 +1029,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(router.edge_ids, ["llm"])
|
self.assertEqual(router.edge_ids, ["llm"])
|
||||||
self.assertEqual(selected["id"], "expression")
|
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")
|
selected = await brain._select_edge("agent")
|
||||||
self.assertEqual(selected["id"], "llm")
|
self.assertEqual(selected["id"], "llm")
|
||||||
|
|
||||||
@@ -1183,21 +1240,19 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
brain._engine.data("agent")["entryMode"] = "generate"
|
brain._engine.data("agent")["entryMode"] = "generate"
|
||||||
generate_config = brain._agent_config("agent")
|
generate_config = brain._agent_config("agent")
|
||||||
self.assertTrue(generate_config["respond_immediately"])
|
self.assertFalse(generate_config["respond_immediately"])
|
||||||
worker.frames.clear()
|
worker.frames.clear()
|
||||||
await brain._manager.set_node_from_config(generate_config)
|
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(
|
brain._engine.data("agent").update(
|
||||||
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
|
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
|
||||||
)
|
)
|
||||||
fixed_config = brain._agent_config("agent")
|
fixed_config = brain._agent_config("agent")
|
||||||
self.assertFalse(fixed_config["respond_immediately"])
|
self.assertFalse(fixed_config["respond_immediately"])
|
||||||
self.assertEqual(
|
self.assertNotIn("pre_actions", fixed_config)
|
||||||
fixed_config["pre_actions"][0]["type"],
|
|
||||||
"workflow_fixed_speech",
|
|
||||||
)
|
|
||||||
self.assertEqual(fixed_config["pre_actions"][0]["text"], "您好,王先生")
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
fixed_config["task_messages"],
|
fixed_config["task_messages"],
|
||||||
[
|
[
|
||||||
@@ -1216,10 +1271,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{"role": "assistant", "content": "您好,王先生"},
|
{"role": "assistant", "content": "您好,王先生"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.assertEqual(fixed_config["pre_actions"][0]["node_id"], "agent")
|
|
||||||
worker.frames.clear()
|
worker.frames.clear()
|
||||||
queued.clear()
|
queued.clear()
|
||||||
await brain._manager.set_node_from_config(fixed_config)
|
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.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
||||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||||
context_updates = [
|
context_updates = [
|
||||||
@@ -1263,7 +1318,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
class FakeRouter:
|
class FakeRouter:
|
||||||
async def select_edge(self, **_kwargs):
|
async def select_edge(self, **_kwargs):
|
||||||
return "goto_finish"
|
return LLMRouteResult(
|
||||||
|
status=RouteStatus.MATCHED,
|
||||||
|
function_name="goto_finish",
|
||||||
|
)
|
||||||
|
|
||||||
brain._router = FakeRouter()
|
brain._router = FakeRouter()
|
||||||
handled = await brain.on_user_turn_end("我的需求已经说完了")
|
handled = await brain.on_user_turn_end("我的需求已经说完了")
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
self.assertEqual(self.reasons, ["tool_only"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from models import AssistantConfig
|
from models import AssistantConfig
|
||||||
|
from services.workflow.models import RouteStatus
|
||||||
from services.workflow_router import WorkflowLLMRouter
|
from services.workflow_router import WorkflowLLMRouter
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +63,8 @@ class WorkflowLLMRouterTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
edge_description=lambda _edge: "用户已经回答姓名",
|
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(requests[0]["tool_choice"], "required")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[message["role"] for message in requests[0]["messages"]],
|
[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",
|
"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 = [
|
terminal_graphs = [
|
||||||
{
|
{
|
||||||
"specVersion": 3,
|
"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(
|
self.assertTrue(
|
||||||
any(
|
any(
|
||||||
"action 的出边不能少于 1" in error
|
"Agent 节点 agent 不能只有默认路径" in error
|
||||||
for error in validate_graph(action_without_exit)
|
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):
|
def test_all_source_nodes_allow_multiple_conditional_paths(self):
|
||||||
expression = {
|
expression = {
|
||||||
"combinator": "and",
|
"combinator": "and",
|
||||||
@@ -448,6 +482,41 @@ class WorkflowGraphTests(unittest.TestCase):
|
|||||||
self.assertNotIn("服务 王先生", custom_prompt)
|
self.assertNotIn("服务 王先生", custom_prompt)
|
||||||
self.assertIn("处理订单", 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { WorkflowSettings } from "@/components/workflow/types";
|
import type { WorkflowSettings } from "@/components/workflow/types";
|
||||||
import { defaultGraph, type WorkflowGraph } from "@/components/workflow/specs";
|
import { defaultGraph, type WorkflowGraph } from "@/components/workflow/specs";
|
||||||
import type { DynamicVariableDefinition } from "@/lib/api";
|
import type { DynamicVariableDefinition } from "@/lib/api";
|
||||||
import { defaultTurnConfig } from "@/lib/turn-config";
|
|
||||||
|
|
||||||
function initialWorkflowSettings(): WorkflowSettings {
|
type WorkflowPanel =
|
||||||
const graph = defaultGraph();
|
| { type: "debug" }
|
||||||
|
| { type: "settings" }
|
||||||
|
| { type: "node"; nodeId: string }
|
||||||
|
| { type: "edge"; edgeId: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function settingsFromGraph(graph: WorkflowGraph): WorkflowSettings {
|
||||||
return {
|
return {
|
||||||
globalPrompt: graph.settings.globalPrompt,
|
globalPrompt: graph.settings.globalPrompt,
|
||||||
llm: graph.settings.defaultLlmResourceId,
|
llm: graph.settings.defaultLlmResourceId,
|
||||||
@@ -21,8 +26,8 @@ function initialWorkflowSettings(): WorkflowSettings {
|
|||||||
topN: graph.settings.knowledgeTopN,
|
topN: graph.settings.knowledgeTopN,
|
||||||
scoreThreshold: graph.settings.knowledgeScoreThreshold,
|
scoreThreshold: graph.settings.knowledgeScoreThreshold,
|
||||||
},
|
},
|
||||||
allowInterrupt: true,
|
allowInterrupt: graph.settings.enableInterrupt,
|
||||||
turnConfig: defaultTurnConfig(),
|
turnConfig: graph.settings.turnConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,19 +37,67 @@ export function useWorkflowEditorState() {
|
|||||||
const [workflowGraph, setWorkflowGraph] = useState<WorkflowGraph>(() =>
|
const [workflowGraph, setWorkflowGraph] = useState<WorkflowGraph>(() =>
|
||||||
defaultGraph(),
|
defaultGraph(),
|
||||||
);
|
);
|
||||||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>(
|
const workflowSettings = useMemo(
|
||||||
initialWorkflowSettings,
|
() => 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] =
|
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
|
||||||
useState<Record<string, DynamicVariableDefinition>>({});
|
useState<Record<string, DynamicVariableDefinition>>({});
|
||||||
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
|
const [workflowPanel, setWorkflowPanel] = useState<WorkflowPanel>(null);
|
||||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
const workflowDebugOpen = workflowPanel?.type === "debug";
|
||||||
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(
|
const workflowSettingsOpen = workflowPanel?.type === "settings";
|
||||||
null,
|
const workflowEditingNodeId =
|
||||||
);
|
workflowPanel?.type === "node" ? workflowPanel.nodeId : null;
|
||||||
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(
|
const workflowEditingEdgeId =
|
||||||
null,
|
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 [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||||
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
|
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
|
||||||
|
|
||||||
@@ -65,6 +118,7 @@ export function useWorkflowEditorState() {
|
|||||||
setWorkflowEditingNodeId,
|
setWorkflowEditingNodeId,
|
||||||
workflowEditingEdgeId,
|
workflowEditingEdgeId,
|
||||||
setWorkflowEditingEdgeId,
|
setWorkflowEditingEdgeId,
|
||||||
|
workflowPanel,
|
||||||
activeNodeId,
|
activeNodeId,
|
||||||
setActiveNodeId,
|
setActiveNodeId,
|
||||||
dynamicVariablesOpen,
|
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,
|
useNodesState,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { Braces, Plus, Settings2, X } from "lucide-react";
|
import { Braces, Plus, Settings2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -26,13 +26,6 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -40,6 +33,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
import { AddNodeDialog } from "./AddNodeDialog";
|
||||||
import { edgeTypes } from "./ConditionEdge";
|
import { edgeTypes } from "./ConditionEdge";
|
||||||
import { hasDefaultPath, newEdgeData } from "./edge-rules";
|
import { hasDefaultPath, newEdgeData } from "./edge-rules";
|
||||||
import {
|
import {
|
||||||
@@ -52,8 +46,8 @@ import { nodeTypes } from "./GenericNode";
|
|||||||
import { EdgeSettingsPanel } from "./panels/EdgeSettingsPanel";
|
import { EdgeSettingsPanel } from "./panels/EdgeSettingsPanel";
|
||||||
import { GlobalSettingsPanel } from "./panels/GlobalSettingsPanel";
|
import { GlobalSettingsPanel } from "./panels/GlobalSettingsPanel";
|
||||||
import { NodeSettingsPanel } from "./panels/NodeSettingsPanel";
|
import { NodeSettingsPanel } from "./panels/NodeSettingsPanel";
|
||||||
|
import { WorkflowSidePanel } from "./WorkflowSidePanel";
|
||||||
import {
|
import {
|
||||||
accentVar,
|
|
||||||
defaultGraph,
|
defaultGraph,
|
||||||
type NodeSpecMap,
|
type NodeSpecMap,
|
||||||
type RuntimeNodeSpec,
|
type RuntimeNodeSpec,
|
||||||
@@ -195,7 +189,9 @@ export function WorkflowCanvas({
|
|||||||
settings.tts,
|
settings.tts,
|
||||||
settings.toolIds,
|
settings.toolIds,
|
||||||
settings.knowledgeBaseId,
|
settings.knowledgeBaseId,
|
||||||
settings.knowledgeRetrievalConfig,
|
settings.knowledgeRetrievalConfig.mode,
|
||||||
|
settings.knowledgeRetrievalConfig.topN,
|
||||||
|
settings.knowledgeRetrievalConfig.scoreThreshold,
|
||||||
settings.allowInterrupt,
|
settings.allowInterrupt,
|
||||||
settings.turnConfig,
|
settings.turnConfig,
|
||||||
]);
|
]);
|
||||||
@@ -203,6 +199,9 @@ export function WorkflowCanvas({
|
|||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
if (!connection.source || !connection.target) return;
|
if (!connection.source || !connection.target) return;
|
||||||
|
const sourceType = nodes.find(
|
||||||
|
(node) => node.id === connection.source,
|
||||||
|
)?.type;
|
||||||
setEdges((eds) =>
|
setEdges((eds) =>
|
||||||
addEdge(
|
addEdge(
|
||||||
{
|
{
|
||||||
@@ -210,13 +209,13 @@ export function WorkflowCanvas({
|
|||||||
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
|
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
|
||||||
type: "condition",
|
type: "condition",
|
||||||
animated: true,
|
animated: true,
|
||||||
data: newEdgeData(eds, connection.source),
|
data: newEdgeData(eds, connection.source, sourceType),
|
||||||
},
|
},
|
||||||
eds,
|
eds,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[setEdges],
|
[nodes, setEdges],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
|
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
|
||||||
@@ -277,7 +276,7 @@ export function WorkflowCanvas({
|
|||||||
target: id,
|
target: id,
|
||||||
type: "condition",
|
type: "condition",
|
||||||
animated: true,
|
animated: true,
|
||||||
data: newEdgeData(currentEdges, source.id),
|
data: newEdgeData(currentEdges, source.id, source.type),
|
||||||
},
|
},
|
||||||
currentEdges,
|
currentEdges,
|
||||||
);
|
);
|
||||||
@@ -577,9 +576,11 @@ export function WorkflowCanvas({
|
|||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 添加节点弹窗 */}
|
<AddNodeDialog
|
||||||
<Dialog
|
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
|
connectingFromSource={Boolean(addSourceId)}
|
||||||
|
specs={addableSpecs}
|
||||||
|
canAdd={canAddSpec}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setAddOpen(open);
|
setAddOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -587,131 +588,43 @@ export function WorkflowCanvas({
|
|||||||
setAddPosition(null);
|
setAddPosition(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
onSelect={addNode}
|
||||||
<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>
|
|
||||||
|
|
||||||
{(debugOpen ||
|
{(debugOpen ||
|
||||||
settingsOpen ||
|
settingsOpen ||
|
||||||
(editingNode && editingSpec) ||
|
(editingNode && editingSpec) ||
|
||||||
editingEdge) && (
|
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 ? (
|
{debugOpen ? (
|
||||||
debugPanel
|
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">
|
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto bg-canvas-soft px-4 pb-5 pt-4">
|
||||||
{settingsOpen ? (
|
{settingsOpen ? (
|
||||||
<GlobalSettingsPanel
|
<GlobalSettingsPanel
|
||||||
@@ -741,6 +654,16 @@ export function WorkflowCanvas({
|
|||||||
<EdgeSettingsPanel
|
<EdgeSettingsPanel
|
||||||
key={editingEdge.id}
|
key={editingEdge.id}
|
||||||
edge={editingEdge}
|
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(
|
hasOtherDefaultPath={hasDefaultPath(
|
||||||
edges,
|
edges,
|
||||||
editingEdge.source,
|
editingEdge.source,
|
||||||
@@ -752,9 +675,8 @@ export function WorkflowCanvas({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
) : null}
|
</WorkflowSidePanel>
|
||||||
</aside>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</EdgeActionContext.Provider>
|
</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
|
* Agent nodes need a real condition before they can leave; a lone default path
|
||||||
* incomplete LLM condition so the graph never silently gains two defaults.
|
* 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);
|
const priority = nextEdgePriority(edges, sourceId);
|
||||||
|
if (sourceType === "agent" && !hasDefaultPath(edges, sourceId)) {
|
||||||
|
return { mode: "llm", priority, condition: "" };
|
||||||
|
}
|
||||||
return hasDefaultPath(edges, sourceId)
|
return hasDefaultPath(edges, sourceId)
|
||||||
? { mode: "llm", priority, condition: "" }
|
? { mode: "llm", priority, condition: "" }
|
||||||
: { mode: "always", priority };
|
: { mode: "always", priority };
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ import type { ExpressionRule, WorkflowEdgeData } from "../specs";
|
|||||||
|
|
||||||
export function EdgeSettingsPanel({
|
export function EdgeSettingsPanel({
|
||||||
edge,
|
edge,
|
||||||
|
sourceType,
|
||||||
|
isOnlyOutgoing,
|
||||||
hasOtherDefaultPath,
|
hasOtherDefaultPath,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
edge: Edge;
|
edge: Edge;
|
||||||
|
sourceType?: string;
|
||||||
|
isOnlyOutgoing: boolean;
|
||||||
hasOtherDefaultPath: boolean;
|
hasOtherDefaultPath: boolean;
|
||||||
onChange: (patch: WorkflowEdgeData) => void;
|
onChange: (patch: WorkflowEdgeData) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -109,7 +113,10 @@ export function EdgeSettingsPanel({
|
|||||||
{
|
{
|
||||||
value: "always",
|
value: "always",
|
||||||
label: "默认路径",
|
label: "默认路径",
|
||||||
disabled: mode !== "always" && hasOtherDefaultPath,
|
disabled:
|
||||||
|
mode !== "always" &&
|
||||||
|
(hasOtherDefaultPath ||
|
||||||
|
(sourceType === "agent" && isOnlyOutgoing)),
|
||||||
},
|
},
|
||||||
{ value: "llm", label: "大模型判断" },
|
{ value: "llm", label: "大模型判断" },
|
||||||
{ value: "expression", label: "表达式" },
|
{ value: "expression", label: "表达式" },
|
||||||
@@ -127,24 +134,31 @@ export function EdgeSettingsPanel({
|
|||||||
当前节点已经有一条默认路径;其它出边需要使用大模型判断或表达式。
|
当前节点已经有一条默认路径;其它出边需要使用大模型判断或表达式。
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<label className="block">
|
{sourceType === "agent" && isOnlyOutgoing && mode === "always" && (
|
||||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
<span className="-mt-1 block text-xs text-destructive">
|
||||||
优先级
|
Agent 不能只有默认路径;请改为条件路径,或删除连接以持续对话。
|
||||||
</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>
|
</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>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
|
|||||||
Reference in New Issue
Block a user