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:
Xin Wang
2026-07-11 22:26:31 +08:00
parent dfb9c5bd11
commit 00270a5c01
23 changed files with 1270 additions and 414 deletions

View File

@@ -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"]

View File

@@ -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: ...

View 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,
)

View 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())

View File

@@ -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:

View File

@@ -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)

View 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

View File

@@ -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)

View 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}")