diff --git a/docs b/docs index da1f6d7..6adafa1 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit da1f6d7c3959e53c1d5e6b08bc9c7edc755e1440 +Subproject commit 6adafa1069fc22d6f08d42d339b7dfad167d86b6 diff --git a/internal/ai/runtime/reply_commit_service.go b/internal/ai/runtime/reply_commit_service.go index ea58539..6bd78a4 100644 --- a/internal/ai/runtime/reply_commit_service.go +++ b/internal/ai/runtime/reply_commit_service.go @@ -22,6 +22,7 @@ type replyCommitInput struct { AIAgent models.AIAgent ReplyText string ClientPrefix string + WorkflowRunID int64 IncrementRound bool } @@ -34,7 +35,7 @@ func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Messag if replyText == "" { return nil, nil } - replyMessage, err := svc.MessageService.SendAIMessageWithRequestID( + replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID( input.Conversation.ID, input.AIAgent.ID, fmt.Sprintf("%s_%d", strings.TrimSpace(input.ClientPrefix), input.Message.ID), @@ -43,6 +44,7 @@ func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Messag "", s.buildAIPrincipal(input.AIAgent), input.Message.RequestID, + input.WorkflowRunID, ) if err != nil || !input.IncrementRound { return replyMessage, err diff --git a/internal/ai/runtime/reply_commit_service_test.go b/internal/ai/runtime/reply_commit_service_test.go new file mode 100644 index 0000000..54198a9 --- /dev/null +++ b/internal/ai/runtime/reply_commit_service_test.go @@ -0,0 +1,120 @@ +package runtime + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestReplyCommitStoresWorkflowRunIDOnAIMessage(t *testing.T) { + db := setupReplyCommitTestDB(t) + aiAgent := createReplyCommitTestAIAgent(t, db) + conversation := createReplyCommitTestConversation(t, db, aiAgent.ID) + + replyMessage, err := newReplyCommitService().CommitAIReply(replyCommitInput{ + Conversation: *conversation, + Message: models.Message{ID: 101, RequestID: "trace-101"}, + AIAgent: *aiAgent, + ReplyText: "AI reply", + ClientPrefix: "ai_reply", + WorkflowRunID: 9988, + }) + if err != nil { + t.Fatalf("CommitAIReply() error = %v", err) + } + if replyMessage == nil { + t.Fatalf("expected reply message") + } + if replyMessage.WorkflowRunID != 9988 { + t.Fatalf("replyMessage.WorkflowRunID=%d want 9988", replyMessage.WorkflowRunID) + } + + var stored models.Message + if err := db.First(&stored, replyMessage.ID).Error; err != nil { + t.Fatalf("find reply message: %v", err) + } + if stored.WorkflowRunID != 9988 { + t.Fatalf("stored.WorkflowRunID=%d want 9988", stored.WorkflowRunID) + } +} + +func setupReplyCommitTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbName := "reply_commit_test_" + strings.NewReplacer("/", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sqlite db: %v", err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Fatalf("close sqlite db: %v", err) + } + }) + if err := db.AutoMigrate( + &models.AIAgent{}, + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Conversation{}, + &models.ConversationReadState{}, + &models.ConversationEventLog{}, + &models.Message{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + return db +} + +func createReplyCommitTestAIAgent(t *testing.T, db *gorm.DB) *models.AIAgent { + t.Helper() + now := time.Now() + item := &models.AIAgent{ + Name: "reply-agent", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + if err := db.Create(item).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + return item +} + +func createReplyCommitTestConversation(t *testing.T, db *gorm.DB, aiAgentID int64) *models.Conversation { + t.Helper() + now := time.Now() + item := &models.Conversation{ + CustomerID: 1, + ChannelID: 11, + AIAgentID: aiAgentID, + Status: enums.IMConversationStatusAIServing, + LastActiveAt: now, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + if err := db.Create(item).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + return item +} diff --git a/internal/ai/runtime/reply_interrupt_service.go b/internal/ai/runtime/reply_interrupt_service.go index 1212893..adec32b 100644 --- a/internal/ai/runtime/reply_interrupt_service.go +++ b/internal/ai/runtime/reply_interrupt_service.go @@ -32,11 +32,12 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne summary = expiredInterruptSummary() replyCtx.setSummary(summary) replyMessage, expireErr := owner.commit.CommitAIReply(replyCommitInput{ - Conversation: replyCtx.Conversation, - Message: replyCtx.Message, - AIAgent: replyCtx.AIAgent, - ReplyText: summary.ReplyText, - ClientPrefix: "ai_interrupt_expired", + Conversation: replyCtx.Conversation, + Message: replyCtx.Message, + AIAgent: replyCtx.AIAgent, + ReplyText: summary.ReplyText, + ClientPrefix: "ai_interrupt_expired", + WorkflowRunID: summary.WorkflowRunID, }) if expireErr != nil { return expireErr @@ -57,11 +58,12 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne } if summary != nil && strings.TrimSpace(summary.ReplyText) != "" { replyMessage, err := owner.commit.CommitAIReply(replyCommitInput{ - Conversation: replyCtx.Conversation, - Message: replyCtx.Message, - AIAgent: replyCtx.AIAgent, - ReplyText: summary.ReplyText, - ClientPrefix: "ai_resume", + Conversation: replyCtx.Conversation, + Message: replyCtx.Message, + AIAgent: replyCtx.AIAgent, + ReplyText: summary.ReplyText, + ClientPrefix: "ai_resume", + WorkflowRunID: summary.WorkflowRunID, }) if err != nil { return err @@ -86,11 +88,12 @@ func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, pending = svc.ConversationInterruptService.GetByCheckPointID(summary.CheckPointID) replyText := resolveInterruptPrompt(summary) replyMessage, err := owner.commit.CommitAIReply(replyCommitInput{ - Conversation: replyCtx.Conversation, - Message: replyCtx.Message, - AIAgent: replyCtx.AIAgent, - ReplyText: replyText, - ClientPrefix: "ai_interrupt", + Conversation: replyCtx.Conversation, + Message: replyCtx.Message, + AIAgent: replyCtx.AIAgent, + ReplyText: replyText, + ClientPrefix: "ai_interrupt", + WorkflowRunID: summary.WorkflowRunID, }) if err != nil { return err @@ -107,11 +110,12 @@ func (s *replyInterruptService) HandleInterruptedResume(owner *aiReplyService, r } replyText := resolveInterruptPrompt(summary) replyMessage, err := owner.commit.CommitAIReply(replyCommitInput{ - Conversation: replyCtx.Conversation, - Message: replyCtx.Message, - AIAgent: replyCtx.AIAgent, - ReplyText: replyText, - ClientPrefix: "ai_interrupt_resume", + Conversation: replyCtx.Conversation, + Message: replyCtx.Message, + AIAgent: replyCtx.AIAgent, + ReplyText: replyText, + ClientPrefix: "ai_interrupt_resume", + WorkflowRunID: summary.WorkflowRunID, }) if err != nil { return err diff --git a/internal/ai/runtime/reply_trigger_service.go b/internal/ai/runtime/reply_trigger_service.go index 038280f..19686fd 100644 --- a/internal/ai/runtime/reply_trigger_service.go +++ b/internal/ai/runtime/reply_trigger_service.go @@ -84,11 +84,12 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte } if summary != nil && strings.TrimSpace(summary.ReplyText) != "" { _, err := s.commit.CommitAIReply(replyCommitInput{ - Conversation: replyCtx.Conversation, - Message: replyCtx.Message, - AIAgent: replyCtx.AIAgent, - ReplyText: summary.ReplyText, - ClientPrefix: "ai_reply", + Conversation: replyCtx.Conversation, + Message: replyCtx.Message, + AIAgent: replyCtx.AIAgent, + ReplyText: summary.ReplyText, + ClientPrefix: "ai_reply", + WorkflowRunID: summary.WorkflowRunID, }) if err != nil { return err diff --git a/internal/builders/conversation_builder.go b/internal/builders/conversation_builder.go index 286b335..b570cf7 100644 --- a/internal/builders/conversation_builder.go +++ b/internal/builders/conversation_builder.go @@ -150,6 +150,7 @@ func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, c ID: item.ID, ConversationID: item.ConversationID, RequestID: item.RequestID, + WorkflowRunID: item.WorkflowRunID, ClientMsgID: item.ClientMsgID, SenderType: item.SenderType, SenderID: item.SenderID, diff --git a/internal/builders/conversation_builder_test.go b/internal/builders/conversation_builder_test.go index ccd7f74..86e1c6f 100644 --- a/internal/builders/conversation_builder_test.go +++ b/internal/builders/conversation_builder_test.go @@ -3,6 +3,8 @@ package builders import ( "testing" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/i18nx" ) @@ -97,3 +99,18 @@ func TestLocalizeRenderableMessageContent(t *testing.T) { }) } } + +func TestBuildMessageIncludesWorkflowRunID(t *testing.T) { + resp := BuildMessageWithReadStatesAndLocale(&models.Message{ + ID: 1, + ConversationID: 2, + SenderType: enums.IMSenderTypeAI, + MessageType: enums.IMMessageTypeText, + Content: "AI reply", + WorkflowRunID: 9988, + }, nil, nil, nil, nil, nil, i18nx.DefaultLocale) + + if resp.WorkflowRunID != 9988 { + t.Fatalf("resp.WorkflowRunID=%d want 9988", resp.WorkflowRunID) + } +} diff --git a/internal/models/models.go b/internal/models/models.go index e9e582a..e6b212e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -390,6 +390,7 @@ type Message struct { ID int64 `gorm:"primaryKey;autoIncrement"` ConversationID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_conversation_seq;uniqueIndex:uk_conversation_client_msg"` RequestID string `gorm:"type:varchar(128);not null;default:'';index"` + WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"` ClientMsgID string `gorm:"type:varchar(128);not null;default:'';uniqueIndex:uk_conversation_client_msg"` SenderType enums.IMSenderType `gorm:"type:varchar(30);not null;default:'';index"` SenderID int64 `gorm:"type:bigint;not null;default:0;index"` diff --git a/internal/pkg/dto/response/message_response.go b/internal/pkg/dto/response/message_response.go index 427727d..349b58a 100644 --- a/internal/pkg/dto/response/message_response.go +++ b/internal/pkg/dto/response/message_response.go @@ -6,6 +6,7 @@ type MessageResponse struct { ID int64 `json:"id"` ConversationID int64 `json:"conversationId"` RequestID string `json:"requestId,omitempty"` + WorkflowRunID int64 `json:"workflowRunId,omitempty"` ClientMsgID string `json:"clientMsgId,omitempty"` SenderType enums.IMSenderType `json:"senderType"` SenderID int64 `json:"senderId"` diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 48fe4a1..3c2cd26 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -132,11 +132,11 @@ func (s *messageService) GetConversationReadTarget(conversationID, messageID int func (s *messageService) SendMessage(conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser) (*models.Message, error) { switch senderType { case enums.IMSenderTypeAgent: - return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "") + return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "", 0) case enums.IMSenderTypeAI: - return s.sendMessage(conversationID, enums.IMSenderTypeAI, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "") + return s.sendMessage(conversationID, enums.IMSenderTypeAI, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "", 0) case enums.IMSenderTypeCustomer: - return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, external, "") + return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, external, "", 0) default: return nil, errorsx.InvalidParamI18n("error.e0080") } @@ -147,7 +147,7 @@ func (s *messageService) SendAgentMessage(conversationID int64, reqSenderID int6 } func (s *messageService) SendAgentMessageWithRequestID(conversationID int64, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, requestID string) (*models.Message, error) { - return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, requestID) + return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, requestID, 0) } func (s *messageService) RecallAgentMessage(messageID int64, operator *dto.AuthPrincipal) (*models.Message, error) { @@ -250,7 +250,11 @@ func (s *messageService) SendAIMessage(conversationID int64, aiAgentID int64, cl } func (s *messageService) SendAIMessageWithRequestID(conversationID int64, aiAgentID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, requestID string) (*models.Message, error) { - return s.sendMessage(conversationID, enums.IMSenderTypeAI, aiAgentID, clientMsgID, messageType, content, payload, operator, nil, requestID) + return s.SendAIMessageWithRequestIDAndWorkflowRunID(conversationID, aiAgentID, clientMsgID, messageType, content, payload, operator, requestID, 0) +} + +func (s *messageService) SendAIMessageWithRequestIDAndWorkflowRunID(conversationID int64, aiAgentID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, requestID string, workflowRunID int64) (*models.Message, error) { + return s.sendMessage(conversationID, enums.IMSenderTypeAI, aiAgentID, clientMsgID, messageType, content, payload, operator, nil, requestID, workflowRunID) } func (s *messageService) SendAIServiceNotice(conversationID int64, aiAgentID int64, content string) (*models.Message, error) { @@ -269,7 +273,7 @@ func (s *messageService) SendAIServiceNoticeWithRequestID(conversationID int64, UserID: 0, Username: "system", Nickname: "system", - }, nil, requestID) + }, nil, requestID, 0) } func (s *messageService) createAIWelcomeMessage(ctx *sqls.TxContext, conversation *models.Conversation, aiAgent *models.AIAgent, now time.Time) (*models.Message, error) { @@ -370,11 +374,11 @@ func (s *messageService) SendCustomerMessage(conversationID int64, clientMsgID s func (s *messageService) SendCustomerMessageWithRequestID(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser, requestID string) (*models.Message, error) { ext := external - return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext, requestID) + return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext, requestID, 0) } func (s *messageService) sendMessage(conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string, - messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string) (*models.Message, error) { + messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, workflowRunID int64) (*models.Message, error) { if senderType == enums.IMSenderTypeCustomer { if external == nil || strings.TrimSpace(external.ExternalID) == "" { @@ -391,11 +395,11 @@ func (s *messageService) sendMessage(conversationID int64, senderType enums.IMSe if err != nil { return nil, err } - return s.sendValidatedMessage(conversation, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID) + return s.sendValidatedMessage(conversation, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID, workflowRunID) } func (s *messageService) sendValidatedMessage(conversation *models.Conversation, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string, - messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string) (*models.Message, error) { + messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, workflowRunID int64) (*models.Message, error) { var err error var summary string @@ -432,6 +436,7 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, message := &models.Message{ ConversationID: conversation.ID, RequestID: traceID, + WorkflowRunID: workflowRunID, ClientMsgID: clientMsgID, SenderType: senderType, SenderID: reqSenderID, diff --git a/internal/services/message_service_test.go b/internal/services/message_service_test.go index 54fa46b..6ff8fc9 100644 --- a/internal/services/message_service_test.go +++ b/internal/services/message_service_test.go @@ -6,6 +6,7 @@ import ( "time" "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/openidentity" @@ -103,6 +104,30 @@ func welcomeTestExternalUser(id string) openidentity.ExternalUser { } } +func createMessageTestConversation(t *testing.T, db *gorm.DB, aiAgentID int64) *models.Conversation { + t.Helper() + now := time.Now() + conversation := &models.Conversation{ + CustomerID: 1, + ChannelID: 11, + AIAgentID: aiAgentID, + Status: enums.IMConversationStatusAIServing, + LastActiveAt: now, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + if err := db.Create(conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + return conversation +} + +func workflowTestAIPrincipal() *dto.AuthPrincipal { + return &dto.AuthPrincipal{UserID: 0, Username: "AI", Nickname: "AI"} +} + func TestConversationCreateCreatesAIWelcomeMessage(t *testing.T) { db := setupMessageWelcomeTestDB(t) aiAgent := createWelcomeTestAIAgent(t, db, " 您好,请问有什么可以帮您? ") @@ -197,6 +222,38 @@ func TestSendCustomerMessageStoresRequestIDOnMessageAndEvent(t *testing.T) { } } +func TestSendAIMessageStoresWorkflowRunID(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, "") + conversation := createMessageTestConversation(t, db, aiAgent.ID) + + message, err := MessageService.SendAIMessageWithRequestIDAndWorkflowRunID( + conversation.ID, + aiAgent.ID, + "ai-reply-workflow-1", + enums.IMMessageTypeText, + "AI reply", + "", + workflowTestAIPrincipal(), + "trace-workflow-1", + 9988, + ) + if err != nil { + t.Fatalf("SendAIMessageWithRequestIDAndWorkflowRunID() error = %v", err) + } + if message.WorkflowRunID != 9988 { + t.Fatalf("message.WorkflowRunID=%d want 9988", message.WorkflowRunID) + } + + var stored models.Message + if err := db.First(&stored, message.ID).Error; err != nil { + t.Fatalf("find message: %v", err) + } + if stored.WorkflowRunID != 9988 { + t.Fatalf("stored.WorkflowRunID=%d want 9988", stored.WorkflowRunID) + } +} + func TestConversationCreateDoesNotDuplicateWelcomeMessageForExistingConversation(t *testing.T) { db := setupMessageWelcomeTestDB(t) aiAgent := createWelcomeTestAIAgent(t, db, "欢迎咨询") diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go index f286f0d..66ba912 100644 --- a/internal/services/ws_service.go +++ b/internal/services/ws_service.go @@ -327,6 +327,7 @@ func (s *wsService) buildRealtimeMessage(item *models.Message) response.MessageR ID: item.ID, ConversationID: item.ConversationID, RequestID: item.RequestID, + WorkflowRunID: item.WorkflowRunID, ClientMsgID: item.ClientMsgID, SenderType: item.SenderType, SenderID: item.SenderID, diff --git a/web/app/dashboard/conversations/_components/chat-panel.tsx b/web/app/dashboard/conversations/_components/chat-panel.tsx index 6054d23..adf235e 100644 --- a/web/app/dashboard/conversations/_components/chat-panel.tsx +++ b/web/app/dashboard/conversations/_components/chat-panel.tsx @@ -9,14 +9,24 @@ import { useRef, useState, } from "react"; -import { BotIcon, LockKeyholeIcon, UserCheckIcon } from "lucide-react"; +import { + AlertTriangleIcon, + BotIcon, + LockKeyholeIcon, + TimerIcon, + UserCheckIcon, + WorkflowIcon, +} from "lucide-react"; import { toast } from "sonner"; import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog"; import { ImMessageHTML } from "@/components/im-message-html"; import { useImageLightbox } from "@/components/image-lightbox"; +import { JsonTreeViewer } from "@/components/json-tree-viewer"; +import { ProjectDialog } from "@/components/project-dialog"; import { useI18n } from "@/i18n/provider"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -36,6 +46,11 @@ import { assignAgentConversation, type AgentMessage, } from "@/lib/api/agent"; +import { + fetchAIWorkflowRun, + type AIWorkflowNodeRun, + type AIWorkflowRun, +} from "@/lib/api/admin"; import { readSession } from "@/lib/auth"; import { renderIMMessageHTML } from "@/lib/im-message"; import { @@ -153,6 +168,10 @@ export function ChatPanel() { const [claiming, setClaiming] = useState(false); const [claimDialogOpen, setClaimDialogOpen] = useState(false); const [transferDialogOpen, setTransferDialogOpen] = useState(false); + const [workflowRunDialogOpen, setWorkflowRunDialogOpen] = useState(false); + const [workflowRunLoading, setWorkflowRunLoading] = useState(false); + const [activeWorkflowRun, setActiveWorkflowRun] = + useState(null); const isLgUp = useIsLgUp(); const isClosedConversation = conversation?.status === 4; const isPendingConversation = conversation?.status === 2; @@ -397,6 +416,23 @@ export function ChatPanel() { await loadMessages(conversationId, { forceLoading: true, reset: true }); }; + const openWorkflowRunDetail = useCallback( + async (runId: number) => { + setWorkflowRunDialogOpen(true); + setWorkflowRunLoading(true); + try { + const data = await fetchAIWorkflowRun(runId); + setActiveWorkflowRun(data); + } catch (error) { + toast.error(error instanceof Error ? error.message : "加载 AI 执行详情失败"); + setWorkflowRunDialogOpen(false); + } finally { + setWorkflowRunLoading(false); + } + }, + [], + ); + if (!conversation) { return (
@@ -447,6 +483,7 @@ export function ChatPanel() { onRecall={async (messageId) => { await recallMessage(messageId); }} + onOpenWorkflowRun={openWorkflowRunDetail} /> )) ) : ( @@ -594,6 +631,17 @@ export function ChatPanel() { await reloadConversationData(conversation.id); }} /> + { + setWorkflowRunDialogOpen(open); + if (!open) { + setActiveWorkflowRun(null); + } + }} + />
); } @@ -604,6 +652,7 @@ type MessageItemProps = { canRecall: boolean; recalling: boolean; onRecall: (messageId: number) => Promise; + onOpenWorkflowRun: (runId: number) => Promise; }; const MessageItem = memo( @@ -613,6 +662,7 @@ const MessageItem = memo( canRecall, recalling, onRecall, + onOpenWorkflowRun, }: MessageItemProps) { const t = useI18n(); const { open: openImageLightbox } = useImageLightbox(); @@ -706,6 +756,20 @@ const MessageItem = memo( {recalling ? t("conversation.recalling") : t("conversation.recall")} ) : null} + {isAi && message.workflowRunId ? ( + + ) : null} @@ -754,7 +818,8 @@ const MessageItem = memo( prevProps.onImageSettled === nextProps.onImageSettled && prevProps.canRecall === nextProps.canRecall && prevProps.recalling === nextProps.recalling && - prevProps.onRecall === nextProps.onRecall, + prevProps.onRecall === nextProps.onRecall && + prevProps.onOpenWorkflowRun === nextProps.onOpenWorkflowRun, ); function buildMessageHTML(message: { @@ -764,3 +829,199 @@ function buildMessageHTML(message: { }) { return renderIMMessageHTML(message); } + +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 WorkflowRunDetailRow({ + label, + value, +}: { + label: string; + value: string; +}) { + const empty = !value.trim(); + return ( +
+ + {label} + + + {empty ? "—" : value} + +
+ ); +} + +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 WorkflowPreviewBlock({ + 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) as unknown; + } catch { + return null; + } +} diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 61c8883..3167f44 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -143,6 +143,7 @@ export type AdminConversationDetail = AdminConversation & { export type AdminMessage = { id: number conversationId: number + workflowRunId?: number clientMsgId?: string senderType: string senderId: number diff --git a/web/lib/api/agent.ts b/web/lib/api/agent.ts index 3951aa8..fbd93ef 100644 --- a/web/lib/api/agent.ts +++ b/web/lib/api/agent.ts @@ -70,6 +70,7 @@ export type AgentConversationDetail = AgentConversation & { export type AgentMessage = { id: number conversationId: number + workflowRunId?: number clientMsgId?: string senderType: string senderId: number diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts index 60a8a57..08f214c 100644 --- a/web/lib/api/im.ts +++ b/web/lib/api/im.ts @@ -64,6 +64,7 @@ export type ImConversationDetail = ImConversation export type ImMessage = { id: number conversationId: number + workflowRunId?: number clientMsgId?: string senderType: string senderId: number