Add Dify integration and enhance workflow node specifications
- Introduce new fields `dify_api_url` and `dify_api_key` in `AssistantConfig` for Dify API integration. - Update `requirements.txt` to include `dify-client-python` for Dify SDK support. - Modify `config_resolver` to handle Dify connection information. - Add a new `globalNode` type in workflow specifications to provide unified settings across workflows. - Enhance node specifications with additional constraints and default values for better configuration management. - Update frontend components to support the new `globalNode` type and its properties, improving workflow editor functionality.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""可插拔的「大脑」:把不同助手类型在运行时的差异收口到各自的 Brain 实现。"""
|
||||
|
||||
from services.brains.base import Brain, BrainSpec
|
||||
from services.brains.base import Brain, BrainRuntime, BrainSpec
|
||||
from services.brains.registry import SPECS, build_brain
|
||||
|
||||
__all__ = ["Brain", "BrainSpec", "SPECS", "build_brain"]
|
||||
__all__ = ["Brain", "BrainRuntime", "BrainSpec", "SPECS", "build_brain"]
|
||||
|
||||
@@ -1,46 +1,117 @@
|
||||
"""「大脑」抽象:把不同助手类型(prompt/workflow/fastgpt/…)在运行时的差异收口。
|
||||
"""Conversation-brain contracts shared by every assistant type.
|
||||
|
||||
cascade 管线骨架对所有类型一致(STT → LLM 槽 → TTS),变化的只有:
|
||||
- 谁产出助手文本(LLM 槽里塞什么)——build_llm
|
||||
- 开场白来源(静态 / 外部异步拉取)——greeting
|
||||
- 对话上下文归谁维护——spec.owns_context
|
||||
- 是否支持 realtime——spec.supported_runtime_modes
|
||||
|
||||
阶段 1 只抽到「够 fastgpt 用」的程度;workflow 编排仍内联在 pipeline.py,
|
||||
待阶段 2 再搬进 WorkflowBrain 收口。
|
||||
Brain selects who owns reasoning and conversation state. The Pipecat pipeline
|
||||
still owns media transport, STT/TTS, transcript delivery, and interruption
|
||||
semantics. This keeps assistant-specific orchestration out of pipeline.py
|
||||
without coupling brains to Pipecat internals more than necessary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, runtime_checkable
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from models import AssistantConfig
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.frames.frames import Frame
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrainSpec:
|
||||
"""类型元数据。单一来源,供运行时门控与上下文归属决策复用。"""
|
||||
"""Static capabilities used by validation and runtime dispatch."""
|
||||
|
||||
type: str
|
||||
supported_runtime_modes: frozenset[str]
|
||||
# True:由本服务维护 LLMContext(prompt/workflow);
|
||||
# False:上下文/知识库/工具由外部服务端接管(fastgpt/dify),本地不写 context。
|
||||
# False means context, knowledge bases, and tools live on an external agent.
|
||||
owns_context: bool
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Brain(Protocol):
|
||||
"""每通电话 new 一个实例(可持有 chatId / 当前节点等会话状态)。"""
|
||||
class CallEndPort(Protocol):
|
||||
"""Small call-lifecycle surface available to a brain."""
|
||||
|
||||
@property
|
||||
def ending(self) -> bool: ...
|
||||
|
||||
def begin(self, reason: str) -> None: ...
|
||||
|
||||
def arm_after_speech(self) -> None: ...
|
||||
|
||||
async def finish(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrainRuntime:
|
||||
"""Pipeline-owned capabilities injected into one brain session."""
|
||||
|
||||
context: LLMContext
|
||||
llm: Any
|
||||
queue_frame: Callable[[Frame], Awaitable[None]]
|
||||
set_system_prompt: Callable[[str], None]
|
||||
set_tools: Callable[[list[FunctionSchema] | None], None]
|
||||
call_end: CallEndPort
|
||||
|
||||
|
||||
class BaseBrain:
|
||||
"""No-op lifecycle defaults for brains without local orchestration."""
|
||||
|
||||
spec: BrainSpec
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
"""开场白。内部类型通常直接用 cfg.greeting;外部类型异步拉取后端配置。"""
|
||||
...
|
||||
return cfg.greeting
|
||||
|
||||
def system_prompt(self, cfg: AssistantConfig) -> str:
|
||||
return cfg.prompt if self.spec.owns_context else ""
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||
"""返回丢进管线 LLM 槽位的帧处理器(标准 LLMService 或外部托管的伪 LLM)。"""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
|
||||
"""Register tools and initialize per-call orchestration."""
|
||||
|
||||
async def on_connected(self) -> None:
|
||||
"""Handle a connected client after the common greeting is queued."""
|
||||
|
||||
def record_user_message(self, content: str) -> None:
|
||||
"""Observe a committed user message for brain-owned routing state."""
|
||||
|
||||
async def on_assistant_text_start(self, turn_id: str) -> None:
|
||||
"""Observe the start of a generated assistant turn."""
|
||||
|
||||
async def on_assistant_text_end(
|
||||
self,
|
||||
turn_id: str,
|
||||
content: str,
|
||||
interrupted: bool,
|
||||
) -> None:
|
||||
"""Observe the completion of a generated assistant turn."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Brain(Protocol):
|
||||
"""One instance per call; implementations may keep conversation state."""
|
||||
|
||||
spec: BrainSpec
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str: ...
|
||||
|
||||
def system_prompt(self, cfg: AssistantConfig) -> str: ...
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor: ...
|
||||
|
||||
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None: ...
|
||||
|
||||
async def on_connected(self) -> None: ...
|
||||
|
||||
def record_user_message(self, content: str) -> None: ...
|
||||
|
||||
async def on_assistant_text_start(self, turn_id: str) -> None: ...
|
||||
|
||||
async def on_assistant_text_end(
|
||||
self,
|
||||
turn_id: str,
|
||||
content: str,
|
||||
interrupted: bool,
|
||||
) -> None: ...
|
||||
|
||||
61
backend/services/brains/dify_brain.py
Normal file
61
backend/services/brains/dify_brain.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Dify-hosted brain: prompt, workflow, tools, and context live in Dify."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from dify_client import AsyncClient
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
|
||||
from services.brains.base import BaseBrain, BrainSpec
|
||||
from services.brains.dify_llm import DifyLLMService, normalize_api_base
|
||||
|
||||
|
||||
class DifyBrain(BaseBrain):
|
||||
spec = BrainSpec(
|
||||
type="dify",
|
||||
supported_runtime_modes=frozenset({"pipeline"}),
|
||||
owns_context=False,
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self._user_id = f"ai-video-{uuid4().hex}"
|
||||
self._client: AsyncClient | None = None
|
||||
|
||||
def _get_client(self, cfg: AssistantConfig) -> AsyncClient:
|
||||
if self._client is None:
|
||||
if not cfg.dify_api_key:
|
||||
raise ValueError("缺少 Dify Agent apiKey")
|
||||
self._client = AsyncClient(
|
||||
api_key=cfg.dify_api_key,
|
||||
api_base=normalize_api_base(cfg.dify_api_url),
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
"""Use Dify's opening statement, with the local greeting as fallback."""
|
||||
try:
|
||||
api_base = normalize_api_base(cfg.dify_api_url)
|
||||
response = await self._get_client(cfg).arequest(
|
||||
f"{api_base}/parameters",
|
||||
"GET",
|
||||
params={"user": self._user_id},
|
||||
timeout=15.0,
|
||||
)
|
||||
opening = str(response.json().get("opening_statement") or "").strip()
|
||||
return opening or cfg.greeting
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - greeting failure should not block a call
|
||||
logger.warning(f"Dify 获取开场白失败,回退 cfg.greeting: {exc}")
|
||||
return cfg.greeting
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||
return DifyLLMService(
|
||||
cfg,
|
||||
client=self._get_client(cfg),
|
||||
user_id=self._user_id,
|
||||
)
|
||||
127
backend/services/brains/dify_llm.py
Normal file
127
backend/services/brains/dify_llm.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Dify chat applications exposed as a Pipecat LLM processor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from dify_client import AsyncClient, models
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from pipecat.frames.frames import (
|
||||
Frame,
|
||||
LLMContextFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
LLMTextFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.llm_service import LLMService
|
||||
from pipecat.services.settings import LLMSettings
|
||||
|
||||
|
||||
def normalize_api_base(url: str) -> str:
|
||||
"""Accept a Dify host, /v1 base URL, or full chat endpoint."""
|
||||
base = (url or "https://api.dify.ai").strip().rstrip("/")
|
||||
if base.endswith("/chat-messages"):
|
||||
base = base[: -len("/chat-messages")]
|
||||
if not base.endswith("/v1"):
|
||||
base = f"{base}/v1"
|
||||
return base
|
||||
|
||||
|
||||
def last_user_text(messages: list[dict]) -> str:
|
||||
for message in reversed(messages or []):
|
||||
if message.get("role") != "user":
|
||||
continue
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "".join(
|
||||
str(part.get("text") or "")
|
||||
for part in content
|
||||
if isinstance(part, dict)
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
class DifyLLMService(LLMService):
|
||||
"""Stream Dify answer events into Pipecat's standard text frames."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cfg: AssistantConfig,
|
||||
*,
|
||||
client: AsyncClient | None = None,
|
||||
user_id: str | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
settings=LLMSettings(
|
||||
model=None,
|
||||
system_instruction=None,
|
||||
temperature=None,
|
||||
max_tokens=None,
|
||||
top_p=None,
|
||||
top_k=None,
|
||||
frequency_penalty=None,
|
||||
presence_penalty=None,
|
||||
seed=None,
|
||||
filter_incomplete_user_turns=None,
|
||||
user_turn_completion_config=None,
|
||||
)
|
||||
)
|
||||
self._client = client or AsyncClient(
|
||||
api_key=cfg.dify_api_key,
|
||||
api_base=normalize_api_base(cfg.dify_api_url),
|
||||
)
|
||||
self._user_id = user_id or f"ai-video-{uuid4().hex}"
|
||||
self._conversation_id = ""
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
if not isinstance(frame, LLMContextFrame):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
user_text = last_user_text(frame.context.get_messages())
|
||||
if not user_text:
|
||||
return
|
||||
|
||||
await self.push_frame(LLMFullResponseStartFrame())
|
||||
try:
|
||||
request = models.ChatRequest(
|
||||
query=user_text,
|
||||
inputs={},
|
||||
user=self._user_id,
|
||||
response_mode=models.ResponseMode.STREAMING,
|
||||
conversation_id=self._conversation_id,
|
||||
auto_generate_name=False,
|
||||
)
|
||||
events = await self._client.achat_messages(request, timeout=120.0)
|
||||
async for event in events:
|
||||
conversation_id = getattr(event, "conversation_id", "")
|
||||
if conversation_id:
|
||||
self._conversation_id = conversation_id
|
||||
|
||||
event_name = str(getattr(event, "event", ""))
|
||||
if event_name == "error":
|
||||
logger.error(
|
||||
"Dify 流式错误: "
|
||||
f"code={getattr(event, 'code', '')} "
|
||||
f"message={getattr(event, 'message', '')}"
|
||||
)
|
||||
continue
|
||||
text = (
|
||||
getattr(event, "answer", "")
|
||||
if event_name in {"message", "agent_message"}
|
||||
else ""
|
||||
)
|
||||
if event_name == "text_chunk":
|
||||
text = getattr(getattr(event, "data", None), "text", "")
|
||||
if text:
|
||||
await self.push_frame(LLMTextFrame(text))
|
||||
except Exception as exc: # noqa: BLE001 - one failed turn must not kill the call
|
||||
logger.error(f"Dify 调用失败: {exc}")
|
||||
finally:
|
||||
await self.push_frame(LLMFullResponseEndFrame())
|
||||
@@ -15,7 +15,7 @@ from models import AssistantConfig
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
|
||||
from services.brains.base import BrainSpec
|
||||
from services.brains.base import BaseBrain, BrainSpec
|
||||
from services.brains.fastgpt_llm import FastGPTLLMService, normalize_base_url
|
||||
|
||||
|
||||
@@ -38,15 +38,16 @@ def _extract_welcome(payload: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class FastGPTBrain:
|
||||
class FastGPTBrain(BaseBrain):
|
||||
def __init__(self):
|
||||
self.spec = BrainSpec(
|
||||
type="fastgpt",
|
||||
supported_runtime_modes=frozenset({"pipeline"}),
|
||||
owns_context=False,
|
||||
)
|
||||
self._chat_id = uuid4().hex
|
||||
|
||||
spec = BrainSpec(
|
||||
type="fastgpt",
|
||||
supported_runtime_modes=frozenset({"pipeline"}),
|
||||
owns_context=False,
|
||||
)
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
"""优先用 FastGPT 后台配置的开场白;无 app_id 或取不到时回退 cfg.greeting。"""
|
||||
if not cfg.fastgpt_app_id:
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""内部 LLM 大脑:prompt 与 workflow。
|
||||
|
||||
二者都用本地维护的 LLMContext + OpenAI 兼容 LLM,支持 cascade 与 realtime。
|
||||
workflow 的图编排(切提示/转移工具/node-active)阶段 1 仍内联在 pipeline.py,
|
||||
这里只负责提供 LLM 槽位与元数据,行为与改造前完全一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from models import AssistantConfig
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
|
||||
from services.brains.base import BrainSpec
|
||||
|
||||
_CASCADE_AND_REALTIME = frozenset({"pipeline", "realtime"})
|
||||
|
||||
|
||||
class InternalBrain:
|
||||
"""prompt / workflow 共用。"""
|
||||
|
||||
def __init__(self, brain_type: str):
|
||||
self.spec = BrainSpec(
|
||||
type=brain_type,
|
||||
supported_runtime_modes=_CASCADE_AND_REALTIME,
|
||||
owns_context=True,
|
||||
)
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
# 内部类型的开场白由 pipeline.py 现有逻辑(workflow 起始节点 / cfg.greeting)决定,
|
||||
# 该方法仅为满足 Brain 协议,实际不在内部路径上被调用。
|
||||
return cfg.greeting
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||
from services.pipecat.service_factory import create_llm
|
||||
|
||||
return create_llm(cfg)
|
||||
106
backend/services/brains/prompt_brain.py
Normal file
106
backend/services/brains/prompt_brain.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Local prompt assistant, including prompt-only reusable tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from models import AssistantConfig
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
from pipecat.services.llm_service import (
|
||||
FunctionCallParams,
|
||||
FunctionCallResultProperties,
|
||||
)
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||
|
||||
|
||||
class PromptBrain(BaseBrain):
|
||||
spec = BrainSpec(
|
||||
type="prompt",
|
||||
supported_runtime_modes=frozenset({"pipeline", "realtime"}),
|
||||
owns_context=True,
|
||||
)
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||
from services.pipecat.service_factory import create_llm
|
||||
|
||||
return create_llm(cfg)
|
||||
|
||||
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
|
||||
schemas: list[FunctionSchema] = []
|
||||
for tool in cfg.tools:
|
||||
if tool.type != "end_call":
|
||||
continue
|
||||
schema, handler = self._make_end_call_tool(tool, runtime)
|
||||
schemas.append(schema)
|
||||
runtime.llm.register_function(tool.function_name, handler)
|
||||
runtime.set_tools(schemas)
|
||||
|
||||
@staticmethod
|
||||
def _make_end_call_tool(tool, runtime: BrainRuntime):
|
||||
config = (tool.definition or {}).get("config") or {}
|
||||
message_type = str(config.get("message_type") or "none")
|
||||
custom_message = str(config.get("custom_message") or "").strip()
|
||||
capture_reason = bool(config.get("capture_reason", True))
|
||||
|
||||
async def end_call(params: FunctionCallParams) -> None:
|
||||
reason = str(params.arguments.get("reason") or "end_call_tool").strip()
|
||||
runtime.call_end.begin(reason)
|
||||
await params.result_callback(
|
||||
{"status": "success", "action": "ending_call"},
|
||||
properties=FunctionCallResultProperties(run_llm=False),
|
||||
)
|
||||
|
||||
if message_type != "custom" or not custom_message:
|
||||
await runtime.call_end.finish()
|
||||
return
|
||||
|
||||
turn_id = uuid4().hex
|
||||
timestamp = time_now_iso8601()
|
||||
for message in (
|
||||
{
|
||||
"type": "assistant-text-start",
|
||||
"turn_id": turn_id,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
{
|
||||
"type": "assistant-text-delta",
|
||||
"turn_id": turn_id,
|
||||
"delta": custom_message,
|
||||
},
|
||||
{
|
||||
"type": "assistant-text-end",
|
||||
"turn_id": turn_id,
|
||||
"content": custom_message,
|
||||
"interrupted": False,
|
||||
},
|
||||
):
|
||||
await runtime.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(message=message)
|
||||
)
|
||||
runtime.call_end.arm_after_speech()
|
||||
await runtime.queue_frame(
|
||||
TTSSpeakFrame(custom_message, append_to_context=False)
|
||||
)
|
||||
|
||||
properties = (
|
||||
{
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "结束本次通话的简短原因。",
|
||||
}
|
||||
}
|
||||
if capture_reason
|
||||
else {}
|
||||
)
|
||||
schema = FunctionSchema(
|
||||
name=tool.function_name,
|
||||
description=tool.description or "结束当前通话。",
|
||||
properties=properties,
|
||||
required=["reason"] if capture_reason else [],
|
||||
)
|
||||
return schema, end_call
|
||||
@@ -1,25 +1,40 @@
|
||||
"""类型 → Brain 工厂。新增一种大脑 = 加一个 brain 文件 + 在此注册一行。"""
|
||||
"""Explicit assistant-type registry. Unsupported types never silently degrade."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from models import AssistantConfig
|
||||
|
||||
from services.brains.base import Brain, BrainSpec
|
||||
from services.brains.dify_brain import DifyBrain
|
||||
from services.brains.fastgpt_brain import FastGPTBrain
|
||||
from services.brains.internal_brain import InternalBrain
|
||||
from services.brains.prompt_brain import PromptBrain
|
||||
from services.brains.workflow_brain import WorkflowBrain
|
||||
|
||||
|
||||
def _workflow(cfg: AssistantConfig) -> Brain:
|
||||
return WorkflowBrain(cfg.graph)
|
||||
|
||||
|
||||
BRAIN_FACTORIES: dict[str, Callable[[AssistantConfig], Brain]] = {
|
||||
"prompt": lambda _cfg: PromptBrain(),
|
||||
"workflow": _workflow,
|
||||
"dify": lambda _cfg: DifyBrain(),
|
||||
"fastgpt": lambda _cfg: FastGPTBrain(),
|
||||
}
|
||||
|
||||
# 各类型的元数据(供 schema 校验 / realtime 门控复用,无需实例化 Brain)。
|
||||
SPECS: dict[str, BrainSpec] = {
|
||||
"prompt": InternalBrain("prompt").spec,
|
||||
"workflow": InternalBrain("workflow").spec,
|
||||
"fastgpt": FastGPTBrain().spec,
|
||||
"prompt": PromptBrain.spec,
|
||||
"workflow": WorkflowBrain.spec,
|
||||
"dify": DifyBrain.spec,
|
||||
"fastgpt": FastGPTBrain.spec,
|
||||
}
|
||||
|
||||
|
||||
def build_brain(cfg: AssistantConfig) -> Brain:
|
||||
"""按 cfg.type 构造每通电话的 Brain 实例(未知类型回退 prompt)。"""
|
||||
if cfg.type == "fastgpt":
|
||||
return FastGPTBrain()
|
||||
if cfg.type in ("prompt", "workflow"):
|
||||
return InternalBrain(cfg.type)
|
||||
return InternalBrain("prompt")
|
||||
try:
|
||||
factory = BRAIN_FACTORIES[cfg.type]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"尚未实现的助手类型: {cfg.type}") from exc
|
||||
return factory(cfg)
|
||||
|
||||
188
backend/services/brains/workflow_brain.py
Normal file
188
backend/services/brains/workflow_brain.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Local graph-driven workflow assistant and its per-call state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
|
||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowState:
|
||||
current: str
|
||||
ended: bool = False
|
||||
turns_in_node: int = 0
|
||||
end_turn_id: str | None = None
|
||||
|
||||
|
||||
class WorkflowBrain(BaseBrain):
|
||||
spec = BrainSpec(
|
||||
type="workflow",
|
||||
supported_runtime_modes=frozenset({"pipeline"}),
|
||||
owns_context=True,
|
||||
)
|
||||
_FALLBACK_AFTER_TURNS = 2
|
||||
|
||||
def __init__(self, graph: dict[str, Any]):
|
||||
self._engine = WorkflowEngine(graph or {})
|
||||
if not self._engine.has_graph() or not self._engine.start_id:
|
||||
raise ValueError("WorkflowBrain 缺少有效的 startCall 节点")
|
||||
self._state = WorkflowState(current=self._engine.start_id)
|
||||
self._history: list[dict[str, str]] = []
|
||||
self._cfg: AssistantConfig | None = None
|
||||
self._runtime: BrainRuntime | None = None
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
return self._engine.greeting() or cfg.greeting
|
||||
|
||||
def system_prompt(self, cfg: AssistantConfig) -> str:
|
||||
return self._engine.system_prompt_for(self._state.current)
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||
from services.pipecat.service_factory import create_llm
|
||||
|
||||
return create_llm(cfg)
|
||||
|
||||
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
|
||||
self._cfg = cfg
|
||||
self._runtime = runtime
|
||||
for edge in self._engine.edges:
|
||||
if edge.get("target"):
|
||||
runtime.llm.register_function(
|
||||
self._engine.edge_fn_name(edge),
|
||||
self._make_transition_handler(edge),
|
||||
)
|
||||
self._apply_node(self._state.current)
|
||||
logger.info(
|
||||
f"工作流模式启用: 起始节点={self._engine.name(self._state.current)}"
|
||||
)
|
||||
|
||||
async def on_connected(self) -> None:
|
||||
await self._emit_node_active(self._state.current)
|
||||
|
||||
def record_user_message(self, content: str) -> None:
|
||||
if content:
|
||||
self._history.append({"role": "user", "content": content})
|
||||
|
||||
async def on_assistant_text_start(self, turn_id: str) -> None:
|
||||
if self._state.ended and self._state.end_turn_id is None:
|
||||
self._state.end_turn_id = turn_id
|
||||
|
||||
async def on_assistant_text_end(
|
||||
self,
|
||||
turn_id: str,
|
||||
content: str,
|
||||
interrupted: bool,
|
||||
) -> None:
|
||||
if not content or interrupted:
|
||||
return
|
||||
self._history.append({"role": "assistant", "content": content})
|
||||
if turn_id == self._state.end_turn_id:
|
||||
runtime = self._require_runtime()
|
||||
runtime.call_end.begin("completed")
|
||||
runtime.call_end.arm_after_speech()
|
||||
elif not self._state.ended:
|
||||
self._state.turns_in_node += 1
|
||||
await self._fallback_route()
|
||||
|
||||
def _apply_node(self, node_id: str) -> None:
|
||||
runtime = self._require_runtime()
|
||||
runtime.set_system_prompt(self._engine.system_prompt_for(node_id))
|
||||
if self._engine.is_end(node_id):
|
||||
runtime.set_tools([])
|
||||
return
|
||||
runtime.set_tools(
|
||||
[
|
||||
FunctionSchema(
|
||||
name=self._engine.edge_fn_name(edge),
|
||||
description=self._engine.edge_description(edge),
|
||||
properties={},
|
||||
required=[],
|
||||
)
|
||||
for edge in self._engine.outgoing(node_id)
|
||||
]
|
||||
)
|
||||
|
||||
async def _go_to_node(self, target: str) -> None:
|
||||
self._state.current = target
|
||||
self._state.turns_in_node = 0
|
||||
if self._engine.is_end(target):
|
||||
self._state.ended = True
|
||||
await self._emit_node_active(target)
|
||||
self._apply_node(target)
|
||||
|
||||
async def _emit_node_active(self, node_id: str | None) -> None:
|
||||
if node_id:
|
||||
await self._require_runtime().queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={"type": "node-active", "nodeId": node_id}
|
||||
)
|
||||
)
|
||||
|
||||
async def _speak_transition(self, edge: dict | None) -> None:
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
await self._require_runtime().queue_frame(
|
||||
TTSSpeakFrame(speech, append_to_context=False)
|
||||
)
|
||||
|
||||
def _make_transition_handler(self, edge: dict):
|
||||
target = str(edge.get("target"))
|
||||
|
||||
async def handler(params) -> None:
|
||||
logger.info(f"LLM 触发转移 → {self._engine.name(target)}")
|
||||
if not self._engine.is_end(target):
|
||||
await self._speak_transition(edge)
|
||||
await self._go_to_node(target)
|
||||
await params.result_callback({"status": "ok"})
|
||||
|
||||
return handler
|
||||
|
||||
async def _fallback_route(self) -> None:
|
||||
if self._state.ended:
|
||||
return
|
||||
if self._state.turns_in_node < self._FALLBACK_AFTER_TURNS:
|
||||
return
|
||||
if not self._engine.outgoing(self._state.current):
|
||||
return
|
||||
|
||||
cfg = self._require_config()
|
||||
target = await self._engine.route(
|
||||
self._state.current,
|
||||
self._history,
|
||||
api_key=self._require(cfg.llm_api_key, "LLM apiKey"),
|
||||
base_url=self._require(cfg.llm_base_url, "LLM apiUrl"),
|
||||
model=self._require(cfg.model, "LLM modelId"),
|
||||
)
|
||||
if target and target != self._state.current:
|
||||
logger.info(f"文本兜底触发转移 → {self._engine.name(target)}")
|
||||
if not self._engine.is_end(target):
|
||||
await self._speak_transition(
|
||||
self._engine.find_edge(self._state.current, target)
|
||||
)
|
||||
await self._go_to_node(target)
|
||||
|
||||
def _require_runtime(self) -> BrainRuntime:
|
||||
if self._runtime is None:
|
||||
raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime")
|
||||
return self._runtime
|
||||
|
||||
def _require_config(self) -> AssistantConfig:
|
||||
if self._cfg is None:
|
||||
raise RuntimeError("WorkflowBrain 尚未初始化配置")
|
||||
return self._cfg
|
||||
|
||||
@staticmethod
|
||||
def _require(value: str, label: str) -> str:
|
||||
if value:
|
||||
return value
|
||||
raise ValueError(f"缺少模型资源配置: {label}")
|
||||
Reference in New Issue
Block a user