diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 2c2f357a..9c27fb8c 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -20,10 +20,10 @@ import ( "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" - "cyberstrike-ai/internal/reasoning" "cyberstrike-ai/internal/mcp/builtin" "cyberstrike-ai/internal/multiagent" "cyberstrike-ai/internal/openai" + "cyberstrike-ai/internal/reasoning" "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" @@ -188,8 +188,8 @@ type AgentHandler struct { hitlWhitelistSaver HitlToolWhitelistSaver hitlStrategySaver HitlAuditStrategySaver hitlDefaultReviewerSaver HitlDefaultReviewerSaver - auditLLM *openai.Client - audit *audit.Service + auditLLM *openai.Client + audit *audit.Service } // SetAudit wires platform audit logging. @@ -332,13 +332,13 @@ type ChatReasoningRequest struct { // ChatRequest 聊天请求 type ChatRequest struct { - Message string `json:"message" binding:"required"` - ConversationID string `json:"conversationId,omitempty"` - ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id) - Role string `json:"role,omitempty"` // 角色名称 - Attachments []ChatAttachment `json:"attachments,omitempty"` - WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具 - Hitl *HITLRequest `json:"hitl,omitempty"` + Message string `json:"message" binding:"required"` + ConversationID string `json:"conversationId,omitempty"` + ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id) + Role string `json:"role,omitempty"` // 角色名称 + Attachments []ChatAttachment `json:"attachments,omitempty"` + WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具 + Hitl *HITLRequest `json:"hitl,omitempty"` Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"` // Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。 Orchestration string `json:"orchestration,omitempty"` @@ -988,11 +988,20 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun } } - // 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅 - if sendEventFunc != nil { - sendEventFunc(eventType, message, data) - } else { - h.publishProgressToTaskEventBus(conversationID, eventType, message, data) + // 工具输出片段不在详情区实时展示;完整结果由 tool_result 落库后按需拉取。 + if eventType == "tool_result_delta" { + return + } + + deferToolProgressSend := eventType == "tool_call" || eventType == "tool_result" + // 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅。 + // 工具事件需先落库拿 processDetailId,再向前端发送摘要,避免大 payload 默认进入浏览器。 + if !deferToolProgressSend { + if sendEventFunc != nil { + sendEventFunc(eventType, message, data) + } else { + h.publishProgressToTaskEventBus(conversationID, eventType, message, data) + } } // 保存tool_call事件中的参数 @@ -1360,9 +1369,28 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun // 在关键过程事件落库前,先把「规划中」与聚合中的 thinking / reasoning_chain 流落库 flushResponsePlan() flushThinkingStreams() - if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil { + processDetailID, err := h.db.AddProcessDetailWithID(assistantMessageID, conversationID, eventType, message, data) + if err != nil { h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType)) } + if deferToolProgressSend { + clientData := summarizeProcessDetailData(eventType, data) + if m, ok := clientData.(map[string]interface{}); ok { + m["processDetailId"] = processDetailID + } + if sendEventFunc != nil { + sendEventFunc(eventType, message, clientData) + } else { + h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData) + } + } + } else if deferToolProgressSend { + clientData := summarizeProcessDetailData(eventType, data) + if sendEventFunc != nil { + sendEventFunc(eventType, message, clientData) + } else { + h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData) + } } } } @@ -1495,10 +1523,10 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "status": "cancelling", - "conversationId": req.ConversationID, - "message": msg, - "continueAfter": false, + "status": "cancelling", + "conversationId": req.ConversationID, + "message": msg, + "continueAfter": false, "interruptWithNote": false, }) } diff --git a/internal/handler/conversation.go b/internal/handler/conversation.go index 7715a698..cc60da63 100644 --- a/internal/handler/conversation.go +++ b/internal/handler/conversation.go @@ -176,10 +176,16 @@ func (h *ConversationHandler) GetConversation(c *gin.Context) { c.JSON(http.StatusOK, conv) } +const ( + defaultProcessDetailsPageLimit = 50 + maxProcessDetailsPageLimit = 500 +) + // GetMessageProcessDetails 获取指定消息的过程详情(按需加载) // 查询参数: // - summary=1:仅返回摘要(total / iterationCount / maxIteration) -// - limit + offset:分页返回 processDetails(未指定 limit 时保持全量兼容) +// - limit + offset:分页返回 processDetails(未指定 limit 时默认 50 条) +// - full=1:显式返回全量 processDetails(用于导出/兼容旧集成,不建议 UI 展开时使用) func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) { messageID := c.Param("id") if messageID == "" { @@ -199,52 +205,84 @@ func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) { return } - limitStr := strings.TrimSpace(c.Query("limit")) - if limitStr != "" { - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"}) - return - } - if limit > 500 { - limit = 500 - } - offset, _ := strconv.Atoi(strings.TrimSpace(c.Query("offset"))) - if offset < 0 { - offset = 0 - } - - details, total, err := h.db.GetProcessDetailsPage(messageID, limit, offset) + fullStr := strings.TrimSpace(c.Query("full")) + if fullStr == "1" || strings.EqualFold(fullStr, "true") || strings.EqualFold(fullStr, "yes") { + details, err := h.db.GetProcessDetails(messageID) if err != nil { - h.logger.Error("分页获取过程详情失败", zap.Error(err)) + h.logger.Error("获取过程详情失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + details = database.DedupeConsecutiveProcessDetails(details) - out := processDetailsToJSON(h.logger, details) + out := processDetailsToJSON(h.logger, details, true) c.JSON(http.StatusOK, gin.H{ "processDetails": out, - "total": total, - "offset": offset, - "limit": limit, - "hasMore": offset+len(out) < total, + "total": len(out), + "offset": 0, + "limit": len(out), + "hasMore": false, }) return } - details, err := h.db.GetProcessDetails(messageID) + limitStr := strings.TrimSpace(c.Query("limit")) + limit := defaultProcessDetailsPageLimit + if limitStr != "" { + parsedLimit, err := strconv.Atoi(limitStr) + if err != nil || parsedLimit <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"}) + return + } + limit = parsedLimit + } + if limit > maxProcessDetailsPageLimit { + limit = maxProcessDetailsPageLimit + } + offset, _ := strconv.Atoi(strings.TrimSpace(c.Query("offset"))) + if offset < 0 { + offset = 0 + } + + details, total, err := h.db.GetProcessDetailsPage(messageID, limit, offset) if err != nil { - h.logger.Error("获取过程详情失败", zap.Error(err)) + h.logger.Error("分页获取过程详情失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - details = database.DedupeConsecutiveProcessDetails(details) - out := processDetailsToJSON(h.logger, details) - c.JSON(http.StatusOK, gin.H{"processDetails": out, "total": len(out)}) + out := processDetailsToJSON(h.logger, details, false) + c.JSON(http.StatusOK, gin.H{ + "processDetails": out, + "total": total, + "offset": offset, + "limit": limit, + "hasMore": offset+len(out) < total, + }) } -func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail) []map[string]interface{} { +// GetProcessDetail 获取单条完整过程详情。列表接口默认不给工具 payload,用户点开单条工具时再拉这里。 +func (h *ConversationHandler) GetProcessDetail(c *gin.Context) { + id := strings.TrimSpace(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "process detail id required"}) + return + } + detail, err := h.db.GetProcessDetailByID(id) + if err != nil { + h.logger.Error("获取过程详情失败", zap.Error(err)) + c.JSON(http.StatusNotFound, gin.H{"error": "过程详情不存在"}) + return + } + out := processDetailsToJSON(h.logger, []database.ProcessDetail{*detail}, true) + if len(out) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "过程详情不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"processDetail": out[0]}) +} + +func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail, includeToolPayload bool) []map[string]interface{} { out := make([]map[string]interface{}, 0, len(details)) for _, d := range details { var data interface{} @@ -253,6 +291,9 @@ func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail) logger.Warn("解析过程详情数据失败", zap.Error(err)) } } + if !includeToolPayload { + data = summarizeProcessDetailData(d.EventType, data) + } out = append(out, map[string]interface{}{ "id": d.ID, "messageId": d.MessageID, @@ -266,6 +307,27 @@ func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail) return out } +func summarizeProcessDetailData(eventType string, data interface{}) interface{} { + m, ok := data.(map[string]interface{}) + if !ok || (eventType != "tool_call" && eventType != "tool_result") { + return data + } + allow := map[string]bool{ + "toolName": true, "toolCallId": true, "index": true, "total": true, + "success": true, "isError": true, "executionId": true, + "einoAgent": true, "einoRole": true, "einoScope": true, "orchestration": true, + "agentFacing": true, + } + out := make(map[string]interface{}, len(allow)+1) + for k, v := range m { + if allow[k] { + out[k] = v + } + } + out["_payloadDeferred"] = true + return out +} + // UpdateConversationRequest 更新对话请求 type UpdateConversationRequest struct { Title string `json:"title"` diff --git a/internal/handler/monitor.go b/internal/handler/monitor.go index e44a17cf..8f597bbf 100644 --- a/internal/handler/monitor.go +++ b/internal/handler/monitor.go @@ -575,7 +575,7 @@ func (h *MonitorHandler) lookupExecution(id string) *mcp.ToolExecution { return nil } -// BatchGetToolNames 批量获取工具执行的工具名称(消除前端 N+1 请求) +// BatchGetToolNames 批量获取工具执行摘要(消除前端 N+1 请求) func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) { var req struct { IDs []string `json:"ids"` @@ -585,24 +585,29 @@ func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) { return } - result := make(map[string]string, len(req.IDs)) + type executionSummary struct { + ToolName string `json:"toolName"` + Status string `json:"status"` + } + + result := make(map[string]executionSummary, len(req.IDs)) for _, id := range req.IDs { // 先从内部MCP服务器查找 if exec, exists := h.mcpServer.GetExecution(id); exists { - result[id] = exec.ToolName + result[id] = executionSummary{ToolName: exec.ToolName, Status: exec.Status} continue } // 再从外部MCP管理器查找 if h.externalMCPMgr != nil { if exec, exists := h.externalMCPMgr.GetExecution(id); exists { - result[id] = exec.ToolName + result[id] = executionSummary{ToolName: exec.ToolName, Status: exec.Status} continue } } // 最后从数据库查找 if h.db != nil { if exec, err := h.db.GetToolExecution(id); err == nil && exec != nil { - result[id] = exec.ToolName + result[id] = executionSummary{ToolName: exec.ToolName, Status: exec.Status} } } } diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index 4860e89c..4ce3fe5f 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -4735,7 +4735,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "get": map[string]interface{}{ "tags": []string{"对话交互"}, "summary": "获取消息过程详情", - "description": "按需加载指定消息的执行过程详情,包括工具调用、思考过程等事件。", + "description": "按需分页加载指定消息的执行过程详情,包括工具调用、思考过程等事件。默认返回 50 条;导出或旧集成需要全量时可显式传 full=1。", "operationId": "getMessageProcessDetails", "parameters": []map[string]interface{}{ { @@ -4745,6 +4745,34 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "description": "消息ID", "schema": map[string]interface{}{"type": "string"}, }, + { + "name": "summary", + "in": "query", + "required": false, + "description": "仅返回过程详情摘要(total / iterationCount / maxIteration)", + "schema": map[string]interface{}{"type": "boolean"}, + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "分页大小,默认 50,最大 500", + "schema": map[string]interface{}{"type": "integer", "default": 50, "maximum": 500}, + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "分页偏移量,默认 0", + "schema": map[string]interface{}{"type": "integer", "default": 0}, + }, + { + "name": "full", + "in": "query", + "required": false, + "description": "显式返回全量过程详情;仅建议导出/兼容旧集成使用", + "schema": map[string]interface{}{"type": "boolean"}, + }, }, "responses": map[string]interface{}{ "200": map[string]interface{}{ @@ -4769,6 +4797,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, + "total": map[string]interface{}{"type": "integer", "description": "过程详情总数"}, + "offset": map[string]interface{}{"type": "integer", "description": "当前分页偏移量"}, + "limit": map[string]interface{}{"type": "integer", "description": "当前分页大小"}, + "hasMore": map[string]interface{}{"type": "boolean", "description": "是否还有更多过程详情"}, + "summary": map[string]interface{}{ + "type": "object", + "description": "summary=1 时返回的摘要", + }, }, }, },