Add workflow support and enhance runtime configuration in models and services

- Introduce RuntimeModelResource and RuntimeKnowledgeBase classes to manage workflow resources.
- Update AssistantConfig to include workflow_model_resources and workflow_knowledge_bases for better integration.
- Refactor validation and processing logic in routes and services to accommodate workflow types.
- Implement dynamic variable support for workflow assistants and enhance graph normalization.
- Add ToolExecutor for reusable tool execution across different assistant types.
- Update various services to ensure compatibility with new workflow features and improve error handling.
This commit is contained in:
Xin Wang
2026-07-13 16:13:27 +08:00
parent 6108b00007
commit 32aef14ddb
27 changed files with 2563 additions and 910 deletions

View File

@@ -27,6 +27,21 @@ class RuntimeTool(BaseModel):
secrets: dict = Field(default_factory=dict)
class RuntimeModelResource(BaseModel):
id: str
name: str = ""
capability: str
interface_type: str
values: dict = Field(default_factory=dict)
secrets: dict = Field(default_factory=dict)
class RuntimeKnowledgeBase(BaseModel):
id: str
name: str = ""
description: str = ""
class AssistantConfig(BaseModel):
"""运行时配置:前端可见部分(name/prompt/...) + 服务端注入部分(*_api_key/*_base_url)。"""
@@ -93,6 +108,8 @@ class AssistantConfig(BaseModel):
# workflow 类型:节点图(nodes/edges)。非 workflow 为空,引擎据此决定是否启用。
graph: dict = {}
workflow_model_resources: dict[str, RuntimeModelResource] = Field(default_factory=dict)
workflow_knowledge_bases: dict[str, RuntimeKnowledgeBase] = Field(default_factory=dict)
# 外部托管类型(fastgpt/dify/opencode)的连接信息:context/KB/tools 由对方服务端接管。
dify_api_url: str = ""

View File

@@ -2,7 +2,7 @@
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
# silero -> 本地 VAD(判断用户说话起止),语音必备
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
pipecat-ai[webrtc,websocket,silero,openai]==1.4.0
pipecat-ai[webrtc,websocket,silero,openai]==1.5.0
Pillow>=11.1.0,<13
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)

View File

@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException
from schemas import AssistantOut, AssistantUpsert
from services.auth import require_admin
from services.masking import mask, resolve_incoming_key
from services.node_specs import validate_graph
from services.node_specs import graph_references, normalize_graph, validate_graph
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,9 +31,45 @@ def _validate_workflow(body: AssistantUpsert) -> None:
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
if body.type != "workflow":
return
errors = validate_graph(body.graph or {})
body.graph = normalize_graph(body.graph or {})
errors = validate_graph(body.graph)
if errors:
raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
refs = graph_references(body.graph)
body.tool_ids = list(dict.fromkeys([*body.tool_ids, *sorted(refs["tools"])]))
async def _validate_workflow_references(
session: AsyncSession, body: AssistantUpsert
) -> None:
if body.type != "workflow" or not body.graph.get("nodes"):
return
graph = body.graph
settings = graph.get("settings") or {}
resource_expectations: dict[str, str] = {}
for key, capability in (
("defaultAsrResourceId", "ASR"),
("defaultTtsResourceId", "TTS"),
):
if settings.get(key):
resource_expectations[str(settings[key])] = capability
knowledge_ids: set[str] = set()
for node in graph.get("nodes") or []:
data = node.get("data") or {}
if data.get("asrResourceId"):
resource_expectations[str(data["asrResourceId"])] = "ASR"
if data.get("ttsResourceId"):
resource_expectations[str(data["ttsResourceId"])] = "TTS"
if data.get("knowledgeBaseId"):
knowledge_ids.add(str(data["knowledgeBaseId"]))
for resource_id, capability in resource_expectations.items():
resource = await session.get(ModelResource, resource_id)
if not resource or not resource.enabled or resource.capability != capability:
raise HTTPException(400, f"Workflow 引用了无效的 {capability} 资源:{resource_id}")
for knowledge_id in knowledge_ids:
knowledge = await session.get(KnowledgeBase, knowledge_id)
if not knowledge or knowledge.status != "active":
raise HTTPException(400, f"Workflow 引用了无效知识库:{knowledge_id}")
async def _validate_vision_model(
@@ -101,7 +137,11 @@ async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, s
async def _sync_tool_bindings(
session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str]
) -> None:
requested = list(dict.fromkeys(tool_ids)) if assistant_type == "prompt" else []
requested = (
list(dict.fromkeys(tool_ids))
if assistant_type in {"prompt", "workflow"}
else []
)
if requested:
tools = (
await session.execute(select(Tool).where(Tool.id.in_(requested)))
@@ -158,7 +198,11 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
api_url=assistant.api_url,
api_key=mask(assistant.api_key),
app_id=assistant.app_id,
graph=assistant.graph or {},
graph=(
normalize_graph(assistant.graph or {})
if assistant.type == "workflow"
else {}
),
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
)
@@ -176,6 +220,7 @@ async def create_assistant(
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
):
_validate_workflow(body)
await _validate_workflow_references(session, body)
await _validate_vision_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()
@@ -248,6 +293,7 @@ async def update_assistant(
if not assistant:
raise HTTPException(404, "助手不存在")
_validate_workflow(body)
await _validate_workflow_references(session, body)
await _validate_vision_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()

View File

@@ -15,6 +15,7 @@ from schemas import (
)
from services.auth import require_admin
from services.knowledge import create_document, delete_storage_object, process_document, search
from services.node_specs import graph_references
import settings
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@@ -123,6 +124,14 @@ async def delete_knowledge_base(
)).scalar_one_or_none()
if referenced:
raise HTTPException(409, "知识库正被助手引用,无法删除")
workflows = (
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
).scalars().all()
if any(
kb_id in graph_references(assistant.graph or {})["knowledge_bases"]
for assistant in workflows
):
raise HTTPException(409, "知识库正被 Workflow 节点引用,无法删除")
documents = (await session.execute(
select(KnowledgeDocument).where(KnowledgeDocument.knowledge_base_id == kb_id)
)).scalars().all()

View File

