diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index efc7f9f..d610bf4 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -229,10 +229,10 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) { } func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { - group.GET("/:id", dashboard.AIWorkflowGetBy) group.POST("/create", dashboard.AIWorkflowPostCreate) group.POST("/update", dashboard.AIWorkflowPostUpdate) group.POST("/delete", dashboard.AIWorkflowPostDelete) + group.POST("/restore-version", dashboard.AIWorkflowPostRestoreVersion) group.Any("/list", dashboard.AIWorkflowAnyList) group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList) group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition) @@ -242,6 +242,8 @@ func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { group.GET("/run/:id", dashboard.AIWorkflowGetRunBy) group.Any("/version/list", dashboard.AIWorkflowAnyVersionList) group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy) + group.GET("/:id/usage", dashboard.AIWorkflowGetUsage) + group.GET("/:id", dashboard.AIWorkflowGetBy) } func registerDashboardAgentRunRoutes(group *gin.RouterGroup) { diff --git a/internal/handlers/dashboard/ai_workflow_handler.go b/internal/handlers/dashboard/ai_workflow_handler.go index 980f4f4..a5cdacf 100644 --- a/internal/handlers/dashboard/ai_workflow_handler.go +++ b/internal/handlers/dashboard/ai_workflow_handler.go @@ -99,6 +99,44 @@ func AIWorkflowPostDelete(ctx *gin.Context) { httpx.WriteJSON(ctx, nil) } +func AIWorkflowPostRestoreVersion(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.RestoreAIWorkflowVersionRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.AIWorkflowService.RestoreVersion(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func AIWorkflowGetUsage(ctx *gin.Context) { + id, ok := httpx.GetPathInt64(ctx, "id") + if !ok { + return + } + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + items := services.AIWorkflowService.ListUsage(id) + ret := make([]response.AIWorkflowUsageResponse, 0, len(items)) + for _, item := range items { + if item.Agent == nil || item.Version == nil { + continue + } + ret = append(ret, response.AIWorkflowUsageResponse{AIAgentID: item.Agent.ID, AIAgentName: item.Agent.Name, WorkflowVersionID: item.Binding.WorkflowVersionID, WorkflowVersion: item.Version.Version, Enabled: item.Binding.Enabled}) + } + httpx.WriteJSON(ctx, ret) +} + func AIWorkflowGetNodeSpecList(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { httpx.WriteJSON(ctx, err) diff --git a/internal/pkg/dto/request/ai_workflow_request.go b/internal/pkg/dto/request/ai_workflow_request.go index 9794fc3..ec9b82d 100644 --- a/internal/pkg/dto/request/ai_workflow_request.go +++ b/internal/pkg/dto/request/ai_workflow_request.go @@ -29,3 +29,8 @@ type PublishAIWorkflowRequest struct { type AIWorkflowVersionListRequest struct { WorkflowID int64 `json:"workflowId"` } + +type RestoreAIWorkflowVersionRequest struct { + WorkflowID int64 `json:"workflowId"` + WorkflowVersionID int64 `json:"workflowVersionId"` +} diff --git a/internal/pkg/dto/response/ai_workflow_response.go b/internal/pkg/dto/response/ai_workflow_response.go index f050452..519c662 100644 --- a/internal/pkg/dto/response/ai_workflow_response.go +++ b/internal/pkg/dto/response/ai_workflow_response.go @@ -47,6 +47,14 @@ type AIWorkflowTemplateResponse struct { Definition dsl.Definition `json:"definition"` } +type AIWorkflowUsageResponse struct { + AIAgentID int64 `json:"aiAgentId"` + AIAgentName string `json:"aiAgentName"` + WorkflowVersionID int64 `json:"workflowVersionId"` + WorkflowVersion int `json:"workflowVersion"` + Enabled bool `json:"enabled"` +} + type AIWorkflowNodeSpecResponse struct { Type string `json:"type"` Title string `json:"title"` diff --git a/internal/repositories/ai_agent_workflow_binding_repository.go b/internal/repositories/ai_agent_workflow_binding_repository.go index bd13b0e..d21679d 100644 --- a/internal/repositories/ai_agent_workflow_binding_repository.go +++ b/internal/repositories/ai_agent_workflow_binding_repository.go @@ -30,6 +30,14 @@ func (r *aiAgentWorkflowBindingRepository) FindEnabledByAgentID(db *gorm.DB, age return ret } +func (r *aiAgentWorkflowBindingRepository) FindByWorkflowID(db *gorm.DB, workflowID int64) []models.AIAgentWorkflowBinding { + ret := make([]models.AIAgentWorkflowBinding, 0) + if workflowID > 0 { + db.Where("workflow_id = ?", workflowID).Order("priority ASC, id ASC").Find(&ret) + } + return ret +} + func (r *aiAgentWorkflowBindingRepository) CountByWorkflowID(db *gorm.DB, workflowID int64) int64 { var count int64 if workflowID > 0 { diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index 89d7d92..9494505 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -48,6 +48,12 @@ type AIWorkflowTemplate struct { Definition dsl.Definition } +type AIWorkflowUsageItem struct { + Binding models.AIAgentWorkflowBinding + Agent *models.AIAgent + Version *models.AIWorkflowVersion +} + func (s *aiWorkflowService) Get(id int64) *models.AIWorkflow { if id <= 0 { return nil @@ -263,6 +269,27 @@ func (s *aiWorkflowService) DeleteWorkflow(id int64, operator *dto.AuthPrincipal }) } +func (s *aiWorkflowService) RestoreVersion(req request.RestoreAIWorkflowVersionRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + workflow := s.Get(req.WorkflowID) + version := s.GetVersion(req.WorkflowVersionID) + if workflow == nil || version == nil || version.WorkflowID != workflow.ID { + return errorsx.InvalidParamI18n("error.e0002") + } + return repositories.AIWorkflowRepository.Updates(sqls.DB(), workflow.ID, map[string]any{"draft_definition": version.Definition, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now()}) +} + +func (s *aiWorkflowService) ListUsage(workflowID int64) []AIWorkflowUsageItem { + bindings := repositories.AIAgentWorkflowBindingRepository.FindByWorkflowID(sqls.DB(), workflowID) + ret := make([]AIWorkflowUsageItem, 0, len(bindings)) + for _, binding := range bindings { + ret = append(ret, AIWorkflowUsageItem{Binding: binding, Agent: repositories.AIAgentRepository.Get(sqls.DB(), binding.AIAgentID), Version: repositories.AIWorkflowVersionRepository.Get(sqls.DB(), binding.WorkflowVersionID)}) + } + return ret +} + func (s *aiWorkflowService) PublishWorkflow(req request.PublishAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflowVersion, error) { if operator == nil { return nil, errorsx.UnauthorizedI18n("error.auth.expired") diff --git a/web/app/dashboard/ai-agents/_components/config-workbench.tsx b/web/app/dashboard/ai-agents/_components/config-workbench.tsx index 831b97e..9aabcf1 100644 --- a/web/app/dashboard/ai-agents/_components/config-workbench.tsx +++ b/web/app/dashboard/ai-agents/_components/config-workbench.tsx @@ -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: }, { key: "capabilities", title: "能力来源", icon: }, diff --git a/web/app/dashboard/ai-workflows/page.tsx b/web/app/dashboard/ai-workflows/page.tsx index 57281c2..db486a8 100644 --- a/web/app/dashboard/ai-workflows/page.tsx +++ b/web/app/dashboard/ai-workflows/page.tsx @@ -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([]) - const [nodeSpecs, setNodeSpecs] = useState([]) - const [active, setActive] = useState(null) - const [name, setName] = useState("") - const [description, setDescription] = useState("") - const [definition, setDefinition] = useState(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 - - - setName(event.target.value)} placeholder="工作流名称" className="max-w-sm" /> setDescription(event.target.value)} placeholder="业务说明(可选)" className="mt-2 min-h-16 max-w-xl resize-none" /> void save()}>保存草稿 void publish()}>发布工作流 - void validateAIWorkflow(definition).then((result) => toast[result.valid ? "success" : "error"](result.valid ? "工作流校验通过" : "工作流存在校验错误"))} validateDisabled={saving} /> - - + const [items, setItems] = useState([]); const [nodeSpecs, setNodeSpecs] = useState([]) + const [active, setActive] = useState(null); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [definition, setDefinition] = useState(emptyDefinition) + const [versions, setVersions] = useState([]); const [usage, setUsage] = useState([]); 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 + + {active || name ? <> { 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="工作流名称" />{active?.publishedVersionId ? `当前已发布` : "未发布"}{dirty ? 未保存 : null} { 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="补充业务目标、适用场景和边界" /> void validateAIWorkflow(definition).then((r) => toast[r.valid ? "success" : "error"](r.valid ? "校验通过" : `发现 ${r.errors.length} 个问题`))}>校验 void save()}>保存草稿 void publish()}>发布版本 + 编辑画布版本历史 ({versions.length})使用情况 ({usage.length}) { setDefinition(next); setDirty(true) }} onSaveDraft={() => void save()} onPublish={() => void publish()} saveDraftDisabled={saving} publishDisabled={saving || !active} />{versions.map((version) => v{version.version}发布于 {formatDateTime(version.publishedAt || version.createdAt)}发布人:{version.publishedByName || "-"} · 指纹 {version.definitionHash.slice(0, 10)} void restore(version)}>恢复为草稿) || 尚未发布版本。}{usage.length ? usage.map((item) => {item.aiAgentName}固定关联 v{item.workflowVersion}{item.enabled ? "启用" : "已停用"}) : 暂未被任何 Agent 使用,可安全删除。}> : 创建第一个工作流从空白画布开始,发布后再关联给 Agent。创建工作流} } diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index e1963af..097cc8a 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -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(`/api/dashboard/ai-agent/${agentId}/workflow`) -} - export function fetchAIWorkflows(query?: Record) { return request>(`/api/dashboard/ai-workflow/list${toQueryString(query)}`) } @@ -1055,11 +1059,12 @@ export function deleteAIWorkflow(id: number) { return request("/api/dashboard/ai-workflow/delete", { method: "POST", body: JSON.stringify({ id }) }) } -export function saveAIAgentWorkflow(payload: CreateAIWorkflowPayload) { - return request("/api/dashboard/ai-agent/workflow/save", { - method: "POST", - body: JSON.stringify(payload), - }) +export function fetchAIWorkflowUsage(id: number) { + return request(`/api/dashboard/ai-workflow/${id}/usage`) +} + +export function restoreAIWorkflowVersion(workflowId: number, workflowVersionId: number) { + return request("/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("/api/dashboard/ai-agent/workflow/publish", { - method: "POST", - body: JSON.stringify({ agentId, definition }), - }) -} - export function fetchUsers(query?: Record) { return request>( `/api/dashboard/user/list${toQueryString(query)}`
尚未发布版本。
暂未被任何 Agent 使用,可安全删除。
从空白画布开始,发布后再关联给 Agent。