Merge branch 'feature/workflow'
This commit is contained in:
@@ -27,6 +27,21 @@ class RuntimeTool(BaseModel):
|
|||||||
secrets: dict = Field(default_factory=dict)
|
secrets: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeModelResource(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str = ""
|
||||||
|
capability: str
|
||||||
|
interface_type: str
|
||||||
|
values: dict = Field(default_factory=dict)
|
||||||
|
secrets: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeKnowledgeBase(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str = ""
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
class AssistantConfig(BaseModel):
|
class AssistantConfig(BaseModel):
|
||||||
"""运行时配置:前端可见部分(name/prompt/...) + 服务端注入部分(*_api_key/*_base_url)。"""
|
"""运行时配置:前端可见部分(name/prompt/...) + 服务端注入部分(*_api_key/*_base_url)。"""
|
||||||
|
|
||||||
@@ -93,6 +108,8 @@ class AssistantConfig(BaseModel):
|
|||||||
|
|
||||||
# workflow 类型:节点图(nodes/edges)。非 workflow 为空,引擎据此决定是否启用。
|
# workflow 类型:节点图(nodes/edges)。非 workflow 为空,引擎据此决定是否启用。
|
||||||
graph: dict = {}
|
graph: dict = {}
|
||||||
|
workflow_model_resources: dict[str, RuntimeModelResource] = Field(default_factory=dict)
|
||||||
|
workflow_knowledge_bases: dict[str, RuntimeKnowledgeBase] = Field(default_factory=dict)
|
||||||
|
|
||||||
# 外部托管类型(fastgpt/dify/opencode)的连接信息:context/KB/tools 由对方服务端接管。
|
# 外部托管类型(fastgpt/dify/opencode)的连接信息:context/KB/tools 由对方服务端接管。
|
||||||
dify_api_url: str = ""
|
dify_api_url: str = ""
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
||||||
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
||||||
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
|
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
|
||||||
pipecat-ai[webrtc,websocket,silero,openai]==1.4.0
|
pipecat-ai[webrtc,websocket,silero,openai]==1.5.0
|
||||||
Pillow>=11.1.0,<13
|
Pillow>=11.1.0,<13
|
||||||
|
|
||||||
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)
|
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from schemas import AssistantOut, AssistantUpsert
|
from schemas import AssistantOut, AssistantUpsert
|
||||||
from services.auth import require_admin
|
from services.auth import require_admin
|
||||||
from services.masking import mask, resolve_incoming_key
|
from services.masking import mask, resolve_incoming_key
|
||||||
from services.node_specs import validate_graph
|
from services.node_specs import graph_references, normalize_graph, validate_graph
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -31,9 +31,54 @@ def _validate_workflow(body: AssistantUpsert) -> None:
|
|||||||
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
|
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
|
||||||
if body.type != "workflow":
|
if body.type != "workflow":
|
||||||
return
|
return
|
||||||
errors = validate_graph(body.graph or {})
|
body.graph = normalize_graph(body.graph or {})
|
||||||
|
errors = validate_graph(body.graph)
|
||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
|
raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
|
||||||
|
refs = graph_references(body.graph)
|
||||||
|
body.tool_ids = list(dict.fromkeys([*body.tool_ids, *sorted(refs["tools"])]))
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_workflow_references(
|
||||||
|
session: AsyncSession, body: AssistantUpsert
|
||||||
|
) -> None:
|
||||||
|
if body.type != "workflow" or not body.graph.get("nodes"):
|
||||||
|
return
|
||||||
|
graph = body.graph
|
||||||
|
settings = graph.get("settings") or {}
|
||||||
|
resource_expectations: dict[str, str] = {}
|
||||||
|
for key, capability in (
|
||||||
|
("defaultLlmResourceId", "LLM"),
|
||||||
|
("defaultAsrResourceId", "ASR"),
|
||||||
|
("defaultTtsResourceId", "TTS"),
|
||||||
|
):
|
||||||
|
if settings.get(key):
|
||||||
|
resource_expectations[str(settings[key])] = capability
|
||||||
|
knowledge_ids: set[str] = (
|
||||||
|
{str(settings["knowledgeBaseId"])}
|
||||||
|
if settings.get("knowledgeBaseId")
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
for node in graph.get("nodes") or []:
|
||||||
|
data = node.get("data") or {}
|
||||||
|
if node.get("type") == "agent" and data.get("inheritGlobalConfig", True):
|
||||||
|
continue
|
||||||
|
if data.get("llmResourceId"):
|
||||||
|
resource_expectations[str(data["llmResourceId"])] = "LLM"
|
||||||
|
if data.get("asrResourceId"):
|
||||||
|
resource_expectations[str(data["asrResourceId"])] = "ASR"
|
||||||
|
if data.get("ttsResourceId"):
|
||||||
|
resource_expectations[str(data["ttsResourceId"])] = "TTS"
|
||||||
|
if data.get("knowledgeBaseId"):
|
||||||
|
knowledge_ids.add(str(data["knowledgeBaseId"]))
|
||||||
|
for resource_id, capability in resource_expectations.items():
|
||||||
|
resource = await session.get(ModelResource, resource_id)
|
||||||
|
if not resource or not resource.enabled or resource.capability != capability:
|
||||||
|
raise HTTPException(400, f"Workflow 引用了无效的 {capability} 资源:{resource_id}")
|
||||||
|
for knowledge_id in knowledge_ids:
|
||||||
|
knowledge = await session.get(KnowledgeBase, knowledge_id)
|
||||||
|
if not knowledge or knowledge.status != "active":
|
||||||
|
raise HTTPException(400, f"Workflow 引用了无效知识库:{knowledge_id}")
|
||||||
|
|
||||||
|
|
||||||
async def _validate_vision_model(
|
async def _validate_vision_model(
|
||||||
@@ -101,7 +146,11 @@ async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, s
|
|||||||
async def _sync_tool_bindings(
|
async def _sync_tool_bindings(
|
||||||
session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str]
|
session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
requested = list(dict.fromkeys(tool_ids)) if assistant_type == "prompt" else []
|
requested = (
|
||||||
|
list(dict.fromkeys(tool_ids))
|
||||||
|
if assistant_type in {"prompt", "workflow"}
|
||||||
|
else []
|
||||||
|
)
|
||||||
if requested:
|
if requested:
|
||||||
tools = (
|
tools = (
|
||||||
await session.execute(select(Tool).where(Tool.id.in_(requested)))
|
await session.execute(select(Tool).where(Tool.id.in_(requested)))
|
||||||
@@ -158,7 +207,11 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
|||||||
api_url=assistant.api_url,
|
api_url=assistant.api_url,
|
||||||
api_key=mask(assistant.api_key),
|
api_key=mask(assistant.api_key),
|
||||||
app_id=assistant.app_id,
|
app_id=assistant.app_id,
|
||||||
graph=assistant.graph or {},
|
graph=(
|
||||||
|
normalize_graph(assistant.graph or {})
|
||||||
|
if assistant.type == "workflow"
|
||||||
|
else {}
|
||||||
|
),
|
||||||
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
|
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,6 +229,7 @@ async def create_assistant(
|
|||||||
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
|
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
|
||||||
):
|
):
|
||||||
_validate_workflow(body)
|
_validate_workflow(body)
|
||||||
|
await _validate_workflow_references(session, body)
|
||||||
await _validate_vision_model(session, body)
|
await _validate_vision_model(session, body)
|
||||||
await _validate_knowledge_base(session, body)
|
await _validate_knowledge_base(session, body)
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
@@ -248,6 +302,7 @@ async def update_assistant(
|
|||||||
if not assistant:
|
if not assistant:
|
||||||
raise HTTPException(404, "助手不存在")
|
raise HTTPException(404, "助手不存在")
|
||||||
_validate_workflow(body)
|
_validate_workflow(body)
|
||||||
|
await _validate_workflow_references(session, body)
|
||||||
await _validate_vision_model(session, body)
|
await _validate_vision_model(session, body)
|
||||||
await _validate_knowledge_base(session, body)
|
await _validate_knowledge_base(session, body)
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from schemas import (
|
|||||||
)
|
)
|
||||||
from services.auth import require_admin
|
from services.auth import require_admin
|
||||||
from services.knowledge import create_document, delete_storage_object, process_document, search
|
from services.knowledge import create_document, delete_storage_object, process_document, search
|
||||||
|
from services.node_specs import graph_references
|
||||||
import settings
|
import settings
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -123,6 +124,14 @@ async def delete_knowledge_base(
|
|||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if referenced:
|
if referenced:
|
||||||
raise HTTPException(409, "知识库正被助手引用,无法删除")
|
raise HTTPException(409, "知识库正被助手引用,无法删除")
|
||||||
|
workflows = (
|
||||||
|
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
|
||||||
|
).scalars().all()
|
||||||
|
if any(
|
||||||
|
kb_id in graph_references(assistant.graph or {})["knowledge_bases"]
|
||||||
|
for assistant in workflows
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "知识库正被 Workflow 节点引用,无法删除")
|
||||||
documents = (await session.execute(
|
documents = (await session.execute(
|
||||||
select(KnowledgeDocument).where(KnowledgeDocument.knowledge_base_id == kb_id)
|
select(KnowledgeDocument).where(KnowledgeDocument.knowledge_base_id == kb_id)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from db.models import (
|
from db.models import (
|
||||||
|
Assistant,
|
||||||
AssistantModelBinding,
|
AssistantModelBinding,
|
||||||
InterfaceDefinition,
|
InterfaceDefinition,
|
||||||
KnowledgeBase,
|
KnowledgeBase,
|
||||||
@@ -20,6 +21,7 @@ from services.auth import require_admin
|
|||||||
from services.interface_catalog import validate_fields
|
from services.interface_catalog import validate_fields
|
||||||
from services.masking import mask_secrets, merge_secrets
|
from services.masking import mask_secrets, merge_secrets
|
||||||
from services.model_resource_tester import test_model_resource
|
from services.model_resource_tester import test_model_resource
|
||||||
|
from services.node_specs import graph_references
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -102,6 +104,14 @@ async def _clear_incompatible_references(
|
|||||||
) -> None:
|
) -> None:
|
||||||
if capability == resource.capability:
|
if capability == resource.capability:
|
||||||
return
|
return
|
||||||
|
workflows = (
|
||||||
|
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
|
||||||
|
).scalars().all()
|
||||||
|
if any(
|
||||||
|
resource.id in graph_references(assistant.graph or {})["model_resources"]
|
||||||
|
for assistant in workflows
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "模型资源正被 Workflow 节点引用,不能修改能力类型")
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(AssistantModelBinding).where(
|
delete(AssistantModelBinding).where(
|
||||||
AssistantModelBinding.model_resource_id == resource.id
|
AssistantModelBinding.model_resource_id == resource.id
|
||||||
@@ -263,6 +273,14 @@ async def delete_model_resource(
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if in_use:
|
if in_use:
|
||||||
raise HTTPException(409, "该模型资源仍被助手引用")
|
raise HTTPException(409, "该模型资源仍被助手引用")
|
||||||
|
workflows = (
|
||||||
|
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
|
||||||
|
).scalars().all()
|
||||||
|
if any(
|
||||||
|
resource_id in graph_references(assistant.graph or {})["model_resources"]
|
||||||
|
for assistant in workflows
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "该模型资源仍被 Workflow 节点引用")
|
||||||
await session.delete(row)
|
await session.delete(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class AssistantUpsert(CamelModel):
|
|||||||
setattr(self, field, "")
|
setattr(self, field, "")
|
||||||
if "graph" not in allowed:
|
if "graph" not in allowed:
|
||||||
self.graph = {}
|
self.graph = {}
|
||||||
if self.type != "prompt":
|
if self.type not in {"prompt", "workflow"}:
|
||||||
self.tool_ids = []
|
self.tool_ids = []
|
||||||
self.dynamic_variable_definitions = {}
|
self.dynamic_variable_definitions = {}
|
||||||
# 外部托管大脑只能 cascade,拦住不兼容的 realtime
|
# 外部托管大脑只能 cascade,拦住不兼容的 realtime
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ def clear_auth_cookie(response: Response) -> None:
|
|||||||
|
|
||||||
async def require_admin(request: Request) -> AdminUser:
|
async def require_admin(request: Request) -> AdminUser:
|
||||||
user = verify_admin_token(request.cookies.get(settings.AUTH_COOKIE_NAME))
|
user = verify_admin_token(request.cookies.get(settings.AUTH_COOKIE_NAME))
|
||||||
|
if not user:
|
||||||
|
authorization = request.headers.get("authorization", "")
|
||||||
|
scheme, _, encoded_credentials = authorization.partition(" ")
|
||||||
|
if scheme.lower() == "basic" and encoded_credentials:
|
||||||
|
try:
|
||||||
|
credentials = base64.b64decode(encoded_credentials).decode("utf-8")
|
||||||
|
username, separator, password = credentials.partition(":")
|
||||||
|
if separator:
|
||||||
|
user = authenticate_admin(username, password)
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
user = None
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ without coupling brains to Pipecat internals more than necessary.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Protocol, runtime_checkable
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
from models import AssistantConfig
|
from models import AssistantConfig
|
||||||
@@ -52,6 +52,15 @@ class BrainRuntime:
|
|||||||
set_system_prompt: Callable[[str], None]
|
set_system_prompt: Callable[[str], None]
|
||||||
set_tools: Callable[[list[FunctionSchema] | None], None]
|
set_tools: Callable[[list[FunctionSchema] | None], None]
|
||||||
call_end: CallEndPort
|
call_end: CallEndPort
|
||||||
|
worker: Any = None
|
||||||
|
context_aggregator: Any = None
|
||||||
|
transport: Any = None
|
||||||
|
switch_services: (
|
||||||
|
Callable[[str | None, str | None, str | None], Awaitable[None]] | None
|
||||||
|
) = None
|
||||||
|
set_knowledge_scope: Callable[[dict[str, Any]], None] | None = None
|
||||||
|
set_input_enabled: Callable[[bool], None] | None = None
|
||||||
|
flow_global_functions: list[Any] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class BaseBrain:
|
class BaseBrain:
|
||||||
@@ -77,6 +86,15 @@ class BaseBrain:
|
|||||||
def record_user_message(self, content: str) -> None:
|
def record_user_message(self, content: str) -> None:
|
||||||
"""Observe a committed user message for brain-owned routing state."""
|
"""Observe a committed user message for brain-owned routing state."""
|
||||||
|
|
||||||
|
async def on_user_turn_end(self, content: str) -> bool:
|
||||||
|
"""Handle a complete user turn before the conversational LLM runs.
|
||||||
|
|
||||||
|
Return True when the brain scheduled the next action itself and the
|
||||||
|
in-flight context frame must not reach the previous Agent's LLM.
|
||||||
|
"""
|
||||||
|
self.record_user_message(content)
|
||||||
|
return False
|
||||||
|
|
||||||
async def on_assistant_text_start(self, turn_id: str) -> None:
|
async def on_assistant_text_start(self, turn_id: str) -> None:
|
||||||
"""Observe the start of a generated assistant turn."""
|
"""Observe the start of a generated assistant turn."""
|
||||||
|
|
||||||
@@ -107,6 +125,8 @@ class Brain(Protocol):
|
|||||||
|
|
||||||
def record_user_message(self, content: str) -> None: ...
|
def record_user_message(self, content: str) -> None: ...
|
||||||
|
|
||||||
|
async def on_user_turn_end(self, content: str) -> bool: ...
|
||||||
|
|
||||||
async def on_assistant_text_start(self, turn_id: str) -> None: ...
|
async def on_assistant_text_start(self, turn_id: str) -> None: ...
|
||||||
|
|
||||||
async def on_assistant_text_end(
|
async def on_assistant_text_end(
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
from urllib.parse import quote
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
|
||||||
from models import AssistantConfig
|
from models import AssistantConfig
|
||||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||||
@@ -20,10 +17,9 @@ from pipecat.utils.time import time_now_iso8601
|
|||||||
|
|
||||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||||
from services.runtime_variables import (
|
from services.runtime_variables import (
|
||||||
DynamicVariableError,
|
|
||||||
DynamicVariableStore,
|
DynamicVariableStore,
|
||||||
value_at_path,
|
|
||||||
)
|
)
|
||||||
|
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||||
|
|
||||||
|
|
||||||
class PromptBrain(BaseBrain):
|
class PromptBrain(BaseBrain):
|
||||||
@@ -37,6 +33,7 @@ class PromptBrain(BaseBrain):
|
|||||||
self._cfg = cfg
|
self._cfg = cfg
|
||||||
self._dynamic_enabled = True
|
self._dynamic_enabled = True
|
||||||
self._store = DynamicVariableStore.from_config(cfg)
|
self._store = DynamicVariableStore.from_config(cfg)
|
||||||
|
self._tools = ToolExecutor(self._store)
|
||||||
self._runtime: BrainRuntime | None = None
|
self._runtime: BrainRuntime | None = None
|
||||||
|
|
||||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||||
@@ -85,120 +82,16 @@ class PromptBrain(BaseBrain):
|
|||||||
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
|
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
|
||||||
|
|
||||||
def _make_http_tool(self, tool, runtime: BrainRuntime):
|
def _make_http_tool(self, tool, runtime: BrainRuntime):
|
||||||
config = (tool.definition or {}).get("config") or {}
|
properties, required = self._tools.schema_parts(tool)
|
||||||
parameters = list(config.get("parameters") or [])
|
self._tools.register_secrets(tool)
|
||||||
properties = {
|
|
||||||
str(parameter.get("name")): {
|
|
||||||
"type": str(parameter.get("type") or "string"),
|
|
||||||
"description": str(parameter.get("description") or ""),
|
|
||||||
}
|
|
||||||
for parameter in parameters
|
|
||||||
if parameter.get("name")
|
|
||||||
}
|
|
||||||
required = [
|
|
||||||
str(parameter["name"])
|
|
||||||
for parameter in parameters
|
|
||||||
if parameter.get("name") and parameter.get("required", True)
|
|
||||||
]
|
|
||||||
|
|
||||||
tool_secrets = tool.secrets or {}
|
|
||||||
dynamic_secrets = tool_secrets.get("dynamic_variables") or {}
|
|
||||||
for name, value in dynamic_secrets.items():
|
|
||||||
if not str(name).startswith("secret__"):
|
|
||||||
raise DynamicVariableError(f"工具密钥变量必须以 secret__ 开头: {name}")
|
|
||||||
self._store.secrets[str(name)] = str(value)
|
|
||||||
|
|
||||||
async def call_http(params: FunctionCallParams) -> None:
|
async def call_http(params: FunctionCallParams) -> None:
|
||||||
arguments = params.arguments or {}
|
|
||||||
url = self._store.render(str(config.get("url") or ""))
|
|
||||||
configured_headers = self._store.render_data(
|
|
||||||
deepcopy(config.get("headers") or {}), allow_secrets=True
|
|
||||||
)
|
|
||||||
secret_headers = self._store.render_data(
|
|
||||||
deepcopy(tool_secrets.get("headers") or {}), allow_secrets=True
|
|
||||||
)
|
|
||||||
headers: dict[str, str] = {}
|
|
||||||
query: dict[str, object] = {}
|
|
||||||
body = self._store.render_data(deepcopy(config.get("body") or {}))
|
|
||||||
|
|
||||||
for parameter in parameters:
|
|
||||||
name = str(parameter.get("name") or "")
|
|
||||||
if not name or name not in arguments:
|
|
||||||
continue
|
|
||||||
value = arguments[name]
|
|
||||||
location = str(parameter.get("location") or "body")
|
|
||||||
if location == "path":
|
|
||||||
encoded = quote(str(value), safe="")
|
|
||||||
url = url.replace(f"{{{name}}}", encoded)
|
|
||||||
elif location == "query":
|
|
||||||
query[name] = value
|
|
||||||
elif location == "header":
|
|
||||||
headers[name] = str(value)
|
|
||||||
else:
|
|
||||||
body[name] = value
|
|
||||||
|
|
||||||
# Admin-configured headers win over model-provided arguments; secret
|
|
||||||
# headers are applied last so an LLM can never replace credentials.
|
|
||||||
headers.update(
|
|
||||||
{str(key): str(value) for key, value in configured_headers.items()}
|
|
||||||
)
|
|
||||||
headers.update({str(key): str(value) for key, value in secret_headers.items()})
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
result = await self._tools.execute(tool, dict(params.arguments or {}))
|
||||||
timeout=float(config.get("timeout_seconds") or 15),
|
if result["updated_variables"]:
|
||||||
follow_redirects=False,
|
|
||||||
) as client:
|
|
||||||
response = await client.request(
|
|
||||||
str(config.get("method") or "GET"),
|
|
||||||
url,
|
|
||||||
headers=headers,
|
|
||||||
params=query,
|
|
||||||
json=body if body else None,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
if len(response.content) > 1_000_000:
|
|
||||||
raise DynamicVariableError("HTTP 工具响应超过 1 MB 限制")
|
|
||||||
try:
|
|
||||||
payload = response.json()
|
|
||||||
except ValueError:
|
|
||||||
payload = {"text": response.text[:8000]}
|
|
||||||
|
|
||||||
updated: list[str] = []
|
|
||||||
assignments = config.get("dynamic_variable_assignments") or {}
|
|
||||||
for variable_name, path in assignments.items():
|
|
||||||
try:
|
|
||||||
value = value_at_path(payload, str(path))
|
|
||||||
except KeyError:
|
|
||||||
try:
|
|
||||||
value = value_at_path({"response": payload}, str(path))
|
|
||||||
except KeyError:
|
|
||||||
continue
|
|
||||||
self._store.assign(str(variable_name), value)
|
|
||||||
updated.append(str(variable_name))
|
|
||||||
if updated:
|
|
||||||
self._refresh_prompt()
|
self._refresh_prompt()
|
||||||
await params.result_callback(
|
await params.result_callback(result)
|
||||||
{
|
except (ToolExecutionError, ValueError) as exc:
|
||||||
"status": "ok",
|
|
||||||
"status_code": response.status_code,
|
|
||||||
"data": payload,
|
|
||||||
"updated_variables": updated,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
await params.result_callback(
|
|
||||||
{"status": "error", "message": "HTTP 工具调用超时"}
|
|
||||||
)
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
await params.result_callback(
|
|
||||||
{
|
|
||||||
"status": "error",
|
|
||||||
"status_code": exc.response.status_code,
|
|
||||||
"message": "HTTP 工具返回错误状态",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except (httpx.RequestError, DynamicVariableError) as exc:
|
|
||||||
await params.result_callback(
|
await params.result_callback(
|
||||||
{"status": "error", "message": f"HTTP 工具调用失败: {exc}"}
|
{"status": "error", "message": f"HTTP 工具调用失败: {exc}"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from services.brains.workflow_brain import WorkflowBrain
|
|||||||
|
|
||||||
|
|
||||||
def _workflow(cfg: AssistantConfig) -> Brain:
|
def _workflow(cfg: AssistantConfig) -> Brain:
|
||||||
return WorkflowBrain(cfg.graph)
|
return WorkflowBrain(cfg)
|
||||||
|
|
||||||
|
|
||||||
BRAIN_FACTORIES: dict[str, Callable[[AssistantConfig], Brain]] = {
|
BRAIN_FACTORIES: dict[str, Callable[[AssistantConfig], Brain]] = {
|
||||||
|
|||||||
@@ -1,27 +1,43 @@
|
|||||||
"""Local graph-driven workflow assistant and its per-call state."""
|
"""Pipecat Flows-backed Workflow v3 brain."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from models import AssistantConfig
|
from models import AssistantConfig, RuntimeTool
|
||||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
from db.session import SessionLocal
|
||||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
from pipecat.flows import (
|
||||||
|
ContextStrategy,
|
||||||
|
ContextStrategyConfig,
|
||||||
|
FlowManager,
|
||||||
|
FlowsFunctionSchema,
|
||||||
|
NodeConfig,
|
||||||
|
)
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
LLMRunFrame,
|
||||||
|
LLMUpdateSettingsFrame,
|
||||||
|
OutputTransportMessageUrgentFrame,
|
||||||
|
TTSSpeakFrame,
|
||||||
|
)
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
from pipecat.processors.frame_processor import FrameProcessor
|
from pipecat.processors.frame_processor import FrameProcessor
|
||||||
|
from pipecat.services.settings import LLMSettings
|
||||||
|
from pipecat.utils.time import time_now_iso8601
|
||||||
|
|
||||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||||
|
from services.knowledge import search as search_knowledge
|
||||||
|
from services.runtime_variables import DynamicVariableStore
|
||||||
|
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||||
from services.workflow_engine import WorkflowEngine
|
from services.workflow_engine import WorkflowEngine
|
||||||
|
from services.workflow_router import STAY_ON_CURRENT_AGENT, WorkflowLLMRouter
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
MAX_AUTOMATIC_HOPS = 50
|
||||||
class WorkflowState:
|
AGENT_STAGE_INSTRUCTION = (
|
||||||
current: str
|
"工作流路由已在用户一轮输入结束时完成。只执行当前阶段任务,"
|
||||||
ended: bool = False
|
"不要自行解释、模拟或宣布节点切换。"
|
||||||
turns_in_node: int = 0
|
)
|
||||||
end_turn_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowBrain(BaseBrain):
|
class WorkflowBrain(BaseBrain):
|
||||||
@@ -30,22 +46,29 @@ class WorkflowBrain(BaseBrain):
|
|||||||
supported_runtime_modes=frozenset({"pipeline"}),
|
supported_runtime_modes=frozenset({"pipeline"}),
|
||||||
owns_context=True,
|
owns_context=True,
|
||||||
)
|
)
|
||||||
_FALLBACK_AFTER_TURNS = 2
|
|
||||||
|
|
||||||
def __init__(self, graph: dict[str, Any]):
|
def __init__(self, cfg_or_graph: AssistantConfig | dict[str, Any]):
|
||||||
|
cfg = cfg_or_graph if isinstance(cfg_or_graph, AssistantConfig) else None
|
||||||
|
graph = cfg.graph if cfg is not None else cfg_or_graph
|
||||||
self._engine = WorkflowEngine(graph or {})
|
self._engine = WorkflowEngine(graph or {})
|
||||||
if not self._engine.has_graph() or not self._engine.start_id:
|
if not self._engine.has_graph() or not self._engine.start_id:
|
||||||
raise ValueError("WorkflowBrain 缺少有效的 startCall 节点")
|
raise ValueError("WorkflowBrain 缺少有效的 Start 节点")
|
||||||
self._state = WorkflowState(current=self._engine.start_id)
|
self._cfg = cfg
|
||||||
self._history: list[dict[str, str]] = []
|
self._store = DynamicVariableStore.from_config(cfg or AssistantConfig(type="workflow"))
|
||||||
self._cfg: AssistantConfig | None = None
|
self._tools = ToolExecutor(self._store)
|
||||||
|
self._tool_by_id: dict[str, RuntimeTool] = {
|
||||||
|
tool.id: tool for tool in (cfg.tools if cfg else [])
|
||||||
|
}
|
||||||
self._runtime: BrainRuntime | None = None
|
self._runtime: BrainRuntime | None = None
|
||||||
|
self._manager: FlowManager | None = None
|
||||||
|
self._router = WorkflowLLMRouter(cfg or AssistantConfig(type="workflow"))
|
||||||
|
self._ended = False
|
||||||
|
|
||||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||||
return self._engine.greeting() or cfg.greeting
|
return self._engine.greeting(self._store) or cfg.greeting
|
||||||
|
|
||||||
def system_prompt(self, cfg: AssistantConfig) -> str:
|
def system_prompt(self, cfg: AssistantConfig) -> str:
|
||||||
return self._engine.system_prompt_for(self._state.current)
|
return self._store.render(self._engine.global_prompt())
|
||||||
|
|
||||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||||
from services.pipecat.service_factory import create_llm
|
from services.pipecat.service_factory import create_llm
|
||||||
@@ -53,72 +76,422 @@ class WorkflowBrain(BaseBrain):
|
|||||||
return create_llm(cfg)
|
return create_llm(cfg)
|
||||||
|
|
||||||
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
|
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
|
||||||
|
if runtime.worker is None or runtime.context_aggregator is None:
|
||||||
|
raise RuntimeError("WorkflowBrain 需要 PipelineWorker 和 context aggregator pair")
|
||||||
self._cfg = cfg
|
self._cfg = cfg
|
||||||
self._runtime = runtime
|
self._runtime = runtime
|
||||||
for edge in self._engine.edges:
|
self._store = DynamicVariableStore.from_config(cfg)
|
||||||
if edge.get("target"):
|
self._tools = ToolExecutor(self._store)
|
||||||
runtime.llm.register_function(
|
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
||||||
self._engine.edge_fn_name(edge),
|
self._router = WorkflowLLMRouter(cfg)
|
||||||
self._make_transition_handler(edge),
|
self._manager = FlowManager(
|
||||||
)
|
worker=runtime.worker,
|
||||||
self._apply_node(self._state.current)
|
llm=runtime.llm,
|
||||||
logger.info(
|
context_aggregator=runtime.context_aggregator,
|
||||||
f"工作流模式启用: 起始节点={self._engine.name(self._state.current)}"
|
transport=runtime.transport,
|
||||||
|
global_functions=runtime.flow_global_functions,
|
||||||
)
|
)
|
||||||
|
self._manager.state["variables"] = self._store.values
|
||||||
|
|
||||||
async def on_connected(self) -> None:
|
async def on_connected(self) -> None:
|
||||||
await self._emit_node_active(self._state.current)
|
await self._emit_node_active(self._engine.start_id)
|
||||||
|
edge = self._engine.deterministic_edge(
|
||||||
|
self._engine.start_id,
|
||||||
|
self._store,
|
||||||
|
include_default=True,
|
||||||
|
)
|
||||||
|
if not edge and self._engine.has_outgoing(self._engine.start_id):
|
||||||
|
raise RuntimeError("Start 初始化后没有命中的表达式边或默认边")
|
||||||
|
node_config = (
|
||||||
|
await self._follow_edge(edge)
|
||||||
|
if edge
|
||||||
|
else self._passive_node_config(self._engine.start_id)
|
||||||
|
)
|
||||||
|
if self._manager is None:
|
||||||
|
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||||
|
await self._manager.initialize(node_config)
|
||||||
|
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
||||||
|
|
||||||
def record_user_message(self, content: str) -> None:
|
def record_user_message(self, content: str) -> None:
|
||||||
if content:
|
if content and not self._ended:
|
||||||
self._history.append({"role": "user", "content": content})
|
self._store.record("user", content)
|
||||||
|
|
||||||
async def on_assistant_text_start(self, turn_id: str) -> None:
|
async def on_user_turn_end(self, content: str) -> bool:
|
||||||
if self._state.ended and self._state.end_turn_id is None:
|
"""Route a complete user turn before any Agent is allowed to reply."""
|
||||||
self._state.end_turn_id = turn_id
|
if not content or self._ended:
|
||||||
|
return True
|
||||||
|
self.record_user_message(content)
|
||||||
|
manager = self._require_manager()
|
||||||
|
current = manager.current_node
|
||||||
|
if not current or self._engine.node_type(current) != "agent":
|
||||||
|
return True
|
||||||
|
|
||||||
|
edge = self._engine.deterministic_edge(
|
||||||
|
current,
|
||||||
|
self._store,
|
||||||
|
include_default=False,
|
||||||
|
)
|
||||||
|
outgoing = self._engine.outgoing(current)
|
||||||
|
llm_edges = [
|
||||||
|
candidate
|
||||||
|
for candidate in outgoing
|
||||||
|
if self._engine.edge_mode(candidate) == "llm"
|
||||||
|
]
|
||||||
|
default_edge = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in outgoing
|
||||||
|
if self._engine.edge_mode(candidate) == "always"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if edge is None and llm_edges:
|
||||||
|
selected = await self._router_for_node(current).select_edge(
|
||||||
|
node_name=self._engine.name(current),
|
||||||
|
node_prompt=self._engine.prompt_for(current, self._store),
|
||||||
|
edges=llm_edges,
|
||||||
|
history=self._store.history,
|
||||||
|
variables={
|
||||||
|
key: value
|
||||||
|
for key, value in self._store.values.items()
|
||||||
|
if not key.startswith("system__")
|
||||||
|
},
|
||||||
|
edge_name=self._engine.edge_fn_name,
|
||||||
|
edge_description=self._engine.edge_description,
|
||||||
|
)
|
||||||
|
if selected and selected != STAY_ON_CURRENT_AGENT:
|
||||||
|
edge = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in llm_edges
|
||||||
|
if self._engine.edge_fn_name(candidate) == selected
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
elif selected == STAY_ON_CURRENT_AGENT:
|
||||||
|
edge = default_edge
|
||||||
|
elif edge is None and not llm_edges:
|
||||||
|
edge = default_edge
|
||||||
|
|
||||||
|
if edge and manager.current_node == current:
|
||||||
|
next_config = await self._follow_edge(edge)
|
||||||
|
await manager.set_node_from_config(next_config)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# The incoming LLMContextFrame is intentionally suppressed by the
|
||||||
|
# pipeline router. Queue prompt refresh + inference in this order so
|
||||||
|
# this user turn is answered with the current Agent's latest variables.
|
||||||
|
await self._refresh_agent_prompt(current)
|
||||||
|
await self._require_runtime().queue_frame(LLMRunFrame())
|
||||||
|
return True
|
||||||
|
|
||||||
async def on_assistant_text_end(
|
async def on_assistant_text_end(
|
||||||
self,
|
self,
|
||||||
turn_id: str,
|
_turn_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
interrupted: bool,
|
interrupted: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not content or interrupted:
|
if not content or interrupted or self._ended:
|
||||||
return
|
return
|
||||||
self._history.append({"role": "assistant", "content": content})
|
self._store.record("agent", content, completed_agent_turn=True)
|
||||||
if turn_id == self._state.end_turn_id:
|
|
||||||
runtime = self._require_runtime()
|
|
||||||
runtime.call_end.begin("completed")
|
|
||||||
runtime.call_end.arm_after_speech()
|
|
||||||
elif not self._state.ended:
|
|
||||||
self._state.turns_in_node += 1
|
|
||||||
await self._fallback_route()
|
|
||||||
|
|
||||||
def _apply_node(self, node_id: str) -> None:
|
async def _refresh_agent_prompt(self, node_id: str) -> None:
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
runtime.set_system_prompt(self._engine.system_prompt_for(node_id))
|
await runtime.queue_frame(
|
||||||
if self._engine.is_end(node_id):
|
LLMUpdateSettingsFrame(
|
||||||
runtime.set_tools([])
|
delta=LLMSettings(
|
||||||
return
|
system_instruction=self._agent_role_message(node_id)
|
||||||
runtime.set_tools(
|
|
||||||
[
|
|
||||||
FunctionSchema(
|
|
||||||
name=self._engine.edge_fn_name(edge),
|
|
||||||
description=self._engine.edge_description(edge),
|
|
||||||
properties={},
|
|
||||||
required=[],
|
|
||||||
)
|
)
|
||||||
for edge in self._engine.outgoing(node_id)
|
)
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _go_to_node(self, target: str) -> None:
|
def _agent_role_message(self, node_id: str) -> str:
|
||||||
self._state.current = target
|
"""Build one provider-compatible system instruction for an Agent stage."""
|
||||||
self._state.turns_in_node = 0
|
stage_prompt = self._engine.prompt_for(node_id, self._store)
|
||||||
if self._engine.is_end(target):
|
return f"{stage_prompt}\n\n[工作流执行规则]\n{AGENT_STAGE_INSTRUCTION}"
|
||||||
self._state.ended = True
|
|
||||||
await self._emit_node_active(target)
|
def _router_for_node(self, node_id: str) -> WorkflowLLMRouter:
|
||||||
self._apply_node(target)
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
|
resource_id = stage.llm_resource_id
|
||||||
|
cfg = self._cfg
|
||||||
|
resource = cfg.workflow_model_resources.get(resource_id) if cfg else None
|
||||||
|
if not cfg or not resource:
|
||||||
|
return self._router
|
||||||
|
from services.pipecat.service_factory import config_with_resource
|
||||||
|
|
||||||
|
return WorkflowLLMRouter(config_with_resource(cfg, resource))
|
||||||
|
|
||||||
|
async def _apply_agent_stage(self, node_id: str) -> None:
|
||||||
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
|
await self._emit_node_active(node_id)
|
||||||
|
if self._runtime and self._runtime.set_input_enabled:
|
||||||
|
self._runtime.set_input_enabled(True)
|
||||||
|
runtime = self._require_runtime()
|
||||||
|
if runtime.switch_services:
|
||||||
|
await runtime.switch_services(
|
||||||
|
stage.llm_resource_id or None,
|
||||||
|
stage.asr_resource_id or None,
|
||||||
|
stage.tts_resource_id or None,
|
||||||
|
)
|
||||||
|
if runtime.set_knowledge_scope:
|
||||||
|
runtime.set_knowledge_scope(
|
||||||
|
{
|
||||||
|
"knowledge_base_id": stage.knowledge_base_id,
|
||||||
|
"mode": stage.knowledge_mode,
|
||||||
|
"top_n": stage.knowledge_top_n,
|
||||||
|
"score_threshold": stage.knowledge_score_threshold,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _agent_config(self, node_id: str) -> NodeConfig:
|
||||||
|
data = self._engine.data(node_id)
|
||||||
|
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||||
|
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||||
|
strategy = (
|
||||||
|
ContextStrategy.RESET
|
||||||
|
if data.get("contextPolicy") == "fresh"
|
||||||
|
else ContextStrategy.APPEND
|
||||||
|
)
|
||||||
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
|
functions: list[FlowsFunctionSchema] = []
|
||||||
|
for tool_id in stage.tool_ids:
|
||||||
|
tool = self._tool_by_id.get(str(tool_id))
|
||||||
|
if tool and tool.type == "http":
|
||||||
|
functions.append(self._flow_tool(tool, node_id))
|
||||||
|
knowledge_function = self._knowledge_function(node_id)
|
||||||
|
if knowledge_function:
|
||||||
|
functions.append(knowledge_function)
|
||||||
|
config: NodeConfig = {
|
||||||
|
"name": node_id,
|
||||||
|
"role_message": self._agent_role_message(node_id),
|
||||||
|
"task_messages": (
|
||||||
|
[{"role": "assistant", "content": entry_speech}]
|
||||||
|
if entry_mode == "fixed_speech"
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
"functions": functions,
|
||||||
|
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||||
|
"respond_immediately": entry_mode == "generate",
|
||||||
|
}
|
||||||
|
if entry_mode == "fixed_speech":
|
||||||
|
config["pre_actions"] = [
|
||||||
|
{
|
||||||
|
"type": "workflow_fixed_speech",
|
||||||
|
"text": entry_speech,
|
||||||
|
"handler": self._play_fixed_speech,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return config
|
||||||
|
|
||||||
|
async def _play_fixed_speech(self, action: dict, _flow_manager: FlowManager) -> None:
|
||||||
|
"""Play and persist Agent entry speech without creating an LLM turn."""
|
||||||
|
await self._queue_visible_speech(str(action.get("text") or ""))
|
||||||
|
|
||||||
|
async def _queue_visible_speech(self, text: str) -> None:
|
||||||
|
"""Show and persist fixed workflow speech before sending it to TTS."""
|
||||||
|
content = text.strip()
|
||||||
|
if not content:
|
||||||
|
return
|
||||||
|
self._store.record("agent", content)
|
||||||
|
runtime = self._require_runtime()
|
||||||
|
await runtime.queue_frame(
|
||||||
|
OutputTransportMessageUrgentFrame(
|
||||||
|
message={
|
||||||
|
"type": "transcript",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content,
|
||||||
|
"timestamp": time_now_iso8601(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await runtime.queue_frame(TTSSpeakFrame(content, append_to_context=False))
|
||||||
|
|
||||||
|
def _passive_node_config(self, node_id: str) -> NodeConfig:
|
||||||
|
"""Keep a non-conversational terminal node active without ending the call."""
|
||||||
|
return {
|
||||||
|
"name": node_id,
|
||||||
|
"role_message": self._store.render(self._engine.global_prompt()),
|
||||||
|
"task_messages": [],
|
||||||
|
"functions": [],
|
||||||
|
"context_strategy": ContextStrategyConfig(strategy=ContextStrategy.APPEND),
|
||||||
|
"respond_immediately": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _flow_tool(self, tool: RuntimeTool, node_id: str) -> FlowsFunctionSchema:
|
||||||
|
properties, required = self._tools.schema_parts(tool)
|
||||||
|
self._tools.register_secrets(tool)
|
||||||
|
|
||||||
|
async def handler(args, _flow_manager):
|
||||||
|
try:
|
||||||
|
result = await self._tools.execute(tool, dict(args or {}))
|
||||||
|
except ToolExecutionError as exc:
|
||||||
|
return {"status": "error", "message": str(exc)}
|
||||||
|
if result.get("updated_variables"):
|
||||||
|
await self._refresh_agent_prompt(node_id)
|
||||||
|
edge = self._engine.deterministic_edge(
|
||||||
|
node_id,
|
||||||
|
self._store,
|
||||||
|
include_default=False,
|
||||||
|
)
|
||||||
|
if edge:
|
||||||
|
return result, await self._follow_edge(edge)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return FlowsFunctionSchema(
|
||||||
|
name=tool.function_name,
|
||||||
|
description=tool.description or f"调用 {tool.name}",
|
||||||
|
properties=properties,
|
||||||
|
required=required,
|
||||||
|
handler=handler,
|
||||||
|
cancel_on_interruption=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
||||||
|
stage = self._engine.agent_stage_config(node_id)
|
||||||
|
knowledge_id = str(stage.knowledge_base_id or "")
|
||||||
|
if not knowledge_id or stage.knowledge_mode != "on_demand":
|
||||||
|
return None
|
||||||
|
cfg = self._cfg or AssistantConfig(type="workflow")
|
||||||
|
knowledge = cfg.workflow_knowledge_bases.get(knowledge_id)
|
||||||
|
description = "在当前 Agent 绑定的知识库中检索资料。"
|
||||||
|
if knowledge:
|
||||||
|
description += f"知识库:{knowledge.name}。{knowledge.description}"
|
||||||
|
|
||||||
|
async def handler(args, _flow_manager):
|
||||||
|
query = str((args or {}).get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return {"status": "error", "message": "检索问题为空"}
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
results = await search_knowledge(
|
||||||
|
session,
|
||||||
|
knowledge_id,
|
||||||
|
query,
|
||||||
|
top_k=stage.knowledge_top_n,
|
||||||
|
score_threshold=stage.knowledge_score_threshold,
|
||||||
|
)
|
||||||
|
return {"status": "ok", "results": results}
|
||||||
|
except Exception as exc: # noqa: BLE001 - tool errors are returned to the LLM
|
||||||
|
logger.warning(f"Workflow 知识库检索失败:{exc}")
|
||||||
|
return {"status": "error", "message": "知识库检索暂时不可用"}
|
||||||
|
|
||||||
|
return FlowsFunctionSchema(
|
||||||
|
name="search_knowledge_base",
|
||||||
|
description=description,
|
||||||
|
properties={
|
||||||
|
"query": {"type": "string", "description": "完整问题或检索关键词"}
|
||||||
|
},
|
||||||
|
required=["query"],
|
||||||
|
handler=handler,
|
||||||
|
cancel_on_interruption=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _follow_edge(self, edge: dict) -> NodeConfig:
|
||||||
|
speech = self._engine.edge_transition_speech(edge)
|
||||||
|
if speech:
|
||||||
|
await self._queue_visible_speech(self._store.render(speech))
|
||||||
|
return await self._resolve_path(str(edge.get("target") or ""))
|
||||||
|
|
||||||
|
async def _resolve_path(self, node_id: str) -> NodeConfig:
|
||||||
|
for _ in range(MAX_AUTOMATIC_HOPS):
|
||||||
|
node_type = self._engine.node_type(node_id)
|
||||||
|
if node_type == "agent":
|
||||||
|
await self._apply_agent_stage(node_id)
|
||||||
|
return self._agent_config(node_id)
|
||||||
|
if node_type == "end":
|
||||||
|
await self._enter_end(node_id)
|
||||||
|
return self._passive_node_config(node_id)
|
||||||
|
if node_type == "action":
|
||||||
|
await self._enter_action(node_id)
|
||||||
|
elif node_type == "handoff":
|
||||||
|
await self._enter_handoff(node_id)
|
||||||
|
elif node_type == "start":
|
||||||
|
await self._emit_node_active(node_id)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"工作流指向未知节点:{node_id}")
|
||||||
|
if not self._engine.has_outgoing(node_id):
|
||||||
|
return self._passive_node_config(node_id)
|
||||||
|
edge = self._engine.deterministic_edge(
|
||||||
|
node_id,
|
||||||
|
self._store,
|
||||||
|
include_default=True,
|
||||||
|
)
|
||||||
|
if not edge:
|
||||||
|
raise RuntimeError(f"自动节点 {node_id} 没有命中的表达式边或默认边")
|
||||||
|
speech = self._engine.edge_transition_speech(edge)
|
||||||
|
if speech:
|
||||||
|
await self._queue_visible_speech(self._store.render(speech))
|
||||||
|
node_id = str(edge.get("target") or "")
|
||||||
|
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||||
|
|
||||||
|
async def _enter_action(self, node_id: str) -> None:
|
||||||
|
await self._emit_node_active(node_id)
|
||||||
|
data = self._engine.data(node_id)
|
||||||
|
tool_id = str(data.get("toolId") or "")
|
||||||
|
tool = self._tool_by_id.get(tool_id)
|
||||||
|
if not tool:
|
||||||
|
self._store.values["system__last_action_status"] = "error"
|
||||||
|
self._store.values["system__last_action_error"] = f"工具不存在:{tool_id}"
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
arguments = self._store.render_data(data.get("arguments") or {})
|
||||||
|
await self._tools.execute(
|
||||||
|
tool,
|
||||||
|
arguments,
|
||||||
|
result_assignments=data.get("resultAssignments") or {},
|
||||||
|
)
|
||||||
|
self._store.values["system__last_action_status"] = "ok"
|
||||||
|
self._store.values["system__last_action_error"] = ""
|
||||||
|
except (ToolExecutionError, ValueError) as exc:
|
||||||
|
self._store.values["system__last_action_status"] = "error"
|
||||||
|
self._store.values["system__last_action_error"] = str(exc)[:2048]
|
||||||
|
|
||||||
|
async def _enter_handoff(self, node_id: str) -> None:
|
||||||
|
await self._emit_node_active(node_id)
|
||||||
|
data = self._engine.data(node_id)
|
||||||
|
message = self._store.render(str(data.get("message") or ""))
|
||||||
|
await self._require_runtime().queue_frame(
|
||||||
|
OutputTransportMessageUrgentFrame(
|
||||||
|
message={
|
||||||
|
"type": "handoff-requested",
|
||||||
|
"nodeId": node_id,
|
||||||
|
"targetType": data.get("targetType", "human"),
|
||||||
|
"target": data.get("target", ""),
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if message:
|
||||||
|
await self._queue_visible_speech(message)
|
||||||
|
self._store.values["system__handoff_status"] = "requested"
|
||||||
|
|
||||||
|
async def _enter_end(self, node_id: str) -> None:
|
||||||
|
self._ended = True
|
||||||
|
await self._emit_node_active(node_id)
|
||||||
|
runtime = self._require_runtime()
|
||||||
|
if runtime.set_knowledge_scope:
|
||||||
|
runtime.set_knowledge_scope({"mode": "disabled"})
|
||||||
|
if runtime.set_input_enabled:
|
||||||
|
runtime.set_input_enabled(False)
|
||||||
|
data = self._engine.data(node_id)
|
||||||
|
message = self._store.render(str(data.get("message") or ""))
|
||||||
|
scope = str(data.get("scope") or "session")
|
||||||
|
if scope == "flow":
|
||||||
|
await runtime.queue_frame(
|
||||||
|
OutputTransportMessageUrgentFrame(
|
||||||
|
message={"type": "flow-ended", "nodeId": node_id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if message:
|
||||||
|
await self._queue_visible_speech(message)
|
||||||
|
return
|
||||||
|
runtime.call_end.begin("workflow_completed")
|
||||||
|
if message:
|
||||||
|
runtime.call_end.arm_after_speech()
|
||||||
|
await self._queue_visible_speech(message)
|
||||||
|
else:
|
||||||
|
await runtime.call_end.finish()
|
||||||
|
|
||||||
async def _emit_node_active(self, node_id: str | None) -> None:
|
async def _emit_node_active(self, node_id: str | None) -> None:
|
||||||
if node_id:
|
if node_id:
|
||||||
@@ -128,61 +501,12 @@ class WorkflowBrain(BaseBrain):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _speak_transition(self, edge: dict | None) -> None:
|
|
||||||
speech = self._engine.edge_transition_speech(edge)
|
|
||||||
if speech:
|
|
||||||
await self._require_runtime().queue_frame(
|
|
||||||
TTSSpeakFrame(speech, append_to_context=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _make_transition_handler(self, edge: dict):
|
|
||||||
target = str(edge.get("target"))
|
|
||||||
|
|
||||||
async def handler(params) -> None:
|
|
||||||
logger.info(f"LLM 触发转移 → {self._engine.name(target)}")
|
|
||||||
if not self._engine.is_end(target):
|
|
||||||
await self._speak_transition(edge)
|
|
||||||
await self._go_to_node(target)
|
|
||||||
await params.result_callback({"status": "ok"})
|
|
||||||
|
|
||||||
return handler
|
|
||||||
|
|
||||||
async def _fallback_route(self) -> None:
|
|
||||||
if self._state.ended:
|
|
||||||
return
|
|
||||||
if self._state.turns_in_node < self._FALLBACK_AFTER_TURNS:
|
|
||||||
return
|
|
||||||
if not self._engine.outgoing(self._state.current):
|
|
||||||
return
|
|
||||||
|
|
||||||
cfg = self._require_config()
|
|
||||||
target = await self._engine.route(
|
|
||||||
self._state.current,
|
|
||||||
self._history,
|
|
||||||
api_key=self._require(cfg.llm_api_key, "LLM apiKey"),
|
|
||||||
base_url=self._require(cfg.llm_base_url, "LLM apiUrl"),
|
|
||||||
model=self._require(cfg.model, "LLM modelId"),
|
|
||||||
)
|
|
||||||
if target and target != self._state.current:
|
|
||||||
logger.info(f"文本兜底触发转移 → {self._engine.name(target)}")
|
|
||||||
if not self._engine.is_end(target):
|
|
||||||
await self._speak_transition(
|
|
||||||
self._engine.find_edge(self._state.current, target)
|
|
||||||
)
|
|
||||||
await self._go_to_node(target)
|
|
||||||
|
|
||||||
def _require_runtime(self) -> BrainRuntime:
|
def _require_runtime(self) -> BrainRuntime:
|
||||||
if self._runtime is None:
|
if self._runtime is None:
|
||||||
raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime")
|
raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime")
|
||||||
return self._runtime
|
return self._runtime
|
||||||
|
|
||||||
def _require_config(self) -> AssistantConfig:
|
def _require_manager(self) -> FlowManager:
|
||||||
if self._cfg is None:
|
if self._manager is None:
|
||||||
raise RuntimeError("WorkflowBrain 尚未初始化配置")
|
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||||
return self._cfg
|
return self._manager
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _require(value: str, label: str) -> str:
|
|
||||||
if value:
|
|
||||||
return value
|
|
||||||
raise ValueError(f"缺少模型资源配置: {label}")
|
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ from db.models import (
|
|||||||
ModelResource,
|
ModelResource,
|
||||||
Tool,
|
Tool,
|
||||||
)
|
)
|
||||||
from models import AssistantConfig, RuntimeTool
|
from models import (
|
||||||
|
AssistantConfig,
|
||||||
|
RuntimeKnowledgeBase,
|
||||||
|
RuntimeModelResource,
|
||||||
|
RuntimeTool,
|
||||||
|
)
|
||||||
|
from services.node_specs import graph_references, normalize_graph
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ def _secret(resource: ModelResource | None, key: str, default: str = "") -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[RuntimeTool]:
|
async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[RuntimeTool]:
|
||||||
if assistant.type != "prompt":
|
if assistant.type not in {"prompt", "workflow"}:
|
||||||
return []
|
return []
|
||||||
tools = (
|
tools = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -134,6 +140,43 @@ async def resolve_runtime_config(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
graph = normalize_graph(assistant.graph or {}) if assistant.type == "workflow" else {}
|
||||||
|
refs = graph_references(graph) if graph else {
|
||||||
|
"model_resources": set(),
|
||||||
|
"knowledge_bases": set(),
|
||||||
|
}
|
||||||
|
workflow_resources: dict[str, RuntimeModelResource] = {}
|
||||||
|
if refs["model_resources"]:
|
||||||
|
resources = (
|
||||||
|
await session.execute(
|
||||||
|
select(ModelResource).where(ModelResource.id.in_(refs["model_resources"]))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
workflow_resources = {
|
||||||
|
resource.id: RuntimeModelResource(
|
||||||
|
id=resource.id,
|
||||||
|
name=resource.name,
|
||||||
|
capability=resource.capability,
|
||||||
|
interface_type=resource.interface_type,
|
||||||
|
values=resource.values or {},
|
||||||
|
secrets=resource.secrets or {},
|
||||||
|
)
|
||||||
|
for resource in resources
|
||||||
|
if resource.enabled
|
||||||
|
}
|
||||||
|
workflow_knowledge: dict[str, RuntimeKnowledgeBase] = {}
|
||||||
|
if refs["knowledge_bases"]:
|
||||||
|
knowledge_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(KnowledgeBase).where(KnowledgeBase.id.in_(refs["knowledge_bases"]))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
workflow_knowledge = {
|
||||||
|
kb.id: RuntimeKnowledgeBase(id=kb.id, name=kb.name, description=kb.description)
|
||||||
|
for kb in knowledge_rows
|
||||||
|
if kb.status == "active"
|
||||||
|
}
|
||||||
|
|
||||||
return AssistantConfig(
|
return AssistantConfig(
|
||||||
name=assistant.name,
|
name=assistant.name,
|
||||||
type=assistant.type,
|
type=assistant.type,
|
||||||
@@ -150,7 +193,9 @@ async def resolve_runtime_config(
|
|||||||
knowledge_base_description=knowledge_base.description if knowledge_base else "",
|
knowledge_base_description=knowledge_base.description if knowledge_base else "",
|
||||||
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
|
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
|
||||||
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
|
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
|
||||||
graph=(assistant.graph or {}) if assistant.type == "workflow" else {},
|
graph=graph,
|
||||||
|
workflow_model_resources=workflow_resources,
|
||||||
|
workflow_knowledge_bases=workflow_knowledge,
|
||||||
# 外部托管类型连接信息(DB 存真 key,直接注入)
|
# 外部托管类型连接信息(DB 存真 key,直接注入)
|
||||||
dify_api_url=str(_value(agent_resource, "apiUrl", assistant.api_url)),
|
dify_api_url=str(_value(agent_resource, "apiUrl", assistant.api_url)),
|
||||||
dify_api_key=_secret(agent_resource, "apiKey", assistant.api_key),
|
dify_api_key=_secret(agent_resource, "apiKey", assistant.api_key),
|
||||||
|
|||||||
@@ -1,132 +1,110 @@
|
|||||||
"""工作流节点规格 + 图校验(对齐 dograh 的 node-spec / GraphConstraints 思路)。
|
"""Workflow v3 node catalog, v2 compatibility normalization, and validation."""
|
||||||
|
|
||||||
当前实现 4 个核心节点:开始(startCall)/智能体(agentNode)/结束(endCall)/全局(globalNode)。
|
|
||||||
本模块是「节点类型」的唯一事实源:
|
|
||||||
- /api/node-types 接口直接吐这里的规格;
|
|
||||||
- 助手保存时用这里的约束校验 workflow 图。
|
|
||||||
|
|
||||||
新增节点类型只需在 NODE_SPECS 里加一条并补充约束。前端 specs.ts 与此保持一致。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from copy import deepcopy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# 规格版本号:节点定义有破坏性变更时 +1,前端可据此判断是否需要刷新缓存。
|
|
||||||
SPEC_VERSION = "2"
|
|
||||||
|
|
||||||
# 每个节点的图约束。None 表示不限制。
|
SPEC_VERSION = "3"
|
||||||
# min_incoming / max_incoming:入边数量
|
NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
|
||||||
# min_outgoing / max_outgoing:出边数量
|
EDGE_MODES = {"llm", "expression", "always"}
|
||||||
|
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
|
||||||
|
AUTOMATIC_NODE_TYPES = {"start", "action", "handoff"}
|
||||||
|
EXPRESSION_OPERATORS = {
|
||||||
|
"eq",
|
||||||
|
"neq",
|
||||||
|
"gt",
|
||||||
|
"gte",
|
||||||
|
"lt",
|
||||||
|
"lte",
|
||||||
|
"contains",
|
||||||
|
"in",
|
||||||
|
"exists",
|
||||||
|
}
|
||||||
|
|
||||||
NODE_SPECS: list[dict[str, Any]] = [
|
NODE_SPECS: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"name": "startCall",
|
"name": "start",
|
||||||
"displayName": "开始",
|
"displayName": "Start",
|
||||||
"category": "call_node",
|
"category": "control_node",
|
||||||
"description": "工作流入口,每个流程有且仅有一个。播放开场白,并用自己的提示词进行多轮对话,满足出边条件后流转。",
|
"description": "初始化会话、动态变量和全局观察器,可播放固定开场白。",
|
||||||
"icon": "Play",
|
"icon": "Play",
|
||||||
"accent": "mint",
|
"accent": "mint",
|
||||||
"addable": False,
|
"addable": False,
|
||||||
"constraints": {
|
"constraints": {
|
||||||
"minIncoming": 0,
|
"minIncoming": 0,
|
||||||
"maxIncoming": 0,
|
"maxIncoming": 0,
|
||||||
|
"minOutgoing": 0,
|
||||||
"minInstances": 1,
|
"minInstances": 1,
|
||||||
"maxInstances": 1,
|
"maxInstances": 1,
|
||||||
},
|
},
|
||||||
"fields": [
|
"fields": [
|
||||||
{"key": "name", "label": "节点名称", "type": "text", "default": "开始"},
|
{"key": "name", "label": "节点名称", "type": "text", "default": "Start"},
|
||||||
{"key": "greeting", "label": "开场白", "type": "textarea", "default": ""},
|
{"key": "greeting", "label": "固定开场白", "type": "textarea", "default": ""},
|
||||||
{"key": "prompt", "label": "节点提示词", "type": "textarea", "default": ""},
|
|
||||||
{
|
|
||||||
"key": "allowInterrupt",
|
|
||||||
"label": "允许用户打断",
|
|
||||||
"type": "switch",
|
|
||||||
"default": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "addGlobalPrompt",
|
|
||||||
"label": "应用全局提示词",
|
|
||||||
"type": "switch",
|
|
||||||
"default": True,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "agentNode",
|
"name": "agent",
|
||||||
"displayName": "智能体节点",
|
"displayName": "Agent",
|
||||||
"category": "call_node",
|
"category": "conversation_node",
|
||||||
"description": "对话处理单元。按提示词与用户多轮交互,可有多个并通过条件边流转。",
|
"description": "阶段智能体:绑定上下文、工具、知识库及 ASR/TTS 资源。",
|
||||||
"icon": "Bot",
|
"icon": "Bot",
|
||||||
"accent": "sky",
|
"accent": "sky",
|
||||||
"addable": True,
|
"addable": True,
|
||||||
"constraints": {"minIncoming": 1},
|
"constraints": {"minIncoming": 1, "minOutgoing": 0},
|
||||||
"fields": [
|
"fields": [
|
||||||
{"key": "name", "label": "节点名称", "type": "text", "default": "智能体节点"},
|
{"key": "name", "label": "节点名称", "type": "text", "default": "Agent"},
|
||||||
{
|
{
|
||||||
"key": "prompt",
|
"key": "prompt",
|
||||||
"label": "节点提示词",
|
"label": "阶段提示词",
|
||||||
"type": "textarea",
|
"type": "textarea",
|
||||||
"required": True,
|
"required": True,
|
||||||
"default": "",
|
"default": "",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"key": "allowInterrupt",
|
|
||||||
"label": "允许用户打断",
|
|
||||||
"type": "switch",
|
|
||||||
"default": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "addGlobalPrompt",
|
|
||||||
"label": "应用全局提示词",
|
|
||||||
"type": "switch",
|
|
||||||
"default": True,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "endCall",
|
"name": "action",
|
||||||
"displayName": "结束",
|
"displayName": "Action",
|
||||||
"category": "call_node",
|
"category": "execution_node",
|
||||||
"description": "终止节点,礼貌结束对话。可有多个,均无出边。",
|
"description": "确定性执行指定工具,并将结果字段写入会话动态变量。",
|
||||||
|
"icon": "Zap",
|
||||||
|
"accent": "peach",
|
||||||
|
"addable": True,
|
||||||
|
"constraints": {"minIncoming": 1, "minOutgoing": 1},
|
||||||
|
"fields": [
|
||||||
|
{"key": "name", "label": "节点名称", "type": "text", "default": "Action"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "handoff",
|
||||||
|
"displayName": "Handoff",
|
||||||
|
"category": "execution_node",
|
||||||
|
"description": "转交其他 AI、人工、队列或电话;MVP 发送转交事件后继续路由。",
|
||||||
|
"icon": "PhoneForwarded",
|
||||||
|
"accent": "lavender",
|
||||||
|
"addable": True,
|
||||||
|
"constraints": {"minIncoming": 1, "minOutgoing": 0},
|
||||||
|
"fields": [
|
||||||
|
{"key": "name", "label": "节点名称", "type": "text", "default": "Handoff"},
|
||||||
|
{"key": "target", "label": "转交目标", "type": "text", "default": ""},
|
||||||
|
{"key": "message", "label": "转交提示", "type": "textarea", "default": ""},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "end",
|
||||||
|
"displayName": "End",
|
||||||
|
"category": "control_node",
|
||||||
|
"description": "结束 AI 流程或整个音视频会话。",
|
||||||
"icon": "Flag",
|
"icon": "Flag",
|
||||||
"accent": "rose",
|
"accent": "rose",
|
||||||
"addable": True,
|
"addable": True,
|
||||||
"constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0},
|
"constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0},
|
||||||
"fields": [
|
"fields": [
|
||||||
{"key": "name", "label": "节点名称", "type": "text", "default": "结束"},
|
{"key": "name", "label": "节点名称", "type": "text", "default": "End"},
|
||||||
{"key": "prompt", "label": "结束语提示词", "type": "textarea", "default": ""},
|
{"key": "message", "label": "固定结束语", "type": "textarea", "default": ""},
|
||||||
{
|
|
||||||
"key": "addGlobalPrompt",
|
|
||||||
"label": "应用全局提示词",
|
|
||||||
"type": "switch",
|
|
||||||
"default": False,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "globalNode",
|
|
||||||
"displayName": "全局节点",
|
|
||||||
"category": "global_node",
|
|
||||||
"description": "为整个工作流提供统一的人设、语气和公共规则。无需连线,每个流程最多一个。",
|
|
||||||
"icon": "Globe2",
|
|
||||||
"accent": "lavender",
|
|
||||||
"addable": True,
|
|
||||||
"constraints": {
|
|
||||||
"minIncoming": 0,
|
|
||||||
"maxIncoming": 0,
|
|
||||||
"minOutgoing": 0,
|
|
||||||
"maxOutgoing": 0,
|
|
||||||
"maxInstances": 1,
|
|
||||||
},
|
|
||||||
"fields": [
|
|
||||||
{"key": "name", "label": "节点名称", "type": "text", "default": "全局设定"},
|
|
||||||
{
|
|
||||||
"key": "prompt",
|
|
||||||
"label": "全局提示词",
|
|
||||||
"type": "textarea",
|
|
||||||
"required": True,
|
|
||||||
"default": "你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -135,108 +113,372 @@ _SPEC_BY_NAME = {spec["name"]: spec for spec in NODE_SPECS}
|
|||||||
|
|
||||||
|
|
||||||
def node_types_response() -> dict[str, Any]:
|
def node_types_response() -> dict[str, Any]:
|
||||||
"""/api/node-types 的响应体(camelCase,直接喂前端)。"""
|
|
||||||
return {"specVersion": SPEC_VERSION, "nodeTypes": NODE_SPECS}
|
return {"specVersion": SPEC_VERSION, "nodeTypes": NODE_SPECS}
|
||||||
|
|
||||||
|
|
||||||
def validate_graph(graph: dict[str, Any]) -> list[str]:
|
def _edge_data_v3(edge: dict, source_type: str) -> dict:
|
||||||
"""校验 workflow 图,返回错误信息列表(空列表 = 通过)。
|
data = deepcopy(edge.get("data") or {})
|
||||||
|
if data.get("mode") in EDGE_MODES:
|
||||||
|
data.setdefault("priority", 10)
|
||||||
|
return data
|
||||||
|
condition = str(data.pop("condition", "") or "").strip()
|
||||||
|
transition = data.pop("transition_speech", data.get("transitionSpeech", ""))
|
||||||
|
data.update(
|
||||||
|
{
|
||||||
|
"mode": "llm" if condition and source_type == "agent" else "always",
|
||||||
|
"priority": 10,
|
||||||
|
"condition": condition,
|
||||||
|
"transitionSpeech": transition,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
基础规则(对齐 dograh 的核心不变量):
|
|
||||||
1. 节点类型必须是已注册类型;
|
|
||||||
2. 有且仅有一个 startCall;
|
|
||||||
3. 至少有一个 endCall,全局节点最多一个;
|
|
||||||
4. 边的 source/target 必须指向存在的节点;
|
|
||||||
5. 入边/出边数量满足各节点类型的约束。
|
|
||||||
|
|
||||||
空图(无节点)视为草稿,直接放行,方便先存后编排。
|
def _normalize_agent_data(data: dict[str, Any]) -> None:
|
||||||
"""
|
"""Add v3 Agent defaults without changing existing node-level behavior."""
|
||||||
errors: list[str] = []
|
data.setdefault("contextPolicy", "inherit")
|
||||||
nodes = graph.get("nodes") or []
|
data.setdefault("entryMode", "wait_user")
|
||||||
edges = graph.get("edges") or []
|
data.setdefault("entrySpeech", "")
|
||||||
|
if "inheritGlobalConfig" not in data:
|
||||||
|
has_node_overrides = any(
|
||||||
|
(
|
||||||
|
data.get("llmResourceId"),
|
||||||
|
data.get("asrResourceId"),
|
||||||
|
data.get("ttsResourceId"),
|
||||||
|
data.get("knowledgeBaseId"),
|
||||||
|
data.get("toolIds"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
data["inheritGlobalConfig"] = not has_node_overrides
|
||||||
|
|
||||||
if not nodes:
|
|
||||||
return errors # 草稿:放行
|
|
||||||
|
|
||||||
node_ids: set[str] = set()
|
def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") -> None:
|
||||||
type_counts: dict[str, int] = {}
|
settings.setdefault("globalPrompt", global_prompt)
|
||||||
node_type_by_id: dict[str, str] = {}
|
settings.setdefault("defaultLlmResourceId", "")
|
||||||
|
settings.setdefault("defaultAsrResourceId", "")
|
||||||
|
settings.setdefault("defaultTtsResourceId", "")
|
||||||
|
settings.setdefault("toolIds", [])
|
||||||
|
settings.setdefault("knowledgeBaseId", "")
|
||||||
|
settings.setdefault("knowledgeMode", "automatic")
|
||||||
|
settings.setdefault("knowledgeTopN", 5)
|
||||||
|
settings.setdefault("knowledgeScoreThreshold", 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Return a deep-copied v3 graph; preserve v3 IDs and migrate v2 semantics."""
|
||||||
|
source = deepcopy(graph or {})
|
||||||
|
if str(source.get("specVersion") or "") == SPEC_VERSION:
|
||||||
|
settings = source.setdefault("settings", {})
|
||||||
|
_normalize_settings(settings)
|
||||||
|
source.setdefault("nodes", [])
|
||||||
|
source.setdefault("edges", [])
|
||||||
|
for node in source["nodes"]:
|
||||||
|
if node.get("type") != "agent":
|
||||||
|
continue
|
||||||
|
data = node.setdefault("data", {})
|
||||||
|
_normalize_agent_data(data)
|
||||||
|
return source
|
||||||
|
|
||||||
|
nodes = source.get("nodes") or []
|
||||||
|
edges = source.get("edges") or []
|
||||||
|
global_prompt = ""
|
||||||
|
mapped_nodes: list[dict] = []
|
||||||
|
type_by_id: dict[str, str] = {}
|
||||||
|
start_prompt_nodes: dict[str, str] = {}
|
||||||
|
|
||||||
|
type_map = {
|
||||||
|
"startCall": "start",
|
||||||
|
"agentNode": "agent",
|
||||||
|
"endCall": "end",
|
||||||
|
"start": "start",
|
||||||
|
"agent": "agent",
|
||||||
|
"action": "action",
|
||||||
|
"handoff": "handoff",
|
||||||
|
"end": "end",
|
||||||
|
}
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
node_id = node.get("id")
|
old_type = str(node.get("type") or "")
|
||||||
node_type = node.get("type")
|
data = deepcopy(node.get("data") or {})
|
||||||
if not node_id:
|
if old_type == "globalNode":
|
||||||
errors.append("存在缺少 id 的节点")
|
global_prompt = str(data.get("prompt") or "")
|
||||||
continue
|
continue
|
||||||
if node_id in node_ids:
|
new_type = type_map.get(old_type, old_type)
|
||||||
errors.append(f"节点 id 重复:{node_id}")
|
migrated = deepcopy(node)
|
||||||
node_ids.add(node_id)
|
migrated["type"] = new_type
|
||||||
if node_type not in _SPEC_BY_NAME:
|
if new_type == "end":
|
||||||
errors.append(f"未知节点类型:{node_type}(节点 {node_id})")
|
data["message"] = data.pop("message", data.pop("prompt", ""))
|
||||||
continue
|
data.setdefault("scope", "session")
|
||||||
node_type_by_id[node_id] = node_type
|
elif new_type == "agent":
|
||||||
type_counts[node_type] = type_counts.get(node_type, 0) + 1
|
_normalize_agent_data(data)
|
||||||
|
elif new_type == "start":
|
||||||
|
prompt = str(data.pop("prompt", "") or "").strip()
|
||||||
|
if prompt:
|
||||||
|
start_prompt_nodes[str(node.get("id"))] = prompt
|
||||||
|
for key in ("allowInterrupt", "addGlobalPrompt"):
|
||||||
|
data.pop(key, None)
|
||||||
|
migrated["data"] = data
|
||||||
|
mapped_nodes.append(migrated)
|
||||||
|
if migrated.get("id"):
|
||||||
|
type_by_id[str(migrated["id"])] = new_type
|
||||||
|
|
||||||
start_count = type_counts.get("startCall", 0)
|
mapped_edges: list[dict] = []
|
||||||
if start_count == 0:
|
for edge in edges:
|
||||||
errors.append("工作流必须有一个「开始」节点")
|
migrated = deepcopy(edge)
|
||||||
elif start_count > 1:
|
migrated["data"] = _edge_data_v3(
|
||||||
errors.append("工作流只能有一个「开始」节点")
|
migrated, type_by_id.get(str(migrated.get("source")), "")
|
||||||
|
)
|
||||||
|
mapped_edges.append(migrated)
|
||||||
|
|
||||||
if type_counts.get("endCall", 0) == 0:
|
# A v2 Start was conversational. Insert a synthetic Agent so its prompt remains active.
|
||||||
errors.append("工作流至少需要一个「结束」节点")
|
for start_id, prompt in start_prompt_nodes.items():
|
||||||
|
synthetic_id = f"{start_id}-migrated-agent"
|
||||||
for node_type, spec in _SPEC_BY_NAME.items():
|
start_node = next((n for n in mapped_nodes if n.get("id") == start_id), None)
|
||||||
# 开始节点上方已有更明确的中文错误提示,避免重复报错。
|
position = (start_node or {}).get("position") or {"x": 100, "y": 120}
|
||||||
if node_type == "startCall":
|
mapped_nodes.append(
|
||||||
continue
|
{
|
||||||
constraints = spec["constraints"]
|
"id": synthetic_id,
|
||||||
count = type_counts.get(node_type, 0)
|
"type": "agent",
|
||||||
_check_count(
|
"position": {"x": position.get("x", 100) + 300, "y": position.get("y", 120)},
|
||||||
errors,
|
"data": {
|
||||||
count,
|
"name": "迁移的开场 Agent",
|
||||||
constraints,
|
"prompt": prompt,
|
||||||
"Instances",
|
"contextPolicy": "inherit",
|
||||||
node_type,
|
"inheritGlobalConfig": True,
|
||||||
"实例",
|
"entryMode": "wait_user",
|
||||||
|
"entrySpeech": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for edge in mapped_edges:
|
||||||
|
if edge.get("source") == start_id:
|
||||||
|
edge["source"] = synthetic_id
|
||||||
|
edge["data"] = _edge_data_v3(edge, "agent")
|
||||||
|
if str(edge["data"].get("condition") or "").strip():
|
||||||
|
edge["data"]["mode"] = "llm"
|
||||||
|
mapped_edges.append(
|
||||||
|
{
|
||||||
|
"id": f"e-{start_id}-{synthetic_id}",
|
||||||
|
"source": start_id,
|
||||||
|
"target": synthetic_id,
|
||||||
|
"data": {"mode": "always", "priority": 0, "transitionSpeech": ""},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 统计入边/出边
|
settings = deepcopy(source.get("settings") or {})
|
||||||
incoming: dict[str, int] = {nid: 0 for nid in node_ids}
|
_normalize_settings(settings, global_prompt=global_prompt)
|
||||||
outgoing: dict[str, int] = {nid: 0 for nid in node_ids}
|
return {
|
||||||
for edge in edges:
|
"specVersion": 3,
|
||||||
source = edge.get("source")
|
"settings": settings,
|
||||||
target = edge.get("target")
|
"nodes": mapped_nodes,
|
||||||
if source not in node_ids:
|
"edges": mapped_edges,
|
||||||
errors.append(f"连线指向了不存在的源节点:{source}")
|
**({"viewport": deepcopy(source["viewport"])} if source.get("viewport") else {}),
|
||||||
continue
|
}
|
||||||
if target not in node_ids:
|
|
||||||
errors.append(f"连线指向了不存在的目标节点:{target}")
|
|
||||||
continue
|
|
||||||
outgoing[source] += 1
|
|
||||||
incoming[target] += 1
|
|
||||||
|
|
||||||
for node_id, node_type in node_type_by_id.items():
|
|
||||||
constraints = _SPEC_BY_NAME[node_type]["constraints"]
|
|
||||||
name = node_type
|
|
||||||
_check_count(errors, incoming[node_id], constraints, "Incoming", name, "入边")
|
|
||||||
_check_count(errors, outgoing[node_id], constraints, "Outgoing", name, "出边")
|
|
||||||
|
|
||||||
|
def _validate_expression(expression: Any) -> list[str]:
|
||||||
|
if not isinstance(expression, dict):
|
||||||
|
return ["表达式条件不能为空"]
|
||||||
|
combinator = expression.get("combinator", "and")
|
||||||
|
if combinator not in {"and", "or"}:
|
||||||
|
return ["表达式组合方式必须是 and 或 or"]
|
||||||
|
rules = expression.get("rules")
|
||||||
|
if not isinstance(rules, list) or not rules:
|
||||||
|
return ["表达式至少需要一条规则"]
|
||||||
|
errors = []
|
||||||
|
for rule in rules:
|
||||||
|
if not isinstance(rule, dict) or not rule.get("variable"):
|
||||||
|
errors.append("表达式规则缺少变量")
|
||||||
|
elif rule.get("operator") not in EXPRESSION_OPERATORS:
|
||||||
|
errors.append(f"不支持的表达式运算符:{rule.get('operator')}")
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def _check_count(
|
def validate_graph(graph: dict[str, Any]) -> list[str]:
|
||||||
errors: list[str],
|
graph = normalize_graph(graph)
|
||||||
actual: int,
|
nodes = graph.get("nodes") or []
|
||||||
constraints: dict[str, int],
|
edges = graph.get("edges") or []
|
||||||
suffix: str,
|
if not nodes:
|
||||||
node_type: str,
|
return []
|
||||||
label: str,
|
|
||||||
) -> None:
|
errors: list[str] = []
|
||||||
lo = constraints.get(f"min{suffix}")
|
node_by_id: dict[str, dict] = {}
|
||||||
hi = constraints.get(f"max{suffix}")
|
counts: dict[str, int] = defaultdict(int)
|
||||||
display = _SPEC_BY_NAME[node_type]["displayName"]
|
for node in nodes:
|
||||||
if lo is not None and actual < lo:
|
node_id = str(node.get("id") or "")
|
||||||
errors.append(f"「{display}」节点{label}数量不能少于 {lo}(当前 {actual})")
|
node_type = str(node.get("type") or "")
|
||||||
if hi is not None and actual > hi:
|
if not node_id:
|
||||||
errors.append(f"「{display}」节点{label}数量不能多于 {hi}(当前 {actual})")
|
errors.append("存在缺少 id 的节点")
|
||||||
|
continue
|
||||||
|
if node_id in node_by_id:
|
||||||
|
errors.append(f"节点 id 重复:{node_id}")
|
||||||
|
if node_type not in NODE_TYPES:
|
||||||
|
errors.append(f"未知节点类型:{node_type}(节点 {node_id})")
|
||||||
|
node_by_id[node_id] = node
|
||||||
|
counts[node_type] += 1
|
||||||
|
|
||||||
|
if node_type == "agent":
|
||||||
|
data = node.get("data") or {}
|
||||||
|
entry_mode = data.get("entryMode", "wait_user")
|
||||||
|
if entry_mode not in AGENT_ENTRY_MODES:
|
||||||
|
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}")
|
||||||
|
elif entry_mode == "fixed_speech" and not str(
|
||||||
|
data.get("entrySpeech") or ""
|
||||||
|
).strip():
|
||||||
|
errors.append(f"Agent 节点 {node_id} 的固定进入语不能为空")
|
||||||
|
|
||||||
|
if counts["start"] != 1:
|
||||||
|
errors.append("工作流必须有且仅有一个 Start 节点")
|
||||||
|
incoming: dict[str, int] = defaultdict(int)
|
||||||
|
outgoing: dict[str, int] = defaultdict(int)
|
||||||
|
adj: dict[str, list[str]] = defaultdict(list)
|
||||||
|
auto_adj: dict[str, list[str]] = defaultdict(list)
|
||||||
|
priorities: dict[str, set[int]] = defaultdict(set)
|
||||||
|
always_counts: dict[str, int] = defaultdict(int)
|
||||||
|
for edge in edges:
|
||||||
|
edge_id = str(edge.get("id") or "")
|
||||||
|
source_id = str(edge.get("source") or "")
|
||||||
|
target_id = str(edge.get("target") or "")
|
||||||
|
if source_id not in node_by_id:
|
||||||
|
errors.append(f"边 {edge_id} 指向不存在的源节点:{source_id}")
|
||||||
|
continue
|
||||||
|
if target_id not in node_by_id:
|
||||||
|
errors.append(f"边 {edge_id} 指向不存在的目标节点:{target_id}")
|
||||||
|
continue
|
||||||
|
if source_id == target_id and node_by_id[source_id].get("type") != "agent":
|
||||||
|
errors.append(f"自动节点不能自连:{source_id}")
|
||||||
|
data = edge.get("data") or {}
|
||||||
|
mode = data.get("mode")
|
||||||
|
if mode not in EDGE_MODES:
|
||||||
|
errors.append(f"边 {edge_id} 的判断模式无效:{mode}")
|
||||||
|
if mode == "llm" and node_by_id[source_id].get("type") != "agent":
|
||||||
|
errors.append(f"LLM 判断边只能从 Agent 发出:{edge_id}")
|
||||||
|
if mode == "llm" and not str(data.get("condition") or "").strip():
|
||||||
|
errors.append(f"LLM 判断边缺少自然语言条件:{edge_id}")
|
||||||
|
if mode == "expression":
|
||||||
|
expression_errors = _validate_expression(data.get("expression"))
|
||||||
|
errors.extend(f"边 {edge_id}:{item}" for item in expression_errors)
|
||||||
|
try:
|
||||||
|
priority = int(data.get("priority", 10))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
errors.append(f"边 {edge_id} 的优先级必须是整数")
|
||||||
|
priority = 10
|
||||||
|
if priority in priorities[source_id]:
|
||||||
|
errors.append(f"节点 {source_id} 的出边优先级不能重复:{priority}")
|
||||||
|
priorities[source_id].add(priority)
|
||||||
|
if mode == "always":
|
||||||
|
always_counts[source_id] += 1
|
||||||
|
if always_counts[source_id] > 1:
|
||||||
|
errors.append(f"节点 {source_id} 最多只能有一条默认边")
|
||||||
|
incoming[target_id] += 1
|
||||||
|
outgoing[source_id] += 1
|
||||||
|
adj[source_id].append(target_id)
|
||||||
|
source_is_automatic = node_by_id[source_id].get("type") != "agent"
|
||||||
|
target_is_automatic = node_by_id[target_id].get("type") != "agent"
|
||||||
|
if source_is_automatic and target_is_automatic:
|
||||||
|
auto_adj[source_id].append(target_id)
|
||||||
|
|
||||||
|
for node_id, node in node_by_id.items():
|
||||||
|
spec = _SPEC_BY_NAME.get(str(node.get("type")))
|
||||||
|
if not spec:
|
||||||
|
continue
|
||||||
|
constraints = spec["constraints"]
|
||||||
|
for actual, suffix, label in (
|
||||||
|
(incoming[node_id], "Incoming", "入边"),
|
||||||
|
(outgoing[node_id], "Outgoing", "出边"),
|
||||||
|
):
|
||||||
|
lo = constraints.get(f"min{suffix}")
|
||||||
|
hi = constraints.get(f"max{suffix}")
|
||||||
|
if lo is not None and actual < lo:
|
||||||
|
errors.append(f"节点 {node_id} 的{label}不能少于 {lo}")
|
||||||
|
if hi is not None and actual > hi:
|
||||||
|
errors.append(f"节点 {node_id} 的{label}不能多于 {hi}")
|
||||||
|
node_type = node.get("type")
|
||||||
|
if (
|
||||||
|
node_type in AUTOMATIC_NODE_TYPES
|
||||||
|
and outgoing[node_id] > 0
|
||||||
|
and always_counts[node_id] != 1
|
||||||
|
):
|
||||||
|
errors.append(f"自动节点 {node_id} 存在出边时必须有且仅有一条默认边")
|
||||||
|
|
||||||
|
start_id = next(
|
||||||
|
(node_id for node_id, node in node_by_id.items() if node.get("type") == "start"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if start_id:
|
||||||
|
reached = {start_id}
|
||||||
|
queue = deque([start_id])
|
||||||
|
while queue:
|
||||||
|
current = queue.popleft()
|
||||||
|
for target in adj[current]:
|
||||||
|
if target not in reached:
|
||||||
|
reached.add(target)
|
||||||
|
queue.append(target)
|
||||||
|
for node_id in node_by_id.keys() - reached:
|
||||||
|
errors.append(f"节点不可从 Start 到达:{node_id}")
|
||||||
|
|
||||||
|
# Reject cycles made only of instantaneous nodes; Agent cycles are valid waits.
|
||||||
|
visiting: set[str] = set()
|
||||||
|
visited: set[str] = set()
|
||||||
|
|
||||||
|
def visit(node_id: str) -> bool:
|
||||||
|
if node_id in visiting:
|
||||||
|
return True
|
||||||
|
if node_id in visited:
|
||||||
|
return False
|
||||||
|
visiting.add(node_id)
|
||||||
|
for target in auto_adj[node_id]:
|
||||||
|
if visit(target):
|
||||||
|
return True
|
||||||
|
visiting.remove(node_id)
|
||||||
|
visited.add(node_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
automatic_node_ids = (
|
||||||
|
node_id
|
||||||
|
for node_id, node in node_by_id.items()
|
||||||
|
if node.get("type") != "agent"
|
||||||
|
)
|
||||||
|
if any(visit(node_id) for node_id in automatic_node_ids):
|
||||||
|
errors.append("Start/Action/Handoff/End 之间不能形成无等待循环")
|
||||||
|
return list(dict.fromkeys(errors))
|
||||||
|
|
||||||
|
|
||||||
|
def graph_references(graph: dict[str, Any]) -> dict[str, set[str]]:
|
||||||
|
"""Collect externally referenced IDs for save/runtime validation."""
|
||||||
|
normalized = normalize_graph(graph)
|
||||||
|
settings = normalized.get("settings") or {}
|
||||||
|
resources = {
|
||||||
|
str(value)
|
||||||
|
for value in (
|
||||||
|
settings.get("defaultLlmResourceId"),
|
||||||
|
settings.get("defaultAsrResourceId"),
|
||||||
|
settings.get("defaultTtsResourceId"),
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
tools: set[str] = {str(tool_id) for tool_id in settings.get("toolIds") or []}
|
||||||
|
knowledge: set[str] = (
|
||||||
|
{str(settings["knowledgeBaseId"])}
|
||||||
|
if settings.get("knowledgeBaseId")
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
for node in normalized.get("nodes") or []:
|
||||||
|
data = node.get("data") or {}
|
||||||
|
inherits_global = (
|
||||||
|
node.get("type") == "agent" and data.get("inheritGlobalConfig", True)
|
||||||
|
)
|
||||||
|
if not inherits_global:
|
||||||
|
for resource_id in (
|
||||||
|
data.get("llmResourceId"),
|
||||||
|
data.get("asrResourceId"),
|
||||||
|
data.get("ttsResourceId"),
|
||||||
|
):
|
||||||
|
if resource_id:
|
||||||
|
resources.add(str(resource_id))
|
||||||
|
for tool_id in data.get("toolIds") or []:
|
||||||
|
tools.add(str(tool_id))
|
||||||
|
if data.get("knowledgeBaseId"):
|
||||||
|
knowledge.add(str(data["knowledgeBaseId"]))
|
||||||
|
if data.get("toolId"):
|
||||||
|
tools.add(str(data["toolId"]))
|
||||||
|
return {"model_resources": resources, "tools": tools, "knowledge_bases": knowledge}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from services.pipecat.call_lifecycle import (
|
|||||||
EndCallAfterSpeechProcessor,
|
EndCallAfterSpeechProcessor,
|
||||||
)
|
)
|
||||||
from services.pipecat.service_factory import (
|
from services.pipecat.service_factory import (
|
||||||
|
config_with_resource,
|
||||||
|
create_llm,
|
||||||
create_realtime_service,
|
create_realtime_service,
|
||||||
create_stt,
|
create_stt,
|
||||||
create_tts,
|
create_tts,
|
||||||
@@ -32,6 +34,7 @@ from services.knowledge import search as search_knowledge
|
|||||||
|
|
||||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||||
|
from pipecat.flows import FlowsFunctionSchema
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
EndFrame,
|
EndFrame,
|
||||||
InputTransportMessageFrame,
|
InputTransportMessageFrame,
|
||||||
@@ -40,6 +43,7 @@ from pipecat.frames.frames import (
|
|||||||
LLMFullResponseStartFrame,
|
LLMFullResponseStartFrame,
|
||||||
LLMContextFrame,
|
LLMContextFrame,
|
||||||
LLMTextFrame,
|
LLMTextFrame,
|
||||||
|
ManuallySwitchServiceFrame,
|
||||||
LLMMessagesAppendFrame,
|
LLMMessagesAppendFrame,
|
||||||
OutputTransportMessageUrgentFrame,
|
OutputTransportMessageUrgentFrame,
|
||||||
TextFrame,
|
TextFrame,
|
||||||
@@ -48,6 +52,8 @@ from pipecat.frames.frames import (
|
|||||||
UserImageRequestFrame,
|
UserImageRequestFrame,
|
||||||
)
|
)
|
||||||
from pipecat.pipeline.pipeline import Pipeline
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.llm_switcher import LLMSwitcher
|
||||||
|
from pipecat.pipeline.service_switcher import ServiceSwitcher
|
||||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
from pipecat.processors.aggregators.llm_response_universal import (
|
from pipecat.processors.aggregators.llm_response_universal import (
|
||||||
@@ -397,15 +403,59 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
|
|||||||
self._knowledge_base_id = knowledge_base_id
|
self._knowledge_base_id = knowledge_base_id
|
||||||
self._top_n = top_n
|
self._top_n = top_n
|
||||||
self._score_threshold = score_threshold
|
self._score_threshold = score_threshold
|
||||||
|
self._mode = "automatic" if knowledge_base_id else "disabled"
|
||||||
self._last_signature = ""
|
self._last_signature = ""
|
||||||
|
|
||||||
|
def set_scope(self, scope: dict) -> None:
|
||||||
|
self._knowledge_base_id = scope.get("knowledge_base_id") or None
|
||||||
|
self._mode = str(scope.get("mode") or "disabled")
|
||||||
|
self._top_n = int(scope.get("top_n") or 5)
|
||||||
|
self._score_threshold = float(scope.get("score_threshold") or 0.0)
|
||||||
|
self._last_signature = ""
|
||||||
|
|
||||||
|
def _clear_context(self, messages: list[dict]) -> None:
|
||||||
|
# Remove the legacy Workflow knowledge message so an in-flight context
|
||||||
|
# created before this compatibility fix cannot keep sending that role.
|
||||||
|
messages[:] = [
|
||||||
|
message
|
||||||
|
for message in messages
|
||||||
|
if not (
|
||||||
|
message.get("role") == "developer"
|
||||||
|
and KNOWLEDGE_CONTEXT_MARKER in str(message.get("content") or "")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
system_message = next(
|
||||||
|
(message for message in messages if message.get("role") == "system"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if system_message is not None:
|
||||||
|
content = str(system_message.get("content") or "")
|
||||||
|
system_message["content"] = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
|
||||||
|
|
||||||
|
def _set_context(self, messages: list[dict], block: str) -> None:
|
||||||
|
"""Store retrieved knowledge in a provider-compatible system message."""
|
||||||
|
self._clear_context(messages)
|
||||||
|
system_message = next(
|
||||||
|
(message for message in messages if message.get("role") == "system"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if system_message is None:
|
||||||
|
messages.insert(0, {"role": "system", "content": block})
|
||||||
|
return
|
||||||
|
content = str(system_message.get("content") or "").rstrip()
|
||||||
|
system_message["content"] = f"{content}\n\n{block}" if content else block
|
||||||
|
|
||||||
async def process_frame(self, frame, direction: FrameDirection):
|
async def process_frame(self, frame, direction: FrameDirection):
|
||||||
await super().process_frame(frame, direction)
|
await super().process_frame(frame, direction)
|
||||||
if not self._knowledge_base_id or not isinstance(frame, LLMContextFrame):
|
if not isinstance(frame, LLMContextFrame):
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
return
|
return
|
||||||
|
|
||||||
messages = frame.context.get_messages()
|
messages = frame.context.get_messages()
|
||||||
|
if self._mode != "automatic" or not self._knowledge_base_id:
|
||||||
|
self._clear_context(messages)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
return
|
||||||
user_messages = [message for message in messages if message.get("role") == "user"]
|
user_messages = [message for message in messages if message.get("role") == "user"]
|
||||||
if not user_messages:
|
if not user_messages:
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
@@ -435,16 +485,53 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
|
|||||||
for index, item in enumerate(results)
|
for index, item in enumerate(results)
|
||||||
) or "未检索到相关资料。"
|
) or "未检索到相关资料。"
|
||||||
block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}"
|
block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}"
|
||||||
system_message = next((message for message in messages if message.get("role") == "system"), None)
|
self._set_context(messages, block)
|
||||||
if system_message is None:
|
|
||||||
messages.insert(0, {"role": "system", "content": block})
|
|
||||||
else:
|
|
||||||
content = str(system_message.get("content") or "")
|
|
||||||
base = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
|
|
||||||
system_message["content"] = f"{base}\n\n{block}" if base else block
|
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
|
class UserTurnRoutingProcessor(FrameProcessor):
|
||||||
|
"""Give a brain first right of refusal before a new user turn reaches the LLM."""
|
||||||
|
|
||||||
|
def __init__(self, brain: Brain):
|
||||||
|
super().__init__()
|
||||||
|
self._brain = brain
|
||||||
|
self._last_user_message: dict | None = None
|
||||||
|
|
||||||
|
async def process_frame(self, frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if direction != FrameDirection.DOWNSTREAM or not isinstance(
|
||||||
|
frame, LLMContextFrame
|
||||||
|
):
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_message = next(
|
||||||
|
(
|
||||||
|
message
|
||||||
|
for message in reversed(frame.context.get_messages())
|
||||||
|
if message.get("role") == "user"
|
||||||
|
and isinstance(message.get("content"), str)
|
||||||
|
and str(message.get("content") or "").strip()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if user_message is None:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
return
|
||||||
|
|
||||||
|
if user_message is self._last_user_message:
|
||||||
|
# Programmatic LLMRunFrame after a node transition reuses the same
|
||||||
|
# user message. It is a response run, not another routing event.
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
return
|
||||||
|
self._last_user_message = user_message
|
||||||
|
|
||||||
|
content = str(user_message.get("content") or "").strip()
|
||||||
|
handled = await self._brain.on_user_turn_end(content)
|
||||||
|
if not handled:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
||||||
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
||||||
|
|
||||||
@@ -457,7 +544,6 @@ class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
|||||||
self._stream_turn_id: str | None = None
|
self._stream_turn_id: str | None = None
|
||||||
self._stream_timestamp = ""
|
self._stream_timestamp = ""
|
||||||
self._stream_text = ""
|
self._stream_text = ""
|
||||||
|
|
||||||
async def process_frame(self, frame, direction: FrameDirection):
|
async def process_frame(self, frame, direction: FrameDirection):
|
||||||
await super().process_frame(frame, direction)
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
@@ -505,6 +591,72 @@ class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
|||||||
self._stream_text = ""
|
self._stream_text = ""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowAggregatorPair:
|
||||||
|
"""Small public-shape adapter required by Pipecat FlowManager."""
|
||||||
|
|
||||||
|
def __init__(self, user_aggregator, assistant_aggregator):
|
||||||
|
self._user = user_aggregator
|
||||||
|
self._assistant = assistant_aggregator
|
||||||
|
|
||||||
|
def user(self):
|
||||||
|
return self._user
|
||||||
|
|
||||||
|
def assistant(self):
|
||||||
|
return self._assistant
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_service_switcher(
|
||||||
|
cfg: AssistantConfig, capability: str, base_service: FrameProcessor
|
||||||
|
):
|
||||||
|
"""Build one switcher and an ID lookup for every referenced voice resource."""
|
||||||
|
create = create_stt if capability == "ASR" else create_tts
|
||||||
|
settings = cfg.graph.get("settings") or {}
|
||||||
|
default_key = (
|
||||||
|
"defaultAsrResourceId" if capability == "ASR" else "defaultTtsResourceId"
|
||||||
|
)
|
||||||
|
default_id = str(settings.get(default_key) or "")
|
||||||
|
services_by_id = {}
|
||||||
|
for resource_id, resource in cfg.workflow_model_resources.items():
|
||||||
|
if resource.capability != capability:
|
||||||
|
continue
|
||||||
|
services_by_id[resource_id] = (
|
||||||
|
base_service
|
||||||
|
if resource_id == default_id
|
||||||
|
else create(config_with_resource(cfg, resource))
|
||||||
|
)
|
||||||
|
primary = services_by_id.get(default_id, base_service)
|
||||||
|
services = [primary]
|
||||||
|
services.extend(
|
||||||
|
service for service in services_by_id.values() if service is not primary
|
||||||
|
)
|
||||||
|
if base_service is not primary:
|
||||||
|
services.append(base_service)
|
||||||
|
return ServiceSwitcher(services=services), services_by_id, primary
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_llm_switcher(cfg: AssistantConfig, base_service):
|
||||||
|
"""Build an LLM switcher for the global model and Agent overrides."""
|
||||||
|
settings = cfg.graph.get("settings") or {}
|
||||||
|
default_id = str(settings.get("defaultLlmResourceId") or "")
|
||||||
|
services_by_id = {}
|
||||||
|
for resource_id, resource in cfg.workflow_model_resources.items():
|
||||||
|
if resource.capability != "LLM":
|
||||||
|
continue
|
||||||
|
services_by_id[resource_id] = (
|
||||||
|
base_service
|
||||||
|
if resource_id == default_id
|
||||||
|
else create_llm(config_with_resource(cfg, resource))
|
||||||
|
)
|
||||||
|
primary = services_by_id.get(default_id, base_service)
|
||||||
|
services = [primary]
|
||||||
|
services.extend(
|
||||||
|
service for service in services_by_id.values() if service is not primary
|
||||||
|
)
|
||||||
|
if base_service is not primary:
|
||||||
|
services.append(base_service)
|
||||||
|
return LLMSwitcher(llms=services), services_by_id, primary
|
||||||
|
|
||||||
|
|
||||||
async def run_pipeline(
|
async def run_pipeline(
|
||||||
transport,
|
transport,
|
||||||
cfg: AssistantConfig,
|
cfg: AssistantConfig,
|
||||||
@@ -545,8 +697,38 @@ async def run_pipeline(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
stt = create_stt(cfg)
|
graph_settings = cfg.graph.get("settings") or {}
|
||||||
tts = create_tts(cfg)
|
default_llm_resource = cfg.workflow_model_resources.get(
|
||||||
|
str(graph_settings.get("defaultLlmResourceId") or "")
|
||||||
|
)
|
||||||
|
default_asr_resource = cfg.workflow_model_resources.get(
|
||||||
|
str(graph_settings.get("defaultAsrResourceId") or "")
|
||||||
|
)
|
||||||
|
default_tts_resource = cfg.workflow_model_resources.get(
|
||||||
|
str(graph_settings.get("defaultTtsResourceId") or "")
|
||||||
|
)
|
||||||
|
stt = create_stt(
|
||||||
|
config_with_resource(cfg, default_asr_resource)
|
||||||
|
if cfg.type == "workflow" and default_asr_resource
|
||||||
|
else cfg
|
||||||
|
)
|
||||||
|
tts = create_tts(
|
||||||
|
config_with_resource(cfg, default_tts_resource)
|
||||||
|
if cfg.type == "workflow" and default_tts_resource
|
||||||
|
else cfg
|
||||||
|
)
|
||||||
|
stt_processor = stt
|
||||||
|
tts_processor = tts
|
||||||
|
stt_services: dict[str, FrameProcessor] = {}
|
||||||
|
tts_services: dict[str, FrameProcessor] = {}
|
||||||
|
current_voice_services: dict[str, FrameProcessor] = {"asr": stt, "tts": tts}
|
||||||
|
if cfg.type == "workflow":
|
||||||
|
stt_processor, stt_services, current_voice_services["asr"] = (
|
||||||
|
_workflow_service_switcher(cfg, "ASR", stt)
|
||||||
|
)
|
||||||
|
tts_processor, tts_services, current_voice_services["tts"] = (
|
||||||
|
_workflow_service_switcher(cfg, "TTS", tts)
|
||||||
|
)
|
||||||
|
|
||||||
greeting = await brain.greeting(cfg)
|
greeting = await brain.greeting(cfg)
|
||||||
system_content = brain.system_prompt(cfg)
|
system_content = brain.system_prompt(cfg)
|
||||||
@@ -594,17 +776,33 @@ async def run_pipeline(
|
|||||||
return "\n\n".join(part for part in [text, *hints] if part)
|
return "\n\n".join(part for part in [text, *hints] if part)
|
||||||
|
|
||||||
context = LLMContext(
|
context = LLMContext(
|
||||||
messages=[{"role": "system", "content": with_vision_hint(system_content)}]
|
messages=(
|
||||||
|
[]
|
||||||
|
if cfg.type == "workflow"
|
||||||
|
else [{"role": "system", "content": with_vision_hint(system_content)}]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
input_state = {"enabled": True}
|
||||||
# LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
|
# LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
|
||||||
llm = brain.build_llm(cfg, context)
|
llm = brain.build_llm(
|
||||||
|
config_with_resource(cfg, default_llm_resource)
|
||||||
|
if cfg.type == "workflow" and default_llm_resource
|
||||||
|
else cfg,
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
llm_services: dict[str, FrameProcessor] = {}
|
||||||
|
current_llm_service = llm
|
||||||
|
if cfg.type == "workflow":
|
||||||
|
llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
|
||||||
user_aggregator = LLMUserAggregator(
|
user_aggregator = LLMUserAggregator(
|
||||||
context,
|
context,
|
||||||
params=LLMUserAggregatorParams(
|
params=LLMUserAggregatorParams(
|
||||||
vad_analyzer=create_vad_analyzer(cfg.turnConfig),
|
vad_analyzer=create_vad_analyzer(cfg.turnConfig),
|
||||||
user_mute_strategies=[
|
user_mute_strategies=[
|
||||||
FunctionCallUserMuteStrategy(),
|
FunctionCallUserMuteStrategy(),
|
||||||
CallEndingUserMuteStrategy(lambda: call_end.ending),
|
CallEndingUserMuteStrategy(
|
||||||
|
lambda: call_end.ending or not input_state["enabled"]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
user_turn_strategies=create_user_turn_strategies(
|
user_turn_strategies=create_user_turn_strategies(
|
||||||
cfg.turnConfig,
|
cfg.turnConfig,
|
||||||
@@ -612,8 +810,11 @@ async def run_pipeline(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
user_turn_router = UserTurnRoutingProcessor(brain)
|
||||||
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
||||||
text_input = TextInputProcessor(should_ignore_input=lambda: call_end.ending)
|
text_input = TextInputProcessor(
|
||||||
|
should_ignore_input=lambda: call_end.ending or not input_state["enabled"]
|
||||||
|
)
|
||||||
vision_capture = VisionCaptureProcessor()
|
vision_capture = VisionCaptureProcessor()
|
||||||
knowledge_retrieval = KnowledgeRetrievalProcessor(
|
knowledge_retrieval = KnowledgeRetrievalProcessor(
|
||||||
automatic_knowledge_id,
|
automatic_knowledge_id,
|
||||||
@@ -723,14 +924,55 @@ async def run_pipeline(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if vision_enabled:
|
flow_global_functions = []
|
||||||
|
if cfg.type == "workflow" and vision_enabled:
|
||||||
|
async def flow_fetch_user_image(args, _flow_manager):
|
||||||
|
question = str((args or {}).get("question") or "请描述当前画面。")
|
||||||
|
user_id = vision_state.get("client_id")
|
||||||
|
if not user_id:
|
||||||
|
return {
|
||||||
|
"status": "no_video_client",
|
||||||
|
"message": "当前还没有可用的摄像头视频流。",
|
||||||
|
}
|
||||||
|
request = UserImageRequestFrame(
|
||||||
|
user_id=user_id,
|
||||||
|
text=question,
|
||||||
|
append_to_context=False,
|
||||||
|
function_name=VISION_TOOL_NAME,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
frame = await vision_capture.request_image(llm, request)
|
||||||
|
observation = await _analyze_image_with_vision_model(cfg, frame, question)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"question": question,
|
||||||
|
"observation": observation or "视觉模型没有返回有效观察结果。",
|
||||||
|
}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"status": "timeout", "message": "等待摄像头视频帧超时。"}
|
||||||
|
except Exception as exc: # noqa: BLE001 - return tool errors to the LLM
|
||||||
|
logger.warning(f"Workflow 视觉理解失败:{exc}")
|
||||||
|
return {"status": "error", "message": "视觉理解暂时不可用。"}
|
||||||
|
|
||||||
|
flow_global_functions.append(
|
||||||
|
FlowsFunctionSchema(
|
||||||
|
name=VISION_TOOL_NAME,
|
||||||
|
description=vision_schema.description,
|
||||||
|
properties=vision_schema.properties,
|
||||||
|
required=vision_schema.required,
|
||||||
|
handler=flow_fetch_user_image,
|
||||||
|
cancel_on_interruption=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if vision_enabled and cfg.type != "workflow":
|
||||||
llm.register_function(VISION_TOOL_NAME, fetch_user_image)
|
llm.register_function(VISION_TOOL_NAME, fetch_user_image)
|
||||||
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
|
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
|
||||||
llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge)
|
llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge)
|
||||||
|
|
||||||
def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None:
|
def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None:
|
||||||
tools = list(schemas or [])
|
tools = list(schemas or [])
|
||||||
if vision_enabled:
|
if vision_enabled and cfg.type != "workflow":
|
||||||
tools.append(vision_schema)
|
tools.append(vision_schema)
|
||||||
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
|
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
|
||||||
tools.append(knowledge_schema)
|
tools.append(knowledge_schema)
|
||||||
@@ -751,15 +993,16 @@ async def run_pipeline(
|
|||||||
transport.input(),
|
transport.input(),
|
||||||
vision_capture,
|
vision_capture,
|
||||||
text_input,
|
text_input,
|
||||||
stt,
|
stt_processor,
|
||||||
user_aggregator,
|
user_aggregator,
|
||||||
|
user_turn_router,
|
||||||
knowledge_retrieval,
|
knowledge_retrieval,
|
||||||
llm,
|
llm,
|
||||||
# Aggregate the streamed LLM text before TTS. On interruption,
|
# Aggregate the streamed LLM text before TTS. On interruption,
|
||||||
# Pipecat commits the generated prefix immediately instead of
|
# Pipecat commits the generated prefix immediately instead of
|
||||||
# waiting for a TTS provider to emit spoken-text/timestamp frames.
|
# waiting for a TTS provider to emit spoken-text/timestamp frames.
|
||||||
assistant_aggregator,
|
assistant_aggregator,
|
||||||
tts,
|
tts_processor,
|
||||||
EndCallAfterSpeechProcessor(call_end),
|
EndCallAfterSpeechProcessor(call_end),
|
||||||
ConversationHistoryProcessor(recorder),
|
ConversationHistoryProcessor(recorder),
|
||||||
transport.output(),
|
transport.output(),
|
||||||
@@ -774,6 +1017,51 @@ async def run_pipeline(
|
|||||||
enable_rtvi=False,
|
enable_rtvi=False,
|
||||||
)
|
)
|
||||||
worker_holder["worker"] = worker
|
worker_holder["worker"] = worker
|
||||||
|
default_workflow_services = {
|
||||||
|
"llm": current_llm_service,
|
||||||
|
**current_voice_services,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def switch_workflow_services(
|
||||||
|
llm_resource_id: str | None,
|
||||||
|
asr_resource_id: str | None,
|
||||||
|
tts_resource_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
nonlocal current_llm_service
|
||||||
|
requested = (
|
||||||
|
("llm", llm_services, llm_resource_id),
|
||||||
|
("asr", stt_services, asr_resource_id),
|
||||||
|
("tts", tts_services, tts_resource_id),
|
||||||
|
)
|
||||||
|
for kind, services, resource_id in requested:
|
||||||
|
target = (
|
||||||
|
services.get(resource_id)
|
||||||
|
if resource_id
|
||||||
|
else default_workflow_services[kind]
|
||||||
|
)
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
|
||||||
|
current = (
|
||||||
|
current_llm_service
|
||||||
|
if kind == "llm"
|
||||||
|
else current_voice_services[kind]
|
||||||
|
)
|
||||||
|
if current is target:
|
||||||
|
continue
|
||||||
|
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
|
||||||
|
if kind == "llm":
|
||||||
|
current_llm_service = target
|
||||||
|
else:
|
||||||
|
current_voice_services[kind] = target
|
||||||
|
await worker.queue_frame(
|
||||||
|
OutputTransportMessageUrgentFrame(
|
||||||
|
message={
|
||||||
|
"type": "service-switched",
|
||||||
|
"capability": kind.upper(),
|
||||||
|
"resourceId": resource_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def queue_transcript(role: str, content: str, timestamp: str) -> None:
|
async def queue_transcript(role: str, content: str, timestamp: str) -> None:
|
||||||
if content:
|
if content:
|
||||||
@@ -810,6 +1098,16 @@ async def run_pipeline(
|
|||||||
set_system_prompt=set_system_prompt,
|
set_system_prompt=set_system_prompt,
|
||||||
set_tools=set_visible_tools,
|
set_tools=set_visible_tools,
|
||||||
call_end=call_end,
|
call_end=call_end,
|
||||||
|
worker=worker,
|
||||||
|
context_aggregator=WorkflowAggregatorPair(
|
||||||
|
user_aggregator,
|
||||||
|
assistant_aggregator,
|
||||||
|
),
|
||||||
|
transport=transport,
|
||||||
|
switch_services=switch_workflow_services,
|
||||||
|
set_knowledge_scope=knowledge_retrieval.set_scope,
|
||||||
|
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled),
|
||||||
|
flow_global_functions=flow_global_functions,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -823,8 +1121,6 @@ async def run_pipeline(
|
|||||||
|
|
||||||
@user_aggregator.event_handler("on_user_turn_stopped")
|
@user_aggregator.event_handler("on_user_turn_stopped")
|
||||||
async def on_user_turn_stopped(_aggregator, _strategy, message):
|
async def on_user_turn_stopped(_aggregator, _strategy, message):
|
||||||
if message.content:
|
|
||||||
brain.record_user_message(message.content)
|
|
||||||
await queue_transcript("user", message.content, message.timestamp)
|
await queue_transcript("user", message.content, message.timestamp)
|
||||||
|
|
||||||
@assistant_aggregator.event_handler("on_assistant_text_start")
|
@assistant_aggregator.event_handler("on_assistant_text_start")
|
||||||
@@ -869,7 +1165,6 @@ async def run_pipeline(
|
|||||||
@text_input.event_handler("on_text_input")
|
@text_input.event_handler("on_text_input")
|
||||||
async def on_text_input(_processor, text):
|
async def on_text_input(_processor, text):
|
||||||
pending_text_inputs.append(text)
|
pending_text_inputs.append(text)
|
||||||
brain.record_user_message(text)
|
|
||||||
# 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
|
# 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
|
||||||
await queue_transcript("user", text, time_now_iso8601())
|
await queue_transcript("user", text, time_now_iso8601())
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from models import AssistantConfig
|
from models import AssistantConfig, RuntimeModelResource
|
||||||
|
|
||||||
from pipecat.services.openai.llm import OpenAILLMService
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
from pipecat.services.openai.stt import OpenAISTTService
|
from pipecat.services.openai.stt import OpenAISTTService
|
||||||
@@ -26,6 +26,42 @@ from services.pipecat.xfyun_tts import DEFAULT_XFYUN_TTS_URL, XfyunTTSService
|
|||||||
TTS_STOP_FRAME_TIMEOUT_S = 1.0
|
TTS_STOP_FRAME_TIMEOUT_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def config_with_resource(
|
||||||
|
cfg: AssistantConfig, resource: RuntimeModelResource
|
||||||
|
) -> AssistantConfig:
|
||||||
|
"""Return a call-local config view for one workflow model resource."""
|
||||||
|
result = cfg.model_copy(deep=True)
|
||||||
|
values = resource.values or {}
|
||||||
|
secrets = resource.secrets or {}
|
||||||
|
if resource.capability == "LLM":
|
||||||
|
result.model = str(values.get("modelId") or "")
|
||||||
|
result.llm_interface_type = resource.interface_type
|
||||||
|
result.llm_values = values
|
||||||
|
result.llm_secrets = secrets
|
||||||
|
result.llm_api_key = str(secrets.get("apiKey") or "")
|
||||||
|
result.llm_base_url = str(values.get("apiUrl") or "")
|
||||||
|
elif resource.capability == "ASR":
|
||||||
|
result.asr = str(values.get("modelId") or "")
|
||||||
|
result.stt_language = str(values.get("language") or "")
|
||||||
|
result.stt_interface_type = resource.interface_type
|
||||||
|
result.stt_values = values
|
||||||
|
result.stt_secrets = secrets
|
||||||
|
result.stt_api_key = str(secrets.get("apiKey") or "")
|
||||||
|
result.stt_base_url = str(values.get("apiUrl") or "")
|
||||||
|
elif resource.capability == "TTS":
|
||||||
|
result.tts_model = str(values.get("modelId") or "")
|
||||||
|
result.voice = str(values.get("voice") or "")
|
||||||
|
result.tts_speed = float(values.get("speed") or 1.0)
|
||||||
|
result.tts_interface_type = resource.interface_type
|
||||||
|
result.tts_values = values
|
||||||
|
result.tts_secrets = secrets
|
||||||
|
result.tts_api_key = str(secrets.get("apiKey") or "")
|
||||||
|
result.tts_base_url = str(values.get("apiUrl") or "")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"工作流模型资源能力无效:{resource.capability}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _require(value: str, label: str) -> str:
|
def _require(value: str, label: str) -> str:
|
||||||
if value:
|
if value:
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -175,9 +175,9 @@ def prepare_dynamic_config(
|
|||||||
trusted_values: dict[str, Any] | None = None,
|
trusted_values: dict[str, Any] | None = None,
|
||||||
) -> AssistantConfig:
|
) -> AssistantConfig:
|
||||||
"""Validate and merge one call's values without mutating stored config."""
|
"""Validate and merge one call's values without mutating stored config."""
|
||||||
if cfg.type != "prompt":
|
if cfg.type not in {"prompt", "workflow"}:
|
||||||
if client_values:
|
if client_values:
|
||||||
raise DynamicVariableError("动态变量目前仅支持 prompt 助手")
|
raise DynamicVariableError("动态变量仅支持 prompt 和 workflow 助手")
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
supplied = _validate_public(client_values)
|
supplied = _validate_public(client_values)
|
||||||
@@ -214,9 +214,13 @@ def prepare_dynamic_config(
|
|||||||
prepared = cfg.model_copy(deep=True)
|
prepared = cfg.model_copy(deep=True)
|
||||||
prepared.dynamic_variables = merged
|
prepared.dynamic_variables = merged
|
||||||
prepared.conversation_id = conversation_id
|
prepared.conversation_id = conversation_id
|
||||||
# Validate prompt and greeting before media resources are allocated.
|
# Validate top-level prompt/greeting before media resources are allocated.
|
||||||
|
# Workflow node templates are rendered lazily as their nodes become active.
|
||||||
store = DynamicVariableStore.from_config(prepared)
|
store = DynamicVariableStore.from_config(prepared)
|
||||||
store.render(prepared.prompt)
|
if prepared.type == "prompt":
|
||||||
|
store.render(prepared.prompt)
|
||||||
|
elif prepared.type == "workflow":
|
||||||
|
store.render_data(prepared.graph)
|
||||||
store.render(prepared.greeting)
|
store.render(prepared.greeting)
|
||||||
return prepared
|
return prepared
|
||||||
|
|
||||||
|
|||||||
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 @@
|
|||||||
"""工作流图引擎(第一版)。
|
"""Pure Workflow v3 graph queries and deterministic edge evaluation."""
|
||||||
|
|
||||||
对应 dograh 的 pipecat_engine.py,极简实现:
|
|
||||||
- 单个 startCall 入口,开场白来自该节点;
|
|
||||||
- agentNode 用各自的 prompt 驱动多轮对话;
|
|
||||||
- globalNode 不参与连线,按节点开关向会话节点注入统一提示词;
|
|
||||||
- 每轮助手回复后,用一次轻量 LLM「路由」判断是否满足某条出边的 condition,
|
|
||||||
满足则切换当前节点(linear = 单边;branching = 多边按条件分流);
|
|
||||||
- 到达 endCall 播放结束语并停止路由。
|
|
||||||
|
|
||||||
只读图结构,不持有对话状态(当前节点由 pipeline 维护),便于单测。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from services.node_specs import normalize_graph
|
||||||
|
from services.runtime_variables import DynamicVariableStore
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentStageConfig:
|
||||||
|
"""The complete assistant configuration active inside one Agent node."""
|
||||||
|
|
||||||
|
inherits_global: bool
|
||||||
|
llm_resource_id: str
|
||||||
|
asr_resource_id: str
|
||||||
|
tts_resource_id: str
|
||||||
|
tool_ids: tuple[str, ...]
|
||||||
|
knowledge_base_id: str | None
|
||||||
|
knowledge_mode: str
|
||||||
|
knowledge_top_n: int
|
||||||
|
knowledge_score_threshold: float
|
||||||
|
|
||||||
|
|
||||||
class WorkflowEngine:
|
class WorkflowEngine:
|
||||||
def __init__(self, graph: dict[str, Any]):
|
def __init__(self, graph: dict[str, Any]):
|
||||||
nodes = graph.get("nodes") or []
|
self.graph = normalize_graph(graph)
|
||||||
self.nodes: dict[str, dict] = {n["id"]: n for n in nodes if n.get("id")}
|
self.settings = self.graph.get("settings") or {}
|
||||||
self.edges: list[dict] = graph.get("edges") or []
|
self.nodes: dict[str, dict] = {
|
||||||
self.start_id: str | None = next(
|
str(node["id"]): node
|
||||||
(nid for nid, n in self.nodes.items() if n.get("type") == "startCall"),
|
for node in self.graph.get("nodes") or []
|
||||||
None,
|
if node.get("id")
|
||||||
)
|
}
|
||||||
self.global_id: str | None = next(
|
self.edges: list[dict] = list(self.graph.get("edges") or [])
|
||||||
(nid for nid, n in self.nodes.items() if n.get("type") == "globalNode"),
|
self.start_id = next(
|
||||||
|
(
|
||||||
|
node_id
|
||||||
|
for node_id, node in self.nodes.items()
|
||||||
|
if node.get("type") == "start"
|
||||||
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- 结构查询 ----
|
def has_graph(self) -> bool:
|
||||||
def node_type(self, nid: str | None) -> str | None:
|
return bool(self.start_id)
|
||||||
return self.nodes.get(nid or "", {}).get("type")
|
|
||||||
|
|
||||||
def data(self, nid: str | None) -> dict:
|
def node_type(self, node_id: str | None) -> str | None:
|
||||||
return self.nodes.get(nid or "", {}).get("data") or {}
|
return self.nodes.get(node_id or "", {}).get("type")
|
||||||
|
|
||||||
def name(self, nid: str | None) -> str:
|
def data(self, node_id: str | None) -> dict:
|
||||||
return self.data(nid).get("name") or (self.node_type(nid) or "")
|
return self.nodes.get(node_id or "", {}).get("data") or {}
|
||||||
|
|
||||||
def outgoing(self, nid: str | None) -> list[dict]:
|
def name(self, node_id: str | None) -> str:
|
||||||
return [e for e in self.edges if e.get("source") == nid]
|
return str(self.data(node_id).get("name") or self.node_type(node_id) or "")
|
||||||
|
|
||||||
|
def outgoing(self, node_id: str | None) -> list[dict]:
|
||||||
|
result = [edge for edge in self.edges if edge.get("source") == node_id]
|
||||||
|
return sorted(
|
||||||
|
result,
|
||||||
|
key=lambda edge: int((edge.get("data") or {}).get("priority", 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_outgoing(self, node_id: str | None) -> bool:
|
||||||
|
return any(edge.get("source") == node_id for edge in self.edges)
|
||||||
|
|
||||||
|
def edge_mode(self, edge: dict) -> str:
|
||||||
|
return str((edge.get("data") or {}).get("mode") or "always")
|
||||||
|
|
||||||
def edge_fn_name(self, edge: dict) -> str:
|
def edge_fn_name(self, edge: dict) -> str:
|
||||||
"""每条边对应一个 LLM 函数名(稳定、合法标识符)。"""
|
|
||||||
raw = edge.get("id") or f"{edge.get('source')}_{edge.get('target')}"
|
raw = edge.get("id") or f"{edge.get('source')}_{edge.get('target')}"
|
||||||
slug = re.sub(r"[^a-z0-9]+", "_", str(raw).lower()).strip("_")
|
slug = re.sub(r"[^a-z0-9]+", "_", str(raw).lower()).strip("_")
|
||||||
return f"goto_{slug or 'next'}"
|
return f"goto_{slug or 'next'}"
|
||||||
|
|
||||||
def edge_condition(self, edge: dict) -> str:
|
def edge_description(self, edge: dict) -> str:
|
||||||
return (edge.get("data") or {}).get("condition") or ""
|
data = edge.get("data") or {}
|
||||||
|
target = self.name(str(edge.get("target") or ""))
|
||||||
|
condition = str(data.get("condition") or "").strip()
|
||||||
|
if condition:
|
||||||
|
return f"当满足以下条件时转到「{target}」:{condition}"
|
||||||
|
return f"当当前阶段任务完成时转到「{target}」。"
|
||||||
|
|
||||||
def edge_transition_speech(self, edge: dict | None) -> str:
|
def edge_transition_speech(self, edge: dict | None) -> str:
|
||||||
"""命中该边、切换节点瞬间播报的过渡语(可选,掩盖延迟,不写入上下文)。"""
|
|
||||||
if not edge:
|
if not edge:
|
||||||
return ""
|
return ""
|
||||||
return (edge.get("data") or {}).get("transition_speech") or ""
|
data = edge.get("data") or {}
|
||||||
|
return str(
|
||||||
def find_edge(self, source: str | None, target: str | None) -> dict | None:
|
data.get("transitionSpeech") or data.get("transition_speech") or ""
|
||||||
for edge in self.edges:
|
|
||||||
if edge.get("source") == source and edge.get("target") == target:
|
|
||||||
return edge
|
|
||||||
return None
|
|
||||||
|
|
||||||
def edge_description(self, edge: dict) -> str:
|
|
||||||
"""作为转移函数的 description 交给 LLM:满足该条件时模型应调用此函数。"""
|
|
||||||
cond = self.edge_condition(edge)
|
|
||||||
target = self.name(edge.get("target"))
|
|
||||||
if cond:
|
|
||||||
return f"当满足以下条件时调用以转到节点「{target}」:{cond}"
|
|
||||||
return f"当当前节点任务完成、应继续推进对话时调用以转到节点「{target}」。"
|
|
||||||
|
|
||||||
def is_end(self, nid: str | None) -> bool:
|
|
||||||
return self.node_type(nid) == "endCall"
|
|
||||||
|
|
||||||
def has_graph(self) -> bool:
|
|
||||||
return self.start_id is not None
|
|
||||||
|
|
||||||
def greeting(self) -> str:
|
|
||||||
return self.data(self.start_id).get("greeting") or ""
|
|
||||||
|
|
||||||
def system_prompt_for(self, nid: str | None) -> str:
|
|
||||||
"""组合当前节点提示与可选的全局提示(开始节点也是会话节点)。"""
|
|
||||||
header = f"[当前节点:{self.name(nid)}]"
|
|
||||||
node_data = self.data(nid)
|
|
||||||
prompt = str(node_data.get("prompt") or "").strip()
|
|
||||||
node_type = self.node_type(nid)
|
|
||||||
default_add_global = node_type in {"startCall", "agentNode"}
|
|
||||||
add_global = bool(node_data.get("addGlobalPrompt", default_add_global))
|
|
||||||
global_prompt = (
|
|
||||||
str(self.data(self.global_id).get("prompt") or "").strip()
|
|
||||||
if add_global and self.global_id
|
|
||||||
else ""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sections = [header]
|
def global_prompt(self) -> str:
|
||||||
if global_prompt:
|
return str(self.settings.get("globalPrompt") or "").strip()
|
||||||
sections.append(f"[全局规则]\n{global_prompt}")
|
|
||||||
|
def inherits_global_config(self, node_id: str) -> bool:
|
||||||
|
"""Return the Agent's explicit configuration scope, defaulting to global."""
|
||||||
|
return bool(self.data(node_id).get("inheritGlobalConfig", True))
|
||||||
|
|
||||||
|
def agent_stage_config(self, node_id: str) -> AgentStageConfig:
|
||||||
|
"""Resolve either Workflow defaults or one Agent's complete override."""
|
||||||
|
data = self.data(node_id)
|
||||||
|
inherits_global = self.inherits_global_config(node_id)
|
||||||
|
source = self.settings if inherits_global else data
|
||||||
|
llm_key = "defaultLlmResourceId" if inherits_global else "llmResourceId"
|
||||||
|
asr_key = "defaultAsrResourceId" if inherits_global else "asrResourceId"
|
||||||
|
tts_key = "defaultTtsResourceId" if inherits_global else "ttsResourceId"
|
||||||
|
knowledge_base_id = str(source.get("knowledgeBaseId") or "")
|
||||||
|
return AgentStageConfig(
|
||||||
|
inherits_global=inherits_global,
|
||||||
|
llm_resource_id=str(source.get(llm_key) or ""),
|
||||||
|
asr_resource_id=str(source.get(asr_key) or ""),
|
||||||
|
tts_resource_id=str(source.get(tts_key) or ""),
|
||||||
|
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []),
|
||||||
|
knowledge_base_id=knowledge_base_id or None,
|
||||||
|
knowledge_mode=(
|
||||||
|
str(source.get("knowledgeMode") or "automatic")
|
||||||
|
if knowledge_base_id
|
||||||
|
else "disabled"
|
||||||
|
),
|
||||||
|
knowledge_top_n=int(source.get("knowledgeTopN") or 5),
|
||||||
|
knowledge_score_threshold=float(
|
||||||
|
source.get("knowledgeScoreThreshold") or 0.0
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
||||||
|
"""Build the Agent system prompt according to its inheritance setting."""
|
||||||
|
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
|
||||||
|
sections = [f"[当前阶段:{self.name(node_id)}]"]
|
||||||
|
if self.inherits_global_config(node_id) and self.global_prompt():
|
||||||
|
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
|
||||||
if prompt:
|
if prompt:
|
||||||
sections.append(f"[当前节点任务]\n{prompt}")
|
sections.append(f"[当前阶段任务]\n{prompt}")
|
||||||
return "\n\n".join(sections)
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
# ---- 路由:决定下一节点 ----
|
def greeting(self, store: DynamicVariableStore) -> str:
|
||||||
async def route(
|
return store.render(str(self.data(self.start_id).get("greeting") or ""))
|
||||||
|
|
||||||
|
def expression_matches(self, expression: dict, values: dict[str, Any]) -> bool:
|
||||||
|
results = []
|
||||||
|
for rule in expression.get("rules") or []:
|
||||||
|
name = str(rule.get("variable") or "")
|
||||||
|
operator = str(rule.get("operator") or "")
|
||||||
|
expected = rule.get("value")
|
||||||
|
exists = name in values
|
||||||
|
actual = values.get(name)
|
||||||
|
try:
|
||||||
|
if operator == "exists":
|
||||||
|
matched = exists if expected is not False else not exists
|
||||||
|
elif operator == "eq":
|
||||||
|
matched = actual == expected
|
||||||
|
elif operator == "neq":
|
||||||
|
matched = actual != expected
|
||||||
|
elif operator == "gt":
|
||||||
|
matched = actual > expected
|
||||||
|
elif operator == "gte":
|
||||||
|
matched = actual >= expected
|
||||||
|
elif operator == "lt":
|
||||||
|
matched = actual < expected
|
||||||
|
elif operator == "lte":
|
||||||
|
matched = actual <= expected
|
||||||
|
elif operator == "contains":
|
||||||
|
matched = expected in actual
|
||||||
|
elif operator == "in":
|
||||||
|
matched = actual in expected
|
||||||
|
else:
|
||||||
|
matched = False
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
matched = False
|
||||||
|
results.append(matched)
|
||||||
|
if not results:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
all(results)
|
||||||
|
if expression.get("combinator", "and") == "and"
|
||||||
|
else any(results)
|
||||||
|
)
|
||||||
|
|
||||||
|
def deterministic_edge(
|
||||||
self,
|
self,
|
||||||
nid: str | None,
|
node_id: str,
|
||||||
history: list[dict],
|
store: DynamicVariableStore,
|
||||||
*,
|
*,
|
||||||
api_key: str,
|
include_default: bool,
|
||||||
base_url: str,
|
) -> dict | None:
|
||||||
model: str,
|
default = None
|
||||||
) -> str | None:
|
for edge in self.outgoing(node_id):
|
||||||
"""根据对话历史判断当前节点是否应转移。返回目标节点 id,或 None 表示停留。"""
|
data = edge.get("data") or {}
|
||||||
outs = self.outgoing(nid)
|
mode = data.get("mode")
|
||||||
if not outs:
|
if mode == "expression" and self.expression_matches(
|
||||||
return None
|
data.get("expression") or {}, store.values
|
||||||
|
):
|
||||||
|
return edge
|
||||||
|
if mode == "always":
|
||||||
|
default = edge
|
||||||
|
return default if include_default else None
|
||||||
|
|
||||||
options = []
|
def llm_edges(self, node_id: str) -> list[dict]:
|
||||||
for i, edge in enumerate(outs, 1):
|
return [
|
||||||
edata = edge.get("data") or {}
|
edge
|
||||||
cond = edata.get("condition") or "(无明确条件,作为默认后继)"
|
for edge in self.outgoing(node_id)
|
||||||
tgt_name = self.name(edge.get("target"))
|
if self.edge_mode(edge) in {"llm", "always"}
|
||||||
options.append(f"{i}. 条件:{cond} → 目标节点:{tgt_name}")
|
]
|
||||||
|
|
||||||
convo = "\n".join(
|
|
||||||
f"{m['role']}: {m['content']}" for m in history[-8:] if m.get("content")
|
|
||||||
)
|
|
||||||
system = (
|
|
||||||
"你是语音对话的流程路由器。根据最近的对话,判断是否已满足某条转移条件。\n"
|
|
||||||
"规则:仅当某条件被明确满足时,返回其编号;若都不满足或不确定,返回 0 "
|
|
||||||
"(停留在当前节点继续对话)。只输出一个数字,不要任何解释。"
|
|
||||||
)
|
|
||||||
user = (
|
|
||||||
"可选转移:\n"
|
|
||||||
+ "\n".join(options)
|
|
||||||
+ f"\n\n对话记录:\n{convo}\n\n请只返回编号(0 表示停留):"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
|
|
||||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url or None)
|
|
||||||
resp = await client.chat.completions.create(
|
|
||||||
model=model,
|
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": system},
|
|
||||||
{"role": "user", "content": user},
|
|
||||||
],
|
|
||||||
temperature=0,
|
|
||||||
max_tokens=5,
|
|
||||||
)
|
|
||||||
text = (resp.choices[0].message.content or "").strip()
|
|
||||||
except Exception as exc: # noqa: BLE001 - 路由失败不应中断通话
|
|
||||||
logger.warning(f"工作流路由调用失败,停留当前节点: {exc}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
match = re.search(r"\d+", text)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
idx = int(match.group())
|
|
||||||
if idx < 1 or idx > len(outs):
|
|
||||||
return None
|
|
||||||
target = outs[idx - 1].get("target")
|
|
||||||
logger.info(f"工作流路由: {self.name(nid)} → {self.name(target)} (edge {idx})")
|
|
||||||
return target
|
|
||||||
|
|||||||
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,
|
LLMContextFrame,
|
||||||
LLMFullResponseEndFrame,
|
LLMFullResponseEndFrame,
|
||||||
LLMFullResponseStartFrame,
|
LLMFullResponseStartFrame,
|
||||||
|
LLMRunFrame,
|
||||||
LLMTextFrame,
|
LLMTextFrame,
|
||||||
|
OutputTransportMessageUrgentFrame,
|
||||||
|
TTSSpeakFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
@@ -111,6 +114,19 @@ class BrainRegistryTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("user_name", assistant.dynamic_variable_definitions)
|
self.assertIn("user_name", assistant.dynamic_variable_definitions)
|
||||||
|
|
||||||
|
def test_workflow_keeps_dynamic_variables_and_tool_bindings(self):
|
||||||
|
assistant = AssistantUpsert(
|
||||||
|
name="workflow",
|
||||||
|
type="workflow",
|
||||||
|
toolIds=["tool_a"],
|
||||||
|
dynamicVariableDefinitions={
|
||||||
|
"customer": {"type": "string", "required": False, "default": "王先生"}
|
||||||
|
},
|
||||||
|
graph={},
|
||||||
|
)
|
||||||
|
self.assertEqual(assistant.tool_ids, ["tool_a"])
|
||||||
|
self.assertIn("customer", assistant.dynamic_variable_definitions)
|
||||||
|
|
||||||
|
|
||||||
class DifyHelpersTests(unittest.TestCase):
|
class DifyHelpersTests(unittest.TestCase):
|
||||||
def test_normalize_api_base(self):
|
def test_normalize_api_base(self):
|
||||||
@@ -363,7 +379,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
params = FakeFunctionParams(
|
params = FakeFunctionParams(
|
||||||
{"order_id": "A/1", "Authorization": "attacker-value"}
|
{"order_id": "A/1", "Authorization": "attacker-value"}
|
||||||
)
|
)
|
||||||
with patch("services.brains.prompt_brain.httpx.AsyncClient", FakeClient):
|
with patch("services.tool_executor.httpx.AsyncClient", FakeClient):
|
||||||
await llm.functions["lookup_order"](params)
|
await llm.functions["lookup_order"](params)
|
||||||
|
|
||||||
self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1")
|
self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1")
|
||||||
@@ -375,68 +391,315 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
async def test_nodes_without_outgoing_edges_remain_active(self):
|
||||||
graph = {
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "start",
|
|
||||||
"type": "startCall",
|
|
||||||
"data": {"name": "开始", "prompt": "收集需求"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "end",
|
|
||||||
"type": "endCall",
|
|
||||||
"data": {"name": "结束", "prompt": "礼貌结束"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"edges": [
|
|
||||||
{
|
|
||||||
"id": "finish",
|
|
||||||
"source": "start",
|
|
||||||
"target": "end",
|
|
||||||
"data": {"condition": "需求已收集"},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
brain = WorkflowBrain(graph)
|
|
||||||
llm = FakeLLM()
|
|
||||||
context = LLMContext(messages=[])
|
|
||||||
queued = []
|
queued = []
|
||||||
prompts = []
|
|
||||||
visible_tools = []
|
|
||||||
call_end = FakeCallEnd()
|
|
||||||
|
|
||||||
async def queue_frame(frame):
|
async def queue_frame(frame):
|
||||||
queued.append(frame)
|
queued.append(frame)
|
||||||
|
|
||||||
|
runtime = BrainRuntime(
|
||||||
|
context=LLMContext(messages=[]),
|
||||||
|
llm=FakeLLM(),
|
||||||
|
queue_frame=queue_frame,
|
||||||
|
set_system_prompt=lambda _prompt: None,
|
||||||
|
set_tools=lambda _tools: None,
|
||||||
|
call_end=FakeCallEnd(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
def __init__(self, current_node=None):
|
||||||
|
self.current_node = current_node
|
||||||
|
|
||||||
|
async def initialize(self, config):
|
||||||
|
self.current_node = config["name"]
|
||||||
|
|
||||||
|
start_brain = WorkflowBrain(
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [{"id": "start", "type": "start", "data": {}}],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
start_brain._runtime = runtime
|
||||||
|
start_brain._manager = FakeManager()
|
||||||
|
await start_brain.on_connected()
|
||||||
|
self.assertEqual(start_brain._manager.current_node, "start")
|
||||||
|
|
||||||
|
agent_brain = WorkflowBrain(
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {"globalPrompt": "全局规则"},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "agent",
|
||||||
|
"type": "agent",
|
||||||
|
"data": {"prompt": "持续回答"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "begin",
|
||||||
|
"source": "start",
|
||||||
|
"target": "agent",
|
||||||
|
"data": {"mode": "always", "priority": 0},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
agent_brain._runtime = runtime
|
||||||
|
agent_brain._manager = FakeManager("agent")
|
||||||
|
queued.clear()
|
||||||
|
handled = await agent_brain.on_user_turn_end("请继续回答")
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(agent_brain._manager.current_node, "agent")
|
||||||
|
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
||||||
|
|
||||||
|
handoff_brain = WorkflowBrain(
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "handoff",
|
||||||
|
"type": "handoff",
|
||||||
|
"data": {"targetType": "human"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
handoff_brain._runtime = runtime
|
||||||
|
handoff_config = await handoff_brain._resolve_path("handoff")
|
||||||
|
self.assertEqual(handoff_config["name"], "handoff")
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("type") == "handoff-requested"
|
||||||
|
for frame in queued
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
||||||
|
graph = {
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {
|
||||||
|
"globalPrompt": "全局规则",
|
||||||
|
"defaultLlmResourceId": "llm_global",
|
||||||
|
"defaultAsrResourceId": "asr_global",
|
||||||
|
"defaultTtsResourceId": "tts_global",
|
||||||
|
"knowledgeBaseId": "kb_global",
|
||||||
|
"knowledgeMode": "automatic",
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"type": "start",
|
||||||
|
"data": {"name": "Start"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "agent",
|
||||||
|
"type": "agent",
|
||||||
|
"data": {
|
||||||
|
"name": "收集需求",
|
||||||
|
"prompt": "服务 {{user_name}}",
|
||||||
|
"contextPolicy": "fresh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end",
|
||||||
|
"type": "end",
|
||||||
|
"data": {"name": "End", "message": "感谢来电", "scope": "session"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "begin",
|
||||||
|
"source": "start",
|
||||||
|
"target": "agent",
|
||||||
|
"data": {"mode": "always", "priority": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "finish",
|
||||||
|
"source": "agent",
|
||||||
|
"target": "end",
|
||||||
|
"data": {
|
||||||
|
"mode": "llm",
|
||||||
|
"priority": 10,
|
||||||
|
"condition": "需求已收集",
|
||||||
|
"transitionSpeech": "正在为你结束流程",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
cfg = prepare_dynamic_config(
|
||||||
|
AssistantConfig(
|
||||||
|
type="workflow",
|
||||||
|
graph=graph,
|
||||||
|
dynamic_variable_definitions={
|
||||||
|
"user_name": {"type": "string", "required": True}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{"user_name": "王先生"},
|
||||||
|
assistant_id="asst_workflow",
|
||||||
|
)
|
||||||
|
brain = WorkflowBrain(cfg)
|
||||||
|
llm = FakeLLM()
|
||||||
|
context = LLMContext(messages=[])
|
||||||
|
queued = []
|
||||||
|
service_switches = []
|
||||||
|
knowledge_scopes = []
|
||||||
|
call_end = FakeCallEnd()
|
||||||
|
|
||||||
|
class FakeWorker:
|
||||||
|
def __init__(self):
|
||||||
|
self.frames = []
|
||||||
|
self.handlers = {}
|
||||||
|
|
||||||
|
def set_reached_downstream_filter(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def event_handler(self, name):
|
||||||
|
def decorator(fn):
|
||||||
|
self.handlers[name] = fn
|
||||||
|
return fn
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
async def queue_frame(self, frame):
|
||||||
|
self.frames.append(frame)
|
||||||
|
|
||||||
|
async def queue_frames(self, frames):
|
||||||
|
self.frames.extend(frames)
|
||||||
|
|
||||||
|
worker = FakeWorker()
|
||||||
|
pair = SimpleNamespace(
|
||||||
|
user=lambda: SimpleNamespace(_context=context),
|
||||||
|
assistant=lambda: SimpleNamespace(has_function_calls_in_progress=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def queue_frame(frame):
|
||||||
|
queued.append(frame)
|
||||||
|
|
||||||
|
async def switch_services(llm_id, asr_id, tts_id):
|
||||||
|
service_switches.append((llm_id, asr_id, tts_id))
|
||||||
|
|
||||||
runtime = BrainRuntime(
|
runtime = BrainRuntime(
|
||||||
context=context,
|
context=context,
|
||||||
llm=llm,
|
llm=llm,
|
||||||
queue_frame=queue_frame,
|
queue_frame=queue_frame,
|
||||||
set_system_prompt=prompts.append,
|
set_system_prompt=lambda _prompt: None,
|
||||||
set_tools=lambda tools: visible_tools.append(tools or []),
|
set_tools=lambda _tools: None,
|
||||||
call_end=call_end,
|
call_end=call_end,
|
||||||
|
worker=worker,
|
||||||
|
context_aggregator=pair,
|
||||||
|
switch_services=switch_services,
|
||||||
|
set_knowledge_scope=knowledge_scopes.append,
|
||||||
)
|
)
|
||||||
await brain.setup(AssistantConfig(type="workflow", graph=graph), runtime)
|
await brain.setup(cfg, runtime)
|
||||||
|
await brain.on_connected()
|
||||||
self.assertIn("goto_finish", llm.functions)
|
self.assertEqual(brain._manager.current_node, "agent")
|
||||||
self.assertIn("收集需求", prompts[-1])
|
self.assertEqual(
|
||||||
self.assertEqual(visible_tools[-1][0].name, "goto_finish")
|
service_switches,
|
||||||
|
[("llm_global", "asr_global", "tts_global")],
|
||||||
params = FakeFunctionParams()
|
|
||||||
await llm.functions["goto_finish"](params)
|
|
||||||
self.assertEqual(params.result, {"status": "ok"})
|
|
||||||
self.assertIn("礼貌结束", prompts[-1])
|
|
||||||
self.assertEqual(visible_tools[-1], [])
|
|
||||||
|
|
||||||
await brain.on_assistant_text_start("closing-turn")
|
|
||||||
await brain.on_assistant_text_end(
|
|
||||||
"closing-turn",
|
|
||||||
"感谢来电,再见。",
|
|
||||||
False,
|
|
||||||
)
|
)
|
||||||
|
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_global")
|
||||||
|
|
||||||
|
brain._engine.data("agent").update(
|
||||||
|
{
|
||||||
|
"inheritGlobalConfig": False,
|
||||||
|
"llmResourceId": "llm_agent",
|
||||||
|
"asrResourceId": "asr_agent",
|
||||||
|
"ttsResourceId": "tts_agent",
|
||||||
|
"knowledgeBaseId": "kb_agent",
|
||||||
|
"knowledgeMode": "on_demand",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await brain._apply_agent_stage("agent")
|
||||||
|
self.assertEqual(
|
||||||
|
service_switches[-1],
|
||||||
|
("llm_agent", "asr_agent", "tts_agent"),
|
||||||
|
)
|
||||||
|
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_agent")
|
||||||
|
agent_config = brain._agent_config("agent")
|
||||||
|
self.assertIn("王先生", agent_config["role_message"])
|
||||||
|
self.assertIn("工作流路由已在用户一轮输入结束时完成", agent_config["role_message"])
|
||||||
|
self.assertEqual(agent_config["task_messages"], [])
|
||||||
|
self.assertFalse(agent_config["respond_immediately"])
|
||||||
|
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||||
|
self.assertEqual(
|
||||||
|
agent_config["context_strategy"].strategy.value,
|
||||||
|
"reset",
|
||||||
|
)
|
||||||
|
|
||||||
|
brain._engine.data("agent")["entryMode"] = "generate"
|
||||||
|
generate_config = brain._agent_config("agent")
|
||||||
|
self.assertTrue(generate_config["respond_immediately"])
|
||||||
|
worker.frames.clear()
|
||||||
|
await brain._manager.set_node_from_config(generate_config)
|
||||||
|
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||||
|
|
||||||
|
brain._engine.data("agent").update(
|
||||||
|
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
|
||||||
|
)
|
||||||
|
fixed_config = brain._agent_config("agent")
|
||||||
|
self.assertFalse(fixed_config["respond_immediately"])
|
||||||
|
self.assertEqual(
|
||||||
|
fixed_config["pre_actions"][0]["type"],
|
||||||
|
"workflow_fixed_speech",
|
||||||
|
)
|
||||||
|
self.assertEqual(fixed_config["pre_actions"][0]["text"], "您好,王先生")
|
||||||
|
self.assertEqual(
|
||||||
|
fixed_config["task_messages"],
|
||||||
|
[{"role": "assistant", "content": "您好,王先生"}],
|
||||||
|
)
|
||||||
|
worker.frames.clear()
|
||||||
|
queued.clear()
|
||||||
|
await brain._manager.set_node_from_config(fixed_config)
|
||||||
|
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
||||||
|
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
function.name == "goto_finish"
|
||||||
|
for function in brain._agent_config("agent")["functions"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await brain.on_assistant_text_end("old-turn", "需求已收集", False)
|
||||||
|
self.assertEqual(brain._manager.current_node, "agent")
|
||||||
|
|
||||||
|
class FakeRouter:
|
||||||
|
async def select_edge(self, **_kwargs):
|
||||||
|
return "goto_finish"
|
||||||
|
|
||||||
|
brain._router = FakeRouter()
|
||||||
|
handled = await brain.on_user_turn_end("我的需求已经说完了")
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(brain._manager.current_node, "end")
|
||||||
|
self.assertIn("我的需求已经说完了", brain._store.values["system__conversation_history"])
|
||||||
self.assertTrue(call_end.ending)
|
self.assertTrue(call_end.ending)
|
||||||
self.assertTrue(call_end.armed)
|
self.assertTrue(call_end.armed)
|
||||||
|
self.assertTrue(any(getattr(frame, "text", "") == "感谢来电" for frame in queued))
|
||||||
|
assistant_transcripts = [
|
||||||
|
frame.message.get("content")
|
||||||
|
for frame in queued
|
||||||
|
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("type") == "transcript"
|
||||||
|
and frame.message.get("role") == "assistant"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
assistant_transcripts,
|
||||||
|
["您好,王先生", "正在为你结束流程", "感谢来电"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"正在为你结束流程",
|
||||||
|
brain._store.values["system__conversation_history"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"感谢来电",
|
||||||
|
brain._store.values["system__conversation_history"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from models import AssistantConfig
|
from models import AssistantConfig
|
||||||
from services.pipecat.pipeline import _knowledge_tool_description
|
from pipecat.frames.frames import LLMContextFrame
|
||||||
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from services.pipecat.pipeline import (
|
||||||
|
KNOWLEDGE_CONTEXT_MARKER,
|
||||||
|
KnowledgeRetrievalProcessor,
|
||||||
|
UserTurnRoutingProcessor,
|
||||||
|
_knowledge_tool_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeToolDescriptionTest(unittest.TestCase):
|
class KnowledgeToolDescriptionTest(unittest.TestCase):
|
||||||
@@ -33,6 +41,57 @@ class KnowledgeToolDescriptionTest(unittest.TestCase):
|
|||||||
self.assertNotIn("\n ", description)
|
self.assertNotIn("\n ", description)
|
||||||
self.assertLess(len(description), 1000)
|
self.assertLess(len(description), 1000)
|
||||||
|
|
||||||
|
def test_workflow_knowledge_uses_system_role(self):
|
||||||
|
processor = KnowledgeRetrievalProcessor(None)
|
||||||
|
messages = [
|
||||||
|
{"role": "assistant", "content": "你好"},
|
||||||
|
{
|
||||||
|
"role": "developer",
|
||||||
|
"content": f"{KNOWLEDGE_CONTEXT_MARKER}\n旧检索结果",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
processor._set_context(
|
||||||
|
messages,
|
||||||
|
f"{KNOWLEDGE_CONTEXT_MARKER}\n新检索结果",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(messages[0]["role"], "system")
|
||||||
|
self.assertIn("新检索结果", messages[0]["content"])
|
||||||
|
self.assertFalse(any(message["role"] == "developer" for message in messages))
|
||||||
|
|
||||||
|
|
||||||
|
class UserTurnRoutingProcessorTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_routes_each_user_message_once_before_response_run(self):
|
||||||
|
class FakeBrain:
|
||||||
|
def __init__(self):
|
||||||
|
self.turns = []
|
||||||
|
|
||||||
|
async def on_user_turn_end(self, content):
|
||||||
|
self.turns.append(content)
|
||||||
|
return True
|
||||||
|
|
||||||
|
brain = FakeBrain()
|
||||||
|
processor = UserTurnRoutingProcessor(brain)
|
||||||
|
forwarded = []
|
||||||
|
|
||||||
|
async def push_frame(frame, direction):
|
||||||
|
forwarded.append((frame, direction))
|
||||||
|
|
||||||
|
processor.push_frame = push_frame
|
||||||
|
context = LLMContext(messages=[{"role": "user", "content": "我叫李白"}])
|
||||||
|
frame = LLMContextFrame(context)
|
||||||
|
|
||||||
|
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
|
||||||
|
self.assertEqual(brain.turns, ["我叫李白"])
|
||||||
|
self.assertEqual(forwarded, [])
|
||||||
|
|
||||||
|
# A queued LLMRunFrame after the transition uses the same context. It
|
||||||
|
# must reach the target Agent without invoking routing a second time.
|
||||||
|
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
|
||||||
|
self.assertEqual(brain.turns, ["我叫李白"])
|
||||||
|
self.assertEqual(forwarded, [(frame, FrameDirection.DOWNSTREAM)])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
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,
|
Save,
|
||||||
Mic,
|
Mic,
|
||||||
Send,
|
Send,
|
||||||
HelpCircle,
|
|
||||||
Waypoints,
|
Waypoints,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -53,13 +52,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -94,12 +86,6 @@ import { PageHeader } from "@/components/ui/page-header";
|
|||||||
import { FilterPills } from "@/components/ui/filter-pills";
|
import { FilterPills } from "@/components/ui/filter-pills";
|
||||||
import { SearchInput } from "@/components/ui/search-input";
|
import { SearchInput } from "@/components/ui/search-input";
|
||||||
import { ListToolbar } from "@/components/ui/list-toolbar";
|
import { ListToolbar } from "@/components/ui/list-toolbar";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -131,6 +117,7 @@ import {
|
|||||||
WorkflowEditor,
|
WorkflowEditor,
|
||||||
type WorkflowSettings,
|
type WorkflowSettings,
|
||||||
} from "@/components/workflow/WorkflowEditor";
|
} from "@/components/workflow/WorkflowEditor";
|
||||||
|
import { HelpHint, SectionCard } from "@/components/editor/section-card";
|
||||||
import {
|
import {
|
||||||
defaultGraph,
|
defaultGraph,
|
||||||
type WorkflowGraph,
|
type WorkflowGraph,
|
||||||
@@ -369,7 +356,7 @@ type AssistantTypeOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
/** 提示词、Dify、FastGPT 类型已落地,工作流暂时显示占位页 */
|
/** 提示词、工作流、Dify、FastGPT 已落地;OpenCode 暂时显示即将上线 */
|
||||||
available: boolean;
|
available: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -386,7 +373,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
|
|||||||
label: "使用工作流构建",
|
label: "使用工作流构建",
|
||||||
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
|
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
|
||||||
icon: <Workflow size={20} />,
|
icon: <Workflow size={20} />,
|
||||||
available: false,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "Dify",
|
type: "Dify",
|
||||||
@@ -407,7 +394,7 @@ const assistantTypeOptions: AssistantTypeOption[] = [
|
|||||||
label: "使用 OpenCode 构建",
|
label: "使用 OpenCode 构建",
|
||||||
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
|
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
|
||||||
icon: <Terminal size={20} />,
|
icon: <Terminal size={20} />,
|
||||||
available: true,
|
available: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -478,10 +465,26 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
defaultGraph(),
|
defaultGraph(),
|
||||||
);
|
);
|
||||||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
||||||
|
globalPrompt: defaultGraph().settings.globalPrompt,
|
||||||
|
llm: defaultGraph().settings.defaultLlmResourceId,
|
||||||
|
asr: defaultGraph().settings.defaultAsrResourceId,
|
||||||
|
tts: defaultGraph().settings.defaultTtsResourceId,
|
||||||
|
toolIds: defaultGraph().settings.toolIds,
|
||||||
|
knowledgeBaseId: defaultGraph().settings.knowledgeBaseId,
|
||||||
|
knowledgeRetrievalConfig: {
|
||||||
|
mode: defaultGraph().settings.knowledgeMode,
|
||||||
|
topN: defaultGraph().settings.knowledgeTopN,
|
||||||
|
scoreThreshold: defaultGraph().settings.knowledgeScoreThreshold,
|
||||||
|
},
|
||||||
allowInterrupt: true,
|
allowInterrupt: true,
|
||||||
turnConfig: defaultTurnConfig(),
|
turnConfig: defaultTurnConfig(),
|
||||||
});
|
});
|
||||||
const [debugOpen, setDebugOpen] = useState(false);
|
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
|
||||||
|
useState<Record<string, DynamicVariableDefinition>>({});
|
||||||
|
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
|
||||||
|
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
||||||
|
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(null);
|
||||||
|
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(null);
|
||||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||||
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
|
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
|
||||||
|
|
||||||
@@ -708,6 +711,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: workflowName,
|
name: workflowName,
|
||||||
graph: workflowGraph,
|
graph: workflowGraph,
|
||||||
settings: workflowSettings,
|
settings: workflowSettings,
|
||||||
|
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -848,20 +852,41 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
? (assistant.graph as WorkflowGraph)
|
? (assistant.graph as WorkflowGraph)
|
||||||
: defaultGraph();
|
: defaultGraph();
|
||||||
const wfSettings: WorkflowSettings = {
|
const wfSettings: WorkflowSettings = {
|
||||||
llm: assistant.modelResourceIds.LLM,
|
llm:
|
||||||
asr: assistant.modelResourceIds.ASR,
|
graph.settings?.defaultLlmResourceId ||
|
||||||
tts: assistant.modelResourceIds.TTS,
|
assistant.modelResourceIds.LLM,
|
||||||
|
asr: graph.settings?.defaultAsrResourceId || assistant.modelResourceIds.ASR,
|
||||||
|
tts: graph.settings?.defaultTtsResourceId || assistant.modelResourceIds.TTS,
|
||||||
|
toolIds: graph.settings?.toolIds ?? [],
|
||||||
|
knowledgeBaseId:
|
||||||
|
graph.settings?.knowledgeBaseId || assistant.knowledgeBaseId || "",
|
||||||
|
knowledgeRetrievalConfig: {
|
||||||
|
mode:
|
||||||
|
graph.settings?.knowledgeMode ||
|
||||||
|
assistant.knowledgeRetrievalConfig.mode,
|
||||||
|
topN:
|
||||||
|
graph.settings?.knowledgeTopN ??
|
||||||
|
assistant.knowledgeRetrievalConfig.topN,
|
||||||
|
scoreThreshold:
|
||||||
|
graph.settings?.knowledgeScoreThreshold ??
|
||||||
|
assistant.knowledgeRetrievalConfig.scoreThreshold,
|
||||||
|
},
|
||||||
|
globalPrompt: graph.settings?.globalPrompt ?? "",
|
||||||
allowInterrupt: assistant.enableInterrupt,
|
allowInterrupt: assistant.enableInterrupt,
|
||||||
turnConfig: assistant.turnConfig,
|
turnConfig: assistant.turnConfig,
|
||||||
};
|
};
|
||||||
setWorkflowName(assistant.name);
|
setWorkflowName(assistant.name);
|
||||||
setWorkflowGraph(graph);
|
setWorkflowGraph(graph);
|
||||||
setWorkflowSettings(wfSettings);
|
setWorkflowSettings(wfSettings);
|
||||||
|
setWorkflowDynamicVariableDefinitions(
|
||||||
|
assistant.dynamicVariableDefinitions ?? {},
|
||||||
|
);
|
||||||
setSavedSnapshot(
|
setSavedSnapshot(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
name: assistant.name,
|
name: assistant.name,
|
||||||
graph,
|
graph,
|
||||||
settings: wfSettings,
|
settings: wfSettings,
|
||||||
|
dynamicVariableDefinitions: assistant.dynamicVariableDefinitions ?? {},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -912,7 +937,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
|
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
|
||||||
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
|
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
|
||||||
},
|
},
|
||||||
|
knowledgeBaseId: workflowSettings.knowledgeBaseId || null,
|
||||||
|
knowledgeRetrievalConfig: workflowSettings.knowledgeRetrievalConfig,
|
||||||
|
toolIds: workflowSettings.toolIds,
|
||||||
graph: workflowGraph as unknown as Record<string, unknown>,
|
graph: workflowGraph as unknown as Record<string, unknown>,
|
||||||
|
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -932,6 +961,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: workflowName,
|
name: workflowName,
|
||||||
graph: workflowGraph,
|
graph: workflowGraph,
|
||||||
settings: workflowSettings,
|
settings: workflowSettings,
|
||||||
|
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const dirty =
|
const dirty =
|
||||||
@@ -1377,7 +1407,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
|
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<span className="self-center text-xs text-destructive">
|
<span
|
||||||
|
role="alert"
|
||||||
|
title={saveError}
|
||||||
|
className="line-clamp-1 max-w-[min(42vw,560px)] self-center text-right text-sm leading-5 text-destructive"
|
||||||
|
>
|
||||||
{saveError}
|
{saveError}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1385,7 +1419,12 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
||||||
disabled={!editingId}
|
disabled={!editingId}
|
||||||
onClick={() => setDebugOpen(true)}
|
onClick={() => {
|
||||||
|
setWorkflowSettingsOpen(false);
|
||||||
|
setWorkflowEditingNodeId(null);
|
||||||
|
setWorkflowEditingEdgeId(null);
|
||||||
|
setWorkflowDebugOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Bug size={16} />
|
<Bug size={16} />
|
||||||
调试
|
调试
|
||||||
@@ -1411,43 +1450,51 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
onChange={setWorkflowGraph}
|
onChange={setWorkflowGraph}
|
||||||
settings={workflowSettings}
|
settings={workflowSettings}
|
||||||
onSettingsChange={setWorkflowSettings}
|
onSettingsChange={setWorkflowSettings}
|
||||||
|
onOpenDynamicVariables={() => setDynamicVariablesOpen(true)}
|
||||||
|
editingNodeId={workflowEditingNodeId}
|
||||||
|
onEditingNodeIdChange={setWorkflowEditingNodeId}
|
||||||
|
editingEdgeId={workflowEditingEdgeId}
|
||||||
|
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
|
||||||
|
settingsOpen={workflowSettingsOpen}
|
||||||
|
onSettingsOpenChange={setWorkflowSettingsOpen}
|
||||||
|
debugOpen={workflowDebugOpen}
|
||||||
|
onDebugOpenChange={(open) => {
|
||||||
|
setWorkflowDebugOpen(open);
|
||||||
|
if (!open) setActiveNodeId(null);
|
||||||
|
}}
|
||||||
|
debugPanel={
|
||||||
|
<DebugDrawer
|
||||||
|
overlay
|
||||||
|
assistantId={editingId}
|
||||||
|
onClose={() => {
|
||||||
|
setWorkflowDebugOpen(false);
|
||||||
|
setActiveNodeId(null);
|
||||||
|
}}
|
||||||
|
hasUnsavedChanges={dirty}
|
||||||
|
onNodeActive={setActiveNodeId}
|
||||||
|
dynamicVariablesEnabled
|
||||||
|
dynamicVariableDefinitions={workflowDynamicVariableDefinitions}
|
||||||
|
/>
|
||||||
|
}
|
||||||
activeNodeId={activeNodeId}
|
activeNodeId={activeNodeId}
|
||||||
modelOptions={{
|
modelOptions={{
|
||||||
llm: credOptions("LLM"),
|
llm: credOptions("LLM"),
|
||||||
asr: credOptions("ASR"),
|
asr: credOptions("ASR"),
|
||||||
tts: credOptions("TTS"),
|
tts: credOptions("TTS"),
|
||||||
}}
|
}}
|
||||||
|
toolOptions={tools
|
||||||
|
.filter((tool) => tool.status === "active")
|
||||||
|
.map((tool) => ({ value: tool.id, label: tool.name }))}
|
||||||
|
knowledgeOptions={kbOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Sheet
|
<DynamicVariablesDialog
|
||||||
open={debugOpen}
|
open={dynamicVariablesOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={setDynamicVariablesOpen}
|
||||||
setDebugOpen(open);
|
definitions={workflowDynamicVariableDefinitions}
|
||||||
if (!open) setActiveNodeId(null);
|
onChange={setWorkflowDynamicVariableDefinitions}
|
||||||
}}
|
/>
|
||||||
modal={false}
|
|
||||||
>
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
showOverlay={false}
|
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
|
||||||
className="w-[440px] gap-0 border-l border-hairline bg-card p-0 sm:max-w-[440px]"
|
|
||||||
>
|
|
||||||
<SheetHeader className="sr-only">
|
|
||||||
<SheetTitle>语音调试</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
与当前助手进行语音对话调试,画布会高亮正在激活的节点。
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<DebugDrawer
|
|
||||||
assistantId={editingId}
|
|
||||||
asSheet
|
|
||||||
hasUnsavedChanges={dirty}
|
|
||||||
onNodeActive={setActiveNodeId}
|
|
||||||
/>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1488,10 +1535,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Boxes size={18} />}
|
icon={<Boxes size={15} />}
|
||||||
title="Dify 应用配置"
|
title="Dify 应用配置"
|
||||||
description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
|
description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
|
||||||
>
|
>
|
||||||
@@ -1505,7 +1552,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="语音配置"
|
title="语音配置"
|
||||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
||||||
>
|
>
|
||||||
@@ -1526,7 +1573,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={15} />}
|
||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
@@ -1584,10 +1631,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Database size={18} />}
|
icon={<Database size={15} />}
|
||||||
title="FastGPT 应用配置"
|
title="FastGPT 应用配置"
|
||||||
description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
|
description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
|
||||||
>
|
>
|
||||||
@@ -1601,7 +1648,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="语音配置"
|
title="语音配置"
|
||||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
||||||
>
|
>
|
||||||
@@ -1622,7 +1669,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={15} />}
|
||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
@@ -1684,10 +1731,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Terminal size={18} />}
|
icon={<Terminal size={15} />}
|
||||||
title="OpenCode 服务配置"
|
title="OpenCode 服务配置"
|
||||||
description="从「模型资源」中选择 OpenCode 服务资源。"
|
description="从「模型资源」中选择 OpenCode 服务资源。"
|
||||||
>
|
>
|
||||||
@@ -1701,7 +1748,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<MessageSquareText size={18} />}
|
icon={<MessageSquareText size={15} />}
|
||||||
title="提示词"
|
title="提示词"
|
||||||
description="描述助手的角色、能力和回答要求"
|
description="描述助手的角色、能力和回答要求"
|
||||||
>
|
>
|
||||||
@@ -1714,7 +1761,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="模型与语音配置"
|
title="模型与语音配置"
|
||||||
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
|
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
|
||||||
>
|
>
|
||||||
@@ -1759,7 +1806,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={15} />}
|
||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
@@ -1823,10 +1870,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-6">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -1838,25 +1885,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
|
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
||||||
form.runtimeMode === "pipeline"
|
form.runtimeMode === "pipeline"
|
||||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
<Waypoints size={18} />
|
<Waypoints size={15} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium text-foreground">Pipeline 模式</span>
|
<span className="text-sm font-medium text-foreground">Pipeline 模式</span>
|
||||||
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
|
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{form.runtimeMode === "pipeline" && (
|
{form.runtimeMode === "pipeline" && (
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
<Check size={14} />
|
<Check size={12} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1873,25 +1920,25 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
|
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
||||||
form.runtimeMode === "realtime"
|
form.runtimeMode === "realtime"
|
||||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
<AudioLines size={18} />
|
<AudioLines size={15} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium text-foreground">Realtime 模式</span>
|
<span className="text-sm font-medium text-foreground">Realtime 模式</span>
|
||||||
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
|
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{form.runtimeMode === "realtime" && (
|
{form.runtimeMode === "realtime" && (
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
<Check size={14} />
|
<Check size={12} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1900,7 +1947,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<MessageSquareText size={18} />}
|
icon={<MessageSquareText size={15} />}
|
||||||
title="提示词"
|
title="提示词"
|
||||||
description="描述助手的角色、能力和回答要求"
|
description="描述助手的角色、能力和回答要求"
|
||||||
>
|
>
|
||||||
@@ -1918,7 +1965,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
|
|
||||||
{form.runtimeMode === "pipeline" ? (
|
{form.runtimeMode === "pipeline" ? (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="模型配置"
|
title="模型配置"
|
||||||
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
||||||
>
|
>
|
||||||
@@ -1963,7 +2010,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
) : (
|
) : (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={18} />}
|
icon={<Brain size={15} />}
|
||||||
title="模型配置"
|
title="模型配置"
|
||||||
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
||||||
>
|
>
|
||||||
@@ -1978,7 +2025,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Bot size={18} />}
|
icon={<Bot size={15} />}
|
||||||
title="开场白"
|
title="开场白"
|
||||||
description="助手与用户首次对话时的开场语"
|
description="助手与用户首次对话时的开场语"
|
||||||
>
|
>
|
||||||
@@ -1995,7 +2042,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
|
|
||||||
{form.runtimeMode === "pipeline" && (
|
{form.runtimeMode === "pipeline" && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Database size={18} />}
|
icon={<Database size={15} />}
|
||||||
title="知识库配置"
|
title="知识库配置"
|
||||||
description="选择助手回答时可检索的业务知识来源"
|
description="选择助手回答时可检索的业务知识来源"
|
||||||
>
|
>
|
||||||
@@ -2021,7 +2068,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Wrench size={18} />}
|
icon={<Wrench size={15} />}
|
||||||
title="工具"
|
title="工具"
|
||||||
description="配置该提示词助手可以调用的工具"
|
description="配置该提示词助手可以调用的工具"
|
||||||
>
|
>
|
||||||
@@ -2033,7 +2080,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={15} />}
|
||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
@@ -2132,7 +2179,8 @@ function SegmentedIconButton({
|
|||||||
|
|
||||||
function DebugDrawer({
|
function DebugDrawer({
|
||||||
assistantId,
|
assistantId,
|
||||||
asSheet = false,
|
overlay = false,
|
||||||
|
onClose,
|
||||||
hasUnsavedChanges = false,
|
hasUnsavedChanges = false,
|
||||||
onNodeActive,
|
onNodeActive,
|
||||||
vision = false,
|
vision = false,
|
||||||
@@ -2140,7 +2188,8 @@ function DebugDrawer({
|
|||||||
dynamicVariableDefinitions = {},
|
dynamicVariableDefinitions = {},
|
||||||
}: {
|
}: {
|
||||||
assistantId: string | null;
|
assistantId: string | null;
|
||||||
asSheet?: boolean;
|
overlay?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
hasUnsavedChanges?: boolean;
|
hasUnsavedChanges?: boolean;
|
||||||
onNodeActive?: (nodeId: string | null) => void;
|
onNodeActive?: (nodeId: string | null) => void;
|
||||||
vision?: boolean;
|
vision?: boolean;
|
||||||
@@ -2179,14 +2228,25 @@ function DebugDrawer({
|
|||||||
[camera, preview],
|
[camera, preview],
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerClass = asSheet
|
|
||||||
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
|
|
||||||
: "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={containerClass}>
|
<aside
|
||||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
|
className={overlay
|
||||||
|
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-card"
|
||||||
|
: "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex"}
|
||||||
|
>
|
||||||
|
<div className={`flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-hairline py-3 ${overlay ? "px-4" : "px-5"}`}>
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
{overlay && onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭调试预览"
|
||||||
|
title="关闭"
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="shrink-0 text-sm font-medium text-foreground">
|
<div className="shrink-0 text-sm font-medium text-foreground">
|
||||||
调试与预览
|
调试与预览
|
||||||
</div>
|
</div>
|
||||||
@@ -3344,29 +3404,6 @@ function EditableTitle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HelpHint({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="查看说明"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
|
||||||
>
|
|
||||||
<HelpCircle size={14} />
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
className="w-72 text-sm leading-6 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DynamicVariableEditorHint({
|
function DynamicVariableEditorHint({
|
||||||
count,
|
count,
|
||||||
onOpen,
|
onOpen,
|
||||||
@@ -3624,45 +3661,6 @@ function DynamicVariablesDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionCard({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const hasHeader = Boolean(title);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="rounded-2xl border-hairline bg-card text-card-foreground shadow-sm">
|
|
||||||
{hasHeader && (
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{icon && (
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<CardTitle className="text-base font-medium">{title}</CardTitle>
|
|
||||||
{description && <HelpHint text={description} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardContent className={hasHeader ? "space-y-4" : undefined}>
|
|
||||||
{children}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextAreaField({
|
function TextAreaField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -3679,7 +3677,7 @@ function TextAreaField({
|
|||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
{label && (
|
{label && (
|
||||||
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
|
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
|
||||||
)}
|
)}
|
||||||
<Textarea
|
<Textarea
|
||||||
value={value}
|
value={value}
|
||||||
@@ -3688,7 +3686,7 @@ function TextAreaField({
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
// Override ui/textarea's field-sizing-content so `rows` sets a real height
|
// Override ui/textarea's field-sizing-content so `rows` sets a real height
|
||||||
// instead of collapsing to min-h-16 when the value is short.
|
// instead of collapsing to min-h-16 when the value is short.
|
||||||
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -3714,7 +3712,7 @@ function ResourceSelectField({
|
|||||||
return (
|
return (
|
||||||
<div className="block">
|
<div className="block">
|
||||||
{label && (
|
{label && (
|
||||||
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
|
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
@@ -4023,18 +4021,18 @@ function ToggleRow({
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"flex items-center justify-between border border-hairline bg-canvas-soft",
|
"flex items-center justify-between border border-hairline bg-canvas-soft",
|
||||||
hasIcon ? "rounded-2xl p-5" : "rounded-xl p-4",
|
hasIcon ? "rounded-xl p-3.5" : "rounded-xl px-3.5 py-3",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"flex items-center font-medium text-foreground",
|
"flex items-center text-sm font-medium text-foreground",
|
||||||
hasIcon ? "gap-3" : "gap-1.5",
|
hasIcon ? "gap-2.5" : "gap-1.5",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{icon && (
|
{icon && (
|
||||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -4044,7 +4042,7 @@ function ToggleRow({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { HelpCircle, Settings2 } from "lucide-react";
|
import { Settings2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { HelpHint } from "@/components/editor/section-card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
@@ -13,11 +14,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -59,7 +55,7 @@ export function TurnConfigEditor({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-hairline bg-card p-4 shadow-sm">
|
<div className="flex items-center justify-between gap-3 rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
允许用户打断
|
允许用户打断
|
||||||
@@ -72,7 +68,7 @@ export function TurnConfigEditor({
|
|||||||
aria-label="打开允许用户打断高级配置"
|
aria-label="打开允许用户打断高级配置"
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Settings2 size={14} />
|
<Settings2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
||||||
@@ -161,29 +157,6 @@ function ConfigSection({ title, children }: { title: string; children: ReactNode
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HelpHint({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="查看允许用户打断说明"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
|
||||||
>
|
|
||||||
<HelpCircle size={14} />
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
className="w-72 text-sm leading-6 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberField({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
|
function NumberField({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
|
||||||
return (
|
return (
|
||||||
<label className="block space-y-2">
|
<label className="block space-y-2">
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 条件边。边携带 condition(自然语言条件,LLM 据此决定是否走这条路径)与
|
* Workflow v3 edge: LLM judgement, variable expression, or deterministic default.
|
||||||
* label(日志里识别该路径的短标签)。悬停/选中时在标签旁显示「编辑 / 删除」按钮。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
getSmoothStepPath,
|
getBezierPath,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
@@ -36,20 +35,22 @@ export function ConditionEdge({
|
|||||||
// 点击标签:只选中这条边(露出编辑/删除按钮),不直接进入编辑。
|
// 点击标签:只选中这条边(露出编辑/删除按钮),不直接进入编辑。
|
||||||
const selectThisEdge = () =>
|
const selectThisEdge = () =>
|
||||||
setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === id })));
|
setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === id })));
|
||||||
const [path, labelX, labelY] = getSmoothStepPath({
|
const [path, labelX, labelY] = getBezierPath({
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
sourcePosition,
|
sourcePosition,
|
||||||
targetX,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
targetPosition,
|
targetPosition,
|
||||||
borderRadius: 8,
|
curvature: 0.28,
|
||||||
offset: 20,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const label = ((data?.label as string) || (data?.condition as string) || "")
|
const mode = (data?.mode as string) || "always";
|
||||||
.toString()
|
const label = (
|
||||||
.trim();
|
(data?.label as string) ||
|
||||||
|
(mode === "llm" ? (data?.condition as string) : "") ||
|
||||||
|
(mode === "expression" ? "变量表达式" : "默认路径")
|
||||||
|
).toString().trim();
|
||||||
const expanded = hovered || selected;
|
const expanded = hovered || selected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,9 +27,30 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
|
|
||||||
const nodeData = data as WorkflowNodeData;
|
const nodeData = data as WorkflowNodeData;
|
||||||
const Icon = spec.icon;
|
const Icon = spec.icon;
|
||||||
const preview = (nodeData.greeting || nodeData.prompt || "")
|
const preview = (nodeData.greeting || nodeData.prompt || nodeData.message || "")
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
|
const entryModeLabel = {
|
||||||
|
wait_user: "等待用户",
|
||||||
|
generate: "立即回复",
|
||||||
|
fixed_speech: "固定进入语",
|
||||||
|
}[nodeData.entryMode ?? "wait_user"];
|
||||||
|
const inheritsGlobal = nodeData.inheritGlobalConfig !== false;
|
||||||
|
const meta = type === "agent"
|
||||||
|
? inheritsGlobal
|
||||||
|
? [entryModeLabel, "继承全局配置"]
|
||||||
|
: [
|
||||||
|
entryModeLabel,
|
||||||
|
"自定义配置",
|
||||||
|
nodeData.llmResourceId ? "独立 LLM" : null,
|
||||||
|
`${nodeData.toolIds?.length ?? 0} 工具`,
|
||||||
|
nodeData.knowledgeBaseId ? "知识库" : null,
|
||||||
|
nodeData.asrResourceId ? "独立 ASR" : null,
|
||||||
|
nodeData.ttsResourceId ? "独立 TTS" : null,
|
||||||
|
].filter(Boolean)
|
||||||
|
: type === "action" && nodeData.toolId
|
||||||
|
? ["确定性工具"]
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -46,7 +67,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
{spec.hasTarget && (
|
{spec.hasTarget && (
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Top}
|
||||||
className="!h-3 !w-3 !border-[3px] !border-card !bg-muted-soft"
|
className="!h-3 !w-3 !border-[3px] !border-card !bg-muted-soft"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -86,7 +107,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
>
|
>
|
||||||
<Pencil size={13} />
|
<Pencil size={13} />
|
||||||
</button>
|
</button>
|
||||||
{type !== "startCall" && (
|
{type !== "start" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="删除节点"
|
title="删除节点"
|
||||||
@@ -129,11 +150,22 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{meta.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{meta.map((item) => (
|
||||||
|
<span key={item as string} className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{spec.hasSource && (
|
{spec.hasSource && (
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Bottom}
|
||||||
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary"
|
title="拖到节点或画布空白处"
|
||||||
|
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary transition-transform hover:!scale-125"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,8 +173,9 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
startCall: GenericNode,
|
start: GenericNode,
|
||||||
agentNode: GenericNode,
|
agent: GenericNode,
|
||||||
endCall: GenericNode,
|
action: GenericNode,
|
||||||
globalNode: GenericNode,
|
handoff: GenericNode,
|
||||||
|
end: GenericNode,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,67 @@
|
|||||||
/**
|
/** Workflow v3 public graph types and defaults. */
|
||||||
* 工作流节点的前端类型与运行期规格。
|
|
||||||
*
|
|
||||||
* 节点「目录」(有哪些类型、各自的字段与约束)由后端 /api/node-types 提供,
|
|
||||||
* 通过 useNodeSpecs 拉取后用 toRuntimeSpec 转成带 React 组件(图标)的运行期规格。
|
|
||||||
* 本文件只保留:类型定义、默认图、图标/配色解析。新增节点类型改后端即可。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as LucideIcons from "lucide-react";
|
import * as LucideIcons from "lucide-react";
|
||||||
import { Circle, type LucideIcon } from "lucide-react";
|
import { Circle, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import type { NodeSpecDto } from "@/lib/api";
|
import type { NodeSpecDto } from "@/lib/api";
|
||||||
|
|
||||||
export type WorkflowNodeType =
|
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
|
||||||
| "startCall"
|
export type ContextPolicy = "inherit" | "fresh";
|
||||||
| "agentNode"
|
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
|
||||||
| "endCall"
|
export type AgentEntryMode = "wait_user" | "generate" | "fixed_speech";
|
||||||
| "globalNode";
|
export type EdgeMode = "llm" | "expression" | "always";
|
||||||
|
export type ExpressionOperator =
|
||||||
|
| "eq"
|
||||||
|
| "neq"
|
||||||
|
| "gt"
|
||||||
|
| "gte"
|
||||||
|
| "lt"
|
||||||
|
| "lte"
|
||||||
|
| "contains"
|
||||||
|
| "in"
|
||||||
|
| "exists";
|
||||||
|
|
||||||
export type WorkflowNodeData = {
|
export type WorkflowNodeData = {
|
||||||
/** 节点显示名 */
|
|
||||||
name: string;
|
name: string;
|
||||||
/** 开场白(仅 startCall) */
|
|
||||||
greeting?: string;
|
greeting?: string;
|
||||||
/** 节点提示词 */
|
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
/** 允许打断(仅 agentNode) */
|
contextPolicy?: ContextPolicy;
|
||||||
allowInterrupt?: boolean;
|
inheritGlobalConfig?: boolean;
|
||||||
/** 是否合并全局节点提示词(start/agent 默认开启,end 默认关闭) */
|
entryMode?: AgentEntryMode;
|
||||||
addGlobalPrompt?: boolean;
|
entrySpeech?: string;
|
||||||
|
toolIds?: string[];
|
||||||
|
knowledgeBaseId?: string;
|
||||||
|
knowledgeMode?: KnowledgeMode;
|
||||||
|
knowledgeTopN?: number;
|
||||||
|
knowledgeScoreThreshold?: number;
|
||||||
|
llmResourceId?: string;
|
||||||
|
asrResourceId?: string;
|
||||||
|
ttsResourceId?: string;
|
||||||
|
toolId?: string;
|
||||||
|
arguments?: Record<string, unknown>;
|
||||||
|
resultAssignments?: Record<string, string>;
|
||||||
|
targetType?: "ai" | "human" | "queue" | "phone";
|
||||||
|
target?: string;
|
||||||
|
message?: string;
|
||||||
|
scope?: "flow" | "session";
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExpressionRule = {
|
||||||
|
variable: string;
|
||||||
|
operator: ExpressionOperator;
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowEdgeData = {
|
||||||
|
mode: EdgeMode;
|
||||||
|
priority: number;
|
||||||
|
condition?: string;
|
||||||
|
expression?: { combinator: "and" | "or"; rules: ExpressionRule[] };
|
||||||
|
label?: string;
|
||||||
|
transitionSpeech?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type FieldSpec = {
|
export type FieldSpec = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -39,7 +70,6 @@ export type FieldSpec = {
|
|||||||
default?: unknown;
|
default?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 解析后的运行期节点规格(DTO + 解析出的 React 图标 + 派生句柄) */
|
|
||||||
export type RuntimeNodeSpec = {
|
export type RuntimeNodeSpec = {
|
||||||
type: string;
|
type: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -47,9 +77,7 @@ export type RuntimeNodeSpec = {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
accent: string;
|
accent: string;
|
||||||
addable: boolean;
|
addable: boolean;
|
||||||
/** 入边句柄(开始节点没有) */
|
|
||||||
hasTarget: boolean;
|
hasTarget: boolean;
|
||||||
/** 出边句柄(结束节点没有) */
|
|
||||||
hasSource: boolean;
|
hasSource: boolean;
|
||||||
constraints: {
|
constraints: {
|
||||||
minIncoming?: number;
|
minIncoming?: number;
|
||||||
@@ -62,7 +90,6 @@ export type RuntimeNodeSpec = {
|
|||||||
fields: FieldSpec[];
|
fields: FieldSpec[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 渐变 token → CSS 变量名(图标底色用),未知配色回落到 sky */
|
|
||||||
export const ACCENT_VAR: Record<string, string> = {
|
export const ACCENT_VAR: Record<string, string> = {
|
||||||
mint: "--gradient-mint",
|
mint: "--gradient-mint",
|
||||||
sky: "--gradient-sky",
|
sky: "--gradient-sky",
|
||||||
@@ -75,13 +102,11 @@ export function accentVar(accent: string): string {
|
|||||||
return ACCENT_VAR[accent] ?? ACCENT_VAR.sky;
|
return ACCENT_VAR[accent] ?? ACCENT_VAR.sky;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按名字解析 Lucide 图标,找不到回落到 Circle(对齐 dograh resolveIcon)。 */
|
|
||||||
export function resolveIcon(name: string): LucideIcon {
|
export function resolveIcon(name: string): LucideIcon {
|
||||||
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
|
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
|
||||||
return icons[name] ?? Circle;
|
return icons[name] ?? Circle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 后端 DTO → 运行期规格。hasTarget/hasSource 由入/出边上限派生。 */
|
|
||||||
export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
|
export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
|
||||||
return {
|
return {
|
||||||
type: dto.name,
|
type: dto.name,
|
||||||
@@ -93,12 +118,12 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
|
|||||||
hasTarget: dto.constraints.maxIncoming !== 0,
|
hasTarget: dto.constraints.maxIncoming !== 0,
|
||||||
hasSource: dto.constraints.maxOutgoing !== 0,
|
hasSource: dto.constraints.maxOutgoing !== 0,
|
||||||
constraints: dto.constraints,
|
constraints: dto.constraints,
|
||||||
fields: dto.fields.map((f) => ({
|
fields: dto.fields.map((field) => ({
|
||||||
key: f.key,
|
key: field.key,
|
||||||
label: f.label,
|
label: field.label,
|
||||||
type: f.type,
|
type: field.type,
|
||||||
required: f.required,
|
required: field.required,
|
||||||
default: f.default,
|
default: field.default,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -106,6 +131,18 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
|
|||||||
export type NodeSpecMap = Record<string, RuntimeNodeSpec>;
|
export type NodeSpecMap = Record<string, RuntimeNodeSpec>;
|
||||||
|
|
||||||
export type WorkflowGraph = {
|
export type WorkflowGraph = {
|
||||||
|
specVersion: 3;
|
||||||
|
settings: {
|
||||||
|
globalPrompt: string;
|
||||||
|
defaultLlmResourceId: string;
|
||||||
|
defaultAsrResourceId: string;
|
||||||
|
defaultTtsResourceId: string;
|
||||||
|
toolIds: string[];
|
||||||
|
knowledgeBaseId: string;
|
||||||
|
knowledgeMode: "automatic" | "on_demand";
|
||||||
|
knowledgeTopN: number;
|
||||||
|
knowledgeScoreThreshold: number;
|
||||||
|
};
|
||||||
nodes: Array<{
|
nodes: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
type: WorkflowNodeType;
|
type: WorkflowNodeType;
|
||||||
@@ -116,62 +153,77 @@ export type WorkflowGraph = {
|
|||||||
id: string;
|
id: string;
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
data?: { condition?: string; label?: string; transition_speech?: string };
|
data: WorkflowEdgeData;
|
||||||
}>;
|
}>;
|
||||||
viewport?: { x: number; y: number; zoom: number };
|
viewport?: { x: number; y: number; zoom: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 新建工作流的默认图:全局规则 + 开始 → 智能体 → 结束 */
|
|
||||||
export function defaultGraph(): WorkflowGraph {
|
export function defaultGraph(): WorkflowGraph {
|
||||||
return {
|
return {
|
||||||
|
specVersion: 3,
|
||||||
|
settings: {
|
||||||
|
globalPrompt:
|
||||||
|
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
||||||
|
defaultLlmResourceId: "",
|
||||||
|
defaultAsrResourceId: "",
|
||||||
|
defaultTtsResourceId: "",
|
||||||
|
toolIds: [],
|
||||||
|
knowledgeBaseId: "",
|
||||||
|
knowledgeMode: "automatic",
|
||||||
|
knowledgeTopN: 5,
|
||||||
|
knowledgeScoreThreshold: 0,
|
||||||
|
},
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
id: "start",
|
id: "start",
|
||||||
type: "startCall",
|
type: "start",
|
||||||
position: { x: 100, y: 120 },
|
position: { x: 360, y: 60 },
|
||||||
data: {
|
data: {
|
||||||
name: "开始",
|
name: "Start",
|
||||||
greeting: "你好,我是 AI 视频助手,有什么可以帮你?",
|
greeting: "你好,我是 AI 视频助手,有什么可以帮你?",
|
||||||
prompt: "了解用户的需求,并在信息明确后进入下一节点。",
|
|
||||||
allowInterrupt: true,
|
|
||||||
addGlobalPrompt: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
type: "agentNode",
|
type: "agent",
|
||||||
position: { x: 420, y: 120 },
|
position: { x: 360, y: 300 },
|
||||||
data: {
|
data: {
|
||||||
name: "智能体节点",
|
name: "Agent",
|
||||||
prompt: "根据用户需求提供清晰、准确的帮助。",
|
prompt: "了解用户需求并提供清晰、准确的帮助。",
|
||||||
allowInterrupt: true,
|
contextPolicy: "inherit",
|
||||||
addGlobalPrompt: true,
|
inheritGlobalConfig: true,
|
||||||
|
entryMode: "wait_user",
|
||||||
|
entrySpeech: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "end",
|
id: "end",
|
||||||
type: "endCall",
|
type: "end",
|
||||||
position: { x: 740, y: 120 },
|
position: { x: 360, y: 540 },
|
||||||
data: {
|
data: {
|
||||||
name: "结束",
|
name: "End",
|
||||||
prompt: "总结已经完成的事项,礼貌道别并结束通话。",
|
message: "感谢你的来电,再见。",
|
||||||
addGlobalPrompt: false,
|
scope: "session",
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "global",
|
|
||||||
type: "globalNode",
|
|
||||||
position: { x: 100, y: 400 },
|
|
||||||
data: {
|
|
||||||
name: "全局设定",
|
|
||||||
prompt:
|
|
||||||
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
{ id: "e-start-agent", source: "start", target: "agent-1", data: {} },
|
{
|
||||||
{ id: "e-agent-end", source: "agent-1", target: "end", data: {} },
|
id: "e-start-agent",
|
||||||
|
source: "start",
|
||||||
|
target: "agent-1",
|
||||||
|
data: { mode: "always", priority: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e-agent-end",
|
||||||
|
source: "agent-1",
|
||||||
|
target: "end",
|
||||||
|
data: {
|
||||||
|
mode: "llm",
|
||||||
|
priority: 10,
|
||||||
|
condition: "当前阶段任务已经完成,适合结束会话",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,35 @@ export const API_BASE =
|
|||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding" | "Agent";
|
export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding" | "Agent";
|
||||||
|
|
||||||
|
function formatErrorDetail(detail: unknown): string | null {
|
||||||
|
if (typeof detail === "string") return detail;
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
const messages = detail
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") return null;
|
||||||
|
const record = item as { loc?: unknown; msg?: unknown };
|
||||||
|
const message = typeof record.msg === "string" ? record.msg : null;
|
||||||
|
if (!message) return null;
|
||||||
|
const path = Array.isArray(record.loc)
|
||||||
|
? record.loc
|
||||||
|
.filter((part) => part !== "body")
|
||||||
|
.map(String)
|
||||||
|
.join(".")
|
||||||
|
: "";
|
||||||
|
return path ? `${path}:${message}` : message;
|
||||||
|
})
|
||||||
|
.filter((message): message is string => Boolean(message));
|
||||||
|
return messages.length ? messages.join(";") : null;
|
||||||
|
}
|
||||||
|
if (detail && typeof detail === "object") {
|
||||||
|
const record = detail as { message?: unknown; msg?: unknown };
|
||||||
|
if (typeof record.message === "string") return record.message;
|
||||||
|
if (typeof record.msg === "string") return record.msg;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const isFormData = init?.body instanceof FormData;
|
const isFormData = init?.body instanceof FormData;
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
@@ -30,8 +59,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = `请求失败 (${res.status})`;
|
let detail = `请求失败 (${res.status})`;
|
||||||
try {
|
try {
|
||||||
const body = (await res.json()) as { detail?: string };
|
const body = (await res.json()) as { detail?: unknown };
|
||||||
if (body?.detail) detail = body.detail;
|
detail = formatErrorDetail(body?.detail) ?? detail;
|
||||||
} catch {
|
} catch {
|
||||||
// 响应体不是 JSON,沿用默认错误信息
|
// 响应体不是 JSON,沿用默认错误信息
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user