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:
Xin Wang
2026-07-14 11:08:11 +08:00
parent 665f727796
commit f74040adf3
18 changed files with 848 additions and 194 deletions

View File

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

View File

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

View File

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

View File

@@ -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]:

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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

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

View File

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

View File

@@ -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 = [

View File

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

View File

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

View File

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

View File

@@ -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: [
{ {

View File

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

View 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,
),
},
};
}