feat: add AI workflow run management with detailed audit items and UI integration

This commit is contained in:
mlogclub
2026-06-24 09:52:00 +08:00
parent 02ae515bd6
commit 46bcc55658
12 changed files with 694 additions and 10 deletions
+1 -1
Submodule docs updated: d75c9282f9...57c8077187
+29 -1
View File
@@ -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()
}
@@ -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 {
@@ -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))
}
@@ -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"`
+70
View File
@@ -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
+31 -6
View File
@@ -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 {
+418
View File
@@ -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, string | number>) => 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<AIWorkflowRun, "status" | "statusName">) {
return item.statusName || (item.status ? String(item.status) : "-")
}
export default function DashboardAIWorkflowRunsPage() {
const t = useI18n()
const [agents, setAgents] = useState<AIAgent[]>([])
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [activeRun, setActiveRun] = useState<AIWorkflowRun | null>(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 (
<>
<DashboardListPage<AIWorkflowRun>
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) => (
<div className="min-w-0 space-y-1">
<div className="truncate font-medium">
{item.workflowName || `Workflow #${item.workflowId}`}
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>v{item.workflowVersion || "-"}</span>
<span>#{item.workflowVersionId || "-"}</span>
</div>
</div>
),
},
{
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) => (
<div className="space-y-1 text-sm">
<div>{t("workflowRun.conversationShort", { id: item.conversationId || "-" })}</div>
<div className="text-xs text-muted-foreground">
{t("workflowRun.messageShort", { id: item.messageId || "-" })}
</div>
</div>
),
},
{
key: "status",
label: t("workflowRun.status"),
className: "w-32",
render: (item) => (
<Badge variant={statusBadgeVariant(item.statusName)}>
{formatRunStatus(item)}
</Badge>
),
},
{
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 ? (
<div className="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">{item.errorMessage}</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
),
},
]}
labels={{
refresh: t("workflowRun.refresh"),
query: t("workflowRun.query"),
loading: t("workflowRun.loading"),
empty: t("workflowRun.empty"),
loadFailed: t("workflowRun.loadFailed"),
}}
/>
<WorkflowRunDetailDialog
open={detailOpen}
loading={detailLoading}
run={activeRun}
onOpenChange={(open) => {
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 (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<WorkflowIcon className="size-4" />
{t("workflowRun.detailTitle")}
</span>
}
description={run ? `Run #${run.id}` : t("workflowRun.detailDescription")}
size="xl"
allowFullscreen
defaultFullscreen
bodyClassName="min-h-0"
footer={
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("workflowRun.close")}
</Button>
}
>
{loading ? (
<div className="py-10 text-sm text-muted-foreground">{t("workflowRun.loadingDetail")}</div>
) : run ? (
<div className="space-y-4">
<div className="grid gap-2 rounded-md border bg-muted/20 p-3 text-sm md:grid-cols-2">
<DetailRow label={t("workflowRun.workflow")} value={run.workflowName || `#${run.workflowId}`} />
<DetailRow label={t("workflowRun.version")} value={`v${run.workflowVersion || "-"} / #${run.workflowVersionId}`} />
<DetailRow label={t("workflowRun.agent")} value={run.aiAgentName || `#${run.aiAgentId}`} />
<DetailRow label={t("workflowRun.status")} value={formatRunStatus(run)} />
<DetailRow label={t("workflowRun.conversationId")} value={`#${run.conversationId}`} />
<DetailRow label={t("workflowRun.messageId")} value={`#${run.messageId}`} />
<DetailRow label={t("workflowRun.startedAt")} value={run.startedAt ? formatDateTime(run.startedAt) : "-"} />
<DetailRow label={t("workflowRun.endedAt")} value={run.endedAt ? formatDateTime(run.endedAt) : "-"} />
<DetailRow label={t("workflowRun.duration")} value={`${run.durationMs || 0} ms`} />
<DetailRow label={t("workflowRun.interruptNode")} value={run.interruptNodeId || "-"} />
</div>
{run.errorMessage ? (
<div className="rounded-md 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, index) => (
<WorkflowNodeRunBlock key={node.id || node.nodeId || index} node={node} t={t} />
))}
{!run.nodes || run.nodes.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("workflowRun.emptyNodes")}</p>
) : null}
</div>
</div>
) : (
<div className="py-10 text-sm text-muted-foreground">{t("workflowRun.notFound")}</div>
)}
</ProjectDialog>
)
}
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 (
<div className="rounded-md border bg-background p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<MessageSquareTextIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">{node.nodeId || `#${node.id}`}</span>
<Badge variant={statusBadgeVariant(node.statusName)}>{node.statusName || node.status || "-"}</Badge>
</div>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{node.nodeType || "unknown"}</span>
<span className="inline-flex items-center gap-1">
<Clock3Icon className="size-3.5" />
{node.durationMs} ms
</span>
</div>
</div>
{node.errorMessage ? (
<div className="flex max-w-xl 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">{node.errorMessage}</span>
</div>
) : null}
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-2">
<PreviewBlock title={t("workflowRun.input")} raw={inputPreview} value={inputValue} />
<PreviewBlock title={t("workflowRun.output")} raw={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 DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="min-w-0 truncate font-medium">{value || "-"}</span>
</div>
)
}
function safeParseJSON(raw: string): unknown | null {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
try {
return JSON.parse(trimmed)
} catch {
return null
}
}
+4
View File
@@ -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
+7
View File
@@ -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: <MessageSquareCodeIcon />,
requiredPermission: "mcp.view",
},
{
titleKey: "nav.workflowRuns",
url: "/dashboard/ai-workflow-runs",
icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view",
},
],
},
{
+40
View File
@@ -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",
+40
View File
@@ -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": "系统管理",