Files
ai-video-fullstack/frontend/src/components/workflow/specs.ts

314 lines
8.0 KiB
TypeScript

/** Workflow v3 public graph types and defaults. */
import * as LucideIcons from "lucide-react";
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"
| "message"
| "action"
| "handoff"
| "end";
export type ContextPolicy = "inherit" | "fresh";
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
export type AgentEntryMode = "wait_user" | "generate" | "fixed_speech";
export type ActionResultAssignmentMode = "inherit" | "override" | "none";
export type ActionUserInputPolicy = "queue" | "block";
export type EdgeMode = "llm" | "expression" | "always";
export type ExpressionOperator =
| "eq"
| "neq"
| "gt"
| "gte"
| "lt"
| "lte"
| "contains"
| "in"
| "exists";
export type WorkflowNodeData = {
name: string;
prompt?: string;
contextPolicy?: ContextPolicy;
inheritGlobalConfig?: boolean;
entryMode?: AgentEntryMode;
entrySpeech?: string;
toolIds?: string[];
knowledgeBaseId?: string;
knowledgeMode?: KnowledgeMode;
knowledgeTopN?: number;
knowledgeScoreThreshold?: number;
llmResourceId?: string;
asrResourceId?: string;
ttsResourceId?: string;
visionEnabled?: boolean;
visionModelResourceId?: string;
enableInterrupt?: boolean;
turnConfig?: TurnConfig;
toolId?: string;
arguments?: Record<string, unknown>;
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;
scope?: "flow" | "session";
[key: string]: unknown;
};
/**
* Resolve how an Action writes tool results while preserving workflows saved
* before resultAssignmentMode existed. Those nodes explicitly passed an empty
* mapping at runtime, so an absent/empty mapping means "none", not "inherit".
*/
export function actionResultAssignmentMode(
data: WorkflowNodeData,
): ActionResultAssignmentMode {
if (
data.resultAssignmentMode === "inherit" ||
data.resultAssignmentMode === "override" ||
data.resultAssignmentMode === "none"
) {
return data.resultAssignmentMode;
}
return Object.keys(data.resultAssignments ?? {}).length > 0
? "override"
: "none";
}
/** Older Action nodes allowed input to continue, which matches queue. */
export function actionUserInputPolicy(
data: WorkflowNodeData,
): ActionUserInputPolicy {
return data.userInputPolicy === "block" ? "block" : "queue";
}
export type ExpressionRule = {
variable: string;
operator: ExpressionOperator;
value?: unknown;
};
export type WorkflowEdgeData = {
mode: EdgeMode;
priority: number;
condition?: string;
expression?: { combinator: "and" | "or"; rules: ExpressionRule[] };
label?: string;
transitionSpeech?: string;
};
export type FieldSpec = {
key: string;
label: string;
type: "text" | "textarea" | "switch";
required?: boolean;
default?: unknown;
};
export type RuntimeNodeSpec = {
type: string;
displayName: string;
description: string;
icon: LucideIcon;
accent: string;
addable: boolean;
hasTarget: boolean;
hasSource: boolean;
constraints: {
minIncoming?: number;
maxIncoming?: number;
minOutgoing?: number;
maxOutgoing?: number;
minInstances?: number;
maxInstances?: number;
};
fields: FieldSpec[];
};
export const ACCENT_VAR: Record<string, string> = {
mint: "--gradient-mint",
sky: "--gradient-sky",
rose: "--gradient-rose",
peach: "--gradient-peach",
lavender: "--gradient-lavender",
};
export function accentVar(accent: string): string {
return ACCENT_VAR[accent] ?? ACCENT_VAR.sky;
}
export function resolveIcon(name: string): LucideIcon {
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
return icons[name] ?? Circle;
}
export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
return {
type: dto.name,
displayName: dto.displayName,
description: dto.description,
icon: resolveIcon(dto.icon),
accent: dto.accent,
addable: dto.addable,
hasTarget: dto.constraints.maxIncoming !== 0,
hasSource: dto.constraints.maxOutgoing !== 0,
constraints: dto.constraints,
fields: dto.fields.map((field) => ({
key: field.key,
label: field.label,
type: field.type,
required: field.required,
default: field.default,
})),
};
}
export type NodeSpecMap = Record<string, RuntimeNodeSpec>;
export type WorkflowGraph = {
specVersion: 3;
settings: {
globalPrompt: string;
defaultLlmResourceId: string;
defaultAsrResourceId: string;
defaultTtsResourceId: string;
visionEnabled: boolean;
visionModelResourceId: string;
toolIds: string[];
knowledgeBaseId: string;
knowledgeMode: "automatic" | "on_demand";
knowledgeTopN: number;
knowledgeScoreThreshold: number;
enableInterrupt: boolean;
turnConfig: TurnConfig;
};
nodes: Array<{
id: string;
type: WorkflowNodeType;
position: { x: number; y: number };
data: WorkflowNodeData;
}>;
edges: Array<{
id: string;
source: string;
target: string;
data: WorkflowEdgeData;
}>;
viewport?: { x: number; y: number; zoom: number };
};
export function defaultGraph(): WorkflowGraph {
return {
specVersion: 3,
settings: {
globalPrompt:
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
defaultLlmResourceId: "",
defaultAsrResourceId: "",
defaultTtsResourceId: "",
visionEnabled: false,
visionModelResourceId: "",
toolIds: [],
knowledgeBaseId: "",
knowledgeMode: "automatic",
knowledgeTopN: 5,
knowledgeScoreThreshold: 0,
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
},
nodes: [
{
id: "start",
type: "start",
position: { x: 360, y: 60 },
data: {
name: "Start",
},
},
{
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: 480 },
data: {
name: "Agent",
prompt: "了解用户需求并提供清晰、准确的帮助。",
contextPolicy: "inherit",
inheritGlobalConfig: true,
entryMode: "wait_user",
entrySpeech: "",
},
},
{
id: "end",
type: "end",
position: { x: 360, y: 700 },
data: {
name: "End",
message: "感谢你的来电,再见。",
scope: "session",
},
},
],
edges: [
{
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 },
},
{
id: "e-agent-end",
source: "agent-1",
target: "end",
data: {
mode: "llm",
priority: 10,
condition: "当前阶段任务已经完成,适合结束会话",
},
},
],
};
}
/** The camera stays connected when at least one effective Agent can use it. */
export function workflowUsesVision(graph: WorkflowGraph): boolean {
return graph.nodes.some((node) => {
if (node.type !== "agent") return false;
const inheritsGlobal = node.data.inheritGlobalConfig !== false;
return inheritsGlobal
? graph.settings.visionEnabled
: Boolean(node.data.visionEnabled);
});
}