Add workflow support and enhance runtime configuration in models and services
- Introduce RuntimeModelResource and RuntimeKnowledgeBase classes to manage workflow resources. - Update AssistantConfig to include workflow_model_resources and workflow_knowledge_bases for better integration. - Refactor validation and processing logic in routes and services to accommodate workflow types. - Implement dynamic variable support for workflow assistants and enhance graph normalization. - Add ToolExecutor for reusable tool execution across different assistant types. - Update various services to ensure compatibility with new workflow features and improve error handling.
This commit is contained in:
@@ -27,6 +27,21 @@ class RuntimeTool(BaseModel):
|
||||
secrets: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RuntimeModelResource(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
capability: str
|
||||
interface_type: str
|
||||
values: dict = Field(default_factory=dict)
|
||||
secrets: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RuntimeKnowledgeBase(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class AssistantConfig(BaseModel):
|
||||
"""运行时配置:前端可见部分(name/prompt/...) + 服务端注入部分(*_api_key/*_base_url)。"""
|
||||
|
||||
@@ -93,6 +108,8 @@ class AssistantConfig(BaseModel):
|
||||
|
||||
# workflow 类型:节点图(nodes/edges)。非 workflow 为空,引擎据此决定是否启用。
|
||||
graph: dict = {}
|
||||
workflow_model_resources: dict[str, RuntimeModelResource] = Field(default_factory=dict)
|
||||
workflow_knowledge_bases: dict[str, RuntimeKnowledgeBase] = Field(default_factory=dict)
|
||||
|
||||
# 外部托管类型(fastgpt/dify/opencode)的连接信息:context/KB/tools 由对方服务端接管。
|
||||
dify_api_url: str = ""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
||||
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
||||
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
|
||||
pipecat-ai[webrtc,websocket,silero,openai]==1.4.0
|
||||
pipecat-ai[webrtc,websocket,silero,openai]==1.5.0
|
||||
Pillow>=11.1.0,<13
|
||||
|
||||
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from schemas import AssistantOut, AssistantUpsert
|
||||
from services.auth import require_admin
|
||||
from services.masking import mask, resolve_incoming_key
|
||||
from services.node_specs import validate_graph
|
||||
from services.node_specs import graph_references, normalize_graph, validate_graph
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -31,9 +31,45 @@ def _validate_workflow(body: AssistantUpsert) -> None:
|
||||
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
|
||||
if body.type != "workflow":
|
||||
return
|
||||
errors = validate_graph(body.graph or {})
|
||||
body.graph = normalize_graph(body.graph or {})
|
||||
errors = validate_graph(body.graph)
|
||||
if errors:
|
||||
raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
|
||||
refs = graph_references(body.graph)
|
||||
body.tool_ids = list(dict.fromkeys([*body.tool_ids, *sorted(refs["tools"])]))
|
||||
|
||||
|
||||
async def _validate_workflow_references(
|
||||
session: AsyncSession, body: AssistantUpsert
|
||||
) -> None:
|
||||
if body.type != "workflow" or not body.graph.get("nodes"):
|
||||
return
|
||||
graph = body.graph
|
||||
settings = graph.get("settings") or {}
|
||||
resource_expectations: dict[str, str] = {}
|
||||
for key, capability in (
|
||||
("defaultAsrResourceId", "ASR"),
|
||||
("defaultTtsResourceId", "TTS"),
|
||||
):
|
||||
if settings.get(key):
|
||||
resource_expectations[str(settings[key])] = capability
|
||||
knowledge_ids: set[str] = set()
|
||||
for node in graph.get("nodes") or []:
|
||||
data = node.get("data") or {}
|
||||
if data.get("asrResourceId"):
|
||||
resource_expectations[str(data["asrResourceId"])] = "ASR"
|
||||
if data.get("ttsResourceId"):
|
||||
resource_expectations[str(data["ttsResourceId"])] = "TTS"
|
||||
if data.get("knowledgeBaseId"):
|
||||
knowledge_ids.add(str(data["knowledgeBaseId"]))
|
||||
for resource_id, capability in resource_expectations.items():
|
||||
resource = await session.get(ModelResource, resource_id)
|
||||
if not resource or not resource.enabled or resource.capability != capability:
|
||||
raise HTTPException(400, f"Workflow 引用了无效的 {capability} 资源:{resource_id}")
|
||||
for knowledge_id in knowledge_ids:
|
||||
knowledge = await session.get(KnowledgeBase, knowledge_id)
|
||||
if not knowledge or knowledge.status != "active":
|
||||
raise HTTPException(400, f"Workflow 引用了无效知识库:{knowledge_id}")
|
||||
|
||||
|
||||
async def _validate_vision_model(
|
||||
@@ -101,7 +137,11 @@ async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, s
|
||||
async def _sync_tool_bindings(
|
||||
session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str]
|
||||
) -> None:
|
||||
requested = list(dict.fromkeys(tool_ids)) if assistant_type == "prompt" else []
|
||||
requested = (
|
||||
list(dict.fromkeys(tool_ids))
|
||||
if assistant_type in {"prompt", "workflow"}
|
||||
else []
|
||||
)
|
||||
if requested:
|
||||
tools = (
|
||||
await session.execute(select(Tool).where(Tool.id.in_(requested)))
|
||||
@@ -158,7 +198,11 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
||||
api_url=assistant.api_url,
|
||||
api_key=mask(assistant.api_key),
|
||||
app_id=assistant.app_id,
|
||||
graph=assistant.graph or {},
|
||||
graph=(
|
||||
normalize_graph(assistant.graph or {})
|
||||
if assistant.type == "workflow"
|
||||
else {}
|
||||
),
|
||||
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
|
||||
)
|
||||
|
||||
@@ -176,6 +220,7 @@ async def create_assistant(
|
||||
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
_validate_workflow(body)
|
||||
await _validate_workflow_references(session, body)
|
||||
await _validate_vision_model(session, body)
|
||||
await _validate_knowledge_base(session, body)
|
||||
data = body.model_dump()
|
||||
@@ -248,6 +293,7 @@ async def update_assistant(
|
||||
if not assistant:
|
||||
raise HTTPException(404, "助手不存在")
|
||||
_validate_workflow(body)
|
||||
await _validate_workflow_references(session, body)
|
||||
await _validate_vision_model(session, body)
|
||||
await _validate_knowledge_base(session, body)
|
||||
data = body.model_dump()
|
||||
|
||||
@@ -15,6 +15,7 @@ from schemas import (
|
||||
)
|
||||
from services.auth import require_admin
|
||||
from services.knowledge import create_document, delete_storage_object, process_document, search
|
||||
from services.node_specs import graph_references
|
||||
import settings
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -123,6 +124,14 @@ async def delete_knowledge_base(
|
||||
)).scalar_one_or_none()
|
||||
if referenced:
|
||||
raise HTTPException(409, "知识库正被助手引用,无法删除")
|
||||
workflows = (
|
||||
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
|
||||
).scalars().all()
|
||||
if any(
|
||||
kb_id in graph_references(assistant.graph or {})["knowledge_bases"]
|
||||
for assistant in workflows
|
||||
):
|
||||
raise HTTPException(409, "知识库正被 Workflow 节点引用,无法删除")
|
||||
documents = (await session.execute(
|
||||
select(KnowledgeDocument).where(KnowledgeDocument.knowledge_base_id == kb_id)
|
||||
)).scalars().all()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import uuid
|
||||
|
||||
from db.models import (
|
||||
Assistant,
|
||||
AssistantModelBinding,
|
||||
InterfaceDefinition,
|
||||
KnowledgeBase,
|
||||
@@ -20,6 +21,7 @@ from services.auth import require_admin
|
||||
from services.interface_catalog import validate_fields
|
||||
from services.masking import mask_secrets, merge_secrets
|
||||
from services.model_resource_tester import test_model_resource
|
||||
from services.node_specs import graph_references
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -102,6 +104,14 @@ async def _clear_incompatible_references(
|
||||
) -> None:
|
||||
if capability == resource.capability:
|
||||
return
|
||||
workflows = (
|
||||
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
|
||||
).scalars().all()
|
||||
if any(
|
||||
resource.id in graph_references(assistant.graph or {})["model_resources"]
|
||||
for assistant in workflows
|
||||
):
|
||||
raise HTTPException(409, "模型资源正被 Workflow 节点引用,不能修改能力类型")
|
||||
await session.execute(
|
||||
delete(AssistantModelBinding).where(
|
||||
AssistantModelBinding.model_resource_id == resource.id
|
||||
@@ -263,6 +273,14 @@ async def delete_model_resource(
|
||||
).scalar_one_or_none()
|
||||
if in_use:
|
||||
raise HTTPException(409, "该模型资源仍被助手引用")
|
||||
workflows = (
|
||||
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
|
||||
).scalars().all()
|
||||
if any(
|
||||
resource_id in graph_references(assistant.graph or {})["model_resources"]
|
||||
for assistant in workflows
|
||||
):
|
||||
raise HTTPException(409, "该模型资源仍被 Workflow 节点引用")
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -138,7 +138,7 @@ class AssistantUpsert(CamelModel):
|
||||
setattr(self, field, "")
|
||||
if "graph" not in allowed:
|
||||
self.graph = {}
|
||||
if self.type != "prompt":
|
||||
if self.type not in {"prompt", "workflow"}:
|
||||
self.tool_ids = []
|
||||
self.dynamic_variable_definitions = {}
|
||||
# 外部托管大脑只能 cascade,拦住不兼容的 realtime
|
||||
|
||||
@@ -109,6 +109,17 @@ def clear_auth_cookie(response: Response) -> None:
|
||||
|
||||
async def require_admin(request: Request) -> AdminUser:
|
||||
user = verify_admin_token(request.cookies.get(settings.AUTH_COOKIE_NAME))
|
||||
if not user:
|
||||
authorization = request.headers.get("authorization", "")
|
||||
scheme, _, encoded_credentials = authorization.partition(" ")
|
||||
if scheme.lower() == "basic" and encoded_credentials:
|
||||
try:
|
||||
credentials = base64.b64decode(encoded_credentials).decode("utf-8")
|
||||
username, separator, password = credentials.partition(":")
|
||||
if separator:
|
||||
user = authenticate_admin(username, password)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
user = None
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -9,7 +9,7 @@ without coupling brains to Pipecat internals more than necessary.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from models import AssistantConfig
|
||||
@@ -52,6 +52,13 @@ class BrainRuntime:
|
||||
set_system_prompt: Callable[[str], None]
|
||||
set_tools: Callable[[list[FunctionSchema] | None], None]
|
||||
call_end: CallEndPort
|
||||
worker: Any = None
|
||||
context_aggregator: Any = None
|
||||
transport: Any = None
|
||||
switch_services: Callable[[str | None, str | None], Awaitable[None]] | None = None
|
||||
set_knowledge_scope: Callable[[dict[str, Any]], None] | None = None
|
||||
set_input_enabled: Callable[[bool], None] | None = None
|
||||
flow_global_functions: list[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
class BaseBrain:
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from models import AssistantConfig
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||
@@ -20,10 +17,9 @@ from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||
from services.runtime_variables import (
|
||||
DynamicVariableError,
|
||||
DynamicVariableStore,
|
||||
value_at_path,
|
||||
)
|
||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||
|
||||
|
||||
class PromptBrain(BaseBrain):
|
||||
@@ -37,6 +33,7 @@ class PromptBrain(BaseBrain):
|
||||
self._cfg = cfg
|
||||
self._dynamic_enabled = True
|
||||
self._store = DynamicVariableStore.from_config(cfg)
|
||||
self._tools = ToolExecutor(self._store)
|
||||
self._runtime: BrainRuntime | None = None
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
@@ -85,120 +82,16 @@ class PromptBrain(BaseBrain):
|
||||
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
|
||||
|
||||
def _make_http_tool(self, tool, runtime: BrainRuntime):
|
||||
config = (tool.definition or {}).get("config") or {}
|
||||
parameters = list(config.get("parameters") or [])
|
||||
properties = {
|
||||
str(parameter.get("name")): {
|
||||
"type": str(parameter.get("type") or "string"),
|
||||
"description": str(parameter.get("description") or ""),
|
||||
}
|
||||
for parameter in parameters
|
||||
if parameter.get("name")
|
||||
}
|
||||
required = [
|
||||
str(parameter["name"])
|
||||
for parameter in parameters
|
||||
if parameter.get("name") and parameter.get("required", True)
|
||||
]
|
||||
|
||||
tool_secrets = tool.secrets or {}
|
||||
dynamic_secrets = tool_secrets.get("dynamic_variables") or {}
|
||||
for name, value in dynamic_secrets.items():
|
||||
if not str(name).startswith("secret__"):
|
||||
raise DynamicVariableError(f"工具密钥变量必须以 secret__ 开头: {name}")
|
||||
self._store.secrets[str(name)] = str(value)
|
||||
properties, required = self._tools.schema_parts(tool)
|
||||
self._tools.register_secrets(tool)
|
||||
|
||||
async def call_http(params: FunctionCallParams) -> None:
|
||||
arguments = params.arguments or {}
|
||||
url = self._store.render(str(config.get("url") or ""))
|
||||
configured_headers = self._store.render_data(
|
||||
deepcopy(config.get("headers") or {}), allow_secrets=True
|
||||
)
|
||||
secret_headers = self._store.render_data(
|
||||
deepcopy(tool_secrets.get("headers") or {}), allow_secrets=True
|
||||
)
|
||||
headers: dict[str, str] = {}
|
||||
query: dict[str, object] = {}
|
||||
body = self._store.render_data(deepcopy(config.get("body") or {}))
|
||||
|
||||
for parameter in parameters:
|
||||
name = str(parameter.get("name") or "")
|
||||
if not name or name not in arguments:
|
||||
continue
|
||||
value = arguments[name]
|
||||
location = str(parameter.get("location") or "body")
|
||||
if location == "path":
|
||||
encoded = quote(str(value), safe="")
|
||||
url = url.replace(f"{{{name}}}", encoded)
|
||||
elif location == "query":
|
||||
query[name] = value
|
||||
elif location == "header":
|
||||
headers[name] = str(value)
|
||||
else:
|
||||
body[name] = value
|
||||
|
||||
# Admin-configured headers win over model-provided arguments; secret
|
||||
# headers are applied last so an LLM can never replace credentials.
|
||||
headers.update(
|
||||
{str(key): str(value) for key, value in configured_headers.items()}
|
||||
)
|
||||
headers.update({str(key): str(value) for key, value in secret_headers.items()})
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=float(config.get("timeout_seconds") or 15),
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = await client.request(
|
||||
str(config.get("method") or "GET"),
|
||||
url,
|
||||
headers=headers,
|
||||
params=query,
|
||||
json=body if body else None,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if len(response.content) > 1_000_000:
|
||||
raise DynamicVariableError("HTTP 工具响应超过 1 MB 限制")
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = {"text": response.text[:8000]}
|
||||
|
||||
updated: list[str] = []
|
||||
assignments = config.get("dynamic_variable_assignments") or {}
|
||||
for variable_name, path in assignments.items():
|
||||
try:
|
||||
value = value_at_path(payload, str(path))
|
||||
except KeyError:
|
||||
try:
|
||||
value = value_at_path({"response": payload}, str(path))
|
||||
except KeyError:
|
||||
continue
|
||||
self._store.assign(str(variable_name), value)
|
||||
updated.append(str(variable_name))
|
||||
if updated:
|
||||
result = await self._tools.execute(tool, dict(params.arguments or {}))
|
||||
if result["updated_variables"]:
|
||||
self._refresh_prompt()
|
||||
await params.result_callback(
|
||||
{
|
||||
"status": "ok",
|
||||
"status_code": response.status_code,
|
||||
"data": payload,
|
||||
"updated_variables": updated,
|
||||
}
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
await params.result_callback(
|
||||
{"status": "error", "message": "HTTP 工具调用超时"}
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
await params.result_callback(
|
||||
{
|
||||
"status": "error",
|
||||
"status_code": exc.response.status_code,
|
||||
"message": "HTTP 工具返回错误状态",
|
||||
}
|
||||
)
|
||||
except (httpx.RequestError, DynamicVariableError) as exc:
|
||||
await params.result_callback(result)
|
||||
except (ToolExecutionError, ValueError) as exc:
|
||||
await params.result_callback(
|
||||
{"status": "error", "message": f"HTTP 工具调用失败: {exc}"}
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from services.brains.workflow_brain import WorkflowBrain
|
||||
|
||||
|
||||
def _workflow(cfg: AssistantConfig) -> Brain:
|
||||
return WorkflowBrain(cfg.graph)
|
||||
return WorkflowBrain(cfg)
|
||||
|
||||
|
||||
BRAIN_FACTORIES: dict[str, Callable[[AssistantConfig], Brain]] = {
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
"""Local graph-driven workflow assistant and its per-call state."""
|
||||
"""Pipecat Flows-backed Workflow v3 brain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||
from models import AssistantConfig, RuntimeTool
|
||||
from db.session import SessionLocal
|
||||
from pipecat.flows import (
|
||||
ContextStrategy,
|
||||
ContextStrategyConfig,
|
||||
FlowManager,
|
||||
FlowsFunctionSchema,
|
||||
NodeConfig,
|
||||
)
|
||||
from pipecat.frames.frames import (
|
||||
LLMUpdateSettingsFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
from pipecat.services.settings import LLMSettings
|
||||
|
||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||
from services.knowledge import search as search_knowledge
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowState:
|
||||
current: str
|
||||
ended: bool = False
|
||||
turns_in_node: int = 0
|
||||
end_turn_id: str | None = None
|
||||
MAX_AUTOMATIC_HOPS = 50
|
||||
AGENT_STAGE_INSTRUCTION = (
|
||||
"完成当前阶段任务。需要流转时必须调用对应的转移函数;"
|
||||
"不要在调用转移函数后继续生成口头回复。"
|
||||
)
|
||||
|
||||
|
||||
class WorkflowBrain(BaseBrain):
|
||||
@@ -30,22 +43,28 @@ class WorkflowBrain(BaseBrain):
|
||||
supported_runtime_modes=frozenset({"pipeline"}),
|
||||
owns_context=True,
|
||||
)
|
||||
_FALLBACK_AFTER_TURNS = 2
|
||||
|
||||
def __init__(self, graph: dict[str, Any]):
|
||||
def __init__(self, cfg_or_graph: AssistantConfig | dict[str, Any]):
|
||||
cfg = cfg_or_graph if isinstance(cfg_or_graph, AssistantConfig) else None
|
||||
graph = cfg.graph if cfg is not None else cfg_or_graph
|
||||
self._engine = WorkflowEngine(graph or {})
|
||||
if not self._engine.has_graph() or not self._engine.start_id:
|
||||
raise ValueError("WorkflowBrain 缺少有效的 startCall 节点")
|
||||
self._state = WorkflowState(current=self._engine.start_id)
|
||||
self._history: list[dict[str, str]] = []
|
||||
self._cfg: AssistantConfig | None = None
|
||||
raise ValueError("WorkflowBrain 缺少有效的 Start 节点")
|
||||
self._cfg = cfg
|
||||
self._store = DynamicVariableStore.from_config(cfg or AssistantConfig(type="workflow"))
|
||||
self._tools = ToolExecutor(self._store)
|
||||
self._tool_by_id: dict[str, RuntimeTool] = {
|
||||
tool.id: tool for tool in (cfg.tools if cfg else [])
|
||||
}
|
||||
self._runtime: BrainRuntime | None = None
|
||||
self._manager: FlowManager | None = None
|
||||
self._ended = False
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
return self._engine.greeting() or cfg.greeting
|
||||
return self._engine.greeting(self._store) or cfg.greeting
|
||||
|
||||
def system_prompt(self, cfg: AssistantConfig) -> str:
|
||||
return self._engine.system_prompt_for(self._state.current)
|
||||
return self._store.render(self._engine.global_prompt())
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||
from services.pipecat.service_factory import create_llm
|
||||
@@ -53,72 +72,331 @@ class WorkflowBrain(BaseBrain):
|
||||
return create_llm(cfg)
|
||||
|
||||
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
|
||||
if runtime.worker is None or runtime.context_aggregator is None:
|
||||
raise RuntimeError("WorkflowBrain 需要 PipelineWorker 和 context aggregator pair")
|
||||
self._cfg = cfg
|
||||
self._runtime = runtime
|
||||
for edge in self._engine.edges:
|
||||
if edge.get("target"):
|
||||
runtime.llm.register_function(
|
||||
self._engine.edge_fn_name(edge),
|
||||
self._make_transition_handler(edge),
|
||||
)
|
||||
self._apply_node(self._state.current)
|
||||
logger.info(
|
||||
f"工作流模式启用: 起始节点={self._engine.name(self._state.current)}"
|
||||
self._store = DynamicVariableStore.from_config(cfg)
|
||||
self._tools = ToolExecutor(self._store)
|
||||
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
||||
self._manager = FlowManager(
|
||||
worker=runtime.worker,
|
||||
llm=runtime.llm,
|
||||
context_aggregator=runtime.context_aggregator,
|
||||
transport=runtime.transport,
|
||||
global_functions=runtime.flow_global_functions,
|
||||
)
|
||||
self._manager.state["variables"] = self._store.values
|
||||
|
||||
async def on_connected(self) -> None:
|
||||
await self._emit_node_active(self._state.current)
|
||||
await self._emit_node_active(self._engine.start_id)
|
||||
edge = self._engine.deterministic_edge(
|
||||
self._engine.start_id,
|
||||
self._store,
|
||||
include_default=True,
|
||||
)
|
||||
if not edge:
|
||||
raise RuntimeError("Start 初始化后没有命中的表达式边或默认边")
|
||||
node_config = await self._follow_edge(edge)
|
||||
if self._manager is None:
|
||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||
await self._manager.initialize(node_config)
|
||||
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
||||
|
||||
def record_user_message(self, content: str) -> None:
|
||||
if content:
|
||||
self._history.append({"role": "user", "content": content})
|
||||
|
||||
async def on_assistant_text_start(self, turn_id: str) -> None:
|
||||
if self._state.ended and self._state.end_turn_id is None:
|
||||
self._state.end_turn_id = turn_id
|
||||
if content and not self._ended:
|
||||
self._store.record("user", content)
|
||||
|
||||
async def on_assistant_text_end(
|
||||
self,
|
||||
turn_id: str,
|
||||
_turn_id: str,
|
||||
content: str,
|
||||
interrupted: bool,
|
||||
) -> None:
|
||||
if not content or interrupted:
|
||||
if not content or interrupted or self._ended:
|
||||
return
|
||||
self._history.append({"role": "assistant", "content": content})
|
||||
if turn_id == self._state.end_turn_id:
|
||||
runtime = self._require_runtime()
|
||||
runtime.call_end.begin("completed")
|
||||
runtime.call_end.arm_after_speech()
|
||||
elif not self._state.ended:
|
||||
self._state.turns_in_node += 1
|
||||
await self._fallback_route()
|
||||
self._store.record("agent", content, completed_agent_turn=True)
|
||||
manager = self._require_manager()
|
||||
current = manager.current_node
|
||||
if not current or self._engine.node_type(current) != "agent":
|
||||
return
|
||||
await self._refresh_agent_prompt(current)
|
||||
edge = self._engine.deterministic_edge(
|
||||
current,
|
||||
self._store,
|
||||
include_default=False,
|
||||
)
|
||||
if edge and manager.current_node == current:
|
||||
next_config = await self._follow_edge(edge)
|
||||
await manager.set_node_from_config(next_config)
|
||||
|
||||
def _apply_node(self, node_id: str) -> None:
|
||||
async def _refresh_agent_prompt(self, node_id: str) -> None:
|
||||
runtime = self._require_runtime()
|
||||
runtime.set_system_prompt(self._engine.system_prompt_for(node_id))
|
||||
if self._engine.is_end(node_id):
|
||||
runtime.set_tools([])
|
||||
return
|
||||
runtime.set_tools(
|
||||
[
|
||||
FunctionSchema(
|
||||
name=self._engine.edge_fn_name(edge),
|
||||
description=self._engine.edge_description(edge),
|
||||
properties={},
|
||||
required=[],
|
||||
await runtime.queue_frame(
|
||||
LLMUpdateSettingsFrame(
|
||||
delta=LLMSettings(
|
||||
system_instruction=self._agent_role_message(node_id)
|
||||
)
|
||||
for edge in self._engine.outgoing(node_id)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
async def _go_to_node(self, target: str) -> None:
|
||||
self._state.current = target
|
||||
self._state.turns_in_node = 0
|
||||
if self._engine.is_end(target):
|
||||
self._state.ended = True
|
||||
await self._emit_node_active(target)
|
||||
self._apply_node(target)
|
||||
def _agent_role_message(self, node_id: str) -> str:
|
||||
"""Build one provider-compatible system instruction for an Agent stage."""
|
||||
stage_prompt = self._engine.prompt_for(node_id, self._store)
|
||||
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
|
||||
|
||||
async def _apply_agent_stage(self, node_id: str) -> None:
|
||||
data = self._engine.data(node_id)
|
||||
await self._emit_node_active(node_id)
|
||||
if self._runtime and self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(True)
|
||||
asr_id = str(
|
||||
data.get("asrResourceId")
|
||||
or self._engine.settings.get("defaultAsrResourceId")
|
||||
or ""
|
||||
)
|
||||
tts_id = str(
|
||||
data.get("ttsResourceId")
|
||||
or self._engine.settings.get("defaultTtsResourceId")
|
||||
or ""
|
||||
)
|
||||
runtime = self._require_runtime()
|
||||
if runtime.switch_services:
|
||||
await runtime.switch_services(asr_id or None, tts_id or None)
|
||||
if runtime.set_knowledge_scope:
|
||||
runtime.set_knowledge_scope(
|
||||
{
|
||||
"knowledge_base_id": data.get("knowledgeBaseId"),
|
||||
"mode": data.get("knowledgeMode", "disabled"),
|
||||
"top_n": int(data.get("knowledgeTopN") or 5),
|
||||
"score_threshold": float(data.get("knowledgeScoreThreshold") or 0.0),
|
||||
}
|
||||
)
|
||||
|
||||
def _agent_config(self, node_id: str) -> NodeConfig:
|
||||
data = self._engine.data(node_id)
|
||||
strategy = (
|
||||
ContextStrategy.RESET
|
||||
if data.get("contextPolicy") == "fresh"
|
||||
else ContextStrategy.APPEND
|
||||
)
|
||||
functions: list[FlowsFunctionSchema] = []
|
||||
for tool_id in data.get("toolIds") or []:
|
||||
tool = self._tool_by_id.get(str(tool_id))
|
||||
if tool and tool.type == "http":
|
||||
functions.append(self._flow_tool(tool, node_id))
|
||||
knowledge_function = self._knowledge_function(node_id)
|
||||
if knowledge_function:
|
||||
functions.append(knowledge_function)
|
||||
for edge in self._engine.llm_edges(node_id):
|
||||
functions.append(self._flow_edge(edge))
|
||||
return {
|
||||
"name": node_id,
|
||||
"role_message": self._agent_role_message(node_id),
|
||||
"task_messages": [],
|
||||
"functions": functions,
|
||||
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||
"respond_immediately": True,
|
||||
}
|
||||
|
||||
def _terminal_config(self, node_id: str) -> NodeConfig:
|
||||
return {
|
||||
"name": node_id,
|
||||
"role_message": self._store.render(self._engine.global_prompt()),
|
||||
"task_messages": [],
|
||||
"functions": [],
|
||||
"context_strategy": ContextStrategyConfig(strategy=ContextStrategy.APPEND),
|
||||
"respond_immediately": False,
|
||||
}
|
||||
|
||||
def _flow_tool(self, tool: RuntimeTool, node_id: str) -> FlowsFunctionSchema:
|
||||
properties, required = self._tools.schema_parts(tool)
|
||||
self._tools.register_secrets(tool)
|
||||
|
||||
async def handler(args, _flow_manager):
|
||||
try:
|
||||
result = await self._tools.execute(tool, dict(args or {}))
|
||||
except ToolExecutionError as exc:
|
||||
return {"status": "error", "message": str(exc)}
|
||||
if result.get("updated_variables"):
|
||||
await self._refresh_agent_prompt(node_id)
|
||||
edge = self._engine.deterministic_edge(
|
||||
node_id,
|
||||
self._store,
|
||||
include_default=False,
|
||||
)
|
||||
if edge:
|
||||
return result, await self._follow_edge(edge)
|
||||
return result
|
||||
|
||||
return FlowsFunctionSchema(
|
||||
name=tool.function_name,
|
||||
description=tool.description or f"调用 {tool.name}",
|
||||
properties=properties,
|
||||
required=required,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
def _flow_edge(self, edge: dict) -> FlowsFunctionSchema:
|
||||
async def handler(_args, _flow_manager):
|
||||
return None, await self._follow_edge(edge)
|
||||
|
||||
return FlowsFunctionSchema(
|
||||
name=self._engine.edge_fn_name(edge),
|
||||
description=self._engine.edge_description(edge),
|
||||
properties={},
|
||||
required=[],
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
||||
data = self._engine.data(node_id)
|
||||
knowledge_id = str(data.get("knowledgeBaseId") or "")
|
||||
if not knowledge_id or data.get("knowledgeMode") != "on_demand":
|
||||
return None
|
||||
cfg = self._cfg or AssistantConfig(type="workflow")
|
||||
knowledge = cfg.workflow_knowledge_bases.get(knowledge_id)
|
||||
description = "在当前 Agent 绑定的知识库中检索资料。"
|
||||
if knowledge:
|
||||
description += f"知识库:{knowledge.name}。{knowledge.description}"
|
||||
|
||||
async def handler(args, _flow_manager):
|
||||
query = str((args or {}).get("query") or "").strip()
|
||||
if not query:
|
||||
return {"status": "error", "message": "检索问题为空"}
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
results = await search_knowledge(
|
||||
session,
|
||||
knowledge_id,
|
||||
query,
|
||||
top_k=int(data.get("knowledgeTopN") or 5),
|
||||
score_threshold=float(data.get("knowledgeScoreThreshold") or 0.0),
|
||||
)
|
||||
return {"status": "ok", "results": results}
|
||||
except Exception as exc: # noqa: BLE001 - tool errors are returned to the LLM
|
||||
logger.warning(f"Workflow 知识库检索失败:{exc}")
|
||||
return {"status": "error", "message": "知识库检索暂时不可用"}
|
||||
|
||||
return FlowsFunctionSchema(
|
||||
name="search_knowledge_base",
|
||||
description=description,
|
||||
properties={
|
||||
"query": {"type": "string", "description": "完整问题或检索关键词"}
|
||||
},
|
||||
required=["query"],
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
async def _follow_edge(self, edge: dict) -> NodeConfig:
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
await self._require_runtime().queue_frame(
|
||||
TTSSpeakFrame(self._store.render(speech), append_to_context=False)
|
||||
)
|
||||
return await self._resolve_path(str(edge.get("target") or ""))
|
||||
|
||||
async def _resolve_path(self, node_id: str) -> NodeConfig:
|
||||
for _ in range(MAX_AUTOMATIC_HOPS):
|
||||
node_type = self._engine.node_type(node_id)
|
||||
if node_type == "agent":
|
||||
await self._apply_agent_stage(node_id)
|
||||
return self._agent_config(node_id)
|
||||
if node_type == "end":
|
||||
await self._enter_end(node_id)
|
||||
return self._terminal_config(node_id)
|
||||
if node_type == "action":
|
||||
await self._enter_action(node_id)
|
||||
elif node_type == "handoff":
|
||||
await self._enter_handoff(node_id)
|
||||
elif node_type == "start":
|
||||
await self._emit_node_active(node_id)
|
||||
else:
|
||||
raise RuntimeError(f"工作流指向未知节点:{node_id}")
|
||||
edge = self._engine.deterministic_edge(
|
||||
node_id,
|
||||
self._store,
|
||||
include_default=True,
|
||||
)
|
||||
if not edge:
|
||||
raise RuntimeError(f"自动节点 {node_id} 没有命中的表达式边或默认边")
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
await self._require_runtime().queue_frame(
|
||||
TTSSpeakFrame(self._store.render(speech), append_to_context=False)
|
||||
)
|
||||
node_id = str(edge.get("target") or "")
|
||||
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||
|
||||
async def _enter_action(self, node_id: str) -> None:
|
||||
await self._emit_node_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
tool_id = str(data.get("toolId") or "")
|
||||
tool = self._tool_by_id.get(tool_id)
|
||||
if not tool:
|
||||
self._store.values["system__last_action_status"] = "error"
|
||||
self._store.values["system__last_action_error"] = f"工具不存在:{tool_id}"
|
||||
return
|
||||
try:
|
||||
arguments = self._store.render_data(data.get("arguments") or {})
|
||||
await self._tools.execute(
|
||||
tool,
|
||||
arguments,
|
||||
result_assignments=data.get("resultAssignments") or {},
|
||||
)
|
||||
self._store.values["system__last_action_status"] = "ok"
|
||||
self._store.values["system__last_action_error"] = ""
|
||||
except (ToolExecutionError, ValueError) as exc:
|
||||
self._store.values["system__last_action_status"] = "error"
|
||||
self._store.values["system__last_action_error"] = str(exc)[:2048]
|
||||
|
||||
async def _enter_handoff(self, node_id: str) -> None:
|
||||
await self._emit_node_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
message = self._store.render(str(data.get("message") or ""))
|
||||
await self._require_runtime().queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={
|
||||
"type": "handoff-requested",
|
||||
"nodeId": node_id,
|
||||
"targetType": data.get("targetType", "human"),
|
||||
"target": data.get("target", ""),
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
)
|
||||
if message:
|
||||
await self._require_runtime().queue_frame(
|
||||
TTSSpeakFrame(message, append_to_context=False)
|
||||
)
|
||||
self._store.values["system__handoff_status"] = "requested"
|
||||
|
||||
async def _enter_end(self, node_id: str) -> None:
|
||||
self._ended = True
|
||||
await self._emit_node_active(node_id)
|
||||
runtime = self._require_runtime()
|
||||
if runtime.set_knowledge_scope:
|
||||
runtime.set_knowledge_scope({"mode": "disabled"})
|
||||
if runtime.set_input_enabled:
|
||||
runtime.set_input_enabled(False)
|
||||
data = self._engine.data(node_id)
|
||||
message = self._store.render(str(data.get("message") or ""))
|
||||
scope = str(data.get("scope") or "session")
|
||||
if scope == "flow":
|
||||
await runtime.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={"type": "flow-ended", "nodeId": node_id}
|
||||
)
|
||||
)
|
||||
if message:
|
||||
await runtime.queue_frame(TTSSpeakFrame(message, append_to_context=False))
|
||||
return
|
||||
runtime.call_end.begin("workflow_completed")
|
||||
if message:
|
||||
runtime.call_end.arm_after_speech()
|
||||
await runtime.queue_frame(TTSSpeakFrame(message, append_to_context=False))
|
||||
else:
|
||||
await runtime.call_end.finish()
|
||||
|
||||
async def _emit_node_active(self, node_id: str | None) -> None:
|
||||
if node_id:
|
||||
@@ -128,61 +406,12 @@ class WorkflowBrain(BaseBrain):
|
||||
)
|
||||
)
|
||||
|
||||
async def _speak_transition(self, edge: dict | None) -> None:
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
await self._require_runtime().queue_frame(
|
||||
TTSSpeakFrame(speech, append_to_context=False)
|
||||
)
|
||||
|
||||
def _make_transition_handler(self, edge: dict):
|
||||
target = str(edge.get("target"))
|
||||
|
||||
async def handler(params) -> None:
|
||||
logger.info(f"LLM 触发转移 → {self._engine.name(target)}")
|
||||
if not self._engine.is_end(target):
|
||||
await self._speak_transition(edge)
|
||||
await self._go_to_node(target)
|
||||
await params.result_callback({"status": "ok"})
|
||||
|
||||
return handler
|
||||
|
||||
async def _fallback_route(self) -> None:
|
||||
if self._state.ended:
|
||||
return
|
||||
if self._state.turns_in_node < self._FALLBACK_AFTER_TURNS:
|
||||
return
|
||||
if not self._engine.outgoing(self._state.current):
|
||||
return
|
||||
|
||||
cfg = self._require_config()
|
||||
target = await self._engine.route(
|
||||
self._state.current,
|
||||
self._history,
|
||||
api_key=self._require(cfg.llm_api_key, "LLM apiKey"),
|
||||
base_url=self._require(cfg.llm_base_url, "LLM apiUrl"),
|
||||
model=self._require(cfg.model, "LLM modelId"),
|
||||
)
|
||||
if target and target != self._state.current:
|
||||
logger.info(f"文本兜底触发转移 → {self._engine.name(target)}")
|
||||
if not self._engine.is_end(target):
|
||||
await self._speak_transition(
|
||||
self._engine.find_edge(self._state.current, target)
|
||||
)
|
||||
await self._go_to_node(target)
|
||||
|
||||
def _require_runtime(self) -> BrainRuntime:
|
||||
if self._runtime is None:
|
||||
raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime")
|
||||
return self._runtime
|
||||
|
||||
def _require_config(self) -> AssistantConfig:
|
||||
if self._cfg is None:
|
||||
raise RuntimeError("WorkflowBrain 尚未初始化配置")
|
||||
return self._cfg
|
||||
|
||||
@staticmethod
|
||||
def _require(value: str, label: str) -> str:
|
||||
if value:
|
||||
return value
|
||||
raise ValueError(f"缺少模型资源配置: {label}")
|
||||
def _require_manager(self) -> FlowManager:
|
||||
if self._manager is None:
|
||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||
return self._manager
|
||||
|
||||
@@ -12,7 +12,13 @@ from db.models import (
|
||||
ModelResource,
|
||||
Tool,
|
||||
)
|
||||
from models import AssistantConfig, RuntimeTool
|
||||
from models import (
|
||||
AssistantConfig,
|
||||
RuntimeKnowledgeBase,
|
||||
RuntimeModelResource,
|
||||
RuntimeTool,
|
||||
)
|
||||
from services.node_specs import graph_references, normalize_graph
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -83,7 +89,7 @@ def _secret(resource: ModelResource | None, key: str, default: str = "") -> str:
|
||||
|
||||
|
||||
async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[RuntimeTool]:
|
||||
if assistant.type != "prompt":
|
||||
if assistant.type not in {"prompt", "workflow"}:
|
||||
return []
|
||||
tools = (
|
||||
await session.execute(
|
||||
@@ -134,6 +140,43 @@ async def resolve_runtime_config(
|
||||
else None
|
||||
)
|
||||
|
||||
graph = normalize_graph(assistant.graph or {}) if assistant.type == "workflow" else {}
|
||||
refs = graph_references(graph) if graph else {
|
||||
"model_resources": set(),
|
||||
"knowledge_bases": set(),
|
||||
}
|
||||
workflow_resources: dict[str, RuntimeModelResource] = {}
|
||||
if refs["model_resources"]:
|
||||
resources = (
|
||||
await session.execute(
|
||||
select(ModelResource).where(ModelResource.id.in_(refs["model_resources"]))
|
||||
)
|
||||
).scalars().all()
|
||||
workflow_resources = {
|
||||
resource.id: RuntimeModelResource(
|
||||
id=resource.id,
|
||||
name=resource.name,
|
||||
capability=resource.capability,
|
||||
interface_type=resource.interface_type,
|
||||
values=resource.values or {},
|
||||
secrets=resource.secrets or {},
|
||||
)
|
||||
for resource in resources
|
||||
if resource.enabled
|
||||
}
|
||||
workflow_knowledge: dict[str, RuntimeKnowledgeBase] = {}
|
||||
if refs["knowledge_bases"]:
|
||||
knowledge_rows = (
|
||||
await session.execute(
|
||||
select(KnowledgeBase).where(KnowledgeBase.id.in_(refs["knowledge_bases"]))
|
||||
)
|
||||
).scalars().all()
|
||||
workflow_knowledge = {
|
||||
kb.id: RuntimeKnowledgeBase(id=kb.id, name=kb.name, description=kb.description)
|
||||
for kb in knowledge_rows
|
||||
if kb.status == "active"
|
||||
}
|
||||
|
||||
return AssistantConfig(
|
||||
name=assistant.name,
|
||||
type=assistant.type,
|
||||
@@ -150,7 +193,9 @@ async def resolve_runtime_config(
|
||||
knowledge_base_description=knowledge_base.description if knowledge_base else "",
|
||||
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
|
||||
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
|
||||
graph=(assistant.graph or {}) if assistant.type == "workflow" else {},
|
||||
graph=graph,
|
||||
workflow_model_resources=workflow_resources,
|
||||
workflow_knowledge_bases=workflow_knowledge,
|
||||
# 外部托管类型连接信息(DB 存真 key,直接注入)
|
||||
dify_api_url=str(_value(agent_resource, "apiUrl", assistant.api_url)),
|
||||
dify_api_key=_secret(agent_resource, "apiKey", assistant.api_key),
|
||||
|
||||
@@ -1,132 +1,108 @@
|
||||
"""工作流节点规格 + 图校验(对齐 dograh 的 node-spec / GraphConstraints 思路)。
|
||||
|
||||
当前实现 4 个核心节点:开始(startCall)/智能体(agentNode)/结束(endCall)/全局(globalNode)。
|
||||
本模块是「节点类型」的唯一事实源:
|
||||
- /api/node-types 接口直接吐这里的规格;
|
||||
- 助手保存时用这里的约束校验 workflow 图。
|
||||
|
||||
新增节点类型只需在 NODE_SPECS 里加一条并补充约束。前端 specs.ts 与此保持一致。
|
||||
"""
|
||||
"""Workflow v3 node catalog, v2 compatibility normalization, and validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
# 规格版本号:节点定义有破坏性变更时 +1,前端可据此判断是否需要刷新缓存。
|
||||
SPEC_VERSION = "2"
|
||||
|
||||
# 每个节点的图约束。None 表示不限制。
|
||||
# min_incoming / max_incoming:入边数量
|
||||
# min_outgoing / max_outgoing:出边数量
|
||||
SPEC_VERSION = "3"
|
||||
NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
|
||||
EDGE_MODES = {"llm", "expression", "always"}
|
||||
EXPRESSION_OPERATORS = {
|
||||
"eq",
|
||||
"neq",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"contains",
|
||||
"in",
|
||||
"exists",
|
||||
}
|
||||
|
||||
NODE_SPECS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "startCall",
|
||||
"displayName": "开始",
|
||||
"category": "call_node",
|
||||
"description": "工作流入口,每个流程有且仅有一个。播放开场白,并用自己的提示词进行多轮对话,满足出边条件后流转。",
|
||||
"name": "start",
|
||||
"displayName": "Start",
|
||||
"category": "control_node",
|
||||
"description": "初始化会话、动态变量和全局观察器,可播放固定开场白。",
|
||||
"icon": "Play",
|
||||
"accent": "mint",
|
||||
"addable": False,
|
||||
"constraints": {
|
||||
"minIncoming": 0,
|
||||
"maxIncoming": 0,
|
||||
"minOutgoing": 1,
|
||||
"minInstances": 1,
|
||||
"maxInstances": 1,
|
||||
},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "开始"},
|
||||
{"key": "greeting", "label": "开场白", "type": "textarea", "default": ""},
|
||||
{"key": "prompt", "label": "节点提示词", "type": "textarea", "default": ""},
|
||||
{
|
||||
"key": "allowInterrupt",
|
||||
"label": "允许用户打断",
|
||||
"type": "switch",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"key": "addGlobalPrompt",
|
||||
"label": "应用全局提示词",
|
||||
"type": "switch",
|
||||
"default": True,
|
||||
},
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Start"},
|
||||
{"key": "greeting", "label": "固定开场白", "type": "textarea", "default": ""},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "agentNode",
|
||||
"displayName": "智能体节点",
|
||||
"category": "call_node",
|
||||
"description": "对话处理单元。按提示词与用户多轮交互,可有多个并通过条件边流转。",
|
||||
"name": "agent",
|
||||
"displayName": "Agent",
|
||||
"category": "conversation_node",
|
||||
"description": "阶段智能体:绑定上下文、工具、知识库及 ASR/TTS 资源。",
|
||||
"icon": "Bot",
|
||||
"accent": "sky",
|
||||
"addable": True,
|
||||
"constraints": {"minIncoming": 1},
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "智能体节点"},
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Agent"},
|
||||
{
|
||||
"key": "prompt",
|
||||
"label": "节点提示词",
|
||||
"label": "阶段提示词",
|
||||
"type": "textarea",
|
||||
"required": True,
|
||||
"default": "",
|
||||
},
|
||||
{
|
||||
"key": "allowInterrupt",
|
||||
"label": "允许用户打断",
|
||||
"type": "switch",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"key": "addGlobalPrompt",
|
||||
"label": "应用全局提示词",
|
||||
"type": "switch",
|
||||
"default": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "endCall",
|
||||
"displayName": "结束",
|
||||
"category": "call_node",
|
||||
"description": "终止节点,礼貌结束对话。可有多个,均无出边。",
|
||||
"name": "action",
|
||||
"displayName": "Action",
|
||||
"category": "execution_node",
|
||||
"description": "确定性执行指定工具,并将结果字段写入会话动态变量。",
|
||||
"icon": "Zap",
|
||||
"accent": "peach",
|
||||
"addable": True,
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Action"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "handoff",
|
||||
"displayName": "Handoff",
|
||||
"category": "execution_node",
|
||||
"description": "转交其他 AI、人工、队列或电话;MVP 发送转交事件后继续路由。",
|
||||
"icon": "PhoneForwarded",
|
||||
"accent": "lavender",
|
||||
"addable": True,
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Handoff"},
|
||||
{"key": "target", "label": "转交目标", "type": "text", "default": ""},
|
||||
{"key": "message", "label": "转交提示", "type": "textarea", "default": ""},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"displayName": "End",
|
||||
"category": "control_node",
|
||||
"description": "结束 AI 流程或整个音视频会话。",
|
||||
"icon": "Flag",
|
||||
"accent": "rose",
|
||||
"addable": True,
|
||||
"constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "结束"},
|
||||
{"key": "prompt", "label": "结束语提示词", "type": "textarea", "default": ""},
|
||||
{
|
||||
"key": "addGlobalPrompt",
|
||||
"label": "应用全局提示词",
|
||||
"type": "switch",
|
||||
"default": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "globalNode",
|
||||
"displayName": "全局节点",
|
||||
"category": "global_node",
|
||||
"description": "为整个工作流提供统一的人设、语气和公共规则。无需连线,每个流程最多一个。",
|
||||
"icon": "Globe2",
|
||||
"accent": "lavender",
|
||||
"addable": True,
|
||||
"constraints": {
|
||||
"minIncoming": 0,
|
||||
"maxIncoming": 0,
|
||||
"minOutgoing": 0,
|
||||
"maxOutgoing": 0,
|
||||
"maxInstances": 1,
|
||||
},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "全局设定"},
|
||||
{
|
||||
"key": "prompt",
|
||||
"label": "全局提示词",
|
||||
"type": "textarea",
|
||||
"required": True,
|
||||
"default": "你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
||||
},
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "End"},
|
||||
{"key": "message", "label": "固定结束语", "type": "textarea", "default": ""},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -135,108 +111,303 @@ _SPEC_BY_NAME = {spec["name"]: spec for spec in NODE_SPECS}
|
||||
|
||||
|
||||
def node_types_response() -> dict[str, Any]:
|
||||
"""/api/node-types 的响应体(camelCase,直接喂前端)。"""
|
||||
return {"specVersion": SPEC_VERSION, "nodeTypes": NODE_SPECS}
|
||||
|
||||
|
||||
def validate_graph(graph: dict[str, Any]) -> list[str]:
|
||||
"""校验 workflow 图,返回错误信息列表(空列表 = 通过)。
|
||||
def _edge_data_v3(edge: dict, source_type: str) -> dict:
|
||||
data = deepcopy(edge.get("data") or {})
|
||||
if data.get("mode") in EDGE_MODES:
|
||||
data.setdefault("priority", 10)
|
||||
return data
|
||||
condition = str(data.pop("condition", "") or "").strip()
|
||||
transition = data.pop("transition_speech", data.get("transitionSpeech", ""))
|
||||
data.update(
|
||||
{
|
||||
"mode": "llm" if condition and source_type == "agent" else "always",
|
||||
"priority": 10,
|
||||
"condition": condition,
|
||||
"transitionSpeech": transition,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
基础规则(对齐 dograh 的核心不变量):
|
||||
1. 节点类型必须是已注册类型;
|
||||
2. 有且仅有一个 startCall;
|
||||
3. 至少有一个 endCall,全局节点最多一个;
|
||||
4. 边的 source/target 必须指向存在的节点;
|
||||
5. 入边/出边数量满足各节点类型的约束。
|
||||
|
||||
空图(无节点)视为草稿,直接放行,方便先存后编排。
|
||||
"""
|
||||
errors: list[str] = []
|
||||
nodes = graph.get("nodes") or []
|
||||
edges = graph.get("edges") or []
|
||||
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return a deep-copied v3 graph; preserve v3 IDs and migrate v2 semantics."""
|
||||
source = deepcopy(graph or {})
|
||||
if str(source.get("specVersion") or "") == SPEC_VERSION:
|
||||
source.setdefault("settings", {})
|
||||
source.setdefault("nodes", [])
|
||||
source.setdefault("edges", [])
|
||||
return source
|
||||
|
||||
if not nodes:
|
||||
return errors # 草稿:放行
|
||||
|
||||
node_ids: set[str] = set()
|
||||
type_counts: dict[str, int] = {}
|
||||
node_type_by_id: dict[str, str] = {}
|
||||
nodes = source.get("nodes") or []
|
||||
edges = source.get("edges") or []
|
||||
global_prompt = ""
|
||||
mapped_nodes: list[dict] = []
|
||||
type_by_id: dict[str, str] = {}
|
||||
start_prompt_nodes: dict[str, str] = {}
|
||||
|
||||
type_map = {
|
||||
"startCall": "start",
|
||||
"agentNode": "agent",
|
||||
"endCall": "end",
|
||||
"start": "start",
|
||||
"agent": "agent",
|
||||
"action": "action",
|
||||
"handoff": "handoff",
|
||||
"end": "end",
|
||||
}
|
||||
for node in nodes:
|
||||
node_id = node.get("id")
|
||||
node_type = node.get("type")
|
||||
if not node_id:
|
||||
errors.append("存在缺少 id 的节点")
|
||||
old_type = str(node.get("type") or "")
|
||||
data = deepcopy(node.get("data") or {})
|
||||
if old_type == "globalNode":
|
||||
global_prompt = str(data.get("prompt") or "")
|
||||
continue
|
||||
if node_id in node_ids:
|
||||
errors.append(f"节点 id 重复:{node_id}")
|
||||
node_ids.add(node_id)
|
||||
if node_type not in _SPEC_BY_NAME:
|
||||
errors.append(f"未知节点类型:{node_type}(节点 {node_id})")
|
||||
continue
|
||||
node_type_by_id[node_id] = node_type
|
||||
type_counts[node_type] = type_counts.get(node_type, 0) + 1
|
||||
new_type = type_map.get(old_type, old_type)
|
||||
migrated = deepcopy(node)
|
||||
migrated["type"] = new_type
|
||||
if new_type == "end":
|
||||
data["message"] = data.pop("message", data.pop("prompt", ""))
|
||||
data.setdefault("scope", "session")
|
||||
elif new_type == "agent":
|
||||
data.setdefault("contextPolicy", "inherit")
|
||||
data.setdefault("toolIds", [])
|
||||
data.setdefault("knowledgeMode", "disabled")
|
||||
elif new_type == "start":
|
||||
prompt = str(data.pop("prompt", "") or "").strip()
|
||||
if prompt:
|
||||
start_prompt_nodes[str(node.get("id"))] = prompt
|
||||
for key in ("allowInterrupt", "addGlobalPrompt"):
|
||||
data.pop(key, None)
|
||||
migrated["data"] = data
|
||||
mapped_nodes.append(migrated)
|
||||
if migrated.get("id"):
|
||||
type_by_id[str(migrated["id"])] = new_type
|
||||
|
||||
start_count = type_counts.get("startCall", 0)
|
||||
if start_count == 0:
|
||||
errors.append("工作流必须有一个「开始」节点")
|
||||
elif start_count > 1:
|
||||
errors.append("工作流只能有一个「开始」节点")
|
||||
mapped_edges: list[dict] = []
|
||||
for edge in edges:
|
||||
migrated = deepcopy(edge)
|
||||
migrated["data"] = _edge_data_v3(
|
||||
migrated, type_by_id.get(str(migrated.get("source")), "")
|
||||
)
|
||||
mapped_edges.append(migrated)
|
||||
|
||||
if type_counts.get("endCall", 0) == 0:
|
||||
errors.append("工作流至少需要一个「结束」节点")
|
||||
|
||||
for node_type, spec in _SPEC_BY_NAME.items():
|
||||
# 开始节点上方已有更明确的中文错误提示,避免重复报错。
|
||||
if node_type == "startCall":
|
||||
continue
|
||||
constraints = spec["constraints"]
|
||||
count = type_counts.get(node_type, 0)
|
||||
_check_count(
|
||||
errors,
|
||||
count,
|
||||
constraints,
|
||||
"Instances",
|
||||
node_type,
|
||||
"实例",
|
||||
# A v2 Start was conversational. Insert a synthetic Agent so its prompt remains active.
|
||||
for start_id, prompt in start_prompt_nodes.items():
|
||||
synthetic_id = f"{start_id}-migrated-agent"
|
||||
start_node = next((n for n in mapped_nodes if n.get("id") == start_id), None)
|
||||
position = (start_node or {}).get("position") or {"x": 100, "y": 120}
|
||||
mapped_nodes.append(
|
||||
{
|
||||
"id": synthetic_id,
|
||||
"type": "agent",
|
||||
"position": {"x": position.get("x", 100) + 300, "y": position.get("y", 120)},
|
||||
"data": {
|
||||
"name": "迁移的开场 Agent",
|
||||
"prompt": prompt,
|
||||
"contextPolicy": "inherit",
|
||||
"toolIds": [],
|
||||
"knowledgeMode": "disabled",
|
||||
},
|
||||
}
|
||||
)
|
||||
for edge in mapped_edges:
|
||||
if edge.get("source") == start_id:
|
||||
edge["source"] = synthetic_id
|
||||
edge["data"] = _edge_data_v3(edge, "agent")
|
||||
if str(edge["data"].get("condition") or "").strip():
|
||||
edge["data"]["mode"] = "llm"
|
||||
mapped_edges.append(
|
||||
{
|
||||
"id": f"e-{start_id}-{synthetic_id}",
|
||||
"source": start_id,
|
||||
"target": synthetic_id,
|
||||
"data": {"mode": "always", "priority": 0, "transitionSpeech": ""},
|
||||
}
|
||||
)
|
||||
|
||||
# 统计入边/出边
|
||||
incoming: dict[str, int] = {nid: 0 for nid in node_ids}
|
||||
outgoing: dict[str, int] = {nid: 0 for nid in node_ids}
|
||||
for edge in edges:
|
||||
source = edge.get("source")
|
||||
target = edge.get("target")
|
||||
if source not in node_ids:
|
||||
errors.append(f"连线指向了不存在的源节点:{source}")
|
||||
continue
|
||||
if target not in node_ids:
|
||||
errors.append(f"连线指向了不存在的目标节点:{target}")
|
||||
continue
|
||||
outgoing[source] += 1
|
||||
incoming[target] += 1
|
||||
settings = deepcopy(source.get("settings") or {})
|
||||
settings.setdefault("globalPrompt", global_prompt)
|
||||
settings.setdefault("defaultAsrResourceId", "")
|
||||
settings.setdefault("defaultTtsResourceId", "")
|
||||
return {
|
||||
"specVersion": 3,
|
||||
"settings": settings,
|
||||
"nodes": mapped_nodes,
|
||||
"edges": mapped_edges,
|
||||
**({"viewport": deepcopy(source["viewport"])} if source.get("viewport") else {}),
|
||||
}
|
||||
|
||||
for node_id, node_type in node_type_by_id.items():
|
||||
constraints = _SPEC_BY_NAME[node_type]["constraints"]
|
||||
name = node_type
|
||||
_check_count(errors, incoming[node_id], constraints, "Incoming", name, "入边")
|
||||
_check_count(errors, outgoing[node_id], constraints, "Outgoing", name, "出边")
|
||||
|
||||
def _validate_expression(expression: Any) -> list[str]:
|
||||
if not isinstance(expression, dict):
|
||||
return ["表达式条件不能为空"]
|
||||
combinator = expression.get("combinator", "and")
|
||||
if combinator not in {"and", "or"}:
|
||||
return ["表达式组合方式必须是 and 或 or"]
|
||||
rules = expression.get("rules")
|
||||
if not isinstance(rules, list) or not rules:
|
||||
return ["表达式至少需要一条规则"]
|
||||
errors = []
|
||||
for rule in rules:
|
||||
if not isinstance(rule, dict) or not rule.get("variable"):
|
||||
errors.append("表达式规则缺少变量")
|
||||
elif rule.get("operator") not in EXPRESSION_OPERATORS:
|
||||
errors.append(f"不支持的表达式运算符:{rule.get('operator')}")
|
||||
return errors
|
||||
|
||||
|
||||
def _check_count(
|
||||
errors: list[str],
|
||||
actual: int,
|
||||
constraints: dict[str, int],
|
||||
suffix: str,
|
||||
node_type: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
lo = constraints.get(f"min{suffix}")
|
||||
hi = constraints.get(f"max{suffix}")
|
||||
display = _SPEC_BY_NAME[node_type]["displayName"]
|
||||
if lo is not None and actual < lo:
|
||||
errors.append(f"「{display}」节点{label}数量不能少于 {lo}(当前 {actual})")
|
||||
if hi is not None and actual > hi:
|
||||
errors.append(f"「{display}」节点{label}数量不能多于 {hi}(当前 {actual})")
|
||||
def validate_graph(graph: dict[str, Any]) -> list[str]:
|
||||
graph = normalize_graph(graph)
|
||||
nodes = graph.get("nodes") or []
|
||||
edges = graph.get("edges") or []
|
||||
if not nodes:
|
||||
return []
|
||||
|
||||
errors: list[str] = []
|
||||
node_by_id: dict[str, dict] = {}
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
for node in nodes:
|
||||
node_id = str(node.get("id") or "")
|
||||
node_type = str(node.get("type") or "")
|
||||
if not node_id:
|
||||
errors.append("存在缺少 id 的节点")
|
||||
continue
|
||||
if node_id in node_by_id:
|
||||
errors.append(f"节点 id 重复:{node_id}")
|
||||
if node_type not in NODE_TYPES:
|
||||
errors.append(f"未知节点类型:{node_type}(节点 {node_id})")
|
||||
node_by_id[node_id] = node
|
||||
counts[node_type] += 1
|
||||
|
||||
if counts["start"] != 1:
|
||||
errors.append("工作流必须有且仅有一个 Start 节点")
|
||||
if counts["end"] < 1:
|
||||
errors.append("工作流至少需要一个 End 节点")
|
||||
|
||||
incoming: dict[str, int] = defaultdict(int)
|
||||
outgoing: dict[str, int] = defaultdict(int)
|
||||
adj: dict[str, list[str]] = defaultdict(list)
|
||||
auto_adj: dict[str, list[str]] = defaultdict(list)
|
||||
priorities: dict[str, set[int]] = defaultdict(set)
|
||||
always_counts: dict[str, int] = defaultdict(int)
|
||||
for edge in edges:
|
||||
edge_id = str(edge.get("id") or "")
|
||||
source_id = str(edge.get("source") or "")
|
||||
target_id = str(edge.get("target") or "")
|
||||
if source_id not in node_by_id:
|
||||
errors.append(f"边 {edge_id} 指向不存在的源节点:{source_id}")
|
||||
continue
|
||||
if target_id not in node_by_id:
|
||||
errors.append(f"边 {edge_id} 指向不存在的目标节点:{target_id}")
|
||||
continue
|
||||
if source_id == target_id and node_by_id[source_id].get("type") != "agent":
|
||||
errors.append(f"自动节点不能自连:{source_id}")
|
||||
data = edge.get("data") or {}
|
||||
mode = data.get("mode")
|
||||
if mode not in EDGE_MODES:
|
||||
errors.append(f"边 {edge_id} 的判断模式无效:{mode}")
|
||||
if mode == "llm" and node_by_id[source_id].get("type") != "agent":
|
||||
errors.append(f"LLM 判断边只能从 Agent 发出:{edge_id}")
|
||||
if mode == "llm" and not str(data.get("condition") or "").strip():
|
||||
errors.append(f"LLM 判断边缺少自然语言条件:{edge_id}")
|
||||
if mode == "expression":
|
||||
errors.extend(f"边 {edge_id}:{item}" for item in _validate_expression(data.get("expression")))
|
||||
try:
|
||||
priority = int(data.get("priority", 10))
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"边 {edge_id} 的优先级必须是整数")
|
||||
priority = 10
|
||||
if priority in priorities[source_id]:
|
||||
errors.append(f"节点 {source_id} 的出边优先级不能重复:{priority}")
|
||||
priorities[source_id].add(priority)
|
||||
if mode == "always":
|
||||
always_counts[source_id] += 1
|
||||
if always_counts[source_id] > 1:
|
||||
errors.append(f"节点 {source_id} 最多只能有一条默认边")
|
||||
incoming[target_id] += 1
|
||||
outgoing[source_id] += 1
|
||||
adj[source_id].append(target_id)
|
||||
if node_by_id[source_id].get("type") != "agent" and node_by_id[target_id].get("type") != "agent":
|
||||
auto_adj[source_id].append(target_id)
|
||||
|
||||
for node_id, node in node_by_id.items():
|
||||
spec = _SPEC_BY_NAME.get(str(node.get("type")))
|
||||
if not spec:
|
||||
continue
|
||||
constraints = spec["constraints"]
|
||||
for actual, suffix, label in (
|
||||
(incoming[node_id], "Incoming", "入边"),
|
||||
(outgoing[node_id], "Outgoing", "出边"),
|
||||
):
|
||||
lo = constraints.get(f"min{suffix}")
|
||||
hi = constraints.get(f"max{suffix}")
|
||||
if lo is not None and actual < lo:
|
||||
errors.append(f"节点 {node_id} 的{label}不能少于 {lo}")
|
||||
if hi is not None and actual > hi:
|
||||
errors.append(f"节点 {node_id} 的{label}不能多于 {hi}")
|
||||
if node.get("type") in {"start", "action", "handoff"} and always_counts[node_id] != 1:
|
||||
errors.append(f"自动节点 {node_id} 必须有且仅有一条默认边")
|
||||
|
||||
start_id = next((nid for nid, n in node_by_id.items() if n.get("type") == "start"), None)
|
||||
if start_id:
|
||||
reached = {start_id}
|
||||
queue = deque([start_id])
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
for target in adj[current]:
|
||||
if target not in reached:
|
||||
reached.add(target)
|
||||
queue.append(target)
|
||||
for node_id in node_by_id.keys() - reached:
|
||||
errors.append(f"节点不可从 Start 到达:{node_id}")
|
||||
|
||||
# Reject cycles made only of instantaneous nodes; Agent cycles are valid waits.
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(node_id: str) -> bool:
|
||||
if node_id in visiting:
|
||||
return True
|
||||
if node_id in visited:
|
||||
return False
|
||||
visiting.add(node_id)
|
||||
for target in auto_adj[node_id]:
|
||||
if visit(target):
|
||||
return True
|
||||
visiting.remove(node_id)
|
||||
visited.add(node_id)
|
||||
return False
|
||||
|
||||
if any(visit(node_id) for node_id, node in node_by_id.items() if node.get("type") != "agent"):
|
||||
errors.append("Start/Action/Handoff/End 之间不能形成无等待循环")
|
||||
return list(dict.fromkeys(errors))
|
||||
|
||||
|
||||
def graph_references(graph: dict[str, Any]) -> dict[str, set[str]]:
|
||||
"""Collect externally referenced IDs for save/runtime validation."""
|
||||
normalized = normalize_graph(graph)
|
||||
settings = normalized.get("settings") or {}
|
||||
resources = {
|
||||
str(value)
|
||||
for value in (
|
||||
settings.get("defaultAsrResourceId"),
|
||||
settings.get("defaultTtsResourceId"),
|
||||
)
|
||||
if value
|
||||
}
|
||||
tools: set[str] = set()
|
||||
knowledge: set[str] = set()
|
||||
for node in normalized.get("nodes") or []:
|
||||
data = node.get("data") or {}
|
||||
for resource_id in (data.get("asrResourceId"), data.get("ttsResourceId")):
|
||||
if resource_id:
|
||||
resources.add(str(resource_id))
|
||||
for tool_id in data.get("toolIds") or []:
|
||||
tools.add(str(tool_id))
|
||||
if data.get("toolId"):
|
||||
tools.add(str(data["toolId"]))
|
||||
if data.get("knowledgeBaseId"):
|
||||
knowledge.add(str(data["knowledgeBaseId"]))
|
||||
return {"model_resources": resources, "tools": tools, "knowledge_bases": knowledge}
|
||||
|
||||
@@ -23,6 +23,7 @@ from services.pipecat.call_lifecycle import (
|
||||
EndCallAfterSpeechProcessor,
|
||||
)
|
||||
from services.pipecat.service_factory import (
|
||||
config_with_resource,
|
||||
create_realtime_service,
|
||||
create_stt,
|
||||
create_tts,
|
||||
@@ -32,6 +33,7 @@ from services.knowledge import search as search_knowledge
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.flows import FlowsFunctionSchema
|
||||
from pipecat.frames.frames import (
|
||||
EndFrame,
|
||||
InputTransportMessageFrame,
|
||||
@@ -40,6 +42,7 @@ from pipecat.frames.frames import (
|
||||
LLMFullResponseStartFrame,
|
||||
LLMContextFrame,
|
||||
LLMTextFrame,
|
||||
ManuallySwitchServiceFrame,
|
||||
LLMMessagesAppendFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
TextFrame,
|
||||
@@ -48,6 +51,7 @@ from pipecat.frames.frames import (
|
||||
UserImageRequestFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.service_switcher import ServiceSwitcher
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
@@ -397,15 +401,59 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
|
||||
self._knowledge_base_id = knowledge_base_id
|
||||
self._top_n = top_n
|
||||
self._score_threshold = score_threshold
|
||||
self._mode = "automatic" if knowledge_base_id else "disabled"
|
||||
self._last_signature = ""
|
||||
|
||||
def set_scope(self, scope: dict) -> None:
|
||||
self._knowledge_base_id = scope.get("knowledge_base_id") or None
|
||||
self._mode = str(scope.get("mode") or "disabled")
|
||||
self._top_n = int(scope.get("top_n") or 5)
|
||||
self._score_threshold = float(scope.get("score_threshold") or 0.0)
|
||||
self._last_signature = ""
|
||||
|
||||
def _clear_context(self, messages: list[dict]) -> None:
|
||||
# Remove the legacy Workflow knowledge message so an in-flight context
|
||||
# created before this compatibility fix cannot keep sending that role.
|
||||
messages[:] = [
|
||||
message
|
||||
for message in messages
|
||||
if not (
|
||||
message.get("role") == "developer"
|
||||
and KNOWLEDGE_CONTEXT_MARKER in str(message.get("content") or "")
|
||||
)
|
||||
]
|
||||
system_message = next(
|
||||
(message for message in messages if message.get("role") == "system"),
|
||||
None,
|
||||
)
|
||||
if system_message is not None:
|
||||
content = str(system_message.get("content") or "")
|
||||
system_message["content"] = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
|
||||
|
||||
def _set_context(self, messages: list[dict], block: str) -> None:
|
||||
"""Store retrieved knowledge in a provider-compatible system message."""
|
||||
self._clear_context(messages)
|
||||
system_message = next(
|
||||
(message for message in messages if message.get("role") == "system"),
|
||||
None,
|
||||
)
|
||||
if system_message is None:
|
||||
messages.insert(0, {"role": "system", "content": block})
|
||||
return
|
||||
content = str(system_message.get("content") or "").rstrip()
|
||||
system_message["content"] = f"{content}\n\n{block}" if content else block
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if not self._knowledge_base_id or not isinstance(frame, LLMContextFrame):
|
||||
if not isinstance(frame, LLMContextFrame):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
messages = frame.context.get_messages()
|
||||
if self._mode != "automatic" or not self._knowledge_base_id:
|
||||
self._clear_context(messages)
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
user_messages = [message for message in messages if message.get("role") == "user"]
|
||||
if not user_messages:
|
||||
await self.push_frame(frame, direction)
|
||||
@@ -435,13 +483,7 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
|
||||
for index, item in enumerate(results)
|
||||
) or "未检索到相关资料。"
|
||||
block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}"
|
||||
system_message = next((message for message in messages if message.get("role") == "system"), None)
|
||||
if system_message is None:
|
||||
messages.insert(0, {"role": "system", "content": block})
|
||||
else:
|
||||
content = str(system_message.get("content") or "")
|
||||
base = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
|
||||
system_message["content"] = f"{base}\n\n{block}" if base else block
|
||||
self._set_context(messages, block)
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
@@ -457,7 +499,6 @@ class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
||||
self._stream_turn_id: str | None = None
|
||||
self._stream_timestamp = ""
|
||||
self._stream_text = ""
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
@@ -505,6 +546,49 @@ class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
||||
self._stream_text = ""
|
||||
|
||||
|
||||
class WorkflowAggregatorPair:
|
||||
"""Small public-shape adapter required by Pipecat FlowManager."""
|
||||
|
||||
def __init__(self, user_aggregator, assistant_aggregator):
|
||||
self._user = user_aggregator
|
||||
self._assistant = assistant_aggregator
|
||||
|
||||
def user(self):
|
||||
return self._user
|
||||
|
||||
def assistant(self):
|
||||
return self._assistant
|
||||
|
||||
|
||||
def _workflow_service_switcher(
|
||||
cfg: AssistantConfig, capability: str, base_service: FrameProcessor
|
||||
):
|
||||
"""Build one switcher and an ID lookup for every referenced voice resource."""
|
||||
create = create_stt if capability == "ASR" else create_tts
|
||||
settings = cfg.graph.get("settings") or {}
|
||||
default_key = (
|
||||
"defaultAsrResourceId" if capability == "ASR" else "defaultTtsResourceId"
|
||||
)
|
||||
default_id = str(settings.get(default_key) or "")
|
||||
services_by_id = {}
|
||||
for resource_id, resource in cfg.workflow_model_resources.items():
|
||||
if resource.capability != capability:
|
||||
continue
|
||||
services_by_id[resource_id] = (
|
||||
base_service
|
||||
if resource_id == default_id
|
||||
else create(config_with_resource(cfg, resource))
|
||||
)
|
||||
primary = services_by_id.get(default_id, base_service)
|
||||
services = [primary]
|
||||
services.extend(
|
||||
service for service in services_by_id.values() if service is not primary
|
||||
)
|
||||
if base_service is not primary:
|
||||
services.append(base_service)
|
||||
return ServiceSwitcher(services=services), services_by_id, primary
|
||||
|
||||
|
||||
async def run_pipeline(
|
||||
transport,
|
||||
cfg: AssistantConfig,
|
||||
@@ -545,8 +629,35 @@ async def run_pipeline(
|
||||
)
|
||||
return
|
||||
|
||||
stt = create_stt(cfg)
|
||||
tts = create_tts(cfg)
|
||||
graph_settings = cfg.graph.get("settings") or {}
|
||||
default_asr_resource = cfg.workflow_model_resources.get(
|
||||
str(graph_settings.get("defaultAsrResourceId") or "")
|
||||
)
|
||||
default_tts_resource = cfg.workflow_model_resources.get(
|
||||
str(graph_settings.get("defaultTtsResourceId") or "")
|
||||
)
|
||||
stt = create_stt(
|
||||
config_with_resource(cfg, default_asr_resource)
|
||||
if cfg.type == "workflow" and default_asr_resource
|
||||
else cfg
|
||||
)
|
||||
tts = create_tts(
|
||||
config_with_resource(cfg, default_tts_resource)
|
||||
if cfg.type == "workflow" and default_tts_resource
|
||||
else cfg
|
||||
)
|
||||
stt_processor = stt
|
||||
tts_processor = tts
|
||||
stt_services: dict[str, FrameProcessor] = {}
|
||||
tts_services: dict[str, FrameProcessor] = {}
|
||||
current_voice_services: dict[str, FrameProcessor] = {"asr": stt, "tts": tts}
|
||||
if cfg.type == "workflow":
|
||||
stt_processor, stt_services, current_voice_services["asr"] = (
|
||||
_workflow_service_switcher(cfg, "ASR", stt)
|
||||
)
|
||||
tts_processor, tts_services, current_voice_services["tts"] = (
|
||||
_workflow_service_switcher(cfg, "TTS", tts)
|
||||
)
|
||||
|
||||
greeting = await brain.greeting(cfg)
|
||||
system_content = brain.system_prompt(cfg)
|
||||
@@ -594,8 +705,13 @@ async def run_pipeline(
|
||||
return "\n\n".join(part for part in [text, *hints] if part)
|
||||
|
||||
context = LLMContext(
|
||||
messages=[{"role": "system", "content": with_vision_hint(system_content)}]
|
||||
messages=(
|
||||
[]
|
||||
if cfg.type == "workflow"
|
||||
else [{"role": "system", "content": with_vision_hint(system_content)}]
|
||||
)
|
||||
)
|
||||
input_state = {"enabled": True}
|
||||
# LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
|
||||
llm = brain.build_llm(cfg, context)
|
||||
user_aggregator = LLMUserAggregator(
|
||||
@@ -604,7 +720,9 @@ async def run_pipeline(
|
||||
vad_analyzer=create_vad_analyzer(cfg.turnConfig),
|
||||
user_mute_strategies=[
|
||||
FunctionCallUserMuteStrategy(),
|
||||
CallEndingUserMuteStrategy(lambda: call_end.ending),
|
||||
CallEndingUserMuteStrategy(
|
||||
lambda: call_end.ending or not input_state["enabled"]
|
||||
),
|
||||
],
|
||||
user_turn_strategies=create_user_turn_strategies(
|
||||
cfg.turnConfig,
|
||||
@@ -613,7 +731,9 @@ async def run_pipeline(
|
||||
),
|
||||
)
|
||||
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
||||
text_input = TextInputProcessor(should_ignore_input=lambda: call_end.ending)
|
||||
text_input = TextInputProcessor(
|
||||
should_ignore_input=lambda: call_end.ending or not input_state["enabled"]
|
||||
)
|
||||
vision_capture = VisionCaptureProcessor()
|
||||
knowledge_retrieval = KnowledgeRetrievalProcessor(
|
||||
automatic_knowledge_id,
|
||||
@@ -723,14 +843,54 @@ async def run_pipeline(
|
||||
}
|
||||
)
|
||||
|
||||
if vision_enabled:
|
||||
flow_global_functions = []
|
||||
if cfg.type == "workflow" and vision_enabled:
|
||||
async def flow_fetch_user_image(args, _flow_manager):
|
||||
question = str((args or {}).get("question") or "请描述当前画面。")
|
||||
user_id = vision_state.get("client_id")
|
||||
if not user_id:
|
||||
return {
|
||||
"status": "no_video_client",
|
||||
"message": "当前还没有可用的摄像头视频流。",
|
||||
}
|
||||
request = UserImageRequestFrame(
|
||||
user_id=user_id,
|
||||
text=question,
|
||||
append_to_context=False,
|
||||
function_name=VISION_TOOL_NAME,
|
||||
)
|
||||
try:
|
||||
frame = await vision_capture.request_image(llm, request)
|
||||
observation = await _analyze_image_with_vision_model(cfg, frame, question)
|
||||
return {
|
||||
"status": "ok",
|
||||
"question": question,
|
||||
"observation": observation or "视觉模型没有返回有效观察结果。",
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
return {"status": "timeout", "message": "等待摄像头视频帧超时。"}
|
||||
except Exception as exc: # noqa: BLE001 - return tool errors to the LLM
|
||||
logger.warning(f"Workflow 视觉理解失败:{exc}")
|
||||
return {"status": "error", "message": "视觉理解暂时不可用。"}
|
||||
|
||||
flow_global_functions.append(
|
||||
FlowsFunctionSchema(
|
||||
name=VISION_TOOL_NAME,
|
||||
description=vision_schema.description,
|
||||
properties=vision_schema.properties,
|
||||
required=vision_schema.required,
|
||||
handler=flow_fetch_user_image,
|
||||
)
|
||||
)
|
||||
|
||||
if vision_enabled and cfg.type != "workflow":
|
||||
llm.register_function(VISION_TOOL_NAME, fetch_user_image)
|
||||
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
|
||||
llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge)
|
||||
|
||||
def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None:
|
||||
tools = list(schemas or [])
|
||||
if vision_enabled:
|
||||
if vision_enabled and cfg.type != "workflow":
|
||||
tools.append(vision_schema)
|
||||
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
|
||||
tools.append(knowledge_schema)
|
||||
@@ -751,7 +911,7 @@ async def run_pipeline(
|
||||
transport.input(),
|
||||
vision_capture,
|
||||
text_input,
|
||||
stt,
|
||||
stt_processor,
|
||||
user_aggregator,
|
||||
knowledge_retrieval,
|
||||
llm,
|
||||
@@ -759,7 +919,7 @@ async def run_pipeline(
|
||||
# Pipecat commits the generated prefix immediately instead of
|
||||
# waiting for a TTS provider to emit spoken-text/timestamp frames.
|
||||
assistant_aggregator,
|
||||
tts,
|
||||
tts_processor,
|
||||
EndCallAfterSpeechProcessor(call_end),
|
||||
ConversationHistoryProcessor(recorder),
|
||||
transport.output(),
|
||||
@@ -774,6 +934,33 @@ async def run_pipeline(
|
||||
enable_rtvi=False,
|
||||
)
|
||||
worker_holder["worker"] = worker
|
||||
default_voice_services = dict(current_voice_services)
|
||||
|
||||
async def switch_workflow_services(
|
||||
asr_resource_id: str | None,
|
||||
tts_resource_id: str | None,
|
||||
) -> None:
|
||||
requested = (
|
||||
("asr", stt_services, asr_resource_id),
|
||||
("tts", tts_services, tts_resource_id),
|
||||
)
|
||||
for kind, services, resource_id in requested:
|
||||
target = services.get(resource_id) if resource_id else default_voice_services[kind]
|
||||
if target is None:
|
||||
raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
|
||||
if current_voice_services[kind] is target:
|
||||
continue
|
||||
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
|
||||
current_voice_services[kind] = target
|
||||
await worker.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={
|
||||
"type": "service-switched",
|
||||
"capability": kind.upper(),
|
||||
"resourceId": resource_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async def queue_transcript(role: str, content: str, timestamp: str) -> None:
|
||||
if content:
|
||||
@@ -810,6 +997,16 @@ async def run_pipeline(
|
||||
set_system_prompt=set_system_prompt,
|
||||
set_tools=set_visible_tools,
|
||||
call_end=call_end,
|
||||
worker=worker,
|
||||
context_aggregator=WorkflowAggregatorPair(
|
||||
user_aggregator,
|
||||
assistant_aggregator,
|
||||
),
|
||||
transport=transport,
|
||||
switch_services=switch_workflow_services,
|
||||
set_knowledge_scope=knowledge_retrieval.set_scope,
|
||||
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled),
|
||||
flow_global_functions=flow_global_functions,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from models import AssistantConfig, RuntimeModelResource
|
||||
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.services.openai.stt import OpenAISTTService
|
||||
@@ -26,6 +26,35 @@ from services.pipecat.xfyun_tts import DEFAULT_XFYUN_TTS_URL, XfyunTTSService
|
||||
TTS_STOP_FRAME_TIMEOUT_S = 1.0
|
||||
|
||||
|
||||
def config_with_resource(
|
||||
cfg: AssistantConfig, resource: RuntimeModelResource
|
||||
) -> AssistantConfig:
|
||||
"""Return a call-local config view for one workflow ASR/TTS resource."""
|
||||
result = cfg.model_copy(deep=True)
|
||||
values = resource.values or {}
|
||||
secrets = resource.secrets or {}
|
||||
if resource.capability == "ASR":
|
||||
result.asr = str(values.get("modelId") or "")
|
||||
result.stt_language = str(values.get("language") or "")
|
||||
result.stt_interface_type = resource.interface_type
|
||||
result.stt_values = values
|
||||
result.stt_secrets = secrets
|
||||
result.stt_api_key = str(secrets.get("apiKey") or "")
|
||||
result.stt_base_url = str(values.get("apiUrl") or "")
|
||||
elif resource.capability == "TTS":
|
||||
result.tts_model = str(values.get("modelId") or "")
|
||||
result.voice = str(values.get("voice") or "")
|
||||
result.tts_speed = float(values.get("speed") or 1.0)
|
||||
result.tts_interface_type = resource.interface_type
|
||||
result.tts_values = values
|
||||
result.tts_secrets = secrets
|
||||
result.tts_api_key = str(secrets.get("apiKey") or "")
|
||||
result.tts_base_url = str(values.get("apiUrl") or "")
|
||||
else:
|
||||
raise ValueError(f"工作流语音资源能力无效:{resource.capability}")
|
||||
return result
|
||||
|
||||
|
||||
def _require(value: str, label: str) -> str:
|
||||
if value:
|
||||
return value
|
||||
|
||||
@@ -175,9 +175,9 @@ def prepare_dynamic_config(
|
||||
trusted_values: dict[str, Any] | None = None,
|
||||
) -> AssistantConfig:
|
||||
"""Validate and merge one call's values without mutating stored config."""
|
||||
if cfg.type != "prompt":
|
||||
if cfg.type not in {"prompt", "workflow"}:
|
||||
if client_values:
|
||||
raise DynamicVariableError("动态变量目前仅支持 prompt 助手")
|
||||
raise DynamicVariableError("动态变量仅支持 prompt 和 workflow 助手")
|
||||
return cfg
|
||||
|
||||
supplied = _validate_public(client_values)
|
||||
@@ -214,9 +214,13 @@ def prepare_dynamic_config(
|
||||
prepared = cfg.model_copy(deep=True)
|
||||
prepared.dynamic_variables = merged
|
||||
prepared.conversation_id = conversation_id
|
||||
# Validate prompt and greeting before media resources are allocated.
|
||||
# Validate top-level prompt/greeting before media resources are allocated.
|
||||
# Workflow node templates are rendered lazily as their nodes become active.
|
||||
store = DynamicVariableStore.from_config(prepared)
|
||||
store.render(prepared.prompt)
|
||||
if prepared.type == "prompt":
|
||||
store.render(prepared.prompt)
|
||||
elif prepared.type == "workflow":
|
||||
store.render_data(prepared.graph)
|
||||
store.render(prepared.greeting)
|
||||
return prepared
|
||||
|
||||
|
||||
154
backend/services/tool_executor.py
Normal file
154
backend/services/tool_executor.py
Normal 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,
|
||||
}
|
||||
@@ -1,170 +1,140 @@
|
||||
"""工作流图引擎(第一版)。
|
||||
|
||||
对应 dograh 的 pipecat_engine.py,极简实现:
|
||||
- 单个 startCall 入口,开场白来自该节点;
|
||||
- agentNode 用各自的 prompt 驱动多轮对话;
|
||||
- globalNode 不参与连线,按节点开关向会话节点注入统一提示词;
|
||||
- 每轮助手回复后,用一次轻量 LLM「路由」判断是否满足某条出边的 condition,
|
||||
满足则切换当前节点(linear = 单边;branching = 多边按条件分流);
|
||||
- 到达 endCall 播放结束语并停止路由。
|
||||
|
||||
只读图结构,不持有对话状态(当前节点由 pipeline 维护),便于单测。
|
||||
"""
|
||||
"""Pure Workflow v3 graph queries and deterministic edge evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from services.node_specs import normalize_graph
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
|
||||
|
||||
class WorkflowEngine:
|
||||
def __init__(self, graph: dict[str, Any]):
|
||||
nodes = graph.get("nodes") or []
|
||||
self.nodes: dict[str, dict] = {n["id"]: n for n in nodes if n.get("id")}
|
||||
self.edges: list[dict] = graph.get("edges") or []
|
||||
self.start_id: str | None = next(
|
||||
(nid for nid, n in self.nodes.items() if n.get("type") == "startCall"),
|
||||
None,
|
||||
)
|
||||
self.global_id: str | None = next(
|
||||
(nid for nid, n in self.nodes.items() if n.get("type") == "globalNode"),
|
||||
self.graph = normalize_graph(graph)
|
||||
self.settings = self.graph.get("settings") or {}
|
||||
self.nodes: dict[str, dict] = {
|
||||
str(node["id"]): node
|
||||
for node in self.graph.get("nodes") or []
|
||||
if node.get("id")
|
||||
}
|
||||
self.edges: list[dict] = list(self.graph.get("edges") or [])
|
||||
self.start_id = next(
|
||||
(node_id for node_id, node in self.nodes.items() if node.get("type") == "start"),
|
||||
None,
|
||||
)
|
||||
|
||||
# ---- 结构查询 ----
|
||||
def node_type(self, nid: str | None) -> str | None:
|
||||
return self.nodes.get(nid or "", {}).get("type")
|
||||
def has_graph(self) -> bool:
|
||||
return bool(self.start_id)
|
||||
|
||||
def data(self, nid: str | None) -> dict:
|
||||
return self.nodes.get(nid or "", {}).get("data") or {}
|
||||
def node_type(self, node_id: str | None) -> str | None:
|
||||
return self.nodes.get(node_id or "", {}).get("type")
|
||||
|
||||
def name(self, nid: str | None) -> str:
|
||||
return self.data(nid).get("name") or (self.node_type(nid) or "")
|
||||
def data(self, node_id: str | None) -> dict:
|
||||
return self.nodes.get(node_id or "", {}).get("data") or {}
|
||||
|
||||
def outgoing(self, nid: str | None) -> list[dict]:
|
||||
return [e for e in self.edges if e.get("source") == nid]
|
||||
def name(self, node_id: str | None) -> str:
|
||||
return str(self.data(node_id).get("name") or self.node_type(node_id) or "")
|
||||
|
||||
def outgoing(self, node_id: str | None) -> list[dict]:
|
||||
result = [edge for edge in self.edges if edge.get("source") == node_id]
|
||||
return sorted(result, key=lambda edge: int((edge.get("data") or {}).get("priority", 10)))
|
||||
|
||||
def edge_mode(self, edge: dict) -> str:
|
||||
return str((edge.get("data") or {}).get("mode") or "always")
|
||||
|
||||
def edge_fn_name(self, edge: dict) -> str:
|
||||
"""每条边对应一个 LLM 函数名(稳定、合法标识符)。"""
|
||||
raw = edge.get("id") or f"{edge.get('source')}_{edge.get('target')}"
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", str(raw).lower()).strip("_")
|
||||
return f"goto_{slug or 'next'}"
|
||||
|
||||
def edge_condition(self, edge: dict) -> str:
|
||||
return (edge.get("data") or {}).get("condition") or ""
|
||||
def edge_description(self, edge: dict) -> str:
|
||||
data = edge.get("data") or {}
|
||||
target = self.name(str(edge.get("target") or ""))
|
||||
condition = str(data.get("condition") or "").strip()
|
||||
if condition:
|
||||
return f"当满足以下条件时转到「{target}」:{condition}"
|
||||
return f"当当前阶段任务完成时转到「{target}」。"
|
||||
|
||||
def edge_transition_speech(self, edge: dict | None) -> str:
|
||||
"""命中该边、切换节点瞬间播报的过渡语(可选,掩盖延迟,不写入上下文)。"""
|
||||
if not edge:
|
||||
return ""
|
||||
return (edge.get("data") or {}).get("transition_speech") or ""
|
||||
data = edge.get("data") or {}
|
||||
return str(data.get("transitionSpeech") or data.get("transition_speech") or "")
|
||||
|
||||
def find_edge(self, source: str | None, target: str | None) -> dict | None:
|
||||
for edge in self.edges:
|
||||
if edge.get("source") == source and edge.get("target") == target:
|
||||
return edge
|
||||
return None
|
||||
def global_prompt(self) -> str:
|
||||
return str(self.settings.get("globalPrompt") or "").strip()
|
||||
|
||||
def edge_description(self, edge: dict) -> str:
|
||||
"""作为转移函数的 description 交给 LLM:满足该条件时模型应调用此函数。"""
|
||||
cond = self.edge_condition(edge)
|
||||
target = self.name(edge.get("target"))
|
||||
if cond:
|
||||
return f"当满足以下条件时调用以转到节点「{target}」:{cond}"
|
||||
return f"当当前节点任务完成、应继续推进对话时调用以转到节点「{target}」。"
|
||||
|
||||
def is_end(self, nid: str | None) -> bool:
|
||||
return self.node_type(nid) == "endCall"
|
||||
|
||||
def has_graph(self) -> bool:
|
||||
return self.start_id is not None
|
||||
|
||||
def greeting(self) -> str:
|
||||
return self.data(self.start_id).get("greeting") or ""
|
||||
|
||||
def system_prompt_for(self, nid: str | None) -> str:
|
||||
"""组合当前节点提示与可选的全局提示(开始节点也是会话节点)。"""
|
||||
header = f"[当前节点:{self.name(nid)}]"
|
||||
node_data = self.data(nid)
|
||||
prompt = str(node_data.get("prompt") or "").strip()
|
||||
node_type = self.node_type(nid)
|
||||
default_add_global = node_type in {"startCall", "agentNode"}
|
||||
add_global = bool(node_data.get("addGlobalPrompt", default_add_global))
|
||||
global_prompt = (
|
||||
str(self.data(self.global_id).get("prompt") or "").strip()
|
||||
if add_global and self.global_id
|
||||
else ""
|
||||
)
|
||||
|
||||
sections = [header]
|
||||
if global_prompt:
|
||||
sections.append(f"[全局规则]\n{global_prompt}")
|
||||
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
||||
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
|
||||
sections = [f"[当前阶段:{self.name(node_id)}]"]
|
||||
if self.global_prompt():
|
||||
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
|
||||
if prompt:
|
||||
sections.append(f"[当前节点任务]\n{prompt}")
|
||||
sections.append(f"[当前阶段任务]\n{prompt}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
# ---- 路由:决定下一节点 ----
|
||||
async def route(
|
||||
def greeting(self, store: DynamicVariableStore) -> str:
|
||||
return store.render(str(self.data(self.start_id).get("greeting") or ""))
|
||||
|
||||
def expression_matches(self, expression: dict, values: dict[str, Any]) -> bool:
|
||||
results = []
|
||||
for rule in expression.get("rules") or []:
|
||||
name = str(rule.get("variable") or "")
|
||||
operator = str(rule.get("operator") or "")
|
||||
expected = rule.get("value")
|
||||
exists = name in values
|
||||
actual = values.get(name)
|
||||
try:
|
||||
if operator == "exists":
|
||||
matched = exists if expected is not False else not exists
|
||||
elif operator == "eq":
|
||||
matched = actual == expected
|
||||
elif operator == "neq":
|
||||
matched = actual != expected
|
||||
elif operator == "gt":
|
||||
matched = actual > expected
|
||||
elif operator == "gte":
|
||||
matched = actual >= expected
|
||||
elif operator == "lt":
|
||||
matched = actual < expected
|
||||
elif operator == "lte":
|
||||
matched = actual <= expected
|
||||
elif operator == "contains":
|
||||
matched = expected in actual
|
||||
elif operator == "in":
|
||||
matched = actual in expected
|
||||
else:
|
||||
matched = False
|
||||
except (TypeError, ValueError):
|
||||
matched = False
|
||||
results.append(matched)
|
||||
if not results:
|
||||
return False
|
||||
return all(results) if expression.get("combinator", "and") == "and" else any(results)
|
||||
|
||||
def deterministic_edge(
|
||||
self,
|
||||
nid: str | None,
|
||||
history: list[dict],
|
||||
node_id: str,
|
||||
store: DynamicVariableStore,
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
) -> str | None:
|
||||
"""根据对话历史判断当前节点是否应转移。返回目标节点 id,或 None 表示停留。"""
|
||||
outs = self.outgoing(nid)
|
||||
if not outs:
|
||||
return None
|
||||
include_default: bool,
|
||||
) -> dict | None:
|
||||
default = None
|
||||
for edge in self.outgoing(node_id):
|
||||
data = edge.get("data") or {}
|
||||
mode = data.get("mode")
|
||||
if mode == "expression" and self.expression_matches(
|
||||
data.get("expression") or {}, store.values
|
||||
):
|
||||
return edge
|
||||
if mode == "always":
|
||||
default = edge
|
||||
return default if include_default else None
|
||||
|
||||
options = []
|
||||
for i, edge in enumerate(outs, 1):
|
||||
edata = edge.get("data") or {}
|
||||
cond = edata.get("condition") or "(无明确条件,作为默认后继)"
|
||||
tgt_name = self.name(edge.get("target"))
|
||||
options.append(f"{i}. 条件:{cond} → 目标节点:{tgt_name}")
|
||||
|
||||
convo = "\n".join(
|
||||
f"{m['role']}: {m['content']}" for m in history[-8:] if m.get("content")
|
||||
)
|
||||
system = (
|
||||
"你是语音对话的流程路由器。根据最近的对话,判断是否已满足某条转移条件。\n"
|
||||
"规则:仅当某条件被明确满足时,返回其编号;若都不满足或不确定,返回 0 "
|
||||
"(停留在当前节点继续对话)。只输出一个数字,不要任何解释。"
|
||||
)
|
||||
user = (
|
||||
"可选转移:\n"
|
||||
+ "\n".join(options)
|
||||
+ f"\n\n对话记录:\n{convo}\n\n请只返回编号(0 表示停留):"
|
||||
)
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url or None)
|
||||
resp = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
temperature=0,
|
||||
max_tokens=5,
|
||||
)
|
||||
text = (resp.choices[0].message.content or "").strip()
|
||||
except Exception as exc: # noqa: BLE001 - 路由失败不应中断通话
|
||||
logger.warning(f"工作流路由调用失败,停留当前节点: {exc}")
|
||||
return None
|
||||
|
||||
match = re.search(r"\d+", text)
|
||||
if not match:
|
||||
return None
|
||||
idx = int(match.group())
|
||||
if idx < 1 or idx > len(outs):
|
||||
return None
|
||||
target = outs[idx - 1].get("target")
|
||||
logger.info(f"工作流路由: {self.name(nid)} → {self.name(target)} (edge {idx})")
|
||||
return target
|
||||
def llm_edges(self, node_id: str) -> list[dict]:
|
||||
return [
|
||||
edge
|
||||
for edge in self.outgoing(node_id)
|
||||
if self.edge_mode(edge) in {"llm", "always"}
|
||||
]
|
||||
|
||||
@@ -111,6 +111,19 @@ class BrainRegistryTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("user_name", assistant.dynamic_variable_definitions)
|
||||
|
||||
def test_workflow_keeps_dynamic_variables_and_tool_bindings(self):
|
||||
assistant = AssistantUpsert(
|
||||
name="workflow",
|
||||
type="workflow",
|
||||
toolIds=["tool_a"],
|
||||
dynamicVariableDefinitions={
|
||||
"customer": {"type": "string", "required": False, "default": "王先生"}
|
||||
},
|
||||
graph={},
|
||||
)
|
||||
self.assertEqual(assistant.tool_ids, ["tool_a"])
|
||||
self.assertIn("customer", assistant.dynamic_variable_definitions)
|
||||
|
||||
|
||||
class DifyHelpersTests(unittest.TestCase):
|
||||
def test_normalize_api_base(self):
|
||||
@@ -363,7 +376,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
params = FakeFunctionParams(
|
||||
{"order_id": "A/1", "Authorization": "attacker-value"}
|
||||
)
|
||||
with patch("services.brains.prompt_brain.httpx.AsyncClient", FakeClient):
|
||||
with patch("services.tool_executor.httpx.AsyncClient", FakeClient):
|
||||
await llm.functions["lookup_order"](params)
|
||||
|
||||
self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1")
|
||||
@@ -377,35 +390,91 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {"globalPrompt": "全局规则"},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "startCall",
|
||||
"data": {"name": "开始", "prompt": "收集需求"},
|
||||
"type": "start",
|
||||
"data": {"name": "Start"},
|
||||
},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {
|
||||
"name": "收集需求",
|
||||
"prompt": "服务 {{user_name}}",
|
||||
"contextPolicy": "fresh",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "endCall",
|
||||
"data": {"name": "结束", "prompt": "礼貌结束"},
|
||||
"type": "end",
|
||||
"data": {"name": "End", "message": "感谢来电", "scope": "session"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "finish",
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
},
|
||||
{
|
||||
"id": "finish",
|
||||
"source": "agent",
|
||||
"target": "end",
|
||||
"data": {"condition": "需求已收集"},
|
||||
"data": {
|
||||
"mode": "llm",
|
||||
"priority": 10,
|
||||
"condition": "需求已收集",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
brain = WorkflowBrain(graph)
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="workflow",
|
||||
graph=graph,
|
||||
dynamic_variable_definitions={
|
||||
"user_name": {"type": "string", "required": True}
|
||||
},
|
||||
),
|
||||
{"user_name": "王先生"},
|
||||
assistant_id="asst_workflow",
|
||||
)
|
||||
brain = WorkflowBrain(cfg)
|
||||
llm = FakeLLM()
|
||||
context = LLMContext(messages=[])
|
||||
queued = []
|
||||
prompts = []
|
||||
visible_tools = []
|
||||
call_end = FakeCallEnd()
|
||||
|
||||
class FakeWorker:
|
||||
def __init__(self):
|
||||
self.frames = []
|
||||
self.handlers = {}
|
||||
|
||||
def set_reached_downstream_filter(self, *_args):
|
||||
pass
|
||||
|
||||
def event_handler(self, name):
|
||||
def decorator(fn):
|
||||
self.handlers[name] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
async def queue_frame(self, frame):
|
||||
self.frames.append(frame)
|
||||
|
||||
async def queue_frames(self, frames):
|
||||
self.frames.extend(frames)
|
||||
|
||||
worker = FakeWorker()
|
||||
pair = SimpleNamespace(
|
||||
user=lambda: SimpleNamespace(_context=context),
|
||||
assistant=lambda: SimpleNamespace(has_function_calls_in_progress=False),
|
||||
)
|
||||
|
||||
async def queue_frame(frame):
|
||||
queued.append(frame)
|
||||
|
||||
@@ -413,30 +482,34 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
context=context,
|
||||
llm=llm,
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=prompts.append,
|
||||
set_tools=lambda tools: visible_tools.append(tools or []),
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=call_end,
|
||||
worker=worker,
|
||||
context_aggregator=pair,
|
||||
)
|
||||
await brain.setup(AssistantConfig(type="workflow", graph=graph), runtime)
|
||||
|
||||
self.assertIn("goto_finish", llm.functions)
|
||||
self.assertIn("收集需求", prompts[-1])
|
||||
self.assertEqual(visible_tools[-1][0].name, "goto_finish")
|
||||
|
||||
params = FakeFunctionParams()
|
||||
await llm.functions["goto_finish"](params)
|
||||
self.assertEqual(params.result, {"status": "ok"})
|
||||
self.assertIn("礼貌结束", prompts[-1])
|
||||
self.assertEqual(visible_tools[-1], [])
|
||||
|
||||
await brain.on_assistant_text_start("closing-turn")
|
||||
await brain.on_assistant_text_end(
|
||||
"closing-turn",
|
||||
"感谢来电,再见。",
|
||||
False,
|
||||
await brain.setup(cfg, runtime)
|
||||
await brain.on_connected()
|
||||
self.assertEqual(brain._manager.current_node, "agent")
|
||||
agent_config = brain._agent_config("agent")
|
||||
self.assertIn("王先生", agent_config["role_message"])
|
||||
self.assertIn("完成当前阶段任务", agent_config["role_message"])
|
||||
self.assertEqual(agent_config["task_messages"], [])
|
||||
self.assertEqual(
|
||||
agent_config["context_strategy"].strategy.value,
|
||||
"reset",
|
||||
)
|
||||
|
||||
edge_function = next(
|
||||
function
|
||||
for function in brain._agent_config("agent")["functions"]
|
||||
if function.name == "goto_finish"
|
||||
)
|
||||
_, terminal = await edge_function.handler({}, brain._manager)
|
||||
self.assertEqual(terminal["name"], "end")
|
||||
self.assertTrue(call_end.ending)
|
||||
self.assertTrue(call_end.armed)
|
||||
self.assertTrue(any(getattr(frame, "text", "") == "感谢来电" for frame in queued))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import unittest
|
||||
|
||||
from models import AssistantConfig
|
||||
from services.pipecat.pipeline import _knowledge_tool_description
|
||||
from services.pipecat.pipeline import (
|
||||
KNOWLEDGE_CONTEXT_MARKER,
|
||||
KnowledgeRetrievalProcessor,
|
||||
_knowledge_tool_description,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeToolDescriptionTest(unittest.TestCase):
|
||||
@@ -33,6 +37,25 @@ class KnowledgeToolDescriptionTest(unittest.TestCase):
|
||||
self.assertNotIn("\n ", description)
|
||||
self.assertLess(len(description), 1000)
|
||||
|
||||
def test_workflow_knowledge_uses_system_role(self):
|
||||
processor = KnowledgeRetrievalProcessor(None)
|
||||
messages = [
|
||||
{"role": "assistant", "content": "你好"},
|
||||
{
|
||||
"role": "developer",
|
||||
"content": f"{KNOWLEDGE_CONTEXT_MARKER}\n旧检索结果",
|
||||
},
|
||||
]
|
||||
|
||||
processor._set_context(
|
||||
messages,
|
||||
f"{KNOWLEDGE_CONTEXT_MARKER}\n新检索结果",
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["role"], "system")
|
||||
self.assertIn("新检索结果", messages[0]["content"])
|
||||
self.assertFalse(any(message["role"] == "developer" for message in messages))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
118
backend/tests/test_workflow_v3.py
Normal file
118
backend/tests/test_workflow_v3.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from models import AssistantConfig, RuntimeModelResource
|
||||
from services.pipecat.service_factory import config_with_resource
|
||||
from services.node_specs import normalize_graph, validate_graph
|
||||
from services.runtime_variables import DynamicVariableStore, prepare_dynamic_config
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
def valid_graph():
|
||||
return {
|
||||
"specVersion": 3,
|
||||
"settings": {"globalPrompt": "服务 {{customer}}"},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {"name": "Start"}},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {"name": "Agent", "prompt": "处理订单", "contextPolicy": "fresh"},
|
||||
},
|
||||
{"id": "end", "type": "end", "data": {"name": "End", "scope": "flow"}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
},
|
||||
{
|
||||
"id": "paid",
|
||||
"source": "agent",
|
||||
"target": "end",
|
||||
"data": {
|
||||
"mode": "expression",
|
||||
"priority": 10,
|
||||
"expression": {
|
||||
"combinator": "and",
|
||||
"rules": [
|
||||
{"variable": "order_status", "operator": "eq", "value": "paid"}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class WorkflowGraphTests(unittest.TestCase):
|
||||
def test_voice_resource_creates_isolated_runtime_config(self):
|
||||
base = AssistantConfig(type="workflow", asr="default", voice="default")
|
||||
asr = RuntimeModelResource(
|
||||
id="asr_1",
|
||||
capability="ASR",
|
||||
interface_type="openai-asr",
|
||||
values={"modelId": "sensevoice", "language": "zh", "apiUrl": "https://asr.test"},
|
||||
secrets={"apiKey": "secret"},
|
||||
)
|
||||
resolved = config_with_resource(base, asr)
|
||||
self.assertEqual(resolved.asr, "sensevoice")
|
||||
self.assertEqual(resolved.stt_api_key, "secret")
|
||||
self.assertEqual(base.asr, "default")
|
||||
|
||||
def test_v2_start_prompt_is_preserved_in_synthetic_agent(self):
|
||||
graph = normalize_graph(
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "s", "type": "startCall", "data": {"prompt": "询问需求"}},
|
||||
{"id": "e", "type": "endCall", "data": {"prompt": "再见"}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "done", "source": "s", "target": "e", "data": {"condition": "完成"}}
|
||||
],
|
||||
}
|
||||
)
|
||||
migrated = next(node for node in graph["nodes"] if node["type"] == "agent")
|
||||
self.assertEqual(migrated["data"]["prompt"], "询问需求")
|
||||
self.assertEqual(validate_graph(graph), [])
|
||||
|
||||
def test_rejects_automatic_cycle_and_duplicate_priorities(self):
|
||||
graph = valid_graph()
|
||||
graph["nodes"].insert(1, {"id": "action", "type": "action", "data": {}})
|
||||
graph["edges"] = [
|
||||
{"id": "a", "source": "start", "target": "action", "data": {"mode": "always", "priority": 0}},
|
||||
{"id": "b", "source": "action", "target": "start", "data": {"mode": "always", "priority": 0}},
|
||||
{"id": "c", "source": "agent", "target": "end", "data": {"mode": "always", "priority": 10}},
|
||||
]
|
||||
errors = validate_graph(graph)
|
||||
self.assertTrue(any("无等待循环" in error for error in errors))
|
||||
|
||||
def test_expression_routes_using_session_variables(self):
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="workflow",
|
||||
graph=valid_graph(),
|
||||
dynamic_variable_definitions={
|
||||
"customer": {"type": "string", "default": "王先生"},
|
||||
"order_status": {"type": "string", "default": "pending"},
|
||||
},
|
||||
),
|
||||
{},
|
||||
assistant_id="asst_test",
|
||||
)
|
||||
store = DynamicVariableStore.from_config(cfg)
|
||||
engine = WorkflowEngine(cfg.graph)
|
||||
self.assertIsNone(engine.deterministic_edge("agent", store, include_default=False))
|
||||
store.assign("order_status", "paid")
|
||||
self.assertEqual(
|
||||
engine.deterministic_edge("agent", store, include_default=False)["target"],
|
||||
"end",
|
||||
)
|
||||
self.assertIn("王先生", engine.prompt_for("agent", store))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -53,13 +53,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -478,10 +471,15 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
defaultGraph(),
|
||||
);
|
||||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
||||
globalPrompt: defaultGraph().settings.globalPrompt,
|
||||
allowInterrupt: true,
|
||||
turnConfig: defaultTurnConfig(),
|
||||
});
|
||||
const [debugOpen, setDebugOpen] = useState(false);
|
||||
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
|
||||
useState<Record<string, DynamicVariableDefinition>>({});
|
||||
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
|
||||
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(null);
|
||||
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(null);
|
||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
|
||||
|
||||
@@ -708,6 +706,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
name: workflowName,
|
||||
graph: workflowGraph,
|
||||
settings: workflowSettings,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -849,19 +848,24 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
: defaultGraph();
|
||||
const wfSettings: WorkflowSettings = {
|
||||
llm: assistant.modelResourceIds.LLM,
|
||||
asr: assistant.modelResourceIds.ASR,
|
||||
tts: assistant.modelResourceIds.TTS,
|
||||
asr: graph.settings?.defaultAsrResourceId || assistant.modelResourceIds.ASR,
|
||||
tts: graph.settings?.defaultTtsResourceId || assistant.modelResourceIds.TTS,
|
||||
globalPrompt: graph.settings?.globalPrompt ?? "",
|
||||
allowInterrupt: assistant.enableInterrupt,
|
||||
turnConfig: assistant.turnConfig,
|
||||
};
|
||||
setWorkflowName(assistant.name);
|
||||
setWorkflowGraph(graph);
|
||||
setWorkflowSettings(wfSettings);
|
||||
setWorkflowDynamicVariableDefinitions(
|
||||
assistant.dynamicVariableDefinitions ?? {},
|
||||
);
|
||||
setSavedSnapshot(
|
||||
JSON.stringify({
|
||||
name: assistant.name,
|
||||
graph,
|
||||
settings: wfSettings,
|
||||
dynamicVariableDefinitions: assistant.dynamicVariableDefinitions ?? {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -913,6 +917,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
|
||||
},
|
||||
graph: workflowGraph as unknown as Record<string, unknown>,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -932,6 +937,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
name: workflowName,
|
||||
graph: workflowGraph,
|
||||
settings: workflowSettings,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
})
|
||||
: null;
|
||||
const dirty =
|
||||
@@ -1377,7 +1383,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{saveError && (
|
||||
<span className="self-center text-xs text-destructive">
|
||||
<span
|
||||
role="alert"
|
||||
title={saveError}
|
||||
className="line-clamp-2 max-w-[min(42vw,560px)] self-center text-right text-sm leading-5 text-destructive"
|
||||
>
|
||||
{saveError}
|
||||
</span>
|
||||
)}
|
||||
@@ -1385,7 +1395,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
variant="outline"
|
||||
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
||||
disabled={!editingId}
|
||||
onClick={() => setDebugOpen(true)}
|
||||
onClick={() => {
|
||||
setWorkflowEditingNodeId(null);
|
||||
setWorkflowEditingEdgeId(null);
|
||||
setWorkflowDebugOpen(true);
|
||||
}}
|
||||
>
|
||||
<Bug size={16} />
|
||||
调试
|
||||
@@ -1411,43 +1425,49 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
onChange={setWorkflowGraph}
|
||||
settings={workflowSettings}
|
||||
onSettingsChange={setWorkflowSettings}
|
||||
onOpenDynamicVariables={() => setDynamicVariablesOpen(true)}
|
||||
editingNodeId={workflowEditingNodeId}
|
||||
onEditingNodeIdChange={setWorkflowEditingNodeId}
|
||||
editingEdgeId={workflowEditingEdgeId}
|
||||
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
|
||||
debugOpen={workflowDebugOpen}
|
||||
onDebugOpenChange={(open) => {
|
||||
setWorkflowDebugOpen(open);
|
||||
if (!open) setActiveNodeId(null);
|
||||
}}
|
||||
debugPanel={
|
||||
<DebugDrawer
|
||||
overlay
|
||||
assistantId={editingId}
|
||||
onClose={() => {
|
||||
setWorkflowDebugOpen(false);
|
||||
setActiveNodeId(null);
|
||||
}}
|
||||
hasUnsavedChanges={dirty}
|
||||
onNodeActive={setActiveNodeId}
|
||||
dynamicVariablesEnabled
|
||||
dynamicVariableDefinitions={workflowDynamicVariableDefinitions}
|
||||
/>
|
||||
}
|
||||
activeNodeId={activeNodeId}
|
||||
modelOptions={{
|
||||
llm: credOptions("LLM"),
|
||||
asr: credOptions("ASR"),
|
||||
tts: credOptions("TTS"),
|
||||
}}
|
||||
toolOptions={tools
|
||||
.filter((tool) => tool.status === "active")
|
||||
.map((tool) => ({ value: tool.id, label: tool.name }))}
|
||||
knowledgeOptions={kbOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
open={debugOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDebugOpen(open);
|
||||
if (!open) setActiveNodeId(null);
|
||||
}}
|
||||
modal={false}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showOverlay={false}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
className="w-[440px] gap-0 border-l border-hairline bg-card p-0 sm:max-w-[440px]"
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>语音调试</SheetTitle>
|
||||
<SheetDescription>
|
||||
与当前助手进行语音对话调试,画布会高亮正在激活的节点。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<DebugDrawer
|
||||
assistantId={editingId}
|
||||
asSheet
|
||||
hasUnsavedChanges={dirty}
|
||||
onNodeActive={setActiveNodeId}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<DynamicVariablesDialog
|
||||
open={dynamicVariablesOpen}
|
||||
onOpenChange={setDynamicVariablesOpen}
|
||||
definitions={workflowDynamicVariableDefinitions}
|
||||
onChange={setWorkflowDynamicVariableDefinitions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2132,7 +2152,8 @@ function SegmentedIconButton({
|
||||
|
||||
function DebugDrawer({
|
||||
assistantId,
|
||||
asSheet = false,
|
||||
overlay = false,
|
||||
onClose,
|
||||
hasUnsavedChanges = false,
|
||||
onNodeActive,
|
||||
vision = false,
|
||||
@@ -2140,7 +2161,8 @@ function DebugDrawer({
|
||||
dynamicVariableDefinitions = {},
|
||||
}: {
|
||||
assistantId: string | null;
|
||||
asSheet?: boolean;
|
||||
overlay?: boolean;
|
||||
onClose?: () => void;
|
||||
hasUnsavedChanges?: boolean;
|
||||
onNodeActive?: (nodeId: string | null) => void;
|
||||
vision?: boolean;
|
||||
@@ -2179,14 +2201,25 @@ function DebugDrawer({
|
||||
[camera, preview],
|
||||
);
|
||||
|
||||
const containerClass = asSheet
|
||||
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
|
||||
: "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex";
|
||||
|
||||
return (
|
||||
<aside className={containerClass}>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
|
||||
<aside
|
||||
className={overlay
|
||||
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-card"
|
||||
: "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex"}
|
||||
>
|
||||
<div className={`flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-hairline py-3 ${overlay ? "px-4" : "px-5"}`}>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{overlay && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭调试预览"
|
||||
title="关闭"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div className="shrink-0 text-sm font-medium text-foreground">
|
||||
调试与预览
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 条件边。边携带 condition(自然语言条件,LLM 据此决定是否走这条路径)与
|
||||
* label(日志里识别该路径的短标签)。悬停/选中时在标签旁显示「编辑 / 删除」按钮。
|
||||
* Workflow v3 edge: LLM judgement, variable expression, or deterministic default.
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getSmoothStepPath,
|
||||
getBezierPath,
|
||||
type EdgeProps,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
@@ -36,20 +35,22 @@ export function ConditionEdge({
|
||||
// 点击标签:只选中这条边(露出编辑/删除按钮),不直接进入编辑。
|
||||
const selectThisEdge = () =>
|
||||
setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === id })));
|
||||
const [path, labelX, labelY] = getSmoothStepPath({
|
||||
const [path, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
borderRadius: 8,
|
||||
offset: 20,
|
||||
curvature: 0.28,
|
||||
});
|
||||
|
||||
const label = ((data?.label as string) || (data?.condition as string) || "")
|
||||
.toString()
|
||||
.trim();
|
||||
const mode = (data?.mode as string) || "always";
|
||||
const label = (
|
||||
(data?.label as string) ||
|
||||
(mode === "llm" ? (data?.condition as string) : "") ||
|
||||
(mode === "expression" ? "变量表达式" : "默认路径")
|
||||
).toString().trim();
|
||||
const expanded = hovered || selected;
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,9 +27,20 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
|
||||
const nodeData = data as WorkflowNodeData;
|
||||
const Icon = spec.icon;
|
||||
const preview = (nodeData.greeting || nodeData.prompt || "")
|
||||
const preview = (nodeData.greeting || nodeData.prompt || nodeData.message || "")
|
||||
.toString()
|
||||
.trim();
|
||||
const meta = type === "agent"
|
||||
? [
|
||||
nodeData.contextPolicy === "fresh" ? "独立上下文" : "继承上下文",
|
||||
`${nodeData.toolIds?.length ?? 0} 工具`,
|
||||
nodeData.knowledgeBaseId ? "知识库" : null,
|
||||
nodeData.asrResourceId ? "独立 ASR" : null,
|
||||
nodeData.ttsResourceId ? "独立 TTS" : null,
|
||||
].filter(Boolean)
|
||||
: type === "action" && nodeData.toolId
|
||||
? ["确定性工具"]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -46,7 +57,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
{spec.hasTarget && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
position={Position.Top}
|
||||
className="!h-3 !w-3 !border-[3px] !border-card !bg-muted-soft"
|
||||
/>
|
||||
)}
|
||||
@@ -86,7 +97,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
{type !== "startCall" && (
|
||||
{type !== "start" && (
|
||||
<button
|
||||
type="button"
|
||||
title="删除节点"
|
||||
@@ -129,11 +140,22 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{meta.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{meta.map((item) => (
|
||||
<span key={item as string} className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{spec.hasSource && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary"
|
||||
position={Position.Bottom}
|
||||
title="拖到节点或画布空白处"
|
||||
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary transition-transform hover:!scale-125"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -141,8 +163,9 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
}
|
||||
|
||||
export const nodeTypes = {
|
||||
startCall: GenericNode,
|
||||
agentNode: GenericNode,
|
||||
endCall: GenericNode,
|
||||
globalNode: GenericNode,
|
||||
start: GenericNode,
|
||||
agent: GenericNode,
|
||||
action: GenericNode,
|
||||
handoff: GenericNode,
|
||||
end: GenericNode,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,62 @@
|
||||
/**
|
||||
* 工作流节点的前端类型与运行期规格。
|
||||
*
|
||||
* 节点「目录」(有哪些类型、各自的字段与约束)由后端 /api/node-types 提供,
|
||||
* 通过 useNodeSpecs 拉取后用 toRuntimeSpec 转成带 React 组件(图标)的运行期规格。
|
||||
* 本文件只保留:类型定义、默认图、图标/配色解析。新增节点类型改后端即可。
|
||||
*/
|
||||
/** Workflow v3 public graph types and defaults. */
|
||||
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { Circle, type LucideIcon } from "lucide-react";
|
||||
|
||||
import type { NodeSpecDto } from "@/lib/api";
|
||||
|
||||
export type WorkflowNodeType =
|
||||
| "startCall"
|
||||
| "agentNode"
|
||||
| "endCall"
|
||||
| "globalNode";
|
||||
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
|
||||
export type ContextPolicy = "inherit" | "fresh";
|
||||
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
|
||||
export type EdgeMode = "llm" | "expression" | "always";
|
||||
export type ExpressionOperator =
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "contains"
|
||||
| "in"
|
||||
| "exists";
|
||||
|
||||
export type WorkflowNodeData = {
|
||||
/** 节点显示名 */
|
||||
name: string;
|
||||
/** 开场白(仅 startCall) */
|
||||
greeting?: string;
|
||||
/** 节点提示词 */
|
||||
prompt?: string;
|
||||
/** 允许打断(仅 agentNode) */
|
||||
allowInterrupt?: boolean;
|
||||
/** 是否合并全局节点提示词(start/agent 默认开启,end 默认关闭) */
|
||||
addGlobalPrompt?: boolean;
|
||||
contextPolicy?: ContextPolicy;
|
||||
toolIds?: string[];
|
||||
knowledgeBaseId?: string;
|
||||
knowledgeMode?: KnowledgeMode;
|
||||
knowledgeTopN?: number;
|
||||
knowledgeScoreThreshold?: number;
|
||||
asrResourceId?: string;
|
||||
ttsResourceId?: string;
|
||||
toolId?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
resultAssignments?: Record<string, string>;
|
||||
targetType?: "ai" | "human" | "queue" | "phone";
|
||||
target?: string;
|
||||
message?: string;
|
||||
scope?: "flow" | "session";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ExpressionRule = {
|
||||
variable: string;
|
||||
operator: ExpressionOperator;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
export type WorkflowEdgeData = {
|
||||
mode: EdgeMode;
|
||||
priority: number;
|
||||
condition?: string;
|
||||
expression?: { combinator: "and" | "or"; rules: ExpressionRule[] };
|
||||
label?: string;
|
||||
transitionSpeech?: string;
|
||||
};
|
||||
|
||||
export type FieldSpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -39,7 +65,6 @@ export type FieldSpec = {
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
/** 解析后的运行期节点规格(DTO + 解析出的 React 图标 + 派生句柄) */
|
||||
export type RuntimeNodeSpec = {
|
||||
type: string;
|
||||
displayName: string;
|
||||
@@ -47,9 +72,7 @@ export type RuntimeNodeSpec = {
|
||||
icon: LucideIcon;
|
||||
accent: string;
|
||||
addable: boolean;
|
||||
/** 入边句柄(开始节点没有) */
|
||||
hasTarget: boolean;
|
||||
/** 出边句柄(结束节点没有) */
|
||||
hasSource: boolean;
|
||||
constraints: {
|
||||
minIncoming?: number;
|
||||
@@ -62,7 +85,6 @@ export type RuntimeNodeSpec = {
|
||||
fields: FieldSpec[];
|
||||
};
|
||||
|
||||
/** 渐变 token → CSS 变量名(图标底色用),未知配色回落到 sky */
|
||||
export const ACCENT_VAR: Record<string, string> = {
|
||||
mint: "--gradient-mint",
|
||||
sky: "--gradient-sky",
|
||||
@@ -75,13 +97,11 @@ export function accentVar(accent: string): string {
|
||||
return ACCENT_VAR[accent] ?? ACCENT_VAR.sky;
|
||||
}
|
||||
|
||||
/** 按名字解析 Lucide 图标,找不到回落到 Circle(对齐 dograh resolveIcon)。 */
|
||||
export function resolveIcon(name: string): LucideIcon {
|
||||
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
|
||||
return icons[name] ?? Circle;
|
||||
}
|
||||
|
||||
/** 后端 DTO → 运行期规格。hasTarget/hasSource 由入/出边上限派生。 */
|
||||
export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
|
||||
return {
|
||||
type: dto.name,
|
||||
@@ -93,12 +113,12 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
|
||||
hasTarget: dto.constraints.maxIncoming !== 0,
|
||||
hasSource: dto.constraints.maxOutgoing !== 0,
|
||||
constraints: dto.constraints,
|
||||
fields: dto.fields.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
required: f.required,
|
||||
default: f.default,
|
||||
fields: dto.fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
default: field.default,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -106,6 +126,12 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
|
||||
export type NodeSpecMap = Record<string, RuntimeNodeSpec>;
|
||||
|
||||
export type WorkflowGraph = {
|
||||
specVersion: 3;
|
||||
settings: {
|
||||
globalPrompt: string;
|
||||
defaultAsrResourceId: string;
|
||||
defaultTtsResourceId: string;
|
||||
};
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
type: WorkflowNodeType;
|
||||
@@ -116,62 +142,72 @@ export type WorkflowGraph = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
data?: { condition?: string; label?: string; transition_speech?: string };
|
||||
data: WorkflowEdgeData;
|
||||
}>;
|
||||
viewport?: { x: number; y: number; zoom: number };
|
||||
};
|
||||
|
||||
/** 新建工作流的默认图:全局规则 + 开始 → 智能体 → 结束 */
|
||||
export function defaultGraph(): WorkflowGraph {
|
||||
return {
|
||||
specVersion: 3,
|
||||
settings: {
|
||||
globalPrompt:
|
||||
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
||||
defaultAsrResourceId: "",
|
||||
defaultTtsResourceId: "",
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: "start",
|
||||
type: "startCall",
|
||||
position: { x: 100, y: 120 },
|
||||
type: "start",
|
||||
position: { x: 360, y: 60 },
|
||||
data: {
|
||||
name: "开始",
|
||||
greeting: "你好,我是 AI 视频助手,有什么可以帮你?",
|
||||
prompt: "了解用户的需求,并在信息明确后进入下一节点。",
|
||||
allowInterrupt: true,
|
||||
addGlobalPrompt: true,
|
||||
name: "Start",
|
||||
greeting: "你好,我是 AI 视频助手,有什么可以帮你?",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent-1",
|
||||
type: "agentNode",
|
||||
position: { x: 420, y: 120 },
|
||||
type: "agent",
|
||||
position: { x: 360, y: 300 },
|
||||
data: {
|
||||
name: "智能体节点",
|
||||
prompt: "根据用户需求提供清晰、准确的帮助。",
|
||||
allowInterrupt: true,
|
||||
addGlobalPrompt: true,
|
||||
name: "Agent",
|
||||
prompt: "了解用户需求并提供清晰、准确的帮助。",
|
||||
contextPolicy: "inherit",
|
||||
toolIds: [],
|
||||
knowledgeMode: "disabled",
|
||||
knowledgeTopN: 5,
|
||||
knowledgeScoreThreshold: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "end",
|
||||
type: "endCall",
|
||||
position: { x: 740, y: 120 },
|
||||
type: "end",
|
||||
position: { x: 360, y: 540 },
|
||||
data: {
|
||||
name: "结束",
|
||||
prompt: "总结已经完成的事项,礼貌道别并结束通话。",
|
||||
addGlobalPrompt: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "global",
|
||||
type: "globalNode",
|
||||
position: { x: 100, y: 400 },
|
||||
data: {
|
||||
name: "全局设定",
|
||||
prompt:
|
||||
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
||||
name: "End",
|
||||
message: "感谢你的来电,再见。",
|
||||
scope: "session",
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e-start-agent", source: "start", target: "agent-1", data: {} },
|
||||
{ id: "e-agent-end", source: "agent-1", target: "end", data: {} },
|
||||
{
|
||||
id: "e-start-agent",
|
||||
source: "start",
|
||||
target: "agent-1",
|
||||
data: { mode: "always", priority: 0 },
|
||||
},
|
||||
{
|
||||
id: "e-agent-end",
|
||||
source: "agent-1",
|
||||
target: "end",
|
||||
data: {
|
||||
mode: "llm",
|
||||
priority: 10,
|
||||
condition: "当前阶段任务已经完成,适合结束会话",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,35 @@ export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding" | "Agent";
|
||||
|
||||
function formatErrorDetail(detail: unknown): string | null {
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const record = item as { loc?: unknown; msg?: unknown };
|
||||
const message = typeof record.msg === "string" ? record.msg : null;
|
||||
if (!message) return null;
|
||||
const path = Array.isArray(record.loc)
|
||||
? record.loc
|
||||
.filter((part) => part !== "body")
|
||||
.map(String)
|
||||
.join(".")
|
||||
: "";
|
||||
return path ? `${path}:${message}` : message;
|
||||
})
|
||||
.filter((message): message is string => Boolean(message));
|
||||
return messages.length ? messages.join(";") : null;
|
||||
}
|
||||
if (detail && typeof detail === "object") {
|
||||
const record = detail as { message?: unknown; msg?: unknown };
|
||||
if (typeof record.message === "string") return record.message;
|
||||
if (typeof record.msg === "string") return record.msg;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const isFormData = init?.body instanceof FormData;
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
@@ -30,8 +59,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let detail = `请求失败 (${res.status})`;
|
||||
try {
|
||||
const body = (await res.json()) as { detail?: string };
|
||||
if (body?.detail) detail = body.detail;
|
||||
const body = (await res.json()) as { detail?: unknown };
|
||||
detail = formatErrorDetail(body?.detail) ?? detail;
|
||||
} catch {
|
||||
// 响应体不是 JSON,沿用默认错误信息
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user