feat: Enhance AI Workflow with Variable Contracts and Input Mapping

- Added ConfigSchema, InputSchema, OutputSchema, and DefaultInputs to AIWorkflowNodeSpecResponse.
- Implemented BuildAIWorkflowNodeSpecs to include variable contracts for start and send_reply nodes.
- Introduced applyAutoInputMappings to automatically map inputs based on node connections.
- Enhanced validation to check for required input mappings in workflows.
- Updated workflow editor to support variable selection for node inputs.
- Translated node names and labels to Chinese for better localization.
- Added tests for variable mapping and validation logic.
This commit is contained in:
mlogclub
2026-06-22 18:33:39 +08:00
parent 8a2c076691
commit d1f39ae57e
19 changed files with 1332 additions and 73 deletions
@@ -89,19 +89,55 @@ const emptyDefinition: AIWorkflowDefinition = {
{
id: "start_1",
type: "start",
name: "Start",
position: { x: 0, y: 80 },
name: "开始",
position: { x: 0, y: 120 },
config: {},
},
{
id: "retrieve_1",
type: "knowledge_retrieve",
name: "知识检索",
position: { x: 260, y: 120 },
config: {},
inputs: {
query: { nodeId: "start_1", field: "userMessage" },
},
},
{
id: "reply_1",
type: "llm_reply",
name: "AI 回复",
position: { x: 520, y: 120 },
config: {},
inputs: {
userMessage: { nodeId: "start_1", field: "userMessage" },
knowledgeItems: { nodeId: "retrieve_1", field: "items" },
},
},
{
id: "send_1",
type: "send_reply",
name: "发送回复",
position: { x: 780, y: 120 },
config: {},
inputs: {
replyText: { nodeId: "reply_1", field: "replyText" },
},
},
{
id: "end_1",
type: "end",
name: "End",
position: { x: 360, y: 80 },
name: "结束",
position: { x: 1040, y: 120 },
config: {},
},
],
edges: [{ id: "edge_start_end", source: "start_1", target: "end_1" }],
edges: [
{ id: "edge_start_retrieve", source: "start_1", target: "retrieve_1" },
{ id: "edge_retrieve_reply", source: "retrieve_1", target: "reply_1" },
{ id: "edge_reply_send", source: "reply_1", target: "send_1" },
{ id: "edge_send_end", source: "send_1", target: "end_1" },
],
}
function toText(value: string | number | undefined | null) {
@@ -7,51 +7,85 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { VariableSelector } from "./variable-selector"
import type {
WorkflowNodeSpec,
WorkflowVariableRef,
WorkflowVariableSelector,
} from "./workflow-utils"
type WorkflowNodeData = Record<string, unknown> & {
nodeType?: string
name?: string
config?: Record<string, unknown>
inputs?: Record<string, WorkflowVariableSelector>
}
export function NodeConfigPanel({
node,
nodeSpec,
availableVariables,
onChange,
}: {
node: Node<WorkflowNodeData> | null
nodeSpec?: WorkflowNodeSpec
availableVariables: WorkflowVariableRef[]
onChange: (nodeId: string, data: WorkflowNodeData) => void
}) {
if (!node) {
return (
<div className="flex h-full items-center justify-center px-4 text-sm text-muted-foreground">
Select a node to edit its properties.
</div>
)
}
return <NodeConfigForm key={node.id} node={node} onChange={onChange} />
return (
<NodeConfigForm
key={node.id}
node={node}
nodeSpec={nodeSpec}
availableVariables={availableVariables}
onChange={onChange}
/>
)
}
function NodeConfigForm({
node,
nodeSpec,
availableVariables,
onChange,
}: {
node: Node<WorkflowNodeData>
nodeSpec?: WorkflowNodeSpec
availableVariables: WorkflowVariableRef[]
onChange: (nodeId: string, data: WorkflowNodeData) => void
}) {
const [name, setName] = useState(node.data.name ?? "")
const [configText, setConfigText] = useState(JSON.stringify(node.data.config ?? {}, null, 2))
const [inputs, setInputs] = useState<Record<string, WorkflowVariableSelector>>(
node.data.inputs ?? {}
)
const [error, setError] = useState("")
const inputSchema = nodeSpec?.inputSchema ?? []
const outputSchema = nodeSpec?.outputSchema ?? []
const commitChange = (next: Partial<WorkflowNodeData>) => {
onChange(node.id, {
...node.data,
name: name.trim() || node.data.nodeType || node.id,
config: node.data.config ?? {},
inputs,
...next,
})
}
const handleApply = () => {
try {
const parsed = JSON.parse(configText || "{}") as Record<string, unknown>
setError("")
onChange(node.id, {
...node.data,
name: name.trim() || node.data.nodeType || node.id,
config: parsed,
})
commitChange({ config: parsed })
} catch {
setError("Config must be valid JSON.")
}
@@ -64,24 +98,90 @@ function NodeConfigForm({
<div className="mt-1 text-xs text-muted-foreground">{node.id}</div>
</div>
<div className="space-y-2">
<Label htmlFor="workflow-node-name">Name</Label>
<Label htmlFor="workflow-node-name"></Label>
<Input
id="workflow-node-name"
value={name}
onChange={(event) => setName(event.target.value)}
onBlur={() => commitChange({ name: name.trim() || node.data.nodeType || node.id })}
/>
</div>
<div className="min-h-0 flex-1 space-y-2">
<Label htmlFor="workflow-node-config">Config JSON</Label>
<Textarea
id="workflow-node-config"
className="h-64 font-mono text-xs"
value={configText}
onChange={(event) => setConfigText(event.target.value)}
/>
</div>
{error ? <div className="text-xs text-destructive">{error}</div> : null}
<Button onClick={handleApply}>Apply</Button>
{inputSchema.length > 0 ? (
<div className="space-y-3">
<div className="text-sm font-medium"></div>
{availableVariables.length === 0 ? (
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
</div>
) : null}
{inputSchema.map((input) => (
<div key={input.name} className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">
{input.name}
{input.required ? <span className="text-destructive"> *</span> : null}
</Label>
<span className="text-xs text-muted-foreground">{input.type}</span>
</div>
<VariableSelector
value={inputs[input.name]}
variables={availableVariables}
onChange={(value) => {
const nextInputs = {
...inputs,
[input.name]: value,
}
setInputs(nextInputs)
commitChange({
inputs: nextInputs,
})
}}
/>
{inputs[input.name] ? (
<div className="text-xs text-muted-foreground">
{inputs[input.name].nodeId}.{inputs[input.name].field}
</div>
) : null}
{input.description ? (
<div className="text-xs text-muted-foreground">{input.description}</div>
) : null}
</div>
))}
</div>
) : null}
<details className="rounded-md border bg-background p-3">
<summary className="cursor-pointer text-sm font-medium"> JSON</summary>
<div className="mt-3 space-y-2">
<Textarea
id="workflow-node-config"
className="h-40 font-mono text-xs"
value={configText}
onChange={(event) => setConfigText(event.target.value)}
/>
{error ? <div className="text-xs text-destructive">{error}</div> : null}
<Button type="button" variant="outline" size="sm" onClick={handleApply}>
</Button>
</div>
</details>
{outputSchema.length > 0 ? (
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="space-y-1 rounded-md border bg-background p-2">
{outputSchema.map((output) => (
<div key={output.name} className="space-y-0.5 rounded-sm px-1 py-0.5">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="truncate font-medium">{output.name}</span>
<span className="shrink-0 text-muted-foreground">{output.type}</span>
</div>
{output.description ? (
<div className="text-xs text-muted-foreground">{output.description}</div>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,35 @@
"use client"
import { OptionCombobox } from "@/components/option-combobox"
import type { WorkflowVariableRef, WorkflowVariableSelector } from "./workflow-utils"
export function VariableSelector({
value,
variables,
onChange,
}: {
value?: WorkflowVariableSelector
variables: WorkflowVariableRef[]
onChange: (value: WorkflowVariableSelector) => void
}) {
const options = variables.map((item) => ({
value: `${item.nodeId}.${item.field}`,
label: `${item.nodeName}.${item.field} · ${item.type}`,
}))
const selectedValue = value?.nodeId && value.field ? `${value.nodeId}.${value.field}` : ""
return (
<OptionCombobox
value={selectedValue}
options={options}
placeholder="选择变量"
searchPlaceholder="搜索变量"
emptyText="没有可用上游变量"
onChange={(nextValue) => {
const [nodeId, ...fieldParts] = nextValue.split(".")
onChange({ nodeId, field: fieldParts.join(".") })
}}
/>
)
}
@@ -6,15 +6,18 @@ import {
addEdge,
Background,
Controls,
Handle,
MiniMap,
Position,
ReactFlow,
useEdgesState,
useNodesState,
type Connection,
type Edge,
type Node,
type NodeProps,
} from "@xyflow/react"
import { PlusIcon } from "lucide-react"
import { AlertCircleIcon, CheckCircle2Icon, PlusIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Badge } from "@/components/ui/badge"
@@ -31,11 +34,16 @@ import {
} from "@/components/ui/resizable"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import {
applyAutoInputMappings,
fromApiDefinition,
getAvailableVariables,
getNodeSpec,
getRequiredInputs,
toApiDefinition,
validateWorkflowDraft,
type WorkflowEditorEdge,
type WorkflowEditorNode,
type WorkflowNodeSpec,
} from "./workflow-utils"
import { NodeConfigPanel } from "./node-config-panel"
@@ -43,22 +51,33 @@ type WorkflowNodeData = Record<string, unknown> & {
nodeType?: string
name?: string
config?: Record<string, unknown>
inputs?: Record<string, { nodeId: string; field: string }>
label?: string
title?: string
description?: string
inputCount?: number
outputCount?: number
missingInputs?: string[]
}
type WorkflowFlowNode = Node<WorkflowNodeData>
type WorkflowFlowEdge = Edge
const nodeTypes = {
workflowNode: WorkflowCanvasNode,
}
function toFlowNodes(definition: AIWorkflowDefinition): WorkflowFlowNode[] {
return fromApiDefinition(definition).nodes.map((node) => ({
id: node.id,
type: "default",
type: "workflowNode",
position: node.position,
data: {
nodeType: node.data?.nodeType ?? node.type,
name: node.data?.name ?? node.id,
label: node.data?.name ?? node.type ?? node.id,
config: node.data?.config ?? {},
inputs: node.data?.inputs ?? {},
},
}))
}
@@ -82,6 +101,7 @@ function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]) {
nodeType: node.data.nodeType,
name: node.data.name,
config: node.data.config,
inputs: node.data.inputs,
},
})) as WorkflowEditorNode[],
edges: edges.map((edge) => ({
@@ -113,14 +133,31 @@ export function WorkflowEditor({
() => nodes.find((node) => node.id === selectedNodeId) ?? null,
[nodes, selectedNodeId]
)
const validation = useMemo(() => validateWorkflowDraft(toDraft(nodes, edges)), [nodes, edges])
const draft = useMemo(() => toDraft(nodes, edges), [nodes, edges])
const validation = useMemo(
() => validateWorkflowDraft(draft, nodeSpecs),
[draft, nodeSpecs]
)
const renderedNodes = useMemo(
() => enrichNodesForRender(nodes, nodeSpecs),
[nodes, nodeSpecs]
)
const selectedNodeSpec = useMemo(
() => getNodeSpec(nodeSpecs, selectedNode?.data.nodeType ?? ""),
[nodeSpecs, selectedNode]
)
const availableVariables = useMemo(
() => (selectedNode ? getAvailableVariables(draft, selectedNode.id, nodeSpecs) : []),
[draft, nodeSpecs, selectedNode]
)
useEffect(() => {
onDefinitionChange(toApiDefinition(toDraft(nodes, edges)) as AIWorkflowDefinition)
}, [edges, nodes, onDefinitionChange])
onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition)
}, [draft, onDefinitionChange])
const onConnect = useCallback(
(connection: Connection) => {
let newEdge: WorkflowFlowEdge | null = null
setEdges((current) => {
let nextIndex = current.length + 1
let id = `edge_${connection.source}_${connection.target}_${nextIndex}`
@@ -128,6 +165,10 @@ export function WorkflowEditor({
nextIndex += 1
id = `edge_${connection.source}_${connection.target}_${nextIndex}`
}
newEdge = {
...connection,
id,
} as WorkflowFlowEdge
return addEdge(
{
...connection,
@@ -136,8 +177,32 @@ export function WorkflowEditor({
current
)
})
if (connection.source && connection.target) {
setNodes((currentNodes) => {
const currentDraft = toDraft(currentNodes, newEdge ? [...edges, newEdge] : edges)
const nextDraft = applyAutoInputMappings(
currentDraft,
connection.source!,
connection.target!,
nodeSpecs
)
return currentNodes.map((node) => {
const nextNode = nextDraft.nodes.find((item) => item.id === node.id)
if (!nextNode) {
return node
}
return {
...node,
data: {
...node.data,
inputs: nextNode.data?.inputs ?? node.data.inputs,
},
}
})
})
}
},
[setEdges]
[edges, nodeSpecs, setEdges, setNodes]
)
const addNode = (spec: AIWorkflowNodeSpec) => {
@@ -152,13 +217,14 @@ export function WorkflowEditor({
...current,
{
id,
type: "default",
type: "workflowNode",
position: { x: 120 + current.length * 28, y: 100 + current.length * 24 },
data: {
nodeType: spec.type,
name: spec.title,
label: spec.title,
config: {},
inputs: spec.defaultInputs ?? {},
},
},
]
@@ -185,7 +251,7 @@ export function WorkflowEditor({
<ResizablePanelGroup orientation="horizontal" className="h-full min-h-0 border-t">
<ResizablePanel defaultSize="18%" minSize="12%" maxSize="34%" className="min-h-0">
<aside className="h-full min-h-0 overflow-y-auto bg-muted/20 p-3">
<div className="mb-3 text-sm font-medium">Nodes</div>
<div className="mb-3 text-sm font-medium"></div>
<div className="space-y-2">
{nodeSpecs.map((spec) => (
<button
@@ -200,6 +266,10 @@ export function WorkflowEditor({
<span className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{spec.description}
</span>
<span className="mt-1 flex gap-2 text-[11px] text-muted-foreground">
<span> {spec.inputSchema?.length ?? 0}</span>
<span> {spec.outputSchema?.length ?? 0}</span>
</span>
</span>
</button>
))}
@@ -210,8 +280,9 @@ export function WorkflowEditor({
<ResizablePanel defaultSize="56%" minSize="30%" className="min-h-0">
<section className="relative h-full min-h-0">
<ReactFlow
nodes={nodes}
nodes={renderedNodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
@@ -228,7 +299,12 @@ export function WorkflowEditor({
<ResizableHandle withHandle />
<ResizablePanel defaultSize="26%" minSize="18%" maxSize="40%" className="min-h-0">
<aside className="h-full min-h-0 overflow-y-auto bg-muted/10">
<NodeConfigPanel node={selectedNode} onChange={updateNodeData} />
<NodeConfigPanel
node={selectedNode}
nodeSpec={selectedNodeSpec}
availableVariables={availableVariables}
onChange={updateNodeData}
/>
{!validation.valid ? (
<div className="border-t p-4">
<div className="mb-2 text-sm font-medium">Local validation</div>
@@ -254,6 +330,73 @@ export function WorkflowEditor({
)
}
function enrichNodesForRender(
nodes: WorkflowFlowNode[],
nodeSpecs: AIWorkflowNodeSpec[]
): WorkflowFlowNode[] {
return nodes.map((node) => {
const spec = getNodeSpec(nodeSpecs, node.data.nodeType ?? "")
const missingInputs = getRequiredInputs(spec).filter((input) => {
const selector = node.data.inputs?.[input.name]
return !selector?.nodeId || !selector.field
})
return {
...node,
data: {
...node.data,
title: spec?.title ?? node.data.name ?? node.id,
description: spec?.description ?? "",
inputCount: spec?.inputSchema?.length ?? 0,
outputCount: spec?.outputSchema?.length ?? 0,
missingInputs: missingInputs.map((input) => input.name),
},
}
})
}
function WorkflowCanvasNode({ data, selected }: NodeProps<WorkflowFlowNode>) {
const missingInputs = data.missingInputs ?? []
const hasIssue = missingInputs.length > 0
return (
<div
className={[
"min-w-56 rounded-md border bg-background shadow-sm",
selected ? "ring-2 ring-ring" : "",
hasIssue ? "border-destructive/70" : "border-border",
].join(" ")}
>
<Handle type="target" position={Position.Left} />
<div className="flex items-start gap-2 border-b px-3 py-2">
{hasIssue ? (
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
) : (
<CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-emerald-600" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{data.name ?? data.title}</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">{data.title}</div>
</div>
</div>
<div className="space-y-2 px-3 py-2 text-xs">
<div className="flex justify-between text-muted-foreground">
<span> {data.inputCount ?? 0}</span>
<span> {data.outputCount ?? 0}</span>
</div>
{hasIssue ? (
<div className="rounded-sm bg-destructive/10 px-2 py-1 text-destructive">
{missingInputs.join("、")}
</div>
) : (
<div className="rounded-sm bg-emerald-500/10 px-2 py-1 text-emerald-700">
</div>
)}
</div>
<Handle type="source" position={Position.Right} />
</div>
)
}
function WorkflowValidationBadge({
errors,
valid,
@@ -53,6 +53,155 @@ describe("validateWorkflowDraft", () => {
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /target node does not exist/)
})
it("rejects missing required input mapping", async () => {
const { validateWorkflowDraft } = await loadModule()
const result = validateWorkflowDraft(
{
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
{ id: "reply_1", type: "send_reply", position: { x: 200, y: 0 }, data: {} },
{ id: "end_1", type: "end", position: { x: 400, y: 0 }, data: {} },
],
edges: [
{ id: "e1", source: "start_1", target: "reply_1" },
{ id: "e2", source: "reply_1", target: "end_1" },
],
},
[
{
type: "send_reply",
inputSchema: [{ name: "replyText", type: "string", required: true }],
},
]
)
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /缺少必填输入「replyText」/)
})
})
describe("applyAutoInputMappings", () => {
it("maps start user message to knowledge retrieve query", async () => {
const { applyAutoInputMappings } = await loadModule()
const draft = applyAutoInputMappings(
{
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
{ id: "retrieve_1", type: "knowledge_retrieve", position: { x: 200, y: 0 }, data: {} },
],
edges: [{ id: "e1", source: "start_1", target: "retrieve_1" }],
},
"start_1",
"retrieve_1",
[
{
type: "start",
outputSchema: [{ name: "userMessage", type: "string", description: "Message" }],
},
{
type: "knowledge_retrieve",
inputSchema: [{ name: "query", type: "string", required: true }],
},
]
)
assert.deepEqual(plain(draft.nodes[1].data.inputs), {
query: { nodeId: "start_1", field: "userMessage" },
})
})
it("maps llm reply text to send reply content", async () => {
const { applyAutoInputMappings } = await loadModule()
const draft = applyAutoInputMappings(
{
nodes: [
{ id: "llm_1", type: "llm_reply", position: { x: 0, y: 0 }, data: {} },
{ id: "send_1", type: "send_reply", position: { x: 200, y: 0 }, data: {} },
],
edges: [{ id: "e1", source: "llm_1", target: "send_1" }],
},
"llm_1",
"send_1",
[
{
type: "llm_reply",
outputSchema: [{ name: "replyText", type: "string", description: "Reply" }],
},
{
type: "send_reply",
inputSchema: [{ name: "replyText", type: "string", required: true }],
},
]
)
assert.deepEqual(plain(draft.nodes[1].data.inputs), {
replyText: { nodeId: "llm_1", field: "replyText" },
})
})
})
describe("getAvailableVariables", () => {
it("exposes start outputs to retrieve node", async () => {
const { getAvailableVariables } = await loadModule()
const variables = getAvailableVariables(
{
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: { name: "Start" } },
{ id: "retrieve_1", type: "knowledge_retrieve", position: { x: 200, y: 0 }, data: {} },
],
edges: [{ id: "e1", source: "start_1", target: "retrieve_1" }],
},
"retrieve_1",
[
{
type: "start",
outputSchema: [{ name: "userMessage", type: "string", description: "Message" }],
},
]
)
assert.deepEqual(plain(variables), [
{
nodeId: "start_1",
nodeName: "Start",
field: "userMessage",
type: "string",
description: "Message",
},
])
})
it("hides variables from downstream nodes", async () => {
const { getAvailableVariables } = await loadModule()
const variables = getAvailableVariables(
{
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
{ id: "reply_1", type: "send_reply", position: { x: 200, y: 0 }, data: {} },
{ id: "end_1", type: "end", position: { x: 400, y: 0 }, data: {} },
],
edges: [
{ id: "e1", source: "start_1", target: "reply_1" },
{ id: "e2", source: "reply_1", target: "end_1" },
],
},
"reply_1",
[
{
type: "end",
outputSchema: [{ name: "status", type: "string", description: "Status" }],
},
]
)
assert.deepEqual(plain(variables), [])
})
})
describe("toApiDefinition", () => {
@@ -11,6 +11,7 @@ export type WorkflowEditorNode = {
nodeType?: string
name?: string
config?: Record<string, unknown>
inputs?: Record<string, WorkflowVariableSelector>
}
}
@@ -39,6 +40,7 @@ export type WorkflowDefinition = {
name: string
position: WorkflowNodePosition
config: Record<string, unknown>
inputs?: Record<string, WorkflowVariableSelector>
}[]
edges: {
id: string
@@ -50,12 +52,54 @@ export type WorkflowDefinition = {
}[]
}
export type WorkflowVariableType =
| "string"
| "integer"
| "boolean"
| "object"
| "array<string>"
| "array<int>"
| "array<object>"
| "any"
export type WorkflowVariableSelector = {
nodeId: string
field: string
}
export type WorkflowVariableSpec = {
name: string
type: WorkflowVariableType
required?: boolean
description?: string
}
export type WorkflowNodeSpec = {
type: string
title?: string
description?: string
inputSchema?: WorkflowVariableSpec[]
outputSchema?: WorkflowVariableSpec[]
defaultInputs?: Record<string, WorkflowVariableSelector>
}
export type WorkflowVariableRef = {
nodeId: string
nodeName: string
field: string
type: string
description: string
}
export type WorkflowDraftValidation = {
valid: boolean
errors: string[]
}
export function validateWorkflowDraft(draft: WorkflowDraft): WorkflowDraftValidation {
export function validateWorkflowDraft(
draft: WorkflowDraft,
nodeSpecs: WorkflowNodeSpec[] = []
): WorkflowDraftValidation {
const errors: string[] = []
const nodeIds = new Set<string>()
let startCount = 0
@@ -104,6 +148,21 @@ export function validateWorkflowDraft(draft: WorkflowDraft): WorkflowDraftValida
}
}
for (const node of draft.nodes) {
const nodeType = node.data?.nodeType ?? node.type ?? ""
const spec = getNodeSpec(nodeSpecs, nodeType)
if (!spec) {
continue
}
for (const input of getRequiredInputs(spec)) {
const selector = node.data?.inputs?.[input.name]
if (!selector?.nodeId || !selector.field) {
const nodeName = node.data?.name ?? spec.title ?? node.id
errors.push(`${nodeName} 缺少必填输入「${input.name}」,请选择上游节点的输出变量。`)
}
}
}
return {
valid: errors.length === 0,
errors,
@@ -124,6 +183,7 @@ export function toApiDefinition(draft: WorkflowDraft): WorkflowDefinition {
y: node.position.y,
},
config: node.data?.config ?? {},
...(node.data?.inputs ? { inputs: node.data.inputs } : {}),
})),
edges: draft.edges.map((edge) => ({
id: edge.id,
@@ -150,6 +210,7 @@ export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft
nodeType: node.type,
name: node.name,
config: node.config ?? {},
inputs: node.inputs ?? {},
},
})),
edges: (definition.edges ?? []).map((edge) => ({
@@ -160,3 +221,166 @@ export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft
})),
}
}
export function applyAutoInputMappings(
draft: WorkflowDraft,
sourceNodeId: string,
targetNodeId: string,
nodeSpecs: WorkflowNodeSpec[]
): WorkflowDraft {
const sourceNode = draft.nodes.find((node) => node.id === sourceNodeId)
const targetNode = draft.nodes.find((node) => node.id === targetNodeId)
if (!sourceNode || !targetNode) {
return draft
}
const sourceSpec = getNodeSpec(nodeSpecs, sourceNode.data?.nodeType ?? sourceNode.type ?? "")
const targetSpec = getNodeSpec(nodeSpecs, targetNode.data?.nodeType ?? targetNode.type ?? "")
if (!sourceSpec || !targetSpec) {
return draft
}
const nextInputs = { ...(targetNode.data?.inputs ?? {}) }
let changed = false
for (const input of targetSpec.inputSchema ?? []) {
if (nextInputs[input.name]) {
continue
}
const output = findPreferredOutput(input.name, input.type, sourceSpec.outputSchema ?? [])
if (!output) {
continue
}
nextInputs[input.name] = { nodeId: sourceNodeId, field: output.name }
changed = true
}
if (!changed) {
return draft
}
return {
...draft,
nodes: draft.nodes.map((node) =>
node.id === targetNodeId
? {
...node,
data: {
...node.data,
inputs: nextInputs,
},
}
: node
),
}
}
function findPreferredOutput(
inputName: string,
inputType: WorkflowVariableType,
outputs: WorkflowVariableSpec[]
): WorkflowVariableSpec | undefined {
const preferred = preferredOutputName(inputName)
if (preferred) {
const exact = outputs.find((output) => output.name === preferred && variableTypesCompatible(inputType, output.type))
if (exact) {
return exact
}
}
const sameName = outputs.find((output) => output.name === inputName && variableTypesCompatible(inputType, output.type))
if (sameName) {
return sameName
}
return outputs.find((output) => variableTypesCompatible(inputType, output.type))
}
function preferredOutputName(inputName: string): string {
switch (inputName) {
case "query":
case "userMessage":
case "issue":
case "prompt":
return "userMessage"
case "knowledgeItems":
return "items"
case "replyText":
return "replyText"
case "confirmed":
return "confirmed"
case "ticketDraft":
return "ticketDraft"
case "reason":
return "reason"
default:
return ""
}
}
function variableTypesCompatible(input: WorkflowVariableType, output: WorkflowVariableType): boolean {
return input === "any" || output === "any" || input === output
}
export function getNodeSpec(
nodeSpecs: WorkflowNodeSpec[],
nodeType: string
): WorkflowNodeSpec | undefined {
return nodeSpecs.find((spec) => spec.type === nodeType)
}
export function getRequiredInputs(spec: WorkflowNodeSpec | undefined): WorkflowVariableSpec[] {
return (spec?.inputSchema ?? []).filter((item) => item.required)
}
export function getAvailableVariables(
draft: WorkflowDraft,
nodeId: string,
nodeSpecs: WorkflowNodeSpec[]
): WorkflowVariableRef[] {
const ancestors = collectAncestorNodeIds(draft, nodeId)
const nodesById = new Map(draft.nodes.map((node) => [node.id, node]))
const variables: WorkflowVariableRef[] = []
for (const sourceNodeId of ancestors) {
const sourceNode = nodesById.get(sourceNodeId)
if (!sourceNode) {
continue
}
const nodeType = sourceNode.data?.nodeType ?? sourceNode.type ?? ""
const spec = getNodeSpec(nodeSpecs, nodeType)
for (const output of spec?.outputSchema ?? []) {
variables.push({
nodeId: sourceNode.id,
nodeName: sourceNode.data?.name ?? spec?.title ?? sourceNode.id,
field: output.name,
type: output.type,
description: output.description ?? "",
})
}
}
return variables
}
function collectAncestorNodeIds(draft: WorkflowDraft, nodeId: string): string[] {
const incoming = new Map<string, string[]>()
for (const edge of draft.edges) {
const sources = incoming.get(edge.target) ?? []
sources.push(edge.source)
incoming.set(edge.target, sources)
}
const visited = new Set<string>()
const ordered: string[] = []
function visit(current: string) {
for (const source of incoming.get(current) ?? []) {
if (visited.has(source)) {
continue
}
visited.add(source)
visit(source)
ordered.push(source)
}
}
visit(nodeId)
return ordered
}
+27
View File
@@ -286,6 +286,28 @@ export type AIWorkflowPosition = {
y: number
}
export type AIWorkflowVariableType =
| "string"
| "integer"
| "boolean"
| "object"
| "array<string>"
| "array<int>"
| "array<object>"
| "any"
export type AIWorkflowVariableSelector = {
nodeId: string
field: string
}
export type AIWorkflowVariableSpec = {
name: string
type: AIWorkflowVariableType
required?: boolean
description: string
}
export type AIWorkflowDefinition = {
schemaVersion: number
entryNodeId: string
@@ -295,6 +317,7 @@ export type AIWorkflowDefinition = {
name: string
position: AIWorkflowPosition
config: Record<string, unknown>
inputs?: Record<string, AIWorkflowVariableSelector>
}[]
edges: {
id: string
@@ -342,6 +365,10 @@ export type AIWorkflowNodeSpec = {
riskLevel: "low" | "medium" | "high"
interruptible: boolean
requiresConfirmationPredecessor: boolean
configSchema?: unknown
inputSchema?: AIWorkflowVariableSpec[]
outputSchema?: AIWorkflowVariableSpec[]
defaultInputs?: Record<string, AIWorkflowVariableSelector>
}
export type AIWorkflowValidationResult = {