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

@@ -85,55 +85,18 @@ export function PromptEditor({
handlePromptVisionEnabledChange,
handlePromptModelChange,
}: PromptEditorProps) {
const openingMessage = form.startup.actions.find(
(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 }));
const openingMessage = form.startup.openingMessage;
function setOpeningMessage(enabled: boolean) {
const otherActions = form.startup.actions.filter(
(action) => action.id !== "opening_message",
);
const defaultToolId = showMessageTools[0]?.value ?? "";
updateForm("startup", {
executionMode: "sequential",
actions: enabled
? [
...otherActions,
{
id: "opening_message",
phase: "opening",
toolId: defaultToolId,
required: true,
arguments: {
title: "重要提示",
message: "请确认已阅读以上信息。",
actions: [
{ id: "confirmed", label: "确认", style: "primary" },
],
dismissible: false,
},
},
]
: otherActions,
...form.startup,
openingMessage: enabled
? {
title: "重要提示",
message: "请确认已阅读以上信息。",
confirmLabel: "确认",
}
: null,
});
}
@@ -143,16 +106,10 @@ export function PromptEditor({
if (!openingMessage) return;
updateForm("startup", {
...form.startup,
actions: form.startup.actions.map((action) =>
action.id === openingMessage.id ? { ...action, ...patch } : action,
),
openingMessage: { ...openingMessage, ...patch },
});
}
function updateOpeningArguments(patch: Record<string, unknown>) {
updateOpeningMessage({ arguments: { ...openingArguments, ...patch } });
}
return (
<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">
@@ -235,33 +192,21 @@ export function PromptEditor({
<div className="mt-4 space-y-3 border-t border-hairline pt-4">
<ToggleRow
title="开场确认弹窗"
description="与开场白同时显示,确认且播报完成后才允许用户开始对话。"
description="与开场白同时显示,用户确认后即可开始对话,不等待播报完成。"
checked={Boolean(openingMessage)}
onChange={setOpeningMessage}
/>
{openingMessage && (
<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">
<span className="mb-1.5 block text-sm font-medium text-foreground">
</span>
<Input
value={String(openingArguments.title ?? "")}
value={openingMessage.title}
onChange={(event) =>
updateOpeningArguments({ title: event.target.value })
updateOpeningMessage({ title: event.target.value })
}
placeholder="重要提示"
className="border-hairline-strong bg-background"
@@ -269,8 +214,8 @@ export function PromptEditor({
</label>
<TextAreaField
label="重要信息"
value={String(openingArguments.message ?? "")}
onChange={(message) => updateOpeningArguments({ message })}
value={openingMessage.message}
onChange={(message) => updateOpeningMessage({ message })}
placeholder="请输入需要用户确认的重要信息"
rows={4}
/>
@@ -279,16 +224,10 @@ export function PromptEditor({
</span>
<Input
value={String(openingButton.label ?? "")}
value={openingMessage.confirmLabel}
onChange={(event) =>
updateOpeningArguments({
actions: [
{
id: "confirmed",
label: event.target.value,
style: "primary",
},
],
updateOpeningMessage({
confirmLabel: event.target.value,
})
}
placeholder="确认"
@@ -296,8 +235,8 @@ export function PromptEditor({
/>
</label>
<p className="text-xs leading-5 text-muted-foreground">
</p>
</div>
)}
@@ -318,10 +257,14 @@ export function PromptEditor({
value={form.runtimeMode}
onChange={(runtimeMode) => {
updateForm("runtimeMode", runtimeMode);
if (runtimeMode === "realtime" && form.startup.actions.length) {
if (
runtimeMode === "realtime" &&
(form.startup.actions.length || form.startup.openingMessage)
) {
updateForm("startup", {
executionMode: "sequential",
actions: [],
openingMessage: null,
});
}
}}

View File

@@ -177,7 +177,11 @@ function blankPromptForm(name: string): AssistantForm {
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
startup: { executionMode: "sequential", actions: [] },
startup: {
executionMode: "sequential",
actions: [],
openingMessage: null,
},
visionEnabled: false,
visionModelResourceId: "",
toolIds: [],
@@ -466,7 +470,11 @@ export function AssistantPage(props: AssistantPageProps) {
a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(),
enableInterrupt: a.enableInterrupt,
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,
visionModelResourceId: a.visionModelResourceId ?? "",
toolIds: a.toolIds ?? [],
@@ -536,7 +544,11 @@ export function AssistantPage(props: AssistantPageProps) {
greeting: "",
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
startup: { executionMode: "sequential", actions: [] },
startup: {
executionMode: "sequential",
actions: [],
openingMessage: null,
},
visionEnabled: false,
visionModelResourceId: null,
modelResourceIds: {},

View File

@@ -27,7 +27,12 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
const nodeData = data as WorkflowNodeData;
const Icon = spec.icon;
const preview = (nodeData.greeting || nodeData.prompt || nodeData.message || "")
const preview = (
nodeData.prompt ||
nodeData.speech ||
nodeData.message ||
""
)
.toString()
.trim();
const entryModeLabel = {
@@ -50,8 +55,17 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
nodeData.visionEnabled ? "视觉理解" : null,
].filter(Boolean)
: type === "action" && nodeData.toolId
? ["确定性工具"]
: [];
? [
"确定性工具",
nodeData.userInputPolicy === "block" ? "禁止输入" : null,
].filter(Boolean)
: type === "message"
? [
nodeData.speech ? "固定播报" : null,
nodeData.showMessage ? "客户端消息" : null,
nodeData.requireConfirmation ? "等待确认" : null,
].filter(Boolean)
: [];
return (
<div
@@ -192,6 +206,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
export const nodeTypes = {
start: GenericNode,
agent: GenericNode,
message: GenericNode,
action: GenericNode,
handoff: GenericNode,
end: GenericNode,

View File

@@ -63,21 +63,30 @@ let nodeSeq = 0;
function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
const data: WorkflowNodeData = {
name: spec.displayName,
...(spec.type === "agent"
? {
contextPolicy: "inherit",
inheritGlobalConfig: true,
entryMode: "wait_user",
entrySpeech: "",
}
: spec.type === "action"
? {
arguments: {},
resultAssignmentMode: "inherit",
userInputPolicy: "queue",
}
: {}),
};
if (spec.type === "agent") {
Object.assign(data, {
contextPolicy: "inherit",
inheritGlobalConfig: true,
entryMode: "wait_user",
entrySpeech: "",
});
} else if (spec.type === "action") {
Object.assign(data, {
arguments: {},
resultAssignmentMode: "inherit",
userInputPolicy: "queue",
});
} else if (spec.type === "message") {
Object.assign(data, {
speech: "",
showMessage: false,
title: "重要提示",
message: "",
confirmLabel: "确认",
requireConfirmation: false,
});
}
for (const field of spec.fields) {
if (field.default !== undefined) data[field.key] = field.default;
}

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

View File

@@ -1,6 +1,6 @@
"use client";
import { Flag, PhoneForwarded, Play, Tag } from "lucide-react";
import { Flag, PhoneForwarded, Tag } from "lucide-react";
import { useState } from "react";
import { SectionCard } from "@/components/editor/section-card";
@@ -11,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ActionNodePanel } from "./ActionNodePanel";
import { AgentNodePanel } from "./AgentNodePanel";
import { MessageNodePanel } from "./MessageNodePanel";
import { NodeSelect, ToolOptionPicker } from "./controls";
import type { RuntimeNodeSpec, WorkflowNodeData } from "../specs";
import type { ModelOption, WorkflowSettings } from "../types";
@@ -82,6 +83,7 @@ export function NodeSettingsPanel({
spec={spec}
draft={draft}
set={set}
setPatch={setPatch}
toolOptions={toolOptions}
argumentsJson={argumentsJson}
assignmentsJson={assignmentsJson}
@@ -247,6 +249,10 @@ export function NodeSettingsPanel({
/>
)}
{spec.type === "message" && (
<MessageNodePanel draft={draft} set={set} setPatch={setPatch} />
)}
{spec.type === "handoff" && (
<NodeSelect
label="转交类型"
@@ -283,6 +289,7 @@ function WorkflowNodePanelForm({
spec,
draft,
set,
setPatch,
toolOptions,
argumentsJson,
assignmentsJson,
@@ -294,6 +301,7 @@ function WorkflowNodePanelForm({
spec: RuntimeNodeSpec;
draft: WorkflowNodeData;
set: (key: string, val: unknown) => void;
setPatch: (patch: Partial<WorkflowNodeData>) => void;
toolOptions: ModelOption[];
argumentsJson: string;
assignmentsJson: string;
@@ -321,27 +329,6 @@ function WorkflowNodePanelForm({
</label>
</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" && (
<ActionNodePanel
draft={draft}
@@ -356,6 +343,10 @@ function WorkflowNodePanelForm({
/>
)}
{spec.type === "message" && (
<MessageNodePanel draft={draft} set={set} setPatch={setPatch} />
)}
{spec.type === "handoff" && (
<SectionCard
icon={<PhoneForwarded size={15} />}

View File

@@ -6,7 +6,13 @@ import { Circle, type LucideIcon } from "lucide-react";
import type { NodeSpecDto, TurnConfig } from "@/lib/api";
import { defaultTurnConfig } from "@/lib/turn-config";
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
export type WorkflowNodeType =
| "start"
| "agent"
| "message"
| "action"
| "handoff"
| "end";
export type ContextPolicy = "inherit" | "fresh";
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
export type AgentEntryMode = "wait_user" | "generate" | "fixed_speech";
@@ -26,7 +32,6 @@ export type ExpressionOperator =
export type WorkflowNodeData = {
name: string;
greeting?: string;
prompt?: string;
contextPolicy?: ContextPolicy;
inheritGlobalConfig?: boolean;
@@ -49,6 +54,11 @@ export type WorkflowNodeData = {
resultAssignmentMode?: ActionResultAssignmentMode;
resultAssignments?: Record<string, string>;
userInputPolicy?: ActionUserInputPolicy;
speech?: string;
showMessage?: boolean;
title?: string;
confirmLabel?: string;
requireConfirmation?: boolean;
targetType?: "ai" | "human" | "queue" | "phone";
target?: string;
message?: string;
@@ -224,13 +234,26 @@ export function defaultGraph(): WorkflowGraph {
position: { x: 360, y: 60 },
data: {
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",
type: "agent",
position: { x: 360, y: 300 },
position: { x: 360, y: 480 },
data: {
name: "Agent",
prompt: "了解用户需求并提供清晰、准确的帮助。",
@@ -243,7 +266,7 @@ export function defaultGraph(): WorkflowGraph {
{
id: "end",
type: "end",
position: { x: 360, y: 540 },
position: { x: 360, y: 700 },
data: {
name: "End",
message: "感谢你的来电,再见。",
@@ -253,8 +276,14 @@ export function defaultGraph(): WorkflowGraph {
],
edges: [
{
id: "e-start-agent",
id: "e-start-message",
source: "start",
target: "message-1",
data: { mode: "always", priority: 0 },
},
{
id: "e-message-agent",
source: "message-1",
target: "agent-1",
data: { mode: "always", priority: 0 },
},

View File

@@ -207,6 +207,11 @@ export type StartupAction = {
export type StartupConfig = {
executionMode: "sequential";
actions: StartupAction[];
openingMessage: {
title: string;
message: string;
confirmLabel: string;
} | null;
};
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */