feat: Implement AI Agent Workflow Binding functionality

- Added aiAgentWorkflowBindingRepository for managing workflow bindings associated with AI agents.
- Enhanced agentRevisionService to include workflow bindings in agent revisions.
- Updated aIAgentService to handle workflow bindings during agent creation and updates.
- Introduced ai_agent_workflow_binding_service for managing workflow binding logic.
- Created new API endpoints for fetching, creating, updating, and deleting AI workflows.
- Developed a new dashboard page for managing AI workflows.
- Updated frontend components to support workflow binding management in agent configuration.
- Added necessary tests for workflow binding functionality and updated existing tests for compatibility.
- Translated relevant UI texts and messages for workflow management.
This commit is contained in:
mlogclub
2026-07-25 15:24:49 +08:00
parent e4ad83dc20
commit 7b86fd1f09
21 changed files with 590 additions and 136 deletions
@@ -45,6 +45,7 @@ import {
fetchAIWorkflowNodeSpecs,
fetchAIWorkflowTemplates,
fetchAIWorkflowVersions,
fetchAIWorkflows,
fetchAgentTeamsAll,
fetchMCPCatalog,
fetchSkillDefinitionsAll,
@@ -56,9 +57,11 @@ import {
updateAIAgent,
validateAIWorkflow,
type AIAgent,
type AIAgentWorkflowBindingInput,
type AgentRevision,
type AIConfig,
type AIWorkflowDefinition,
type AIWorkflow,
type AIWorkflowNodeSpec,
type AIWorkflowTemplate,
type AIWorkflowVersion,
@@ -161,6 +164,9 @@ export function AIAgentConfigWorkbench({
const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([])
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<number[]>([])
const [directTools, setDirectTools] = useState<DirectToolItem[]>([])
const [workflowBindings, setWorkflowBindings] = useState<AIAgentWorkflowBindingInput[]>([])
const [publishedWorkflows, setPublishedWorkflows] = useState<AIWorkflow[]>([])
const [workflowToAdd, setWorkflowToAdd] = useState("")
const [definition, setDefinition] = useState<AIWorkflowDefinition>(fallbackDefinition)
const [workflowRevision, setWorkflowRevision] = useState(0)
@@ -200,6 +206,7 @@ export function AIAgentConfigWorkbench({
skillList,
knowledgeBaseList,
catalog,
workflowPage,
] = await Promise.all([
fetchAIWorkflowNodeSpecs(),
fetchAIWorkflowDefaultDefinition().catch(() => fallbackDefinition),
@@ -209,6 +216,7 @@ export function AIAgentConfigWorkbench({
fetchSkillDefinitionsAll({ status: Status.Ok }),
fetchKnowledgeBasesAll({ status: Status.Ok }),
fetchMCPCatalog(),
fetchAIWorkflows({ limit: 100 }),
])
setNodeSpecs(specs ?? [])
@@ -218,6 +226,7 @@ export function AIAgentConfigWorkbench({
setSkills(skillList ?? [])
setKnowledgeBases(knowledgeBaseList ?? [])
setToolCatalog(catalog ?? [])
setPublishedWorkflows((workflowPage.results ?? []).filter((item) => item.publishedVersionId > 0))
if (!currentAgentId || currentAgentId <= 0) {
setAgent(null)
@@ -239,24 +248,19 @@ export function AIAgentConfigWorkbench({
setSelectedSkillIds([])
setSelectedKnowledgeBaseIds([])
setDirectTools([])
setWorkflowBindings([])
replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition)
return
}
const [agentDetail, workflowDetail, revisionList] = await Promise.all([
const [agentDetail, revisionList] = await Promise.all([
fetchAIAgent(currentAgentId),
fetchAIAgentWorkflow(currentAgentId),
fetchAIAgentRevisions(currentAgentId),
])
setAgent(agentDetail)
setAgentRevisions(revisionList ?? [])
if (workflowDetail?.id > 0) {
const versionPage = await fetchAIWorkflowVersions({ workflowId: workflowDetail.id, limit: 20 })
setWorkflowVersions(versionPage.results ?? [])
} else {
setWorkflowVersions([])
}
setWorkflowVersions([])
setName(agentDetail.name)
setDescription(agentDetail.description || "")
setAIConfigId(toText(agentDetail.aiConfigId))
@@ -273,7 +277,8 @@ export function AIAgentConfigWorkbench({
setSelectedSkillIds(agentDetail.skillIds ?? [])
setSelectedKnowledgeBaseIds(agentDetail.knowledgeBaseIds ?? [])
setDirectTools(agentDetail.directTools ?? [])
replaceWorkflowDefinition(workflowDetail.draftDefinition ?? defaultDefinition ?? fallbackDefinition)
setWorkflowBindings((agentDetail.workflowBindings ?? []).map(({ workflowVersionId, toolName, triggerInstruction, priority, enabled }) => ({ workflowVersionId, toolName, triggerInstruction, priority, enabled })))
replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition)
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to load Agent config")
} finally {
@@ -296,8 +301,8 @@ export function AIAgentConfigWorkbench({
const runtimeModeOptions = useMemo(
() => [
{ value: "autonomous", label: "自主接待" },
{ value: "hybrid", label: "自主接待 + 流" },
{ value: "workflow", label: "高级编排 / Playbooks" },
{ value: "hybrid", label: "自主接待 + 工作流" },
{ value: "workflow", label: "仅工作流" },
],
[]
)
@@ -402,6 +407,13 @@ export function AIAgentConfigWorkbench({
setDirectToolToAdd("")
}
function addWorkflowBinding(value: string) {
const workflow = publishedWorkflows.find((item) => item.publishedVersionId === Number(value))
if (!workflow || workflowBindings.some((item) => item.workflowVersionId === workflow.publishedVersionId)) return
setWorkflowBindings((current) => [...current, { workflowVersionId: workflow.publishedVersionId, toolName: workflow.name, triggerInstruction: "", priority: current.length + 1, enabled: true }])
setWorkflowToAdd("")
}
function buildPayload(): CreateAIAgentPayload {
return {
name: name.trim(),
@@ -419,7 +431,8 @@ export function AIAgentConfigWorkbench({
fallbackMessage: fallbackMessage.trim(),
knowledgeBaseIds: uniqueNumbers(selectedKnowledgeBaseIds),
skillIds: uniqueNumbers(selectedSkillIds),
directTools,
directTools,
workflowBindings,
}
}
@@ -447,12 +460,12 @@ export function AIAgentConfigWorkbench({
}
async function publishAutonomousAgent() {
if (!agent || runtimeMode !== "autonomous") return
if (!agent || (runtimeMode !== "autonomous" && runtimeMode !== "hybrid")) return
setSavingAgent(true)
try {
await publishAIAgent(agent.id)
await loadData()
toast.success("Autonomous Agent published")
toast.success("Agent published")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to publish Autonomous Agent")
} finally {
@@ -580,7 +593,7 @@ export function AIAgentConfigWorkbench({
const sections: { key: SectionKey; title: string; icon: ReactNode }[] = [
{ key: "basic", title: "基础信息", icon: <SettingsIcon /> },
{ key: "capabilities", title: "能力来源", icon: <PlugIcon /> },
{ key: "workflow", title: "高级编排 / Playbooks", icon: <GitBranchIcon /> },
{ key: "workflow", title: "关联工作流", icon: <GitBranchIcon /> },
]
const selectedTeamOptions = selectedOptions(selectedTeamIds, teamOptions)
@@ -615,7 +628,7 @@ export function AIAgentConfigWorkbench({
null
) : (
<>
{agent && runtimeMode === "autonomous" ? <Button type="button" variant="outline" disabled={savingAgent || loading} onClick={publishAutonomousAgent}> Agent</Button> : null}
{agent && (runtimeMode === "autonomous" || runtimeMode === "hybrid") ? <Button type="button" variant="outline" disabled={savingAgent || loading} onClick={publishAutonomousAgent}> Agent</Button> : null}
<Button
type="button"
variant="outline"
@@ -633,7 +646,7 @@ export function AIAgentConfigWorkbench({
<div className="flex min-h-0 flex-1 flex-col bg-background">
{agent && !runtimePublished ? (
<div className="shrink-0 border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-900">
{runtimeMode === "autonomous" ? "未发布 Agent,AI 不会自动回复。保存配置后发布 Agent,再绑定渠道或启用自动回复。" : runtimeMode === "hybrid" ? "未发布 Hybrid Agent,AI 不会自动回复。保存配置后请进入“高级编排 / Playbooks”发布一个版本。" : "未发布 PlaybookAI 不会自动回复。保存配置后请进入“高级编排 / Playbooks”发布一个版本,再绑定渠道或启用自动回复。"}
{runtimeMode === "autonomous" ? "未发布 Agent,AI 不会自动回复。保存配置后发布 Agent,再绑定渠道或启用自动回复。" : runtimeMode === "hybrid" ? "未发布 Hybrid Agent,AI 不会自动回复。保存配置后请关联已发布工作流,再发布 Agent。" : "未发布工作流AI 不会自动回复。请先关联一个已发布工作流。"}
</div>
) : null}
<div className="shrink-0 border-b bg-muted/30 px-4 py-2">
@@ -870,46 +883,23 @@ export function AIAgentConfigWorkbench({
) : null}
{activeSection === "workflow" ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2">
<OptionCombobox
value={selectedWorkflowTemplate}
options={workflowTemplates.map((item) => ({ value: item.code, label: item.name }))}
placeholder="选择 Playbook 模板"
onChange={setSelectedWorkflowTemplate}
/>
<Button type="button" variant="outline" size="sm" disabled={!selectedWorkflowTemplate || savingWorkflow || loading} onClick={applySelectedWorkflowTemplate}>
</Button>
<ConfigSection>
<div className="flex items-start justify-between gap-4">
<div><h2 className="text-base font-semibold"></h2><p className="mt-1 text-sm text-muted-foreground"> Agent 稿</p></div>
<Button type="button" variant="outline" onClick={() => window.location.assign("/dashboard/ai-workflows")}></Button>
</div>
<WorkflowEditor
key={workflowRevision}
definition={definition}
nodeSpecs={nodeSpecs}
onDefinitionChange={setDefinition}
historyDisabled={savingWorkflow || loading}
onRestoreDefault={restoreDefaultWorkflow}
restoreDefaultDisabled={savingWorkflow || loading}
onValidate={validateWorkflowDraft}
validateDisabled={savingWorkflow || loading || !currentAgentId}
onSaveDraft={saveWorkflowDraft}
saveDraftDisabled={savingWorkflow || loading || !currentAgentId}
onPublish={publishWorkflow}
publishDisabled={savingWorkflow || loading || !currentAgentId}
toolbarExtra={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 rounded-none px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setVersionDialogOpen(true)}
>
<HistoryIcon className="size-3.5" />
</Button>
}
/>
</div>
<div className="flex max-w-xl items-center gap-2">
<OptionCombobox value={workflowToAdd} options={publishedWorkflows.filter((item) => !workflowBindings.some((binding) => binding.workflowVersionId === item.publishedVersionId)).map((item) => ({ value: String(item.publishedVersionId), label: `${item.name} · v#${item.publishedVersionId}` }))} placeholder="选择已发布工作流" onChange={setWorkflowToAdd} />
<Button type="button" variant="outline" disabled={!workflowToAdd} onClick={() => addWorkflowBinding(workflowToAdd)}></Button>
</div>
<div className="space-y-2">
{workflowBindings.length === 0 ? <div className="rounded-md border border-dashed p-5 text-sm text-muted-foreground">Hybrid </div> : workflowBindings.map((binding) => {
const workflow = publishedWorkflows.find((item) => item.publishedVersionId === binding.workflowVersionId)
return <div key={binding.workflowVersionId} className="flex items-center gap-3 rounded-md border p-3"><GitBranchIcon className="size-4 text-muted-foreground" /><div className="min-w-0 flex-1"><div className="font-medium">{workflow?.name || binding.toolName || `工作流版本 #${binding.workflowVersionId}`}</div><div className="text-xs text-muted-foreground"> #{binding.workflowVersionId}</div></div><Button type="button" variant="ghost" size="sm" onClick={() => setWorkflowBindings((current) => current.filter((item) => item.workflowVersionId !== binding.workflowVersionId))}></Button></div>
})}
</div>
<div className="flex justify-end gap-2"><Button type="button" disabled={savingAgent || loading} onClick={saveAgentSettings}> Agent </Button>{agent && runtimeMode === "hybrid" ? <Button type="button" disabled={savingAgent || loading} onClick={publishAutonomousAgent}> Agent</Button> : null}</div>
</ConfigSection>
) : null}
<Dialog open={versionDialogOpen} onOpenChange={setVersionDialogOpen}>
+2 -2
View File
@@ -127,7 +127,7 @@ export default function DashboardAIAgentsPage() {
},
{
key: "workflow",
label: "Playbook 状态",
label: "工作流状态",
render: (item) => {
const published = isWorkflowPublished(item);
return (
@@ -144,7 +144,7 @@ export default function DashboardAIAgentsPage() {
</div>
{!published ? (
<div className="text-xs text-muted-foreground">
PlaybookAI
AI
</div>
) : (
<div className="text-xs text-muted-foreground">
+99
View File
@@ -0,0 +1,99 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { PlusIcon } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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 { 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" }],
}
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>
}
+54 -3
View File
@@ -241,7 +241,7 @@ export type AIAgent = {
knowledgeBaseIds: number[]
skillIds: number[]
skills: { id: number; name: string }[]
directTools: {
directTools: {
toolCode: string
serverCode: string
toolName: string
@@ -249,6 +249,7 @@ export type AIAgent = {
description: string
arguments?: Record<string, string>
}[]
workflowBindings: AIAgentWorkflowBinding[]
workflowVersionId: number
publishedRevisionId: number
workflowPublished: boolean
@@ -289,6 +290,27 @@ export type CreateAIAgentPayload = {
description: string
arguments?: Record<string, string>
}[]
workflowBindings: AIAgentWorkflowBindingInput[]
}
export type AIAgentWorkflowBinding = {
id: number
workflowId: number
workflowVersionId: number
workflowName: string
workflowVersion: number
toolName: string
triggerInstruction: string
priority: number
enabled: boolean
}
export type AIAgentWorkflowBindingInput = {
workflowVersionId: number
toolName: string
triggerInstruction: string
priority: number
enabled: boolean
}
export type UpdateAIAgentPayload = CreateAIAgentPayload & {
@@ -437,10 +459,12 @@ export type AIWorkflowValidationResult = {
export type CreateAIWorkflowPayload = {
name: string
description: string
agentId: number
agentId?: number
definition: AIWorkflowDefinition
}
export type UpdateAIWorkflowPayload = CreateAIWorkflowPayload & { id: number }
export type CreateAdminQuickReplyPayload = {
groupName: string
title: string
@@ -1013,6 +1037,26 @@ 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)}`)
}
export function fetchAIWorkflow(id: number) {
return request<AIWorkflow>(`/api/dashboard/ai-workflow/${id}`)
}
export function createAIWorkflow(payload: CreateAIWorkflowPayload) {
return request<AIWorkflow>("/api/dashboard/ai-workflow/create", { method: "POST", body: JSON.stringify(payload) })
}
export function updateAIWorkflow(payload: UpdateAIWorkflowPayload) {
return request<void>("/api/dashboard/ai-workflow/update", { method: "POST", body: JSON.stringify(payload) })
}
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",
@@ -1039,12 +1083,19 @@ export function fetchAIWorkflowVersions(query?: Record<string, string | number |
}
export function validateAIWorkflow(definition: AIWorkflowDefinition) {
return request<AIWorkflowValidationResult>("/api/dashboard/ai-agent/workflow/validate", {
return request<AIWorkflowValidationResult>("/api/dashboard/ai-workflow/validate", {
method: "POST",
body: JSON.stringify({ definition }),
})
}
export function publishAIWorkflow(workflowId: number, definition: AIWorkflowDefinition) {
return request<AIWorkflowVersion>("/api/dashboard/ai-workflow/publish", {
method: "POST",
body: JSON.stringify({ workflowId, definition }),
})
}
export function publishAIAgentWorkflow(agentId: number, definition: AIWorkflowDefinition) {
return request<AIWorkflowVersion>("/api/dashboard/ai-agent/workflow/publish", {
method: "POST",
+6
View File
@@ -193,6 +193,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
icon: <MessageSquareMoreIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.workflows",
url: "/dashboard/ai-workflows",
icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.skillDefinition",
url: "/dashboard/skill-definition",
+1
View File
@@ -2340,6 +2340,7 @@
"aiAgents": "Agents",
"aiWorkflows": "AI Workflows",
"workflowRuns": "Workflow Audit",
"workflows": "Workflows",
"agentRuns": "Agent Audit",
"skillDefinition": "Skills",
"mcp": "MCP tools",
+1
View File
@@ -2340,6 +2340,7 @@
"aiAgents": "Agent",
"aiWorkflows": "AI流程",
"workflowRuns": "流程审计",
"workflows": "工作流",
"agentRuns": "Agent 审计",
"skillDefinition": "Skills",
"mcp": "MCP tools",