feat: add AI workflow editor
This commit is contained in:
@@ -41,12 +41,14 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
fetchAIAgent,
|
||||
fetchAIConfigsAll,
|
||||
fetchAIWorkflowVersions,
|
||||
fetchAgentTeamsAll,
|
||||
fetchKnowledgeBasesAll,
|
||||
fetchMCPCatalog,
|
||||
fetchSkillDefinitionsAll,
|
||||
type AIAgent,
|
||||
type AIConfig,
|
||||
type AIWorkflowVersion,
|
||||
type AdminAgentTeam,
|
||||
type CreateAIAgentPayload,
|
||||
type KnowledgeBase,
|
||||
@@ -94,6 +96,8 @@ type EditForm = {
|
||||
description: string;
|
||||
aiConfigId: string;
|
||||
serviceMode: string;
|
||||
runtimeMode: string;
|
||||
workflowVersionId: string;
|
||||
systemPrompt: string;
|
||||
welcomeMessage: string;
|
||||
replyTimeoutSeconds: number;
|
||||
@@ -102,6 +106,9 @@ type EditForm = {
|
||||
fallbackMessage: string;
|
||||
};
|
||||
|
||||
const AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH = 1;
|
||||
const AI_AGENT_RUNTIME_MODE_WORKFLOW = 2;
|
||||
|
||||
function getServiceModeOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: String(IMConversationServiceMode.AIOnly), label: t("aiAgent.serviceAiOnly") },
|
||||
@@ -132,6 +139,8 @@ function buildForm(item: AIAgent | null): EditForm {
|
||||
description: "",
|
||||
aiConfigId: "",
|
||||
serviceMode: String(IMConversationServiceMode.AIFirst),
|
||||
runtimeMode: String(AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH),
|
||||
workflowVersionId: "",
|
||||
systemPrompt: "",
|
||||
welcomeMessage: "",
|
||||
replyTimeoutSeconds: 180,
|
||||
@@ -145,6 +154,8 @@ function buildForm(item: AIAgent | null): EditForm {
|
||||
description: item.description || "",
|
||||
aiConfigId: item.aiConfigId > 0 ? String(item.aiConfigId) : "",
|
||||
serviceMode: String(item.serviceMode),
|
||||
runtimeMode: String(item.runtimeMode || AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH),
|
||||
workflowVersionId: item.workflowVersionId > 0 ? String(item.workflowVersionId) : "",
|
||||
systemPrompt: item.systemPrompt || "",
|
||||
welcomeMessage: item.welcomeMessage || "",
|
||||
replyTimeoutSeconds: item.replyTimeoutSeconds ?? 180,
|
||||
@@ -167,6 +178,11 @@ function buildPayload(
|
||||
description: form.description.trim(),
|
||||
aiConfigId: Number(form.aiConfigId),
|
||||
serviceMode: Number(form.serviceMode),
|
||||
runtimeMode: Number(form.runtimeMode),
|
||||
workflowVersionId:
|
||||
Number(form.runtimeMode) === AI_AGENT_RUNTIME_MODE_WORKFLOW
|
||||
? Number(form.workflowVersionId)
|
||||
: 0,
|
||||
systemPrompt: form.systemPrompt.trim(),
|
||||
welcomeMessage: form.welcomeMessage.trim(),
|
||||
replyTimeoutSeconds: Number(form.replyTimeoutSeconds),
|
||||
@@ -220,6 +236,8 @@ function EditDialogBody({
|
||||
description: z.string().trim(),
|
||||
aiConfigId: z.string().trim().regex(/^\d+$/, t("aiAgent.aiConfigRequired")),
|
||||
serviceMode: z.string().trim().min(1, t("aiAgent.serviceModeRequired")),
|
||||
runtimeMode: z.string().trim().min(1, t("aiAgent.runtimeModeRequired")),
|
||||
workflowVersionId: z.string().trim(),
|
||||
systemPrompt: z.string().trim(),
|
||||
welcomeMessage: z.string().trim(),
|
||||
replyTimeoutSeconds: z
|
||||
@@ -228,6 +246,18 @@ function EditDialogBody({
|
||||
handoffMode: z.string().trim().min(1, t("aiAgent.handoffModeRequired")),
|
||||
fallbackMode: z.string().trim().min(1, t("aiAgent.fallbackModeRequired")),
|
||||
fallbackMessage: z.string().trim(),
|
||||
}).check((ctx) => {
|
||||
if (
|
||||
ctx.value.runtimeMode === String(AI_AGENT_RUNTIME_MODE_WORKFLOW) &&
|
||||
!/^\d+$/.test(ctx.value.workflowVersionId)
|
||||
) {
|
||||
ctx.issues.push({
|
||||
code: "custom",
|
||||
input: ctx.value.workflowVersionId,
|
||||
message: t("aiAgent.workflowVersionRequired"),
|
||||
path: ["workflowVersionId"],
|
||||
});
|
||||
}
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -236,6 +266,19 @@ function EditDialogBody({
|
||||
[schema],
|
||||
);
|
||||
const serviceModeOptions = useMemo(() => getServiceModeOptions(t), [t]);
|
||||
const runtimeModeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: String(AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH),
|
||||
label: t("aiAgent.runtimeBuiltinGraph"),
|
||||
},
|
||||
{
|
||||
value: String(AI_AGENT_RUNTIME_MODE_WORKFLOW),
|
||||
label: t("aiAgent.runtimeWorkflow"),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
const handoffModeOptions = useMemo(() => getHandoffModeOptions(t), [t]);
|
||||
const fallbackModeOptions = useMemo(() => getFallbackModeOptions(t), [t]);
|
||||
const form = useForm<EditForm>({
|
||||
@@ -262,6 +305,7 @@ function EditDialogBody({
|
||||
const [directToolToAdd, setDirectToolToAdd] = useState("");
|
||||
const [graphToolToAdd, setGraphToolToAdd] = useState("");
|
||||
const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([]);
|
||||
const [workflowVersions, setWorkflowVersions] = useState<AIWorkflowVersion[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [agentTeams, setAgentTeams] = useState<AdminAgentTeam[]>([]);
|
||||
const [skills, setSkills] = useState<SkillDefinition[]>([]);
|
||||
@@ -346,6 +390,23 @@ function EditDialogBody({
|
||||
void loadAgentTeams();
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadWorkflowVersions() {
|
||||
try {
|
||||
const data = await fetchAIWorkflowVersions({
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
});
|
||||
setWorkflowVersions(data.results ?? []);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("aiAgent.loadWorkflowVersionsFailed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
void loadWorkflowVersions();
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadKnowledgeBases() {
|
||||
try {
|
||||
@@ -436,6 +497,15 @@ function EditDialogBody({
|
||||
[agentTeams],
|
||||
);
|
||||
|
||||
const workflowVersionOptions = useMemo(
|
||||
() =>
|
||||
workflowVersions.map((item) => ({
|
||||
value: String(item.id),
|
||||
label: `Workflow #${item.workflowId} · v${item.version}`,
|
||||
})),
|
||||
[workflowVersions],
|
||||
);
|
||||
|
||||
const knowledgeOptions = useMemo(
|
||||
() =>
|
||||
knowledgeBases.map((item) => ({
|
||||
@@ -556,6 +626,7 @@ function EditDialogBody({
|
||||
);
|
||||
|
||||
const handoffMode = watch("handoffMode");
|
||||
const runtimeMode = watch("runtimeMode");
|
||||
const selectedHandoffModeLabel =
|
||||
handoffModeOptions.find((item) => item.value === handoffMode)?.label ??
|
||||
t("aiAgent.notSelected");
|
||||
@@ -747,6 +818,56 @@ function EditDialogBody({
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<Field data-invalid={!!errors.runtimeMode}>
|
||||
<FieldLabel>{t("aiAgent.runtimeMode")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="runtimeMode"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={runtimeModeOptions}
|
||||
placeholder={t("aiAgent.selectRuntimeMode")}
|
||||
searchPlaceholder={t("aiAgent.searchRuntimeMode")}
|
||||
emptyText={t("aiAgent.emptyRuntimeMode")}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.runtimeMode]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
data-invalid={
|
||||
runtimeMode === String(AI_AGENT_RUNTIME_MODE_WORKFLOW) &&
|
||||
!!errors.workflowVersionId
|
||||
}
|
||||
>
|
||||
<FieldLabel>{t("aiAgent.workflowVersion")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="workflowVersionId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={workflowVersionOptions}
|
||||
placeholder={t("aiAgent.selectWorkflowVersion")}
|
||||
searchPlaceholder={t("aiAgent.searchWorkflowVersion")}
|
||||
emptyText={t("aiAgent.emptyWorkflowVersion")}
|
||||
disabled={runtimeMode !== String(AI_AGENT_RUNTIME_MODE_WORKFLOW)}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.workflowVersionId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={!!errors.description}>
|
||||
<FieldLabel htmlFor="ai-agent-description">{t("aiAgent.description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"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"
|
||||
|
||||
type WorkflowNodeData = Record<string, unknown> & {
|
||||
nodeType?: string
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function NodeConfigPanel({
|
||||
node,
|
||||
onChange,
|
||||
}: {
|
||||
node: Node<WorkflowNodeData> | null
|
||||
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} />
|
||||
}
|
||||
|
||||
function NodeConfigForm({
|
||||
node,
|
||||
onChange,
|
||||
}: {
|
||||
node: Node<WorkflowNodeData>
|
||||
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 [error, setError] = useState("")
|
||||
|
||||
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,
|
||||
})
|
||||
} 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">{node.data.nodeType ?? node.id}</div>
|
||||
<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>
|
||||
<Input
|
||||
id="workflow-node-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import "@xyflow/react/dist/style.css"
|
||||
|
||||
import {
|
||||
addEdge,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
} from "@xyflow/react"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||
import {
|
||||
fromApiDefinition,
|
||||
toApiDefinition,
|
||||
validateWorkflowDraft,
|
||||
type WorkflowEditorEdge,
|
||||
type WorkflowEditorNode,
|
||||
} from "./workflow-utils"
|
||||
import { NodeConfigPanel } from "./node-config-panel"
|
||||
|
||||
type WorkflowNodeData = Record<string, unknown> & {
|
||||
nodeType?: string
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
label?: string
|
||||
}
|
||||
|
||||
type WorkflowFlowNode = Node<WorkflowNodeData>
|
||||
type WorkflowFlowEdge = Edge
|
||||
|
||||
function toFlowNodes(definition: AIWorkflowDefinition): WorkflowFlowNode[] {
|
||||
return fromApiDefinition(definition).nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: "default",
|
||||
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 ?? {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function toFlowEdges(definition: AIWorkflowDefinition): WorkflowFlowEdge[] {
|
||||
return (definition.edges ?? []).map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
data: edge.condition ? { condition: edge.condition } : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]) {
|
||||
return {
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
position: node.position,
|
||||
data: {
|
||||
nodeType: node.data.nodeType,
|
||||
name: node.data.name,
|
||||
config: node.data.config,
|
||||
},
|
||||
})) as WorkflowEditorNode[],
|
||||
edges: edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
data: edge.data as WorkflowEditorEdge["data"],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkflowEditor({
|
||||
definition,
|
||||
nodeSpecs,
|
||||
onDefinitionChange,
|
||||
}: {
|
||||
definition: AIWorkflowDefinition
|
||||
nodeSpecs: AIWorkflowNodeSpec[]
|
||||
onDefinitionChange: (definition: AIWorkflowDefinition) => void
|
||||
}) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<WorkflowFlowNode>(
|
||||
toFlowNodes(definition)
|
||||
)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<WorkflowFlowEdge>(
|
||||
toFlowEdges(definition)
|
||||
)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||
[nodes, selectedNodeId]
|
||||
)
|
||||
const validation = useMemo(() => validateWorkflowDraft(toDraft(nodes, edges)), [nodes, edges])
|
||||
|
||||
useEffect(() => {
|
||||
onDefinitionChange(toApiDefinition(toDraft(nodes, edges)) as AIWorkflowDefinition)
|
||||
}, [edges, nodes, onDefinitionChange])
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
setEdges((current) => {
|
||||
let nextIndex = current.length + 1
|
||||
let id = `edge_${connection.source}_${connection.target}_${nextIndex}`
|
||||
while (current.some((edge) => edge.id === id)) {
|
||||
nextIndex += 1
|
||||
id = `edge_${connection.source}_${connection.target}_${nextIndex}`
|
||||
}
|
||||
return addEdge(
|
||||
{
|
||||
...connection,
|
||||
id,
|
||||
},
|
||||
current
|
||||
)
|
||||
})
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
const addNode = (spec: AIWorkflowNodeSpec) => {
|
||||
setNodes((current) => {
|
||||
let nextIndex = current.length + 1
|
||||
let id = `${spec.type}_${nextIndex}`
|
||||
while (current.some((node) => node.id === id)) {
|
||||
nextIndex += 1
|
||||
id = `${spec.type}_${nextIndex}`
|
||||
}
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id,
|
||||
type: "default",
|
||||
position: { x: 120 + current.length * 28, y: 100 + current.length * 24 },
|
||||
data: {
|
||||
nodeType: spec.type,
|
||||
name: spec.title,
|
||||
label: spec.title,
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
label: data.name ?? data.nodeType ?? node.id,
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-cols-[220px_minmax(0,1fr)_320px] border-t">
|
||||
<aside className="min-h-0 overflow-y-auto border-r bg-muted/20 p-3">
|
||||
<div className="mb-3 text-sm font-medium">Nodes</div>
|
||||
<div className="space-y-2">
|
||||
{nodeSpecs.map((spec) => (
|
||||
<button
|
||||
key={spec.type}
|
||||
type="button"
|
||||
onClick={() => addNode(spec)}
|
||||
className="flex w-full items-start gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted"
|
||||
>
|
||||
<PlusIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{spec.title}</span>
|
||||
<span className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{spec.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<section className="relative min-h-0">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
<div className="absolute left-3 top-3 flex gap-2">
|
||||
<Badge variant={validation.valid ? "default" : "destructive"}>
|
||||
{validation.valid ? "Valid draft" : `${validation.errors.length} issues`}
|
||||
</Badge>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="min-h-0 overflow-y-auto border-l bg-muted/10">
|
||||
<NodeConfigPanel node={selectedNode} onChange={updateNodeData} />
|
||||
{!validation.valid ? (
|
||||
<div className="border-t p-4">
|
||||
<div className="mb-2 text-sm font-medium">Local validation</div>
|
||||
<ul className="space-y-1 text-xs text-destructive">
|
||||
{validation.errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="border-t p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => onDefinitionChange(toApiDefinition(toDraft(nodes, edges)) as AIWorkflowDefinition)}
|
||||
>
|
||||
Sync definition
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
function plain(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
async function loadModule() {
|
||||
const source = await readFile(new URL("./workflow-utils.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "workflow-utils.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
describe("validateWorkflowDraft", () => {
|
||||
it("rejects missing start", async () => {
|
||||
const { validateWorkflowDraft } = await loadModule()
|
||||
|
||||
const result = validateWorkflowDraft({
|
||||
nodes: [{ id: "end_1", type: "end", position: { x: 0, y: 0 }, data: {} }],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
assert.equal(result.valid, false)
|
||||
assert.match(result.errors.join("\n"), /exactly one start/)
|
||||
})
|
||||
|
||||
it("rejects dangling edge", async () => {
|
||||
const { validateWorkflowDraft } = await loadModule()
|
||||
|
||||
const result = validateWorkflowDraft({
|
||||
nodes: [
|
||||
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "end_1", type: "end", position: { x: 200, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "missing_1" }],
|
||||
})
|
||||
|
||||
assert.equal(result.valid, false)
|
||||
assert.match(result.errors.join("\n"), /target node does not exist/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("toApiDefinition", () => {
|
||||
it("preserves xyflow node positions", async () => {
|
||||
const { toApiDefinition } = await loadModule()
|
||||
|
||||
const definition = toApiDefinition({
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "start",
|
||||
position: { x: 12, y: 34 },
|
||||
data: { name: "Start", config: { enabled: true } },
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "end",
|
||||
position: { x: 240, y: 80 },
|
||||
data: { name: "End", config: {} },
|
||||
},
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
|
||||
})
|
||||
|
||||
assert.deepEqual(plain(definition), {
|
||||
schemaVersion: 1,
|
||||
entryNodeId: "start_1",
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "start",
|
||||
name: "Start",
|
||||
position: { x: 12, y: 34 },
|
||||
config: { enabled: true },
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "end",
|
||||
name: "End",
|
||||
position: { x: 240, y: 80 },
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
|
||||
})
|
||||
})
|
||||
|
||||
it("uses node data type for xyflow default nodes", async () => {
|
||||
const { toApiDefinition } = await loadModule()
|
||||
|
||||
const definition = toApiDefinition({
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "default",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { nodeType: "start", name: "Start", config: {} },
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "default",
|
||||
position: { x: 200, y: 0 },
|
||||
data: { nodeType: "end", name: "End", config: {} },
|
||||
},
|
||||
],
|
||||
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
|
||||
})
|
||||
|
||||
assert.equal(definition.entryNodeId, "start_1")
|
||||
assert.equal(definition.nodes[0].type, "start")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
export type WorkflowNodePosition = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type WorkflowEditorNode = {
|
||||
id: string
|
||||
type?: string
|
||||
position: WorkflowNodePosition
|
||||
data?: {
|
||||
nodeType?: string
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkflowEditorEdge = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
data?: {
|
||||
condition?: {
|
||||
expression: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkflowDraft = {
|
||||
nodes: WorkflowEditorNode[]
|
||||
edges: WorkflowEditorEdge[]
|
||||
}
|
||||
|
||||
export type WorkflowDefinition = {
|
||||
schemaVersion: number
|
||||
entryNodeId: string
|
||||
nodes: {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
position: WorkflowNodePosition
|
||||
config: Record<string, unknown>
|
||||
}[]
|
||||
edges: {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
condition?: {
|
||||
expression: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
export type WorkflowDraftValidation = {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export function validateWorkflowDraft(draft: WorkflowDraft): WorkflowDraftValidation {
|
||||
const errors: string[] = []
|
||||
const nodeIds = new Set<string>()
|
||||
let startCount = 0
|
||||
let endCount = 0
|
||||
|
||||
for (const node of draft.nodes) {
|
||||
const id = node.id.trim()
|
||||
if (!id) {
|
||||
errors.push("node id is required")
|
||||
continue
|
||||
}
|
||||
if (nodeIds.has(id)) {
|
||||
errors.push(`duplicate node id: ${id}`)
|
||||
}
|
||||
nodeIds.add(id)
|
||||
const nodeType = node.data?.nodeType ?? node.type
|
||||
if (nodeType === "start") {
|
||||
startCount += 1
|
||||
}
|
||||
if (nodeType === "end") {
|
||||
endCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (startCount !== 1) {
|
||||
errors.push("workflow must contain exactly one start node")
|
||||
}
|
||||
if (endCount < 1) {
|
||||
errors.push("workflow must contain at least one end node")
|
||||
}
|
||||
|
||||
const edgeIds = new Set<string>()
|
||||
for (const edge of draft.edges) {
|
||||
const id = edge.id.trim()
|
||||
if (!id) {
|
||||
errors.push("edge id is required")
|
||||
} else if (edgeIds.has(id)) {
|
||||
errors.push(`duplicate edge id: ${id}`)
|
||||
}
|
||||
edgeIds.add(id)
|
||||
if (!nodeIds.has(edge.source)) {
|
||||
errors.push(`edge source node does not exist: ${edge.source}`)
|
||||
}
|
||||
if (!nodeIds.has(edge.target)) {
|
||||
errors.push(`edge target node does not exist: ${edge.target}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
export function toApiDefinition(draft: WorkflowDraft): WorkflowDefinition {
|
||||
const startNode = draft.nodes.find((node) => (node.data?.nodeType ?? node.type) === "start")
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
entryNodeId: startNode?.id ?? "",
|
||||
nodes: draft.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.data?.nodeType ?? node.type ?? "",
|
||||
name: node.data?.name ?? node.type ?? node.id,
|
||||
position: {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
},
|
||||
config: node.data?.config ?? {},
|
||||
})),
|
||||
edges: draft.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
...(edge.data?.condition
|
||||
? {
|
||||
condition: {
|
||||
expression: edge.data.condition.expression,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft {
|
||||
return {
|
||||
nodes: (definition.nodes ?? []).map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
position: node.position ?? { x: 0, y: 0 },
|
||||
data: {
|
||||
nodeType: node.type,
|
||||
name: node.name,
|
||||
config: node.config ?? {},
|
||||
},
|
||||
})),
|
||||
edges: (definition.edges ?? []).map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
data: edge.condition ? { condition: edge.condition } : undefined,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { CheckCircle2Icon, GitBranchIcon, SaveIcon, SendIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
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 {
|
||||
createAIWorkflow,
|
||||
fetchAIWorkflowNodeSpecs,
|
||||
fetchAIWorkflows,
|
||||
publishAIWorkflow,
|
||||
updateAIWorkflow,
|
||||
validateAIWorkflow,
|
||||
type AIWorkflow,
|
||||
type AIWorkflowDefinition,
|
||||
type AIWorkflowNodeSpec,
|
||||
type AIWorkflowValidationResult,
|
||||
} from "@/lib/api/admin"
|
||||
import { WorkflowEditor } from "./_components/workflow-editor"
|
||||
|
||||
const emptyDefinition: AIWorkflowDefinition = {
|
||||
schemaVersion: 1,
|
||||
entryNodeId: "start_1",
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "start",
|
||||
name: "Start",
|
||||
position: { x: 0, y: 80 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "end",
|
||||
name: "End",
|
||||
position: { x: 360, y: 80 },
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
edges: [{ id: "edge_start_end", source: "start_1", target: "end_1" }],
|
||||
}
|
||||
|
||||
export default function DashboardAIWorkflowsPage() {
|
||||
const [workflows, setWorkflows] = useState<AIWorkflow[]>([])
|
||||
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
|
||||
const [selected, setSelected] = useState<AIWorkflow | null>(null)
|
||||
const [name, setName] = useState("Customer support flow")
|
||||
const [description, setDescription] = useState("")
|
||||
const [ownerId, setOwnerId] = useState("1")
|
||||
const [definition, setDefinition] = useState<AIWorkflowDefinition>(emptyDefinition)
|
||||
const [validation, setValidation] = useState<AIWorkflowValidationResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const editorKey = useMemo(
|
||||
() => `${selected?.id ?? "new"}-${selected?.updatedAt ?? ""}`,
|
||||
[selected?.id, selected?.updatedAt]
|
||||
)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const [workflowPage, specs] = await Promise.all([
|
||||
fetchAIWorkflows({ page: 1, limit: 50, status: 0 }),
|
||||
fetchAIWorkflowNodeSpecs(),
|
||||
])
|
||||
setWorkflows(workflowPage?.results ?? [])
|
||||
setNodeSpecs(specs ?? [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to load workflows")
|
||||
})
|
||||
}, [loadData])
|
||||
|
||||
const selectWorkflow = (workflow: AIWorkflow) => {
|
||||
setSelected(workflow)
|
||||
setName(workflow.name)
|
||||
setDescription(workflow.description)
|
||||
setOwnerId(String(workflow.ownerId || 1))
|
||||
setDefinition(workflow.draftDefinition ?? emptyDefinition)
|
||||
setValidation(null)
|
||||
}
|
||||
|
||||
const createNew = () => {
|
||||
setSelected(null)
|
||||
setName("Customer support flow")
|
||||
setDescription("")
|
||||
setOwnerId("1")
|
||||
setDefinition(emptyDefinition)
|
||||
setValidation(null)
|
||||
}
|
||||
|
||||
const saveDraft = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const payload = {
|
||||
name,
|
||||
description,
|
||||
ownerType: "ai_agent",
|
||||
ownerId: Number(ownerId) || 0,
|
||||
definition,
|
||||
}
|
||||
if (selected) {
|
||||
await updateAIWorkflow({ id: selected.id, ...payload })
|
||||
toast.success("Draft saved")
|
||||
} else {
|
||||
const created = await createAIWorkflow(payload)
|
||||
setSelected(created)
|
||||
toast.success("Workflow created")
|
||||
}
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save workflow")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runValidation = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await validateAIWorkflow(definition)
|
||||
setValidation(result)
|
||||
toast[result.valid ? "success" : "error"](
|
||||
result.valid ? "Workflow is valid" : "Workflow has validation errors"
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to validate workflow")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const publish = async () => {
|
||||
if (!selected) {
|
||||
toast.error("Save the workflow before publishing.")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const version = await publishAIWorkflow(selected.id, definition)
|
||||
toast.success(`Published version ${version.version}`)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to publish workflow")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-var(--header-height))] min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-semibold">AI Workflows</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Edit and publish customer-service conversation flows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={createNew}>
|
||||
New
|
||||
</Button>
|
||||
<Button variant="outline" disabled={loading} onClick={runValidation}>
|
||||
<CheckCircle2Icon className="size-4" />
|
||||
Validate
|
||||
</Button>
|
||||
<Button variant="outline" disabled={loading} onClick={saveDraft}>
|
||||
<SaveIcon className="size-4" />
|
||||
Save draft
|
||||
</Button>
|
||||
<Button disabled={loading || !selected} onClick={publish}>
|
||||
<SendIcon className="size-4" />
|
||||
Publish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto border-r bg-muted/20">
|
||||
<div className="space-y-4 border-b p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow-name">Name</Label>
|
||||
<Input
|
||||
id="workflow-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow-owner">AI Agent ID</Label>
|
||||
<Input
|
||||
id="workflow-owner"
|
||||
type="number"
|
||||
min={1}
|
||||
value={ownerId}
|
||||
onChange={(event) => setOwnerId(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow-description">Description</Label>
|
||||
<Textarea
|
||||
id="workflow-description"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-sm font-medium">Workflows</div>
|
||||
<div className="space-y-2">
|
||||
{workflows.map((workflow) => (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type="button"
|
||||
onClick={() => selectWorkflow(workflow)}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm hover:bg-muted ${
|
||||
selected?.id === workflow.id ? "border-primary bg-primary/5" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium">{workflow.name}</span>
|
||||
{workflow.publishedVersionId ? (
|
||||
<Badge variant="secondary">Published</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
Agent #{workflow.ownerId}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{workflows.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No workflows yet.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2 text-sm">
|
||||
<GitBranchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{selected ? selected.name : "Unsaved workflow"}</span>
|
||||
{validation ? (
|
||||
<Badge variant={validation.valid ? "default" : "destructive"}>
|
||||
{validation.valid ? "Backend valid" : `${validation.errors.length} backend errors`}
|
||||
</Badge>
|
||||
) : null}
|
||||
{validation && !validation.valid ? (
|
||||
<span className="truncate text-xs text-destructive">
|
||||
{validation.errors.map((item) => item.message).join("; ")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<WorkflowEditor
|
||||
key={editorKey}
|
||||
definition={definition}
|
||||
nodeSpecs={nodeSpecs}
|
||||
onDefinitionChange={setDefinition}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user