Enhance conversation history and runtime variable management
- Update ConversationRecorder to include source and nodeId metadata in transcripts for better context tracking. - Introduce optional variable handling in DynamicVariableStore, allowing for unset variables to be rendered as empty without raising errors. - Refactor WorkflowBrain to apply turn configurations and manage interaction policies dynamically, improving agent responsiveness. - Implement tests to ensure proper handling of updated session variables and workflow metadata in various scenarios.
This commit is contained in:
@@ -60,6 +60,9 @@ class BrainRuntime:
|
|||||||
) = None
|
) = None
|
||||||
set_knowledge_scope: Callable[[dict[str, Any]], None] | None = None
|
set_knowledge_scope: Callable[[dict[str, Any]], None] | None = None
|
||||||
set_input_enabled: Callable[[bool], None] | None = None
|
set_input_enabled: Callable[[bool], None] | None = None
|
||||||
|
apply_turn_config: (
|
||||||
|
Callable[[bool, dict[str, Any]], Awaitable[None]] | None
|
||||||
|
) = None
|
||||||
flow_global_functions: list[Any] = field(default_factory=list)
|
flow_global_functions: list[Any] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -49,7 +50,13 @@ class WorkflowBrain(BaseBrain):
|
|||||||
|
|
||||||
def __init__(self, cfg_or_graph: AssistantConfig | dict[str, Any]):
|
def __init__(self, cfg_or_graph: AssistantConfig | dict[str, Any]):
|
||||||
cfg = cfg_or_graph if isinstance(cfg_or_graph, AssistantConfig) else None
|
cfg = cfg_or_graph if isinstance(cfg_or_graph, AssistantConfig) else None
|
||||||
graph = cfg.graph if cfg is not None else cfg_or_graph
|
graph = deepcopy(cfg.graph if cfg is not None else cfg_or_graph)
|
||||||
|
if cfg is not None:
|
||||||
|
# Graph v3 owns Workflow defaults. Keep older saved graphs compatible
|
||||||
|
# by filling the new interaction settings from the assistant row.
|
||||||
|
settings = graph.setdefault("settings", {})
|
||||||
|
settings.setdefault("enableInterrupt", cfg.enableInterrupt)
|
||||||
|
settings.setdefault("turnConfig", deepcopy(cfg.turnConfig))
|
||||||
self._engine = WorkflowEngine(graph or {})
|
self._engine = WorkflowEngine(graph or {})
|
||||||
if not self._engine.has_graph() or not self._engine.start_id:
|
if not self._engine.has_graph() or not self._engine.start_id:
|
||||||
raise ValueError("WorkflowBrain 缺少有效的 Start 节点")
|
raise ValueError("WorkflowBrain 缺少有效的 Start 节点")
|
||||||
@@ -95,6 +102,10 @@ class WorkflowBrain(BaseBrain):
|
|||||||
|
|
||||||
async def on_connected(self) -> None:
|
async def on_connected(self) -> None:
|
||||||
await self._emit_node_active(self._engine.start_id)
|
await self._emit_node_active(self._engine.start_id)
|
||||||
|
await self._emit_variables(
|
||||||
|
reason="initialized",
|
||||||
|
node_id=self._engine.start_id,
|
||||||
|
)
|
||||||
edge = self._engine.deterministic_edge(
|
edge = self._engine.deterministic_edge(
|
||||||
self._engine.start_id,
|
self._engine.start_id,
|
||||||
self._store,
|
self._store,
|
||||||
@@ -228,6 +239,11 @@ class WorkflowBrain(BaseBrain):
|
|||||||
if self._runtime and self._runtime.set_input_enabled:
|
if self._runtime and self._runtime.set_input_enabled:
|
||||||
self._runtime.set_input_enabled(True)
|
self._runtime.set_input_enabled(True)
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
|
if runtime.apply_turn_config:
|
||||||
|
await runtime.apply_turn_config(
|
||||||
|
stage.enable_interrupt,
|
||||||
|
stage.turn_config,
|
||||||
|
)
|
||||||
if runtime.switch_services:
|
if runtime.switch_services:
|
||||||
await runtime.switch_services(
|
await runtime.switch_services(
|
||||||
stage.llm_resource_id or None,
|
stage.llm_resource_id or None,
|
||||||
@@ -248,6 +264,11 @@ class WorkflowBrain(BaseBrain):
|
|||||||
data = self._engine.data(node_id)
|
data = self._engine.data(node_id)
|
||||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||||
|
fixed_reply_messages = (
|
||||||
|
[{"role": "assistant", "content": entry_speech}]
|
||||||
|
if entry_mode == "fixed_speech" and entry_speech
|
||||||
|
else []
|
||||||
|
)
|
||||||
strategy = (
|
strategy = (
|
||||||
ContextStrategy.RESET
|
ContextStrategy.RESET
|
||||||
if data.get("contextPolicy") == "fresh"
|
if data.get("contextPolicy") == "fresh"
|
||||||
@@ -265,11 +286,10 @@ class WorkflowBrain(BaseBrain):
|
|||||||
config: NodeConfig = {
|
config: NodeConfig = {
|
||||||
"name": node_id,
|
"name": node_id,
|
||||||
"role_message": self._agent_role_message(node_id),
|
"role_message": self._agent_role_message(node_id),
|
||||||
"task_messages": (
|
# Flows writes task_messages into the Pipecat LLM context. The
|
||||||
[{"role": "assistant", "content": entry_speech}]
|
# pre-action below is responsible only for display, persistence,
|
||||||
if entry_mode == "fixed_speech"
|
# dynamic conversation history, and TTS playback.
|
||||||
else []
|
"task_messages": fixed_reply_messages,
|
||||||
),
|
|
||||||
"functions": functions,
|
"functions": functions,
|
||||||
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||||
"respond_immediately": entry_mode == "generate",
|
"respond_immediately": entry_mode == "generate",
|
||||||
@@ -279,6 +299,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
{
|
{
|
||||||
"type": "workflow_fixed_speech",
|
"type": "workflow_fixed_speech",
|
||||||
"text": entry_speech,
|
"text": entry_speech,
|
||||||
|
"node_id": node_id,
|
||||||
"handler": self._play_fixed_speech,
|
"handler": self._play_fixed_speech,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -286,9 +307,19 @@ class WorkflowBrain(BaseBrain):
|
|||||||
|
|
||||||
async def _play_fixed_speech(self, action: dict, _flow_manager: FlowManager) -> None:
|
async def _play_fixed_speech(self, action: dict, _flow_manager: FlowManager) -> None:
|
||||||
"""Play and persist Agent entry speech without creating an LLM turn."""
|
"""Play and persist Agent entry speech without creating an LLM turn."""
|
||||||
await self._queue_visible_speech(str(action.get("text") or ""))
|
await self._queue_visible_speech(
|
||||||
|
str(action.get("text") or ""),
|
||||||
|
source="workflow-fixed-reply",
|
||||||
|
node_id=str(action.get("node_id") or "") or None,
|
||||||
|
)
|
||||||
|
|
||||||
async def _queue_visible_speech(self, text: str) -> None:
|
async def _queue_visible_speech(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
source: str = "workflow-speech",
|
||||||
|
node_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""Show and persist fixed workflow speech before sending it to TTS."""
|
"""Show and persist fixed workflow speech before sending it to TTS."""
|
||||||
content = text.strip()
|
content = text.strip()
|
||||||
if not content:
|
if not content:
|
||||||
@@ -302,6 +333,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": content,
|
"content": content,
|
||||||
"timestamp": time_now_iso8601(),
|
"timestamp": time_now_iso8601(),
|
||||||
|
"source": source,
|
||||||
|
**({"nodeId": node_id} if node_id else {}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -327,7 +360,13 @@ class WorkflowBrain(BaseBrain):
|
|||||||
result = await self._tools.execute(tool, dict(args or {}))
|
result = await self._tools.execute(tool, dict(args or {}))
|
||||||
except ToolExecutionError as exc:
|
except ToolExecutionError as exc:
|
||||||
return {"status": "error", "message": str(exc)}
|
return {"status": "error", "message": str(exc)}
|
||||||
if result.get("updated_variables"):
|
updated_variables = list(result.get("updated_variables") or [])
|
||||||
|
if updated_variables:
|
||||||
|
await self._emit_variables(
|
||||||
|
reason="tool",
|
||||||
|
node_id=node_id,
|
||||||
|
changed=updated_variables,
|
||||||
|
)
|
||||||
await self._refresh_agent_prompt(node_id)
|
await self._refresh_agent_prompt(node_id)
|
||||||
edge = self._engine.deterministic_edge(
|
edge = self._engine.deterministic_edge(
|
||||||
node_id,
|
node_id,
|
||||||
@@ -436,11 +475,18 @@ class WorkflowBrain(BaseBrain):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
arguments = self._store.render_data(data.get("arguments") or {})
|
arguments = self._store.render_data(data.get("arguments") or {})
|
||||||
await self._tools.execute(
|
result = await self._tools.execute(
|
||||||
tool,
|
tool,
|
||||||
arguments,
|
arguments,
|
||||||
result_assignments=data.get("resultAssignments") or {},
|
result_assignments=data.get("resultAssignments") or {},
|
||||||
)
|
)
|
||||||
|
updated_variables = list(result.get("updated_variables") or [])
|
||||||
|
if updated_variables:
|
||||||
|
await self._emit_variables(
|
||||||
|
reason="action",
|
||||||
|
node_id=node_id,
|
||||||
|
changed=updated_variables,
|
||||||
|
)
|
||||||
self._store.values["system__last_action_status"] = "ok"
|
self._store.values["system__last_action_status"] = "ok"
|
||||||
self._store.values["system__last_action_error"] = ""
|
self._store.values["system__last_action_error"] = ""
|
||||||
except (ToolExecutionError, ValueError) as exc:
|
except (ToolExecutionError, ValueError) as exc:
|
||||||
@@ -501,6 +547,40 @@ class WorkflowBrain(BaseBrain):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _public_variables(self) -> dict[str, str | int | float | bool]:
|
||||||
|
"""Return the browser-safe part of this session's variable state."""
|
||||||
|
return {
|
||||||
|
name: value
|
||||||
|
for name, value in self._store.values.items()
|
||||||
|
if not name.startswith(("system__", "secret__"))
|
||||||
|
and isinstance(value, (str, int, float, bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _emit_variables(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
node_id: str | None,
|
||||||
|
changed: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Publish a safe snapshot so Workflow debug mirrors runtime state."""
|
||||||
|
message: dict[str, Any] = {
|
||||||
|
"type": "workflow-variables",
|
||||||
|
"reason": reason,
|
||||||
|
"variables": self._public_variables(),
|
||||||
|
}
|
||||||
|
if node_id:
|
||||||
|
message["nodeId"] = node_id
|
||||||
|
if changed:
|
||||||
|
message["changed"] = [
|
||||||
|
name
|
||||||
|
for name in changed
|
||||||
|
if not name.startswith(("system__", "secret__"))
|
||||||
|
]
|
||||||
|
await self._require_runtime().queue_frame(
|
||||||
|
OutputTransportMessageUrgentFrame(message=message)
|
||||||
|
)
|
||||||
|
|
||||||
def _require_runtime(self) -> BrainRuntime:
|
def _require_runtime(self) -> BrainRuntime:
|
||||||
if self._runtime is None:
|
if self._runtime is None:
|
||||||
raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime")
|
raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime")
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ class ConversationRecorder:
|
|||||||
role = str(message.get("role") or "")
|
role = str(message.get("role") or "")
|
||||||
content = str(message.get("content") or "").strip()
|
content = str(message.get("content") or "").strip()
|
||||||
event_key = f"transcript:{role}:{timestamp}:{content}"
|
event_key = f"transcript:{role}:{timestamp}:{content}"
|
||||||
|
if message.get("source"):
|
||||||
|
extra["source"] = str(message["source"])
|
||||||
|
if message.get("nodeId"):
|
||||||
|
extra["node_id"] = str(message["nodeId"])
|
||||||
elif event_type == "assistant-text-end":
|
elif event_type == "assistant-text-end":
|
||||||
role = "assistant"
|
role = "assistant"
|
||||||
content = str(message.get("content") or "").strip()
|
content = str(message.get("content") or "").strip()
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") ->
|
|||||||
settings.setdefault("knowledgeMode", "automatic")
|
settings.setdefault("knowledgeMode", "automatic")
|
||||||
settings.setdefault("knowledgeTopN", 5)
|
settings.setdefault("knowledgeTopN", 5)
|
||||||
settings.setdefault("knowledgeScoreThreshold", 0.0)
|
settings.setdefault("knowledgeScoreThreshold", 0.0)
|
||||||
|
settings.setdefault("enableInterrupt", True)
|
||||||
|
settings.setdefault("turnConfig", {})
|
||||||
|
|
||||||
|
|
||||||
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -50,6 +51,7 @@ from pipecat.frames.frames import (
|
|||||||
TTSSpeakFrame,
|
TTSSpeakFrame,
|
||||||
UserImageRawFrame,
|
UserImageRawFrame,
|
||||||
UserImageRequestFrame,
|
UserImageRequestFrame,
|
||||||
|
VADParamsUpdateFrame,
|
||||||
)
|
)
|
||||||
from pipecat.pipeline.pipeline import Pipeline
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
from pipecat.pipeline.llm_switcher import LLMSwitcher
|
from pipecat.pipeline.llm_switcher import LLMSwitcher
|
||||||
@@ -58,7 +60,6 @@ from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
|||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
from pipecat.processors.aggregators.llm_response_universal import (
|
from pipecat.processors.aggregators.llm_response_universal import (
|
||||||
LLMAssistantAggregator,
|
LLMAssistantAggregator,
|
||||||
LLMUserAggregator,
|
|
||||||
LLMUserAggregatorParams,
|
LLMUserAggregatorParams,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
@@ -72,8 +73,10 @@ from pipecat.turns.user_mute.function_call_user_mute_strategy import (
|
|||||||
FunctionCallUserMuteStrategy,
|
FunctionCallUserMuteStrategy,
|
||||||
)
|
)
|
||||||
from services.pipecat.turn_config import (
|
from services.pipecat.turn_config import (
|
||||||
|
ConfigurableLLMUserAggregator,
|
||||||
create_user_turn_strategies,
|
create_user_turn_strategies,
|
||||||
create_vad_analyzer,
|
create_vad_analyzer,
|
||||||
|
create_vad_params,
|
||||||
)
|
)
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
from pipecat.workers.runner import WorkerRunner
|
from pipecat.workers.runner import WorkerRunner
|
||||||
@@ -794,7 +797,7 @@ async def run_pipeline(
|
|||||||
current_llm_service = llm
|
current_llm_service = llm
|
||||||
if cfg.type == "workflow":
|
if cfg.type == "workflow":
|
||||||
llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
|
llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
|
||||||
user_aggregator = LLMUserAggregator(
|
user_aggregator = ConfigurableLLMUserAggregator(
|
||||||
context,
|
context,
|
||||||
params=LLMUserAggregatorParams(
|
params=LLMUserAggregatorParams(
|
||||||
vad_analyzer=create_vad_analyzer(cfg.turnConfig),
|
vad_analyzer=create_vad_analyzer(cfg.turnConfig),
|
||||||
@@ -1063,6 +1066,31 @@ async def run_pipeline(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
current_enable_interrupt = cfg.enableInterrupt
|
||||||
|
current_turn_config = dict(cfg.turnConfig)
|
||||||
|
|
||||||
|
async def apply_workflow_turn_config(
|
||||||
|
enable_interrupt: bool,
|
||||||
|
turn_config: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Apply one Agent's interaction policy before its next user turn."""
|
||||||
|
nonlocal current_enable_interrupt, current_turn_config
|
||||||
|
normalized = dict(turn_config or {})
|
||||||
|
if (
|
||||||
|
current_enable_interrupt == enable_interrupt
|
||||||
|
and current_turn_config == normalized
|
||||||
|
):
|
||||||
|
return
|
||||||
|
await user_aggregator.apply_turn_strategies(
|
||||||
|
normalized,
|
||||||
|
enable_interruptions=enable_interrupt,
|
||||||
|
)
|
||||||
|
await worker.queue_frame(
|
||||||
|
VADParamsUpdateFrame(params=create_vad_params(normalized))
|
||||||
|
)
|
||||||
|
current_enable_interrupt = enable_interrupt
|
||||||
|
current_turn_config = normalized
|
||||||
|
|
||||||
async def queue_transcript(role: str, content: str, timestamp: str) -> None:
|
async def queue_transcript(role: str, content: str, timestamp: str) -> None:
|
||||||
if content:
|
if content:
|
||||||
await worker.queue_frame(
|
await worker.queue_frame(
|
||||||
@@ -1107,6 +1135,7 @@ async def run_pipeline(
|
|||||||
switch_services=switch_workflow_services,
|
switch_services=switch_workflow_services,
|
||||||
set_knowledge_scope=knowledge_retrieval.set_scope,
|
set_knowledge_scope=knowledge_retrieval.set_scope,
|
||||||
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled),
|
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled),
|
||||||
|
apply_turn_config=apply_workflow_turn_config,
|
||||||
flow_global_functions=flow_global_functions,
|
flow_global_functions=flow_global_functions,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from typing import Any
|
|||||||
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
|
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
|
||||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
from pipecat.audio.vad.vad_analyzer import VADParams
|
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||||
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
|
from pipecat.processors.aggregators.llm_response_universal import (
|
||||||
|
LLMUserAggregator,
|
||||||
|
LLMUserAggregatorParams,
|
||||||
|
)
|
||||||
from pipecat.turns.user_start import (
|
from pipecat.turns.user_start import (
|
||||||
TranscriptionUserTurnStartStrategy,
|
TranscriptionUserTurnStartStrategy,
|
||||||
VADUserTurnStartStrategy,
|
VADUserTurnStartStrategy,
|
||||||
@@ -39,18 +44,21 @@ def _value(config: dict[str, Any], snake: str, camel: str, default: Any) -> Any:
|
|||||||
return config.get(snake, config.get(camel, default))
|
return config.get(snake, config.get(camel, default))
|
||||||
|
|
||||||
|
|
||||||
def create_vad_analyzer(turn_config: dict[str, Any]) -> SileroVADAnalyzer:
|
def create_vad_params(turn_config: dict[str, Any]) -> VADParams:
|
||||||
|
"""Translate product settings into Pipecat's runtime VAD parameters."""
|
||||||
vad = _section(turn_config, "vad", "vad")
|
vad = _section(turn_config, "vad", "vad")
|
||||||
return SileroVADAnalyzer(
|
return VADParams(
|
||||||
params=VADParams(
|
confidence=float(vad.get("confidence", DEFAULT_VAD["confidence"])),
|
||||||
confidence=float(vad.get("confidence", DEFAULT_VAD["confidence"])),
|
start_secs=float(_value(vad, "start_secs", "startSecs", 0.2)),
|
||||||
start_secs=float(_value(vad, "start_secs", "startSecs", 0.2)),
|
stop_secs=float(_value(vad, "stop_secs", "stopSecs", 0.2)),
|
||||||
stop_secs=float(_value(vad, "stop_secs", "stopSecs", 0.2)),
|
min_volume=float(_value(vad, "min_volume", "minVolume", 0.6)),
|
||||||
min_volume=float(_value(vad, "min_volume", "minVolume", 0.6)),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_vad_analyzer(turn_config: dict[str, Any]) -> SileroVADAnalyzer:
|
||||||
|
return SileroVADAnalyzer(params=create_vad_params(turn_config))
|
||||||
|
|
||||||
|
|
||||||
def create_user_turn_strategies(
|
def create_user_turn_strategies(
|
||||||
turn_config: dict[str, Any], *, enable_interruptions: bool
|
turn_config: dict[str, Any], *, enable_interruptions: bool
|
||||||
) -> UserTurnStrategies:
|
) -> UserTurnStrategies:
|
||||||
@@ -87,3 +95,34 @@ def create_user_turn_strategies(
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
return UserTurnStrategies(start=start, stop=stop)
|
return UserTurnStrategies(start=start, stop=stop)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurableLLMUserAggregator(LLMUserAggregator):
|
||||||
|
"""LLM user aggregator with one stable project-level runtime update API.
|
||||||
|
|
||||||
|
Pipecat 1.5 exposes ``UserTurnController.update_strategies`` but does not
|
||||||
|
surface it on ``LLMUserAggregator``. Keeping that version-specific bridge
|
||||||
|
here prevents Workflow orchestration from depending on Pipecat internals.
|
||||||
|
VAD threshold updates still use Pipecat's public ``VADParamsUpdateFrame``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
context: LLMContext,
|
||||||
|
*,
|
||||||
|
params: LLMUserAggregatorParams | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(context, params=params, **kwargs)
|
||||||
|
|
||||||
|
async def apply_turn_strategies(
|
||||||
|
self,
|
||||||
|
turn_config: dict[str, Any],
|
||||||
|
*,
|
||||||
|
enable_interruptions: bool,
|
||||||
|
) -> None:
|
||||||
|
strategies = create_user_turn_strategies(
|
||||||
|
turn_config,
|
||||||
|
enable_interruptions=enable_interruptions,
|
||||||
|
)
|
||||||
|
await self._user_turn_controller.update_strategies(strategies)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Conversation-scoped dynamic variables for prompt pipeline assistants.
|
"""Conversation-scoped dynamic variables shared by Prompt and Workflow.
|
||||||
|
|
||||||
The renderer is deliberately small: it only understands ``{{ name }}``
|
The renderer is deliberately small: it only understands ``{{ name }}``
|
||||||
placeholders and never evaluates expressions. A value is substituted once,
|
placeholders and never evaluates expressions. A value is substituted once,
|
||||||
@@ -21,6 +21,7 @@ from models import AssistantConfig
|
|||||||
Primitive = str | int | float | bool
|
Primitive = str | int | float | bool
|
||||||
VARIABLE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
VARIABLE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
||||||
PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}")
|
PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}")
|
||||||
|
FULL_PLACEHOLDER = re.compile(r"^{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}$")
|
||||||
MAX_VARIABLES = 50
|
MAX_VARIABLES = 50
|
||||||
MAX_VALUE_LENGTH = 2048
|
MAX_VALUE_LENGTH = 2048
|
||||||
MAX_HISTORY_ENTRIES = 50
|
MAX_HISTORY_ENTRIES = 50
|
||||||
@@ -91,37 +92,71 @@ class DynamicVariableStore:
|
|||||||
self,
|
self,
|
||||||
values: dict[str, Primitive],
|
values: dict[str, Primitive],
|
||||||
secrets: dict[str, str] | None = None,
|
secrets: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
optional_names: set[str] | None = None,
|
||||||
|
variable_types: dict[str, str] | None = None,
|
||||||
):
|
):
|
||||||
self.values = dict(values)
|
self.values = dict(values)
|
||||||
self.secrets = dict(secrets or {})
|
self.secrets = dict(secrets or {})
|
||||||
|
self.optional_names = set(optional_names or set())
|
||||||
|
self.variable_types = dict(variable_types or {})
|
||||||
self.history: list[dict[str, str]] = []
|
self.history: list[dict[str, str]] = []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, cfg: AssistantConfig) -> "DynamicVariableStore":
|
def from_config(cls, cfg: AssistantConfig) -> "DynamicVariableStore":
|
||||||
return cls(cfg.dynamic_variables, cfg.secret_dynamic_variables)
|
definitions = cfg.dynamic_variable_definitions or {}
|
||||||
|
optional_names = {
|
||||||
|
name
|
||||||
|
for name, definition in definitions.items()
|
||||||
|
if not definition.get("required", False)
|
||||||
|
and definition.get("default") is None
|
||||||
|
}
|
||||||
|
variable_types = {
|
||||||
|
name: str(definition.get("type") or "string")
|
||||||
|
for name, definition in definitions.items()
|
||||||
|
}
|
||||||
|
return cls(
|
||||||
|
cfg.dynamic_variables,
|
||||||
|
cfg.secret_dynamic_variables,
|
||||||
|
optional_names=optional_names,
|
||||||
|
variable_types=variable_types,
|
||||||
|
)
|
||||||
|
|
||||||
def render(self, template: str, *, allow_secrets: bool = False) -> str:
|
def _refresh_time(self) -> None:
|
||||||
if not template:
|
|
||||||
return template
|
|
||||||
timezone = str(self.values.get("system__timezone") or "Asia/Shanghai")
|
timezone = str(self.values.get("system__timezone") or "Asia/Shanghai")
|
||||||
try:
|
try:
|
||||||
now = datetime.now(ZoneInfo(timezone))
|
now = datetime.now(ZoneInfo(timezone))
|
||||||
self.values["system__time"] = now.strftime("%A, %H:%M %d %B %Y")
|
self.values["system__time"] = now.strftime("%A, %H:%M %d %B %Y")
|
||||||
self.values["system__time_utc"] = now.astimezone(ZoneInfo("UTC")).isoformat()
|
self.values["system__time_utc"] = now.astimezone(
|
||||||
|
ZoneInfo("UTC")
|
||||||
|
).isoformat()
|
||||||
except ZoneInfoNotFoundError:
|
except ZoneInfoNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _resolve(self, name: str, *, allow_secrets: bool) -> Primitive:
|
||||||
|
if name.startswith("secret__"):
|
||||||
|
if not allow_secrets:
|
||||||
|
raise DynamicVariableError(f"密钥变量 {name} 只能用于 HTTP Header")
|
||||||
|
if name not in self.secrets:
|
||||||
|
raise DynamicVariableError(f"缺少密钥变量: {name}")
|
||||||
|
return self.secrets[name]
|
||||||
|
if name in self.values:
|
||||||
|
return self.values[name]
|
||||||
|
# Optional variables intentionally remain absent from ``values`` so an
|
||||||
|
# ``exists`` expression can distinguish unset from an explicit value.
|
||||||
|
# Text templates still render predictably instead of failing the call.
|
||||||
|
if name in self.optional_names:
|
||||||
|
return ""
|
||||||
|
raise DynamicVariableError(f"缺少动态变量: {name}")
|
||||||
|
|
||||||
|
def render(self, template: str, *, allow_secrets: bool = False) -> str:
|
||||||
|
if not template:
|
||||||
|
return template
|
||||||
|
self._refresh_time()
|
||||||
|
|
||||||
def replace(match: re.Match[str]) -> str:
|
def replace(match: re.Match[str]) -> str:
|
||||||
name = match.group(1)
|
name = match.group(1)
|
||||||
if name.startswith("secret__"):
|
value = self._resolve(name, allow_secrets=allow_secrets)
|
||||||
if not allow_secrets:
|
|
||||||
raise DynamicVariableError(f"密钥变量 {name} 只能用于 HTTP Header")
|
|
||||||
if name not in self.secrets:
|
|
||||||
raise DynamicVariableError(f"缺少密钥变量: {name}")
|
|
||||||
return self.secrets[name]
|
|
||||||
if name not in self.values:
|
|
||||||
raise DynamicVariableError(f"缺少动态变量: {name}")
|
|
||||||
value = self.values[name]
|
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return "true" if value else "false"
|
return "true" if value else "false"
|
||||||
return str(value)
|
return str(value)
|
||||||
@@ -130,6 +165,12 @@ class DynamicVariableStore:
|
|||||||
|
|
||||||
def render_data(self, value: Any, *, allow_secrets: bool = False) -> Any:
|
def render_data(self, value: Any, *, allow_secrets: bool = False) -> Any:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
exact = FULL_PLACEHOLDER.fullmatch(value)
|
||||||
|
if exact:
|
||||||
|
self._refresh_time()
|
||||||
|
return deepcopy(
|
||||||
|
self._resolve(exact.group(1), allow_secrets=allow_secrets)
|
||||||
|
)
|
||||||
return self.render(value, allow_secrets=allow_secrets)
|
return self.render(value, allow_secrets=allow_secrets)
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [self.render_data(item, allow_secrets=allow_secrets) for item in value]
|
return [self.render_data(item, allow_secrets=allow_secrets) for item in value]
|
||||||
@@ -163,7 +204,11 @@ class DynamicVariableStore:
|
|||||||
def assign(self, name: str, value: Any) -> None:
|
def assign(self, name: str, value: Any) -> None:
|
||||||
if name.startswith(("system__", "secret__")) or not VARIABLE_NAME.fullmatch(name):
|
if name.startswith(("system__", "secret__")) or not VARIABLE_NAME.fullmatch(name):
|
||||||
raise DynamicVariableError(f"工具不能更新保留变量: {name}")
|
raise DynamicVariableError(f"工具不能更新保留变量: {name}")
|
||||||
self.values[name] = _primitive(value, name)
|
primitive = _primitive(value, name)
|
||||||
|
expected = self.variable_types.get(name)
|
||||||
|
if expected and not _type_matches(primitive, expected):
|
||||||
|
raise DynamicVariableError(f"动态变量 {name} 类型应为 {expected}")
|
||||||
|
self.values[name] = primitive
|
||||||
|
|
||||||
|
|
||||||
def prepare_dynamic_config(
|
def prepare_dynamic_config(
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class AgentStageConfig:
|
|||||||
knowledge_mode: str
|
knowledge_mode: str
|
||||||
knowledge_top_n: int
|
knowledge_top_n: int
|
||||||
knowledge_score_threshold: float
|
knowledge_score_threshold: float
|
||||||
|
enable_interrupt: bool
|
||||||
|
turn_config: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class WorkflowEngine:
|
class WorkflowEngine:
|
||||||
@@ -106,6 +108,12 @@ class WorkflowEngine:
|
|||||||
asr_key = "defaultAsrResourceId" if inherits_global else "asrResourceId"
|
asr_key = "defaultAsrResourceId" if inherits_global else "asrResourceId"
|
||||||
tts_key = "defaultTtsResourceId" if inherits_global else "ttsResourceId"
|
tts_key = "defaultTtsResourceId" if inherits_global else "ttsResourceId"
|
||||||
knowledge_base_id = str(source.get("knowledgeBaseId") or "")
|
knowledge_base_id = str(source.get("knowledgeBaseId") or "")
|
||||||
|
global_turn_config = self.settings.get("turnConfig")
|
||||||
|
if not isinstance(global_turn_config, dict):
|
||||||
|
global_turn_config = {}
|
||||||
|
turn_config = source.get("turnConfig", global_turn_config)
|
||||||
|
if not isinstance(turn_config, dict):
|
||||||
|
turn_config = global_turn_config
|
||||||
return AgentStageConfig(
|
return AgentStageConfig(
|
||||||
inherits_global=inherits_global,
|
inherits_global=inherits_global,
|
||||||
llm_resource_id=str(source.get(llm_key) or ""),
|
llm_resource_id=str(source.get(llm_key) or ""),
|
||||||
@@ -122,6 +130,13 @@ class WorkflowEngine:
|
|||||||
knowledge_score_threshold=float(
|
knowledge_score_threshold=float(
|
||||||
source.get("knowledgeScoreThreshold") or 0.0
|
source.get("knowledgeScoreThreshold") or 0.0
|
||||||
),
|
),
|
||||||
|
enable_interrupt=bool(
|
||||||
|
source.get(
|
||||||
|
"enableInterrupt",
|
||||||
|
self.settings.get("enableInterrupt", True),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
turn_config=dict(turn_config),
|
||||||
)
|
)
|
||||||
|
|
||||||
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pipecat.frames.frames import (
|
|||||||
LLMContextFrame,
|
LLMContextFrame,
|
||||||
LLMFullResponseEndFrame,
|
LLMFullResponseEndFrame,
|
||||||
LLMFullResponseStartFrame,
|
LLMFullResponseStartFrame,
|
||||||
|
LLMMessagesUpdateFrame,
|
||||||
LLMRunFrame,
|
LLMRunFrame,
|
||||||
LLMTextFrame,
|
LLMTextFrame,
|
||||||
OutputTransportMessageUrgentFrame,
|
OutputTransportMessageUrgentFrame,
|
||||||
@@ -391,6 +392,78 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_action_publishes_updated_session_variables(self):
|
||||||
|
tool = RuntimeTool(
|
||||||
|
id="lookup",
|
||||||
|
name="查询订单",
|
||||||
|
function_name="lookup_order",
|
||||||
|
type="http",
|
||||||
|
)
|
||||||
|
cfg = prepare_dynamic_config(
|
||||||
|
AssistantConfig(
|
||||||
|
type="workflow",
|
||||||
|
graph={
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "lookup_action",
|
||||||
|
"type": "action",
|
||||||
|
"data": {
|
||||||
|
"toolId": "lookup",
|
||||||
|
"resultAssignments": {
|
||||||
|
"order_status": "order.status"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
},
|
||||||
|
dynamic_variable_definitions={
|
||||||
|
"order_status": {"type": "string", "default": "pending"}
|
||||||
|
},
|
||||||
|
tools=[tool],
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
assistant_id="asst_workflow_action",
|
||||||
|
)
|
||||||
|
brain = WorkflowBrain(cfg)
|
||||||
|
queued = []
|
||||||
|
|
||||||
|
async def queue_frame(frame):
|
||||||
|
queued.append(frame)
|
||||||
|
|
||||||
|
async def execute(_tool, _arguments, *, result_assignments=None):
|
||||||
|
self.assertEqual(result_assignments, {"order_status": "order.status"})
|
||||||
|
brain._store.assign("order_status", "paid")
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"updated_variables": ["order_status"],
|
||||||
|
}
|
||||||
|
|
||||||
|
brain._runtime = BrainRuntime(
|
||||||
|
context=LLMContext(messages=[]),
|
||||||
|
llm=FakeLLM(),
|
||||||
|
queue_frame=queue_frame,
|
||||||
|
set_system_prompt=lambda _prompt: None,
|
||||||
|
set_tools=lambda _tools: None,
|
||||||
|
call_end=FakeCallEnd(),
|
||||||
|
)
|
||||||
|
brain._tools.execute = execute
|
||||||
|
|
||||||
|
await brain._enter_action("lookup_action")
|
||||||
|
|
||||||
|
variable_events = [
|
||||||
|
frame.message
|
||||||
|
for frame in queued
|
||||||
|
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("type") == "workflow-variables"
|
||||||
|
]
|
||||||
|
self.assertEqual(variable_events[-1]["reason"], "action")
|
||||||
|
self.assertEqual(variable_events[-1]["changed"], ["order_status"])
|
||||||
|
self.assertEqual(variable_events[-1]["variables"], {"order_status": "paid"})
|
||||||
|
|
||||||
async def test_nodes_without_outgoing_edges_remain_active(self):
|
async def test_nodes_without_outgoing_edges_remain_active(self):
|
||||||
queued = []
|
queued = []
|
||||||
|
|
||||||
@@ -492,6 +565,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"defaultTtsResourceId": "tts_global",
|
"defaultTtsResourceId": "tts_global",
|
||||||
"knowledgeBaseId": "kb_global",
|
"knowledgeBaseId": "kb_global",
|
||||||
"knowledgeMode": "automatic",
|
"knowledgeMode": "automatic",
|
||||||
|
"enableInterrupt": False,
|
||||||
|
"turnConfig": {
|
||||||
|
"bargeIn": {"strategy": "transcription"},
|
||||||
|
"vad": {"confidence": 0.55},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
@@ -551,6 +629,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
queued = []
|
queued = []
|
||||||
service_switches = []
|
service_switches = []
|
||||||
knowledge_scopes = []
|
knowledge_scopes = []
|
||||||
|
turn_configs = []
|
||||||
call_end = FakeCallEnd()
|
call_end = FakeCallEnd()
|
||||||
|
|
||||||
class FakeWorker:
|
class FakeWorker:
|
||||||
@@ -585,6 +664,9 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
async def switch_services(llm_id, asr_id, tts_id):
|
async def switch_services(llm_id, asr_id, tts_id):
|
||||||
service_switches.append((llm_id, asr_id, tts_id))
|
service_switches.append((llm_id, asr_id, tts_id))
|
||||||
|
|
||||||
|
async def apply_turn_config(enable_interrupt, turn_config):
|
||||||
|
turn_configs.append((enable_interrupt, turn_config))
|
||||||
|
|
||||||
runtime = BrainRuntime(
|
runtime = BrainRuntime(
|
||||||
context=context,
|
context=context,
|
||||||
llm=llm,
|
llm=llm,
|
||||||
@@ -596,15 +678,27 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
context_aggregator=pair,
|
context_aggregator=pair,
|
||||||
switch_services=switch_services,
|
switch_services=switch_services,
|
||||||
set_knowledge_scope=knowledge_scopes.append,
|
set_knowledge_scope=knowledge_scopes.append,
|
||||||
|
apply_turn_config=apply_turn_config,
|
||||||
)
|
)
|
||||||
await brain.setup(cfg, runtime)
|
await brain.setup(cfg, runtime)
|
||||||
await brain.on_connected()
|
await brain.on_connected()
|
||||||
self.assertEqual(brain._manager.current_node, "agent")
|
self.assertEqual(brain._manager.current_node, "agent")
|
||||||
|
variable_events = [
|
||||||
|
frame.message
|
||||||
|
for frame in queued
|
||||||
|
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("type") == "workflow-variables"
|
||||||
|
]
|
||||||
|
self.assertEqual(variable_events[0]["reason"], "initialized")
|
||||||
|
self.assertEqual(variable_events[0]["variables"], {"user_name": "王先生"})
|
||||||
|
self.assertNotIn("system__conversation_id", variable_events[0]["variables"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
service_switches,
|
service_switches,
|
||||||
[("llm_global", "asr_global", "tts_global")],
|
[("llm_global", "asr_global", "tts_global")],
|
||||||
)
|
)
|
||||||
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_global")
|
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_global")
|
||||||
|
self.assertEqual(turn_configs[-1][0], False)
|
||||||
|
self.assertEqual(turn_configs[-1][1]["vad"]["confidence"], 0.55)
|
||||||
|
|
||||||
brain._engine.data("agent").update(
|
brain._engine.data("agent").update(
|
||||||
{
|
{
|
||||||
@@ -614,6 +708,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"ttsResourceId": "tts_agent",
|
"ttsResourceId": "tts_agent",
|
||||||
"knowledgeBaseId": "kb_agent",
|
"knowledgeBaseId": "kb_agent",
|
||||||
"knowledgeMode": "on_demand",
|
"knowledgeMode": "on_demand",
|
||||||
|
"enableInterrupt": True,
|
||||||
|
"turnConfig": {
|
||||||
|
"bargeIn": {"strategy": "vad"},
|
||||||
|
"turnDetection": {"strategy": "smart_turn"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await brain._apply_agent_stage("agent")
|
await brain._apply_agent_stage("agent")
|
||||||
@@ -622,6 +721,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
("llm_agent", "asr_agent", "tts_agent"),
|
("llm_agent", "asr_agent", "tts_agent"),
|
||||||
)
|
)
|
||||||
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_agent")
|
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_agent")
|
||||||
|
self.assertEqual(turn_configs[-1][0], True)
|
||||||
|
self.assertEqual(
|
||||||
|
turn_configs[-1][1]["turnDetection"]["strategy"],
|
||||||
|
"smart_turn",
|
||||||
|
)
|
||||||
agent_config = brain._agent_config("agent")
|
agent_config = brain._agent_config("agent")
|
||||||
self.assertIn("王先生", agent_config["role_message"])
|
self.assertIn("王先生", agent_config["role_message"])
|
||||||
self.assertIn("工作流路由已在用户一轮输入结束时完成", agent_config["role_message"])
|
self.assertIn("工作流路由已在用户一轮输入结束时完成", agent_config["role_message"])
|
||||||
@@ -654,11 +758,30 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
fixed_config["task_messages"],
|
fixed_config["task_messages"],
|
||||||
[{"role": "assistant", "content": "您好,王先生"}],
|
[{"role": "assistant", "content": "您好,王先生"}],
|
||||||
)
|
)
|
||||||
|
self.assertEqual(fixed_config["pre_actions"][0]["node_id"], "agent")
|
||||||
worker.frames.clear()
|
worker.frames.clear()
|
||||||
queued.clear()
|
queued.clear()
|
||||||
await brain._manager.set_node_from_config(fixed_config)
|
await brain._manager.set_node_from_config(fixed_config)
|
||||||
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
||||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||||
|
context_updates = [
|
||||||
|
frame
|
||||||
|
for frame in worker.frames
|
||||||
|
if isinstance(frame, LLMMessagesUpdateFrame)
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
context_updates[-1].messages,
|
||||||
|
[{"role": "assistant", "content": "您好,王先生"}],
|
||||||
|
)
|
||||||
|
fixed_reply_events = [
|
||||||
|
frame.message
|
||||||
|
for frame in queued
|
||||||
|
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("source") == "workflow-fixed-reply"
|
||||||
|
]
|
||||||
|
self.assertEqual(fixed_reply_events[0]["content"], "您好,王先生")
|
||||||
|
self.assertEqual(fixed_reply_events[0]["nodeId"], "agent")
|
||||||
|
self.assertIn("您好,王先生", brain._store.values["system__conversation_history"])
|
||||||
|
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
any(
|
any(
|
||||||
|
|||||||
37
backend/tests/test_conversation_history.py
Normal file
37
backend/tests/test_conversation_history.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from services.conversation_history import ConversationRecorder
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationRecorderTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_fixed_reply_transcript_keeps_workflow_metadata(self):
|
||||||
|
recorder = ConversationRecorder("conv_test")
|
||||||
|
recorder._append = AsyncMock()
|
||||||
|
|
||||||
|
await recorder.record_transport_message(
|
||||||
|
{
|
||||||
|
"type": "transcript",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "请稍等,我正在处理。",
|
||||||
|
"timestamp": "2026-07-14T10:00:00+08:00",
|
||||||
|
"source": "workflow-fixed-reply",
|
||||||
|
"nodeId": "agent_service",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder._append.assert_awaited_once_with(
|
||||||
|
"assistant",
|
||||||
|
"请稍等,我正在处理。",
|
||||||
|
"2026-07-14T10:00:00+08:00",
|
||||||
|
{
|
||||||
|
"source": "workflow-fixed-reply",
|
||||||
|
"node_id": "agent_service",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -79,6 +79,58 @@ class DynamicVariableTests(unittest.TestCase):
|
|||||||
"Bearer private",
|
"Bearer private",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_optional_workflow_variable_renders_empty_but_remains_unset(self):
|
||||||
|
cfg = AssistantConfig(
|
||||||
|
type="workflow",
|
||||||
|
graph={
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {"globalPrompt": "称呼 {{nickname}}"},
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"type": "start",
|
||||||
|
"data": {"greeting": "您好 {{nickname}}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
},
|
||||||
|
greeting="",
|
||||||
|
dynamic_variable_definitions={
|
||||||
|
"nickname": {
|
||||||
|
"type": "string",
|
||||||
|
"required": False,
|
||||||
|
"default": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
prepared = prepare_dynamic_config(cfg, {}, assistant_id="asst_workflow")
|
||||||
|
store = DynamicVariableStore.from_config(prepared)
|
||||||
|
|
||||||
|
self.assertEqual(store.render("您好 {{nickname}}"), "您好 ")
|
||||||
|
self.assertNotIn("nickname", store.values)
|
||||||
|
with self.assertRaisesRegex(DynamicVariableError, "缺少动态变量"):
|
||||||
|
store.render("{{not_defined}}")
|
||||||
|
|
||||||
|
def test_exact_placeholder_keeps_action_argument_type(self):
|
||||||
|
store = DynamicVariableStore(
|
||||||
|
{"count": 3, "confirmed": True},
|
||||||
|
variable_types={"count": "number", "confirmed": "boolean"},
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = store.render_data(
|
||||||
|
{
|
||||||
|
"count": "{{count}}",
|
||||||
|
"confirmed": "{{confirmed}}",
|
||||||
|
"label": "数量 {{count}}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(rendered["count"], 3)
|
||||||
|
self.assertIs(rendered["confirmed"], True)
|
||||||
|
self.assertEqual(rendered["label"], "数量 3")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(DynamicVariableError, "类型应为 number"):
|
||||||
|
store.assign("count", "four")
|
||||||
|
|
||||||
def test_tool_assignment_and_system_history(self):
|
def test_tool_assignment_and_system_history(self):
|
||||||
store = DynamicVariableStore(
|
store = DynamicVariableStore(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ class WorkflowGraphTests(unittest.TestCase):
|
|||||||
"knowledgeMode": "on_demand",
|
"knowledgeMode": "on_demand",
|
||||||
"knowledgeTopN": 8,
|
"knowledgeTopN": 8,
|
||||||
"knowledgeScoreThreshold": 0.4,
|
"knowledgeScoreThreshold": 0.4,
|
||||||
|
"enableInterrupt": False,
|
||||||
|
"turnConfig": {
|
||||||
|
"bargeIn": {"strategy": "transcription"},
|
||||||
|
"turnDetection": {"strategy": "silence", "silenceTimeoutSecs": 1.2},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
engine = WorkflowEngine(graph)
|
engine = WorkflowEngine(graph)
|
||||||
@@ -177,6 +182,11 @@ class WorkflowGraphTests(unittest.TestCase):
|
|||||||
self.assertEqual(inherited.llm_resource_id, "llm_global")
|
self.assertEqual(inherited.llm_resource_id, "llm_global")
|
||||||
self.assertEqual(inherited.tool_ids, ("tool_global",))
|
self.assertEqual(inherited.tool_ids, ("tool_global",))
|
||||||
self.assertEqual(inherited.knowledge_mode, "on_demand")
|
self.assertEqual(inherited.knowledge_mode, "on_demand")
|
||||||
|
self.assertFalse(inherited.enable_interrupt)
|
||||||
|
self.assertEqual(
|
||||||
|
inherited.turn_config["bargeIn"]["strategy"],
|
||||||
|
"transcription",
|
||||||
|
)
|
||||||
|
|
||||||
engine.data("agent").update(
|
engine.data("agent").update(
|
||||||
{
|
{
|
||||||
@@ -184,12 +194,22 @@ class WorkflowGraphTests(unittest.TestCase):
|
|||||||
"llmResourceId": "llm_agent",
|
"llmResourceId": "llm_agent",
|
||||||
"toolIds": ["tool_agent"],
|
"toolIds": ["tool_agent"],
|
||||||
"knowledgeBaseId": "",
|
"knowledgeBaseId": "",
|
||||||
|
"enableInterrupt": True,
|
||||||
|
"turnConfig": {
|
||||||
|
"bargeIn": {"strategy": "vad"},
|
||||||
|
"turnDetection": {"strategy": "smart_turn"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
custom = engine.agent_stage_config("agent")
|
custom = engine.agent_stage_config("agent")
|
||||||
self.assertEqual(custom.llm_resource_id, "llm_agent")
|
self.assertEqual(custom.llm_resource_id, "llm_agent")
|
||||||
self.assertEqual(custom.tool_ids, ("tool_agent",))
|
self.assertEqual(custom.tool_ids, ("tool_agent",))
|
||||||
self.assertEqual(custom.knowledge_mode, "disabled")
|
self.assertEqual(custom.knowledge_mode, "disabled")
|
||||||
|
self.assertTrue(custom.enable_interrupt)
|
||||||
|
self.assertEqual(
|
||||||
|
custom.turn_config["turnDetection"]["strategy"],
|
||||||
|
"smart_turn",
|
||||||
|
)
|
||||||
|
|
||||||
def test_start_agent_and_handoff_may_have_no_outgoing_edge(self):
|
def test_start_agent_and_handoff_may_have_no_outgoing_edge(self):
|
||||||
terminal_graphs = [
|
terminal_graphs = [
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ import {
|
|||||||
defaultGraph,
|
defaultGraph,
|
||||||
type WorkflowGraph,
|
type WorkflowGraph,
|
||||||
} from "@/components/workflow/specs";
|
} from "@/components/workflow/specs";
|
||||||
|
import {
|
||||||
|
defaultTurnConfig,
|
||||||
|
normalizeTurnConfig,
|
||||||
|
} from "@/lib/turn-config";
|
||||||
|
|
||||||
type RuntimeMode = "pipeline" | "realtime";
|
type RuntimeMode = "pipeline" | "realtime";
|
||||||
|
|
||||||
@@ -185,24 +189,6 @@ const assistantTypes: AssistantType[] = [
|
|||||||
"OpenCode",
|
"OpenCode",
|
||||||
];
|
];
|
||||||
|
|
||||||
function defaultTurnConfig(): TurnConfig {
|
|
||||||
return {
|
|
||||||
bargeIn: {
|
|
||||||
strategy: "vad",
|
|
||||||
},
|
|
||||||
vad: {
|
|
||||||
confidence: 0.7,
|
|
||||||
startSecs: 0.2,
|
|
||||||
stopSecs: 0.2,
|
|
||||||
minVolume: 0.6,
|
|
||||||
},
|
|
||||||
turnDetection: {
|
|
||||||
strategy: "silence",
|
|
||||||
silenceTimeoutSecs: 0.6,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
||||||
return {
|
return {
|
||||||
mode: "automatic",
|
mode: "automatic",
|
||||||
@@ -305,6 +291,45 @@ function activeDynamicVariableDefinitions(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activeWorkflowDynamicVariableDefinitions(
|
||||||
|
graph: WorkflowGraph,
|
||||||
|
saved: Record<string, DynamicVariableDefinition>,
|
||||||
|
): Record<string, DynamicVariableDefinition> {
|
||||||
|
const names = new Set(extractDynamicVariableNames(JSON.stringify(graph)));
|
||||||
|
|
||||||
|
// Expression operands and Action assignment destinations are variable
|
||||||
|
// references even though they do not use the {{placeholder}} syntax.
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
for (const rule of edge.data.expression?.rules ?? []) {
|
||||||
|
if (isPublicDynamicVariableName(rule.variable)) names.add(rule.variable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
for (const name of Object.keys(node.data.resultAssignments ?? {})) {
|
||||||
|
if (isPublicDynamicVariableName(name)) names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
[...names].sort().map((name) => [
|
||||||
|
name,
|
||||||
|
saved[name] ?? {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicDynamicVariableName(name: string): boolean {
|
||||||
|
return (
|
||||||
|
/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) &&
|
||||||
|
!name.startsWith("system__") &&
|
||||||
|
!name.startsWith("secret__")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function blankFastGptForm(name: string): FastGptForm {
|
function blankFastGptForm(name: string): FastGptForm {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -492,6 +517,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
[form.prompt, form.greeting],
|
[form.prompt, form.greeting],
|
||||||
form.dynamicVariableDefinitions,
|
form.dynamicVariableDefinitions,
|
||||||
);
|
);
|
||||||
|
const effectiveWorkflowDynamicVariableDefinitions =
|
||||||
|
activeWorkflowDynamicVariableDefinitions(
|
||||||
|
workflowGraph,
|
||||||
|
workflowDynamicVariableDefinitions,
|
||||||
|
);
|
||||||
|
|
||||||
const loadAssistants = useCallback(async () => {
|
const loadAssistants = useCallback(async () => {
|
||||||
setListLoading(true);
|
setListLoading(true);
|
||||||
@@ -603,7 +633,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
knowledgeRetrievalConfig:
|
knowledgeRetrievalConfig:
|
||||||
a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(),
|
a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(),
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
turnConfig: a.turnConfig,
|
turnConfig: normalizeTurnConfig(a.turnConfig),
|
||||||
visionEnabled: a.visionEnabled,
|
visionEnabled: a.visionEnabled,
|
||||||
visionModelResourceId: a.visionModelResourceId ?? "",
|
visionModelResourceId: a.visionModelResourceId ?? "",
|
||||||
toolIds: a.toolIds ?? [],
|
toolIds: a.toolIds ?? [],
|
||||||
@@ -711,7 +741,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: workflowName,
|
name: workflowName,
|
||||||
graph: workflowGraph,
|
graph: workflowGraph,
|
||||||
settings: workflowSettings,
|
settings: workflowSettings,
|
||||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
dynamicVariableDefinitions: effectiveWorkflowDynamicVariableDefinitions,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -756,7 +786,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
asr: a.modelResourceIds.ASR ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.modelResourceIds.TTS ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
turnConfig: a.turnConfig,
|
turnConfig: normalizeTurnConfig(a.turnConfig),
|
||||||
};
|
};
|
||||||
setDifyForm(next);
|
setDifyForm(next);
|
||||||
return next;
|
return next;
|
||||||
@@ -786,7 +816,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
asr: a.modelResourceIds.ASR ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.modelResourceIds.TTS ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
turnConfig: a.turnConfig,
|
turnConfig: normalizeTurnConfig(a.turnConfig),
|
||||||
};
|
};
|
||||||
setFastGptForm(next);
|
setFastGptForm(next);
|
||||||
return next;
|
return next;
|
||||||
@@ -818,7 +848,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
asr: a.modelResourceIds.ASR ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.modelResourceIds.TTS ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
turnConfig: a.turnConfig,
|
turnConfig: normalizeTurnConfig(a.turnConfig),
|
||||||
visionEnabled: a.visionEnabled,
|
visionEnabled: a.visionEnabled,
|
||||||
visionModelResourceId: a.visionModelResourceId ?? "",
|
visionModelResourceId: a.visionModelResourceId ?? "",
|
||||||
};
|
};
|
||||||
@@ -872,21 +902,29 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
assistant.knowledgeRetrievalConfig.scoreThreshold,
|
assistant.knowledgeRetrievalConfig.scoreThreshold,
|
||||||
},
|
},
|
||||||
globalPrompt: graph.settings?.globalPrompt ?? "",
|
globalPrompt: graph.settings?.globalPrompt ?? "",
|
||||||
allowInterrupt: assistant.enableInterrupt,
|
allowInterrupt:
|
||||||
turnConfig: assistant.turnConfig,
|
graph.settings?.enableInterrupt ?? assistant.enableInterrupt,
|
||||||
|
turnConfig: normalizeTurnConfig(
|
||||||
|
graph.settings?.turnConfig ?? assistant.turnConfig,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
setWorkflowName(assistant.name);
|
setWorkflowName(assistant.name);
|
||||||
setWorkflowGraph(graph);
|
setWorkflowGraph(graph);
|
||||||
setWorkflowSettings(wfSettings);
|
setWorkflowSettings(wfSettings);
|
||||||
|
const dynamicVariableDefinitions =
|
||||||
|
activeWorkflowDynamicVariableDefinitions(
|
||||||
|
graph,
|
||||||
|
assistant.dynamicVariableDefinitions ?? {},
|
||||||
|
);
|
||||||
setWorkflowDynamicVariableDefinitions(
|
setWorkflowDynamicVariableDefinitions(
|
||||||
assistant.dynamicVariableDefinitions ?? {},
|
dynamicVariableDefinitions,
|
||||||
);
|
);
|
||||||
setSavedSnapshot(
|
setSavedSnapshot(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
name: assistant.name,
|
name: assistant.name,
|
||||||
graph,
|
graph,
|
||||||
settings: wfSettings,
|
settings: wfSettings,
|
||||||
dynamicVariableDefinitions: assistant.dynamicVariableDefinitions ?? {},
|
dynamicVariableDefinitions,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -941,7 +979,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
knowledgeRetrievalConfig: workflowSettings.knowledgeRetrievalConfig,
|
knowledgeRetrievalConfig: workflowSettings.knowledgeRetrievalConfig,
|
||||||
toolIds: workflowSettings.toolIds,
|
toolIds: workflowSettings.toolIds,
|
||||||
graph: workflowGraph as unknown as Record<string, unknown>,
|
graph: workflowGraph as unknown as Record<string, unknown>,
|
||||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
dynamicVariableDefinitions: effectiveWorkflowDynamicVariableDefinitions,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -961,7 +999,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: workflowName,
|
name: workflowName,
|
||||||
graph: workflowGraph,
|
graph: workflowGraph,
|
||||||
settings: workflowSettings,
|
settings: workflowSettings,
|
||||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
dynamicVariableDefinitions:
|
||||||
|
effectiveWorkflowDynamicVariableDefinitions,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const dirty =
|
const dirty =
|
||||||
@@ -1473,7 +1512,9 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
hasUnsavedChanges={dirty}
|
hasUnsavedChanges={dirty}
|
||||||
onNodeActive={setActiveNodeId}
|
onNodeActive={setActiveNodeId}
|
||||||
dynamicVariablesEnabled
|
dynamicVariablesEnabled
|
||||||
dynamicVariableDefinitions={workflowDynamicVariableDefinitions}
|
dynamicVariableDefinitions={
|
||||||
|
effectiveWorkflowDynamicVariableDefinitions
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
activeNodeId={activeNodeId}
|
activeNodeId={activeNodeId}
|
||||||
@@ -1492,7 +1533,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
<DynamicVariablesDialog
|
<DynamicVariablesDialog
|
||||||
open={dynamicVariablesOpen}
|
open={dynamicVariablesOpen}
|
||||||
onOpenChange={setDynamicVariablesOpen}
|
onOpenChange={setDynamicVariablesOpen}
|
||||||
definitions={workflowDynamicVariableDefinitions}
|
definitions={effectiveWorkflowDynamicVariableDefinitions}
|
||||||
onChange={setWorkflowDynamicVariableDefinitions}
|
onChange={setWorkflowDynamicVariableDefinitions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1872,80 +1913,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-4">
|
<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">
|
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
<SectionCard>
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => updateForm("runtimeMode", "pipeline")}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
updateForm("runtimeMode", "pipeline");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={[
|
|
||||||
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
|
||||||
form.runtimeMode === "pipeline"
|
|
||||||
? "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">
|
|
||||||
<Waypoints size={15} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-sm font-medium text-foreground">Pipeline 模式</span>
|
|
||||||
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{form.runtimeMode === "pipeline" && (
|
|
||||||
<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
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => updateForm("runtimeMode", "realtime")}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
updateForm("runtimeMode", "realtime");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={[
|
|
||||||
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
|
||||||
form.runtimeMode === "realtime"
|
|
||||||
? "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">
|
|
||||||
<AudioLines size={15} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-sm font-medium text-foreground">Realtime 模式</span>
|
|
||||||
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{form.runtimeMode === "realtime" && (
|
|
||||||
<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>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<MessageSquareText size={15} />}
|
icon={<MessageSquareText size={15} />}
|
||||||
title="提示词"
|
title="提示词"
|
||||||
@@ -1963,12 +1930,38 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{form.runtimeMode === "pipeline" ? (
|
<SectionCard
|
||||||
<SectionCard
|
icon={<Bot size={15} />}
|
||||||
icon={<Brain size={15} />}
|
title="开场白"
|
||||||
title="模型配置"
|
description="助手与用户首次对话时的开场语"
|
||||||
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
>
|
||||||
>
|
<TextAreaField
|
||||||
|
value={form.greeting}
|
||||||
|
onChange={(value) => updateForm("greeting", value)}
|
||||||
|
placeholder="请输入助手开场白"
|
||||||
|
/>
|
||||||
|
<DynamicVariableEditorHint
|
||||||
|
count={Object.keys(effectiveDynamicVariableDefinitions).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
|
<ToggleRow
|
||||||
title="视觉理解"
|
title="视觉理解"
|
||||||
hint="开启后,开始对话时会允许助手按需理解当前视频画面。视觉模型选「模型自己」时,大语言模型本身必须支持图片输入。"
|
hint="开启后,开始对话时会允许助手按需理解当前视频画面。视觉模型选「模型自己」时,大语言模型本身必须支持图片输入。"
|
||||||
@@ -2007,13 +2000,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
options={credOptions("TTS")}
|
options={credOptions("TTS")}
|
||||||
noneLabel="无"
|
noneLabel="无"
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<SectionCard
|
|
||||||
icon={<Brain size={15} />}
|
|
||||||
title="模型配置"
|
|
||||||
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
|
||||||
>
|
|
||||||
<ResourceSelectField
|
<ResourceSelectField
|
||||||
label="Realtime 模型"
|
label="Realtime 模型"
|
||||||
value={form.realtimeModel}
|
value={form.realtimeModel}
|
||||||
@@ -2021,23 +2009,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
options={credOptions("Realtime")}
|
options={credOptions("Realtime")}
|
||||||
noneLabel="无"
|
noneLabel="无"
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<SectionCard
|
|
||||||
icon={<Bot size={15} />}
|
|
||||||
title="开场白"
|
|
||||||
description="助手与用户首次对话时的开场语"
|
|
||||||
>
|
|
||||||
<TextAreaField
|
|
||||||
value={form.greeting}
|
|
||||||
onChange={(value) => updateForm("greeting", value)}
|
|
||||||
placeholder="请输入助手开场白"
|
|
||||||
/>
|
|
||||||
<DynamicVariableEditorHint
|
|
||||||
count={Object.keys(effectiveDynamicVariableDefinitions).length}
|
|
||||||
onOpen={() => setDynamicVariablesOpen(true)}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{form.runtimeMode === "pipeline" && (
|
{form.runtimeMode === "pipeline" && (
|
||||||
@@ -2206,10 +2178,23 @@ function DebugDrawer({
|
|||||||
>({});
|
>({});
|
||||||
const recording =
|
const recording =
|
||||||
preview.status === "connecting" || preview.status === "connected";
|
preview.status === "connecting" || preview.status === "connected";
|
||||||
const dynamicVariableEntries = Object.entries(dynamicVariableDefinitions);
|
const displayedDefinitions = { ...dynamicVariableDefinitions };
|
||||||
|
for (const [name, value] of Object.entries(preview.sessionVariables)) {
|
||||||
|
displayedDefinitions[name] ??= {
|
||||||
|
type:
|
||||||
|
typeof value === "number"
|
||||||
|
? "number"
|
||||||
|
: typeof value === "boolean"
|
||||||
|
? "boolean"
|
||||||
|
: "string",
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const dynamicVariableEntries = Object.entries(displayedDefinitions);
|
||||||
const resolvedDynamicVariables: Record<string, string | number | boolean> = {};
|
const resolvedDynamicVariables: Record<string, string | number | boolean> = {};
|
||||||
let dynamicVariablesError = "";
|
let dynamicVariablesError = "";
|
||||||
for (const [name, definition] of dynamicVariableEntries) {
|
for (const [name, definition] of Object.entries(dynamicVariableDefinitions)) {
|
||||||
const value = dynamicVariableValues[name] ?? definition.default;
|
const value = dynamicVariableValues[name] ?? definition.default;
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
if (definition.required && !dynamicVariablesError) {
|
if (definition.required && !dynamicVariablesError) {
|
||||||
@@ -2265,7 +2250,8 @@ function DebugDrawer({
|
|||||||
<DynamicVariableValuesPopover
|
<DynamicVariableValuesPopover
|
||||||
entries={dynamicVariableEntries}
|
entries={dynamicVariableEntries}
|
||||||
values={dynamicVariableValues}
|
values={dynamicVariableValues}
|
||||||
disabled={recording}
|
sessionValues={preview.sessionVariables}
|
||||||
|
readOnly={recording}
|
||||||
onChange={setDynamicVariableValues}
|
onChange={setDynamicVariableValues}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -2330,12 +2316,14 @@ function DebugDrawer({
|
|||||||
function DynamicVariableValuesPopover({
|
function DynamicVariableValuesPopover({
|
||||||
entries,
|
entries,
|
||||||
values,
|
values,
|
||||||
disabled,
|
sessionValues,
|
||||||
|
readOnly,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
entries: [string, DynamicVariableDefinition][];
|
entries: [string, DynamicVariableDefinition][];
|
||||||
values: Record<string, string | number | boolean>;
|
values: Record<string, string | number | boolean>;
|
||||||
disabled: boolean;
|
sessionValues: Record<string, string | number | boolean>;
|
||||||
|
readOnly: boolean;
|
||||||
onChange: React.Dispatch<
|
onChange: React.Dispatch<
|
||||||
React.SetStateAction<Record<string, string | number | boolean>>
|
React.SetStateAction<Record<string, string | number | boolean>>
|
||||||
>;
|
>;
|
||||||
@@ -2354,10 +2342,9 @@ function DynamicVariableValuesPopover({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
aria-label={readOnly ? "查看本次会话变量" : "设置本次会话变量"}
|
||||||
aria-label="设置本次会话变量"
|
title={readOnly ? "查看实时会话变量" : "本次会话变量"}
|
||||||
title="本次会话变量"
|
className="relative flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||||
className="relative flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
<Braces size={15} />
|
<Braces size={15} />
|
||||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border border-card bg-surface-strong px-1 text-[9px] tabular-nums text-foreground">
|
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border border-card bg-surface-strong px-1 text-[9px] tabular-nums text-foreground">
|
||||||
@@ -2376,7 +2363,9 @@ function DynamicVariableValuesPopover({
|
|||||||
本次会话变量
|
本次会话变量
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
这些值只用于下一次调试会话,不会修改助手配置。
|
{readOnly
|
||||||
|
? "当前值会在 Action 或工具更新变量后实时刷新。"
|
||||||
|
: "这些值只用于下一次调试会话,不会修改助手配置。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-72 space-y-3 overflow-y-auto pr-1">
|
<div className="max-h-72 space-y-3 overflow-y-auto pr-1">
|
||||||
@@ -2386,11 +2375,14 @@ function DynamicVariableValuesPopover({
|
|||||||
当前没有会话变量
|
当前没有会话变量
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
|
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
|
||||||
在提示词或开场白中添加 {"{{variable_name}}"} 后,可在这里设置调试值。
|
在工作流提示词、节点话术、边条件或 Action 中引用变量后,
|
||||||
|
可在这里设置调试值。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : entries.map(([name, definition]) => {
|
) : entries.map(([name, definition]) => {
|
||||||
const value = values[name] ?? definition.default ?? "";
|
const value = readOnly
|
||||||
|
? sessionValues[name] ?? definition.default ?? ""
|
||||||
|
: values[name] ?? definition.default ?? "";
|
||||||
return (
|
return (
|
||||||
<label key={name} className="block space-y-1.5">
|
<label key={name} className="block space-y-1.5">
|
||||||
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||||
@@ -2408,6 +2400,7 @@ function DynamicVariableValuesPopover({
|
|||||||
</span>
|
</span>
|
||||||
{definition.type === "boolean" ? (
|
{definition.type === "boolean" ? (
|
||||||
<Select
|
<Select
|
||||||
|
disabled={readOnly}
|
||||||
value={value === "" ? "unset" : String(value)}
|
value={value === "" ? "unset" : String(value)}
|
||||||
onValueChange={(next) =>
|
onValueChange={(next) =>
|
||||||
setValue(
|
setValue(
|
||||||
@@ -2430,6 +2423,7 @@ function DynamicVariableValuesPopover({
|
|||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
|
disabled={readOnly}
|
||||||
type={definition.type === "number" ? "number" : "text"}
|
type={definition.type === "number" ? "number" : "text"}
|
||||||
value={typeof value === "boolean" ? String(value) : value}
|
value={typeof value === "boolean" ? String(value) : value}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -3465,7 +3459,7 @@ function DynamicVariablesDialog({
|
|||||||
动态变量
|
动态变量
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs leading-5">
|
<DialogDescription className="text-xs leading-5">
|
||||||
提示词和开场白中使用的自定义变量会自动出现在这里。
|
助手配置中引用的自定义变量会自动出现在这里。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -3493,7 +3487,7 @@ function DynamicVariablesDialog({
|
|||||||
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
|
<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>
|
<div className="text-sm font-medium text-foreground">还没有自定义变量</div>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
在提示词或开场白中输入 {"{{customer_name}}"} 即可添加。
|
在提示词或节点话术中输入 {"{{customer_name}}"} 即可添加。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -3661,6 +3655,78 @@ function DynamicVariablesDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TextAreaField({
|
function TextAreaField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -56,14 +56,27 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
<div
|
<div
|
||||||
data-node-id={id}
|
data-node-id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative w-[250px] rounded-2xl border bg-card p-4 text-card-foreground shadow-sm transition-[border-color,box-shadow,transform]",
|
"group relative isolate w-[250px] rounded-2xl border bg-card p-4 text-card-foreground shadow-sm transition-[border-color,box-shadow,transform]",
|
||||||
isActive
|
isActive
|
||||||
? "border-success shadow-[0_12px_34px_color-mix(in_srgb,var(--success)_20%,transparent)] ring-2 ring-success/50"
|
? "border-success/90 shadow-[0_0_0_1px_color-mix(in_srgb,var(--success)_38%,transparent),0_0_18px_color-mix(in_srgb,var(--success)_38%,transparent),0_0_46px_color-mix(in_srgb,var(--success)_22%,transparent),0_16px_38px_color-mix(in_srgb,var(--success)_18%,transparent)] ring-1 ring-success/60"
|
||||||
: selected
|
: selected
|
||||||
? "border-primary shadow-[0_12px_34px_color-mix(in_srgb,var(--primary)_16%,transparent)]"
|
? "border-primary shadow-[0_12px_34px_color-mix(in_srgb,var(--primary)_16%,transparent)]"
|
||||||
: "border-hairline hover:border-hairline-strong hover:shadow-md",
|
: "border-hairline hover:border-hairline-strong hover:shadow-md",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -inset-1 rounded-[1.2rem] border border-success/35 opacity-80 motion-safe:animate-pulse"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-5 -bottom-px h-px bg-gradient-to-r from-transparent via-success to-transparent shadow-[0_0_12px_var(--success)]"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{spec.hasTarget && (
|
{spec.hasTarget && (
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
@@ -81,9 +94,12 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="absolute -top-3 left-3 flex items-center gap-1.5 rounded-full bg-success px-2 py-0.5 text-[10px] font-medium text-on-primary shadow-sm">
|
<div className="absolute -top-3 left-3 flex items-center gap-2 rounded-full border border-success/60 bg-card/95 px-2.5 py-1 text-[10px] font-semibold tracking-[0.14em] text-success shadow-[0_0_16px_color-mix(in_srgb,var(--success)_42%,transparent)] backdrop-blur-sm">
|
||||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-on-primary" />
|
<span className="relative flex h-2 w-2">
|
||||||
对话中
|
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-55 motion-safe:animate-ping" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-success shadow-[0_0_8px_var(--success)]" />
|
||||||
|
</span>
|
||||||
|
LIVE · 对话中
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import type { KnowledgeRetrievalConfig, TurnConfig } from "@/lib/api";
|
import type { KnowledgeRetrievalConfig, TurnConfig } from "@/lib/api";
|
||||||
import { TurnConfigEditor } from "@/components/turn-config-editor";
|
import { TurnConfigEditor } from "@/components/turn-config-editor";
|
||||||
|
import { normalizeTurnConfig } from "@/lib/turn-config";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -195,6 +196,8 @@ function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph {
|
|||||||
knowledgeMode: "automatic",
|
knowledgeMode: "automatic",
|
||||||
knowledgeTopN: 5,
|
knowledgeTopN: 5,
|
||||||
knowledgeScoreThreshold: 0,
|
knowledgeScoreThreshold: 0,
|
||||||
|
enableInterrupt: true,
|
||||||
|
turnConfig: defaultGraph().settings.turnConfig,
|
||||||
},
|
},
|
||||||
nodes: nodes.map((n) => ({
|
nodes: nodes.map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
@@ -261,6 +264,8 @@ function Canvas({
|
|||||||
knowledgeTopN: settings.knowledgeRetrievalConfig.topN,
|
knowledgeTopN: settings.knowledgeRetrievalConfig.topN,
|
||||||
knowledgeScoreThreshold:
|
knowledgeScoreThreshold:
|
||||||
settings.knowledgeRetrievalConfig.scoreThreshold,
|
settings.knowledgeRetrievalConfig.scoreThreshold,
|
||||||
|
enableInterrupt: settings.allowInterrupt,
|
||||||
|
turnConfig: settings.turnConfig,
|
||||||
};
|
};
|
||||||
onChangeRef.current?.(graph);
|
onChangeRef.current?.(graph);
|
||||||
}, [
|
}, [
|
||||||
@@ -273,6 +278,8 @@ function Canvas({
|
|||||||
settings.toolIds,
|
settings.toolIds,
|
||||||
settings.knowledgeBaseId,
|
settings.knowledgeBaseId,
|
||||||
settings.knowledgeRetrievalConfig,
|
settings.knowledgeRetrievalConfig,
|
||||||
|
settings.allowInterrupt,
|
||||||
|
settings.turnConfig,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
@@ -1519,6 +1526,9 @@ function AgentPanelForm({
|
|||||||
topN: Number(draft.knowledgeTopN ?? 5),
|
topN: Number(draft.knowledgeTopN ?? 5),
|
||||||
scoreThreshold: Number(draft.knowledgeScoreThreshold ?? 0),
|
scoreThreshold: Number(draft.knowledgeScoreThreshold ?? 0),
|
||||||
};
|
};
|
||||||
|
const agentTurnConfig = normalizeTurnConfig(
|
||||||
|
draft.turnConfig ?? workflowSettings.turnConfig,
|
||||||
|
);
|
||||||
|
|
||||||
const setInheritance = (inheritGlobalConfig: boolean) => {
|
const setInheritance = (inheritGlobalConfig: boolean) => {
|
||||||
if (inheritGlobalConfig) {
|
if (inheritGlobalConfig) {
|
||||||
@@ -1550,6 +1560,9 @@ function AgentPanelForm({
|
|||||||
knowledgeScoreThreshold:
|
knowledgeScoreThreshold:
|
||||||
draft.knowledgeScoreThreshold ??
|
draft.knowledgeScoreThreshold ??
|
||||||
workflowSettings.knowledgeRetrievalConfig.scoreThreshold,
|
workflowSettings.knowledgeRetrievalConfig.scoreThreshold,
|
||||||
|
enableInterrupt:
|
||||||
|
draft.enableInterrupt ?? workflowSettings.allowInterrupt,
|
||||||
|
turnConfig: agentTurnConfig,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1592,10 +1605,10 @@ function AgentPanelForm({
|
|||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<MessageSquareText size={15} />}
|
icon={<MessageSquareText size={15} />}
|
||||||
title="提示词"
|
title={inheritsGlobal ? "任务" : "提示词"}
|
||||||
description={
|
description={
|
||||||
inheritsGlobal
|
inheritsGlobal
|
||||||
? "描述当前阶段任务,并与工作流全局提示词合并"
|
? "描述当前阶段要完成的目标;角色、能力和通用规则继承工作流全局配置"
|
||||||
: "描述当前独立助手的角色、能力和回答要求"
|
: "描述当前独立助手的角色、能力和回答要求"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1603,7 +1616,11 @@ function AgentPanelForm({
|
|||||||
rows={8}
|
rows={8}
|
||||||
value={draft.prompt ?? ""}
|
value={draft.prompt ?? ""}
|
||||||
onChange={(event) => set("prompt", event.target.value)}
|
onChange={(event) => set("prompt", event.target.value)}
|
||||||
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
|
placeholder={
|
||||||
|
inheritsGlobal
|
||||||
|
? "例如:确认用户身份,并收集需要查询的订单编号"
|
||||||
|
: "请输入提示词,描述助手的角色、能力和回答要求"
|
||||||
|
}
|
||||||
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -1714,6 +1731,23 @@ function AgentPanelForm({
|
|||||||
onChange={(toolIds) => set("toolIds", toolIds)}
|
onChange={(toolIds) => set("toolIds", toolIds)}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import * as LucideIcons from "lucide-react";
|
import * as LucideIcons from "lucide-react";
|
||||||
import { Circle, type LucideIcon } from "lucide-react";
|
import { Circle, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import type { NodeSpecDto } from "@/lib/api";
|
import type { NodeSpecDto, TurnConfig } from "@/lib/api";
|
||||||
|
import { defaultTurnConfig } from "@/lib/turn-config";
|
||||||
|
|
||||||
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
|
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
|
||||||
export type ContextPolicy = "inherit" | "fresh";
|
export type ContextPolicy = "inherit" | "fresh";
|
||||||
@@ -37,6 +38,8 @@ export type WorkflowNodeData = {
|
|||||||
llmResourceId?: string;
|
llmResourceId?: string;
|
||||||
asrResourceId?: string;
|
asrResourceId?: string;
|
||||||
ttsResourceId?: string;
|
ttsResourceId?: string;
|
||||||
|
enableInterrupt?: boolean;
|
||||||
|
turnConfig?: TurnConfig;
|
||||||
toolId?: string;
|
toolId?: string;
|
||||||
arguments?: Record<string, unknown>;
|
arguments?: Record<string, unknown>;
|
||||||
resultAssignments?: Record<string, string>;
|
resultAssignments?: Record<string, string>;
|
||||||
@@ -142,6 +145,8 @@ export type WorkflowGraph = {
|
|||||||
knowledgeMode: "automatic" | "on_demand";
|
knowledgeMode: "automatic" | "on_demand";
|
||||||
knowledgeTopN: number;
|
knowledgeTopN: number;
|
||||||
knowledgeScoreThreshold: number;
|
knowledgeScoreThreshold: number;
|
||||||
|
enableInterrupt: boolean;
|
||||||
|
turnConfig: TurnConfig;
|
||||||
};
|
};
|
||||||
nodes: Array<{
|
nodes: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -172,6 +177,8 @@ export function defaultGraph(): WorkflowGraph {
|
|||||||
knowledgeMode: "automatic",
|
knowledgeMode: "automatic",
|
||||||
knowledgeTopN: 5,
|
knowledgeTopN: 5,
|
||||||
knowledgeScoreThreshold: 0,
|
knowledgeScoreThreshold: 0,
|
||||||
|
enableInterrupt: true,
|
||||||
|
turnConfig: defaultTurnConfig(),
|
||||||
},
|
},
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ export type ChatMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AppMessage = Record<string, unknown> & { type?: string };
|
type AppMessage = Record<string, unknown> & { type?: string };
|
||||||
|
type DynamicVariableValue = string | number | boolean;
|
||||||
|
|
||||||
|
function publicVariableSnapshot(
|
||||||
|
value: unknown,
|
||||||
|
): Record<string, DynamicVariableValue> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).filter(([name, item]) =>
|
||||||
|
!name.startsWith("system__") &&
|
||||||
|
!name.startsWith("secret__") &&
|
||||||
|
(typeof item === "string" ||
|
||||||
|
typeof item === "number" ||
|
||||||
|
typeof item === "boolean"),
|
||||||
|
) as [string, DynamicVariableValue][],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class AppSmallWebRTCTransport extends SmallWebRTCTransport {
|
class AppSmallWebRTCTransport extends SmallWebRTCTransport {
|
||||||
onAppMessage?: (message: AppMessage) => void;
|
onAppMessage?: (message: AppMessage) => void;
|
||||||
@@ -185,6 +201,9 @@ export function useVoicePreview(
|
|||||||
const [videoStream, setVideoStream] = useState<MediaStream | null>(null);
|
const [videoStream, setVideoStream] = useState<MediaStream | null>(null);
|
||||||
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
|
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [sessionVariables, setSessionVariables] = useState<
|
||||||
|
Record<string, DynamicVariableValue>
|
||||||
|
>({});
|
||||||
const [callEnded, setCallEnded] = useState(false);
|
const [callEnded, setCallEnded] = useState(false);
|
||||||
const [networkQuality, setNetworkQuality] =
|
const [networkQuality, setNetworkQuality] =
|
||||||
useState<NetworkQuality>("unknown");
|
useState<NetworkQuality>("unknown");
|
||||||
@@ -387,6 +406,8 @@ export function useVoicePreview(
|
|||||||
);
|
);
|
||||||
} else if (msg.type === "node-active" && typeof msg.nodeId === "string") {
|
} else if (msg.type === "node-active" && typeof msg.nodeId === "string") {
|
||||||
onNodeActiveRef.current?.(msg.nodeId);
|
onNodeActiveRef.current?.(msg.nodeId);
|
||||||
|
} else if (msg.type === "workflow-variables") {
|
||||||
|
setSessionVariables(publicVariableSnapshot(msg.variables));
|
||||||
} else if (msg.type === "call-ended") {
|
} else if (msg.type === "call-ended") {
|
||||||
endedByServerRef.current = true;
|
endedByServerRef.current = true;
|
||||||
setCallEnded(true);
|
setCallEnded(true);
|
||||||
@@ -407,6 +428,7 @@ export function useVoicePreview(
|
|||||||
setError(null);
|
setError(null);
|
||||||
setMicWarning(null);
|
setMicWarning(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setSessionVariables(publicVariableSnapshot(options.dynamicVariables ?? {}));
|
||||||
pendingAssistantTurnsRef.current.clear();
|
pendingAssistantTurnsRef.current.clear();
|
||||||
setCallEnded(false);
|
setCallEnded(false);
|
||||||
endedByServerRef.current = false;
|
endedByServerRef.current = false;
|
||||||
@@ -593,6 +615,7 @@ export function useVoicePreview(
|
|||||||
videoStream,
|
videoStream,
|
||||||
remoteStream,
|
remoteStream,
|
||||||
messages,
|
messages,
|
||||||
|
sessionVariables,
|
||||||
callEnded,
|
callEnded,
|
||||||
networkQuality,
|
networkQuality,
|
||||||
audioInputs,
|
audioInputs,
|
||||||
|
|||||||
59
frontend/src/lib/turn-config.ts
Normal file
59
frontend/src/lib/turn-config.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { TurnConfig } from "@/lib/api";
|
||||||
|
|
||||||
|
export function defaultTurnConfig(): TurnConfig {
|
||||||
|
return {
|
||||||
|
bargeIn: { strategy: "vad" },
|
||||||
|
vad: {
|
||||||
|
confidence: 0.7,
|
||||||
|
startSecs: 0.2,
|
||||||
|
stopSecs: 0.2,
|
||||||
|
minVolume: 0.6,
|
||||||
|
},
|
||||||
|
turnDetection: {
|
||||||
|
strategy: "silence",
|
||||||
|
silenceTimeoutSecs: 0.6,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object"
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOr(value: unknown, fallback: number): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value)
|
||||||
|
? value
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill settings introduced after the first Workflow release for old records. */
|
||||||
|
export function normalizeTurnConfig(value: unknown): TurnConfig {
|
||||||
|
const defaults = defaultTurnConfig();
|
||||||
|
const input = record(value);
|
||||||
|
const bargeIn = record(input.bargeIn);
|
||||||
|
const vad = record(input.vad);
|
||||||
|
const turnDetection = record(input.turnDetection);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bargeIn: {
|
||||||
|
strategy:
|
||||||
|
bargeIn.strategy === "transcription" ? "transcription" : "vad",
|
||||||
|
},
|
||||||
|
vad: {
|
||||||
|
confidence: numberOr(vad.confidence, defaults.vad.confidence),
|
||||||
|
startSecs: numberOr(vad.startSecs, defaults.vad.startSecs),
|
||||||
|
stopSecs: numberOr(vad.stopSecs, defaults.vad.stopSecs),
|
||||||
|
minVolume: numberOr(vad.minVolume, defaults.vad.minVolume),
|
||||||
|
},
|
||||||
|
turnDetection: {
|
||||||
|
strategy:
|
||||||
|
turnDetection.strategy === "smart_turn" ? "smart_turn" : "silence",
|
||||||
|
silenceTimeoutSecs: numberOr(
|
||||||
|
turnDetection.silenceTimeoutSecs,
|
||||||
|
defaults.turnDetection.silenceTimeoutSecs,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user