feat: make workflow messages resumable
This commit is contained in:
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import replace
|
from dataclasses import dataclass, replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -63,9 +63,29 @@ from services.workflow_router import WorkflowLLMRouter
|
|||||||
MAX_AUTOMATIC_HOPS = 50
|
MAX_AUTOMATIC_HOPS = 50
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _MessageContinuation:
|
||||||
|
"""Resume one Workflow path after its visible Message gate completes."""
|
||||||
|
|
||||||
|
token: int
|
||||||
|
node_id: str
|
||||||
|
context_messages: list[dict[str, str]]
|
||||||
|
triggering_user_text: str
|
||||||
|
task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ConfiguredFlowManager(FlowManager):
|
class ConfiguredFlowManager(FlowManager):
|
||||||
"""Preserve Flow transitions while suppressing late async-tool replies."""
|
"""Preserve Flow transitions while suppressing late async-tool replies."""
|
||||||
|
|
||||||
|
ENTRY_ACTION_TYPE = "workflow_function_transition_entry"
|
||||||
|
|
||||||
|
async def _set_node(self, node_id: str, node_config: NodeConfig) -> None:
|
||||||
|
"""Notify Workflow only after FlowManager committed the active node."""
|
||||||
|
await super()._set_node(node_id, node_config)
|
||||||
|
after_activation = node_config.get("workflow_after_activation")
|
||||||
|
if callable(after_activation):
|
||||||
|
await after_activation(node_id)
|
||||||
|
|
||||||
async def _create_transition_func(self, name, handler):
|
async def _create_transition_func(self, name, handler):
|
||||||
transition = await super()._create_transition_func(name, handler)
|
transition = await super()._create_transition_func(name, handler)
|
||||||
if not getattr(handler, "_suppress_followup_llm", False):
|
if not getattr(handler, "_suppress_followup_llm", False):
|
||||||
@@ -136,6 +156,8 @@ 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._next_message_token = 1
|
||||||
|
self._pending_message: _MessageContinuation | None = None
|
||||||
|
|
||||||
async def greeting(self, _cfg: AssistantConfig) -> str:
|
async def greeting(self, _cfg: AssistantConfig) -> str:
|
||||||
"""Workflow opening speech belongs to an explicit Message or Agent."""
|
"""Workflow opening speech belongs to an explicit Message or Agent."""
|
||||||
@@ -179,6 +201,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
self._ended = False
|
self._ended = False
|
||||||
|
self._next_message_token = 1
|
||||||
|
self._pending_message = None
|
||||||
self._manager = ConfiguredFlowManager(
|
self._manager = ConfiguredFlowManager(
|
||||||
worker=runtime.worker,
|
worker=runtime.worker,
|
||||||
llm=runtime.llm,
|
llm=runtime.llm,
|
||||||
@@ -199,8 +223,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||||
|
|
||||||
node_config = await self._initial_node_config()
|
node_config = await self._initial_node_config()
|
||||||
await self._manager.initialize(node_config)
|
await self._activate_node_config(node_config, initialize=True)
|
||||||
await self._after_node_activated(node_config)
|
|
||||||
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
||||||
|
|
||||||
async def _initial_node_config(self) -> NodeConfig:
|
async def _initial_node_config(self) -> NodeConfig:
|
||||||
@@ -298,8 +321,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
decision.edge,
|
decision.edge,
|
||||||
triggering_user_text=content,
|
triggering_user_text=content,
|
||||||
)
|
)
|
||||||
await manager.set_node_from_config(next_config)
|
await self._activate_node_config(
|
||||||
await self._after_node_activated(
|
|
||||||
next_config,
|
next_config,
|
||||||
triggering_user_text=content,
|
triggering_user_text=content,
|
||||||
)
|
)
|
||||||
@@ -390,9 +412,12 @@ class WorkflowBrain(BaseBrain):
|
|||||||
*,
|
*,
|
||||||
triggering_user_text: str = "",
|
triggering_user_text: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Publish activation and perform exactly one Agent entry behavior."""
|
"""Run the entry behavior owned by the newly active node."""
|
||||||
node_id = str(node_config.get("name") or "")
|
node_id = str(node_config.get("name") or "")
|
||||||
node_type = self._engine.node_type(node_id)
|
node_type = self._engine.node_type(node_id)
|
||||||
|
if node_type == "message":
|
||||||
|
await self._activate_message_continuation(node_id)
|
||||||
|
return
|
||||||
if node_type != "agent":
|
if node_type != "agent":
|
||||||
if node_type == "start":
|
if node_type == "start":
|
||||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||||
@@ -401,17 +426,6 @@ class WorkflowBrain(BaseBrain):
|
|||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
data = self._engine.data(node_id)
|
data = self._engine.data(node_id)
|
||||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||||
if entry_mode == "fixed_speech":
|
|
||||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
|
||||||
await self._queue_visible_speech(
|
|
||||||
entry_speech,
|
|
||||||
source="workflow-fixed-reply",
|
|
||||||
node_id=node_id,
|
|
||||||
)
|
|
||||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
|
||||||
self._state.consume_user_turn()
|
|
||||||
return
|
|
||||||
|
|
||||||
should_run = entry_mode == "generate" or bool(triggering_user_text)
|
should_run = entry_mode == "generate" or bool(triggering_user_text)
|
||||||
if should_run:
|
if should_run:
|
||||||
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||||
@@ -420,6 +434,24 @@ class WorkflowBrain(BaseBrain):
|
|||||||
|
|
||||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||||
|
|
||||||
|
async def _activate_node_config(
|
||||||
|
self,
|
||||||
|
node_config: NodeConfig,
|
||||||
|
*,
|
||||||
|
triggering_user_text: str = "",
|
||||||
|
initialize: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Install one node and dispatch its entry behavior exactly once."""
|
||||||
|
manager = self._require_manager()
|
||||||
|
if initialize:
|
||||||
|
await manager.initialize(node_config)
|
||||||
|
else:
|
||||||
|
await manager.set_node_from_config(node_config)
|
||||||
|
await self._after_node_activated(
|
||||||
|
node_config,
|
||||||
|
triggering_user_text=triggering_user_text,
|
||||||
|
)
|
||||||
|
|
||||||
async def _queue_visible_speech(
|
async def _queue_visible_speech(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -529,21 +561,25 @@ class WorkflowBrain(BaseBrain):
|
|||||||
state; it never performs a second LLM run.
|
state; it never performs a second LLM run.
|
||||||
"""
|
"""
|
||||||
node_id = str(node_config.get("name") or "")
|
node_id = str(node_config.get("name") or "")
|
||||||
if self._engine.node_type(node_id) != "agent":
|
node_type = self._engine.node_type(node_id)
|
||||||
|
if node_type == "message":
|
||||||
|
configured = dict(node_config)
|
||||||
|
configured["workflow_after_activation"] = (
|
||||||
|
self._activate_message_continuation
|
||||||
|
)
|
||||||
|
return configured
|
||||||
|
if node_type != "agent":
|
||||||
return node_config
|
return node_config
|
||||||
entry_mode = str(
|
entry_mode = str(
|
||||||
self._engine.data(node_id).get("entryMode") or "wait_user"
|
self._engine.data(node_id).get("entryMode") or "wait_user"
|
||||||
)
|
)
|
||||||
should_run = entry_mode == "generate" or bool(triggering_user_text)
|
should_run = entry_mode == "generate" or bool(triggering_user_text)
|
||||||
configured = dict(node_config)
|
configured = dict(node_config)
|
||||||
configured["respond_immediately"] = (
|
configured["respond_immediately"] = should_run
|
||||||
should_run and entry_mode != "fixed_speech"
|
|
||||||
)
|
|
||||||
configured["pre_actions"] = [
|
configured["pre_actions"] = [
|
||||||
{
|
{
|
||||||
"type": "workflow_function_transition_entry",
|
"type": ConfiguredFlowManager.ENTRY_ACTION_TYPE,
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
"entry_mode": entry_mode,
|
|
||||||
"should_run": should_run,
|
"should_run": should_run,
|
||||||
"handler": self._activate_from_flow_transition,
|
"handler": self._activate_from_flow_transition,
|
||||||
}
|
}
|
||||||
@@ -558,19 +594,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
"""Apply visible entry state without manually queueing an LLM run."""
|
"""Apply visible entry state without manually queueing an LLM run."""
|
||||||
node_id = str(action.get("node_id") or "")
|
node_id = str(action.get("node_id") or "")
|
||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
entry_mode = str(action.get("entry_mode") or "wait_user")
|
if action.get("should_run"):
|
||||||
if entry_mode == "fixed_speech":
|
|
||||||
entry_speech = self._store.render(
|
|
||||||
str(self._engine.data(node_id).get("entrySpeech") or "")
|
|
||||||
)
|
|
||||||
await self._queue_visible_speech(
|
|
||||||
entry_speech,
|
|
||||||
source="workflow-fixed-reply",
|
|
||||||
node_id=node_id,
|
|
||||||
)
|
|
||||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
|
||||||
self._state.consume_user_turn()
|
|
||||||
elif action.get("should_run"):
|
|
||||||
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||||
else:
|
else:
|
||||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||||
@@ -619,10 +643,11 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self,
|
self,
|
||||||
edge: dict,
|
edge: dict,
|
||||||
*,
|
*,
|
||||||
|
leading_messages: list[dict[str, str]] | None = None,
|
||||||
triggering_user_text: str = "",
|
triggering_user_text: str = "",
|
||||||
) -> NodeConfig:
|
) -> NodeConfig:
|
||||||
await self._begin_edge_transition(edge)
|
await self._begin_edge_transition(edge)
|
||||||
leading_messages: list[dict[str, str]] = []
|
context_messages = list(leading_messages or [])
|
||||||
speech = self._engine.edge_transition_speech(edge)
|
speech = self._engine.edge_transition_speech(edge)
|
||||||
if speech:
|
if speech:
|
||||||
content = self._store.render(speech).strip()
|
content = self._store.render(speech).strip()
|
||||||
@@ -632,12 +657,12 @@ class WorkflowBrain(BaseBrain):
|
|||||||
source="workflow-edge-transition",
|
source="workflow-edge-transition",
|
||||||
node_id=str(edge.get("target") or "") or None,
|
node_id=str(edge.get("target") or "") or None,
|
||||||
)
|
)
|
||||||
leading_messages.append(
|
context_messages.append(
|
||||||
{"role": "assistant", "content": content}
|
{"role": "assistant", "content": content}
|
||||||
)
|
)
|
||||||
return await self._resolve_path(
|
return await self._resolve_path(
|
||||||
str(edge.get("target") or ""),
|
str(edge.get("target") or ""),
|
||||||
leading_messages=leading_messages,
|
leading_messages=context_messages,
|
||||||
triggering_user_text=triggering_user_text,
|
triggering_user_text=triggering_user_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -672,13 +697,12 @@ class WorkflowBrain(BaseBrain):
|
|||||||
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":
|
elif node_type == "message":
|
||||||
message_result = await self._enter_message(node_id)
|
self._prepare_message_continuation(
|
||||||
if not message_result.succeeded:
|
node_id,
|
||||||
return self._passive_node_config(node_id, context_messages)
|
context_messages=context_messages,
|
||||||
if message_result.speech:
|
triggering_user_text=triggering_user_text,
|
||||||
context_messages.append(
|
)
|
||||||
{"role": "assistant", "content": message_result.speech}
|
return self._passive_node_config(node_id, context_messages)
|
||||||
)
|
|
||||||
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":
|
||||||
@@ -708,6 +732,127 @@ class WorkflowBrain(BaseBrain):
|
|||||||
node_id = str(edge.get("target") or "")
|
node_id = str(edge.get("target") or "")
|
||||||
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||||
|
|
||||||
|
def _prepare_message_continuation(
|
||||||
|
self,
|
||||||
|
node_id: str,
|
||||||
|
*,
|
||||||
|
context_messages: list[dict[str, str]],
|
||||||
|
triggering_user_text: str,
|
||||||
|
) -> None:
|
||||||
|
"""Save the path state without waiting inside the pipeline call stack."""
|
||||||
|
token = self._next_message_token
|
||||||
|
self._next_message_token += 1
|
||||||
|
self._pending_message = _MessageContinuation(
|
||||||
|
token=token,
|
||||||
|
node_id=node_id,
|
||||||
|
context_messages=[dict(message) for message in context_messages],
|
||||||
|
triggering_user_text=triggering_user_text,
|
||||||
|
)
|
||||||
|
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
||||||
|
|
||||||
|
async def _activate_message_continuation(self, node_id: str) -> None:
|
||||||
|
"""Start a prepared Message only after FlowManager made it active."""
|
||||||
|
continuation = self._pending_message
|
||||||
|
if (
|
||||||
|
continuation is None
|
||||||
|
or continuation.node_id != node_id
|
||||||
|
or continuation.task is not None
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
||||||
|
await self._emit_node_active(node_id)
|
||||||
|
runtime = self._require_runtime()
|
||||||
|
if runtime.set_input_enabled is not None:
|
||||||
|
runtime.set_input_enabled(False)
|
||||||
|
continuation.task = asyncio.create_task(
|
||||||
|
self._complete_message_continuation(continuation),
|
||||||
|
name=f"workflow-message-{node_id}-{continuation.token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _complete_message_continuation(
|
||||||
|
self,
|
||||||
|
continuation: _MessageContinuation,
|
||||||
|
) -> None:
|
||||||
|
"""Wait for playback/confirmation outside routing, then resume once."""
|
||||||
|
try:
|
||||||
|
result = await self._enter_message(
|
||||||
|
continuation.node_id,
|
||||||
|
already_active=True,
|
||||||
|
input_already_blocked=True,
|
||||||
|
)
|
||||||
|
if not result.succeeded:
|
||||||
|
if self._pending_message is continuation:
|
||||||
|
self._pending_message = None
|
||||||
|
return
|
||||||
|
await self._resume_message_continuation(continuation, result)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - keep callback failures visible
|
||||||
|
logger.exception(f"Message 节点续跑失败:{exc}")
|
||||||
|
if self._pending_message is continuation:
|
||||||
|
self._pending_message = None
|
||||||
|
self._state.enter(
|
||||||
|
continuation.node_id,
|
||||||
|
WorkflowStatus.WAITING_USER,
|
||||||
|
)
|
||||||
|
runtime = self._require_runtime()
|
||||||
|
if runtime.set_input_enabled is not None:
|
||||||
|
runtime.set_input_enabled(True)
|
||||||
|
await self._require_output().emit_error(
|
||||||
|
"Message 节点完成后无法继续工作流",
|
||||||
|
node_id=continuation.node_id,
|
||||||
|
code="workflow_message_resume_error",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resume_message_continuation(
|
||||||
|
self,
|
||||||
|
continuation: _MessageContinuation,
|
||||||
|
result: MessageStageResult,
|
||||||
|
) -> None:
|
||||||
|
"""Advance from the completed Message without blocking media frames."""
|
||||||
|
async with self._turn_lock:
|
||||||
|
manager = self._require_manager()
|
||||||
|
if (
|
||||||
|
self._ended
|
||||||
|
or self._pending_message is not continuation
|
||||||
|
or self._state.current_node_id != continuation.node_id
|
||||||
|
or str(manager.current_node or "") != continuation.node_id
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._pending_message = None
|
||||||
|
context_messages = [
|
||||||
|
dict(message) for message in continuation.context_messages
|
||||||
|
]
|
||||||
|
if result.speech:
|
||||||
|
context_messages.append(
|
||||||
|
{"role": "assistant", "content": result.speech}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._engine.has_outgoing(continuation.node_id):
|
||||||
|
self._state.enter(
|
||||||
|
continuation.node_id,
|
||||||
|
WorkflowStatus.WAITING_USER,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
edge = await self._select_edge(continuation.node_id)
|
||||||
|
if not edge:
|
||||||
|
self._state.enter(
|
||||||
|
continuation.node_id,
|
||||||
|
WorkflowStatus.WAITING_USER,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
next_config = await self._follow_edge(
|
||||||
|
edge,
|
||||||
|
leading_messages=context_messages,
|
||||||
|
triggering_user_text=continuation.triggering_user_text,
|
||||||
|
)
|
||||||
|
await self._activate_node_config(
|
||||||
|
next_config,
|
||||||
|
triggering_user_text=continuation.triggering_user_text,
|
||||||
|
)
|
||||||
|
|
||||||
async def _enter_action(self, node_id: str) -> ActionOutcome:
|
async def _enter_action(self, node_id: str) -> ActionOutcome:
|
||||||
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
@@ -760,9 +905,16 @@ class WorkflowBrain(BaseBrain):
|
|||||||
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:
|
async def _enter_message(
|
||||||
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
self,
|
||||||
await self._emit_node_active(node_id)
|
node_id: str,
|
||||||
|
*,
|
||||||
|
already_active: bool = False,
|
||||||
|
input_already_blocked: bool = False,
|
||||||
|
) -> MessageStageResult:
|
||||||
|
if not already_active:
|
||||||
|
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
||||||
|
await self._emit_node_active(node_id)
|
||||||
data = self._engine.data(node_id)
|
data = self._engine.data(node_id)
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
speech = self._store.render(str(data.get("speech") or "")).strip()
|
speech = self._store.render(str(data.get("speech") or "")).strip()
|
||||||
@@ -795,6 +947,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
),
|
),
|
||||||
set_input_enabled=runtime.set_input_enabled,
|
set_input_enabled=runtime.set_input_enabled,
|
||||||
|
input_already_blocked=input_already_blocked,
|
||||||
on_started=lambda: self._emit_trace(
|
on_started=lambda: self._emit_trace(
|
||||||
"message_started",
|
"message_started",
|
||||||
nodeId=node_id,
|
nodeId=node_id,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Any
|
|||||||
SPEC_VERSION = "3"
|
SPEC_VERSION = "3"
|
||||||
NODE_TYPES = {"start", "agent", "message", "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"}
|
||||||
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", "message", "action", "handoff"}
|
AUTOMATIC_NODE_TYPES = {"start", "message", "action", "handoff"}
|
||||||
@@ -149,10 +149,11 @@ def _edge_data_v3(edge: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_agent_data(data: dict[str, Any]) -> None:
|
def _normalize_agent_data(data: dict[str, Any]) -> None:
|
||||||
"""Add v3 Agent defaults without changing existing node-level behavior."""
|
"""Keep Agent entry focused on conversation, not fixed interaction."""
|
||||||
data.setdefault("contextPolicy", "inherit")
|
data.setdefault("contextPolicy", "inherit")
|
||||||
data.setdefault("entryMode", "wait_user")
|
if data.get("entryMode") not in AGENT_ENTRY_MODES:
|
||||||
data.setdefault("entrySpeech", "")
|
data["entryMode"] = "wait_user"
|
||||||
|
data.pop("entrySpeech", None)
|
||||||
if "inheritGlobalConfig" not in data:
|
if "inheritGlobalConfig" not in data:
|
||||||
has_node_overrides = any(
|
has_node_overrides = any(
|
||||||
(
|
(
|
||||||
@@ -300,7 +301,6 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
"contextPolicy": "inherit",
|
"contextPolicy": "inherit",
|
||||||
"inheritGlobalConfig": True,
|
"inheritGlobalConfig": True,
|
||||||
"entryMode": "wait_user",
|
"entryMode": "wait_user",
|
||||||
"entrySpeech": "",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -376,10 +376,6 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
|||||||
entry_mode = data.get("entryMode", "wait_user")
|
entry_mode = data.get("entryMode", "wait_user")
|
||||||
if entry_mode not in AGENT_ENTRY_MODES:
|
if entry_mode not in AGENT_ENTRY_MODES:
|
||||||
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}")
|
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}")
|
||||||
elif entry_mode == "fixed_speech" and not str(
|
|
||||||
data.get("entrySpeech") or ""
|
|
||||||
).strip():
|
|
||||||
errors.append(f"Agent 节点 {node_id} 的固定进入语不能为空")
|
|
||||||
elif node_type == "message":
|
elif node_type == "message":
|
||||||
data = node.get("data") or {}
|
data = node.get("data") or {}
|
||||||
speech = data.get("speech")
|
speech = data.get("speech")
|
||||||
|
|||||||
@@ -109,25 +109,15 @@ class WorkflowAgentStage:
|
|||||||
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)
|
||||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
|
||||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
|
||||||
strategy = (
|
strategy = (
|
||||||
ContextStrategy.RESET
|
ContextStrategy.RESET
|
||||||
if data.get("contextPolicy") == "fresh"
|
if data.get("contextPolicy") == "fresh"
|
||||||
else ContextStrategy.APPEND
|
else ContextStrategy.APPEND
|
||||||
)
|
)
|
||||||
fixed_reply_messages = (
|
|
||||||
[{"role": "assistant", "content": entry_speech}]
|
|
||||||
if entry_mode == "fixed_speech" and entry_speech
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"name": node_id,
|
"name": node_id,
|
||||||
"role_message": self.role_message(node_id),
|
"role_message": self.role_message(node_id),
|
||||||
"task_messages": [
|
"task_messages": list(leading_messages or []),
|
||||||
*(leading_messages or []),
|
|
||||||
*fixed_reply_messages,
|
|
||||||
],
|
|
||||||
"functions": functions,
|
"functions": functions,
|
||||||
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||||
# Direct node activations let WorkflowRuntime decide whether to run
|
# Direct node activations let WorkflowRuntime decide whether to run
|
||||||
|
|||||||
@@ -965,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_starts_without_workflow_greeting(self):
|
async def test_initial_message_starts_without_workflow_greeting(self):
|
||||||
brain = WorkflowBrain(
|
brain = WorkflowBrain(
|
||||||
{
|
{
|
||||||
"specVersion": 3,
|
"specVersion": 3,
|
||||||
@@ -976,20 +976,30 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"type": "start",
|
"type": "start",
|
||||||
"data": {"greeting": "欢迎使用"},
|
"data": {"greeting": "欢迎使用"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "message",
|
||||||
|
"type": "message",
|
||||||
|
"data": {
|
||||||
|
"speech": "请问您怎么称呼?",
|
||||||
|
"showMessage": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "agent",
|
"id": "agent",
|
||||||
"type": "agent",
|
"type": "agent",
|
||||||
"data": {
|
"data": {"prompt": "收集用户信息"},
|
||||||
"prompt": "收集用户信息",
|
|
||||||
"entryMode": "fixed_speech",
|
|
||||||
"entrySpeech": "请问您怎么称呼?",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"edges": [
|
"edges": [
|
||||||
{
|
{
|
||||||
"id": "begin",
|
"id": "begin",
|
||||||
"source": "start",
|
"source": "start",
|
||||||
|
"target": "message",
|
||||||
|
"data": {"mode": "always", "priority": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "after_message",
|
||||||
|
"source": "message",
|
||||||
"target": "agent",
|
"target": "agent",
|
||||||
"data": {"mode": "always", "priority": 0},
|
"data": {"mode": "always", "priority": 0},
|
||||||
}
|
}
|
||||||
@@ -1032,15 +1042,17 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
await brain.on_connected()
|
await brain.on_connected()
|
||||||
|
|
||||||
|
self.assertEqual(brain._manager.current_node, "message")
|
||||||
|
for _ in range(3):
|
||||||
|
await asyncio.sleep(0)
|
||||||
self.assertEqual(brain._manager.current_node, "agent")
|
self.assertEqual(brain._manager.current_node, "agent")
|
||||||
fixed_speech_frames = [
|
message_speech_frames = [
|
||||||
frame for frame in queued if isinstance(frame, TTSSpeakFrame)
|
frame for frame in queued if isinstance(frame, TTSSpeakFrame)
|
||||||
]
|
]
|
||||||
self.assertEqual(len(fixed_speech_frames), 1)
|
self.assertEqual(len(message_speech_frames), 1)
|
||||||
self.assertEqual(fixed_speech_frames[0].text, "请问您怎么称呼?")
|
self.assertEqual(message_speech_frames[0].text, "请问您怎么称呼?")
|
||||||
|
|
||||||
# Workflow no longer owns a greeting playback lifecycle. Stray generic
|
# Stray generic greeting notifications must not replay the Message.
|
||||||
# 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)]),
|
||||||
@@ -1533,6 +1545,175 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(result.succeeded)
|
self.assertTrue(result.succeeded)
|
||||||
self.assertEqual(input_states, [False, True])
|
self.assertEqual(input_states, [False, True])
|
||||||
|
|
||||||
|
async def test_message_between_agents_resumes_after_playback(self):
|
||||||
|
graph = {
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "opening",
|
||||||
|
"type": "message",
|
||||||
|
"data": {"speech": "欢迎使用。"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "agent1",
|
||||||
|
"type": "agent",
|
||||||
|
"data": {"prompt": "收集基本信息"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "middle",
|
||||||
|
"type": "message",
|
||||||
|
"data": {"speech": "现在进入信息确认。"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "agent2",
|
||||||
|
"type": "agent",
|
||||||
|
"data": {"prompt": "确认信息", "contextPolicy": "fresh"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end",
|
||||||
|
"type": "end",
|
||||||
|
"data": {"scope": "session"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "start-opening",
|
||||||
|
"source": "start",
|
||||||
|
"target": "opening",
|
||||||
|
"data": {"mode": "always"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "opening-agent1",
|
||||||
|
"source": "opening",
|
||||||
|
"target": "agent1",
|
||||||
|
"data": {"mode": "always"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "agent1-middle",
|
||||||
|
"source": "agent1",
|
||||||
|
"target": "middle",
|
||||||
|
"data": {
|
||||||
|
"mode": "llm",
|
||||||
|
"priority": 10,
|
||||||
|
"condition": "基本信息已经收集完成",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "middle-agent2",
|
||||||
|
"source": "middle",
|
||||||
|
"target": "agent2",
|
||||||
|
"data": {"mode": "always"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "agent2-end",
|
||||||
|
"source": "agent2",
|
||||||
|
"target": "end",
|
||||||
|
"data": {
|
||||||
|
"mode": "llm",
|
||||||
|
"priority": 10,
|
||||||
|
"condition": "用户确认可以结束通话",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
brain = WorkflowBrain(graph)
|
||||||
|
queued = []
|
||||||
|
input_states = []
|
||||||
|
|
||||||
|
class PlaybackCallEnd(FakeCallEnd):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.completions = []
|
||||||
|
|
||||||
|
def track_speech(self):
|
||||||
|
completion = asyncio.get_running_loop().create_future()
|
||||||
|
self.completions.append(completion)
|
||||||
|
return completion
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.current_node = None
|
||||||
|
self.configs = []
|
||||||
|
|
||||||
|
async def initialize(self, config):
|
||||||
|
self.current_node = config["name"]
|
||||||
|
self.configs.append(config)
|
||||||
|
|
||||||
|
async def set_node_from_config(self, config):
|
||||||
|
self.current_node = config["name"]
|
||||||
|
self.configs.append(config)
|
||||||
|
|
||||||
|
async def queue_frame(frame):
|
||||||
|
queued.append(frame)
|
||||||
|
|
||||||
|
call_end = PlaybackCallEnd()
|
||||||
|
manager = FakeManager()
|
||||||
|
|
||||||
|
class MatchingRouter:
|
||||||
|
async def select_edge(self, **kwargs):
|
||||||
|
edge = kwargs["edges"][0]
|
||||||
|
return LLMRouteResult(
|
||||||
|
status=RouteStatus.MATCHED,
|
||||||
|
function_name=kwargs["edge_name"](edge),
|
||||||
|
)
|
||||||
|
|
||||||
|
brain._router = MatchingRouter()
|
||||||
|
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,
|
||||||
|
set_input_enabled=input_states.append,
|
||||||
|
)
|
||||||
|
brain._manager = manager
|
||||||
|
|
||||||
|
await brain.on_connected()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
self.assertEqual(manager.current_node, "opening")
|
||||||
|
self.assertEqual(len(call_end.completions), 1)
|
||||||
|
|
||||||
|
call_end.completions[0].set_result(None)
|
||||||
|
for _ in range(5):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if manager.current_node == "agent1":
|
||||||
|
break
|
||||||
|
self.assertEqual(manager.current_node, "agent1")
|
||||||
|
|
||||||
|
# The user-turn processor must return while the second Message is
|
||||||
|
# still waiting for its transport playback boundary.
|
||||||
|
await asyncio.wait_for(
|
||||||
|
brain.on_user_turn_end("基本信息已经收集完成"),
|
||||||
|
timeout=0.1,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
self.assertEqual(manager.current_node, "middle")
|
||||||
|
self.assertEqual(len(call_end.completions), 2)
|
||||||
|
self.assertFalse(call_end.completions[1].done())
|
||||||
|
|
||||||
|
call_end.completions[1].set_result(None)
|
||||||
|
for _ in range(5):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if manager.current_node == "agent2":
|
||||||
|
break
|
||||||
|
self.assertEqual(manager.current_node, "agent2")
|
||||||
|
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
||||||
|
self.assertEqual(
|
||||||
|
manager.configs[-1]["task_messages"],
|
||||||
|
[
|
||||||
|
{"role": "user", "content": "基本信息已经收集完成"},
|
||||||
|
{"role": "assistant", "content": "现在进入信息确认。"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
await brain.on_assistant_text_end("agent2-turn", "信息确认完成", False)
|
||||||
|
await brain.on_user_turn_end("结束通话")
|
||||||
|
self.assertEqual(manager.current_node, "end")
|
||||||
|
self.assertTrue(call_end.finished)
|
||||||
|
|
||||||
async def test_nodes_without_outgoing_edges_remain_active(self):
|
async def test_nodes_without_outgoing_edges_remain_active(self):
|
||||||
queued = []
|
queued = []
|
||||||
|
|
||||||
@@ -2087,6 +2268,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(context.get_messages(), [])
|
self.assertEqual(context.get_messages(), [])
|
||||||
await brain.on_connected()
|
await brain.on_connected()
|
||||||
self.assertEqual(brain._manager.current_node, "agent")
|
self.assertEqual(brain._manager.current_node, "agent")
|
||||||
|
await brain.on_client_ready()
|
||||||
variable_events = [
|
variable_events = [
|
||||||
frame.message
|
frame.message
|
||||||
for frame in queued
|
for frame in queued
|
||||||
@@ -2150,58 +2332,14 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await brain._after_node_activated(generate_config)
|
await brain._after_node_activated(generate_config)
|
||||||
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
|
||||||
|
|
||||||
brain._engine.data("agent").update(
|
brain._engine.data("agent")["entryMode"] = "wait_user"
|
||||||
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
|
|
||||||
)
|
|
||||||
fixed_config = brain._agent_config("agent")
|
|
||||||
self.assertFalse(fixed_config["respond_immediately"])
|
|
||||||
self.assertNotIn("pre_actions", fixed_config)
|
|
||||||
self.assertEqual(
|
|
||||||
fixed_config["task_messages"],
|
|
||||||
[{"role": "assistant", "content": "您好,王先生"}],
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
brain._agent_config(
|
brain._agent_config(
|
||||||
"agent",
|
"agent",
|
||||||
[{"role": "assistant", "content": "正在进入下一阶段"}],
|
[{"role": "assistant", "content": "正在进入下一阶段"}],
|
||||||
)["task_messages"],
|
)["task_messages"],
|
||||||
[
|
[{"role": "assistant", "content": "正在进入下一阶段"}],
|
||||||
{"role": "assistant", "content": "正在进入下一阶段"},
|
|
||||||
{"role": "assistant", "content": "您好,王先生"},
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
worker.frames.clear()
|
|
||||||
queued.clear()
|
|
||||||
await brain._manager.set_node_from_config(fixed_config)
|
|
||||||
await brain._after_node_activated(fixed_config)
|
|
||||||
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
|
||||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
|
||||||
context_updates = [
|
|
||||||
frame
|
|
||||||
for frame in worker.frames
|
|
||||||
if isinstance(frame, LLMMessagesUpdateFrame)
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
context_updates[-1].messages,
|
|
||||||
[{"role": "assistant", "content": "您好,王先生"}],
|
|
||||||
)
|
|
||||||
self.assertFalse(
|
|
||||||
any(
|
|
||||||
isinstance(frame, OutputTransportMessageUrgentFrame)
|
|
||||||
and frame.message.get("source") == "workflow-fixed-reply"
|
|
||||||
for frame in queued
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await brain.on_client_ready()
|
|
||||||
fixed_reply_events = [
|
|
||||||
frame.message
|
|
||||||
for frame in queued
|
|
||||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
|
||||||
and frame.message.get("source") == "workflow-fixed-reply"
|
|
||||||
]
|
|
||||||
self.assertEqual(fixed_reply_events[0]["content"], "您好,王先生")
|
|
||||||
self.assertEqual(fixed_reply_events[0]["nodeId"], "agent")
|
|
||||||
self.assertIn("您好,王先生", brain._store.values["system__conversation_history"])
|
|
||||||
|
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
any(
|
any(
|
||||||
@@ -2252,7 +2390,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
assistant_transcripts,
|
assistant_transcripts,
|
||||||
["您好,王先生", "正在为你结束流程", "感谢来电"],
|
["正在为你结束流程", "感谢来电"],
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"正在为你结束流程",
|
"正在为你结束流程",
|
||||||
|
|||||||
@@ -111,22 +111,27 @@ class WorkflowGraphTests(unittest.TestCase):
|
|||||||
self.assertTrue(body.vision_enabled)
|
self.assertTrue(body.vision_enabled)
|
||||||
self.assertIsNone(body.vision_model_resource_id)
|
self.assertIsNone(body.vision_model_resource_id)
|
||||||
|
|
||||||
def test_agent_entry_mode_defaults_and_validation(self):
|
def test_agent_entry_modes_only_control_conversation_start(self):
|
||||||
graph = valid_graph()
|
graph = valid_graph()
|
||||||
normalized = normalize_graph(graph)
|
normalized = normalize_graph(graph)
|
||||||
agent = next(node for node in normalized["nodes"] if node["type"] == "agent")
|
agent = next(node for node in normalized["nodes"] if node["type"] == "agent")
|
||||||
self.assertEqual(agent["data"]["entryMode"], "wait_user")
|
self.assertEqual(agent["data"]["entryMode"], "wait_user")
|
||||||
self.assertEqual(agent["data"]["entrySpeech"], "")
|
self.assertNotIn("entrySpeech", agent["data"])
|
||||||
self.assertTrue(agent["data"]["inheritGlobalConfig"])
|
self.assertTrue(agent["data"]["inheritGlobalConfig"])
|
||||||
self.assertEqual(agent["data"]["contextPolicy"], "fresh")
|
self.assertEqual(agent["data"]["contextPolicy"], "fresh")
|
||||||
|
|
||||||
agent["data"]["entryMode"] = "fixed_speech"
|
agent["data"]["entryMode"] = "generate"
|
||||||
self.assertTrue(
|
|
||||||
any("固定进入语不能为空" in error for error in validate_graph(normalized))
|
|
||||||
)
|
|
||||||
agent["data"]["entrySpeech"] = "您好,{{customer}}"
|
|
||||||
self.assertEqual(validate_graph(normalized), [])
|
self.assertEqual(validate_graph(normalized), [])
|
||||||
|
|
||||||
|
agent["data"]["entryMode"] = "fixed_speech"
|
||||||
|
agent["data"]["entrySpeech"] = "您好,{{customer}}"
|
||||||
|
cleaned = normalize_graph(normalized)
|
||||||
|
cleaned_agent = next(
|
||||||
|
node for node in cleaned["nodes"] if node["type"] == "agent"
|
||||||
|
)
|
||||||
|
self.assertEqual(cleaned_agent["data"]["entryMode"], "wait_user")
|
||||||
|
self.assertNotIn("entrySpeech", cleaned_agent["data"])
|
||||||
|
|
||||||
def test_action_defaults_preserve_legacy_result_assignment_behavior(self):
|
def test_action_defaults_preserve_legacy_result_assignment_behavior(self):
|
||||||
graph = valid_graph()
|
graph = valid_graph()
|
||||||
graph["nodes"].extend(
|
graph["nodes"].extend(
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
const entryModeLabel = {
|
const entryModeLabel = {
|
||||||
wait_user: "等待用户",
|
wait_user: "等待用户",
|
||||||
generate: "立即回复",
|
generate: "立即回复",
|
||||||
fixed_speech: "固定进入语",
|
|
||||||
}[nodeData.entryMode ?? "wait_user"];
|
}[nodeData.entryMode ?? "wait_user"];
|
||||||
const inheritsGlobal = nodeData.inheritGlobalConfig !== false;
|
const inheritsGlobal = nodeData.inheritGlobalConfig !== false;
|
||||||
const meta = type === "agent"
|
const meta = type === "agent"
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
|
|||||||
contextPolicy: "inherit",
|
contextPolicy: "inherit",
|
||||||
inheritGlobalConfig: true,
|
inheritGlobalConfig: true,
|
||||||
entryMode: "wait_user",
|
entryMode: "wait_user",
|
||||||
entrySpeech: "",
|
|
||||||
});
|
});
|
||||||
} else if (spec.type === "action") {
|
} else if (spec.type === "action") {
|
||||||
Object.assign(data, {
|
Object.assign(data, {
|
||||||
|
|||||||
@@ -170,28 +170,13 @@ export function AgentNodePanel({
|
|||||||
options={[
|
options={[
|
||||||
{ value: "wait_user", label: "等待用户说话(默认)" },
|
{ value: "wait_user", label: "等待用户说话(默认)" },
|
||||||
{ value: "generate", label: "立即让 LLM 回复" },
|
{ value: "generate", label: "立即让 LLM 回复" },
|
||||||
{ value: "fixed_speech", label: "播放固定进入语" },
|
|
||||||
]}
|
]}
|
||||||
onChange={(value) => set("entryMode", value || "wait_user")}
|
onChange={(value) => set("entryMode", value || "wait_user")}
|
||||||
allowNone={false}
|
allowNone={false}
|
||||||
/>
|
/>
|
||||||
{draft.entryMode === "fixed_speech" && (
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
<label className="block">
|
固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。
|
||||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
</p>
|
||||||
固定进入语 <span className="text-destructive">*</span>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
value={draft.entrySpeech ?? ""}
|
|
||||||
onChange={(event) => set("entrySpeech", event.target.value)}
|
|
||||||
placeholder="例如:您好,请告诉我需要处理的问题。"
|
|
||||||
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
||||||
/>
|
|
||||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
|
||||||
支持使用 {"{{variable}}"} 动态变量;只播放语音,不调用 LLM。
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{!inheritsGlobal && (
|
{!inheritsGlobal && (
|
||||||
|
|||||||
@@ -171,28 +171,13 @@ export function NodeSettingsPanel({
|
|||||||
options={[
|
options={[
|
||||||
{ value: "wait_user", label: "等待用户说话(默认)" },
|
{ value: "wait_user", label: "等待用户说话(默认)" },
|
||||||
{ value: "generate", label: "立即让 LLM 回复" },
|
{ value: "generate", label: "立即让 LLM 回复" },
|
||||||
{ value: "fixed_speech", label: "播放固定进入语" },
|
|
||||||
]}
|
]}
|
||||||
onChange={(value) => set("entryMode", value || "wait_user")}
|
onChange={(value) => set("entryMode", value || "wait_user")}
|
||||||
allowNone={false}
|
allowNone={false}
|
||||||
/>
|
/>
|
||||||
{draft.entryMode === "fixed_speech" && (
|
<p className="text-xs leading-5 text-muted-soft">
|
||||||
<div className="block">
|
固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。
|
||||||
<div className="mb-2 text-sm font-medium text-foreground">
|
</p>
|
||||||
固定进入语 <span className="text-destructive">*</span>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
value={draft.entrySpeech ?? ""}
|
|
||||||
onChange={(event) => set("entrySpeech", event.target.value)}
|
|
||||||
placeholder="例如:您好,请告诉我需要处理的问题。"
|
|
||||||
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
||||||
/>
|
|
||||||
<span className="mt-2 block text-xs text-muted-soft">
|
|
||||||
支持使用 {"{{variable}}"} 动态变量;只播放语音,不调用 LLM。
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium text-foreground">可用工具</label>
|
<label className="text-sm font-medium text-foreground">可用工具</label>
|
||||||
<ToolOptionPicker
|
<ToolOptionPicker
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type WorkflowNodeType =
|
|||||||
| "end";
|
| "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";
|
||||||
export type ActionResultAssignmentMode = "inherit" | "override" | "none";
|
export type ActionResultAssignmentMode = "inherit" | "override" | "none";
|
||||||
export type ActionUserInputPolicy = "queue" | "block";
|
export type ActionUserInputPolicy = "queue" | "block";
|
||||||
export type EdgeMode = "llm" | "expression" | "always";
|
export type EdgeMode = "llm" | "expression" | "always";
|
||||||
@@ -36,7 +36,6 @@ export type WorkflowNodeData = {
|
|||||||
contextPolicy?: ContextPolicy;
|
contextPolicy?: ContextPolicy;
|
||||||
inheritGlobalConfig?: boolean;
|
inheritGlobalConfig?: boolean;
|
||||||
entryMode?: AgentEntryMode;
|
entryMode?: AgentEntryMode;
|
||||||
entrySpeech?: string;
|
|
||||||
toolIds?: string[];
|
toolIds?: string[];
|
||||||
knowledgeBaseId?: string;
|
knowledgeBaseId?: string;
|
||||||
knowledgeMode?: KnowledgeMode;
|
knowledgeMode?: KnowledgeMode;
|
||||||
@@ -260,7 +259,6 @@ export function defaultGraph(): WorkflowGraph {
|
|||||||
contextPolicy: "inherit",
|
contextPolicy: "inherit",
|
||||||
inheritGlobalConfig: true,
|
inheritGlobalConfig: true,
|
||||||
entryMode: "wait_user",
|
entryMode: "wait_user",
|
||||||
entrySpeech: "",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user