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 {}
|
||||
resource_expectations: dict[str, str] = {}
|
||||
for key, capability in (
|
||||
("defaultLlmResourceId", "LLM"),
|
||||
("defaultAsrResourceId", "ASR"),
|
||||
("defaultTtsResourceId", "TTS"),
|
||||
):
|
||||
if settings.get(key):
|
||||
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 []:
|
||||
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"):
|
||||
resource_expectations[str(data["asrResourceId"])] = "ASR"
|
||||
if data.get("ttsResourceId"):
|
||||
|
||||
@@ -55,7 +55,9 @@ class BrainRuntime:
|
||||
worker: Any = None
|
||||
context_aggregator: 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_input_enabled: Callable[[bool], None] | None = None
|
||||
flow_global_functions: list[Any] = field(default_factory=list)
|
||||
@@ -84,6 +86,15 @@ class BaseBrain:
|
||||
def record_user_message(self, content: str) -> None:
|
||||
"""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:
|
||||
"""Observe the start of a generated assistant turn."""
|
||||
|
||||
@@ -114,6 +125,8 @@ class Brain(Protocol):
|
||||
|
||||
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_end(
|
||||
|
||||
@@ -15,6 +15,7 @@ from pipecat.flows import (
|
||||
NodeConfig,
|
||||
)
|
||||
from pipecat.frames.frames import (
|
||||
LLMRunFrame,
|
||||
LLMUpdateSettingsFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
TTSSpeakFrame,
|
||||
@@ -22,18 +23,20 @@ from pipecat.frames.frames import (
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
from pipecat.services.settings import LLMSettings
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||
from services.knowledge import search as search_knowledge
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
from services.workflow_router import STAY_ON_CURRENT_AGENT, WorkflowLLMRouter
|
||||
|
||||
|
||||
MAX_AUTOMATIC_HOPS = 50
|
||||
AGENT_STAGE_INSTRUCTION = (
|
||||
"完成当前阶段任务。需要流转时必须调用对应的转移函数;"
|
||||
"不要在调用转移函数后继续生成口头回复。"
|
||||
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
|
||||
"不要自行解释、模拟或宣布节点切换。"
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +61,7 @@ class WorkflowBrain(BaseBrain):
|
||||
}
|
||||
self._runtime: BrainRuntime | None = None
|
||||
self._manager: FlowManager | None = None
|
||||
self._router = WorkflowLLMRouter(cfg or AssistantConfig(type="workflow"))
|
||||
self._ended = False
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
@@ -79,6 +83,7 @@ class WorkflowBrain(BaseBrain):
|
||||
self._store = DynamicVariableStore.from_config(cfg)
|
||||
self._tools = ToolExecutor(self._store)
|
||||
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
||||
self._router = WorkflowLLMRouter(cfg)
|
||||
self._manager = FlowManager(
|
||||
worker=runtime.worker,
|
||||
llm=runtime.llm,
|
||||
@@ -95,9 +100,13 @@ class WorkflowBrain(BaseBrain):
|
||||
self._store,
|
||||
include_default=True,
|
||||
)
|
||||
if not edge:
|
||||
if not edge and self._engine.has_outgoing(self._engine.start_id):
|
||||
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:
|
||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||
await self._manager.initialize(node_config)
|
||||
@@ -107,6 +116,76 @@ class WorkflowBrain(BaseBrain):
|
||||
if content and not self._ended:
|
||||
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(
|
||||
self,
|
||||
_turn_id: str,
|
||||
@@ -116,19 +195,6 @@ class WorkflowBrain(BaseBrain):
|
||||
if not content or interrupted or self._ended:
|
||||
return
|
||||
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:
|
||||
runtime = self._require_runtime()
|
||||
@@ -145,61 +211,104 @@ class WorkflowBrain(BaseBrain):
|
||||
stage_prompt = self._engine.prompt_for(node_id, self._store)
|
||||
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
|
||||
|
||||
def _router_for_node(self, node_id: str) -> WorkflowLLMRouter:
|
||||
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:
|
||||
data = self._engine.data(node_id)
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
await self._emit_node_active(node_id)
|
||||
if self._runtime and self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(True)
|
||||
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()
|
||||
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:
|
||||
runtime.set_knowledge_scope(
|
||||
{
|
||||
"knowledge_base_id": data.get("knowledgeBaseId"),
|
||||
"mode": data.get("knowledgeMode", "disabled"),
|
||||
"top_n": int(data.get("knowledgeTopN") or 5),
|
||||
"score_threshold": float(data.get("knowledgeScoreThreshold") or 0.0),
|
||||
"knowledge_base_id": stage.knowledge_base_id,
|
||||
"mode": stage.knowledge_mode,
|
||||
"top_n": stage.knowledge_top_n,
|
||||
"score_threshold": stage.knowledge_score_threshold,
|
||||
}
|
||||
)
|
||||
|
||||
def _agent_config(self, node_id: str) -> NodeConfig:
|
||||
data = self._engine.data(node_id)
|
||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||
strategy = (
|
||||
ContextStrategy.RESET
|
||||
if data.get("contextPolicy") == "fresh"
|
||||
else ContextStrategy.APPEND
|
||||
)
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
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))
|
||||
if tool and tool.type == "http":
|
||||
functions.append(self._flow_tool(tool, node_id))
|
||||
knowledge_function = self._knowledge_function(node_id)
|
||||
if knowledge_function:
|
||||
functions.append(knowledge_function)
|
||||
for edge in self._engine.llm_edges(node_id):
|
||||
functions.append(self._flow_edge(edge))
|
||||
return {
|
||||
config: NodeConfig = {
|
||||
"name": 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,
|
||||
"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 {
|
||||
"name": node_id,
|
||||
"role_message": self._store.render(self._engine.global_prompt()),
|
||||
@@ -235,24 +344,13 @@ class WorkflowBrain(BaseBrain):
|
||||
properties=properties,
|
||||
required=required,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
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,
|
||||
cancel_on_interruption=True,
|
||||
)
|
||||
|
||||
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
||||
data = self._engine.data(node_id)
|
||||
knowledge_id = str(data.get("knowledgeBaseId") or "")
|
||||
if not knowledge_id or data.get("knowledgeMode") != "on_demand":
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
knowledge_id = str(stage.knowledge_base_id or "")
|
||||
if not knowledge_id or stage.knowledge_mode != "on_demand":
|
||||
return None
|
||||
cfg = self._cfg or AssistantConfig(type="workflow")
|
||||
knowledge = cfg.workflow_knowledge_bases.get(knowledge_id)
|
||||
@@ -270,8 +368,8 @@ class WorkflowBrain(BaseBrain):
|
||||
session,
|
||||
knowledge_id,
|
||||
query,
|
||||
top_k=int(data.get("knowledgeTopN") or 5),
|
||||
score_threshold=float(data.get("knowledgeScoreThreshold") or 0.0),
|
||||
top_k=stage.knowledge_top_n,
|
||||
score_threshold=stage.knowledge_score_threshold,
|
||||
)
|
||||
return {"status": "ok", "results": results}
|
||||
except Exception as exc: # noqa: BLE001 - tool errors are returned to the LLM
|
||||
@@ -286,14 +384,13 @@ class WorkflowBrain(BaseBrain):
|
||||
},
|
||||
required=["query"],
|
||||
handler=handler,
|
||||
cancel_on_interruption=True,
|
||||
)
|
||||
|
||||
async def _follow_edge(self, edge: dict) -> NodeConfig:
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
await self._require_runtime().queue_frame(
|
||||
TTSSpeakFrame(self._store.render(speech), append_to_context=False)
|
||||
)
|
||||
await self._queue_visible_speech(self._store.render(speech))
|
||||
return await self._resolve_path(str(edge.get("target") or ""))
|
||||
|
||||
async def _resolve_path(self, node_id: str) -> NodeConfig:
|
||||
@@ -304,7 +401,7 @@ class WorkflowBrain(BaseBrain):
|
||||
return self._agent_config(node_id)
|
||||
if node_type == "end":
|
||||
await self._enter_end(node_id)
|
||||
return self._terminal_config(node_id)
|
||||
return self._passive_node_config(node_id)
|
||||
if node_type == "action":
|
||||
await self._enter_action(node_id)
|
||||
elif node_type == "handoff":
|
||||
@@ -313,6 +410,8 @@ class WorkflowBrain(BaseBrain):
|
||||
await self._emit_node_active(node_id)
|
||||
else:
|
||||
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(
|
||||
node_id,
|
||||
self._store,
|
||||
@@ -322,9 +421,7 @@ class WorkflowBrain(BaseBrain):
|
||||
raise RuntimeError(f"自动节点 {node_id} 没有命中的表达式边或默认边")
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
await self._require_runtime().queue_frame(
|
||||
TTSSpeakFrame(self._store.render(speech), append_to_context=False)
|
||||
)
|
||||
await self._queue_visible_speech(self._store.render(speech))
|
||||
node_id = str(edge.get("target") or "")
|
||||
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||
|
||||
@@ -366,9 +463,7 @@ class WorkflowBrain(BaseBrain):
|
||||
)
|
||||
)
|
||||
if message:
|
||||
await self._require_runtime().queue_frame(
|
||||
TTSSpeakFrame(message, append_to_context=False)
|
||||
)
|
||||
await self._queue_visible_speech(message)
|
||||
self._store.values["system__handoff_status"] = "requested"
|
||||
|
||||
async def _enter_end(self, node_id: str) -> None:
|
||||
@@ -389,12 +484,12 @@ class WorkflowBrain(BaseBrain):
|
||||
)
|
||||
)
|
||||
if message:
|
||||
await runtime.queue_frame(TTSSpeakFrame(message, append_to_context=False))
|
||||
await self._queue_visible_speech(message)
|
||||
return
|
||||
runtime.call_end.begin("workflow_completed")
|
||||
if message:
|
||||
runtime.call_end.arm_after_speech()
|
||||
await runtime.queue_frame(TTSSpeakFrame(message, append_to_context=False))
|
||||
await self._queue_visible_speech(message)
|
||||
else:
|
||||
await runtime.call_end.finish()
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from typing import Any
|
||||
SPEC_VERSION = "3"
|
||||
NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
|
||||
EDGE_MODES = {"llm", "expression", "always"}
|
||||
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
|
||||
AUTOMATIC_NODE_TYPES = {"start", "action", "handoff"}
|
||||
EXPRESSION_OPERATORS = {
|
||||
"eq",
|
||||
"neq",
|
||||
@@ -34,7 +36,7 @@ NODE_SPECS: list[dict[str, Any]] = [
|
||||
"constraints": {
|
||||
"minIncoming": 0,
|
||||
"maxIncoming": 0,
|
||||
"minOutgoing": 1,
|
||||
"minOutgoing": 0,
|
||||
"minInstances": 1,
|
||||
"maxInstances": 1,
|
||||
},
|
||||
@@ -51,7 +53,7 @@ NODE_SPECS: list[dict[str, Any]] = [
|
||||
"icon": "Bot",
|
||||
"accent": "sky",
|
||||
"addable": True,
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 0},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Agent"},
|
||||
{
|
||||
@@ -84,7 +86,7 @@ NODE_SPECS: list[dict[str, Any]] = [
|
||||
"icon": "PhoneForwarded",
|
||||
"accent": "lavender",
|
||||
"addable": True,
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 0},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Handoff"},
|
||||
{"key": "target", "label": "转交目标", "type": "text", "default": ""},
|
||||
@@ -132,13 +134,49 @@ def _edge_data_v3(edge: dict, source_type: str) -> dict:
|
||||
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]:
|
||||
"""Return a deep-copied v3 graph; preserve v3 IDs and migrate v2 semantics."""
|
||||
source = deepcopy(graph or {})
|
||||
if str(source.get("specVersion") or "") == SPEC_VERSION:
|
||||
source.setdefault("settings", {})
|
||||
settings = source.setdefault("settings", {})
|
||||
_normalize_settings(settings)
|
||||
source.setdefault("nodes", [])
|
||||
source.setdefault("edges", [])
|
||||
for node in source["nodes"]:
|
||||
if node.get("type") != "agent":
|
||||
continue
|
||||
data = node.setdefault("data", {})
|
||||
_normalize_agent_data(data)
|
||||
return source
|
||||
|
||||
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.setdefault("scope", "session")
|
||||
elif new_type == "agent":
|
||||
data.setdefault("contextPolicy", "inherit")
|
||||
data.setdefault("toolIds", [])
|
||||
data.setdefault("knowledgeMode", "disabled")
|
||||
_normalize_agent_data(data)
|
||||
elif new_type == "start":
|
||||
prompt = str(data.pop("prompt", "") or "").strip()
|
||||
if prompt:
|
||||
@@ -207,8 +243,9 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"name": "迁移的开场 Agent",
|
||||
"prompt": prompt,
|
||||
"contextPolicy": "inherit",
|
||||
"toolIds": [],
|
||||
"knowledgeMode": "disabled",
|
||||
"inheritGlobalConfig": True,
|
||||
"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.setdefault("globalPrompt", global_prompt)
|
||||
settings.setdefault("defaultAsrResourceId", "")
|
||||
settings.setdefault("defaultTtsResourceId", "")
|
||||
_normalize_settings(settings, global_prompt=global_prompt)
|
||||
return {
|
||||
"specVersion": 3,
|
||||
"settings": settings,
|
||||
@@ -281,11 +316,18 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
||||
node_by_id[node_id] = node
|
||||
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:
|
||||
errors.append("工作流必须有且仅有一个 Start 节点")
|
||||
if counts["end"] < 1:
|
||||
errors.append("工作流至少需要一个 End 节点")
|
||||
|
||||
incoming: dict[str, int] = defaultdict(int)
|
||||
outgoing: dict[str, int] = defaultdict(int)
|
||||
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():
|
||||
errors.append(f"LLM 判断边缺少自然语言条件:{edge_id}")
|
||||
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:
|
||||
priority = int(data.get("priority", 10))
|
||||
except (TypeError, ValueError):
|
||||
@@ -329,7 +372,9 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
||||
incoming[target_id] += 1
|
||||
outgoing[source_id] += 1
|
||||
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)
|
||||
|
||||
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}")
|
||||
if hi is not None and actual > hi:
|
||||
errors.append(f"节点 {node_id} 的{label}不能多于 {hi}")
|
||||
if node.get("type") in {"start", "action", "handoff"} and always_counts[node_id] != 1:
|
||||
errors.append(f"自动节点 {node_id} 必须有且仅有一条默认边")
|
||||
node_type = node.get("type")
|
||||
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:
|
||||
reached = {start_id}
|
||||
queue = deque([start_id])
|
||||
@@ -380,7 +433,12 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
||||
visited.add(node_id)
|
||||
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 之间不能形成无等待循环")
|
||||
return list(dict.fromkeys(errors))
|
||||
|
||||
@@ -392,22 +450,35 @@ def graph_references(graph: dict[str, Any]) -> dict[str, set[str]]:
|
||||
resources = {
|
||||
str(value)
|
||||
for value in (
|
||||
settings.get("defaultLlmResourceId"),
|
||||
settings.get("defaultAsrResourceId"),
|
||||
settings.get("defaultTtsResourceId"),
|
||||
)
|
||||
if value
|
||||
}
|
||||
tools: set[str] = set()
|
||||
knowledge: set[str] = set()
|
||||
tools: set[str] = {str(tool_id) for tool_id in settings.get("toolIds") or []}
|
||||
knowledge: set[str] = (
|
||||
{str(settings["knowledgeBaseId"])}
|
||||
if settings.get("knowledgeBaseId")
|
||||
else set()
|
||||
)
|
||||
for node in normalized.get("nodes") or []:
|
||||
data = node.get("data") or {}
|
||||
for resource_id in (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))
|
||||
inherits_global = (
|
||||
node.get("type") == "agent" and data.get("inheritGlobalConfig", True)
|
||||
)
|
||||
if not inherits_global:
|
||||
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"):
|
||||
tools.add(str(data["toolId"]))
|
||||
if data.get("knowledgeBaseId"):
|
||||
knowledge.add(str(data["knowledgeBaseId"]))
|
||||
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 (
|
||||
config_with_resource,
|
||||
create_llm,
|
||||
create_realtime_service,
|
||||
create_stt,
|
||||
create_tts,
|
||||
@@ -51,6 +52,7 @@ from pipecat.frames.frames import (
|
||||
UserImageRequestFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.llm_switcher import LLMSwitcher
|
||||
from pipecat.pipeline.service_switcher import ServiceSwitcher
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
@@ -487,6 +489,49 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
|
||||
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):
|
||||
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
||||
|
||||
@@ -589,6 +634,29 @@ def _workflow_service_switcher(
|
||||
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(
|
||||
transport,
|
||||
cfg: AssistantConfig,
|
||||
@@ -630,6 +698,9 @@ async def run_pipeline(
|
||||
return
|
||||
|
||||
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(
|
||||
str(graph_settings.get("defaultAsrResourceId") or "")
|
||||
)
|
||||
@@ -713,7 +784,16 @@ async def run_pipeline(
|
||||
)
|
||||
input_state = {"enabled": True}
|
||||
# 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(
|
||||
context,
|
||||
params=LLMUserAggregatorParams(
|
||||
@@ -730,6 +810,7 @@ async def run_pipeline(
|
||||
),
|
||||
),
|
||||
)
|
||||
user_turn_router = UserTurnRoutingProcessor(brain)
|
||||
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
||||
text_input = TextInputProcessor(
|
||||
should_ignore_input=lambda: call_end.ending or not input_state["enabled"]
|
||||
@@ -880,6 +961,7 @@ async def run_pipeline(
|
||||
properties=vision_schema.properties,
|
||||
required=vision_schema.required,
|
||||
handler=flow_fetch_user_image,
|
||||
cancel_on_interruption=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -913,6 +995,7 @@ async def run_pipeline(
|
||||
text_input,
|
||||
stt_processor,
|
||||
user_aggregator,
|
||||
user_turn_router,
|
||||
knowledge_retrieval,
|
||||
llm,
|
||||
# Aggregate the streamed LLM text before TTS. On interruption,
|
||||
@@ -934,24 +1017,42 @@ async def run_pipeline(
|
||||
enable_rtvi=False,
|
||||
)
|
||||
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(
|
||||
llm_resource_id: str | None,
|
||||
asr_resource_id: str | None,
|
||||
tts_resource_id: str | None,
|
||||
) -> None:
|
||||
nonlocal current_llm_service
|
||||
requested = (
|
||||
("llm", llm_services, llm_resource_id),
|
||||
("asr", stt_services, asr_resource_id),
|
||||
("tts", tts_services, tts_resource_id),
|
||||
)
|
||||
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:
|
||||
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
|
||||
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(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={
|
||||
@@ -1020,8 +1121,6 @@ async def run_pipeline(
|
||||
|
||||
@user_aggregator.event_handler("on_user_turn_stopped")
|
||||
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)
|
||||
|
||||
@assistant_aggregator.event_handler("on_assistant_text_start")
|
||||
@@ -1066,7 +1165,6 @@ async def run_pipeline(
|
||||
@text_input.event_handler("on_text_input")
|
||||
async def on_text_input(_processor, text):
|
||||
pending_text_inputs.append(text)
|
||||
brain.record_user_message(text)
|
||||
# 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
|
||||
await queue_transcript("user", text, time_now_iso8601())
|
||||
|
||||
|
||||
@@ -29,11 +29,18 @@ TTS_STOP_FRAME_TIMEOUT_S = 1.0
|
||||
def config_with_resource(
|
||||
cfg: AssistantConfig, resource: RuntimeModelResource
|
||||
) -> 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)
|
||||
values = resource.values 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.stt_language = str(values.get("language") or "")
|
||||
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_base_url = str(values.get("apiUrl") or "")
|
||||
else:
|
||||
raise ValueError(f"工作流语音资源能力无效:{resource.capability}")
|
||||
raise ValueError(f"工作流模型资源能力无效:{resource.capability}")
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -3,12 +3,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from services.node_specs import normalize_graph
|
||||
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:
|
||||
def __init__(self, graph: dict[str, Any]):
|
||||
self.graph = normalize_graph(graph)
|
||||
@@ -20,7 +36,11 @@ class WorkflowEngine:
|
||||
}
|
||||
self.edges: list[dict] = list(self.graph.get("edges") or [])
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -38,7 +58,13 @@ class WorkflowEngine:
|
||||
|
||||
def outgoing(self, node_id: str | None) -> list[dict]:
|
||||
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:
|
||||
return str((edge.get("data") or {}).get("mode") or "always")
|
||||
@@ -60,15 +86,49 @@ class WorkflowEngine:
|
||||
if not edge:
|
||||
return ""
|
||||
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:
|
||||
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:
|
||||
"""Build the Agent system prompt according to its inheritance setting."""
|
||||
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
|
||||
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())}")
|
||||
if prompt:
|
||||
sections.append(f"[当前阶段任务]\n{prompt}")
|
||||
@@ -111,7 +171,11 @@ class WorkflowEngine:
|
||||
results.append(matched)
|
||||
if not results:
|
||||
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(
|
||||
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,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
LLMRunFrame,
|
||||
LLMTextFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
@@ -388,10 +391,108 @@ class PromptBrainTests(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):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {"globalPrompt": "全局规则"},
|
||||
"settings": {
|
||||
"globalPrompt": "全局规则",
|
||||
"defaultLlmResourceId": "llm_global",
|
||||
"defaultAsrResourceId": "asr_global",
|
||||
"defaultTtsResourceId": "tts_global",
|
||||
"knowledgeBaseId": "kb_global",
|
||||
"knowledgeMode": "automatic",
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
@@ -428,6 +529,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
"mode": "llm",
|
||||
"priority": 10,
|
||||
"condition": "需求已收集",
|
||||
"transitionSpeech": "正在为你结束流程",
|
||||
},
|
||||
}
|
||||
],
|
||||
@@ -447,6 +549,8 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
llm = FakeLLM()
|
||||
context = LLMContext(messages=[])
|
||||
queued = []
|
||||
service_switches = []
|
||||
knowledge_scopes = []
|
||||
call_end = FakeCallEnd()
|
||||
|
||||
class FakeWorker:
|
||||
@@ -478,6 +582,9 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def queue_frame(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(
|
||||
context=context,
|
||||
llm=llm,
|
||||
@@ -487,29 +594,112 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
call_end=call_end,
|
||||
worker=worker,
|
||||
context_aggregator=pair,
|
||||
switch_services=switch_services,
|
||||
set_knowledge_scope=knowledge_scopes.append,
|
||||
)
|
||||
await brain.setup(cfg, runtime)
|
||||
await brain.on_connected()
|
||||
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")
|
||||
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.assertFalse(agent_config["respond_immediately"])
|
||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||
self.assertEqual(
|
||||
agent_config["context_strategy"].strategy.value,
|
||||
"reset",
|
||||
)
|
||||
|
||||
edge_function = next(
|
||||
function
|
||||
for function in brain._agent_config("agent")["functions"]
|
||||
if function.name == "goto_finish"
|
||||
brain._engine.data("agent")["entryMode"] = "generate"
|
||||
generate_config = brain._agent_config("agent")
|
||||
self.assertTrue(generate_config["respond_immediately"])
|
||||
worker.frames.clear()
|
||||
await brain._manager.set_node_from_config(generate_config)
|
||||
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||
|
||||
brain._engine.data("agent").update(
|
||||
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
|
||||
)
|
||||
_, terminal = await edge_function.handler({}, brain._manager)
|
||||
self.assertEqual(terminal["name"], "end")
|
||||
fixed_config = brain._agent_config("agent")
|
||||
self.assertFalse(fixed_config["respond_immediately"])
|
||||
self.assertEqual(
|
||||
fixed_config["pre_actions"][0]["type"],
|
||||
"workflow_fixed_speech",
|
||||
)
|
||||
self.assertEqual(fixed_config["pre_actions"][0]["text"], "您好,王先生")
|
||||
self.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.armed)
|
||||
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__":
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import unittest
|
||||
|
||||
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 (
|
||||
KNOWLEDGE_CONTEXT_MARKER,
|
||||
KnowledgeRetrievalProcessor,
|
||||
UserTurnRoutingProcessor,
|
||||
_knowledge_tool_description,
|
||||
)
|
||||
|
||||
@@ -57,5 +61,37 @@ class KnowledgeToolDescriptionTest(unittest.TestCase):
|
||||
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__":
|
||||
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 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.workflow_engine import WorkflowEngine
|
||||
|
||||
@@ -49,6 +49,22 @@ def valid_graph():
|
||||
|
||||
|
||||
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):
|
||||
base = AssistantConfig(type="workflow", asr="default", voice="default")
|
||||
asr = RuntimeModelResource(
|
||||
@@ -63,6 +79,191 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
self.assertEqual(resolved.stt_api_key, "secret")
|
||||
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):
|
||||
graph = normalize_graph(
|
||||
{
|
||||
@@ -113,6 +314,15 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
)
|
||||
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__":
|
||||
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,
|
||||
Mic,
|
||||
Send,
|
||||
HelpCircle,
|
||||
Waypoints,
|
||||
AudioLines,
|
||||
Terminal,
|
||||
@@ -87,12 +86,6 @@ import { PageHeader } from "@/components/ui/page-header";
|
||||
import { FilterPills } from "@/components/ui/filter-pills";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import { ListToolbar } from "@/components/ui/list-toolbar";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -124,6 +117,7 @@ import {
|
||||
WorkflowEditor,
|
||||
type WorkflowSettings,
|
||||
} from "@/components/workflow/WorkflowEditor";
|
||||
import { HelpHint, SectionCard } from "@/components/editor/section-card";
|
||||
import {
|
||||
defaultGraph,
|
||||
type WorkflowGraph,
|
||||
@@ -362,7 +356,7 @@ type AssistantTypeOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
/** 提示词、Dify、FastGPT 类型已落地,工作流暂时显示占位页 */
|
||||
/** 提示词、工作流、Dify、FastGPT 已落地;OpenCode 暂时显示即将上线 */
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
@@ -379,7 +373,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
|
||||
label: "使用工作流构建",
|
||||
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
|
||||
icon: <Workflow size={20} />,
|
||||
available: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
type: "Dify",
|
||||
@@ -400,7 +394,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
|
||||
label: "使用 OpenCode 构建",
|
||||
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
|
||||
icon: <Terminal size={20} />,
|
||||
available: true,
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -472,12 +466,23 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
);
|
||||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
||||
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,
|
||||
turnConfig: defaultTurnConfig(),
|
||||
});
|
||||
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
|
||||
useState<Record<string, DynamicVariableDefinition>>({});
|
||||
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
|
||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
||||
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(null);
|
||||
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(null);
|
||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||
@@ -847,9 +852,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
? (assistant.graph as WorkflowGraph)
|
||||
: defaultGraph();
|
||||
const wfSettings: WorkflowSettings = {
|
||||
llm: assistant.modelResourceIds.LLM,
|
||||
llm:
|
||||
graph.settings?.defaultLlmResourceId ||
|
||||
assistant.modelResourceIds.LLM,
|
||||
asr: graph.settings?.defaultAsrResourceId || assistant.modelResourceIds.ASR,
|
||||
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 ?? "",
|
||||
allowInterrupt: assistant.enableInterrupt,
|
||||
turnConfig: assistant.turnConfig,
|
||||
@@ -916,6 +937,9 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
|
||||
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
|
||||
},
|
||||
knowledgeBaseId: workflowSettings.knowledgeBaseId || null,
|
||||
knowledgeRetrievalConfig: workflowSettings.knowledgeRetrievalConfig,
|
||||
toolIds: workflowSettings.toolIds,
|
||||
graph: workflowGraph as unknown as Record<string, unknown>,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
}),
|
||||
@@ -1386,7 +1410,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
<span
|
||||
role="alert"
|
||||
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}
|
||||
</span>
|
||||
@@ -1396,6 +1420,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
||||
disabled={!editingId}
|
||||
onClick={() => {
|
||||
setWorkflowSettingsOpen(false);
|
||||
setWorkflowEditingNodeId(null);
|
||||
setWorkflowEditingEdgeId(null);
|
||||
setWorkflowDebugOpen(true);
|
||||
@@ -1430,6 +1455,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
onEditingNodeIdChange={setWorkflowEditingNodeId}
|
||||
editingEdgeId={workflowEditingEdgeId}
|
||||
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
|
||||
settingsOpen={workflowSettingsOpen}
|
||||
onSettingsOpenChange={setWorkflowSettingsOpen}
|
||||
debugOpen={workflowDebugOpen}
|
||||
onDebugOpenChange={(open) => {
|
||||
setWorkflowDebugOpen(open);
|
||||
@@ -1508,10 +1535,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<SectionCard
|
||||
icon={<Boxes size={18} />}
|
||||
icon={<Boxes size={15} />}
|
||||
title="Dify 应用配置"
|
||||
description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
|
||||
>
|
||||
@@ -1525,7 +1552,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="语音配置"
|
||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
||||
>
|
||||
@@ -1546,7 +1573,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
description="设置实时视频对话时的交互体验"
|
||||
>
|
||||
@@ -1604,10 +1631,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<SectionCard
|
||||
icon={<Database size={18} />}
|
||||
icon={<Database size={15} />}
|
||||
title="FastGPT 应用配置"
|
||||
description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
|
||||
>
|
||||
@@ -1621,7 +1648,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="语音配置"
|
||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
||||
>
|
||||
@@ -1642,7 +1669,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
description="设置实时视频对话时的交互体验"
|
||||
>
|
||||
@@ -1704,10 +1731,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<SectionCard
|
||||
icon={<Terminal size={18} />}
|
||||
icon={<Terminal size={15} />}
|
||||
title="OpenCode 服务配置"
|
||||
description="从「模型资源」中选择 OpenCode 服务资源。"
|
||||
>
|
||||
@@ -1721,7 +1748,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={18} />}
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="提示词"
|
||||
description="描述助手的角色、能力和回答要求"
|
||||
>
|
||||
@@ -1734,7 +1761,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="模型与语音配置"
|
||||
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
|
||||
>
|
||||
@@ -1779,7 +1806,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
description="设置实时视频对话时的交互体验"
|
||||
>
|
||||
@@ -1843,10 +1870,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<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
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -1858,25 +1885,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
}
|
||||
}}
|
||||
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"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<Waypoints size={18} />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<Waypoints size={15} />
|
||||
</div>
|
||||
<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 级联组成语音管线,灵活选配各模块。" />
|
||||
</div>
|
||||
</div>
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={14} />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1893,25 +1920,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
}
|
||||
}}
|
||||
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"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<AudioLines size={18} />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<AudioLines size={15} />
|
||||
</div>
|
||||
<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="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
|
||||
</div>
|
||||
</div>
|
||||
{form.runtimeMode === "realtime" && (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={14} />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1920,7 +1947,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={18} />}
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="提示词"
|
||||
description="描述助手的角色、能力和回答要求"
|
||||
>
|
||||
@@ -1938,7 +1965,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="模型配置"
|
||||
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
||||
>
|
||||
@@ -1983,7 +2010,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="模型配置"
|
||||
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
||||
>
|
||||
@@ -1998,7 +2025,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
icon={<Bot size={18} />}
|
||||
icon={<Bot size={15} />}
|
||||
title="开场白"
|
||||
description="助手与用户首次对话时的开场语"
|
||||
>
|
||||
@@ -2015,7 +2042,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<SectionCard
|
||||
icon={<Database size={18} />}
|
||||
icon={<Database size={15} />}
|
||||
title="知识库配置"
|
||||
description="选择助手回答时可检索的业务知识来源"
|
||||
>
|
||||
@@ -2041,7 +2068,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
icon={<Wrench size={18} />}
|
||||
icon={<Wrench size={15} />}
|
||||
title="工具"
|
||||
description="配置该提示词助手可以调用的工具"
|
||||
>
|
||||
@@ -2053,7 +2080,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
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({
|
||||
count,
|
||||
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({
|
||||
label,
|
||||
value,
|
||||
@@ -3712,7 +3677,7 @@ function TextAreaField({
|
||||
return (
|
||||
<label className="block">
|
||||
{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
|
||||
value={value}
|
||||
@@ -3721,7 +3686,7 @@ function TextAreaField({
|
||||
rows={rows}
|
||||
// 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.
|
||||
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>
|
||||
);
|
||||
@@ -3747,7 +3712,7 @@ function ResourceSelectField({
|
||||
return (
|
||||
<div className="block">
|
||||
{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
|
||||
@@ -4056,18 +4021,18 @@ function ToggleRow({
|
||||
<div
|
||||
className={[
|
||||
"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(" ")}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={[
|
||||
"flex items-center font-medium text-foreground",
|
||||
hasIcon ? "gap-3" : "gap-1.5",
|
||||
"flex items-center text-sm font-medium text-foreground",
|
||||
hasIcon ? "gap-2.5" : "gap-1.5",
|
||||
].join(" ")}
|
||||
>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -4077,7 +4042,7 @@ function ToggleRow({
|
||||
</span>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -13,11 +14,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -59,7 +55,7 @@ export function TurnConfigEditor({
|
||||
});
|
||||
|
||||
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">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
允许用户打断
|
||||
@@ -72,7 +68,7 @@ export function TurnConfigEditor({
|
||||
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"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
<Settings2 size={13} />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<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 }) {
|
||||
return (
|
||||
<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 || "")
|
||||
.toString()
|
||||
.trim();
|
||||
const entryModeLabel = {
|
||||
wait_user: "等待用户",
|
||||
generate: "立即回复",
|
||||
fixed_speech: "固定进入语",
|
||||
}[nodeData.entryMode ?? "wait_user"];
|
||||
const inheritsGlobal = nodeData.inheritGlobalConfig !== false;
|
||||
const meta = type === "agent"
|
||||
? [
|
||||
nodeData.contextPolicy === "fresh" ? "独立上下文" : "继承上下文",
|
||||
`${nodeData.toolIds?.length ?? 0} 工具`,
|
||||
nodeData.knowledgeBaseId ? "知识库" : null,
|
||||
nodeData.asrResourceId ? "独立 ASR" : null,
|
||||
nodeData.ttsResourceId ? "独立 TTS" : null,
|
||||
].filter(Boolean)
|
||||
? inheritsGlobal
|
||||
? [entryModeLabel, "继承全局配置"]
|
||||
: [
|
||||
entryModeLabel,
|
||||
"自定义配置",
|
||||
nodeData.llmResourceId ? "独立 LLM" : null,
|
||||
`${nodeData.toolIds?.length ?? 0} 工具`,
|
||||
nodeData.knowledgeBaseId ? "知识库" : null,
|
||||
nodeData.asrResourceId ? "独立 ASR" : null,
|
||||
nodeData.ttsResourceId ? "独立 TTS" : null,
|
||||
].filter(Boolean)
|
||||
: 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 ContextPolicy = "inherit" | "fresh";
|
||||
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
|
||||
export type AgentEntryMode = "wait_user" | "generate" | "fixed_speech";
|
||||
export type EdgeMode = "llm" | "expression" | "always";
|
||||
export type ExpressionOperator =
|
||||
| "eq"
|
||||
@@ -25,11 +26,15 @@ export type WorkflowNodeData = {
|
||||
greeting?: string;
|
||||
prompt?: string;
|
||||
contextPolicy?: ContextPolicy;
|
||||
inheritGlobalConfig?: boolean;
|
||||
entryMode?: AgentEntryMode;
|
||||
entrySpeech?: string;
|
||||
toolIds?: string[];
|
||||
knowledgeBaseId?: string;
|
||||
knowledgeMode?: KnowledgeMode;
|
||||
knowledgeTopN?: number;
|
||||
knowledgeScoreThreshold?: number;
|
||||
llmResourceId?: string;
|
||||
asrResourceId?: string;
|
||||
ttsResourceId?: string;
|
||||
toolId?: string;
|
||||
@@ -129,8 +134,14 @@ export type WorkflowGraph = {
|
||||
specVersion: 3;
|
||||
settings: {
|
||||
globalPrompt: string;
|
||||
defaultLlmResourceId: string;
|
||||
defaultAsrResourceId: string;
|
||||
defaultTtsResourceId: string;
|
||||
toolIds: string[];
|
||||
knowledgeBaseId: string;
|
||||
knowledgeMode: "automatic" | "on_demand";
|
||||
knowledgeTopN: number;
|
||||
knowledgeScoreThreshold: number;
|
||||
};
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
@@ -153,8 +164,14 @@ export function defaultGraph(): WorkflowGraph {
|
||||
settings: {
|
||||
globalPrompt:
|
||||
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
||||
defaultLlmResourceId: "",
|
||||
defaultAsrResourceId: "",
|
||||
defaultTtsResourceId: "",
|
||||
toolIds: [],
|
||||
knowledgeBaseId: "",
|
||||
knowledgeMode: "automatic",
|
||||
knowledgeTopN: 5,
|
||||
knowledgeScoreThreshold: 0,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
@@ -174,10 +191,9 @@ export function defaultGraph(): WorkflowGraph {
|
||||
name: "Agent",
|
||||
prompt: "了解用户需求并提供清晰、准确的帮助。",
|
||||
contextPolicy: "inherit",
|
||||
toolIds: [],
|
||||
knowledgeMode: "disabled",
|
||||
knowledgeTopN: 5,
|
||||
knowledgeScoreThreshold: 0,
|
||||
inheritGlobalConfig: true,
|
||||
entryMode: "wait_user",
|
||||
entrySpeech: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user