refactor: enhance AI workflow management with restore version and usage tracking features

This commit is contained in:
mlogclub
2026-07-25 18:19:59 +08:00
parent 1663db7f24
commit 05eb85167c
9 changed files with 126 additions and 191 deletions
@@ -38,7 +38,6 @@ import {
createAIAgent,
fetchAIAgent,
fetchAIAgentRevisions,
fetchAIAgentWorkflow,
fetchAIConfigsAll,
fetchKnowledgeBasesAll,
fetchAIWorkflowDefaultDefinition,
@@ -49,11 +48,9 @@ import {
fetchAgentTeamsAll,
fetchMCPCatalog,
fetchSkillDefinitionsAll,
publishAIAgentWorkflow,
publishAIAgent,
rollbackAIAgent,
rollbackAIAgentRollout,
saveAIAgentWorkflow,
updateAIAgent,
validateAIWorkflow,
type AIAgent,
@@ -473,23 +470,6 @@ export function AIAgentConfigWorkbench({
}
}
async function saveWorkflowDraft() {
if (!currentAgentId) return
setSavingWorkflow(true)
try {
await saveAIAgentWorkflow({
name: "",
description: "",
definition,
})
toast.success("Workflow draft saved")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to save workflow draft")
} finally {
setSavingWorkflow(false)
}
}
async function rollbackAgentRevision(revisionId: number) {
if (!agent || revisionId <= 0 || revisionId === agent.publishedRevisionId) return
setSavingAgent(true)
@@ -520,74 +500,6 @@ export function AIAgentConfigWorkbench({
}
}
async function validateWorkflowDraft() {
setSavingWorkflow(true)
try {
const result = await validateAIWorkflow(definition)
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 {
setSavingWorkflow(false)
}
}
async function restoreDefaultWorkflow() {
if (savingWorkflow || loading) return
setSavingWorkflow(true)
try {
const defaultDefinition = await fetchAIWorkflowDefaultDefinition()
replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition)
toast.success("已恢复默认流程,保存草稿或发布后生效")
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复默认流程失败")
} finally {
setSavingWorkflow(false)
}
}
function applySelectedWorkflowTemplate() {
const template = workflowTemplates.find((item) => item.code === selectedWorkflowTemplate)
if (!template) return
replaceWorkflowDefinition(template.definition)
toast.success(`已应用 ${template.name} 模板,保存草稿或发布后生效`)
}
async function publishWorkflow() {
if (!currentAgentId) return
setSavingWorkflow(true)
try {
const saved = await saveAIAgentWorkflow({
name: "",
description: "",
definition,
})
const version = await publishAIAgentWorkflow(currentAgentId, definition)
toast.success(`Published version ${version.version}`)
setAgent((current) =>
current ? { ...current, workflowVersionId: version.id } : current
)
if (saved.id > 0) {
const versionPage = await fetchAIWorkflowVersions({
workflowId: saved.id,
limit: 20,
})
setWorkflowVersions(versionPage.results ?? [])
} else {
setWorkflowVersions((current) =>
current.some((item) => item.id === version.id) ? current : [version, ...current]
)
}
onAgentSaved?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to publish workflow")
} finally {
setSavingWorkflow(false)
}
}
const sections: { key: SectionKey; title: string; icon: ReactNode }[] = [
{ key: "basic", title: "基础信息", icon: <SettingsIcon /> },
{ key: "capabilities", title: "能力来源", icon: <PlugIcon /> },
+23 -86
View File
@@ -1,99 +1,36 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { PlusIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { ArrowLeftIcon, PlusIcon, SearchIcon } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import {
createAIWorkflow,
fetchAIWorkflow,
fetchAIWorkflowNodeSpecs,
fetchAIWorkflows,
publishAIWorkflow,
updateAIWorkflow,
validateAIWorkflow,
type AIWorkflow,
type AIWorkflowDefinition,
type AIWorkflowNodeSpec,
} from "@/lib/api/admin"
import { cn, formatDateTime } from "@/lib/utils"
import { createAIWorkflow, deleteAIWorkflow, fetchAIWorkflow, fetchAIWorkflowNodeSpecs, fetchAIWorkflows, fetchAIWorkflowUsage, fetchAIWorkflowVersions, publishAIWorkflow, restoreAIWorkflowVersion, updateAIWorkflow, validateAIWorkflow, type AIWorkflow, type AIWorkflowDefinition, type AIWorkflowNodeSpec, type AIWorkflowUsage, type AIWorkflowVersion } from "@/lib/api/admin"
import { WorkflowEditor } from "./_components/workflow-editor"
const emptyDefinition: AIWorkflowDefinition = {
schemaVersion: 2,
nodes: [
{ id: "start_1", type: "start", meta: { position: { x: 0, y: 80 } }, data: { title: "开始", config: {}, inputsValues: {} } },
{ id: "end_1", type: "end", meta: { position: { x: 260, y: 80 } }, data: { title: "结束", config: {}, inputsValues: {} } },
],
edges: [{ sourceNodeID: "start_1", targetNodeID: "end_1", sourcePortID: "edge_start_end" }],
}
const emptyDefinition: AIWorkflowDefinition = { schemaVersion: 2, nodes: [{ id: "start_1", type: "start", meta: { position: { x: 0, y: 80 } }, data: { title: "开始", config: {}, inputsValues: {} } }, { id: "end_1", type: "end", meta: { position: { x: 260, y: 80 } }, data: { title: "结束", config: {}, inputsValues: {} } }], edges: [{ sourceNodeID: "start_1", targetNodeID: "end_1", sourcePortID: "edge_start_end" }] }
export default function DashboardAIWorkflowsPage() {
const [items, setItems] = useState<AIWorkflow[]>([])
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [active, setActive] = useState<AIWorkflow | null>(null)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [definition, setDefinition] = useState<AIWorkflowDefinition>(emptyDefinition)
const [saving, setSaving] = useState(false)
const select = useCallback(async (id: number) => {
const item = await fetchAIWorkflow(id)
setActive(item)
setName(item.name)
setDescription(item.description)
setDefinition(item.draftDefinition)
}, [])
const reload = useCallback(async () => {
const [page, specs] = await Promise.all([fetchAIWorkflows({ limit: 100 }), fetchAIWorkflowNodeSpecs()])
setItems(page.results)
setNodeSpecs(specs)
if (!active && page.results[0]) await select(page.results[0].id)
}, [active, select])
useEffect(() => { void reload().catch((error) => toast.error(error instanceof Error ? error.message : "加载工作流失败")) }, [reload])
async function save() {
if (!name.trim()) { toast.error("请填写工作流名称"); return }
setSaving(true)
try {
if (active) {
await updateAIWorkflow({ id: active.id, name: name.trim(), description: description.trim(), definition })
await select(active.id)
} else {
const created = await createAIWorkflow({ name: name.trim(), description: description.trim(), definition })
await select(created.id)
}
await reload()
toast.success("工作流草稿已保存")
} catch (error) { toast.error(error instanceof Error ? error.message : "保存工作流失败") } finally { setSaving(false) }
}
async function publish() {
if (!active) { toast.error("请先保存工作流草稿"); return }
setSaving(true)
try {
const version = await publishAIWorkflow(active.id, definition)
await select(active.id)
await reload()
toast.success(`已发布工作流 v${version.version}`)
} catch (error) { toast.error(error instanceof Error ? error.message : "发布工作流失败") } finally { setSaving(false) }
}
function create() { setActive(null); setName(""); setDescription(""); setDefinition(emptyDefinition) }
return <div className="flex h-full min-h-0 bg-background">
<aside className="w-72 shrink-0 border-r bg-muted/20 p-3">
<div className="mb-3 flex items-center justify-between"><div><h1 className="font-semibold"></h1><p className="text-xs text-muted-foreground"> Agent </p></div><Button size="icon" variant="outline" onClick={create}><PlusIcon className="size-4" /></Button></div>
<div className="space-y-1">{items.map((item) => <button key={item.id} type="button" onClick={() => void select(item.id)} className={`w-full rounded-md p-3 text-left ${active?.id === item.id ? "bg-background shadow-sm" : "hover:bg-background/70"}`}><div className="truncate font-medium">{item.name}</div><div className="mt-1 text-xs text-muted-foreground">{item.publishedVersionId > 0 ? `已发布版本 #${item.publishedVersionId}` : "未发布"}</div></button>)}</div>
</aside>
<section className="flex min-w-0 flex-1 flex-col">
<header className="flex shrink-0 items-center gap-3 border-b px-5 py-3"><div className="min-w-0 flex-1"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="工作流名称" className="max-w-sm" /><Textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="业务说明(可选)" className="mt-2 min-h-16 max-w-xl resize-none" /></div><Button variant="outline" disabled={saving} onClick={() => void save()}>稿</Button><Button disabled={saving || !active} onClick={() => void publish()}></Button></header>
<div className="min-h-0 flex-1"><WorkflowEditor definition={definition} nodeSpecs={nodeSpecs} onDefinitionChange={setDefinition} onValidate={() => void validateAIWorkflow(definition).then((result) => toast[result.valid ? "success" : "error"](result.valid ? "工作流校验通过" : "工作流存在校验错误"))} validateDisabled={saving} /></div>
</section>
</div>
const [items, setItems] = useState<AIWorkflow[]>([]); const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [active, setActive] = useState<AIWorkflow | null>(null); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [definition, setDefinition] = useState<AIWorkflowDefinition>(emptyDefinition)
const [versions, setVersions] = useState<AIWorkflowVersion[]>([]); const [usage, setUsage] = useState<AIWorkflowUsage[]>([]); const [query, setQuery] = useState(""); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false)
const loadList = useCallback(async () => { const [page, specs] = await Promise.all([fetchAIWorkflows({ limit: 100 }), fetchAIWorkflowNodeSpecs()]); setItems(page.results ?? []); setNodeSpecs(specs ?? []) }, [])
const select = useCallback(async (id: number) => { const [item, versionPage, uses] = await Promise.all([fetchAIWorkflow(id), fetchAIWorkflowVersions({ workflowId: id, limit: 50 }), fetchAIWorkflowUsage(id)]); setActive(item); setName(item.name); setDescription(item.description); setDefinition(item.draftDefinition); setVersions(versionPage.results ?? []); setUsage(uses ?? []); setDirty(false) }, [])
useEffect(() => { void loadList().then(async () => { if (!active) { const page = await fetchAIWorkflows({ limit: 1 }); if (page.results?.[0]) await select(page.results[0].id) } }).catch((e) => toast.error(e instanceof Error ? e.message : "加载工作流失败")) }, [active, loadList, select])
const visible = useMemo(() => items.filter((item) => item.name.toLowerCase().includes(query.trim().toLowerCase())), [items, query])
const create = () => { setActive(null); setName(""); setDescription(""); setDefinition(emptyDefinition); setVersions([]); setUsage([]); setDirty(false) }
const save = async () => { if (!name.trim()) return toast.error("请填写工作流名称"); setSaving(true); try { if (active) { await updateAIWorkflow({ id: active.id, name: name.trim(), description: description.trim(), definition }); await select(active.id) } else { const created = await createAIWorkflow({ name: name.trim(), description: description.trim(), definition }); await loadList(); await select(created.id) }; await loadList(); toast.success("草稿已保存") } catch (e) { toast.error(e instanceof Error ? e.message : "保存失败") } finally { setSaving(false) } }
const publish = async () => { if (!active) return toast.error("请先保存草稿"); setSaving(true); try { const version = await publishAIWorkflow(active.id, definition); await select(active.id); await loadList(); toast.success(`已发布 v${version.version}`) } catch (e) { toast.error(e instanceof Error ? e.message : "发布失败") } finally { setSaving(false) } }
const restore = async (version: AIWorkflowVersion) => { if (!active) return; try { await restoreAIWorkflowVersion(active.id, version.id); await select(active.id); toast.success(`已将 v${version.version} 恢复为草稿`) } catch (e) { toast.error(e instanceof Error ? e.message : "恢复失败") } }
const remove = async () => { if (!active || !confirm(`确认删除“${active.name}”?`)) return; try { await deleteAIWorkflow(active.id); create(); await loadList(); toast.success("工作流已删除") } catch (e) { toast.error(e instanceof Error ? e.message : "无法删除:该工作流可能仍被 Agent 使用") } }
return <div className="h-full min-h-0 overflow-hidden bg-background"><div className="flex h-full min-h-0 overflow-hidden">
<aside className="flex w-80 shrink-0 flex-col border-r bg-muted/15"><div className="shrink-0 space-y-3 border-b p-4"><div className="flex items-center justify-between"><div><h1 className="text-base font-semibold"></h1><p className="mt-1 text-xs text-muted-foreground"> Agent </p></div><Button size="icon" onClick={create}><PlusIcon className="size-4" /></Button></div><div className="relative"><SearchIcon className="absolute left-3 top-2.5 size-4 text-muted-foreground" /><Input value={query} onChange={(e) => setQuery(e.target.value)} className="pl-9" placeholder="搜索工作流" /></div></div><div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2">{visible.length ? visible.map((item) => <button key={item.id} type="button" onClick={() => void select(item.id)} className={cn("mb-1 w-full rounded-lg border p-3 text-left transition-colors", active?.id === item.id ? "border-primary/30 bg-primary/5" : "border-transparent hover:bg-muted")}><div className="truncate font-medium">{item.name}</div><div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{item.description || "暂无业务说明"}</div><div className="mt-3 flex items-center justify-between text-xs"><Badge variant={item.publishedVersionId ? "secondary" : "outline"}>{item.publishedVersionId ? "已发布" : "草稿"}</Badge><span className="text-muted-foreground">{formatDateTime(item.updatedAt)}</span></div></button>) : <div className="p-8 text-center text-sm text-muted-foreground"></div>}</div></aside>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">{active || name ? <><header className="shrink-0 border-b bg-background px-6 py-4"><div className="flex items-start gap-4"><Button variant="ghost" size="icon" className="md:hidden" onClick={create}><ArrowLeftIcon className="size-4" /></Button><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><Input value={name} onChange={(e) => { setName(e.target.value); setDirty(true) }} className="max-w-md border-0 px-0 text-lg font-semibold shadow-none focus-visible:ring-0" placeholder="工作流名称" /><Badge variant={active?.publishedVersionId ? "secondary" : "outline"}>{active?.publishedVersionId ? `当前已发布` : "未发布"}</Badge>{dirty ? <span className="text-xs text-amber-600"></span> : null}</div><Textarea value={description} onChange={(e) => { setDescription(e.target.value); setDirty(true) }} className="mt-1 min-h-0 max-w-2xl resize-none border-0 px-0 text-sm shadow-none focus-visible:ring-0" placeholder="补充业务目标、适用场景和边界" /></div><div className="flex shrink-0 gap-2"><Button variant="outline" disabled={saving} onClick={() => void validateAIWorkflow(definition).then((r) => toast[r.valid ? "success" : "error"](r.valid ? "校验通过" : `发现 ${r.errors.length} 个问题`))}></Button><Button variant="outline" disabled={saving} onClick={() => void save()}>稿</Button><Button disabled={saving || !active} onClick={() => void publish()}></Button></div></div></header>
<Tabs defaultValue="editor" className="flex min-h-0 flex-1 flex-col"><div className="shrink-0 border-b px-6"><TabsList className="h-11 bg-transparent"><TabsTrigger value="editor"></TabsTrigger><TabsTrigger value="versions"> ({versions.length})</TabsTrigger><TabsTrigger value="usage">使 ({usage.length})</TabsTrigger></TabsList></div><TabsContent value="editor" className="min-h-0 flex-1 overflow-hidden data-[state=inactive]:hidden"><WorkflowEditor definition={definition} nodeSpecs={nodeSpecs} onDefinitionChange={(next) => { setDefinition(next); setDirty(true) }} onSaveDraft={() => void save()} onPublish={() => void publish()} saveDraftDisabled={saving} publishDisabled={saving || !active} /></TabsContent><TabsContent value="versions" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6">{versions.map((version) => <div key={version.id} className="mb-3 flex items-center gap-4 rounded-lg border p-4"><Badge>v{version.version}</Badge><div className="min-w-0 flex-1"><div className="text-sm font-medium"> {formatDateTime(version.publishedAt || version.createdAt)}</div><div className="text-xs text-muted-foreground">{version.publishedByName || "-"} · {version.definitionHash.slice(0, 10)}</div></div><Button variant="outline" size="sm" onClick={() => void restore(version)}>稿</Button></div>) || <p className="text-sm text-muted-foreground"></p>}</TabsContent><TabsContent value="usage" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6">{usage.length ? usage.map((item) => <div key={`${item.aiAgentId}-${item.workflowVersionId}`} className="mb-3 flex items-center justify-between rounded-lg border p-4"><div><div className="font-medium">{item.aiAgentName}</div><div className="mt-1 text-sm text-muted-foreground"> v{item.workflowVersion}</div></div><Badge variant={item.enabled ? "secondary" : "outline"}>{item.enabled ? "启用" : "已停用"}</Badge></div>) : <p className="text-sm text-muted-foreground"> Agent 使</p>}</TabsContent></Tabs></> : <div className="flex flex-1 items-center justify-center"><div className="text-center"><h2 className="font-semibold"></h2><p className="mt-2 text-sm text-muted-foreground"> Agent</p><Button className="mt-4" onClick={create}></Button></div></div>}</main></div></div>
}
+14 -16
View File
@@ -455,6 +455,14 @@ export type AIWorkflowValidationResult = {
}[]
}
export type AIWorkflowUsage = {
aiAgentId: number
aiAgentName: string
workflowVersionId: number
workflowVersion: number
enabled: boolean
}
export type CreateAIWorkflowPayload = {
name: string
description: string
@@ -1031,10 +1039,6 @@ export function updateAIAgentStatus(id: number, status: number) {
})
}
export function fetchAIAgentWorkflow(agentId: number) {
return request<AIWorkflow>(`/api/dashboard/ai-agent/${agentId}/workflow`)
}
export function fetchAIWorkflows(query?: Record<string, string | number | undefined>) {
return request<PageResult<AIWorkflow>>(`/api/dashboard/ai-workflow/list${toQueryString(query)}`)
}
@@ -1055,11 +1059,12 @@ export function deleteAIWorkflow(id: number) {
return request<void>("/api/dashboard/ai-workflow/delete", { method: "POST", body: JSON.stringify({ id }) })
}
export function saveAIAgentWorkflow(payload: CreateAIWorkflowPayload) {
return request<AIWorkflow>("/api/dashboard/ai-agent/workflow/save", {
method: "POST",
body: JSON.stringify(payload),
})
export function fetchAIWorkflowUsage(id: number) {
return request<AIWorkflowUsage[]>(`/api/dashboard/ai-workflow/${id}/usage`)
}
export function restoreAIWorkflowVersion(workflowId: number, workflowVersionId: number) {
return request<void>("/api/dashboard/ai-workflow/restore-version", { method: "POST", body: JSON.stringify({ workflowId, workflowVersionId }) })
}
export function fetchAIWorkflowNodeSpecs() {
@@ -1094,13 +1099,6 @@ export function publishAIWorkflow(workflowId: number, definition: AIWorkflowDefi
})
}
export function publishAIAgentWorkflow(agentId: number, definition: AIWorkflowDefinition) {
return request<AIWorkflowVersion>("/api/dashboard/ai-agent/workflow/publish", {
method: "POST",
body: JSON.stringify({ agentId, definition }),
})
}
export function fetchUsers(query?: Record<string, string | number | undefined>) {
return request<PageResult<AdminUser>>(
`/api/dashboard/user/list${toQueryString(query)}`