Merge branch 'feature/workflow'

This commit is contained in:
Xin Wang
2026-07-14 09:39:00 +08:00
32 changed files with 4920 additions and 1208 deletions

View File

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

View File

@@ -2,7 +2,7 @@
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc # webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
# silero -> 本地 VAD(判断用户说话起止),语音必备 # silero -> 本地 VAD(判断用户说话起止),语音必备
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它) # 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 Pillow>=11.1.0,<13
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话) # 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 schemas import AssistantOut, AssistantUpsert
from services.auth import require_admin from services.auth import require_admin
from services.masking import mask, resolve_incoming_key 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,9 +31,54 @@ def _validate_workflow(body: AssistantUpsert) -> None:
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。""" """workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
if body.type != "workflow": if body.type != "workflow":
return return
errors = validate_graph(body.graph or {}) body.graph = normalize_graph(body.graph or {})
errors = validate_graph(body.graph)
if errors: if errors:
raise HTTPException(400, "工作流校验失败:" + ";".join(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 (
("defaultLlmResourceId", "LLM"),
("defaultAsrResourceId", "ASR"),
("defaultTtsResourceId", "TTS"),
):
if settings.get(key):
resource_expectations[str(settings[key])] = capability
knowledge_ids: set[str] = (
{str(settings["knowledgeBaseId"])}
if settings.get("knowledgeBaseId")
else set()
)
for node in graph.get("nodes") or []:
data = node.get("data") or {}
if node.get("type") == "agent" and data.get("inheritGlobalConfig", True):
continue
if data.get("llmResourceId"):
resource_expectations[str(data["llmResourceId"])] = "LLM"
if data.get("asrResourceId"):
resource_expectations[str(data["asrResourceId"])] = "ASR"
if data.get("ttsResourceId"):
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( async def _validate_vision_model(
@@ -101,7 +146,11 @@ async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, s
async def _sync_tool_bindings( async def _sync_tool_bindings(
session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str] session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str]
) -> None: ) -> 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: if requested:
tools = ( tools = (
await session.execute(select(Tool).where(Tool.id.in_(requested))) await session.execute(select(Tool).where(Tool.id.in_(requested)))
@@ -158,7 +207,11 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
api_url=assistant.api_url, api_url=assistant.api_url,
api_key=mask(assistant.api_key), api_key=mask(assistant.api_key),
app_id=assistant.app_id, 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, updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
) )
@@ -176,6 +229,7 @@ async def create_assistant(
body: AssistantUpsert, session: AsyncSession = Depends(get_session) body: AssistantUpsert, session: AsyncSession = Depends(get_session)
): ):
_validate_workflow(body) _validate_workflow(body)
await _validate_workflow_references(session, body)
await _validate_vision_model(session, body) await _validate_vision_model(session, body)
await _validate_knowledge_base(session, body) await _validate_knowledge_base(session, body)
data = body.model_dump() data = body.model_dump()
@@ -248,6 +302,7 @@ async def update_assistant(
if not assistant: if not assistant:
raise HTTPException(404, "助手不存在") raise HTTPException(404, "助手不存在")
_validate_workflow(body) _validate_workflow(body)
await _validate_workflow_references(session, body)
await _validate_vision_model(session, body) await _validate_vision_model(session, body)
await _validate_knowledge_base(session, body) await _validate_knowledge_base(session, body)
data = body.model_dump() data = body.model_dump()

View File

@@ -15,6 +15,7 @@ from schemas import (
) )
from services.auth import require_admin from services.auth import require_admin
from services.knowledge import create_document, delete_storage_object, process_document, search from services.knowledge import create_document, delete_storage_object, process_document, search
from services.node_specs import graph_references
import settings import settings
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -123,6 +124,14 @@ async def delete_knowledge_base(
)).scalar_one_or_none() )).scalar_one_or_none()
if referenced: if referenced:
raise HTTPException(409, "知识库正被助手引用,无法删除") 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( documents = (await session.execute(
select(KnowledgeDocument).where(KnowledgeDocument.knowledge_base_id == kb_id) select(KnowledgeDocument).where(KnowledgeDocument.knowledge_base_id == kb_id)
)).scalars().all() )).scalars().all()

View File

