Add AI workflow run management with listing and detail retrieval endpoints, including corresponding response structures and UI integration

This commit is contained in:
mlogclub
2026-06-23 23:05:17 +08:00
parent a670084374
commit 1440c3c4c3
10 changed files with 594 additions and 1 deletions
+2
View File
@@ -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)
}
+2
View File
@@ -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",
}
+93
View File
@@ -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)
}
@@ -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))
}
@@ -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"`
}
@@ -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, &params.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
}
+16
View File
@@ -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
@@ -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{})
@@ -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({
) : (
<div className="space-y-4 py-3">
<CustomerBody conversation={conversation} />
<WorkflowRunsSection conversation={conversation} />
</div>
)}
</div>
@@ -317,6 +331,270 @@ function ConversationTagSection({
);
}
function WorkflowRunsSection({ conversation }: { conversation: AgentConversation }) {
const [runs, setRuns] = useState<AIWorkflowRun[]>([]);
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [activeRun, setActiveRun] = useState<AIWorkflowRun | null>(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 (
<section className="space-y-2 border-t pt-2">
<SectionHeading>AI </SectionHeading>
{loading ? (
<p className="text-sm text-muted-foreground"></p>
) : runs.length > 0 ? (
<div className="space-y-2">
{runs.map((run) => (
<button
key={run.id}
type="button"
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-left transition-colors hover:bg-muted/40"
onClick={() => void openDetail(run.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<WorkflowIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium text-foreground">
Run #{run.id}
</span>
<WorkflowRunStatusBadge statusName={run.statusName} />
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>Workflow #{run.workflowVersionId || run.workflowId || "-"}</span>
<span>Message #{run.messageId || "-"}</span>
</div>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{run.startedAt ? formatDateTime(run.startedAt) : "—"}
</span>
</div>
{run.errorMessage ? (
<div className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
<span className="line-clamp-2 break-all">{run.errorMessage}</span>
</div>
) : null}
</button>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> AI </p>
)}
<WorkflowRunDetailDialog
open={detailOpen}
loading={detailLoading}
run={activeRun}
onOpenChange={(open) => {
setDetailOpen(open);
if (!open) {
setActiveRun(null);
}
}}
/>
</section>
);
}
function WorkflowRunDetailDialog({
open,
loading,
run,
onOpenChange,
}: {
open: boolean;
loading: boolean;
run: AIWorkflowRun | null;
onOpenChange: (open: boolean) => void;
}) {
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<WorkflowIcon className="size-4" />
AI
</span>
}
description={run ? `Run #${run.id}` : "Workflow 执行链路"}
size="xl"
allowFullscreen
footer={
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
}
>
{loading ? (
<div className="px-6 py-10 text-sm text-muted-foreground"></div>
) : run ? (
<div className="space-y-4 px-6 pb-6">
<div className="grid gap-2 rounded-lg border bg-muted/20 p-3 text-sm md:grid-cols-2">
<DetailRow label="Workflow" value={`#${run.workflowId} / v${run.workflowVersionId}`} />
<DetailRow label="会话" value={`#${run.conversationId}`} />
<DetailRow label="消息" value={`#${run.messageId}`} />
<DetailRow label="Agent" value={`#${run.aiAgentId}`} />
<DetailRow label="状态" value={run.statusName || String(run.status)} />
<DetailRow label="开始" value={run.startedAt ? formatDateTime(run.startedAt) : ""} />
<DetailRow label="结束" value={run.endedAt ? formatDateTime(run.endedAt) : ""} />
<DetailRow label="中断节点" value={run.interruptNodeId || ""} />
</div>
{run.errorMessage ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{run.errorMessage}
</div>
) : null}
<div className="space-y-3">
{(run.nodes ?? []).map((node) => (
<WorkflowNodeRunBlock key={node.id} node={node} />
))}
{!run.nodes || run.nodes.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : null}
</div>
</div>
) : (
<div className="px-6 py-10 text-sm text-muted-foreground"></div>
)}
</ProjectDialog>
);
}
function WorkflowNodeRunBlock({ node }: { node: AIWorkflowNodeRun }) {
const inputValue = safeParseJSON(node.inputPreview);
const outputValue = safeParseJSON(node.outputPreview);
return (
<div className="rounded-lg border bg-background p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{node.nodeId || `Node #${node.id}`}
</span>
<WorkflowRunStatusBadge statusName={node.statusName} />
</div>
<div className="mt-1 text-xs text-muted-foreground">{node.nodeType || "unknown"}</div>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<TimerIcon className="size-3.5" />
{node.durationMs} ms
</div>
</div>
{node.errorMessage ? (
<div className="mt-3 rounded-md bg-destructive/5 px-3 py-2 text-xs text-destructive">
{node.errorMessage}
</div>
) : null}
<div className="mt-3 grid gap-3 lg:grid-cols-2">
<PreviewBlock title="输入" raw={node.inputPreview} value={inputValue} />
<PreviewBlock title="输出" raw={node.outputPreview} value={outputValue} />
</div>
</div>
);
}
function PreviewBlock({
title,
raw,
value,
}: {
title: string;
raw: string;
value: unknown;
}) {
return (
<div className="min-w-0">
<div className="mb-1 text-xs font-medium text-muted-foreground">{title}</div>
{value !== null ? (
<JsonTreeViewer value={value} collapsed={2} />
) : raw.trim() ? (
<pre className="max-h-72 overflow-auto rounded-md border bg-muted/20 p-3 text-xs whitespace-pre-wrap break-all">
{raw}
</pre>
) : (
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
</div>
)}
</div>
);
}
function WorkflowRunStatusBadge({ statusName }: { statusName: string }) {
const normalized = statusName.trim();
const variant =
normalized === "failed"
? "destructive"
: normalized === "interrupted"
? "outline"
: "secondary";
return (
<Badge variant={variant} className="h-5 px-1.5 text-[11px]">
{normalized || "unknown"}
</Badge>
);
}
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;
+46
View File
@@ -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<AgentRunLog>(`/api/dashboard/agent-run-log/${id}`)
}
export function fetchAIWorkflowRuns(
query?: Record<string, string | number | undefined>
) {
return request<PageResult<AIWorkflowRun>>(
`/api/dashboard/ai-workflow/run/list${toQueryString(query)}`
)
}
export function fetchAIWorkflowRun(id: number) {
return request<AIWorkflowRun>(`/api/dashboard/ai-workflow/run/${id}`)
}
export function updateSkillDefinitionStatus(id: number, status: number) {
return request<void>("/api/dashboard/skill-definition/update_status", {
method: "POST",