diff --git a/backend/services/pipecat/pipeline.py b/backend/services/pipecat/pipeline.py
index 4fac104..c095b7a 100644
--- a/backend/services/pipecat/pipeline.py
+++ b/backend/services/pipecat/pipeline.py
@@ -8,10 +8,8 @@
import asyncio
import base64
-from collections.abc import Callable
from io import BytesIO
from typing import Any
-from uuid import uuid4
from loguru import logger
from models import AssistantConfig
@@ -25,7 +23,6 @@ from services.pipecat.call_lifecycle import (
)
from services.pipecat.service_factory import (
config_with_resource,
- create_llm,
create_realtime_service,
create_stt,
create_tts,
@@ -38,37 +35,19 @@ from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.flows import FlowsFunctionSchema
from pipecat.frames.frames import (
EndFrame,
- InputTransportMessageFrame,
- InterruptionFrame,
- LLMFullResponseEndFrame,
- LLMFullResponseStartFrame,
- LLMContextFrame,
- LLMTextFrame,
- ManuallySwitchServiceFrame,
- LLMMessagesAppendFrame,
OutputTransportMessageUrgentFrame,
- TextFrame,
- TTSSpeakFrame,
UserImageRawFrame,
UserImageRequestFrame,
VADParamsUpdateFrame,
)
from pipecat.pipeline.pipeline import Pipeline
-from pipecat.pipeline.llm_switcher import LLMSwitcher
-from pipecat.pipeline.service_switcher import ServiceSwitcher
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
- LLMAssistantAggregator,
LLMUserAggregatorParams,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
-from pipecat.runner.utils import (
- get_transport_client_id,
- maybe_capture_participant_camera,
-)
from pipecat.services.llm_service import FunctionCallParams
-from pipecat.turns.user_mute.base_user_mute_strategy import BaseUserMuteStrategy
from pipecat.turns.user_mute.function_call_user_mute_strategy import (
FunctionCallUserMuteStrategy,
)
@@ -78,7 +57,28 @@ from services.pipecat.turn_config import (
create_vad_analyzer,
create_vad_params,
)
-from pipecat.utils.time import time_now_iso8601
+from services.pipecat.processors import (
+ KNOWLEDGE_CONTEXT_MARKER,
+ CallEndingUserMuteStrategy,
+ ConversationHistoryProcessor,
+ KnowledgeRetrievalProcessor,
+ PassthroughLLMAssistantAggregator,
+ RealtimeDynamicVariableProcessor,
+ RealtimeTextInputProcessor,
+ TextInputProcessor,
+ UserTurnRoutingProcessor,
+ VisionCaptureProcessor,
+ WorkflowAggregatorPair,
+)
+from services.pipecat.workflow_services import (
+ WorkflowServiceController,
+ build_workflow_llm_switcher,
+ build_workflow_voice_switcher,
+)
+from services.pipecat.pipeline_events import (
+ bind_cascade_pipeline_events,
+ bind_realtime_pipeline_events,
+)
from pipecat.workers.runner import WorkerRunner
@@ -101,9 +101,6 @@ ON_DEMAND_KNOWLEDGE_SYSTEM_HINT = (
"先调用 search_knowledge_base 检索;回答资料事实时只根据检索内容,"
"资料不足要明确说明。"
)
-KNOWLEDGE_CONTEXT_MARKER = ""
-
-
def _compact_knowledge_metadata(value: str, max_length: int) -> str:
"""Keep tool metadata useful without letting it dominate the model context."""
compact = " ".join(value.split())
@@ -193,473 +190,6 @@ async def _analyze_image_with_vision_model(
return str(content or "").strip()
-def _text_input(message) -> tuple[str, bool] | None:
- """解析现有 user-text 与 RTVI send-text 两种前端文字消息。"""
- if not isinstance(message, dict):
- return None
- if message.get("type") == "user-text":
- text = str(message.get("text") or "").strip()
- return (text, True) if text else None
- if message.get("type") == "send-text":
- data = message.get("data")
- if not isinstance(data, dict):
- return None
- text = str(data.get("content") or "").strip()
- options = data.get("options")
- run_immediately = not isinstance(options, dict) or options.get(
- "run_immediately", True
- )
- return (text, bool(run_immediately)) if text else None
- return None
-
-
-class TextInputProcessor(FrameProcessor):
- """把 transport 文字消息转换成 LLM 可消费的帧。
-
- run_immediately(默认/打断):先通过 on_text_input 事件把用户文字交给
- run_pipeline 登记,再用 broadcast_interruption() 打断当前播报。新的 LLM
- 回复由 assistant aggregator 确认处理完 interruption 后触发。
- run_immediately=False(RTVI send-text 静默追加):仅把文字写进上下文,
- 不打断、不触发推理。
- """
-
- def __init__(self, should_ignore_input: Callable[[], bool] | None = None):
- super().__init__()
- self._should_ignore_input = should_ignore_input or (lambda: False)
- # 立即触发的文字(含打断语义)走 on_text_input;静默追加另走一条事件
- self._register_event_handler("on_text_input")
- self._register_event_handler("on_text_append")
- self._register_event_handler("on_client_ready")
-
- async def process_frame(self, frame, direction: FrameDirection):
- await super().process_frame(frame, direction)
-
- if not isinstance(frame, InputTransportMessageFrame):
- await self.push_frame(frame, direction)
- return
-
- if isinstance(frame.message, dict) and frame.message.get("type") == "client-ready":
- await self._call_event_handler("on_client_ready")
- return
-
- parsed = _text_input(frame.message)
- if not parsed:
- await self.push_frame(frame, direction)
- return
-
- if self._should_ignore_input():
- logger.debug("通话正在结束,忽略后续文字输入")
- return
-
- text, run_immediately = parsed
- if run_immediately:
- # 先登记文字再打断。下一轮 LLM 由 assistant aggregator 在真正处理完
- # InterruptionFrame 后触发,避免新回复被这次 interruption 一起取消。
- await self._call_event_handler("on_text_input", text)
- await self.broadcast_interruption()
- else:
- await self._call_event_handler("on_text_append", text)
-
-
-class CallEndingUserMuteStrategy(BaseUserMuteStrategy):
- """Keep user media muted after an end-call tool starts terminating a call."""
-
- def __init__(self, is_call_ending: Callable[[], bool]):
- super().__init__()
- self._is_call_ending = is_call_ending
-
- async def process_frame(self, frame) -> bool:
- await super().process_frame(frame)
- return self._is_call_ending()
-
-
-class VisionCaptureProcessor(FrameProcessor):
- """Capture one requested video frame for auxiliary vision-model analysis."""
-
- def __init__(self, timeout_s: float = 3.0):
- super().__init__()
- self._timeout_s = timeout_s
- self._pending: dict[str, asyncio.Future[UserImageRawFrame]] = {}
-
- async def request_image(
- self,
- requester: FrameProcessor,
- request: UserImageRequestFrame,
- ) -> UserImageRawFrame:
- key = request.tool_call_id or str(uuid4())
- request.tool_call_id = key
- request.append_to_context = False
- request.result_callback = None
-
- loop = asyncio.get_running_loop()
- future: asyncio.Future[UserImageRawFrame] = loop.create_future()
- self._pending[key] = future
- await requester.push_frame(request, FrameDirection.UPSTREAM)
- try:
- return await asyncio.wait_for(future, timeout=self._timeout_s)
- finally:
- self._pending.pop(key, None)
-
- async def process_frame(self, frame, direction: FrameDirection):
- await super().process_frame(frame, direction)
-
- if (
- isinstance(frame, UserImageRawFrame)
- and frame.request
- and frame.request.tool_call_id
- and frame.request.tool_call_id in self._pending
- ):
- future = self._pending[frame.request.tool_call_id]
- if not future.done():
- future.set_result(frame)
- return
-
- await self.push_frame(frame, direction)
-
-
-class RealtimeDynamicVariableProcessor(FrameProcessor):
- """Keep realtime system turn/history variables current between responses."""
-
- def __init__(self, brain: Brain, cfg: AssistantConfig, realtime):
- super().__init__()
- self._brain = brain
- self._cfg = cfg
- self._realtime = realtime
-
- async def _refresh_instructions(self) -> None:
- update = getattr(self._realtime, "update_instructions", None)
- if callable(update):
- await update(self._brain.system_prompt(self._cfg))
-
- async def process_frame(self, frame, direction: FrameDirection):
- await super().process_frame(frame, direction)
- if isinstance(frame, OutputTransportMessageUrgentFrame):
- message = frame.message
- if isinstance(message, dict):
- event_type = message.get("type")
- if event_type == "transcript" and message.get("role") == "user":
- content = str(message.get("content") or "").strip()
- if content:
- self._brain.record_user_message(content)
- await self._refresh_instructions()
- elif event_type == "assistant-text-end":
- await self._brain.on_assistant_text_end(
- str(message.get("turn_id") or ""),
- str(message.get("content") or ""),
- bool(message.get("interrupted", False)),
- )
- await self._refresh_instructions()
- await self.push_frame(frame, direction)
-
-
-class RealtimeTextInputProcessor(FrameProcessor):
- """Route text input directly to a realtime service without cascade semantics."""
-
- def __init__(self):
- super().__init__()
- self._register_event_handler("on_text_input")
- self._register_event_handler("on_text_append")
-
- async def process_frame(self, frame, direction: FrameDirection):
- await super().process_frame(frame, direction)
-
- if not isinstance(frame, InputTransportMessageFrame):
- await self.push_frame(frame, direction)
- return
-
- parsed = _text_input(frame.message)
- if not parsed:
- await self.push_frame(frame, direction)
- return
-
- text, run_immediately = parsed
- await self._call_event_handler(
- "on_text_input" if run_immediately else "on_text_append",
- text,
- )
-
-
-class ConversationHistoryProcessor(FrameProcessor):
- """从最终客户端事件旁路保存历史,不改变 Pipecat 的上下文与帧语义。"""
-
- def __init__(self, recorder: ConversationRecorder | None):
- super().__init__()
- self._recorder = recorder
-
- async def process_frame(self, frame, direction: FrameDirection):
- await super().process_frame(frame, direction)
- await self.push_frame(frame, direction)
- if self._recorder and isinstance(frame, OutputTransportMessageUrgentFrame):
- await self._recorder.record_transport_message(frame.message)
-
-
-class KnowledgeRetrievalProcessor(FrameProcessor):
- """Retrieve before local LLM inference without changing Pipecat internals."""
-
- def __init__(
- self,
- knowledge_base_id: str | None,
- top_n: int = 5,
- score_threshold: float = 0.0,
- ):
- super().__init__()
- self._knowledge_base_id = knowledge_base_id
- self._top_n = top_n
- self._score_threshold = score_threshold
- self._mode = "automatic" if knowledge_base_id else "disabled"
- self._last_signature = ""
-
- def set_scope(self, scope: dict) -> None:
- self._knowledge_base_id = scope.get("knowledge_base_id") or None
- self._mode = str(scope.get("mode") or "disabled")
- self._top_n = int(scope.get("top_n") or 5)
- self._score_threshold = float(scope.get("score_threshold") or 0.0)
- self._last_signature = ""
-
- def _clear_context(self, messages: list[dict]) -> None:
- # Remove the legacy Workflow knowledge message so an in-flight context
- # created before this compatibility fix cannot keep sending that role.
- messages[:] = [
- message
- for message in messages
- if not (
- message.get("role") == "developer"
- and KNOWLEDGE_CONTEXT_MARKER in str(message.get("content") or "")
- )
- ]
- system_message = next(
- (message for message in messages if message.get("role") == "system"),
- None,
- )
- if system_message is not None:
- content = str(system_message.get("content") or "")
- system_message["content"] = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
-
- def _set_context(self, messages: list[dict], block: str) -> None:
- """Store retrieved knowledge in a provider-compatible system message."""
- self._clear_context(messages)
- system_message = next(
- (message for message in messages if message.get("role") == "system"),
- None,
- )
- if system_message is None:
- messages.insert(0, {"role": "system", "content": block})
- return
- content = str(system_message.get("content") or "").rstrip()
- system_message["content"] = f"{content}\n\n{block}" if content else block
-
- async def process_frame(self, frame, direction: FrameDirection):
- await super().process_frame(frame, direction)
- if not isinstance(frame, LLMContextFrame):
- await self.push_frame(frame, direction)
- return
-
- messages = frame.context.get_messages()
- if self._mode != "automatic" or not self._knowledge_base_id:
- self._clear_context(messages)
- await self.push_frame(frame, direction)
- return
- user_messages = [message for message in messages if message.get("role") == "user"]
- if not user_messages:
- await self.push_frame(frame, direction)
- return
- query = str(user_messages[-1].get("content") or "").strip()
- signature = f"{len(user_messages)}:{query}"
- if not query or signature == self._last_signature:
- await self.push_frame(frame, direction)
- return
- self._last_signature = signature
-
- try:
- async with SessionLocal() as session:
- results = await search_knowledge(
- session,
- self._knowledge_base_id,
- query,
- top_k=self._top_n,
- score_threshold=self._score_threshold,
- )
- except Exception as exc:
- logger.warning(f"自动知识库检索失败: {exc}")
- results = []
-
- sources = "\n\n".join(
- f"[{index + 1}] 来源:{item['document']}(相关度 {item['score']})\n{item['content']}"
- for index, item in enumerate(results)
- ) or "未检索到相关资料。"
- block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}"
- self._set_context(messages, block)
- await self.push_frame(frame, direction)
-
-
-class UserTurnRoutingProcessor(FrameProcessor):
- """Give a brain first right of refusal before a new user turn reaches the LLM."""
-
- def __init__(self, brain: Brain):
- super().__init__()
- self._brain = brain
- self._last_user_message: dict | None = None
-
- async def process_frame(self, frame, direction: FrameDirection):
- await super().process_frame(frame, direction)
- if direction != FrameDirection.DOWNSTREAM or not isinstance(
- frame, LLMContextFrame
- ):
- await self.push_frame(frame, direction)
- return
-
- user_message = next(
- (
- message
- for message in reversed(frame.context.get_messages())
- if message.get("role") == "user"
- and isinstance(message.get("content"), str)
- and str(message.get("content") or "").strip()
- ),
- None,
- )
- if user_message is None:
- await self.push_frame(frame, direction)
- return
-
- if user_message is self._last_user_message:
- # Programmatic LLMRunFrame after a node transition reuses the same
- # user message. It is a response run, not another routing event.
- await self.push_frame(frame, direction)
- return
- self._last_user_message = user_message
-
- content = str(user_message.get("content") or "").strip()
- handled = await self._brain.on_user_turn_end(content)
- if not handled:
- await self.push_frame(frame, direction)
-
-
-class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
- """聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self._register_event_handler("on_interruption_processed")
- self._register_event_handler("on_assistant_text_start")
- self._register_event_handler("on_assistant_text_delta")
- self._register_event_handler("on_assistant_text_end")
- self._stream_turn_id: str | None = None
- self._stream_timestamp = ""
- self._stream_text = ""
- async def process_frame(self, frame, direction: FrameDirection):
- await super().process_frame(frame, direction)
-
- if isinstance(frame, LLMFullResponseStartFrame):
- self._stream_turn_id = uuid4().hex
- self._stream_timestamp = time_now_iso8601()
- self._stream_text = ""
- await self._call_event_handler(
- "on_assistant_text_start",
- self._stream_turn_id,
- self._stream_timestamp,
- )
- elif isinstance(frame, LLMTextFrame) and self._stream_turn_id:
- self._stream_text += frame.text
- await self._call_event_handler(
- "on_assistant_text_delta",
- self._stream_turn_id,
- frame.text,
- )
- elif isinstance(frame, LLMFullResponseEndFrame):
- await self._finish_text_stream(interrupted=False)
-
- # LLMAssistantAggregator 默认会消费这些帧。放在 TTS 前用于中断时保存
- # 已生成前缀时,必须显式透传,否则 TTS 收不到任何 LLM 回复。
- if isinstance(
- frame,
- (LLMFullResponseStartFrame, LLMFullResponseEndFrame, TextFrame),
- ):
- await self.push_frame(frame, direction)
- elif isinstance(frame, InterruptionFrame):
- await self._finish_text_stream(interrupted=True)
- await self._call_event_handler("on_interruption_processed")
-
- async def _finish_text_stream(self, *, interrupted: bool):
- if not self._stream_turn_id:
- return
- await self._call_event_handler(
- "on_assistant_text_end",
- self._stream_turn_id,
- self._stream_text,
- interrupted,
- )
- self._stream_turn_id = None
- self._stream_timestamp = ""
- self._stream_text = ""
-
-
-class WorkflowAggregatorPair:
- """Small public-shape adapter required by Pipecat FlowManager."""
-
- def __init__(self, user_aggregator, assistant_aggregator):
- self._user = user_aggregator
- self._assistant = assistant_aggregator
-
- def user(self):
- return self._user
-
- def assistant(self):
- return self._assistant
-
-
-def _workflow_service_switcher(
- cfg: AssistantConfig, capability: str, base_service: FrameProcessor
-):
- """Build one switcher and an ID lookup for every referenced voice resource."""
- create = create_stt if capability == "ASR" else create_tts
- settings = cfg.graph.get("settings") or {}
- default_key = (
- "defaultAsrResourceId" if capability == "ASR" else "defaultTtsResourceId"
- )
- default_id = str(settings.get(default_key) or "")
- services_by_id = {}
- for resource_id, resource in cfg.workflow_model_resources.items():
- if resource.capability != capability:
- continue
- services_by_id[resource_id] = (
- base_service
- if resource_id == default_id
- else create(config_with_resource(cfg, resource))
- )
- primary = services_by_id.get(default_id, base_service)
- services = [primary]
- services.extend(
- service for service in services_by_id.values() if service is not primary
- )
- if base_service is not primary:
- services.append(base_service)
- return ServiceSwitcher(services=services), services_by_id, primary
-
-
-def _workflow_llm_switcher(cfg: AssistantConfig, base_service):
- """Build an LLM switcher for the global model and Agent overrides."""
- settings = cfg.graph.get("settings") or {}
- default_id = str(settings.get("defaultLlmResourceId") or "")
- services_by_id = {}
- for resource_id, resource in cfg.workflow_model_resources.items():
- if resource.capability != "LLM":
- continue
- services_by_id[resource_id] = (
- base_service
- if resource_id == default_id
- else create_llm(config_with_resource(cfg, resource))
- )
- primary = services_by_id.get(default_id, base_service)
- services = [primary]
- services.extend(
- service for service in services_by_id.values() if service is not primary
- )
- if base_service is not primary:
- services.append(base_service)
- return LLMSwitcher(llms=services), services_by_id, primary
-
-
async def run_pipeline(
transport,
cfg: AssistantConfig,
@@ -727,10 +257,10 @@ async def run_pipeline(
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)
+ build_workflow_voice_switcher(cfg, "ASR", stt)
)
tts_processor, tts_services, current_voice_services["tts"] = (
- _workflow_service_switcher(cfg, "TTS", tts)
+ build_workflow_voice_switcher(cfg, "TTS", tts)
)
greeting = await brain.greeting(cfg)
@@ -796,7 +326,7 @@ async def run_pipeline(
llm_services: dict[str, FrameProcessor] = {}
current_llm_service = llm
if cfg.type == "workflow":
- llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
+ llm, llm_services, current_llm_service = build_workflow_llm_switcher(cfg, llm)
user_aggregator = ConfigurableLLMUserAggregator(
context,
params=LLMUserAggregatorParams(
@@ -1020,52 +550,15 @@ async def run_pipeline(
enable_rtvi=False,
)
worker_holder["worker"] = worker
- default_workflow_services = {
- "llm": current_llm_service,
- **current_voice_services,
- }
-
- async def switch_workflow_services(
- llm_resource_id: str | None,
- asr_resource_id: str | None,
- tts_resource_id: str | None,
- ) -> None:
- nonlocal current_llm_service
- requested = (
- ("llm", llm_services, llm_resource_id),
- ("asr", stt_services, asr_resource_id),
- ("tts", tts_services, tts_resource_id),
- )
- for kind, services, resource_id in requested:
- target = (
- services.get(resource_id)
- if resource_id
- else default_workflow_services[kind]
- )
- if target is None:
- raise ValueError(f"Workflow {kind.upper()} 资源未加载:{resource_id}")
- current = (
- current_llm_service
- if kind == "llm"
- else current_voice_services[kind]
- )
- if current is target:
- continue
- await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
- if kind == "llm":
- current_llm_service = target
- else:
- current_voice_services[kind] = target
- await worker.queue_frame(
- OutputTransportMessageUrgentFrame(
- message={
- "type": "service-switched",
- "capability": kind.upper(),
- "resourceId": resource_id,
- }
- )
- )
-
+ service_controller = WorkflowServiceController(
+ worker=worker,
+ llm_services=llm_services,
+ voice_services={"asr": stt_services, "tts": tts_services},
+ current_services={
+ "llm": current_llm_service,
+ **current_voice_services,
+ },
+ )
current_enable_interrupt = cfg.enableInterrupt
current_turn_config = dict(cfg.turnConfig)
@@ -1091,21 +584,6 @@ async def run_pipeline(
current_enable_interrupt = enable_interrupt
current_turn_config = normalized
- async def queue_transcript(role: str, content: str, timestamp: str) -> None:
- if content:
- await worker.queue_frame(
- OutputTransportMessageUrgentFrame(
- message={
- "type": "transcript",
- "role": role,
- "content": content,
- "timestamp": timestamp,
- },
- )
- )
-
- greeting_transcript_sent = False
- pending_text_inputs: list[str] = []
def set_system_prompt(text: str) -> None:
"""替换上下文里的系统提示(节点切换时整体替换,而非追加)。"""
@@ -1132,7 +610,7 @@ async def run_pipeline(
assistant_aggregator,
),
transport=transport,
- switch_services=switch_workflow_services,
+ switch_services=service_controller.switch,
set_knowledge_scope=knowledge_retrieval.set_scope,
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled),
apply_turn_config=apply_workflow_turn_config,
@@ -1140,110 +618,18 @@ async def run_pipeline(
),
)
- async def append_user_text_to_context(text: str, *, run_llm: bool) -> None:
- await worker.queue_frame(
- LLMMessagesAppendFrame(
- messages=[{"role": "user", "content": text}],
- run_llm=run_llm,
- )
- )
-
- @user_aggregator.event_handler("on_user_turn_stopped")
- async def on_user_turn_stopped(_aggregator, _strategy, message):
- await queue_transcript("user", message.content, message.timestamp)
-
- @assistant_aggregator.event_handler("on_assistant_text_start")
- async def on_assistant_text_start(_aggregator, turn_id, timestamp):
- await brain.on_assistant_text_start(turn_id)
- await worker.queue_frame(
- OutputTransportMessageUrgentFrame(
- message={
- "type": "assistant-text-start",
- "turn_id": turn_id,
- "timestamp": timestamp,
- }
- )
- )
-
- @assistant_aggregator.event_handler("on_assistant_text_delta")
- async def on_assistant_text_delta(_aggregator, turn_id, delta):
- await worker.queue_frame(
- OutputTransportMessageUrgentFrame(
- message={
- "type": "assistant-text-delta",
- "turn_id": turn_id,
- "delta": delta,
- }
- )
- )
-
- @assistant_aggregator.event_handler("on_assistant_text_end")
- async def on_assistant_text_end(_aggregator, turn_id, content, interrupted):
- await worker.queue_frame(
- OutputTransportMessageUrgentFrame(
- message={
- "type": "assistant-text-end",
- "turn_id": turn_id,
- "content": content,
- "interrupted": interrupted,
- }
- )
- )
- await brain.on_assistant_text_end(turn_id, content, interrupted)
-
- @text_input.event_handler("on_text_input")
- async def on_text_input(_processor, text):
- pending_text_inputs.append(text)
- # 前端显示不依赖 interruption 后续事件,必须在打断前先排入发送队列。
- await queue_transcript("user", text, time_now_iso8601())
-
- @assistant_aggregator.event_handler("on_interruption_processed")
- async def on_interruption_processed(_aggregator):
- if not pending_text_inputs:
- return
- text = pending_text_inputs.pop(0)
- # assistant aggregator 已处理完 interruption,现在再启动下一轮 LLM。
- await append_user_text_to_context(text, run_llm=True)
-
- @text_input.event_handler("on_text_append")
- async def on_text_append(_processor, text):
- # 静默追加:写进上下文但不打断、不触发推理;transcript 照常上报
- brain.record_user_message(text)
- await queue_transcript("user", text, time_now_iso8601())
- await append_user_text_to_context(text, run_llm=False)
-
- @text_input.event_handler("on_client_ready")
- async def on_client_ready(_processor):
- nonlocal greeting_transcript_sent
- if greeting and not greeting_transcript_sent:
- greeting_transcript_sent = True
- await queue_transcript("assistant", greeting, time_now_iso8601())
- await brain.on_client_ready()
-
- @transport.event_handler("on_client_connected")
- async def on_client_connected(_transport, _client):
- if vision_enabled:
- try:
- vision_state["client_id"] = get_transport_client_id(
- _transport,
- _client,
- )
- await maybe_capture_participant_camera(_transport, _client)
- logger.info(f"视觉理解已接入视频客户端: {vision_state['client_id']}")
- except Exception as e:
- logger.warning(f"视觉理解摄像头捕获初始化失败: {e}")
- if greeting:
- # 外部托管类型的上下文由对方服务端维护,开场白不写入本地 context
- if brain.spec.owns_context:
- context.add_message({"role": "assistant", "content": greeting})
- await worker.queue_frame(TTSSpeakFrame(greeting, append_to_context=False))
- await brain.on_connected()
-
- @transport.event_handler("on_client_disconnected")
- async def on_client_disconnected(_transport, _client):
- logger.info("对端断开,结束管线")
- await worker.queue_frame(EndFrame())
-
+ bind_cascade_pipeline_events(
+ transport=transport,
+ worker=worker,
+ brain=brain,
+ context=context,
+ text_input=text_input,
+ user_aggregator=user_aggregator,
+ assistant_aggregator=assistant_aggregator,
+ greeting=greeting,
+ vision_enabled=vision_enabled,
+ vision_state=vision_state,
+ )
runner = WorkerRunner(handle_sigint=False)
run_status = "completed"
try:
@@ -1306,40 +692,13 @@ async def run_realtime_pipeline(
enable_rtvi=False,
)
- async def queue_transcript(role: str, content: str) -> None:
- if content:
- await worker.queue_frame(
- OutputTransportMessageUrgentFrame(
- message={
- "type": "transcript",
- "role": role,
- "content": content,
- "timestamp": time_now_iso8601(),
- },
- )
- )
-
- @text_input.event_handler("on_text_input")
- async def on_text_input(_processor, text):
- await queue_transcript("user", text)
- await realtime.interrupt()
- await realtime.send_text(text, run_immediately=True)
-
- @text_input.event_handler("on_text_append")
- async def on_text_append(_processor, text):
- await queue_transcript("user", text)
- await realtime.send_text(text, run_immediately=False)
-
- @transport.event_handler("on_client_connected")
- async def on_client_connected(_transport, _client):
- if greeting:
- await realtime.speak(greeting)
-
- @transport.event_handler("on_client_disconnected")
- async def on_client_disconnected(_transport, _client):
- logger.info("Realtime 对端断开,结束管线")
- await worker.queue_frame(EndFrame())
-
+ bind_realtime_pipeline_events(
+ transport=transport,
+ worker=worker,
+ realtime=realtime,
+ text_input=text_input,
+ greeting=greeting,
+ )
runner = WorkerRunner(handle_sigint=False)
run_status = "completed"
try:
diff --git a/backend/services/pipecat/pipeline_events.py b/backend/services/pipecat/pipeline_events.py
new file mode 100644
index 0000000..ca7b7c1
--- /dev/null
+++ b/backend/services/pipecat/pipeline_events.py
@@ -0,0 +1,199 @@
+"""Event registration for cascade and realtime conversation pipelines."""
+
+from loguru import logger
+
+from pipecat.frames.frames import (
+ EndFrame,
+ LLMMessagesAppendFrame,
+ OutputTransportMessageUrgentFrame,
+ TTSSpeakFrame,
+)
+from pipecat.runner.utils import (
+ get_transport_client_id,
+ maybe_capture_participant_camera,
+)
+from pipecat.utils.time import time_now_iso8601
+
+
+def bind_cascade_pipeline_events(
+ *,
+ transport,
+ worker,
+ brain,
+ context,
+ text_input,
+ user_aggregator,
+ assistant_aggregator,
+ greeting: str,
+ vision_enabled: bool,
+ vision_state: dict[str, str | None],
+) -> None:
+ """Connect processors to transport events without owning pipeline assembly."""
+
+ pending_text_inputs: list[str] = []
+ greeting_transcript_sent = False
+
+ async def queue_transcript(role: str, content: str, timestamp: str) -> None:
+ if not content:
+ return
+ await worker.queue_frame(
+ OutputTransportMessageUrgentFrame(
+ message={
+ "type": "transcript",
+ "role": role,
+ "content": content,
+ "timestamp": timestamp,
+ }
+ )
+ )
+
+ async def append_user_text_to_context(text: str, *, run_llm: bool) -> None:
+ await worker.queue_frame(
+ LLMMessagesAppendFrame(
+ messages=[{"role": "user", "content": text}],
+ run_llm=run_llm,
+ )
+ )
+
+ @user_aggregator.event_handler("on_user_turn_stopped")
+ async def on_user_turn_stopped(_aggregator, _strategy, message):
+ await queue_transcript("user", message.content, message.timestamp)
+
+ @assistant_aggregator.event_handler("on_assistant_text_start")
+ async def on_assistant_text_start(_aggregator, turn_id, timestamp):
+ await brain.on_assistant_text_start(turn_id)
+ await worker.queue_frame(
+ OutputTransportMessageUrgentFrame(
+ message={
+ "type": "assistant-text-start",
+ "turn_id": turn_id,
+ "timestamp": timestamp,
+ }
+ )
+ )
+
+ @assistant_aggregator.event_handler("on_assistant_text_delta")
+ async def on_assistant_text_delta(_aggregator, turn_id, delta):
+ await worker.queue_frame(
+ OutputTransportMessageUrgentFrame(
+ message={
+ "type": "assistant-text-delta",
+ "turn_id": turn_id,
+ "delta": delta,
+ }
+ )
+ )
+
+ @assistant_aggregator.event_handler("on_assistant_text_end")
+ async def on_assistant_text_end(_aggregator, turn_id, content, interrupted):
+ await worker.queue_frame(
+ OutputTransportMessageUrgentFrame(
+ message={
+ "type": "assistant-text-end",
+ "turn_id": turn_id,
+ "content": content,
+ "interrupted": interrupted,
+ }
+ )
+ )
+ await brain.on_assistant_text_end(turn_id, content, interrupted)
+
+ @text_input.event_handler("on_text_input")
+ async def on_text_input(_processor, text):
+ pending_text_inputs.append(text)
+ # The transcript must be queued before the interruption is broadcast.
+ await queue_transcript("user", text, time_now_iso8601())
+
+ @assistant_aggregator.event_handler("on_interruption_processed")
+ async def on_interruption_processed(_aggregator):
+ if not pending_text_inputs:
+ return
+ text = pending_text_inputs.pop(0)
+ await append_user_text_to_context(text, run_llm=True)
+
+ @text_input.event_handler("on_text_append")
+ async def on_text_append(_processor, text):
+ brain.record_user_message(text)
+ await queue_transcript("user", text, time_now_iso8601())
+ await append_user_text_to_context(text, run_llm=False)
+
+ @text_input.event_handler("on_client_ready")
+ async def on_client_ready(_processor):
+ nonlocal greeting_transcript_sent
+ if greeting and not greeting_transcript_sent:
+ greeting_transcript_sent = True
+ await queue_transcript("assistant", greeting, time_now_iso8601())
+ await brain.on_client_ready()
+
+ @transport.event_handler("on_client_connected")
+ async def on_client_connected(_transport, _client):
+ if vision_enabled:
+ try:
+ vision_state["client_id"] = get_transport_client_id(
+ _transport,
+ _client,
+ )
+ await maybe_capture_participant_camera(_transport, _client)
+ logger.info(
+ f"视觉理解已接入视频客户端: {vision_state['client_id']}"
+ )
+ except Exception as exc: # noqa: BLE001 - media availability is optional
+ logger.warning(f"视觉理解摄像头捕获初始化失败: {exc}")
+ if greeting:
+ if brain.spec.owns_context:
+ context.add_message({"role": "assistant", "content": greeting})
+ await worker.queue_frame(
+ TTSSpeakFrame(greeting, append_to_context=False)
+ )
+ await brain.on_connected()
+
+ @transport.event_handler("on_client_disconnected")
+ async def on_client_disconnected(_transport, _client):
+ logger.info("对端断开,结束管线")
+ await worker.queue_frame(EndFrame())
+
+
+def bind_realtime_pipeline_events(
+ *,
+ transport,
+ worker,
+ realtime,
+ text_input,
+ greeting: str,
+) -> None:
+ """Connect text and lifecycle events for a realtime model pipeline."""
+
+ async def queue_transcript(role: str, content: str) -> None:
+ if not content:
+ return
+ await worker.queue_frame(
+ OutputTransportMessageUrgentFrame(
+ message={
+ "type": "transcript",
+ "role": role,
+ "content": content,
+ "timestamp": time_now_iso8601(),
+ }
+ )
+ )
+
+ @text_input.event_handler("on_text_input")
+ async def on_text_input(_processor, text):
+ await queue_transcript("user", text)
+ await realtime.interrupt()
+ await realtime.send_text(text, run_immediately=True)
+
+ @text_input.event_handler("on_text_append")
+ async def on_text_append(_processor, text):
+ await queue_transcript("user", text)
+ await realtime.send_text(text, run_immediately=False)
+
+ @transport.event_handler("on_client_connected")
+ async def on_client_connected(_transport, _client):
+ if greeting:
+ await realtime.speak(greeting)
+
+ @transport.event_handler("on_client_disconnected")
+ async def on_client_disconnected(_transport, _client):
+ logger.info("Realtime 对端断开,结束管线")
+ await worker.queue_frame(EndFrame())
diff --git a/backend/services/pipecat/processors.py b/backend/services/pipecat/processors.py
new file mode 100644
index 0000000..7452b03
--- /dev/null
+++ b/backend/services/pipecat/processors.py
@@ -0,0 +1,452 @@
+"""Reusable frame processors shared by cascade and realtime pipelines."""
+
+import asyncio
+from collections.abc import Callable
+from uuid import uuid4
+
+from loguru import logger
+from models import AssistantConfig
+from services.brains import Brain
+from services.conversation_history import ConversationRecorder
+from services.knowledge import search as search_knowledge
+from db.session import SessionLocal
+
+from pipecat.frames.frames import (
+ InputTransportMessageFrame,
+ InterruptionFrame,
+ LLMContextFrame,
+ LLMFullResponseEndFrame,
+ LLMFullResponseStartFrame,
+ LLMTextFrame,
+ OutputTransportMessageUrgentFrame,
+ TextFrame,
+ UserImageRawFrame,
+ UserImageRequestFrame,
+)
+from pipecat.processors.aggregators.llm_response_universal import (
+ LLMAssistantAggregator,
+)
+from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
+from pipecat.turns.user_mute.base_user_mute_strategy import BaseUserMuteStrategy
+from pipecat.utils.time import time_now_iso8601
+
+
+KNOWLEDGE_CONTEXT_MARKER = ""
+
+
+def _text_input(message) -> tuple[str, bool] | None:
+ """解析现有 user-text 与 RTVI send-text 两种前端文字消息。"""
+ if not isinstance(message, dict):
+ return None
+ if message.get("type") == "user-text":
+ text = str(message.get("text") or "").strip()
+ return (text, True) if text else None
+ if message.get("type") == "send-text":
+ data = message.get("data")
+ if not isinstance(data, dict):
+ return None
+ text = str(data.get("content") or "").strip()
+ options = data.get("options")
+ run_immediately = not isinstance(options, dict) or options.get(
+ "run_immediately", True
+ )
+ return (text, bool(run_immediately)) if text else None
+ return None
+
+
+class TextInputProcessor(FrameProcessor):
+ """把 transport 文字消息转换成 LLM 可消费的帧。
+
+ run_immediately(默认/打断):先通过 on_text_input 事件把用户文字交给
+ run_pipeline 登记,再用 broadcast_interruption() 打断当前播报。新的 LLM
+ 回复由 assistant aggregator 确认处理完 interruption 后触发。
+ run_immediately=False(RTVI send-text 静默追加):仅把文字写进上下文,
+ 不打断、不触发推理。
+ """
+
+ def __init__(self, should_ignore_input: Callable[[], bool] | None = None):
+ super().__init__()
+ self._should_ignore_input = should_ignore_input or (lambda: False)
+ # 立即触发的文字(含打断语义)走 on_text_input;静默追加另走一条事件
+ self._register_event_handler("on_text_input")
+ self._register_event_handler("on_text_append")
+ self._register_event_handler("on_client_ready")
+
+ async def process_frame(self, frame, direction: FrameDirection):
+ await super().process_frame(frame, direction)
+
+ if not isinstance(frame, InputTransportMessageFrame):
+ await self.push_frame(frame, direction)
+ return
+
+ if isinstance(frame.message, dict) and frame.message.get("type") == "client-ready":
+ await self._call_event_handler("on_client_ready")
+ return
+
+ parsed = _text_input(frame.message)
+ if not parsed:
+ await self.push_frame(frame, direction)
+ return
+
+ if self._should_ignore_input():
+ logger.debug("通话正在结束,忽略后续文字输入")
+ return
+
+ text, run_immediately = parsed
+ if run_immediately:
+ # 先登记文字再打断。下一轮 LLM 由 assistant aggregator 在真正处理完
+ # InterruptionFrame 后触发,避免新回复被这次 interruption 一起取消。
+ await self._call_event_handler("on_text_input", text)
+ await self.broadcast_interruption()
+ else:
+ await self._call_event_handler("on_text_append", text)
+
+
+class CallEndingUserMuteStrategy(BaseUserMuteStrategy):
+ """Keep user media muted after an end-call tool starts terminating a call."""
+
+ def __init__(self, is_call_ending: Callable[[], bool]):
+ super().__init__()
+ self._is_call_ending = is_call_ending
+
+ async def process_frame(self, frame) -> bool:
+ await super().process_frame(frame)
+ return self._is_call_ending()
+
+
+class VisionCaptureProcessor(FrameProcessor):
+ """Capture one requested video frame for auxiliary vision-model analysis."""
+
+ def __init__(self, timeout_s: float = 3.0):
+ super().__init__()
+ self._timeout_s = timeout_s
+ self._pending: dict[str, asyncio.Future[UserImageRawFrame]] = {}
+
+ async def request_image(
+ self,
+ requester: FrameProcessor,
+ request: UserImageRequestFrame,
+ ) -> UserImageRawFrame:
+ key = request.tool_call_id or str(uuid4())
+ request.tool_call_id = key
+ request.append_to_context = False
+ request.result_callback = None
+
+ loop = asyncio.get_running_loop()
+ future: asyncio.Future[UserImageRawFrame] = loop.create_future()
+ self._pending[key] = future
+ await requester.push_frame(request, FrameDirection.UPSTREAM)
+ try:
+ return await asyncio.wait_for(future, timeout=self._timeout_s)
+ finally:
+ self._pending.pop(key, None)
+
+ async def process_frame(self, frame, direction: FrameDirection):
+ await super().process_frame(frame, direction)
+
+ if (
+ isinstance(frame, UserImageRawFrame)
+ and frame.request
+ and frame.request.tool_call_id
+ and frame.request.tool_call_id in self._pending
+ ):
+ future = self._pending[frame.request.tool_call_id]
+ if not future.done():
+ future.set_result(frame)
+ return
+
+ await self.push_frame(frame, direction)
+
+
+class RealtimeDynamicVariableProcessor(FrameProcessor):
+ """Keep realtime system turn/history variables current between responses."""
+
+ def __init__(self, brain: Brain, cfg: AssistantConfig, realtime):
+ super().__init__()
+ self._brain = brain
+ self._cfg = cfg
+ self._realtime = realtime
+
+ async def _refresh_instructions(self) -> None:
+ update = getattr(self._realtime, "update_instructions", None)
+ if callable(update):
+ await update(self._brain.system_prompt(self._cfg))
+
+ async def process_frame(self, frame, direction: FrameDirection):
+ await super().process_frame(frame, direction)
+ if isinstance(frame, OutputTransportMessageUrgentFrame):
+ message = frame.message
+ if isinstance(message, dict):
+ event_type = message.get("type")
+ if event_type == "transcript" and message.get("role") == "user":
+ content = str(message.get("content") or "").strip()
+ if content:
+ self._brain.record_user_message(content)
+ await self._refresh_instructions()
+ elif event_type == "assistant-text-end":
+ await self._brain.on_assistant_text_end(
+ str(message.get("turn_id") or ""),
+ str(message.get("content") or ""),
+ bool(message.get("interrupted", False)),
+ )
+ await self._refresh_instructions()
+ await self.push_frame(frame, direction)
+
+
+class RealtimeTextInputProcessor(FrameProcessor):
+ """Route text input directly to a realtime service without cascade semantics."""
+
+ def __init__(self):
+ super().__init__()
+ self._register_event_handler("on_text_input")
+ self._register_event_handler("on_text_append")
+
+ async def process_frame(self, frame, direction: FrameDirection):
+ await super().process_frame(frame, direction)
+
+ if not isinstance(frame, InputTransportMessageFrame):
+ await self.push_frame(frame, direction)
+ return
+
+ parsed = _text_input(frame.message)
+ if not parsed:
+ await self.push_frame(frame, direction)
+ return
+
+ text, run_immediately = parsed
+ await self._call_event_handler(
+ "on_text_input" if run_immediately else "on_text_append",
+ text,
+ )
+
+
+class ConversationHistoryProcessor(FrameProcessor):
+ """从最终客户端事件旁路保存历史,不改变 Pipecat 的上下文与帧语义。"""
+
+ def __init__(self, recorder: ConversationRecorder | None):
+ super().__init__()
+ self._recorder = recorder
+
+ async def process_frame(self, frame, direction: FrameDirection):
+ await super().process_frame(frame, direction)
+ await self.push_frame(frame, direction)
+ if self._recorder and isinstance(frame, OutputTransportMessageUrgentFrame):
+ await self._recorder.record_transport_message(frame.message)
+
+
+class KnowledgeRetrievalProcessor(FrameProcessor):
+ """Retrieve before local LLM inference without changing Pipecat internals."""
+
+ def __init__(
+ self,
+ knowledge_base_id: str | None,
+ top_n: int = 5,
+ score_threshold: float = 0.0,
+ ):
+ super().__init__()
+ self._knowledge_base_id = knowledge_base_id
+ self._top_n = top_n
+ self._score_threshold = score_threshold
+ self._mode = "automatic" if knowledge_base_id else "disabled"
+ self._last_signature = ""
+
+ def set_scope(self, scope: dict) -> None:
+ self._knowledge_base_id = scope.get("knowledge_base_id") or None
+ self._mode = str(scope.get("mode") or "disabled")
+ self._top_n = int(scope.get("top_n") or 5)
+ self._score_threshold = float(scope.get("score_threshold") or 0.0)
+ self._last_signature = ""
+
+ def _clear_context(self, messages: list[dict]) -> None:
+ # Remove the legacy Workflow knowledge message so an in-flight context
+ # created before this compatibility fix cannot keep sending that role.
+ messages[:] = [
+ message
+ for message in messages
+ if not (
+ message.get("role") == "developer"
+ and KNOWLEDGE_CONTEXT_MARKER in str(message.get("content") or "")
+ )
+ ]
+ system_message = next(
+ (message for message in messages if message.get("role") == "system"),
+ None,
+ )
+ if system_message is not None:
+ content = str(system_message.get("content") or "")
+ system_message["content"] = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
+
+ def _set_context(self, messages: list[dict], block: str) -> None:
+ """Store retrieved knowledge in a provider-compatible system message."""
+ self._clear_context(messages)
+ system_message = next(
+ (message for message in messages if message.get("role") == "system"),
+ None,
+ )
+ if system_message is None:
+ messages.insert(0, {"role": "system", "content": block})
+ return
+ content = str(system_message.get("content") or "").rstrip()
+ system_message["content"] = f"{content}\n\n{block}" if content else block
+
+ async def process_frame(self, frame, direction: FrameDirection):
+ await super().process_frame(frame, direction)
+ if not isinstance(frame, LLMContextFrame):
+ await self.push_frame(frame, direction)
+ return
+
+ messages = frame.context.get_messages()
+ if self._mode != "automatic" or not self._knowledge_base_id:
+ self._clear_context(messages)
+ await self.push_frame(frame, direction)
+ return
+ user_messages = [message for message in messages if message.get("role") == "user"]
+ if not user_messages:
+ await self.push_frame(frame, direction)
+ return
+ query = str(user_messages[-1].get("content") or "").strip()
+ signature = f"{len(user_messages)}:{query}"
+ if not query or signature == self._last_signature:
+ await self.push_frame(frame, direction)
+ return
+ self._last_signature = signature
+
+ try:
+ async with SessionLocal() as session:
+ results = await search_knowledge(
+ session,
+ self._knowledge_base_id,
+ query,
+ top_k=self._top_n,
+ score_threshold=self._score_threshold,
+ )
+ except Exception as exc:
+ logger.warning(f"自动知识库检索失败: {exc}")
+ results = []
+
+ sources = "\n\n".join(
+ f"[{index + 1}] 来源:{item['document']}(相关度 {item['score']})\n{item['content']}"
+ for index, item in enumerate(results)
+ ) or "未检索到相关资料。"
+ block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}"
+ self._set_context(messages, block)
+ await self.push_frame(frame, direction)
+
+
+class UserTurnRoutingProcessor(FrameProcessor):
+ """Give a brain first right of refusal before a new user turn reaches the LLM."""
+
+ def __init__(self, brain: Brain):
+ super().__init__()
+ self._brain = brain
+ self._last_user_message: dict | None = None
+
+ async def process_frame(self, frame, direction: FrameDirection):
+ await super().process_frame(frame, direction)
+ if direction != FrameDirection.DOWNSTREAM or not isinstance(
+ frame, LLMContextFrame
+ ):
+ await self.push_frame(frame, direction)
+ return
+
+ user_message = next(
+ (
+ message
+ for message in reversed(frame.context.get_messages())
+ if message.get("role") == "user"
+ and isinstance(message.get("content"), str)
+ and str(message.get("content") or "").strip()
+ ),
+ None,
+ )
+ if user_message is None:
+ await self.push_frame(frame, direction)
+ return
+
+ if user_message is self._last_user_message:
+ # Programmatic LLMRunFrame after a node transition reuses the same
+ # user message. It is a response run, not another routing event.
+ await self.push_frame(frame, direction)
+ return
+ self._last_user_message = user_message
+
+ content = str(user_message.get("content") or "").strip()
+ handled = await self._brain.on_user_turn_end(content)
+ if not handled:
+ await self.push_frame(frame, direction)
+
+
+class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
+ """聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._register_event_handler("on_interruption_processed")
+ self._register_event_handler("on_assistant_text_start")
+ self._register_event_handler("on_assistant_text_delta")
+ self._register_event_handler("on_assistant_text_end")
+ self._stream_turn_id: str | None = None
+ self._stream_timestamp = ""
+ self._stream_text = ""
+ async def process_frame(self, frame, direction: FrameDirection):
+ await super().process_frame(frame, direction)
+
+ if isinstance(frame, LLMFullResponseStartFrame):
+ self._stream_turn_id = uuid4().hex
+ self._stream_timestamp = time_now_iso8601()
+ self._stream_text = ""
+ await self._call_event_handler(
+ "on_assistant_text_start",
+ self._stream_turn_id,
+ self._stream_timestamp,
+ )
+ elif isinstance(frame, LLMTextFrame) and self._stream_turn_id:
+ self._stream_text += frame.text
+ await self._call_event_handler(
+ "on_assistant_text_delta",
+ self._stream_turn_id,
+ frame.text,
+ )
+ elif isinstance(frame, LLMFullResponseEndFrame):
+ await self._finish_text_stream(interrupted=False)
+
+ # LLMAssistantAggregator 默认会消费这些帧。放在 TTS 前用于中断时保存
+ # 已生成前缀时,必须显式透传,否则 TTS 收不到任何 LLM 回复。
+ if isinstance(
+ frame,
+ (LLMFullResponseStartFrame, LLMFullResponseEndFrame, TextFrame),
+ ):
+ await self.push_frame(frame, direction)
+ elif isinstance(frame, InterruptionFrame):
+ await self._finish_text_stream(interrupted=True)
+ await self._call_event_handler("on_interruption_processed")
+
+ async def _finish_text_stream(self, *, interrupted: bool):
+ if not self._stream_turn_id:
+ return
+ await self._call_event_handler(
+ "on_assistant_text_end",
+ self._stream_turn_id,
+ self._stream_text,
+ interrupted,
+ )
+ self._stream_turn_id = None
+ self._stream_timestamp = ""
+ 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
+
+
+
diff --git a/backend/services/pipecat/workflow_services.py b/backend/services/pipecat/workflow_services.py
new file mode 100644
index 0000000..c940fcc
--- /dev/null
+++ b/backend/services/pipecat/workflow_services.py
@@ -0,0 +1,131 @@
+"""Workflow model resource loading and runtime service switching."""
+
+from models import AssistantConfig
+from services.pipecat.service_factory import (
+ config_with_resource,
+ create_llm,
+ create_stt,
+ create_tts,
+)
+
+from pipecat.frames.frames import (
+ ManuallySwitchServiceFrame,
+ OutputTransportMessageUrgentFrame,
+)
+from pipecat.pipeline.llm_switcher import LLMSwitcher
+from pipecat.pipeline.service_switcher import ServiceSwitcher
+from pipecat.processors.frame_processor import FrameProcessor
+
+
+def build_workflow_voice_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 build_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
+
+
+
+
+class WorkflowServiceController:
+ """Switch one Workflow stage's model resources without leaking state."""
+
+ def __init__(
+ self,
+ *,
+ worker,
+ llm_services: dict[str, FrameProcessor],
+ voice_services: dict[str, dict[str, FrameProcessor]],
+ current_services: dict[str, FrameProcessor],
+ ) -> None:
+ self._worker = worker
+ self._services = {
+ "llm": llm_services,
+ "asr": voice_services["asr"],
+ "tts": voice_services["tts"],
+ }
+ self._current = dict(current_services)
+ self._defaults = dict(current_services)
+
+ async def switch(
+ self,
+ llm_resource_id: str | None,
+ asr_resource_id: str | None,
+ tts_resource_id: str | None,
+ ) -> None:
+ requested = (
+ ("llm", llm_resource_id),
+ ("asr", asr_resource_id),
+ ("tts", tts_resource_id),
+ )
+ for kind, resource_id in requested:
+ target = (
+ self._services[kind].get(resource_id)
+ if resource_id
+ else self._defaults[kind]
+ )
+ if target is None:
+ raise ValueError(
+ f"Workflow {kind.upper()} 资源未加载:{resource_id}"
+ )
+ if self._current[kind] is target:
+ continue
+
+ await self._worker.queue_frame(
+ ManuallySwitchServiceFrame(service=target)
+ )
+ self._current[kind] = target
+ await self._worker.queue_frame(
+ OutputTransportMessageUrgentFrame(
+ message={
+ "type": "service-switched",
+ "capability": kind.upper(),
+ "resourceId": resource_id,
+ }
+ )
+ )
+
diff --git a/frontend/src/components/assistant-editor/debug-preview.tsx b/frontend/src/components/assistant-editor/debug-preview.tsx
new file mode 100644
index 0000000..7bb326a
--- /dev/null
+++ b/frontend/src/components/assistant-editor/debug-preview.tsx
@@ -0,0 +1,1253 @@
+"use client";
+
+import type React from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ AudioLines,
+ Braces,
+ Check,
+ Copy,
+ Loader2,
+ MessageSquareText,
+ Mic,
+ Orbit,
+ PhoneOff,
+ Send,
+ Smartphone,
+ Sparkles,
+ Video,
+ Waves,
+ X,
+} from "lucide-react";
+
+import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
+import { AuraVisualizer } from "@/components/ui/aura-visualizer";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
+import { Textarea } from "@/components/ui/textarea";
+import { WaveVisualizer } from "@/components/ui/wave-visualizer";
+import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline";
+import { useCameraPreview, type CameraPreview } from "@/hooks/use-camera-preview";
+import {
+ useVoicePreview,
+ type ChatMessage,
+ type VoicePreview,
+ type VoicePreviewStatus,
+} from "@/hooks/use-voice-preview";
+import type { DynamicVariableDefinition } from "@/lib/api";
+
+type VizStyle = "aura" | "nebula" | "bars" | "wave";
+
+// 调试面板顶部主视图:聊天记录 / 视频流
+type DebugView = "chat" | "video";
+type DebugInputMode = "mic" | "text";
+
+const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
+ [
+ { style: "aura", label: "光环", icon: },
+ { style: "nebula", label: "星云", icon: },
+ { style: "bars", label: "频谱", icon: },
+ { style: "wave", label: "波形", icon: },
+ ];
+
+// 中央语音可视化(光环/星云/频谱/波形)暂时隐藏:调试面板固定为
+// 「上聊天记录 + 下波形监控」布局。置 true 可恢复可视化视图与样式切换。
+const SHOW_VOICE_VIZ = false;
+
+function SegmentedIconGroup({
+ children,
+ label,
+}: {
+ children: React.ReactNode;
+ label: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SegmentedIconButton({
+ selected,
+ label,
+ onClick,
+ children,
+}: {
+ selected: boolean;
+ label: string;
+ onClick: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export function DebugDrawer({
+ assistantId,
+ overlay = false,
+ onClose,
+ hasUnsavedChanges = false,
+ onNodeActive,
+ vision = false,
+ dynamicVariablesEnabled = false,
+ dynamicVariableDefinitions = {},
+}: {
+ assistantId: string | null;
+ overlay?: boolean;
+ onClose?: () => void;
+ hasUnsavedChanges?: boolean;
+ onNodeActive?: (nodeId: string | null) => void;
+ vision?: boolean;
+ dynamicVariablesEnabled?: boolean;
+ dynamicVariableDefinitions?: Record;
+}) {
+ const preview = useVoicePreview(assistantId, onNodeActive);
+ const camera = useCameraPreview();
+ const [showTranscript, setShowTranscript] = useState(false);
+ const [vizStyle, setVizStyle] = useState("aura");
+ const [view, setView] = useState("chat");
+ const [dynamicVariableValues, setDynamicVariableValues] = useState<
+ Record
+ >({});
+ const recording =
+ preview.status === "connecting" || preview.status === "connected";
+ const displayedDefinitions = { ...dynamicVariableDefinitions };
+ for (const [name, value] of Object.entries(preview.sessionVariables)) {
+ displayedDefinitions[name] ??= {
+ type:
+ typeof value === "number"
+ ? "number"
+ : typeof value === "boolean"
+ ? "boolean"
+ : "string",
+ required: false,
+ default: null,
+ };
+ }
+ const dynamicVariableEntries = Object.entries(displayedDefinitions);
+ const resolvedDynamicVariables: Record = {};
+ let dynamicVariablesError = "";
+ for (const [name, definition] of Object.entries(dynamicVariableDefinitions)) {
+ const value = dynamicVariableValues[name] ?? definition.default;
+ if (value === null || value === undefined || value === "") {
+ if (definition.required && !dynamicVariablesError) {
+ dynamicVariablesError = `请先填写必填变量 ${name}`;
+ }
+ continue;
+ }
+ resolvedDynamicVariables[name] = value;
+ }
+
+ const selectCamera = useCallback(
+ async (deviceId: string) => {
+ await camera.selectCamera(deviceId);
+ preview.selectCamera(deviceId);
+ },
+ [camera, preview],
+ );
+
+ return (
+
+ );
+}
+
+function DynamicVariableValuesPopover({
+ entries,
+ values,
+ sessionValues,
+ readOnly,
+ onChange,
+}: {
+ entries: [string, DynamicVariableDefinition][];
+ values: Record;
+ sessionValues: Record;
+ readOnly: boolean;
+ onChange: React.Dispatch<
+ React.SetStateAction>
+ >;
+}) {
+ function setValue(name: string, value: string | number | boolean | undefined) {
+ onChange((current) => {
+ const next = { ...current };
+ if (value === undefined) delete next[name];
+ else next[name] = value;
+ return next;
+ });
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ 本次会话变量
+
+
+ {readOnly
+ ? "当前值会在 Action 或工具更新变量后实时刷新。"
+ : "这些值只用于下一次调试会话,不会修改助手配置。"}
+
+
+
+ {entries.length === 0 ? (
+
+
+ 当前没有会话变量
+
+
+ 在工作流提示词、节点话术、边条件或 Action 中引用变量后,
+ 可在这里设置调试值。
+
+
+ ) : entries.map(([name, definition]) => {
+ const value = readOnly
+ ? sessionValues[name] ?? definition.default ?? ""
+ : values[name] ?? definition.default ?? "";
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+function CallPreviewLink({ assistantId }: { assistantId: string | null }) {
+ const [callUrl, setCallUrl] = useState("");
+ const [copied, setCopied] = useState(false);
+
+ const copyLink = useCallback(async () => {
+ if (!callUrl) return;
+ await navigator.clipboard.writeText(callUrl);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1600);
+ }, [callUrl]);
+
+ return (
+ {
+ if (open && assistantId) {
+ setCallUrl(
+ `${window.location.origin}/call/${encodeURIComponent(assistantId)}`,
+ );
+ setCopied(false);
+ }
+ }}
+ >
+
+
+
+
+
+
手机通话预览
+
+ 复制链接并在浏览器中打开,即可进入全屏视频通话界面。
+
+
+
+ event.currentTarget.select()}
+ className="h-9 min-w-0 flex-1 text-xs"
+ />
+
+
+ {copied && 链接已复制
}
+
+
+ );
+}
+
+function DeviceSelectField({
+ icon,
+ ariaLabel,
+ placeholder,
+ fallbackLabel,
+ value,
+ devices,
+ onSelect,
+}: {
+ icon: React.ReactNode;
+ ariaLabel: string;
+ placeholder: string;
+ fallbackLabel: string;
+ value: string;
+ devices: MediaDeviceInfo[];
+ onSelect: (deviceId: string) => void;
+}) {
+ return (
+
+ );
+}
+
+function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) {
+ return (
+ }
+ ariaLabel="选择麦克风"
+ placeholder="默认麦克风"
+ fallbackLabel="麦克风"
+ value={preview.selectedDeviceId}
+ devices={preview.audioInputs}
+ onSelect={(deviceId) => preview.selectDevice(deviceId)}
+ />
+ );
+}
+
+function CameraDeviceField({
+ camera,
+ onSelect,
+}: {
+ camera: CameraPreview;
+ onSelect?: (deviceId: string) => void | Promise;
+}) {
+ return (
+ }
+ ariaLabel="选择摄像头"
+ placeholder="默认摄像头"
+ fallbackLabel="摄像头"
+ value={camera.deviceId}
+ devices={camera.devices}
+ onSelect={(deviceId) => void (onSelect ?? camera.selectCamera)(deviceId)}
+ />
+ );
+}
+
+function DebugInputModeButton({
+ selected,
+ label,
+ onClick,
+ children,
+}: {
+ selected: boolean;
+ label: string;
+ onClick: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function DebugVoicePanel({
+ view,
+ onViewChange,
+ showTranscript,
+ vizStyle,
+ assistantId,
+ preview,
+ camera,
+ hasUnsavedChanges,
+ vision,
+ dynamicVariables,
+ dynamicVariablesError,
+}: {
+ view: DebugView;
+ onViewChange: (view: DebugView) => void;
+ showTranscript: boolean;
+ vizStyle: VizStyle;
+ assistantId: string | null;
+ preview: VoicePreview;
+ camera: CameraPreview;
+ hasUnsavedChanges: boolean;
+ vision: boolean;
+ dynamicVariables: Record;
+ dynamicVariablesError: string;
+}) {
+ const {
+ status,
+ error,
+ micWarning,
+ localStream,
+ remoteStream,
+ messages,
+ sendText,
+ connect,
+ disconnect,
+ audioRef,
+ } = preview;
+ const recording = status === "connecting" || status === "connected";
+ const [textDraft, setTextDraft] = useState("");
+ const [inputMode, setInputMode] = useState("mic");
+ const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript);
+ const idleOrFailed = status === "idle" || status === "failed";
+ const showIdleHub =
+ idleOrFailed &&
+ (view === "video" || (messages.length === 0 && inChatView));
+ const startBlockedMessage = !assistantId
+ ? "请先保存助手,再开始对话。"
+ : hasUnsavedChanges
+ ? "请先保存当前改动,再开始对话。"
+ : dynamicVariablesError
+ ? dynamicVariablesError
+ : "";
+ const startDisabled = status === "connecting" || Boolean(startBlockedMessage);
+
+ function handleSendText() {
+ if (sendText(textDraft)) {
+ setTextDraft("");
+ }
+ }
+
+ const startConversation = useCallback(async () => {
+ if (!assistantId || hasUnsavedChanges) return;
+ if (dynamicVariablesError) return;
+ await connect({
+ visionEnabled: vision,
+ dynamicVariables,
+ });
+ }, [
+ assistantId,
+ connect,
+ dynamicVariables,
+ dynamicVariablesError,
+ hasUnsavedChanges,
+ vision,
+ ]);
+
+ return (
+
+ {/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
+
+ {vision && !showIdleHub ? (
+
+ ) : view === "video" ? (
+ showIdleHub ? (
+
+ ) : (
+
+ )
+ ) : !SHOW_VOICE_VIZ || showTranscript ? (
+ showIdleHub ? (
+
+ ) : (
+
+ )
+ ) : (
+
+
+
+
+
+ {recording ? "会话进行中" : "准备开始"}
+
+
+
+ {(() => {
+ const shared = {
+ active: Boolean(localStream),
+ stream: localStream,
+ className: "relative shrink-0",
+ } as const;
+ if (vizStyle === "aura")
+ return
;
+ if (vizStyle === "nebula")
+ return
;
+ if (vizStyle === "bars")
+ return
;
+ return
;
+ })()}
+
+
+
+
+ {status === "connecting"
+ ? "连接中…"
+ : status === "connected"
+ ? micWarning
+ ? "仅收听模式"
+ : "我在聆听"
+ : "开始一次语音对话"}
+
+
+ {status === "failed"
+ ? error ||
+ "连接失败,请确认后端已启动且助手已保存后重试。"
+ : !assistantId
+ ? "请先保存助手,再开始语音预览。"
+ : hasUnsavedChanges
+ ? "请先保存当前改动,再开始语音预览。"
+ : micWarning
+ ? `${micWarning} 可接收助手播报,但无法发送语音。`
+ : recording
+ ? "直接说话即可。助手会在您停顿后自然回应。"
+ : "测试语音识别、响应速度与助手的播报效果。"}
+
+
+
+
+
+ )}
+
+
+
+
+
+ setInputMode("mic")}
+ >
+
+
+ setInputMode("text")}
+ >
+
+
+
+ {inputMode === "mic" ? (
+
+ ) : (
+
+ {inputMode === "text" && (
+
+ )}
+ {!showIdleHub && (
+
+
+ {!recording && startBlockedMessage && (
+
+ {startBlockedMessage}
+
+ )}
+
+ )}
+
+
+
+ {/* 底部双轨波形:会话连通后默认展开,空闲时收起 */}
+
+
+ );
+}
+
+// 空闲态只保留一个明确入口,避免中间区域显得拥挤。
+function DebugIdleHub({
+ status,
+ error,
+ message,
+ connect,
+}: {
+ status: VoicePreviewStatus;
+ error: string | null;
+ message: string;
+ connect: () => Promise;
+}) {
+ const helperText =
+ message ||
+ (status === "failed"
+ ? error || "连接失败,请确认后端已启动且助手已保存后重试。"
+ : "");
+
+ return (
+
+
+ {helperText && (
+
+ {helperText}
+
+ )}
+
+ );
+}
+
+function DebugVisionWorkspace({
+ view,
+ onViewChange,
+ messages,
+ recording,
+ camera,
+ videoStream,
+}: {
+ view: DebugView;
+ onViewChange: (view: DebugView) => void;
+ messages: ChatMessage[];
+ recording: boolean;
+ camera: CameraPreview;
+ videoStream: MediaStream | null;
+}) {
+ const containerRef = useRef(null);
+ const [position, setPosition] = useState({ x: 16, y: 16 });
+ const positionedRef = useRef(false);
+ const dragRef = useRef<{
+ pointerId: number;
+ startX: number;
+ startY: number;
+ originX: number;
+ originY: number;
+ moved: boolean;
+ } | null>(null);
+ const latestMessage = [...messages]
+ .reverse()
+ .find((message) => message.content.trim());
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const rect = container.getBoundingClientRect();
+ if (!positionedRef.current) {
+ positionedRef.current = true;
+ setPosition({ x: Math.max(16, rect.width - 192), y: 16 });
+ return;
+ }
+ setPosition((current) => ({
+ x: Math.min(Math.max(16, current.x), Math.max(16, rect.width - 192)),
+ y: Math.min(Math.max(16, current.y), Math.max(16, rect.height - 116)),
+ }));
+ }, [view]);
+
+ const moveFloatingVideo = useCallback((event: React.PointerEvent) => {
+ const drag = dragRef.current;
+ const container = containerRef.current;
+ if (!drag || !container || drag.pointerId !== event.pointerId) return;
+ const dx = event.clientX - drag.startX;
+ const dy = event.clientY - drag.startY;
+ if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true;
+ const rect = container.getBoundingClientRect();
+ setPosition({
+ x: Math.min(Math.max(16, drag.originX + dx), Math.max(16, rect.width - 192)),
+ y: Math.min(Math.max(16, drag.originY + dy), Math.max(16, rect.height - 116)),
+ });
+ }, []);
+
+ if (view === "video") {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+function DebugVideoPanel({
+ camera,
+ streamOverride,
+ compact = false,
+}: {
+ camera: CameraPreview;
+ streamOverride?: MediaStream | null;
+ compact?: boolean;
+}) {
+ const { stream, error, starting, active } = camera;
+ const effectiveStream = streamOverride ?? stream;
+ const effectiveActive = Boolean(effectiveStream) || active;
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ if (videoRef.current) videoRef.current.srcObject = effectiveStream;
+ }, [effectiveStream]);
+
+ return (
+
+
+ {!effectiveActive ? (
+
+
+
+ {starting ? "正在开启摄像头…" : "摄像头不可用"}
+
+ {error && (
+
{error}
+ )}
+
+ ) : null}
+
+ );
+}
+
+function DebugConnectionStatus({
+ status,
+ micWarning,
+}: {
+ status: VoicePreviewStatus;
+ micWarning: string | null;
+}) {
+ const recording = status === "connecting" || status === "connected";
+ const label =
+ status === "connecting"
+ ? "连接中…"
+ : status === "connected"
+ ? micWarning
+ ? "仅收听"
+ : "进行中"
+ : status === "failed"
+ ? "连接失败"
+ : "准备开始";
+
+ return (
+
+
+ {label}
+
+ );
+}
+
+// ISO 时间戳 → HH:MM(本地时区),解析失败返回空串
+function formatMessageTime(iso: string): string {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "";
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+function DebugTranscriptPanel({
+ messages,
+ recording = false,
+}: {
+ messages: ChatMessage[];
+ recording?: boolean;
+}) {
+ const scrollRef = useRef(null);
+
+ // 新消息时滚到底部
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (el) el.scrollTop = el.scrollHeight;
+ }, [messages]);
+
+ if (messages.length === 0) {
+ return (
+
+
+
+
+ {recording ? "暂无聊天记录" : "尚未开始对话"}
+
+
+ {recording
+ ? "开口说话或在下方输入文字,对话内容会实时显示在这里。"
+ : "点击「开始对话」后,语音与文字消息会实时显示在这里。"}
+
+
+
+ );
+ }
+
+ return (
+
+
+ {messages.map((message) => {
+ const time = formatMessageTime(message.timestamp);
+ return message.role === "assistant" ? (
+
+
+ 助手{time ? ` · ${time}` : ""}
+
+
+ {message.content}
+
+
+ ) : (
+
+
+ 我{time ? ` · ${time}` : ""}
+
+
+ {message.content}
+
+
+ );
+ })}
+
+
+ );
+}
+
diff --git a/frontend/src/components/assistant-editor/dynamic-variables.tsx b/frontend/src/components/assistant-editor/dynamic-variables.tsx
new file mode 100644
index 0000000..3f78bdd
--- /dev/null
+++ b/frontend/src/components/assistant-editor/dynamic-variables.tsx
@@ -0,0 +1,362 @@
+"use client";
+
+import { Braces, Check, Copy } from "lucide-react";
+import { useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs";
+import type { WorkflowGraph } from "@/components/workflow/specs";
+import type { DynamicVariableDefinition } from "@/lib/api";
+
+const DYNAMIC_VARIABLE_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_]{0,63})\s*}}/g;
+const SYSTEM_DYNAMIC_VARIABLES = [
+ ["system__conversation_id", "会话 ID"],
+ ["system__time", "当前时间"],
+ ["system__timezone", "会话时区"],
+ ["system__agent_turns", "助手轮次"],
+ ["system__conversation_history", "会话历史"],
+] as const;
+
+function isPublicDynamicVariableName(name: string): boolean {
+ return (
+ /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) &&
+ !name.startsWith("system__") &&
+ !name.startsWith("secret__")
+ );
+}
+
+function extractDynamicVariableNames(...templates: string[]): string[] {
+ const names = new Set();
+ for (const template of templates) {
+ for (const match of template.matchAll(DYNAMIC_VARIABLE_PATTERN)) {
+ if (isPublicDynamicVariableName(match[1])) names.add(match[1]);
+ }
+ }
+ return [...names];
+}
+
+function definitionFor(
+ name: string,
+ saved: Record,
+): DynamicVariableDefinition {
+ return (
+ saved[name] ?? {
+ type: "string",
+ required: false,
+ default: null,
+ }
+ );
+}
+
+export function activeDynamicVariableDefinitions(
+ templates: string[],
+ saved: Record,
+): Record {
+ return Object.fromEntries(
+ extractDynamicVariableNames(...templates).map((name) => [
+ name,
+ definitionFor(name, saved),
+ ]),
+ );
+}
+
+export function activeWorkflowDynamicVariableDefinitions(
+ graph: WorkflowGraph,
+ saved: Record,
+): Record {
+ const names = new Set(extractDynamicVariableNames(JSON.stringify(graph)));
+
+ for (const edge of graph.edges) {
+ for (const rule of edge.data.expression?.rules ?? []) {
+ if (isPublicDynamicVariableName(rule.variable)) names.add(rule.variable);
+ }
+ }
+ for (const node of graph.nodes) {
+ for (const name of Object.keys(node.data.resultAssignments ?? {})) {
+ if (isPublicDynamicVariableName(name)) names.add(name);
+ }
+ }
+
+ return Object.fromEntries(
+ [...names]
+ .sort()
+ .map((name) => [name, definitionFor(name, saved)]),
+ );
+}
+
+export function DynamicVariableEditorHint({
+ count,
+ onOpen,
+}: {
+ count: number;
+ onOpen: () => void;
+}) {
+ return (
+
+
+ 输入{" "}
+
+ {"{{"}
+ {" "}
+ 添加变量,保存时自动识别
+
+
+
+ );
+}
+
+export function DynamicVariablesDialog({
+ open,
+ onOpenChange,
+ definitions,
+ onChange,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ definitions: Record;
+ onChange: (definitions: Record) => void;
+}) {
+ const entries = Object.entries(definitions);
+ const [copiedSystemVariable, setCopiedSystemVariable] = useState(
+ null,
+ );
+
+ function updateDefinition(
+ name: string,
+ patch: Partial,
+ ) {
+ onChange({
+ ...definitions,
+ [name]: { ...definitions[name], ...patch },
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/assistant-editor/editor-controls.tsx b/frontend/src/components/assistant-editor/editor-controls.tsx
new file mode 100644
index 0000000..e69691a
--- /dev/null
+++ b/frontend/src/components/assistant-editor/editor-controls.tsx
@@ -0,0 +1,620 @@
+"use client";
+
+import type React from "react";
+import { useEffect, useRef, useState } from "react";
+import {
+ AudioLines,
+ Check,
+ ChevronLeft,
+ Copy,
+ Pencil,
+ PhoneOff,
+ Plus,
+ Settings2,
+ Waypoints,
+ Wrench,
+ X,
+} from "lucide-react";
+
+import { HelpHint } from "@/components/editor/section-card";
+import { Badge } from "@/components/ui/badge";
+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 { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import type { KnowledgeRetrievalConfig, Tool } from "@/lib/api";
+
+import type { RuntimeMode } from "./types";
+
+export function EditorBackButton({ onClick }: { onClick: () => void }) {
+ return (
+
+ );
+}
+
+export function AssistantIdentity({ assistantId }: { assistantId: string | null }) {
+ const [copied, setCopied] = useState(false);
+
+ async function copyId() {
+ if (!assistantId) return;
+ await navigator.clipboard.writeText(assistantId);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1600);
+ }
+
+ return (
+
+
+ {assistantId ? `ID · ${assistantId}` : "ID · 保存后生成"}
+
+ {assistantId && (
+
+ )}
+ {!assistantId && }
+
+ );
+}
+
+export function EditableTitle({
+ value,
+ onChange,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (editing) {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }
+ }, [editing]);
+
+ function startEdit() {
+ setDraft(value);
+ setEditing(true);
+ }
+
+ function commit() {
+ const next = draft.trim();
+ if (next) {
+ onChange(next);
+ }
+ setEditing(false);
+ }
+
+ if (editing) {
+ return (
+ setDraft(event.target.value)}
+ onBlur={commit}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ commit();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ setEditing(false);
+ }
+ }}
+ className="font-display display-sm w-[min(60vw,420px)] border-b border-primary bg-transparent text-ink outline-none"
+ />
+ );
+ }
+
+ return (
+
+ );
+}
+
+export function RuntimeModeSelector({
+ value,
+ onChange,
+}: {
+ value: RuntimeMode;
+ onChange: (mode: RuntimeMode) => void;
+}) {
+ const options = [
+ {
+ value: "pipeline" as const,
+ label: "Pipeline 模式",
+ hint: "通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。",
+ icon: Waypoints,
+ },
+ {
+ value: "realtime" as const,
+ label: "Realtime 模式",
+ hint: "使用原生实时语音模型,模型直接处理音频输入并生成语音回复。",
+ icon: AudioLines,
+ },
+ ];
+
+ return (
+
+ {options.map((option) => {
+ const Icon = option.icon;
+ const selected = value === option.value;
+ const select = () => onChange(option.value);
+ return (
+
{
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ select();
+ }
+ }}
+ className={[
+ "cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
+ selected
+ ? "border-primary bg-primary/5 ring-1 ring-primary"
+ : "border-hairline bg-canvas-soft hover:border-hairline-strong",
+ ].join(" ")}
+ >
+
+
+
+
+
+
+
+ {option.label}
+
+
+
+
+ {selected && (
+
+
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
+
+export function TextAreaField({
+ label,
+ value,
+ placeholder,
+ rows = 4,
+ onChange,
+}: {
+ label?: string;
+ value: string;
+ placeholder?: string;
+ rows?: number;
+ onChange: (value: string) => void;
+}) {
+ return (
+
+ );
+}
+
+// Radix Select 不允许空字符串 value,用哨兵表示"未选/无"
+const NONE_VALUE = "__none__";
+
+export function ResourceSelectField({
+ label,
+ value,
+ options,
+ noneLabel,
+ onChange,
+}: {
+ label?: string;
+ value: string;
+ options: { value: string; label: string }[];
+ /** 提供则在顶部加一个"无/默认"选项,选中映射为空串 */
+ noneLabel?: string;
+ onChange: (value: string) => void;
+}) {
+ return (
+
+ {label && (
+
{label}
+ )}
+
+
+
+ );
+}
+
+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(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 (
+ <>
+
+
+
+ >
+ );
+}
+
+export function ToolPicker({
+ tools,
+ selectedIds,
+ onChange,
+}: {
+ tools: Tool[];
+ selectedIds: string[];
+ onChange: (ids: string[]) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [draftIds, setDraftIds] = useState(selectedIds);
+ const selectedTools = selectedIds
+ .map((id) => tools.find((tool) => tool.id === id))
+ .filter((tool): tool is Tool => Boolean(tool));
+
+ function openPicker() {
+ setDraftIds(selectedIds);
+ setOpen(true);
+ }
+
+ return (
+ <>
+
+ {selectedTools.map((tool) => (
+
+ {tool.type === "end_call" ?
:
}
+
{tool.name}
+
+
+ ))}
+
+
+
+
+ >
+ );
+}
+
+export function ToggleRow({
+ icon,
+ title,
+ description,
+ hint,
+ checked,
+ onChange,
+}: {
+ icon?: React.ReactNode;
+ title: string;
+ description?: string;
+ hint?: string;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+}) {
+ const hasIcon = Boolean(icon);
+
+ return (
+
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {title}
+ {hint && }
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/assistant-editor/prompt-editor.tsx b/frontend/src/components/assistant-editor/prompt-editor.tsx
new file mode 100644
index 0000000..5c1f8de
--- /dev/null
+++ b/frontend/src/components/assistant-editor/prompt-editor.tsx
@@ -0,0 +1,302 @@
+"use client";
+
+import {
+ Bot,
+ Brain,
+ Database,
+ Loader2,
+ MessageSquareText,
+ Save,
+ Sparkles,
+ Wrench,
+} from "lucide-react";
+
+import { DebugDrawer } from "@/components/assistant-editor/debug-preview";
+import {
+ DynamicVariableEditorHint,
+ DynamicVariablesDialog,
+} from "@/components/assistant-editor/dynamic-variables";
+import {
+ AssistantIdentity,
+ EditableTitle,
+ EditorBackButton,
+ KnowledgeRetrievalConfigDialog,
+ ResourceSelectField,
+ RuntimeModeSelector,
+ TextAreaField,
+ ToggleRow,
+ ToolPicker,
+} from "@/components/assistant-editor/editor-controls";
+import type { AssistantForm } from "@/components/assistant-editor/types";
+import { SectionCard } from "@/components/editor/section-card";
+import { TurnConfigEditor } from "@/components/turn-config-editor";
+import { Button } from "@/components/ui/button";
+import type { DynamicVariableDefinition, Tool } from "@/lib/api";
+
+type ResourceOption = { value: string; label: string };
+
+type PromptEditorProps = {
+ assistantId: string | null;
+ form: AssistantForm;
+ dynamicVariablesOpen: boolean;
+ dynamicVariableDefinitions: Record;
+ saving: boolean;
+ dirty: boolean;
+ saveError: string | null;
+ llmOptions: ResourceOption[];
+ asrOptions: ResourceOption[];
+ ttsOptions: ResourceOption[];
+ realtimeOptions: ResourceOption[];
+ visionModelOptions: ResourceOption[];
+ knowledgeOptions: ResourceOption[];
+ tools: Tool[];
+ onBack: () => void;
+ onSave: () => void;
+ setDynamicVariablesOpen: (open: boolean) => void;
+ updateForm: (
+ key: K,
+ value: AssistantForm[K],
+ ) => void;
+ handlePromptVisionEnabledChange: (enabled: boolean) => void;
+ handlePromptModelChange: (value: string) => void;
+};
+
+export function PromptEditor({
+ assistantId,
+ form,
+ dynamicVariablesOpen,
+ dynamicVariableDefinitions,
+ saving,
+ dirty,
+ saveError,
+ llmOptions,
+ asrOptions,
+ ttsOptions,
+ realtimeOptions,
+ visionModelOptions,
+ knowledgeOptions: kbOptions,
+ tools,
+ onBack,
+ onSave,
+ setDynamicVariablesOpen,
+ updateForm,
+ handlePromptVisionEnabledChange,
+ handlePromptModelChange,
+}: PromptEditorProps) {
+ return (
+
+
+
+
+
updateForm("name", value)}
+ />
+
+
+
+
+ {saveError && (
+
+ {saveError}
+
+ )}
+
+
+
+
+
+ updateForm(
+ "dynamicVariableDefinitions",
+ dynamicVariableDefinitions,
+ )
+ }
+ />
+
+
+
+
}
+ title="提示词"
+ description="描述助手的角色、能力和回答要求"
+ >
+
updateForm("prompt", value)}
+ placeholder="请输入提示词,描述助手的角色、能力和回答要求"
+ rows={8}
+ />
+ setDynamicVariablesOpen(true)}
+ />
+
+
+ }
+ title="开场白"
+ description="助手与用户首次对话时的开场语"
+ >
+ updateForm("greeting", value)}
+ placeholder="请输入助手开场白"
+ />
+ setDynamicVariablesOpen(true)}
+ />
+
+
+ }
+ title="模型配置"
+ description={
+ form.runtimeMode === "pipeline"
+ ? "选择运行方式,以及大语言模型、语音识别与语音合成资源"
+ : "选择运行方式;Realtime 模型内置语音识别与语音合成"
+ }
+ >
+ updateForm("runtimeMode", runtimeMode)}
+ />
+
+ {form.runtimeMode === "pipeline" ? (
+ <>
+
+ {form.visionEnabled && (
+
+ updateForm("visionModelResourceId", value)
+ }
+ options={visionModelOptions}
+ noneLabel="模型自己"
+ />
+ )}
+
+ updateForm("asr", value)}
+ options={asrOptions}
+ noneLabel="无"
+ />
+ updateForm("voice", value)}
+ options={ttsOptions}
+ noneLabel="无"
+ />
+ >
+ ) : (
+ updateForm("realtimeModel", value)}
+ options={realtimeOptions}
+ noneLabel="无"
+ />
+ )}
+
+
+ {form.runtimeMode === "pipeline" && (
+ }
+ title="知识库配置"
+ description="选择助手回答时可检索的业务知识来源"
+ >
+
+
+ 知识库选择
+
+
+ updateForm("knowledgeRetrievalConfig", config)
+ }
+ />
+
+ updateForm("knowledgeBase", value)}
+ options={kbOptions}
+ noneLabel="无"
+ />
+
+ )}
+
+ }
+ title="工具"
+ description="配置该提示词助手可以调用的工具"
+ >
+ tool.status === "active")}
+ selectedIds={form.toolIds}
+ onChange={(toolIds) => updateForm("toolIds", toolIds)}
+ />
+
+
+ }
+ title="交互策略"
+ description="设置实时视频对话时的交互体验"
+ >
+ {form.runtimeMode === "pipeline" ? (
+ updateForm("enableInterrupt", checked)}
+ onConfigChange={(config) => updateForm("turnConfig", config)}
+ />
+ ) : (
+
+ 高级抢话与轮次检测当前仅支持 Pipeline 模式。
+
+ )}
+
+
+
+
+
+
+ );
+}
+
diff --git a/frontend/src/components/assistant-editor/types.ts b/frontend/src/components/assistant-editor/types.ts
new file mode 100644
index 0000000..e6e0bda
--- /dev/null
+++ b/frontend/src/components/assistant-editor/types.ts
@@ -0,0 +1,26 @@
+import type {
+ DynamicVariableDefinition,
+ KnowledgeRetrievalConfig,
+ TurnConfig,
+} from "@/lib/api";
+
+export type RuntimeMode = "pipeline" | "realtime";
+
+export type AssistantForm = {
+ name: string;
+ greeting: string;
+ prompt: string;
+ dynamicVariableDefinitions: Record;
+ runtimeMode: RuntimeMode;
+ realtimeModel: string;
+ model: string;
+ asr: string;
+ voice: string;
+ knowledgeBase: string;
+ knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
+ enableInterrupt: boolean;
+ turnConfig: TurnConfig;
+ visionEnabled: boolean;
+ visionModelResourceId: string;
+ toolIds: string[];
+};
diff --git a/frontend/src/components/assistant-editor/use-workflow-editor-state.ts b/frontend/src/components/assistant-editor/use-workflow-editor-state.ts
new file mode 100644
index 0000000..65a7678
--- /dev/null
+++ b/frontend/src/components/assistant-editor/use-workflow-editor-state.ts
@@ -0,0 +1,73 @@
+"use client";
+
+import { useState } from "react";
+
+import type { WorkflowSettings } from "@/components/workflow/types";
+import { defaultGraph, type WorkflowGraph } from "@/components/workflow/specs";
+import type { DynamicVariableDefinition } from "@/lib/api";
+import { defaultTurnConfig } from "@/lib/turn-config";
+
+function initialWorkflowSettings(): WorkflowSettings {
+ const graph = defaultGraph();
+ return {
+ globalPrompt: graph.settings.globalPrompt,
+ llm: graph.settings.defaultLlmResourceId,
+ asr: graph.settings.defaultAsrResourceId,
+ tts: graph.settings.defaultTtsResourceId,
+ toolIds: graph.settings.toolIds,
+ knowledgeBaseId: graph.settings.knowledgeBaseId,
+ knowledgeRetrievalConfig: {
+ mode: graph.settings.knowledgeMode,
+ topN: graph.settings.knowledgeTopN,
+ scoreThreshold: graph.settings.knowledgeScoreThreshold,
+ },
+ allowInterrupt: true,
+ turnConfig: defaultTurnConfig(),
+ };
+}
+
+/** Owns Workflow editor state; AssistantPage only coordinates loading and saving. */
+export function useWorkflowEditorState() {
+ const [workflowName, setWorkflowName] = useState("");
+ const [workflowGraph, setWorkflowGraph] = useState(() =>
+ defaultGraph(),
+ );
+ const [workflowSettings, setWorkflowSettings] = useState(
+ initialWorkflowSettings,
+ );
+ const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
+ useState>({});
+ const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
+ const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
+ const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState(
+ null,
+ );
+ const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState(
+ null,
+ );
+ const [activeNodeId, setActiveNodeId] = useState(null);
+ const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
+
+ return {
+ workflowName,
+ setWorkflowName,
+ workflowGraph,
+ setWorkflowGraph,
+ workflowSettings,
+ setWorkflowSettings,
+ workflowDynamicVariableDefinitions,
+ setWorkflowDynamicVariableDefinitions,
+ workflowDebugOpen,
+ setWorkflowDebugOpen,
+ workflowSettingsOpen,
+ setWorkflowSettingsOpen,
+ workflowEditingNodeId,
+ setWorkflowEditingNodeId,
+ workflowEditingEdgeId,
+ setWorkflowEditingEdgeId,
+ activeNodeId,
+ setActiveNodeId,
+ dynamicVariablesOpen,
+ setDynamicVariablesOpen,
+ };
+}
diff --git a/frontend/src/components/assistant-editor/workflow-page.tsx b/frontend/src/components/assistant-editor/workflow-page.tsx
new file mode 100644
index 0000000..002a239
--- /dev/null
+++ b/frontend/src/components/assistant-editor/workflow-page.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { Bug, Loader2, Save } from "lucide-react";
+
+import { DebugDrawer } from "@/components/assistant-editor/debug-preview";
+import { DynamicVariablesDialog } from "@/components/assistant-editor/dynamic-variables";
+import {
+ AssistantIdentity,
+ EditableTitle,
+ EditorBackButton,
+} from "@/components/assistant-editor/editor-controls";
+import { Button } from "@/components/ui/button";
+import { WorkflowEditor } from "@/components/workflow/WorkflowEditor";
+import type { WorkflowSettings } from "@/components/workflow/types";
+import type { WorkflowGraph } from "@/components/workflow/specs";
+import type { DynamicVariableDefinition } from "@/lib/api";
+
+type ResourceOption = { value: string; label: string };
+
+type WorkflowPageProps = {
+ assistantId: string | null;
+ workflowName: string;
+ workflowGraph: WorkflowGraph;
+ workflowSettings: WorkflowSettings;
+ dynamicVariableDefinitions: Record;
+ workflowDebugOpen: boolean;
+ workflowSettingsOpen: boolean;
+ workflowEditingNodeId: string | null;
+ workflowEditingEdgeId: string | null;
+ activeNodeId: string | null;
+ dynamicVariablesOpen: boolean;
+ saving: boolean;
+ dirty: boolean;
+ saveError: string | null;
+ modelOptions: {
+ llm: ResourceOption[];
+ asr: ResourceOption[];
+ tts: ResourceOption[];
+ };
+ toolOptions: ResourceOption[];
+ knowledgeOptions: ResourceOption[];
+ onBack: () => void;
+ onSave: () => void;
+ setWorkflowName: (name: string) => void;
+ setWorkflowGraph: (graph: WorkflowGraph) => void;
+ setWorkflowSettings: (settings: WorkflowSettings) => void;
+ setWorkflowDynamicVariableDefinitions: (
+ definitions: Record,
+ ) => void;
+ setWorkflowDebugOpen: (open: boolean) => void;
+ setWorkflowSettingsOpen: (open: boolean) => void;
+ setWorkflowEditingNodeId: (nodeId: string | null) => void;
+ setWorkflowEditingEdgeId: (edgeId: string | null) => void;
+ setActiveNodeId: (nodeId: string | null) => void;
+ setDynamicVariablesOpen: (open: boolean) => void;
+};
+
+export function WorkflowPage({
+ assistantId,
+ workflowName,
+ workflowGraph,
+ workflowSettings,
+ dynamicVariableDefinitions,
+ workflowDebugOpen,
+ workflowSettingsOpen,
+ workflowEditingNodeId,
+ workflowEditingEdgeId,
+ activeNodeId,
+ dynamicVariablesOpen,
+ saving,
+ dirty,
+ saveError,
+ modelOptions,
+ toolOptions,
+ knowledgeOptions: kbOptions,
+ onBack,
+ onSave,
+ setWorkflowName,
+ setWorkflowGraph,
+ setWorkflowSettings,
+ setWorkflowDynamicVariableDefinitions,
+ setWorkflowDebugOpen,
+ setWorkflowSettingsOpen,
+ setWorkflowEditingNodeId,
+ setWorkflowEditingEdgeId,
+ setActiveNodeId,
+ setDynamicVariablesOpen,
+}: WorkflowPageProps) {
+ return (
+
+
+
+
+
+ {saveError && (
+
+ {saveError}
+
+ )}
+
+
+
+
+
+
+ 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={
+ {
+ setWorkflowDebugOpen(false);
+ setActiveNodeId(null);
+ }}
+ hasUnsavedChanges={dirty}
+ onNodeActive={setActiveNodeId}
+ dynamicVariablesEnabled
+ dynamicVariableDefinitions={
+ dynamicVariableDefinitions
+ }
+ />
+ }
+ activeNodeId={activeNodeId}
+ modelOptions={modelOptions}
+ toolOptions={toolOptions}
+ knowledgeOptions={kbOptions}
+ />
+
+
+
+
+ );
+ }
diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx
index 43d5b5c..ecf0fb8 100644
--- a/frontend/src/components/pages/AssistantPage.tsx
+++ b/frontend/src/components/pages/AssistantPage.tsx
@@ -1,7 +1,6 @@
"use client";
import {
- Bot,
Boxes,
Brain,
Check,
@@ -19,74 +18,26 @@ import {
ChevronUp,
ChevronDown,
Save,
- Mic,
- Send,
- Waypoints,
- AudioLines,
Terminal,
Loader2,
- PhoneOff,
- Orbit,
- Waves,
- Bug,
- Video,
- Smartphone,
- Wrench,
- Braces,
- Settings2,
- X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
-import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { Switch } from "@/components/ui/switch";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@/components/ui/tabs";
-import { AuraVisualizer } from "@/components/ui/aura-visualizer";
-import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
-import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
-import { WaveVisualizer } from "@/components/ui/wave-visualizer";
-import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline";
import { DataList } from "@/components/ui/data-list";
import { PageHeader } from "@/components/ui/page-header";
import { FilterPills } from "@/components/ui/filter-pills";
import { SearchInput } from "@/components/ui/search-input";
import { ListToolbar } from "@/components/ui/list-toolbar";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
assistantsApi,
@@ -96,28 +47,14 @@ import {
type Assistant,
type AssistantType as ApiAssistantType,
type AssistantUpsert,
- type DynamicVariableDefinition,
type KnowledgeBase,
type KnowledgeRetrievalConfig,
type ModelResource,
type Tool,
type TurnConfig,
} from "@/lib/api";
-import {
- useVoicePreview,
- type ChatMessage,
- type VoicePreview,
- type VoicePreviewStatus,
-} from "@/hooks/use-voice-preview";
-import {
- useCameraPreview,
- type CameraPreview,
-} from "@/hooks/use-camera-preview";
-import {
- WorkflowEditor,
- type WorkflowSettings,
-} from "@/components/workflow/WorkflowEditor";
-import { HelpHint, SectionCard } from "@/components/editor/section-card";
+import type { WorkflowSettings } from "@/components/workflow/types";
+import { SectionCard } from "@/components/editor/section-card";
import {
defaultGraph,
type WorkflowGraph,
@@ -126,27 +63,23 @@ import {
defaultTurnConfig,
normalizeTurnConfig,
} from "@/lib/turn-config";
-
-type RuntimeMode = "pipeline" | "realtime";
-
-type AssistantForm = {
- name: string;
- greeting: string;
- prompt: string;
- dynamicVariableDefinitions: Record;
- runtimeMode: RuntimeMode;
- realtimeModel: string;
- model: string;
- asr: string;
- voice: string;
- knowledgeBase: string;
- knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
- enableInterrupt: boolean;
- turnConfig: TurnConfig;
- visionEnabled: boolean;
- visionModelResourceId: string;
- toolIds: string[];
-};
+import {
+ activeDynamicVariableDefinitions,
+ activeWorkflowDynamicVariableDefinitions,
+} from "@/components/assistant-editor/dynamic-variables";
+import { useWorkflowEditorState } from "@/components/assistant-editor/use-workflow-editor-state";
+import { DebugDrawer } from "@/components/assistant-editor/debug-preview";
+import {
+ AssistantIdentity,
+ EditableTitle,
+ EditorBackButton,
+ ResourceSelectField,
+ TextAreaField,
+ ToggleRow,
+} from "@/components/assistant-editor/editor-controls";
+import type { AssistantForm } from "@/components/assistant-editor/types";
+import { PromptEditor } from "@/components/assistant-editor/prompt-editor";
+import { WorkflowPage } from "@/components/assistant-editor/workflow-page";
type FastGptForm = {
name: string;
@@ -253,83 +186,6 @@ function blankPromptForm(name: string): AssistantForm {
};
}
-const DYNAMIC_VARIABLE_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_]{0,63})\s*}}/g;
-const SYSTEM_DYNAMIC_VARIABLES = [
- ["system__conversation_id", "会话 ID"],
- ["system__time", "当前时间"],
- ["system__timezone", "会话时区"],
- ["system__agent_turns", "助手轮次"],
- ["system__conversation_history", "会话历史"],
-] as const;
-
-function extractDynamicVariableNames(...templates: string[]): string[] {
- const names = new Set();
- for (const template of templates) {
- for (const match of template.matchAll(DYNAMIC_VARIABLE_PATTERN)) {
- const name = match[1];
- if (!name.startsWith("system__") && !name.startsWith("secret__")) {
- names.add(name);
- }
- }
- }
- return [...names];
-}
-
-function activeDynamicVariableDefinitions(
- templates: string[],
- saved: Record,
-): Record {
- return Object.fromEntries(
- extractDynamicVariableNames(...templates).map((name) => [
- name,
- saved[name] ?? {
- type: "string",
- required: false,
- default: null,
- },
- ]),
- );
-}
-
-function activeWorkflowDynamicVariableDefinitions(
- graph: WorkflowGraph,
- saved: Record,
-): Record {
- const names = new Set(extractDynamicVariableNames(JSON.stringify(graph)));
-
- // Expression operands and Action assignment destinations are variable
- // references even though they do not use the {{placeholder}} syntax.
- for (const edge of graph.edges) {
- for (const rule of edge.data.expression?.rules ?? []) {
- if (isPublicDynamicVariableName(rule.variable)) names.add(rule.variable);
- }
- }
- for (const node of graph.nodes) {
- for (const name of Object.keys(node.data.resultAssignments ?? {})) {
- if (isPublicDynamicVariableName(name)) names.add(name);
- }
- }
-
- return Object.fromEntries(
- [...names].sort().map((name) => [
- name,
- saved[name] ?? {
- type: "string",
- required: false,
- default: null,
- },
- ]),
- );
-}
-
-function isPublicDynamicVariableName(name: string): boolean {
- return (
- /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) &&
- !name.startsWith("system__") &&
- !name.startsWith("secret__")
- );
-}
-
function blankFastGptForm(name: string): FastGptForm {
return {
name,
@@ -484,34 +340,28 @@ export function AssistantPage(props: AssistantPageProps) {
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
const [savedSnapshot, setSavedSnapshot] = useState(null);
- // workflow 编辑器:名称 + 图(nodes/edges)。graph 实时由画布回传。
- const [workflowName, setWorkflowName] = useState("");
- const [workflowGraph, setWorkflowGraph] = useState(() =>
- defaultGraph(),
- );
- const [workflowSettings, setWorkflowSettings] = useState({
- globalPrompt: defaultGraph().settings.globalPrompt,
- llm: defaultGraph().settings.defaultLlmResourceId,
- asr: defaultGraph().settings.defaultAsrResourceId,
- tts: defaultGraph().settings.defaultTtsResourceId,
- toolIds: defaultGraph().settings.toolIds,
- knowledgeBaseId: defaultGraph().settings.knowledgeBaseId,
- knowledgeRetrievalConfig: {
- mode: defaultGraph().settings.knowledgeMode,
- topN: defaultGraph().settings.knowledgeTopN,
- scoreThreshold: defaultGraph().settings.knowledgeScoreThreshold,
- },
- allowInterrupt: true,
- turnConfig: defaultTurnConfig(),
- });
- const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
- useState>({});
- const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
- const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
- const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState(null);
- const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState(null);
- const [activeNodeId, setActiveNodeId] = useState(null);
- const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
+ const {
+ workflowName,
+ setWorkflowName,
+ workflowGraph,
+ setWorkflowGraph,
+ workflowSettings,
+ setWorkflowSettings,
+ workflowDynamicVariableDefinitions,
+ setWorkflowDynamicVariableDefinitions,
+ workflowDebugOpen,
+ setWorkflowDebugOpen,
+ workflowSettingsOpen,
+ setWorkflowSettingsOpen,
+ workflowEditingNodeId,
+ setWorkflowEditingNodeId,
+ workflowEditingEdgeId,
+ setWorkflowEditingEdgeId,
+ activeNodeId,
+ setActiveNodeId,
+ dynamicVariablesOpen,
+ setDynamicVariablesOpen,
+ } = useWorkflowEditorState();
const effectiveDynamicVariableDefinitions = activeDynamicVariableDefinitions(
[form.prompt, form.greeting],
@@ -941,7 +791,14 @@ export function AssistantPage(props: AssistantPageProps) {
return () => {
cancelled = true;
};
- }, [props.mode, editingId]);
+ }, [
+ props.mode,
+ editingId,
+ setWorkflowDynamicVariableDefinitions,
+ setWorkflowGraph,
+ setWorkflowName,
+ setWorkflowSettings,
+ ]);
function handleSaveOpenCode() {
void save(
@@ -1436,110 +1293,49 @@ export function AssistantPage(props: AssistantPageProps) {
if (view === "workflow") {
return (
-
-
-
-
router.push("/assistants")} />
-
-
-
-
-
- {saveError && (
-
- {saveError}
-
- )}
-
-
-
-
-
-
- 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={
- {
- setWorkflowDebugOpen(false);
- setActiveNodeId(null);
- }}
- hasUnsavedChanges={dirty}
- onNodeActive={setActiveNodeId}
- dynamicVariablesEnabled
- dynamicVariableDefinitions={
- effectiveWorkflowDynamicVariableDefinitions
- }
- />
- }
- activeNodeId={activeNodeId}
- modelOptions={{
- llm: credOptions("LLM"),
- asr: credOptions("ASR"),
- tts: credOptions("TTS"),
- }}
- toolOptions={tools
- .filter((tool) => tool.status === "active")
- .map((tool) => ({ value: tool.id, label: tool.name }))}
- knowledgeOptions={kbOptions}
- />
-
-
-
-
+ tool.status === "active")
+ .map((tool) => ({ value: tool.id, label: tool.name }))}
+ knowledgeOptions={kbOptions}
+ onBack={() => router.push("/assistants")}
+ onSave={() => void handleSaveWorkflow()}
+ setWorkflowName={setWorkflowName}
+ setWorkflowGraph={setWorkflowGraph}
+ setWorkflowSettings={setWorkflowSettings}
+ setWorkflowDynamicVariableDefinitions={
+ setWorkflowDynamicVariableDefinitions
+ }
+ setWorkflowDebugOpen={setWorkflowDebugOpen}
+ setWorkflowSettingsOpen={setWorkflowSettingsOpen}
+ setWorkflowEditingNodeId={setWorkflowEditingNodeId}
+ setWorkflowEditingEdgeId={setWorkflowEditingEdgeId}
+ setActiveNodeId={setActiveNodeId}
+ setDynamicVariablesOpen={setDynamicVariablesOpen}
+ />
);
}
-
if (view === "create-dify") {
return (
@@ -1867,2253 +1663,27 @@ export function AssistantPage(props: AssistantPageProps) {
}
return (
-
-
-
-
router.push("/assistants")} />
- updateForm("name", value)}
- />
-
-
-
-
- {saveError && (
-
- {saveError}
-
- )}
-
-
-
-
-
- updateForm(
- "dynamicVariableDefinitions",
- dynamicVariableDefinitions,
- )
- }
- />
-
-
-
-
}
- title="提示词"
- description="描述助手的角色、能力和回答要求"
- >
-
updateForm("prompt", value)}
- placeholder="请输入提示词,描述助手的角色、能力和回答要求"
- rows={8}
- />
- setDynamicVariablesOpen(true)}
- />
-
-
- }
- title="开场白"
- description="助手与用户首次对话时的开场语"
- >
- updateForm("greeting", value)}
- placeholder="请输入助手开场白"
- />
- setDynamicVariablesOpen(true)}
- />
-
-
- }
- title="模型配置"
- description={
- form.runtimeMode === "pipeline"
- ? "选择运行方式,以及大语言模型、语音识别与语音合成资源"
- : "选择运行方式;Realtime 模型内置语音识别与语音合成"
- }
- >
- updateForm("runtimeMode", runtimeMode)}
- />
-
- {form.runtimeMode === "pipeline" ? (
- <>
-
- {form.visionEnabled && (
-
- updateForm("visionModelResourceId", value)
- }
- options={visionModelOptionsFor(form.model)}
- noneLabel="模型自己"
- />
- )}
-
- updateForm("asr", value)}
- options={credOptions("ASR")}
- noneLabel="无"
- />
- updateForm("voice", value)}
- options={credOptions("TTS")}
- noneLabel="无"
- />
- >
- ) : (
- updateForm("realtimeModel", value)}
- options={credOptions("Realtime")}
- noneLabel="无"
- />
- )}
-
-
- {form.runtimeMode === "pipeline" && (
- }
- title="知识库配置"
- description="选择助手回答时可检索的业务知识来源"
- >
-
-
- 知识库选择
-
-
- updateForm("knowledgeRetrievalConfig", config)
- }
- />
-
- updateForm("knowledgeBase", value)}
- options={kbOptions}
- noneLabel="无"
- />
-
- )}
-
- }
- title="工具"
- description="配置该提示词助手可以调用的工具"
- >
- tool.status === "active")}
- selectedIds={form.toolIds}
- onChange={(toolIds) => updateForm("toolIds", toolIds)}
- />
-
-
- }
- title="交互策略"
- description="设置实时视频对话时的交互体验"
- >
- {form.runtimeMode === "pipeline" ? (
- updateForm("enableInterrupt", checked)}
- onConfigChange={(config) => updateForm("turnConfig", config)}
- />
- ) : (
-
- 高级抢话与轮次检测当前仅支持 Pipeline 模式。
-
- )}
-
-
-
-
-
-
- );
-}
-
-type VizStyle = "aura" | "nebula" | "bars" | "wave";
-
-// 调试面板顶部主视图:聊天记录 / 视频流
-type DebugView = "chat" | "video";
-type DebugInputMode = "mic" | "text";
-
-const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
- [
- { style: "aura", label: "光环", icon:
},
- { style: "nebula", label: "星云", icon:
},
- { style: "bars", label: "频谱", icon:
},
- { style: "wave", label: "波形", icon:
},
- ];
-
-// 中央语音可视化(光环/星云/频谱/波形)暂时隐藏:调试面板固定为
-// 「上聊天记录 + 下波形监控」布局。置 true 可恢复可视化视图与样式切换。
-const SHOW_VOICE_VIZ = false;
-
-function SegmentedIconGroup({
- children,
- label,
-}: {
- children: React.ReactNode;
- label: string;
-}) {
- return (
-
- {children}
-
- );
-}
-
-function SegmentedIconButton({
- selected,
- label,
- onClick,
- children,
-}: {
- selected: boolean;
- label: string;
- onClick: () => void;
- children: React.ReactNode;
-}) {
- return (
-
- );
-}
-
-function DebugDrawer({
- assistantId,
- overlay = false,
- onClose,
- hasUnsavedChanges = false,
- onNodeActive,
- vision = false,
- dynamicVariablesEnabled = false,
- dynamicVariableDefinitions = {},
-}: {
- assistantId: string | null;
- overlay?: boolean;
- onClose?: () => void;
- hasUnsavedChanges?: boolean;
- onNodeActive?: (nodeId: string | null) => void;
- vision?: boolean;
- dynamicVariablesEnabled?: boolean;
- dynamicVariableDefinitions?: Record
;
-}) {
- const preview = useVoicePreview(assistantId, onNodeActive);
- const camera = useCameraPreview();
- const [showTranscript, setShowTranscript] = useState(false);
- const [vizStyle, setVizStyle] = useState("aura");
- const [view, setView] = useState("chat");
- const [dynamicVariableValues, setDynamicVariableValues] = useState<
- Record
- >({});
- const recording =
- preview.status === "connecting" || preview.status === "connected";
- const displayedDefinitions = { ...dynamicVariableDefinitions };
- for (const [name, value] of Object.entries(preview.sessionVariables)) {
- displayedDefinitions[name] ??= {
- type:
- typeof value === "number"
- ? "number"
- : typeof value === "boolean"
- ? "boolean"
- : "string",
- required: false,
- default: null,
- };
- }
- const dynamicVariableEntries = Object.entries(displayedDefinitions);
- const resolvedDynamicVariables: Record = {};
- let dynamicVariablesError = "";
- for (const [name, definition] of Object.entries(dynamicVariableDefinitions)) {
- const value = dynamicVariableValues[name] ?? definition.default;
- if (value === null || value === undefined || value === "") {
- if (definition.required && !dynamicVariablesError) {
- dynamicVariablesError = `请先填写必填变量 ${name}`;
- }
- continue;
- }
- resolvedDynamicVariables[name] = value;
- }
-
- const selectCamera = useCallback(
- async (deviceId: string) => {
- await camera.selectCamera(deviceId);
- preview.selectCamera(deviceId);
- },
- [camera, preview],
- );
-
- return (
-
- );
-}
-
-function DynamicVariableValuesPopover({
- entries,
- values,
- sessionValues,
- readOnly,
- onChange,
-}: {
- entries: [string, DynamicVariableDefinition][];
- values: Record;
- sessionValues: Record;
- readOnly: boolean;
- onChange: React.Dispatch<
- React.SetStateAction>
- >;
-}) {
- function setValue(name: string, value: string | number | boolean | undefined) {
- onChange((current) => {
- const next = { ...current };
- if (value === undefined) delete next[name];
- else next[name] = value;
- return next;
- });
- }
-
- return (
-
-
-
-
-
-
-
-
- 本次会话变量
-
-
- {readOnly
- ? "当前值会在 Action 或工具更新变量后实时刷新。"
- : "这些值只用于下一次调试会话,不会修改助手配置。"}
-
-
-
- {entries.length === 0 ? (
-
-
- 当前没有会话变量
-
-
- 在工作流提示词、节点话术、边条件或 Action 中引用变量后,
- 可在这里设置调试值。
-
-
- ) : entries.map(([name, definition]) => {
- const value = readOnly
- ? sessionValues[name] ?? definition.default ?? ""
- : values[name] ?? definition.default ?? "";
- return (
-
- );
- })}
-
-
-
- );
-}
-
-function CallPreviewLink({ assistantId }: { assistantId: string | null }) {
- const [callUrl, setCallUrl] = useState("");
- const [copied, setCopied] = useState(false);
-
- const copyLink = useCallback(async () => {
- if (!callUrl) return;
- await navigator.clipboard.writeText(callUrl);
- setCopied(true);
- window.setTimeout(() => setCopied(false), 1600);
- }, [callUrl]);
-
- return (
- {
- if (open && assistantId) {
- setCallUrl(
- `${window.location.origin}/call/${encodeURIComponent(assistantId)}`,
- );
- setCopied(false);
- }
- }}
- >
-
-
-
-
-
-
手机通话预览
-
- 复制链接并在浏览器中打开,即可进入全屏视频通话界面。
-
-
-
- event.currentTarget.select()}
- className="h-9 min-w-0 flex-1 text-xs"
- />
-
-
- {copied && 链接已复制
}
-
-
- );
-}
-
-function DeviceSelectField({
- icon,
- ariaLabel,
- placeholder,
- fallbackLabel,
- value,
- devices,
- onSelect,
-}: {
- icon: React.ReactNode;
- ariaLabel: string;
- placeholder: string;
- fallbackLabel: string;
- value: string;
- devices: MediaDeviceInfo[];
- onSelect: (deviceId: string) => void;
-}) {
- return (
-
- );
-}
-
-function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) {
- return (
- }
- ariaLabel="选择麦克风"
- placeholder="默认麦克风"
- fallbackLabel="麦克风"
- value={preview.selectedDeviceId}
- devices={preview.audioInputs}
- onSelect={(deviceId) => preview.selectDevice(deviceId)}
+ router.push("/assistants")}
+ onSave={() => void handleSavePrompt()}
+ setDynamicVariablesOpen={setDynamicVariablesOpen}
+ updateForm={updateForm}
+ handlePromptVisionEnabledChange={handlePromptVisionEnabledChange}
+ handlePromptModelChange={handlePromptModelChange}
/>
);
}
-
-function CameraDeviceField({
- camera,
- onSelect,
-}: {
- camera: CameraPreview;
- onSelect?: (deviceId: string) => void | Promise;
-}) {
- return (
- }
- ariaLabel="选择摄像头"
- placeholder="默认摄像头"
- fallbackLabel="摄像头"
- value={camera.deviceId}
- devices={camera.devices}
- onSelect={(deviceId) => void (onSelect ?? camera.selectCamera)(deviceId)}
- />
- );
-}
-
-function DebugInputModeButton({
- selected,
- label,
- onClick,
- children,
-}: {
- selected: boolean;
- label: string;
- onClick: () => void;
- children: React.ReactNode;
-}) {
- return (
-
- );
-}
-
-function DebugVoicePanel({
- view,
- onViewChange,
- showTranscript,
- vizStyle,
- assistantId,
- preview,
- camera,
- hasUnsavedChanges,
- vision,
- dynamicVariables,
- dynamicVariablesError,
-}: {
- view: DebugView;
- onViewChange: (view: DebugView) => void;
- showTranscript: boolean;
- vizStyle: VizStyle;
- assistantId: string | null;
- preview: VoicePreview;
- camera: CameraPreview;
- hasUnsavedChanges: boolean;
- vision: boolean;
- dynamicVariables: Record;
- dynamicVariablesError: string;
-}) {
- const {
- status,
- error,
- micWarning,
- localStream,
- remoteStream,
- messages,
- sendText,
- connect,
- disconnect,
- audioRef,
- } = preview;
- const recording = status === "connecting" || status === "connected";
- const [textDraft, setTextDraft] = useState("");
- const [inputMode, setInputMode] = useState("mic");
- const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript);
- const idleOrFailed = status === "idle" || status === "failed";
- const showIdleHub =
- idleOrFailed &&
- (view === "video" || (messages.length === 0 && inChatView));
- const startBlockedMessage = !assistantId
- ? "请先保存助手,再开始对话。"
- : hasUnsavedChanges
- ? "请先保存当前改动,再开始对话。"
- : dynamicVariablesError
- ? dynamicVariablesError
- : "";
- const startDisabled = status === "connecting" || Boolean(startBlockedMessage);
-
- function handleSendText() {
- if (sendText(textDraft)) {
- setTextDraft("");
- }
- }
-
- const startConversation = useCallback(async () => {
- if (!assistantId || hasUnsavedChanges) return;
- if (dynamicVariablesError) return;
- await connect({
- visionEnabled: vision,
- dynamicVariables,
- });
- }, [
- assistantId,
- connect,
- dynamicVariables,
- dynamicVariablesError,
- hasUnsavedChanges,
- vision,
- ]);
-
- return (
-
- {/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
-
- {vision && !showIdleHub ? (
-
- ) : view === "video" ? (
- showIdleHub ? (
-
- ) : (
-
- )
- ) : !SHOW_VOICE_VIZ || showTranscript ? (
- showIdleHub ? (
-
- ) : (
-
- )
- ) : (
-
-
-
-
-
- {recording ? "会话进行中" : "准备开始"}
-
-
-
- {(() => {
- const shared = {
- active: Boolean(localStream),
- stream: localStream,
- className: "relative shrink-0",
- } as const;
- if (vizStyle === "aura")
- return
;
- if (vizStyle === "nebula")
- return
;
- if (vizStyle === "bars")
- return
;
- return
;
- })()}
-
-
-
-
- {status === "connecting"
- ? "连接中…"
- : status === "connected"
- ? micWarning
- ? "仅收听模式"
- : "我在聆听"
- : "开始一次语音对话"}
-
-
- {status === "failed"
- ? error ||
- "连接失败,请确认后端已启动且助手已保存后重试。"
- : !assistantId
- ? "请先保存助手,再开始语音预览。"
- : hasUnsavedChanges
- ? "请先保存当前改动,再开始语音预览。"
- : micWarning
- ? `${micWarning} 可接收助手播报,但无法发送语音。`
- : recording
- ? "直接说话即可。助手会在您停顿后自然回应。"
- : "测试语音识别、响应速度与助手的播报效果。"}
-
-
-
-
-
- )}
-
-
-
-
-
- setInputMode("mic")}
- >
-
-
- setInputMode("text")}
- >
-
-
-
- {inputMode === "mic" ? (
-
- ) : (
-
- {inputMode === "text" && (
-
- )}
- {!showIdleHub && (
-
-
- {!recording && startBlockedMessage && (
-
- {startBlockedMessage}
-
- )}
-
- )}
-
-
-
- {/* 底部双轨波形:会话连通后默认展开,空闲时收起 */}
-
-
- );
-}
-
-// 空闲态只保留一个明确入口,避免中间区域显得拥挤。
-function DebugIdleHub({
- status,
- error,
- message,
- connect,
-}: {
- status: VoicePreviewStatus;
- error: string | null;
- message: string;
- connect: () => Promise;
-}) {
- const helperText =
- message ||
- (status === "failed"
- ? error || "连接失败,请确认后端已启动且助手已保存后重试。"
- : "");
-
- return (
-
-
- {helperText && (
-
- {helperText}
-
- )}
-
- );
-}
-
-function DebugVisionWorkspace({
- view,
- onViewChange,
- messages,
- recording,
- camera,
- videoStream,
-}: {
- view: DebugView;
- onViewChange: (view: DebugView) => void;
- messages: ChatMessage[];
- recording: boolean;
- camera: CameraPreview;
- videoStream: MediaStream | null;
-}) {
- const containerRef = useRef(null);
- const [position, setPosition] = useState({ x: 16, y: 16 });
- const positionedRef = useRef(false);
- const dragRef = useRef<{
- pointerId: number;
- startX: number;
- startY: number;
- originX: number;
- originY: number;
- moved: boolean;
- } | null>(null);
- const latestMessage = [...messages]
- .reverse()
- .find((message) => message.content.trim());
-
- useEffect(() => {
- const container = containerRef.current;
- if (!container) return;
- const rect = container.getBoundingClientRect();
- if (!positionedRef.current) {
- positionedRef.current = true;
- setPosition({ x: Math.max(16, rect.width - 192), y: 16 });
- return;
- }
- setPosition((current) => ({
- x: Math.min(Math.max(16, current.x), Math.max(16, rect.width - 192)),
- y: Math.min(Math.max(16, current.y), Math.max(16, rect.height - 116)),
- }));
- }, [view]);
-
- const moveFloatingVideo = useCallback((event: React.PointerEvent) => {
- const drag = dragRef.current;
- const container = containerRef.current;
- if (!drag || !container || drag.pointerId !== event.pointerId) return;
- const dx = event.clientX - drag.startX;
- const dy = event.clientY - drag.startY;
- if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true;
- const rect = container.getBoundingClientRect();
- setPosition({
- x: Math.min(Math.max(16, drag.originX + dx), Math.max(16, rect.width - 192)),
- y: Math.min(Math.max(16, drag.originY + dy), Math.max(16, rect.height - 116)),
- });
- }, []);
-
- if (view === "video") {
- return (
-
-
-
-
- );
- }
-
- return (
-
-
-
-
- );
-}
-
-function DebugVideoPanel({
- camera,
- streamOverride,
- compact = false,
-}: {
- camera: CameraPreview;
- streamOverride?: MediaStream | null;
- compact?: boolean;
-}) {
- const { stream, error, starting, active } = camera;
- const effectiveStream = streamOverride ?? stream;
- const effectiveActive = Boolean(effectiveStream) || active;
- const videoRef = useRef(null);
-
- useEffect(() => {
- if (videoRef.current) videoRef.current.srcObject = effectiveStream;
- }, [effectiveStream]);
-
- return (
-
-
- {!effectiveActive ? (
-
-
-
- {starting ? "正在开启摄像头…" : "摄像头不可用"}
-
- {error && (
-
{error}
- )}
-
- ) : null}
-
- );
-}
-
-function DebugConnectionStatus({
- status,
- micWarning,
-}: {
- status: VoicePreviewStatus;
- micWarning: string | null;
-}) {
- const recording = status === "connecting" || status === "connected";
- const label =
- status === "connecting"
- ? "连接中…"
- : status === "connected"
- ? micWarning
- ? "仅收听"
- : "进行中"
- : status === "failed"
- ? "连接失败"
- : "准备开始";
-
- return (
-
-
- {label}
-
- );
-}
-
-// ISO 时间戳 → HH:MM(本地时区),解析失败返回空串
-function formatMessageTime(iso: string): string {
- const d = new Date(iso);
- if (Number.isNaN(d.getTime())) return "";
- const pad = (n: number) => String(n).padStart(2, "0");
- return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
-}
-
-function DebugTranscriptPanel({
- messages,
- recording = false,
-}: {
- messages: ChatMessage[];
- recording?: boolean;
-}) {
- const scrollRef = useRef(null);
-
- // 新消息时滚到底部
- useEffect(() => {
- const el = scrollRef.current;
- if (el) el.scrollTop = el.scrollHeight;
- }, [messages]);
-
- if (messages.length === 0) {
- return (
-
-
-
-
- {recording ? "暂无聊天记录" : "尚未开始对话"}
-
-
- {recording
- ? "开口说话或在下方输入文字,对话内容会实时显示在这里。"
- : "点击「开始对话」后,语音与文字消息会实时显示在这里。"}
-
-
-
- );
- }
-
- return (
-
-
- {messages.map((message) => {
- const time = formatMessageTime(message.timestamp);
- return message.role === "assistant" ? (
-
-
- 助手{time ? ` · ${time}` : ""}
-
-
- {message.content}
-
-
- ) : (
-
-
- 我{time ? ` · ${time}` : ""}
-
-
- {message.content}
-
-
- );
- })}
-
-
- );
-}
-
-function EditorBackButton({ onClick }: { onClick: () => void }) {
- return (
-
- );
-}
-
-function AssistantIdentity({ assistantId }: { assistantId: string | null }) {
- const [copied, setCopied] = useState(false);
-
- async function copyId() {
- if (!assistantId) return;
- await navigator.clipboard.writeText(assistantId);
- setCopied(true);
- window.setTimeout(() => setCopied(false), 1600);
- }
-
- return (
-
-
- {assistantId ? `ID · ${assistantId}` : "ID · 保存后生成"}
-
- {assistantId && (
-
- )}
- {!assistantId && }
-
- );
-}
-
-function EditableTitle({
- value,
- onChange,
-}: {
- value: string;
- onChange: (value: string) => void;
-}) {
- const [editing, setEditing] = useState(false);
- const [draft, setDraft] = useState(value);
- const inputRef = useRef(null);
-
- useEffect(() => {
- if (editing) {
- inputRef.current?.focus();
- inputRef.current?.select();
- }
- }, [editing]);
-
- function startEdit() {
- setDraft(value);
- setEditing(true);
- }
-
- function commit() {
- const next = draft.trim();
- if (next) {
- onChange(next);
- }
- setEditing(false);
- }
-
- if (editing) {
- return (
- setDraft(event.target.value)}
- onBlur={commit}
- onKeyDown={(event) => {
- if (event.key === "Enter") {
- event.preventDefault();
- commit();
- } else if (event.key === "Escape") {
- event.preventDefault();
- setEditing(false);
- }
- }}
- className="font-display display-sm w-[min(60vw,420px)] border-b border-primary bg-transparent text-ink outline-none"
- />
- );
- }
-
- return (
-
- );
-}
-
-function DynamicVariableEditorHint({
- count,
- onOpen,
-}: {
- count: number;
- onOpen: () => void;
-}) {
- return (
-
-
-
- 输入 {"{{"}{" "}
- 添加变量,保存时自动识别
-
-
-
-
- );
-}
-
-function DynamicVariablesDialog({
- open,
- onOpenChange,
- definitions,
- onChange,
-}: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- definitions: Record;
- onChange: (definitions: Record) => void;
-}) {
- const entries = Object.entries(definitions);
- const [copiedSystemVariable, setCopiedSystemVariable] = useState(
- null,
- );
-
- function updateDefinition(
- name: string,
- patch: Partial,
- ) {
- onChange({
- ...definitions,
- [name]: { ...definitions[name], ...patch },
- });
- }
-
- return (
-
- );
-}
-
-function RuntimeModeSelector({
- value,
- onChange,
-}: {
- value: RuntimeMode;
- onChange: (mode: RuntimeMode) => void;
-}) {
- const options = [
- {
- value: "pipeline" as const,
- label: "Pipeline 模式",
- hint: "通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。",
- icon: Waypoints,
- },
- {
- value: "realtime" as const,
- label: "Realtime 模式",
- hint: "使用原生实时语音模型,模型直接处理音频输入并生成语音回复。",
- icon: AudioLines,
- },
- ];
-
- return (
-
- {options.map((option) => {
- const Icon = option.icon;
- const selected = value === option.value;
- const select = () => onChange(option.value);
- return (
-
{
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- select();
- }
- }}
- className={[
- "cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
- selected
- ? "border-primary bg-primary/5 ring-1 ring-primary"
- : "border-hairline bg-canvas-soft hover:border-hairline-strong",
- ].join(" ")}
- >
-
-
-
-
-
-
-
- {option.label}
-
-
-
-
- {selected && (
-
-
-
- )}
-
-
- );
- })}
-
- );
-}
-
-function TextAreaField({
- label,
- value,
- placeholder,
- rows = 4,
- onChange,
-}: {
- label?: string;
- value: string;
- placeholder?: string;
- rows?: number;
- onChange: (value: string) => void;
-}) {
- return (
-
- );
-}
-
-// Radix Select 不允许空字符串 value,用哨兵表示"未选/无"
-const NONE_VALUE = "__none__";
-
-function ResourceSelectField({
- label,
- value,
- options,
- noneLabel,
- onChange,
-}: {
- label?: string;
- value: string;
- options: { value: string; label: string }[];
- /** 提供则在顶部加一个"无/默认"选项,选中映射为空串 */
- noneLabel?: string;
- onChange: (value: string) => void;
-}) {
- return (
-
- {label && (
-
{label}
- )}
-
-
-
- );
-}
-
-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(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 (
- <>
-
-
-
- >
- );
-}
-
-function ToolPicker({
- tools,
- selectedIds,
- onChange,
-}: {
- tools: Tool[];
- selectedIds: string[];
- onChange: (ids: string[]) => void;
-}) {
- const [open, setOpen] = useState(false);
- const [draftIds, setDraftIds] = useState(selectedIds);
- const selectedTools = selectedIds
- .map((id) => tools.find((tool) => tool.id === id))
- .filter((tool): tool is Tool => Boolean(tool));
-
- function openPicker() {
- setDraftIds(selectedIds);
- setOpen(true);
- }
-
- return (
- <>
-
- {selectedTools.map((tool) => (
-
- {tool.type === "end_call" ?
:
}
-
{tool.name}
-
-
- ))}
-
-
-
-
- >
- );
-}
-
-function ToggleRow({
- icon,
- title,
- description,
- hint,
- checked,
- onChange,
-}: {
- icon?: React.ReactNode;
- title: string;
- description?: string;
- hint?: string;
- checked: boolean;
- onChange: (checked: boolean) => void;
-}) {
- const hasIcon = Boolean(icon);
-
- return (
-
-
-
- {icon && (
-
- {icon}
-
- )}
-
- {title}
- {hint && }
-
-
- {description && (
-
- {description}
-
- )}
-
-
-
- );
-}
diff --git a/frontend/src/components/workflow/WorkflowCanvas.tsx b/frontend/src/components/workflow/WorkflowCanvas.tsx
new file mode 100644
index 0000000..a8ee414
--- /dev/null
+++ b/frontend/src/components/workflow/WorkflowCanvas.tsx
@@ -0,0 +1,772 @@
+"use client";
+
+import {
+ addEdge,
+ Background,
+ BackgroundVariant,
+ type Connection,
+ Controls,
+ type Edge,
+ type Node,
+ type NodeChange,
+ type OnConnectEnd,
+ Panel,
+ ReactFlow,
+ useEdgesState,
+ useNodesState,
+ useReactFlow,
+} from "@xyflow/react";
+import { Braces, Plus, Settings2, X } from "lucide-react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+import { edgeTypes } from "./ConditionEdge";
+import {
+ ActiveNodeContext,
+ EdgeActionContext,
+ NodeActionContext,
+ NodeSpecsContext,
+} from "./context";
+import { nodeTypes } from "./GenericNode";
+import { EdgeSettingsPanel } from "./panels/EdgeSettingsPanel";
+import { GlobalSettingsPanel } from "./panels/GlobalSettingsPanel";
+import { NodeSettingsPanel } from "./panels/NodeSettingsPanel";
+import {
+ accentVar,
+ defaultGraph,
+ type NodeSpecMap,
+ type RuntimeNodeSpec,
+ type WorkflowGraph,
+ type WorkflowNodeData,
+ type WorkflowNodeType,
+} from "./specs";
+import type { WorkflowEditorProps } from "./types";
+
+let nodeSeq = 0;
+
+function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
+ const data: WorkflowNodeData = {
+ name: spec.displayName,
+ ...(spec.type === "agent"
+ ? {
+ contextPolicy: "inherit",
+ inheritGlobalConfig: true,
+ entryMode: "wait_user",
+ entrySpeech: "",
+ }
+ : {}),
+ };
+ for (const field of spec.fields) {
+ if (field.default !== undefined) data[field.key] = field.default;
+ }
+ return data;
+}
+
+function toFlow(graph: WorkflowGraph): { nodes: Node[]; edges: Edge[] } {
+ return {
+ nodes: graph.nodes.map((n) => ({
+ id: n.id,
+ type: n.type,
+ position: n.position,
+ data: n.data,
+ })),
+ edges: graph.edges.map((e) => ({
+ id: e.id,
+ type: "condition",
+ source: e.source,
+ target: e.target,
+ data: e.data ?? {},
+ })),
+ };
+}
+
+function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph {
+ return {
+ specVersion: 3,
+ settings: {
+ globalPrompt: "",
+ defaultLlmResourceId: "",
+ defaultAsrResourceId: "",
+ defaultTtsResourceId: "",
+ toolIds: [],
+ knowledgeBaseId: "",
+ knowledgeMode: "automatic",
+ knowledgeTopN: 5,
+ knowledgeScoreThreshold: 0,
+ enableInterrupt: true,
+ turnConfig: defaultGraph().settings.turnConfig,
+ },
+ nodes: nodes.map((n) => ({
+ id: n.id,
+ type: n.type as WorkflowNodeType,
+ position: n.position,
+ data: n.data as WorkflowNodeData,
+ })),
+ edges: edges.map((e) => ({
+ id: e.id,
+ source: e.source,
+ target: e.target,
+ data: (e.data ?? {
+ mode: "always",
+ priority: 10,
+ }) as WorkflowGraph["edges"][number]["data"],
+ })),
+ };
+}
+
+export function WorkflowCanvas({
+ value,
+ onChange,
+ settings,
+ onSettingsChange,
+ modelOptions,
+ activeNodeId,
+ onOpenDynamicVariables,
+ editingNodeId,
+ onEditingNodeIdChange,
+ editingEdgeId,
+ onEditingEdgeIdChange,
+ settingsOpen,
+ onSettingsOpenChange: setSettingsOpen,
+ debugOpen,
+ onDebugOpenChange,
+ debugPanel,
+ specsByType,
+ toolOptions = [],
+ knowledgeOptions = [],
+}: WorkflowEditorProps & { specsByType: NodeSpecMap }) {
+ const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]);
+ const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
+ const [addOpen, setAddOpen] = useState(false);
+ const [addSourceId, setAddSourceId] = useState(null);
+ const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null);
+ const { screenToFlowPosition } = useReactFlow();
+
+ // 回传画布状态给外部(助手 graph)。用 ref 避免把 onChange 放进依赖导致循环。
+ const onChangeRef = useRef(onChange);
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ }, [onChange]);
+ useEffect(() => {
+ const graph = fromFlow(nodes, edges);
+ graph.settings = {
+ globalPrompt: settings.globalPrompt,
+ defaultLlmResourceId: settings.llm ?? "",
+ defaultAsrResourceId: settings.asr ?? "",
+ defaultTtsResourceId: settings.tts ?? "",
+ toolIds: settings.toolIds,
+ knowledgeBaseId: settings.knowledgeBaseId,
+ knowledgeMode: settings.knowledgeRetrievalConfig.mode,
+ knowledgeTopN: settings.knowledgeRetrievalConfig.topN,
+ knowledgeScoreThreshold:
+ settings.knowledgeRetrievalConfig.scoreThreshold,
+ enableInterrupt: settings.allowInterrupt,
+ turnConfig: settings.turnConfig,
+ };
+ onChangeRef.current?.(graph);
+ }, [
+ nodes,
+ edges,
+ settings.globalPrompt,
+ settings.llm,
+ settings.asr,
+ settings.tts,
+ settings.toolIds,
+ settings.knowledgeBaseId,
+ settings.knowledgeRetrievalConfig,
+ settings.allowInterrupt,
+ settings.turnConfig,
+ ]);
+
+ const onConnect = useCallback(
+ (connection: Connection) => {
+ const sourceType = nodes.find((node) => node.id === connection.source)?.type;
+ const priority =
+ edges.filter((edge) => edge.source === connection.source).length * 10 + 10;
+ setEdges((eds) =>
+ addEdge(
+ {
+ ...connection,
+ id: `e-${connection.source}-${connection.target}-${Date.now()}`,
+ type: "condition",
+ animated: true,
+ data:
+ sourceType === "agent"
+ ? {
+ mode: "llm",
+ priority,
+ condition: "当前阶段任务已经完成",
+ }
+ : { mode: "always", priority },
+ },
+ eds,
+ ),
+ );
+ },
+ [nodes, edges, setEdges],
+ );
+
+ // 连线约束:不能连入开始节点(无入边句柄),不能自连。
+ const isValidConnection = useCallback(
+ (c: Connection | Edge) => {
+ if (c.source === c.target) return false;
+ const source = nodes.find((n) => n.id === c.source);
+ const target = nodes.find((n) => n.id === c.target);
+ if (!source || !target) return false;
+
+ const sourceSpec = specsByType[source.type as string];
+ const targetSpec = specsByType[target.type as string];
+ if (!sourceSpec?.hasSource || !targetSpec?.hasTarget) return false;
+ if (edges.some((e) => e.source === c.source && e.target === c.target)) {
+ return false;
+ }
+
+ const sourceLimit = sourceSpec.constraints.maxOutgoing;
+ if (
+ sourceLimit !== undefined &&
+ edges.filter((e) => e.source === c.source).length >= sourceLimit
+ ) {
+ return false;
+ }
+ const targetLimit = targetSpec.constraints.maxIncoming;
+ if (
+ targetLimit !== undefined &&
+ edges.filter((e) => e.target === c.target).length >= targetLimit
+ ) {
+ return false;
+ }
+ return true;
+ },
+ [edges, nodes, specsByType],
+ );
+
+ const addNode = useCallback(
+ (spec: RuntimeNodeSpec) => {
+ nodeSeq += 1;
+ const id = `${spec.type}-${Date.now()}-${nodeSeq}`;
+ const source = addSourceId
+ ? nodes.find((node) => node.id === addSourceId)
+ : undefined;
+ const position = addPosition
+ ? { x: addPosition.x - 125, y: addPosition.y }
+ : screenToFlowPosition({
+ x: window.innerWidth / 2,
+ y: window.innerHeight / 2,
+ });
+ const data = defaultNodeData(spec);
+ setNodes((ns) => [...ns, { id, type: spec.type, position, data }]);
+ if (source) {
+ setEdges((currentEdges) => {
+ const priority =
+ currentEdges.filter((edge) => edge.source === source.id).length * 10 + 10;
+ return addEdge(
+ {
+ id: `e-${source.id}-${id}-${Date.now()}`,
+ source: source.id,
+ target: id,
+ type: "condition",
+ animated: true,
+ data:
+ source.type === "agent"
+ ? {
+ mode: "llm",
+ priority,
+ condition: "当前阶段任务已经完成",
+ }
+ : { mode: "always", priority },
+ },
+ currentEdges,
+ );
+ });
+ }
+ setAddOpen(false);
+ setAddSourceId(null);
+ setAddPosition(null);
+ setSettingsOpen(false);
+ onDebugOpenChange(false);
+ onEditingEdgeIdChange(null);
+ onEditingNodeIdChange(id);
+ },
+ [
+ addPosition,
+ addSourceId,
+ nodes,
+ onDebugOpenChange,
+ onEditingEdgeIdChange,
+ onEditingNodeIdChange,
+ screenToFlowPosition,
+ setEdges,
+ setNodes,
+ setSettingsOpen,
+ ],
+ );
+
+ const updateNodeData = useCallback(
+ (id: string, patch: Partial) => {
+ setNodes((ns) =>
+ ns.map((n) =>
+ n.id === id ? { ...n, data: { ...n.data, ...patch } } : n,
+ ),
+ );
+ },
+ [setNodes],
+ );
+
+ const deleteNode = useCallback(
+ (id: string) => {
+ if (nodes.find((node) => node.id === id)?.type === "start") return;
+ setNodes((ns) => ns.filter((n) => n.id !== id));
+ setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
+ if (editingNodeId === id) onEditingNodeIdChange(null);
+ },
+ [editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges],
+ );
+
+ const handleNodesChange = useCallback(
+ (changes: NodeChange[]) => {
+ const startIds = new Set(
+ nodes.filter((node) => node.type === "start").map((node) => node.id),
+ );
+ onNodesChange(
+ changes.filter(
+ (change) => change.type !== "remove" || !startIds.has(change.id),
+ ),
+ );
+ },
+ [nodes, onNodesChange],
+ );
+
+ const updateEdgeData = useCallback(
+ (
+ id: string,
+ patch: WorkflowGraph["edges"][number]["data"],
+ ) => {
+ setEdges((es) =>
+ es.map((e) =>
+ e.id === id ? { ...e, data: { ...(e.data ?? {}), ...patch } } : e,
+ ),
+ );
+ },
+ [setEdges],
+ );
+
+ const deleteEdge = useCallback(
+ (id: string) => {
+ setEdges((es) => es.filter((e) => e.id !== id));
+ if (editingEdgeId === id) onEditingEdgeIdChange(null);
+ },
+ [editingEdgeId, onEditingEdgeIdChange, setEdges],
+ );
+
+ const canCreateFromSource = useCallback(
+ (id: string) => {
+ const source = nodes.find((node) => node.id === id);
+ if (!source) return false;
+ const spec = specsByType[source.type as string];
+ if (!spec?.hasSource) return false;
+ const outgoingCount = edges.filter((edge) => edge.source === id).length;
+ if (
+ spec.constraints.maxOutgoing !== undefined &&
+ outgoingCount >= spec.constraints.maxOutgoing
+ ) {
+ return false;
+ }
+ return source.type === "agent" || outgoingCount === 0;
+ },
+ [edges, nodes, specsByType],
+ );
+ const onConnectEnd = useCallback(
+ (event, connectionState) => {
+ if (
+ connectionState.isValid ||
+ connectionState.toNode ||
+ connectionState.fromHandle?.type !== "source" ||
+ !connectionState.fromNode ||
+ !canCreateFromSource(connectionState.fromNode.id)
+ ) {
+ return;
+ }
+ const pointer = "changedTouches" in event
+ ? event.changedTouches[0]
+ : event;
+ if (!pointer) return;
+ setAddSourceId(connectionState.fromNode.id);
+ setAddPosition(
+ screenToFlowPosition({ x: pointer.clientX, y: pointer.clientY }),
+ );
+ setAddOpen(true);
+ },
+ [canCreateFromSource, screenToFlowPosition],
+ );
+ const openSettings = useCallback(() => {
+ onDebugOpenChange(false);
+ onEditingNodeIdChange(null);
+ onEditingEdgeIdChange(null);
+ setSettingsOpen(true);
+ }, [
+ onDebugOpenChange,
+ onEditingEdgeIdChange,
+ onEditingNodeIdChange,
+ setSettingsOpen,
+ ]);
+
+ const nodeActions = useMemo(
+ () => ({
+ edit: (id: string) => {
+ setSettingsOpen(false);
+ onDebugOpenChange(false);
+ onEditingEdgeIdChange(null);
+ onEditingNodeIdChange(id);
+ },
+ remove: deleteNode,
+ }),
+ [
+ deleteNode,
+ onDebugOpenChange,
+ onEditingEdgeIdChange,
+ onEditingNodeIdChange,
+ setSettingsOpen,
+ ],
+ );
+ const edgeActions = useMemo(
+ () => ({
+ edit: (id: string) => {
+ setSettingsOpen(false);
+ onDebugOpenChange(false);
+ onEditingNodeIdChange(null);
+ onEditingEdgeIdChange(id);
+ },
+ remove: deleteEdge,
+ }),
+ [
+ deleteEdge,
+ onDebugOpenChange,
+ onEditingEdgeIdChange,
+ onEditingNodeIdChange,
+ setSettingsOpen,
+ ],
+ );
+
+ const editingNode = nodes.find((n) => n.id === editingNodeId);
+ const editingSpec = editingNode ? specsByType[editingNode.type as string] : null;
+ const editingEdge = edges.find((e) => e.id === editingEdgeId);
+ const addableSpecs = Object.values(specsByType).filter((s) => s.addable);
+ const canAddSpec = useCallback(
+ (spec: RuntimeNodeSpec) => {
+ const limit = spec.constraints.maxInstances;
+ if (limit === undefined) return true;
+ return nodes.filter((node) => node.type === spec.type).length < limit;
+ },
+ [nodes],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ onEditingEdgeIdChange(null);
+ }}
+ fitView
+ proOptions={{ hideAttribution: true }}
+ defaultEdgeOptions={{ type: "condition", animated: true }}
+ >
+
+
+
+
+
+
+
+
+
+ 添加节点
+
+
+
+
+
+
+ 工作流设置
+
+
+
+
+
+
+ 动态变量
+
+
+
+
+
+
+
+ {/* 添加节点弹窗 */}
+
+
+ {(debugOpen ||
+ settingsOpen ||
+ (editingNode && editingSpec) ||
+ editingEdge) && (
+
+ )}
+
+
+
+
+
+ );
+}
+
diff --git a/frontend/src/components/workflow/WorkflowEditor.tsx b/frontend/src/components/workflow/WorkflowEditor.tsx
index 1b4faad..662d8a6 100644
--- a/frontend/src/components/workflow/WorkflowEditor.tsx
+++ b/frontend/src/components/workflow/WorkflowEditor.tsx
@@ -8,2211 +8,15 @@
* 节点目录来自后端 /api/node-types。数据通过 value/onChange 与外部 graph 同步。
*/
-import {
- addEdge,
- Background,
- BackgroundVariant,
- type Connection,
- Controls,
- type Edge,
- type Node,
- type NodeChange,
- type OnConnectEnd,
- Panel,
- ReactFlow,
- ReactFlowProvider,
- useEdgesState,
- useNodesState,
- useReactFlow,
-} from "@xyflow/react";
+import { ReactFlowProvider } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
-import {
- AudioLines,
- Braces,
- Bot,
- Brain,
- Database,
- Flag,
- GitBranch,
- Loader2,
- MessageSquareText,
- PhoneForwarded,
- Play,
- Plus,
- Settings2,
- Sparkles,
- Tag,
- Trash2,
- Wrench,
- X,
- Zap,
-} from "lucide-react";
-import {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactNode,
-} from "react";
+import { Loader2 } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { SectionCard } from "@/components/editor/section-card";
-import { KnowledgeRetrievalConfigDialog } from "@/components/editor/knowledge-retrieval-config-dialog";
-import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import type { KnowledgeRetrievalConfig, TurnConfig } from "@/lib/api";
-import { TurnConfigEditor } from "@/components/turn-config-editor";
-import { normalizeTurnConfig } from "@/lib/turn-config";
-import { Textarea } from "@/components/ui/textarea";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-
-import { edgeTypes } from "./ConditionEdge";
-import {
- ActiveNodeContext,
- EdgeActionContext,
- NodeActionContext,
- NodeSpecsContext,
-} from "./context";
-import { nodeTypes } from "./GenericNode";
-import {
- accentVar,
- defaultGraph,
- type NodeSpecMap,
- type RuntimeNodeSpec,
- type ExpressionRule,
- type WorkflowGraph,
- type WorkflowEdgeData,
- type WorkflowNodeData,
- type WorkflowNodeType,
-} from "./specs";
+import { WorkflowCanvas } from "./WorkflowCanvas";
import { useNodeSpecs } from "./useNodeSpecs";
+import type { WorkflowEditorProps } from "./types";
-export type WorkflowSettings = {
- llm?: string;
- asr?: string;
- tts?: string;
- toolIds: string[];
- knowledgeBaseId: string;
- knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
- globalPrompt: string;
- allowInterrupt: boolean;
- turnConfig: TurnConfig;
-};
-
-export type ModelOption = { value: string; label: string };
-
-export type WorkflowEditorProps = {
- value?: WorkflowGraph;
- onChange?: (graph: WorkflowGraph) => void;
- settings: WorkflowSettings;
- onSettingsChange: (settings: WorkflowSettings) => void;
- modelOptions: { llm: ModelOption[]; asr: ModelOption[]; tts: ModelOption[] };
- toolOptions?: ModelOption[];
- knowledgeOptions?: ModelOption[];
- onOpenDynamicVariables: () => void;
- editingNodeId: string | null;
- onEditingNodeIdChange: (nodeId: string | null) => void;
- editingEdgeId: string | null;
- onEditingEdgeIdChange: (edgeId: string | null) => void;
- settingsOpen: boolean;
- onSettingsOpenChange: (open: boolean) => void;
- debugOpen: boolean;
- onDebugOpenChange: (open: boolean) => void;
- debugPanel: ReactNode;
- /** 调试通话中当前激活的节点 id(用于高亮)。 */
- activeNodeId?: string | null;
-};
-
-let nodeSeq = 0;
-const NONE = "__none__";
-
-function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
- const data: WorkflowNodeData = {
- name: spec.displayName,
- ...(spec.type === "agent"
- ? {
- contextPolicy: "inherit",
- inheritGlobalConfig: true,
- entryMode: "wait_user",
- entrySpeech: "",
- }
- : {}),
- };
- for (const field of spec.fields) {
- if (field.default !== undefined) data[field.key] = field.default;
- }
- return data;
-}
-
-function toFlow(graph: WorkflowGraph): { nodes: Node[]; edges: Edge[] } {
- return {
- nodes: graph.nodes.map((n) => ({
- id: n.id,
- type: n.type,
- position: n.position,
- data: n.data,
- })),
- edges: graph.edges.map((e) => ({
- id: e.id,
- type: "condition",
- source: e.source,
- target: e.target,
- data: e.data ?? {},
- })),
- };
-}
-
-function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph {
- return {
- specVersion: 3,
- settings: {
- globalPrompt: "",
- defaultLlmResourceId: "",
- defaultAsrResourceId: "",
- defaultTtsResourceId: "",
- toolIds: [],
- knowledgeBaseId: "",
- knowledgeMode: "automatic",
- knowledgeTopN: 5,
- knowledgeScoreThreshold: 0,
- enableInterrupt: true,
- turnConfig: defaultGraph().settings.turnConfig,
- },
- nodes: nodes.map((n) => ({
- id: n.id,
- type: n.type as WorkflowNodeType,
- position: n.position,
- data: n.data as WorkflowNodeData,
- })),
- edges: edges.map((e) => ({
- id: e.id,
- source: e.source,
- target: e.target,
- data: (e.data ?? {
- mode: "always",
- priority: 10,
- }) as WorkflowGraph["edges"][number]["data"],
- })),
- };
-}
-
-function Canvas({
- value,
- onChange,
- settings,
- onSettingsChange,
- modelOptions,
- activeNodeId,
- onOpenDynamicVariables,
- editingNodeId,
- onEditingNodeIdChange,
- editingEdgeId,
- onEditingEdgeIdChange,
- settingsOpen,
- onSettingsOpenChange: setSettingsOpen,
- debugOpen,
- onDebugOpenChange,
- debugPanel,
- specsByType,
- toolOptions = [],
- knowledgeOptions = [],
-}: WorkflowEditorProps & { specsByType: NodeSpecMap }) {
- const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]);
- const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
- const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
- const [addOpen, setAddOpen] = useState(false);
- const [addSourceId, setAddSourceId] = useState(null);
- const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null);
- const { screenToFlowPosition } = useReactFlow();
-
- // 回传画布状态给外部(助手 graph)。用 ref 避免把 onChange 放进依赖导致循环。
- const onChangeRef = useRef(onChange);
- useEffect(() => {
- onChangeRef.current = onChange;
- }, [onChange]);
- useEffect(() => {
- const graph = fromFlow(nodes, edges);
- graph.settings = {
- globalPrompt: settings.globalPrompt,
- defaultLlmResourceId: settings.llm ?? "",
- defaultAsrResourceId: settings.asr ?? "",
- defaultTtsResourceId: settings.tts ?? "",
- toolIds: settings.toolIds,
- knowledgeBaseId: settings.knowledgeBaseId,
- knowledgeMode: settings.knowledgeRetrievalConfig.mode,
- knowledgeTopN: settings.knowledgeRetrievalConfig.topN,
- knowledgeScoreThreshold:
- settings.knowledgeRetrievalConfig.scoreThreshold,
- enableInterrupt: settings.allowInterrupt,
- turnConfig: settings.turnConfig,
- };
- onChangeRef.current?.(graph);
- }, [
- nodes,
- edges,
- settings.globalPrompt,
- settings.llm,
- settings.asr,
- settings.tts,
- settings.toolIds,
- settings.knowledgeBaseId,
- settings.knowledgeRetrievalConfig,
- settings.allowInterrupt,
- settings.turnConfig,
- ]);
-
- const onConnect = useCallback(
- (connection: Connection) => {
- const sourceType = nodes.find((node) => node.id === connection.source)?.type;
- const priority =
- edges.filter((edge) => edge.source === connection.source).length * 10 + 10;
- setEdges((eds) =>
- addEdge(
- {
- ...connection,
- id: `e-${connection.source}-${connection.target}-${Date.now()}`,
- type: "condition",
- animated: true,
- data:
- sourceType === "agent"
- ? {
- mode: "llm",
- priority,
- condition: "当前阶段任务已经完成",
- }
- : { mode: "always", priority },
- },
- eds,
- ),
- );
- },
- [nodes, edges, setEdges],
- );
-
- // 连线约束:不能连入开始节点(无入边句柄),不能自连。
- const isValidConnection = useCallback(
- (c: Connection | Edge) => {
- if (c.source === c.target) return false;
- const source = nodes.find((n) => n.id === c.source);
- const target = nodes.find((n) => n.id === c.target);
- if (!source || !target) return false;
-
- const sourceSpec = specsByType[source.type as string];
- const targetSpec = specsByType[target.type as string];
- if (!sourceSpec?.hasSource || !targetSpec?.hasTarget) return false;
- if (edges.some((e) => e.source === c.source && e.target === c.target)) {
- return false;
- }
-
- const sourceLimit = sourceSpec.constraints.maxOutgoing;
- if (
- sourceLimit !== undefined &&
- edges.filter((e) => e.source === c.source).length >= sourceLimit
- ) {
- return false;
- }
- const targetLimit = targetSpec.constraints.maxIncoming;
- if (
- targetLimit !== undefined &&
- edges.filter((e) => e.target === c.target).length >= targetLimit
- ) {
- return false;
- }
- return true;
- },
- [edges, nodes, specsByType],
- );
-
- const addNode = useCallback(
- (spec: RuntimeNodeSpec) => {
- nodeSeq += 1;
- const id = `${spec.type}-${Date.now()}-${nodeSeq}`;
- const source = addSourceId
- ? nodes.find((node) => node.id === addSourceId)
- : undefined;
- const position = addPosition
- ? { x: addPosition.x - 125, y: addPosition.y }
- : screenToFlowPosition({
- x: window.innerWidth / 2,
- y: window.innerHeight / 2,
- });
- const data = defaultNodeData(spec);
- setNodes((ns) => [...ns, { id, type: spec.type, position, data }]);
- if (source) {
- setEdges((currentEdges) => {
- const priority =
- currentEdges.filter((edge) => edge.source === source.id).length * 10 + 10;
- return addEdge(
- {
- id: `e-${source.id}-${id}-${Date.now()}`,
- source: source.id,
- target: id,
- type: "condition",
- animated: true,
- data:
- source.type === "agent"
- ? {
- mode: "llm",
- priority,
- condition: "当前阶段任务已经完成",
- }
- : { mode: "always", priority },
- },
- currentEdges,
- );
- });
- }
- setAddOpen(false);
- setAddSourceId(null);
- setAddPosition(null);
- setSettingsOpen(false);
- onDebugOpenChange(false);
- onEditingEdgeIdChange(null);
- onEditingNodeIdChange(id);
- },
- [
- addPosition,
- addSourceId,
- nodes,
- onDebugOpenChange,
- onEditingEdgeIdChange,
- onEditingNodeIdChange,
- screenToFlowPosition,
- setEdges,
- setNodes,
- setSettingsOpen,
- ],
- );
-
- const updateNodeData = useCallback(
- (id: string, patch: Partial) => {
- setNodes((ns) =>
- ns.map((n) =>
- n.id === id ? { ...n, data: { ...n.data, ...patch } } : n,
- ),
- );
- },
- [setNodes],
- );
-
- const deleteNode = useCallback(
- (id: string) => {
- if (nodes.find((node) => node.id === id)?.type === "start") return;
- setNodes((ns) => ns.filter((n) => n.id !== id));
- setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
- if (editingNodeId === id) onEditingNodeIdChange(null);
- },
- [editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges],
- );
-
- const handleNodesChange = useCallback(
- (changes: NodeChange[]) => {
- const startIds = new Set(
- nodes.filter((node) => node.type === "start").map((node) => node.id),
- );
- onNodesChange(
- changes.filter(
- (change) => change.type !== "remove" || !startIds.has(change.id),
- ),
- );
- },
- [nodes, onNodesChange],
- );
-
- const updateEdgeData = useCallback(
- (
- id: string,
- patch: WorkflowGraph["edges"][number]["data"],
- ) => {
- setEdges((es) =>
- es.map((e) =>
- e.id === id ? { ...e, data: { ...(e.data ?? {}), ...patch } } : e,
- ),
- );
- },
- [setEdges],
- );
-
- const deleteEdge = useCallback(
- (id: string) => {
- setEdges((es) => es.filter((e) => e.id !== id));
- if (editingEdgeId === id) onEditingEdgeIdChange(null);
- },
- [editingEdgeId, onEditingEdgeIdChange, setEdges],
- );
-
- const canCreateFromSource = useCallback(
- (id: string) => {
- const source = nodes.find((node) => node.id === id);
- if (!source) return false;
- const spec = specsByType[source.type as string];
- if (!spec?.hasSource) return false;
- const outgoingCount = edges.filter((edge) => edge.source === id).length;
- if (
- spec.constraints.maxOutgoing !== undefined &&
- outgoingCount >= spec.constraints.maxOutgoing
- ) {
- return false;
- }
- return source.type === "agent" || outgoingCount === 0;
- },
- [edges, nodes, specsByType],
- );
- const onConnectEnd = useCallback(
- (event, connectionState) => {
- if (
- connectionState.isValid ||
- connectionState.toNode ||
- connectionState.fromHandle?.type !== "source" ||
- !connectionState.fromNode ||
- !canCreateFromSource(connectionState.fromNode.id)
- ) {
- return;
- }
- const pointer = "changedTouches" in event
- ? event.changedTouches[0]
- : event;
- if (!pointer) return;
- setAddSourceId(connectionState.fromNode.id);
- setAddPosition(
- screenToFlowPosition({ x: pointer.clientX, y: pointer.clientY }),
- );
- setAddOpen(true);
- },
- [canCreateFromSource, screenToFlowPosition],
- );
- const openSettings = useCallback(() => {
- onDebugOpenChange(false);
- onEditingNodeIdChange(null);
- onEditingEdgeIdChange(null);
- setSettingsOpen(true);
- }, [
- onDebugOpenChange,
- onEditingEdgeIdChange,
- onEditingNodeIdChange,
- setSettingsOpen,
- ]);
-
- const nodeActions = useMemo(
- () => ({
- edit: (id: string) => {
- setSettingsOpen(false);
- onDebugOpenChange(false);
- onEditingEdgeIdChange(null);
- onEditingNodeIdChange(id);
- },
- remove: deleteNode,
- }),
- [
- deleteNode,
- onDebugOpenChange,
- onEditingEdgeIdChange,
- onEditingNodeIdChange,
- setSettingsOpen,
- ],
- );
- const edgeActions = useMemo(
- () => ({
- edit: (id: string) => {
- setSettingsOpen(false);
- onDebugOpenChange(false);
- onEditingNodeIdChange(null);
- onEditingEdgeIdChange(id);
- },
- remove: deleteEdge,
- }),
- [
- deleteEdge,
- onDebugOpenChange,
- onEditingEdgeIdChange,
- onEditingNodeIdChange,
- setSettingsOpen,
- ],
- );
-
- const editingNode = nodes.find((n) => n.id === editingNodeId);
- const editingSpec = editingNode ? specsByType[editingNode.type as string] : null;
- const editingEdge = edges.find((e) => e.id === editingEdgeId);
- const addableSpecs = Object.values(specsByType).filter((s) => s.addable);
- const canAddSpec = useCallback(
- (spec: RuntimeNodeSpec) => {
- const limit = spec.constraints.maxInstances;
- if (limit === undefined) return true;
- return nodes.filter((node) => node.type === spec.type).length < limit;
- },
- [nodes],
- );
-
- return (
-
-
-
-
-
-
-
-
-
- {
- onEditingEdgeIdChange(null);
- }}
- fitView
- proOptions={{ hideAttribution: true }}
- defaultEdgeOptions={{ type: "condition", animated: true }}
- >
-
-
-
-
-
-
-
-
-
- 添加节点
-
-
-
-
-
-
- 工作流设置
-
-
-
-
-
-
- 动态变量
-
-
-
-
-
-
-
- {/* 添加节点弹窗 */}
-
-
- {(debugOpen ||
- settingsOpen ||
- (editingNode && editingSpec) ||
- editingEdge) && (
-
- )}
-
-
-
-
-
- );
-}
-
-function SettingsForm({
- settings,
- onSettingsChange,
- modelOptions,
- toolOptions,
- knowledgeOptions,
-}: {
- settings: WorkflowSettings;
- onSettingsChange: (settings: WorkflowSettings) => void;
- modelOptions: WorkflowEditorProps["modelOptions"];
- toolOptions: ModelOption[];
- knowledgeOptions: ModelOption[];
-}) {
- return (
-
-
}
- title="全局提示词"
- description="与所有选择继承全局配置的 Agent 节点提示词合并"
- >
-
- );
-}
-
-function ModelSelect({
- label,
- value,
- options,
- onChange,
-}: {
- label: string;
- value?: string;
- options: ModelOption[];
- onChange: (value: string | undefined) => void;
-}) {
- return (
-
- {label && (
-
{label}
- )}
-
-
- );
-}
-
-function NodeForm({
- panel = false,
- spec,
- data,
- toolOptions,
- knowledgeOptions,
- llmOptions,
- asrOptions,
- ttsOptions,
- workflowSettings,
- onChange,
-}: {
- panel?: boolean;
- spec: RuntimeNodeSpec;
- data: WorkflowNodeData;
- toolOptions: ModelOption[];
- knowledgeOptions: ModelOption[];
- llmOptions: ModelOption[];
- asrOptions: ModelOption[];
- ttsOptions: ModelOption[];
- workflowSettings: WorkflowSettings;
- onChange: (patch: WorkflowNodeData) => void;
-}) {
- const [draft, setDraft] = useState({ ...data });
- const [argumentsJson, setArgumentsJson] = useState(
- JSON.stringify(data.arguments ?? {}, null, 2),
- );
- const [assignmentsJson, setAssignmentsJson] = useState(
- JSON.stringify(data.resultAssignments ?? {}, null, 2),
- );
- const [jsonError, setJsonError] = useState("");
- const commit = (next: WorkflowNodeData) => {
- setDraft(next);
- onChange(next);
- };
- const set = (key: string, val: unknown) =>
- commit({ ...draft, [key]: val });
- const setPatch = (patch: Partial) =>
- commit({ ...draft, ...patch });
- const commitActionJson = (
- nextArgumentsJson: string,
- nextAssignmentsJson: string,
- ) => {
- try {
- const args = JSON.parse(nextArgumentsJson) as Record;
- const assignments = JSON.parse(nextAssignmentsJson) as Record;
- if (Array.isArray(args) || Array.isArray(assignments)) throw new Error();
- setJsonError("");
- commit({ ...draft, arguments: args, resultAssignments: assignments });
- } catch {
- setJsonError("参数和结果映射必须是 JSON 对象");
- }
- };
-
- if (panel) {
- if (spec.type !== "agent") {
- return (
-
- );
- }
- return (
-
- );
- }
-
- return (
- <>
- {!panel && (
-
-
- 编辑{spec.displayName}
-
-
- )}
-
-
- {spec.fields.map((field) => {
- const raw = draft[field.key];
- if (field.type === "switch") {
- return (
-
- );
- }
- return (
-
-
- {field.type === "textarea" ? (
-
- );
- })}
-
- {spec.type === "agent" && (
- <>
-
set("entryMode", value || "wait_user")}
- allowNone={false}
- />
- {draft.entryMode === "fixed_speech" && (
-
- )}
-
-
- set("toolIds", toolIds)}
- />
-
- set("knowledgeBaseId", value || "")}
- />
- set("knowledgeMode", value || "disabled")}
- allowNone={false}
- />
- set("asrResourceId", value || "")}
- noneLabel="继承 Workflow 默认值"
- />
- set("ttsResourceId", value || "")}
- noneLabel="继承 Workflow 默认值"
- />
- >
- )}
-
- {spec.type === "action" && (
- <>
- set("toolId", value || "")}
- noneLabel="请选择工具"
- />
-
-
-
-
-
-
- {jsonError && {jsonError}}
- >
- )}
-
- {spec.type === "handoff" && (
- set("targetType", value || "human")}
- allowNone={false}
- />
- )}
-
- {spec.type === "end" && (
- set("scope", value || "session")}
- allowNone={false}
- />
- )}
-
- >
- );
-}
-
-function WorkflowNodePanelForm({
- spec,
- draft,
- set,
- toolOptions,
- argumentsJson,
- assignmentsJson,
- jsonError,
- setArgumentsJson,
- setAssignmentsJson,
- commitActionJson,
-}: {
- spec: RuntimeNodeSpec;
- draft: WorkflowNodeData;
- set: (key: string, val: unknown) => void;
- toolOptions: ModelOption[];
- argumentsJson: string;
- assignmentsJson: string;
- jsonError: string;
- setArgumentsJson: (value: string) => void;
- setAssignmentsJson: (value: string) => void;
- commitActionJson: (argumentsValue: string, assignmentsValue: string) => void;
-}) {
- return (
-
-
}
- title="节点信息"
- description="节点在画布上显示的名称"
- >
-
-
-
- {spec.type === "start" && (
-
}
- title="开场白"
- description="会话建立后首先向用户播放的固定内容"
- >
-
-
- )}
-
- {spec.type === "action" && (
-
}
- title="工具执行"
- description="确定性调用工具,并将响应字段写入会话动态变量"
- >
-
set("toolId", value || "")}
- noneLabel="请选择工具"
- />
-
-
- {jsonError && (
-
- {jsonError}
-
- )}
-
- )}
-
- {spec.type === "handoff" && (
- }
- title="转交配置"
- description="发送转交事件并继续执行工作流"
- >
- set("targetType", value || "human")}
- allowNone={false}
- />
-
-
-
- )}
-
- {spec.type === "end" && (
- }
- title="结束配置"
- description="结束工作流,并可在结束前播放一段固定回复"
- >
- set("scope", value || "session")}
- allowNone={false}
- />
-
-
- )}
-
- );
-}
-
-/** Agent 右栏:与 AssistantPage 共用紧凑 SectionCard。 */
-function AgentPanelForm({
- draft,
- set,
- setPatch,
- workflowSettings,
- toolOptions,
- knowledgeOptions,
- llmOptions,
- asrOptions,
- ttsOptions,
-}: {
- draft: WorkflowNodeData;
- set: (key: string, val: unknown) => void;
- setPatch: (patch: Partial) => void;
- workflowSettings: WorkflowSettings;
- toolOptions: ModelOption[];
- knowledgeOptions: ModelOption[];
- llmOptions: ModelOption[];
- asrOptions: ModelOption[];
- ttsOptions: ModelOption[];
-}) {
- const inheritsGlobal = draft.inheritGlobalConfig !== false;
- const knowledgeConfig: KnowledgeRetrievalConfig = {
- mode:
- draft.knowledgeMode === "on_demand" ? "on_demand" : "automatic",
- topN: Number(draft.knowledgeTopN ?? 5),
- scoreThreshold: Number(draft.knowledgeScoreThreshold ?? 0),
- };
- const agentTurnConfig = normalizeTurnConfig(
- draft.turnConfig ?? workflowSettings.turnConfig,
- );
-
- const setInheritance = (inheritGlobalConfig: boolean) => {
- if (inheritGlobalConfig) {
- setPatch({ inheritGlobalConfig: true });
- return;
- }
- setPatch({
- inheritGlobalConfig: false,
- llmResourceId:
- (draft.llmResourceId as string) || workflowSettings.llm || "",
- asrResourceId:
- (draft.asrResourceId as string) || workflowSettings.asr || "",
- ttsResourceId:
- (draft.ttsResourceId as string) || workflowSettings.tts || "",
- toolIds: draft.toolIds?.length
- ? draft.toolIds
- : workflowSettings.toolIds,
- knowledgeBaseId:
- (draft.knowledgeBaseId as string) ||
- workflowSettings.knowledgeBaseId,
- knowledgeMode:
- draft.knowledgeMode === "on_demand" ||
- draft.knowledgeMode === "automatic"
- ? draft.knowledgeMode
- : workflowSettings.knowledgeRetrievalConfig.mode,
- knowledgeTopN:
- draft.knowledgeTopN ??
- workflowSettings.knowledgeRetrievalConfig.topN,
- knowledgeScoreThreshold:
- draft.knowledgeScoreThreshold ??
- workflowSettings.knowledgeRetrievalConfig.scoreThreshold,
- enableInterrupt:
- draft.enableInterrupt ?? workflowSettings.allowInterrupt,
- turnConfig: agentTurnConfig,
- });
- };
-
- return (
-
-
}
- title="节点信息"
- description="节点在画布上显示的名称"
- >
-
-
-
-
}
- title="配置范围"
- description="默认复用工作流全局的模型、语音、知识库和工具"
- >
-
-
-
- 继承全局配置
-
-
- {inheritsGlobal
- ? "全局提示词会与当前节点提示词合并。"
- : "当前节点使用独立的完整助手配置。"}
-
-
-
-
-
-
-
}
- title={inheritsGlobal ? "任务" : "提示词"}
- description={
- inheritsGlobal
- ? "描述当前阶段要完成的目标;角色、能力和通用规则继承工作流全局配置"
- : "描述当前独立助手的角色、能力和回答要求"
- }
- >
-
- );
-}
-
-function ToolOptionPicker({
- options,
- selectedIds,
- onChange,
-}: {
- options: ModelOption[];
- selectedIds: string[];
- onChange: (ids: string[]) => void;
-}) {
- const [open, setOpen] = useState(false);
- const [draftIds, setDraftIds] = useState(selectedIds);
- const selected = selectedIds
- .map((id) => options.find((option) => option.value === id))
- .filter((option): option is ModelOption => Boolean(option));
-
- return (
- <>
-
- {selected.map((option) => (
-
-
- {option.label}
-
-
- ))}
-
-
-
-
- >
- );
-}
-
-function NodeSelect({
- label,
- value,
- options,
- onChange,
- allowNone = true,
- noneLabel = "未选择",
-}: {
- label: string;
- value: string;
- options: ModelOption[];
- onChange: (value: string | undefined) => void;
- allowNone?: boolean;
- noneLabel?: string;
-}) {
- return (
-
- {label && (
-
{label}
- )}
-
-
- );
-}
-
-function EdgeForm({
- edge,
- sourceType,
- onChange,
-}: {
- edge: Edge;
- sourceType?: string;
- onChange: (patch: WorkflowEdgeData) => void;
-}) {
- const data = (edge.data ?? { mode: "always", priority: 10 }) as WorkflowEdgeData;
- const [mode, setMode] = useState(data.mode ?? "always");
- const [priority, setPriority] = useState(data.priority ?? 10);
- const [label, setLabel] = useState(data.label ?? "");
- const [condition, setCondition] = useState(data.condition ?? "");
- const [transitionSpeech, setTransitionSpeech] = useState(data.transitionSpeech ?? "");
- const [combinator, setCombinator] = useState<"and" | "or">(
- data.expression?.combinator ?? "and",
- );
- const [rules, setRules] = useState(
- data.expression?.rules?.length
- ? data.expression.rules
- : [{ variable: "", operator: "eq", value: "" }],
- );
-
- const publish = ({
- nextMode = mode,
- nextPriority = priority,
- nextLabel = label,
- nextCondition = condition,
- nextTransitionSpeech = transitionSpeech,
- nextCombinator = combinator,
- nextRules = rules,
- }: {
- nextMode?: WorkflowEdgeData["mode"];
- nextPriority?: number;
- nextLabel?: string;
- nextCondition?: string;
- nextTransitionSpeech?: string;
- nextCombinator?: "and" | "or";
- nextRules?: ExpressionRule[];
- }) =>
- onChange({
- mode: nextMode,
- priority: nextPriority,
- label: nextLabel.trim() ? nextLabel : undefined,
- condition: nextMode === "llm" ? nextCondition : undefined,
- expression:
- nextMode === "expression"
- ? { combinator: nextCombinator, rules: nextRules }
- : undefined,
- transitionSpeech: nextTransitionSpeech.trim()
- ? nextTransitionSpeech
- : undefined,
- });
-
- const setRule = (index: number, patch: Partial) => {
- const nextRules = rules.map((rule, ruleIndex) =>
- ruleIndex === index ? { ...rule, ...patch } : rule,
- );
- setRules(nextRules);
- publish({ nextRules });
- };
-
- const parseValue = (value: string): unknown => {
- if (value === "true") return true;
- if (value === "false") return false;
- if (value !== "" && Number.isFinite(Number(value))) return Number(value);
- return value;
- };
-
- return (
-
-
}
- title="路由方式"
- description="选择由 Agent 判断、动态变量表达式判断,或作为确定性默认路径"
- >
-
{
- const nextMode =
- (value as WorkflowEdgeData["mode"]) || "always";
- setMode(nextMode);
- publish({ nextMode });
- }}
- allowNone={false}
- />
-
-
-
- }
- title="触发条件"
- description="配置画布标签以及这条连接被命中的条件"
- >
-
-
- {mode === "llm" && (
-
- )}
-
- {mode === "expression" && (
-
-
{
- const nextCombinator = value === "or" ? "or" : "and";
- setCombinator(nextCombinator);
- publish({ nextCombinator });
- }}
- allowNone={false}
- />
- {rules.map((rule, index) => (
-
-
setRule(index, { variable: event.target.value })}
- className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
- />
-
-
-
- setRule(index, {
- value: parseValue(event.target.value),
- })
- }
- className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
- />
-
-
- {!rule.variable.trim() && (
-
- 请输入动态变量名。
-
- )}
-
- ))}
-
-
- )}
-
- {mode === "always" && (
-
- 当前节点没有其它条件命中时,将沿这条默认路径继续执行。
-
- )}
-
-
- }
- title="过渡语"
- description="命中连接后、进入下一节点前播放的固定内容"
- >
-
-
-
- );
-}
+export type { ModelOption, WorkflowEditorProps, WorkflowSettings } from "./types";
export function WorkflowEditor(props: WorkflowEditorProps) {
const { byType, loading, error } = useNodeSpecs();
@@ -2235,7 +39,7 @@ export function WorkflowEditor(props: WorkflowEditorProps) {
return (
-
+
);
}
diff --git a/frontend/src/components/workflow/panels/ActionNodePanel.tsx b/frontend/src/components/workflow/panels/ActionNodePanel.tsx
new file mode 100644
index 0000000..cece73c
--- /dev/null
+++ b/frontend/src/components/workflow/panels/ActionNodePanel.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { Zap } from "lucide-react";
+
+import { SectionCard } from "@/components/editor/section-card";
+import { Textarea } from "@/components/ui/textarea";
+
+import { NodeSelect } from "./controls";
+import type { WorkflowNodeData } from "../specs";
+import type { ModelOption } from "../types";
+
+type ActionNodePanelProps = {
+ draft: WorkflowNodeData;
+ set: (key: string, value: unknown) => void;
+ toolOptions: ModelOption[];
+ argumentsJson: string;
+ assignmentsJson: string;
+ jsonError: string;
+ setArgumentsJson: (value: string) => void;
+ setAssignmentsJson: (value: string) => void;
+ commitActionJson: (argumentsValue: string, assignmentsValue: string) => void;
+};
+
+export function ActionNodePanel({
+ draft,
+ set,
+ toolOptions,
+ argumentsJson,
+ assignmentsJson,
+ jsonError,
+ setArgumentsJson,
+ setAssignmentsJson,
+ commitActionJson,
+}: ActionNodePanelProps) {
+ return (
+ }
+ title="工具执行"
+ description="确定性调用工具,并将响应字段写入会话动态变量"
+ >
+ set("toolId", value || "")}
+ noneLabel="请选择工具"
+ />
+
+
+ {jsonError && (
+
+ {jsonError}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/workflow/panels/AgentNodePanel.tsx b/frontend/src/components/workflow/panels/AgentNodePanel.tsx
new file mode 100644
index 0000000..810f937
--- /dev/null
+++ b/frontend/src/components/workflow/panels/AgentNodePanel.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import {
+ Bot,
+ Brain,
+ Database,
+ MessageSquareText,
+ Settings2,
+ Sparkles,
+ Tag,
+ Wrench,
+} from "lucide-react";
+
+import { KnowledgeRetrievalConfigDialog } from "@/components/editor/knowledge-retrieval-config-dialog";
+import { SectionCard } from "@/components/editor/section-card";
+import { TurnConfigEditor } from "@/components/turn-config-editor";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import type { KnowledgeRetrievalConfig } from "@/lib/api";
+import { normalizeTurnConfig } from "@/lib/turn-config";
+
+import { NodeSelect, ToolOptionPicker } from "./controls";
+import type { WorkflowNodeData } from "../specs";
+import type { ModelOption, WorkflowSettings } from "../types";
+
+export function AgentNodePanel({
+ draft,
+ set,
+ setPatch,
+ workflowSettings,
+ toolOptions,
+ knowledgeOptions,
+ llmOptions,
+ asrOptions,
+ ttsOptions,
+}: {
+ draft: WorkflowNodeData;
+ set: (key: string, val: unknown) => void;
+ setPatch: (patch: Partial) => void;
+ workflowSettings: WorkflowSettings;
+ toolOptions: ModelOption[];
+ knowledgeOptions: ModelOption[];
+ llmOptions: ModelOption[];
+ asrOptions: ModelOption[];
+ ttsOptions: ModelOption[];
+}) {
+ const inheritsGlobal = draft.inheritGlobalConfig !== false;
+ const knowledgeConfig: KnowledgeRetrievalConfig = {
+ mode:
+ draft.knowledgeMode === "on_demand" ? "on_demand" : "automatic",
+ topN: Number(draft.knowledgeTopN ?? 5),
+ scoreThreshold: Number(draft.knowledgeScoreThreshold ?? 0),
+ };
+ const agentTurnConfig = normalizeTurnConfig(
+ draft.turnConfig ?? workflowSettings.turnConfig,
+ );
+
+ const setInheritance = (inheritGlobalConfig: boolean) => {
+ if (inheritGlobalConfig) {
+ setPatch({ inheritGlobalConfig: true });
+ return;
+ }
+ setPatch({
+ inheritGlobalConfig: false,
+ llmResourceId:
+ (draft.llmResourceId as string) || workflowSettings.llm || "",
+ asrResourceId:
+ (draft.asrResourceId as string) || workflowSettings.asr || "",
+ ttsResourceId:
+ (draft.ttsResourceId as string) || workflowSettings.tts || "",
+ toolIds: draft.toolIds?.length
+ ? draft.toolIds
+ : workflowSettings.toolIds,
+ knowledgeBaseId:
+ (draft.knowledgeBaseId as string) ||
+ workflowSettings.knowledgeBaseId,
+ knowledgeMode:
+ draft.knowledgeMode === "on_demand" ||
+ draft.knowledgeMode === "automatic"
+ ? draft.knowledgeMode
+ : workflowSettings.knowledgeRetrievalConfig.mode,
+ knowledgeTopN:
+ draft.knowledgeTopN ??
+ workflowSettings.knowledgeRetrievalConfig.topN,
+ knowledgeScoreThreshold:
+ draft.knowledgeScoreThreshold ??
+ workflowSettings.knowledgeRetrievalConfig.scoreThreshold,
+ enableInterrupt:
+ draft.enableInterrupt ?? workflowSettings.allowInterrupt,
+ turnConfig: agentTurnConfig,
+ });
+ };
+
+ return (
+
+
}
+ title="节点信息"
+ description="节点在画布上显示的名称"
+ >
+
+
+
+
}
+ title="配置范围"
+ description="默认复用工作流全局的模型、语音、知识库和工具"
+ >
+
+
+
+ 继承全局配置
+
+
+ {inheritsGlobal
+ ? "全局提示词会与当前节点提示词合并。"
+ : "当前节点使用独立的完整助手配置。"}
+
+
+
+
+
+
+
}
+ title={inheritsGlobal ? "任务" : "提示词"}
+ description={
+ inheritsGlobal
+ ? "描述当前阶段要完成的目标;角色、能力和通用规则继承工作流全局配置"
+ : "描述当前独立助手的角色、能力和回答要求"
+ }
+ >
+
+ );
+}
+
+
diff --git a/frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx b/frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx
new file mode 100644
index 0000000..53c23f0
--- /dev/null
+++ b/frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx
@@ -0,0 +1,337 @@
+"use client";
+
+import type { Edge } from "@xyflow/react";
+import {
+ Braces,
+ GitBranch,
+ MessageSquareText,
+ Plus,
+ Trash2,
+} from "lucide-react";
+import { useState } from "react";
+
+import { SectionCard } from "@/components/editor/section-card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+
+import { NodeSelect } from "./controls";
+import type { ExpressionRule, WorkflowEdgeData } from "../specs";
+
+export function EdgeSettingsPanel({
+ edge,
+ sourceType,
+ onChange,
+}: {
+ edge: Edge;
+ sourceType?: string;
+ onChange: (patch: WorkflowEdgeData) => void;
+}) {
+ const data = (edge.data ?? { mode: "always", priority: 10 }) as WorkflowEdgeData;
+ const [mode, setMode] = useState(data.mode ?? "always");
+ const [priority, setPriority] = useState(data.priority ?? 10);
+ const [label, setLabel] = useState(data.label ?? "");
+ const [condition, setCondition] = useState(data.condition ?? "");
+ const [transitionSpeech, setTransitionSpeech] = useState(data.transitionSpeech ?? "");
+ const [combinator, setCombinator] = useState<"and" | "or">(
+ data.expression?.combinator ?? "and",
+ );
+ const [rules, setRules] = useState(
+ data.expression?.rules?.length
+ ? data.expression.rules
+ : [{ variable: "", operator: "eq", value: "" }],
+ );
+
+ const publish = ({
+ nextMode = mode,
+ nextPriority = priority,
+ nextLabel = label,
+ nextCondition = condition,
+ nextTransitionSpeech = transitionSpeech,
+ nextCombinator = combinator,
+ nextRules = rules,
+ }: {
+ nextMode?: WorkflowEdgeData["mode"];
+ nextPriority?: number;
+ nextLabel?: string;
+ nextCondition?: string;
+ nextTransitionSpeech?: string;
+ nextCombinator?: "and" | "or";
+ nextRules?: ExpressionRule[];
+ }) =>
+ onChange({
+ mode: nextMode,
+ priority: nextPriority,
+ label: nextLabel.trim() ? nextLabel : undefined,
+ condition: nextMode === "llm" ? nextCondition : undefined,
+ expression:
+ nextMode === "expression"
+ ? { combinator: nextCombinator, rules: nextRules }
+ : undefined,
+ transitionSpeech: nextTransitionSpeech.trim()
+ ? nextTransitionSpeech
+ : undefined,
+ });
+
+ const setRule = (index: number, patch: Partial) => {
+ const nextRules = rules.map((rule, ruleIndex) =>
+ ruleIndex === index ? { ...rule, ...patch } : rule,
+ );
+ setRules(nextRules);
+ publish({ nextRules });
+ };
+
+ const parseValue = (value: string): unknown => {
+ if (value === "true") return true;
+ if (value === "false") return false;
+ if (value !== "" && Number.isFinite(Number(value))) return Number(value);
+ return value;
+ };
+
+ return (
+
+
}
+ title="路由方式"
+ description="选择由 Agent 判断、动态变量表达式判断,或作为确定性默认路径"
+ >
+
{
+ const nextMode =
+ (value as WorkflowEdgeData["mode"]) || "always";
+ setMode(nextMode);
+ publish({ nextMode });
+ }}
+ allowNone={false}
+ />
+
+
+
+ }
+ title="触发条件"
+ description="配置画布标签以及这条连接被命中的条件"
+ >
+
+
+ {mode === "llm" && (
+
+ )}
+
+ {mode === "expression" && (
+
+
{
+ const nextCombinator = value === "or" ? "or" : "and";
+ setCombinator(nextCombinator);
+ publish({ nextCombinator });
+ }}
+ allowNone={false}
+ />
+ {rules.map((rule, index) => (
+
+
setRule(index, { variable: event.target.value })}
+ className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
+ />
+
+
+
+ setRule(index, {
+ value: parseValue(event.target.value),
+ })
+ }
+ className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
+ />
+
+
+ {!rule.variable.trim() && (
+
+ 请输入动态变量名。
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {mode === "always" && (
+
+ 当前节点没有其它条件命中时,将沿这条默认路径继续执行。
+
+ )}
+
+
+ }
+ title="过渡语"
+ description="命中连接后、进入下一节点前播放的固定内容"
+ >
+
+
+
+ );
+}
+
+
diff --git a/frontend/src/components/workflow/panels/GlobalSettingsPanel.tsx b/frontend/src/components/workflow/panels/GlobalSettingsPanel.tsx
new file mode 100644
index 0000000..6e94347
--- /dev/null
+++ b/frontend/src/components/workflow/panels/GlobalSettingsPanel.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import {
+ AudioLines,
+ Brain,
+ Database,
+ MessageSquareText,
+ Sparkles,
+ Wrench,
+} from "lucide-react";
+
+import { KnowledgeRetrievalConfigDialog } from "@/components/editor/knowledge-retrieval-config-dialog";
+import { SectionCard } from "@/components/editor/section-card";
+import { TurnConfigEditor } from "@/components/turn-config-editor";
+import { Textarea } from "@/components/ui/textarea";
+
+import { ModelSelect, NodeSelect, ToolOptionPicker } from "./controls";
+import type {
+ ModelOption,
+ WorkflowEditorProps,
+ WorkflowSettings,
+} from "../types";
+
+export function GlobalSettingsPanel({
+ settings,
+ onSettingsChange,
+ modelOptions,
+ toolOptions,
+ knowledgeOptions,
+}: {
+ settings: WorkflowSettings;
+ onSettingsChange: (settings: WorkflowSettings) => void;
+ modelOptions: WorkflowEditorProps["modelOptions"];
+ toolOptions: ModelOption[];
+ knowledgeOptions: ModelOption[];
+}) {
+ return (
+
+
}
+ title="全局提示词"
+ description="与所有选择继承全局配置的 Agent 节点提示词合并"
+ >
+
+ );
+}
+
+
diff --git a/frontend/src/components/workflow/panels/NodeSettingsPanel.tsx b/frontend/src/components/workflow/panels/NodeSettingsPanel.tsx
new file mode 100644
index 0000000..1d6ddf2
--- /dev/null
+++ b/frontend/src/components/workflow/panels/NodeSettingsPanel.tsx
@@ -0,0 +1,458 @@
+"use client";
+
+import { Flag, PhoneForwarded, Play, Tag } from "lucide-react";
+import { useState } from "react";
+
+import { SectionCard } from "@/components/editor/section-card";
+import { DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+
+import { ActionNodePanel } from "./ActionNodePanel";
+import { AgentNodePanel } from "./AgentNodePanel";
+import { NodeSelect, ToolOptionPicker } from "./controls";
+import type { RuntimeNodeSpec, WorkflowNodeData } from "../specs";
+import type { ModelOption, WorkflowSettings } from "../types";
+
+export function NodeSettingsPanel({
+ panel = false,
+ spec,
+ data,
+ toolOptions,
+ knowledgeOptions,
+ llmOptions,
+ asrOptions,
+ ttsOptions,
+ workflowSettings,
+ onChange,
+}: {
+ panel?: boolean;
+ spec: RuntimeNodeSpec;
+ data: WorkflowNodeData;
+ toolOptions: ModelOption[];
+ knowledgeOptions: ModelOption[];
+ llmOptions: ModelOption[];
+ asrOptions: ModelOption[];
+ ttsOptions: ModelOption[];
+ workflowSettings: WorkflowSettings;
+ onChange: (patch: WorkflowNodeData) => void;
+}) {
+ const [draft, setDraft] = useState({ ...data });
+ const [argumentsJson, setArgumentsJson] = useState(
+ JSON.stringify(data.arguments ?? {}, null, 2),
+ );
+ const [assignmentsJson, setAssignmentsJson] = useState(
+ JSON.stringify(data.resultAssignments ?? {}, null, 2),
+ );
+ const [jsonError, setJsonError] = useState("");
+ const commit = (next: WorkflowNodeData) => {
+ setDraft(next);
+ onChange(next);
+ };
+ const set = (key: string, val: unknown) =>
+ commit({ ...draft, [key]: val });
+ const setPatch = (patch: Partial) =>
+ commit({ ...draft, ...patch });
+ const commitActionJson = (
+ nextArgumentsJson: string,
+ nextAssignmentsJson: string,
+ ) => {
+ try {
+ const args = JSON.parse(nextArgumentsJson) as Record;
+ const assignments = JSON.parse(nextAssignmentsJson) as Record;
+ if (Array.isArray(args) || Array.isArray(assignments)) throw new Error();
+ setJsonError("");
+ commit({ ...draft, arguments: args, resultAssignments: assignments });
+ } catch {
+ setJsonError("参数和结果映射必须是 JSON 对象");
+ }
+ };
+
+ if (panel) {
+ if (spec.type !== "agent") {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {!panel && (
+
+
+ 编辑{spec.displayName}
+
+
+ )}
+
+
+ {spec.fields.map((field) => {
+ const raw = draft[field.key];
+ if (field.type === "switch") {
+ return (
+
+ );
+ }
+ return (
+
+
+ {field.type === "textarea" ? (
+
+ );
+ })}
+
+ {spec.type === "agent" && (
+ <>
+
set("entryMode", value || "wait_user")}
+ allowNone={false}
+ />
+ {draft.entryMode === "fixed_speech" && (
+
+
+ 固定进入语 *
+
+
+ )}
+
+
+ set("toolIds", toolIds)}
+ />
+
+ set("knowledgeBaseId", value || "")}
+ />
+ set("knowledgeMode", value || "disabled")}
+ allowNone={false}
+ />
+ set("asrResourceId", value || "")}
+ noneLabel="继承 Workflow 默认值"
+ />
+ set("ttsResourceId", value || "")}
+ noneLabel="继承 Workflow 默认值"
+ />
+ >
+ )}
+
+ {spec.type === "action" && (
+ <>
+ set("toolId", value || "")}
+ noneLabel="请选择工具"
+ />
+
+
+
+
+
+
+ {jsonError && {jsonError}}
+ >
+ )}
+
+ {spec.type === "handoff" && (
+ set("targetType", value || "human")}
+ allowNone={false}
+ />
+ )}
+
+ {spec.type === "end" && (
+ set("scope", value || "session")}
+ allowNone={false}
+ />
+ )}
+
+ >
+ );
+}
+
+function WorkflowNodePanelForm({
+ spec,
+ draft,
+ set,
+ toolOptions,
+ argumentsJson,
+ assignmentsJson,
+ jsonError,
+ setArgumentsJson,
+ setAssignmentsJson,
+ commitActionJson,
+}: {
+ spec: RuntimeNodeSpec;
+ draft: WorkflowNodeData;
+ set: (key: string, val: unknown) => void;
+ toolOptions: ModelOption[];
+ argumentsJson: string;
+ assignmentsJson: string;
+ jsonError: string;
+ setArgumentsJson: (value: string) => void;
+ setAssignmentsJson: (value: string) => void;
+ commitActionJson: (argumentsValue: string, assignmentsValue: string) => void;
+}) {
+ return (
+
+
}
+ title="节点信息"
+ description="节点在画布上显示的名称"
+ >
+
+
+
+ {spec.type === "start" && (
+
}
+ title="开场白"
+ description="会话建立后首先向用户播放的固定内容"
+ >
+
+
+ )}
+
+ {spec.type === "action" && (
+
+ )}
+
+ {spec.type === "handoff" && (
+
}
+ title="转交配置"
+ description="发送转交事件并继续执行工作流"
+ >
+
set("targetType", value || "human")}
+ allowNone={false}
+ />
+
+
+
+ )}
+
+ {spec.type === "end" && (
+ }
+ title="结束配置"
+ description="结束工作流,并可在结束前播放一段固定回复"
+ >
+ set("scope", value || "session")}
+ allowNone={false}
+ />
+
+
+ )}
+
+ );
+}
+
+/** Agent 右栏:与 AssistantPage 共用紧凑 SectionCard。 */
+
diff --git a/frontend/src/components/workflow/panels/controls.tsx b/frontend/src/components/workflow/panels/controls.tsx
new file mode 100644
index 0000000..81d37b3
--- /dev/null
+++ b/frontend/src/components/workflow/panels/controls.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import { Plus, Wrench, X } 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 {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+import type { ModelOption } from "../types";
+
+const NONE = "__none__";
+
+export function ModelSelect({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string;
+ value?: string;
+ options: ModelOption[];
+ onChange: (value: string | undefined) => void;
+}) {
+ return (
+
+ {label && (
+
{label}
+ )}
+
+
+ );
+}
+
+export function ToolOptionPicker({
+ options,
+ selectedIds,
+ onChange,
+}: {
+ options: ModelOption[];
+ selectedIds: string[];
+ onChange: (ids: string[]) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [draftIds, setDraftIds] = useState(selectedIds);
+ const selected = selectedIds
+ .map((id) => options.find((option) => option.value === id))
+ .filter((option): option is ModelOption => Boolean(option));
+
+ return (
+ <>
+
+ {selected.map((option) => (
+
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+ >
+ );
+}
+
+export function NodeSelect({
+ label,
+ value,
+ options,
+ onChange,
+ allowNone = true,
+ noneLabel = "未选择",
+}: {
+ label: string;
+ value: string;
+ options: ModelOption[];
+ onChange: (value: string | undefined) => void;
+ allowNone?: boolean;
+ noneLabel?: string;
+}) {
+ return (
+
+ {label && (
+
{label}
+ )}
+
+
+ );
+}
+
+
diff --git a/frontend/src/components/workflow/types.ts b/frontend/src/components/workflow/types.ts
new file mode 100644
index 0000000..464c00c
--- /dev/null
+++ b/frontend/src/components/workflow/types.ts
@@ -0,0 +1,41 @@
+import type { ReactNode } from "react";
+
+import type { KnowledgeRetrievalConfig, TurnConfig } from "@/lib/api";
+
+import type { WorkflowGraph } from "./specs";
+
+export type WorkflowSettings = {
+ llm?: string;
+ asr?: string;
+ tts?: string;
+ toolIds: string[];
+ knowledgeBaseId: string;
+ knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
+ globalPrompt: string;
+ allowInterrupt: boolean;
+ turnConfig: TurnConfig;
+};
+
+export type ModelOption = { value: string; label: string };
+
+export type WorkflowEditorProps = {
+ value?: WorkflowGraph;
+ onChange?: (graph: WorkflowGraph) => void;
+ settings: WorkflowSettings;
+ onSettingsChange: (settings: WorkflowSettings) => void;
+ modelOptions: { llm: ModelOption[]; asr: ModelOption[]; tts: ModelOption[] };
+ toolOptions?: ModelOption[];
+ knowledgeOptions?: ModelOption[];
+ onOpenDynamicVariables: () => void;
+ editingNodeId: string | null;
+ onEditingNodeIdChange: (nodeId: string | null) => void;
+ editingEdgeId: string | null;
+ onEditingEdgeIdChange: (edgeId: string | null) => void;
+ settingsOpen: boolean;
+ onSettingsOpenChange: (open: boolean) => void;
+ debugOpen: boolean;
+ onDebugOpenChange: (open: boolean) => void;
+ debugPanel: ReactNode;
+ /** 调试通话中当前激活的节点 id(用于高亮)。 */
+ activeNodeId?: string | null;
+};