Enhance workflow routing and agent configuration management
- Introduce WorkflowLLMRouter for pre-response LLM routing, allowing agents to determine the appropriate function to call based on user input. - Implement UserTurnRoutingProcessor to manage user turns before reaching the LLM, ensuring proper routing and handling of user messages. - Refactor WorkflowBrain to integrate new routing logic and enhance agent stage configuration, including entry modes and resource management. - Update service factory to support dynamic LLM resource configuration based on workflow settings. - Add tests for new routing functionality and ensure proper handling of user messages in various scenarios.
This commit is contained in:
@@ -48,14 +48,23 @@ async def _validate_workflow_references(
|
|||||||
settings = graph.get("settings") or {}
|
settings = graph.get("settings") or {}
|
||||||
resource_expectations: dict[str, str] = {}
|
resource_expectations: dict[str, str] = {}
|
||||||
for key, capability in (
|
for key, capability in (
|
||||||
|
("defaultLlmResourceId", "LLM"),
|
||||||
("defaultAsrResourceId", "ASR"),
|
("defaultAsrResourceId", "ASR"),
|
||||||
("defaultTtsResourceId", "TTS"),
|
("defaultTtsResourceId", "TTS"),
|
||||||
):
|
):
|
||||||
if settings.get(key):
|
if settings.get(key):
|
||||||
resource_expectations[str(settings[key])] = capability
|
resource_expectations[str(settings[key])] = capability
|
||||||
knowledge_ids: set[str] = set()
|
knowledge_ids: set[str] = (
|
||||||
|
{str(settings["knowledgeBaseId"])}
|
||||||
|
if settings.get("knowledgeBaseId")
|
||||||
|
else set()
|
||||||
|
)
|
||||||
for node in graph.get("nodes") or []:
|
for node in graph.get("nodes") or []:
|
||||||
data = node.get("data") or {}
|
data = node.get("data") or {}
|
||||||
|
if node.get("type") == "agent" and data.get("inheritGlobalConfig", True):
|
||||||
|
continue
|
||||||
|
if data.get("llmResourceId"):
|
||||||
|
resource_expectations[str(data["llmResourceId"])] = "LLM"
|
||||||
if data.get("asrResourceId"):
|
if data.get("asrResourceId"):
|
||||||
resource_expectations[str(data["asrResourceId"])] = "ASR"
|
resource_expectations[str(data["asrResourceId"])] = "ASR"
|
||||||
if data.get("ttsResourceId"):
|
if data.get("ttsResourceId"):
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ class BrainRuntime:
|
|||||||
worker: Any = None
|
worker: Any = None
|
||||||
context_aggregator: Any = None
|
context_aggregator: Any = None
|
||||||
transport: Any = None
|
transport: Any = None
|
||||||
switch_services: Callable[[str | None, str | None], Awaitable[None]] | None = None
|
switch_services: (
|
||||||
|
Callable[[str | None, str | None, str | None], Awaitable[None]] | None
|
||||||
|
) = None
|
||||||
set_knowledge_scope: Callable[[dict[str, Any]], None] | None = None
|
set_knowledge_scope: Callable[[dict[str, Any]], None] | None = None
|
||||||
set_input_enabled: Callable[[bool], None] | None = None
|
set_input_enabled: Callable[[bool], None] | None = None
|
||||||
flow_global_functions: list[Any] = field(default_factory=list)
|
flow_global_functions: list[Any] = field(default_factory=list)
|
||||||
@@ -84,6 +86,15 @@ class BaseBrain:
|
|||||||
def record_user_message(self, content: str) -> None:
|
def record_user_message(self, content: str) -> None:
|
||||||
"""Observe a committed user message for brain-owned routing state."""
|
"""Observe a committed user message for brain-owned routing state."""
|
||||||
|
|
||||||
|
async def on_user_turn_end(self, content: str) -> bool:
|
||||||
|
"""Handle a complete user turn before the conversational LLM runs.
|
||||||
|
|
||||||
|
Return True when the brain scheduled the next action itself and the
|
||||||
|
in-flight context frame must not reach the previous Agent's LLM.
|
||||||
|
"""
|
||||||
|
self.record_user_message(content)
|
||||||
|
return False
|
||||||
|
|
||||||
async def on_assistant_text_start(self, turn_id: str) -> None:
|
async def on_assistant_text_start(self, turn_id: str) -> None:
|
||||||
"""Observe the start of a generated assistant turn."""
|
"""Observe the start of a generated assistant turn."""
|
||||||
|
|
||||||
@@ -114,6 +125,8 @@ class Brain(Protocol):
|
|||||||
|
|
||||||
def record_user_message(self, content: str) -> None: ...
|
def record_user_message(self, content: str) -> None: ...
|
||||||
|
|
||||||
|
async def on_user_turn_end(self, content: str) -> bool: ...
|
||||||
|
|
||||||
async def on_assistant_text_start(self, turn_id: str) -> None: ...
|
async def on_assistant_text_start(self, turn_id: str) -> None: ...
|
||||||
|
|
||||||
async def on_assistant_text_end(
|
async def on_assistant_text_end(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pipecat.flows import (
|
|||||||
NodeConfig,
|
NodeConfig,
|
||||||
)
|
)
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
|
LLMRunFrame,
|
||||||
LLMUpdateSettingsFrame,
|
LLMUpdateSettingsFrame,
|
||||||
OutputTransportMessageUrgentFrame,
|
OutputTransportMessageUrgentFrame,
|
||||||
TTSSpeakFrame,
|
TTSSpeakFrame,
|
||||||
@@ -22,18 +23,20 @@ from pipecat.frames.frames import (
|
|||||||
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.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_engine import WorkflowEngine
|
from services.workflow_engine import WorkflowEngine
|
||||||
|
from services.workflow_router import STAY_ON_CURRENT_AGENT, WorkflowLLMRouter
|
||||||
|
|
||||||
|
|
||||||
MAX_AUTOMATIC_HOPS = 50
|
MAX_AUTOMATIC_HOPS = 50
|
||||||
AGENT_STAGE_INSTRUCTION = (
|
AGENT_STAGE_INSTRUCTION = (
|
||||||
"完成当前阶段任务。需要流转时必须调用对应的转移函数;"
|
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
|
||||||
"不要在调用转移函数后继续生成口头回复。"
|
"不要自行解释、模拟或宣布节点切换。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ 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._ended = False
|
self._ended = False
|
||||||
|
|
||||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||||
@@ -79,6 +83,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._store = DynamicVariableStore.from_config(cfg)
|
self._store = DynamicVariableStore.from_config(cfg)
|
||||||
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._manager = FlowManager(
|
self._manager = FlowManager(
|
||||||
worker=runtime.worker,
|
worker=runtime.worker,
|
||||||
llm=runtime.llm,
|
llm=runtime.llm,
|
||||||
@@ -95,9 +100,13 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._store,
|
self._store,
|
||||||
include_default=True,
|
include_default=True,
|
||||||
)
|
)
|
||||||
if not edge:
|
if not edge and self._engine.has_outgoing(self._engine.start_id):
|
||||||
raise RuntimeError("Start 初始化后没有命中的表达式边或默认边")
|
raise RuntimeError("Start 初始化后没有命中的表达式边或默认边")
|
||||||
node_config = await self._follow_edge(edge)
|
node_config = (
|
||||||
|
await self._follow_edge(edge)
|
||||||
|
if edge
|
||||||
|
else self._passive_node_config(self._engine.start_id)
|
||||||
|
)
|
||||||
if self._manager is None:
|
if self._manager is None:
|
||||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||||
await self._manager.initialize(node_config)
|
await self._manager.initialize(node_config)
|
||||||
@@ -107,6 +116,76 @@ class WorkflowBrain(BaseBrain):
|
|||||||
if content and not self._ended:
|
if content and not self._ended:
|
||||||
self._store.record("user", content)
|
self._store.record("user", content)
|
||||||
|
|
||||||
|
async def on_user_turn_end(self, content: str) -> bool:
|
||||||
|
"""Route a complete user turn before any Agent is allowed to reply."""
|
||||||
|
if not content or self._ended:
|
||||||
|
return True
|
||||||
|
self.record_user_message(content)
|
||||||
|
manager = self._require_manager()
|
||||||
|
current = manager.current_node
|
||||||
|
if not current or self._engine.node_type(current) != "agent":
|
||||||
|
return True
|
||||||
|
|
||||||
|
edge = self._engine.deterministic_edge(
|
||||||
|
current,
|
||||||
|
self._store,
|
||||||
|
include_default=False,
|
||||||
|
)
|
||||||
|
outgoing = self._engine.outgoing(current)
|
||||||
|
llm_edges = [
|
||||||
|
candidate
|
||||||
|
for candidate in outgoing
|
||||||
|
if self._engine.edge_mode(candidate) == "llm"
|
||||||
|
]
|
||||||
|
default_edge = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in outgoing
|
||||||
|
if self._engine.edge_mode(candidate) == "always"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if edge is None and llm_edges:
|
||||||
|
selected = await self._router_for_node(current).select_edge(
|
||||||
|
node_name=self._engine.name(current),
|
||||||
|
node_prompt=self._engine.prompt_for(current, 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 and selected != STAY_ON_CURRENT_AGENT:
|
||||||
|
edge = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in llm_edges
|
||||||
|
if self._engine.edge_fn_name(candidate) == selected
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
elif selected == STAY_ON_CURRENT_AGENT:
|
||||||
|
edge = default_edge
|
||||||
|
elif edge is None and not llm_edges:
|
||||||
|
edge = default_edge
|
||||||
|
|
||||||
|
if edge and manager.current_node == current:
|
||||||
|
next_config = await self._follow_edge(edge)
|
||||||
|
await manager.set_node_from_config(next_config)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# The incoming LLMContextFrame is intentionally suppressed by the
|
||||||
|
# pipeline router. Queue prompt refresh + inference in this order so
|
||||||
|
# this user turn is answered with the current Agent's latest variables.
|
||||||
|
await self._refresh_agent_prompt(current)
|
||||||
|
await self._require_runtime().queue_frame(LLMRunFrame())
|
||||||
|
return True
|
||||||
|
|
||||||
async def on_assistant_text_end(
|
async def on_assistant_text_end(
|
||||||
self,
|
self,
|
||||||
_turn_id: str,
|
_turn_id: str,
|
||||||
@@ -116,19 +195,6 @@ 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)
|
||||||
manager = self._require_manager()
|
|
||||||
current = manager.current_node
|
|
||||||
if not current or self._engine.node_type(current) != "agent":
|
|
||||||
return
|
|
||||||
await self._refresh_agent_prompt(current)
|
|
||||||
edge = self._engine.deterministic_edge(
|
|
||||||
current,
|
|
||||||
self._store,
|
|
||||||
include_default=False,
|
|
||||||
)
|
|
||||||
if edge and manager.current_node == current:
|
|
||||||
next_config = await self._follow_edge(edge)
|
|
||||||
await manager.set_node_from_config(next_config)
|
|
||||||
|
|
||||||
async def _refresh_agent_prompt(self, node_id: str) -> None:
|
async def _refresh_agent_prompt(self, node_id: str) -> None:
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
@@ -145,61 +211,104 @@ class WorkflowBrain(BaseBrain):
|
|||||||
stage_prompt = self._engine.prompt_for(node_id, self._store)
|
stage_prompt = self._engine.prompt_for(node_id, self._store)
|
||||||
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
|
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
|
||||||
|
|
||||||
|
def _router_for_node(self, node_id: str) -> WorkflowLLMRouter:
|
||||||
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
|
resource_id = stage.llm_resource_id
|
||||||
|
cfg = self._cfg
|
||||||
|
resource = cfg.workflow_model_resources.get(resource_id) if cfg else None
|
||||||
|
if not cfg or not resource:
|
||||||
|
return self._router
|
||||||
|
from services.pipecat.service_factory import config_with_resource
|
||||||
|
|
||||||
|
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:
|
||||||
data = self._engine.data(node_id)
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
if self._runtime and self._runtime.set_input_enabled:
|
if self._runtime and self._runtime.set_input_enabled:
|
||||||
self._runtime.set_input_enabled(True)
|
self._runtime.set_input_enabled(True)
|
||||||
asr_id = str(
|
|
||||||
data.get("asrResourceId")
|
|
||||||
or self._engine.settings.get("defaultAsrResourceId")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
tts_id = str(
|
|
||||||
data.get("ttsResourceId")
|
|
||||||
or self._engine.settings.get("defaultTtsResourceId")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
if runtime.switch_services:
|
if runtime.switch_services:
|
||||||
await runtime.switch_services(asr_id or None, tts_id or None)
|
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:
|
if runtime.set_knowledge_scope:
|
||||||
runtime.set_knowledge_scope(
|
runtime.set_knowledge_scope(
|
||||||
{
|
{
|
||||||
"knowledge_base_id": data.get("knowledgeBaseId"),
|
"knowledge_base_id": stage.knowledge_base_id,
|
||||||
"mode": data.get("knowledgeMode", "disabled"),
|
"mode": stage.knowledge_mode,
|
||||||
"top_n": int(data.get("knowledgeTopN") or 5),
|
"top_n": stage.knowledge_top_n,
|
||||||
"score_threshold": float(data.get("knowledgeScoreThreshold") or 0.0),
|
"score_threshold": stage.knowledge_score_threshold,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _agent_config(self, node_id: str) -> NodeConfig:
|
def _agent_config(self, node_id: str) -> NodeConfig:
|
||||||
data = self._engine.data(node_id)
|
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 = (
|
strategy = (
|
||||||
ContextStrategy.RESET
|
ContextStrategy.RESET
|
||||||
if data.get("contextPolicy") == "fresh"
|
if data.get("contextPolicy") == "fresh"
|
||||||
else ContextStrategy.APPEND
|
else ContextStrategy.APPEND
|
||||||
)
|
)
|
||||||
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
functions: list[FlowsFunctionSchema] = []
|
functions: list[FlowsFunctionSchema] = []
|
||||||
for tool_id in data.get("toolIds") or []:
|
for tool_id in stage.tool_ids:
|
||||||
tool = self._tool_by_id.get(str(tool_id))
|
tool = self._tool_by_id.get(str(tool_id))
|
||||||
if tool and tool.type == "http":
|
if tool and tool.type == "http":
|
||||||
functions.append(self._flow_tool(tool, node_id))
|
functions.append(self._flow_tool(tool, node_id))
|
||||||
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)
|
||||||
for edge in self._engine.llm_edges(node_id):
|
config: NodeConfig = {
|
||||||
functions.append(self._flow_edge(edge))
|
|
||||||
return {
|
|
||||||
"name": node_id,
|
"name": node_id,
|
||||||
"role_message": self._agent_role_message(node_id),
|
"role_message": self._agent_role_message(node_id),
|
||||||
"task_messages": [],
|
"task_messages": (
|
||||||
|
[{"role": "assistant", "content": entry_speech}]
|
||||||
|
if entry_mode == "fixed_speech"
|
||||||
|
else []
|
||||||
|
),
|
||||||
"functions": functions,
|
"functions": functions,
|
||||||
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||||
"respond_immediately": True,
|
"respond_immediately": entry_mode == "generate",
|
||||||
}
|
}
|
||||||
|
if entry_mode == "fixed_speech":
|
||||||
|
config["pre_actions"] = [
|
||||||
|
{
|
||||||
|
"type": "workflow_fixed_speech",
|
||||||
|
"text": entry_speech,
|
||||||
|
"handler": self._play_fixed_speech,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return config
|
||||||
|
|
||||||
def _terminal_config(self, node_id: str) -> NodeConfig:
|
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 ""))
|
||||||
|
|
||||||
|
async def _queue_visible_speech(self, text: str) -> None:
|
||||||
|
"""Show and persist fixed workflow speech before sending it to TTS."""
|
||||||
|
content = text.strip()
|
||||||
|
if not content:
|
||||||
|
return
|
||||||
|
self._store.record("agent", content)
|
||||||
|
runtime = self._require_runtime()
|
||||||
|
await runtime.queue_frame(
|
||||||
|
OutputTransportMessageUrgentFrame(
|
||||||
|
message={
|
||||||
|
"type": "transcript",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content,
|
||||||
|
"timestamp": time_now_iso8601(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await runtime.queue_frame(TTSSpeakFrame(content, append_to_context=False))
|
||||||
|
|
||||||
|
def _passive_node_config(self, node_id: str) -> NodeConfig:
|
||||||
|
"""Keep a non-conversational terminal node active without ending the call."""
|
||||||
return {
|
return {
|
||||||
"name": node_id,
|
"name": node_id,
|
||||||
"role_message": self._store.render(self._engine.global_prompt()),
|
"role_message": self._store.render(self._engine.global_prompt()),
|
||||||
@@ -235,24 +344,13 @@ class WorkflowBrain(BaseBrain):
|
|||||||
properties=properties,
|
properties=properties,
|
||||||
required=required,
|
required=required,
|
||||||
handler=handler,
|
handler=handler,
|
||||||
)
|
cancel_on_interruption=True,
|
||||||
|
|
||||||
def _flow_edge(self, edge: dict) -> FlowsFunctionSchema:
|
|
||||||
async def handler(_args, _flow_manager):
|
|
||||||
return None, await self._follow_edge(edge)
|
|
||||||
|
|
||||||
return FlowsFunctionSchema(
|
|
||||||
name=self._engine.edge_fn_name(edge),
|
|
||||||
description=self._engine.edge_description(edge),
|
|
||||||
properties={},
|
|
||||||
required=[],
|
|
||||||
handler=handler,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
||||||
data = self._engine.data(node_id)
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
knowledge_id = str(data.get("knowledgeBaseId") or "")
|
knowledge_id = str(stage.knowledge_base_id or "")
|
||||||
if not knowledge_id or data.get("knowledgeMode") != "on_demand":
|
if not knowledge_id or stage.knowledge_mode != "on_demand":
|
||||||
return None
|
return None
|
||||||
cfg = self._cfg or AssistantConfig(type="workflow")
|
cfg = self._cfg or AssistantConfig(type="workflow")
|
||||||
knowledge = cfg.workflow_knowledge_bases.get(knowledge_id)
|
knowledge = cfg.workflow_knowledge_bases.get(knowledge_id)
|
||||||
@@ -270,8 +368,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
session,
|
session,
|
||||||
knowledge_id,
|
knowledge_id,
|
||||||
query,
|
query,
|
||||||
top_k=int(data.get("knowledgeTopN") or 5),
|
top_k=stage.knowledge_top_n,
|
||||||
score_threshold=float(data.get("knowledgeScoreThreshold") or 0.0),
|
score_threshold=stage.knowledge_score_threshold,
|
||||||
)
|
)
|
||||||
return {"status": "ok", "results": results}
|
return {"status": "ok", "results": results}
|
||||||
except Exception as exc: # noqa: BLE001 - tool errors are returned to the LLM
|
except Exception as exc: # noqa: BLE001 - tool errors are returned to the LLM
|
||||||
@@ -286,14 +384,13 @@ class WorkflowBrain(BaseBrain):
|
|||||||
},
|
},
|
||||||
required=["query"],
|
required=["query"],
|
||||||
handler=handler,
|
handler=handler,
|
||||||
|
cancel_on_interruption=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _follow_edge(self, edge: dict) -> NodeConfig:
|
async def _follow_edge(self, edge: dict) -> NodeConfig:
|
||||||
speech = self._engine.edge_transition_speech(edge)
|
speech = self._engine.edge_transition_speech(edge)
|
||||||
if speech:
|
if speech:
|
||||||
await self._require_runtime().queue_frame(
|
await self._queue_visible_speech(self._store.render(speech))
|
||||||
TTSSpeakFrame(self._store.render(speech), append_to_context=False)
|
|
||||||
)
|
|
||||||
return await self._resolve_path(str(edge.get("target") or ""))
|
return await self._resolve_path(str(edge.get("target") or ""))
|
||||||
|
|
||||||
async def _resolve_path(self, node_id: str) -> NodeConfig:
|
async def _resolve_path(self, node_id: str) -> NodeConfig:
|
||||||
@@ -304,7 +401,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
return self._agent_config(node_id)
|
return self._agent_config(node_id)
|
||||||
if node_type == "end":
|
if node_type == "end":
|
||||||
await self._enter_end(node_id)
|
await self._enter_end(node_id)
|
||||||
return self._terminal_config(node_id)
|
return self._passive_node_config(node_id)
|
||||||
if node_type == "action":
|
if node_type == "action":
|
||||||
await self._enter_action(node_id)
|
await self._enter_action(node_id)
|
||||||
elif node_type == "handoff":
|
elif node_type == "handoff":
|
||||||
@@ -313,6 +410,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
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}")
|
||||||
|
if not self._engine.has_outgoing(node_id):
|
||||||
|
return self._passive_node_config(node_id)
|
||||||
edge = self._engine.deterministic_edge(
|
edge = self._engine.deterministic_edge(
|
||||||
node_id,
|
node_id,
|
||||||
self._store,
|
self._store,
|
||||||
@@ -322,9 +421,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
raise RuntimeError(f"自动节点 {node_id} 没有命中的表达式边或默认边")
|
raise RuntimeError(f"自动节点 {node_id} 没有命中的表达式边或默认边")
|
||||||
speech = self._engine.edge_transition_speech(edge)
|
speech = self._engine.edge_transition_speech(edge)
|
||||||
if speech:
|
if speech:
|
||||||
await self._require_runtime().queue_frame(
|
await self._queue_visible_speech(self._store.render(speech))
|
||||||
TTSSpeakFrame(self._store.render(speech), append_to_context=False)
|
|
||||||
)
|
|
||||||
node_id = str(edge.get("target") or "")
|
node_id = str(edge.get("target") or "")
|
||||||
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||||
|
|
||||||
@@ -366,9 +463,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if message:
|
if message:
|
||||||
await self._require_runtime().queue_frame(
|
await self._queue_visible_speech(message)
|
||||||
TTSSpeakFrame(message, append_to_context=False)
|
|
||||||
)
|
|
||||||
self._store.values["system__handoff_status"] = "requested"
|
self._store.values["system__handoff_status"] = "requested"
|
||||||
|
|
||||||
async def _enter_end(self, node_id: str) -> None:
|
async def _enter_end(self, node_id: str) -> None:
|
||||||
@@ -389,12 +484,12 @@ class WorkflowBrain(BaseBrain):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if message:
|
if message:
|
||||||
await runtime.queue_frame(TTSSpeakFrame(message, append_to_context=False))
|
await self._queue_visible_speech(message)
|
||||||
return
|
return
|
||||||
runtime.call_end.begin("workflow_completed")
|
runtime.call_end.begin("workflow_completed")
|
||||||
if message:
|
if message:
|
||||||
runtime.call_end.arm_after_speech()
|
runtime.call_end.arm_after_speech()
|
||||||
await runtime.queue_frame(TTSSpeakFrame(message, append_to_context=False))
|
await self._queue_visible_speech(message)
|
||||||
else:
|
else:
|
||||||
await runtime.call_end.finish()
|
await runtime.call_end.finish()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from typing import Any
|
|||||||
SPEC_VERSION = "3"
|
SPEC_VERSION = "3"
|
||||||
NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
|
NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
|
||||||
EDGE_MODES = {"llm", "expression", "always"}
|
EDGE_MODES = {"llm", "expression", "always"}
|
||||||
|
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
|
||||||
|
AUTOMATIC_NODE_TYPES = {"start", "action", "handoff"}
|
||||||
EXPRESSION_OPERATORS = {
|
EXPRESSION_OPERATORS = {
|
||||||
"eq",
|
"eq",
|
||||||
"neq",
|
"neq",
|
||||||
@@ -34,7 +36,7 @@ NODE_SPECS: list[dict[str, Any]] = [
|
|||||||
"constraints": {
|
"constraints": {
|
||||||
"minIncoming": 0,
|
"minIncoming": 0,
|
||||||
"maxIncoming": 0,
|
"maxIncoming": 0,
|
||||||
"minOutgoing": 1,
|
"minOutgoing": 0,
|
||||||
"minInstances": 1,
|
"minInstances": 1,
|
||||||
"maxInstances": 1,
|
"maxInstances": 1,
|
||||||
},
|
},
|
||||||
@@ -51,7 +53,7 @@ NODE_SPECS: list[dict[str, Any]] = [
|
|||||||
"icon": "Bot",
|
"icon": "Bot",
|
||||||
"accent": "sky",
|
"accent": "sky",
|
||||||
"addable": True,
|
"addable": True,
|
||||||
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
"constraints": {"minIncoming": 1, "minOutgoing": 0},
|
||||||
"fields": [
|
"fields": [
|
||||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Agent"},
|
{"key": "name", "label": "节点名称", "type": "text", "default": "Agent"},
|
||||||
{
|
{
|
||||||
@@ -84,7 +86,7 @@ NODE_SPECS: list[dict[str, Any]] = [
|
|||||||
"icon": "PhoneForwarded",
|
"icon": "PhoneForwarded",
|
||||||
"accent": "lavender",
|
"accent": "lavender",
|
||||||
"addable": True,
|
"addable": True,
|
||||||
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
"constraints": {"minIncoming": 1, "minOutgoing": 0},
|
||||||
"fields": [
|
"fields": [
|
||||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Handoff"},
|
{"key": "name", "label": "节点名称", "type": "text", "default": "Handoff"},
|
||||||
{"key": "target", "label": "转交目标", "type": "text", "default": ""},
|
{"key": "target", "label": "转交目标", "type": "text", "default": ""},
|
||||||
@@ -132,13 +134,49 @@ def _edge_data_v3(edge: dict, source_type: str) -> dict:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_agent_data(data: dict[str, Any]) -> None:
|
||||||
|
"""Add v3 Agent defaults without changing existing node-level behavior."""
|
||||||
|
data.setdefault("contextPolicy", "inherit")
|
||||||
|
data.setdefault("entryMode", "wait_user")
|
||||||
|
data.setdefault("entrySpeech", "")
|
||||||
|
if "inheritGlobalConfig" not in data:
|
||||||
|
has_node_overrides = any(
|
||||||
|
(
|
||||||
|
data.get("llmResourceId"),
|
||||||
|
data.get("asrResourceId"),
|
||||||
|
data.get("ttsResourceId"),
|
||||||
|
data.get("knowledgeBaseId"),
|
||||||
|
data.get("toolIds"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
data["inheritGlobalConfig"] = not has_node_overrides
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") -> None:
|
||||||
|
settings.setdefault("globalPrompt", global_prompt)
|
||||||
|
settings.setdefault("defaultLlmResourceId", "")
|
||||||
|
settings.setdefault("defaultAsrResourceId", "")
|
||||||
|
settings.setdefault("defaultTtsResourceId", "")
|
||||||
|
settings.setdefault("toolIds", [])
|
||||||
|
settings.setdefault("knowledgeBaseId", "")
|
||||||
|
settings.setdefault("knowledgeMode", "automatic")
|
||||||
|
settings.setdefault("knowledgeTopN", 5)
|
||||||
|
settings.setdefault("knowledgeScoreThreshold", 0.0)
|
||||||
|
|
||||||
|
|
||||||
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""Return a deep-copied v3 graph; preserve v3 IDs and migrate v2 semantics."""
|
"""Return a deep-copied v3 graph; preserve v3 IDs and migrate v2 semantics."""
|
||||||
source = deepcopy(graph or {})
|
source = deepcopy(graph or {})
|
||||||
if str(source.get("specVersion") or "") == SPEC_VERSION:
|
if str(source.get("specVersion") or "") == SPEC_VERSION:
|
||||||
source.setdefault("settings", {})
|
settings = source.setdefault("settings", {})
|
||||||
|
_normalize_settings(settings)
|
||||||
source.setdefault("nodes", [])
|
source.setdefault("nodes", [])
|
||||||
source.setdefault("edges", [])
|
source.setdefault("edges", [])
|
||||||
|
for node in source["nodes"]:
|
||||||
|
if node.get("type") != "agent":
|
||||||
|
continue
|
||||||
|
data = node.setdefault("data", {})
|
||||||
|
_normalize_agent_data(data)
|
||||||
return source
|
return source
|
||||||
|
|
||||||
nodes = source.get("nodes") or []
|
nodes = source.get("nodes") or []
|
||||||
@@ -171,9 +209,7 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
data["message"] = data.pop("message", data.pop("prompt", ""))
|
data["message"] = data.pop("message", data.pop("prompt", ""))
|
||||||
data.setdefault("scope", "session")
|
data.setdefault("scope", "session")
|
||||||
elif new_type == "agent":
|
elif new_type == "agent":
|
||||||
data.setdefault("contextPolicy", "inherit")
|
_normalize_agent_data(data)
|
||||||
data.setdefault("toolIds", [])
|
|
||||||
data.setdefault("knowledgeMode", "disabled")
|
|
||||||
elif new_type == "start":
|
elif new_type == "start":
|
||||||
prompt = str(data.pop("prompt", "") or "").strip()
|
prompt = str(data.pop("prompt", "") or "").strip()
|
||||||
if prompt:
|
if prompt:
|
||||||
@@ -207,8 +243,9 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
"name": "迁移的开场 Agent",
|
"name": "迁移的开场 Agent",
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"contextPolicy": "inherit",
|
"contextPolicy": "inherit",
|
||||||
"toolIds": [],
|
"inheritGlobalConfig": True,
|
||||||
"knowledgeMode": "disabled",
|
"entryMode": "wait_user",
|
||||||
|
"entrySpeech": "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -228,9 +265,7 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
settings = deepcopy(source.get("settings") or {})
|
settings = deepcopy(source.get("settings") or {})
|
||||||
settings.setdefault("globalPrompt", global_prompt)
|
_normalize_settings(settings, global_prompt=global_prompt)
|
||||||
settings.setdefault("defaultAsrResourceId", "")
|
|
||||||
settings.setdefault("defaultTtsResourceId", "")
|
|
||||||
return {
|
return {
|
||||||
"specVersion": 3,
|
"specVersion": 3,
|
||||||
"settings": settings,
|
"settings": settings,
|
||||||
@@ -281,11 +316,18 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
|||||||
node_by_id[node_id] = node
|
node_by_id[node_id] = node
|
||||||
counts[node_type] += 1
|
counts[node_type] += 1
|
||||||
|
|
||||||
|
if node_type == "agent":
|
||||||
|
data = node.get("data") or {}
|
||||||
|
entry_mode = data.get("entryMode", "wait_user")
|
||||||
|
if entry_mode not in AGENT_ENTRY_MODES:
|
||||||
|
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}")
|
||||||
|
elif entry_mode == "fixed_speech" and not str(
|
||||||
|
data.get("entrySpeech") or ""
|
||||||
|
).strip():
|
||||||
|
errors.append(f"Agent 节点 {node_id} 的固定进入语不能为空")
|
||||||
|
|
||||||
if counts["start"] != 1:
|
if counts["start"] != 1:
|
||||||
errors.append("工作流必须有且仅有一个 Start 节点")
|
errors.append("工作流必须有且仅有一个 Start 节点")
|
||||||
if counts["end"] < 1:
|
|
||||||
errors.append("工作流至少需要一个 End 节点")
|
|
||||||
|
|
||||||
incoming: dict[str, int] = defaultdict(int)
|
incoming: dict[str, int] = defaultdict(int)
|
||||||
outgoing: dict[str, int] = defaultdict(int)
|
outgoing: dict[str, int] = defaultdict(int)
|
||||||
adj: dict[str, list[str]] = defaultdict(list)
|
adj: dict[str, list[str]] = defaultdict(list)
|
||||||
@@ -313,7 +355,8 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
|||||||
if mode == "llm" and not str(data.get("condition") or "").strip():
|
if mode == "llm" and not str(data.get("condition") or "").strip():
|
||||||
errors.append(f"LLM 判断边缺少自然语言条件:{edge_id}")
|
errors.append(f"LLM 判断边缺少自然语言条件:{edge_id}")
|
||||||
if mode == "expression":
|
if mode == "expression":
|
||||||
errors.extend(f"边 {edge_id}:{item}" for item in _validate_expression(data.get("expression")))
|
expression_errors = _validate_expression(data.get("expression"))
|
||||||
|
errors.extend(f"边 {edge_id}:{item}" for item in expression_errors)
|
||||||
try:
|
try:
|
||||||
priority = int(data.get("priority", 10))
|
priority = int(data.get("priority", 10))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -329,7 +372,9 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
|||||||
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)
|
||||||
if node_by_id[source_id].get("type") != "agent" and node_by_id[target_id].get("type") != "agent":
|
source_is_automatic = node_by_id[source_id].get("type") != "agent"
|
||||||
|
target_is_automatic = node_by_id[target_id].get("type") != "agent"
|
||||||
|
if source_is_automatic and target_is_automatic:
|
||||||
auto_adj[source_id].append(target_id)
|
auto_adj[source_id].append(target_id)
|
||||||
|
|
||||||
for node_id, node in node_by_id.items():
|
for node_id, node in node_by_id.items():
|
||||||
@@ -347,10 +392,18 @@ 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") in {"start", "action", "handoff"} and always_counts[node_id] != 1:
|
node_type = node.get("type")
|
||||||
errors.append(f"自动节点 {node_id} 必须有且仅有一条默认边")
|
if (
|
||||||
|
node_type in AUTOMATIC_NODE_TYPES
|
||||||
|
and outgoing[node_id] > 0
|
||||||
|
and always_counts[node_id] != 1
|
||||||
|
):
|
||||||
|
errors.append(f"自动节点 {node_id} 存在出边时必须有且仅有一条默认边")
|
||||||
|
|
||||||
start_id = next((nid for nid, n in node_by_id.items() if n.get("type") == "start"), None)
|
start_id = next(
|
||||||
|
(node_id for node_id, node in node_by_id.items() if node.get("type") == "start"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
if start_id:
|
if start_id:
|
||||||
reached = {start_id}
|
reached = {start_id}
|
||||||
queue = deque([start_id])
|
queue = deque([start_id])
|
||||||
@@ -380,7 +433,12 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
|||||||
visited.add(node_id)
|
visited.add(node_id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if any(visit(node_id) for node_id, node in node_by_id.items() if node.get("type") != "agent"):
|
automatic_node_ids = (
|
||||||
|
node_id
|
||||||
|
for node_id, node in node_by_id.items()
|
||||||
|
if node.get("type") != "agent"
|
||||||
|
)
|
||||||
|
if any(visit(node_id) for node_id in automatic_node_ids):
|
||||||
errors.append("Start/Action/Handoff/End 之间不能形成无等待循环")
|
errors.append("Start/Action/Handoff/End 之间不能形成无等待循环")
|
||||||
return list(dict.fromkeys(errors))
|
return list(dict.fromkeys(errors))
|
||||||
|
|
||||||
@@ -392,22 +450,35 @@ def graph_references(graph: dict[str, Any]) -> dict[str, set[str]]:
|
|||||||
resources = {
|
resources = {
|
||||||
str(value)
|
str(value)
|
||||||
for value in (
|
for value in (
|
||||||
|
settings.get("defaultLlmResourceId"),
|
||||||
settings.get("defaultAsrResourceId"),
|
settings.get("defaultAsrResourceId"),
|
||||||
settings.get("defaultTtsResourceId"),
|
settings.get("defaultTtsResourceId"),
|
||||||
)
|
)
|
||||||
if value
|
if value
|
||||||
}
|
}
|
||||||
tools: set[str] = set()
|
tools: set[str] = {str(tool_id) for tool_id in settings.get("toolIds") or []}
|
||||||
knowledge: set[str] = set()
|
knowledge: set[str] = (
|
||||||
|
{str(settings["knowledgeBaseId"])}
|
||||||
|
if settings.get("knowledgeBaseId")
|
||||||
|
else set()
|
||||||
|
)
|
||||||
for node in normalized.get("nodes") or []:
|
for node in normalized.get("nodes") or []:
|
||||||
data = node.get("data") or {}
|
data = node.get("data") or {}
|
||||||
for resource_id in (data.get("asrResourceId"), data.get("ttsResourceId")):
|
inherits_global = (
|
||||||
if resource_id:
|
node.get("type") == "agent" and data.get("inheritGlobalConfig", True)
|
||||||
resources.add(str(resource_id))
|
)
|
||||||
for tool_id in data.get("toolIds") or []:
|
if not inherits_global:
|
||||||
tools.add(str(tool_id))
|
for resource_id in (
|
||||||
|
data.get("llmResourceId"),
|
||||||
|
data.get("asrResourceId"),
|
||||||
|
data.get("ttsResourceId"),
|
||||||
|
):
|
||||||
|
if resource_id:
|
||||||
|
resources.add(str(resource_id))
|
||||||
|
for tool_id in data.get("toolIds") or []:
|
||||||
|
tools.add(str(tool_id))
|
||||||
|
if data.get("knowledgeBaseId"):
|
||||||
|
knowledge.add(str(data["knowledgeBaseId"]))
|
||||||
if data.get("toolId"):
|
if data.get("toolId"):
|
||||||
tools.add(str(data["toolId"]))
|
tools.add(str(data["toolId"]))
|
||||||
if data.get("knowledgeBaseId"):
|
|
||||||
knowledge.add(str(data["knowledgeBaseId"]))
|
|
||||||
return {"model_resources": resources, "tools": tools, "knowledge_bases": knowledge}
|
return {"model_resources": resources, "tools": tools, "knowledge_bases": knowledge}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from services.pipecat.call_lifecycle import (
|
|||||||
)
|
)
|
||||||
from services.pipecat.service_factory import (
|
from services.pipecat.service_factory import (
|
||||||
config_with_resource,
|
config_with_resource,
|
||||||
|
create_llm,
|
||||||
create_realtime_service,
|
create_realtime_service,
|
||||||
create_stt,
|
create_stt,
|
||||||
create_tts,
|
create_tts,
|
||||||
@@ -51,6 +52,7 @@ from pipecat.frames.frames import (
|
|||||||
UserImageRequestFrame,
|
UserImageRequestFrame,
|
||||||
)
|
)
|
||||||
from pipecat.pipeline.pipeline import Pipeline
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.llm_switcher import LLMSwitcher
|
||||||
from pipecat.pipeline.service_switcher import ServiceSwitcher
|
from pipecat.pipeline.service_switcher import ServiceSwitcher
|
||||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
@@ -487,6 +489,49 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
|
|||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
|
class UserTurnRoutingProcessor(FrameProcessor):
|
||||||
|
"""Give a brain first right of refusal before a new user turn reaches the LLM."""
|
||||||
|
|
||||||
|
def __init__(self, brain: Brain):
|
||||||
|
super().__init__()
|
||||||
|
self._brain = brain
|
||||||
|
self._last_user_message: dict | None = None
|
||||||
|
|
||||||
|
async def process_frame(self, frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if direction != FrameDirection.DOWNSTREAM or not isinstance(
|
||||||
|
frame, LLMContextFrame
|
||||||
|
):
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_message = next(
|
||||||
|
(
|
||||||
|
message
|
||||||
|
for message in reversed(frame.context.get_messages())
|
||||||
|
if message.get("role") == "user"
|
||||||
|
and isinstance(message.get("content"), str)
|
||||||
|
and str(message.get("content") or "").strip()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if user_message is None:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
return
|
||||||
|
|
||||||
|
if user_message is self._last_user_message:
|
||||||
|
# Programmatic LLMRunFrame after a node transition reuses the same
|
||||||
|
# user message. It is a response run, not another routing event.
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
return
|
||||||
|
self._last_user_message = user_message
|
||||||
|
|
||||||
|
content = str(user_message.get("content") or "").strip()
|
||||||
|
handled = await self._brain.on_user_turn_end(content)
|
||||||
|
if not handled:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
||||||
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
||||||
|
|
||||||
@@ -589,6 +634,29 @@ def _workflow_service_switcher(
|
|||||||
return ServiceSwitcher(services=services), services_by_id, primary
|
return ServiceSwitcher(services=services), services_by_id, primary
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_llm_switcher(cfg: AssistantConfig, base_service):
|
||||||
|
"""Build an LLM switcher for the global model and Agent overrides."""
|
||||||
|
settings = cfg.graph.get("settings") or {}
|
||||||
|
default_id = str(settings.get("defaultLlmResourceId") or "")
|
||||||
|
services_by_id = {}
|
||||||
|
for resource_id, resource in cfg.workflow_model_resources.items():
|
||||||
|
if resource.capability != "LLM":
|
||||||
|
continue
|
||||||
|
services_by_id[resource_id] = (
|
||||||
|
base_service
|
||||||
|
if resource_id == default_id
|
||||||
|
else create_llm(config_with_resource(cfg, resource))
|
||||||
|
)
|
||||||
|
primary = services_by_id.get(default_id, base_service)
|
||||||
|
services = [primary]
|
||||||
|
services.extend(
|
||||||
|
service for service in services_by_id.values() if service is not primary
|
||||||
|
)
|
||||||
|
if base_service is not primary:
|
||||||
|
services.append(base_service)
|
||||||
|
return LLMSwitcher(llms=services), services_by_id, primary
|
||||||
|
|
||||||
|
|
||||||
async def run_pipeline(
|
async def run_pipeline(
|
||||||
transport,
|
transport,
|
||||||
cfg: AssistantConfig,
|
cfg: AssistantConfig,
|
||||||
@@ -630,6 +698,9 @@ async def run_pipeline(
|
|||||||
return
|
return
|
||||||
|
|
||||||
graph_settings = cfg.graph.get("settings") or {}
|
graph_settings = cfg.graph.get("settings") or {}
|
||||||
|
default_llm_resource = cfg.workflow_model_resources.get(
|
||||||
|
str(graph_settings.get("defaultLlmResourceId") or "")
|
||||||
|
)
|
||||||
default_asr_resource = cfg.workflow_model_resources.get(
|
default_asr_resource = cfg.workflow_model_resources.get(
|
||||||
str(graph_settings.get("defaultAsrResourceId") or "")
|
str(graph_settings.get("defaultAsrResourceId") or "")
|
||||||
)
|
)
|
||||||
@@ -713,7 +784,16 @@ async def run_pipeline(
|
|||||||
)
|
)
|
||||||
input_state = {"enabled": True}
|
input_state = {"enabled": True}
|
||||||
# LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
|
# LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
|
||||||
llm = brain.build_llm(cfg, context)
|
llm = brain.build_llm(
|
||||||
|
config_with_resource(cfg, default_llm_resource)
|
||||||
|
if cfg.type == "workflow" and default_llm_resource
|
||||||
|
else cfg,
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
llm_services: dict[str, FrameProcessor] = {}
|
||||||
|
current_llm_service = llm
|
||||||
|
if cfg.type == "workflow":
|
||||||
|
llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
|
||||||
user_aggregator = LLMUserAggregator(
|
user_aggregator = LLMUserAggregator(
|
||||||
context,
|
context,
|
||||||
params=LLMUserAggregatorParams(
|
params=LLMUserAggregatorParams(
|
||||||
@@ -730,6 +810,7 @@ async def run_pipeline(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
user_turn_router = UserTurnRoutingProcessor(brain)
|
||||||
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
||||||
text_input = TextInputProcessor(
|
text_input = TextInputProcessor(
|
||||||
should_ignore_input=lambda: call_end.ending or not input_state["enabled"]
|
should_ignore_input=lambda: call_end.ending or not input_state["enabled"]
|
||||||
@@ -880,6 +961,7 @@ async def run_pipeline(
|
|||||||
properties=vision_schema.properties,
|
properties=vision_schema.properties,
|
||||||
required=vision_schema.required,
|
required=vision_schema.required,
|
||||||
handler=flow_fetch_user_image,
|
handler=flow_fetch_user_image,
|
||||||
|
cancel_on_interruption=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -913,6 +995,7 @@ async def run_pipeline(
|
|||||||
text_input,
|
text_input,
|
||||||
stt_processor,
|
stt_processor,
|
||||||
user_aggregator,
|
user_aggregator,
|
||||||
|
user_turn_router,
|
||||||
knowledge_retrieval,
|
knowledge_retrieval,
|
||||||
llm,
|
llm,
|
||||||
# Aggregate the streamed LLM text before TTS. On interruption,
|
# Aggregate the streamed LLM text before TTS. On interruption,
|
||||||
@@ -934,24 +1017,42 @@ async def run_pipeline(
|
|||||||
enable_rtvi=False,
|
enable_rtvi=False,
|
||||||
)
|
)
|
||||||
worker_holder["worker"] = worker
|
worker_holder["worker"] = worker
|
||||||
default_voice_services = dict(current_voice_services)
|
default_workflow_services = {
|
||||||
|
"llm": current_llm_service,
|
||||||
|
**current_voice_services,
|
||||||
|
}
|
||||||
|
|
||||||
async def switch_workflow_services(
|
async def switch_workflow_services(
|
||||||
|
llm_resource_id: str | None,
|
||||||
asr_resource_id: str | None,
|
asr_resource_id: str | None,
|
||||||
tts_resource_id: str | None,
|
tts_resource_id: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
nonlocal current_llm_service
|
||||||
requested = (
|
requested = (
|
||||||
|
("llm", llm_services, llm_resource_id),
|
||||||
("asr", stt_services, asr_resource_id),
|
("asr", stt_services, asr_resource_id),
|
||||||
("tts", tts_services, tts_resource_id),
|
("tts", tts_services, tts_resource_id),
|
||||||
)
|
)
|
||||||
for kind, services, resource_id in requested:
|
for kind, services, resource_id in requested:
|
||||||
target = services.get(resource_id) if resource_id else default_voice_services[kind]
|
target = (
|
||||||
|
services.get(resource_id)
|
||||||
|
if resource_id
|
||||||
|
else default_workflow_services[kind]
|
||||||
|
)
|
||||||
if target is None:
|
if target is None:
|
||||||
raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
|
raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
|
||||||
if current_voice_services[kind] is target:
|
current = (
|
||||||
|
current_llm_service
|
||||||
|
if kind == "llm"
|
||||||
|
else current_voice_services[kind]
|
||||||
|
)
|
||||||
|
if current is target:
|
||||||
continue
|
continue
|
||||||
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
|
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
|
||||||
current_voice_services[kind] = target
|
if kind == "llm":
|
||||||
|
current_llm_service = target
|
||||||
|
else:
|
||||||
|
current_voice_services[kind] = target
|
||||||
await worker.queue_frame(
|
await worker.queue_frame(
|
||||||
OutputTransportMessageUrgentFrame(
|
OutputTransportMessageUrgentFrame(
|
||||||
message={
|
message={
|
||||||
@@ -1020,8 +1121,6 @@ async def run_pipeline(
|
|||||||
|
|
||||||
@user_aggregator.event_handler("on_user_turn_stopped")
|
@user_aggregator.event_handler("on_user_turn_stopped")
|
||||||
async def on_user_turn_stopped(_aggregator, _strategy, message):
|
async def on_user_turn_stopped(_aggregator, _strategy, message):
|
||||||
if message.content:
|
|
||||||
brain.record_user_message(message.content)
|
|
||||||
await queue_transcript("user", message.content, message.timestamp)
|
await queue_transcript("user", message.content, message.timestamp)
|
||||||
|
|
||||||
@assistant_aggregator.event_handler("on_assistant_text_start")
|
@assistant_aggregator.event_handler("on_assistant_text_start")
|
||||||
@@ -1066,7 +1165,6 @@ async def run_pipeline(
|
|||||||
@text_input.event_handler("on_text_input")
|
@text_input.event_handler("on_text_input")
|
||||||
async def on_text_input(_processor, text):
|
async def on_text_input(_processor, text):
|
||||||
pending_text_inputs.append(text)
|
pending_text_inputs.append(text)
|
||||||
brain.record_user_message(text)
|
|
||||||
# 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
|
# 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
|
||||||
await queue_transcript("user", text, time_now_iso8601())
|
await queue_transcript("user", text, time_now_iso8601())
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,18 @@ TTS_STOP_FRAME_TIMEOUT_S = 1.0
|
|||||||
def config_with_resource(
|
def config_with_resource(
|
||||||
cfg: AssistantConfig, resource: RuntimeModelResource
|
cfg: AssistantConfig, resource: RuntimeModelResource
|
||||||
) -> AssistantConfig:
|
) -> AssistantConfig:
|
||||||
"""Return a call-local config view for one workflow ASR/TTS resource."""
|
"""Return a call-local config view for one workflow model resource."""
|
||||||
result = cfg.model_copy(deep=True)
|
result = cfg.model_copy(deep=True)
|
||||||
values = resource.values or {}
|
values = resource.values or {}
|
||||||
secrets = resource.secrets or {}
|
secrets = resource.secrets or {}
|
||||||
if resource.capability == "ASR":
|
if resource.capability == "LLM":
|
||||||
|
result.model = str(values.get("modelId") or "")
|
||||||
|
result.llm_interface_type = resource.interface_type
|
||||||
|
result.llm_values = values
|
||||||
|
result.llm_secrets = secrets
|
||||||
|
result.llm_api_key = str(secrets.get("apiKey") or "")
|
||||||
|
result.llm_base_url = str(values.get("apiUrl") or "")
|
||||||
|
elif resource.capability == "ASR":
|
||||||
result.asr = str(values.get("modelId") or "")
|
result.asr = str(values.get("modelId") or "")
|
||||||
result.stt_language = str(values.get("language") or "")
|
result.stt_language = str(values.get("language") or "")
|
||||||
result.stt_interface_type = resource.interface_type
|
result.stt_interface_type = resource.interface_type
|
||||||
@@ -51,7 +58,7 @@ def config_with_resource(
|
|||||||
result.tts_api_key = str(secrets.get("apiKey") or "")
|
result.tts_api_key = str(secrets.get("apiKey") or "")
|
||||||
result.tts_base_url = str(values.get("apiUrl") or "")
|
result.tts_base_url = str(values.get("apiUrl") or "")
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"工作流语音资源能力无效:{resource.capability}")
|
raise ValueError(f"工作流模型资源能力无效:{resource.capability}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from services.node_specs import normalize_graph
|
from services.node_specs import normalize_graph
|
||||||
from services.runtime_variables import DynamicVariableStore
|
from services.runtime_variables import DynamicVariableStore
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentStageConfig:
|
||||||
|
"""The complete assistant configuration active inside one Agent node."""
|
||||||
|
|
||||||
|
inherits_global: bool
|
||||||
|
llm_resource_id: str
|
||||||
|
asr_resource_id: str
|
||||||
|
tts_resource_id: str
|
||||||
|
tool_ids: tuple[str, ...]
|
||||||
|
knowledge_base_id: str | None
|
||||||
|
knowledge_mode: str
|
||||||
|
knowledge_top_n: int
|
||||||
|
knowledge_score_threshold: float
|
||||||
|
|
||||||
|
|
||||||
class WorkflowEngine:
|
class WorkflowEngine:
|
||||||
def __init__(self, graph: dict[str, Any]):
|
def __init__(self, graph: dict[str, Any]):
|
||||||
self.graph = normalize_graph(graph)
|
self.graph = normalize_graph(graph)
|
||||||
@@ -20,7 +36,11 @@ class WorkflowEngine:
|
|||||||
}
|
}
|
||||||
self.edges: list[dict] = list(self.graph.get("edges") or [])
|
self.edges: list[dict] = list(self.graph.get("edges") or [])
|
||||||
self.start_id = next(
|
self.start_id = next(
|
||||||
(node_id for node_id, node in self.nodes.items() if node.get("type") == "start"),
|
(
|
||||||
|
node_id
|
||||||
|
for node_id, node in self.nodes.items()
|
||||||
|
if node.get("type") == "start"
|
||||||
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +58,13 @@ class WorkflowEngine:
|
|||||||
|
|
||||||
def outgoing(self, node_id: str | None) -> list[dict]:
|
def outgoing(self, node_id: str | None) -> list[dict]:
|
||||||
result = [edge for edge in self.edges if edge.get("source") == node_id]
|
result = [edge for edge in self.edges if edge.get("source") == node_id]
|
||||||
return sorted(result, key=lambda edge: int((edge.get("data") or {}).get("priority", 10)))
|
return sorted(
|
||||||
|
result,
|
||||||
|
key=lambda edge: int((edge.get("data") or {}).get("priority", 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_outgoing(self, node_id: str | None) -> bool:
|
||||||
|
return any(edge.get("source") == node_id for edge in self.edges)
|
||||||
|
|
||||||
def edge_mode(self, edge: dict) -> str:
|
def edge_mode(self, edge: dict) -> str:
|
||||||
return str((edge.get("data") or {}).get("mode") or "always")
|
return str((edge.get("data") or {}).get("mode") or "always")
|
||||||
@@ -60,15 +86,49 @@ class WorkflowEngine:
|
|||||||
if not edge:
|
if not edge:
|
||||||
return ""
|
return ""
|
||||||
data = edge.get("data") or {}
|
data = edge.get("data") or {}
|
||||||
return str(data.get("transitionSpeech") or data.get("transition_speech") or "")
|
return str(
|
||||||
|
data.get("transitionSpeech") or data.get("transition_speech") or ""
|
||||||
|
)
|
||||||
|
|
||||||
def global_prompt(self) -> str:
|
def global_prompt(self) -> str:
|
||||||
return str(self.settings.get("globalPrompt") or "").strip()
|
return str(self.settings.get("globalPrompt") or "").strip()
|
||||||
|
|
||||||
|
def inherits_global_config(self, node_id: str) -> bool:
|
||||||
|
"""Return the Agent's explicit configuration scope, defaulting to global."""
|
||||||
|
return bool(self.data(node_id).get("inheritGlobalConfig", True))
|
||||||
|
|
||||||
|
def agent_stage_config(self, node_id: str) -> AgentStageConfig:
|
||||||
|
"""Resolve either Workflow defaults or one Agent's complete override."""
|
||||||
|
data = self.data(node_id)
|
||||||
|
inherits_global = self.inherits_global_config(node_id)
|
||||||
|
source = self.settings if inherits_global else data
|
||||||
|
llm_key = "defaultLlmResourceId" if inherits_global else "llmResourceId"
|
||||||
|
asr_key = "defaultAsrResourceId" if inherits_global else "asrResourceId"
|
||||||
|
tts_key = "defaultTtsResourceId" if inherits_global else "ttsResourceId"
|
||||||
|
knowledge_base_id = str(source.get("knowledgeBaseId") or "")
|
||||||
|
return AgentStageConfig(
|
||||||
|
inherits_global=inherits_global,
|
||||||
|
llm_resource_id=str(source.get(llm_key) or ""),
|
||||||
|
asr_resource_id=str(source.get(asr_key) or ""),
|
||||||
|
tts_resource_id=str(source.get(tts_key) or ""),
|
||||||
|
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []),
|
||||||
|
knowledge_base_id=knowledge_base_id or None,
|
||||||
|
knowledge_mode=(
|
||||||
|
str(source.get("knowledgeMode") or "automatic")
|
||||||
|
if knowledge_base_id
|
||||||
|
else "disabled"
|
||||||
|
),
|
||||||
|
knowledge_top_n=int(source.get("knowledgeTopN") or 5),
|
||||||
|
knowledge_score_threshold=float(
|
||||||
|
source.get("knowledgeScoreThreshold") or 0.0
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
||||||
|
"""Build the Agent system prompt according to its inheritance setting."""
|
||||||
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
|
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
|
||||||
sections = [f"[当前阶段:{self.name(node_id)}]"]
|
sections = [f"[当前阶段:{self.name(node_id)}]"]
|
||||||
if self.global_prompt():
|
if self.inherits_global_config(node_id) and self.global_prompt():
|
||||||
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
|
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
|
||||||
if prompt:
|
if prompt:
|
||||||
sections.append(f"[当前阶段任务]\n{prompt}")
|
sections.append(f"[当前阶段任务]\n{prompt}")
|
||||||
@@ -111,7 +171,11 @@ class WorkflowEngine:
|
|||||||
results.append(matched)
|
results.append(matched)
|
||||||
if not results:
|
if not results:
|
||||||
return False
|
return False
|
||||||
return all(results) if expression.get("combinator", "and") == "and" else any(results)
|
return (
|
||||||
|
all(results)
|
||||||
|
if expression.get("combinator", "and") == "and"
|
||||||
|
else any(results)
|
||||||
|
)
|
||||||
|
|
||||||
def deterministic_edge(
|
def deterministic_edge(
|
||||||
self,
|
self,
|
||||||
|
|||||||
129
backend/services/workflow_router.py
Normal file
129
backend/services/workflow_router.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Pre-response LLM routing for Workflow Agent edges.
|
||||||
|
|
||||||
|
The router deliberately uses a separate, short completion. Its only output is
|
||||||
|
a required function choice, so the current Agent cannot speak before the graph
|
||||||
|
has decided whether the user turn belongs to another node.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from models import AssistantConfig
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
|
||||||
|
STAY_ON_CURRENT_AGENT = "workflow_stay_on_current_agent"
|
||||||
|
MAX_ROUTING_HISTORY_ENTRIES = 20
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowLLMRouter:
|
||||||
|
"""Select one LLM edge before the conversational LLM is allowed to reply."""
|
||||||
|
|
||||||
|
def __init__(self, cfg: AssistantConfig):
|
||||||
|
self._cfg = cfg
|
||||||
|
|
||||||
|
async def select_edge(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
node_name: str,
|
||||||
|
node_prompt: str,
|
||||||
|
edges: list[dict[str, Any]],
|
||||||
|
history: list[dict[str, str]],
|
||||||
|
variables: dict[str, Any],
|
||||||
|
edge_name: Callable[[dict[str, Any]], str],
|
||||||
|
edge_description: Callable[[dict[str, Any]], str],
|
||||||
|
) -> str | None:
|
||||||
|
"""Return an edge function name, STAY, or None when routing failed."""
|
||||||
|
if not edges:
|
||||||
|
return STAY_ON_CURRENT_AGENT
|
||||||
|
|
||||||
|
names = {edge_name(edge) for edge in edges}
|
||||||
|
stay_name = STAY_ON_CURRENT_AGENT
|
||||||
|
while stay_name in names:
|
||||||
|
stay_name = f"_{stay_name}"
|
||||||
|
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": edge_name(edge),
|
||||||
|
"description": edge_description(edge),
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for edge in edges
|
||||||
|
]
|
||||||
|
tools.append(
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": stay_name,
|
||||||
|
"description": "所有转移条件都不满足,继续由当前 Agent 处理用户消息。",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ordered_conditions = "\n".join(
|
||||||
|
f"{index + 1}. {edge_description(edge)}"
|
||||||
|
for index, edge in enumerate(edges)
|
||||||
|
)
|
||||||
|
router_prompt = (
|
||||||
|
"你是工作流路由器,不是对话助手。收到一轮完整用户输入后,"
|
||||||
|
"必须且只能调用一个提供的函数,禁止输出任何口头回复。\n"
|
||||||
|
"按给出的顺序判断转移条件;选择第一个明确满足的转移函数。"
|
||||||
|
"如果没有条件满足,调用留在当前 Agent 的函数。\n\n"
|
||||||
|
f"当前节点:{node_name}\n"
|
||||||
|
f"当前节点任务:{node_prompt or '未配置'}\n"
|
||||||
|
f"转移条件:\n{ordered_conditions}"
|
||||||
|
)
|
||||||
|
recent_history = history[-MAX_ROUTING_HISTORY_ENTRIES:]
|
||||||
|
routing_input = json.dumps(
|
||||||
|
{
|
||||||
|
"conversation": recent_history,
|
||||||
|
"session_variables": variables,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
extra_body = self._cfg.llm_values.get("extraBody")
|
||||||
|
request_extra = (
|
||||||
|
{"extra_body": extra_body} if isinstance(extra_body, dict) else {}
|
||||||
|
)
|
||||||
|
client = AsyncOpenAI(
|
||||||
|
api_key=self._cfg.llm_api_key,
|
||||||
|
base_url=self._cfg.llm_base_url,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=self._cfg.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": router_prompt},
|
||||||
|
{"role": "user", "content": routing_input},
|
||||||
|
],
|
||||||
|
tools=tools,
|
||||||
|
tool_choice="required",
|
||||||
|
temperature=0,
|
||||||
|
**request_extra,
|
||||||
|
)
|
||||||
|
tool_calls = response.choices[0].message.tool_calls or []
|
||||||
|
if not tool_calls:
|
||||||
|
logger.warning("Workflow 路由 LLM 未返回函数调用,留在当前 Agent")
|
||||||
|
return STAY_ON_CURRENT_AGENT
|
||||||
|
selected = str(tool_calls[0].function.name or "")
|
||||||
|
if selected == stay_name:
|
||||||
|
return STAY_ON_CURRENT_AGENT
|
||||||
|
if selected not in names:
|
||||||
|
logger.warning(f"Workflow 路由 LLM 返回未知函数:{selected}")
|
||||||
|
return STAY_ON_CURRENT_AGENT
|
||||||
|
return selected
|
||||||
|
except Exception as exc: # noqa: BLE001 - routing failure must not end the call
|
||||||
|
logger.warning(f"Workflow LLM 边判断失败,留在当前 Agent:{exc}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
@@ -9,7 +9,10 @@ from pipecat.frames.frames import (
|
|||||||
LLMContextFrame,
|
LLMContextFrame,
|
||||||
LLMFullResponseEndFrame,
|
LLMFullResponseEndFrame,
|
||||||
LLMFullResponseStartFrame,
|
LLMFullResponseStartFrame,
|
||||||
|
LLMRunFrame,
|
||||||
LLMTextFrame,
|
LLMTextFrame,
|
||||||
|
OutputTransportMessageUrgentFrame,
|
||||||
|
TTSSpeakFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
@@ -388,10 +391,108 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_nodes_without_outgoing_edges_remain_active(self):
|
||||||
|
queued = []
|
||||||
|
|
||||||
|
async def queue_frame(frame):
|
||||||
|
queued.append(frame)
|
||||||
|
|
||||||
|
runtime = BrainRuntime(
|
||||||
|
context=LLMContext(messages=[]),
|
||||||
|
llm=FakeLLM(),
|
||||||
|
queue_frame=queue_frame,
|
||||||
|
set_system_prompt=lambda _prompt: None,
|
||||||
|
set_tools=lambda _tools: None,
|
||||||
|
call_end=FakeCallEnd(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
def __init__(self, current_node=None):
|
||||||
|
self.current_node = current_node
|
||||||
|
|
||||||
|
async def initialize(self, config):
|
||||||
|
self.current_node = config["name"]
|
||||||
|
|
||||||
|
start_brain = WorkflowBrain(
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [{"id": "start", "type": "start", "data": {}}],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
start_brain._runtime = runtime
|
||||||
|
start_brain._manager = FakeManager()
|
||||||
|
await start_brain.on_connected()
|
||||||
|
self.assertEqual(start_brain._manager.current_node, "start")
|
||||||
|
|
||||||
|
agent_brain = WorkflowBrain(
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {"globalPrompt": "全局规则"},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "agent",
|
||||||
|
"type": "agent",
|
||||||
|
"data": {"prompt": "持续回答"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "begin",
|
||||||
|
"source": "start",
|
||||||
|
"target": "agent",
|
||||||
|
"data": {"mode": "always", "priority": 0},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
agent_brain._runtime = runtime
|
||||||
|
agent_brain._manager = FakeManager("agent")
|
||||||
|
queued.clear()
|
||||||
|
handled = await agent_brain.on_user_turn_end("请继续回答")
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(agent_brain._manager.current_node, "agent")
|
||||||
|
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
||||||
|
|
||||||
|
handoff_brain = WorkflowBrain(
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "handoff",
|
||||||
|
"type": "handoff",
|
||||||
|
"data": {"targetType": "human"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
handoff_brain._runtime = runtime
|
||||||
|
handoff_config = await handoff_brain._resolve_path("handoff")
|
||||||
|
self.assertEqual(handoff_config["name"], "handoff")
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("type") == "handoff-requested"
|
||||||
|
for frame in queued
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
||||||
graph = {
|
graph = {
|
||||||
"specVersion": 3,
|
"specVersion": 3,
|
||||||
"settings": {"globalPrompt": "全局规则"},
|
"settings": {
|
||||||
|
"globalPrompt": "全局规则",
|
||||||
|
"defaultLlmResourceId": "llm_global",
|
||||||
|
"defaultAsrResourceId": "asr_global",
|
||||||
|
"defaultTtsResourceId": "tts_global",
|
||||||
|
"knowledgeBaseId": "kb_global",
|
||||||
|
"knowledgeMode": "automatic",
|
||||||
|
},
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "start",
|
"id": "start",
|
||||||
@@ -428,6 +529,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"mode": "llm",
|
"mode": "llm",
|
||||||
"priority": 10,
|
"priority": 10,
|
||||||
"condition": "需求已收集",
|
"condition": "需求已收集",
|
||||||
|
"transitionSpeech": "正在为你结束流程",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -447,6 +549,8 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
llm = FakeLLM()
|
llm = FakeLLM()
|
||||||
context = LLMContext(messages=[])
|
context = LLMContext(messages=[])
|
||||||
queued = []
|
queued = []
|
||||||
|
service_switches = []
|
||||||
|
knowledge_scopes = []
|
||||||
call_end = FakeCallEnd()
|
call_end = FakeCallEnd()
|
||||||
|
|
||||||
class FakeWorker:
|
class FakeWorker:
|
||||||
@@ -478,6 +582,9 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
async def queue_frame(frame):
|
async def queue_frame(frame):
|
||||||
queued.append(frame)
|
queued.append(frame)
|
||||||
|
|
||||||
|
async def switch_services(llm_id, asr_id, tts_id):
|
||||||
|
service_switches.append((llm_id, asr_id, tts_id))
|
||||||
|
|
||||||
runtime = BrainRuntime(
|
runtime = BrainRuntime(
|
||||||
context=context,
|
context=context,
|
||||||
llm=llm,
|
llm=llm,
|
||||||
@@ -487,29 +594,112 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
call_end=call_end,
|
call_end=call_end,
|
||||||
worker=worker,
|
worker=worker,
|
||||||
context_aggregator=pair,
|
context_aggregator=pair,
|
||||||
|
switch_services=switch_services,
|
||||||
|
set_knowledge_scope=knowledge_scopes.append,
|
||||||
)
|
)
|
||||||
await brain.setup(cfg, runtime)
|
await brain.setup(cfg, runtime)
|
||||||
await brain.on_connected()
|
await brain.on_connected()
|
||||||
self.assertEqual(brain._manager.current_node, "agent")
|
self.assertEqual(brain._manager.current_node, "agent")
|
||||||
|
self.assertEqual(
|
||||||
|
service_switches,
|
||||||
|
[("llm_global", "asr_global", "tts_global")],
|
||||||
|
)
|
||||||
|
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_global")
|
||||||
|
|
||||||
|
brain._engine.data("agent").update(
|
||||||
|
{
|
||||||
|
"inheritGlobalConfig": False,
|
||||||
|
"llmResourceId": "llm_agent",
|
||||||
|
"asrResourceId": "asr_agent",
|
||||||
|
"ttsResourceId": "tts_agent",
|
||||||
|
"knowledgeBaseId": "kb_agent",
|
||||||
|
"knowledgeMode": "on_demand",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await brain._apply_agent_stage("agent")
|
||||||
|
self.assertEqual(
|
||||||
|
service_switches[-1],
|
||||||
|
("llm_agent", "asr_agent", "tts_agent"),
|
||||||
|
)
|
||||||
|
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_agent")
|
||||||
agent_config = brain._agent_config("agent")
|
agent_config = brain._agent_config("agent")
|
||||||
self.assertIn("王先生", agent_config["role_message"])
|
self.assertIn("王先生", agent_config["role_message"])
|
||||||
self.assertIn("完成当前阶段任务", agent_config["role_message"])
|
self.assertIn("工作流路由已在用户一轮输入结束时完成", agent_config["role_message"])
|
||||||
self.assertEqual(agent_config["task_messages"], [])
|
self.assertEqual(agent_config["task_messages"], [])
|
||||||
|
self.assertFalse(agent_config["respond_immediately"])
|
||||||
|
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
agent_config["context_strategy"].strategy.value,
|
agent_config["context_strategy"].strategy.value,
|
||||||
"reset",
|
"reset",
|
||||||
)
|
)
|
||||||
|
|
||||||
edge_function = next(
|
brain._engine.data("agent")["entryMode"] = "generate"
|
||||||
function
|
generate_config = brain._agent_config("agent")
|
||||||
for function in brain._agent_config("agent")["functions"]
|
self.assertTrue(generate_config["respond_immediately"])
|
||||||
if function.name == "goto_finish"
|
worker.frames.clear()
|
||||||
|
await brain._manager.set_node_from_config(generate_config)
|
||||||
|
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||||
|
|
||||||
|
brain._engine.data("agent").update(
|
||||||
|
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
|
||||||
)
|
)
|
||||||
_, terminal = await edge_function.handler({}, brain._manager)
|
fixed_config = brain._agent_config("agent")
|
||||||
self.assertEqual(terminal["name"], "end")
|
self.assertFalse(fixed_config["respond_immediately"])
|
||||||
|
self.assertEqual(
|
||||||
|
fixed_config["pre_actions"][0]["type"],
|
||||||
|
"workflow_fixed_speech",
|
||||||
|
)
|
||||||
|
self.assertEqual(fixed_config["pre_actions"][0]["text"], "您好,王先生")
|
||||||
|
self.assertEqual(
|
||||||
|
fixed_config["task_messages"],
|
||||||
|
[{"role": "assistant", "content": "您好,王先生"}],
|
||||||
|
)
|
||||||
|
worker.frames.clear()
|
||||||
|
queued.clear()
|
||||||
|
await brain._manager.set_node_from_config(fixed_config)
|
||||||
|
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
||||||
|
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
function.name == "goto_finish"
|
||||||
|
for function in brain._agent_config("agent")["functions"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await brain.on_assistant_text_end("old-turn", "需求已收集", False)
|
||||||
|
self.assertEqual(brain._manager.current_node, "agent")
|
||||||
|
|
||||||
|
class FakeRouter:
|
||||||
|
async def select_edge(self, **_kwargs):
|
||||||
|
return "goto_finish"
|
||||||
|
|
||||||
|
brain._router = FakeRouter()
|
||||||
|
handled = await brain.on_user_turn_end("我的需求已经说完了")
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(brain._manager.current_node, "end")
|
||||||
|
self.assertIn("我的需求已经说完了", brain._store.values["system__conversation_history"])
|
||||||
self.assertTrue(call_end.ending)
|
self.assertTrue(call_end.ending)
|
||||||
self.assertTrue(call_end.armed)
|
self.assertTrue(call_end.armed)
|
||||||
self.assertTrue(any(getattr(frame, "text", "") == "感谢来电" for frame in queued))
|
self.assertTrue(any(getattr(frame, "text", "") == "感谢来电" for frame in queued))
|
||||||
|
assistant_transcripts = [
|
||||||
|
frame.message.get("content")
|
||||||
|
for frame in queued
|
||||||
|
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("type") == "transcript"
|
||||||
|
and frame.message.get("role") == "assistant"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
assistant_transcripts,
|
||||||
|
["您好,王先生", "正在为你结束流程", "感谢来电"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"正在为你结束流程",
|
||||||
|
brain._store.values["system__conversation_history"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"感谢来电",
|
||||||
|
brain._store.values["system__conversation_history"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from models import AssistantConfig
|
from models import AssistantConfig
|
||||||
|
from pipecat.frames.frames import LLMContextFrame
|
||||||
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
from services.pipecat.pipeline import (
|
from services.pipecat.pipeline import (
|
||||||
KNOWLEDGE_CONTEXT_MARKER,
|
KNOWLEDGE_CONTEXT_MARKER,
|
||||||
KnowledgeRetrievalProcessor,
|
KnowledgeRetrievalProcessor,
|
||||||
|
UserTurnRoutingProcessor,
|
||||||
_knowledge_tool_description,
|
_knowledge_tool_description,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,5 +61,37 @@ class KnowledgeToolDescriptionTest(unittest.TestCase):
|
|||||||
self.assertFalse(any(message["role"] == "developer" for message in messages))
|
self.assertFalse(any(message["role"] == "developer" for message in messages))
|
||||||
|
|
||||||
|
|
||||||
|
class UserTurnRoutingProcessorTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_routes_each_user_message_once_before_response_run(self):
|
||||||
|
class FakeBrain:
|
||||||
|
def __init__(self):
|
||||||
|
self.turns = []
|
||||||
|
|
||||||
|
async def on_user_turn_end(self, content):
|
||||||
|
self.turns.append(content)
|
||||||
|
return True
|
||||||
|
|
||||||
|
brain = FakeBrain()
|
||||||
|
processor = UserTurnRoutingProcessor(brain)
|
||||||
|
forwarded = []
|
||||||
|
|
||||||
|
async def push_frame(frame, direction):
|
||||||
|
forwarded.append((frame, direction))
|
||||||
|
|
||||||
|
processor.push_frame = push_frame
|
||||||
|
context = LLMContext(messages=[{"role": "user", "content": "我叫李白"}])
|
||||||
|
frame = LLMContextFrame(context)
|
||||||
|
|
||||||
|
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
|
||||||
|
self.assertEqual(brain.turns, ["我叫李白"])
|
||||||
|
self.assertEqual(forwarded, [])
|
||||||
|
|
||||||
|
# A queued LLMRunFrame after the transition uses the same context. It
|
||||||
|
# must reach the target Agent without invoking routing a second time.
|
||||||
|
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
|
||||||
|
self.assertEqual(brain.turns, ["我叫李白"])
|
||||||
|
self.assertEqual(forwarded, [(frame, FrameDirection.DOWNSTREAM)])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
75
backend/tests/test_workflow_router.py
Normal file
75
backend/tests/test_workflow_router.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from models import AssistantConfig
|
||||||
|
from services.workflow_router import WorkflowLLMRouter
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowLLMRouterTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_uses_required_tool_choice_without_developer_messages(self):
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
class FakeCompletions:
|
||||||
|
async def create(self, **kwargs):
|
||||||
|
requests.append(kwargs)
|
||||||
|
return SimpleNamespace(
|
||||||
|
choices=[
|
||||||
|
SimpleNamespace(
|
||||||
|
message=SimpleNamespace(
|
||||||
|
tool_calls=[
|
||||||
|
SimpleNamespace(
|
||||||
|
function=SimpleNamespace(name="goto_age", arguments="{}")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, **_kwargs):
|
||||||
|
self.chat = SimpleNamespace(completions=FakeCompletions())
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
cfg = AssistantConfig(
|
||||||
|
type="workflow",
|
||||||
|
model="deepseek-chat",
|
||||||
|
llm_api_key="secret",
|
||||||
|
llm_base_url="https://llm.test/v1",
|
||||||
|
)
|
||||||
|
router = WorkflowLLMRouter(cfg)
|
||||||
|
edges = [
|
||||||
|
{
|
||||||
|
"id": "age",
|
||||||
|
"data": {"condition": "用户已经回答姓名", "priority": 10},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("services.workflow_router.AsyncOpenAI", FakeClient):
|
||||||
|
selected = await router.select_edge(
|
||||||
|
node_name="询问姓名",
|
||||||
|
node_prompt="询问用户姓名",
|
||||||
|
edges=edges,
|
||||||
|
history=[{"role": "user", "message": "我叫李白"}],
|
||||||
|
variables={"customer_type": "new"},
|
||||||
|
edge_name=lambda _edge: "goto_age",
|
||||||
|
edge_description=lambda _edge: "用户已经回答姓名",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(selected, "goto_age")
|
||||||
|
self.assertEqual(requests[0]["tool_choice"], "required")
|
||||||
|
self.assertEqual(
|
||||||
|
[message["role"] for message in requests[0]["messages"]],
|
||||||
|
["system", "user"],
|
||||||
|
)
|
||||||
|
self.assertNotIn("developer", str(requests[0]["messages"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -4,7 +4,7 @@ import unittest
|
|||||||
|
|
||||||
from models import AssistantConfig, RuntimeModelResource
|
from models import AssistantConfig, RuntimeModelResource
|
||||||
from services.pipecat.service_factory import config_with_resource
|
from services.pipecat.service_factory import config_with_resource
|
||||||
from services.node_specs import normalize_graph, validate_graph
|
from services.node_specs import graph_references, normalize_graph, validate_graph
|
||||||
from services.runtime_variables import DynamicVariableStore, prepare_dynamic_config
|
from services.runtime_variables import DynamicVariableStore, prepare_dynamic_config
|
||||||
from services.workflow_engine import WorkflowEngine
|
from services.workflow_engine import WorkflowEngine
|
||||||
|
|
||||||
@@ -49,6 +49,22 @@ def valid_graph():
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowGraphTests(unittest.TestCase):
|
class WorkflowGraphTests(unittest.TestCase):
|
||||||
|
def test_agent_entry_mode_defaults_and_validation(self):
|
||||||
|
graph = valid_graph()
|
||||||
|
normalized = normalize_graph(graph)
|
||||||
|
agent = next(node for node in normalized["nodes"] if node["type"] == "agent")
|
||||||
|
self.assertEqual(agent["data"]["entryMode"], "wait_user")
|
||||||
|
self.assertEqual(agent["data"]["entrySpeech"], "")
|
||||||
|
self.assertTrue(agent["data"]["inheritGlobalConfig"])
|
||||||
|
self.assertEqual(agent["data"]["contextPolicy"], "fresh")
|
||||||
|
|
||||||
|
agent["data"]["entryMode"] = "fixed_speech"
|
||||||
|
self.assertTrue(
|
||||||
|
any("固定进入语不能为空" in error for error in validate_graph(normalized))
|
||||||
|
)
|
||||||
|
agent["data"]["entrySpeech"] = "您好,{{customer}}"
|
||||||
|
self.assertEqual(validate_graph(normalized), [])
|
||||||
|
|
||||||
def test_voice_resource_creates_isolated_runtime_config(self):
|
def test_voice_resource_creates_isolated_runtime_config(self):
|
||||||
base = AssistantConfig(type="workflow", asr="default", voice="default")
|
base = AssistantConfig(type="workflow", asr="default", voice="default")
|
||||||
asr = RuntimeModelResource(
|
asr = RuntimeModelResource(
|
||||||
@@ -63,6 +79,191 @@ class WorkflowGraphTests(unittest.TestCase):
|
|||||||
self.assertEqual(resolved.stt_api_key, "secret")
|
self.assertEqual(resolved.stt_api_key, "secret")
|
||||||
self.assertEqual(base.asr, "default")
|
self.assertEqual(base.asr, "default")
|
||||||
|
|
||||||
|
llm = RuntimeModelResource(
|
||||||
|
id="llm_1",
|
||||||
|
capability="LLM",
|
||||||
|
interface_type="openai-llm",
|
||||||
|
values={"modelId": "deepseek-chat", "apiUrl": "https://llm.test/v1"},
|
||||||
|
secrets={"apiKey": "llm-secret"},
|
||||||
|
)
|
||||||
|
llm_resolved = config_with_resource(base, llm)
|
||||||
|
self.assertEqual(llm_resolved.model, "deepseek-chat")
|
||||||
|
self.assertEqual(llm_resolved.llm_api_key, "llm-secret")
|
||||||
|
|
||||||
|
def test_global_and_custom_agent_references_are_preserved(self):
|
||||||
|
graph = valid_graph()
|
||||||
|
graph["settings"].update(
|
||||||
|
{
|
||||||
|
"defaultLlmResourceId": "llm_global",
|
||||||
|
"defaultAsrResourceId": "asr_global",
|
||||||
|
"defaultTtsResourceId": "tts_global",
|
||||||
|
"toolIds": ["tool_global"],
|
||||||
|
"knowledgeBaseId": "kb_global",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
|
||||||
|
agent["data"].update(
|
||||||
|
{
|
||||||
|
"inheritGlobalConfig": False,
|
||||||
|
"llmResourceId": "llm_agent",
|
||||||
|
"asrResourceId": "asr_agent",
|
||||||
|
"ttsResourceId": "tts_agent",
|
||||||
|
"toolIds": ["tool_agent"],
|
||||||
|
"knowledgeBaseId": "kb_agent",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
refs = graph_references(graph)
|
||||||
|
self.assertEqual(
|
||||||
|
refs["model_resources"],
|
||||||
|
{
|
||||||
|
"llm_global",
|
||||||
|
"asr_global",
|
||||||
|
"tts_global",
|
||||||
|
"llm_agent",
|
||||||
|
"asr_agent",
|
||||||
|
"tts_agent",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(refs["tools"], {"tool_global", "tool_agent"})
|
||||||
|
self.assertEqual(refs["knowledge_bases"], {"kb_global", "kb_agent"})
|
||||||
|
|
||||||
|
def test_existing_agent_override_disables_implicit_inheritance(self):
|
||||||
|
graph = valid_graph()
|
||||||
|
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
|
||||||
|
agent["data"]["toolIds"] = ["legacy_tool"]
|
||||||
|
normalized = normalize_graph(graph)
|
||||||
|
normalized_agent = next(
|
||||||
|
node for node in normalized["nodes"] if node["type"] == "agent"
|
||||||
|
)
|
||||||
|
self.assertFalse(normalized_agent["data"]["inheritGlobalConfig"])
|
||||||
|
|
||||||
|
def test_inherited_agent_ignores_stale_custom_references(self):
|
||||||
|
graph = valid_graph()
|
||||||
|
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
|
||||||
|
agent["data"].update(
|
||||||
|
{
|
||||||
|
"inheritGlobalConfig": True,
|
||||||
|
"llmResourceId": "stale_llm",
|
||||||
|
"asrResourceId": "stale_asr",
|
||||||
|
"ttsResourceId": "stale_tts",
|
||||||
|
"toolIds": ["stale_tool"],
|
||||||
|
"knowledgeBaseId": "stale_kb",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
refs = graph_references(graph)
|
||||||
|
|
||||||
|
self.assertNotIn("stale_llm", refs["model_resources"])
|
||||||
|
self.assertNotIn("stale_tool", refs["tools"])
|
||||||
|
self.assertNotIn("stale_kb", refs["knowledge_bases"])
|
||||||
|
|
||||||
|
def test_agent_effective_config_inherits_then_switches_to_override(self):
|
||||||
|
graph = valid_graph()
|
||||||
|
graph["settings"].update(
|
||||||
|
{
|
||||||
|
"defaultLlmResourceId": "llm_global",
|
||||||
|
"defaultAsrResourceId": "asr_global",
|
||||||
|
"defaultTtsResourceId": "tts_global",
|
||||||
|
"toolIds": ["tool_global"],
|
||||||
|
"knowledgeBaseId": "kb_global",
|
||||||
|
"knowledgeMode": "on_demand",
|
||||||
|
"knowledgeTopN": 8,
|
||||||
|
"knowledgeScoreThreshold": 0.4,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
engine = WorkflowEngine(graph)
|
||||||
|
inherited = engine.agent_stage_config("agent")
|
||||||
|
self.assertEqual(inherited.llm_resource_id, "llm_global")
|
||||||
|
self.assertEqual(inherited.tool_ids, ("tool_global",))
|
||||||
|
self.assertEqual(inherited.knowledge_mode, "on_demand")
|
||||||
|
|
||||||
|
engine.data("agent").update(
|
||||||
|
{
|
||||||
|
"inheritGlobalConfig": False,
|
||||||
|
"llmResourceId": "llm_agent",
|
||||||
|
"toolIds": ["tool_agent"],
|
||||||
|
"knowledgeBaseId": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
custom = engine.agent_stage_config("agent")
|
||||||
|
self.assertEqual(custom.llm_resource_id, "llm_agent")
|
||||||
|
self.assertEqual(custom.tool_ids, ("tool_agent",))
|
||||||
|
self.assertEqual(custom.knowledge_mode, "disabled")
|
||||||
|
|
||||||
|
def test_start_agent_and_handoff_may_have_no_outgoing_edge(self):
|
||||||
|
terminal_graphs = [
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [{"id": "start", "type": "start", "data": {}}],
|
||||||
|
"edges": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "agent",
|
||||||
|
"type": "agent",
|
||||||
|
"data": {"prompt": "持续处理用户问题"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "begin",
|
||||||
|
"source": "start",
|
||||||
|
"target": "agent",
|
||||||
|
"data": {"mode": "always", "priority": 0},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{"id": "handoff", "type": "handoff", "data": {}},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "begin",
|
||||||
|
"source": "start",
|
||||||
|
"target": "handoff",
|
||||||
|
"data": {"mode": "always", "priority": 0},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for graph in terminal_graphs:
|
||||||
|
with self.subTest(node=graph["nodes"][-1]["type"]):
|
||||||
|
self.assertEqual(validate_graph(graph), [])
|
||||||
|
|
||||||
|
action_without_exit = {
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{"id": "action", "type": "action", "data": {}},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "begin",
|
||||||
|
"source": "start",
|
||||||
|
"target": "action",
|
||||||
|
"data": {"mode": "always", "priority": 0},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"action 的出边不能少于 1" in error
|
||||||
|
for error in validate_graph(action_without_exit)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def test_v2_start_prompt_is_preserved_in_synthetic_agent(self):
|
def test_v2_start_prompt_is_preserved_in_synthetic_agent(self):
|
||||||
graph = normalize_graph(
|
graph = normalize_graph(
|
||||||
{
|
{
|
||||||
@@ -113,6 +314,15 @@ class WorkflowGraphTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("王先生", engine.prompt_for("agent", store))
|
self.assertIn("王先生", engine.prompt_for("agent", store))
|
||||||
|
|
||||||
|
inherited_prompt = engine.prompt_for("agent", store)
|
||||||
|
self.assertIn("服务 王先生", inherited_prompt)
|
||||||
|
self.assertIn("处理订单", inherited_prompt)
|
||||||
|
|
||||||
|
engine.data("agent")["inheritGlobalConfig"] = False
|
||||||
|
custom_prompt = engine.prompt_for("agent", store)
|
||||||
|
self.assertNotIn("服务 王先生", custom_prompt)
|
||||||
|
self.assertIn("处理订单", custom_prompt)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Settings2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type { KnowledgeRetrievalConfig } from "@/lib/api";
|
||||||
|
|
||||||
|
export const DEFAULT_KNOWLEDGE_RETRIEVAL_CONFIG: KnowledgeRetrievalConfig = {
|
||||||
|
mode: "automatic",
|
||||||
|
topN: 5,
|
||||||
|
scoreThreshold: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KnowledgeRetrievalConfigDialog({
|
||||||
|
disabled,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
disabled: boolean;
|
||||||
|
value: KnowledgeRetrievalConfig;
|
||||||
|
onChange: (config: KnowledgeRetrievalConfig) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
setDraft(value);
|
||||||
|
setError(null);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
if (draft.topN === 0 || draft.topN < -1 || !Number.isInteger(draft.topN)) {
|
||||||
|
setError("Top N 必须为 -1 或大于 0 的整数");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.scoreThreshold < 0 || draft.scoreThreshold > 1) {
|
||||||
|
setError("最低相关度必须在 0 到 1 之间");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(draft);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={openDialog}
|
||||||
|
aria-label="打开知识库高级配置"
|
||||||
|
title={
|
||||||
|
disabled
|
||||||
|
? "请先选择知识库"
|
||||||
|
: `${value.mode === "automatic" ? "自动检索" : "模型主动检索"} · Top N ${value.topN === -1 ? "不限" : value.topN} · 最低相关度 ${value.scoreThreshold}`
|
||||||
|
}
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>知识库高级配置</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
设置检索触发方式、返回数量和相关度过滤条件。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium text-foreground">检索方式</div>
|
||||||
|
<Select
|
||||||
|
value={draft.mode}
|
||||||
|
onValueChange={(mode: "automatic" | "on_demand") =>
|
||||||
|
setDraft({ ...draft, mode })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="automatic">自动检索</SelectItem>
|
||||||
|
<SelectItem value="on_demand">模型主动检索</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{draft.mode === "automatic"
|
||||||
|
? "每轮用户提问后自动检索,响应行为更稳定。"
|
||||||
|
: "由大模型判断是否调用知识库,依赖模型的工具调用能力。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
最多返回片段数
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="-1"
|
||||||
|
value={draft.topN}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft({ ...draft, topN: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||||
|
填写 -1 时保留所有达到阈值的结果。
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
最低相关度
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
value={draft.scoreThreshold}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
scoreThreshold: Number(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||||
|
仅保留相关度达到该值的片段,范围 0–1。
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={saveDraft}>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/components/editor/section-card.tsx
Normal file
92
frontend/src/components/editor/section-card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact section chrome shared by assistant editors and workflow node panels.
|
||||||
|
* Density matches the debug preview drawer (text-sm titles, tight padding).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function SectionCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon?: ReactNode;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const hasHeader = Boolean(title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"gap-3 rounded-2xl border border-hairline bg-card py-3.5 text-card-foreground shadow-sm ring-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasHeader && (
|
||||||
|
<CardHeader className="gap-0 px-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{icon && (
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<CardTitle className="text-sm font-medium leading-none">
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
{description && <HelpHint text={description} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={cn("px-4", hasHeader && "space-y-3")}>
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpHint({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="查看说明"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||||
|
>
|
||||||
|
<HelpCircle size={13} />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-72 text-sm leading-6 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Mic,
|
Mic,
|
||||||
Send,
|
Send,
|
||||||
HelpCircle,
|
|
||||||
Waypoints,
|
Waypoints,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -87,12 +86,6 @@ import { PageHeader } from "@/components/ui/page-header";
|
|||||||
import { FilterPills } from "@/components/ui/filter-pills";
|
import { FilterPills } from "@/components/ui/filter-pills";
|
||||||
import { SearchInput } from "@/components/ui/search-input";
|
import { SearchInput } from "@/components/ui/search-input";
|
||||||
import { ListToolbar } from "@/components/ui/list-toolbar";
|
import { ListToolbar } from "@/components/ui/list-toolbar";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -124,6 +117,7 @@ import {
|
|||||||
WorkflowEditor,
|
WorkflowEditor,
|
||||||
type WorkflowSettings,
|
type WorkflowSettings,
|
||||||
} from "@/components/workflow/WorkflowEditor";
|
} from "@/components/workflow/WorkflowEditor";
|
||||||
|
import { HelpHint, SectionCard } from "@/components/editor/section-card";
|
||||||
import {
|
import {
|
||||||
defaultGraph,
|
defaultGraph,
|
||||||
type WorkflowGraph,
|
type WorkflowGraph,
|
||||||
@@ -362,7 +356,7 @@ type AssistantTypeOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
/** 提示词、Dify、FastGPT 类型已落地,工作流暂时显示占位页 */
|
/** 提示词、工作流、Dify、FastGPT 已落地;OpenCode 暂时显示即将上线 */
|
||||||
available: boolean;
|
available: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -379,7 +373,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
|
|||||||
label: "使用工作流构建",
|
label: "使用工作流构建",
|
||||||
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
|
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
|
||||||
icon: <Workflow size={20} />,
|
icon: <Workflow size={20} />,
|
||||||
available: false,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "Dify",
|
type: "Dify",
|
||||||
@@ -400,7 +394,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
|
|||||||
label: "使用 OpenCode 构建",
|
label: "使用 OpenCode 构建",
|
||||||
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
|
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
|
||||||
icon: <Terminal size={20} />,
|
icon: <Terminal size={20} />,
|
||||||
available: true,
|
available: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -472,12 +466,23 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
);
|
);
|
||||||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
||||||
globalPrompt: defaultGraph().settings.globalPrompt,
|
globalPrompt: defaultGraph().settings.globalPrompt,
|
||||||
|
llm: defaultGraph().settings.defaultLlmResourceId,
|
||||||
|
asr: defaultGraph().settings.defaultAsrResourceId,
|
||||||
|
tts: defaultGraph().settings.defaultTtsResourceId,
|
||||||
|
toolIds: defaultGraph().settings.toolIds,
|
||||||
|
knowledgeBaseId: defaultGraph().settings.knowledgeBaseId,
|
||||||
|
knowledgeRetrievalConfig: {
|
||||||
|
mode: defaultGraph().settings.knowledgeMode,
|
||||||
|
topN: defaultGraph().settings.knowledgeTopN,
|
||||||
|
scoreThreshold: defaultGraph().settings.knowledgeScoreThreshold,
|
||||||
|
},
|
||||||
allowInterrupt: true,
|
allowInterrupt: true,
|
||||||
turnConfig: defaultTurnConfig(),
|
turnConfig: defaultTurnConfig(),
|
||||||
});
|
});
|
||||||
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
|
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
|
||||||
useState<Record<string, DynamicVariableDefinition>>({});
|
useState<Record<string, DynamicVariableDefinition>>({});
|
||||||
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
|
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
|
||||||
|
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
||||||
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(null);
|
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(null);
|
||||||
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(null);
|
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(null);
|
||||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||||
@@ -847,9 +852,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
? (assistant.graph as WorkflowGraph)
|
? (assistant.graph as WorkflowGraph)
|
||||||
: defaultGraph();
|
: defaultGraph();
|
||||||
const wfSettings: WorkflowSettings = {
|
const wfSettings: WorkflowSettings = {
|
||||||
llm: assistant.modelResourceIds.LLM,
|
llm:
|
||||||
|
graph.settings?.defaultLlmResourceId ||
|
||||||
|
assistant.modelResourceIds.LLM,
|
||||||
asr: graph.settings?.defaultAsrResourceId || assistant.modelResourceIds.ASR,
|
asr: graph.settings?.defaultAsrResourceId || assistant.modelResourceIds.ASR,
|
||||||
tts: graph.settings?.defaultTtsResourceId || assistant.modelResourceIds.TTS,
|
tts: graph.settings?.defaultTtsResourceId || assistant.modelResourceIds.TTS,
|
||||||
|
toolIds: graph.settings?.toolIds ?? [],
|
||||||
|
knowledgeBaseId:
|
||||||
|
graph.settings?.knowledgeBaseId || assistant.knowledgeBaseId || "",
|
||||||
|
knowledgeRetrievalConfig: {
|
||||||
|
mode:
|
||||||
|
graph.settings?.knowledgeMode ||
|
||||||
|
assistant.knowledgeRetrievalConfig.mode,
|
||||||
|
topN:
|
||||||
|
graph.settings?.knowledgeTopN ??
|
||||||
|
assistant.knowledgeRetrievalConfig.topN,
|
||||||
|
scoreThreshold:
|
||||||
|
graph.settings?.knowledgeScoreThreshold ??
|
||||||
|
assistant.knowledgeRetrievalConfig.scoreThreshold,
|
||||||
|
},
|
||||||
globalPrompt: graph.settings?.globalPrompt ?? "",
|
globalPrompt: graph.settings?.globalPrompt ?? "",
|
||||||
allowInterrupt: assistant.enableInterrupt,
|
allowInterrupt: assistant.enableInterrupt,
|
||||||
turnConfig: assistant.turnConfig,
|
turnConfig: assistant.turnConfig,
|
||||||
@@ -916,6 +937,9 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
|
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
|
||||||
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
|
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
|
||||||
},
|
},
|
||||||
|
knowledgeBaseId: workflowSettings.knowledgeBaseId || null,
|
||||||
|
knowledgeRetrievalConfig: workflowSettings.knowledgeRetrievalConfig,
|
||||||
|
toolIds: workflowSettings.toolIds,
|
||||||
graph: workflowGraph as unknown as Record<string, unknown>,
|
graph: workflowGraph as unknown as Record<string, unknown>,
|
||||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||||
}),
|
}),
|
||||||
@@ -1386,7 +1410,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
<span
|
<span
|
||||||
role="alert"
|
role="alert"
|
||||||
title={saveError}
|
title={saveError}
|
||||||
className="line-clamp-2 max-w-[min(42vw,560px)] self-center text-right text-sm leading-5 text-destructive"
|
className="line-clamp-1 max-w-[min(42vw,560px)] self-center text-right text-sm leading-5 text-destructive"
|
||||||
>
|
>
|
||||||
{saveError}
|
{saveError}
|
||||||
</span>
|
</span>
|
||||||
@@ -1396,6 +1420,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
||||||
disabled={!editingId}
|
disabled={!editingId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setWorkflowSettingsOpen(false);
|
||||||
setWorkflowEditingNodeId(null);
|
setWorkflowEditingNodeId(null);
|
||||||
setWorkflowEditingEdgeId(null);
|
setWorkflowEditingEdgeId(null);
|
||||||
setWorkflowDebugOpen(true);
|
setWorkflowDebugOpen(true);
|
||||||
@@ -1430,6 +1455,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
onEditingNodeIdChange={setWorkflowEditingNodeId}
|
onEditingNodeIdChange={setWorkflowEditingNodeId}
|
||||||
editingEdgeId={workflowEditingEdgeId}
|
editingEdgeId={workflowEditingEdgeId}
|
||||||
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
|
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
|
||||||
|
settingsOpen={workflowSettingsOpen}
|
||||||
|
onSettingsOpenChange={setWorkflowSettingsOpen}
|
||||||
debugOpen={workflowDebugOpen}
|
debugOpen={workflowDebugOpen}
|
||||||
onDebugOpenChange={(open) => {
|
onDebugOpenChange={(open) => {
|
||||||
setWorkflowDebugOpen(open);
|
setWorkflowDebugOpen(open);
|
||||||
@@ -1508,10 +1535,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Boxes size={18} />}
|
icon={<Boxes size={15} />}
|
||||||
title="Dify 应用配置"
|
title="Dify 应用配置"
|
||||||
description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
|
description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
|
||||||
>
|
>
|
||||||
@@ -1525,7 +1552,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="语音配置"
|
title="语音配置"
|
||||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
||||||
>
|
>
|
||||||
@@ -1546,7 +1573,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={15} />}
|
||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
@@ -1604,10 +1631,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Database size={18} />}
|
icon={<Database size={15} />}
|
||||||
title="FastGPT 应用配置"
|
title="FastGPT 应用配置"
|
||||||
description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
|
description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
|
||||||
>
|
>
|
||||||
@@ -1621,7 +1648,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="语音配置"
|
title="语音配置"
|
||||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
||||||
>
|
>
|
||||||
@@ -1642,7 +1669,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={15} />}
|
||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
@@ -1704,10 +1731,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Terminal size={18} />}
|
icon={<Terminal size={15} />}
|
||||||
title="OpenCode 服务配置"
|
title="OpenCode 服务配置"
|
||||||
description="从「模型资源」中选择 OpenCode 服务资源。"
|
description="从「模型资源」中选择 OpenCode 服务资源。"
|
||||||
>
|
>
|
||||||
@@ -1721,7 +1748,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<MessageSquareText size={18} />}
|
icon={<MessageSquareText size={15} />}
|
||||||
title="提示词"
|
title="提示词"
|
||||||
description="描述助手的角色、能力和回答要求"
|
description="描述助手的角色、能力和回答要求"
|
||||||
>
|
>
|
||||||
@@ -1734,7 +1761,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="模型与语音配置"
|
title="模型与语音配置"
|
||||||
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
|
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
|
||||||
>
|
>
|
||||||
@@ -1779,7 +1806,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={15} />}
|
||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
@@ -1843,10 +1870,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -1858,25 +1885,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
|
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
||||||
form.runtimeMode === "pipeline"
|
form.runtimeMode === "pipeline"
|
||||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
<Waypoints size={18} />
|
<Waypoints size={15} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium text-foreground">Pipeline 模式</span>
|
<span className="text-sm font-medium text-foreground">Pipeline 模式</span>
|
||||||
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
|
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{form.runtimeMode === "pipeline" && (
|
{form.runtimeMode === "pipeline" && (
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
<Check size={14} />
|
<Check size={12} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1893,25 +1920,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
|
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
||||||
form.runtimeMode === "realtime"
|
form.runtimeMode === "realtime"
|
||||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
<AudioLines size={18} />
|
<AudioLines size={15} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium text-foreground">Realtime 模式</span>
|
<span className="text-sm font-medium text-foreground">Realtime 模式</span>
|
||||||
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
|
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{form.runtimeMode === "realtime" && (
|
{form.runtimeMode === "realtime" && (
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
<Check size={14} />
|
<Check size={12} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1920,7 +1947,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<MessageSquareText size={18} />}
|
icon={<MessageSquareText size={15} />}
|
||||||
title="提示词"
|
title="提示词"
|
||||||
description="描述助手的角色、能力和回答要求"
|
description="描述助手的角色、能力和回答要求"
|
||||||
>
|
>
|
||||||
@@ -1938,7 +1965,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
|
|
||||||
{form.runtimeMode === "pipeline" ? (
|
{form.runtimeMode === "pipeline" ? (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="模型配置"
|
title="模型配置"
|
||||||
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
||||||
>
|
>
|
||||||
@@ -1983,7 +2010,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
) : (
|
) : (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="模型配置"
|
title="模型配置"
|
||||||
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
||||||
>
|
>
|
||||||
@@ -1998,7 +2025,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Bot size={18} />}
|
icon={<Bot size={15} />}
|
||||||
title="开场白"
|
title="开场白"
|
||||||
description="助手与用户首次对话时的开场语"
|
description="助手与用户首次对话时的开场语"
|
||||||
>
|
>
|
||||||
@@ -2015,7 +2042,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
|
|
||||||
{form.runtimeMode === "pipeline" && (
|
{form.runtimeMode === "pipeline" && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Database size={18} />}
|
icon={<Database size={15} />}
|
||||||
title="知识库配置"
|
title="知识库配置"
|
||||||
description="选择助手回答时可检索的业务知识来源"
|
description="选择助手回答时可检索的业务知识来源"
|
||||||
>
|
>
|
||||||
@@ -2041,7 +2068,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Wrench size={18} />}
|
icon={<Wrench size={15} />}
|
||||||
title="工具"
|
title="工具"
|
||||||
description="配置该提示词助手可以调用的工具"
|
description="配置该提示词助手可以调用的工具"
|
||||||
>
|
>
|
||||||
@@ -2053,7 +2080,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={15} />}
|
||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
@@ -3377,29 +3404,6 @@ function EditableTitle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HelpHint({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="查看说明"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
|
||||||
>
|
|
||||||
<HelpCircle size={14} />
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
className="w-72 text-sm leading-6 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DynamicVariableEditorHint({
|
function DynamicVariableEditorHint({
|
||||||
count,
|
count,
|
||||||
onOpen,
|
onOpen,
|
||||||
@@ -3657,45 +3661,6 @@ function DynamicVariablesDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionCard({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const hasHeader = Boolean(title);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="rounded-2xl border-hairline bg-card text-card-foreground shadow-sm">
|
|
||||||
{hasHeader && (
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{icon && (
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<CardTitle className="text-base font-medium">{title}</CardTitle>
|
|
||||||
{description && <HelpHint text={description} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardContent className={hasHeader ? "space-y-4" : undefined}>
|
|
||||||
{children}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextAreaField({
|
function TextAreaField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -3712,7 +3677,7 @@ function TextAreaField({
|
|||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
{label && (
|
{label && (
|
||||||
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
|
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
|
||||||
)}
|
)}
|
||||||
<Textarea
|
<Textarea
|
||||||
value={value}
|
value={value}
|
||||||
@@ -3721,7 +3686,7 @@ function TextAreaField({
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
// Override ui/textarea's field-sizing-content so `rows` sets a real height
|
// Override ui/textarea's field-sizing-content so `rows` sets a real height
|
||||||
// instead of collapsing to min-h-16 when the value is short.
|
// instead of collapsing to min-h-16 when the value is short.
|
||||||
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -3747,7 +3712,7 @@ function ResourceSelectField({
|
|||||||
return (
|
return (
|
||||||
<div className="block">
|
<div className="block">
|
||||||
{label && (
|
{label && (
|
||||||
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
|
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
@@ -4056,18 +4021,18 @@ function ToggleRow({
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"flex items-center justify-between border border-hairline bg-canvas-soft",
|
"flex items-center justify-between border border-hairline bg-canvas-soft",
|
||||||
hasIcon ? "rounded-2xl p-5" : "rounded-xl p-4",
|
hasIcon ? "rounded-xl p-3.5" : "rounded-xl px-3.5 py-3",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"flex items-center font-medium text-foreground",
|
"flex items-center text-sm font-medium text-foreground",
|
||||||
hasIcon ? "gap-3" : "gap-1.5",
|
hasIcon ? "gap-2.5" : "gap-1.5",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{icon && (
|
{icon && (
|
||||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -4077,7 +4042,7 @@ function ToggleRow({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { HelpCircle, Settings2 } from "lucide-react";
|
import { Settings2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { HelpHint } from "@/components/editor/section-card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
@@ -13,11 +14,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -59,7 +55,7 @@ export function TurnConfigEditor({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-hairline bg-card p-4 shadow-sm">
|
<div className="flex items-center justify-between gap-3 rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
允许用户打断
|
允许用户打断
|
||||||
@@ -72,7 +68,7 @@ export function TurnConfigEditor({
|
|||||||
aria-label="打开允许用户打断高级配置"
|
aria-label="打开允许用户打断高级配置"
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Settings2 size={14} />
|
<Settings2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
||||||
@@ -161,29 +157,6 @@ function ConfigSection({ title, children }: { title: string; children: ReactNode
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HelpHint({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="查看允许用户打断说明"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
|
||||||
>
|
|
||||||
<HelpCircle size={14} />
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
className="w-72 text-sm leading-6 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberField({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
|
function NumberField({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
|
||||||
return (
|
return (
|
||||||
<label className="block space-y-2">
|
<label className="block space-y-2">
|
||||||
|
|||||||
@@ -30,14 +30,24 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
const preview = (nodeData.greeting || nodeData.prompt || nodeData.message || "")
|
const preview = (nodeData.greeting || nodeData.prompt || nodeData.message || "")
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
|
const entryModeLabel = {
|
||||||
|
wait_user: "等待用户",
|
||||||
|
generate: "立即回复",
|
||||||
|
fixed_speech: "固定进入语",
|
||||||
|
}[nodeData.entryMode ?? "wait_user"];
|
||||||
|
const inheritsGlobal = nodeData.inheritGlobalConfig !== false;
|
||||||
const meta = type === "agent"
|
const meta = type === "agent"
|
||||||
? [
|
? inheritsGlobal
|
||||||
nodeData.contextPolicy === "fresh" ? "独立上下文" : "继承上下文",
|
? [entryModeLabel, "继承全局配置"]
|
||||||
`${nodeData.toolIds?.length ?? 0} 工具`,
|
: [
|
||||||
nodeData.knowledgeBaseId ? "知识库" : null,
|
entryModeLabel,
|
||||||
nodeData.asrResourceId ? "独立 ASR" : null,
|
"自定义配置",
|
||||||
nodeData.ttsResourceId ? "独立 TTS" : null,
|
nodeData.llmResourceId ? "独立 LLM" : null,
|
||||||
].filter(Boolean)
|
`${nodeData.toolIds?.length ?? 0} 工具`,
|
||||||
|
nodeData.knowledgeBaseId ? "知识库" : null,
|
||||||
|
nodeData.asrResourceId ? "独立 ASR" : null,
|
||||||
|
nodeData.ttsResourceId ? "独立 TTS" : null,
|
||||||
|
].filter(Boolean)
|
||||||
: type === "action" && nodeData.toolId
|
: type === "action" && nodeData.toolId
|
||||||
? ["确定性工具"]
|
? ["确定性工具"]
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import type { NodeSpecDto } from "@/lib/api";
|
|||||||
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
|
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
|
||||||
export type ContextPolicy = "inherit" | "fresh";
|
export type ContextPolicy = "inherit" | "fresh";
|
||||||
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
|
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
|
||||||
|
export type AgentEntryMode = "wait_user" | "generate" | "fixed_speech";
|
||||||
export type EdgeMode = "llm" | "expression" | "always";
|
export type EdgeMode = "llm" | "expression" | "always";
|
||||||
export type ExpressionOperator =
|
export type ExpressionOperator =
|
||||||
| "eq"
|
| "eq"
|
||||||
@@ -25,11 +26,15 @@ export type WorkflowNodeData = {
|
|||||||
greeting?: string;
|
greeting?: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
contextPolicy?: ContextPolicy;
|
contextPolicy?: ContextPolicy;
|
||||||
|
inheritGlobalConfig?: boolean;
|
||||||
|
entryMode?: AgentEntryMode;
|
||||||
|
entrySpeech?: string;
|
||||||
toolIds?: string[];
|
toolIds?: string[];
|
||||||
knowledgeBaseId?: string;
|
knowledgeBaseId?: string;
|
||||||
knowledgeMode?: KnowledgeMode;
|
knowledgeMode?: KnowledgeMode;
|
||||||
knowledgeTopN?: number;
|
knowledgeTopN?: number;
|
||||||
knowledgeScoreThreshold?: number;
|
knowledgeScoreThreshold?: number;
|
||||||
|
llmResourceId?: string;
|
||||||
asrResourceId?: string;
|
asrResourceId?: string;
|
||||||
ttsResourceId?: string;
|
ttsResourceId?: string;
|
||||||
toolId?: string;
|
toolId?: string;
|
||||||
@@ -129,8 +134,14 @@ export type WorkflowGraph = {
|
|||||||
specVersion: 3;
|
specVersion: 3;
|
||||||
settings: {
|
settings: {
|
||||||
globalPrompt: string;
|
globalPrompt: string;
|
||||||
|
defaultLlmResourceId: string;
|
||||||
defaultAsrResourceId: string;
|
defaultAsrResourceId: string;
|
||||||
defaultTtsResourceId: string;
|
defaultTtsResourceId: string;
|
||||||
|
toolIds: string[];
|
||||||
|
knowledgeBaseId: string;
|
||||||
|
knowledgeMode: "automatic" | "on_demand";
|
||||||
|
knowledgeTopN: number;
|
||||||
|
knowledgeScoreThreshold: number;
|
||||||
};
|
};
|
||||||
nodes: Array<{
|
nodes: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -153,8 +164,14 @@ export function defaultGraph(): WorkflowGraph {
|
|||||||
settings: {
|
settings: {
|
||||||
globalPrompt:
|
globalPrompt:
|
||||||
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
||||||
|
defaultLlmResourceId: "",
|
||||||
defaultAsrResourceId: "",
|
defaultAsrResourceId: "",
|
||||||
defaultTtsResourceId: "",
|
defaultTtsResourceId: "",
|
||||||
|
toolIds: [],
|
||||||
|
knowledgeBaseId: "",
|
||||||
|
knowledgeMode: "automatic",
|
||||||
|
knowledgeTopN: 5,
|
||||||
|
knowledgeScoreThreshold: 0,
|
||||||
},
|
},
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
@@ -174,10 +191,9 @@ export function defaultGraph(): WorkflowGraph {
|
|||||||
name: "Agent",
|
name: "Agent",
|
||||||
prompt: "了解用户需求并提供清晰、准确的帮助。",
|
prompt: "了解用户需求并提供清晰、准确的帮助。",
|
||||||
contextPolicy: "inherit",
|
contextPolicy: "inherit",
|
||||||
toolIds: [],
|
inheritGlobalConfig: true,
|
||||||
knowledgeMode: "disabled",
|
entryMode: "wait_user",
|
||||||
knowledgeTopN: 5,
|
entrySpeech: "",
|
||||||
knowledgeScoreThreshold: 0,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user