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:
@@ -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:
|
||||
|
||||
199
backend/services/pipecat/pipeline_events.py
Normal file
199
backend/services/pipecat/pipeline_events.py
Normal 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())
|
||||
452
backend/services/pipecat/processors.py
Normal file
452
backend/services/pipecat/processors.py
Normal 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
|
||||
|
||||
|
||||
|
||||
131
backend/services/pipecat/workflow_services.py
Normal file
131
backend/services/pipecat/workflow_services.py
Normal 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,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
1253
frontend/src/components/assistant-editor/debug-preview.tsx
Normal file
1253
frontend/src/components/assistant-editor/debug-preview.tsx
Normal file
File diff suppressed because it is too large
Load Diff
362
frontend/src/components/assistant-editor/dynamic-variables.tsx
Normal file
362
frontend/src/components/assistant-editor/dynamic-variables.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
620
frontend/src/components/assistant-editor/editor-controls.tsx
Normal file
620
frontend/src/components/assistant-editor/editor-controls.tsx
Normal 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">
|
||||
仅保留相关度达到该值的片段,范围 0–1。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
302
frontend/src/components/assistant-editor/prompt-editor.tsx
Normal file
302
frontend/src/components/assistant-editor/prompt-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
26
frontend/src/components/assistant-editor/types.ts
Normal file
26
frontend/src/components/assistant-editor/types.ts
Normal 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[];
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
186
frontend/src/components/assistant-editor/workflow-page.tsx
Normal file
186
frontend/src/components/assistant-editor/workflow-page.tsx
Normal 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
772
frontend/src/components/workflow/WorkflowCanvas.tsx
Normal file
772
frontend/src/components/workflow/WorkflowCanvas.tsx
Normal 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
91
frontend/src/components/workflow/panels/ActionNodePanel.tsx
Normal file
91
frontend/src/components/workflow/panels/ActionNodePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
frontend/src/components/workflow/panels/AgentNodePanel.tsx
Normal file
283
frontend/src/components/workflow/panels/AgentNodePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
337
frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx
Normal file
337
frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
150
frontend/src/components/workflow/panels/GlobalSettingsPanel.tsx
Normal file
150
frontend/src/components/workflow/panels/GlobalSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
458
frontend/src/components/workflow/panels/NodeSettingsPanel.tsx
Normal file
458
frontend/src/components/workflow/panels/NodeSettingsPanel.tsx
Normal 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。 */
|
||||
|
||||
214
frontend/src/components/workflow/panels/controls.tsx
Normal file
214
frontend/src/components/workflow/panels/controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
41
frontend/src/components/workflow/types.ts
Normal file
41
frontend/src/components/workflow/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user