feat(workflow): enhance message stages and image routing
This commit is contained in:
@@ -16,7 +16,11 @@ import {
|
||||
NodeActionContext,
|
||||
NodeSpecsContext,
|
||||
} from "./context";
|
||||
import { accentVar, type WorkflowNodeData } from "./specs";
|
||||
import {
|
||||
accentVar,
|
||||
messageCompletionPolicy,
|
||||
type WorkflowNodeData,
|
||||
} from "./specs";
|
||||
|
||||
export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
const specs = useContext(NodeSpecsContext);
|
||||
@@ -27,6 +31,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
if (!spec) return null;
|
||||
|
||||
const nodeData = data as WorkflowNodeData;
|
||||
const messagePolicy = messageCompletionPolicy(nodeData);
|
||||
const Icon = spec.icon;
|
||||
const preview = (
|
||||
nodeData.prompt ||
|
||||
@@ -62,8 +67,10 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
: type === "message"
|
||||
? [
|
||||
nodeData.speech ? "固定播报" : null,
|
||||
nodeData.showMessage ? "客户端消息" : null,
|
||||
nodeData.requireConfirmation ? "等待确认" : null,
|
||||
messagePolicy === "confirmation" ? "客户端消息" : null,
|
||||
messagePolicy === "interruptible" ? "可打断" : null,
|
||||
messagePolicy === "playback" ? "播放完继续" : null,
|
||||
messagePolicy === "confirmation" ? "确认后继续" : null,
|
||||
].filter(Boolean)
|
||||
: [];
|
||||
|
||||
|
||||
@@ -80,11 +80,10 @@ function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
|
||||
} else if (spec.type === "message") {
|
||||
Object.assign(data, {
|
||||
speech: "",
|
||||
showMessage: false,
|
||||
title: "重要提示",
|
||||
message: "",
|
||||
confirmLabel: "确认",
|
||||
requireConfirmation: false,
|
||||
completionPolicy: "playback",
|
||||
});
|
||||
}
|
||||
for (const field of spec.fields) {
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import { ArrowRight, MessageSquareText } from "lucide-react";
|
||||
|
||||
import { SectionCard } from "@/components/editor/section-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import type { WorkflowNodeData } from "../specs";
|
||||
import {
|
||||
messageCompletionPolicy,
|
||||
type MessageCompletionPolicy,
|
||||
type WorkflowNodeData,
|
||||
} from "../specs";
|
||||
|
||||
const COMPLETION_OPTIONS: Array<{
|
||||
value: MessageCompletionPolicy;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "interruptible",
|
||||
label: "可打断",
|
||||
description: "用户说话、发文字或图片时立即进入下一节点,并保留这次输入。",
|
||||
},
|
||||
{
|
||||
value: "playback",
|
||||
label: "播放完继续",
|
||||
description: "播报期间关闭输入,实际播放完成后自动进入下一节点。",
|
||||
},
|
||||
{
|
||||
value: "confirmation",
|
||||
label: "确认后继续",
|
||||
description: "关闭对话输入并显示弹窗,只有用户在客户端确认后才能继续。",
|
||||
},
|
||||
];
|
||||
|
||||
export function MessageNodePanel({
|
||||
draft,
|
||||
@@ -18,111 +50,126 @@ export function MessageNodePanel({
|
||||
set: (key: string, value: unknown) => void;
|
||||
setPatch: (patch: Partial<WorkflowNodeData>) => void;
|
||||
}) {
|
||||
const showMessage = Boolean(draft.showMessage);
|
||||
const requireConfirmation = Boolean(draft.requireConfirmation);
|
||||
const completionPolicy = messageCompletionPolicy(draft);
|
||||
const completionDescription = COMPLETION_OPTIONS.find(
|
||||
(option) => option.value === completionPolicy,
|
||||
)?.description;
|
||||
|
||||
const selectCompletionPolicy = (value: MessageCompletionPolicy) => {
|
||||
setPatch({
|
||||
completionPolicy: value,
|
||||
requireConfirmation: undefined,
|
||||
showMessage: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
显示客户端消息
|
||||
<>
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="固定播报"
|
||||
description="配置进入 Message 节点后播放的固定话术"
|
||||
>
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
||||
播报内容 <span className="text-destructive">*</span>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
{!draft.speech?.trim() && (
|
||||
<p role="alert" className="text-xs leading-5 text-destructive">
|
||||
请输入播报内容。
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<ArrowRight size={15} />}
|
||||
title="继续方式"
|
||||
description="控制 Message 节点何时进入下一节点"
|
||||
>
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium text-foreground">继续行为</div>
|
||||
<Select
|
||||
value={completionPolicy}
|
||||
onValueChange={(value: MessageCompletionPolicy) =>
|
||||
selectCompletionPolicy(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COMPLETION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
{completionDescription}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!draft.speech?.trim() && !showMessage && (
|
||||
<p role="alert" className="text-xs leading-5 text-destructive">
|
||||
Message 至少需要固定播报或客户端消息。
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
{completionPolicy === "confirmation" && (
|
||||
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">客户端确认消息</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
使用平台内置弹窗,不需要创建或绑定 Client Tool。
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p className="border-t border-hairline pt-3 text-xs leading-5 text-muted-foreground">
|
||||
确认前禁止语音、文字和图片输入;确认后立即进入下一节点,不等待播报结束。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ export type WorkflowNodeType =
|
||||
export type ContextPolicy = "inherit" | "fresh";
|
||||
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
|
||||
export type AgentEntryMode = "wait_user" | "generate";
|
||||
export type MessageCompletionPolicy =
|
||||
| "interruptible"
|
||||
| "playback"
|
||||
| "confirmation";
|
||||
export type ActionResultAssignmentMode = "inherit" | "override" | "none";
|
||||
export type ActionUserInputPolicy = "queue" | "block";
|
||||
export type EdgeMode = "llm" | "expression" | "always";
|
||||
@@ -54,9 +58,12 @@ export type WorkflowNodeData = {
|
||||
resultAssignments?: Record<string, string>;
|
||||
userInputPolicy?: ActionUserInputPolicy;
|
||||
speech?: string;
|
||||
/** Legacy field read only while an older graph is open in the editor. */
|
||||
showMessage?: boolean;
|
||||
title?: string;
|
||||
confirmLabel?: string;
|
||||
completionPolicy?: MessageCompletionPolicy;
|
||||
/** Legacy field read only while an older graph is open in the editor. */
|
||||
requireConfirmation?: boolean;
|
||||
targetType?: "ai" | "human" | "queue" | "phone";
|
||||
target?: string;
|
||||
@@ -65,6 +72,20 @@ export type WorkflowNodeData = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/** Infer the new Message policy for graphs saved before it was explicit. */
|
||||
export function messageCompletionPolicy(
|
||||
data: WorkflowNodeData,
|
||||
): MessageCompletionPolicy {
|
||||
if (
|
||||
data.completionPolicy === "interruptible" ||
|
||||
data.completionPolicy === "playback" ||
|
||||
data.completionPolicy === "confirmation"
|
||||
) {
|
||||
return data.completionPolicy;
|
||||
}
|
||||
return data.requireConfirmation === true ? "confirmation" : "playback";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve how an Action writes tool results while preserving workflows saved
|
||||
* before resultAssignmentMode existed. Those nodes explicitly passed an empty
|
||||
@@ -242,11 +263,10 @@ export function defaultGraph(): WorkflowGraph {
|
||||
data: {
|
||||
name: "开场消息",
|
||||
speech: "你好,我是 AI 视频助手,有什么可以帮你?",
|
||||
showMessage: false,
|
||||
title: "重要提示",
|
||||
message: "",
|
||||
confirmLabel: "确认",
|
||||
requireConfirmation: false,
|
||||
completionPolicy: "playback",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user