Files
ai-agent/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx
T

566 lines
20 KiB
TypeScript

"use client"
import { useState } from "react"
import type { Node } from "@xyflow/react"
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 { OptionCombobox } from "@/components/option-combobox"
import { VariableSelector } from "./variable-selector"
import type {
WorkflowConditionBranch,
WorkflowNodeSpec,
WorkflowNodeConfig,
WorkflowVariableRef,
WorkflowVariableSpec,
WorkflowVariableSelector,
} from "./workflow-utils"
type WorkflowNodeData = Record<string, unknown> & {
nodeType?: string
name?: string
title?: string
config?: WorkflowNodeConfig
inputs?: Record<string, WorkflowVariableSelector>
}
export type WorkflowBranchSummary = {
branchId: string
targetNodeId: string
targetName: string
conditionLabel: string
isDefault: boolean
}
export function NodeConfigPanel({
node,
nodeSpec,
availableVariables,
branchSummaries = [],
onChange,
}: {
node: Node<WorkflowNodeData> | null
nodeSpec?: WorkflowNodeSpec
availableVariables: WorkflowVariableRef[]
branchSummaries?: WorkflowBranchSummary[]
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">
选择一个节点后,可以配置输入映射并查看输出变量。
</div>
)
}
return (
<NodeConfigForm
key={node.id}
node={node}
nodeSpec={nodeSpec}
availableVariables={availableVariables}
branchSummaries={branchSummaries}
onChange={onChange}
/>
)
}
function NodeConfigForm({
node,
nodeSpec,
availableVariables,
branchSummaries,
onChange,
}: {
node: Node<WorkflowNodeData>
nodeSpec?: WorkflowNodeSpec
availableVariables: WorkflowVariableRef[]
branchSummaries: WorkflowBranchSummary[]
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 isConditionNode = node.data.nodeType === "condition"
const fallbackNodeName = nodeSpec?.title || node.data.title || node.data.nodeType || node.id
const panelTitle = name.trim() || node.data.name?.trim() || fallbackNodeName
const commitChange = (next: Partial<WorkflowNodeData>) => {
onChange(node.id, {
...node.data,
name: name.trim() || fallbackNodeName,
config: node.data.config ?? {},
inputs,
...next,
})
}
const handleApply = () => {
try {
const parsed = JSON.parse(configText || "{}") as Record<string, unknown>
setError("")
commitChange({ config: parsed })
} catch {
setError("Config must be valid JSON.")
}
}
return (
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
<div>
<div className="text-sm font-medium">{panelTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">
{node.data.nodeType && node.data.nodeType !== panelTitle
? `${node.id} · ${node.data.nodeType}`
: node.id}
</div>
</div>
<div className="space-y-2">
<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>
{isConditionNode ? (
<ConditionNodePanel
branches={node.data.config?.branches ?? []}
branchSummaries={branchSummaries}
availableVariables={availableVariables}
outputSchema={outputSchema}
onChange={(branches) => commitChange({ config: { ...(node.data.config ?? {}), branches } })}
/>
) : (
<>
{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>
)
}
function ConditionNodePanel({
branches,
branchSummaries,
availableVariables,
outputSchema,
onChange,
}: {
branches: WorkflowConditionBranch[]
branchSummaries: WorkflowBranchSummary[]
availableVariables: WorkflowVariableRef[]
outputSchema: WorkflowVariableSpec[]
onChange: (branches: WorkflowConditionBranch[]) => void
}) {
const summariesByBranchID = new Map(branchSummaries.map((item) => [item.branchId, item]))
const commitBranch = (branchId: string, patch: Partial<WorkflowConditionBranch>) => {
onChange(branches.map((branch) => (
branch.id === branchId ? normalizeBranch({ ...branch, ...patch }) : branch
)))
}
const addBranch = () => {
const index = branches.length + 1
const nextBranch = {
id: `branch_${index}`,
name: `分支 ${index}`,
targetNodeId: "",
condition: { operator: "eq" },
}
const defaultIndex = branches.findIndex((branch) => branch.default)
if (defaultIndex >= 0) {
onChange([
...branches.slice(0, defaultIndex),
nextBranch,
...branches.slice(defaultIndex),
])
return
}
onChange([
...branches,
nextBranch,
{
id: "default",
name: "其他情况",
targetNodeId: "",
default: true,
},
])
}
const deleteBranch = (branchId: string) => {
onChange(branches.filter((branch) => branch.id !== branchId || branch.default))
}
const moveBranch = (branchId: string, direction: -1 | 1) => {
const index = branches.findIndex((branch) => branch.id === branchId)
if (index < 0 || branches[index]?.default) {
return
}
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= branches.length || branches[nextIndex]?.default) {
return
}
const next = [...branches]
const current = next[index]
next[index] = next[nextIndex]
next[nextIndex] = current
onChange(next)
}
return (
<>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium">分支</div>
<Button type="button" variant="outline" size="sm" onClick={addBranch}>
添加分支
</Button>
</div>
{branches.length > 0 ? (
<div className="space-y-2">
{branches.map((branch, index) => {
const summary = summariesByBranchID.get(branch.id)
const condition = branch.condition ?? {}
const selectedVariable = findConditionVariable(availableVariables, condition.left)
const operatorOptions = getConditionOperatorOptions(selectedVariable)
const conditionRight = condition.right === undefined || condition.right === null
? ""
: String(condition.right)
return (
<div key={branch.id} className="space-y-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="min-w-0 truncate font-medium">
{branch.default ? "ELSE" : index === 0 ? "IF" : "ELSE IF"}
</span>
<span className="shrink-0 rounded-sm bg-muted px-1.5 py-0.5 text-muted-foreground">
{branch.default ? "默认" : "条件"}
</span>
</div>
<div className="space-y-1.5">
<Label className="text-xs">分支名称</Label>
<Input
value={branch.name ?? ""}
onChange={(event) => commitBranch(branch.id, { name: event.target.value })}
placeholder="例如:需要转人工"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">目标节点</Label>
<div className="rounded-md border border-dashed bg-muted/20 px-2 py-2 text-xs text-muted-foreground">
{summary?.targetNodeId
? `已连接到:${summary.targetName}`
: "请从画布中该分支右侧连接点拖线到目标节点"}
</div>
</div>
{branch.default ? (
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
未命中上方条件时进入:{summary?.targetName ?? (branch.targetNodeId || "未选择目标节点")}
</div>
) : (
<div className="space-y-3 rounded-md border bg-muted/20 p-2">
<div className="space-y-1.5">
<Label className="text-xs">判断变量</Label>
<VariableSelector
value={condition.left}
variables={availableVariables}
onChange={(value) => commitBranch(branch.id, {
condition: { ...condition, left: value },
})}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">判断方式</Label>
<OptionCombobox
value={condition.operator ?? "eq"}
options={operatorOptions}
placeholder="选择判断方式"
searchPlaceholder="搜索判断方式"
emptyText="没有可用判断方式"
onChange={(value) => commitBranch(branch.id, {
condition: { ...condition, operator: value },
})}
/>
</div>
{!conditionOperatorWithoutRight(condition.operator ?? "eq") ? (
<ConditionRightControl
value={conditionRight}
variable={selectedVariable}
onChange={(right) => commitBranch(branch.id, {
condition: { ...condition, right },
})}
/>
) : null}
</div>
)}
<div className="flex flex-wrap gap-2">
{!branch.default && index > 0 ? (
<Button type="button" size="sm" variant="outline" onClick={() => moveBranch(branch.id, -1)}>
上移
</Button>
) : null}
{!branch.default && index < branches.findIndex((item) => item.default) - 1 ? (
<Button type="button" size="sm" variant="outline" onClick={() => moveBranch(branch.id, 1)}>
下移
</Button>
) : null}
{!branch.default ? (
<Button type="button" size="sm" variant="outline" onClick={() => deleteBranch(branch.id)}>
删除
</Button>
) : null}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{summary?.conditionLabel ?? "尚未完成分支配置"}
</div>
</div>
)
})}
</div>
) : (
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
当前还没有分支。
</div>
)}
</div>
{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}
</>
)
}
const conditionOperators = [
{ value: "eq", label: "等于" },
{ value: "neq", label: "不等于" },
{ value: "contains", label: "包含" },
{ value: "exists", label: "存在" },
{ value: "not_exists", label: "不存在" },
{ value: "truthy", label: "为真" },
{ value: "is_true", label: "为真" },
{ value: "falsy", label: "为假" },
{ value: "is_false", label: "为假" },
{ value: "gt", label: "大于" },
{ value: "gte", label: "大于等于" },
{ value: "lt", label: "小于" },
{ value: "lte", label: "小于等于" },
]
function ConditionRightControl({
value,
variable,
onChange,
}: {
value: string
variable?: WorkflowVariableRef
onChange: (value: unknown) => void
}) {
const valueOptions = getConditionValueOptions(variable)
if (valueOptions.length > 0) {
return (
<div className="space-y-1.5">
<Label className="text-xs">比较值</Label>
<OptionCombobox
value={value}
options={valueOptions}
placeholder="选择比较值"
searchPlaceholder="搜索比较值"
emptyText="当前变量没有可选值"
onChange={(nextValue) => onChange(decodeConditionRight(nextValue, variable))}
/>
</div>
)
}
return (
<div className="space-y-1.5">
<Label className="text-xs">比较值</Label>
<Input
type={variable?.type === "number" || variable?.type === "integer" ? "number" : "text"}
value={value}
onChange={(event) => onChange(normalizeConditionRight(event.target.value, variable))}
placeholder={variable ? `请输入${variable.label || variable.field}的比较值` : "请输入比较值"}
/>
</div>
)
}
function conditionOperatorWithoutRight(operator: string) {
return ["exists", "not_exists", "truthy", "is_true", "falsy", "is_false"].includes(operator)
}
function normalizeConditionRight(value: string, variable?: WorkflowVariableRef) {
const trimmed = value.trim()
if (variable?.type === "boolean") {
return trimmed === "true"
}
if (variable?.type === "number" || variable?.type === "integer") {
return trimmed === "" ? "" : Number(trimmed)
}
if (variable?.type === "string") {
return trimmed
}
if (trimmed === "true") return true
if (trimmed === "false") return false
if (trimmed !== "" && !Number.isNaN(Number(trimmed))) return Number(trimmed)
return trimmed
}
function findConditionVariable(
variables: WorkflowVariableRef[],
selector?: WorkflowVariableSelector
): WorkflowVariableRef | undefined {
if (!selector?.nodeId || !selector.field) {
return undefined
}
return variables.find((item) => item.nodeId === selector.nodeId && item.field === selector.field)
}
function getConditionOperatorOptions(variable?: WorkflowVariableRef) {
if (!variable?.operators?.length) {
return conditionOperators
}
const allowed = new Set(variable.operators)
return conditionOperators.filter((item) => allowed.has(item.value))
}
function getConditionValueOptions(variable?: WorkflowVariableRef) {
if (variable?.valueOptions?.length) {
return variable.valueOptions.map((item) => ({
value: encodeConditionRight(item.value),
label: item.label,
}))
}
if (variable?.type === "boolean") {
return [
{ value: "true", label: "是" },
{ value: "false", label: "否" },
]
}
return []
}
function encodeConditionRight(value: unknown) {
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
return JSON.stringify(value)
}
function decodeConditionRight(value: string, variable?: WorkflowVariableRef) {
if (variable?.type === "boolean") {
return value === "true"
}
if (variable?.type === "number" || variable?.type === "integer") {
return Number(value)
}
const option = variable?.valueOptions?.find((item) => encodeConditionRight(item.value) === value)
return option ? option.value : value
}
function normalizeBranch(branch: WorkflowConditionBranch): WorkflowConditionBranch {
if (branch.default) {
const { condition: _condition, ...rest } = branch
return rest
}
return {
...branch,
condition: branch.condition ?? { operator: "eq" },
}
}