@@ -3,6 +3,7 @@
import uuid import uuid
from db.models import ( from db.models import (
Assistant,
AssistantModelBinding, AssistantModelBinding,
InterfaceDefinition, InterfaceDefinition,
KnowledgeBase, KnowledgeBase,
@@ -20,6 +21,7 @@ from services.auth import require_admin
from services.interface_catalog import validate_fields from services.interface_catalog import validate_fields
from services.masking import mask_secrets, merge_secrets from services.masking import mask_secrets, merge_secrets
from services.model_resource_tester import test_model_resource from services.model_resource_tester import test_model_resource
from services.node_specs import graph_references
from sqlalchemy import delete, select, update from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -102,6 +104,14 @@ async def _clear_incompatible_references(
) -> None: ) -> None:
if capability == resource.capability: if capability == resource.capability:
return 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( await session.execute(
delete(AssistantModelBinding).where( delete(AssistantModelBinding).where(
AssistantModelBinding.model_resource_id == resource.id AssistantModelBinding.model_resource_id == resource.id
@@ -263,6 +273,14 @@ async def delete_model_resource(
).scalar_one_or_none() ).scalar_one_or_none()
if in_use: if in_use:
raise HTTPException(409, "该模型资源仍被助手引用") 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.delete(row)
await session.commit() await session.commit()
return {"ok": True} return {"ok": True}

View File

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

View File

@@ -109,6 +109,17 @@ def clear_auth_cookie(response: Response) -> None:
async def require_admin(request: Request) -> AdminUser: async def require_admin(request: Request) -> AdminUser:
user = verify_admin_token(request.cookies.get(settings.AUTH_COOKIE_NAME)) 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: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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 __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable from typing import Any, Protocol, runtime_checkable
from models import AssistantConfig from models import AssistantConfig
@@ -52,6 +52,15 @@ class BrainRuntime:
set_system_prompt: Callable[[str], None] set_system_prompt: Callable[[str], None]
set_tools: Callable[[list[FunctionSchema] | None], None] set_tools: Callable[[list[FunctionSchema] | None], None]
call_end: CallEndPort call_end: CallEndPort
worker: Any = None
context_aggregator: Any = None
transport: Any = None
switch_services: (
Callable[[str | None, str | None, str | None], Awaitable[None]] | None
) = None
set_knowledge_scope: Callable[[dict[str, Any]], None] | None = None
set_input_enabled: Callable[[bool], None] | None = None
flow_global_functions: list[Any] = field(default_factory=list)
class BaseBrain: class BaseBrain:
@@ -77,6 +86,15 @@ class BaseBrain:
def record_user_message(self, content: str) -> None: def record_user_message(self, content: str) -> None:
"""Observe a committed user message for brain-owned routing state.""" """Observe a committed user message for brain-owned routing state."""
async def on_user_turn_end(self, content: str) -> bool:
"""Handle a complete user turn before the conversational LLM runs.
Return True when the brain scheduled the next action itself and the
in-flight context frame must not reach the previous Agent's LLM.
"""
self.record_user_message(content)
return False
async def on_assistant_text_start(self, turn_id: str) -> None: async def on_assistant_text_start(self, turn_id: str) -> None:
"""Observe the start of a generated assistant turn.""" """Observe the start of a generated assistant turn."""
@@ -107,6 +125,8 @@ class Brain(Protocol):
def record_user_message(self, content: str) -> None: ... def record_user_message(self, content: str) -> None: ...
async def on_user_turn_end(self, content: str) -> bool: ...
async def on_assistant_text_start(self, turn_id: str) -> None: ... async def on_assistant_text_start(self, turn_id: str) -> None: ...
async def on_assistant_text_end( async def on_assistant_text_end(

View File

@@ -2,11 +2,8 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from urllib.parse import quote
from uuid import uuid4 from uuid import uuid4
import httpx
from models import AssistantConfig from models import AssistantConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame 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.brains.base import BaseBrain, BrainRuntime, BrainSpec
from services.runtime_variables import ( from services.runtime_variables import (
DynamicVariableError,
DynamicVariableStore, DynamicVariableStore,
value_at_path,
) )
from services.tool_executor import ToolExecutionError, ToolExecutor
class PromptBrain(BaseBrain): class PromptBrain(BaseBrain):
@@ -37,6 +33,7 @@ class PromptBrain(BaseBrain):
self._cfg = cfg self._cfg = cfg
self._dynamic_enabled = True self._dynamic_enabled = True
self._store = DynamicVariableStore.from_config(cfg) self._store = DynamicVariableStore.from_config(cfg)
self._tools = ToolExecutor(self._store)
self._runtime: BrainRuntime | None = None self._runtime: BrainRuntime | None = None
async def greeting(self, cfg: AssistantConfig) -> str: 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)) self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
def _make_http_tool(self, tool, runtime: BrainRuntime): def _make_http_tool(self, tool, runtime: BrainRuntime):
config = (tool.definition or {}).get("config") or {} properties, required = self._tools.schema_parts(tool)
parameters = list(config.get("parameters") or []) self._tools.register_secrets(tool)
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)
async def call_http(params: FunctionCallParams) -> None: 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: try:
async with httpx.AsyncClient( result = await self._tools.execute(tool, dict(params.arguments or {}))
timeout=float(config.get("timeout_seconds") or 15), if result["updated_variables"]:
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:
self._refresh_prompt() self._refresh_prompt()
await params.result_callback( await params.result_callback(result)
{ except (ToolExecutionError, ValueError) as exc:
"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( await params.result_callback(
{"status": "error", "message": f"HTTP 工具调用失败: {exc}"} {"status": "error", "message": f"HTTP 工具调用失败: {exc}"}
) )

View File

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

View File

@@ -1,27 +1,43 @@
"""Local graph-driven workflow assistant and its per-call state.""" """Pipecat Flows-backed Workflow v3 brain."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import Any from typing import Any
from loguru import logger from loguru import logger
from models import AssistantConfig from models import AssistantConfig, RuntimeTool
from pipecat.adapters.schemas.function_schema import FunctionSchema from db.session import SessionLocal
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame from pipecat.flows import (
ContextStrategy,
ContextStrategyConfig,
FlowManager,
FlowsFunctionSchema,
NodeConfig,
)
from pipecat.frames.frames import (
LLMRunFrame,
LLMUpdateSettingsFrame,
OutputTransportMessageUrgentFrame,
TTSSpeakFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameProcessor from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.settings import LLMSettings
from pipecat.utils.time import time_now_iso8601
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
from services.knowledge import search as search_knowledge
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
from services.workflow_engine import WorkflowEngine from services.workflow_engine import WorkflowEngine
from services.workflow_router import STAY_ON_CURRENT_AGENT, WorkflowLLMRouter
@dataclass MAX_AUTOMATIC_HOPS = 50
class WorkflowState: AGENT_STAGE_INSTRUCTION = (
current: str "工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
ended: bool = False "不要自行解释、模拟或宣布节点切换。"
turns_in_node: int = 0 )
end_turn_id: str | None = None
class WorkflowBrain(BaseBrain): class WorkflowBrain(BaseBrain):
@@ -30,22 +46,29 @@ class WorkflowBrain(BaseBrain):
supported_runtime_modes=frozenset({"pipeline"}), supported_runtime_modes=frozenset({"pipeline"}),
owns_context=True, 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 {}) self._engine = WorkflowEngine(graph or {})
if not self._engine.has_graph() or not self._engine.start_id: if not self._engine.has_graph() or not self._engine.start_id:
raise ValueError("WorkflowBrain 缺少有效的 startCall 节点") raise ValueError("WorkflowBrain 缺少有效的 Start 节点")
self._state = WorkflowState(current=self._engine.start_id) self._cfg = cfg
self._history: list[dict[str, str]] = [] self._store = DynamicVariableStore.from_config(cfg or AssistantConfig(type="workflow"))
self._cfg: AssistantConfig | None = None 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._runtime: BrainRuntime | None = None
self._manager: FlowManager | None = None
self._router = WorkflowLLMRouter(cfg or AssistantConfig(type="workflow"))
self._ended = False
async def greeting(self, cfg: AssistantConfig) -> str: 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: 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: def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
from services.pipecat.service_factory import create_llm from services.pipecat.service_factory import create_llm
@@ -53,72 +76,422 @@ class WorkflowBrain(BaseBrain):
return create_llm(cfg) return create_llm(cfg)
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None: 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._cfg = cfg
self._runtime = runtime self._runtime = runtime
for edge in self._engine.edges: self._store = DynamicVariableStore.from_config(cfg)
if edge.get("target"): self._tools = ToolExecutor(self._store)
runtime.llm.register_function( self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._engine.edge_fn_name(edge), self._router = WorkflowLLMRouter(cfg)
self._make_transition_handler(edge), self._manager = FlowManager(
) worker=runtime.worker,
self._apply_node(self._state.current) llm=runtime.llm,
logger.info( context_aggregator=runtime.context_aggregator,
f"工作流模式启用: 起始节点={self._engine.name(self._state.current)}" transport=runtime.transport,
global_functions=runtime.flow_global_functions,
) )
self._manager.state["variables"] = self._store.values
async def on_connected(self) -> None: 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 and self._engine.has_outgoing(self._engine.start_id):
raise RuntimeError("Start 初始化后没有命中的表达式边或默认边")
node_config = (
await self._follow_edge(edge)
if edge
else self._passive_node_config(self._engine.start_id)
)
if self._manager is None:
raise RuntimeError("Workflow FlowManager 尚未初始化")
await self._manager.initialize(node_config)
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
def record_user_message(self, content: str) -> None: def record_user_message(self, content: str) -> None:
if content: if content and not self._ended:
self._history.append({"role": "user", "content": content}) self._store.record("user", content)
async def on_assistant_text_start(self, turn_id: str) -> None: async def on_user_turn_end(self, content: str) -> bool:
if self._state.ended and self._state.end_turn_id is None: """Route a complete user turn before any Agent is allowed to reply."""
self._state.end_turn_id = turn_id if not content or self._ended:
return True
self.record_user_message(content)
manager = self._require_manager()
current = manager.current_node
if not current or self._engine.node_type(current) != "agent":
return True
edge = self._engine.deterministic_edge(
current,
self._store,
include_default=False,
)
outgoing = self._engine.outgoing(current)
llm_edges = [
candidate
for candidate in outgoing
if self._engine.edge_mode(candidate) == "llm"
]
default_edge = next(
(
candidate
for candidate in outgoing
if self._engine.edge_mode(candidate) == "always"
),
None,
)
if edge is None and llm_edges:
selected = await self._router_for_node(current).select_edge(
node_name=self._engine.name(current),
node_prompt=self._engine.prompt_for(current, self._store),
edges=llm_edges,
history=self._store.history,
variables={
key: value
for key, value in self._store.values.items()
if not key.startswith("system__")
},
edge_name=self._engine.edge_fn_name,
edge_description=self._engine.edge_description,
)
if selected and selected != STAY_ON_CURRENT_AGENT:
edge = next(
(
candidate
for candidate in llm_edges
if self._engine.edge_fn_name(candidate) == selected
),
None,
)
elif selected == STAY_ON_CURRENT_AGENT:
edge = default_edge
elif edge is None and not llm_edges:
edge = default_edge
if edge and manager.current_node == current:
next_config = await self._follow_edge(edge)
await manager.set_node_from_config(next_config)
return True
# The incoming LLMContextFrame is intentionally suppressed by the
# pipeline router. Queue prompt refresh + inference in this order so
# this user turn is answered with the current Agent's latest variables.
await self._refresh_agent_prompt(current)
await self._require_runtime().queue_frame(LLMRunFrame())
return True
async def on_assistant_text_end( async def on_assistant_text_end(
self, self,
turn_id: str, _turn_id: str,
content: str, content: str,
interrupted: bool, interrupted: bool,
) -> None: ) -> None:
if not content or interrupted: if not content or interrupted or self._ended:
return return
self._history.append({"role": "assistant", "content": content}) self._store.record("agent", content, completed_agent_turn=True)
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()
def _apply_node(self, node_id: str) -> None: async def _refresh_agent_prompt(self, node_id: str) -> None:
runtime = self._require_runtime() runtime = self._require_runtime()
runtime.set_system_prompt(self._engine.system_prompt_for(node_id)) await runtime.queue_frame(
if self._engine.is_end(node_id): LLMUpdateSettingsFrame(
runtime.set_tools([]) delta=LLMSettings(
return system_instruction=self._agent_role_message(node_id)
runtime.set_tools(
[
FunctionSchema(
name=self._engine.edge_fn_name(edge),
description=self._engine.edge_description(edge),
properties={},
required=[],
) )
for edge in self._engine.outgoing(node_id) )
]
) )
async def _go_to_node(self, target: str) -> None: def _agent_role_message(self, node_id: str) -> str:
self._state.current = target """Build one provider-compatible system instruction for an Agent stage."""
self._state.turns_in_node = 0 stage_prompt = self._engine.prompt_for(node_id, self._store)
if self._engine.is_end(target): return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
self._state.ended = True
await self._emit_node_active(target) def _router_for_node(self, node_id: str) -> WorkflowLLMRouter:
self._apply_node(target) stage = self._engine.agent_stage_config(node_id)
resource_id = stage.llm_resource_id
cfg = self._cfg
resource = cfg.workflow_model_resources.get(resource_id) if cfg else None
if not cfg or not resource:
return self._router
from services.pipecat.service_factory import config_with_resource
return WorkflowLLMRouter(config_with_resource(cfg, resource))
async def _apply_agent_stage(self, node_id: str) -> None:
stage = self._engine.agent_stage_config(node_id)
await self._emit_node_active(node_id)
if self._runtime and self._runtime.set_input_enabled:
self._runtime.set_input_enabled(True)
runtime = self._require_runtime()
if runtime.switch_services:
await runtime.switch_services(
stage.llm_resource_id or None,
stage.asr_resource_id or None,
stage.tts_resource_id or None,
)
if runtime.set_knowledge_scope:
runtime.set_knowledge_scope(
{
"knowledge_base_id": stage.knowledge_base_id,
"mode": stage.knowledge_mode,
"top_n": stage.knowledge_top_n,
"score_threshold": stage.knowledge_score_threshold,
}
)
def _agent_config(self, node_id: str) -> NodeConfig:
data = self._engine.data(node_id)
entry_mode = str(data.get("entryMode") or "wait_user")
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
strategy = (
ContextStrategy.RESET
if data.get("contextPolicy") == "fresh"
else ContextStrategy.APPEND
)
stage = self._engine.agent_stage_config(node_id)
functions: list[FlowsFunctionSchema] = []
for tool_id in stage.tool_ids:
tool = self._tool_by_id.get(str(tool_id))
if tool and tool.type == "http":
functions.append(self._flow_tool(tool, node_id))
knowledge_function = self._knowledge_function(node_id)
if knowledge_function:
functions.append(knowledge_function)
config: NodeConfig = {
"name": node_id,
"role_message": self._agent_role_message(node_id),
"task_messages": (
[{"role": "assistant", "content": entry_speech}]
if entry_mode == "fixed_speech"
else []
),
"functions": functions,
"context_strategy": ContextStrategyConfig(strategy=strategy),
"respond_immediately": entry_mode == "generate",
}
if entry_mode == "fixed_speech":
config["pre_actions"] = [
{
"type": "workflow_fixed_speech",
"text": entry_speech,
"handler": self._play_fixed_speech,
}
]
return config
async def _play_fixed_speech(self, action: dict, _flow_manager: FlowManager) -> None:
"""Play and persist Agent entry speech without creating an LLM turn."""
await self._queue_visible_speech(str(action.get("text") or ""))
async def _queue_visible_speech(self, text: str) -> None:
"""Show and persist fixed workflow speech before sending it to TTS."""
content = text.strip()
if not content:
return
self._store.record("agent", content)
runtime = self._require_runtime()
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "transcript",
"role": "assistant",
"content": content,
"timestamp": time_now_iso8601(),
}
)
)
await runtime.queue_frame(TTSSpeakFrame(content, append_to_context=False))
def _passive_node_config(self, node_id: str) -> NodeConfig:
"""Keep a non-conversational terminal node active without ending the call."""
return {
"name": node_id,
"role_message": self._store.render(self._engine.global_prompt()),
"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,
cancel_on_interruption=True,
)
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
stage = self._engine.agent_stage_config(node_id)
knowledge_id = str(stage.knowledge_base_id or "")
if not knowledge_id or stage.knowledge_mode != "on_demand":
return None
cfg = self._cfg or AssistantConfig(type="workflow")
knowledge = cfg.workflow_knowledge_bases.get(knowledge_id)
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=stage.knowledge_top_n,
score_threshold=stage.knowledge_score_threshold,
)
return {"status": "ok", "results": results}
except Exception as exc: # noqa: BLE001 - tool errors are returned to the LLM
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,
cancel_on_interruption=True,
)
async def _follow_edge(self, edge: dict) -> NodeConfig:
speech = self._engine.edge_transition_speech(edge)
if speech:
await self._queue_visible_speech(self._store.render(speech))
return await self._resolve_path(str(edge.get("target") or ""))
async def _resolve_path(self, node_id: str) -> NodeConfig:
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._passive_node_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}")
if not self._engine.has_outgoing(node_id):
return self._passive_node_config(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._queue_visible_speech(self._store.render(speech))
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._queue_visible_speech(message)
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 self._queue_visible_speech(message)
return
runtime.call_end.begin("workflow_completed")
if message:
runtime.call_end.arm_after_speech()
await self._queue_visible_speech(message)
else:
await runtime.call_end.finish()
async def _emit_node_active(self, node_id: str | None) -> None: async def _emit_node_active(self, node_id: str | None) -> None:
if node_id: if node_id:
@@ -128,61 +501,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: def _require_runtime(self) -> BrainRuntime:
if self._runtime is None: if self._runtime is None:
raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime") raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime")
return self._runtime return self._runtime
def _require_config(self) -> AssistantConfig: def _require_manager(self) -> FlowManager:
if self._cfg is None: if self._manager is None:
raise RuntimeError("WorkflowBrain 尚未初始化配置") raise RuntimeError("Workflow FlowManager 尚未初始化")
return self._cfg return self._manager
@staticmethod
def _require(value: str, label: str) -> str:
if value:
return value
raise ValueError(f"缺少模型资源配置: {label}")

View File

@@ -12,7 +12,13 @@ from db.models import (
ModelResource, ModelResource,
Tool, 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 import select
from sqlalchemy.ext.asyncio import AsyncSession 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]: async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[RuntimeTool]:
if assistant.type != "prompt": if assistant.type not in {"prompt", "workflow"}:
return [] return []
tools = ( tools = (
await session.execute( await session.execute(
@@ -134,6 +140,43 @@ async def resolve_runtime_config(
else None 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( return AssistantConfig(
name=assistant.name, name=assistant.name,
type=assistant.type, type=assistant.type,
@@ -150,7 +193,9 @@ async def resolve_runtime_config(
knowledge_base_description=knowledge_base.description if knowledge_base else "", knowledge_base_description=knowledge_base.description if knowledge_base else "",
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {}, knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话 # 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,直接注入) # 外部托管类型连接信息(DB 存真 key,直接注入)
dify_api_url=str(_value(agent_resource, "apiUrl", assistant.api_url)), dify_api_url=str(_value(agent_resource, "apiUrl", assistant.api_url)),
dify_api_key=_secret(agent_resource, "apiKey", assistant.api_key), dify_api_key=_secret(agent_resource, "apiKey", assistant.api_key),

View File

@@ -1,132 +1,110 @@
"""工作流节点规格 + 图校验(对齐 dograh 的 node-spec / GraphConstraints 思路)。 """Workflow v3 node catalog, v2 compatibility normalization, and validation."""
当前实现 4 个核心节点:开始(startCall)/智能体(agentNode)/结束(endCall)/全局(globalNode)。
本模块是「节点类型」的唯一事实源:
- /api/node-types 接口直接吐这里的规格;
- 助手保存时用这里的约束校验 workflow 图。
新增节点类型只需在 NODE_SPECS 里加一条并补充约束。前端 specs.ts 与此保持一致。
"""
from __future__ import annotations from __future__ import annotations
from collections import defaultdict, deque
from copy import deepcopy
from typing import Any from typing import Any
# 规格版本号:节点定义有破坏性变更时 +1,前端可据此判断是否需要刷新缓存。
SPEC_VERSION = "2"
# 每个节点的图约束。None 表示不限制。 SPEC_VERSION = "3"
# min_incoming / max_incoming:入边数量 NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
# min_outgoing / max_outgoing:出边数量 EDGE_MODES = {"llm", "expression", "always"}
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
AUTOMATIC_NODE_TYPES = {"start", "action", "handoff"}
EXPRESSION_OPERATORS = {
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"in",
"exists",
}
NODE_SPECS: list[dict[str, Any]] = [ NODE_SPECS: list[dict[str, Any]] = [
{ {
"name": "startCall", "name": "start",
"displayName": "开始", "displayName": "Start",
"category": "call_node", "category": "control_node",
"description": "工作流入口,每个流程有且仅有一个。播放开场白,并用自己的提示词进行多轮对话,满足出边条件后流转", "description": "初始化会话、动态变量和全局观察器,可播放固定开场白",
"icon": "Play", "icon": "Play",
"accent": "mint", "accent": "mint",
"addable": False, "addable": False,
"constraints": { "constraints": {
"minIncoming": 0, "minIncoming": 0,
"maxIncoming": 0, "maxIncoming": 0,
"minOutgoing": 0,
"minInstances": 1, "minInstances": 1,
"maxInstances": 1, "maxInstances": 1,
}, },
"fields": [ "fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "开始"}, {"key": "name", "label": "节点名称", "type": "text", "default": "Start"},
{"key": "greeting", "label": "开场白", "type": "textarea", "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,
},
], ],
}, },
{ {
"name": "agentNode", "name": "agent",
"displayName": "智能体节点", "displayName": "Agent",
"category": "call_node", "category": "conversation_node",
"description": "对话处理单元。按提示词与用户多轮交互,可有多个并通过条件边流转", "description": "阶段智能体:绑定上下文、工具、知识库及 ASR/TTS 资源",
"icon": "Bot", "icon": "Bot",
"accent": "sky", "accent": "sky",
"addable": True, "addable": True,
"constraints": {"minIncoming": 1}, "constraints": {"minIncoming": 1, "minOutgoing": 0},
"fields": [ "fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "智能体节点"}, {"key": "name", "label": "节点名称", "type": "text", "default": "Agent"},
{ {
"key": "prompt", "key": "prompt",
"label": "节点提示词", "label": "阶段提示词",
"type": "textarea", "type": "textarea",
"required": True, "required": True,
"default": "", "default": "",
}, },
{
"key": "allowInterrupt",
"label": "允许用户打断",
"type": "switch",
"default": True,
},
{
"key": "addGlobalPrompt",
"label": "应用全局提示词",
"type": "switch",
"default": True,
},
], ],
}, },
{ {
"name": "endCall", "name": "action",
"displayName": "结束", "displayName": "Action",
"category": "call_node", "category": "execution_node",
"description": "终止节点,礼貌结束对话。可有多个,均无出边", "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": 0},
"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", "icon": "Flag",
"accent": "rose", "accent": "rose",
"addable": True, "addable": True,
"constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0}, "constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0},
"fields": [ "fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "结束"}, {"key": "name", "label": "节点名称", "type": "text", "default": "End"},
{"key": "prompt", "label": "结束语提示词", "type": "textarea", "default": ""}, {"key": "message", "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": "你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
},
], ],
}, },
] ]
@@ -135,108 +113,372 @@ _SPEC_BY_NAME = {spec["name"]: spec for spec in NODE_SPECS}
def node_types_response() -> dict[str, Any]: def node_types_response() -> dict[str, Any]:
"""/api/node-types 的响应体(camelCase,直接喂前端)。"""
return {"specVersion": SPEC_VERSION, "nodeTypes": NODE_SPECS} return {"specVersion": SPEC_VERSION, "nodeTypes": NODE_SPECS}
def validate_graph(graph: dict[str, Any]) -> list[str]: def _edge_data_v3(edge: dict, source_type: str) -> dict:
"""校验 workflow 图,返回错误信息列表(空列表 = 通过)。 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. 入边/出边数量满足各节点类型的约束。
空图(无节点)视为草稿,直接放行,方便先存后编排。 def _normalize_agent_data(data: dict[str, Any]) -> None:
""" """Add v3 Agent defaults without changing existing node-level behavior."""
errors: list[str] = [] data.setdefault("contextPolicy", "inherit")
nodes = graph.get("nodes") or [] data.setdefault("entryMode", "wait_user")
edges = graph.get("edges") or [] data.setdefault("entrySpeech", "")
if "inheritGlobalConfig" not in data:
has_node_overrides = any(
(
data.get("llmResourceId"),
data.get("asrResourceId"),
data.get("ttsResourceId"),
data.get("knowledgeBaseId"),
data.get("toolIds"),
)
)
data["inheritGlobalConfig"] = not has_node_overrides
if not nodes:
return errors # 草稿:放行
node_ids: set[str] = set() def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") -> None:
type_counts: dict[str, int] = {} settings.setdefault("globalPrompt", global_prompt)
node_type_by_id: dict[str, str] = {} settings.setdefault("defaultLlmResourceId", "")
settings.setdefault("defaultAsrResourceId", "")
settings.setdefault("defaultTtsResourceId", "")
settings.setdefault("toolIds", [])
settings.setdefault("knowledgeBaseId", "")
settings.setdefault("knowledgeMode", "automatic")
settings.setdefault("knowledgeTopN", 5)
settings.setdefault("knowledgeScoreThreshold", 0.0)
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
"""Return a deep-copied v3 graph; preserve v3 IDs and migrate v2 semantics."""
source = deepcopy(graph or {})
if str(source.get("specVersion") or "") == SPEC_VERSION:
settings = source.setdefault("settings", {})
_normalize_settings(settings)
source.setdefault("nodes", [])
source.setdefault("edges", [])
for node in source["nodes"]:
if node.get("type") != "agent":
continue
data = node.setdefault("data", {})
_normalize_agent_data(data)
return source
nodes = source.get("nodes") or []
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: for node in nodes:
node_id = node.get("id") old_type = str(node.get("type") or "")
node_type = node.get("type") data = deepcopy(node.get("data") or {})
if not node_id: if old_type == "globalNode":
errors.append("存在缺少 id 的节点") global_prompt = str(data.get("prompt") or "")
continue continue
if node_id in node_ids: new_type = type_map.get(old_type, old_type)
errors.append(f"节点 id 重复:{node_id}") migrated = deepcopy(node)
node_ids.add(node_id) migrated["type"] = new_type
if node_type not in _SPEC_BY_NAME: if new_type == "end":
errors.append(f"未知节点类型:{node_type}(节点 {node_id})") data["message"] = data.pop("message", data.pop("prompt", ""))
continue data.setdefault("scope", "session")
node_type_by_id[node_id] = node_type elif new_type == "agent":
type_counts[node_type] = type_counts.get(node_type, 0) + 1 _normalize_agent_data(data)
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) mapped_edges: list[dict] = []
if start_count == 0: for edge in edges:
errors.append("工作流必须有一个「开始」节点") migrated = deepcopy(edge)
elif start_count > 1: migrated["data"] = _edge_data_v3(
errors.append("工作流只能有一个「开始」节点") migrated, type_by_id.get(str(migrated.get("source")), "")
)
mapped_edges.append(migrated)
if type_counts.get("endCall", 0) == 0: # A v2 Start was conversational. Insert a synthetic Agent so its prompt remains active.
errors.append("工作流至少需要一个「结束」节点") for start_id, prompt in start_prompt_nodes.items():
synthetic_id = f"{start_id}-migrated-agent"
for node_type, spec in _SPEC_BY_NAME.items(): 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}
if node_type == "startCall": mapped_nodes.append(
continue {
constraints = spec["constraints"] "id": synthetic_id,
count = type_counts.get(node_type, 0) "type": "agent",
_check_count( "position": {"x": position.get("x", 100) + 300, "y": position.get("y", 120)},
errors, "data": {
count, "name": "迁移的开场 Agent",
constraints, "prompt": prompt,
"Instances", "contextPolicy": "inherit",
node_type, "inheritGlobalConfig": True,
"实例", "entryMode": "wait_user",
"entrySpeech": "",
},
}
)
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": ""},
}
) )
# 统计入边/出边 settings = deepcopy(source.get("settings") or {})
incoming: dict[str, int] = {nid: 0 for nid in node_ids} _normalize_settings(settings, global_prompt=global_prompt)
outgoing: dict[str, int] = {nid: 0 for nid in node_ids} return {
for edge in edges: "specVersion": 3,
source = edge.get("source") "settings": settings,
target = edge.get("target") "nodes": mapped_nodes,
if source not in node_ids: "edges": mapped_edges,
errors.append(f"连线指向了不存在的源节点:{source}") **({"viewport": deepcopy(source["viewport"])} if source.get("viewport") else {}),
continue }
if target not in node_ids:
errors.append(f"连线指向了不存在的目标节点:{target}")
continue
outgoing[source] += 1
incoming[target] += 1
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 return errors
def _check_count( def validate_graph(graph: dict[str, Any]) -> list[str]:
errors: list[str], graph = normalize_graph(graph)
actual: int, nodes = graph.get("nodes") or []
constraints: dict[str, int], edges = graph.get("edges") or []
suffix: str, if not nodes:
node_type: str, return []
label: str,
) -> None: errors: list[str] = []
lo = constraints.get(f"min{suffix}") node_by_id: dict[str, dict] = {}
hi = constraints.get(f"max{suffix}") counts: dict[str, int] = defaultdict(int)
display = _SPEC_BY_NAME[node_type]["displayName"] for node in nodes:
if lo is not None and actual < lo: node_id = str(node.get("id") or "")
errors.append(f"{display}」节点{label}数量不能少于 {lo}(当前 {actual})") node_type = str(node.get("type") or "")
if hi is not None and actual > hi: if not node_id:
errors.append(f"{display}」节点{label}数量不能多于 {hi}(当前 {actual})") 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 node_type == "agent":
data = node.get("data") or {}
entry_mode = data.get("entryMode", "wait_user")
if entry_mode not in AGENT_ENTRY_MODES:
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}")
elif entry_mode == "fixed_speech" and not str(
data.get("entrySpeech") or ""
).strip():
errors.append(f"Agent 节点 {node_id} 的固定进入语不能为空")
if counts["start"] != 1:
errors.append("工作流必须有且仅有一个 Start 节点")
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":
expression_errors = _validate_expression(data.get("expression"))
errors.extend(f"{edge_id}:{item}" for item in expression_errors)
try:
priority = int(data.get("priority", 10))
except (TypeError, ValueError):
errors.append(f"{edge_id} 的优先级必须是整数")
priority = 10
if priority in priorities[source_id]:
errors.append(f"节点 {source_id} 的出边优先级不能重复:{priority}")
priorities[source_id].add(priority)
if mode == "always":
always_counts[source_id] += 1
if always_counts[source_id] > 1:
errors.append(f"节点 {source_id} 最多只能有一条默认边")
incoming[target_id] += 1
outgoing[source_id] += 1
adj[source_id].append(target_id)
source_is_automatic = node_by_id[source_id].get("type") != "agent"
target_is_automatic = node_by_id[target_id].get("type") != "agent"
if source_is_automatic and target_is_automatic:
auto_adj[source_id].append(target_id)
for node_id, node in node_by_id.items():
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}")
node_type = node.get("type")
if (
node_type in AUTOMATIC_NODE_TYPES
and outgoing[node_id] > 0
and always_counts[node_id] != 1
):
errors.append(f"自动节点 {node_id} 存在出边时必须有且仅有一条默认边")
start_id = next(
(node_id for node_id, node in node_by_id.items() if node.get("type") == "start"),
None,
)
if start_id:
reached = {start_id}
queue = deque([start_id])
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
automatic_node_ids = (
node_id
for node_id, node in node_by_id.items()
if node.get("type") != "agent"
)
if any(visit(node_id) for node_id in automatic_node_ids):
errors.append("Start/Action/Handoff/End 之间不能形成无等待循环")
return list(dict.fromkeys(errors))
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("defaultLlmResourceId"),
settings.get("defaultAsrResourceId"),
settings.get("defaultTtsResourceId"),
)
if value
}
tools: set[str] = {str(tool_id) for tool_id in settings.get("toolIds") or []}
knowledge: set[str] = (
{str(settings["knowledgeBaseId"])}
if settings.get("knowledgeBaseId")
else set()
)
for node in normalized.get("nodes") or []:
data = node.get("data") or {}
inherits_global = (
node.get("type") == "agent" and data.get("inheritGlobalConfig", True)
)
if not inherits_global:
for resource_id in (
data.get("llmResourceId"),
data.get("asrResourceId"),
data.get("ttsResourceId"),
):
if resource_id:
resources.add(str(resource_id))
for tool_id in data.get("toolIds") or []:
tools.add(str(tool_id))
if data.get("knowledgeBaseId"):
knowledge.add(str(data["knowledgeBaseId"]))
if data.get("toolId"):
tools.add(str(data["toolId"]))
return {"model_resources": resources, "tools": tools, "knowledge_bases": knowledge}

View File

@@ -23,6 +23,8 @@ from services.pipecat.call_lifecycle import (
EndCallAfterSpeechProcessor, EndCallAfterSpeechProcessor,
) )
from services.pipecat.service_factory import ( from services.pipecat.service_factory import (
config_with_resource,
create_llm,
create_realtime_service, create_realtime_service,
create_stt, create_stt,
create_tts, create_tts,
@@ -32,6 +34,7 @@ from services.knowledge import search as search_knowledge
from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.flows import FlowsFunctionSchema
from pipecat.frames.frames import ( from pipecat.frames.frames import (
EndFrame, EndFrame,
InputTransportMessageFrame, InputTransportMessageFrame,
@@ -40,6 +43,7 @@ from pipecat.frames.frames import (
LLMFullResponseStartFrame, LLMFullResponseStartFrame,
LLMContextFrame, LLMContextFrame,
LLMTextFrame, LLMTextFrame,
ManuallySwitchServiceFrame,
LLMMessagesAppendFrame, LLMMessagesAppendFrame,
OutputTransportMessageUrgentFrame, OutputTransportMessageUrgentFrame,
TextFrame, TextFrame,
@@ -48,6 +52,8 @@ from pipecat.frames.frames import (
UserImageRequestFrame, UserImageRequestFrame,
) )
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.llm_switcher import LLMSwitcher
from pipecat.pipeline.service_switcher import ServiceSwitcher
from pipecat.pipeline.worker import PipelineParams, PipelineWorker from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.processors.aggregators.llm_response_universal import (
@@ -397,15 +403,59 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
self._knowledge_base_id = knowledge_base_id self._knowledge_base_id = knowledge_base_id
self._top_n = top_n self._top_n = top_n
self._score_threshold = score_threshold self._score_threshold = score_threshold
self._mode = "automatic" if knowledge_base_id else "disabled"
self._last_signature = "" 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): async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction) 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) await self.push_frame(frame, direction)
return return
messages = frame.context.get_messages() 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"] user_messages = [message for message in messages if message.get("role") == "user"]
if not user_messages: if not user_messages:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
@@ -435,16 +485,53 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
for index, item in enumerate(results) for index, item in enumerate(results)
) or "未检索到相关资料。" ) or "未检索到相关资料。"
block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}" block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}"
system_message = next((message for message in messages if message.get("role") == "system"), None) self._set_context(messages, block)
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
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
class UserTurnRoutingProcessor(FrameProcessor):
"""Give a brain first right of refusal before a new user turn reaches the LLM."""
def __init__(self, brain: Brain):
super().__init__()
self._brain = brain
self._last_user_message: dict | None = None
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if direction != FrameDirection.DOWNSTREAM or not isinstance(
frame, LLMContextFrame
):
await self.push_frame(frame, direction)
return
user_message = next(
(
message
for message in reversed(frame.context.get_messages())
if message.get("role") == "user"
and isinstance(message.get("content"), str)
and str(message.get("content") or "").strip()
),
None,
)
if user_message is None:
await self.push_frame(frame, direction)
return
if user_message is self._last_user_message:
# Programmatic LLMRunFrame after a node transition reuses the same
# user message. It is a response run, not another routing event.
await self.push_frame(frame, direction)
return
self._last_user_message = user_message
content = str(user_message.get("content") or "").strip()
handled = await self._brain.on_user_turn_end(content)
if not handled:
await self.push_frame(frame, direction)
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator): class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。""" """聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
@@ -457,7 +544,6 @@ class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
self._stream_turn_id: str | None = None self._stream_turn_id: str | None = None
self._stream_timestamp = "" self._stream_timestamp = ""
self._stream_text = "" self._stream_text = ""
async def process_frame(self, frame, direction: FrameDirection): async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
@@ -505,6 +591,72 @@ class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
self._stream_text = "" 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
def _workflow_llm_switcher(cfg: AssistantConfig, base_service):
"""Build an LLM switcher for the global model and Agent overrides."""
settings = cfg.graph.get("settings") or {}
default_id = str(settings.get("defaultLlmResourceId") or "")
services_by_id = {}
for resource_id, resource in cfg.workflow_model_resources.items():
if resource.capability != "LLM":
continue
services_by_id[resource_id] = (
base_service
if resource_id == default_id
else create_llm(config_with_resource(cfg, resource))
)
primary = services_by_id.get(default_id, base_service)
services = [primary]
services.extend(
service for service in services_by_id.values() if service is not primary
)
if base_service is not primary:
services.append(base_service)
return LLMSwitcher(llms=services), services_by_id, primary
async def run_pipeline( async def run_pipeline(
transport, transport,
cfg: AssistantConfig, cfg: AssistantConfig,
@@ -545,8 +697,38 @@ async def run_pipeline(
) )
return return
stt = create_stt(cfg) graph_settings = cfg.graph.get("settings") or {}
tts = create_tts(cfg) default_llm_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultLlmResourceId") or "")
)
default_asr_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultAsrResourceId") or "")
)
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) greeting = await brain.greeting(cfg)
system_content = brain.system_prompt(cfg) system_content = brain.system_prompt(cfg)
@@ -594,17 +776,33 @@ async def run_pipeline(
return "\n\n".join(part for part in [text, *hints] if part) return "\n\n".join(part for part in [text, *hints] if part)
context = LLMContext( 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 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
llm = brain.build_llm(cfg, context) llm = brain.build_llm(
config_with_resource(cfg, default_llm_resource)
if cfg.type == "workflow" and default_llm_resource
else cfg,
context,
)
llm_services: dict[str, FrameProcessor] = {}
current_llm_service = llm
if cfg.type == "workflow":
llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
user_aggregator = LLMUserAggregator( user_aggregator = LLMUserAggregator(
context, context,
params=LLMUserAggregatorParams( params=LLMUserAggregatorParams(
vad_analyzer=create_vad_analyzer(cfg.turnConfig), vad_analyzer=create_vad_analyzer(cfg.turnConfig),
user_mute_strategies=[ user_mute_strategies=[
FunctionCallUserMuteStrategy(), FunctionCallUserMuteStrategy(),
CallEndingUserMuteStrategy(lambda: call_end.ending), CallEndingUserMuteStrategy(
lambda: call_end.ending or not input_state["enabled"]
),
], ],
user_turn_strategies=create_user_turn_strategies( user_turn_strategies=create_user_turn_strategies(
cfg.turnConfig, cfg.turnConfig,
@@ -612,8 +810,11 @@ async def run_pipeline(
), ),
), ),
) )
user_turn_router = UserTurnRoutingProcessor(brain)
assistant_aggregator = PassthroughLLMAssistantAggregator(context) 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() vision_capture = VisionCaptureProcessor()
knowledge_retrieval = KnowledgeRetrievalProcessor( knowledge_retrieval = KnowledgeRetrievalProcessor(
automatic_knowledge_id, automatic_knowledge_id,
@@ -723,14 +924,55 @@ 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,
cancel_on_interruption=True,
)
)
if vision_enabled and cfg.type != "workflow":
llm.register_function(VISION_TOOL_NAME, fetch_user_image) llm.register_function(VISION_TOOL_NAME, fetch_user_image)
if cfg.knowledge_base_id and knowledge_mode == "on_demand": if cfg.knowledge_base_id and knowledge_mode == "on_demand":
llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge) llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge)
def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None: def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None:
tools = list(schemas or []) tools = list(schemas or [])
if vision_enabled: if vision_enabled and cfg.type != "workflow":
tools.append(vision_schema) tools.append(vision_schema)
if cfg.knowledge_base_id and knowledge_mode == "on_demand": if cfg.knowledge_base_id and knowledge_mode == "on_demand":
tools.append(knowledge_schema) tools.append(knowledge_schema)
@@ -751,15 +993,16 @@ async def run_pipeline(
transport.input(), transport.input(),
vision_capture, vision_capture,
text_input, text_input,
stt, stt_processor,
user_aggregator, user_aggregator,
user_turn_router,
knowledge_retrieval, knowledge_retrieval,
llm, llm,
# Aggregate the streamed LLM text before TTS. On interruption, # Aggregate the streamed LLM text before TTS. On interruption,
# Pipecat commits the generated prefix immediately instead of # Pipecat commits the generated prefix immediately instead of
# waiting for a TTS provider to emit spoken-text/timestamp frames. # waiting for a TTS provider to emit spoken-text/timestamp frames.
assistant_aggregator, assistant_aggregator,
tts, tts_processor,
EndCallAfterSpeechProcessor(call_end), EndCallAfterSpeechProcessor(call_end),
ConversationHistoryProcessor(recorder), ConversationHistoryProcessor(recorder),
transport.output(), transport.output(),
@@ -774,6 +1017,51 @@ async def run_pipeline(
enable_rtvi=False, enable_rtvi=False,
) )
worker_holder["worker"] = worker worker_holder["worker"] = worker
default_workflow_services = {
"llm": current_llm_service,
**current_voice_services,
}
async def switch_workflow_services(
llm_resource_id: str | None,
asr_resource_id: str | None,
tts_resource_id: str | None,
) -> None:
nonlocal current_llm_service
requested = (
("llm", llm_services, llm_resource_id),
("asr", stt_services, asr_resource_id),
("tts", tts_services, tts_resource_id),
)
for kind, services, resource_id in requested:
target = (
services.get(resource_id)
if resource_id
else default_workflow_services[kind]
)
if target is None:
raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
current = (
current_llm_service
if kind == "llm"
else current_voice_services[kind]
)
if current is target:
continue
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
if kind == "llm":
current_llm_service = target
else:
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: async def queue_transcript(role: str, content: str, timestamp: str) -> None:
if content: if content:
@@ -810,6 +1098,16 @@ async def run_pipeline(
set_system_prompt=set_system_prompt, set_system_prompt=set_system_prompt,
set_tools=set_visible_tools, set_tools=set_visible_tools,
call_end=call_end, 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,
), ),
) )
@@ -823,8 +1121,6 @@ async def run_pipeline(
@user_aggregator.event_handler("on_user_turn_stopped") @user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(_aggregator, _strategy, message): async def on_user_turn_stopped(_aggregator, _strategy, message):
if message.content:
brain.record_user_message(message.content)
await queue_transcript("user", message.content, message.timestamp) await queue_transcript("user", message.content, message.timestamp)
@assistant_aggregator.event_handler("on_assistant_text_start") @assistant_aggregator.event_handler("on_assistant_text_start")
@@ -869,7 +1165,6 @@ async def run_pipeline(
@text_input.event_handler("on_text_input") @text_input.event_handler("on_text_input")
async def on_text_input(_processor, text): async def on_text_input(_processor, text):
pending_text_inputs.append(text) pending_text_inputs.append(text)
brain.record_user_message(text)
# 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。 # 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
await queue_transcript("user", text, time_now_iso8601()) await queue_transcript("user", text, time_now_iso8601())

View File

@@ -5,7 +5,7 @@
""" """
from loguru import logger from loguru import logger
from models import AssistantConfig from models import AssistantConfig, RuntimeModelResource
from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.openai.stt import OpenAISTTService from pipecat.services.openai.stt import OpenAISTTService
@@ -26,6 +26,42 @@ from services.pipecat.xfyun_tts import DEFAULT_XFYUN_TTS_URL, XfyunTTSService
TTS_STOP_FRAME_TIMEOUT_S = 1.0 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 model resource."""
result = cfg.model_copy(deep=True)
values = resource.values or {}
secrets = resource.secrets or {}
if resource.capability == "LLM":
result.model = str(values.get("modelId") or "")
result.llm_interface_type = resource.interface_type
result.llm_values = values
result.llm_secrets = secrets
result.llm_api_key = str(secrets.get("apiKey") or "")
result.llm_base_url = str(values.get("apiUrl") or "")
elif resource.capability == "ASR":
result.asr = str(values.get("modelId") or "")
result.stt_language = str(values.get("language") or "")
result.stt_interface_type = resource.interface_type
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: def _require(value: str, label: str) -> str:
if value: if value:
return value return value

View File

@@ -175,9 +175,9 @@ def prepare_dynamic_config(
trusted_values: dict[str, Any] | None = None, trusted_values: dict[str, Any] | None = None,
) -> AssistantConfig: ) -> AssistantConfig:
"""Validate and merge one call's values without mutating stored config.""" """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: if client_values:
raise DynamicVariableError("动态变量目前仅支持 prompt 助手") raise DynamicVariableError("动态变量仅支持 prompt 和 workflow 助手")
return cfg return cfg
supplied = _validate_public(client_values) supplied = _validate_public(client_values)
@@ -214,9 +214,13 @@ def prepare_dynamic_config(
prepared = cfg.model_copy(deep=True) prepared = cfg.model_copy(deep=True)
prepared.dynamic_variables = merged prepared.dynamic_variables = merged
prepared.conversation_id = conversation_id 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 = 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) store.render(prepared.greeting)
return prepared 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,204 @@
"""工作流图引擎(第一版)。 """Pure Workflow v3 graph queries and deterministic edge evaluation."""
对应 dograh 的 pipecat_engine.py,极简实现:
- 单个 startCall 入口,开场白来自该节点;
- agentNode 用各自的 prompt 驱动多轮对话;
- globalNode 不参与连线,按节点开关向会话节点注入统一提示词;
- 每轮助手回复后,用一次轻量 LLM「路由」判断是否满足某条出边的 condition,
满足则切换当前节点(linear = 单边;branching = 多边按条件分流);
- 到达 endCall 播放结束语并停止路由。
只读图结构,不持有对话状态(当前节点由 pipeline 维护),便于单测。
"""
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass
from typing import Any from typing import Any
from loguru import logger from services.node_specs import normalize_graph
from services.runtime_variables import DynamicVariableStore
@dataclass(frozen=True)
class AgentStageConfig:
"""The complete assistant configuration active inside one Agent node."""
inherits_global: bool
llm_resource_id: str
asr_resource_id: str
tts_resource_id: str
tool_ids: tuple[str, ...]
knowledge_base_id: str | None
knowledge_mode: str
knowledge_top_n: int
knowledge_score_threshold: float
class WorkflowEngine: class WorkflowEngine:
def __init__(self, graph: dict[str, Any]): def __init__(self, graph: dict[str, Any]):
nodes = graph.get("nodes") or [] self.graph = normalize_graph(graph)
self.nodes: dict[str, dict] = {n["id"]: n for n in nodes if n.get("id")} self.settings = self.graph.get("settings") or {}
self.edges: list[dict] = graph.get("edges") or [] self.nodes: dict[str, dict] = {
self.start_id: str | None = next( str(node["id"]): node
(nid for nid, n in self.nodes.items() if n.get("type") == "startCall"), for node in self.graph.get("nodes") or []
None, if node.get("id")
) }
self.global_id: str | None = next( self.edges: list[dict] = list(self.graph.get("edges") or [])
(nid for nid, n in self.nodes.items() if n.get("type") == "globalNode"), self.start_id = next(
(
node_id
for node_id, node in self.nodes.items()
if node.get("type") == "start"
),
None, None,
) )
# ---- 结构查询 ---- def has_graph(self) -> bool:
def node_type(self, nid: str | None) -> str | None: return bool(self.start_id)
return self.nodes.get(nid or "", {}).get("type")
def data(self, nid: str | None) -> dict: def node_type(self, node_id: str | None) -> str | None:
return self.nodes.get(nid or "", {}).get("data") or {} return self.nodes.get(node_id or "", {}).get("type")
def name(self, nid: str | None) -> str: def data(self, node_id: str | None) -> dict:
return self.data(nid).get("name") or (self.node_type(nid) or "") return self.nodes.get(node_id or "", {}).get("data") or {}
def outgoing(self, nid: str | None) -> list[dict]: def name(self, node_id: str | None) -> str:
return [e for e in self.edges if e.get("source") == nid] 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 has_outgoing(self, node_id: str | None) -> bool:
return any(edge.get("source") == node_id for edge in self.edges)
def edge_mode(self, edge: dict) -> str:
return str((edge.get("data") or {}).get("mode") or "always")
def edge_fn_name(self, edge: dict) -> str: def edge_fn_name(self, edge: dict) -> str:
"""每条边对应一个 LLM 函数名(稳定、合法标识符)。"""
raw = edge.get("id") or f"{edge.get('source')}_{edge.get('target')}" raw = edge.get("id") or f"{edge.get('source')}_{edge.get('target')}"
slug = re.sub(r"[^a-z0-9]+", "_", str(raw).lower()).strip("_") slug = re.sub(r"[^a-z0-9]+", "_", str(raw).lower()).strip("_")
return f"goto_{slug or 'next'}" return f"goto_{slug or 'next'}"
def edge_condition(self, edge: dict) -> str: def edge_description(self, edge: dict) -> str:
return (edge.get("data") or {}).get("condition") or "" 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: def edge_transition_speech(self, edge: dict | None) -> str:
"""命中该边、切换节点瞬间播报的过渡语(可选,掩盖延迟,不写入上下文)。"""
if not edge: if not edge:
return "" return ""
return (edge.get("data") or {}).get("transition_speech") or "" data = edge.get("data") or {}
return str(
def find_edge(self, source: str | None, target: str | None) -> dict | None: data.get("transitionSpeech") or data.get("transition_speech") or ""
for edge in self.edges:
if edge.get("source") == source and edge.get("target") == target:
return edge
return None
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] def global_prompt(self) -> str:
if global_prompt: return str(self.settings.get("globalPrompt") or "").strip()
sections.append(f"[全局规则]\n{global_prompt}")
def inherits_global_config(self, node_id: str) -> bool:
"""Return the Agent's explicit configuration scope, defaulting to global."""
return bool(self.data(node_id).get("inheritGlobalConfig", True))
def agent_stage_config(self, node_id: str) -> AgentStageConfig:
"""Resolve either Workflow defaults or one Agent's complete override."""
data = self.data(node_id)
inherits_global = self.inherits_global_config(node_id)
source = self.settings if inherits_global else data
llm_key = "defaultLlmResourceId" if inherits_global else "llmResourceId"
asr_key = "defaultAsrResourceId" if inherits_global else "asrResourceId"
tts_key = "defaultTtsResourceId" if inherits_global else "ttsResourceId"
knowledge_base_id = str(source.get("knowledgeBaseId") or "")
return AgentStageConfig(
inherits_global=inherits_global,
llm_resource_id=str(source.get(llm_key) or ""),
asr_resource_id=str(source.get(asr_key) or ""),
tts_resource_id=str(source.get(tts_key) or ""),
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []),
knowledge_base_id=knowledge_base_id or None,
knowledge_mode=(
str(source.get("knowledgeMode") or "automatic")
if knowledge_base_id
else "disabled"
),
knowledge_top_n=int(source.get("knowledgeTopN") or 5),
knowledge_score_threshold=float(
source.get("knowledgeScoreThreshold") or 0.0
),
)
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
"""Build the Agent system prompt according to its inheritance setting."""
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
sections = [f"[当前阶段:{self.name(node_id)}]"]
if self.inherits_global_config(node_id) and self.global_prompt():
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
if prompt: if prompt:
sections.append(f"[当前节点任务]\n{prompt}") sections.append(f"[当前阶段任务]\n{prompt}")
return "\n\n".join(sections) return "\n\n".join(sections)
# ---- 路由:决定下一节点 ---- def greeting(self, store: DynamicVariableStore) -> str:
async def route( 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, self,
nid: str | None, node_id: str,
history: list[dict], store: DynamicVariableStore,
*, *,
api_key: str, include_default: bool,
base_url: str, ) -> dict | None:
model: str, default = None
) -> str | None: for edge in self.outgoing(node_id):
"""根据对话历史判断当前节点是否应转移。返回目标节点 id,或 None 表示停留。""" data = edge.get("data") or {}
outs = self.outgoing(nid) mode = data.get("mode")
if not outs: if mode == "expression" and self.expression_matches(
return None data.get("expression") or {}, store.values
):
return edge
if mode == "always":
default = edge
return default if include_default else None
options = [] def llm_edges(self, node_id: str) -> list[dict]:
for i, edge in enumerate(outs, 1): return [
edata = edge.get("data") or {} edge
cond = edata.get("condition") or "(无明确条件,作为默认后继)" for edge in self.outgoing(node_id)
tgt_name = self.name(edge.get("target")) if self.edge_mode(edge) in {"llm", "always"}
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

View File

@@ -0,0 +1,129 @@
"""Pre-response LLM routing for Workflow Agent edges.
The router deliberately uses a separate, short completion. Its only output is
a required function choice, so the current Agent cannot speak before the graph
has decided whether the user turn belongs to another node.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
from loguru import logger
from models import AssistantConfig
from openai import AsyncOpenAI
STAY_ON_CURRENT_AGENT = "workflow_stay_on_current_agent"
MAX_ROUTING_HISTORY_ENTRIES = 20
class WorkflowLLMRouter:
"""Select one LLM edge before the conversational LLM is allowed to reply."""
def __init__(self, cfg: AssistantConfig):
self._cfg = cfg
async def select_edge(
self,
*,
node_name: str,
node_prompt: str,
edges: list[dict[str, Any]],
history: list[dict[str, str]],
variables: dict[str, Any],
edge_name: Callable[[dict[str, Any]], str],
edge_description: Callable[[dict[str, Any]], str],
) -> str | None:
"""Return an edge function name, STAY, or None when routing failed."""
if not edges:
return STAY_ON_CURRENT_AGENT
names = {edge_name(edge) for edge in edges}
stay_name = STAY_ON_CURRENT_AGENT
while stay_name in names:
stay_name = f"_{stay_name}"
tools = [
{
"type": "function",
"function": {
"name": edge_name(edge),
"description": edge_description(edge),
"parameters": {"type": "object", "properties": {}},
},
}
for edge in edges
]
tools.append(
{
"type": "function",
"function": {
"name": stay_name,
"description": "所有转移条件都不满足,继续由当前 Agent 处理用户消息。",
"parameters": {"type": "object", "properties": {}},
},
}
)
ordered_conditions = "\n".join(
f"{index + 1}. {edge_description(edge)}"
for index, edge in enumerate(edges)
)
router_prompt = (
"你是工作流路由器,不是对话助手。收到一轮完整用户输入后,"
"必须且只能调用一个提供的函数,禁止输出任何口头回复。\n"
"按给出的顺序判断转移条件;选择第一个明确满足的转移函数。"
"如果没有条件满足,调用留在当前 Agent 的函数。\n\n"
f"当前节点:{node_name}\n"
f"当前节点任务:{node_prompt or '未配置'}\n"
f"转移条件:\n{ordered_conditions}"
)
recent_history = history[-MAX_ROUTING_HISTORY_ENTRIES:]
routing_input = json.dumps(
{
"conversation": recent_history,
"session_variables": variables,
},
ensure_ascii=False,
separators=(",", ":"),
)
extra_body = self._cfg.llm_values.get("extraBody")
request_extra = (
{"extra_body": extra_body} if isinstance(extra_body, dict) else {}
)
client = AsyncOpenAI(
api_key=self._cfg.llm_api_key,
base_url=self._cfg.llm_base_url,
timeout=15.0,
)
try:
response = await client.chat.completions.create(
model=self._cfg.model,
messages=[
{"role": "system", "content": router_prompt},
{"role": "user", "content": routing_input},
],
tools=tools,
tool_choice="required",
temperature=0,
**request_extra,
)
tool_calls = response.choices[0].message.tool_calls or []
if not tool_calls:
logger.warning("Workflow 路由 LLM 未返回函数调用,留在当前 Agent")
return STAY_ON_CURRENT_AGENT
selected = str(tool_calls[0].function.name or "")
if selected == stay_name:
return STAY_ON_CURRENT_AGENT
if selected not in names:
logger.warning(f"Workflow 路由 LLM 返回未知函数:{selected}")
return STAY_ON_CURRENT_AGENT
return selected
except Exception as exc: # noqa: BLE001 - routing failure must not end the call
logger.warning(f"Workflow LLM 边判断失败,留在当前 Agent:{exc}")
return None
finally:
await client.close()

View File

@@ -9,7 +9,10 @@ from pipecat.frames.frames import (
LLMContextFrame, LLMContextFrame,
LLMFullResponseEndFrame, LLMFullResponseEndFrame,
LLMFullResponseStartFrame, LLMFullResponseStartFrame,
LLMRunFrame,
LLMTextFrame, LLMTextFrame,
OutputTransportMessageUrgentFrame,
TTSSpeakFrame,
) )
from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
@@ -111,6 +114,19 @@ class BrainRegistryTests(unittest.TestCase):
) )
self.assertIn("user_name", assistant.dynamic_variable_definitions) 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): class DifyHelpersTests(unittest.TestCase):
def test_normalize_api_base(self): def test_normalize_api_base(self):
@@ -363,7 +379,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
params = FakeFunctionParams( params = FakeFunctionParams(
{"order_id": "A/1", "Authorization": "attacker-value"} {"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) await llm.functions["lookup_order"](params)
self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1") self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1")
@@ -375,68 +391,315 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_transition_and_end_are_owned_by_workflow_brain(self): async def test_nodes_without_outgoing_edges_remain_active(self):
graph = {
"nodes": [
{
"id": "start",
"type": "startCall",
"data": {"name": "开始", "prompt": "收集需求"},
},
{
"id": "end",
"type": "endCall",
"data": {"name": "结束", "prompt": "礼貌结束"},
},
],
"edges": [
{
"id": "finish",
"source": "start",
"target": "end",
"data": {"condition": "需求已收集"},
}
],
}
brain = WorkflowBrain(graph)
llm = FakeLLM()
context = LLMContext(messages=[])
queued = [] queued = []
prompts = []
visible_tools = []
call_end = FakeCallEnd()
async def queue_frame(frame): async def queue_frame(frame):
queued.append(frame) queued.append(frame)
runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
)
class FakeManager:
def __init__(self, current_node=None):
self.current_node = current_node
async def initialize(self, config):
self.current_node = config["name"]
start_brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [{"id": "start", "type": "start", "data": {}}],
"edges": [],
}
)
start_brain._runtime = runtime
start_brain._manager = FakeManager()
await start_brain.on_connected()
self.assertEqual(start_brain._manager.current_node, "start")
agent_brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {"globalPrompt": "全局规则"},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {"prompt": "持续回答"},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
}
],
}
)
agent_brain._runtime = runtime
agent_brain._manager = FakeManager("agent")
queued.clear()
handled = await agent_brain.on_user_turn_end("请继续回答")
self.assertTrue(handled)
self.assertEqual(agent_brain._manager.current_node, "agent")
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
handoff_brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "handoff",
"type": "handoff",
"data": {"targetType": "human"},
},
],
"edges": [],
}
)
handoff_brain._runtime = runtime
handoff_config = await handoff_brain._resolve_path("handoff")
self.assertEqual(handoff_config["name"], "handoff")
self.assertTrue(
any(
isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("type") == "handoff-requested"
for frame in queued
)
)
async def test_transition_and_end_are_owned_by_workflow_brain(self):
graph = {
"specVersion": 3,
"settings": {
"globalPrompt": "全局规则",
"defaultLlmResourceId": "llm_global",
"defaultAsrResourceId": "asr_global",
"defaultTtsResourceId": "tts_global",
"knowledgeBaseId": "kb_global",
"knowledgeMode": "automatic",
},
"nodes": [
{
"id": "start",
"type": "start",
"data": {"name": "Start"},
},
{
"id": "agent",
"type": "agent",
"data": {
"name": "收集需求",
"prompt": "服务 {{user_name}}",
"contextPolicy": "fresh",
},
},
{
"id": "end",
"type": "end",
"data": {"name": "End", "message": "感谢来电", "scope": "session"},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
},
{
"id": "finish",
"source": "agent",
"target": "end",
"data": {
"mode": "llm",
"priority": 10,
"condition": "需求已收集",
"transitionSpeech": "正在为你结束流程",
},
}
],
}
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 = []
service_switches = []
knowledge_scopes = []
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)
async def switch_services(llm_id, asr_id, tts_id):
service_switches.append((llm_id, asr_id, tts_id))
runtime = BrainRuntime( runtime = BrainRuntime(
context=context, context=context,
llm=llm, llm=llm,
queue_frame=queue_frame, queue_frame=queue_frame,
set_system_prompt=prompts.append, set_system_prompt=lambda _prompt: None,
set_tools=lambda tools: visible_tools.append(tools or []), set_tools=lambda _tools: None,
call_end=call_end, call_end=call_end,
worker=worker,
context_aggregator=pair,
switch_services=switch_services,
set_knowledge_scope=knowledge_scopes.append,
) )
await brain.setup(AssistantConfig(type="workflow", graph=graph), runtime) await brain.setup(cfg, runtime)
await brain.on_connected()
self.assertIn("goto_finish", llm.functions) self.assertEqual(brain._manager.current_node, "agent")
self.assertIn("收集需求", prompts[-1]) self.assertEqual(
self.assertEqual(visible_tools[-1][0].name, "goto_finish") service_switches,
[("llm_global", "asr_global", "tts_global")],
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,
) )
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_global")
brain._engine.data("agent").update(
{
"inheritGlobalConfig": False,
"llmResourceId": "llm_agent",
"asrResourceId": "asr_agent",
"ttsResourceId": "tts_agent",
"knowledgeBaseId": "kb_agent",
"knowledgeMode": "on_demand",
}
)
await brain._apply_agent_stage("agent")
self.assertEqual(
service_switches[-1],
("llm_agent", "asr_agent", "tts_agent"),
)
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_agent")
agent_config = brain._agent_config("agent")
self.assertIn("王先生", agent_config["role_message"])
self.assertIn("工作流路由已在用户一轮输入结束时完成", agent_config["role_message"])
self.assertEqual(agent_config["task_messages"], [])
self.assertFalse(agent_config["respond_immediately"])
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
self.assertEqual(
agent_config["context_strategy"].strategy.value,
"reset",
)
brain._engine.data("agent")["entryMode"] = "generate"
generate_config = brain._agent_config("agent")
self.assertTrue(generate_config["respond_immediately"])
worker.frames.clear()
await brain._manager.set_node_from_config(generate_config)
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
brain._engine.data("agent").update(
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
)
fixed_config = brain._agent_config("agent")
self.assertFalse(fixed_config["respond_immediately"])
self.assertEqual(
fixed_config["pre_actions"][0]["type"],
"workflow_fixed_speech",
)
self.assertEqual(fixed_config["pre_actions"][0]["text"], "您好,王先生")
self.assertEqual(
fixed_config["task_messages"],
[{"role": "assistant", "content": "您好,王先生"}],
)
worker.frames.clear()
queued.clear()
await brain._manager.set_node_from_config(fixed_config)
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
self.assertFalse(
any(
function.name == "goto_finish"
for function in brain._agent_config("agent")["functions"]
)
)
await brain.on_assistant_text_end("old-turn", "需求已收集", False)
self.assertEqual(brain._manager.current_node, "agent")
class FakeRouter:
async def select_edge(self, **_kwargs):
return "goto_finish"
brain._router = FakeRouter()
handled = await brain.on_user_turn_end("我的需求已经说完了")
self.assertTrue(handled)
self.assertEqual(brain._manager.current_node, "end")
self.assertIn("我的需求已经说完了", brain._store.values["system__conversation_history"])
self.assertTrue(call_end.ending) self.assertTrue(call_end.ending)
self.assertTrue(call_end.armed) self.assertTrue(call_end.armed)
self.assertTrue(any(getattr(frame, "text", "") == "感谢来电" for frame in queued))
assistant_transcripts = [
frame.message.get("content")
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("type") == "transcript"
and frame.message.get("role") == "assistant"
]
self.assertEqual(
assistant_transcripts,
["您好,王先生", "正在为你结束流程", "感谢来电"],
)
self.assertIn(
"正在为你结束流程",
brain._store.values["system__conversation_history"],
)
self.assertIn(
"感谢来电",
brain._store.values["system__conversation_history"],
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,7 +1,15 @@
import unittest import unittest
from models import AssistantConfig from models import AssistantConfig
from services.pipecat.pipeline import _knowledge_tool_description from pipecat.frames.frames import LLMContextFrame
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from services.pipecat.pipeline import (
KNOWLEDGE_CONTEXT_MARKER,
KnowledgeRetrievalProcessor,
UserTurnRoutingProcessor,
_knowledge_tool_description,
)
class KnowledgeToolDescriptionTest(unittest.TestCase): class KnowledgeToolDescriptionTest(unittest.TestCase):
@@ -33,6 +41,57 @@ class KnowledgeToolDescriptionTest(unittest.TestCase):
self.assertNotIn("\n ", description) self.assertNotIn("\n ", description)
self.assertLess(len(description), 1000) 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))
class UserTurnRoutingProcessorTest(unittest.IsolatedAsyncioTestCase):
async def test_routes_each_user_message_once_before_response_run(self):
class FakeBrain:
def __init__(self):
self.turns = []
async def on_user_turn_end(self, content):
self.turns.append(content)
return True
brain = FakeBrain()
processor = UserTurnRoutingProcessor(brain)
forwarded = []
async def push_frame(frame, direction):
forwarded.append((frame, direction))
processor.push_frame = push_frame
context = LLMContext(messages=[{"role": "user", "content": "我叫李白"}])
frame = LLMContextFrame(context)
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
self.assertEqual(brain.turns, ["我叫李白"])
self.assertEqual(forwarded, [])
# A queued LLMRunFrame after the transition uses the same context. It
# must reach the target Agent without invoking routing a second time.
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
self.assertEqual(brain.turns, ["我叫李白"])
self.assertEqual(forwarded, [(frame, FrameDirection.DOWNSTREAM)])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from models import AssistantConfig
from services.workflow_router import WorkflowLLMRouter
class WorkflowLLMRouterTest(unittest.IsolatedAsyncioTestCase):
async def test_uses_required_tool_choice_without_developer_messages(self):
requests = []
class FakeCompletions:
async def create(self, **kwargs):
requests.append(kwargs)
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
tool_calls=[
SimpleNamespace(
function=SimpleNamespace(name="goto_age", arguments="{}")
)
]
)
)
]
)
class FakeClient:
def __init__(self, **_kwargs):
self.chat = SimpleNamespace(completions=FakeCompletions())
self.closed = False
async def close(self):
self.closed = True
cfg = AssistantConfig(
type="workflow",
model="deepseek-chat",
llm_api_key="secret",
llm_base_url="https://llm.test/v1",
)
router = WorkflowLLMRouter(cfg)
edges = [
{
"id": "age",
"data": {"condition": "用户已经回答姓名", "priority": 10},
}
]
with patch("services.workflow_router.AsyncOpenAI", FakeClient):
selected = await router.select_edge(
node_name="询问姓名",
node_prompt="询问用户姓名",
edges=edges,
history=[{"role": "user", "message": "我叫李白"}],
variables={"customer_type": "new"},
edge_name=lambda _edge: "goto_age",
edge_description=lambda _edge: "用户已经回答姓名",
)
self.assertEqual(selected, "goto_age")
self.assertEqual(requests[0]["tool_choice"], "required")
self.assertEqual(
[message["role"] for message in requests[0]["messages"]],
["system", "user"],
)
self.assertNotIn("developer", str(requests[0]["messages"]))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,328 @@
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 graph_references, 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_agent_entry_mode_defaults_and_validation(self):
graph = valid_graph()
normalized = normalize_graph(graph)
agent = next(node for node in normalized["nodes"] if node["type"] == "agent")
self.assertEqual(agent["data"]["entryMode"], "wait_user")
self.assertEqual(agent["data"]["entrySpeech"], "")
self.assertTrue(agent["data"]["inheritGlobalConfig"])
self.assertEqual(agent["data"]["contextPolicy"], "fresh")
agent["data"]["entryMode"] = "fixed_speech"
self.assertTrue(
any("固定进入语不能为空" in error for error in validate_graph(normalized))
)
agent["data"]["entrySpeech"] = "您好,{{customer}}"
self.assertEqual(validate_graph(normalized), [])
def test_voice_resource_creates_isolated_runtime_config(self):
base = AssistantConfig(type="workflow", asr="default", voice="default")
asr = RuntimeModelResource(
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")
llm = RuntimeModelResource(
id="llm_1",
capability="LLM",
interface_type="openai-llm",
values={"modelId": "deepseek-chat", "apiUrl": "https://llm.test/v1"},
secrets={"apiKey": "llm-secret"},
)
llm_resolved = config_with_resource(base, llm)
self.assertEqual(llm_resolved.model, "deepseek-chat")
self.assertEqual(llm_resolved.llm_api_key, "llm-secret")
def test_global_and_custom_agent_references_are_preserved(self):
graph = valid_graph()
graph["settings"].update(
{
"defaultLlmResourceId": "llm_global",
"defaultAsrResourceId": "asr_global",
"defaultTtsResourceId": "tts_global",
"toolIds": ["tool_global"],
"knowledgeBaseId": "kb_global",
}
)
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
agent["data"].update(
{
"inheritGlobalConfig": False,
"llmResourceId": "llm_agent",
"asrResourceId": "asr_agent",
"ttsResourceId": "tts_agent",
"toolIds": ["tool_agent"],
"knowledgeBaseId": "kb_agent",
}
)
refs = graph_references(graph)
self.assertEqual(
refs["model_resources"],
{
"llm_global",
"asr_global",
"tts_global",
"llm_agent",
"asr_agent",
"tts_agent",
},
)
self.assertEqual(refs["tools"], {"tool_global", "tool_agent"})
self.assertEqual(refs["knowledge_bases"], {"kb_global", "kb_agent"})
def test_existing_agent_override_disables_implicit_inheritance(self):
graph = valid_graph()
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
agent["data"]["toolIds"] = ["legacy_tool"]
normalized = normalize_graph(graph)
normalized_agent = next(
node for node in normalized["nodes"] if node["type"] == "agent"
)
self.assertFalse(normalized_agent["data"]["inheritGlobalConfig"])
def test_inherited_agent_ignores_stale_custom_references(self):
graph = valid_graph()
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
agent["data"].update(
{
"inheritGlobalConfig": True,
"llmResourceId": "stale_llm",
"asrResourceId": "stale_asr",
"ttsResourceId": "stale_tts",
"toolIds": ["stale_tool"],
"knowledgeBaseId": "stale_kb",
}
)
refs = graph_references(graph)
self.assertNotIn("stale_llm", refs["model_resources"])
self.assertNotIn("stale_tool", refs["tools"])
self.assertNotIn("stale_kb", refs["knowledge_bases"])
def test_agent_effective_config_inherits_then_switches_to_override(self):
graph = valid_graph()
graph["settings"].update(
{
"defaultLlmResourceId": "llm_global",
"defaultAsrResourceId": "asr_global",
"defaultTtsResourceId": "tts_global",
"toolIds": ["tool_global"],
"knowledgeBaseId": "kb_global",
"knowledgeMode": "on_demand",
"knowledgeTopN": 8,
"knowledgeScoreThreshold": 0.4,
}
)
engine = WorkflowEngine(graph)
inherited = engine.agent_stage_config("agent")
self.assertEqual(inherited.llm_resource_id, "llm_global")
self.assertEqual(inherited.tool_ids, ("tool_global",))
self.assertEqual(inherited.knowledge_mode, "on_demand")
engine.data("agent").update(
{
"inheritGlobalConfig": False,
"llmResourceId": "llm_agent",
"toolIds": ["tool_agent"],
"knowledgeBaseId": "",
}
)
custom = engine.agent_stage_config("agent")
self.assertEqual(custom.llm_resource_id, "llm_agent")
self.assertEqual(custom.tool_ids, ("tool_agent",))
self.assertEqual(custom.knowledge_mode, "disabled")
def test_start_agent_and_handoff_may_have_no_outgoing_edge(self):
terminal_graphs = [
{
"specVersion": 3,
"settings": {},
"nodes": [{"id": "start", "type": "start", "data": {}}],
"edges": [],
},
{
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {"prompt": "持续处理用户问题"},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
}
],
},
{
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "handoff", "type": "handoff", "data": {}},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "handoff",
"data": {"mode": "always", "priority": 0},
}
],
},
]
for graph in terminal_graphs:
with self.subTest(node=graph["nodes"][-1]["type"]):
self.assertEqual(validate_graph(graph), [])
action_without_exit = {
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "action", "type": "action", "data": {}},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "action",
"data": {"mode": "always", "priority": 0},
}
],
}
self.assertTrue(
any(
"action 的出边不能少于 1" in error
for error in validate_graph(action_without_exit)
)
)
def test_v2_start_prompt_is_preserved_in_synthetic_agent(self):
graph = normalize_graph(
{
"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))
inherited_prompt = engine.prompt_for("agent", store)
self.assertIn("服务 王先生", inherited_prompt)
self.assertIn("处理订单", inherited_prompt)
engine.data("agent")["inheritGlobalConfig"] = False
custom_prompt = engine.prompt_for("agent", store)
self.assertNotIn("服务 王先生", custom_prompt)
self.assertIn("处理订单", custom_prompt)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,168 @@
"use client";
import { Settings2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { KnowledgeRetrievalConfig } from "@/lib/api";
export const DEFAULT_KNOWLEDGE_RETRIEVAL_CONFIG: KnowledgeRetrievalConfig = {
mode: "automatic",
topN: 5,
scoreThreshold: 0,
};
export function KnowledgeRetrievalConfigDialog({
disabled,
value,
onChange,
}: {
disabled: boolean;
value: KnowledgeRetrievalConfig;
onChange: (config: KnowledgeRetrievalConfig) => void;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState<string | null>(null);
function openDialog() {
setDraft(value);
setError(null);
setOpen(true);
}
function saveDraft() {
if (draft.topN === 0 || draft.topN < -1 || !Number.isInteger(draft.topN)) {
setError("Top N 必须为 -1 或大于 0 的整数");
return;
}
if (draft.scoreThreshold < 0 || draft.scoreThreshold > 1) {
setError("最低相关度必须在 0 到 1 之间");
return;
}
onChange(draft);
setOpen(false);
}
return (
<>
<button
type="button"
disabled={disabled}
onClick={openDialog}
aria-label="打开知识库高级配置"
title={
disabled
? "请先选择知识库"
: `${value.mode === "automatic" ? "自动检索" : "模型主动检索"} · Top N ${value.topN === -1 ? "不限" : value.topN} · 最低相关度 ${value.scoreThreshold}`
}
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
>
<Settings2 size={14} />
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground"></div>
<Select
value={draft.mode}
onValueChange={(mode: "automatic" | "on_demand") =>
setDraft({ ...draft, mode })
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="automatic"></SelectItem>
<SelectItem value="on_demand"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{draft.mode === "automatic"
? "每轮用户提问后自动检索,响应行为更稳定。"
: "由大模型判断是否调用知识库,依赖模型的工具调用能力。"}
</p>
</div>
<label className="block">
<span className="mb-2 block text-sm font-medium text-foreground">
</span>
<Input
type="number"
step="1"
min="-1"
value={draft.topN}
onChange={(event) =>
setDraft({ ...draft, topN: Number(event.target.value) })
}
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
-1
</span>
</label>
<label className="block">
<span className="mb-2 block text-sm font-medium text-foreground">
</span>
<Input
type="number"
step="0.01"
min="0"
max="1"
value={draft.scoreThreshold}
onChange={(event) =>
setDraft({
...draft,
scoreThreshold: Number(event.target.value),
})
}
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
01
</span>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="button" onClick={saveDraft}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
/**
* Compact section chrome shared by assistant editors and workflow node panels.
* Density matches the debug preview drawer (text-sm titles, tight padding).
*/
import { HelpCircle } from "lucide-react";
import type { ReactNode } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export function SectionCard({
icon,
title,
description,
children,
className,
}: {
icon?: ReactNode;
title?: string;
description?: string;
children: ReactNode;
className?: string;
}) {
const hasHeader = Boolean(title);
return (
<Card
size="sm"
className={cn(
"gap-3 rounded-2xl border border-hairline bg-card py-3.5 text-card-foreground shadow-sm ring-0",
className,
)}
>
{hasHeader && (
<CardHeader className="gap-0 px-4">
<div className="flex items-center gap-2.5">
{icon && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon}
</div>
)}
<div className="flex min-w-0 items-center gap-1.5">
<CardTitle className="text-sm font-medium leading-none">
{title}
</CardTitle>
{description && <HelpHint text={description} />}
</div>
</div>
</CardHeader>
)}
<CardContent className={cn("px-4", hasHeader && "space-y-3")}>
{children}
</CardContent>
</Card>
);
}
export function HelpHint({ text }: { text: string }) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="查看说明"
onClick={(event) => event.stopPropagation()}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
<HelpCircle size={13} />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-72 text-sm leading-6 text-muted-foreground"
>
{text}
</PopoverContent>
</Popover>
);
}

View File

@@ -21,7 +21,6 @@ import {
Save, Save,
Mic, Mic,
Send, Send,
HelpCircle,
Waypoints, Waypoints,
AudioLines, AudioLines,
Terminal, Terminal,
@@ -53,13 +52,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -94,12 +86,6 @@ import { PageHeader } from "@/components/ui/page-header";
import { FilterPills } from "@/components/ui/filter-pills"; import { FilterPills } from "@/components/ui/filter-pills";
import { SearchInput } from "@/components/ui/search-input"; import { SearchInput } from "@/components/ui/search-input";
import { ListToolbar } from "@/components/ui/list-toolbar"; import { ListToolbar } from "@/components/ui/list-toolbar";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
@@ -131,6 +117,7 @@ import {
WorkflowEditor, WorkflowEditor,
type WorkflowSettings, type WorkflowSettings,
} from "@/components/workflow/WorkflowEditor"; } from "@/components/workflow/WorkflowEditor";
import { HelpHint, SectionCard } from "@/components/editor/section-card";
import { import {
defaultGraph, defaultGraph,
type WorkflowGraph, type WorkflowGraph,
@@ -369,7 +356,7 @@ type AssistantTypeOption = {
label: string; label: string;
description: string; description: string;
icon: React.ReactNode; icon: React.ReactNode;
/** 提示词、Dify、FastGPT 类型已落地,工作流暂时显示占位页 */ /** 提示词、工作流、Dify、FastGPT 已落地OpenCode 暂时显示即将上线 */
available: boolean; available: boolean;
}; };
@@ -386,7 +373,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
label: "使用工作流构建", label: "使用工作流构建",
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。", description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
icon: <Workflow size={20} />, icon: <Workflow size={20} />,
available: false, available: true,
}, },
{ {
type: "Dify", type: "Dify",
@@ -407,7 +394,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
label: "使用 OpenCode 构建", label: "使用 OpenCode 构建",
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。", description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
icon: <Terminal size={20} />, icon: <Terminal size={20} />,
available: true, available: false,
}, },
]; ];
@@ -478,10 +465,26 @@ export function AssistantPage(props: AssistantPageProps) {
defaultGraph(), defaultGraph(),
); );
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({ const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
globalPrompt: defaultGraph().settings.globalPrompt,
llm: defaultGraph().settings.defaultLlmResourceId,
asr: defaultGraph().settings.defaultAsrResourceId,
tts: defaultGraph().settings.defaultTtsResourceId,
toolIds: defaultGraph().settings.toolIds,
knowledgeBaseId: defaultGraph().settings.knowledgeBaseId,
knowledgeRetrievalConfig: {
mode: defaultGraph().settings.knowledgeMode,
topN: defaultGraph().settings.knowledgeTopN,
scoreThreshold: defaultGraph().settings.knowledgeScoreThreshold,
},
allowInterrupt: true, allowInterrupt: true,
turnConfig: defaultTurnConfig(), turnConfig: defaultTurnConfig(),
}); });
const [debugOpen, setDebugOpen] = useState(false); const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
useState<Record<string, DynamicVariableDefinition>>({});
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(null);
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(null);
const [activeNodeId, setActiveNodeId] = useState<string | null>(null); const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false); const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
@@ -708,6 +711,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: workflowName, name: workflowName,
graph: workflowGraph, graph: workflowGraph,
settings: workflowSettings, settings: workflowSettings,
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
}), }),
); );
} }
@@ -848,20 +852,41 @@ export function AssistantPage(props: AssistantPageProps) {
? (assistant.graph as WorkflowGraph) ? (assistant.graph as WorkflowGraph)
: defaultGraph(); : defaultGraph();
const wfSettings: WorkflowSettings = { const wfSettings: WorkflowSettings = {
llm: assistant.modelResourceIds.LLM, llm:
asr: assistant.modelResourceIds.ASR, graph.settings?.defaultLlmResourceId ||
tts: assistant.modelResourceIds.TTS, assistant.modelResourceIds.LLM,
asr: graph.settings?.defaultAsrResourceId || assistant.modelResourceIds.ASR,
tts: graph.settings?.defaultTtsResourceId || assistant.modelResourceIds.TTS,
toolIds: graph.settings?.toolIds ?? [],
knowledgeBaseId:
graph.settings?.knowledgeBaseId || assistant.knowledgeBaseId || "",
knowledgeRetrievalConfig: {
mode:
graph.settings?.knowledgeMode ||
assistant.knowledgeRetrievalConfig.mode,
topN:
graph.settings?.knowledgeTopN ??
assistant.knowledgeRetrievalConfig.topN,
scoreThreshold:
graph.settings?.knowledgeScoreThreshold ??
assistant.knowledgeRetrievalConfig.scoreThreshold,
},
globalPrompt: graph.settings?.globalPrompt ?? "",
allowInterrupt: assistant.enableInterrupt, allowInterrupt: assistant.enableInterrupt,
turnConfig: assistant.turnConfig, turnConfig: assistant.turnConfig,
}; };
setWorkflowName(assistant.name); setWorkflowName(assistant.name);
setWorkflowGraph(graph); setWorkflowGraph(graph);
setWorkflowSettings(wfSettings); setWorkflowSettings(wfSettings);
setWorkflowDynamicVariableDefinitions(
assistant.dynamicVariableDefinitions ?? {},
);
setSavedSnapshot( setSavedSnapshot(
JSON.stringify({ JSON.stringify({
name: assistant.name, name: assistant.name,
graph, graph,
settings: wfSettings, settings: wfSettings,
dynamicVariableDefinitions: assistant.dynamicVariableDefinitions ?? {},
}), }),
); );
} }
@@ -912,7 +937,11 @@ export function AssistantPage(props: AssistantPageProps) {
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}), ...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}), ...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
}, },
knowledgeBaseId: workflowSettings.knowledgeBaseId || null,
knowledgeRetrievalConfig: workflowSettings.knowledgeRetrievalConfig,
toolIds: workflowSettings.toolIds,
graph: workflowGraph as unknown as Record<string, unknown>, graph: workflowGraph as unknown as Record<string, unknown>,
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
}), }),
); );
} }
@@ -932,6 +961,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: workflowName, name: workflowName,
graph: workflowGraph, graph: workflowGraph,
settings: workflowSettings, settings: workflowSettings,
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
}) })
: null; : null;
const dirty = const dirty =
@@ -1377,7 +1407,11 @@ export function AssistantPage(props: AssistantPageProps) {
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
{saveError && ( {saveError && (
<span className="self-center text-xs text-destructive"> <span
role="alert"
title={saveError}
className="line-clamp-1 max-w-[min(42vw,560px)] self-center text-right text-sm leading-5 text-destructive"
>
{saveError} {saveError}
</span> </span>
)} )}
@@ -1385,7 +1419,12 @@ export function AssistantPage(props: AssistantPageProps) {
variant="outline" variant="outline"
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong" className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
disabled={!editingId} disabled={!editingId}
onClick={() => setDebugOpen(true)} onClick={() => {
setWorkflowSettingsOpen(false);
setWorkflowEditingNodeId(null);
setWorkflowEditingEdgeId(null);
setWorkflowDebugOpen(true);
}}
> >
<Bug size={16} /> <Bug size={16} />
@@ -1411,43 +1450,51 @@ export function AssistantPage(props: AssistantPageProps) {
onChange={setWorkflowGraph} onChange={setWorkflowGraph}
settings={workflowSettings} settings={workflowSettings}
onSettingsChange={setWorkflowSettings} onSettingsChange={setWorkflowSettings}
onOpenDynamicVariables={() => setDynamicVariablesOpen(true)}
editingNodeId={workflowEditingNodeId}
onEditingNodeIdChange={setWorkflowEditingNodeId}
editingEdgeId={workflowEditingEdgeId}
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
settingsOpen={workflowSettingsOpen}
onSettingsOpenChange={setWorkflowSettingsOpen}
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} activeNodeId={activeNodeId}
modelOptions={{ modelOptions={{
llm: credOptions("LLM"), llm: credOptions("LLM"),
asr: credOptions("ASR"), asr: credOptions("ASR"),
tts: credOptions("TTS"), tts: credOptions("TTS"),
}} }}
toolOptions={tools
.filter((tool) => tool.status === "active")
.map((tool) => ({ value: tool.id, label: tool.name }))}
knowledgeOptions={kbOptions}
/> />
</div> </div>
<Sheet <DynamicVariablesDialog
open={debugOpen} open={dynamicVariablesOpen}
onOpenChange={(open) => { onOpenChange={setDynamicVariablesOpen}
setDebugOpen(open); definitions={workflowDynamicVariableDefinitions}
if (!open) setActiveNodeId(null); onChange={setWorkflowDynamicVariableDefinitions}
}} />
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>
</div> </div>
); );
} }
@@ -1488,10 +1535,10 @@ export function AssistantPage(props: AssistantPageProps) {
</div> </div>
</div> </div>
<div className="flex min-h-0 flex-1 gap-6"> <div className="flex min-h-0 flex-1 gap-4">
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1"> <div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
<SectionCard <SectionCard
icon={<Boxes size={18} />} icon={<Boxes size={15} />}
title="Dify 应用配置" title="Dify 应用配置"
description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。" description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
> >
@@ -1505,7 +1552,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
icon={<Brain size={18} />} icon={<Brain size={15} />}
title="语音配置" title="语音配置"
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。" description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
> >
@@ -1526,7 +1573,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
icon={<Sparkles size={18} />} icon={<Sparkles size={15} />}
title="交互策略" title="交互策略"
description="设置实时视频对话时的交互体验" description="设置实时视频对话时的交互体验"
> >
@@ -1584,10 +1631,10 @@ export function AssistantPage(props: AssistantPageProps) {
</div> </div>
</div> </div>
<div className="flex min-h-0 flex-1 gap-6"> <div className="flex min-h-0 flex-1 gap-4">
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1"> <div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
<SectionCard <SectionCard
icon={<Database size={18} />} icon={<Database size={15} />}
title="FastGPT 应用配置" title="FastGPT 应用配置"
description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。" description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
> >
@@ -1601,7 +1648,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
icon={<Brain size={18} />} icon={<Brain size={15} />}
title="语音配置" title="语音配置"
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。" description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
> >
@@ -1622,7 +1669,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
icon={<Sparkles size={18} />} icon={<Sparkles size={15} />}
title="交互策略" title="交互策略"
description="设置实时视频对话时的交互体验" description="设置实时视频对话时的交互体验"
> >
@@ -1684,10 +1731,10 @@ export function AssistantPage(props: AssistantPageProps) {
</div> </div>
</div> </div>
<div className="flex min-h-0 flex-1 gap-6"> <div className="flex min-h-0 flex-1 gap-4">
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1"> <div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
<SectionCard <SectionCard
icon={<Terminal size={18} />} icon={<Terminal size={15} />}
title="OpenCode 服务配置" title="OpenCode 服务配置"
description="从「模型资源」中选择 OpenCode 服务资源。" description="从「模型资源」中选择 OpenCode 服务资源。"
> >
@@ -1701,7 +1748,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
icon={<MessageSquareText size={18} />} icon={<MessageSquareText size={15} />}
title="提示词" title="提示词"
description="描述助手的角色、能力和回答要求" description="描述助手的角色、能力和回答要求"
> >
@@ -1714,7 +1761,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
icon={<Brain size={18} />} icon={<Brain size={15} />}
title="模型与语音配置" title="模型与语音配置"
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。" description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
> >
@@ -1759,7 +1806,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
icon={<Sparkles size={18} />} icon={<Sparkles size={15} />}
title="交互策略" title="交互策略"
description="设置实时视频对话时的交互体验" description="设置实时视频对话时的交互体验"
> >
@@ -1823,10 +1870,10 @@ export function AssistantPage(props: AssistantPageProps) {
} }
/> />
<div className="flex min-h-0 flex-1 gap-6"> <div className="flex min-h-0 flex-1 gap-4">
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1"> <div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
<SectionCard> <SectionCard>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -1838,25 +1885,25 @@ export function AssistantPage(props: AssistantPageProps) {
} }
}} }}
className={[ className={[
"cursor-pointer rounded-2xl border p-5 text-left transition-colors", "cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
form.runtimeMode === "pipeline" form.runtimeMode === "pipeline"
? "border-primary bg-primary/5 ring-1 ring-primary" ? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-hairline bg-canvas-soft hover:border-hairline-strong", : "border-hairline bg-canvas-soft hover:border-hairline-strong",
].join(" ")} ].join(" ")}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Waypoints size={18} /> <Waypoints size={15} />
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Pipeline </span> <span className="text-sm font-medium text-foreground">Pipeline </span>
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" /> <HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
</div> </div>
</div> </div>
{form.runtimeMode === "pipeline" && ( {form.runtimeMode === "pipeline" && (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground"> <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check size={14} /> <Check size={12} />
</span> </span>
)} )}
</div> </div>
@@ -1873,25 +1920,25 @@ export function AssistantPage(props: AssistantPageProps) {
} }
}} }}
className={[ className={[
"cursor-pointer rounded-2xl border p-5 text-left transition-colors", "cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
form.runtimeMode === "realtime" form.runtimeMode === "realtime"
? "border-primary bg-primary/5 ring-1 ring-primary" ? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-hairline bg-canvas-soft hover:border-hairline-strong", : "border-hairline bg-canvas-soft hover:border-hairline-strong",
].join(" ")} ].join(" ")}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<AudioLines size={18} /> <AudioLines size={15} />
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Realtime </span> <span className="text-sm font-medium text-foreground">Realtime </span>
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" /> <HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
</div> </div>
</div> </div>
{form.runtimeMode === "realtime" && ( {form.runtimeMode === "realtime" && (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground"> <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check size={14} /> <Check size={12} />
</span> </span>
)} )}
</div> </div>
@@ -1900,7 +1947,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
icon={<MessageSquareText size={18} />} icon={<MessageSquareText size={15} />}
title="提示词" title="提示词"
description="描述助手的角色、能力和回答要求" description="描述助手的角色、能力和回答要求"
> >
@@ -1918,7 +1965,7 @@ export function AssistantPage(props: AssistantPageProps) {
{form.runtimeMode === "pipeline" ? ( {form.runtimeMode === "pipeline" ? (
<SectionCard <SectionCard
icon={<Brain size={18} />} icon={<Brain size={15} />}
title="模型配置" title="模型配置"
description="从「模型资源」中选择大语言模型、语音识别与语音合成" description="从「模型资源」中选择大语言模型、语音识别与语音合成"
> >
@@ -1963,7 +2010,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
) : ( ) : (
<SectionCard <SectionCard
icon={<Brain size={18} />} icon={<Brain size={15} />}
title="模型配置" title="模型配置"
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成" description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
> >
@@ -1978,7 +2025,7 @@ export function AssistantPage(props: AssistantPageProps) {
)} )}
<SectionCard <SectionCard
icon={<Bot size={18} />} icon={<Bot size={15} />}
title="开场白" title="开场白"
description="助手与用户首次对话时的开场语" description="助手与用户首次对话时的开场语"
> >
@@ -1995,7 +2042,7 @@ export function AssistantPage(props: AssistantPageProps) {
{form.runtimeMode === "pipeline" && ( {form.runtimeMode === "pipeline" && (
<SectionCard <SectionCard
icon={<Database size={18} />} icon={<Database size={15} />}
title="知识库配置" title="知识库配置"
description="选择助手回答时可检索的业务知识来源" description="选择助手回答时可检索的业务知识来源"
> >
@@ -2021,7 +2068,7 @@ export function AssistantPage(props: AssistantPageProps) {
)} )}
<SectionCard <SectionCard
icon={<Wrench size={18} />} icon={<Wrench size={15} />}
title="工具" title="工具"
description="配置该提示词助手可以调用的工具" description="配置该提示词助手可以调用的工具"
> >
@@ -2033,7 +2080,7 @@ export function AssistantPage(props: AssistantPageProps) {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
icon={<Sparkles size={18} />} icon={<Sparkles size={15} />}
title="交互策略" title="交互策略"
description="设置实时视频对话时的交互体验" description="设置实时视频对话时的交互体验"
> >
@@ -2132,7 +2179,8 @@ function SegmentedIconButton({
function DebugDrawer({ function DebugDrawer({
assistantId, assistantId,
asSheet = false, overlay = false,
onClose,
hasUnsavedChanges = false, hasUnsavedChanges = false,
onNodeActive, onNodeActive,
vision = false, vision = false,
@@ -2140,7 +2188,8 @@ function DebugDrawer({
dynamicVariableDefinitions = {}, dynamicVariableDefinitions = {},
}: { }: {
assistantId: string | null; assistantId: string | null;
asSheet?: boolean; overlay?: boolean;
onClose?: () => void;
hasUnsavedChanges?: boolean; hasUnsavedChanges?: boolean;
onNodeActive?: (nodeId: string | null) => void; onNodeActive?: (nodeId: string | null) => void;
vision?: boolean; vision?: boolean;
@@ -2179,14 +2228,25 @@ function DebugDrawer({
[camera, preview], [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 ( return (
<aside className={containerClass}> <aside
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3"> 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"> <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 className="shrink-0 text-sm font-medium text-foreground">
</div> </div>
@@ -3344,29 +3404,6 @@ function EditableTitle({
); );
} }
function HelpHint({ text }: { text: string }) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="查看说明"
onClick={(event) => event.stopPropagation()}
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
<HelpCircle size={14} />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-72 text-sm leading-6 text-muted-foreground"
>
{text}
</PopoverContent>
</Popover>
);
}
function DynamicVariableEditorHint({ function DynamicVariableEditorHint({
count, count,
onOpen, onOpen,
@@ -3624,45 +3661,6 @@ function DynamicVariablesDialog({
); );
} }
function SectionCard({
icon,
title,
description,
children,
}: {
icon?: React.ReactNode;
title?: string;
description?: string;
children: React.ReactNode;
}) {
const hasHeader = Boolean(title);
return (
<Card className="rounded-2xl border-hairline bg-card text-card-foreground shadow-sm">
{hasHeader && (
<CardHeader>
<div className="flex items-center gap-3">
{icon && (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon}
</div>
)}
<div className="flex items-center gap-1.5">
<CardTitle className="text-base font-medium">{title}</CardTitle>
{description && <HelpHint text={description} />}
</div>
</div>
</CardHeader>
)}
<CardContent className={hasHeader ? "space-y-4" : undefined}>
{children}
</CardContent>
</Card>
);
}
function TextAreaField({ function TextAreaField({
label, label,
value, value,
@@ -3679,7 +3677,7 @@ function TextAreaField({
return ( return (
<label className="block"> <label className="block">
{label && ( {label && (
<div className="mb-2 text-sm font-medium text-foreground">{label}</div> <div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)} )}
<Textarea <Textarea
value={value} value={value}
@@ -3688,7 +3686,7 @@ function TextAreaField({
rows={rows} rows={rows}
// Override ui/textarea's field-sizing-content so `rows` sets a real height // Override ui/textarea's field-sizing-content so `rows` sets a real height
// instead of collapsing to min-h-16 when the value is short. // instead of collapsing to min-h-16 when the value is short.
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft" className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/> />
</label> </label>
); );
@@ -3714,7 +3712,7 @@ function ResourceSelectField({
return ( return (
<div className="block"> <div className="block">
{label && ( {label && (
<div className="mb-2 text-sm font-medium text-foreground">{label}</div> <div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)} )}
<Select <Select
@@ -4023,18 +4021,18 @@ function ToggleRow({
<div <div
className={[ className={[
"flex items-center justify-between border border-hairline bg-canvas-soft", "flex items-center justify-between border border-hairline bg-canvas-soft",
hasIcon ? "rounded-2xl p-5" : "rounded-xl p-4", hasIcon ? "rounded-xl p-3.5" : "rounded-xl px-3.5 py-3",
].join(" ")} ].join(" ")}
> >
<div> <div>
<div <div
className={[ className={[
"flex items-center font-medium text-foreground", "flex items-center text-sm font-medium text-foreground",
hasIcon ? "gap-3" : "gap-1.5", hasIcon ? "gap-2.5" : "gap-1.5",
].join(" ")} ].join(" ")}
> >
{icon && ( {icon && (
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon} {icon}
</span> </span>
)} )}
@@ -4044,7 +4042,7 @@ function ToggleRow({
</span> </span>
</div> </div>
{description && ( {description && (
<div className="mt-1 text-sm text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{description} {description}
</div> </div>
)} )}

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { HelpCircle, Settings2 } from "lucide-react"; import { Settings2 } from "lucide-react";
import { HelpHint } from "@/components/editor/section-card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import {
@@ -13,11 +14,6 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -59,7 +55,7 @@ export function TurnConfigEditor({
}); });
return ( return (
<div className="flex items-center justify-between gap-4 rounded-2xl border border-hairline bg-card p-4 shadow-sm"> <div className="flex items-center justify-between gap-3 rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
@@ -72,7 +68,7 @@ export function TurnConfigEditor({
aria-label="打开允许用户打断高级配置" aria-label="打开允许用户打断高级配置"
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground" className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
> >
<Settings2 size={14} /> <Settings2 size={13} />
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden"> <DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
@@ -161,29 +157,6 @@ function ConfigSection({ title, children }: { title: string; children: ReactNode
); );
} }
function HelpHint({ text }: { text: string }) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="查看允许用户打断说明"
onClick={(event) => event.stopPropagation()}
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
<HelpCircle size={14} />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-72 text-sm leading-6 text-muted-foreground"
>
{text}
</PopoverContent>
</Popover>
);
}
function NumberField({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) { function NumberField({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
return ( return (
<label className="block space-y-2"> <label className="block space-y-2">

View File

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

View File

@@ -27,9 +27,30 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
const nodeData = data as WorkflowNodeData; const nodeData = data as WorkflowNodeData;
const Icon = spec.icon; const Icon = spec.icon;
const preview = (nodeData.greeting || nodeData.prompt || "") const preview = (nodeData.greeting || nodeData.prompt || nodeData.message || "")
.toString() .toString()
.trim(); .trim();
const entryModeLabel = {
wait_user: "等待用户",
generate: "立即回复",
fixed_speech: "固定进入语",
}[nodeData.entryMode ?? "wait_user"];
const inheritsGlobal = nodeData.inheritGlobalConfig !== false;
const meta = type === "agent"
? inheritsGlobal
? [entryModeLabel, "继承全局配置"]
: [
entryModeLabel,
"自定义配置",
nodeData.llmResourceId ? "独立 LLM" : null,
`${nodeData.toolIds?.length ?? 0} 工具`,
nodeData.knowledgeBaseId ? "知识库" : null,
nodeData.asrResourceId ? "独立 ASR" : null,
nodeData.ttsResourceId ? "独立 TTS" : null,
].filter(Boolean)
: type === "action" && nodeData.toolId
? ["确定性工具"]
: [];
return ( return (
<div <div
@@ -46,7 +67,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
{spec.hasTarget && ( {spec.hasTarget && (
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Top}
className="!h-3 !w-3 !border-[3px] !border-card !bg-muted-soft" className="!h-3 !w-3 !border-[3px] !border-card !bg-muted-soft"
/> />
)} )}
@@ -86,7 +107,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
> >
<Pencil size={13} /> <Pencil size={13} />
</button> </button>
{type !== "startCall" && ( {type !== "start" && (
<button <button
type="button" type="button"
title="删除节点" title="删除节点"
@@ -129,11 +150,22 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
</p> </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 && ( {spec.hasSource && (
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Bottom}
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary" title="拖到节点或画布空白处"
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary transition-transform hover:!scale-125"
/> />
)} )}
</div> </div>
@@ -141,8 +173,9 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
} }
export const nodeTypes = { export const nodeTypes = {
startCall: GenericNode, start: GenericNode,
agentNode: GenericNode, agent: GenericNode,
endCall: GenericNode, action: GenericNode,
globalNode: GenericNode, handoff: GenericNode,
end: GenericNode,
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,67 @@
/** /** Workflow v3 public graph types and defaults. */
* 工作流节点的前端类型与运行期规格。
*
* 节点「目录」(有哪些类型、各自的字段与约束)由后端 /api/node-types 提供,
* 通过 useNodeSpecs 拉取后用 toRuntimeSpec 转成带 React 组件(图标)的运行期规格。
* 本文件只保留:类型定义、默认图、图标/配色解析。新增节点类型改后端即可。
*/
import * as LucideIcons from "lucide-react"; import * as LucideIcons from "lucide-react";
import { Circle, type LucideIcon } from "lucide-react"; import { Circle, type LucideIcon } from "lucide-react";
import type { NodeSpecDto } from "@/lib/api"; import type { NodeSpecDto } from "@/lib/api";
export type WorkflowNodeType = export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
| "startCall" export type ContextPolicy = "inherit" | "fresh";
| "agentNode" export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
| "endCall" export type AgentEntryMode = "wait_user" | "generate" | "fixed_speech";
| "globalNode"; export type EdgeMode = "llm" | "expression" | "always";
export type ExpressionOperator =
| "eq"
| "neq"
| "gt"
| "gte"
| "lt"
| "lte"
| "contains"
| "in"
| "exists";
export type WorkflowNodeData = { export type WorkflowNodeData = {
/** 节点显示名 */
name: string; name: string;
/** 开场白(仅 startCall) */
greeting?: string; greeting?: string;
/** 节点提示词 */
prompt?: string; prompt?: string;
/** 允许打断(仅 agentNode) */ contextPolicy?: ContextPolicy;
allowInterrupt?: boolean; inheritGlobalConfig?: boolean;
/** 是否合并全局节点提示词(start/agent 默认开启,end 默认关闭) */ entryMode?: AgentEntryMode;
addGlobalPrompt?: boolean; entrySpeech?: string;
toolIds?: string[];
knowledgeBaseId?: string;
knowledgeMode?: KnowledgeMode;
knowledgeTopN?: number;
knowledgeScoreThreshold?: number;
llmResourceId?: string;
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; [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 = { export type FieldSpec = {
key: string; key: string;
label: string; label: string;
@@ -39,7 +70,6 @@ export type FieldSpec = {
default?: unknown; default?: unknown;
}; };
/** 解析后的运行期节点规格(DTO + 解析出的 React 图标 + 派生句柄) */
export type RuntimeNodeSpec = { export type RuntimeNodeSpec = {
type: string; type: string;
displayName: string; displayName: string;
@@ -47,9 +77,7 @@ export type RuntimeNodeSpec = {
icon: LucideIcon; icon: LucideIcon;
accent: string; accent: string;
addable: boolean; addable: boolean;
/** 入边句柄(开始节点没有) */
hasTarget: boolean; hasTarget: boolean;
/** 出边句柄(结束节点没有) */
hasSource: boolean; hasSource: boolean;
constraints: { constraints: {
minIncoming?: number; minIncoming?: number;
@@ -62,7 +90,6 @@ export type RuntimeNodeSpec = {
fields: FieldSpec[]; fields: FieldSpec[];
}; };
/** 渐变 token → CSS 变量名(图标底色用),未知配色回落到 sky */
export const ACCENT_VAR: Record<string, string> = { export const ACCENT_VAR: Record<string, string> = {
mint: "--gradient-mint", mint: "--gradient-mint",
sky: "--gradient-sky", sky: "--gradient-sky",
@@ -75,13 +102,11 @@ export function accentVar(accent: string): string {
return ACCENT_VAR[accent] ?? ACCENT_VAR.sky; return ACCENT_VAR[accent] ?? ACCENT_VAR.sky;
} }
/** 按名字解析 Lucide 图标,找不到回落到 Circle(对齐 dograh resolveIcon)。 */
export function resolveIcon(name: string): LucideIcon { export function resolveIcon(name: string): LucideIcon {
const icons = LucideIcons as unknown as Record<string, LucideIcon>; const icons = LucideIcons as unknown as Record<string, LucideIcon>;
return icons[name] ?? Circle; return icons[name] ?? Circle;
} }
/** 后端 DTO → 运行期规格。hasTarget/hasSource 由入/出边上限派生。 */
export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec { export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
return { return {
type: dto.name, type: dto.name,
@@ -93,12 +118,12 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
hasTarget: dto.constraints.maxIncoming !== 0, hasTarget: dto.constraints.maxIncoming !== 0,
hasSource: dto.constraints.maxOutgoing !== 0, hasSource: dto.constraints.maxOutgoing !== 0,
constraints: dto.constraints, constraints: dto.constraints,
fields: dto.fields.map((f) => ({ fields: dto.fields.map((field) => ({
key: f.key, key: field.key,
label: f.label, label: field.label,
type: f.type, type: field.type,
required: f.required, required: field.required,
default: f.default, default: field.default,
})), })),
}; };
} }
@@ -106,6 +131,18 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
export type NodeSpecMap = Record<string, RuntimeNodeSpec>; export type NodeSpecMap = Record<string, RuntimeNodeSpec>;
export type WorkflowGraph = { export type WorkflowGraph = {
specVersion: 3;
settings: {
globalPrompt: string;
defaultLlmResourceId: string;
defaultAsrResourceId: string;
defaultTtsResourceId: string;
toolIds: string[];
knowledgeBaseId: string;
knowledgeMode: "automatic" | "on_demand";
knowledgeTopN: number;
knowledgeScoreThreshold: number;
};
nodes: Array<{ nodes: Array<{
id: string; id: string;
type: WorkflowNodeType; type: WorkflowNodeType;
@@ -116,62 +153,77 @@ export type WorkflowGraph = {
id: string; id: string;
source: string; source: string;
target: string; target: string;
data?: { condition?: string; label?: string; transition_speech?: string }; data: WorkflowEdgeData;
}>; }>;
viewport?: { x: number; y: number; zoom: number }; viewport?: { x: number; y: number; zoom: number };
}; };
/** 新建工作流的默认图:全局规则 + 开始 → 智能体 → 结束 */
export function defaultGraph(): WorkflowGraph { export function defaultGraph(): WorkflowGraph {
return { return {
specVersion: 3,
settings: {
globalPrompt:
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
defaultLlmResourceId: "",
defaultAsrResourceId: "",
defaultTtsResourceId: "",
toolIds: [],
knowledgeBaseId: "",
knowledgeMode: "automatic",
knowledgeTopN: 5,
knowledgeScoreThreshold: 0,
},
nodes: [ nodes: [
{ {
id: "start", id: "start",
type: "startCall", type: "start",
position: { x: 100, y: 120 }, position: { x: 360, y: 60 },
data: { data: {
name: "开始", name: "Start",
greeting: "你好,我是 AI 视频助手,有什么可以帮你?", greeting: "你好我是 AI 视频助手有什么可以帮你",
prompt: "了解用户的需求,并在信息明确后进入下一节点。",
allowInterrupt: true,
addGlobalPrompt: true,
}, },
}, },
{ {
id: "agent-1", id: "agent-1",
type: "agentNode", type: "agent",
position: { x: 420, y: 120 }, position: { x: 360, y: 300 },
data: { data: {
name: "智能体节点", name: "Agent",
prompt: "根据用户需求提供清晰、准确的帮助。", prompt: "了解用户需求提供清晰、准确的帮助。",
allowInterrupt: true, contextPolicy: "inherit",
addGlobalPrompt: true, inheritGlobalConfig: true,
entryMode: "wait_user",
entrySpeech: "",
}, },
}, },
{ {
id: "end", id: "end",
type: "endCall", type: "end",
position: { x: 740, y: 120 }, position: { x: 360, y: 540 },
data: { data: {
name: "结束", name: "End",
prompt: "总结已经完成的事项,礼貌道别并结束通话。", message: "感谢你的来电,再见。",
addGlobalPrompt: false, scope: "session",
},
},
{
id: "global",
type: "globalNode",
position: { x: 100, y: 400 },
data: {
name: "全局设定",
prompt:
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
}, },
}, },
], ],
edges: [ 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"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding" | "Agent"; 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> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const isFormData = init?.body instanceof FormData; const isFormData = init?.body instanceof FormData;
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
@@ -30,8 +59,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
if (!res.ok) { if (!res.ok) {
let detail = `请求失败 (${res.status})`; let detail = `请求失败 (${res.status})`;
try { try {
const body = (await res.json()) as { detail?: string }; const body = (await res.json()) as { detail?: unknown };
if (body?.detail) detail = body.detail; detail = formatErrorDetail(body?.detail) ?? detail;
} catch { } catch {
// 响应体不是 JSON,沿用默认错误信息 // 响应体不是 JSON,沿用默认错误信息
} }