feat: add deterministic message interaction stages
This commit is contained in:
@@ -100,9 +100,18 @@ class StartupAction(CamelModel):
|
|||||||
required: bool = True
|
required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningMessageConfig(CamelModel):
|
||||||
|
"""Built-in Prompt opening interaction; it is not a reusable LLM tool."""
|
||||||
|
|
||||||
|
title: str = Field(default="重要提示", min_length=1, max_length=120)
|
||||||
|
message: str = Field(min_length=1, max_length=2000)
|
||||||
|
confirm_label: str = Field(default="确认", min_length=1, max_length=40)
|
||||||
|
|
||||||
|
|
||||||
class StartupConfig(CamelModel):
|
class StartupConfig(CamelModel):
|
||||||
execution_mode: Literal["sequential"] = "sequential"
|
execution_mode: Literal["sequential"] = "sequential"
|
||||||
actions: list[StartupAction] = Field(default_factory=list, max_length=5)
|
actions: list[StartupAction] = Field(default_factory=list, max_length=5)
|
||||||
|
opening_message: OpeningMessageConfig | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_unique_action_ids(self):
|
def validate_unique_action_ids(self):
|
||||||
@@ -163,13 +172,17 @@ class AssistantUpsert(CamelModel):
|
|||||||
setattr(self, field, "")
|
setattr(self, field, "")
|
||||||
if "graph" not in allowed:
|
if "graph" not in allowed:
|
||||||
self.graph = {}
|
self.graph = {}
|
||||||
|
if self.type == "workflow":
|
||||||
|
self.greeting = ""
|
||||||
if self.type not in {"prompt", "workflow"}:
|
if self.type not in {"prompt", "workflow"}:
|
||||||
self.tool_ids = []
|
self.tool_ids = []
|
||||||
self.dynamic_variable_definitions = {}
|
self.dynamic_variable_definitions = {}
|
||||||
if self.type != "prompt":
|
if self.type != "prompt":
|
||||||
self.startup = StartupConfig()
|
self.startup = StartupConfig()
|
||||||
if self.runtime_mode == "realtime" and self.startup.actions:
|
if self.runtime_mode == "realtime" and (
|
||||||
raise ValueError("Prompt Realtime 模式暂不支持启动 Action")
|
self.startup.actions or self.startup.opening_message is not None
|
||||||
|
):
|
||||||
|
raise ValueError("Prompt Realtime 模式暂不支持启动阶段")
|
||||||
# 外部托管大脑只能 cascade,拦住不兼容的 realtime
|
# 外部托管大脑只能 cascade,拦住不兼容的 realtime
|
||||||
if self.runtime_mode == "realtime" and self.type not in REALTIME_CAPABLE_TYPES:
|
if self.runtime_mode == "realtime" and self.type not in REALTIME_CAPABLE_TYPES:
|
||||||
raise ValueError(f"类型 {self.type} 不支持 realtime 运行模式")
|
raise ValueError(f"类型 {self.type} 不支持 realtime 运行模式")
|
||||||
|
|||||||
100
backend/services/action_stage.py
Normal file
100
backend/services/action_stage.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Shared deterministic stage for one or more tool Actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from models import RuntimeTool
|
||||||
|
from services.action_runtime import ActionOutcome, ActionRunner, ActionStatus
|
||||||
|
|
||||||
|
|
||||||
|
InputPolicy = Literal["queue", "block"]
|
||||||
|
OutcomeHook = Callable[["StageAction", ActionOutcome], Awaitable[None]]
|
||||||
|
StartedHook = Callable[[], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StageAction:
|
||||||
|
"""One deterministic tool invocation inside an Action stage."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tool: RuntimeTool | None
|
||||||
|
arguments: dict[str, Any] = field(default_factory=dict)
|
||||||
|
result_assignments: dict[str, str] | None = None
|
||||||
|
required: bool = True
|
||||||
|
invocation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActionStageSpec:
|
||||||
|
"""Mode-independent description produced by Prompt or Workflow config."""
|
||||||
|
|
||||||
|
actions: tuple[StageAction, ...] = ()
|
||||||
|
input_policy: InputPolicy = "queue"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActionStageResult:
|
||||||
|
"""Ordered Action outcomes; optional failures do not fail the stage."""
|
||||||
|
|
||||||
|
succeeded: bool
|
||||||
|
outcomes: tuple[ActionOutcome, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class ActionStageRunner:
|
||||||
|
"""Run deterministic tool Actions under one optional user-input gate."""
|
||||||
|
|
||||||
|
def __init__(self, actions: ActionRunner) -> None:
|
||||||
|
self._actions = actions
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
spec: ActionStageSpec,
|
||||||
|
*,
|
||||||
|
set_input_enabled: Callable[[bool], None] | None = None,
|
||||||
|
input_already_blocked: bool = False,
|
||||||
|
release_input_on_failure: bool = True,
|
||||||
|
on_started: StartedHook | None = None,
|
||||||
|
on_outcome: OutcomeHook | None = None,
|
||||||
|
) -> ActionStageResult:
|
||||||
|
input_setter = set_input_enabled
|
||||||
|
block_input = spec.input_policy == "block" and input_setter is not None
|
||||||
|
if block_input and not input_already_blocked:
|
||||||
|
input_setter(False)
|
||||||
|
|
||||||
|
result: ActionStageResult | None = None
|
||||||
|
try:
|
||||||
|
if on_started is not None:
|
||||||
|
await on_started()
|
||||||
|
|
||||||
|
outcomes: list[ActionOutcome] = []
|
||||||
|
succeeded = True
|
||||||
|
for action in spec.actions:
|
||||||
|
outcome = await self._actions.execute(
|
||||||
|
action.tool,
|
||||||
|
action.arguments,
|
||||||
|
result_assignments=action.result_assignments,
|
||||||
|
invocation_id=action.invocation_id,
|
||||||
|
)
|
||||||
|
outcomes.append(outcome)
|
||||||
|
if on_outcome is not None:
|
||||||
|
await on_outcome(action, outcome)
|
||||||
|
if outcome.status == ActionStatus.SUCCESS:
|
||||||
|
continue
|
||||||
|
if outcome.status == ActionStatus.CANCELLED or action.required:
|
||||||
|
succeeded = False
|
||||||
|
break
|
||||||
|
|
||||||
|
result = ActionStageResult(
|
||||||
|
succeeded=succeeded,
|
||||||
|
outcomes=tuple(outcomes),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
if block_input and (
|
||||||
|
release_input_on_failure
|
||||||
|
or (result is not None and result.succeeded)
|
||||||
|
):
|
||||||
|
input_setter(True)
|
||||||
@@ -64,7 +64,7 @@ class CallEndPort(Protocol):
|
|||||||
|
|
||||||
def arm_after_speech(self) -> None: ...
|
def arm_after_speech(self) -> None: ...
|
||||||
|
|
||||||
def track_speech(self) -> None: ...
|
def track_speech(self) -> Awaitable[None] | None: ...
|
||||||
|
|
||||||
async def arm_after_tracked_speech(self) -> None: ...
|
async def arm_after_tracked_speech(self) -> None: ...
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -25,10 +26,18 @@ from services.brains.base import (
|
|||||||
SessionVariableUpdate,
|
SessionVariableUpdate,
|
||||||
)
|
)
|
||||||
from services.action_runtime import (
|
from services.action_runtime import (
|
||||||
|
ActionOutcome,
|
||||||
ActionInvocationCancelled,
|
ActionInvocationCancelled,
|
||||||
ActionRunner,
|
ActionRunner,
|
||||||
ActionStatus,
|
ActionStatus,
|
||||||
)
|
)
|
||||||
|
from services.action_stage import ActionStageRunner, ActionStageSpec, StageAction
|
||||||
|
from services.fixed_speech import FixedSpeechOutput
|
||||||
|
from services.message_stage import (
|
||||||
|
MessageDisplaySpec,
|
||||||
|
MessageStageRunner,
|
||||||
|
MessageStageSpec,
|
||||||
|
)
|
||||||
from services.runtime_variables import DynamicVariableStore
|
from services.runtime_variables import DynamicVariableStore
|
||||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||||
from services.tool_policy import policy_for_tool
|
from services.tool_policy import policy_for_tool
|
||||||
@@ -50,17 +59,31 @@ class PromptBrain(BaseBrain):
|
|||||||
self._store = DynamicVariableStore.from_config(cfg)
|
self._store = DynamicVariableStore.from_config(cfg)
|
||||||
self._tools = ToolExecutor(self._store)
|
self._tools = ToolExecutor(self._store)
|
||||||
self._actions = ActionRunner(self._tools)
|
self._actions = ActionRunner(self._tools)
|
||||||
|
self._action_stages = ActionStageRunner(self._actions)
|
||||||
|
self._message_stages = MessageStageRunner()
|
||||||
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
||||||
self._runtime: BrainRuntime | None = None
|
self._runtime: BrainRuntime | None = None
|
||||||
|
self._output: FixedSpeechOutput | None = None
|
||||||
self._waiting_for_generated_end_speech = False
|
self._waiting_for_generated_end_speech = False
|
||||||
self._greeting_finished = True
|
|
||||||
self._preflight_finished = False
|
self._preflight_finished = False
|
||||||
self._opening_started = False
|
self._opening_started = False
|
||||||
self._opening_finished = False
|
self._opening_finished = False
|
||||||
|
self._opening_input_blocked = False
|
||||||
self._startup_failed = False
|
self._startup_failed = False
|
||||||
|
|
||||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||||
return self._store.render(cfg.greeting) if self._dynamic_enabled else cfg.greeting
|
# The built-in opening Message owns the greeting so speech and the
|
||||||
|
# client dialog can start as one atomic stage.
|
||||||
|
if self._opening_message() is not None:
|
||||||
|
return ""
|
||||||
|
return self._render_greeting(cfg)
|
||||||
|
|
||||||
|
def _render_greeting(self, cfg: AssistantConfig) -> str:
|
||||||
|
return (
|
||||||
|
self._store.render(cfg.greeting)
|
||||||
|
if self._dynamic_enabled
|
||||||
|
else cfg.greeting
|
||||||
|
)
|
||||||
|
|
||||||
def system_prompt(self, cfg: AssistantConfig) -> str:
|
def system_prompt(self, cfg: AssistantConfig) -> str:
|
||||||
return self._store.render(cfg.prompt) if self._dynamic_enabled else cfg.prompt
|
return self._store.render(cfg.prompt) if self._dynamic_enabled else cfg.prompt
|
||||||
@@ -77,12 +100,15 @@ class PromptBrain(BaseBrain):
|
|||||||
self._tools,
|
self._tools,
|
||||||
is_session_ending=lambda: runtime.call_end.ending,
|
is_session_ending=lambda: runtime.call_end.ending,
|
||||||
)
|
)
|
||||||
|
self._action_stages = ActionStageRunner(self._actions)
|
||||||
|
self._message_stages = MessageStageRunner(runtime.client_tools)
|
||||||
|
self._output = FixedSpeechOutput(self._store, runtime)
|
||||||
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
||||||
self._waiting_for_generated_end_speech = False
|
self._waiting_for_generated_end_speech = False
|
||||||
self._greeting_finished = True
|
|
||||||
self._preflight_finished = False
|
self._preflight_finished = False
|
||||||
self._opening_started = False
|
self._opening_started = False
|
||||||
self._opening_finished = not bool(self._startup_actions("opening"))
|
self._opening_finished = not self._has_opening_stage()
|
||||||
|
self._opening_input_blocked = False
|
||||||
self._startup_failed = False
|
self._startup_failed = False
|
||||||
llm_tool_ids = (
|
llm_tool_ids = (
|
||||||
set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None
|
set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None
|
||||||
@@ -119,32 +145,83 @@ class PromptBrain(BaseBrain):
|
|||||||
self._preflight_finished = True
|
self._preflight_finished = True
|
||||||
|
|
||||||
async def on_connected(self, *, greeting_pending: bool = False) -> None:
|
async def on_connected(self, *, greeting_pending: bool = False) -> None:
|
||||||
self._greeting_finished = not greeting_pending
|
|
||||||
if (
|
if (
|
||||||
self._startup_actions("opening")
|
self._has_opening_stage()
|
||||||
and self._runtime is not None
|
and self._runtime is not None
|
||||||
and self._runtime.set_input_enabled is not None
|
and self._runtime.set_input_enabled is not None
|
||||||
):
|
):
|
||||||
self._runtime.set_input_enabled(False)
|
self._runtime.set_input_enabled(False)
|
||||||
|
self._opening_input_blocked = True
|
||||||
|
|
||||||
async def on_client_ready(self) -> None:
|
async def on_client_ready(self) -> None:
|
||||||
|
if self._output is not None:
|
||||||
|
await self._output.mark_client_ready()
|
||||||
if self._opening_started or self._opening_finished or self._startup_failed:
|
if self._opening_started or self._opening_finished or self._startup_failed:
|
||||||
return
|
return
|
||||||
self._opening_started = True
|
self._opening_started = True
|
||||||
|
runtime = self._runtime
|
||||||
|
if runtime is None:
|
||||||
|
raise RuntimeError("PromptBrain 尚未初始化")
|
||||||
|
opening_message = self._opening_message()
|
||||||
|
opening_actions = self._startup_actions("opening")
|
||||||
|
speech = (
|
||||||
|
self._render_greeting(self._cfg).strip()
|
||||||
|
if opening_message is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
if speech:
|
||||||
|
self.prepare_greeting_context(speech, runtime.context)
|
||||||
try:
|
try:
|
||||||
succeeded = await self._run_startup_actions("opening")
|
if opening_message is not None:
|
||||||
|
message_result = await self._message_stages.run(
|
||||||
|
self._opening_message_stage_spec(speech, opening_message),
|
||||||
|
speak=self._speak_opening,
|
||||||
|
set_input_enabled=runtime.set_input_enabled,
|
||||||
|
input_already_blocked=self._opening_input_blocked,
|
||||||
|
release_input_on_success=not bool(opening_actions),
|
||||||
|
release_input_on_failure=False,
|
||||||
|
)
|
||||||
|
if not message_result.succeeded:
|
||||||
|
await self._fail_opening(
|
||||||
|
message_result.error or "开场消息显示失败"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if opening_actions:
|
||||||
|
result = await self._action_stages.run(
|
||||||
|
self._opening_actions_stage_spec(),
|
||||||
|
set_input_enabled=runtime.set_input_enabled,
|
||||||
|
input_already_blocked=self._opening_input_blocked,
|
||||||
|
release_input_on_failure=False,
|
||||||
|
on_outcome=self._publish_opening_outcome,
|
||||||
|
)
|
||||||
|
if not result.succeeded:
|
||||||
|
await self._fail_opening("必需的开场 Action 执行失败")
|
||||||
|
return
|
||||||
except ActionInvocationCancelled:
|
except ActionInvocationCancelled:
|
||||||
self._startup_failed = True
|
self._startup_failed = True
|
||||||
raise
|
raise
|
||||||
if not succeeded:
|
|
||||||
await self._fail_opening("必需的开场 Action 执行失败")
|
|
||||||
return
|
|
||||||
self._opening_finished = True
|
self._opening_finished = True
|
||||||
self._release_startup_gate_if_ready()
|
self._opening_input_blocked = False
|
||||||
|
|
||||||
async def on_greeting_finished(self) -> None:
|
async def _speak_opening(self, content: str) -> Awaitable[None] | None:
|
||||||
self._greeting_finished = True
|
if self._output is None:
|
||||||
self._release_startup_gate_if_ready()
|
raise RuntimeError("Prompt 固定播报输出尚未初始化")
|
||||||
|
return await self._output.speak(
|
||||||
|
content,
|
||||||
|
source="prompt-opening-speech",
|
||||||
|
record_history=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _opening_message(self) -> dict[str, Any] | None:
|
||||||
|
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
|
||||||
|
value = startup.get("opening_message", startup.get("openingMessage"))
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
def _has_opening_stage(self) -> bool:
|
||||||
|
return self._opening_message() is not None or bool(
|
||||||
|
self._startup_actions("opening")
|
||||||
|
)
|
||||||
|
|
||||||
def _startup_actions(self, phase: str) -> list[dict[str, Any]]:
|
def _startup_actions(self, phase: str) -> list[dict[str, Any]]:
|
||||||
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
|
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
|
||||||
@@ -154,6 +231,78 @@ class PromptBrain(BaseBrain):
|
|||||||
if isinstance(action, dict) and action.get("phase", "opening") == phase
|
if isinstance(action, dict) and action.get("phase", "opening") == phase
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _opening_actions_stage_spec(self) -> ActionStageSpec:
|
||||||
|
actions = tuple(
|
||||||
|
StageAction(
|
||||||
|
id=str(action.get("id") or "startup_action"),
|
||||||
|
tool=self._tool_by_id.get(
|
||||||
|
str(action.get("tool_id") or action.get("toolId") or "")
|
||||||
|
),
|
||||||
|
arguments=action.get("arguments") or {},
|
||||||
|
required=bool(action.get("required", True)),
|
||||||
|
invocation_id=self._actions.new_invocation_id(),
|
||||||
|
)
|
||||||
|
for action in self._startup_actions("opening")
|
||||||
|
)
|
||||||
|
return ActionStageSpec(
|
||||||
|
actions=actions,
|
||||||
|
input_policy="block",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _opening_message_stage_spec(
|
||||||
|
self,
|
||||||
|
speech: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
) -> MessageStageSpec:
|
||||||
|
return MessageStageSpec(
|
||||||
|
speech=speech,
|
||||||
|
display=MessageDisplaySpec(
|
||||||
|
title=self._store.render(
|
||||||
|
str(config.get("title") or "重要提示")
|
||||||
|
).strip(),
|
||||||
|
message=self._store.render(
|
||||||
|
str(config.get("message") or "")
|
||||||
|
).strip(),
|
||||||
|
confirm_label=self._store.render(
|
||||||
|
str(
|
||||||
|
config.get("confirm_label")
|
||||||
|
or config.get("confirmLabel")
|
||||||
|
or "确认"
|
||||||
|
)
|
||||||
|
).strip(),
|
||||||
|
),
|
||||||
|
require_confirmation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _publish_opening_outcome(
|
||||||
|
self,
|
||||||
|
action: StageAction,
|
||||||
|
outcome: ActionOutcome,
|
||||||
|
) -> None:
|
||||||
|
if outcome.updated_variables:
|
||||||
|
self._refresh_prompt()
|
||||||
|
if self._runtime is not None:
|
||||||
|
await self._runtime.queue_frame(
|
||||||
|
OutputTransportMessageUrgentFrame(
|
||||||
|
message={
|
||||||
|
"type": "startup-action-result",
|
||||||
|
"actionId": action.id,
|
||||||
|
"phase": "opening",
|
||||||
|
"outcome": outcome.trace_payload(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if outcome.status == ActionStatus.FAILURE and action.required:
|
||||||
|
logger.warning(
|
||||||
|
f"必需的 Prompt opening Action 失败: "
|
||||||
|
f"action={action.id} error={outcome.error}"
|
||||||
|
)
|
||||||
|
elif outcome.status == ActionStatus.FAILURE:
|
||||||
|
logger.warning(
|
||||||
|
f"忽略可选 Prompt opening Action 失败: "
|
||||||
|
f"action={action.id} error={outcome.error}"
|
||||||
|
)
|
||||||
|
|
||||||
async def _run_startup_actions(self, phase: str) -> bool:
|
async def _run_startup_actions(self, phase: str) -> bool:
|
||||||
for action in self._startup_actions(phase):
|
for action in self._startup_actions(phase):
|
||||||
action_id = str(action.get("id") or "startup_action")
|
action_id = str(action.get("id") or "startup_action")
|
||||||
@@ -170,17 +319,6 @@ class PromptBrain(BaseBrain):
|
|||||||
)
|
)
|
||||||
if outcome.updated_variables:
|
if outcome.updated_variables:
|
||||||
self._refresh_prompt()
|
self._refresh_prompt()
|
||||||
if phase == "opening" and self._runtime is not None:
|
|
||||||
await self._runtime.queue_frame(
|
|
||||||
OutputTransportMessageUrgentFrame(
|
|
||||||
message={
|
|
||||||
"type": "startup-action-result",
|
|
||||||
"actionId": action_id,
|
|
||||||
"phase": phase,
|
|
||||||
"outcome": outcome.trace_payload(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if outcome.status == ActionStatus.SUCCESS:
|
if outcome.status == ActionStatus.SUCCESS:
|
||||||
continue
|
continue
|
||||||
if outcome.status == ActionStatus.CANCELLED:
|
if outcome.status == ActionStatus.CANCELLED:
|
||||||
@@ -197,18 +335,6 @@ class PromptBrain(BaseBrain):
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _release_startup_gate_if_ready(self) -> None:
|
|
||||||
runtime = self._runtime
|
|
||||||
if (
|
|
||||||
runtime is not None
|
|
||||||
and runtime.set_input_enabled is not None
|
|
||||||
and self._greeting_finished
|
|
||||||
and self._opening_finished
|
|
||||||
and not self._startup_failed
|
|
||||||
and not runtime.call_end.ending
|
|
||||||
):
|
|
||||||
runtime.set_input_enabled(True)
|
|
||||||
|
|
||||||
async def _fail_opening(self, message: str) -> None:
|
async def _fail_opening(self, message: str) -> None:
|
||||||
self._startup_failed = True
|
self._startup_failed = True
|
||||||
runtime = self._runtime
|
runtime = self._runtime
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -40,7 +41,14 @@ from services.action_runtime import (
|
|||||||
ActionRunner,
|
ActionRunner,
|
||||||
ActionStatus,
|
ActionStatus,
|
||||||
)
|
)
|
||||||
|
from services.action_stage import ActionStageRunner, ActionStageSpec, StageAction
|
||||||
from services.knowledge import search as search_knowledge
|
from services.knowledge import search as search_knowledge
|
||||||
|
from services.message_stage import (
|
||||||
|
MessageDisplaySpec,
|
||||||
|
MessageStageResult,
|
||||||
|
MessageStageRunner,
|
||||||
|
MessageStageSpec,
|
||||||
|
)
|
||||||
from services.runtime_variables import DynamicVariableStore
|
from services.runtime_variables import DynamicVariableStore
|
||||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||||
from services.tool_policy import policy_for_tool
|
from services.tool_policy import policy_for_tool
|
||||||
@@ -110,6 +118,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._store = DynamicVariableStore.from_config(cfg or AssistantConfig(type="workflow"))
|
self._store = DynamicVariableStore.from_config(cfg or AssistantConfig(type="workflow"))
|
||||||
self._tools = ToolExecutor(self._store)
|
self._tools = ToolExecutor(self._store)
|
||||||
self._actions = ActionRunner(self._tools)
|
self._actions = ActionRunner(self._tools)
|
||||||
|
self._action_stages = ActionStageRunner(self._actions)
|
||||||
|
self._message_stages = MessageStageRunner()
|
||||||
self._tool_by_id: dict[str, RuntimeTool] = {
|
self._tool_by_id: dict[str, RuntimeTool] = {
|
||||||
tool.id: tool for tool in (cfg.tools if cfg else [])
|
tool.id: tool for tool in (cfg.tools if cfg else [])
|
||||||
}
|
}
|
||||||
@@ -126,11 +136,10 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._output: WorkflowOutput | None = None
|
self._output: WorkflowOutput | None = None
|
||||||
self._agent_stage: WorkflowAgentStage | None = None
|
self._agent_stage: WorkflowAgentStage | None = None
|
||||||
self._ended = False
|
self._ended = False
|
||||||
self._greeting_context_message: dict[str, str] | None = None
|
|
||||||
self._startup_waiting_for_greeting = False
|
|
||||||
|
|
||||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
async def greeting(self, _cfg: AssistantConfig) -> str:
|
||||||
return self._engine.greeting(self._store) or cfg.greeting
|
"""Workflow opening speech belongs to an explicit Message or Agent."""
|
||||||
|
return ""
|
||||||
|
|
||||||
def system_prompt(self, cfg: AssistantConfig) -> str:
|
def system_prompt(self, cfg: AssistantConfig) -> str:
|
||||||
return self._store.render(self._engine.global_prompt())
|
return self._store.render(self._engine.global_prompt())
|
||||||
@@ -151,6 +160,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._tools,
|
self._tools,
|
||||||
is_session_ending=lambda: runtime.call_end.ending,
|
is_session_ending=lambda: runtime.call_end.ending,
|
||||||
)
|
)
|
||||||
|
self._action_stages = ActionStageRunner(self._actions)
|
||||||
|
self._message_stages = MessageStageRunner(runtime.client_tools)
|
||||||
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
|
||||||
self._router = WorkflowLLMRouter(cfg)
|
self._router = WorkflowLLMRouter(cfg)
|
||||||
self._edge_evaluator = WorkflowEdgeEvaluator(
|
self._edge_evaluator = WorkflowEdgeEvaluator(
|
||||||
@@ -168,8 +179,6 @@ class WorkflowBrain(BaseBrain):
|
|||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
self._ended = False
|
self._ended = False
|
||||||
self._greeting_context_message = None
|
|
||||||
self._startup_waiting_for_greeting = False
|
|
||||||
self._manager = ConfiguredFlowManager(
|
self._manager = ConfiguredFlowManager(
|
||||||
worker=runtime.worker,
|
worker=runtime.worker,
|
||||||
llm=runtime.llm,
|
llm=runtime.llm,
|
||||||
@@ -179,15 +188,6 @@ class WorkflowBrain(BaseBrain):
|
|||||||
)
|
)
|
||||||
self._manager.state["variables"] = self._store.values
|
self._manager.state["variables"] = self._store.values
|
||||||
|
|
||||||
def prepare_greeting_context(
|
|
||||||
self,
|
|
||||||
greeting: str,
|
|
||||||
context: LLMContext,
|
|
||||||
) -> dict[str, str] | None:
|
|
||||||
message = super().prepare_greeting_context(greeting, context)
|
|
||||||
self._greeting_context_message = deepcopy(message) if message else None
|
|
||||||
return message
|
|
||||||
|
|
||||||
async def on_connected(self, *, greeting_pending: bool = False) -> None:
|
async def on_connected(self, *, greeting_pending: bool = False) -> None:
|
||||||
self._state.enter(self._engine.start_id, WorkflowStatus.STARTING)
|
self._state.enter(self._engine.start_id, WorkflowStatus.STARTING)
|
||||||
await self._emit_node_active(self._engine.start_id)
|
await self._emit_node_active(self._engine.start_id)
|
||||||
@@ -198,39 +198,11 @@ class WorkflowBrain(BaseBrain):
|
|||||||
if self._manager is None:
|
if self._manager is None:
|
||||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||||
|
|
||||||
self._startup_waiting_for_greeting = greeting_pending
|
|
||||||
if greeting_pending:
|
|
||||||
# Keep the Workflow on Start until the transport confirms that the
|
|
||||||
# shared greeting has finished. This prevents an initial Agent's
|
|
||||||
# fixed speech (or generated reply) from racing the greeting.
|
|
||||||
await self._manager.initialize(
|
|
||||||
self._passive_node_config(self._engine.start_id)
|
|
||||||
)
|
|
||||||
logger.info("工作流等待 Start 开场白播放完毕")
|
|
||||||
return
|
|
||||||
|
|
||||||
node_config = await self._initial_node_config()
|
node_config = await self._initial_node_config()
|
||||||
await self._manager.initialize(node_config)
|
await self._manager.initialize(node_config)
|
||||||
await self._after_node_activated(node_config)
|
await self._after_node_activated(node_config)
|
||||||
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
||||||
|
|
||||||
async def on_greeting_finished(self) -> None:
|
|
||||||
"""Enter the first node only after Start's greeting reaches playback end."""
|
|
||||||
if not self._startup_waiting_for_greeting or self._ended:
|
|
||||||
return
|
|
||||||
self._startup_waiting_for_greeting = False
|
|
||||||
manager = self._require_manager()
|
|
||||||
if manager.current_node != self._engine.start_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
node_config = await self._initial_node_config()
|
|
||||||
if node_config.get("name") == self._engine.start_id:
|
|
||||||
self._state.enter(self._engine.start_id, WorkflowStatus.WAITING_USER)
|
|
||||||
return
|
|
||||||
await manager.set_node_from_config(node_config)
|
|
||||||
await self._after_node_activated(node_config)
|
|
||||||
logger.info(f"Start 开场白结束,进入节点: {manager.current_node}")
|
|
||||||
|
|
||||||
async def _initial_node_config(self) -> NodeConfig:
|
async def _initial_node_config(self) -> NodeConfig:
|
||||||
"""Only a default-only Start advances before the first user turn."""
|
"""Only a default-only Start advances before the first user turn."""
|
||||||
outgoing = self._engine.outgoing(self._engine.start_id)
|
outgoing = self._engine.outgoing(self._engine.start_id)
|
||||||
@@ -409,7 +381,6 @@ class WorkflowBrain(BaseBrain):
|
|||||||
return self._require_agent_stage().node_config(
|
return self._require_agent_stage().node_config(
|
||||||
node_id,
|
node_id,
|
||||||
functions=functions,
|
functions=functions,
|
||||||
greeting_context_message=self._greeting_context_message,
|
|
||||||
leading_messages=leading_messages,
|
leading_messages=leading_messages,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -455,8 +426,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
*,
|
*,
|
||||||
source: str = "workflow-speech",
|
source: str = "workflow-speech",
|
||||||
node_id: str | None = None,
|
node_id: str | None = None,
|
||||||
) -> None:
|
) -> Awaitable[None] | None:
|
||||||
await self._require_output().speak(
|
return await self._require_output().speak(
|
||||||
text,
|
text,
|
||||||
source=source,
|
source=source,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
@@ -700,6 +671,14 @@ class WorkflowBrain(BaseBrain):
|
|||||||
outcome = await self._enter_action(node_id)
|
outcome = await self._enter_action(node_id)
|
||||||
if not outcome.should_route:
|
if not outcome.should_route:
|
||||||
return self._passive_node_config(node_id, context_messages)
|
return self._passive_node_config(node_id, context_messages)
|
||||||
|
elif node_type == "message":
|
||||||
|
message_result = await self._enter_message(node_id)
|
||||||
|
if not message_result.succeeded:
|
||||||
|
return self._passive_node_config(node_id, context_messages)
|
||||||
|
if message_result.speech:
|
||||||
|
context_messages.append(
|
||||||
|
{"role": "assistant", "content": message_result.speech}
|
||||||
|
)
|
||||||
elif node_type == "handoff":
|
elif node_type == "handoff":
|
||||||
await self._enter_handoff(node_id)
|
await self._enter_handoff(node_id)
|
||||||
elif node_type == "start":
|
elif node_type == "start":
|
||||||
@@ -735,29 +714,36 @@ class WorkflowBrain(BaseBrain):
|
|||||||
data = self._engine.data(node_id)
|
data = self._engine.data(node_id)
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
invocation_id = self._actions.new_invocation_id()
|
invocation_id = self._actions.new_invocation_id()
|
||||||
block_user_input = data.get("userInputPolicy") == "block"
|
|
||||||
if block_user_input and runtime.set_input_enabled:
|
|
||||||
# Blocking only suppresses new audio/text input while the Action
|
|
||||||
# runs. It deliberately does not cancel the tool. The default
|
|
||||||
# queue policy leaves input enabled; the turn lock serializes any
|
|
||||||
# completed user turn until this automatic path has finished.
|
|
||||||
runtime.set_input_enabled(False)
|
|
||||||
tool_id = str(data.get("toolId") or "")
|
tool_id = str(data.get("toolId") or "")
|
||||||
tool = self._tool_by_id.get(tool_id)
|
tool = self._tool_by_id.get(tool_id)
|
||||||
try:
|
try:
|
||||||
await self._emit_trace(
|
stage_result = await self._action_stages.run(
|
||||||
|
ActionStageSpec(
|
||||||
|
actions=(
|
||||||
|
StageAction(
|
||||||
|
id=node_id,
|
||||||
|
tool=tool,
|
||||||
|
arguments=data.get("arguments") or {},
|
||||||
|
result_assignments=self._action_result_assignments(data),
|
||||||
|
invocation_id=invocation_id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
input_policy=(
|
||||||
|
"block"
|
||||||
|
if data.get("userInputPolicy") == "block"
|
||||||
|
else "queue"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
set_input_enabled=runtime.set_input_enabled,
|
||||||
|
on_started=lambda: self._emit_trace(
|
||||||
"action_started",
|
"action_started",
|
||||||
nodeId=node_id,
|
nodeId=node_id,
|
||||||
invocationId=invocation_id,
|
invocationId=invocation_id,
|
||||||
toolId=tool_id,
|
toolId=tool_id,
|
||||||
toolType=tool.type if tool else None,
|
toolType=tool.type if tool else None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
outcome = await self._actions.execute(
|
outcome = stage_result.outcomes[0]
|
||||||
tool,
|
|
||||||
data.get("arguments") or {},
|
|
||||||
result_assignments=self._action_result_assignments(data),
|
|
||||||
invocation_id=invocation_id,
|
|
||||||
)
|
|
||||||
updated_variables = list(outcome.updated_variables)
|
updated_variables = list(outcome.updated_variables)
|
||||||
if updated_variables:
|
if updated_variables:
|
||||||
await self._emit_variables(
|
await self._emit_variables(
|
||||||
@@ -770,13 +756,74 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._set_last_action(outcome)
|
self._set_last_action(outcome)
|
||||||
await self._emit_action_outcome(node_id, outcome)
|
await self._emit_action_outcome(node_id, outcome)
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
if block_user_input and runtime.set_input_enabled:
|
|
||||||
runtime.set_input_enabled(True)
|
|
||||||
self._set_last_action(outcome)
|
self._set_last_action(outcome)
|
||||||
await self._emit_action_outcome(node_id, outcome)
|
await self._emit_action_outcome(node_id, outcome)
|
||||||
return outcome
|
return outcome
|
||||||
|
|
||||||
|
async def _enter_message(self, node_id: str) -> MessageStageResult:
|
||||||
|
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
||||||
|
await self._emit_node_active(node_id)
|
||||||
|
data = self._engine.data(node_id)
|
||||||
|
runtime = self._require_runtime()
|
||||||
|
speech = self._store.render(str(data.get("speech") or "")).strip()
|
||||||
|
show_message = bool(data.get("showMessage", False))
|
||||||
|
require_confirmation = bool(data.get("requireConfirmation", False))
|
||||||
|
display = (
|
||||||
|
MessageDisplaySpec(
|
||||||
|
title=self._store.render(
|
||||||
|
str(data.get("title") or "重要提示")
|
||||||
|
).strip(),
|
||||||
|
message=self._store.render(
|
||||||
|
str(data.get("message") or "")
|
||||||
|
).strip(),
|
||||||
|
confirm_label=self._store.render(
|
||||||
|
str(data.get("confirmLabel") or "确认")
|
||||||
|
).strip(),
|
||||||
|
)
|
||||||
|
if show_message
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
result = await self._message_stages.run(
|
||||||
|
MessageStageSpec(
|
||||||
|
speech=speech,
|
||||||
|
display=display,
|
||||||
|
require_confirmation=require_confirmation,
|
||||||
|
),
|
||||||
|
speak=lambda content: self._queue_visible_speech(
|
||||||
|
content,
|
||||||
|
source="workflow-message-speech",
|
||||||
|
node_id=node_id,
|
||||||
|
),
|
||||||
|
set_input_enabled=runtime.set_input_enabled,
|
||||||
|
on_started=lambda: self._emit_trace(
|
||||||
|
"message_started",
|
||||||
|
nodeId=node_id,
|
||||||
|
hasSpeech=bool(speech),
|
||||||
|
showsMessage=show_message,
|
||||||
|
requiresConfirmation=require_confirmation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if result.succeeded:
|
||||||
|
await self._emit_trace(
|
||||||
|
"message_completed",
|
||||||
|
nodeId=node_id,
|
||||||
|
action=result.action,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||||
|
await self._emit_trace(
|
||||||
|
"message_failed",
|
||||||
|
nodeId=node_id,
|
||||||
|
error=result.error or "Message 节点执行失败",
|
||||||
|
)
|
||||||
|
await self._require_output().emit_error(
|
||||||
|
result.error or "Message 节点执行失败",
|
||||||
|
node_id=node_id,
|
||||||
|
code="workflow_message_error",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def _set_last_action(self, outcome: ActionOutcome) -> None:
|
def _set_last_action(self, outcome: ActionOutcome) -> None:
|
||||||
legacy_status = {
|
legacy_status = {
|
||||||
ActionStatus.SUCCESS: "ok",
|
ActionStatus.SUCCESS: "ok",
|
||||||
|
|||||||
73
backend/services/fixed_speech.py
Normal file
73
backend/services/fixed_speech.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Shared client-visible output for deterministic fixed speech."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||||
|
from pipecat.utils.time import time_now_iso8601
|
||||||
|
|
||||||
|
from services.brains.base import BrainRuntime
|
||||||
|
from services.runtime_variables import DynamicVariableStore
|
||||||
|
|
||||||
|
|
||||||
|
class FixedSpeechOutput:
|
||||||
|
"""Display and synthesize fixed speech without waiting for playback."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: DynamicVariableStore,
|
||||||
|
runtime: BrainRuntime,
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._runtime = runtime
|
||||||
|
self._client_ready = False
|
||||||
|
self._pending_transcripts: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def mark_client_ready(self) -> None:
|
||||||
|
self._client_ready = True
|
||||||
|
pending = self._pending_transcripts
|
||||||
|
self._pending_transcripts = []
|
||||||
|
for message in pending:
|
||||||
|
await self.emit(message)
|
||||||
|
|
||||||
|
async def speak(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
node_id: str | None = None,
|
||||||
|
record_history: bool = True,
|
||||||
|
) -> Awaitable[None] | None:
|
||||||
|
content = text.strip()
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
if record_history:
|
||||||
|
self._store.record("agent", content)
|
||||||
|
transcript = {
|
||||||
|
"type": "transcript",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content,
|
||||||
|
"timestamp": time_now_iso8601(),
|
||||||
|
"source": source,
|
||||||
|
**({"nodeId": node_id} if node_id else {}),
|
||||||
|
}
|
||||||
|
if self._client_ready:
|
||||||
|
await self.emit(transcript)
|
||||||
|
else:
|
||||||
|
self._pending_transcripts.append(transcript)
|
||||||
|
|
||||||
|
track_speech = getattr(self._runtime.call_end, "track_speech", None)
|
||||||
|
playback_completion: Awaitable[None] | None = None
|
||||||
|
if callable(track_speech):
|
||||||
|
playback_completion = track_speech()
|
||||||
|
await self._runtime.queue_frame(
|
||||||
|
TTSSpeakFrame(content, append_to_context=False)
|
||||||
|
)
|
||||||
|
return playback_completion
|
||||||
|
|
||||||
|
async def emit(self, message: dict[str, Any]) -> None:
|
||||||
|
await self._runtime.queue_frame(
|
||||||
|
OutputTransportMessageUrgentFrame(message=message)
|
||||||
|
)
|
||||||
197
backend/services/message_stage.py
Normal file
197
backend/services/message_stage.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""Deterministic speech and client-message interaction shared by all brains."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from services.client_tools import ClientToolError, ClientToolPort
|
||||||
|
|
||||||
|
|
||||||
|
BUILTIN_SHOW_MESSAGE = "show_message"
|
||||||
|
SpeechCompletion = Awaitable[None] | None
|
||||||
|
Speak = Callable[[str], Awaitable[SpeechCompletion]]
|
||||||
|
StartedHook = Callable[[], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MessageDisplaySpec:
|
||||||
|
"""Content rendered by the platform-provided client message dialog."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
confirm_label: str = "确认"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MessageStageSpec:
|
||||||
|
"""Mode-independent fixed speech and optional client interaction."""
|
||||||
|
|
||||||
|
speech: str = ""
|
||||||
|
display: MessageDisplaySpec | None = None
|
||||||
|
require_confirmation: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MessageStageResult:
|
||||||
|
"""Result used by Workflow routing and Prompt opening failure handling."""
|
||||||
|
|
||||||
|
succeeded: bool
|
||||||
|
speech: str = ""
|
||||||
|
action: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageStageRunner:
|
||||||
|
"""Run one atomic user-visible message stage.
|
||||||
|
|
||||||
|
Speech is queued before the client message is dispatched. A confirmation
|
||||||
|
stage completes when the user confirms, even if audio is still playing. A
|
||||||
|
speech-only stage completes at the real transport playback boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client_tools: ClientToolPort | None = None) -> None:
|
||||||
|
self._client_tools = client_tools
|
||||||
|
|
||||||
|
def set_client_tools(self, client_tools: ClientToolPort | None) -> None:
|
||||||
|
self._client_tools = client_tools
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
spec: MessageStageSpec,
|
||||||
|
*,
|
||||||
|
speak: Speak | None = None,
|
||||||
|
set_input_enabled: Callable[[bool], None] | None = None,
|
||||||
|
input_already_blocked: bool = False,
|
||||||
|
release_input_on_success: bool = True,
|
||||||
|
release_input_on_failure: bool = True,
|
||||||
|
on_started: StartedHook | None = None,
|
||||||
|
) -> MessageStageResult:
|
||||||
|
input_setter = set_input_enabled
|
||||||
|
if input_setter is not None and not input_already_blocked:
|
||||||
|
input_setter(False)
|
||||||
|
|
||||||
|
result: MessageStageResult | None = None
|
||||||
|
try:
|
||||||
|
if on_started is not None:
|
||||||
|
await on_started()
|
||||||
|
|
||||||
|
speech = spec.speech.strip()
|
||||||
|
if spec.require_confirmation and spec.display is None:
|
||||||
|
result = MessageStageResult(
|
||||||
|
succeeded=False,
|
||||||
|
speech=speech,
|
||||||
|
error="等待用户确认时必须显示客户端消息",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
if not speech and spec.display is None:
|
||||||
|
result = MessageStageResult(
|
||||||
|
succeeded=False,
|
||||||
|
error="Message 阶段至少需要播报或客户端消息",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
playback_completion: SpeechCompletion = None
|
||||||
|
if speech and speak is not None:
|
||||||
|
playback_completion = await speak(speech)
|
||||||
|
|
||||||
|
action: str | None = None
|
||||||
|
if spec.display is not None:
|
||||||
|
result = await self._show_message(spec, speech=speech)
|
||||||
|
if not result.succeeded:
|
||||||
|
return result
|
||||||
|
action = result.action
|
||||||
|
|
||||||
|
# Confirmation is the gate. It deliberately does not wait for the
|
||||||
|
# audio completion future, so the user can continue immediately.
|
||||||
|
if (
|
||||||
|
playback_completion is not None
|
||||||
|
and not spec.require_confirmation
|
||||||
|
):
|
||||||
|
await playback_completion
|
||||||
|
|
||||||
|
result = MessageStageResult(
|
||||||
|
succeeded=True,
|
||||||
|
speech=speech,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - surface deterministic stage failure
|
||||||
|
result = MessageStageResult(
|
||||||
|
succeeded=False,
|
||||||
|
speech=spec.speech.strip(),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
should_release = (
|
||||||
|
result is not None
|
||||||
|
and (
|
||||||
|
(result.succeeded and release_input_on_success)
|
||||||
|
or (not result.succeeded and release_input_on_failure)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if input_setter is not None and should_release:
|
||||||
|
input_setter(True)
|
||||||
|
|
||||||
|
async def _show_message(
|
||||||
|
self,
|
||||||
|
spec: MessageStageSpec,
|
||||||
|
*,
|
||||||
|
speech: str,
|
||||||
|
) -> MessageStageResult:
|
||||||
|
display = spec.display
|
||||||
|
if display is None:
|
||||||
|
return MessageStageResult(succeeded=True, speech=speech)
|
||||||
|
if self._client_tools is None:
|
||||||
|
return MessageStageResult(
|
||||||
|
succeeded=False,
|
||||||
|
speech=speech,
|
||||||
|
error="当前运行模式不支持客户端消息",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await self._client_tools.call(
|
||||||
|
BUILTIN_SHOW_MESSAGE,
|
||||||
|
{
|
||||||
|
"title": display.title,
|
||||||
|
"message": display.message,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "confirmed",
|
||||||
|
"label": display.confirm_label,
|
||||||
|
"style": "primary",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dismissible": not spec.require_confirmation,
|
||||||
|
},
|
||||||
|
timeout_seconds=3,
|
||||||
|
wait_for_response=spec.require_confirmation,
|
||||||
|
response_wait_mode=(
|
||||||
|
"session" if spec.require_confirmation else "timeout"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except ClientToolError as exc:
|
||||||
|
return MessageStageResult(
|
||||||
|
succeeded=False,
|
||||||
|
speech=speech,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
if response.get("status") != "ok":
|
||||||
|
return MessageStageResult(
|
||||||
|
succeeded=False,
|
||||||
|
speech=speech,
|
||||||
|
error=str(response.get("message") or "客户端消息显示失败"),
|
||||||
|
)
|
||||||
|
data = response.get("data")
|
||||||
|
action = (
|
||||||
|
str(data.get("action") or "") or None
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return MessageStageResult(
|
||||||
|
succeeded=True,
|
||||||
|
speech=speech,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
@@ -8,12 +8,12 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
SPEC_VERSION = "3"
|
SPEC_VERSION = "3"
|
||||||
NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
|
NODE_TYPES = {"start", "agent", "message", "action", "handoff", "end"}
|
||||||
EDGE_MODES = {"llm", "expression", "always"}
|
EDGE_MODES = {"llm", "expression", "always"}
|
||||||
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
|
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
|
||||||
ACTION_RESULT_ASSIGNMENT_MODES = {"inherit", "override", "none"}
|
ACTION_RESULT_ASSIGNMENT_MODES = {"inherit", "override", "none"}
|
||||||
ACTION_USER_INPUT_POLICIES = {"queue", "block"}
|
ACTION_USER_INPUT_POLICIES = {"queue", "block"}
|
||||||
AUTOMATIC_NODE_TYPES = {"start", "action", "handoff"}
|
AUTOMATIC_NODE_TYPES = {"start", "message", "action", "handoff"}
|
||||||
EXPRESSION_OPERATORS = {
|
EXPRESSION_OPERATORS = {
|
||||||
"eq",
|
"eq",
|
||||||
"neq",
|
"neq",
|
||||||
@@ -31,7 +31,7 @@ NODE_SPECS: list[dict[str, Any]] = [
|
|||||||
"name": "start",
|
"name": "start",
|
||||||
"displayName": "Start",
|
"displayName": "Start",
|
||||||
"category": "control_node",
|
"category": "control_node",
|
||||||
"description": "初始化会话、动态变量和全局观察器,可播放固定开场白。",
|
"description": "初始化会话、动态变量和全局观察器。",
|
||||||
"icon": "Play",
|
"icon": "Play",
|
||||||
"accent": "mint",
|
"accent": "mint",
|
||||||
"addable": False,
|
"addable": False,
|
||||||
@@ -44,7 +44,6 @@ NODE_SPECS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
"fields": [
|
"fields": [
|
||||||
{"key": "name", "label": "节点名称", "type": "text", "default": "Start"},
|
{"key": "name", "label": "节点名称", "type": "text", "default": "Start"},
|
||||||
{"key": "greeting", "label": "固定开场白", "type": "textarea", "default": ""},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,6 +66,19 @@ NODE_SPECS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "message",
|
||||||
|
"displayName": "Message",
|
||||||
|
"category": "interaction_node",
|
||||||
|
"description": "固定播报,并可同时显示内置客户端消息、等待用户确认。",
|
||||||
|
"icon": "MessageSquareText",
|
||||||
|
"accent": "lavender",
|
||||||
|
"addable": True,
|
||||||
|
"constraints": {"minIncoming": 1, "minOutgoing": 0},
|
||||||
|
"fields": [
|
||||||
|
{"key": "name", "label": "节点名称", "type": "text", "default": "Message"},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "action",
|
"name": "action",
|
||||||
"displayName": "Action",
|
"displayName": "Action",
|
||||||
@@ -172,6 +184,17 @@ def _normalize_action_data(data: dict[str, Any]) -> None:
|
|||||||
)
|
)
|
||||||
data.setdefault("resultAssignments", {})
|
data.setdefault("resultAssignments", {})
|
||||||
data.setdefault("userInputPolicy", "queue")
|
data.setdefault("userInputPolicy", "queue")
|
||||||
|
data.pop("speech", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_message_data(data: dict[str, Any]) -> None:
|
||||||
|
"""Fill the small built-in Message contract used by runtime and editor."""
|
||||||
|
data.setdefault("speech", "")
|
||||||
|
data.setdefault("showMessage", False)
|
||||||
|
data.setdefault("title", "重要提示")
|
||||||
|
data.setdefault("message", "")
|
||||||
|
data.setdefault("confirmLabel", "确认")
|
||||||
|
data.setdefault("requireConfirmation", False)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") -> None:
|
def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") -> None:
|
||||||
@@ -200,8 +223,12 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
source.setdefault("edges", [])
|
source.setdefault("edges", [])
|
||||||
for node in source["nodes"]:
|
for node in source["nodes"]:
|
||||||
data = node.setdefault("data", {})
|
data = node.setdefault("data", {})
|
||||||
if node.get("type") == "agent":
|
if node.get("type") == "start":
|
||||||
|
data.pop("greeting", None)
|
||||||
|
elif node.get("type") == "agent":
|
||||||
_normalize_agent_data(data)
|
_normalize_agent_data(data)
|
||||||
|
elif node.get("type") == "message":
|
||||||
|
_normalize_message_data(data)
|
||||||
elif node.get("type") == "action":
|
elif node.get("type") == "action":
|
||||||
_normalize_action_data(data)
|
_normalize_action_data(data)
|
||||||
return source
|
return source
|
||||||
@@ -218,6 +245,7 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
"endCall": "end",
|
"endCall": "end",
|
||||||
"start": "start",
|
"start": "start",
|
||||||
"agent": "agent",
|
"agent": "agent",
|
||||||
|
"message": "message",
|
||||||
"action": "action",
|
"action": "action",
|
||||||
"handoff": "handoff",
|
"handoff": "handoff",
|
||||||
"end": "end",
|
"end": "end",
|
||||||
@@ -236,10 +264,13 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
data.setdefault("scope", "session")
|
data.setdefault("scope", "session")
|
||||||
elif new_type == "agent":
|
elif new_type == "agent":
|
||||||
_normalize_agent_data(data)
|
_normalize_agent_data(data)
|
||||||
|
elif new_type == "message":
|
||||||
|
_normalize_message_data(data)
|
||||||
elif new_type == "action":
|
elif new_type == "action":
|
||||||
_normalize_action_data(data)
|
_normalize_action_data(data)
|
||||||
elif new_type == "start":
|
elif new_type == "start":
|
||||||
prompt = str(data.pop("prompt", "") or "").strip()
|
prompt = str(data.pop("prompt", "") or "").strip()
|
||||||
|
data.pop("greeting", None)
|
||||||
if prompt:
|
if prompt:
|
||||||
start_prompt_nodes[str(node.get("id"))] = prompt
|
start_prompt_nodes[str(node.get("id"))] = prompt
|
||||||
for key in ("allowInterrupt", "addGlobalPrompt"):
|
for key in ("allowInterrupt", "addGlobalPrompt"):
|
||||||
@@ -349,6 +380,49 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
|||||||
data.get("entrySpeech") or ""
|
data.get("entrySpeech") or ""
|
||||||
).strip():
|
).strip():
|
||||||
errors.append(f"Agent 节点 {node_id} 的固定进入语不能为空")
|
errors.append(f"Agent 节点 {node_id} 的固定进入语不能为空")
|
||||||
|
elif node_type == "message":
|
||||||
|
data = node.get("data") or {}
|
||||||
|
speech = data.get("speech")
|
||||||
|
show_message = data.get("showMessage")
|
||||||
|
require_confirmation = data.get("requireConfirmation")
|
||||||
|
if not isinstance(speech, str):
|
||||||
|
errors.append(f"Message 节点 {node_id} 的播报内容必须是文本")
|
||||||
|
if not isinstance(show_message, bool):
|
||||||
|
errors.append(f"Message 节点 {node_id} 的弹窗开关必须是布尔值")
|
||||||
|
if not isinstance(require_confirmation, bool):
|
||||||
|
errors.append(f"Message 节点 {node_id} 的确认开关必须是布尔值")
|
||||||
|
if require_confirmation and show_message is not True:
|
||||||
|
errors.append(f"Message 节点 {node_id} 等待确认时必须显示弹窗")
|
||||||
|
if not str(speech or "").strip() and show_message is not True:
|
||||||
|
errors.append(f"Message 节点 {node_id} 至少需要播报或显示弹窗")
|
||||||
|
if show_message is True:
|
||||||
|
title = data.get("title")
|
||||||
|
message = data.get("message")
|
||||||
|
confirm_label = data.get("confirmLabel")
|
||||||
|
if (
|
||||||
|
not isinstance(title, str)
|
||||||
|
or not title.strip()
|
||||||
|
or len(title) > 120
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"Message 节点 {node_id} 的弹窗标题必须为 1-120 个字符"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(message, str)
|
||||||
|
or not message.strip()
|
||||||
|
or len(message) > 2000
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"Message 节点 {node_id} 的弹窗消息必须为 1-2000 个字符"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(confirm_label, str)
|
||||||
|
or not confirm_label.strip()
|
||||||
|
or len(confirm_label) > 40
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"Message 节点 {node_id} 的按钮文字必须为 1-40 个字符"
|
||||||
|
)
|
||||||
elif node_type == "action":
|
elif node_type == "action":
|
||||||
data = node.get("data") or {}
|
data = node.get("data") or {}
|
||||||
assignment_mode = data.get("resultAssignmentMode")
|
assignment_mode = data.get("resultAssignmentMode")
|
||||||
@@ -477,7 +551,7 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
|||||||
if node.get("type") != "agent"
|
if node.get("type") != "agent"
|
||||||
)
|
)
|
||||||
if any(visit(node_id) for node_id in automatic_node_ids):
|
if any(visit(node_id) for node_id in automatic_node_ids):
|
||||||
errors.append("Start/Action/Handoff/End 之间不能形成无等待循环")
|
errors.append("自动节点之间不能形成无等待循环")
|
||||||
return list(dict.fromkeys(errors))
|
return list(dict.fromkeys(errors))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -19,6 +21,7 @@ class CallEndCoordinator:
|
|||||||
self._speaking = False
|
self._speaking = False
|
||||||
self._response_speech_started = False
|
self._response_speech_started = False
|
||||||
self._tracked_speeches = 0
|
self._tracked_speeches = 0
|
||||||
|
self._tracked_speech_completions: deque[asyncio.Future[None]] = deque()
|
||||||
self._finish_after_tracked_speech = False
|
self._finish_after_tracked_speech = False
|
||||||
self._finished = False
|
self._finished = False
|
||||||
self._reason = "completed"
|
self._reason = "completed"
|
||||||
@@ -39,9 +42,12 @@ class CallEndCoordinator:
|
|||||||
"""Wait for the next observed bot speech to finish."""
|
"""Wait for the next observed bot speech to finish."""
|
||||||
self._armed = True
|
self._armed = True
|
||||||
|
|
||||||
def track_speech(self) -> None:
|
def track_speech(self) -> Awaitable[None]:
|
||||||
"""Register one fixed utterance before its TTSSpeakFrame is queued."""
|
"""Register fixed speech and return its transport completion signal."""
|
||||||
|
completion = asyncio.get_running_loop().create_future()
|
||||||
|
self._tracked_speech_completions.append(completion)
|
||||||
self._tracked_speeches += 1
|
self._tracked_speeches += 1
|
||||||
|
return completion
|
||||||
|
|
||||||
async def arm_after_tracked_speech(self) -> None:
|
async def arm_after_tracked_speech(self) -> None:
|
||||||
"""Finish after every already queued fixed utterance has played."""
|
"""Finish after every already queued fixed utterance has played."""
|
||||||
@@ -73,6 +79,9 @@ class CallEndCoordinator:
|
|||||||
self._speaking = False
|
self._speaking = False
|
||||||
if self._tracked_speeches > 0:
|
if self._tracked_speeches > 0:
|
||||||
self._tracked_speeches -= 1
|
self._tracked_speeches -= 1
|
||||||
|
completion = self._tracked_speech_completions.popleft()
|
||||||
|
if not completion.done():
|
||||||
|
completion.set_result(None)
|
||||||
if (
|
if (
|
||||||
self._finish_after_tracked_speech
|
self._finish_after_tracked_speech
|
||||||
and self._tracked_speeches == 0
|
and self._tracked_speeches == 0
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
|
|
||||||
from models import AssistantConfig
|
from models import AssistantConfig
|
||||||
from pipecat.flows import ContextStrategy, ContextStrategyConfig, NodeConfig
|
from pipecat.flows import ContextStrategy, ContextStrategyConfig, NodeConfig
|
||||||
from pipecat.frames.frames import LLMUpdateSettingsFrame
|
from pipecat.frames.frames import LLMUpdateSettingsFrame
|
||||||
@@ -108,7 +106,6 @@ class WorkflowAgentStage:
|
|||||||
node_id: str,
|
node_id: str,
|
||||||
*,
|
*,
|
||||||
functions: list,
|
functions: list,
|
||||||
greeting_context_message: dict[str, str] | None,
|
|
||||||
leading_messages: list[dict[str, str]] | None = None,
|
leading_messages: list[dict[str, str]] | None = None,
|
||||||
) -> NodeConfig:
|
) -> NodeConfig:
|
||||||
data = self._engine.data(node_id)
|
data = self._engine.data(node_id)
|
||||||
@@ -119,11 +116,6 @@ class WorkflowAgentStage:
|
|||||||
if data.get("contextPolicy") == "fresh"
|
if data.get("contextPolicy") == "fresh"
|
||||||
else ContextStrategy.APPEND
|
else ContextStrategy.APPEND
|
||||||
)
|
)
|
||||||
greeting_messages = (
|
|
||||||
[deepcopy(greeting_context_message)]
|
|
||||||
if strategy == ContextStrategy.RESET and greeting_context_message
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
fixed_reply_messages = (
|
fixed_reply_messages = (
|
||||||
[{"role": "assistant", "content": entry_speech}]
|
[{"role": "assistant", "content": entry_speech}]
|
||||||
if entry_mode == "fixed_speech" and entry_speech
|
if entry_mode == "fixed_speech" and entry_speech
|
||||||
@@ -133,7 +125,6 @@ class WorkflowAgentStage:
|
|||||||
"name": node_id,
|
"name": node_id,
|
||||||
"role_message": self.role_message(node_id),
|
"role_message": self.role_message(node_id),
|
||||||
"task_messages": [
|
"task_messages": [
|
||||||
*greeting_messages,
|
|
||||||
*(leading_messages or []),
|
*(leading_messages or []),
|
||||||
*fixed_reply_messages,
|
*fixed_reply_messages,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class WorkflowStatus(StrEnum):
|
|||||||
ROUTING = "routing"
|
ROUTING = "routing"
|
||||||
RUNNING_AGENT = "running_agent"
|
RUNNING_AGENT = "running_agent"
|
||||||
RUNNING_ACTION = "running_action"
|
RUNNING_ACTION = "running_action"
|
||||||
|
RUNNING_MESSAGE = "running_message"
|
||||||
HANDOFF = "handoff"
|
HANDOFF = "handoff"
|
||||||
ENDED = "ended"
|
ENDED = "ended"
|
||||||
|
|
||||||
|
|||||||
@@ -5,65 +5,15 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
from pipecat.frames.frames import OutputTransportMessageUrgentFrame
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
|
|
||||||
from services.brains.base import BrainRuntime
|
from services.fixed_speech import FixedSpeechOutput
|
||||||
from services.runtime_variables import DynamicVariableStore
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowOutput:
|
class WorkflowOutput(FixedSpeechOutput):
|
||||||
"""Publish debug events and fixed speech without duplicating persistence."""
|
"""Publish debug events and fixed speech without duplicating persistence."""
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
store: DynamicVariableStore,
|
|
||||||
runtime: BrainRuntime,
|
|
||||||
) -> None:
|
|
||||||
self._store = store
|
|
||||||
self._runtime = runtime
|
|
||||||
self._client_ready = False
|
|
||||||
self._pending_transcripts: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mark_client_ready(self) -> None:
|
|
||||||
self._client_ready = True
|
|
||||||
pending = self._pending_transcripts
|
|
||||||
self._pending_transcripts = []
|
|
||||||
for message in pending:
|
|
||||||
await self.emit(message)
|
|
||||||
|
|
||||||
async def speak(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
source: str,
|
|
||||||
node_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Record, display and synthesize one Workflow-owned utterance."""
|
|
||||||
content = text.strip()
|
|
||||||
if not content:
|
|
||||||
return
|
|
||||||
self._store.record("agent", content)
|
|
||||||
transcript = {
|
|
||||||
"type": "transcript",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": content,
|
|
||||||
"timestamp": time_now_iso8601(),
|
|
||||||
"source": source,
|
|
||||||
**({"nodeId": node_id} if node_id else {}),
|
|
||||||
}
|
|
||||||
if self._client_ready:
|
|
||||||
await self.emit(transcript)
|
|
||||||
else:
|
|
||||||
self._pending_transcripts.append(transcript)
|
|
||||||
|
|
||||||
track_speech = getattr(self._runtime.call_end, "track_speech", None)
|
|
||||||
if callable(track_speech):
|
|
||||||
track_speech()
|
|
||||||
await self._runtime.queue_frame(
|
|
||||||
TTSSpeakFrame(content, append_to_context=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def emit_node_active(self, node_id: str | None) -> None:
|
async def emit_node_active(self, node_id: str | None) -> None:
|
||||||
if node_id:
|
if node_id:
|
||||||
await self.emit({"type": "node-active", "nodeId": node_id})
|
await self.emit({"type": "node-active", "nodeId": node_id})
|
||||||
|
|||||||
@@ -190,20 +190,12 @@ class WorkflowEngine:
|
|||||||
sections.append(f"[当前阶段任务]\n{prompt}")
|
sections.append(f"[当前阶段任务]\n{prompt}")
|
||||||
return "\n\n".join(sections)
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
def greeting(self, store: DynamicVariableStore) -> str:
|
|
||||||
return store.render(str(self.data(self.start_id).get("greeting") or ""))
|
|
||||||
|
|
||||||
def routing_prompt(self, node_id: str, store: DynamicVariableStore) -> str:
|
def routing_prompt(self, node_id: str, store: DynamicVariableStore) -> str:
|
||||||
"""Describe the current node to the small LLM edge router."""
|
"""Describe the current node to the small LLM edge router."""
|
||||||
if self.node_type(node_id) == "agent":
|
if self.node_type(node_id) == "agent":
|
||||||
return self.prompt_for(node_id, store)
|
return self.prompt_for(node_id, store)
|
||||||
data = self.data(node_id)
|
data = self.data(node_id)
|
||||||
details = (
|
details = data.get("message") or data.get("target") or ""
|
||||||
data.get("greeting")
|
|
||||||
or data.get("message")
|
|
||||||
or data.get("target")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
rendered = store.render(str(details)).strip()
|
rendered = store.render(str(details)).strip()
|
||||||
return f"{self.node_type(node_id) or 'workflow'} 节点:{rendered or self.name(node_id)}"
|
return f"{self.node_type(node_id) or 'workflow'} 节点:{rendered or self.name(node_id)}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
@@ -29,7 +30,7 @@ from services.brains.dify_llm import (
|
|||||||
)
|
)
|
||||||
from services.brains.workflow_brain import WorkflowBrain
|
from services.brains.workflow_brain import WorkflowBrain
|
||||||
from services.runtime_variables import prepare_dynamic_config
|
from services.runtime_variables import prepare_dynamic_config
|
||||||
from services.action_runtime import ActionError, ActionOutcome, ActionStatus
|
from services.action_runtime import ActionOutcome, ActionStatus
|
||||||
from services.workflow.models import (
|
from services.workflow.models import (
|
||||||
LLMRouteResult,
|
LLMRouteResult,
|
||||||
RouteStatus,
|
RouteStatus,
|
||||||
@@ -350,30 +351,32 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
["preflight_1", "preflight_2"],
|
["preflight_1", "preflight_2"],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_opening_actions_wait_for_confirmation_and_greeting(self):
|
async def test_opening_stage_starts_speech_and_releases_on_confirmation(self):
|
||||||
tools = [
|
tool = RuntimeTool(
|
||||||
RuntimeTool(
|
id="opening_data",
|
||||||
id=f"opening_{index}",
|
name="加载开场数据",
|
||||||
name=f"开场动作 {index}",
|
function_name="load_opening_data",
|
||||||
function_name="show_message" if index == 1 else "load_opening_data",
|
type="http",
|
||||||
type="client" if index == 1 else "http",
|
|
||||||
)
|
)
|
||||||
for index in (1, 2)
|
|
||||||
]
|
|
||||||
cfg = AssistantConfig(
|
cfg = AssistantConfig(
|
||||||
type="prompt",
|
type="prompt",
|
||||||
tools=tools,
|
greeting="请阅读并确认重要信息",
|
||||||
|
tools=[tool],
|
||||||
startup={
|
startup={
|
||||||
"execution_mode": "sequential",
|
"execution_mode": "sequential",
|
||||||
|
"opening_message": {
|
||||||
|
"title": "重要提示",
|
||||||
|
"message": "请确认已阅读。",
|
||||||
|
"confirm_label": "确认",
|
||||||
|
},
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"id": f"opening_{index}",
|
"id": "opening_data",
|
||||||
"phase": "opening",
|
"phase": "opening",
|
||||||
"tool_id": f"opening_{index}",
|
"tool_id": "opening_data",
|
||||||
"arguments": {},
|
"arguments": {},
|
||||||
"required": True,
|
"required": True,
|
||||||
}
|
}
|
||||||
for index in (1, 2)
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -384,6 +387,17 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
async def queue_frame(frame):
|
async def queue_frame(frame):
|
||||||
queued.append(frame)
|
queued.append(frame)
|
||||||
|
|
||||||
|
confirmation_started = asyncio.Event()
|
||||||
|
user_confirmed = asyncio.Event()
|
||||||
|
client_calls = []
|
||||||
|
|
||||||
|
class FakeClientTools:
|
||||||
|
async def call(self, function_name, arguments, **options):
|
||||||
|
client_calls.append((function_name, arguments, options))
|
||||||
|
confirmation_started.set()
|
||||||
|
await user_confirmed.wait()
|
||||||
|
return {"status": "ok", "data": {"action": "confirmed"}}
|
||||||
|
|
||||||
await brain.setup(
|
await brain.setup(
|
||||||
cfg,
|
cfg,
|
||||||
BrainRuntime(
|
BrainRuntime(
|
||||||
@@ -393,28 +407,51 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
set_system_prompt=lambda _prompt: None,
|
set_system_prompt=lambda _prompt: None,
|
||||||
set_tools=lambda _tools: None,
|
set_tools=lambda _tools: None,
|
||||||
call_end=FakeCallEnd(),
|
call_end=FakeCallEnd(),
|
||||||
|
client_tools=FakeClientTools(),
|
||||||
set_input_enabled=input_states.append,
|
set_input_enabled=input_states.append,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
brain._actions.execute = AsyncMock(
|
called_tool_ids = []
|
||||||
side_effect=[
|
|
||||||
ActionOutcome(
|
async def execute(tool, *_args, **_kwargs):
|
||||||
invocation_id=f"act_{index}",
|
called_tool_ids.append(tool.id)
|
||||||
|
return ActionOutcome(
|
||||||
|
invocation_id=f"act_{len(called_tool_ids)}",
|
||||||
status=ActionStatus.SUCCESS,
|
status=ActionStatus.SUCCESS,
|
||||||
duration_ms=index,
|
duration_ms=len(called_tool_ids),
|
||||||
)
|
|
||||||
for index in (1, 2)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await brain.on_connected(greeting_pending=True)
|
brain._actions.execute = AsyncMock(side_effect=execute)
|
||||||
await brain.on_client_ready()
|
|
||||||
|
self.assertEqual(await brain.greeting(cfg), "")
|
||||||
|
await brain.on_connected(greeting_pending=False)
|
||||||
|
opening_task = asyncio.create_task(brain.on_client_ready())
|
||||||
|
await confirmation_started.wait()
|
||||||
|
|
||||||
self.assertEqual(input_states, [False])
|
self.assertEqual(input_states, [False])
|
||||||
called_tool_ids = [
|
self.assertEqual(called_tool_ids, [])
|
||||||
call.args[0].id for call in brain._actions.execute.await_args_list
|
self.assertEqual(client_calls[0][0], "show_message")
|
||||||
]
|
self.assertFalse(client_calls[0][1]["dismissible"])
|
||||||
self.assertEqual(called_tool_ids, ["opening_1", "opening_2"])
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
isinstance(frame, TTSSpeakFrame)
|
||||||
|
and frame.text == "请阅读并确认重要信息"
|
||||||
|
for frame in queued
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("type") == "transcript"
|
||||||
|
and frame.message.get("content") == "请阅读并确认重要信息"
|
||||||
|
for frame in queued
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_confirmed.set()
|
||||||
|
await opening_task
|
||||||
|
self.assertEqual(input_states, [False, True])
|
||||||
|
self.assertEqual(called_tool_ids, ["opening_data"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(
|
len(
|
||||||
[
|
[
|
||||||
@@ -424,36 +461,22 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
and frame.message.get("type") == "startup-action-result"
|
and frame.message.get("type") == "startup-action-result"
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
2,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
await brain.on_greeting_finished()
|
|
||||||
self.assertEqual(input_states, [False, True])
|
|
||||||
|
|
||||||
# Replayed client-ready must not execute startup actions twice.
|
# Replayed client-ready must not execute startup actions twice.
|
||||||
await brain.on_client_ready()
|
await brain.on_client_ready()
|
||||||
self.assertEqual(brain._actions.execute.await_count, 2)
|
self.assertEqual(brain._actions.execute.await_count, 1)
|
||||||
|
|
||||||
async def test_required_opening_failure_keeps_input_blocked_and_ends_call(self):
|
async def test_required_opening_failure_keeps_input_blocked_and_ends_call(self):
|
||||||
tool = RuntimeTool(
|
|
||||||
id="opening_message",
|
|
||||||
name="开场确认",
|
|
||||||
function_name="show_message",
|
|
||||||
type="client",
|
|
||||||
)
|
|
||||||
cfg = AssistantConfig(
|
cfg = AssistantConfig(
|
||||||
type="prompt",
|
type="prompt",
|
||||||
tools=[tool],
|
|
||||||
startup={
|
startup={
|
||||||
"actions": [
|
"opening_message": {
|
||||||
{
|
"title": "重要提示",
|
||||||
"id": "opening_message",
|
"message": "请确认已阅读。",
|
||||||
"phase": "opening",
|
"confirm_label": "确认",
|
||||||
"tool_id": tool.id,
|
|
||||||
"arguments": {},
|
|
||||||
"required": True,
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
brain = build_brain(cfg)
|
brain = build_brain(cfg)
|
||||||
@@ -463,6 +486,10 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
async def queue_frame(_frame):
|
async def queue_frame(_frame):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class FailingClientTools:
|
||||||
|
async def call(self, *_args, **_kwargs):
|
||||||
|
return {"status": "error", "message": "客户端未显示消息"}
|
||||||
|
|
||||||
await brain.setup(
|
await brain.setup(
|
||||||
cfg,
|
cfg,
|
||||||
BrainRuntime(
|
BrainRuntime(
|
||||||
@@ -472,20 +499,10 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
set_system_prompt=lambda _prompt: None,
|
set_system_prompt=lambda _prompt: None,
|
||||||
set_tools=lambda _tools: None,
|
set_tools=lambda _tools: None,
|
||||||
call_end=call_end,
|
call_end=call_end,
|
||||||
|
client_tools=FailingClientTools(),
|
||||||
set_input_enabled=input_states.append,
|
set_input_enabled=input_states.append,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
brain._actions.execute = AsyncMock(
|
|
||||||
return_value=ActionOutcome(
|
|
||||||
invocation_id="act_failed",
|
|
||||||
status=ActionStatus.FAILURE,
|
|
||||||
duration_ms=1,
|
|
||||||
error=ActionError(
|
|
||||||
code="tool_error",
|
|
||||||
message="用户未确认",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await brain.on_connected(greeting_pending=False)
|
await brain.on_connected(greeting_pending=False)
|
||||||
await brain.on_client_ready()
|
await brain.on_client_ready()
|
||||||
@@ -948,7 +965,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertNotIn("fetch_user_image", custom_config["role_message"])
|
self.assertNotIn("fetch_user_image", custom_config["role_message"])
|
||||||
self.assertFalse(scopes[-1]["enabled"])
|
self.assertFalse(scopes[-1]["enabled"])
|
||||||
|
|
||||||
async def test_initial_fixed_speech_waits_for_start_greeting_to_finish(self):
|
async def test_initial_fixed_speech_starts_without_workflow_greeting(self):
|
||||||
brain = WorkflowBrain(
|
brain = WorkflowBrain(
|
||||||
{
|
{
|
||||||
"specVersion": 3,
|
"specVersion": 3,
|
||||||
@@ -1006,12 +1023,14 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
brain._manager = FakeManager()
|
brain._manager = FakeManager()
|
||||||
|
|
||||||
await brain.on_connected(greeting_pending=True)
|
self.assertNotIn("greeting", brain._engine.data("start"))
|
||||||
|
self.assertEqual(
|
||||||
self.assertEqual(brain._manager.current_node, "start")
|
await brain.greeting(
|
||||||
self.assertFalse(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
AssistantConfig(type="workflow", greeting="旧助手级开场白")
|
||||||
|
),
|
||||||
await brain.on_greeting_finished()
|
"",
|
||||||
|
)
|
||||||
|
await brain.on_connected()
|
||||||
|
|
||||||
self.assertEqual(brain._manager.current_node, "agent")
|
self.assertEqual(brain._manager.current_node, "agent")
|
||||||
fixed_speech_frames = [
|
fixed_speech_frames = [
|
||||||
@@ -1020,8 +1039,8 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(len(fixed_speech_frames), 1)
|
self.assertEqual(len(fixed_speech_frames), 1)
|
||||||
self.assertEqual(fixed_speech_frames[0].text, "请问您怎么称呼?")
|
self.assertEqual(fixed_speech_frames[0].text, "请问您怎么称呼?")
|
||||||
|
|
||||||
# Playback notifications may be duplicated by a transport reconnect;
|
# Workflow no longer owns a greeting playback lifecycle. Stray generic
|
||||||
# the initial entry behavior must still run only once.
|
# transport notifications must not repeat Agent entry behavior.
|
||||||
await brain.on_greeting_finished()
|
await brain.on_greeting_finished()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len([frame for frame in queued if isinstance(frame, TTSSpeakFrame)]),
|
len([frame for frame in queued if isinstance(frame, TTSSpeakFrame)]),
|
||||||
@@ -1372,6 +1391,148 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await brain._enter_action("queue_action")
|
await brain._enter_action("queue_action")
|
||||||
self.assertEqual(input_states, ["executing"])
|
self.assertEqual(input_states, ["executing"])
|
||||||
|
|
||||||
|
async def test_message_starts_speech_and_releases_on_confirmation(self):
|
||||||
|
brain = WorkflowBrain(
|
||||||
|
AssistantConfig(
|
||||||
|
type="workflow",
|
||||||
|
graph={
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "message",
|
||||||
|
"type": "message",
|
||||||
|
"data": {
|
||||||
|
"speech": "请先确认 {{customer}} 的重要信息。",
|
||||||
|
"showMessage": True,
|
||||||
|
"title": "重要提示",
|
||||||
|
"message": "请核对客户信息。",
|
||||||
|
"confirmLabel": "确认",
|
||||||
|
"requireConfirmation": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
brain._store.values["customer"] = "王先生"
|
||||||
|
events = []
|
||||||
|
|
||||||
|
async def queue_frame(frame):
|
||||||
|
if isinstance(frame, TTSSpeakFrame):
|
||||||
|
events.append(("speech", frame.text))
|
||||||
|
|
||||||
|
class OrderedCallEnd(FakeCallEnd):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.playback_completion = None
|
||||||
|
|
||||||
|
def track_speech(self):
|
||||||
|
self.tracked_speeches += 1
|
||||||
|
events.append("tracked")
|
||||||
|
self.playback_completion = asyncio.get_running_loop().create_future()
|
||||||
|
return self.playback_completion
|
||||||
|
|
||||||
|
message_started = asyncio.Event()
|
||||||
|
user_confirmed = asyncio.Event()
|
||||||
|
|
||||||
|
class FakeClientTools:
|
||||||
|
async def call(self, function_name, arguments, **options):
|
||||||
|
self.function_name = function_name
|
||||||
|
self.arguments = arguments
|
||||||
|
self.options = options
|
||||||
|
events.append("message_displayed")
|
||||||
|
message_started.set()
|
||||||
|
await user_confirmed.wait()
|
||||||
|
return {"status": "ok", "data": {"action": "confirmed"}}
|
||||||
|
|
||||||
|
input_states = []
|
||||||
|
call_end = OrderedCallEnd()
|
||||||
|
client_tools = FakeClientTools()
|
||||||
|
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=call_end,
|
||||||
|
client_tools=client_tools,
|
||||||
|
set_input_enabled=input_states.append,
|
||||||
|
)
|
||||||
|
brain._message_stages.set_client_tools(client_tools)
|
||||||
|
|
||||||
|
message_task = asyncio.create_task(brain._enter_message("message"))
|
||||||
|
await message_started.wait()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
events,
|
||||||
|
[
|
||||||
|
"tracked",
|
||||||
|
("speech", "请先确认 王先生 的重要信息。"),
|
||||||
|
"message_displayed",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(input_states, [False])
|
||||||
|
self.assertEqual(client_tools.function_name, "show_message")
|
||||||
|
self.assertEqual(client_tools.options["response_wait_mode"], "session")
|
||||||
|
|
||||||
|
user_confirmed.set()
|
||||||
|
result = await message_task
|
||||||
|
self.assertTrue(result.succeeded)
|
||||||
|
self.assertEqual(result.action, "confirmed")
|
||||||
|
self.assertFalse(call_end.playback_completion.done())
|
||||||
|
self.assertEqual(input_states, [False, True])
|
||||||
|
|
||||||
|
async def test_speech_only_message_waits_for_transport_playback(self):
|
||||||
|
brain = WorkflowBrain(
|
||||||
|
{
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "message",
|
||||||
|
"type": "message",
|
||||||
|
"data": {"speech": "正在为您准备服务。"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
class PlaybackCallEnd(FakeCallEnd):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.completion = None
|
||||||
|
|
||||||
|
def track_speech(self):
|
||||||
|
self.completion = asyncio.get_running_loop().create_future()
|
||||||
|
return self.completion
|
||||||
|
|
||||||
|
call_end = PlaybackCallEnd()
|
||||||
|
input_states = []
|
||||||
|
brain._runtime = BrainRuntime(
|
||||||
|
context=LLMContext(messages=[]),
|
||||||
|
llm=FakeLLM(),
|
||||||
|
queue_frame=noop_queue_frame,
|
||||||
|
set_system_prompt=lambda _prompt: None,
|
||||||
|
set_tools=lambda _tools: None,
|
||||||
|
call_end=call_end,
|
||||||
|
set_input_enabled=input_states.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
message_task = asyncio.create_task(brain._enter_message("message"))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
self.assertFalse(message_task.done())
|
||||||
|
self.assertEqual(input_states, [False])
|
||||||
|
|
||||||
|
call_end.completion.set_result(None)
|
||||||
|
result = await message_task
|
||||||
|
self.assertTrue(result.succeeded)
|
||||||
|
self.assertEqual(input_states, [False, True])
|
||||||
|
|
||||||
async def test_nodes_without_outgoing_edges_remain_active(self):
|
async def test_nodes_without_outgoing_edges_remain_active(self):
|
||||||
queued = []
|
queued = []
|
||||||
|
|
||||||
@@ -1501,7 +1662,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{
|
{
|
||||||
"id": "start",
|
"id": "start",
|
||||||
"type": "start",
|
"type": "start",
|
||||||
"data": {"name": "Start", "greeting": "你想做什么?"},
|
"data": {"name": "Start"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "eat",
|
"id": "eat",
|
||||||
@@ -1815,10 +1976,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{
|
{
|
||||||
"id": "start",
|
"id": "start",
|
||||||
"type": "start",
|
"type": "start",
|
||||||
"data": {
|
"data": {"name": "Start"},
|
||||||
"name": "Start",
|
|
||||||
"greeting": "欢迎,{{user_name}}",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "agent",
|
"id": "agent",
|
||||||
@@ -1925,13 +2083,8 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
await brain.setup(cfg, runtime)
|
await brain.setup(cfg, runtime)
|
||||||
greeting = await brain.greeting(cfg)
|
greeting = await brain.greeting(cfg)
|
||||||
self.assertEqual(greeting, "欢迎,王先生")
|
self.assertEqual(greeting, "")
|
||||||
greeting_message = {
|
self.assertEqual(context.get_messages(), [])
|
||||||
"role": "system",
|
|
||||||
"content": f"{GREETING_CONTEXT_MARKER}\n欢迎,王先生",
|
|
||||||
}
|
|
||||||
brain.prepare_greeting_context(greeting, context)
|
|
||||||
self.assertEqual(context.get_messages(), [greeting_message])
|
|
||||||
await brain.on_connected()
|
await brain.on_connected()
|
||||||
self.assertEqual(brain._manager.current_node, "agent")
|
self.assertEqual(brain._manager.current_node, "agent")
|
||||||
variable_events = [
|
variable_events = [
|
||||||
@@ -1980,7 +2133,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
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"])
|
||||||
self.assertEqual(agent_config["task_messages"], [greeting_message])
|
self.assertEqual(agent_config["task_messages"], [])
|
||||||
self.assertFalse(agent_config["respond_immediately"])
|
self.assertFalse(agent_config["respond_immediately"])
|
||||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -2005,10 +2158,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertNotIn("pre_actions", fixed_config)
|
self.assertNotIn("pre_actions", fixed_config)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
fixed_config["task_messages"],
|
fixed_config["task_messages"],
|
||||||
[
|
[{"role": "assistant", "content": "您好,王先生"}],
|
||||||
greeting_message,
|
|
||||||
{"role": "assistant", "content": "您好,王先生"},
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
brain._agent_config(
|
brain._agent_config(
|
||||||
@@ -2016,7 +2166,6 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
[{"role": "assistant", "content": "正在进入下一阶段"}],
|
[{"role": "assistant", "content": "正在进入下一阶段"}],
|
||||||
)["task_messages"],
|
)["task_messages"],
|
||||||
[
|
[
|
||||||
greeting_message,
|
|
||||||
{"role": "assistant", "content": "正在进入下一阶段"},
|
{"role": "assistant", "content": "正在进入下一阶段"},
|
||||||
{"role": "assistant", "content": "您好,王先生"},
|
{"role": "assistant", "content": "您好,王先生"},
|
||||||
],
|
],
|
||||||
@@ -2034,10 +2183,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
context_updates[-1].messages,
|
context_updates[-1].messages,
|
||||||
[
|
[{"role": "assistant", "content": "您好,王先生"}],
|
||||||
greeting_message,
|
|
||||||
{"role": "assistant", "content": "您好,王先生"},
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
any(
|
any(
|
||||||
|
|||||||
@@ -45,17 +45,20 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(self.reasons, ["tool_only"])
|
self.assertEqual(self.reasons, ["tool_only"])
|
||||||
|
|
||||||
async def test_workflow_end_waits_for_every_queued_fixed_speech(self):
|
async def test_workflow_end_waits_for_every_queued_fixed_speech(self):
|
||||||
self.coordinator.track_speech()
|
first_completion = self.coordinator.track_speech()
|
||||||
self.coordinator.track_speech()
|
second_completion = self.coordinator.track_speech()
|
||||||
self.coordinator.begin("workflow_completed")
|
self.coordinator.begin("workflow_completed")
|
||||||
await self.coordinator.arm_after_tracked_speech()
|
await self.coordinator.arm_after_tracked_speech()
|
||||||
|
|
||||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||||
|
self.assertTrue(first_completion.done())
|
||||||
|
self.assertFalse(second_completion.done())
|
||||||
self.assertEqual(self.reasons, [])
|
self.assertEqual(self.reasons, [])
|
||||||
|
|
||||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||||
|
self.assertTrue(second_completion.done())
|
||||||
self.assertEqual(self.reasons, ["workflow_completed"])
|
self.assertEqual(self.reasons, ["workflow_completed"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class DynamicVariableTests(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
"id": "start",
|
"id": "start",
|
||||||
"type": "start",
|
"type": "start",
|
||||||
"data": {"greeting": "您好 {{nickname}}"},
|
"data": {"name": "Start"},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"edges": [],
|
"edges": [],
|
||||||
|
|||||||
@@ -77,6 +77,39 @@ class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
startup=startup_body().startup,
|
startup=startup_body().startup,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_realtime_rejects_builtin_opening_message(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "Realtime"):
|
||||||
|
AssistantUpsert(
|
||||||
|
name="Realtime 开场消息",
|
||||||
|
type="prompt",
|
||||||
|
runtimeMode="realtime",
|
||||||
|
startup={
|
||||||
|
"openingMessage": {
|
||||||
|
"title": "重要提示",
|
||||||
|
"message": "请确认已阅读。",
|
||||||
|
"confirmLabel": "确认",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_builtin_opening_message_does_not_reference_a_tool(self):
|
||||||
|
body = AssistantUpsert(
|
||||||
|
name="内置开场消息",
|
||||||
|
type="prompt",
|
||||||
|
startup={
|
||||||
|
"openingMessage": {
|
||||||
|
"title": "重要提示",
|
||||||
|
"message": "请确认已阅读。",
|
||||||
|
"confirmLabel": "确认",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await _validate_startup_actions(FakeSession(None), body)
|
||||||
|
|
||||||
|
self.assertEqual(body.startup.actions, [])
|
||||||
|
self.assertEqual(body.startup.opening_message.confirm_label, "确认")
|
||||||
|
|
||||||
async def test_startup_tool_does_not_need_conversation_binding(self):
|
async def test_startup_tool_does_not_need_conversation_binding(self):
|
||||||
tool = SimpleNamespace(
|
tool = SimpleNamespace(
|
||||||
id="tool_message",
|
id="tool_message",
|
||||||
|
|||||||
@@ -55,6 +55,20 @@ def valid_graph():
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowGraphTests(unittest.TestCase):
|
class WorkflowGraphTests(unittest.TestCase):
|
||||||
|
def test_workflow_removes_assistant_and_start_greetings(self):
|
||||||
|
body = AssistantUpsert(
|
||||||
|
name="无开场白工作流",
|
||||||
|
type="workflow",
|
||||||
|
greeting="旧助手级开场白",
|
||||||
|
graph=valid_graph(),
|
||||||
|
)
|
||||||
|
body.graph["nodes"][0]["data"]["greeting"] = "旧 Start 开场白"
|
||||||
|
|
||||||
|
normalized = normalize_graph(body.graph)
|
||||||
|
|
||||||
|
self.assertEqual(body.greeting, "")
|
||||||
|
self.assertNotIn("greeting", normalized["nodes"][0]["data"])
|
||||||
|
|
||||||
def test_revision_pins_the_normalized_workflow_snapshot(self):
|
def test_revision_pins_the_normalized_workflow_snapshot(self):
|
||||||
first = WorkflowEngine(valid_graph())
|
first = WorkflowEngine(valid_graph())
|
||||||
same_graph = deepcopy(valid_graph())
|
same_graph = deepcopy(valid_graph())
|
||||||
@@ -147,6 +161,7 @@ class WorkflowGraphTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(actions["legacy_none"]["resultAssignments"], {})
|
self.assertEqual(actions["legacy_none"]["resultAssignments"], {})
|
||||||
self.assertEqual(actions["legacy_none"]["userInputPolicy"], "queue")
|
self.assertEqual(actions["legacy_none"]["userInputPolicy"], "queue")
|
||||||
|
self.assertNotIn("speech", actions["legacy_none"])
|
||||||
|
|
||||||
def test_action_advanced_settings_are_validated(self):
|
def test_action_advanced_settings_are_validated(self):
|
||||||
graph = valid_graph()
|
graph = valid_graph()
|
||||||
@@ -167,6 +182,46 @@ class WorkflowGraphTests(unittest.TestCase):
|
|||||||
self.assertTrue(any("结果变量映射必须是对象" in error for error in errors))
|
self.assertTrue(any("结果变量映射必须是对象" in error for error in errors))
|
||||||
self.assertTrue(any("用户输入策略无效" in error for error in errors))
|
self.assertTrue(any("用户输入策略无效" in error for error in errors))
|
||||||
|
|
||||||
|
def test_message_defaults_and_validation(self):
|
||||||
|
graph = valid_graph()
|
||||||
|
graph["nodes"].append(
|
||||||
|
{
|
||||||
|
"id": "message",
|
||||||
|
"type": "message",
|
||||||
|
"data": {"speech": "请确认重要信息"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
normalized = normalize_graph(graph)
|
||||||
|
message = next(
|
||||||
|
node for node in normalized["nodes"] if node["type"] == "message"
|
||||||
|
)
|
||||||
|
self.assertFalse(message["data"]["showMessage"])
|
||||||
|
self.assertFalse(message["data"]["requireConfirmation"])
|
||||||
|
self.assertEqual(message["data"]["confirmLabel"], "确认")
|
||||||
|
|
||||||
|
message["data"].update(
|
||||||
|
{
|
||||||
|
"speech": "",
|
||||||
|
"showMessage": True,
|
||||||
|
"title": "重要提示",
|
||||||
|
"message": "",
|
||||||
|
"confirmLabel": "确认",
|
||||||
|
"requireConfirmation": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
errors = validate_graph(normalized)
|
||||||
|
self.assertTrue(any("弹窗消息必须为" in error for error in errors))
|
||||||
|
|
||||||
|
message["data"].update(
|
||||||
|
{
|
||||||
|
"speech": "请确认",
|
||||||
|
"showMessage": False,
|
||||||
|
"requireConfirmation": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
errors = validate_graph(normalized)
|
||||||
|
self.assertTrue(any("等待确认时必须显示弹窗" in error for error in errors))
|
||||||
|
|
||||||
def test_voice_resource_creates_isolated_runtime_config(self):
|
def test_voice_resource_creates_isolated_runtime_config(self):
|
||||||
base = AssistantConfig(type="workflow", asr="default", voice="default")
|
base = AssistantConfig(type="workflow", asr="default", voice="default")
|
||||||
asr = RuntimeModelResource(
|
asr = RuntimeModelResource(
|
||||||
|
|||||||
@@ -85,55 +85,18 @@ export function PromptEditor({
|
|||||||
handlePromptVisionEnabledChange,
|
handlePromptVisionEnabledChange,
|
||||||
handlePromptModelChange,
|
handlePromptModelChange,
|
||||||
}: PromptEditorProps) {
|
}: PromptEditorProps) {
|
||||||
const openingMessage = form.startup.actions.find(
|
const openingMessage = form.startup.openingMessage;
|
||||||
(action) => action.id === "opening_message" && action.phase === "opening",
|
|
||||||
);
|
|
||||||
const openingArguments = openingMessage?.arguments ?? {};
|
|
||||||
const openingButtons = Array.isArray(openingArguments.actions)
|
|
||||||
? openingArguments.actions
|
|
||||||
: [];
|
|
||||||
const openingButton =
|
|
||||||
openingButtons[0] && typeof openingButtons[0] === "object"
|
|
||||||
? (openingButtons[0] as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const showMessageTools = tools
|
|
||||||
.filter(
|
|
||||||
(tool) =>
|
|
||||||
tool.status === "active" &&
|
|
||||||
tool.type === "client" &&
|
|
||||||
tool.functionName === "show_message" &&
|
|
||||||
tool.definition.type === "client" &&
|
|
||||||
tool.definition.config.waitForResponse &&
|
|
||||||
tool.definition.config.responseWaitMode === "session",
|
|
||||||
)
|
|
||||||
.map((tool) => ({ value: tool.id, label: tool.name }));
|
|
||||||
|
|
||||||
function setOpeningMessage(enabled: boolean) {
|
function setOpeningMessage(enabled: boolean) {
|
||||||
const otherActions = form.startup.actions.filter(
|
|
||||||
(action) => action.id !== "opening_message",
|
|
||||||
);
|
|
||||||
const defaultToolId = showMessageTools[0]?.value ?? "";
|
|
||||||
updateForm("startup", {
|
updateForm("startup", {
|
||||||
executionMode: "sequential",
|
...form.startup,
|
||||||
actions: enabled
|
openingMessage: enabled
|
||||||
? [
|
? {
|
||||||
...otherActions,
|
|
||||||
{
|
|
||||||
id: "opening_message",
|
|
||||||
phase: "opening",
|
|
||||||
toolId: defaultToolId,
|
|
||||||
required: true,
|
|
||||||
arguments: {
|
|
||||||
title: "重要提示",
|
title: "重要提示",
|
||||||
message: "请确认已阅读以上信息。",
|
message: "请确认已阅读以上信息。",
|
||||||
actions: [
|
confirmLabel: "确认",
|
||||||
{ id: "confirmed", label: "确认", style: "primary" },
|
}
|
||||||
],
|
: null,
|
||||||
dismissible: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: otherActions,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,16 +106,10 @@ export function PromptEditor({
|
|||||||
if (!openingMessage) return;
|
if (!openingMessage) return;
|
||||||
updateForm("startup", {
|
updateForm("startup", {
|
||||||
...form.startup,
|
...form.startup,
|
||||||
actions: form.startup.actions.map((action) =>
|
openingMessage: { ...openingMessage, ...patch },
|
||||||
action.id === openingMessage.id ? { ...action, ...patch } : action,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOpeningArguments(patch: Record<string, unknown>) {
|
|
||||||
updateOpeningMessage({ arguments: { ...openingArguments, ...patch } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-mt-6 flex h-full flex-col gap-4">
|
<div className="-mt-6 flex h-full flex-col gap-4">
|
||||||
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
|
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
|
||||||
@@ -235,33 +192,21 @@ export function PromptEditor({
|
|||||||
<div className="mt-4 space-y-3 border-t border-hairline pt-4">
|
<div className="mt-4 space-y-3 border-t border-hairline pt-4">
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
title="开场确认弹窗"
|
title="开场确认弹窗"
|
||||||
description="与开场白同时显示,确认且播报完成后才允许用户开始对话。"
|
description="与开场白同时显示,用户确认后即可开始对话,不等待播报完成。"
|
||||||
checked={Boolean(openingMessage)}
|
checked={Boolean(openingMessage)}
|
||||||
onChange={setOpeningMessage}
|
onChange={setOpeningMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{openingMessage && (
|
{openingMessage && (
|
||||||
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4">
|
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4">
|
||||||
<ResourceSelectField
|
|
||||||
label="消息工具"
|
|
||||||
value={openingMessage.toolId}
|
|
||||||
options={showMessageTools}
|
|
||||||
noneLabel="请选择 show_message 工具"
|
|
||||||
onChange={(toolId) => updateOpeningMessage({ toolId })}
|
|
||||||
/>
|
|
||||||
{showMessageTools.length === 0 && (
|
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
|
||||||
请先在组件管理中创建 functionName 为 show_message、会话内等待的 Client Tool。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
弹窗标题
|
弹窗标题
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
value={String(openingArguments.title ?? "")}
|
value={openingMessage.title}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateOpeningArguments({ title: event.target.value })
|
updateOpeningMessage({ title: event.target.value })
|
||||||
}
|
}
|
||||||
placeholder="重要提示"
|
placeholder="重要提示"
|
||||||
className="border-hairline-strong bg-background"
|
className="border-hairline-strong bg-background"
|
||||||
@@ -269,8 +214,8 @@ export function PromptEditor({
|
|||||||
</label>
|
</label>
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
label="重要信息"
|
label="重要信息"
|
||||||
value={String(openingArguments.message ?? "")}
|
value={openingMessage.message}
|
||||||
onChange={(message) => updateOpeningArguments({ message })}
|
onChange={(message) => updateOpeningMessage({ message })}
|
||||||
placeholder="请输入需要用户确认的重要信息"
|
placeholder="请输入需要用户确认的重要信息"
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
@@ -279,16 +224,10 @@ export function PromptEditor({
|
|||||||
确认按钮
|
确认按钮
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
value={String(openingButton.label ?? "")}
|
value={openingMessage.confirmLabel}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateOpeningArguments({
|
updateOpeningMessage({
|
||||||
actions: [
|
confirmLabel: event.target.value,
|
||||||
{
|
|
||||||
id: "confirmed",
|
|
||||||
label: event.target.value,
|
|
||||||
style: "primary",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="确认"
|
placeholder="确认"
|
||||||
@@ -296,8 +235,8 @@ export function PromptEditor({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
弹窗不可跳过;失败时将结束本次通话。该工具仅用于开场,
|
弹窗不可跳过;失败时将结束本次通话。这是平台内置开场阶段,
|
||||||
如需允许模型后续调用,请另外在“工具”区域勾选。
|
不会作为工具暴露给模型。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -318,10 +257,14 @@ export function PromptEditor({
|
|||||||
value={form.runtimeMode}
|
value={form.runtimeMode}
|
||||||
onChange={(runtimeMode) => {
|
onChange={(runtimeMode) => {
|
||||||
updateForm("runtimeMode", runtimeMode);
|
updateForm("runtimeMode", runtimeMode);
|
||||||
if (runtimeMode === "realtime" && form.startup.actions.length) {
|
if (
|
||||||
|
runtimeMode === "realtime" &&
|
||||||
|
(form.startup.actions.length || form.startup.openingMessage)
|
||||||
|
) {
|
||||||
updateForm("startup", {
|
updateForm("startup", {
|
||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
actions: [],
|
actions: [],
|
||||||
|
openingMessage: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -177,7 +177,11 @@ function blankPromptForm(name: string): AssistantForm {
|
|||||||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||||
enableInterrupt: true,
|
enableInterrupt: true,
|
||||||
turnConfig: defaultTurnConfig(),
|
turnConfig: defaultTurnConfig(),
|
||||||
startup: { executionMode: "sequential", actions: [] },
|
startup: {
|
||||||
|
executionMode: "sequential",
|
||||||
|
actions: [],
|
||||||
|
openingMessage: null,
|
||||||
|
},
|
||||||
visionEnabled: false,
|
visionEnabled: false,
|
||||||
visionModelResourceId: "",
|
visionModelResourceId: "",
|
||||||
toolIds: [],
|
toolIds: [],
|
||||||
@@ -466,7 +470,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(),
|
a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(),
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
turnConfig: normalizeTurnConfig(a.turnConfig),
|
turnConfig: normalizeTurnConfig(a.turnConfig),
|
||||||
startup: a.startup ?? { executionMode: "sequential", actions: [] },
|
startup: {
|
||||||
|
executionMode: "sequential",
|
||||||
|
actions: a.startup?.actions ?? [],
|
||||||
|
openingMessage: a.startup?.openingMessage ?? null,
|
||||||
|
},
|
||||||
visionEnabled: a.visionEnabled,
|
visionEnabled: a.visionEnabled,
|
||||||
visionModelResourceId: a.visionModelResourceId ?? "",
|
visionModelResourceId: a.visionModelResourceId ?? "",
|
||||||
toolIds: a.toolIds ?? [],
|
toolIds: a.toolIds ?? [],
|
||||||
@@ -536,7 +544,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
greeting: "",
|
greeting: "",
|
||||||
enableInterrupt: true,
|
enableInterrupt: true,
|
||||||
turnConfig: defaultTurnConfig(),
|
turnConfig: defaultTurnConfig(),
|
||||||
startup: { executionMode: "sequential", actions: [] },
|
startup: {
|
||||||
|
executionMode: "sequential",
|
||||||
|
actions: [],
|
||||||
|
openingMessage: null,
|
||||||
|
},
|
||||||
visionEnabled: false,
|
visionEnabled: false,
|
||||||
visionModelResourceId: null,
|
visionModelResourceId: null,
|
||||||
modelResourceIds: {},
|
modelResourceIds: {},
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
|
|
||||||
const nodeData = data as WorkflowNodeData;
|
const nodeData = data as WorkflowNodeData;
|
||||||
const Icon = spec.icon;
|
const Icon = spec.icon;
|
||||||
const preview = (nodeData.greeting || nodeData.prompt || nodeData.message || "")
|
const preview = (
|
||||||
|
nodeData.prompt ||
|
||||||
|
nodeData.speech ||
|
||||||
|
nodeData.message ||
|
||||||
|
""
|
||||||
|
)
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
const entryModeLabel = {
|
const entryModeLabel = {
|
||||||
@@ -50,7 +55,16 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
nodeData.visionEnabled ? "视觉理解" : null,
|
nodeData.visionEnabled ? "视觉理解" : null,
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
: type === "action" && nodeData.toolId
|
: type === "action" && nodeData.toolId
|
||||||
? ["确定性工具"]
|
? [
|
||||||
|
"确定性工具",
|
||||||
|
nodeData.userInputPolicy === "block" ? "禁止输入" : null,
|
||||||
|
].filter(Boolean)
|
||||||
|
: type === "message"
|
||||||
|
? [
|
||||||
|
nodeData.speech ? "固定播报" : null,
|
||||||
|
nodeData.showMessage ? "客户端消息" : null,
|
||||||
|
nodeData.requireConfirmation ? "等待确认" : null,
|
||||||
|
].filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -192,6 +206,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
start: GenericNode,
|
start: GenericNode,
|
||||||
agent: GenericNode,
|
agent: GenericNode,
|
||||||
|
message: GenericNode,
|
||||||
action: GenericNode,
|
action: GenericNode,
|
||||||
handoff: GenericNode,
|
handoff: GenericNode,
|
||||||
end: GenericNode,
|
end: GenericNode,
|
||||||
|
|||||||
@@ -63,21 +63,30 @@ let nodeSeq = 0;
|
|||||||
function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
|
function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
|
||||||
const data: WorkflowNodeData = {
|
const data: WorkflowNodeData = {
|
||||||
name: spec.displayName,
|
name: spec.displayName,
|
||||||
...(spec.type === "agent"
|
};
|
||||||
? {
|
if (spec.type === "agent") {
|
||||||
|
Object.assign(data, {
|
||||||
contextPolicy: "inherit",
|
contextPolicy: "inherit",
|
||||||
inheritGlobalConfig: true,
|
inheritGlobalConfig: true,
|
||||||
entryMode: "wait_user",
|
entryMode: "wait_user",
|
||||||
entrySpeech: "",
|
entrySpeech: "",
|
||||||
}
|
});
|
||||||
: spec.type === "action"
|
} else if (spec.type === "action") {
|
||||||
? {
|
Object.assign(data, {
|
||||||
arguments: {},
|
arguments: {},
|
||||||
resultAssignmentMode: "inherit",
|
resultAssignmentMode: "inherit",
|
||||||
userInputPolicy: "queue",
|
userInputPolicy: "queue",
|
||||||
|
});
|
||||||
|
} else if (spec.type === "message") {
|
||||||
|
Object.assign(data, {
|
||||||
|
speech: "",
|
||||||
|
showMessage: false,
|
||||||
|
title: "重要提示",
|
||||||
|
message: "",
|
||||||
|
confirmLabel: "确认",
|
||||||
|
requireConfirmation: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
for (const field of spec.fields) {
|
for (const field of spec.fields) {
|
||||||
if (field.default !== undefined) data[field.key] = field.default;
|
if (field.default !== undefined) data[field.key] = field.default;
|
||||||
}
|
}
|
||||||
|
|||||||
128
frontend/src/components/workflow/panels/MessageNodePanel.tsx
Normal file
128
frontend/src/components/workflow/panels/MessageNodePanel.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MessageSquareText } from "lucide-react";
|
||||||
|
|
||||||
|
import { SectionCard } from "@/components/editor/section-card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import type { WorkflowNodeData } from "../specs";
|
||||||
|
|
||||||
|
export function MessageNodePanel({
|
||||||
|
draft,
|
||||||
|
set,
|
||||||
|
setPatch,
|
||||||
|
}: {
|
||||||
|
draft: WorkflowNodeData;
|
||||||
|
set: (key: string, value: unknown) => void;
|
||||||
|
setPatch: (patch: Partial<WorkflowNodeData>) => void;
|
||||||
|
}) {
|
||||||
|
const showMessage = Boolean(draft.showMessage);
|
||||||
|
const requireConfirmation = Boolean(draft.requireConfirmation);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={<MessageSquareText size={15} />}
|
||||||
|
title="播报与确认"
|
||||||
|
description="播放固定话术,并可同时显示平台内置的客户端消息"
|
||||||
|
>
|
||||||
|
<label className="block">
|
||||||
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
||||||
|
固定播报(可选)
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
value={draft.speech ?? ""}
|
||||||
|
onChange={(event) => set("speech", event.target.value)}
|
||||||
|
placeholder="例如:您好,在开始服务前请确认以下重要信息。"
|
||||||
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
||||||
|
/>
|
||||||
|
<span className="mt-1.5 block text-xs leading-5 text-muted-foreground">
|
||||||
|
支持 {"{{variable}}"} 动态变量。仅播报时,播放完成后进入下一节点。
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start justify-between gap-4 rounded-xl border border-hairline bg-canvas-soft px-4 py-3">
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-foreground">
|
||||||
|
显示客户端消息
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||||
|
使用平台内置弹窗,不需要创建或绑定 Client Tool。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={showMessage}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setPatch({
|
||||||
|
showMessage: checked,
|
||||||
|
...(!checked ? { requireConfirmation: false } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{showMessage && (
|
||||||
|
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
|
弹窗标题
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={draft.title ?? ""}
|
||||||
|
onChange={(event) => set("title", event.target.value)}
|
||||||
|
placeholder="重要提示"
|
||||||
|
className="border-hairline-strong bg-background"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
|
重要信息 <span className="text-destructive">*</span>
|
||||||
|
</span>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
value={draft.message ?? ""}
|
||||||
|
onChange={(event) => set("message", event.target.value)}
|
||||||
|
placeholder="请输入需要向用户展示的重要信息"
|
||||||
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
|
按钮文字
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={draft.confirmLabel ?? ""}
|
||||||
|
onChange={(event) => set("confirmLabel", event.target.value)}
|
||||||
|
placeholder="确认"
|
||||||
|
className="border-hairline-strong bg-background"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start justify-between gap-4 border-t border-hairline pt-3">
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-foreground">
|
||||||
|
等待用户确认
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||||
|
确认前禁止语音、文字和图片输入;确认后立即进入下一节点,不等待播报结束。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={requireConfirmation}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
set("requireConfirmation", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!draft.speech?.trim() && !showMessage && (
|
||||||
|
<p role="alert" className="text-xs leading-5 text-destructive">
|
||||||
|
Message 至少需要固定播报或客户端消息。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Flag, PhoneForwarded, Play, Tag } from "lucide-react";
|
import { Flag, PhoneForwarded, Tag } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { SectionCard } from "@/components/editor/section-card";
|
import { SectionCard } from "@/components/editor/section-card";
|
||||||
@@ -11,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
|
|
||||||
import { ActionNodePanel } from "./ActionNodePanel";
|
import { ActionNodePanel } from "./ActionNodePanel";
|
||||||
import { AgentNodePanel } from "./AgentNodePanel";
|
import { AgentNodePanel } from "./AgentNodePanel";
|
||||||
|
import { MessageNodePanel } from "./MessageNodePanel";
|
||||||
import { NodeSelect, ToolOptionPicker } from "./controls";
|
import { NodeSelect, ToolOptionPicker } from "./controls";
|
||||||
import type { RuntimeNodeSpec, WorkflowNodeData } from "../specs";
|
import type { RuntimeNodeSpec, WorkflowNodeData } from "../specs";
|
||||||
import type { ModelOption, WorkflowSettings } from "../types";
|
import type { ModelOption, WorkflowSettings } from "../types";
|
||||||
@@ -82,6 +83,7 @@ export function NodeSettingsPanel({
|
|||||||
spec={spec}
|
spec={spec}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
set={set}
|
set={set}
|
||||||
|
setPatch={setPatch}
|
||||||
toolOptions={toolOptions}
|
toolOptions={toolOptions}
|
||||||
argumentsJson={argumentsJson}
|
argumentsJson={argumentsJson}
|
||||||
assignmentsJson={assignmentsJson}
|
assignmentsJson={assignmentsJson}
|
||||||
@@ -247,6 +249,10 @@ export function NodeSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{spec.type === "message" && (
|
||||||
|
<MessageNodePanel draft={draft} set={set} setPatch={setPatch} />
|
||||||
|
)}
|
||||||
|
|
||||||
{spec.type === "handoff" && (
|
{spec.type === "handoff" && (
|
||||||
<NodeSelect
|
<NodeSelect
|
||||||
label="转交类型"
|
label="转交类型"
|
||||||
@@ -283,6 +289,7 @@ function WorkflowNodePanelForm({
|
|||||||
spec,
|
spec,
|
||||||
draft,
|
draft,
|
||||||
set,
|
set,
|
||||||
|
setPatch,
|
||||||
toolOptions,
|
toolOptions,
|
||||||
argumentsJson,
|
argumentsJson,
|
||||||
assignmentsJson,
|
assignmentsJson,
|
||||||
@@ -294,6 +301,7 @@ function WorkflowNodePanelForm({
|
|||||||
spec: RuntimeNodeSpec;
|
spec: RuntimeNodeSpec;
|
||||||
draft: WorkflowNodeData;
|
draft: WorkflowNodeData;
|
||||||
set: (key: string, val: unknown) => void;
|
set: (key: string, val: unknown) => void;
|
||||||
|
setPatch: (patch: Partial<WorkflowNodeData>) => void;
|
||||||
toolOptions: ModelOption[];
|
toolOptions: ModelOption[];
|
||||||
argumentsJson: string;
|
argumentsJson: string;
|
||||||
assignmentsJson: string;
|
assignmentsJson: string;
|
||||||
@@ -321,27 +329,6 @@ function WorkflowNodePanelForm({
|
|||||||
</label>
|
</label>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{spec.type === "start" && (
|
|
||||||
<SectionCard
|
|
||||||
icon={<Play size={15} />}
|
|
||||||
title="开场白"
|
|
||||||
description="会话建立后首先向用户播放的固定内容"
|
|
||||||
>
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
||||||
固定开场白
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
rows={5}
|
|
||||||
value={draft.greeting ?? ""}
|
|
||||||
onChange={(event) => set("greeting", event.target.value)}
|
|
||||||
placeholder="例如:你好,我是 AI 视频助手,有什么可以帮你?"
|
|
||||||
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{spec.type === "action" && (
|
{spec.type === "action" && (
|
||||||
<ActionNodePanel
|
<ActionNodePanel
|
||||||
draft={draft}
|
draft={draft}
|
||||||
@@ -356,6 +343,10 @@ function WorkflowNodePanelForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{spec.type === "message" && (
|
||||||
|
<MessageNodePanel draft={draft} set={set} setPatch={setPatch} />
|
||||||
|
)}
|
||||||
|
|
||||||
{spec.type === "handoff" && (
|
{spec.type === "handoff" && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<PhoneForwarded size={15} />}
|
icon={<PhoneForwarded size={15} />}
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import { Circle, type LucideIcon } from "lucide-react";
|
|||||||
import type { NodeSpecDto, TurnConfig } from "@/lib/api";
|
import type { NodeSpecDto, TurnConfig } from "@/lib/api";
|
||||||
import { defaultTurnConfig } from "@/lib/turn-config";
|
import { defaultTurnConfig } from "@/lib/turn-config";
|
||||||
|
|
||||||
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
|
export type WorkflowNodeType =
|
||||||
|
| "start"
|
||||||
|
| "agent"
|
||||||
|
| "message"
|
||||||
|
| "action"
|
||||||
|
| "handoff"
|
||||||
|
| "end";
|
||||||
export type ContextPolicy = "inherit" | "fresh";
|
export type ContextPolicy = "inherit" | "fresh";
|
||||||
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
|
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
|
||||||
export type AgentEntryMode = "wait_user" | "generate" | "fixed_speech";
|
export type AgentEntryMode = "wait_user" | "generate" | "fixed_speech";
|
||||||
@@ -26,7 +32,6 @@ export type ExpressionOperator =
|
|||||||
|
|
||||||
export type WorkflowNodeData = {
|
export type WorkflowNodeData = {
|
||||||
name: string;
|
name: string;
|
||||||
greeting?: string;
|
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
contextPolicy?: ContextPolicy;
|
contextPolicy?: ContextPolicy;
|
||||||
inheritGlobalConfig?: boolean;
|
inheritGlobalConfig?: boolean;
|
||||||
@@ -49,6 +54,11 @@ export type WorkflowNodeData = {
|
|||||||
resultAssignmentMode?: ActionResultAssignmentMode;
|
resultAssignmentMode?: ActionResultAssignmentMode;
|
||||||
resultAssignments?: Record<string, string>;
|
resultAssignments?: Record<string, string>;
|
||||||
userInputPolicy?: ActionUserInputPolicy;
|
userInputPolicy?: ActionUserInputPolicy;
|
||||||
|
speech?: string;
|
||||||
|
showMessage?: boolean;
|
||||||
|
title?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
requireConfirmation?: boolean;
|
||||||
targetType?: "ai" | "human" | "queue" | "phone";
|
targetType?: "ai" | "human" | "queue" | "phone";
|
||||||
target?: string;
|
target?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -224,13 +234,26 @@ export function defaultGraph(): WorkflowGraph {
|
|||||||
position: { x: 360, y: 60 },
|
position: { x: 360, y: 60 },
|
||||||
data: {
|
data: {
|
||||||
name: "Start",
|
name: "Start",
|
||||||
greeting: "你好,我是 AI 视频助手,有什么可以帮你?",
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "message-1",
|
||||||
|
type: "message",
|
||||||
|
position: { x: 360, y: 260 },
|
||||||
|
data: {
|
||||||
|
name: "开场消息",
|
||||||
|
speech: "你好,我是 AI 视频助手,有什么可以帮你?",
|
||||||
|
showMessage: false,
|
||||||
|
title: "重要提示",
|
||||||
|
message: "",
|
||||||
|
confirmLabel: "确认",
|
||||||
|
requireConfirmation: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
type: "agent",
|
type: "agent",
|
||||||
position: { x: 360, y: 300 },
|
position: { x: 360, y: 480 },
|
||||||
data: {
|
data: {
|
||||||
name: "Agent",
|
name: "Agent",
|
||||||
prompt: "了解用户需求并提供清晰、准确的帮助。",
|
prompt: "了解用户需求并提供清晰、准确的帮助。",
|
||||||
@@ -243,7 +266,7 @@ export function defaultGraph(): WorkflowGraph {
|
|||||||
{
|
{
|
||||||
id: "end",
|
id: "end",
|
||||||
type: "end",
|
type: "end",
|
||||||
position: { x: 360, y: 540 },
|
position: { x: 360, y: 700 },
|
||||||
data: {
|
data: {
|
||||||
name: "End",
|
name: "End",
|
||||||
message: "感谢你的来电,再见。",
|
message: "感谢你的来电,再见。",
|
||||||
@@ -253,8 +276,14 @@ export function defaultGraph(): WorkflowGraph {
|
|||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
{
|
{
|
||||||
id: "e-start-agent",
|
id: "e-start-message",
|
||||||
source: "start",
|
source: "start",
|
||||||
|
target: "message-1",
|
||||||
|
data: { mode: "always", priority: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e-message-agent",
|
||||||
|
source: "message-1",
|
||||||
target: "agent-1",
|
target: "agent-1",
|
||||||
data: { mode: "always", priority: 0 },
|
data: { mode: "always", priority: 0 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -207,6 +207,11 @@ export type StartupAction = {
|
|||||||
export type StartupConfig = {
|
export type StartupConfig = {
|
||||||
executionMode: "sequential";
|
executionMode: "sequential";
|
||||||
actions: StartupAction[];
|
actions: StartupAction[];
|
||||||
|
openingMessage: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
|
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
|
||||||
|
|||||||
Reference in New Issue
Block a user