Add workflow support and enhance runtime configuration in models and services

- Introduce RuntimeModelResource and RuntimeKnowledgeBase classes to manage workflow resources.
- Update AssistantConfig to include workflow_model_resources and workflow_knowledge_bases for better integration.
- Refactor validation and processing logic in routes and services to accommodate workflow types.
- Implement dynamic variable support for workflow assistants and enhance graph normalization.
- Add ToolExecutor for reusable tool execution across different assistant types.
- Update various services to ensure compatibility with new workflow features and improve error handling.
This commit is contained in:
Xin Wang
2026-07-13 16:13:27 +08:00
parent 6108b00007
commit 32aef14ddb
27 changed files with 2563 additions and 910 deletions
@@ -1,14 +1,13 @@
"use client";
/**
* 条件边。边携带 condition(自然语言条件,LLM 据此决定是否走这条路径)与
* label(日志里识别该路径的短标签)。悬停/选中时在标签旁显示「编辑 / 删除」按钮。
* Workflow v3 edge: LLM judgement, variable expression, or deterministic default.
*/
import {
BaseEdge,
EdgeLabelRenderer,
getSmoothStepPath,
getBezierPath,
type EdgeProps,
useReactFlow,
} from "@xyflow/react";
@@ -36,20 +35,22 @@ export function ConditionEdge({
// 点击标签:只选中这条边(露出编辑/删除按钮),不直接进入编辑。
const selectThisEdge = () =>
setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === id })));
const [path, labelX, labelY] = getSmoothStepPath({
const [path, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 8,
offset: 20,
curvature: 0.28,
});
const label = ((data?.label as string) || (data?.condition as string) || "")
.toString()
.trim();
const mode = (data?.mode as string) || "always";
const label = (
(data?.label as string) ||
(mode === "llm" ? (data?.condition as string) : "") ||
(mode === "expression" ? "变量表达式" : "默认路径")
).toString().trim();
const expanded = hovered || selected;
return (
@@ -27,9 +27,20 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
const nodeData = data as WorkflowNodeData;
const Icon = spec.icon;
const preview = (nodeData.greeting || nodeData.prompt || "")
const preview = (nodeData.greeting || nodeData.prompt || nodeData.message || "")
.toString()
.trim();
const meta = type === "agent"
? [
nodeData.contextPolicy === "fresh" ? "独立上下文" : "继承上下文",
`${nodeData.toolIds?.length ?? 0} 工具`,
nodeData.knowledgeBaseId ? "知识库" : null,
nodeData.asrResourceId ? "独立 ASR" : null,
nodeData.ttsResourceId ? "独立 TTS" : null,
].filter(Boolean)
: type === "action" && nodeData.toolId
? ["确定性工具"]
: [];
return (
<div
@@ -46,7 +57,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
{spec.hasTarget && (
<Handle
type="target"
position={Position.Left}
position={Position.Top}
className="!h-3 !w-3 !border-[3px] !border-card !bg-muted-soft"
/>
)}
@@ -86,7 +97,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
>
<Pencil size={13} />
</button>
{type !== "startCall" && (
{type !== "start" && (
<button
type="button"
title="删除节点"
@@ -129,11 +140,22 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
</p>
)}
{meta.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{meta.map((item) => (
<span key={item as string} className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] text-muted-foreground">
{item}
</span>
))}
</div>
)}
{spec.hasSource && (
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary"
position={Position.Bottom}
title="拖到节点或画布空白处"
className="!h-3 !w-3 !border-[3px] !border-card !bg-primary transition-transform hover:!scale-125"
/>
)}
</div>
@@ -141,8 +163,9 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
}
export const nodeTypes = {
startCall: GenericNode,
agentNode: GenericNode,
endCall: GenericNode,
globalNode: GenericNode,
start: GenericNode,
agent: GenericNode,
action: GenericNode,
handoff: GenericNode,
end: GenericNode,
};
File diff suppressed because it is too large Load Diff
+99 -63
View File
@@ -1,36 +1,62 @@
/**
* 工作流节点的前端类型与运行期规格。
*
* 节点「目录」(有哪些类型、各自的字段与约束)由后端 /api/node-types 提供,
* 通过 useNodeSpecs 拉取后用 toRuntimeSpec 转成带 React 组件(图标)的运行期规格。
* 本文件只保留:类型定义、默认图、图标/配色解析。新增节点类型改后端即可。
*/
/** Workflow v3 public graph types and defaults. */
import * as LucideIcons from "lucide-react";
import { Circle, type LucideIcon } from "lucide-react";
import type { NodeSpecDto } from "@/lib/api";
export type WorkflowNodeType =
| "startCall"
| "agentNode"
| "endCall"
| "globalNode";
export type WorkflowNodeType = "start" | "agent" | "action" | "handoff" | "end";
export type ContextPolicy = "inherit" | "fresh";
export type KnowledgeMode = "automatic" | "on_demand" | "disabled";
export type EdgeMode = "llm" | "expression" | "always";
export type ExpressionOperator =
| "eq"
| "neq"
| "gt"
| "gte"
| "lt"
| "lte"
| "contains"
| "in"
| "exists";
export type WorkflowNodeData = {
/** 节点显示名 */
name: string;
/** 开场白(仅 startCall) */
greeting?: string;
/** 节点提示词 */
prompt?: string;
/** 允许打断(仅 agentNode) */
allowInterrupt?: boolean;
/** 是否合并全局节点提示词(start/agent 默认开启,end 默认关闭) */
addGlobalPrompt?: boolean;
contextPolicy?: ContextPolicy;
toolIds?: string[];
knowledgeBaseId?: string;
knowledgeMode?: KnowledgeMode;
knowledgeTopN?: number;
knowledgeScoreThreshold?: number;
asrResourceId?: string;
ttsResourceId?: string;
toolId?: string;
arguments?: Record<string, unknown>;
resultAssignments?: Record<string, string>;
targetType?: "ai" | "human" | "queue" | "phone";
target?: string;
message?: string;
scope?: "flow" | "session";
[key: string]: unknown;
};
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;
@@ -39,7 +65,6 @@ export type FieldSpec = {
default?: unknown;
};
/** 解析后的运行期节点规格(DTO + 解析出的 React 图标 + 派生句柄) */
export type RuntimeNodeSpec = {
type: string;
displayName: string;
@@ -47,9 +72,7 @@ export type RuntimeNodeSpec = {
icon: LucideIcon;
accent: string;
addable: boolean;
/** 入边句柄(开始节点没有) */
hasTarget: boolean;
/** 出边句柄(结束节点没有) */
hasSource: boolean;
constraints: {
minIncoming?: number;
@@ -62,7 +85,6 @@ export type RuntimeNodeSpec = {
fields: FieldSpec[];
};
/** 渐变 token → CSS 变量名(图标底色用),未知配色回落到 sky */
export const ACCENT_VAR: Record<string, string> = {
mint: "--gradient-mint",
sky: "--gradient-sky",
@@ -75,13 +97,11 @@ export function accentVar(accent: string): string {
return ACCENT_VAR[accent] ?? ACCENT_VAR.sky;
}
/** 按名字解析 Lucide 图标,找不到回落到 Circle(对齐 dograh resolveIcon)。 */
export function resolveIcon(name: string): LucideIcon {
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
return icons[name] ?? Circle;
}
/** 后端 DTO → 运行期规格。hasTarget/hasSource 由入/出边上限派生。 */
export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
return {
type: dto.name,
@@ -93,12 +113,12 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
hasTarget: dto.constraints.maxIncoming !== 0,
hasSource: dto.constraints.maxOutgoing !== 0,
constraints: dto.constraints,
fields: dto.fields.map((f) => ({
key: f.key,
label: f.label,
type: f.type,
required: f.required,
default: f.default,
fields: dto.fields.map((field) => ({
key: field.key,
label: field.label,
type: field.type,
required: field.required,
default: field.default,
})),
};
}
@@ -106,6 +126,12 @@ export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
export type NodeSpecMap = Record<string, RuntimeNodeSpec>;
export type WorkflowGraph = {
specVersion: 3;
settings: {
globalPrompt: string;
defaultAsrResourceId: string;
defaultTtsResourceId: string;
};
nodes: Array<{
id: string;
type: WorkflowNodeType;
@@ -116,62 +142,72 @@ export type WorkflowGraph = {
id: string;
source: string;
target: string;
data?: { condition?: string; label?: string; transition_speech?: string };
data: WorkflowEdgeData;
}>;
viewport?: { x: number; y: number; zoom: number };
};
/** 新建工作流的默认图:全局规则 + 开始 → 智能体 → 结束 */
export function defaultGraph(): WorkflowGraph {
return {
specVersion: 3,
settings: {
globalPrompt:
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
defaultAsrResourceId: "",
defaultTtsResourceId: "",
},
nodes: [
{
id: "start",
type: "startCall",
position: { x: 100, y: 120 },
type: "start",
position: { x: 360, y: 60 },
data: {
name: "开始",
greeting: "你好,我是 AI 视频助手,有什么可以帮你?",
prompt: "了解用户的需求,并在信息明确后进入下一节点。",
allowInterrupt: true,
addGlobalPrompt: true,
name: "Start",
greeting: "你好,我是 AI 视频助手,有什么可以帮你?",
},
},
{
id: "agent-1",
type: "agentNode",
position: { x: 420, y: 120 },
type: "agent",
position: { x: 360, y: 300 },
data: {
name: "智能体节点",
prompt: "根据用户需求提供清晰、准确的帮助。",
allowInterrupt: true,
addGlobalPrompt: true,
name: "Agent",
prompt: "了解用户需求并提供清晰、准确的帮助。",
contextPolicy: "inherit",
toolIds: [],
knowledgeMode: "disabled",
knowledgeTopN: 5,
knowledgeScoreThreshold: 0,
},
},
{
id: "end",
type: "endCall",
position: { x: 740, y: 120 },
type: "end",
position: { x: 360, y: 540 },
data: {
name: "结束",
prompt: "总结已经完成的事项,礼貌道别并结束通话。",
addGlobalPrompt: false,
},
},
{
id: "global",
type: "globalNode",
position: { x: 100, y: 400 },
data: {
name: "全局设定",
prompt:
"你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
name: "End",
message: "感谢你的来电,再见。",
scope: "session",
},
},
],
edges: [
{ id: "e-start-agent", source: "start", target: "agent-1", data: {} },
{ id: "e-agent-end", source: "agent-1", target: "end", data: {} },
{
id: "e-start-agent",
source: "start",
target: "agent-1",
data: { mode: "always", priority: 0 },
},
{
id: "e-agent-end",
source: "agent-1",
target: "end",
data: {
mode: "llm",
priority: 10,
condition: "当前阶段任务已经完成,适合结束会话",
},
},
],
};
}