feat: add deterministic message interaction stages

This commit is contained in:
Xin Wang
2026-08-03 07:10:41 +08:00
parent 479a516546
commit 9daa46ed4d
26 changed files with 1356 additions and 414 deletions

View File

@@ -8,12 +8,12 @@ from typing import Any
SPEC_VERSION = "3"
NODE_TYPES = {"start", "agent", "action", "handoff", "end"}
NODE_TYPES = {"start", "agent", "message", "action", "handoff", "end"}
EDGE_MODES = {"llm", "expression", "always"}
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
ACTION_RESULT_ASSIGNMENT_MODES = {"inherit", "override", "none"}
ACTION_USER_INPUT_POLICIES = {"queue", "block"}
AUTOMATIC_NODE_TYPES = {"start", "action", "handoff"}
AUTOMATIC_NODE_TYPES = {"start", "message", "action", "handoff"}
EXPRESSION_OPERATORS = {
"eq",
"neq",
@@ -31,7 +31,7 @@ NODE_SPECS: list[dict[str, Any]] = [
"name": "start",
"displayName": "Start",
"category": "control_node",
"description": "初始化会话、动态变量和全局观察器,可播放固定开场白",
"description": "初始化会话、动态变量和全局观察器。",
"icon": "Play",
"accent": "mint",
"addable": False,
@@ -44,7 +44,6 @@ NODE_SPECS: list[dict[str, Any]] = [
},
"fields": [
{"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",
"displayName": "Action",
@@ -172,6 +184,17 @@ def _normalize_action_data(data: dict[str, Any]) -> None:
)
data.setdefault("resultAssignments", {})
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:
@@ -200,8 +223,12 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
source.setdefault("edges", [])
for node in source["nodes"]:
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)
elif node.get("type") == "message":
_normalize_message_data(data)
elif node.get("type") == "action":
_normalize_action_data(data)
return source
@@ -218,6 +245,7 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
"endCall": "end",
"start": "start",
"agent": "agent",
"message": "message",
"action": "action",
"handoff": "handoff",
"end": "end",
@@ -236,10 +264,13 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
data.setdefault("scope", "session")
elif new_type == "agent":
_normalize_agent_data(data)
elif new_type == "message":
_normalize_message_data(data)
elif new_type == "action":
_normalize_action_data(data)
elif new_type == "start":
prompt = str(data.pop("prompt", "") or "").strip()
data.pop("greeting", None)
if prompt:
start_prompt_nodes[str(node.get("id"))] = prompt
for key in ("allowInterrupt", "addGlobalPrompt"):
@@ -349,6 +380,49 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
data.get("entrySpeech") or ""
).strip():
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":
data = node.get("data") or {}
assignment_mode = data.get("resultAssignmentMode")
@@ -477,7 +551,7 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
if node.get("type") != "agent"
)
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))