Refactor pipeline and assistant page components for improved structure and performance

- Remove unused imports and classes from pipeline.py to streamline the codebase.
- Consolidate dynamic variable handling and workflow management in AssistantPage, enhancing clarity and maintainability.
- Update WorkflowEditor to utilize a more modular approach, improving the overall architecture and reducing complexity.
- Enhance the import structure across components for better organization and readability.
This commit is contained in:
Xin Wang
2026-07-14 12:59:41 +08:00
parent 2d6ff5b7aa
commit 6e8fc70c5a
21 changed files with 6122 additions and 5439 deletions

View File

@@ -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 = "<!-- knowledge-context -->"
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:

View File

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

View File

@@ -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 = "<!-- knowledge-context -->"
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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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<string>();
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<string, DynamicVariableDefinition>,
): DynamicVariableDefinition {
return (
saved[name] ?? {
type: "string",
required: false,
default: null,
}
);
}
export function activeDynamicVariableDefinitions(
templates: string[],
saved: Record<string, DynamicVariableDefinition>,
): Record<string, DynamicVariableDefinition> {
return Object.fromEntries(
extractDynamicVariableNames(...templates).map((name) => [
name,
definitionFor(name, saved),
]),
);
}
export function activeWorkflowDynamicVariableDefinitions(
graph: WorkflowGraph,
saved: Record<string, DynamicVariableDefinition>,
): Record<string, DynamicVariableDefinition> {
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 (
<div className="flex items-center justify-between gap-3 rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-3 py-2 text-xs text-muted-foreground">
<span>
{" "}
<code className="rounded bg-surface-strong px-1 py-0.5 font-mono text-foreground">
{"{{"}
</code>{" "}
</span>
<button
type="button"
onClick={onOpen}
className="shrink-0 font-medium text-foreground underline-offset-4 hover:underline"
>
{count > 0 ? `管理 ${count} 个变量` : "查看变量"}
</button>
</div>
);
}
export function DynamicVariablesDialog({
open,
onOpenChange,
definitions,
onChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
definitions: Record<string, DynamicVariableDefinition>;
onChange: (definitions: Record<string, DynamicVariableDefinition>) => void;
}) {
const entries = Object.entries(definitions);
const [copiedSystemVariable, setCopiedSystemVariable] = useState<string | null>(
null,
);
function updateDefinition(
name: string,
patch: Partial<DynamicVariableDefinition>,
) {
onChange({
...definitions,
[name]: { ...definitions[name], ...patch },
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[78vh] overflow-hidden rounded-2xl border-hairline bg-card p-0 sm:max-w-[520px]">
<DialogHeader className="border-b border-hairline px-6 py-5 text-left">
<DialogTitle className="flex items-center gap-2 text-base font-medium">
<Braces size={17} />
</DialogTitle>
<DialogDescription className="text-xs leading-5">
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="custom" className="min-h-0 px-6 pb-6">
<TabsList className="grid w-full shrink-0 grid-cols-2 bg-surface-strong">
<TabsTrigger value="custom">
<span className="text-[11px] tabular-nums text-muted-foreground">
{entries.length}
</span>
</TabsTrigger>
<TabsTrigger value="system">
<span className="text-[11px] tabular-nums text-muted-foreground">
{SYSTEM_DYNAMIC_VARIABLES.length}
</span>
</TabsTrigger>
</TabsList>
<TabsContent
value="custom"
className="max-h-[calc(78vh-168px)] overflow-y-auto pt-3"
>
{entries.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{"{{customer_name}}"}
</p>
</div>
) : (
<div className="space-y-3">
{entries.map(([name, definition]) => (
<div
key={name}
className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-3.5"
>
<div className="flex items-center justify-between gap-3">
<code className="truncate font-mono text-sm font-medium text-foreground">
{`{{${name}}}`}
</code>
<Badge
variant="secondary"
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
>
</Badge>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<Select
value={definition.type}
onValueChange={(type: DynamicVariableDefinition["type"]) =>
updateDefinition(name, { type, default: null })
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<span className="sr-only"> {name} </span>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="boolean"></SelectItem>
</SelectContent>
</Select>
{definition.type === "boolean" ? (
<Select
value={
definition.default == null
? "unset"
: String(definition.default)
}
onValueChange={(value) =>
updateDefinition(name, {
default:
value === "unset" ? null : value === "true",
})
}
>
<SelectTrigger
aria-label={`变量 ${name} 默认值`}
className="w-full border-hairline-strong bg-background"
>
<SelectValue placeholder="无默认值" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unset"></SelectItem>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type={definition.type === "number" ? "number" : "text"}
value={
typeof definition.default === "boolean"
? String(definition.default)
: definition.default ?? ""
}
onChange={(event) => {
const raw = event.target.value;
updateDefinition(name, {
default:
raw === ""
? null
: definition.type === "number"
? Number(raw)
: raw,
});
}}
placeholder="默认值(可选)"
aria-label={`变量 ${name} 默认值`}
className="border-hairline-strong bg-background"
/>
)}
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">
</span>
<Switch
checked={definition.required}
aria-label={`变量 ${name} 必填`}
onCheckedChange={(required) =>
updateDefinition(name, { required })
}
/>
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent
value="system"
className="max-h-[calc(78vh-168px)] space-y-3 overflow-y-auto pt-3"
>
<p className="text-[11px] leading-5 text-muted-foreground">
</p>
<div className="space-y-1.5">
{SYSTEM_DYNAMIC_VARIABLES.map(([name, label]) => (
<div
key={name}
className="flex items-center justify-between gap-3 rounded-xl border border-hairline bg-background px-3 py-2"
>
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">
{label}
</div>
<code className="block truncate font-mono text-[11px] text-muted-foreground">
{`{{${name}}}`}
</code>
</div>
<button
type="button"
onClick={() => {
void navigator.clipboard.writeText(`{{${name}}}`).then(() => {
setCopiedSystemVariable(name);
window.setTimeout(
() => setCopiedSystemVariable(null),
1400,
);
});
}}
aria-label={
copiedSystemVariable === name
? `${label} 已复制`
: `复制系统变量 ${label}`
}
title="复制引用"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
{copiedSystemVariable === name ? (
<Check size={13} />
) : (
<Copy size={13} />
)}
</button>
</div>
))}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={onClick}
aria-label="返回助手列表"
>
<ChevronLeft size={20} />
</Button>
);
}
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 (
<div className="ml-1 flex shrink-0 items-center rounded-full border border-hairline bg-canvas-soft pl-2.5 text-[11px] text-muted-foreground">
<span className="font-mono">
{assistantId ? `ID · ${assistantId}` : "ID · 保存后生成"}
</span>
{assistantId && (
<button
type="button"
onClick={() => void copyId()}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={copied ? "助手 ID 已复制" : "复制助手 ID"}
title={copied ? "已复制" : "复制 ID"}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
)}
{!assistantId && <span className="h-7 w-2" aria-hidden />}
</div>
);
}
export function EditableTitle({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement>(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 (
<input
ref={inputRef}
value={draft}
onChange={(event) => 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 (
<button
type="button"
onClick={startEdit}
title="点击修改助手名称"
className="group -mx-2 flex min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-strong"
>
<span className="font-display display-sm truncate text-ink">
{value || "未命名助手"}
</span>
<Pencil
size={16}
className="shrink-0 text-muted-soft opacity-0 transition-opacity group-hover:opacity-100"
/>
</button>
);
}
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 (
<div className="grid grid-cols-1 gap-3 border-b border-hairline-soft pb-4 md:grid-cols-2">
{options.map((option) => {
const Icon = option.icon;
const selected = value === option.value;
const select = () => onChange(option.value);
return (
<div
key={option.value}
role="button"
tabIndex={0}
onClick={select}
onKeyDown={(event) => {
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(" ")}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Icon size={15} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
{option.label}
</span>
<HelpHint text={option.hint} />
</div>
</div>
{selected && (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check size={12} />
</span>
)}
</div>
</div>
);
})}
</div>
);
}
export function TextAreaField({
label,
value,
placeholder,
rows = 4,
onChange,
}: {
label?: string;
value: string;
placeholder?: string;
rows?: number;
onChange: (value: string) => void;
}) {
return (
<label className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Textarea
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
rows={rows}
// Override ui/textarea's field-sizing-content so `rows` sets a real height
// instead of collapsing to min-h-16 when the value is short.
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</label>
);
}
// 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 (
<div className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Select
value={value || NONE_VALUE}
onValueChange={(v) => onChange(v === NONE_VALUE ? "" : v)}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue placeholder={label ? `请选择${label}` : "请选择"} />
</SelectTrigger>
<SelectContent className="border-hairline bg-popover text-popover-foreground">
{noneLabel && (
<SelectItem value={NONE_VALUE}>{noneLabel}</SelectItem>
)}
{options.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function KnowledgeRetrievalConfigDialog({
disabled,
value,
onChange,
}: {
disabled: boolean;
value: KnowledgeRetrievalConfig;
onChange: (config: KnowledgeRetrievalConfig) => void;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState<string | null>(null);
function openDialog() {
setDraft(value);
setError(null);
setOpen(true);
}
function saveDraft() {
if (draft.topN === 0 || draft.topN < -1 || !Number.isInteger(draft.topN)) {
setError("Top N 必须为 -1 或大于 0 的整数");
return;
}
if (draft.scoreThreshold < 0 || draft.scoreThreshold > 1) {
setError("最低相关度必须在 0 到 1 之间");
return;
}
onChange(draft);
setOpen(false);
}
return (
<>
<button
type="button"
disabled={disabled}
onClick={openDialog}
aria-label="打开知识库高级配置"
title={
disabled
? "请先选择知识库"
: `${value.mode === "automatic" ? "自动检索" : "模型主动检索"} · Top N ${value.topN === -1 ? "不限" : value.topN} · 最低相关度 ${value.scoreThreshold}`
}
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
>
<Settings2 size={14} />
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground"></div>
<Select
value={draft.mode}
onValueChange={(mode: "automatic" | "on_demand") =>
setDraft({ ...draft, mode })
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="automatic"></SelectItem>
<SelectItem value="on_demand"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{draft.mode === "automatic"
? "每轮用户提问后自动检索,响应行为更稳定。"
: "由大模型判断是否调用知识库,依赖模型的工具调用能力。"}
</p>
</div>
<label className="block">
<span className="mb-2 block text-sm font-medium text-foreground">
</span>
<Input
type="number"
step="1"
min="-1"
value={draft.topN}
onChange={(event) =>
setDraft({ ...draft, topN: Number(event.target.value) })
}
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
-1
</span>
</label>
<label className="block">
<span className="mb-2 block text-sm font-medium text-foreground">
</span>
<Input
type="number"
step="0.01"
min="0"
max="1"
value={draft.scoreThreshold}
onChange={(event) =>
setDraft({
...draft,
scoreThreshold: Number(event.target.value),
})
}
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
01
</span>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="button" onClick={saveDraft}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export function ToolPicker({
tools,
selectedIds,
onChange,
}: {
tools: Tool[];
selectedIds: string[];
onChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [draftIds, setDraftIds] = useState<string[]>(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 (
<>
<div className="flex min-h-9 flex-wrap items-center gap-2">
{selectedTools.map((tool) => (
<div
key={tool.id}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
>
{tool.type === "end_call" ? <PhoneOff size={14} /> : <Wrench size={14} />}
<span className="max-w-48 truncate">{tool.name}</span>
<button
type="button"
onClick={() => onChange(selectedIds.filter((id) => id !== tool.id))}
className="text-muted-soft transition-colors hover:text-foreground"
aria-label={`移除工具 ${tool.name}`}
>
<X size={13} />
</button>
</div>
))}
<Button
type="button"
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={openPicker}
aria-label="添加工具"
title="添加工具"
>
<Plus size={15} />
</Button>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{tools.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="max-h-80 divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
{tools.map((tool) => {
const checked = draftIds.includes(tool.id);
return (
<label
key={tool.id}
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-strong/40"
>
<input
type="checkbox"
checked={checked}
onChange={() =>
setDraftIds((current) =>
checked
? current.filter((id) => id !== tool.id)
: [...current, tool.id],
)
}
className="size-4 accent-primary"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-foreground">
{tool.name}
</span>
<Badge variant="secondary">
{tool.type === "end_call" ? "End Call" : "HTTP"}
</Badge>
</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool.functionName}
</div>
</div>
</label>
);
})}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button
onClick={() => {
onChange(draftIds);
setOpen(false);
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
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 (
<div
className={[
"flex items-center justify-between border border-hairline bg-canvas-soft",
hasIcon ? "rounded-xl p-3.5" : "rounded-xl px-3.5 py-3",
].join(" ")}
>
<div>
<div
className={[
"flex items-center text-sm font-medium text-foreground",
hasIcon ? "gap-2.5" : "gap-1.5",
].join(" ")}
>
{icon && (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon}
</span>
)}
<span className="flex items-center gap-1.5">
<span>{title}</span>
{hint && <HelpHint text={hint} />}
</span>
</div>
{description && (
<div className="mt-1 text-xs text-muted-foreground">
{description}
</div>
)}
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}

View File

@@ -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<string, DynamicVariableDefinition>;
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: <K extends keyof AssistantForm>(
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 (
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={onBack} />
<EditableTitle
value={form.name}
onChange={(value) => updateForm("name", value)}
/>
<AssistantIdentity assistantId={assistantId} />
</div>
<div className="flex shrink-0 gap-2">
{saveError && (
<span className="self-center text-xs text-destructive">
{saveError}
</span>
)}
<Button
className="gap-2"
disabled={saving || !dirty || !form.name.trim()}
onClick={() => onSave()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
</Button>
</div>
</div>
<DynamicVariablesDialog
open={dynamicVariablesOpen}
onOpenChange={setDynamicVariablesOpen}
definitions={dynamicVariableDefinitions}
onChange={(dynamicVariableDefinitions) =>
updateForm(
"dynamicVariableDefinitions",
dynamicVariableDefinitions,
)
}
/>
<div className="flex min-h-0 flex-1 gap-4">
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
<SectionCard
icon={<MessageSquareText size={15} />}
title="提示词"
description="描述助手的角色、能力和回答要求"
>
<TextAreaField
value={form.prompt}
onChange={(value) => updateForm("prompt", value)}
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
rows={8}
/>
<DynamicVariableEditorHint
count={Object.keys(dynamicVariableDefinitions).length}
onOpen={() => setDynamicVariablesOpen(true)}
/>
</SectionCard>
<SectionCard
icon={<Bot size={15} />}
title="开场白"
description="助手与用户首次对话时的开场语"
>
<TextAreaField
value={form.greeting}
onChange={(value) => updateForm("greeting", value)}
placeholder="请输入助手开场白"
/>
<DynamicVariableEditorHint
count={Object.keys(dynamicVariableDefinitions).length}
onOpen={() => setDynamicVariablesOpen(true)}
/>
</SectionCard>
<SectionCard
icon={<Brain size={15} />}
title="模型配置"
description={
form.runtimeMode === "pipeline"
? "选择运行方式,以及大语言模型、语音识别与语音合成资源"
: "选择运行方式Realtime 模型内置语音识别与语音合成"
}
>
<RuntimeModeSelector
value={form.runtimeMode}
onChange={(runtimeMode) => updateForm("runtimeMode", runtimeMode)}
/>
{form.runtimeMode === "pipeline" ? (
<>
<ToggleRow
title="视觉理解"
hint="开启后,开始对话时会允许助手按需理解当前视频画面。视觉模型选「模型自己」时,大语言模型本身必须支持图片输入。"
checked={form.visionEnabled}
onChange={handlePromptVisionEnabledChange}
/>
{form.visionEnabled && (
<ResourceSelectField
label="视觉模型"
value={form.visionModelResourceId}
onChange={(value) =>
updateForm("visionModelResourceId", value)
}
options={visionModelOptions}
noneLabel="模型自己"
/>
)}
<ResourceSelectField
label="大语言模型"
value={form.model}
onChange={handlePromptModelChange}
options={llmOptions}
noneLabel="无"
/>
<ResourceSelectField
label="语音识别"
value={form.asr}
onChange={(value) => updateForm("asr", value)}
options={asrOptions}
noneLabel="无"
/>
<ResourceSelectField
label="语音合成"
value={form.voice}
onChange={(value) => updateForm("voice", value)}
options={ttsOptions}
noneLabel="无"
/>
</>
) : (
<ResourceSelectField
label="Realtime 模型"
value={form.realtimeModel}
onChange={(value) => updateForm("realtimeModel", value)}
options={realtimeOptions}
noneLabel="无"
/>
)}
</SectionCard>
{form.runtimeMode === "pipeline" && (
<SectionCard
icon={<Database size={15} />}
title="知识库配置"
description="选择助手回答时可检索的业务知识来源"
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
</span>
<KnowledgeRetrievalConfigDialog
disabled={!form.knowledgeBase}
value={form.knowledgeRetrievalConfig}
onChange={(config) =>
updateForm("knowledgeRetrievalConfig", config)
}
/>
</div>
<ResourceSelectField
value={form.knowledgeBase}
onChange={(value) => updateForm("knowledgeBase", value)}
options={kbOptions}
noneLabel="无"
/>
</SectionCard>
)}
<SectionCard
icon={<Wrench size={15} />}
title="工具"
description="配置该提示词助手可以调用的工具"
>
<ToolPicker
tools={tools.filter((tool) => tool.status === "active")}
selectedIds={form.toolIds}
onChange={(toolIds) => updateForm("toolIds", toolIds)}
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={15} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
{form.runtimeMode === "pipeline" ? (
<TurnConfigEditor
enabled={form.enableInterrupt}
config={form.turnConfig}
onEnabledChange={(checked) => updateForm("enableInterrupt", checked)}
onConfigChange={(config) => updateForm("turnConfig", config)}
/>
) : (
<p className="text-sm text-muted-foreground">
Pipeline
</p>
)}
</SectionCard>
</div>
<DebugDrawer
assistantId={assistantId}
hasUnsavedChanges={dirty}
vision={form.visionEnabled}
dynamicVariablesEnabled
dynamicVariableDefinitions={dynamicVariableDefinitions}
/>
</div>
</div>
);
}

View File

@@ -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<string, DynamicVariableDefinition>;
runtimeMode: RuntimeMode;
realtimeModel: string;
model: string;
asr: string;
voice: string;
knowledgeBase: string;
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
enableInterrupt: boolean;
turnConfig: TurnConfig;
visionEnabled: boolean;
visionModelResourceId: string;
toolIds: string[];
};

View File

@@ -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<WorkflowGraph>(() =>
defaultGraph(),
);
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>(
initialWorkflowSettings,
);
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
useState<Record<string, DynamicVariableDefinition>>({});
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(
null,
);
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(
null,
);
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
const [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,
};
}

View File

@@ -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<string, DynamicVariableDefinition>;
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<string, DynamicVariableDefinition>,
) => 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 (
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={onBack} />
<EditableTitle value={workflowName} onChange={setWorkflowName} />
<AssistantIdentity assistantId={assistantId} />
</div>
<div className="flex shrink-0 gap-2">
{saveError && (
<span
role="alert"
title={saveError}
className="line-clamp-1 max-w-[min(42vw,560px)] self-center text-right text-sm leading-5 text-destructive"
>
{saveError}
</span>
)}
<Button
variant="outline"
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
disabled={!assistantId}
onClick={() => {
setWorkflowSettingsOpen(false);
setWorkflowEditingNodeId(null);
setWorkflowEditingEdgeId(null);
setWorkflowDebugOpen(true);
}}
>
<Bug size={16} />
</Button>
<Button
className="gap-2"
disabled={saving || !dirty || !workflowName.trim()}
onClick={onSave}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
</Button>
</div>
</div>
<div className="min-h-0 flex-1">
<WorkflowEditor
value={workflowGraph}
onChange={setWorkflowGraph}
settings={workflowSettings}
onSettingsChange={setWorkflowSettings}
onOpenDynamicVariables={() => setDynamicVariablesOpen(true)}
editingNodeId={workflowEditingNodeId}
onEditingNodeIdChange={setWorkflowEditingNodeId}
editingEdgeId={workflowEditingEdgeId}
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
settingsOpen={workflowSettingsOpen}
onSettingsOpenChange={setWorkflowSettingsOpen}
debugOpen={workflowDebugOpen}
onDebugOpenChange={(open) => {
setWorkflowDebugOpen(open);
if (!open) setActiveNodeId(null);
}}
debugPanel={
<DebugDrawer
overlay
assistantId={assistantId}
onClose={() => {
setWorkflowDebugOpen(false);
setActiveNodeId(null);
}}
hasUnsavedChanges={dirty}
onNodeActive={setActiveNodeId}
dynamicVariablesEnabled
dynamicVariableDefinitions={
dynamicVariableDefinitions
}
/>
}
activeNodeId={activeNodeId}
modelOptions={modelOptions}
toolOptions={toolOptions}
knowledgeOptions={kbOptions}
/>
</div>
<DynamicVariablesDialog
open={dynamicVariablesOpen}
onOpenChange={setDynamicVariablesOpen}
definitions={dynamicVariableDefinitions}
onChange={setWorkflowDynamicVariableDefinitions}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<string | null>(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<WorkflowNodeData>) => {
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<OnConnectEnd>(
(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 (
<NodeSpecsContext.Provider value={specsByType}>
<ActiveNodeContext.Provider value={activeNodeId ?? null}>
<NodeActionContext.Provider value={nodeActions}>
<EdgeActionContext.Provider value={edgeActions}>
<div className="relative h-full w-full min-h-[560px]">
<section className="relative h-full w-full overflow-hidden rounded-2xl border border-hairline bg-canvas-soft shadow-sm">
<div
aria-hidden
className="pointer-events-none absolute -right-24 -top-24 z-0 h-80 w-80 rounded-full opacity-30 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-28 left-1/4 z-0 h-72 w-72 rounded-full opacity-25 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-lavender), transparent 68%)",
}}
/>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectEnd={onConnectEnd}
isValidConnection={isValidConnection}
onPaneClick={() => {
onEditingEdgeIdChange(null);
}}
fitView
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{ type: "condition", animated: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={22}
size={1}
color="var(--hairline-strong)"
/>
<Controls
className="!rounded-xl !border !border-hairline !bg-card !shadow-sm [&_button]:!border-hairline [&_button]:!bg-card [&_button]:!text-foreground"
/>
<Panel position="top-left">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="h-10 w-10 rounded-full shadow-sm"
aria-label="添加节点"
onClick={() => {
setAddSourceId(null);
setAddPosition(null);
setAddOpen(true);
}}
>
<Plus size={17} />
</Button>
</TooltipTrigger>
<TooltipContent side="right"></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full border-hairline-strong bg-card text-foreground shadow-sm hover:bg-surface-strong"
aria-label="工作流设置"
onClick={openSettings}
>
<Settings2 size={17} />
</Button>
</TooltipTrigger>
<TooltipContent side="right"></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full border-hairline-strong bg-card text-foreground shadow-sm hover:bg-surface-strong"
aria-label="动态变量"
onClick={onOpenDynamicVariables}
>
<Braces size={17} />
</Button>
</TooltipTrigger>
<TooltipContent side="right"></TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
</ReactFlow>
</section>
{/* 添加节点弹窗 */}
<Dialog
open={addOpen}
onOpenChange={(open) => {
setAddOpen(open);
if (!open) {
setAddSourceId(null);
setAddPosition(null);
}
}}
>
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
<div
aria-hidden
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
}}
/>
<div className="relative flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Plus size={18} />
</div>
<div>
<div className="caption-label text-muted-soft">
</div>
<DialogTitle className="font-display display-sm mt-1 text-ink">
</DialogTitle>
<DialogDescription className="mt-2 leading-6">
{addSourceId
? "选择节点类型,将在当前节点下方创建并自动连接。"
: "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
{addableSpecs.length === 0 ? (
<p className="rounded-2xl border border-dashed border-hairline-strong bg-card px-4 py-8 text-center text-sm text-muted-soft">
</p>
) : null}
{addableSpecs.map((spec) => {
const Icon = spec.icon;
const canAdd = canAddSpec(spec);
return (
<button
key={spec.type}
type="button"
disabled={!canAdd}
className="group relative flex items-start gap-4 overflow-hidden rounded-2xl border border-hairline bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-hairline-strong hover:shadow-md disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:border-hairline disabled:hover:shadow-sm"
onClick={() => addNode(spec)}
>
<span
aria-hidden
className="absolute left-5 right-5 top-0 h-px"
style={{
background: `linear-gradient(90deg, transparent, var(${accentVar(spec.accent)}), transparent)`,
}}
/>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground transition-transform group-hover:scale-105"
style={{
background: `color-mix(in srgb, var(${accentVar(spec.accent)}) 28%, var(--surface-strong))`,
}}
>
<Icon size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span>{spec.displayName}</span>
{!canAdd && (
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
</span>
)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{spec.description}
</div>
</div>
<Plus
size={15}
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
/>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
{(debugOpen ||
settingsOpen ||
(editingNode && editingSpec) ||
editingEdge) && (
<aside className="absolute inset-y-0 right-0 z-40 flex w-1/2 flex-col overflow-hidden rounded-r-2xl border-l border-hairline bg-card shadow-2xl">
{debugOpen ? (
debugPanel
) : settingsOpen ||
(editingNode && editingSpec) ||
editingEdge ? (
<>
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
<button
type="button"
aria-label={
settingsOpen
? "关闭工作流设置"
: editingEdge
? "关闭边编辑"
: "关闭节点编辑"
}
title="关闭"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
onClick={() => {
if (settingsOpen) setSettingsOpen(false);
else if (editingEdge) onEditingEdgeIdChange(null);
else onEditingNodeIdChange(null);
}}
>
<X size={16} />
</button>
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
{settingsOpen
? "工作流设置"
: editingEdge
? "编辑连接条件"
: `编辑${editingSpec?.displayName ?? "节点"}`}
</h2>
</div>
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto bg-canvas-soft px-4 pb-5 pt-4">
{settingsOpen ? (
<GlobalSettingsPanel
settings={settings}
onSettingsChange={onSettingsChange}
modelOptions={modelOptions}
toolOptions={toolOptions}
knowledgeOptions={knowledgeOptions}
/>
) : editingNode && editingSpec ? (
<NodeSettingsPanel
key={editingNode.id}
panel
spec={editingSpec}
data={editingNode.data as WorkflowNodeData}
toolOptions={toolOptions}
knowledgeOptions={knowledgeOptions}
llmOptions={modelOptions.llm}
asrOptions={modelOptions.asr}
ttsOptions={modelOptions.tts}
workflowSettings={settings}
onChange={(patch) =>
updateNodeData(editingNode.id, patch)
}
/>
) : editingEdge ? (
<EdgeSettingsPanel
key={editingEdge.id}
edge={editingEdge}
sourceType={nodes.find((node) => node.id === editingEdge.source)?.type}
onChange={(patch) =>
updateEdgeData(editingEdge.id, patch)
}
/>
) : null}
</div>
</>
) : null}
</aside>
)}
</div>
</EdgeActionContext.Provider>
</NodeActionContext.Provider>
</ActiveNodeContext.Provider>
</NodeSpecsContext.Provider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<SectionCard
icon={<Zap size={15} />}
title="工具执行"
description="确定性调用工具,并将响应字段写入会话动态变量"
>
<NodeSelect
label="执行工具"
value={(draft.toolId as string) || ""}
options={toolOptions}
onChange={(value) => set("toolId", value || "")}
noneLabel="请选择工具"
/>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
JSON
</div>
<Textarea
rows={5}
value={argumentsJson}
onChange={(event) => {
const value = event.target.value;
setArgumentsJson(value);
commitActionJson(value, assignmentsJson);
}}
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background font-mono text-xs text-foreground placeholder:text-muted-soft"
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
使 {"{{variable}}"}
</span>
</label>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
JSON
</div>
<Textarea
rows={4}
value={assignmentsJson}
onChange={(event) => {
const value = event.target.value;
setAssignmentsJson(value);
commitActionJson(argumentsJson, value);
}}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background font-mono text-xs text-foreground placeholder:text-muted-soft"
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
JSON Path
</span>
</label>
{jsonError && (
<p role="alert" className="text-xs text-destructive">
{jsonError}
</p>
)}
</SectionCard>
);
}

View File

@@ -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<WorkflowNodeData>) => 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 (
<div className="space-y-3">
<SectionCard
icon={<Tag size={15} />}
title="节点信息"
description="节点在画布上显示的名称"
>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground"></div>
<Input
value={draft.name ?? ""}
onChange={(event) => set("name", event.target.value)}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
</SectionCard>
<SectionCard
icon={<Settings2 size={15} />}
title="配置范围"
description="默认复用工作流全局的模型、语音、知识库和工具"
>
<div className="flex items-center justify-between gap-4 rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3">
<div>
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{inheritsGlobal
? "全局提示词会与当前节点提示词合并。"
: "当前节点使用独立的完整助手配置。"}
</p>
</div>
<Switch checked={inheritsGlobal} onCheckedChange={setInheritance} />
</div>
</SectionCard>
<SectionCard
icon={<MessageSquareText size={15} />}
title={inheritsGlobal ? "任务" : "提示词"}
description={
inheritsGlobal
? "描述当前阶段要完成的目标;角色、能力和通用规则继承工作流全局配置"
: "描述当前独立助手的角色、能力和回答要求"
}
>
<Textarea
rows={8}
value={draft.prompt ?? ""}
onChange={(event) => set("prompt", event.target.value)}
placeholder={
inheritsGlobal
? "例如:确认用户身份,并收集需要查询的订单编号"
: "请输入提示词,描述助手的角色、能力和回答要求"
}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</SectionCard>
<SectionCard
icon={<Bot size={15} />}
title="进入行为"
description="进入该节点时的首轮交互方式"
>
<NodeSelect
label="进入节点时"
value={(draft.entryMode as string) || "wait_user"}
options={[
{ value: "wait_user", label: "等待用户说话(默认)" },
{ value: "generate", label: "立即让 LLM 回复" },
{ value: "fixed_speech", label: "播放固定进入语" },
]}
onChange={(value) => set("entryMode", value || "wait_user")}
allowNone={false}
/>
{draft.entryMode === "fixed_speech" && (
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
<span className="text-destructive">*</span>
</div>
<Textarea
rows={3}
value={draft.entrySpeech ?? ""}
onChange={(event) => set("entrySpeech", event.target.value)}
placeholder="例如:您好,请告诉我需要处理的问题。"
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
使 {"{{variable}}"} LLM
</span>
</label>
)}
</SectionCard>
{!inheritsGlobal && (
<>
<SectionCard
icon={<Brain size={15} />}
title="模型与语音"
description="当前 Agent 独立使用的推理、语音识别和语音合成资源"
>
<NodeSelect
label="大语言模型"
value={(draft.llmResourceId as string) || ""}
options={llmOptions}
onChange={(value) => set("llmResourceId", value || "")}
noneLabel="请选择模型"
/>
<NodeSelect
label="语音识别"
value={(draft.asrResourceId as string) || ""}
options={asrOptions}
onChange={(value) => set("asrResourceId", value || "")}
noneLabel="请选择语音识别"
/>
<NodeSelect
label="语音合成"
value={(draft.ttsResourceId as string) || ""}
options={ttsOptions}
onChange={(value) => set("ttsResourceId", value || "")}
noneLabel="请选择语音合成"
/>
</SectionCard>
<SectionCard
icon={<Database size={15} />}
title="知识库配置"
description="选择该阶段回答时可检索的业务知识来源"
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
</span>
<KnowledgeRetrievalConfigDialog
disabled={!draft.knowledgeBaseId}
value={knowledgeConfig}
onChange={(config) =>
setPatch({
knowledgeMode: config.mode,
knowledgeTopN: config.topN,
knowledgeScoreThreshold: config.scoreThreshold,
})
}
/>
</div>
<NodeSelect
label=""
value={(draft.knowledgeBaseId as string) || ""}
options={knowledgeOptions}
onChange={(value) => set("knowledgeBaseId", value || "")}
noneLabel="无"
/>
</SectionCard>
<SectionCard
icon={<Wrench size={15} />}
title="工具"
description="配置该阶段可以调用的工具"
>
<ToolOptionPicker
options={toolOptions}
selectedIds={draft.toolIds ?? []}
onChange={(toolIds) => set("toolIds", toolIds)}
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={15} />}
title="交互策略"
description="配置当前 Agent 独立使用的打断和轮次检测策略"
>
<TurnConfigEditor
enabled={
draft.enableInterrupt ?? workflowSettings.allowInterrupt
}
config={agentTurnConfig}
onEnabledChange={(enableInterrupt) =>
set("enableInterrupt", enableInterrupt)
}
onConfigChange={(turnConfig) => set("turnConfig", turnConfig)}
/>
</SectionCard>
</>
)}
</div>
);
}

View File

@@ -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<ExpressionRule[]>(
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<ExpressionRule>) => {
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 (
<div className="space-y-3">
<SectionCard
icon={<GitBranch size={15} />}
title="路由方式"
description="选择由 Agent 判断、动态变量表达式判断,或作为确定性默认路径"
>
<NodeSelect
label="判断方式"
value={mode}
options={[
...(sourceType === "agent" ? [{ value: "llm", label: "LLM 判断" }] : []),
{ value: "expression", label: "动态变量表达式" },
{ value: "always", label: "默认路径" },
]}
onChange={(value) => {
const nextMode =
(value as WorkflowEdgeData["mode"]) || "always";
setMode(nextMode);
publish({ nextMode });
}}
allowNone={false}
/>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Input
type="number"
value={priority}
onChange={(event) => {
const nextPriority = Number(event.target.value) || 0;
setPriority(nextPriority);
publish({ nextPriority });
}}
className="border-hairline-strong bg-background text-foreground"
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
</span>
</label>
</SectionCard>
<SectionCard
icon={<Braces size={15} />}
title="触发条件"
description="配置画布标签以及这条连接被命中的条件"
>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Input
value={label}
maxLength={64}
placeholder="例如:用户想转人工"
onChange={(event) => {
const nextLabel = event.target.value;
setLabel(nextLabel);
publish({ nextLabel });
}}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
{label.length}/64
</span>
</label>
{mode === "llm" && (
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
<span className="text-destructive">*</span>
</div>
<Textarea
rows={4}
value={condition}
placeholder="例如:用户已经明确表示需要人工客服。"
onChange={(event) => {
const nextCondition = event.target.value;
setCondition(nextCondition);
publish({ nextCondition });
}}
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
{!condition.trim() && (
<span className="mt-1.5 block text-xs text-destructive">
LLM
</span>
)}
</label>
)}
{mode === "expression" && (
<div className="space-y-3">
<NodeSelect
label="规则组合"
value={combinator}
options={[
{ value: "and", label: "全部满足AND" },
{ value: "or", label: "任一满足OR" },
]}
onChange={(value) => {
const nextCombinator = value === "or" ? "or" : "and";
setCombinator(nextCombinator);
publish({ nextCombinator });
}}
allowNone={false}
/>
{rules.map((rule, index) => (
<div
key={index}
className="space-y-2 rounded-xl border border-hairline bg-canvas-soft p-3"
>
<Input
value={rule.variable}
placeholder="动态变量名"
onChange={(event) => setRule(index, { variable: event.target.value })}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<Select
value={rule.operator}
onValueChange={(value) =>
setRule(index, {
operator: value as ExpressionRule["operator"],
})
}
>
<SelectTrigger className="border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"in",
"exists",
].map((operator) => (
<SelectItem key={operator} value={operator}>
{operator}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
disabled={rule.operator === "exists"}
value={rule.value == null ? "" : String(rule.value)}
placeholder="比较值"
onChange={(event) =>
setRule(index, {
value: parseValue(event.target.value),
})
}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<Button
type="button"
size="icon"
variant="outline"
disabled={rules.length === 1}
aria-label={`删除第 ${index + 1} 条规则`}
onClick={() => {
const nextRules = rules.filter(
(_, ruleIndex) => ruleIndex !== index,
);
setRules(nextRules);
publish({ nextRules });
}}
>
<Trash2 size={14} />
</Button>
</div>
{!rule.variable.trim() && (
<span className="text-xs text-destructive">
</span>
)}
</div>
))}
<Button
type="button"
variant="outline"
className="w-full gap-2 border-hairline-strong"
onClick={() => {
const nextRules = [
...rules,
{ variable: "", operator: "eq", value: "" } as ExpressionRule,
];
setRules(nextRules);
publish({ nextRules });
}}
>
<Plus size={14} />
</Button>
</div>
)}
{mode === "always" && (
<p className="rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3 text-sm leading-6 text-muted-foreground">
沿
</p>
)}
</SectionCard>
<SectionCard
icon={<MessageSquareText size={15} />}
title="过渡语"
description="命中连接后、进入下一节点前播放的固定内容"
>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Textarea
rows={3}
value={transitionSpeech}
placeholder="例如:好的,正在为你转接。"
onChange={(event) => {
const nextTransitionSpeech = event.target.value;
setTransitionSpeech(nextTransitionSpeech);
publish({ nextTransitionSpeech });
}}
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
使 TTS
</span>
</label>
</SectionCard>
</div>
);
}

View File

@@ -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 (
<div className="space-y-3">
<SectionCard
icon={<MessageSquareText size={15} />}
title="全局提示词"
description="与所有选择继承全局配置的 Agent 节点提示词合并"
>
<Textarea
rows={4}
value={settings.globalPrompt}
onChange={(event) =>
onSettingsChange({
...settings,
globalPrompt: event.target.value,
})
}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</SectionCard>
<SectionCard
icon={<Brain size={15} />}
title="模型配置"
description="工作流中所有 Agent 共用的大语言模型"
>
<ModelSelect
label="大语言模型"
value={settings.llm}
options={modelOptions.llm}
onChange={(v) => onSettingsChange({ ...settings, llm: v })}
/>
</SectionCard>
<SectionCard
icon={<AudioLines size={15} />}
title="语音配置"
description="Agent 节点未单独选择资源时继承这里的默认值"
>
<ModelSelect
label="语音识别"
value={settings.asr}
options={modelOptions.asr}
onChange={(v) => onSettingsChange({ ...settings, asr: v })}
/>
<ModelSelect
label="语音合成"
value={settings.tts}
options={modelOptions.tts}
onChange={(v) => onSettingsChange({ ...settings, tts: v })}
/>
</SectionCard>
<SectionCard
icon={<Database size={15} />}
title="知识库配置"
description="所有继承全局配置的 Agent 都可以使用该知识库"
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground"></span>
<KnowledgeRetrievalConfigDialog
disabled={!settings.knowledgeBaseId}
value={settings.knowledgeRetrievalConfig}
onChange={(knowledgeRetrievalConfig) =>
onSettingsChange({ ...settings, knowledgeRetrievalConfig })
}
/>
</div>
<NodeSelect
label=""
value={settings.knowledgeBaseId}
options={knowledgeOptions}
onChange={(knowledgeBaseId) =>
onSettingsChange({
...settings,
knowledgeBaseId: knowledgeBaseId || "",
})
}
noneLabel="无"
/>
</SectionCard>
<SectionCard
icon={<Wrench size={15} />}
title="工具"
description="所有继承全局配置的 Agent 都可以调用这些工具"
>
<ToolOptionPicker
options={toolOptions}
selectedIds={settings.toolIds}
onChange={(toolIds) => onSettingsChange({ ...settings, toolIds })}
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={15} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<TurnConfigEditor
enabled={settings.allowInterrupt}
config={settings.turnConfig}
onEnabledChange={(allowInterrupt) =>
onSettingsChange({ ...settings, allowInterrupt })
}
onConfigChange={(turnConfig) =>
onSettingsChange({ ...settings, turnConfig })
}
/>
</SectionCard>
</div>
);
}

View File

@@ -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<WorkflowNodeData>({ ...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<WorkflowNodeData>) =>
commit({ ...draft, ...patch });
const commitActionJson = (
nextArgumentsJson: string,
nextAssignmentsJson: string,
) => {
try {
const args = JSON.parse(nextArgumentsJson) as Record<string, unknown>;
const assignments = JSON.parse(nextAssignmentsJson) as Record<string, string>;
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 (
<WorkflowNodePanelForm
spec={spec}
draft={draft}
set={set}
toolOptions={toolOptions}
argumentsJson={argumentsJson}
assignmentsJson={assignmentsJson}
jsonError={jsonError}
setArgumentsJson={setArgumentsJson}
setAssignmentsJson={setAssignmentsJson}
commitActionJson={commitActionJson}
/>
);
}
return (
<AgentNodePanel
draft={draft}
set={set}
setPatch={setPatch}
workflowSettings={workflowSettings}
toolOptions={toolOptions}
knowledgeOptions={knowledgeOptions}
llmOptions={llmOptions}
asrOptions={asrOptions}
ttsOptions={ttsOptions}
/>
);
}
return (
<>
{!panel && (
<DialogHeader>
<DialogTitle className="font-display text-ink">
{spec.displayName}
</DialogTitle>
</DialogHeader>
)}
<div className="flex flex-col gap-5 py-2">
{spec.fields.map((field) => {
const raw = draft[field.key];
if (field.type === "switch") {
return (
<label
key={field.key}
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium text-foreground">
{field.label}
</span>
<Switch
checked={Boolean(raw)}
onCheckedChange={(checked) => set(field.key, checked)}
/>
</label>
);
}
return (
<div key={field.key} className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground">
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</label>
{field.type === "textarea" ? (
<Textarea
rows={4}
value={(raw as string) ?? ""}
onChange={(e) => set(field.key, e.target.value)}
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
) : (
<Input
value={(raw as string) ?? ""}
onChange={(e) => set(field.key, e.target.value)}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
)}
</div>
);
})}
{spec.type === "agent" && (
<>
<NodeSelect
label="进入节点时"
value={(draft.entryMode as string) || "wait_user"}
options={[
{ value: "wait_user", label: "等待用户说话(默认)" },
{ value: "generate", label: "立即让 LLM 回复" },
{ value: "fixed_speech", label: "播放固定进入语" },
]}
onChange={(value) => set("entryMode", value || "wait_user")}
allowNone={false}
/>
{draft.entryMode === "fixed_speech" && (
<div className="block">
<div className="mb-2 text-sm font-medium text-foreground">
<span className="text-destructive">*</span>
</div>
<Textarea
rows={3}
value={draft.entrySpeech ?? ""}
onChange={(event) => set("entrySpeech", event.target.value)}
placeholder="例如:您好,请告诉我需要处理的问题。"
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<span className="mt-2 block text-xs text-muted-soft">
使 {"{{variable}}"} LLM
</span>
</div>
)}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground"></label>
<ToolOptionPicker
options={toolOptions}
selectedIds={draft.toolIds ?? []}
onChange={(toolIds) => set("toolIds", toolIds)}
/>
</div>
<NodeSelect
label="知识库"
value={(draft.knowledgeBaseId as string) || ""}
options={knowledgeOptions}
onChange={(value) => set("knowledgeBaseId", value || "")}
/>
<NodeSelect
label="知识库模式"
value={(draft.knowledgeMode as string) || "disabled"}
options={[
{ value: "disabled", label: "关闭" },
{ value: "automatic", label: "每轮自动检索" },
{ value: "on_demand", label: "Agent 按需调用" },
]}
onChange={(value) => set("knowledgeMode", value || "disabled")}
allowNone={false}
/>
<NodeSelect
label="ASR 资源"
value={(draft.asrResourceId as string) || ""}
options={asrOptions}
onChange={(value) => set("asrResourceId", value || "")}
noneLabel="继承 Workflow 默认值"
/>
<NodeSelect
label="TTS 资源"
value={(draft.ttsResourceId as string) || ""}
options={ttsOptions}
onChange={(value) => set("ttsResourceId", value || "")}
noneLabel="继承 Workflow 默认值"
/>
</>
)}
{spec.type === "action" && (
<>
<NodeSelect
label="确定性执行工具"
value={(draft.toolId as string) || ""}
options={toolOptions}
onChange={(value) => set("toolId", value || "")}
noneLabel="请选择工具"
/>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground"> JSON</label>
<Textarea
rows={5}
value={argumentsJson}
onChange={(event) => {
const value = event.target.value;
setArgumentsJson(value);
commitActionJson(value, assignmentsJson);
}}
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<span className="text-xs text-muted-soft">使 {"{{variable}}"} </span>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground"> JSON</label>
<Textarea
rows={4}
value={assignmentsJson}
onChange={(event) => {
const value = event.target.value;
setAssignmentsJson(value);
commitActionJson(argumentsJson, value);
}}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<span className="text-xs text-muted-soft"> JSON Path</span>
</div>
{jsonError && <span className="text-xs text-destructive">{jsonError}</span>}
</>
)}
{spec.type === "handoff" && (
<NodeSelect
label="转交类型"
value={(draft.targetType as string) || "human"}
options={[
{ value: "ai", label: "其他 AI" },
{ value: "human", label: "人工" },
{ value: "queue", label: "队列" },
{ value: "phone", label: "电话" },
]}
onChange={(value) => set("targetType", value || "human")}
allowNone={false}
/>
)}
{spec.type === "end" && (
<NodeSelect
label="结束范围"
value={(draft.scope as string) || "session"}
options={[
{ value: "flow", label: "仅结束 AI 流程" },
{ value: "session", label: "结束音视频会话" },
]}
onChange={(value) => set("scope", value || "session")}
allowNone={false}
/>
)}
</div>
</>
);
}
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 (
<div className="space-y-3">
<SectionCard
icon={<Tag size={15} />}
title="节点信息"
description="节点在画布上显示的名称"
>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Input
value={draft.name ?? ""}
onChange={(event) => set("name", event.target.value)}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
</SectionCard>
{spec.type === "start" && (
<SectionCard
icon={<Play size={15} />}
title="开场白"
description="会话建立后首先向用户播放的固定内容"
>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Textarea
rows={5}
value={draft.greeting ?? ""}
onChange={(event) => set("greeting", event.target.value)}
placeholder="例如:你好,我是 AI 视频助手,有什么可以帮你?"
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</label>
</SectionCard>
)}
{spec.type === "action" && (
<ActionNodePanel
draft={draft}
set={set}
toolOptions={toolOptions}
argumentsJson={argumentsJson}
assignmentsJson={assignmentsJson}
jsonError={jsonError}
setArgumentsJson={setArgumentsJson}
setAssignmentsJson={setAssignmentsJson}
commitActionJson={commitActionJson}
/>
)}
{spec.type === "handoff" && (
<SectionCard
icon={<PhoneForwarded size={15} />}
title="转交配置"
description="发送转交事件并继续执行工作流"
>
<NodeSelect
label="转交类型"
value={(draft.targetType as string) || "human"}
options={[
{ value: "ai", label: "其他 AI" },
{ value: "human", label: "人工" },
{ value: "queue", label: "队列" },
{ value: "phone", label: "电话" },
]}
onChange={(value) => set("targetType", value || "human")}
allowNone={false}
/>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Input
value={draft.target ?? ""}
onChange={(event) => set("target", event.target.value)}
placeholder="人工、队列、AI 或电话号码"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Textarea
rows={4}
value={draft.message ?? ""}
onChange={(event) => set("message", event.target.value)}
placeholder="例如:好的,正在为你转接人工客服。"
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</label>
</SectionCard>
)}
{spec.type === "end" && (
<SectionCard
icon={<Flag size={15} />}
title="结束配置"
description="结束工作流,并可在结束前播放一段固定回复"
>
<NodeSelect
label="结束范围"
value={(draft.scope as string) || "session"}
options={[
{ value: "flow", label: "仅结束 AI 流程" },
{ value: "session", label: "结束音视频会话" },
]}
onChange={(value) => set("scope", value || "session")}
allowNone={false}
/>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Textarea
rows={4}
value={draft.message ?? ""}
onChange={(event) => set("message", event.target.value)}
placeholder="例如:感谢你的来电,再见。"
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</label>
</SectionCard>
)}
</div>
);
}
/** Agent 右栏:与 AssistantPage 共用紧凑 SectionCard。 */

View File

@@ -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 (
<div className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Select
value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? undefined : v)}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue placeholder="选择模型资源" />
</SelectTrigger>
<SelectContent className="border-hairline bg-popover text-popover-foreground">
<SelectItem value={NONE}></SelectItem>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function ToolOptionPicker({
options,
selectedIds,
onChange,
}: {
options: ModelOption[];
selectedIds: string[];
onChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [draftIds, setDraftIds] = useState<string[]>(selectedIds);
const selected = selectedIds
.map((id) => options.find((option) => option.value === id))
.filter((option): option is ModelOption => Boolean(option));
return (
<>
<div className="flex min-h-9 flex-wrap items-center gap-2">
{selected.map((option) => (
<div
key={option.value}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
>
<Wrench size={14} />
<span className="max-w-48 truncate">{option.label}</span>
<button
type="button"
onClick={() =>
onChange(selectedIds.filter((id) => id !== option.value))
}
className="text-muted-soft transition-colors hover:text-foreground"
aria-label={`移除工具 ${option.label}`}
>
<X size={13} />
</button>
</div>
))}
<Button
type="button"
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => {
setDraftIds(selectedIds);
setOpen(true);
}}
aria-label="添加工具"
title="添加工具"
>
<Plus size={15} />
</Button>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{options.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="max-h-80 divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
{options.map((option) => {
const checked = draftIds.includes(option.value);
return (
<label
key={option.value}
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-strong/40"
>
<input
type="checkbox"
checked={checked}
onChange={() =>
setDraftIds((current) =>
checked
? current.filter((id) => id !== option.value)
: [...current, option.value],
)
}
className="size-4 accent-primary"
/>
<span className="truncate font-medium text-foreground">
{option.label}
</span>
</label>
);
})}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button
onClick={() => {
onChange(draftIds);
setOpen(false);
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
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 (
<div className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Select
value={value || (allowNone ? NONE : options[0]?.value)}
onValueChange={(next) => onChange(next === NONE ? undefined : next)}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue placeholder={label ? `请选择${label}` : "请选择"} />
</SelectTrigger>
<SelectContent className="border-hairline bg-popover text-popover-foreground">
{allowNone && <SelectItem value={NONE}>{noneLabel}</SelectItem>}
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -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;
};