Files
ai-video-fullstack/backend/services/brains/workflow_brain.py
Xin Wang 00270a5c01 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.
2026-07-11 22:26:31 +08:00

189 lines
6.7 KiB
Python

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