diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index ff6d93b..27f2c20 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -231,6 +231,8 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) { func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList) group.POST("/validate", dashboard.AIWorkflowPostValidate) + group.Any("/run/list", dashboard.AIWorkflowAnyRunList) + group.GET("/run/:id", dashboard.AIWorkflowGetRunBy) group.Any("/version/list", dashboard.AIWorkflowAnyVersionList) group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy) } diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 241065a..44e954e 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -41,6 +41,8 @@ func TestNewServerRegistersGinRoutes(t *testing.T) { http.MethodGet + " /api/dashboard/user/:id", http.MethodPost + " /api/dashboard/user/create", http.MethodPost + " /api/dashboard/conversation/send_message", + http.MethodGet + " /api/dashboard/ai-workflow/run/list", + http.MethodGet + " /api/dashboard/ai-workflow/run/:id", http.MethodGet + " /api/ws/dashboard", http.MethodGet + " /api/ws/open", } diff --git a/internal/builders/ai_workflow_builder.go b/internal/builders/ai_workflow_builder.go index 75b45d7..36c01ef 100644 --- a/internal/builders/ai_workflow_builder.go +++ b/internal/builders/ai_workflow_builder.go @@ -2,6 +2,7 @@ package builders import ( "encoding/json" + "time" "agent-desk/internal/ai/workflow/dsl" workflowregistry "agent-desk/internal/ai/workflow/registry" @@ -87,6 +88,71 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor return ret } +func BuildAIWorkflowRun(item *models.AIWorkflowRun) response.AIWorkflowRunResponse { + if item == nil { + return response.AIWorkflowRunResponse{} + } + return response.AIWorkflowRunResponse{ + ID: item.ID, + WorkflowID: item.WorkflowID, + WorkflowVersionID: item.WorkflowVersionID, + ConversationID: item.ConversationID, + AIAgentID: item.AIAgentID, + MessageID: item.MessageID, + Status: item.Status, + StatusName: workflowRunStatusName(item.Status), + StartedAt: formatWorkflowTime(item.StartedAt), + EndedAt: formatWorkflowTimePtr(item.EndedAt), + InterruptType: item.InterruptType, + InterruptNodeID: item.InterruptNodeID, + ErrorMessage: item.ErrorMessage, + CreatedAt: formatWorkflowTime(item.CreatedAt), + UpdatedAt: formatWorkflowTime(item.UpdatedAt), + } +} + +func BuildAIWorkflowRunDetail(item *models.AIWorkflowRun, nodes []models.AIWorkflowNodeRun) response.AIWorkflowRunResponse { + ret := BuildAIWorkflowRun(item) + ret.Nodes = BuildAIWorkflowNodeRunList(nodes) + return ret +} + +func BuildAIWorkflowRunList(list []models.AIWorkflowRun) []response.AIWorkflowRunResponse { + ret := make([]response.AIWorkflowRunResponse, 0, len(list)) + for i := range list { + ret = append(ret, BuildAIWorkflowRun(&list[i])) + } + return ret +} + +func BuildAIWorkflowNodeRun(item *models.AIWorkflowNodeRun) response.AIWorkflowNodeRunResponse { + if item == nil { + return response.AIWorkflowNodeRunResponse{} + } + return response.AIWorkflowNodeRunResponse{ + ID: item.ID, + WorkflowRunID: item.WorkflowRunID, + NodeID: item.NodeID, + NodeType: item.NodeType, + Status: item.Status, + StatusName: workflowRunStatusName(item.Status), + InputPreview: item.InputPreview, + OutputPreview: item.OutputPreview, + ErrorMessage: item.ErrorMessage, + StartedAt: formatWorkflowTime(item.StartedAt), + EndedAt: formatWorkflowTimePtr(item.EndedAt), + DurationMS: item.DurationMS, + } +} + +func BuildAIWorkflowNodeRunList(list []models.AIWorkflowNodeRun) []response.AIWorkflowNodeRunResponse { + ret := make([]response.AIWorkflowNodeRunResponse, 0, len(list)) + for i := range list { + ret = append(ret, BuildAIWorkflowNodeRun(&list[i])) + } + return ret +} + func parseWorkflowDefinition(raw string) dsl.Definition { var ret dsl.Definition if raw == "" { @@ -95,3 +161,30 @@ func parseWorkflowDefinition(raw string) dsl.Definition { _ = json.Unmarshal([]byte(raw), &ret) return ret } + +func workflowRunStatusName(status int) string { + switch status { + case 1: + return "completed" + case 2: + return "interrupted" + case 3: + return "failed" + default: + return "unknown" + } +} + +func formatWorkflowTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format("2006-01-02 15:04:05") +} + +func formatWorkflowTimePtr(value *time.Time) string { + if value == nil { + return "" + } + return formatWorkflowTime(*value) +} diff --git a/internal/handlers/dashboard/ai_workflow_handler.go b/internal/handlers/dashboard/ai_workflow_handler.go index 3c91a98..f79ac56 100644 --- a/internal/handlers/dashboard/ai_workflow_handler.go +++ b/internal/handlers/dashboard/ai_workflow_handler.go @@ -227,3 +227,37 @@ func AIWorkflowGetVersionBy(ctx *gin.Context) { } httpx.WriteJSON(ctx, builders.BuildAIWorkflowVersion(item)) } + +func AIWorkflowAnyRunList(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + cnd := params.NewPagedSqlCnd(ctx, + params.QueryFilter{ParamName: "workflowId"}, + params.QueryFilter{ParamName: "workflowVersionId"}, + params.QueryFilter{ParamName: "conversationId"}, + params.QueryFilter{ParamName: "aiAgentId"}, + params.QueryFilter{ParamName: "messageId"}, + params.QueryFilter{ParamName: "status"}, + ).Desc("id") + list, paging := services.AIWorkflowService.FindRunPageByCnd(cnd) + httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildAIWorkflowRunList(list), Page: paging}) +} + +func AIWorkflowGetRunBy(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 + } + item, nodes := services.AIWorkflowService.GetRunDetail(id) + if item == nil { + httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0002")) + return + } + httpx.WriteJSON(ctx, builders.BuildAIWorkflowRunDetail(item, nodes)) +} diff --git a/internal/pkg/dto/response/ai_workflow_response.go b/internal/pkg/dto/response/ai_workflow_response.go index 07302c3..a162439 100644 --- a/internal/pkg/dto/response/ai_workflow_response.go +++ b/internal/pkg/dto/response/ai_workflow_response.go @@ -53,3 +53,37 @@ type AIWorkflowNodeSpecResponse struct { OutputSchema []workflowregistry.VariableSpec `json:"outputSchema,omitempty"` DefaultInputs map[string]dsl.VariableSelector `json:"defaultInputs,omitempty"` } + +type AIWorkflowRunResponse struct { + ID int64 `json:"id"` + WorkflowID int64 `json:"workflowId"` + WorkflowVersionID int64 `json:"workflowVersionId"` + ConversationID int64 `json:"conversationId"` + AIAgentID int64 `json:"aiAgentId"` + MessageID int64 `json:"messageId"` + Status int `json:"status"` + StatusName string `json:"statusName"` + StartedAt string `json:"startedAt"` + EndedAt string `json:"endedAt"` + InterruptType string `json:"interruptType"` + InterruptNodeID string `json:"interruptNodeId"` + ErrorMessage string `json:"errorMessage"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Nodes []AIWorkflowNodeRunResponse `json:"nodes,omitempty"` +} + +type AIWorkflowNodeRunResponse struct { + ID int64 `json:"id"` + WorkflowRunID int64 `json:"workflowRunId"` + NodeID string `json:"nodeId"` + NodeType string `json:"nodeType"` + Status int `json:"status"` + StatusName string `json:"statusName"` + InputPreview string `json:"inputPreview"` + OutputPreview string `json:"outputPreview"` + ErrorMessage string `json:"errorMessage"` + StartedAt string `json:"startedAt"` + EndedAt string `json:"endedAt"` + DurationMS int `json:"durationMs"` +} diff --git a/internal/repositories/ai_workflow_run_repository.go b/internal/repositories/ai_workflow_run_repository.go index 1d8dc7f..f4624f7 100644 --- a/internal/repositories/ai_workflow_run_repository.go +++ b/internal/repositories/ai_workflow_run_repository.go @@ -2,6 +2,7 @@ package repositories import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/httpx/params" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" @@ -28,6 +29,21 @@ func (r *aiWorkflowRunRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []model return } +func (r *aiWorkflowRunRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.AIWorkflowRun, paging *sqls.Paging) { + return r.FindPageByCnd(db, ¶ms.Cnd) +} + +func (r *aiWorkflowRunRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.AIWorkflowRun, paging *sqls.Paging) { + cnd.Find(db, &list) + count := cnd.Count(db, &models.AIWorkflowRun{}) + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + func (r *aiWorkflowRunRepository) Create(db *gorm.DB, t *models.AIWorkflowRun) error { return db.Create(t).Error } diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index 3771b7f..db4cdb5 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -57,6 +57,22 @@ func (s *aiWorkflowService) FindVersionPageByParams(params *params.QueryParams) return repositories.AIWorkflowVersionRepository.FindPageByParams(sqls.DB(), params) } +func (s *aiWorkflowService) FindRunPageByCnd(cnd *sqls.Cnd) (list []models.AIWorkflowRun, paging *sqls.Paging) { + return repositories.AIWorkflowRunRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *aiWorkflowService) GetRunDetail(id int64) (*models.AIWorkflowRun, []models.AIWorkflowNodeRun) { + if id <= 0 { + return nil, nil + } + run := repositories.AIWorkflowRunRepository.Get(sqls.DB(), id) + if run == nil { + return nil, nil + } + nodes := repositories.AIWorkflowNodeRunRepository.Find(sqls.DB(), sqls.NewCnd().Eq("workflow_run_id", id).Asc("id")) + return run, nodes +} + 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 fad4a94..aa69256 100644 --- a/internal/services/ai_workflow_service_test.go +++ b/internal/services/ai_workflow_service_test.go @@ -3,6 +3,7 @@ package services import ( "encoding/json" "testing" + "time" "agent-desk/internal/ai/workflow/dsl" "agent-desk/internal/models" @@ -151,6 +152,77 @@ func TestAIWorkflowServicePublishRejectsInvalidDSL(t *testing.T) { } } +func TestAIWorkflowServiceRunListAndDetail(t *testing.T) { + setupAIWorkflowTestDB(t) + now := time.Now() + run := models.AIWorkflowRun{ + WorkflowID: 101, + WorkflowVersionID: 202, + ConversationID: 303, + AIAgentID: 12, + MessageID: 404, + Status: 1, + StartedAt: now, + EndedAt: &now, + } + if err := sqls.DB().Create(&run).Error; err != nil { + t.Fatalf("create workflow run: %v", err) + } + otherRun := models.AIWorkflowRun{ + WorkflowID: 101, + WorkflowVersionID: 202, + ConversationID: 999, + AIAgentID: 12, + MessageID: 505, + Status: 1, + StartedAt: now, + } + if err := sqls.DB().Create(&otherRun).Error; err != nil { + t.Fatalf("create other workflow run: %v", err) + } + nodes := []models.AIWorkflowNodeRun{ + { + WorkflowRunID: run.ID, + NodeID: "start_1", + NodeType: "start", + Status: 1, + InputPreview: `{"inputs":{}}`, + OutputPreview: `{"messageId":404}`, + StartedAt: now, + EndedAt: &now, + }, + { + WorkflowRunID: run.ID, + NodeID: "reply_1", + NodeType: "llm_reply", + Status: 1, + OutputPreview: `{"replyText":"hello"}`, + StartedAt: now, + EndedAt: &now, + DurationMS: 8, + }, + } + if err := sqls.DB().Create(&nodes).Error; err != nil { + t.Fatalf("create workflow node runs: %v", err) + } + + list, paging := AIWorkflowService.FindRunPageByCnd(sqls.NewCnd().Eq("conversation_id", 303).Desc("id").Page(1, 20)) + if paging.Total != 1 || len(list) != 1 || list[0].ID != run.ID { + t.Fatalf("unexpected run list: total=%d list=%#v", paging.Total, list) + } + + detail, nodeRuns := AIWorkflowService.GetRunDetail(run.ID) + if detail == nil || detail.ID != run.ID { + t.Fatalf("unexpected detail run: %#v", detail) + } + if len(nodeRuns) != 2 || nodeRuns[0].NodeID != "start_1" || nodeRuns[1].NodeID != "reply_1" { + t.Fatalf("unexpected detail nodes: %#v", nodeRuns) + } + if missing, missingNodes := AIWorkflowService.GetRunDetail(999999); missing != nil || len(missingNodes) != 0 { + t.Fatalf("expected missing detail to be empty, got run=%#v nodes=%#v", missing, missingNodes) + } +} + func setupAIWorkflowTestDB(t *testing.T) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) diff --git a/web/app/dashboard/conversations/_components/conversation-info-panel.tsx b/web/app/dashboard/conversations/_components/conversation-info-panel.tsx index ada0839..94b96f6 100644 --- a/web/app/dashboard/conversations/_components/conversation-info-panel.tsx +++ b/web/app/dashboard/conversations/_components/conversation-info-panel.tsx @@ -1,11 +1,14 @@ "use client"; import { + AlertTriangleIcon, Building2Icon, Link2Icon, MailIcon, PencilIcon, PhoneIcon, + TimerIcon, UserRoundIcon, + WorkflowIcon, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; @@ -13,6 +16,9 @@ import { toast } from "sonner"; import { type CustomerFormSavePayload } from "@/components/customer-form"; import { CustomerFormDialog } from "@/components/customer-form-dialog"; import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"; +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 { Dialog, @@ -29,7 +35,14 @@ import { import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import type { AgentConversation } from "@/lib/api/agent"; -import { type TagTree, fetchTagsAll } from "@/lib/api/admin"; +import { + fetchAIWorkflowRun, + fetchAIWorkflowRuns, + type AIWorkflowNodeRun, + type AIWorkflowRun, + type TagTree, + fetchTagsAll, +} from "@/lib/api/admin"; import { updateCompany, type AdminCompany } from "@/lib/api/company"; import { fetchTickets, type TicketItem } from "@/lib/api/ticket"; import { @@ -243,6 +256,7 @@ export function ConversationInfoPanel({ ) : (
+
)} @@ -317,6 +331,270 @@ function ConversationTagSection({ ); } +function WorkflowRunsSection({ conversation }: { conversation: AgentConversation }) { + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [activeRun, setActiveRun] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function loadRuns() { + setLoading(true); + try { + const data = await fetchAIWorkflowRuns({ + conversationId: conversation.id, + page: 1, + limit: 5, + }); + if (!cancelled) { + setRuns(Array.isArray(data.results) ? data.results : []); + } + } catch (error) { + if (!cancelled) { + toast.error(error instanceof Error ? error.message : "加载 AI 执行记录失败"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void loadRuns(); + return () => { + cancelled = true; + }; + }, [conversation.id]); + + 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 : "加载 AI 执行详情失败"); + setDetailOpen(false); + } finally { + setDetailLoading(false); + } + } + + return ( +
+ AI 执行记录 + {loading ? ( +

加载执行记录中

+ ) : runs.length > 0 ? ( +
+ {runs.map((run) => ( + + ))} +
+ ) : ( +

暂无 AI 执行记录

+ )} + { + setDetailOpen(open); + if (!open) { + setActiveRun(null); + } + }} + /> +
+ ); +} + +function WorkflowRunDetailDialog({ + open, + loading, + run, + onOpenChange, +}: { + open: boolean; + loading: boolean; + run: AIWorkflowRun | null; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + AI 执行详情 + + } + description={run ? `Run #${run.id}` : "Workflow 执行链路"} + size="xl" + allowFullscreen + footer={ + + } + > + {loading ? ( +
加载执行详情中
+ ) : run ? ( +
+
+ + + + + + + + +
+ {run.errorMessage ? ( +
+ {run.errorMessage} +
+ ) : null} +
+ {(run.nodes ?? []).map((node) => ( + + ))} + {!run.nodes || run.nodes.length === 0 ? ( +

暂无节点记录

+ ) : null} +
+
+ ) : ( +
未找到执行记录
+ )} +
+ ); +} + +function WorkflowNodeRunBlock({ node }: { node: AIWorkflowNodeRun }) { + const inputValue = safeParseJSON(node.inputPreview); + const outputValue = safeParseJSON(node.outputPreview); + + return ( +
+
+
+
+ + {node.nodeId || `Node #${node.id}`} + + +
+
{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 WorkflowRunStatusBadge({ statusName }: { statusName: string }) { + const normalized = statusName.trim(); + const variant = + normalized === "failed" + ? "destructive" + : normalized === "interrupted" + ? "outline" + : "secondary"; + return ( + + {normalized || "unknown"} + + ); +} + +function safeParseJSON(raw: string): unknown | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + function CustomerBody({ conversation }: { conversation: AgentConversation }) { const customerId = conversation.customerId ?? 0; diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 0da8b59..506e6e6 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -545,6 +545,40 @@ export type AgentRunLog = { createdAt: string } +export type AIWorkflowNodeRun = { + id: number + workflowRunId: number + nodeId: string + nodeType: string + status: number + statusName: string + inputPreview: string + outputPreview: string + errorMessage: string + startedAt: string + endedAt: string + durationMs: number +} + +export type AIWorkflowRun = { + id: number + workflowId: number + workflowVersionId: number + conversationId: number + aiAgentId: number + messageId: number + status: number + statusName: string + startedAt: string + endedAt: string + interruptType: string + interruptNodeId: string + errorMessage: string + createdAt: string + updatedAt: string + nodes?: AIWorkflowNodeRun[] +} + export type AdminAgentProfile = { id: number userId: number @@ -1108,6 +1142,18 @@ export function fetchAgentRunLog(id: number) { return request(`/api/dashboard/agent-run-log/${id}`) } +export function fetchAIWorkflowRuns( + query?: Record +) { + return request>( + `/api/dashboard/ai-workflow/run/list${toQueryString(query)}` + ) +} + +export function fetchAIWorkflowRun(id: number) { + return request(`/api/dashboard/ai-workflow/run/${id}`) +} + export function updateSkillDefinitionStatus(id: number, status: number) { return request("/api/dashboard/skill-definition/update_status", { method: "POST",