From 46bcc55658938290ed99e9d468c87ba6ff3eff03 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Wed, 24 Jun 2026 09:52:00 +0800 Subject: [PATCH] feat: add AI workflow run management with detailed audit items and UI integration --- docs | 2 +- internal/builders/ai_workflow_builder.go | 30 +- internal/builders/ai_workflow_builder_test.go | 35 ++ .../handlers/dashboard/ai_workflow_handler.go | 17 +- .../pkg/dto/response/ai_workflow_response.go | 4 + internal/services/ai_workflow_service.go | 70 +++ internal/services/ai_workflow_service_test.go | 37 +- web/app/dashboard/ai-workflow-runs/page.tsx | 418 ++++++++++++++++++ web/lib/api/admin.ts | 4 + web/lib/navigation.tsx | 7 + web/messages/en-US.json | 40 ++ web/messages/zh-CN.json | 40 ++ 12 files changed, 694 insertions(+), 10 deletions(-) create mode 100644 web/app/dashboard/ai-workflow-runs/page.tsx diff --git a/docs b/docs index d75c928..57c8077 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit d75c9282f9270b116d23bd1eab5e5fa1a4a17247 +Subproject commit 57c8077187e1eff6303316e601ae8445e50bee62 diff --git a/internal/builders/ai_workflow_builder.go b/internal/builders/ai_workflow_builder.go index 36c01ef..ac8035d 100644 --- a/internal/builders/ai_workflow_builder.go +++ b/internal/builders/ai_workflow_builder.go @@ -89,10 +89,14 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor } func BuildAIWorkflowRun(item *models.AIWorkflowRun) response.AIWorkflowRunResponse { + return BuildAIWorkflowRunWithContext(item, nil, nil, nil) +} + +func BuildAIWorkflowRunWithContext(item *models.AIWorkflowRun, workflow *models.AIWorkflow, version *models.AIWorkflowVersion, agent *models.AIAgent) response.AIWorkflowRunResponse { if item == nil { return response.AIWorkflowRunResponse{} } - return response.AIWorkflowRunResponse{ + ret := response.AIWorkflowRunResponse{ ID: item.ID, WorkflowID: item.WorkflowID, WorkflowVersionID: item.WorkflowVersionID, @@ -103,12 +107,23 @@ func BuildAIWorkflowRun(item *models.AIWorkflowRun) response.AIWorkflowRunRespon StatusName: workflowRunStatusName(item.Status), StartedAt: formatWorkflowTime(item.StartedAt), EndedAt: formatWorkflowTimePtr(item.EndedAt), + DurationMS: workflowRunDurationMS(item.StartedAt, item.EndedAt), InterruptType: item.InterruptType, InterruptNodeID: item.InterruptNodeID, ErrorMessage: item.ErrorMessage, CreatedAt: formatWorkflowTime(item.CreatedAt), UpdatedAt: formatWorkflowTime(item.UpdatedAt), } + if workflow != nil { + ret.WorkflowName = workflow.Name + } + if version != nil { + ret.WorkflowVersion = version.Version + } + if agent != nil { + ret.AIAgentName = agent.Name + } + return ret } func BuildAIWorkflowRunDetail(item *models.AIWorkflowRun, nodes []models.AIWorkflowNodeRun) response.AIWorkflowRunResponse { @@ -117,6 +132,12 @@ func BuildAIWorkflowRunDetail(item *models.AIWorkflowRun, nodes []models.AIWorkf return ret } +func BuildAIWorkflowRunDetailWithContext(item *models.AIWorkflowRun, nodes []models.AIWorkflowNodeRun, workflow *models.AIWorkflow, version *models.AIWorkflowVersion, agent *models.AIAgent) response.AIWorkflowRunResponse { + ret := BuildAIWorkflowRunWithContext(item, workflow, version, agent) + ret.Nodes = BuildAIWorkflowNodeRunList(nodes) + return ret +} + func BuildAIWorkflowRunList(list []models.AIWorkflowRun) []response.AIWorkflowRunResponse { ret := make([]response.AIWorkflowRunResponse, 0, len(list)) for i := range list { @@ -188,3 +209,10 @@ func formatWorkflowTimePtr(value *time.Time) string { } return formatWorkflowTime(*value) } + +func workflowRunDurationMS(startedAt time.Time, endedAt *time.Time) int64 { + if startedAt.IsZero() || endedAt == nil || endedAt.IsZero() { + return 0 + } + return endedAt.Sub(startedAt).Milliseconds() +} diff --git a/internal/builders/ai_workflow_builder_test.go b/internal/builders/ai_workflow_builder_test.go index c643591..447aa26 100644 --- a/internal/builders/ai_workflow_builder_test.go +++ b/internal/builders/ai_workflow_builder_test.go @@ -2,8 +2,10 @@ package builders import ( "testing" + "time" workflowregistry "agent-desk/internal/ai/workflow/registry" + "agent-desk/internal/models" ) func TestBuildAIWorkflowNodeSpecsIncludesVariableContracts(t *testing.T) { @@ -30,6 +32,39 @@ func TestBuildAIWorkflowNodeSpecsIncludesVariableContracts(t *testing.T) { } } +func TestBuildAIWorkflowRunIncludesAuditDisplayFields(t *testing.T) { + startedAt := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC) + endedAt := startedAt.Add(1500 * time.Millisecond) + + resp := BuildAIWorkflowRunWithContext( + &models.AIWorkflowRun{ + ID: 9, + WorkflowID: 11, + WorkflowVersionID: 22, + AIAgentID: 33, + StartedAt: startedAt, + EndedAt: &endedAt, + Status: 1, + }, + &models.AIWorkflow{Name: "售后会话流程"}, + &models.AIWorkflowVersion{Version: 3}, + &models.AIAgent{Name: "售后 Agent"}, + ) + + if resp.WorkflowName != "售后会话流程" { + t.Fatalf("expected workflow name, got %q", resp.WorkflowName) + } + if resp.WorkflowVersion != 3 { + t.Fatalf("expected workflow version 3, got %d", resp.WorkflowVersion) + } + if resp.AIAgentName != "售后 Agent" { + t.Fatalf("expected agent name, got %q", resp.AIAgentName) + } + if resp.DurationMS != 1500 { + t.Fatalf("expected duration 1500ms, got %d", resp.DurationMS) + } +} + func hasResponseVariable(items []workflowregistry.VariableSpec, name string) bool { for _, item := range items { if item.Name == name { diff --git a/internal/handlers/dashboard/ai_workflow_handler.go b/internal/handlers/dashboard/ai_workflow_handler.go index f79ac56..3fff14b 100644 --- a/internal/handlers/dashboard/ai_workflow_handler.go +++ b/internal/handlers/dashboard/ai_workflow_handler.go @@ -2,6 +2,7 @@ package dashboard import ( "agent-desk/internal/builders" + "agent-desk/internal/models" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" @@ -242,7 +243,13 @@ func AIWorkflowAnyRunList(ctx *gin.Context) { params.QueryFilter{ParamName: "status"}, ).Desc("id") list, paging := services.AIWorkflowService.FindRunPageByCnd(cnd) - httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildAIWorkflowRunList(list), Page: paging}) + auditItems := services.AIWorkflowService.BuildRunAuditItems(list) + results := make([]response.AIWorkflowRunResponse, 0, len(auditItems)) + for i := range auditItems { + item := auditItems[i] + results = append(results, builders.BuildAIWorkflowRunWithContext(&item.Run, item.Workflow, item.Version, item.Agent)) + } + httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) } func AIWorkflowGetRunBy(ctx *gin.Context) { @@ -259,5 +266,11 @@ func AIWorkflowGetRunBy(ctx *gin.Context) { httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0002")) return } - httpx.WriteJSON(ctx, builders.BuildAIWorkflowRunDetail(item, nodes)) + auditItems := services.AIWorkflowService.BuildRunAuditItems([]models.AIWorkflowRun{*item}) + if len(auditItems) == 0 { + httpx.WriteJSON(ctx, builders.BuildAIWorkflowRunDetail(item, nodes)) + return + } + auditItem := auditItems[0] + httpx.WriteJSON(ctx, builders.BuildAIWorkflowRunDetailWithContext(&auditItem.Run, nodes, auditItem.Workflow, auditItem.Version, auditItem.Agent)) } diff --git a/internal/pkg/dto/response/ai_workflow_response.go b/internal/pkg/dto/response/ai_workflow_response.go index a162439..1826e02 100644 --- a/internal/pkg/dto/response/ai_workflow_response.go +++ b/internal/pkg/dto/response/ai_workflow_response.go @@ -58,13 +58,17 @@ type AIWorkflowRunResponse struct { ID int64 `json:"id"` WorkflowID int64 `json:"workflowId"` WorkflowVersionID int64 `json:"workflowVersionId"` + WorkflowVersion int `json:"workflowVersion"` + WorkflowName string `json:"workflowName"` ConversationID int64 `json:"conversationId"` AIAgentID int64 `json:"aiAgentId"` + AIAgentName string `json:"aiAgentName"` MessageID int64 `json:"messageId"` Status int `json:"status"` StatusName string `json:"statusName"` StartedAt string `json:"startedAt"` EndedAt string `json:"endedAt"` + DurationMS int64 `json:"durationMs"` InterruptType string `json:"interruptType"` InterruptNodeID string `json:"interruptNodeId"` ErrorMessage string `json:"errorMessage"` diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index db4cdb5..0bfd262 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -35,6 +35,13 @@ type aiWorkflowService struct { registry *workflowregistry.Registry } +type AIWorkflowRunAuditItem struct { + Run models.AIWorkflowRun + Workflow *models.AIWorkflow + Version *models.AIWorkflowVersion + Agent *models.AIAgent +} + func (s *aiWorkflowService) Get(id int64) *models.AIWorkflow { if id <= 0 { return nil @@ -61,6 +68,57 @@ func (s *aiWorkflowService) FindRunPageByCnd(cnd *sqls.Cnd) (list []models.AIWor return repositories.AIWorkflowRunRepository.FindPageByCnd(sqls.DB(), cnd) } +func (s *aiWorkflowService) BuildRunAuditItems(list []models.AIWorkflowRun) []AIWorkflowRunAuditItem { + ret := make([]AIWorkflowRunAuditItem, 0, len(list)) + if len(list) == 0 { + return ret + } + workflowIDs := make([]int64, 0, len(list)) + versionIDs := make([]int64, 0, len(list)) + agentIDs := make([]int64, 0, len(list)) + for _, item := range list { + workflowIDs = appendNonZeroInt64(workflowIDs, item.WorkflowID) + versionIDs = appendNonZeroInt64(versionIDs, item.WorkflowVersionID) + agentIDs = appendNonZeroInt64(agentIDs, item.AIAgentID) + } + var workflows []models.AIWorkflow + if len(workflowIDs) > 0 { + workflows = repositories.AIWorkflowRepository.Find(sqls.DB(), sqls.NewCnd().In("id", workflowIDs)) + } + var versions []models.AIWorkflowVersion + if len(versionIDs) > 0 { + versions = repositories.AIWorkflowVersionRepository.Find(sqls.DB(), sqls.NewCnd().In("id", versionIDs)) + } + var agents []models.AIAgent + if len(agentIDs) > 0 { + agents = repositories.AIAgentRepository.Find(sqls.DB(), sqls.NewCnd().In("id", agentIDs)) + } + workflowByID := make(map[int64]*models.AIWorkflow, len(workflows)) + for i := range workflows { + item := workflows[i] + workflowByID[item.ID] = &item + } + versionByID := make(map[int64]*models.AIWorkflowVersion, len(versions)) + for i := range versions { + item := versions[i] + versionByID[item.ID] = &item + } + agentByID := make(map[int64]*models.AIAgent, len(agents)) + for i := range agents { + item := agents[i] + agentByID[item.ID] = &item + } + for _, run := range list { + ret = append(ret, AIWorkflowRunAuditItem{ + Run: run, + Workflow: workflowByID[run.WorkflowID], + Version: versionByID[run.WorkflowVersionID], + Agent: agentByID[run.AIAgentID], + }) + } + return ret +} + func (s *aiWorkflowService) GetRunDetail(id int64) (*models.AIWorkflowRun, []models.AIWorkflowNodeRun) { if id <= 0 { return nil, nil @@ -73,6 +131,18 @@ func (s *aiWorkflowService) GetRunDetail(id int64) (*models.AIWorkflowRun, []mod return run, nodes } +func appendNonZeroInt64(list []int64, value int64) []int64 { + if value <= 0 { + return list + } + for _, item := range list { + if item == value { + return list + } + } + return append(list, value) +} + func (s *aiWorkflowService) GetByAgentID(agentID int64) *models.AIWorkflow { if agentID <= 0 { return nil diff --git a/internal/services/ai_workflow_service_test.go b/internal/services/ai_workflow_service_test.go index aa69256..9dad78f 100644 --- a/internal/services/ai_workflow_service_test.go +++ b/internal/services/ai_workflow_service_test.go @@ -155,11 +155,23 @@ func TestAIWorkflowServicePublishRejectsInvalidDSL(t *testing.T) { func TestAIWorkflowServiceRunListAndDetail(t *testing.T) { setupAIWorkflowTestDB(t) now := time.Now() + agent := models.AIAgent{Name: "售后 Agent", Status: enums.StatusOk} + if err := sqls.DB().Create(&agent).Error; err != nil { + t.Fatalf("create agent: %v", err) + } + workflow := models.AIWorkflow{Name: "售后流程", AgentID: agent.ID, Status: enums.StatusOk} + if err := sqls.DB().Create(&workflow).Error; err != nil { + t.Fatalf("create workflow: %v", err) + } + version := models.AIWorkflowVersion{WorkflowID: workflow.ID, Version: 7, Status: enums.StatusOk} + if err := sqls.DB().Create(&version).Error; err != nil { + t.Fatalf("create workflow version: %v", err) + } run := models.AIWorkflowRun{ - WorkflowID: 101, - WorkflowVersionID: 202, + WorkflowID: workflow.ID, + WorkflowVersionID: version.ID, ConversationID: 303, - AIAgentID: 12, + AIAgentID: agent.ID, MessageID: 404, Status: 1, StartedAt: now, @@ -169,10 +181,10 @@ func TestAIWorkflowServiceRunListAndDetail(t *testing.T) { t.Fatalf("create workflow run: %v", err) } otherRun := models.AIWorkflowRun{ - WorkflowID: 101, - WorkflowVersionID: 202, + WorkflowID: workflow.ID, + WorkflowVersionID: version.ID, ConversationID: 999, - AIAgentID: 12, + AIAgentID: agent.ID, MessageID: 505, Status: 1, StartedAt: now, @@ -210,6 +222,19 @@ func TestAIWorkflowServiceRunListAndDetail(t *testing.T) { if paging.Total != 1 || len(list) != 1 || list[0].ID != run.ID { t.Fatalf("unexpected run list: total=%d list=%#v", paging.Total, list) } + auditItems := AIWorkflowService.BuildRunAuditItems(list) + if len(auditItems) != 1 { + t.Fatalf("unexpected audit item count: %d", len(auditItems)) + } + if auditItems[0].Workflow == nil || auditItems[0].Workflow.Name != workflow.Name { + t.Fatalf("expected workflow context, got %#v", auditItems[0].Workflow) + } + if auditItems[0].Version == nil || auditItems[0].Version.Version != version.Version { + t.Fatalf("expected version context, got %#v", auditItems[0].Version) + } + if auditItems[0].Agent == nil || auditItems[0].Agent.Name != agent.Name { + t.Fatalf("expected agent context, got %#v", auditItems[0].Agent) + } detail, nodeRuns := AIWorkflowService.GetRunDetail(run.ID) if detail == nil || detail.ID != run.ID { diff --git a/web/app/dashboard/ai-workflow-runs/page.tsx b/web/app/dashboard/ai-workflow-runs/page.tsx new file mode 100644 index 0000000..d95ba56 --- /dev/null +++ b/web/app/dashboard/ai-workflow-runs/page.tsx @@ -0,0 +1,418 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + AlertTriangleIcon, + Clock3Icon, + MessageSquareTextIcon, + WorkflowIcon, +} from "lucide-react" +import { toast } from "sonner" + +import { DashboardListPage } from "@/components/dashboard/list" +import { JsonTreeViewer } from "@/components/json-tree-viewer" +import { ProjectDialog } from "@/components/project-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + fetchAIAgentsAll, + fetchAIWorkflowRun, + fetchAIWorkflowRuns, + type AIAgent, + type AIWorkflowNodeRun, + type AIWorkflowRun, +} from "@/lib/api/admin" +import { formatDateTime } from "@/lib/utils" +import { useI18n } from "@/i18n/provider" + +type TFunction = (key: string, values?: Record) => string + +function getStatusOptions(t: TFunction) { + return [ + { value: "all", label: t("workflowRun.allStatus") }, + { value: "1", label: t("workflowRun.completed") }, + { value: "2", label: t("workflowRun.interrupted") }, + { value: "3", label: t("workflowRun.failed") }, + ] +} + +function statusBadgeVariant(statusName?: string) { + switch ((statusName || "").trim()) { + case "failed": + return "destructive" as const + case "interrupted": + return "outline" as const + case "completed": + return "default" as const + default: + return "secondary" as const + } +} + +function formatRunStatus(item: Pick) { + return item.statusName || (item.status ? String(item.status) : "-") +} + +export default function DashboardAIWorkflowRunsPage() { + const t = useI18n() + const [agents, setAgents] = useState([]) + const [detailOpen, setDetailOpen] = useState(false) + const [detailLoading, setDetailLoading] = useState(false) + const [activeRun, setActiveRun] = useState(null) + const statusOptions = useMemo(() => getStatusOptions(t), [t]) + const agentOptions = useMemo( + () => [ + { value: "all", label: t("workflowRun.allAgents") }, + ...agents.map((agent) => ({ + value: String(agent.id), + label: agent.name, + })), + ], + [agents, t] + ) + + useEffect(() => { + let cancelled = false + + async function loadAgents() { + try { + const data = await fetchAIAgentsAll() + if (!cancelled) { + setAgents(data) + } + } catch (error) { + if (!cancelled) { + toast.error(error instanceof Error ? error.message : t("workflowRun.loadAgentsFailed")) + } + } + } + + void loadAgents() + return () => { + cancelled = true + } + }, [t]) + + async function openDetail(runId: number) { + setDetailOpen(true) + setDetailLoading(true) + try { + const data = await fetchAIWorkflowRun(runId) + setActiveRun(data) + } catch (error) { + toast.error(error instanceof Error ? error.message : t("workflowRun.loadDetailFailed")) + setDetailOpen(false) + } finally { + setDetailLoading(false) + } + } + + return ( + <> + + filters={[ + { + name: "conversationId", + label: t("workflowRun.conversationId"), + placeholder: t("workflowRun.conversationId"), + defaultValue: "", + valueType: "number", + className: "w-full sm:w-44", + }, + { + name: "messageId", + label: t("workflowRun.messageId"), + placeholder: t("workflowRun.messageId"), + defaultValue: "", + valueType: "number", + className: "w-full sm:w-40", + }, + { + name: "workflowVersionId", + label: t("workflowRun.workflowVersionId"), + placeholder: t("workflowRun.workflowVersionId"), + defaultValue: "", + valueType: "number", + className: "w-full sm:w-48", + }, + { + name: "aiAgentId", + label: t("workflowRun.agent"), + type: "select", + defaultValue: "all", + allValue: "all", + valueType: "number", + options: agentOptions, + placeholder: t("workflowRun.agent"), + searchPlaceholder: t("workflowRun.searchAgent"), + emptyText: t("workflowRun.emptyAgent"), + className: "w-full sm:w-56", + }, + { + name: "status", + label: t("workflowRun.status"), + type: "select", + defaultValue: "all", + allValue: "all", + valueType: "number", + options: statusOptions, + placeholder: t("workflowRun.status"), + className: "w-full sm:w-44", + }, + ]} + fetchList={fetchAIWorkflowRuns} + getItemId={(item) => item.id} + getRowClassName={() => "cursor-pointer"} + onRowClick={(item) => void openDetail(item.id)} + columns={[ + { + key: "time", + label: t("workflowRun.startedAt"), + className: "w-42 text-xs text-muted-foreground", + render: (item) => formatDateTime(item.startedAt || item.createdAt), + }, + { + key: "workflow", + label: t("workflowRun.workflow"), + render: (item) => ( +
+
+ {item.workflowName || `Workflow #${item.workflowId}`} +
+
+ v{item.workflowVersion || "-"} + #{item.workflowVersionId || "-"} +
+
+ ), + }, + { + key: "agent", + label: t("workflowRun.agent"), + className: "w-48", + render: (item) => item.aiAgentName || `#${item.aiAgentId}`, + }, + { + key: "message", + label: t("workflowRun.message"), + className: "w-48", + render: (item) => ( +
+
{t("workflowRun.conversationShort", { id: item.conversationId || "-" })}
+
+ {t("workflowRun.messageShort", { id: item.messageId || "-" })} +
+
+ ), + }, + { + key: "status", + label: t("workflowRun.status"), + className: "w-32", + render: (item) => ( + + {formatRunStatus(item)} + + ), + }, + { + key: "duration", + label: t("workflowRun.duration"), + className: "w-28 text-right", + render: (item) => `${item.durationMs || 0} ms`, + }, + { + key: "error", + label: t("workflowRun.error"), + className: "min-w-48", + render: (item) => + item.errorMessage ? ( +
+ + {item.errorMessage} +
+ ) : ( + - + ), + }, + ]} + labels={{ + refresh: t("workflowRun.refresh"), + query: t("workflowRun.query"), + loading: t("workflowRun.loading"), + empty: t("workflowRun.empty"), + loadFailed: t("workflowRun.loadFailed"), + }} + /> + { + setDetailOpen(open) + if (!open) { + setActiveRun(null) + } + }} + t={t} + /> + + ) +} + +function WorkflowRunDetailDialog({ + open, + loading, + run, + onOpenChange, + t, +}: { + open: boolean + loading: boolean + run: AIWorkflowRun | null + onOpenChange: (open: boolean) => void + t: TFunction +}) { + return ( + + + {t("workflowRun.detailTitle")} + + } + description={run ? `Run #${run.id}` : t("workflowRun.detailDescription")} + size="xl" + allowFullscreen + defaultFullscreen + bodyClassName="min-h-0" + footer={ + + } + > + {loading ? ( +
{t("workflowRun.loadingDetail")}
+ ) : run ? ( +
+
+ + + + + + + + + + +
+ {run.errorMessage ? ( +
+ {run.errorMessage} +
+ ) : null} +
+ {(run.nodes ?? []).map((node, index) => ( + + ))} + {!run.nodes || run.nodes.length === 0 ? ( +

{t("workflowRun.emptyNodes")}

+ ) : null} +
+
+ ) : ( +
{t("workflowRun.notFound")}
+ )} +
+ ) +} + +function WorkflowNodeRunBlock({ node, t }: { node: AIWorkflowNodeRun; t: TFunction }) { + const inputPreview = node.inputPreview || "" + const outputPreview = node.outputPreview || "" + const inputValue = safeParseJSON(inputPreview) + const outputValue = safeParseJSON(outputPreview) + + return ( +
+
+
+
+ + {node.nodeId || `#${node.id}`} + {node.statusName || node.status || "-"} +
+
+ {node.nodeType || "unknown"} + + + {node.durationMs} ms + +
+
+ {node.errorMessage ? ( +
+ + {node.errorMessage} +
+ ) : null} +
+
+ + +
+
+ ) +} + +function PreviewBlock({ + title, + raw, + value, +}: { + title: string + raw: string + value: unknown +}) { + return ( +
+
{title}
+ {value !== null ? ( + + ) : raw.trim() ? ( +
+          {raw}
+        
+ ) : ( +
+ - +
+ )} +
+ ) +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value || "-"} +
+ ) +} + +function safeParseJSON(raw: string): unknown | null { + const trimmed = raw.trim() + if (!trimmed) { + return null + } + try { + return JSON.parse(trimmed) + } catch { + return null + } +} diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index ebb2ab1..61c8883 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -530,13 +530,17 @@ export type AIWorkflowRun = { id: number workflowId: number workflowVersionId: number + workflowVersion: number + workflowName: string conversationId: number aiAgentId: number + aiAgentName: string messageId: number status: number statusName: string startedAt: string endedAt: string + durationMs: number interruptType: string interruptNodeId: string errorMessage: string diff --git a/web/lib/navigation.tsx b/web/lib/navigation.tsx index 324ab98..d60d03e 100644 --- a/web/lib/navigation.tsx +++ b/web/lib/navigation.tsx @@ -13,6 +13,7 @@ import { TagsIcon, UserCogIcon, UsersIcon, + WorkflowIcon, } from "lucide-react"; import type { ReactNode } from "react"; @@ -204,6 +205,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [ icon: , requiredPermission: "mcp.view", }, + { + titleKey: "nav.workflowRuns", + url: "/dashboard/ai-workflow-runs", + icon: , + requiredPermission: "aiAgent.view", + }, ], }, { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 481e340..f2aa589 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -2254,6 +2254,45 @@ "creating": "Creating...", "create": "Create Role" }, + "workflowRun": { + "allStatus": "All statuses", + "completed": "Completed", + "interrupted": "Interrupted", + "failed": "Failed", + "allAgents": "All agents", + "loadAgentsFailed": "Could not load AI agents.", + "loadDetailFailed": "Could not load workflow run details.", + "conversationId": "Conversation ID", + "messageId": "Message ID", + "workflowVersionId": "Workflow Version ID", + "agent": "Agent", + "searchAgent": "Search agents", + "emptyAgent": "No agents found", + "status": "Status", + "startedAt": "Started At", + "endedAt": "Ended At", + "workflow": "Workflow", + "version": "Version", + "message": "Message", + "conversationShort": "Conversation #{id}", + "messageShort": "Message #{id}", + "duration": "Duration", + "error": "Error", + "refresh": "Refresh", + "query": "Search", + "loading": "Loading workflow runs", + "empty": "No workflow runs yet", + "loadFailed": "Could not load workflow runs.", + "detailTitle": "Workflow Run Detail", + "detailDescription": "Inspect workflow execution path", + "close": "Close", + "loadingDetail": "Loading workflow run detail", + "interruptNode": "Interrupt Node", + "emptyNodes": "No node records", + "notFound": "Workflow run not found", + "input": "Input", + "output": "Output" + }, "nav": { "overview": "Overview", "receptionCenter": "Support Desk", @@ -2273,6 +2312,7 @@ "aiConfigs": "Model Settings", "aiAgents": "Agents", "aiWorkflows": "AI Workflows", + "workflowRuns": "Workflow Audit", "skillDefinition": "Skills", "mcp": "MCP tools", "system": "System", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 567ea22..1c31c9f 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -2254,6 +2254,45 @@ "creating": "创建中...", "create": "创建角色" }, + "workflowRun": { + "allStatus": "全部状态", + "completed": "已完成", + "interrupted": "已中断", + "failed": "失败", + "allAgents": "全部 Agent", + "loadAgentsFailed": "加载 AI Agent 列表失败", + "loadDetailFailed": "加载流程执行详情失败", + "conversationId": "会话ID", + "messageId": "消息ID", + "workflowVersionId": "流程版本ID", + "agent": "Agent", + "searchAgent": "搜索 Agent", + "emptyAgent": "未找到 Agent", + "status": "状态", + "startedAt": "开始时间", + "endedAt": "结束时间", + "workflow": "流程", + "version": "版本", + "message": "消息", + "conversationShort": "会话 #{id}", + "messageShort": "消息 #{id}", + "duration": "耗时", + "error": "错误", + "refresh": "刷新", + "query": "查询", + "loading": "加载流程执行记录中", + "empty": "暂无流程执行记录", + "loadFailed": "加载流程执行记录失败", + "detailTitle": "流程执行详情", + "detailDescription": "查看流程执行链路", + "close": "关闭", + "loadingDetail": "加载流程执行详情中", + "interruptNode": "中断节点", + "emptyNodes": "暂无节点记录", + "notFound": "未找到流程执行记录", + "input": "输入", + "output": "输出" + }, "nav": { "overview": "总览", "receptionCenter": "接待中心", @@ -2273,6 +2312,7 @@ "aiConfigs": "AI模型", "aiAgents": "Agent", "aiWorkflows": "AI流程", + "workflowRuns": "流程审计", "skillDefinition": "Skills", "mcp": "MCP tools", "system": "系统管理",