feat: add AI workflow run management with detailed audit items and UI integration
This commit is contained in:
+1
-1
Submodule docs updated: d75c9282f9...57c8077187
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "系统管理",
|
||||
|
||||
Reference in New Issue
Block a user