Merge branch 'feature/workflow'
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,54 @@ 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 (
|
||||
("defaultLlmResourceId", "LLM"),
|
||||
("defaultAsrResourceId", "ASR"),
|
||||
("defaultTtsResourceId", "TTS"),
|
||||
):
|
||||
if settings.get(key):
|
||||
resource_expectations[str(settings[key])] = capability
|
||||
knowledge_ids: set[str] = (
|
||||
{str(settings["knowledgeBaseId"])}
|
||||
if settings.get("knowledgeBaseId")
|
||||
else set()
|
||||
)
|
||||
for node in graph.get("nodes") or []:
|
||||
data = node.get("data") or {}
|
||||
if node.get("type") == "agent" and data.get("inheritGlobalConfig", True):
|
||||
continue
|
||||
if data.get("llmResourceId"):
|
||||
resource_expectations[str(data["llmResourceId"])] = "LLM"
|
||||
if data.get("asrResourceId"):
|
||||
resource_expectations[str(data["asrResourceId"])] = "ASR"
|
||||
if data.get("ttsResourceId"):
|
||||
resource_expectations[str(data["ttsResourceId"])] = "TTS"
|
||||
if data.get("knowledgeBaseId"):
|
||||
knowledge_ids.add(str(data["knowledgeBaseId"]))
|
||||
for resource_id, capability in resource_expectations.items():
|
||||
resource = await session.get(ModelResource, resource_id)
|
||||
if not resource or not resource.enabled or resource.capability != capability:
|
||||
raise HTTPException(400, f"Workflow 引用了无效的 {capability} 资源:{resource_id}")
|
||||
for knowledge_id in knowledge_ids:
|
||||
knowledge = await session.get(KnowledgeBase, knowledge_id)
|
||||
if not knowledge or knowledge.status != "active":
|
||||
raise HTTPException(400, f"Workflow 引用了无效知识库:{knowledge_id}")
|
||||
|
||||
|
||||
async def _validate_vision_model(
|
||||
@@ -101,7 +146,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 +207,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 +229,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 +302,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,15 @@ 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, 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:
|
||||
@@ -77,6 +86,15 @@ class BaseBrain:
|
||||
def record_user_message(self, content: str) -> None:
|
||||
"""Observe a committed user message for brain-owned routing state."""
|
||||
|
||||
async def on_user_turn_end(self, content: str) -> bool:
|
||||
"""Handle a complete user turn before the conversational LLM runs.
|
||||
|
||||
Return True when the brain scheduled the next action itself and the
|
||||
in-flight context frame must not reach the previous Agent's LLM.
|
||||
"""
|
||||
self.record_user_message(content)
|
||||
return False
|
||||
|
||||
async def on_assistant_text_start(self, turn_id: str) -> None:
|
||||
"""Observe the start of a generated assistant turn."""
|
||||
|
||||
@@ -107,6 +125,8 @@ class Brain(Protocol):
|
||||
|
||||
def record_user_message(self, content: str) -> None: ...
|
||||
|
||||
async def on_user_turn_end(self, content: str) -> bool: ...
|
||||
|
||||
async def on_assistant_text_start(self, turn_id: str) -> None: ...
|
||||
|
||||
async def on_assistant_text_end(
|
||||
|
||||
@@ -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,43 @@
|
||||
"""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 (
|
||||
LLMRunFrame,
|
||||
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 pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||
from services.knowledge import search as search_knowledge
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
from services.workflow_router import STAY_ON_CURRENT_AGENT, WorkflowLLMRouter
|
||||
|
||||
|
||||
@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 +46,29 @@ 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._router = WorkflowLLMRouter(cfg or AssistantConfig(type="workflow"))
|
||||
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 +76,422 @@ 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._router = WorkflowLLMRouter(cfg)
|
||||
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 and self._engine.has_outgoing(self._engine.start_id):
|
||||
raise RuntimeError("Start 初始化后没有命中的表达式边或默认边")
|
||||
node_config = (
|
||||
await self._follow_edge(edge)
|
||||
if edge
|
||||
else self._passive_node_config(self._engine.start_id)
|
||||
)
|
||||
if self._manager is None:
|
||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||
await self._manager.initialize(node_config)
|
||||
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
||||
|
||||
def record_user_message(self, content: str) -> None:
|
||||
if content:
|
||||
self._history.append({"role": "user", "content": content})
|
||||
if content and not self._ended:
|
||||
self._store.record("user", 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
|
||||
async def on_user_turn_end(self, content: str) -> bool:
|
||||
"""Route a complete user turn before any Agent is allowed to reply."""
|
||||
if not content or self._ended:
|
||||
return True
|
||||
self.record_user_message(content)
|
||||
manager = self._require_manager()
|
||||
current = manager.current_node
|
||||
if not current or self._engine.node_type(current) != "agent":
|
||||
return True
|
||||
|
||||
edge = self._engine.deterministic_edge(
|
||||
current,
|
||||
self._store,
|
||||
include_default=False,
|
||||
)
|
||||
outgoing = self._engine.outgoing(current)
|
||||
llm_edges = [
|
||||
candidate
|
||||
for candidate in outgoing
|
||||
if self._engine.edge_mode(candidate) == "llm"
|
||||
]
|
||||
default_edge = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in outgoing
|
||||
if self._engine.edge_mode(candidate) == "always"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if edge is None and llm_edges:
|
||||
selected = await self._router_for_node(current).select_edge(
|
||||
node_name=self._engine.name(current),
|
||||
node_prompt=self._engine.prompt_for(current, self._store),
|
||||
edges=llm_edges,
|
||||
history=self._store.history,
|
||||
variables={
|
||||
key: value
|
||||
for key, value in self._store.values.items()
|
||||
if not key.startswith("system__")
|
||||
},
|
||||
edge_name=self._engine.edge_fn_name,
|
||||
edge_description=self._engine.edge_description,
|
||||
)
|
||||
if selected and selected != STAY_ON_CURRENT_AGENT:
|
||||
edge = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in llm_edges
|
||||
if self._engine.edge_fn_name(candidate) == selected
|
||||
),
|
||||
None,
|
||||
)
|
||||
elif selected == STAY_ON_CURRENT_AGENT:
|
||||
edge = default_edge
|
||||
elif edge is None and not llm_edges:
|
||||
edge = default_edge
|
||||
|
||||
if edge and manager.current_node == current:
|
||||
next_config = await self._follow_edge(edge)
|
||||
await manager.set_node_from_config(next_config)
|
||||
return True
|
||||
|
||||
# The incoming LLMContextFrame is intentionally suppressed by the
|
||||
# pipeline router. Queue prompt refresh + inference in this order so
|
||||
# this user turn is answered with the current Agent's latest variables.
|
||||
await self._refresh_agent_prompt(current)
|
||||
await self._require_runtime().queue_frame(LLMRunFrame())
|
||||
return True
|
||||
|
||||
async def on_assistant_text_end(
|
||||
self,
|
||||
turn_id: str,
|
||||
_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)
|
||||
|
||||
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}"
|
||||
|
||||
def _router_for_node(self, node_id: str) -> WorkflowLLMRouter:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
resource_id = stage.llm_resource_id
|
||||
cfg = self._cfg
|
||||
resource = cfg.workflow_model_resources.get(resource_id) if cfg else None
|
||||
if not cfg or not resource:
|
||||
return self._router
|
||||
from services.pipecat.service_factory import config_with_resource
|
||||
|
||||
return WorkflowLLMRouter(config_with_resource(cfg, resource))
|
||||
|
||||
async def _apply_agent_stage(self, node_id: str) -> None:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
await self._emit_node_active(node_id)
|
||||
if self._runtime and self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(True)
|
||||
runtime = self._require_runtime()
|
||||
if runtime.switch_services:
|
||||
await runtime.switch_services(
|
||||
stage.llm_resource_id or None,
|
||||
stage.asr_resource_id or None,
|
||||
stage.tts_resource_id or None,
|
||||
)
|
||||
if runtime.set_knowledge_scope:
|
||||
runtime.set_knowledge_scope(
|
||||
{
|
||||
"knowledge_base_id": stage.knowledge_base_id,
|
||||
"mode": stage.knowledge_mode,
|
||||
"top_n": stage.knowledge_top_n,
|
||||
"score_threshold": stage.knowledge_score_threshold,
|
||||
}
|
||||
)
|
||||
|
||||
def _agent_config(self, node_id: str) -> NodeConfig:
|
||||
data = self._engine.data(node_id)
|
||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||
strategy = (
|
||||
ContextStrategy.RESET
|
||||
if data.get("contextPolicy") == "fresh"
|
||||
else ContextStrategy.APPEND
|
||||
)
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
functions: list[FlowsFunctionSchema] = []
|
||||
for tool_id in stage.tool_ids:
|
||||
tool = self._tool_by_id.get(str(tool_id))
|
||||
if tool and tool.type == "http":
|
||||
functions.append(self._flow_tool(tool, node_id))
|
||||
knowledge_function = self._knowledge_function(node_id)
|
||||
if knowledge_function:
|
||||
functions.append(knowledge_function)
|
||||
config: NodeConfig = {
|
||||
"name": node_id,
|
||||
"role_message": self._agent_role_message(node_id),
|
||||
"task_messages": (
|
||||
[{"role": "assistant", "content": entry_speech}]
|
||||
if entry_mode == "fixed_speech"
|
||||
else []
|
||||
),
|
||||
"functions": functions,
|
||||
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||
"respond_immediately": entry_mode == "generate",
|
||||
}
|
||||
if entry_mode == "fixed_speech":
|
||||
config["pre_actions"] = [
|
||||
{
|
||||
"type": "workflow_fixed_speech",
|
||||
"text": entry_speech,
|
||||
"handler": self._play_fixed_speech,
|
||||
}
|
||||
]
|
||||
return config
|
||||
|
||||
async def _play_fixed_speech(self, action: dict, _flow_manager: FlowManager) -> None:
|
||||
"""Play and persist Agent entry speech without creating an LLM turn."""
|
||||
await self._queue_visible_speech(str(action.get("text") or ""))
|
||||
|
||||
async def _queue_visible_speech(self, text: str) -> None:
|
||||
"""Show and persist fixed workflow speech before sending it to TTS."""
|
||||
content = text.strip()
|
||||
if not content:
|
||||
return
|
||||
self._store.record("agent", content)
|
||||
runtime = self._require_runtime()
|
||||
await runtime.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={
|
||||
"type": "transcript",
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"timestamp": time_now_iso8601(),
|
||||
}
|
||||
)
|
||||
)
|
||||
await runtime.queue_frame(TTSSpeakFrame(content, append_to_context=False))
|
||||
|
||||
def _passive_node_config(self, node_id: str) -> NodeConfig:
|
||||
"""Keep a non-conversational terminal node active without ending the call."""
|
||||
return {
|
||||
"name": node_id,
|
||||
"role_message": self._store.render(self._engine.global_prompt()),
|
||||
"task_messages": [],
|
||||
"functions": [],
|
||||
"context_strategy": ContextStrategyConfig(strategy=ContextStrategy.APPEND),
|
||||
"respond_immediately": False,
|
||||
}
|
||||
|
||||
def _flow_tool(self, tool: RuntimeTool, node_id: str) -> FlowsFunctionSchema:
|
||||
properties, required = self._tools.schema_parts(tool)
|
||||
self._tools.register_secrets(tool)
|
||||
|
||||
async def handler(args, _flow_manager):
|
||||
try:
|
||||
result = await self._tools.execute(tool, dict(args or {}))
|
||||
except ToolExecutionError as exc:
|
||||
return {"status": "error", "message": str(exc)}
|
||||
if result.get("updated_variables"):
|
||||
await self._refresh_agent_prompt(node_id)
|
||||
edge = self._engine.deterministic_edge(
|
||||
node_id,
|
||||
self._store,
|
||||
include_default=False,
|
||||
)
|
||||
if edge:
|
||||
return result, await self._follow_edge(edge)
|
||||
return result
|
||||
|
||||
return FlowsFunctionSchema(
|
||||
name=tool.function_name,
|
||||
description=tool.description or f"调用 {tool.name}",
|
||||
properties=properties,
|
||||
required=required,
|
||||
handler=handler,
|
||||
cancel_on_interruption=True,
|
||||
)
|
||||
|
||||
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
||||
stage = self._engine.agent_stage_config(node_id)
|
||||
knowledge_id = str(stage.knowledge_base_id or "")
|
||||
if not knowledge_id or stage.knowledge_mode != "on_demand":
|
||||
return None
|
||||
cfg = self._cfg or AssistantConfig(type="workflow")
|
||||
knowledge = cfg.workflow_knowledge_bases.get(knowledge_id)
|
||||
description = "在当前 Agent 绑定的知识库中检索资料。"
|
||||
if knowledge:
|
||||
description += f"知识库:{knowledge.name}。{knowledge.description}"
|
||||
|
||||
async def handler(args, _flow_manager):
|
||||
query = str((args or {}).get("query") or "").strip()
|
||||
if not query:
|
||||
return {"status": "error", "message": "检索问题为空"}
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
results = await search_knowledge(
|
||||
session,
|
||||
knowledge_id,
|
||||
query,
|
||||
top_k=stage.knowledge_top_n,
|
||||
score_threshold=stage.knowledge_score_threshold,
|
||||
)
|
||||
return {"status": "ok", "results": results}
|
||||
except Exception as exc: # noqa: BLE001 - tool errors are returned to the LLM
|
||||
logger.warning(f"Workflow 知识库检索失败:{exc}")
|
||||
return {"status": "error", "message": "知识库检索暂时不可用"}
|
||||
|
||||
return FlowsFunctionSchema(
|
||||
name="search_knowledge_base",
|
||||
description=description,
|
||||
properties={
|
||||
"query": {"type": "string", "description": "完整问题或检索关键词"}
|
||||
},
|
||||
required=["query"],
|
||||
handler=handler,
|
||||
cancel_on_interruption=True,
|
||||
)
|
||||
|
||||
async def _follow_edge(self, edge: dict) -> NodeConfig:
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
await self._queue_visible_speech(self._store.render(speech))
|
||||
return await self._resolve_path(str(edge.get("target") or ""))
|
||||
|
||||
async def _resolve_path(self, node_id: str) -> NodeConfig:
|
||||
for _ in range(MAX_AUTOMATIC_HOPS):
|
||||
node_type = self._engine.node_type(node_id)
|
||||
if node_type == "agent":
|
||||
await self._apply_agent_stage(node_id)
|
||||
return self._agent_config(node_id)
|
||||
if node_type == "end":
|
||||
await self._enter_end(node_id)
|
||||
return self._passive_node_config(node_id)
|
||||
if node_type == "action":
|
||||
await self._enter_action(node_id)
|
||||
elif node_type == "handoff":
|
||||
await self._enter_handoff(node_id)
|
||||
elif node_type == "start":
|
||||
await self._emit_node_active(node_id)
|
||||
else:
|
||||
raise RuntimeError(f"工作流指向未知节点:{node_id}")
|
||||
if not self._engine.has_outgoing(node_id):
|
||||
return self._passive_node_config(node_id)
|
||||
edge = self._engine.deterministic_edge(
|
||||
node_id,
|
||||
self._store,
|
||||
include_default=True,
|
||||
)
|
||||
if not edge:
|
||||
raise RuntimeError(f"自动节点 {node_id} 没有命中的表达式边或默认边")
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
await self._queue_visible_speech(self._store.render(speech))
|
||||
node_id = str(edge.get("target") or "")
|
||||
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||
|
||||
async def _enter_action(self, node_id: str) -> None:
|
||||
await self._emit_node_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
tool_id = str(data.get("toolId") or "")
|
||||
tool = self._tool_by_id.get(tool_id)
|
||||
if not tool:
|
||||
self._store.values["system__last_action_status"] = "error"
|
||||
self._store.values["system__last_action_error"] = f"工具不存在:{tool_id}"
|
||||
return
|
||||
try:
|
||||
arguments = self._store.render_data(data.get("arguments") or {})
|
||||
await self._tools.execute(
|
||||
tool,
|
||||
arguments,
|
||||
result_assignments=data.get("resultAssignments") or {},
|
||||
)
|
||||
self._store.values["system__last_action_status"] = "ok"
|
||||
self._store.values["system__last_action_error"] = ""
|
||||
except (ToolExecutionError, ValueError) as exc:
|
||||
self._store.values["system__last_action_status"] = "error"
|
||||
self._store.values["system__last_action_error"] = str(exc)[:2048]
|
||||
|
||||
async def _enter_handoff(self, node_id: str) -> None:
|
||||
await self._emit_node_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
message = self._store.render(str(data.get("message") or ""))
|
||||
await self._require_runtime().queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={
|
||||
"type": "handoff-requested",
|
||||
"nodeId": node_id,
|
||||
"targetType": data.get("targetType", "human"),
|
||||
"target": data.get("target", ""),
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
)
|
||||
if message:
|
||||
await self._queue_visible_speech(message)
|
||||
self._store.values["system__handoff_status"] = "requested"
|
||||
|
||||
async def _enter_end(self, node_id: str) -> None:
|
||||
self._ended = True
|
||||
await self._emit_node_active(node_id)
|
||||
runtime = self._require_runtime()
|
||||
if runtime.set_knowledge_scope:
|
||||
runtime.set_knowledge_scope({"mode": "disabled"})
|
||||
if runtime.set_input_enabled:
|
||||
runtime.set_input_enabled(False)
|
||||
data = self._engine.data(node_id)
|
||||
message = self._store.render(str(data.get("message") or ""))
|
||||
scope = str(data.get("scope") or "session")
|
||||
if scope == "flow":
|
||||
await runtime.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={"type": "flow-ended", "nodeId": node_id}
|
||||
)
|
||||
)
|
||||
if message:
|
||||
await self._queue_visible_speech(message)
|
||||
return
|
||||
runtime.call_end.begin("workflow_completed")
|
||||
if message:
|
||||
runtime.call_end.arm_after_speech()
|
||||
await self._queue_visible_speech(message)
|
||||
else:
|
||||
await runtime.call_end.finish()
|
||||
|
||||
async def _emit_node_active(self, node_id: str | None) -> None:
|
||||
if node_id:
|
||||
@@ -128,61 +501,12 @@ class WorkflowBrain(BaseBrain):
|
||||
)
|
||||
)
|
||||
|
||||
async def _speak_transition(self, edge: dict | None) -> None:
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
await self._require_runtime().queue_frame(
|
||||
TTSSpeakFrame(speech, append_to_context=False)
|
||||
)
|
||||
|
||||
def _make_transition_handler(self, edge: dict):
|
||||
target = str(edge.get("target"))
|
||||
|
||||
async def handler(params) -> None:
|
||||
logger.info(f"LLM 触发转移 → {self._engine.name(target)}")
|
||||
if not self._engine.is_end(target):
|
||||
await self._speak_transition(edge)
|
||||
await self._go_to_node(target)
|
||||
await params.result_callback({"status": "ok"})
|
||||
|
||||
return handler
|
||||
|
||||
async def _fallback_route(self) -> None:
|
||||
if self._state.ended:
|
||||
return
|
||||
if self._state.turns_in_node < self._FALLBACK_AFTER_TURNS:
|
||||
return
|
||||
if not self._engine.outgoing(self._state.current):
|
||||
return
|
||||
|
||||
cfg = self._require_config()
|
||||
target = await self._engine.route(
|
||||
self._state.current,
|
||||
self._history,
|
||||
api_key=self._require(cfg.llm_api_key, "LLM apiKey"),
|
||||
base_url=self._require(cfg.llm_base_url, "LLM apiUrl"),
|
||||
model=self._require(cfg.model, "LLM modelId"),
|
||||
)
|
||||
if target and target != self._state.current:
|
||||
logger.info(f"文本兜底触发转移 → {self._engine.name(target)}")
|
||||
if not self._engine.is_end(target):
|
||||
await self._speak_transition(
|
||||
self._engine.find_edge(self._state.current, target)
|
||||
)
|
||||
await self._go_to_node(target)
|
||||
|
||||
def _require_runtime(self) -> BrainRuntime:
|
||||
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,110 @@
|
||||
"""工作流节点规格 + 图校验(对齐 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"}
|
||||
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
|
||||
AUTOMATIC_NODE_TYPES = {"start", "action", "handoff"}
|
||||
EXPRESSION_OPERATORS = {
|
||||
"eq",
|
||||
"neq",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"contains",
|
||||
"in",
|
||||
"exists",
|
||||
}
|
||||
|
||||
NODE_SPECS: list[dict[str, Any]] = [
|
||||
{
|
||||
"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": 0,
|
||||
"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": 0},
|
||||
"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": 0},
|
||||
"fields": [
|
||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Handoff"},
|
||||
{"key": "target", "label": "转交目标", "type": "text", "default": ""},
|
||||
{"key": "message", "label": "转交提示", "type": "textarea", "default": ""},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"displayName": "End",
|
||||
"category": "control_node",
|
||||
"description": "结束 AI 流程或整个音视频会话。",
|
||||
"icon": "Flag",
|
||||
"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 +113,372 @@ _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_agent_data(data: dict[str, Any]) -> None:
|
||||
"""Add v3 Agent defaults without changing existing node-level behavior."""
|
||||
data.setdefault("contextPolicy", "inherit")
|
||||
data.setdefault("entryMode", "wait_user")
|
||||
data.setdefault("entrySpeech", "")
|
||||
if "inheritGlobalConfig" not in data:
|
||||
has_node_overrides = any(
|
||||
(
|
||||
data.get("llmResourceId"),
|
||||
data.get("asrResourceId"),
|
||||
data.get("ttsResourceId"),
|
||||
data.get("knowledgeBaseId"),
|
||||
data.get("toolIds"),
|
||||
)
|
||||
)
|
||||
data["inheritGlobalConfig"] = not has_node_overrides
|
||||
|
||||
if not nodes:
|
||||
return errors # 草稿:放行
|
||||
|
||||
node_ids: set[str] = set()
|
||||
type_counts: dict[str, int] = {}
|
||||
node_type_by_id: dict[str, str] = {}
|
||||
def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") -> None:
|
||||
settings.setdefault("globalPrompt", global_prompt)
|
||||
settings.setdefault("defaultLlmResourceId", "")
|
||||
settings.setdefault("defaultAsrResourceId", "")
|
||||
settings.setdefault("defaultTtsResourceId", "")
|
||||
settings.setdefault("toolIds", [])
|
||||
settings.setdefault("knowledgeBaseId", "")
|
||||
settings.setdefault("knowledgeMode", "automatic")
|
||||
settings.setdefault("knowledgeTopN", 5)
|
||||
settings.setdefault("knowledgeScoreThreshold", 0.0)
|
||||
|
||||
|
||||
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return a deep-copied v3 graph; preserve v3 IDs and migrate v2 semantics."""
|
||||
source = deepcopy(graph or {})
|
||||
if str(source.get("specVersion") or "") == SPEC_VERSION:
|
||||
settings = source.setdefault("settings", {})
|
||||
_normalize_settings(settings)
|
||||
source.setdefault("nodes", [])
|
||||
source.setdefault("edges", [])
|
||||
for node in source["nodes"]:
|
||||
if node.get("type") != "agent":
|
||||
continue
|
||||
data = node.setdefault("data", {})
|
||||
_normalize_agent_data(data)
|
||||
return source
|
||||
|
||||
nodes = source.get("nodes") or []
|
||||
edges = source.get("edges") or []
|
||||
global_prompt = ""
|
||||
mapped_nodes: list[dict] = []
|
||||
type_by_id: dict[str, str] = {}
|
||||
start_prompt_nodes: dict[str, str] = {}
|
||||
|
||||
type_map = {
|
||||
"startCall": "start",
|
||||
"agentNode": "agent",
|
||||
"endCall": "end",
|
||||
"start": "start",
|
||||
"agent": "agent",
|
||||
"action": "action",
|
||||
"handoff": "handoff",
|
||||
"end": "end",
|
||||
}
|
||||
for node in nodes:
|
||||
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":
|
||||
_normalize_agent_data(data)
|
||||
elif new_type == "start":
|
||||
prompt = str(data.pop("prompt", "") or "").strip()
|
||||
if prompt:
|
||||
start_prompt_nodes[str(node.get("id"))] = prompt
|
||||
for key in ("allowInterrupt", "addGlobalPrompt"):
|
||||
data.pop(key, None)
|
||||
migrated["data"] = data
|
||||
mapped_nodes.append(migrated)
|
||||
if migrated.get("id"):
|
||||
type_by_id[str(migrated["id"])] = new_type
|
||||
|
||||
start_count = type_counts.get("startCall", 0)
|
||||
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",
|
||||
"inheritGlobalConfig": True,
|
||||
"entryMode": "wait_user",
|
||||
"entrySpeech": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
for edge in mapped_edges:
|
||||
if edge.get("source") == start_id:
|
||||
edge["source"] = synthetic_id
|
||||
edge["data"] = _edge_data_v3(edge, "agent")
|
||||
if str(edge["data"].get("condition") or "").strip():
|
||||
edge["data"]["mode"] = "llm"
|
||||
mapped_edges.append(
|
||||
{
|
||||
"id": f"e-{start_id}-{synthetic_id}",
|
||||
"source": start_id,
|
||||
"target": synthetic_id,
|
||||
"data": {"mode": "always", "priority": 0, "transitionSpeech": ""},
|
||||
}
|
||||
)
|
||||
|
||||
# 统计入边/出边
|
||||
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 {})
|
||||
_normalize_settings(settings, global_prompt=global_prompt)
|
||||
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 node_type == "agent":
|
||||
data = node.get("data") or {}
|
||||
entry_mode = data.get("entryMode", "wait_user")
|
||||
if entry_mode not in AGENT_ENTRY_MODES:
|
||||
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}")
|
||||
elif entry_mode == "fixed_speech" and not str(
|
||||
data.get("entrySpeech") or ""
|
||||
).strip():
|
||||
errors.append(f"Agent 节点 {node_id} 的固定进入语不能为空")
|
||||
|
||||
if counts["start"] != 1:
|
||||
errors.append("工作流必须有且仅有一个 Start 节点")
|
||||
incoming: dict[str, int] = defaultdict(int)
|
||||
outgoing: dict[str, int] = defaultdict(int)
|
||||
adj: dict[str, list[str]] = defaultdict(list)
|
||||
auto_adj: dict[str, list[str]] = defaultdict(list)
|
||||
priorities: dict[str, set[int]] = defaultdict(set)
|
||||
always_counts: dict[str, int] = defaultdict(int)
|
||||
for edge in edges:
|
||||
edge_id = str(edge.get("id") or "")
|
||||
source_id = str(edge.get("source") or "")
|
||||
target_id = str(edge.get("target") or "")
|
||||
if source_id not in node_by_id:
|
||||
errors.append(f"边 {edge_id} 指向不存在的源节点:{source_id}")
|
||||
continue
|
||||
if target_id not in node_by_id:
|
||||
errors.append(f"边 {edge_id} 指向不存在的目标节点:{target_id}")
|
||||
continue
|
||||
if source_id == target_id and node_by_id[source_id].get("type") != "agent":
|
||||
errors.append(f"自动节点不能自连:{source_id}")
|
||||
data = edge.get("data") or {}
|
||||
mode = data.get("mode")
|
||||
if mode not in EDGE_MODES:
|
||||
errors.append(f"边 {edge_id} 的判断模式无效:{mode}")
|
||||
if mode == "llm" and node_by_id[source_id].get("type") != "agent":
|
||||
errors.append(f"LLM 判断边只能从 Agent 发出:{edge_id}")
|
||||
if mode == "llm" and not str(data.get("condition") or "").strip():
|
||||
errors.append(f"LLM 判断边缺少自然语言条件:{edge_id}")
|
||||
if mode == "expression":
|
||||
expression_errors = _validate_expression(data.get("expression"))
|
||||
errors.extend(f"边 {edge_id}:{item}" for item in expression_errors)
|
||||
try:
|
||||
priority = int(data.get("priority", 10))
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"边 {edge_id} 的优先级必须是整数")
|
||||
priority = 10
|
||||
if priority in priorities[source_id]:
|
||||
errors.append(f"节点 {source_id} 的出边优先级不能重复:{priority}")
|
||||
priorities[source_id].add(priority)
|
||||
if mode == "always":
|
||||
always_counts[source_id] += 1
|
||||
if always_counts[source_id] > 1:
|
||||
errors.append(f"节点 {source_id} 最多只能有一条默认边")
|
||||
incoming[target_id] += 1
|
||||
outgoing[source_id] += 1
|
||||
adj[source_id].append(target_id)
|
||||
source_is_automatic = node_by_id[source_id].get("type") != "agent"
|
||||
target_is_automatic = node_by_id[target_id].get("type") != "agent"
|
||||
if source_is_automatic and target_is_automatic:
|
||||
auto_adj[source_id].append(target_id)
|
||||
|
||||
for node_id, node in node_by_id.items():
|
||||
spec = _SPEC_BY_NAME.get(str(node.get("type")))
|
||||
if not spec:
|
||||
continue
|
||||
constraints = spec["constraints"]
|
||||
for actual, suffix, label in (
|
||||
(incoming[node_id], "Incoming", "入边"),
|
||||
(outgoing[node_id], "Outgoing", "出边"),
|
||||
):
|
||||
lo = constraints.get(f"min{suffix}")
|
||||
hi = constraints.get(f"max{suffix}")
|
||||
if lo is not None and actual < lo:
|
||||
errors.append(f"节点 {node_id} 的{label}不能少于 {lo}")
|
||||
if hi is not None and actual > hi:
|
||||
errors.append(f"节点 {node_id} 的{label}不能多于 {hi}")
|
||||
node_type = node.get("type")
|
||||
if (
|
||||
node_type in AUTOMATIC_NODE_TYPES
|
||||
and outgoing[node_id] > 0
|
||||
and always_counts[node_id] != 1
|
||||
):
|
||||
errors.append(f"自动节点 {node_id} 存在出边时必须有且仅有一条默认边")
|
||||
|
||||
start_id = next(
|
||||
(node_id for node_id, node in node_by_id.items() if node.get("type") == "start"),
|
||||
None,
|
||||
)
|
||||
if start_id:
|
||||
reached = {start_id}
|
||||
queue = deque([start_id])
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
for target in adj[current]:
|
||||
if target not in reached:
|
||||
reached.add(target)
|
||||
queue.append(target)
|
||||
for node_id in node_by_id.keys() - reached:
|
||||
errors.append(f"节点不可从 Start 到达:{node_id}")
|
||||
|
||||
# Reject cycles made only of instantaneous nodes; Agent cycles are valid waits.
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(node_id: str) -> bool:
|
||||
if node_id in visiting:
|
||||
return True
|
||||
if node_id in visited:
|
||||
return False
|
||||
visiting.add(node_id)
|
||||
for target in auto_adj[node_id]:
|
||||
if visit(target):
|
||||
return True
|
||||
visiting.remove(node_id)
|
||||
visited.add(node_id)
|
||||
return False
|
||||
|
||||
automatic_node_ids = (
|
||||
node_id
|
||||
for node_id, node in node_by_id.items()
|
||||
if node.get("type") != "agent"
|
||||
)
|
||||
if any(visit(node_id) for node_id in automatic_node_ids):
|
||||
errors.append("Start/Action/Handoff/End 之间不能形成无等待循环")
|
||||
return list(dict.fromkeys(errors))
|
||||
|
||||
|
||||
def graph_references(graph: dict[str, Any]) -> dict[str, set[str]]:
|
||||
"""Collect externally referenced IDs for save/runtime validation."""
|
||||
normalized = normalize_graph(graph)
|
||||
settings = normalized.get("settings") or {}
|
||||
resources = {
|
||||
str(value)
|
||||
for value in (
|
||||
settings.get("defaultLlmResourceId"),
|
||||
settings.get("defaultAsrResourceId"),
|
||||
settings.get("defaultTtsResourceId"),
|
||||
)
|
||||
if value
|
||||
}
|
||||
tools: set[str] = {str(tool_id) for tool_id in settings.get("toolIds") or []}
|
||||
knowledge: set[str] = (
|
||||
{str(settings["knowledgeBaseId"])}
|
||||
if settings.get("knowledgeBaseId")
|
||||
else set()
|
||||
)
|
||||
for node in normalized.get("nodes") or []:
|
||||
data = node.get("data") or {}
|
||||
inherits_global = (
|
||||
node.get("type") == "agent" and data.get("inheritGlobalConfig", True)
|
||||
)
|
||||
if not inherits_global:
|
||||
for resource_id in (
|
||||
data.get("llmResourceId"),
|
||||
data.get("asrResourceId"),
|
||||
data.get("ttsResourceId"),
|
||||
):
|
||||
if resource_id:
|
||||
resources.add(str(resource_id))
|
||||
for tool_id in data.get("toolIds") or []:
|
||||
tools.add(str(tool_id))
|
||||
if data.get("knowledgeBaseId"):
|
||||
knowledge.add(str(data["knowledgeBaseId"]))
|
||||
if data.get("toolId"):
|
||||
tools.add(str(data["toolId"]))
|
||||
return {"model_resources": resources, "tools": tools, "knowledge_bases": knowledge}
|
||||
|
||||
@@ -23,6 +23,8 @@ from services.pipecat.call_lifecycle import (
|
||||
EndCallAfterSpeechProcessor,
|
||||
)
|
||||
from services.pipecat.service_factory import (
|
||||
config_with_resource,
|
||||
create_llm,
|
||||
create_realtime_service,
|
||||
create_stt,
|
||||
create_tts,
|
||||
@@ -32,6 +34,7 @@ from services.knowledge import search as search_knowledge
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.flows import FlowsFunctionSchema
|
||||
from pipecat.frames.frames import (
|
||||
EndFrame,
|
||||
InputTransportMessageFrame,
|
||||
@@ -40,6 +43,7 @@ from pipecat.frames.frames import (
|
||||
LLMFullResponseStartFrame,
|
||||
LLMContextFrame,
|
||||
LLMTextFrame,
|
||||
ManuallySwitchServiceFrame,
|
||||
LLMMessagesAppendFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
TextFrame,
|
||||
@@ -48,6 +52,8 @@ from pipecat.frames.frames import (
|
||||
UserImageRequestFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.llm_switcher import LLMSwitcher
|
||||
from pipecat.pipeline.service_switcher import ServiceSwitcher
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
@@ -397,15 +403,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,16 +485,53 @@ 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)
|
||||
|
||||
|
||||
class UserTurnRoutingProcessor(FrameProcessor):
|
||||
"""Give a brain first right of refusal before a new user turn reaches the LLM."""
|
||||
|
||||
def __init__(self, brain: Brain):
|
||||
super().__init__()
|
||||
self._brain = brain
|
||||
self._last_user_message: dict | None = None
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if direction != FrameDirection.DOWNSTREAM or not isinstance(
|
||||
frame, LLMContextFrame
|
||||
):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
user_message = next(
|
||||
(
|
||||
message
|
||||
for message in reversed(frame.context.get_messages())
|
||||
if message.get("role") == "user"
|
||||
and isinstance(message.get("content"), str)
|
||||
and str(message.get("content") or "").strip()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if user_message is None:
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
if user_message is self._last_user_message:
|
||||
# Programmatic LLMRunFrame after a node transition reuses the same
|
||||
# user message. It is a response run, not another routing event.
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
self._last_user_message = user_message
|
||||
|
||||
content = str(user_message.get("content") or "").strip()
|
||||
handled = await self._brain.on_user_turn_end(content)
|
||||
if not handled:
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
||||
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
||||
|
||||
@@ -457,7 +544,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 +591,72 @@ 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
|
||||
|
||||
|
||||
def _workflow_llm_switcher(cfg: AssistantConfig, base_service):
|
||||
"""Build an LLM switcher for the global model and Agent overrides."""
|
||||
settings = cfg.graph.get("settings") or {}
|
||||
default_id = str(settings.get("defaultLlmResourceId") or "")
|
||||
services_by_id = {}
|
||||
for resource_id, resource in cfg.workflow_model_resources.items():
|
||||
if resource.capability != "LLM":
|
||||
continue
|
||||
services_by_id[resource_id] = (
|
||||
base_service
|
||||
if resource_id == default_id
|
||||
else create_llm(config_with_resource(cfg, resource))
|
||||
)
|
||||
primary = services_by_id.get(default_id, base_service)
|
||||
services = [primary]
|
||||
services.extend(
|
||||
service for service in services_by_id.values() if service is not primary
|
||||
)
|
||||
if base_service is not primary:
|
||||
services.append(base_service)
|
||||
return LLMSwitcher(llms=services), services_by_id, primary
|
||||
|
||||
|
||||
async def run_pipeline(
|
||||
transport,
|
||||
cfg: AssistantConfig,
|
||||
@@ -545,8 +697,38 @@ async def run_pipeline(
|
||||
)
|
||||
return
|
||||
|
||||
stt = create_stt(cfg)
|
||||
tts = create_tts(cfg)
|
||||
graph_settings = cfg.graph.get("settings") or {}
|
||||
default_llm_resource = cfg.workflow_model_resources.get(
|
||||
str(graph_settings.get("defaultLlmResourceId") or "")
|
||||
)
|
||||
default_asr_resource = cfg.workflow_model_resources.get(
|
||||
str(graph_settings.get("defaultAsrResourceId") or "")
|
||||
)
|
||||
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,17 +776,33 @@ 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)
|
||||
llm = brain.build_llm(
|
||||
config_with_resource(cfg, default_llm_resource)
|
||||
if cfg.type == "workflow" and default_llm_resource
|
||||
else cfg,
|
||||
context,
|
||||
)
|
||||
llm_services: dict[str, FrameProcessor] = {}
|
||||
current_llm_service = llm
|
||||
if cfg.type == "workflow":
|
||||
llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
|
||||
user_aggregator = LLMUserAggregator(
|
||||
context,
|
||||
params=LLMUserAggregatorParams(
|
||||
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,
|
||||
@@ -612,8 +810,11 @@ async def run_pipeline(
|
||||
),
|
||||
),
|
||||
)
|
||||
user_turn_router = UserTurnRoutingProcessor(brain)
|
||||
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 +924,55 @@ async def run_pipeline(
|
||||
}
|
||||
)
|
||||
|
||||
if vision_enabled:
|
||||
flow_global_functions = []
|
||||
if cfg.type == "workflow" and vision_enabled:
|
||||
async def flow_fetch_user_image(args, _flow_manager):
|
||||
question = str((args or {}).get("question") or "请描述当前画面。")
|
||||
user_id = vision_state.get("client_id")
|
||||
if not user_id:
|
||||
return {
|
||||
"status": "no_video_client",
|
||||
"message": "当前还没有可用的摄像头视频流。",
|
||||
}
|
||||
request = UserImageRequestFrame(
|
||||
user_id=user_id,
|
||||
text=question,
|
||||
append_to_context=False,
|
||||
function_name=VISION_TOOL_NAME,
|
||||
)
|
||||
try:
|
||||
frame = await vision_capture.request_image(llm, request)
|
||||
observation = await _analyze_image_with_vision_model(cfg, frame, question)
|
||||
return {
|
||||
"status": "ok",
|
||||
"question": question,
|
||||
"observation": observation or "视觉模型没有返回有效观察结果。",
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
return {"status": "timeout", "message": "等待摄像头视频帧超时。"}
|
||||
except Exception as exc: # noqa: BLE001 - return tool errors to the LLM
|
||||
logger.warning(f"Workflow 视觉理解失败:{exc}")
|
||||
return {"status": "error", "message": "视觉理解暂时不可用。"}
|
||||
|
||||
flow_global_functions.append(
|
||||
FlowsFunctionSchema(
|
||||
name=VISION_TOOL_NAME,
|
||||
description=vision_schema.description,
|
||||
properties=vision_schema.properties,
|
||||
required=vision_schema.required,
|
||||
handler=flow_fetch_user_image,
|
||||
cancel_on_interruption=True,
|
||||
)
|
||||
)
|
||||
|
||||
if vision_enabled and cfg.type != "workflow":
|
||||
llm.register_function(VISION_TOOL_NAME, fetch_user_image)
|
||||
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,15 +993,16 @@ async def run_pipeline(
|
||||
transport.input(),
|
||||
vision_capture,
|
||||
text_input,
|
||||
stt,
|
||||
stt_processor,
|
||||
user_aggregator,
|
||||
user_turn_router,
|
||||
knowledge_retrieval,
|
||||
llm,
|
||||
# Aggregate the streamed LLM text before TTS. On interruption,
|
||||
# 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 +1017,51 @@ async def run_pipeline(
|
||||
enable_rtvi=False,
|
||||
)
|
||||
worker_holder["worker"] = worker
|
||||
default_workflow_services = {
|
||||
"llm": current_llm_service,
|
||||
**current_voice_services,
|
||||
}
|
||||
|
||||
async def switch_workflow_services(
|
||||
llm_resource_id: str | None,
|
||||
asr_resource_id: str | None,
|
||||
tts_resource_id: str | None,
|
||||
) -> None:
|
||||
nonlocal current_llm_service
|
||||
requested = (
|
||||
("llm", llm_services, llm_resource_id),
|
||||
("asr", stt_services, asr_resource_id),
|
||||
("tts", tts_services, tts_resource_id),
|
||||
)
|
||||
for kind, services, resource_id in requested:
|
||||
target = (
|
||||
services.get(resource_id)
|
||||
if resource_id
|
||||
else default_workflow_services[kind]
|
||||
)
|
||||
if target is None:
|
||||
raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
|
||||
current = (
|
||||
current_llm_service
|
||||
if kind == "llm"
|
||||
else current_voice_services[kind]
|
||||
)
|
||||
if current is target:
|
||||
continue
|
||||
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
|
||||
if kind == "llm":
|
||||
current_llm_service = target
|
||||
else:
|
||||
current_voice_services[kind] = target
|
||||
await worker.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={
|
||||
"type": "service-switched",
|
||||
"capability": kind.upper(),
|
||||
"resourceId": resource_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async def queue_transcript(role: str, content: str, timestamp: str) -> None:
|
||||
if content:
|
||||
@@ -810,6 +1098,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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -823,8 +1121,6 @@ async def run_pipeline(
|
||||
|
||||
@user_aggregator.event_handler("on_user_turn_stopped")
|
||||
async def on_user_turn_stopped(_aggregator, _strategy, message):
|
||||
if message.content:
|
||||
brain.record_user_message(message.content)
|
||||
await queue_transcript("user", message.content, message.timestamp)
|
||||
|
||||
@assistant_aggregator.event_handler("on_assistant_text_start")
|
||||
@@ -869,7 +1165,6 @@ async def run_pipeline(
|
||||
@text_input.event_handler("on_text_input")
|
||||
async def on_text_input(_processor, text):
|
||||
pending_text_inputs.append(text)
|
||||
brain.record_user_message(text)
|
||||
# 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
|
||||
await queue_transcript("user", text, time_now_iso8601())
|
||||
|
||||
|
||||
@@ -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,42 @@ 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 model resource."""
|
||||
result = cfg.model_copy(deep=True)
|
||||
values = resource.values or {}
|
||||
secrets = resource.secrets or {}
|
||||
if resource.capability == "LLM":
|
||||
result.model = str(values.get("modelId") or "")
|
||||
result.llm_interface_type = resource.interface_type
|
||||
result.llm_values = values
|
||||
result.llm_secrets = secrets
|
||||
result.llm_api_key = str(secrets.get("apiKey") or "")
|
||||
result.llm_base_url = str(values.get("apiUrl") or "")
|
||||
elif resource.capability == "ASR":
|
||||
result.asr = str(values.get("modelId") or "")
|
||||
result.stt_language = str(values.get("language") or "")
|
||||
result.stt_interface_type = resource.interface_type
|
||||
result.stt_values = values
|
||||
result.stt_secrets = secrets
|
||||
result.stt_api_key = str(secrets.get("apiKey") or "")
|
||||
result.stt_base_url = str(values.get("apiUrl") or "")
|
||||
elif resource.capability == "TTS":
|
||||
result.tts_model = str(values.get("modelId") or "")
|
||||
result.voice = str(values.get("voice") or "")
|
||||
result.tts_speed = float(values.get("speed") or 1.0)
|
||||
result.tts_interface_type = resource.interface_type
|
||||
result.tts_values = values
|
||||
result.tts_secrets = secrets
|
||||
result.tts_api_key = str(secrets.get("apiKey") or "")
|
||||
result.tts_base_url = str(values.get("apiUrl") or "")
|
||||
else:
|
||||
raise ValueError(f"工作流模型资源能力无效:{resource.capability}")
|
||||
return result
|
||||
|
||||
|
||||
def _require(value: str, label: str) -> str:
|
||||
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,204 @@
|
||||
"""工作流图引擎(第一版)。
|
||||
|
||||
对应 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 dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from services.node_specs import normalize_graph
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentStageConfig:
|
||||
"""The complete assistant configuration active inside one Agent node."""
|
||||
|
||||
inherits_global: bool
|
||||
llm_resource_id: str
|
||||
asr_resource_id: str
|
||||
tts_resource_id: str
|
||||
tool_ids: tuple[str, ...]
|
||||
knowledge_base_id: str | None
|
||||
knowledge_mode: str
|
||||
knowledge_top_n: int
|
||||
knowledge_score_threshold: float
|
||||
|
||||
|
||||
class WorkflowEngine:
|
||||
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 has_outgoing(self, node_id: str | None) -> bool:
|
||||
return any(edge.get("source") == node_id for edge in self.edges)
|
||||
|
||||
def edge_mode(self, edge: dict) -> str:
|
||||
return str((edge.get("data") or {}).get("mode") or "always")
|
||||
|
||||
def edge_fn_name(self, edge: dict) -> str:
|
||||
"""每条边对应一个 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 ""
|
||||
|
||||
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 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 ""
|
||||
data = edge.get("data") or {}
|
||||
return str(
|
||||
data.get("transitionSpeech") or data.get("transition_speech") or ""
|
||||
)
|
||||
|
||||
sections = [header]
|
||||
if global_prompt:
|
||||
sections.append(f"[全局规则]\n{global_prompt}")
|
||||
def global_prompt(self) -> str:
|
||||
return str(self.settings.get("globalPrompt") or "").strip()
|
||||
|
||||
def inherits_global_config(self, node_id: str) -> bool:
|
||||
"""Return the Agent's explicit configuration scope, defaulting to global."""
|
||||
return bool(self.data(node_id).get("inheritGlobalConfig", True))
|
||||
|
||||
def agent_stage_config(self, node_id: str) -> AgentStageConfig:
|
||||
"""Resolve either Workflow defaults or one Agent's complete override."""
|
||||
data = self.data(node_id)
|
||||
inherits_global = self.inherits_global_config(node_id)
|
||||
source = self.settings if inherits_global else data
|
||||
llm_key = "defaultLlmResourceId" if inherits_global else "llmResourceId"
|
||||
asr_key = "defaultAsrResourceId" if inherits_global else "asrResourceId"
|
||||
tts_key = "defaultTtsResourceId" if inherits_global else "ttsResourceId"
|
||||
knowledge_base_id = str(source.get("knowledgeBaseId") or "")
|
||||
return AgentStageConfig(
|
||||
inherits_global=inherits_global,
|
||||
llm_resource_id=str(source.get(llm_key) or ""),
|
||||
asr_resource_id=str(source.get(asr_key) or ""),
|
||||
tts_resource_id=str(source.get(tts_key) or ""),
|
||||
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []),
|
||||
knowledge_base_id=knowledge_base_id or None,
|
||||
knowledge_mode=(
|
||||
str(source.get("knowledgeMode") or "automatic")
|
||||
if knowledge_base_id
|
||||
else "disabled"
|
||||
),
|
||||
knowledge_top_n=int(source.get("knowledgeTopN") or 5),
|
||||
knowledge_score_threshold=float(
|
||||
source.get("knowledgeScoreThreshold") or 0.0
|
||||
),
|
||||
)
|
||||
|
||||
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
||||
"""Build the Agent system prompt according to its inheritance setting."""
|
||||
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
|
||||
sections = [f"[当前阶段:{self.name(node_id)}]"]
|
||||
if self.inherits_global_config(node_id) and self.global_prompt():
|
||||
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
|
||||
if prompt:
|
||||
sections.append(f"[当前节点任务]\n{prompt}")
|
||||
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"}
|
||||
]
|
||||
|
||||
129
backend/services/workflow_router.py
Normal file
129
backend/services/workflow_router.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Pre-response LLM routing for Workflow Agent edges.
|
||||
|
||||
The router deliberately uses a separate, short completion. Its only output is
|
||||
a required function choice, so the current Agent cannot speak before the graph
|
||||
has decided whether the user turn belongs to another node.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
STAY_ON_CURRENT_AGENT = "workflow_stay_on_current_agent"
|
||||
MAX_ROUTING_HISTORY_ENTRIES = 20
|
||||
|
||||
|
||||
class WorkflowLLMRouter:
|
||||
"""Select one LLM edge before the conversational LLM is allowed to reply."""
|
||||
|
||||
def __init__(self, cfg: AssistantConfig):
|
||||
self._cfg = cfg
|
||||
|
||||
async def select_edge(
|
||||
self,
|
||||
*,
|
||||
node_name: str,
|
||||
node_prompt: str,
|
||||
edges: list[dict[str, Any]],
|
||||
history: list[dict[str, str]],
|
||||
variables: dict[str, Any],
|
||||
edge_name: Callable[[dict[str, Any]], str],
|
||||
edge_description: Callable[[dict[str, Any]], str],
|
||||
) -> str | None:
|
||||
"""Return an edge function name, STAY, or None when routing failed."""
|
||||
if not edges:
|
||||
return STAY_ON_CURRENT_AGENT
|
||||
|
||||
names = {edge_name(edge) for edge in edges}
|
||||
stay_name = STAY_ON_CURRENT_AGENT
|
||||
while stay_name in names:
|
||||
stay_name = f"_{stay_name}"
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": edge_name(edge),
|
||||
"description": edge_description(edge),
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
for edge in edges
|
||||
]
|
||||
tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": stay_name,
|
||||
"description": "所有转移条件都不满足,继续由当前 Agent 处理用户消息。",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
ordered_conditions = "\n".join(
|
||||
f"{index + 1}. {edge_description(edge)}"
|
||||
for index, edge in enumerate(edges)
|
||||
)
|
||||
router_prompt = (
|
||||
"你是工作流路由器,不是对话助手。收到一轮完整用户输入后,"
|
||||
"必须且只能调用一个提供的函数,禁止输出任何口头回复。\n"
|
||||
"按给出的顺序判断转移条件;选择第一个明确满足的转移函数。"
|
||||
"如果没有条件满足,调用留在当前 Agent 的函数。\n\n"
|
||||
f"当前节点:{node_name}\n"
|
||||
f"当前节点任务:{node_prompt or '未配置'}\n"
|
||||
f"转移条件:\n{ordered_conditions}"
|
||||
)
|
||||
recent_history = history[-MAX_ROUTING_HISTORY_ENTRIES:]
|
||||
routing_input = json.dumps(
|
||||
{
|
||||
"conversation": recent_history,
|
||||
"session_variables": variables,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
extra_body = self._cfg.llm_values.get("extraBody")
|
||||
request_extra = (
|
||||
{"extra_body": extra_body} if isinstance(extra_body, dict) else {}
|
||||
)
|
||||
client = AsyncOpenAI(
|
||||
api_key=self._cfg.llm_api_key,
|
||||
base_url=self._cfg.llm_base_url,
|
||||
timeout=15.0,
|
||||
)
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=self._cfg.model,
|
||||
messages=[
|
||||
{"role": "system", "content": router_prompt},
|
||||
{"role": "user", "content": routing_input},
|
||||
],
|
||||
tools=tools,
|
||||
tool_choice="required",
|
||||
temperature=0,
|
||||
**request_extra,
|
||||
)
|
||||
tool_calls = response.choices[0].message.tool_calls or []
|
||||
if not tool_calls:
|
||||
logger.warning("Workflow 路由 LLM 未返回函数调用,留在当前 Agent")
|
||||
return STAY_ON_CURRENT_AGENT
|
||||
selected = str(tool_calls[0].function.name or "")
|
||||
if selected == stay_name:
|
||||
return STAY_ON_CURRENT_AGENT
|
||||
if selected not in names:
|
||||
logger.warning(f"Workflow 路由 LLM 返回未知函数:{selected}")
|
||||
return STAY_ON_CURRENT_AGENT
|
||||
return selected
|
||||
except Exception as exc: # noqa: BLE001 - routing failure must not end the call
|
||||
logger.warning(f"Workflow LLM 边判断失败,留在当前 Agent:{exc}")
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
@@ -9,7 +9,10 @@ from pipecat.frames.frames import (
|
||||
LLMContextFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
LLMRunFrame,
|
||||
LLMTextFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
@@ -111,6 +114,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 +379,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")
|
||||
@@ -375,68 +391,315 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
||||
graph = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "startCall",
|
||||
"data": {"name": "开始", "prompt": "收集需求"},
|
||||
},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "endCall",
|
||||
"data": {"name": "结束", "prompt": "礼貌结束"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "finish",
|
||||
"source": "start",
|
||||
"target": "end",
|
||||
"data": {"condition": "需求已收集"},
|
||||
}
|
||||
],
|
||||
}
|
||||
brain = WorkflowBrain(graph)
|
||||
llm = FakeLLM()
|
||||
context = LLMContext(messages=[])
|
||||
async def test_nodes_without_outgoing_edges_remain_active(self):
|
||||
queued = []
|
||||
prompts = []
|
||||
visible_tools = []
|
||||
call_end = FakeCallEnd()
|
||||
|
||||
async def queue_frame(frame):
|
||||
queued.append(frame)
|
||||
|
||||
runtime = BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=FakeLLM(),
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=FakeCallEnd(),
|
||||
)
|
||||
|
||||
class FakeManager:
|
||||
def __init__(self, current_node=None):
|
||||
self.current_node = current_node
|
||||
|
||||
async def initialize(self, config):
|
||||
self.current_node = config["name"]
|
||||
|
||||
start_brain = WorkflowBrain(
|
||||
{
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [{"id": "start", "type": "start", "data": {}}],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
start_brain._runtime = runtime
|
||||
start_brain._manager = FakeManager()
|
||||
await start_brain.on_connected()
|
||||
self.assertEqual(start_brain._manager.current_node, "start")
|
||||
|
||||
agent_brain = WorkflowBrain(
|
||||
{
|
||||
"specVersion": 3,
|
||||
"settings": {"globalPrompt": "全局规则"},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {"prompt": "持续回答"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
agent_brain._runtime = runtime
|
||||
agent_brain._manager = FakeManager("agent")
|
||||
queued.clear()
|
||||
handled = await agent_brain.on_user_turn_end("请继续回答")
|
||||
self.assertTrue(handled)
|
||||
self.assertEqual(agent_brain._manager.current_node, "agent")
|
||||
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
||||
|
||||
handoff_brain = WorkflowBrain(
|
||||
{
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{
|
||||
"id": "handoff",
|
||||
"type": "handoff",
|
||||
"data": {"targetType": "human"},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
handoff_brain._runtime = runtime
|
||||
handoff_config = await handoff_brain._resolve_path("handoff")
|
||||
self.assertEqual(handoff_config["name"], "handoff")
|
||||
self.assertTrue(
|
||||
any(
|
||||
isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "handoff-requested"
|
||||
for frame in queued
|
||||
)
|
||||
)
|
||||
|
||||
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {
|
||||
"globalPrompt": "全局规则",
|
||||
"defaultLlmResourceId": "llm_global",
|
||||
"defaultAsrResourceId": "asr_global",
|
||||
"defaultTtsResourceId": "tts_global",
|
||||
"knowledgeBaseId": "kb_global",
|
||||
"knowledgeMode": "automatic",
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "start",
|
||||
"data": {"name": "Start"},
|
||||
},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {
|
||||
"name": "收集需求",
|
||||
"prompt": "服务 {{user_name}}",
|
||||
"contextPolicy": "fresh",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "end",
|
||||
"data": {"name": "End", "message": "感谢来电", "scope": "session"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
},
|
||||
{
|
||||
"id": "finish",
|
||||
"source": "agent",
|
||||
"target": "end",
|
||||
"data": {
|
||||
"mode": "llm",
|
||||
"priority": 10,
|
||||
"condition": "需求已收集",
|
||||
"transitionSpeech": "正在为你结束流程",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="workflow",
|
||||
graph=graph,
|
||||
dynamic_variable_definitions={
|
||||
"user_name": {"type": "string", "required": True}
|
||||
},
|
||||
),
|
||||
{"user_name": "王先生"},
|
||||
assistant_id="asst_workflow",
|
||||
)
|
||||
brain = WorkflowBrain(cfg)
|
||||
llm = FakeLLM()
|
||||
context = LLMContext(messages=[])
|
||||
queued = []
|
||||
service_switches = []
|
||||
knowledge_scopes = []
|
||||
call_end = FakeCallEnd()
|
||||
|
||||
class FakeWorker:
|
||||
def __init__(self):
|
||||
self.frames = []
|
||||
self.handlers = {}
|
||||
|
||||
def set_reached_downstream_filter(self, *_args):
|
||||
pass
|
||||
|
||||
def event_handler(self, name):
|
||||
def decorator(fn):
|
||||
self.handlers[name] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
async def queue_frame(self, frame):
|
||||
self.frames.append(frame)
|
||||
|
||||
async def queue_frames(self, frames):
|
||||
self.frames.extend(frames)
|
||||
|
||||
worker = FakeWorker()
|
||||
pair = SimpleNamespace(
|
||||
user=lambda: SimpleNamespace(_context=context),
|
||||
assistant=lambda: SimpleNamespace(has_function_calls_in_progress=False),
|
||||
)
|
||||
|
||||
async def queue_frame(frame):
|
||||
queued.append(frame)
|
||||
|
||||
async def switch_services(llm_id, asr_id, tts_id):
|
||||
service_switches.append((llm_id, asr_id, tts_id))
|
||||
|
||||
runtime = BrainRuntime(
|
||||
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,
|
||||
switch_services=switch_services,
|
||||
set_knowledge_scope=knowledge_scopes.append,
|
||||
)
|
||||
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")
|
||||
self.assertEqual(
|
||||
service_switches,
|
||||
[("llm_global", "asr_global", "tts_global")],
|
||||
)
|
||||
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_global")
|
||||
|
||||
brain._engine.data("agent").update(
|
||||
{
|
||||
"inheritGlobalConfig": False,
|
||||
"llmResourceId": "llm_agent",
|
||||
"asrResourceId": "asr_agent",
|
||||
"ttsResourceId": "tts_agent",
|
||||
"knowledgeBaseId": "kb_agent",
|
||||
"knowledgeMode": "on_demand",
|
||||
}
|
||||
)
|
||||
await brain._apply_agent_stage("agent")
|
||||
self.assertEqual(
|
||||
service_switches[-1],
|
||||
("llm_agent", "asr_agent", "tts_agent"),
|
||||
)
|
||||
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_agent")
|
||||
agent_config = brain._agent_config("agent")
|
||||
self.assertIn("王先生", agent_config["role_message"])
|
||||
self.assertIn("工作流路由已在用户一轮输入结束时完成", agent_config["role_message"])
|
||||
self.assertEqual(agent_config["task_messages"], [])
|
||||
self.assertFalse(agent_config["respond_immediately"])
|
||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||
self.assertEqual(
|
||||
agent_config["context_strategy"].strategy.value,
|
||||
"reset",
|
||||
)
|
||||
|
||||
brain._engine.data("agent")["entryMode"] = "generate"
|
||||
generate_config = brain._agent_config("agent")
|
||||
self.assertTrue(generate_config["respond_immediately"])
|
||||
worker.frames.clear()
|
||||
await brain._manager.set_node_from_config(generate_config)
|
||||
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||
|
||||
brain._engine.data("agent").update(
|
||||
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
|
||||
)
|
||||
fixed_config = brain._agent_config("agent")
|
||||
self.assertFalse(fixed_config["respond_immediately"])
|
||||
self.assertEqual(
|
||||
fixed_config["pre_actions"][0]["type"],
|
||||
"workflow_fixed_speech",
|
||||
)
|
||||
self.assertEqual(fixed_config["pre_actions"][0]["text"], "您好,王先生")
|
||||
self.assertEqual(
|
||||
fixed_config["task_messages"],
|
||||
[{"role": "assistant", "content": "您好,王先生"}],
|
||||
)
|
||||
worker.frames.clear()
|
||||
queued.clear()
|
||||
await brain._manager.set_node_from_config(fixed_config)
|
||||
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||
|
||||
self.assertFalse(
|
||||
any(
|
||||
function.name == "goto_finish"
|
||||
for function in brain._agent_config("agent")["functions"]
|
||||
)
|
||||
)
|
||||
await brain.on_assistant_text_end("old-turn", "需求已收集", False)
|
||||
self.assertEqual(brain._manager.current_node, "agent")
|
||||
|
||||
class FakeRouter:
|
||||
async def select_edge(self, **_kwargs):
|
||||
return "goto_finish"
|
||||
|
||||
brain._router = FakeRouter()
|
||||
handled = await brain.on_user_turn_end("我的需求已经说完了")
|
||||
self.assertTrue(handled)
|
||||
self.assertEqual(brain._manager.current_node, "end")
|
||||
self.assertIn("我的需求已经说完了", brain._store.values["system__conversation_history"])
|
||||
self.assertTrue(call_end.ending)
|
||||
self.assertTrue(call_end.armed)
|
||||
self.assertTrue(any(getattr(frame, "text", "") == "感谢来电" for frame in queued))
|
||||
assistant_transcripts = [
|
||||
frame.message.get("content")
|
||||
for frame in queued
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "transcript"
|
||||
and frame.message.get("role") == "assistant"
|
||||
]
|
||||
self.assertEqual(
|
||||
assistant_transcripts,
|
||||
["您好,王先生", "正在为你结束流程", "感谢来电"],
|
||||
)
|
||||
self.assertIn(
|
||||
"正在为你结束流程",
|
||||
brain._store.values["system__conversation_history"],
|
||||
)
|
||||
self.assertIn(
|
||||
"感谢来电",
|
||||
brain._store.values["system__conversation_history"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import unittest
|
||||
|
||||
from models import AssistantConfig
|
||||
from services.pipecat.pipeline import _knowledge_tool_description
|
||||
from pipecat.frames.frames import LLMContextFrame
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from services.pipecat.pipeline import (
|
||||
KNOWLEDGE_CONTEXT_MARKER,
|
||||
KnowledgeRetrievalProcessor,
|
||||
UserTurnRoutingProcessor,
|
||||
_knowledge_tool_description,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeToolDescriptionTest(unittest.TestCase):
|
||||
@@ -33,6 +41,57 @@ 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))
|
||||
|
||||
|
||||
class UserTurnRoutingProcessorTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_routes_each_user_message_once_before_response_run(self):
|
||||
class FakeBrain:
|
||||
def __init__(self):
|
||||
self.turns = []
|
||||
|
||||
async def on_user_turn_end(self, content):
|
||||
self.turns.append(content)
|
||||
return True
|
||||
|
||||
brain = FakeBrain()
|
||||
processor = UserTurnRoutingProcessor(brain)
|
||||
forwarded = []
|
||||
|
||||
async def push_frame(frame, direction):
|
||||
forwarded.append((frame, direction))
|
||||
|
||||
processor.push_frame = push_frame
|
||||
context = LLMContext(messages=[{"role": "user", "content": "我叫李白"}])
|
||||
frame = LLMContextFrame(context)
|
||||
|
||||
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
|
||||
self.assertEqual(brain.turns, ["我叫李白"])
|
||||
self.assertEqual(forwarded, [])
|
||||
|
||||
# A queued LLMRunFrame after the transition uses the same context. It
|
||||
# must reach the target Agent without invoking routing a second time.
|
||||
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
|
||||
self.assertEqual(brain.turns, ["我叫李白"])
|
||||
self.assertEqual(forwarded, [(frame, FrameDirection.DOWNSTREAM)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
75
backend/tests/test_workflow_router.py
Normal file
75
backend/tests/test_workflow_router.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from models import AssistantConfig
|
||||
from services.workflow_router import WorkflowLLMRouter
|
||||
|
||||
|
||||
class WorkflowLLMRouterTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_uses_required_tool_choice_without_developer_messages(self):
|
||||
requests = []
|
||||
|
||||
class FakeCompletions:
|
||||
async def create(self, **kwargs):
|
||||
requests.append(kwargs)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
tool_calls=[
|
||||
SimpleNamespace(
|
||||
function=SimpleNamespace(name="goto_age", arguments="{}")
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **_kwargs):
|
||||
self.chat = SimpleNamespace(completions=FakeCompletions())
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
cfg = AssistantConfig(
|
||||
type="workflow",
|
||||
model="deepseek-chat",
|
||||
llm_api_key="secret",
|
||||
llm_base_url="https://llm.test/v1",
|
||||
)
|
||||
router = WorkflowLLMRouter(cfg)
|
||||
edges = [
|
||||
{
|
||||
"id": "age",
|
||||
"data": {"condition": "用户已经回答姓名", "priority": 10},
|
||||
}
|
||||
]
|
||||
|
||||
with patch("services.workflow_router.AsyncOpenAI", FakeClient):
|
||||
selected = await router.select_edge(
|
||||
node_name="询问姓名",
|
||||
node_prompt="询问用户姓名",
|
||||
edges=edges,
|
||||
history=[{"role": "user", "message": "我叫李白"}],
|
||||
variables={"customer_type": "new"},
|
||||
edge_name=lambda _edge: "goto_age",
|
||||
edge_description=lambda _edge: "用户已经回答姓名",
|
||||
)
|
||||
|
||||
self.assertEqual(selected, "goto_age")
|
||||
self.assertEqual(requests[0]["tool_choice"], "required")
|
||||
self.assertEqual(
|
||||
[message["role"] for message in requests[0]["messages"]],
|
||||
["system", "user"],
|
||||
)
|
||||
self.assertNotIn("developer", str(requests[0]["messages"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
328
backend/tests/test_workflow_v3.py
Normal file
328
backend/tests/test_workflow_v3.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from models import AssistantConfig, RuntimeModelResource
|
||||
from services.pipecat.service_factory import config_with_resource
|
||||
from services.node_specs import graph_references, normalize_graph, validate_graph
|
||||
from services.runtime_variables import DynamicVariableStore, prepare_dynamic_config
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
def valid_graph():
|
||||
return {
|
||||
"specVersion": 3,
|
||||
"settings": {"globalPrompt": "服务 {{customer}}"},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {"name": "Start"}},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {"name": "Agent", "prompt": "处理订单", "contextPolicy": "fresh"},
|
||||
},
|
||||
{"id": "end", "type": "end", "data": {"name": "End", "scope": "flow"}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
},
|
||||
{
|
||||
"id": "paid",
|
||||
"source": "agent",
|
||||
"target": "end",
|
||||
"data": {
|
||||
"mode": "expression",
|
||||
"priority": 10,
|
||||
"expression": {
|
||||
"combinator": "and",
|
||||
"rules": [
|
||||
{"variable": "order_status", "operator": "eq", "value": "paid"}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class WorkflowGraphTests(unittest.TestCase):
|
||||
def test_agent_entry_mode_defaults_and_validation(self):
|
||||
graph = valid_graph()
|
||||
normalized = normalize_graph(graph)
|
||||
agent = next(node for node in normalized["nodes"] if node["type"] == "agent")
|
||||
self.assertEqual(agent["data"]["entryMode"], "wait_user")
|
||||
self.assertEqual(agent["data"]["entrySpeech"], "")
|
||||
self.assertTrue(agent["data"]["inheritGlobalConfig"])
|
||||
self.assertEqual(agent["data"]["contextPolicy"], "fresh")
|
||||
|
||||
agent["data"]["entryMode"] = "fixed_speech"
|
||||
self.assertTrue(
|
||||
any("固定进入语不能为空" in error for error in validate_graph(normalized))
|
||||
)
|
||||
agent["data"]["entrySpeech"] = "您好,{{customer}}"
|
||||
self.assertEqual(validate_graph(normalized), [])
|
||||
|
||||
def test_voice_resource_creates_isolated_runtime_config(self):
|
||||
base = AssistantConfig(type="workflow", asr="default", voice="default")
|
||||
asr = RuntimeModelResource(
|
||||
id="asr_1",
|
||||
capability="ASR",
|
||||
interface_type="openai-asr",
|
||||
values={"modelId": "sensevoice", "language": "zh", "apiUrl": "https://asr.test"},
|
||||
secrets={"apiKey": "secret"},
|
||||
)
|
||||
resolved = config_with_resource(base, asr)
|
||||
self.assertEqual(resolved.asr, "sensevoice")
|
||||
self.assertEqual(resolved.stt_api_key, "secret")
|
||||
self.assertEqual(base.asr, "default")
|
||||
|
||||
llm = RuntimeModelResource(
|
||||
id="llm_1",
|
||||
capability="LLM",
|
||||
interface_type="openai-llm",
|
||||
values={"modelId": "deepseek-chat", "apiUrl": "https://llm.test/v1"},
|
||||
secrets={"apiKey": "llm-secret"},
|
||||
)
|
||||
llm_resolved = config_with_resource(base, llm)
|
||||
self.assertEqual(llm_resolved.model, "deepseek-chat")
|
||||
self.assertEqual(llm_resolved.llm_api_key, "llm-secret")
|
||||
|
||||
def test_global_and_custom_agent_references_are_preserved(self):
|
||||
graph = valid_graph()
|
||||
graph["settings"].update(
|
||||
{
|
||||
"defaultLlmResourceId": "llm_global",
|
||||
"defaultAsrResourceId": "asr_global",
|
||||
"defaultTtsResourceId": "tts_global",
|
||||
"toolIds": ["tool_global"],
|
||||
"knowledgeBaseId": "kb_global",
|
||||
}
|
||||
)
|
||||
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
|
||||
agent["data"].update(
|
||||
{
|
||||
"inheritGlobalConfig": False,
|
||||
"llmResourceId": "llm_agent",
|
||||
"asrResourceId": "asr_agent",
|
||||
"ttsResourceId": "tts_agent",
|
||||
"toolIds": ["tool_agent"],
|
||||
"knowledgeBaseId": "kb_agent",
|
||||
}
|
||||
)
|
||||
|
||||
refs = graph_references(graph)
|
||||
self.assertEqual(
|
||||
refs["model_resources"],
|
||||
{
|
||||
"llm_global",
|
||||
"asr_global",
|
||||
"tts_global",
|
||||
"llm_agent",
|
||||
"asr_agent",
|
||||
"tts_agent",
|
||||
},
|
||||
)
|
||||
self.assertEqual(refs["tools"], {"tool_global", "tool_agent"})
|
||||
self.assertEqual(refs["knowledge_bases"], {"kb_global", "kb_agent"})
|
||||
|
||||
def test_existing_agent_override_disables_implicit_inheritance(self):
|
||||
graph = valid_graph()
|
||||
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
|
||||
agent["data"]["toolIds"] = ["legacy_tool"]
|
||||
normalized = normalize_graph(graph)
|
||||
normalized_agent = next(
|
||||
node for node in normalized["nodes"] if node["type"] == "agent"
|
||||
)
|
||||
self.assertFalse(normalized_agent["data"]["inheritGlobalConfig"])
|
||||
|
||||
def test_inherited_agent_ignores_stale_custom_references(self):
|
||||
graph = valid_graph()
|
||||
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
|
||||
agent["data"].update(
|
||||
{
|
||||
"inheritGlobalConfig": True,
|
||||
"llmResourceId": "stale_llm",
|
||||
"asrResourceId": "stale_asr",
|
||||
"ttsResourceId": "stale_tts",
|
||||
"toolIds": ["stale_tool"],
|
||||
"knowledgeBaseId": "stale_kb",
|
||||
}
|
||||
)
|
||||
|
||||
refs = graph_references(graph)
|
||||
|
||||
self.assertNotIn("stale_llm", refs["model_resources"])
|
||||
self.assertNotIn("stale_tool", refs["tools"])
|
||||
self.assertNotIn("stale_kb", refs["knowledge_bases"])
|
||||
|
||||
def test_agent_effective_config_inherits_then_switches_to_override(self):
|
||||
graph = valid_graph()
|
||||
graph["settings"].update(
|
||||
{
|
||||
"defaultLlmResourceId": "llm_global",
|
||||
"defaultAsrResourceId": "asr_global",
|
||||
"defaultTtsResourceId": "tts_global",
|
||||
"toolIds": ["tool_global"],
|
||||
"knowledgeBaseId": "kb_global",
|
||||
"knowledgeMode": "on_demand",
|
||||
"knowledgeTopN": 8,
|
||||
"knowledgeScoreThreshold": 0.4,
|
||||
}
|
||||
)
|
||||
engine = WorkflowEngine(graph)
|
||||
inherited = engine.agent_stage_config("agent")
|
||||
self.assertEqual(inherited.llm_resource_id, "llm_global")
|
||||
self.assertEqual(inherited.tool_ids, ("tool_global",))
|
||||
self.assertEqual(inherited.knowledge_mode, "on_demand")
|
||||
|
||||
engine.data("agent").update(
|
||||
{
|
||||
"inheritGlobalConfig": False,
|
||||
"llmResourceId": "llm_agent",
|
||||
"toolIds": ["tool_agent"],
|
||||
"knowledgeBaseId": "",
|
||||
}
|
||||
)
|
||||
custom = engine.agent_stage_config("agent")
|
||||
self.assertEqual(custom.llm_resource_id, "llm_agent")
|
||||
self.assertEqual(custom.tool_ids, ("tool_agent",))
|
||||
self.assertEqual(custom.knowledge_mode, "disabled")
|
||||
|
||||
def test_start_agent_and_handoff_may_have_no_outgoing_edge(self):
|
||||
terminal_graphs = [
|
||||
{
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [{"id": "start", "type": "start", "data": {}}],
|
||||
"edges": [],
|
||||
},
|
||||
{
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {"prompt": "持续处理用户问题"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{"id": "handoff", "type": "handoff", "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "handoff",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for graph in terminal_graphs:
|
||||
with self.subTest(node=graph["nodes"][-1]["type"]):
|
||||
self.assertEqual(validate_graph(graph), [])
|
||||
|
||||
action_without_exit = {
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{"id": "action", "type": "action", "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "action",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
}
|
||||
],
|
||||
}
|
||||
self.assertTrue(
|
||||
any(
|
||||
"action 的出边不能少于 1" in error
|
||||
for error in validate_graph(action_without_exit)
|
||||
)
|
||||
)
|
||||
|
||||
def test_v2_start_prompt_is_preserved_in_synthetic_agent(self):
|
||||
graph = normalize_graph(
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "s", "type": "startCall", "data": {"prompt": "询问需求"}},
|
||||
{"id": "e", "type": "endCall", "data": {"prompt": "再见"}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "done", "source": "s", "target": "e", "data": {"condition": "完成"}}
|
||||
],
|
||||
}
|
||||
)
|
||||
migrated = next(node for node in graph["nodes"] if node["type"] == "agent")
|
||||
self.assertEqual(migrated["data"]["prompt"], "询问需求")
|
||||
self.assertEqual(validate_graph(graph), [])
|
||||
|
||||
def test_rejects_automatic_cycle_and_duplicate_priorities(self):
|
||||
graph = valid_graph()
|
||||
graph["nodes"].insert(1, {"id": "action", "type": "action", "data": {}})
|
||||
graph["edges"] = [
|
||||
{"id": "a", "source": "start", "target": "action", "data": {"mode": "always", "priority": 0}},
|
||||
{"id": "b", "source": "action", "target": "start", "data": {"mode": "always", "priority": 0}},
|
||||
{"id": "c", "source": "agent", "target": "end", "data": {"mode": "always", "priority": 10}},
|
||||
]
|
||||
errors = validate_graph(graph)
|
||||
self.assertTrue(any("无等待循环" in error for error in errors))
|
||||
|
||||
def test_expression_routes_using_session_variables(self):
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="workflow",
|
||||
graph=valid_graph(),
|
||||
dynamic_variable_definitions={
|
||||
"customer": {"type": "string", "default": "王先生"},
|
||||
"order_status": {"type": "string", "default": "pending"},
|
||||
},
|
||||
),
|
||||
{},
|
||||
assistant_id="asst_test",
|
||||
)
|
||||
store = DynamicVariableStore.from_config(cfg)
|
||||
engine = WorkflowEngine(cfg.graph)
|
||||
self.assertIsNone(engine.deterministic_edge("agent", store, include_default=False))
|
||||
store.assign("order_status", "paid")
|
||||
self.assertEqual(
|
||||
engine.deterministic_edge("agent", store, include_default=False)["target"],
|
||||
"end",
|
||||
)
|
||||
self.assertIn("王先生", engine.prompt_for("agent", store))
|
||||
|
||||
inherited_prompt = engine.prompt_for("agent", store)
|
||||
self.assertIn("服务 王先生", inherited_prompt)
|
||||
self.assertIn("处理订单", inherited_prompt)
|
||||
|
||||
engine.data("agent")["inheritGlobalConfig"] = False
|
||||
custom_prompt = engine.prompt_for("agent", store)
|
||||
self.assertNotIn("服务 王先生", custom_prompt)
|
||||
self.assertIn("处理订单", custom_prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { KnowledgeRetrievalConfig } from "@/lib/api";
|
||||
|
||||
export const DEFAULT_KNOWLEDGE_RETRIEVAL_CONFIG: KnowledgeRetrievalConfig = {
|
||||
mode: "automatic",
|
||||
topN: 5,
|
||||
scoreThreshold: 0,
|
||||
};
|
||||
|
||||
export function KnowledgeRetrievalConfigDialog({
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
value: KnowledgeRetrievalConfig;
|
||||
onChange: (config: KnowledgeRetrievalConfig) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function openDialog() {
|
||||
setDraft(value);
|
||||
setError(null);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (draft.topN === 0 || draft.topN < -1 || !Number.isInteger(draft.topN)) {
|
||||
setError("Top N 必须为 -1 或大于 0 的整数");
|
||||
return;
|
||||
}
|
||||
if (draft.scoreThreshold < 0 || draft.scoreThreshold > 1) {
|
||||
setError("最低相关度必须在 0 到 1 之间");
|
||||
return;
|
||||
}
|
||||
onChange(draft);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={openDialog}
|
||||
aria-label="打开知识库高级配置"
|
||||
title={
|
||||
disabled
|
||||
? "请先选择知识库"
|
||||
: `${value.mode === "automatic" ? "自动检索" : "模型主动检索"} · Top N ${value.topN === -1 ? "不限" : value.topN} · 最低相关度 ${value.scoreThreshold}`
|
||||
}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>知识库高级配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
设置检索触发方式、返回数量和相关度过滤条件。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">检索方式</div>
|
||||
<Select
|
||||
value={draft.mode}
|
||||
onValueChange={(mode: "automatic" | "on_demand") =>
|
||||
setDraft({ ...draft, mode })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="automatic">自动检索</SelectItem>
|
||||
<SelectItem value="on_demand">模型主动检索</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{draft.mode === "automatic"
|
||||
? "每轮用户提问后自动检索,响应行为更稳定。"
|
||||
: "由大模型判断是否调用知识库,依赖模型的工具调用能力。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-foreground">
|
||||
最多返回片段数
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="-1"
|
||||
value={draft.topN}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, topN: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||
填写 -1 时保留所有达到阈值的结果。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-foreground">
|
||||
最低相关度
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={draft.scoreThreshold}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
scoreThreshold: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||
仅保留相关度达到该值的片段,范围 0–1。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={saveDraft}>
|
||||
保存配置
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/editor/section-card.tsx
Normal file
92
frontend/src/components/editor/section-card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Compact section chrome shared by assistant editors and workflow node panels.
|
||||
* Density matches the debug preview drawer (text-sm titles, tight padding).
|
||||
*/
|
||||
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SectionCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const hasHeader = Boolean(title);
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="sm"
|
||||
className={cn(
|
||||
"gap-3 rounded-2xl border border-hairline bg-card py-3.5 text-card-foreground shadow-sm ring-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasHeader && (
|
||||
<CardHeader className="gap-0 px-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{icon && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<CardTitle className="text-sm font-medium leading-none">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && <HelpHint text={description} />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={cn("px-4", hasHeader && "space-y-3")}>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpHint({ text }: { text: string }) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看说明"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
<HelpCircle size={13} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-72 text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
{text}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Save,
|
||||
Mic,
|
||||
Send,
|
||||
HelpCircle,
|
||||
Waypoints,
|
||||
AudioLines,
|
||||
Terminal,
|
||||
@@ -53,13 +52,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -94,12 +86,6 @@ import { PageHeader } from "@/components/ui/page-header";
|
||||
import { FilterPills } from "@/components/ui/filter-pills";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import { ListToolbar } from "@/components/ui/list-toolbar";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -131,6 +117,7 @@ import {
|
||||
WorkflowEditor,
|
||||
type WorkflowSettings,
|
||||
} from "@/components/workflow/WorkflowEditor";
|
||||
import { HelpHint, SectionCard } from "@/components/editor/section-card";
|
||||
import {
|
||||
defaultGraph,
|
||||
type WorkflowGraph,
|
||||
@@ -369,7 +356,7 @@ type AssistantTypeOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
/** 提示词、Dify、FastGPT 类型已落地,工作流暂时显示占位页 */
|
||||
/** 提示词、工作流、Dify、FastGPT 已落地;OpenCode 暂时显示即将上线 */
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
@@ -386,7 +373,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
|
||||
label: "使用工作流构建",
|
||||
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
|
||||
icon: <Workflow size={20} />,
|
||||
available: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
type: "Dify",
|
||||
@@ -407,7 +394,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
|
||||
label: "使用 OpenCode 构建",
|
||||
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
|
||||
icon: <Terminal size={20} />,
|
||||
available: true,
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -478,10 +465,26 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
defaultGraph(),
|
||||
);
|
||||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
||||
globalPrompt: defaultGraph().settings.globalPrompt,
|
||||
llm: defaultGraph().settings.defaultLlmResourceId,
|
||||
asr: defaultGraph().settings.defaultAsrResourceId,
|
||||
tts: defaultGraph().settings.defaultTtsResourceId,
|
||||
toolIds: defaultGraph().settings.toolIds,
|
||||
knowledgeBaseId: defaultGraph().settings.knowledgeBaseId,
|
||||
knowledgeRetrievalConfig: {
|
||||
mode: defaultGraph().settings.knowledgeMode,
|
||||
topN: defaultGraph().settings.knowledgeTopN,
|
||||
scoreThreshold: defaultGraph().settings.knowledgeScoreThreshold,
|
||||
},
|
||||
allowInterrupt: true,
|
||||
turnConfig: defaultTurnConfig(),
|
||||
});
|
||||
const [debugOpen, setDebugOpen] = useState(false);
|
||||
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
|
||||
useState<Record<string, DynamicVariableDefinition>>({});
|
||||
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
|
||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
||||
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(null);
|
||||
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(null);
|
||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
|
||||
|
||||
@@ -708,6 +711,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
name: workflowName,
|
||||
graph: workflowGraph,
|
||||
settings: workflowSettings,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -848,20 +852,41 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
? (assistant.graph as WorkflowGraph)
|
||||
: defaultGraph();
|
||||
const wfSettings: WorkflowSettings = {
|
||||
llm: assistant.modelResourceIds.LLM,
|
||||
asr: assistant.modelResourceIds.ASR,
|
||||
tts: assistant.modelResourceIds.TTS,
|
||||
llm:
|
||||
graph.settings?.defaultLlmResourceId ||
|
||||
assistant.modelResourceIds.LLM,
|
||||
asr: graph.settings?.defaultAsrResourceId || assistant.modelResourceIds.ASR,
|
||||
tts: graph.settings?.defaultTtsResourceId || assistant.modelResourceIds.TTS,
|
||||
toolIds: graph.settings?.toolIds ?? [],
|
||||
knowledgeBaseId:
|
||||
graph.settings?.knowledgeBaseId || assistant.knowledgeBaseId || "",
|
||||
knowledgeRetrievalConfig: {
|
||||
mode:
|
||||
graph.settings?.knowledgeMode ||
|
||||
assistant.knowledgeRetrievalConfig.mode,
|
||||
topN:
|
||||
graph.settings?.knowledgeTopN ??
|
||||
assistant.knowledgeRetrievalConfig.topN,
|
||||
scoreThreshold:
|
||||
graph.settings?.knowledgeScoreThreshold ??
|
||||
assistant.knowledgeRetrievalConfig.scoreThreshold,
|
||||
},
|
||||
globalPrompt: graph.settings?.globalPrompt ?? "",
|
||||
allowInterrupt: assistant.enableInterrupt,
|
||||
turnConfig: assistant.turnConfig,
|
||||
};
|
||||
setWorkflowName(assistant.name);
|
||||
setWorkflowGraph(graph);
|
||||
setWorkflowSettings(wfSettings);
|
||||
setWorkflowDynamicVariableDefinitions(
|
||||
assistant.dynamicVariableDefinitions ?? {},
|
||||
);
|
||||
setSavedSnapshot(
|
||||
JSON.stringify({
|
||||
name: assistant.name,
|
||||
graph,
|
||||
settings: wfSettings,
|
||||
dynamicVariableDefinitions: assistant.dynamicVariableDefinitions ?? {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -912,7 +937,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
|
||||
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
|
||||
},
|
||||
knowledgeBaseId: workflowSettings.knowledgeBaseId || null,
|
||||
knowledgeRetrievalConfig: workflowSettings.knowledgeRetrievalConfig,
|
||||
toolIds: workflowSettings.toolIds,
|
||||
graph: workflowGraph as unknown as Record<string, unknown>,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -932,6 +961,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
name: workflowName,
|
||||
graph: workflowGraph,
|
||||
settings: workflowSettings,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
})
|
||||
: null;
|
||||
const dirty =
|
||||
@@ -1377,7 +1407,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-1 max-w-[min(42vw,560px)] self-center text-right text-sm leading-5 text-destructive"
|
||||
>
|
||||
{saveError}
|
||||
</span>
|
||||
)}
|
||||
@@ -1385,7 +1419,12 @@ 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={() => {
|
||||
setWorkflowSettingsOpen(false);
|
||||
setWorkflowEditingNodeId(null);
|
||||
setWorkflowEditingEdgeId(null);
|
||||
setWorkflowDebugOpen(true);
|
||||
}}
|
||||
>
|
||||
<Bug size={16} />
|
||||
调试
|
||||
@@ -1411,43 +1450,51 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
onChange={setWorkflowGraph}
|
||||
settings={workflowSettings}
|
||||
onSettingsChange={setWorkflowSettings}
|
||||
onOpenDynamicVariables={() => setDynamicVariablesOpen(true)}
|
||||
editingNodeId={workflowEditingNodeId}
|
||||
onEditingNodeIdChange={setWorkflowEditingNodeId}
|
||||
editingEdgeId={workflowEditingEdgeId}
|
||||
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
|
||||
settingsOpen={workflowSettingsOpen}
|
||||
onSettingsOpenChange={setWorkflowSettingsOpen}
|
||||
debugOpen={workflowDebugOpen}
|
||||
onDebugOpenChange={(open) => {
|
||||
setWorkflowDebugOpen(open);
|
||||
if (!open) setActiveNodeId(null);
|
||||
}}
|
||||
debugPanel={
|
||||
<DebugDrawer
|
||||
overlay
|
||||
assistantId={editingId}
|
||||
onClose={() => {
|
||||
setWorkflowDebugOpen(false);
|
||||
setActiveNodeId(null);
|
||||
}}
|
||||
hasUnsavedChanges={dirty}
|
||||
onNodeActive={setActiveNodeId}
|
||||
dynamicVariablesEnabled
|
||||
dynamicVariableDefinitions={workflowDynamicVariableDefinitions}
|
||||
/>
|
||||
}
|
||||
activeNodeId={activeNodeId}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1488,10 +1535,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<SectionCard
|
||||
icon={<Boxes size={18} />}
|
||||
icon={<Boxes size={15} />}
|
||||
title="Dify 应用配置"
|
||||
description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
|
||||
>
|
||||
@@ -1505,7 +1552,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="语音配置"
|
||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
||||
>
|
||||
@@ -1526,7 +1573,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
description="设置实时视频对话时的交互体验"
|
||||
>
|
||||
@@ -1584,10 +1631,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<SectionCard
|
||||
icon={<Database size={18} />}
|
||||
icon={<Database size={15} />}
|
||||
title="FastGPT 应用配置"
|
||||
description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
|
||||
>
|
||||
@@ -1601,7 +1648,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="语音配置"
|
||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
||||
>
|
||||
@@ -1622,7 +1669,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
description="设置实时视频对话时的交互体验"
|
||||
>
|
||||
@@ -1684,10 +1731,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<SectionCard
|
||||
icon={<Terminal size={18} />}
|
||||
icon={<Terminal size={15} />}
|
||||
title="OpenCode 服务配置"
|
||||
description="从「模型资源」中选择 OpenCode 服务资源。"
|
||||
>
|
||||
@@ -1701,7 +1748,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={18} />}
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="提示词"
|
||||
description="描述助手的角色、能力和回答要求"
|
||||
>
|
||||
@@ -1714,7 +1761,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="模型与语音配置"
|
||||
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
|
||||
>
|
||||
@@ -1759,7 +1806,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
description="设置实时视频对话时的交互体验"
|
||||
>
|
||||
@@ -1823,10 +1870,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<SectionCard>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -1838,25 +1885,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
|
||||
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
||||
form.runtimeMode === "pipeline"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<Waypoints size={18} />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<Waypoints size={15} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-foreground">Pipeline 模式</span>
|
||||
<span className="text-sm font-medium text-foreground">Pipeline 模式</span>
|
||||
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
|
||||
</div>
|
||||
</div>
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={14} />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1873,25 +1920,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
|
||||
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
||||
form.runtimeMode === "realtime"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<AudioLines size={18} />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<AudioLines size={15} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-foreground">Realtime 模式</span>
|
||||
<span className="text-sm font-medium text-foreground">Realtime 模式</span>
|
||||
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
|
||||
</div>
|
||||
</div>
|
||||
{form.runtimeMode === "realtime" && (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={14} />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1900,7 +1947,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={18} />}
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="提示词"
|
||||
description="描述助手的角色、能力和回答要求"
|
||||
>
|
||||
@@ -1918,7 +1965,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="模型配置"
|
||||
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
||||
>
|
||||
@@ -1963,7 +2010,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
icon={<Brain size={15} />}
|
||||
title="模型配置"
|
||||
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
||||
>
|
||||
@@ -1978,7 +2025,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
icon={<Bot size={18} />}
|
||||
icon={<Bot size={15} />}
|
||||
title="开场白"
|
||||
description="助手与用户首次对话时的开场语"
|
||||
>
|
||||
@@ -1995,7 +2042,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<SectionCard
|
||||
icon={<Database size={18} />}
|
||||
icon={<Database size={15} />}
|
||||
title="知识库配置"
|
||||
description="选择助手回答时可检索的业务知识来源"
|
||||
>
|
||||
@@ -2021,7 +2068,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
icon={<Wrench size={18} />}
|
||||
icon={<Wrench size={15} />}
|
||||
title="工具"
|
||||
description="配置该提示词助手可以调用的工具"
|
||||
>
|
||||
@@ -2033,7 +2080,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
description="设置实时视频对话时的交互体验"
|
||||
>
|
||||
@@ -2132,7 +2179,8 @@ function SegmentedIconButton({
|
||||
|
||||
function DebugDrawer({
|
||||
assistantId,
|
||||
asSheet = false,
|
||||
overlay = false,
|
||||
onClose,
|
||||
hasUnsavedChanges = false,
|
||||
onNodeActive,
|
||||
vision = false,
|
||||
@@ -2140,7 +2188,8 @@ function DebugDrawer({
|
||||
dynamicVariableDefinitions = {},
|
||||
}: {
|
||||
assistantId: string | null;
|
||||
asSheet?: boolean;
|
||||
overlay?: boolean;
|
||||
onClose?: () => void;
|
||||
hasUnsavedChanges?: boolean;
|
||||
onNodeActive?: (nodeId: string | null) => void;
|
||||
vision?: boolean;
|
||||
@@ -2179,14 +2228,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>
|
||||
@@ -3344,29 +3404,6 @@ function EditableTitle({
|
||||
);
|
||||
}
|
||||
|
||||
function HelpHint({ text }: { text: string }) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看说明"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-72 text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
{text}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicVariableEditorHint({
|
||||
count,
|
||||
onOpen,
|
||||
@@ -3624,45 +3661,6 @@ function DynamicVariablesDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const hasHeader = Boolean(title);
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-hairline bg-card text-card-foreground shadow-sm">
|
||||
{hasHeader && (
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CardTitle className="text-base font-medium">{title}</CardTitle>
|
||||
{description && <HelpHint text={description} />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardContent className={hasHeader ? "space-y-4" : undefined}>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TextAreaField({
|
||||
label,
|
||||
value,
|
||||
@@ -3679,7 +3677,7 @@ function TextAreaField({
|
||||
return (
|
||||
<label className="block">
|
||||
{label && (
|
||||
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
|
||||
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
|
||||
)}
|
||||
<Textarea
|
||||
value={value}
|
||||
@@ -3688,7 +3686,7 @@ function TextAreaField({
|
||||
rows={rows}
|
||||
// Override ui/textarea's field-sizing-content so `rows` sets a real height
|
||||
// instead of collapsing to min-h-16 when the value is short.
|
||||
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -3714,7 +3712,7 @@ function ResourceSelectField({
|
||||
return (
|
||||
<div className="block">
|
||||
{label && (
|
||||
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
|
||||
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
@@ -4023,18 +4021,18 @@ function ToggleRow({
|
||||
<div
|
||||
className={[
|
||||
"flex items-center justify-between border border-hairline bg-canvas-soft",
|
||||
hasIcon ? "rounded-2xl p-5" : "rounded-xl p-4",
|
||||
hasIcon ? "rounded-xl p-3.5" : "rounded-xl px-3.5 py-3",
|
||||
].join(" ")}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={[
|
||||
"flex items-center font-medium text-foreground",
|
||||
hasIcon ? "gap-3" : "gap-1.5",
|
||||
"flex items-center text-sm font-medium text-foreground",
|
||||
hasIcon ? "gap-2.5" : "gap-1.5",
|
||||
].join(" ")}
|
||||
>
|
||||
{icon && (
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
@@ -4044,7 +4042,7 @@ function ToggleRow({
|
||||
</span>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { HelpCircle, Settings2 } from "lucide-react";
|
||||
import { Settings2 } from "lucide-react";
|
||||
|
||||
import { HelpHint } from "@/components/editor/section-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -13,11 +14,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -59,7 +55,7 @@ export function TurnConfigEditor({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-hairline bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
允许用户打断
|
||||
@@ -72,7 +68,7 @@ export function TurnConfigEditor({
|
||||
aria-label="打开允许用户打断高级配置"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
<Settings2 size={13} />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
||||
@@ -161,29 +157,6 @@ function ConfigSection({ title, children }: { title: string; children: ReactNode
|
||||
);
|
||||
}
|
||||
|
||||
function HelpHint({ text }: { text: string }) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看允许用户打断说明"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-72 text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
{text}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
|
||||
@@ -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,30 @@ 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 entryModeLabel = {
|
||||
wait_user: "等待用户",
|
||||
generate: "立即回复",
|
||||
fixed_speech: "固定进入语",
|
||||
}[nodeData.entryMode ?? "wait_user"];
|
||||
const inheritsGlobal = nodeData.inheritGlobalConfig !== false;
|
||||
const meta = type === "agent"
|
||||
? inheritsGlobal
|
||||
? [entryModeLabel, "继承全局配置"]
|
||||
: [
|
||||
entryModeLabel,
|
||||
"自定义配置",
|
||||
nodeData.llmResourceId ? "独立 LLM" : null,
|
||||
`${nodeData.toolIds?.length ?? 0} 工具`,
|
||||
nodeData.knowledgeBaseId ? "知识库" : null,
|
||||
nodeData.asrResourceId ? "独立 ASR" : null,
|
||||
nodeData.ttsResourceId ? "独立 TTS" : null,
|
||||
].filter(Boolean)
|
||||
: type === "action" && nodeData.toolId
|
||||
? ["确定性工具"]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -46,7 +67,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 +107,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
{type !== "startCall" && (
|
||||
{type !== "start" && (
|
||||
<button
|
||||
type="button"
|
||||
title="删除节点"
|
||||
@@ -129,11 +150,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 +173,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,67 @@
|
||||
/**
|
||||
* 工作流节点的前端类型与运行期规格。
|
||||
*
|
||||
* 节点「目录」(有哪些类型、各自的字段与约束)由后端 /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 AgentEntryMode = "wait_user" | "generate" | "fixed_speech";
|
||||
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;
|
||||
inheritGlobalConfig?: boolean;
|
||||
entryMode?: AgentEntryMode;
|
||||
entrySpeech?: string;
|
||||
toolIds?: string[];
|
||||
knowledgeBaseId?: string;
|
||||
knowledgeMode?: KnowledgeMode;
|
||||
knowledgeTopN?: number;
|
||||
knowledgeScoreThreshold?: number;
|
||||
llmResourceId?: string;
|
||||
asrResourceId?: string;
|
||||
ttsResourceId?: string;
|
||||
toolId?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
resultAssignments?: Record<string, string>;
|
||||
targetType?: "ai" | "human" | "queue" | "phone";
|
||||
target?: string;
|
||||
message?: string;
|
||||
scope?: "flow" | "session";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
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 +70,6 @@ export type FieldSpec = {
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
/** 解析后的运行期节点规格(DTO + 解析出的 React 图标 + 派生句柄) */
|
||||
export type RuntimeNodeSpec = {
|
||||
type: string;
|
||||
displayName: string;
|
||||
@@ -47,9 +77,7 @@ export type RuntimeNodeSpec = {
|
||||
icon: LucideIcon;
|
||||
accent: string;
|
||||
addable: boolean;
|
||||
/** 入边句柄(开始节点没有) */
|
||||
hasTarget: boolean;
|
||||
/** 出边句柄(结束节点没有) */
|
||||
hasSource: boolean;
|
||||
constraints: {
|
||||
minIncoming?: number;
|
||||
@@ -62,7 +90,6 @@ export type RuntimeNodeSpec = {
|
||||
fields: FieldSpec[];
|
||||
};
|
||||
|
||||
/** 渐变 token → CSS 变量名(图标底色用),未知配色回落到 sky */
|
||||
export const ACCENT_VAR: Record<string, string> = {
|
||||
mint: "--gradient-mint",
|
||||
sky: "--gradient-sky",
|
||||
@@ -75,13 +102,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 +118,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 +131,18 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
|
||||
export type NodeSpecMap = Record<string, RuntimeNodeSpec>;
|
||||
|
||||
export type WorkflowGraph = {
|
||||
specVersion: 3;
|
||||
settings: {
|
||||
globalPrompt: string;
|
||||
defaultLlmResourceId: string;
|
||||
defaultAsrResourceId: string;
|
||||
defaultTtsResourceId: string;
|
||||
toolIds: string[];
|
||||
knowledgeBaseId: string;
|
||||
knowledgeMode: "automatic" | "on_demand";
|
||||
knowledgeTopN: number;
|
||||
knowledgeScoreThreshold: number;
|
||||
};
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
type: WorkflowNodeType;
|
||||
@@ -116,62 +153,77 @@ 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:
|
||||
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
||||
defaultLlmResourceId: "",
|
||||
defaultAsrResourceId: "",
|
||||
defaultTtsResourceId: "",
|
||||
toolIds: [],
|
||||
knowledgeBaseId: "",
|
||||
knowledgeMode: "automatic",
|
||||
knowledgeTopN: 5,
|
||||
knowledgeScoreThreshold: 0,
|
||||
},
|
||||
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",
|
||||
inheritGlobalConfig: true,
|
||||
entryMode: "wait_user",
|
||||
entrySpeech: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
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