feat: add AI workflow editor

This commit is contained in:
mlogclub
2026-06-22 00:26:16 +08:00
parent df7b199c18
commit 14f50e064f
19 changed files with 1588 additions and 1 deletions
@@ -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,
})),
}
}
+270
View File
@@ -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>
)
}