@@ -3,6 +3,7 @@
import uuid
from db.models import (
Assistant,
AssistantModelBinding,
InterfaceDefinition,
KnowledgeBase,
@@ -20,6 +21,7 @@ from services.auth import require_admin
from services.interface_catalog import validate_fields
from services.masking import mask_secrets, merge_secrets
from services.model_resource_tester import test_model_resource
from services.node_specs import graph_references
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -102,6 +104,14 @@ async def _clear_incompatible_references(
) -> None:
if capability == resource.capability:
return
workflows = (
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
).scalars().all()
if any(
resource.id in graph_references(assistant.graph or {})["model_resources"]
for assistant in workflows
):
raise HTTPException(409, "模型资源正被 Workflow 节点引用,不能修改能力类型")
await session.execute(
delete(AssistantModelBinding).where(
AssistantModelBinding.model_resource_id == resource.id
@@ -263,6 +273,14 @@ async def delete_model_resource(
).scalar_one_or_none()
if in_use:
raise HTTPException(409, "该模型资源仍被助手引用")
workflows = (
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
).scalars().all()
if any(
resource_id in graph_references(assistant.graph or {})["model_resources"]
for assistant in workflows
):
raise HTTPException(409, "该模型资源仍被 Workflow 节点引用")
await session.delete(row)
await session.commit()
return {"ok": True}

View File

@@ -138,7 +138,7 @@ class AssistantUpsert(CamelModel):
setattr(self, field, "")
if "graph" not in allowed:
self.graph = {}
if self.type != "prompt":
if self.type not in {"prompt", "workflow"}:
self.tool_ids = []
self.dynamic_variable_definitions = {}
# 外部托管大脑只能 cascade,拦住不兼容的 realtime

View File

@@ -109,6 +109,17 @@ def clear_auth_cookie(response: Response) -> None:
async def require_admin(request: Request) -> AdminUser:
user = verify_admin_token(request.cookies.get(settings.AUTH_COOKIE_NAME))
if not user:
authorization = request.headers.get("authorization", "")
scheme, _, encoded_credentials = authorization.partition(" ")
if scheme.lower() == "basic" and encoded_credentials:
try:
credentials = base64.b64decode(encoded_credentials).decode("utf-8")
username, separator, password = credentials.partition(":")
if separator:
user = authenticate_admin(username, password)
except (ValueError, UnicodeDecodeError):
user = None
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View File

@@ -9,7 +9,7 @@ without coupling brains to Pipecat internals more than necessary.
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
from models import AssistantConfig
@@ -52,6 +52,13 @@ class BrainRuntime:
set_system_prompt: Callable[[str], None]
set_tools: Callable[[list[FunctionSchema] | None], None]
call_end: CallEndPort
worker: Any = None
context_aggregator: Any = None
transport: Any = None
switch_services: Callable[[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)
class BaseBrain:

View File

@@ -2,11 +2,8 @@
from __future__ import annotations
from copy import deepcopy
from urllib.parse import quote
from uuid import uuid4
import httpx
from models import AssistantConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
@@ -20,10 +17,9 @@ from pipecat.utils.time import time_now_iso8601
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
from services.runtime_variables import (
DynamicVariableError,
DynamicVariableStore,
value_at_path,
)
from services.tool_executor import ToolExecutionError, ToolExecutor
class PromptBrain(BaseBrain):
@@ -37,6 +33,7 @@ class PromptBrain(BaseBrain):
self._cfg = cfg
self._dynamic_enabled = True
self._store = DynamicVariableStore.from_config(cfg)
self._tools = ToolExecutor(self._store)
self._runtime: BrainRuntime | None = None
async def greeting(self, cfg: AssistantConfig) -> str:
@@ -85,120 +82,16 @@ class PromptBrain(BaseBrain):
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
def _make_http_tool(self, tool, runtime: BrainRuntime):
config = (tool.definition or {}).get("config") or {}
parameters = list(config.get("parameters") or [])
properties = {
str(parameter.get("name")): {
"type": str(parameter.get("type") or "string"),
"description": str(parameter.get("description") or ""),
}
for parameter in parameters
if parameter.get("name")
}
required = [
str(parameter["name"])
for parameter in parameters
if parameter.get("name") and parameter.get("required", True)
]
tool_secrets = tool.secrets or {}
dynamic_secrets = tool_secrets.get("dynamic_variables") or {}
for name, value in dynamic_secrets.items():
if not str(name).startswith("secret__"):
raise DynamicVariableError(f"工具密钥变量必须以 secret__ 开头: {name}")
self._store.secrets[str(name)] = str(value)
properties, required = self._tools.schema_parts(tool)
self._tools.register_secrets(tool)
async def call_http(params: FunctionCallParams) -> None:
arguments = params.arguments or {}
url = self._store.render(str(config.get("url") or ""))
configured_headers = self._store.render_data(
deepcopy(config.get("headers") or {}), allow_secrets=True
)
secret_headers = self._store.render_data(
deepcopy(tool_secrets.get("headers") or {}), allow_secrets=True
)
headers: dict[str, str] = {}
query: dict[str, object] = {}
body = self._store.render_data(deepcopy(config.get("body") or {}))
for parameter in parameters:
name = str(parameter.get("name") or "")
if not name or name not in arguments:
continue
value = arguments[name]
location = str(parameter.get("location") or "body")
if location == "path":
encoded = quote(str(value), safe="")
url = url.replace(f"{{{name}}}", encoded)
elif location == "query":
query[name] = value
elif location == "header":
headers[name] = str(value)
else:
body[name] = value
# Admin-configured headers win over model-provided arguments; secret
# headers are applied last so an LLM can never replace credentials.
headers.update(
{str(key): str(value) for key, value in configured_headers.items()}
)
headers.update({str(key): str(value) for key, value in secret_headers.items()})
try:
async with httpx.AsyncClient(
timeout=float(config.get("timeout_seconds") or 15),
follow_redirects=False,
) as client:
response = await client.request(
str(config.get("method") or "GET"),
url,
headers=headers,
params=query,
json=body if body else None,
)
response.raise_for_status()
if len(response.content) > 1_000_000:
raise DynamicVariableError("HTTP 工具响应超过 1 MB 限制")
try:
payload = response.json()
except ValueError:
payload = {"text": response.text[:8000]}
updated: list[str] = []
assignments = config.get("dynamic_variable_assignments") or {}
for variable_name, path in assignments.items():
try:
value = value_at_path(payload, str(path))
except KeyError:
try:
value = value_at_path({"response": payload}, str(path))
except KeyError:
continue
self._store.assign(str(variable_name), value)
updated.append(str(variable_name))
if updated:
result = await self._tools.execute(tool, dict(params.arguments or {}))
if result["updated_variables"]:
self._refresh_prompt()
await params.result_callback(
{
"status": "ok",
"status_code": response.status_code,
"data": payload,
"updated_variables": updated,
}
)
except httpx.TimeoutException:
await params.result_callback(
{"status": "error", "message": "HTTP 工具调用超时"}
)
except httpx.HTTPStatusError as exc:
await params.result_callback(
{
"status": "error",
"status_code": exc.response.status_code,
"message": "HTTP 工具返回错误状态",
}
)
except (httpx.RequestError, DynamicVariableError) as exc:
await params.result_callback(result)
except (ToolExecutionError, ValueError) as exc:
await params.result_callback(
{"status": "error", "message": f"HTTP 工具调用失败: {exc}"}
)

View File

@@ -14,7 +14,7 @@ from services.brains.workflow_brain import WorkflowBrain
def _workflow(cfg: AssistantConfig) -> Brain:
return WorkflowBrain(cfg.graph)
return WorkflowBrain(cfg)
BRAIN_FACTORIES: dict[str, Callable[[AssistantConfig], Brain]] = {

View File

@@ -1,27 +1,40 @@
"""Local graph-driven workflow assistant and its per-call state."""
"""Pipecat Flows-backed Workflow v3 brain."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from loguru import logger
from models import AssistantConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
from models import AssistantConfig, RuntimeTool
from db.session import SessionLocal
from pipecat.flows import (
ContextStrategy,
ContextStrategyConfig,
FlowManager,
FlowsFunctionSchema,
NodeConfig,
)
from pipecat.frames.frames import (
LLMUpdateSettingsFrame,
OutputTransportMessageUrgentFrame,
TTSSpeakFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.settings import LLMSettings
from 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
@dataclass
class WorkflowState:
current: str
ended: bool = False
turns_in_node: int = 0
end_turn_id: str | None = None
MAX_AUTOMATIC_HOPS = 50
AGENT_STAGE_INSTRUCTION = (
"完成当前阶段任务。需要流转时必须调用对应的转移函数;"
"不要在调用转移函数后继续生成口头回复。"
)
class WorkflowBrain(BaseBrain):
@@ -30,22 +43,28 @@ class WorkflowBrain(BaseBrain):
supported_runtime_modes=frozenset({"pipeline"}),
owns_context=True,
)
_FALLBACK_AFTER_TURNS = 2
def __init__(self, graph: dict[str, Any]):
def __init__(self, cfg_or_graph: AssistantConfig | dict[str, Any]):
cfg = cfg_or_graph if isinstance(cfg_or_graph, AssistantConfig) else None
graph = cfg.graph if cfg is not None else cfg_or_graph
self._engine = WorkflowEngine(graph or {})
if not self._engine.has_graph() or not self._engine.start_id:
raise ValueError("WorkflowBrain 缺少有效的 startCall 节点")
self._state = WorkflowState(current=self._engine.start_id)
self._history: list[dict[str, str]] = []
self._cfg: AssistantConfig | None = None
raise ValueError("WorkflowBrain 缺少有效的 Start 节点")
self._cfg = cfg
self._store = DynamicVariableStore.from_config(cfg or AssistantConfig(type="workflow"))
self._tools = ToolExecutor(self._store)
self._tool_by_id: dict[str, RuntimeTool] = {
tool.id: tool for tool in (cfg.tools if cfg else [])
}
self._runtime: BrainRuntime | None = None
self._manager: FlowManager | None = None
self._ended = False
async def greeting(self, cfg: AssistantConfig) -> str:
return self._engine.greeting() or cfg.greeting
return self._engine.greeting(self._store) or cfg.greeting
def system_prompt(self, cfg: AssistantConfig) -> str:
return self._engine.system_prompt_for(self._state.current)
return self._store.render(self._engine.global_prompt())
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
from services.pipecat.service_factory import create_llm
@@ -53,72 +72,331 @@ class WorkflowBrain(BaseBrain):
return create_llm(cfg)
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
if runtime.worker is None or runtime.context_aggregator is None:
raise RuntimeError("WorkflowBrain 需要 PipelineWorker 和 context aggregator pair")
self._cfg = cfg
self._runtime = runtime
for edge in self._engine.edges:
if edge.get("target"):
runtime.llm.register_function(
self._engine.edge_fn_name(edge),
self._make_transition_handler(edge),
)
self._apply_node(self._state.current)
logger.info(
f"工作流模式启用: 起始节点={self._engine.name(self._state.current)}"
self._store = DynamicVariableStore.from_config(cfg)
self._tools = ToolExecutor(self._store)
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._manager = FlowManager(
worker=runtime.worker,
llm=runtime.llm,
context_aggregator=runtime.context_aggregator,
transport=runtime.transport,
global_functions=runtime.flow_global_functions,
)
self._manager.state["variables"] = self._store.values
async def on_connected(self) -> None:
await self._emit_node_active(self._state.current)
await self._emit_node_active(self._engine.start_id)
edge = self._engine.deterministic_edge(
self._engine.start_id,
self._store,
include_default=True,
)
if not edge:
raise RuntimeError("Start 初始化后没有命中的表达式边或默认边")
node_config = await self._follow_edge(edge)
if self._manager is None:
raise RuntimeError("Workflow FlowManager 尚未初始化")
await self._manager.initialize(node_config)
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
def record_user_message(self, content: str) -> None:
if content:
self._history.append({"role": "user", "content": content})
async def on_assistant_text_start(self, turn_id: str) -> None:
if self._state.ended and self._state.end_turn_id is None:
self._state.end_turn_id = turn_id
if content and not self._ended:
self._store.record("user", content)
async def on_assistant_text_end(
self,
turn_id: str,
_turn_id: str,
content: str,
interrupted: bool,
) -> None:
if not content or interrupted:
if not content or interrupted or self._ended:
return
self._history.append({"role": "assistant", "content": content})
if turn_id == self._state.end_turn_id:
runtime = self._require_runtime()
runtime.call_end.begin("completed")
runtime.call_end.arm_after_speech()
elif not self._state.ended:
self._state.turns_in_node += 1
await self._fallback_route()
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)
def _apply_node(self, node_id: str) -> None:
async def _refresh_agent_prompt(self, node_id: str) -> None:
runtime = self._require_runtime()
runtime.set_system_prompt(self._engine.system_prompt_for(node_id))
if self._engine.is_end(node_id):
runtime.set_tools([])
return
runtime.set_tools(
[
FunctionSchema(
name=self._engine.edge_fn_name(edge),
description=self._engine.edge_description(edge),
properties={},
required=[],
await runtime.queue_frame(
LLMUpdateSettingsFrame(
delta=LLMSettings(
system_instruction=self._agent_role_message(node_id)
)
for edge in self._engine.outgoing(node_id)
]
)
)
async def _go_to_node(self, target: str) -> None:
self._state.current = target
self._state.turns_in_node = 0
if self._engine.is_end(target):
self._state.ended = True
await self._emit_node_active(target)
self._apply_node(target)
def _agent_role_message(self, node_id: str) -> str:
"""Build one provider-compatible system instruction for an Agent stage."""
stage_prompt = self._engine.prompt_for(node_id, self._store)
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
async def _apply_agent_stage(self, node_id: str) -> None:
data = self._engine.data(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)
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),
}
)
def _agent_config(self, node_id: str) -> NodeConfig:
data = self._engine.data(node_id)
strategy = (
ContextStrategy.RESET
if data.get("contextPolicy") == "fresh"
else ContextStrategy.APPEND
)
functions: list[FlowsFunctionSchema] = []
for tool_id in data.get("toolIds") or []:
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 {
"name": node_id,
"role_message": self._agent_role_message(node_id),
"task_messages": [],
"functions": functions,
"context_strategy": ContextStrategyConfig(strategy=strategy),
"respond_immediately": True,
}
def _terminal_config(self, node_id: str) -> NodeConfig:
return {
"name": node_id,
"role_message": self._store.render(self._engine.global_prompt()),
"task_messages": [],
"functions": [],
"context_strategy": ContextStrategyConfig(strategy=ContextStrategy.APPEND),
"respond_immediately": False,
}
def _flow_tool(self, tool: RuntimeTool, node_id: str) -> FlowsFunctionSchema:
properties, required = self._tools.schema_parts(tool)
self._tools.register_secrets(tool)
async def handler(args, _flow_manager):
try:
result = await self._tools.execute(tool, dict(args or {}))
except ToolExecutionError as exc:
return {"status": "error", "message": str(exc)}
if result.get("updated_variables"):
await self._refresh_agent_prompt(node_id)
edge = self._engine.deterministic_edge(
node_id,
self._store,
include_default=False,
)
if edge:
return result, await self._follow_edge(edge)
return result
return FlowsFunctionSchema(
name=tool.function_name,
description=tool.description or f"调用 {tool.name}",
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,
)
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":
return None
cfg = self._cfg or AssistantConfig(type="workflow")
knowledge = cfg.workflow_knowledge_bases.get(knowledge_id)
description = "在当前 Agent 绑定的知识库中检索资料。"
if knowledge:
description += f"知识库:{knowledge.name}{knowledge.description}"
async def handler(args, _flow_manager):
query = str((args or {}).get("query") or "").strip()
if not query:
return {"status": "error", "message": "检索问题为空"}
try:
async with SessionLocal() as session:
results = await search_knowledge(
session,
knowledge_id,
query,
top_k=int(data.get("knowledgeTopN") or 5),
score_threshold=float(data.get("knowledgeScoreThreshold") or 0.0),
)
return {"status": "ok", "results": results}
except Exception as exc: # noqa: BLE001 - tool errors are returned to the LLM
logger.warning(f"Workflow 知识库检索失败:{exc}")
return {"status": "error", "message": "知识库检索暂时不可用"}
return FlowsFunctionSchema(
name="search_knowledge_base",
description=description,
properties={
"query": {"type": "string", "description": "完整问题或检索关键词"}
},
required=["query"],
handler=handler,
)
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)
)
return await self._resolve_path(str(edge.get("target") or ""))
async def _resolve_path(self, node_id: str) -> NodeConfig:
for _ in range(MAX_AUTOMATIC_HOPS):
node_type = self._engine.node_type(node_id)
if node_type == "agent":
await self._apply_agent_stage(node_id)
return self._agent_config(node_id)
if node_type == "end":
await self._enter_end(node_id)
return self._terminal_config(node_id)
if node_type == "action":
await self._enter_action(node_id)
elif node_type == "handoff":
await self._enter_handoff(node_id)
elif node_type == "start":
await self._emit_node_active(node_id)
else:
raise RuntimeError(f"工作流指向未知节点:{node_id}")
edge = self._engine.deterministic_edge(
node_id,
self._store,
include_default=True,
)
if not edge:
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)
)
node_id = str(edge.get("target") or "")
raise RuntimeError("工作流连续自动跳转超过安全上限")
async def _enter_action(self, node_id: str) -> None:
await self._emit_node_active(node_id)
data = self._engine.data(node_id)
tool_id = str(data.get("toolId") or "")
tool = self._tool_by_id.get(tool_id)
if not tool:
self._store.values["system__last_action_status"] = "error"
self._store.values["system__last_action_error"] = f"工具不存在:{tool_id}"
return
try:
arguments = self._store.render_data(data.get("arguments") or {})
await self._tools.execute(
tool,
arguments,
result_assignments=data.get("resultAssignments") or {},
)
self._store.values["system__last_action_status"] = "ok"
self._store.values["system__last_action_error"] = ""
except (ToolExecutionError, ValueError) as exc:
self._store.values["system__last_action_status"] = "error"
self._store.values["system__last_action_error"] = str(exc)[:2048]
async def _enter_handoff(self, node_id: str) -> None:
await self._emit_node_active(node_id)
data = self._engine.data(node_id)
message = self._store.render(str(data.get("message") or ""))
await self._require_runtime().queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "handoff-requested",
"nodeId": node_id,
"targetType": data.get("targetType", "human"),
"target": data.get("target", ""),
"message": message,
}
)
)
if message:
await self._require_runtime().queue_frame(
TTSSpeakFrame(message, append_to_context=False)
)
self._store.values["system__handoff_status"] = "requested"
async def _enter_end(self, node_id: str) -> None:
self._ended = True
await self._emit_node_active(node_id)
runtime = self._require_runtime()
if runtime.set_knowledge_scope:
runtime.set_knowledge_scope({"mode": "disabled"})
if runtime.set_input_enabled:
runtime.set_input_enabled(False)
data = self._engine.data(node_id)
message = self._store.render(str(data.get("message") or ""))
scope = str(data.get("scope") or "session")
if scope == "flow":
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={"type": "flow-ended", "nodeId": node_id}
)
)
if message:
await runtime.queue_frame(TTSSpeakFrame(message, append_to_context=False))
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))
else:
await runtime.call_end.finish()
async def _emit_node_active(self, node_id: str | None) -> None:
if node_id:
@@ -128,61 +406,12 @@ class WorkflowBrain(BaseBrain):
)
)
async def _speak_transition(self, edge: dict | None) -> None:
speech = self._engine.edge_transition_speech(edge)
if speech:
await self._require_runtime().queue_frame(
TTSSpeakFrame(speech, append_to_context=False)
)
def _make_transition_handler(self, edge: dict):
target = str(edge.get("target"))
async def handler(params) -> None:
logger.info(f"LLM 触发转移 → {self._engine.name(target)}")
if not self._engine.is_end(target):
await self._speak_transition(edge)
await self._go_to_node(target)
await params.result_callback({"status": "ok"})
return handler
async def _fallback_route(self) -> None:
if self._state.ended:
return
if self._state.turns_in_node < self._FALLBACK_AFTER_TURNS:
return
if not self._engine.outgoing(self._state.current):
return
cfg = self._require_config()
target = await self._engine.route(
self._state.current,
self._history,
api_key=self._require(cfg.llm_api_key, "LLM apiKey"),
base_url=self._require(cfg.llm_base_url, "LLM apiUrl"),
model=self._require(cfg.model, "LLM modelId"),
)
if target and target != self._state.current:
logger.info(f"文本兜底触发转移 → {self._engine.name(target)}")
if not self._engine.is_end(target):
await self._speak_transition(
self._engine.find_edge(self._state.current, target)
)
await self._go_to_node(target)
def _require_runtime(self) -> BrainRuntime:
if self._runtime is None:
raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime")
return self._runtime
def _require_config(self) -> AssistantConfig:
if self._cfg is None:
raise RuntimeError("WorkflowBrain 尚未初始化配置")
return self._cfg
@staticmethod
def _require(value: str, label: str) -> str:
if value:
return value
raise ValueError(f"缺少模型资源配置: {label}")
def _require_manager(self) -> FlowManager:
if self._manager is None:
raise RuntimeError("Workflow FlowManager 尚未初始化")
return self._manager

View File

@@ -12,7 +12,13 @@ from db.models import (
ModelResource,
Tool,
)
from models import AssistantConfig, RuntimeTool
from models import (
AssistantConfig,
RuntimeKnowledgeBase,
RuntimeModelResource,
RuntimeTool,
)
from services.node_specs import graph_references, normalize_graph
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -83,7 +89,7 @@ def _secret(resource: ModelResource | None, key: str, default: str = "") -> str:
async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[RuntimeTool]:
if assistant.type != "prompt":
if assistant.type not in {"prompt", "workflow"}:
return []
tools = (
await session.execute(
@@ -134,6 +140,43 @@ async def resolve_runtime_config(
else None
)
graph = normalize_graph(assistant.graph or {}) if assistant.type == "workflow" else {}
refs = graph_references(graph) if graph else {
"model_resources": set(),
"knowledge_bases": set(),
}
workflow_resources: dict[str, RuntimeModelResource] = {}
if refs["model_resources"]:
resources = (
await session.execute(
select(ModelResource).where(ModelResource.id.in_(refs["model_resources"]))
)
).scalars().all()
workflow_resources = {
resource.id: RuntimeModelResource(
id=resource.id,
name=resource.name,
capability=resource.capability,
interface_type=resource.interface_type,
values=resource.values or {},
secrets=resource.secrets or {},
)
for resource in resources
if resource.enabled
}
workflow_knowledge: dict[str, RuntimeKnowledgeBase] = {}
if refs["knowledge_bases"]:
knowledge_rows = (
await session.execute(
select(KnowledgeBase).where(KnowledgeBase.id.in_(refs["knowledge_bases"]))
)
).scalars().all()
workflow_knowledge = {
kb.id: RuntimeKnowledgeBase(id=kb.id, name=kb.name, description=kb.description)
for kb in knowledge_rows
if kb.status == "active"
}
return AssistantConfig(
name=assistant.name,
type=assistant.type,
@@ -150,7 +193,9 @@ async def resolve_runtime_config(
knowledge_base_description=knowledge_base.description if knowledge_base else "",
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
graph=(assistant.graph or {}) if assistant.type == "workflow" else {},
graph=graph,
workflow_model_resources=workflow_resources,
workflow_knowledge_bases=workflow_knowledge,
# 外部托管类型连接信息(DB 存真 key,直接注入)
dify_api_url=str(_value(agent_resource, "apiUrl", assistant.api_url)),
dify_api_key=_secret(agent_resource, "apiKey", assistant.api_key),

View File

@@ -1,132 +1,108 @@
"""工作流节点规格 + 图校验(对齐 dograh 的 node-spec / GraphConstraints 思路)。
当前实现 4 个核心节点:开始(startCall)/智能体(agentNode)/结束(endCall)/全局(globalNode)。
本模块是「节点类型」的唯一事实源:
- /api/node-types 接口直接吐这里的规格;
- 助手保存时用这里的约束校验 workflow 图。
新增节点类型只需在 NODE_SPECS 里加一条并补充约束。前端 specs.ts 与此保持一致。
"""
"""Workflow v3 node catalog, v2 compatibility normalization, and validation."""
from __future__ import annotations
from collections import defaultdict, deque
from copy import deepcopy
from typing import Any
# 规格版本号:节点定义有破坏性变更时 +1,前端可据此判断是否需要刷新缓存。
SPEC_VERSION = "2"
# 每个节点的图约束。None 表示不限制。
# min_incoming / max_incoming:入边数量
# min_outgoing / max_outgoing:出边数量
SPEC_VERSION = "3"
NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
EDGE_MODES = {"llm", "expression", "always"}
EXPRESSION_OPERATORS = {
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"in",
"exists",
}
NODE_SPECS: list[dict[str, Any]] = [
{
"name": "startCall",
"displayName": "开始",
"category": "call_node",
"description": "工作流入口,每个流程有且仅有一个。播放开场白,并用自己的提示词进行多轮对话,满足出边条件后流转",
"name": "start",
"displayName": "Start",
"category": "control_node",
"description": "初始化会话、动态变量和全局观察器,可播放固定开场白",
"icon": "Play",
"accent": "mint",
"addable": False,
"constraints": {
"minIncoming": 0,
"maxIncoming": 0,
"minOutgoing": 1,
"minInstances": 1,
"maxInstances": 1,
},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "开始"},
{"key": "greeting", "label": "开场白", "type": "textarea", "default": ""},
{"key": "prompt", "label": "节点提示词", "type": "textarea", "default": ""},
{
"key": "allowInterrupt",
"label": "允许用户打断",
"type": "switch",
"default": True,
},
{
"key": "addGlobalPrompt",
"label": "应用全局提示词",
"type": "switch",
"default": True,
},
{"key": "name", "label": "节点名称", "type": "text", "default": "Start"},
{"key": "greeting", "label": "固定开场白", "type": "textarea", "default": ""},
],
},
{
"name": "agentNode",
"displayName": "智能体节点",
"category": "call_node",
"description": "对话处理单元。按提示词与用户多轮交互,可有多个并通过条件边流转",
"name": "agent",
"displayName": "Agent",
"category": "conversation_node",
"description": "阶段智能体:绑定上下文、工具、知识库及 ASR/TTS 资源",
"icon": "Bot",
"accent": "sky",
"addable": True,
"constraints": {"minIncoming": 1},
"constraints": {"minIncoming": 1, "minOutgoing": 1},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "智能体节点"},
{"key": "name", "label": "节点名称", "type": "text", "default": "Agent"},
{
"key": "prompt",
"label": "节点提示词",
"label": "阶段提示词",
"type": "textarea",
"required": True,
"default": "",
},
{
"key": "allowInterrupt",
"label": "允许用户打断",
"type": "switch",
"default": True,
},
{
"key": "addGlobalPrompt",
"label": "应用全局提示词",
"type": "switch",
"default": True,
},
],
},
{
"name": "endCall",
"displayName": "结束",
"category": "call_node",
"description": "终止节点,礼貌结束对话。可有多个,均无出边",
"name": "action",
"displayName": "Action",
"category": "execution_node",
"description": "确定性执行指定工具,并将结果字段写入会话动态变量",
"icon": "Zap",
"accent": "peach",
"addable": True,
"constraints": {"minIncoming": 1, "minOutgoing": 1},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "Action"},
],
},
{
"name": "handoff",
"displayName": "Handoff",
"category": "execution_node",
"description": "转交其他 AI、人工、队列或电话MVP 发送转交事件后继续路由。",
"icon": "PhoneForwarded",
"accent": "lavender",
"addable": True,
"constraints": {"minIncoming": 1, "minOutgoing": 1},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "Handoff"},
{"key": "target", "label": "转交目标", "type": "text", "default": ""},
{"key": "message", "label": "转交提示", "type": "textarea", "default": ""},
],
},
{
"name": "end",
"displayName": "End",
"category": "control_node",
"description": "结束 AI 流程或整个音视频会话。",
"icon": "Flag",
"accent": "rose",
"addable": True,
"constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "结束"},
{"key": "prompt", "label": "结束语提示词", "type": "textarea", "default": ""},
{
"key": "addGlobalPrompt",
"label": "应用全局提示词",
"type": "switch",
"default": False,
},
],
},
{
"name": "globalNode",
"displayName": "全局节点",
"category": "global_node",
"description": "为整个工作流提供统一的人设、语气和公共规则。无需连线,每个流程最多一个。",
"icon": "Globe2",
"accent": "lavender",
"addable": True,
"constraints": {
"minIncoming": 0,
"maxIncoming": 0,
"minOutgoing": 0,
"maxOutgoing": 0,
"maxInstances": 1,
},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "全局设定"},
{
"key": "prompt",
"label": "全局提示词",
"type": "textarea",
"required": True,
"default": "你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
},
{"key": "name", "label": "节点名称", "type": "text", "default": "End"},
{"key": "message", "label": "固定结束语", "type": "textarea", "default": ""},
],
},
]
@@ -135,108 +111,303 @@ _SPEC_BY_NAME = {spec["name"]: spec for spec in NODE_SPECS}
def node_types_response() -> dict[str, Any]:
"""/api/node-types 的响应体(camelCase,直接喂前端)。"""
return {"specVersion": SPEC_VERSION, "nodeTypes": NODE_SPECS}
def validate_graph(graph: dict[str, Any]) -> list[str]:
"""校验 workflow 图,返回错误信息列表(空列表 = 通过)。
def _edge_data_v3(edge: dict, source_type: str) -> dict:
data = deepcopy(edge.get("data") or {})
if data.get("mode") in EDGE_MODES:
data.setdefault("priority", 10)
return data
condition = str(data.pop("condition", "") or "").strip()
transition = data.pop("transition_speech", data.get("transitionSpeech", ""))
data.update(
{
"mode": "llm" if condition and source_type == "agent" else "always",
"priority": 10,
"condition": condition,
"transitionSpeech": transition,
}
)
return data
基础规则(对齐 dograh 的核心不变量):
1. 节点类型必须是已注册类型;
2. 有且仅有一个 startCall;
3. 至少有一个 endCall,全局节点最多一个;
4. 边的 source/target 必须指向存在的节点;
5. 入边/出边数量满足各节点类型的约束。
空图(无节点)视为草稿,直接放行,方便先存后编排。
"""
errors: list[str] = []
nodes = graph.get("nodes") or []
edges = graph.get("edges") or []
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", {})
source.setdefault("nodes", [])
source.setdefault("edges", [])
return source
if not nodes:
return errors # 草稿:放行
node_ids: set[str] = set()
type_counts: dict[str, int] = {}
node_type_by_id: dict[str, str] = {}
nodes = source.get("nodes") or []
edges = source.get("edges") or []
global_prompt = ""
mapped_nodes: list[dict] = []
type_by_id: dict[str, str] = {}
start_prompt_nodes: dict[str, str] = {}
type_map = {
"startCall": "start",
"agentNode": "agent",
"endCall": "end",
"start": "start",
"agent": "agent",
"action": "action",
"handoff": "handoff",
"end": "end",
}
for node in nodes:
node_id = node.get("id")
node_type = node.get("type")
if not node_id:
errors.append("存在缺少 id 的节点")
old_type = str(node.get("type") or "")
data = deepcopy(node.get("data") or {})
if old_type == "globalNode":
global_prompt = str(data.get("prompt") or "")
continue
if node_id in node_ids:
errors.append(f"节点 id 重复:{node_id}")
node_ids.add(node_id)
if node_type not in _SPEC_BY_NAME:
errors.append(f"未知节点类型:{node_type}(节点 {node_id})")
continue
node_type_by_id[node_id] = node_type
type_counts[node_type] = type_counts.get(node_type, 0) + 1
new_type = type_map.get(old_type, old_type)
migrated = deepcopy(node)
migrated["type"] = new_type
if new_type == "end":
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")
elif new_type == "start":
prompt = str(data.pop("prompt", "") or "").strip()
if prompt:
start_prompt_nodes[str(node.get("id"))] = prompt
for key in ("allowInterrupt", "addGlobalPrompt"):
data.pop(key, None)
migrated["data"] = data
mapped_nodes.append(migrated)
if migrated.get("id"):
type_by_id[str(migrated["id"])] = new_type
start_count = type_counts.get("startCall", 0)
if start_count == 0:
errors.append("工作流必须有一个「开始」节点")
elif start_count > 1:
errors.append("工作流只能有一个「开始」节点")
mapped_edges: list[dict] = []
for edge in edges:
migrated = deepcopy(edge)
migrated["data"] = _edge_data_v3(
migrated, type_by_id.get(str(migrated.get("source")), "")
)
mapped_edges.append(migrated)
if type_counts.get("endCall", 0) == 0:
errors.append("工作流至少需要一个「结束」节点")
for node_type, spec in _SPEC_BY_NAME.items():
# 开始节点上方已有更明确的中文错误提示,避免重复报错。
if node_type == "startCall":
continue
constraints = spec["constraints"]
count = type_counts.get(node_type, 0)
_check_count(
errors,
count,
constraints,
"Instances",
node_type,
"实例",
# A v2 Start was conversational. Insert a synthetic Agent so its prompt remains active.
for start_id, prompt in start_prompt_nodes.items():
synthetic_id = f"{start_id}-migrated-agent"
start_node = next((n for n in mapped_nodes if n.get("id") == start_id), None)
position = (start_node or {}).get("position") or {"x": 100, "y": 120}
mapped_nodes.append(
{
"id": synthetic_id,
"type": "agent",
"position": {"x": position.get("x", 100) + 300, "y": position.get("y", 120)},
"data": {
"name": "迁移的开场 Agent",
"prompt": prompt,
"contextPolicy": "inherit",
"toolIds": [],
"knowledgeMode": "disabled",
},
}
)
for edge in mapped_edges:
if edge.get("source") == start_id:
edge["source"] = synthetic_id
edge["data"] = _edge_data_v3(edge, "agent")
if str(edge["data"].get("condition") or "").strip():
edge["data"]["mode"] = "llm"
mapped_edges.append(
{
"id": f"e-{start_id}-{synthetic_id}",
"source": start_id,
"target": synthetic_id,
"data": {"mode": "always", "priority": 0, "transitionSpeech": ""},
}
)
# 统计入边/出边
incoming: dict[str, int] = {nid: 0 for nid in node_ids}
outgoing: dict[str, int] = {nid: 0 for nid in node_ids}
for edge in edges:
source = edge.get("source")
target = edge.get("target")
if source not in node_ids:
errors.append(f"连线指向了不存在的源节点:{source}")
continue
if target not in node_ids:
errors.append(f"连线指向了不存在的目标节点:{target}")
continue
outgoing[source] += 1
incoming[target] += 1
settings = deepcopy(source.get("settings") or {})
settings.setdefault("globalPrompt", global_prompt)
settings.setdefault("defaultAsrResourceId", "")
settings.setdefault("defaultTtsResourceId", "")
return {
"specVersion": 3,
"settings": settings,
"nodes": mapped_nodes,
"edges": mapped_edges,
**({"viewport": deepcopy(source["viewport"])} if source.get("viewport") else {}),
}
for node_id, node_type in node_type_by_id.items():
constraints = _SPEC_BY_NAME[node_type]["constraints"]
name = node_type
_check_count(errors, incoming[node_id], constraints, "Incoming", name, "入边")
_check_count(errors, outgoing[node_id], constraints, "Outgoing", name, "出边")
def _validate_expression(expression: Any) -> list[str]:
if not isinstance(expression, dict):
return ["表达式条件不能为空"]
combinator = expression.get("combinator", "and")
if combinator not in {"and", "or"}:
return ["表达式组合方式必须是 and 或 or"]
rules = expression.get("rules")
if not isinstance(rules, list) or not rules:
return ["表达式至少需要一条规则"]
errors = []
for rule in rules:
if not isinstance(rule, dict) or not rule.get("variable"):
errors.append("表达式规则缺少变量")
elif rule.get("operator") not in EXPRESSION_OPERATORS:
errors.append(f"不支持的表达式运算符:{rule.get('operator')}")
return errors
def _check_count(
errors: list[str],
actual: int,
constraints: dict[str, int],
suffix: str,
node_type: str,
label: str,
) -> None:
lo = constraints.get(f"min{suffix}")
hi = constraints.get(f"max{suffix}")
display = _SPEC_BY_NAME[node_type]["displayName"]
if lo is not None and actual < lo:
errors.append(f"{display}」节点{label}数量不能少于 {lo}(当前 {actual})")
if hi is not None and actual > hi:
errors.append(f"{display}」节点{label}数量不能多于 {hi}(当前 {actual})")
def validate_graph(graph: dict[str, Any]) -> list[str]:
graph = normalize_graph(graph)
nodes = graph.get("nodes") or []
edges = graph.get("edges") or []
if not nodes:
return []
errors: list[str] = []
node_by_id: dict[str, dict] = {}
counts: dict[str, int] = defaultdict(int)
for node in nodes:
node_id = str(node.get("id") or "")
node_type = str(node.get("type") or "")
if not node_id:
errors.append("存在缺少 id 的节点")
continue
if node_id in node_by_id:
errors.append(f"节点 id 重复:{node_id}")
if node_type not in NODE_TYPES:
errors.append(f"未知节点类型:{node_type}(节点 {node_id})")
node_by_id[node_id] = node
counts[node_type] += 1
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)
auto_adj: dict[str, list[str]] = defaultdict(list)
priorities: dict[str, set[int]] = defaultdict(set)
always_counts: dict[str, int] = defaultdict(int)
for edge in edges:
edge_id = str(edge.get("id") or "")
source_id = str(edge.get("source") or "")
target_id = str(edge.get("target") or "")
if source_id not in node_by_id:
errors.append(f"{edge_id} 指向不存在的源节点:{source_id}")
continue
if target_id not in node_by_id:
errors.append(f"{edge_id} 指向不存在的目标节点:{target_id}")
continue
if source_id == target_id and node_by_id[source_id].get("type") != "agent":
errors.append(f"自动节点不能自连:{source_id}")
data = edge.get("data") or {}
mode = data.get("mode")
if mode not in EDGE_MODES:
errors.append(f"{edge_id} 的判断模式无效:{mode}")
if mode == "llm" and node_by_id[source_id].get("type") != "agent":
errors.append(f"LLM 判断边只能从 Agent 发出:{edge_id}")
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")))
try:
priority = int(data.get("priority", 10))
except (TypeError, ValueError):
errors.append(f"{edge_id} 的优先级必须是整数")
priority = 10
if priority in priorities[source_id]:
errors.append(f"节点 {source_id} 的出边优先级不能重复:{priority}")
priorities[source_id].add(priority)
if mode == "always":
always_counts[source_id] += 1
if always_counts[source_id] > 1:
errors.append(f"节点 {source_id} 最多只能有一条默认边")
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":
auto_adj[source_id].append(target_id)
for node_id, node in node_by_id.items():
spec = _SPEC_BY_NAME.get(str(node.get("type")))
if not spec:
continue
constraints = spec["constraints"]
for actual, suffix, label in (
(incoming[node_id], "Incoming", "入边"),
(outgoing[node_id], "Outgoing", "出边"),
):
lo = constraints.get(f"min{suffix}")
hi = constraints.get(f"max{suffix}")
if lo is not None and actual < lo:
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} 必须有且仅有一条默认边")
start_id = next((nid for nid, n in node_by_id.items() if n.get("type") == "start"), None)
if start_id:
reached = {start_id}
queue = deque([start_id])
while queue:
current = queue.popleft()
for target in adj[current]:
if target not in reached:
reached.add(target)
queue.append(target)
for node_id in node_by_id.keys() - reached:
errors.append(f"节点不可从 Start 到达:{node_id}")
# Reject cycles made only of instantaneous nodes; Agent cycles are valid waits.
visiting: set[str] = set()
visited: set[str] = set()
def visit(node_id: str) -> bool:
if node_id in visiting:
return True
if node_id in visited:
return False
visiting.add(node_id)
for target in auto_adj[node_id]:
if visit(target):
return True
visiting.remove(node_id)
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"):
errors.append("Start/Action/Handoff/End 之间不能形成无等待循环")
return list(dict.fromkeys(errors))
def graph_references(graph: dict[str, Any]) -> dict[str, set[str]]:
"""Collect externally referenced IDs for save/runtime validation."""
normalized = normalize_graph(graph)
settings = normalized.get("settings") or {}
resources = {
str(value)
for value in (
settings.get("defaultAsrResourceId"),
settings.get("defaultTtsResourceId"),
)
if value
}
tools: set[str] = set()
knowledge: set[str] = 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))
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}

View File

@@ -23,6 +23,7 @@ from services.pipecat.call_lifecycle import (
EndCallAfterSpeechProcessor,
)
from services.pipecat.service_factory import (
config_with_resource,
create_realtime_service,
create_stt,
create_tts,
@@ -32,6 +33,7 @@ from services.knowledge import search as search_knowledge
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.flows import FlowsFunctionSchema
from pipecat.frames.frames import (
EndFrame,
InputTransportMessageFrame,
@@ -40,6 +42,7 @@ from pipecat.frames.frames import (
LLMFullResponseStartFrame,
LLMContextFrame,
LLMTextFrame,
ManuallySwitchServiceFrame,
LLMMessagesAppendFrame,
OutputTransportMessageUrgentFrame,
TextFrame,
@@ -48,6 +51,7 @@ from pipecat.frames.frames import (
UserImageRequestFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.service_switcher import ServiceSwitcher
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
@@ -397,15 +401,59 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
self._knowledge_base_id = knowledge_base_id
self._top_n = top_n
self._score_threshold = score_threshold
self._mode = "automatic" if knowledge_base_id else "disabled"
self._last_signature = ""
def set_scope(self, scope: dict) -> None:
self._knowledge_base_id = scope.get("knowledge_base_id") or None
self._mode = str(scope.get("mode") or "disabled")
self._top_n = int(scope.get("top_n") or 5)
self._score_threshold = float(scope.get("score_threshold") or 0.0)
self._last_signature = ""
def _clear_context(self, messages: list[dict]) -> None:
# Remove the legacy Workflow knowledge message so an in-flight context
# created before this compatibility fix cannot keep sending that role.
messages[:] = [
message
for message in messages
if not (
message.get("role") == "developer"
and KNOWLEDGE_CONTEXT_MARKER in str(message.get("content") or "")
)
]
system_message = next(
(message for message in messages if message.get("role") == "system"),
None,
)
if system_message is not None:
content = str(system_message.get("content") or "")
system_message["content"] = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
def _set_context(self, messages: list[dict], block: str) -> None:
"""Store retrieved knowledge in a provider-compatible system message."""
self._clear_context(messages)
system_message = next(
(message for message in messages if message.get("role") == "system"),
None,
)
if system_message is None:
messages.insert(0, {"role": "system", "content": block})
return
content = str(system_message.get("content") or "").rstrip()
system_message["content"] = f"{content}\n\n{block}" if content else block
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if not self._knowledge_base_id or not isinstance(frame, LLMContextFrame):
if not isinstance(frame, LLMContextFrame):
await self.push_frame(frame, direction)
return
messages = frame.context.get_messages()
if self._mode != "automatic" or not self._knowledge_base_id:
self._clear_context(messages)
await self.push_frame(frame, direction)
return
user_messages = [message for message in messages if message.get("role") == "user"]
if not user_messages:
await self.push_frame(frame, direction)
@@ -435,13 +483,7 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
for index, item in enumerate(results)
) or "未检索到相关资料。"
block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}"
system_message = next((message for message in messages if message.get("role") == "system"), None)
if system_message is None:
messages.insert(0, {"role": "system", "content": block})
else:
content = str(system_message.get("content") or "")
base = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
system_message["content"] = f"{base}\n\n{block}" if base else block
self._set_context(messages, block)
await self.push_frame(frame, direction)
@@ -457,7 +499,6 @@ class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
self._stream_turn_id: str | None = None
self._stream_timestamp = ""
self._stream_text = ""
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
@@ -505,6 +546,49 @@ class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
self._stream_text = ""
class WorkflowAggregatorPair:
"""Small public-shape adapter required by Pipecat FlowManager."""
def __init__(self, user_aggregator, assistant_aggregator):
self._user = user_aggregator
self._assistant = assistant_aggregator
def user(self):
return self._user
def assistant(self):
return self._assistant
def _workflow_service_switcher(
cfg: AssistantConfig, capability: str, base_service: FrameProcessor
):
"""Build one switcher and an ID lookup for every referenced voice resource."""
create = create_stt if capability == "ASR" else create_tts
settings = cfg.graph.get("settings") or {}
default_key = (
"defaultAsrResourceId" if capability == "ASR" else "defaultTtsResourceId"
)
default_id = str(settings.get(default_key) or "")
services_by_id = {}
for resource_id, resource in cfg.workflow_model_resources.items():
if resource.capability != capability:
continue
services_by_id[resource_id] = (
base_service
if resource_id == default_id
else create(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 ServiceSwitcher(services=services), services_by_id, primary
async def run_pipeline(
transport,
cfg: AssistantConfig,
@@ -545,8 +629,35 @@ async def run_pipeline(
)
return
stt = create_stt(cfg)
tts = create_tts(cfg)
graph_settings = cfg.graph.get("settings") or {}
default_asr_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultAsrResourceId") or "")
)
default_tts_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultTtsResourceId") or "")
)
stt = create_stt(
config_with_resource(cfg, default_asr_resource)
if cfg.type == "workflow" and default_asr_resource
else cfg
)
tts = create_tts(
config_with_resource(cfg, default_tts_resource)
if cfg.type == "workflow" and default_tts_resource
else cfg
)
stt_processor = stt
tts_processor = tts
stt_services: dict[str, FrameProcessor] = {}
tts_services: dict[str, FrameProcessor] = {}
current_voice_services: dict[str, FrameProcessor] = {"asr": stt, "tts": tts}
if cfg.type == "workflow":
stt_processor, stt_services, current_voice_services["asr"] = (
_workflow_service_switcher(cfg, "ASR", stt)
)
tts_processor, tts_services, current_voice_services["tts"] = (
_workflow_service_switcher(cfg, "TTS", tts)
)
greeting = await brain.greeting(cfg)
system_content = brain.system_prompt(cfg)
@@ -594,8 +705,13 @@ async def run_pipeline(
return "\n\n".join(part for part in [text, *hints] if part)
context = LLMContext(
messages=[{"role": "system", "content": with_vision_hint(system_content)}]
messages=(
[]
if cfg.type == "workflow"
else [{"role": "system", "content": with_vision_hint(system_content)}]
)
)
input_state = {"enabled": True}
# LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
llm = brain.build_llm(cfg, context)
user_aggregator = LLMUserAggregator(
@@ -604,7 +720,9 @@ async def run_pipeline(
vad_analyzer=create_vad_analyzer(cfg.turnConfig),
user_mute_strategies=[
FunctionCallUserMuteStrategy(),
CallEndingUserMuteStrategy(lambda: call_end.ending),
CallEndingUserMuteStrategy(
lambda: call_end.ending or not input_state["enabled"]
),
],
user_turn_strategies=create_user_turn_strategies(
cfg.turnConfig,
@@ -613,7 +731,9 @@ async def run_pipeline(
),
)
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
text_input = TextInputProcessor(should_ignore_input=lambda: call_end.ending)
text_input = TextInputProcessor(
should_ignore_input=lambda: call_end.ending or not input_state["enabled"]
)
vision_capture = VisionCaptureProcessor()
knowledge_retrieval = KnowledgeRetrievalProcessor(
automatic_knowledge_id,
@@ -723,14 +843,54 @@ async def run_pipeline(
}
)
if vision_enabled:
flow_global_functions = []
if cfg.type == "workflow" and vision_enabled:
async def flow_fetch_user_image(args, _flow_manager):
question = str((args or {}).get("question") or "请描述当前画面。")
user_id = vision_state.get("client_id")
if not user_id:
return {
"status": "no_video_client",
"message": "当前还没有可用的摄像头视频流。",
}
request = UserImageRequestFrame(
user_id=user_id,
text=question,
append_to_context=False,
function_name=VISION_TOOL_NAME,
)
try:
frame = await vision_capture.request_image(llm, request)
observation = await _analyze_image_with_vision_model(cfg, frame, question)
return {
"status": "ok",
"question": question,
"observation": observation or "视觉模型没有返回有效观察结果。",
}
except asyncio.TimeoutError:
return {"status": "timeout", "message": "等待摄像头视频帧超时。"}
except Exception as exc: # noqa: BLE001 - return tool errors to the LLM
logger.warning(f"Workflow 视觉理解失败:{exc}")
return {"status": "error", "message": "视觉理解暂时不可用。"}
flow_global_functions.append(
FlowsFunctionSchema(
name=VISION_TOOL_NAME,
description=vision_schema.description,
properties=vision_schema.properties,
required=vision_schema.required,
handler=flow_fetch_user_image,
)
)
if vision_enabled and cfg.type != "workflow":
llm.register_function(VISION_TOOL_NAME, fetch_user_image)
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge)
def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None:
tools = list(schemas or [])
if vision_enabled:
if vision_enabled and cfg.type != "workflow":
tools.append(vision_schema)
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
tools.append(knowledge_schema)
@@ -751,7 +911,7 @@ async def run_pipeline(
transport.input(),
vision_capture,
text_input,
stt,
stt_processor,
user_aggregator,
knowledge_retrieval,
llm,
@@ -759,7 +919,7 @@ async def run_pipeline(
# Pipecat commits the generated prefix immediately instead of
# waiting for a TTS provider to emit spoken-text/timestamp frames.
assistant_aggregator,
tts,
tts_processor,
EndCallAfterSpeechProcessor(call_end),
ConversationHistoryProcessor(recorder),
transport.output(),
@@ -774,6 +934,33 @@ async def run_pipeline(
enable_rtvi=False,
)
worker_holder["worker"] = worker
default_voice_services = dict(current_voice_services)
async def switch_workflow_services(
asr_resource_id: str | None,
tts_resource_id: str | None,
) -> None:
requested = (
("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]
if target is None:
raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
if current_voice_services[kind] is target:
continue
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
current_voice_services[kind] = target
await worker.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "service-switched",
"capability": kind.upper(),
"resourceId": resource_id,
}
)
)
async def queue_transcript(role: str, content: str, timestamp: str) -> None:
if content:
@@ -810,6 +997,16 @@ async def run_pipeline(
set_system_prompt=set_system_prompt,
set_tools=set_visible_tools,
call_end=call_end,
worker=worker,
context_aggregator=WorkflowAggregatorPair(
user_aggregator,
assistant_aggregator,
),
transport=transport,
switch_services=switch_workflow_services,
set_knowledge_scope=knowledge_retrieval.set_scope,
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled),
flow_global_functions=flow_global_functions,
),
)

View File

@@ -5,7 +5,7 @@
"""
from loguru import logger
from models import AssistantConfig
from models import AssistantConfig, RuntimeModelResource
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.openai.stt import OpenAISTTService
@@ -26,6 +26,35 @@ from services.pipecat.xfyun_tts import DEFAULT_XFYUN_TTS_URL, XfyunTTSService
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."""
result = cfg.model_copy(deep=True)
values = resource.values or {}
secrets = resource.secrets or {}
if 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
result.stt_values = values
result.stt_secrets = secrets
result.stt_api_key = str(secrets.get("apiKey") or "")
result.stt_base_url = str(values.get("apiUrl") or "")
elif resource.capability == "TTS":
result.tts_model = str(values.get("modelId") or "")
result.voice = str(values.get("voice") or "")
result.tts_speed = float(values.get("speed") or 1.0)
result.tts_interface_type = resource.interface_type
result.tts_values = values
result.tts_secrets = secrets
result.tts_api_key = str(secrets.get("apiKey") or "")
result.tts_base_url = str(values.get("apiUrl") or "")
else:
raise ValueError(f"工作流语音资源能力无效:{resource.capability}")
return result
def _require(value: str, label: str) -> str:
if value:
return value

View File

@@ -175,9 +175,9 @@ def prepare_dynamic_config(
trusted_values: dict[str, Any] | None = None,
) -> AssistantConfig:
"""Validate and merge one call's values without mutating stored config."""
if cfg.type != "prompt":
if cfg.type not in {"prompt", "workflow"}:
if client_values:
raise DynamicVariableError("动态变量目前仅支持 prompt 助手")
raise DynamicVariableError("动态变量仅支持 prompt 和 workflow 助手")
return cfg
supplied = _validate_public(client_values)
@@ -214,9 +214,13 @@ def prepare_dynamic_config(
prepared = cfg.model_copy(deep=True)
prepared.dynamic_variables = merged
prepared.conversation_id = conversation_id
# Validate prompt and greeting before media resources are allocated.
# Validate top-level prompt/greeting before media resources are allocated.
# Workflow node templates are rendered lazily as their nodes become active.
store = DynamicVariableStore.from_config(prepared)
store.render(prepared.prompt)
if prepared.type == "prompt":
store.render(prepared.prompt)
elif prepared.type == "workflow":
store.render_data(prepared.graph)
store.render(prepared.greeting)
return prepared

View File

@@ -0,0 +1,154 @@
"""Reusable deterministic tool execution shared by Prompt, Agent, and Action."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from urllib.parse import quote
import httpx
from models import RuntimeTool
from services.runtime_variables import (
DynamicVariableError,
DynamicVariableStore,
value_at_path,
)
class ToolExecutionError(RuntimeError):
pass
class ToolExecutor:
def __init__(self, store: DynamicVariableStore):
self.store = store
def register_secrets(self, tool: RuntimeTool) -> None:
dynamic = (tool.secrets or {}).get("dynamic_variables") or {}
for name, value in dynamic.items():
if not str(name).startswith("secret__"):
raise DynamicVariableError(f"工具密钥变量必须以 secret__ 开头: {name}")
self.store.secrets[str(name)] = str(value)
@staticmethod
def schema_parts(tool: RuntimeTool) -> tuple[dict[str, Any], list[str]]:
config = (tool.definition or {}).get("config") or {}
parameters = list(config.get("parameters") or [])
properties = {
str(parameter.get("name")): {
"type": str(parameter.get("type") or "string"),
"description": str(parameter.get("description") or ""),
}
for parameter in parameters
if parameter.get("name")
}
required = [
str(parameter["name"])
for parameter in parameters
if parameter.get("name") and parameter.get("required", True)
]
return properties, required
async def execute(
self,
tool: RuntimeTool,
arguments: dict[str, Any] | None = None,
*,
result_assignments: dict[str, str] | None = None,
) -> dict[str, Any]:
self.register_secrets(tool)
if tool.type != "http":
raise ToolExecutionError(f"Action 暂不支持工具类型: {tool.type}")
return await self._execute_http(
tool,
dict(arguments or {}),
result_assignments=result_assignments,
)
async def _execute_http(
self,
tool: RuntimeTool,
arguments: dict[str, Any],
*,
result_assignments: dict[str, str] | None,
) -> dict[str, Any]:
config = (tool.definition or {}).get("config") or {}
parameters = list(config.get("parameters") or [])
url = self.store.render(str(config.get("url") or ""))
configured_headers = self.store.render_data(
deepcopy(config.get("headers") or {}), allow_secrets=True
)
secret_headers = self.store.render_data(
deepcopy((tool.secrets or {}).get("headers") or {}), allow_secrets=True
)
headers: dict[str, str] = {}
query: dict[str, object] = {}
body = self.store.render_data(deepcopy(config.get("body") or {}))
for parameter in parameters:
name = str(parameter.get("name") or "")
if not name or name not in arguments:
continue
value = arguments[name]
location = str(parameter.get("location") or "body")
if location == "path":
url = url.replace(f"{{{name}}}", quote(str(value), safe=""))
elif location == "query":
query[name] = value
elif location == "header":
headers[name] = str(value)
else:
body[name] = value
headers.update({str(key): str(value) for key, value in configured_headers.items()})
headers.update({str(key): str(value) for key, value in secret_headers.items()})
try:
async with httpx.AsyncClient(
timeout=float(config.get("timeout_seconds") or 15),
follow_redirects=False,
) as client:
response = await client.request(
str(config.get("method") or "GET"),
url,
headers=headers,
params=query,
json=body if body else None,
)
response.raise_for_status()
except httpx.TimeoutException as exc:
raise ToolExecutionError("HTTP 工具调用超时") from exc
except httpx.HTTPStatusError as exc:
raise ToolExecutionError(f"HTTP 工具返回错误状态:{exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise ToolExecutionError(f"HTTP 工具调用失败:{exc}") from exc
if len(response.content) > 1_000_000:
raise ToolExecutionError("HTTP 工具响应超过 1 MB 限制")
try:
payload: Any = response.json()
except ValueError:
payload = {"text": response.text[:8000]}
assignments = (
result_assignments
if result_assignments is not None
else config.get("dynamic_variable_assignments") or {}
)
updated: list[str] = []
for variable_name, path in assignments.items():
try:
value = value_at_path(payload, str(path))
except KeyError:
try:
value = value_at_path({"response": payload}, str(path))
except KeyError:
continue
self.store.assign(str(variable_name), value)
updated.append(str(variable_name))
return {
"status": "ok",
"status_code": response.status_code,
"data": payload,
"updated_variables": updated,
}

View File

@@ -1,170 +1,140 @@
"""工作流图引擎(第一版)。
对应 dograh 的 pipecat_engine.py,极简实现:
- 单个 startCall 入口,开场白来自该节点;
- agentNode 用各自的 prompt 驱动多轮对话;
- globalNode 不参与连线,按节点开关向会话节点注入统一提示词;
- 每轮助手回复后,用一次轻量 LLM「路由」判断是否满足某条出边的 condition,
满足则切换当前节点(linear = 单边;branching = 多边按条件分流);
- 到达 endCall 播放结束语并停止路由。
只读图结构,不持有对话状态(当前节点由 pipeline 维护),便于单测。
"""
"""Pure Workflow v3 graph queries and deterministic edge evaluation."""
from __future__ import annotations
import re
from typing import Any
from loguru import logger
from services.node_specs import normalize_graph
from services.runtime_variables import DynamicVariableStore
class WorkflowEngine:
def __init__(self, graph: dict[str, Any]):
nodes = graph.get("nodes") or []
self.nodes: dict[str, dict] = {n["id"]: n for n in nodes if n.get("id")}
self.edges: list[dict] = graph.get("edges") or []
self.start_id: str | None = next(
(nid for nid, n in self.nodes.items() if n.get("type") == "startCall"),
None,
)
self.global_id: str | None = next(
(nid for nid, n in self.nodes.items() if n.get("type") == "globalNode"),
self.graph = normalize_graph(graph)
self.settings = self.graph.get("settings") or {}
self.nodes: dict[str, dict] = {
str(node["id"]): node
for node in self.graph.get("nodes") or []
if node.get("id")
}
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"),
None,
)
# ---- 结构查询 ----
def node_type(self, nid: str | None) -> str | None:
return self.nodes.get(nid or "", {}).get("type")
def has_graph(self) -> bool:
return bool(self.start_id)
def data(self, nid: str | None) -> dict:
return self.nodes.get(nid or "", {}).get("data") or {}
def node_type(self, node_id: str | None) -> str | None:
return self.nodes.get(node_id or "", {}).get("type")
def name(self, nid: str | None) -> str:
return self.data(nid).get("name") or (self.node_type(nid) or "")
def data(self, node_id: str | None) -> dict:
return self.nodes.get(node_id or "", {}).get("data") or {}
def outgoing(self, nid: str | None) -> list[dict]:
return [e for e in self.edges if e.get("source") == nid]
def name(self, node_id: str | None) -> str:
return str(self.data(node_id).get("name") or self.node_type(node_id) or "")
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)))
def edge_mode(self, edge: dict) -> str:
return str((edge.get("data") or {}).get("mode") or "always")
def edge_fn_name(self, edge: dict) -> str:
"""每条边对应一个 LLM 函数名(稳定、合法标识符)。"""
raw = edge.get("id") or f"{edge.get('source')}_{edge.get('target')}"
slug = re.sub(r"[^a-z0-9]+", "_", str(raw).lower()).strip("_")
return f"goto_{slug or 'next'}"
def edge_condition(self, edge: dict) -> str:
return (edge.get("data") or {}).get("condition") or ""
def edge_description(self, edge: dict) -> str:
data = edge.get("data") or {}
target = self.name(str(edge.get("target") or ""))
condition = str(data.get("condition") or "").strip()
if condition:
return f"当满足以下条件时转到「{target}」:{condition}"
return f"当当前阶段任务完成时转到「{target}」。"
def edge_transition_speech(self, edge: dict | None) -> str:
"""命中该边、切换节点瞬间播报的过渡语(可选,掩盖延迟,不写入上下文)。"""
if not edge:
return ""
return (edge.get("data") or {}).get("transition_speech") or ""
data = edge.get("data") or {}
return str(data.get("transitionSpeech") or data.get("transition_speech") or "")
def find_edge(self, source: str | None, target: str | None) -> dict | None:
for edge in self.edges:
if edge.get("source") == source and edge.get("target") == target:
return edge
return None
def global_prompt(self) -> str:
return str(self.settings.get("globalPrompt") or "").strip()
def edge_description(self, edge: dict) -> str:
"""作为转移函数的 description 交给 LLM:满足该条件时模型应调用此函数。"""
cond = self.edge_condition(edge)
target = self.name(edge.get("target"))
if cond:
return f"当满足以下条件时调用以转到节点「{target}」:{cond}"
return f"当当前节点任务完成、应继续推进对话时调用以转到节点「{target}」。"
def is_end(self, nid: str | None) -> bool:
return self.node_type(nid) == "endCall"
def has_graph(self) -> bool:
return self.start_id is not None
def greeting(self) -> str:
return self.data(self.start_id).get("greeting") or ""
def system_prompt_for(self, nid: str | None) -> str:
"""组合当前节点提示与可选的全局提示(开始节点也是会话节点)。"""
header = f"[当前节点:{self.name(nid)}]"
node_data = self.data(nid)
prompt = str(node_data.get("prompt") or "").strip()
node_type = self.node_type(nid)
default_add_global = node_type in {"startCall", "agentNode"}
add_global = bool(node_data.get("addGlobalPrompt", default_add_global))
global_prompt = (
str(self.data(self.global_id).get("prompt") or "").strip()
if add_global and self.global_id
else ""
)
sections = [header]
if global_prompt:
sections.append(f"[全局规则]\n{global_prompt}")
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
sections = [f"[当前阶段:{self.name(node_id)}]"]
if self.global_prompt():
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
if prompt:
sections.append(f"[当前节点任务]\n{prompt}")
sections.append(f"[当前阶段任务]\n{prompt}")
return "\n\n".join(sections)
# ---- 路由:决定下一节点 ----
async def route(
def greeting(self, store: DynamicVariableStore) -> str:
return store.render(str(self.data(self.start_id).get("greeting") or ""))
def expression_matches(self, expression: dict, values: dict[str, Any]) -> bool:
results = []
for rule in expression.get("rules") or []:
name = str(rule.get("variable") or "")
operator = str(rule.get("operator") or "")
expected = rule.get("value")
exists = name in values
actual = values.get(name)
try:
if operator == "exists":
matched = exists if expected is not False else not exists
elif operator == "eq":
matched = actual == expected
elif operator == "neq":
matched = actual != expected
elif operator == "gt":
matched = actual > expected
elif operator == "gte":
matched = actual >= expected
elif operator == "lt":
matched = actual < expected
elif operator == "lte":
matched = actual <= expected
elif operator == "contains":
matched = expected in actual
elif operator == "in":
matched = actual in expected
else:
matched = False
except (TypeError, ValueError):
matched = False
results.append(matched)
if not results:
return False
return all(results) if expression.get("combinator", "and") == "and" else any(results)
def deterministic_edge(
self,
nid: str | None,
history: list[dict],
node_id: str,
store: DynamicVariableStore,
*,
api_key: str,
base_url: str,
model: str,
) -> str | None:
"""根据对话历史判断当前节点是否应转移。返回目标节点 id,或 None 表示停留。"""
outs = self.outgoing(nid)
if not outs:
return None
include_default: bool,
) -> dict | None:
default = None
for edge in self.outgoing(node_id):
data = edge.get("data") or {}
mode = data.get("mode")
if mode == "expression" and self.expression_matches(
data.get("expression") or {}, store.values
):
return edge
if mode == "always":
default = edge
return default if include_default else None
options = []
for i, edge in enumerate(outs, 1):
edata = edge.get("data") or {}
cond = edata.get("condition") or "(无明确条件,作为默认后继)"
tgt_name = self.name(edge.get("target"))
options.append(f"{i}. 条件:{cond} → 目标节点:{tgt_name}")
convo = "\n".join(
f"{m['role']}: {m['content']}" for m in history[-8:] if m.get("content")
)
system = (
"你是语音对话的流程路由器。根据最近的对话,判断是否已满足某条转移条件。\n"
"规则:仅当某条件被明确满足时,返回其编号;若都不满足或不确定,返回 0 "
"(停留在当前节点继续对话)。只输出一个数字,不要任何解释。"
)
user = (
"可选转移:\n"
+ "\n".join(options)
+ f"\n\n对话记录:\n{convo}\n\n请只返回编号(0 表示停留):"
)
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=api_key, base_url=base_url or None)
resp = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
temperature=0,
max_tokens=5,
)
text = (resp.choices[0].message.content or "").strip()
except Exception as exc: # noqa: BLE001 - 路由失败不应中断通话
logger.warning(f"工作流路由调用失败,停留当前节点: {exc}")
return None
match = re.search(r"\d+", text)
if not match:
return None
idx = int(match.group())
if idx < 1 or idx > len(outs):
return None
target = outs[idx - 1].get("target")
logger.info(f"工作流路由: {self.name(nid)}{self.name(target)} (edge {idx})")
return target
def llm_edges(self, node_id: str) -> list[dict]:
return [
edge
for edge in self.outgoing(node_id)
if self.edge_mode(edge) in {"llm", "always"}
]

View File

@@ -111,6 +111,19 @@ class BrainRegistryTests(unittest.TestCase):
)
self.assertIn("user_name", assistant.dynamic_variable_definitions)
def test_workflow_keeps_dynamic_variables_and_tool_bindings(self):
assistant = AssistantUpsert(
name="workflow",
type="workflow",
toolIds=["tool_a"],
dynamicVariableDefinitions={
"customer": {"type": "string", "required": False, "default": "王先生"}
},
graph={},
)
self.assertEqual(assistant.tool_ids, ["tool_a"])
self.assertIn("customer", assistant.dynamic_variable_definitions)
class DifyHelpersTests(unittest.TestCase):
def test_normalize_api_base(self):
@@ -363,7 +376,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
params = FakeFunctionParams(
{"order_id": "A/1", "Authorization": "attacker-value"}
)
with patch("services.brains.prompt_brain.httpx.AsyncClient", FakeClient):
with patch("services.tool_executor.httpx.AsyncClient", FakeClient):
await llm.functions["lookup_order"](params)
self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1")
@@ -377,35 +390,91 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_transition_and_end_are_owned_by_workflow_brain(self):
graph = {
"specVersion": 3,
"settings": {"globalPrompt": "全局规则"},
"nodes": [
{
"id": "start",
"type": "startCall",
"data": {"name": "开始", "prompt": "收集需求"},
"type": "start",
"data": {"name": "Start"},
},
{
"id": "agent",
"type": "agent",
"data": {
"name": "收集需求",
"prompt": "服务 {{user_name}}",
"contextPolicy": "fresh",
},
},
{
"id": "end",
"type": "endCall",
"data": {"name": "结束", "prompt": "礼貌结束"},
"type": "end",
"data": {"name": "End", "message": "感谢来电", "scope": "session"},
},
],
"edges": [
{
"id": "finish",
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
},
{
"id": "finish",
"source": "agent",
"target": "end",
"data": {"condition": "需求已收集"},
"data": {
"mode": "llm",
"priority": 10,
"condition": "需求已收集",
},
}
],
}
brain = WorkflowBrain(graph)
cfg = prepare_dynamic_config(
AssistantConfig(
type="workflow",
graph=graph,
dynamic_variable_definitions={
"user_name": {"type": "string", "required": True}
},
),
{"user_name": "王先生"},
assistant_id="asst_workflow",
)
brain = WorkflowBrain(cfg)
llm = FakeLLM()
context = LLMContext(messages=[])
queued = []
prompts = []
visible_tools = []
call_end = FakeCallEnd()
class FakeWorker:
def __init__(self):
self.frames = []
self.handlers = {}
def set_reached_downstream_filter(self, *_args):
pass
def event_handler(self, name):
def decorator(fn):
self.handlers[name] = fn
return fn
return decorator
async def queue_frame(self, frame):
self.frames.append(frame)
async def queue_frames(self, frames):
self.frames.extend(frames)
worker = FakeWorker()
pair = SimpleNamespace(
user=lambda: SimpleNamespace(_context=context),
assistant=lambda: SimpleNamespace(has_function_calls_in_progress=False),
)
async def queue_frame(frame):
queued.append(frame)
@@ -413,30 +482,34 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
context=context,
llm=llm,
queue_frame=queue_frame,
set_system_prompt=prompts.append,
set_tools=lambda tools: visible_tools.append(tools or []),
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
worker=worker,
context_aggregator=pair,
)
await brain.setup(AssistantConfig(type="workflow", graph=graph), runtime)
self.assertIn("goto_finish", llm.functions)
self.assertIn("收集需求", prompts[-1])
self.assertEqual(visible_tools[-1][0].name, "goto_finish")
params = FakeFunctionParams()
await llm.functions["goto_finish"](params)
self.assertEqual(params.result, {"status": "ok"})
self.assertIn("礼貌结束", prompts[-1])
self.assertEqual(visible_tools[-1], [])
await brain.on_assistant_text_start("closing-turn")
await brain.on_assistant_text_end(
"closing-turn",
"感谢来电,再见。",
False,
await brain.setup(cfg, runtime)
await brain.on_connected()
self.assertEqual(brain._manager.current_node, "agent")
agent_config = brain._agent_config("agent")
self.assertIn("王先生", agent_config["role_message"])
self.assertIn("完成当前阶段任务", agent_config["role_message"])
self.assertEqual(agent_config["task_messages"], [])
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"
)
_, terminal = await edge_function.handler({}, brain._manager)
self.assertEqual(terminal["name"], "end")
self.assertTrue(call_end.ending)
self.assertTrue(call_end.armed)
self.assertTrue(any(getattr(frame, "text", "") == "感谢来电" for frame in queued))
if __name__ == "__main__":

View File

@@ -1,7 +1,11 @@
import unittest
from models import AssistantConfig
from services.pipecat.pipeline import _knowledge_tool_description
from services.pipecat.pipeline import (
KNOWLEDGE_CONTEXT_MARKER,
KnowledgeRetrievalProcessor,
_knowledge_tool_description,
)
class KnowledgeToolDescriptionTest(unittest.TestCase):
@@ -33,6 +37,25 @@ class KnowledgeToolDescriptionTest(unittest.TestCase):
self.assertNotIn("\n ", description)
self.assertLess(len(description), 1000)
def test_workflow_knowledge_uses_system_role(self):
processor = KnowledgeRetrievalProcessor(None)
messages = [
{"role": "assistant", "content": "你好"},
{
"role": "developer",
"content": f"{KNOWLEDGE_CONTEXT_MARKER}\n旧检索结果",
},
]
processor._set_context(
messages,
f"{KNOWLEDGE_CONTEXT_MARKER}\n新检索结果",
)
self.assertEqual(messages[0]["role"], "system")
self.assertIn("新检索结果", messages[0]["content"])
self.assertFalse(any(message["role"] == "developer" for message in messages))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
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.runtime_variables import DynamicVariableStore, prepare_dynamic_config
from services.workflow_engine import WorkflowEngine
def valid_graph():
return {
"specVersion": 3,
"settings": {"globalPrompt": "服务 {{customer}}"},
"nodes": [
{"id": "start", "type": "start", "data": {"name": "Start"}},
{
"id": "agent",
"type": "agent",
"data": {"name": "Agent", "prompt": "处理订单", "contextPolicy": "fresh"},
},
{"id": "end", "type": "end", "data": {"name": "End", "scope": "flow"}},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
},
{
"id": "paid",
"source": "agent",
"target": "end",
"data": {
"mode": "expression",
"priority": 10,
"expression": {
"combinator": "and",
"rules": [
{"variable": "order_status", "operator": "eq", "value": "paid"}
],
},
},
},
],
}
class WorkflowGraphTests(unittest.TestCase):
def test_voice_resource_creates_isolated_runtime_config(self):
base = AssistantConfig(type="workflow", asr="default", voice="default")
asr = RuntimeModelResource(
id="asr_1",
capability="ASR",
interface_type="openai-asr",
values={"modelId": "sensevoice", "language": "zh", "apiUrl": "https://asr.test"},
secrets={"apiKey": "secret"},
)
resolved = config_with_resource(base, asr)
self.assertEqual(resolved.asr, "sensevoice")
self.assertEqual(resolved.stt_api_key, "secret")
self.assertEqual(base.asr, "default")
def test_v2_start_prompt_is_preserved_in_synthetic_agent(self):
graph = normalize_graph(
{
"nodes": [
{"id": "s", "type": "startCall", "data": {"prompt": "询问需求"}},
{"id": "e", "type": "endCall", "data": {"prompt": "再见"}},
],
"edges": [
{"id": "done", "source": "s", "target": "e", "data": {"condition": "完成"}}
],
}
)
migrated = next(node for node in graph["nodes"] if node["type"] == "agent")
self.assertEqual(migrated["data"]["prompt"], "询问需求")
self.assertEqual(validate_graph(graph), [])
def test_rejects_automatic_cycle_and_duplicate_priorities(self):
graph = valid_graph()
graph["nodes"].insert(1, {"id": "action", "type": "action", "data": {}})
graph["edges"] = [
{"id": "a", "source": "start", "target": "action", "data": {"mode": "always", "priority": 0}},
{"id": "b", "source": "action", "target": "start", "data": {"mode": "always", "priority": 0}},
{"id": "c", "source": "agent", "target": "end", "data": {"mode": "always", "priority": 10}},
]
errors = validate_graph(graph)
self.assertTrue(any("无等待循环" in error for error in errors))
def test_expression_routes_using_session_variables(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="workflow",
graph=valid_graph(),
dynamic_variable_definitions={
"customer": {"type": "string", "default": "王先生"},
"order_status": {"type": "string", "default": "pending"},
},
),
{},
assistant_id="asst_test",
)
store = DynamicVariableStore.from_config(cfg)
engine = WorkflowEngine(cfg.graph)
self.assertIsNone(engine.deterministic_edge("agent", store, include_default=False))
store.assign("order_status", "paid")
self.assertEqual(
engine.deterministic_edge("agent", store, include_default=False)["target"],
"end",
)
self.assertIn("王先生", engine.prompt_for("agent", store))
if __name__ == "__main__":
unittest.main()

View File

@@ -53,13 +53,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
DropdownMenu,
DropdownMenuContent,
@@ -478,10 +471,15 @@ export function AssistantPage(props: AssistantPageProps) {
defaultGraph(),
);
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
globalPrompt: defaultGraph().settings.globalPrompt,
allowInterrupt: true,
turnConfig: defaultTurnConfig(),
});
const [debugOpen, setDebugOpen] = useState(false);
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
useState<Record<string, DynamicVariableDefinition>>({});
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(null);
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(null);
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
@@ -708,6 +706,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: workflowName,
graph: workflowGraph,
settings: workflowSettings,
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
}),
);
}
@@ -849,19 +848,24 @@ export function AssistantPage(props: AssistantPageProps) {
: defaultGraph();
const wfSettings: WorkflowSettings = {
llm: assistant.modelResourceIds.LLM,
asr: assistant.modelResourceIds.ASR,
tts: assistant.modelResourceIds.TTS,
asr: graph.settings?.defaultAsrResourceId || assistant.modelResourceIds.ASR,
tts: graph.settings?.defaultTtsResourceId || assistant.modelResourceIds.TTS,
globalPrompt: graph.settings?.globalPrompt ?? "",
allowInterrupt: assistant.enableInterrupt,
turnConfig: assistant.turnConfig,
};
setWorkflowName(assistant.name);
setWorkflowGraph(graph);
setWorkflowSettings(wfSettings);
setWorkflowDynamicVariableDefinitions(
assistant.dynamicVariableDefinitions ?? {},
);
setSavedSnapshot(
JSON.stringify({
name: assistant.name,
graph,
settings: wfSettings,
dynamicVariableDefinitions: assistant.dynamicVariableDefinitions ?? {},
}),
);
}
@@ -913,6 +917,7 @@ export function AssistantPage(props: AssistantPageProps) {
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
},
graph: workflowGraph as unknown as Record<string, unknown>,
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
}),
);
}
@@ -932,6 +937,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: workflowName,
graph: workflowGraph,
settings: workflowSettings,
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
})
: null;
const dirty =
@@ -1377,7 +1383,11 @@ export function AssistantPage(props: AssistantPageProps) {
<div className="flex shrink-0 gap-2">
{saveError && (
<span className="self-center text-xs text-destructive">
<span
role="alert"
title={saveError}
className="line-clamp-2 max-w-[min(42vw,560px)] self-center text-right text-sm leading-5 text-destructive"
>
{saveError}
</span>
)}
@@ -1385,7 +1395,11 @@ export function AssistantPage(props: AssistantPageProps) {
variant="outline"
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
disabled={!editingId}
onClick={() => setDebugOpen(true)}
onClick={() => {
setWorkflowEditingNodeId(null);
setWorkflowEditingEdgeId(null);
setWorkflowDebugOpen(true);
}}
>
<Bug size={16} />
@@ -1411,43 +1425,49 @@ export function AssistantPage(props: AssistantPageProps) {
onChange={setWorkflowGraph}
settings={workflowSettings}
onSettingsChange={setWorkflowSettings}
onOpenDynamicVariables={() => setDynamicVariablesOpen(true)}
editingNodeId={workflowEditingNodeId}
onEditingNodeIdChange={setWorkflowEditingNodeId}
editingEdgeId={workflowEditingEdgeId}
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
debugOpen={workflowDebugOpen}
onDebugOpenChange={(open) => {
setWorkflowDebugOpen(open);
if (!open) setActiveNodeId(null);
}}
debugPanel={
<DebugDrawer
overlay
assistantId={editingId}
onClose={() => {
setWorkflowDebugOpen(false);
setActiveNodeId(null);
}}
hasUnsavedChanges={dirty}
onNodeActive={setActiveNodeId}
dynamicVariablesEnabled
dynamicVariableDefinitions={workflowDynamicVariableDefinitions}
/>
}
activeNodeId={activeNodeId}
modelOptions={{
llm: credOptions("LLM"),
asr: credOptions("ASR"),
tts: credOptions("TTS"),
}}
toolOptions={tools
.filter((tool) => tool.status === "active")
.map((tool) => ({ value: tool.id, label: tool.name }))}
knowledgeOptions={kbOptions}
/>
</div>
<Sheet
open={debugOpen}
onOpenChange={(open) => {
setDebugOpen(open);
if (!open) setActiveNodeId(null);
}}
modal={false}
>
<SheetContent
side="right"
showOverlay={false}
onInteractOutside={(e) => e.preventDefault()}
className="w-[440px] gap-0 border-l border-hairline bg-card p-0 sm:max-w-[440px]"
>
<SheetHeader className="sr-only">
<SheetTitle></SheetTitle>
<SheetDescription>
,
</SheetDescription>
</SheetHeader>
<DebugDrawer
assistantId={editingId}
asSheet
hasUnsavedChanges={dirty}
onNodeActive={setActiveNodeId}
/>
</SheetContent>
</Sheet>
<DynamicVariablesDialog
open={dynamicVariablesOpen}
onOpenChange={setDynamicVariablesOpen}
definitions={workflowDynamicVariableDefinitions}
onChange={setWorkflowDynamicVariableDefinitions}
/>
</div>
);
}
@@ -2132,7 +2152,8 @@ function SegmentedIconButton({
function DebugDrawer({
assistantId,
asSheet = false,
overlay = false,
onClose,
hasUnsavedChanges = false,
onNodeActive,
vision = false,
@@ -2140,7 +2161,8 @@ function DebugDrawer({
dynamicVariableDefinitions = {},
}: {
assistantId: string | null;
asSheet?: boolean;
overlay?: boolean;
onClose?: () => void;
hasUnsavedChanges?: boolean;
onNodeActive?: (nodeId: string | null) => void;
vision?: boolean;
@@ -2179,14 +2201,25 @@ function DebugDrawer({
[camera, preview],
);
const containerClass = asSheet
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
: "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex";
return (
<aside className={containerClass}>
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
<aside
className={overlay
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-card"
: "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex"}
>
<div className={`flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-hairline py-3 ${overlay ? "px-4" : "px-5"}`}>
<div className="flex min-w-0 items-center gap-2.5">
{overlay && onClose && (
<button
type="button"
aria-label="关闭调试预览"
title="关闭"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
onClick={onClose}
>
<X size={16} />
</button>
)}
<div className="shrink-0 text-sm font-medium text-foreground">
</div>

View File

@@ -1,14 +1,13 @@
"use client";
/**
* 条件边。边携带 condition(自然语言条件,LLM 据此决定是否走这条路径)与
* label(日志里识别该路径的短标签)。悬停/选中时在标签旁显示「编辑 / 删除」按钮。
* Workflow v3 edge: LLM judgement, variable expression, or deterministic default.
*/
import {
BaseEdge,
EdgeLabelRenderer,
getSmoothStepPath,
getBezierPath,
type EdgeProps,
useReactFlow,
} from "@xyflow/react";
@@ -36,20 +35,22 @@ export function ConditionEdge({
// 点击标签:只选中这条边(露出编辑/删除按钮),不直接进入编辑。
const selectThisEdge = () =>
setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === id })));
const [path, labelX, labelY] = getSmoothStepPath({
const [path, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 8,
offset: 20,
curvature: 0.28,
});
const label = ((data?.label as string) || (data?.condition as string) || "")
.toString()
.trim();
const mode = (data?.mode as string) || "always";
const label = (
(data?.label as string) ||
(mode === "llm" ? (data?.condition as string) : "") ||
(mode === "expression" ? "变量表达式" : "默认路径")
).toString().trim();
const expanded = hovered || selected;
return (

View File

@@ -27,9 +27,20 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
const nodeData = data as WorkflowNodeData;
const Icon = spec.icon;
const preview = (nodeData.greeting || nodeData.prompt || "")
const preview = (nodeData.greeting || nodeData.prompt || nodeData.message || "")
.toString()
.trim();
const meta = type === "agent"
? [
nodeData.contextPolicy === "fresh" ? "独立上下文" : "继承上下文",
`${nodeData.toolIds?.length ?? 0} 工具`,
nodeData.knowledgeBaseId ? "知识库" : null,
nodeData.asrResourceId ? "独立 ASR" : null,
nodeData.ttsResourceId ? "独立 TTS" : null,
].filter(Boolean)
: type === "action" && nodeData.toolId
? ["确定性工具"]
: [];
return (
<div
@@ -46,7 +57,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
{spec.hasTarget && (
<Handle
type="target"
position={Position.Left}
position={Position.Top}
className="!h-3 !w-3 !border-[3px] !border-card !bg-muted-soft"
/>
)}
@@ -86,7 +97,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
>
<Pencil size={13} />
</button>
{type !== "startCall" && (
{type !== "start" && (
<button
type="button"
title="删除节点"
@@ -129,11 +140,22 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
</p>
)}
{meta.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{meta.map((item) => (
<span key={item as string} className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] text-muted-foreground">
{item}
</span>
))}
</div>
)}
{spec.hasSource && (
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary"
position={Position.Bottom}
title="拖到节点或画布空白处"
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary transition-transform hover:!scale-125"
/>
)}
</div>
@@ -141,8 +163,9 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
}
export const nodeTypes = {
startCall: GenericNode,
agentNode: GenericNode,
endCall: GenericNode,
globalNode: GenericNode,
start: GenericNode,
agent: GenericNode,
action: GenericNode,
handoff: GenericNode,
end: GenericNode,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,62 @@
/**
* 工作流节点的前端类型与运行期规格。
*
* 节点「目录」(有哪些类型、各自的字段与约束)由后端 /api/node-types 提供,
* 通过 useNodeSpecs 拉取后用 toRuntimeSpec 转成带 React 组件(图标)的运行期规格。
* 本文件只保留:类型定义、默认图、图标/配色解析。新增节点类型改后端即可。
*/
/** Workflow v3 public graph types and defaults. */
import * as LucideIcons from "lucide-react";
import { Circle, type LucideIcon } from "lucide-react";
import type { NodeSpecDto } from "@/lib/api";
export type WorkflowNodeType =
| "startCall"
| "agentNode"
| "endCall"
| "globalNode";
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
export type ContextPolicy = "inherit" | "fresh";
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
export type EdgeMode = "llm" | "expression" | "always";
export type ExpressionOperator =
| "eq"
| "neq"
| "gt"
| "gte"
| "lt"
| "lte"
| "contains"
| "in"
| "exists";
export type WorkflowNodeData = {
/** 节点显示名 */
name: string;
/** 开场白(仅 startCall) */
greeting?: string;
/** 节点提示词 */
prompt?: string;
/** 允许打断(仅 agentNode) */
allowInterrupt?: boolean;
/** 是否合并全局节点提示词(start/agent 默认开启,end 默认关闭) */
addGlobalPrompt?: boolean;
contextPolicy?: ContextPolicy;
toolIds?: string[];
knowledgeBaseId?: string;
knowledgeMode?: KnowledgeMode;
knowledgeTopN?: number;
knowledgeScoreThreshold?: number;
asrResourceId?: string;
ttsResourceId?: string;
toolId?: string;
arguments?: Record<string, unknown>;
resultAssignments?: Record<string, string>;
targetType?: "ai" | "human" | "queue" | "phone";
target?: string;
message?: string;
scope?: "flow" | "session";
[key: string]: unknown;
};
export type ExpressionRule = {
variable: string;
operator: ExpressionOperator;
value?: unknown;
};
export type WorkflowEdgeData = {
mode: EdgeMode;
priority: number;
condition?: string;
expression?: { combinator: "and" | "or"; rules: ExpressionRule[] };
label?: string;
transitionSpeech?: string;
};
export type FieldSpec = {
key: string;
label: string;
@@ -39,7 +65,6 @@ export type FieldSpec = {
default?: unknown;
};
/** 解析后的运行期节点规格(DTO + 解析出的 React 图标 + 派生句柄) */
export type RuntimeNodeSpec = {
type: string;
displayName: string;
@@ -47,9 +72,7 @@ export type RuntimeNodeSpec = {
icon: LucideIcon;
accent: string;
addable: boolean;
/** 入边句柄(开始节点没有) */
hasTarget: boolean;
/** 出边句柄(结束节点没有) */
hasSource: boolean;
constraints: {
minIncoming?: number;
@@ -62,7 +85,6 @@ export type RuntimeNodeSpec = {
fields: FieldSpec[];
};
/** 渐变 token → CSS 变量名(图标底色用),未知配色回落到 sky */
export const ACCENT_VAR: Record<string, string> = {
mint: "--gradient-mint",
sky: "--gradient-sky",
@@ -75,13 +97,11 @@ export function accentVar(accent: string): string {
return ACCENT_VAR[accent] ?? ACCENT_VAR.sky;
}
/** 按名字解析 Lucide 图标,找不到回落到 Circle(对齐 dograh resolveIcon)。 */
export function resolveIcon(name: string): LucideIcon {
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
return icons[name] ?? Circle;
}
/** 后端 DTO → 运行期规格。hasTarget/hasSource 由入/出边上限派生。 */
export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
return {
type: dto.name,
@@ -93,12 +113,12 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
hasTarget: dto.constraints.maxIncoming !== 0,
hasSource: dto.constraints.maxOutgoing !== 0,
constraints: dto.constraints,
fields: dto.fields.map((f) => ({
key: f.key,
label: f.label,
type: f.type,
required: f.required,
default: f.default,
fields: dto.fields.map((field) => ({
key: field.key,
label: field.label,
type: field.type,
required: field.required,
default: field.default,
})),
};
}
@@ -106,6 +126,12 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
export type NodeSpecMap = Record<string, RuntimeNodeSpec>;
export type WorkflowGraph = {
specVersion: 3;
settings: {
globalPrompt: string;
defaultAsrResourceId: string;
defaultTtsResourceId: string;
};
nodes: Array<{
id: string;
type: WorkflowNodeType;
@@ -116,62 +142,72 @@ export type WorkflowGraph = {
id: string;
source: string;
target: string;
data?: { condition?: string; label?: string; transition_speech?: string };
data: WorkflowEdgeData;
}>;
viewport?: { x: number; y: number; zoom: number };
};
/** 新建工作流的默认图:全局规则 + 开始 → 智能体 → 结束 */
export function defaultGraph(): WorkflowGraph {
return {
specVersion: 3,
settings: {
globalPrompt:
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
defaultAsrResourceId: "",
defaultTtsResourceId: "",
},
nodes: [
{
id: "start",
type: "startCall",
position: { x: 100, y: 120 },
type: "start",
position: { x: 360, y: 60 },
data: {
name: "开始",
greeting: "你好,我是 AI 视频助手,有什么可以帮你?",
prompt: "了解用户的需求,并在信息明确后进入下一节点。",
allowInterrupt: true,
addGlobalPrompt: true,
name: "Start",
greeting: "你好我是 AI 视频助手有什么可以帮你",
},
},
{
id: "agent-1",
type: "agentNode",
position: { x: 420, y: 120 },
type: "agent",
position: { x: 360, y: 300 },
data: {
name: "智能体节点",
prompt: "根据用户需求提供清晰、准确的帮助。",
allowInterrupt: true,
addGlobalPrompt: true,
name: "Agent",
prompt: "了解用户需求提供清晰、准确的帮助。",
contextPolicy: "inherit",
toolIds: [],
knowledgeMode: "disabled",
knowledgeTopN: 5,
knowledgeScoreThreshold: 0,
},
},
{
id: "end",
type: "endCall",
position: { x: 740, y: 120 },
type: "end",
position: { x: 360, y: 540 },
data: {
name: "结束",
prompt: "总结已经完成的事项,礼貌道别并结束通话。",
addGlobalPrompt: false,
},
},
{
id: "global",
type: "globalNode",
position: { x: 100, y: 400 },
data: {
name: "全局设定",
prompt:
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
name: "End",
message: "感谢你的来电,再见。",
scope: "session",
},
},
],
edges: [
{ id: "e-start-agent", source: "start", target: "agent-1", data: {} },
{ id: "e-agent-end", source: "agent-1", target: "end", data: {} },
{
id: "e-start-agent",
source: "start",
target: "agent-1",
data: { mode: "always", priority: 0 },
},
{
id: "e-agent-end",
source: "agent-1",
target: "end",
data: {
mode: "llm",
priority: 10,
condition: "当前阶段任务已经完成,适合结束会话",
},
},
],
};
}

View File

@@ -11,6 +11,35 @@ export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding" | "Agent";
function formatErrorDetail(detail: unknown): string | null {
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
const messages = detail
.map((item) => {
if (!item || typeof item !== "object") return null;
const record = item as { loc?: unknown; msg?: unknown };
const message = typeof record.msg === "string" ? record.msg : null;
if (!message) return null;
const path = Array.isArray(record.loc)
? record.loc
.filter((part) => part !== "body")
.map(String)
.join(".")
: "";
return path ? `${path}${message}` : message;
})
.filter((message): message is string => Boolean(message));
return messages.length ? messages.join("") : null;
}
if (detail && typeof detail === "object") {
const record = detail as { message?: unknown; msg?: unknown };
if (typeof record.message === "string") return record.message;
if (typeof record.msg === "string") return record.msg;
}
return null;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const isFormData = init?.body instanceof FormData;
const res = await fetch(`${API_BASE}${path}`, {
@@ -30,8 +59,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
if (!res.ok) {
let detail = `请求失败 (${res.status})`;
try {
const body = (await res.json()) as { detail?: string };
if (body?.detail) detail = body.detail;
const body = (await res.json()) as { detail?: unknown };
detail = formatErrorDetail(body?.detail) ?? detail;
} catch {
// 响应体不是 JSON,沿用默认错误信息
}