diff --git a/data/conversations.db b/data/conversations.db new file mode 100644 index 00000000..4ebf78cf Binary files /dev/null and b/data/conversations.db differ diff --git a/data/conversations.db-shm b/data/conversations.db-shm new file mode 100644 index 00000000..f7cdced6 Binary files /dev/null and b/data/conversations.db-shm differ diff --git a/data/conversations.db-wal b/data/conversations.db-wal new file mode 100644 index 00000000..7aec4258 Binary files /dev/null and b/data/conversations.db-wal differ diff --git a/internal/agent/agent.go b/internal/agent/agent.go index a6451212..be3ff05a 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -276,11 +276,17 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his // 获取可用工具 tools := a.getAvailableTools() - // 发送进度更新 + // 发送迭代开始事件 if i == 0 { - sendProgress("progress", "正在分析请求并制定测试策略...", nil) + sendProgress("iteration", "开始分析请求并制定测试策略", map[string]interface{}{ + "iteration": i + 1, + "total": maxIterations, + }) } else { - sendProgress("progress", fmt.Sprintf("正在继续分析(第 %d 轮迭代)...", i+1), nil) + sendProgress("iteration", fmt.Sprintf("第 %d 轮迭代", i+1), map[string]interface{}{ + "iteration": i + 1, + "total": maxIterations, + }) } // 记录每次调用OpenAI @@ -333,6 +339,13 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his // 检查是否有工具调用 if len(choice.Message.ToolCalls) > 0 { + // 如果有思考内容,先发送思考事件 + if choice.Message.Content != "" { + sendProgress("thinking", choice.Message.Content, map[string]interface{}{ + "iteration": i + 1, + }) + } + // 添加assistant消息(包含工具调用) messages = append(messages, ChatMessage{ Role: "assistant", @@ -341,7 +354,10 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his }) // 发送工具调用进度 - sendProgress("progress", fmt.Sprintf("检测到 %d 个工具调用,开始执行...", len(choice.Message.ToolCalls)), nil) + sendProgress("tool_calls_detected", fmt.Sprintf("检测到 %d 个工具调用", len(choice.Message.ToolCalls)), map[string]interface{}{ + "count": len(choice.Message.ToolCalls), + "iteration": i + 1, + }) // 执行所有工具调用 for idx, toolCall := range choice.Message.ToolCalls { @@ -350,8 +366,11 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his sendProgress("tool_call", fmt.Sprintf("正在调用工具: %s", toolCall.Function.Name), map[string]interface{}{ "toolName": toolCall.Function.Name, "arguments": string(toolArgsJSON), + "argumentsObj": toolCall.Function.Arguments, + "toolCallId": toolCall.ID, "index": idx + 1, "total": len(choice.Message.ToolCalls), + "iteration": i + 1, }) // 执行工具 @@ -369,9 +388,12 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his sendProgress("tool_result", fmt.Sprintf("工具 %s 执行失败", toolCall.Function.Name), map[string]interface{}{ "toolName": toolCall.Function.Name, "success": false, + "isError": true, "error": err.Error(), + "toolCallId": toolCall.ID, "index": idx + 1, "total": len(choice.Message.ToolCalls), + "iteration": i + 1, }) a.logger.Warn("工具执行失败,已返回详细错误信息", @@ -399,10 +421,13 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his "toolName": toolCall.Function.Name, "success": !execResult.IsError, "isError": execResult.IsError, - "result": resultPreview, + "result": execResult.Result, // 完整结果 + "resultPreview": resultPreview, // 预览结果 "executionId": execResult.ExecutionID, + "toolCallId": toolCall.ID, "index": idx + 1, "total": len(choice.Message.ToolCalls), + "iteration": i + 1, }) // 如果工具返回了错误,记录日志但不中断流程 @@ -423,6 +448,13 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his Content: choice.Message.Content, }) + // 发送AI思考内容(如果没有工具调用) + if choice.Message.Content != "" { + sendProgress("thinking", choice.Message.Content, map[string]interface{}{ + "iteration": i + 1, + }) + } + // 如果完成,返回结果 if choice.FinishReason == "stop" { sendProgress("progress", "正在生成最终回复...", nil) diff --git a/internal/database/conversation.go b/internal/database/conversation.go index 95905772..e21c6c27 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -21,12 +21,13 @@ type Conversation struct { // Message 消息 type Message struct { - ID string `json:"id"` - ConversationID string `json:"conversationId"` - Role string `json:"role"` - Content string `json:"content"` - MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + ConversationID string `json:"conversationId"` + Role string `json:"role"` + Content string `json:"content"` + MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` + ProcessDetails []map[string]interface{} `json:"processDetails,omitempty"` + CreatedAt time.Time `json:"createdAt"` } // CreateConversation 创建新对话 @@ -91,6 +92,39 @@ func (db *DB) GetConversation(id string) (*Conversation, error) { } conv.Messages = messages + // 加载过程详情(按消息ID分组) + processDetailsMap, err := db.GetProcessDetailsByConversation(id) + if err != nil { + db.logger.Warn("加载过程详情失败", zap.Error(err)) + processDetailsMap = make(map[string][]ProcessDetail) + } + + // 将过程详情附加到对应的消息上 + for i := range conv.Messages { + if details, ok := processDetailsMap[conv.Messages[i].ID]; ok { + // 将ProcessDetail转换为JSON格式,以便前端使用 + detailsJSON := make([]map[string]interface{}, len(details)) + for j, detail := range details { + var data interface{} + if detail.Data != "" { + if err := json.Unmarshal([]byte(detail.Data), &data); err != nil { + db.logger.Warn("解析过程详情数据失败", zap.Error(err)) + } + } + detailsJSON[j] = map[string]interface{}{ + "id": detail.ID, + "messageId": detail.MessageID, + "conversationId": detail.ConversationID, + "eventType": detail.EventType, + "message": detail.Message, + "data": data, + "createdAt": detail.CreatedAt, + } + } + conv.Messages[i].ProcessDetails = detailsJSON + } + } + return &conv, nil } @@ -254,3 +288,111 @@ func (db *DB) GetMessages(conversationID string) ([]Message, error) { return messages, nil } +// ProcessDetail 过程详情事件 +type ProcessDetail struct { + ID string `json:"id"` + MessageID string `json:"messageId"` + ConversationID string `json:"conversationId"` + EventType string `json:"eventType"` // iteration, thinking, tool_calls_detected, tool_call, tool_result, progress, error + Message string `json:"message"` + Data string `json:"data"` // JSON格式的数据 + CreatedAt time.Time `json:"createdAt"` +} + +// AddProcessDetail 添加过程详情事件 +func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message string, data interface{}) error { + id := uuid.New().String() + + var dataJSON string + if data != nil { + jsonData, err := json.Marshal(data) + if err != nil { + db.logger.Warn("序列化过程详情数据失败", zap.Error(err)) + } else { + dataJSON = string(jsonData) + } + } + + _, err := db.Exec( + "INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + id, messageID, conversationID, eventType, message, dataJSON, time.Now(), + ) + if err != nil { + return fmt.Errorf("添加过程详情失败: %w", err) + } + + return nil +} + +// GetProcessDetails 获取消息的过程详情 +func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) { + rows, err := db.Query( + "SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE message_id = ? ORDER BY created_at ASC", + messageID, + ) + if err != nil { + return nil, fmt.Errorf("查询过程详情失败: %w", err) + } + defer rows.Close() + + var details []ProcessDetail + for rows.Next() { + var detail ProcessDetail + var createdAt string + + if err := rows.Scan(&detail.ID, &detail.MessageID, &detail.ConversationID, &detail.EventType, &detail.Message, &detail.Data, &createdAt); err != nil { + return nil, fmt.Errorf("扫描过程详情失败: %w", err) + } + + // 尝试多种时间格式解析 + var err error + detail.CreatedAt, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt) + if err != nil { + detail.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAt) + } + if err != nil { + detail.CreatedAt, err = time.Parse(time.RFC3339, createdAt) + } + + details = append(details, detail) + } + + return details, nil +} + +// GetProcessDetailsByConversation 获取对话的所有过程详情(按消息分组) +func (db *DB) GetProcessDetailsByConversation(conversationID string) (map[string][]ProcessDetail, error) { + rows, err := db.Query( + "SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE conversation_id = ? ORDER BY created_at ASC", + conversationID, + ) + if err != nil { + return nil, fmt.Errorf("查询过程详情失败: %w", err) + } + defer rows.Close() + + detailsMap := make(map[string][]ProcessDetail) + for rows.Next() { + var detail ProcessDetail + var createdAt string + + if err := rows.Scan(&detail.ID, &detail.MessageID, &detail.ConversationID, &detail.EventType, &detail.Message, &detail.Data, &createdAt); err != nil { + return nil, fmt.Errorf("扫描过程详情失败: %w", err) + } + + // 尝试多种时间格式解析 + var err error + detail.CreatedAt, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt) + if err != nil { + detail.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAt) + } + if err != nil { + detail.CreatedAt, err = time.Parse(time.RFC3339, createdAt) + } + + detailsMap[detail.MessageID] = append(detailsMap[detail.MessageID], detail) + } + + return detailsMap, nil +} + diff --git a/internal/database/database.go b/internal/database/database.go index 5091e5ef..0cf0e7e7 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -61,10 +61,26 @@ func (db *DB) initTables() error { FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE );` + // 创建过程详情表 + createProcessDetailsTable := ` + CREATE TABLE IF NOT EXISTS process_details ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + event_type TEXT NOT NULL, + message TEXT, + data TEXT, + created_at DATETIME NOT NULL, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE + );` + // 创建索引 createIndexes := ` CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id); CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at); + CREATE INDEX IF NOT EXISTS idx_process_details_message_id ON process_details(message_id); + CREATE INDEX IF NOT EXISTS idx_process_details_conversation_id ON process_details(conversation_id); ` if _, err := db.Exec(createConversationsTable); err != nil { @@ -75,6 +91,10 @@ func (db *DB) initTables() error { return fmt.Errorf("创建messages表失败: %w", err) } + if _, err := db.Exec(createProcessDetailsTable); err != nil { + return fmt.Errorf("创建process_details表失败: %w", err) + } + if _, err := db.Exec(createIndexes); err != nil { return fmt.Errorf("创建索引失败: %w", err) } diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 50974318..9d33d86d 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -220,9 +220,29 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) { h.logger.Error("保存用户消息失败", zap.Error(err)) } - // 创建进度回调函数 + // 预先创建助手消息,以便关联过程详情 + assistantMsg, err := h.db.AddMessage(conversationID, "assistant", "处理中...", nil) + if err != nil { + h.logger.Error("创建助手消息失败", zap.Error(err)) + // 如果创建失败,继续执行但不保存过程详情 + assistantMsg = nil + } + + // 创建进度回调函数,同时保存到数据库 + var assistantMessageID string + if assistantMsg != nil { + assistantMessageID = assistantMsg.ID + } + progressCallback := func(eventType, message string, data interface{}) { sendEvent(eventType, message, data) + + // 保存过程详情到数据库(排除response和done事件,它们会在后面单独处理) + if assistantMessageID != "" && eventType != "response" && eventType != "done" { + if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil { + h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType)) + } + } } // 执行Agent Loop,传入进度回调 @@ -231,20 +251,44 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) { if err != nil { h.logger.Error("Agent Loop执行失败", zap.Error(err)) sendEvent("error", "执行失败: "+err.Error(), nil) + // 保存错误事件 + if assistantMessageID != "" { + h.db.AddProcessDetail(assistantMessageID, conversationID, "error", "执行失败: "+err.Error(), nil) + } sendEvent("done", "", nil) return } - // 保存助手回复 - _, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs) - if err != nil { - h.logger.Error("保存助手消息失败", zap.Error(err)) + // 更新助手消息内容 + if assistantMsg != nil { + _, err = h.db.Exec( + "UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?", + result.Response, + func() string { + if len(result.MCPExecutionIDs) > 0 { + jsonData, _ := json.Marshal(result.MCPExecutionIDs) + return string(jsonData) + } + return "" + }(), + assistantMessageID, + ) + if err != nil { + h.logger.Error("更新助手消息失败", zap.Error(err)) + } + } else { + // 如果之前创建失败,现在创建 + _, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs) + if err != nil { + h.logger.Error("保存助手消息失败", zap.Error(err)) + } } // 发送最终响应 sendEvent("response", result.Response, map[string]interface{}{ "mcpExecutionIds": result.MCPExecutionIDs, "conversationId": conversationID, + "messageId": assistantMessageID, // 包含消息ID,以便前端关联过程详情 }) sendEvent("done", "", map[string]interface{}{ "conversationId": conversationID, diff --git a/web/static/css/style.css b/web/static/css/style.css index f462ea5e..aa543643 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -167,17 +167,32 @@ header { cursor: pointer; transition: all 0.2s; border: 1px solid transparent; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + position: relative; } .conversation-item:hover { background: var(--bg-tertiary); } +.conversation-item:hover .conversation-delete-btn { + opacity: 1; +} + .conversation-item.active { background: var(--bg-primary); border-color: var(--accent-color); } +.conversation-content { + flex: 1; + min-width: 0; + overflow: hidden; +} + .conversation-title { font-size: 0.875rem; font-weight: 500; @@ -193,6 +208,53 @@ header { color: var(--text-muted); } +.conversation-delete-btn { + width: 28px; + height: 28px; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + opacity: 0; + transition: all 0.2s ease; + flex-shrink: 0; + position: relative; +} + +.conversation-delete-btn svg { + width: 14px; + height: 14px; + transition: all 0.2s ease; +} + +.conversation-delete-btn:hover { + background: rgba(220, 53, 69, 0.1); + color: var(--error-color); + opacity: 1; +} + +.conversation-delete-btn:hover svg { + transform: scale(1.1); +} + +.conversation-delete-btn:active { + background: rgba(220, 53, 69, 0.2); + transform: scale(0.95); +} + +.conversation-item.active .conversation-delete-btn { + opacity: 0.6; +} + +.conversation-item.active:hover .conversation-delete-btn { + opacity: 1; +} + /* 对话界面样式 */ .chat-container { display: flex; @@ -503,6 +565,40 @@ header { gap: 6px; } +.process-detail-btn { + background: rgba(156, 39, 176, 0.1) !important; + border-color: rgba(156, 39, 176, 0.3) !important; + color: #9c27b0 !important; +} + +.process-detail-btn:hover { + background: rgba(156, 39, 176, 0.2) !important; + border-color: #9c27b0 !important; + color: #7b1fa2 !important; +} + +.process-details-container { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-color); + width: 100%; +} + +.process-details-content { + width: 100%; +} + +.process-details-content .progress-timeline { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.process-details-content .progress-timeline.expanded { + max-height: 2000px; + overflow-y: auto; +} + .chat-input-container { display: flex; gap: 12px; @@ -813,3 +909,229 @@ header { margin: 10% auto; } } + +/* 进度展示样式 */ +.progress-container { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + max-width: 100%; +} + +.progress-container.completed { + background: var(--bg-secondary); + border-color: var(--border-color); + opacity: 0.95; +} + +.progress-details { + margin-top: 8px; + margin-bottom: 24px; +} + +.progress-timeline-empty { + padding: 12px; + text-align: center; + color: var(--text-muted); + font-size: 0.875rem; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.progress-title { + font-weight: 600; + color: var(--text-primary); + font-size: 0.9375rem; +} + +.progress-toggle { + padding: 4px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.8125rem; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.progress-toggle:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.progress-timeline { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.progress-timeline.expanded { + max-height: 2000px; + overflow-y: auto; +} + +.timeline-item { + padding: 12px; + margin-bottom: 8px; + border-left: 3px solid var(--border-color); + padding-left: 16px; + background: var(--bg-secondary); + border-radius: 4px; + transition: all 0.2s; +} + +.timeline-item:hover { + background: var(--bg-tertiary); +} + +.timeline-item-iteration { + border-left-color: var(--accent-color); + background: rgba(0, 102, 255, 0.05); +} + +.timeline-item-thinking { + border-left-color: #9c27b0; + background: rgba(156, 39, 176, 0.05); +} + +.timeline-item-tool_call { + border-left-color: #ff9800; + background: rgba(255, 152, 0, 0.05); +} + +.timeline-item-tool_result { + border-left-color: var(--success-color); + background: rgba(40, 167, 69, 0.05); +} + +.timeline-item-tool_result.error { + border-left-color: var(--error-color); + background: rgba(220, 53, 69, 0.05); +} + +.timeline-item-error { + border-left-color: var(--error-color); + background: rgba(220, 53, 69, 0.1); +} + +.timeline-item-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.timeline-item-time { + font-size: 0.75rem; + color: var(--text-muted); + font-family: monospace; + min-width: 70px; +} + +.timeline-item-title { + font-weight: 500; + color: var(--text-primary); + font-size: 0.875rem; + flex: 1; +} + +.timeline-item-content { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.tool-details { + display: flex; + flex-direction: column; + gap: 12px; +} + +.tool-arg-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tool-arg-section strong { + color: var(--text-primary); + font-size: 0.8125rem; +} + +.tool-args { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.8125rem; + line-height: 1.5; + overflow-x: auto; + margin: 0; + color: var(--text-primary); +} + +.tool-result-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tool-result-section strong { + color: var(--text-primary); + font-size: 0.8125rem; +} + +.tool-result { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.8125rem; + line-height: 1.5; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; + color: var(--text-primary); +} + +.tool-result-section.error .tool-result { + background: rgba(220, 53, 69, 0.1); + border-color: var(--error-color); + color: var(--error-color); +} + +.tool-result-section.success .tool-result { + background: rgba(40, 167, 69, 0.1); + border-color: var(--success-color); +} + +.tool-execution-id { + margin-top: 8px; + font-size: 0.75rem; + color: var(--text-muted); +} + +.tool-execution-id code { + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 3px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + color: var(--text-secondary); +} diff --git a/web/static/js/app.js b/web/static/js/app.js index 50bbc9ba..6b0b61fb 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -15,10 +15,9 @@ async function sendMessage() { addMessage('user', message); input.value = ''; - // 创建进度消息容器 - const progressId = addMessage('system', '正在处理中...'); + // 创建进度消息容器(使用详细的进度展示) + const progressId = addProgressMessage(); const progressElement = document.getElementById(progressId); - const progressBubble = progressElement.querySelector('.message-bubble'); let assistantMessageId = null; let mcpExecutionIds = []; @@ -54,7 +53,7 @@ async function sendMessage() { if (line.startsWith('data: ')) { try { const eventData = JSON.parse(line.slice(6)); - handleStreamEvent(eventData, progressElement, progressBubble, progressId, + handleStreamEvent(eventData, progressElement, progressId, () => assistantMessageId, (id) => { assistantMessageId = id; }, () => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; }); } catch (e) { @@ -71,7 +70,7 @@ async function sendMessage() { if (line.startsWith('data: ')) { try { const eventData = JSON.parse(line.slice(6)); - handleStreamEvent(eventData, progressElement, progressBubble, progressId, + handleStreamEvent(eventData, progressElement, progressId, () => assistantMessageId, (id) => { assistantMessageId = id; }, () => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; }); } catch (e) { @@ -87,13 +86,243 @@ async function sendMessage() { } } +// 创建进度消息容器 +function addProgressMessage() { + const messagesDiv = document.getElementById('chat-messages'); + const messageDiv = document.createElement('div'); + messageCounter++; + const id = 'progress-' + Date.now() + '-' + messageCounter; + messageDiv.id = id; + messageDiv.className = 'message system progress-message'; + + const contentWrapper = document.createElement('div'); + contentWrapper.className = 'message-content'; + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble progress-container'; + bubble.innerHTML = ` +
+ 🔍 渗透测试进行中... + +
+
+ `; + + contentWrapper.appendChild(bubble); + messageDiv.appendChild(contentWrapper); + messagesDiv.appendChild(messageDiv); + messagesDiv.scrollTop = messagesDiv.scrollHeight; + + return id; +} + +// 切换进度详情显示 +function toggleProgressDetails(progressId) { + const timeline = document.getElementById(progressId + '-timeline'); + const toggleBtn = document.querySelector(`#${progressId} .progress-toggle`); + + if (!timeline || !toggleBtn) return; + + if (timeline.classList.contains('expanded')) { + timeline.classList.remove('expanded'); + toggleBtn.textContent = '展开详情'; + } else { + timeline.classList.add('expanded'); + toggleBtn.textContent = '收起详情'; + } +} + +// 将进度详情集成到工具调用区域 +function integrateProgressToMCPSection(progressId, assistantMessageId) { + const progressElement = document.getElementById(progressId); + if (!progressElement) return; + + // 获取时间线内容 + const timeline = document.getElementById(progressId + '-timeline'); + let timelineHTML = ''; + if (timeline) { + timelineHTML = timeline.innerHTML; + } + + // 获取助手消息元素 + const assistantElement = document.getElementById(assistantMessageId); + if (!assistantElement) { + removeMessage(progressId); + return; + } + + // 查找MCP调用区域 + const mcpSection = assistantElement.querySelector('.mcp-call-section'); + if (!mcpSection) { + // 如果没有MCP区域,创建详情组件放在消息下方 + convertProgressToDetails(progressId, assistantMessageId); + return; + } + + // 获取时间线内容 + const hasContent = timelineHTML.trim().length > 0; + + // 确保按钮容器存在 + let buttonsContainer = mcpSection.querySelector('.mcp-call-buttons'); + if (!buttonsContainer) { + buttonsContainer = document.createElement('div'); + buttonsContainer.className = 'mcp-call-buttons'; + mcpSection.appendChild(buttonsContainer); + } + + // 创建详情容器,放在MCP按钮区域下方(统一结构) + const detailsId = 'process-details-' + assistantMessageId; + let detailsContainer = document.getElementById(detailsId); + + if (!detailsContainer) { + detailsContainer = document.createElement('div'); + detailsContainer.id = detailsId; + detailsContainer.className = 'process-details-container'; + // 确保容器在按钮容器之后 + if (buttonsContainer.nextSibling) { + mcpSection.insertBefore(detailsContainer, buttonsContainer.nextSibling); + } else { + mcpSection.appendChild(detailsContainer); + } + } + + // 设置详情内容 + detailsContainer.innerHTML = ` +
+ ${hasContent ? `
${timelineHTML}
` : '
暂无过程详情
'} +
+ `; + + // 移除原来的进度消息 + removeMessage(progressId); +} + +// 切换过程详情显示 +function toggleProcessDetails(progressId, assistantMessageId) { + const detailsId = 'process-details-' + assistantMessageId; + const detailsContainer = document.getElementById(detailsId); + if (!detailsContainer) return; + + const content = detailsContainer.querySelector('.process-details-content'); + const timeline = detailsContainer.querySelector('.progress-timeline'); + const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`); + + if (content && timeline) { + if (timeline.classList.contains('expanded')) { + timeline.classList.remove('expanded'); + if (btn) btn.innerHTML = '📋 过程详情'; + } else { + timeline.classList.add('expanded'); + if (btn) btn.innerHTML = '📋 收起详情'; + } + } else if (timeline) { + // 如果只有timeline,直接切换 + if (timeline.classList.contains('expanded')) { + timeline.classList.remove('expanded'); + if (btn) btn.innerHTML = '📋 过程详情'; + } else { + timeline.classList.add('expanded'); + if (btn) btn.innerHTML = '📋 收起详情'; + } + } +} + +// 将进度消息转换为可折叠的详情组件 +function convertProgressToDetails(progressId, assistantMessageId) { + const progressElement = document.getElementById(progressId); + if (!progressElement) return; + + // 获取时间线内容 + const timeline = document.getElementById(progressId + '-timeline'); + // 即使时间线不存在,也创建详情组件(显示空状态) + let timelineHTML = ''; + if (timeline) { + timelineHTML = timeline.innerHTML; + } + + // 获取助手消息元素 + const assistantElement = document.getElementById(assistantMessageId); + if (!assistantElement) { + removeMessage(progressId); + return; + } + + // 创建详情组件 + const detailsId = 'details-' + Date.now() + '-' + messageCounter++; + const detailsDiv = document.createElement('div'); + detailsDiv.id = detailsId; + detailsDiv.className = 'message system progress-details'; + + const contentWrapper = document.createElement('div'); + contentWrapper.className = 'message-content'; + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble progress-container completed'; + + // 获取时间线HTML内容 + const hasContent = timelineHTML.trim().length > 0; + + // 总是显示详情组件,即使没有内容也显示 + bubble.innerHTML = ` +
+ 📋 渗透测试详情 + ${hasContent ? `` : ''} +
+ ${hasContent ? `
${timelineHTML}
` : '
暂无过程详情(可能执行过快或未触发详细事件)
'} + `; + + contentWrapper.appendChild(bubble); + detailsDiv.appendChild(contentWrapper); + + // 将详情组件插入到助手消息之后 + const messagesDiv = document.getElementById('chat-messages'); + // assistantElement 是消息div,需要插入到它的下一个兄弟节点之前 + if (assistantElement.nextSibling) { + messagesDiv.insertBefore(detailsDiv, assistantElement.nextSibling); + } else { + // 如果没有下一个兄弟节点,直接追加 + messagesDiv.appendChild(detailsDiv); + } + + // 移除原来的进度消息 + removeMessage(progressId); + + // 滚动到底部 + messagesDiv.scrollTop = messagesDiv.scrollHeight; +} + // 处理流式事件 -function handleStreamEvent(event, progressElement, progressBubble, progressId, +function handleStreamEvent(event, progressElement, progressId, getAssistantId, setAssistantId, getMcpIds, setMcpIds) { + const timeline = document.getElementById(progressId + '-timeline'); + if (!timeline) return; + switch (event.type) { - case 'progress': - // 更新进度消息 - progressBubble.textContent = event.message; + case 'iteration': + // 添加迭代标记 + addTimelineItem(timeline, 'iteration', { + title: `第 ${event.data?.iteration || 1} 轮迭代`, + message: event.message, + data: event.data + }); + break; + + case 'thinking': + // 显示AI思考内容 + addTimelineItem(timeline, 'thinking', { + title: '🤔 AI思考', + message: event.message, + data: event.data + }); + break; + + case 'tool_calls_detected': + // 工具调用检测 + addTimelineItem(timeline, 'tool_calls_detected', { + title: `🔧 检测到 ${event.data?.count || 0} 个工具调用`, + message: event.message, + data: event.data + }); break; case 'tool_call': @@ -102,7 +331,12 @@ function handleStreamEvent(event, progressElement, progressBubble, progressId, const toolName = toolInfo.toolName || '未知工具'; const index = toolInfo.index || 0; const total = toolInfo.total || 0; - progressBubble.innerHTML = `🔧 正在调用工具: ${escapeHtml(toolName)} (${index}/${total})`; + addTimelineItem(timeline, 'tool_call', { + title: `🔧 调用工具: ${escapeHtml(toolName)} (${index}/${total})`, + message: event.message, + data: toolInfo, + expanded: false + }); break; case 'tool_result': @@ -111,12 +345,24 @@ function handleStreamEvent(event, progressElement, progressBubble, progressId, const resultToolName = resultInfo.toolName || '未知工具'; const success = resultInfo.success !== false; const statusIcon = success ? '✅' : '❌'; - progressBubble.innerHTML = `${statusIcon} 工具 ${escapeHtml(resultToolName)} 执行${success ? '完成' : '失败'}`; + addTimelineItem(timeline, 'tool_result', { + title: `${statusIcon} 工具 ${escapeHtml(resultToolName)} 执行${success ? '完成' : '失败'}`, + message: event.message, + data: resultInfo, + expanded: false + }); + break; + + case 'progress': + // 更新进度状态 + const progressTitle = document.querySelector(`#${progressId} .progress-title`); + if (progressTitle) { + progressTitle.textContent = '🔍 ' + event.message; + } break; case 'response': - // 移除进度消息,显示最终回复 - removeMessage(progressId); + // 先添加助手回复 const responseData = event.data || {}; const mcpIds = responseData.mcpExecutionIds || []; setMcpIds(mcpIds); @@ -127,24 +373,31 @@ function handleStreamEvent(event, progressElement, progressBubble, progressId, updateActiveConversation(); } - // 添加助手回复 - const assistantId = addMessage('assistant', event.message, mcpIds); + // 添加助手回复,并传入进度ID以便集成详情 + const assistantId = addMessage('assistant', event.message, mcpIds, progressId); setAssistantId(assistantId); + // 将进度详情集成到工具调用区域 + integrateProgressToMCPSection(progressId, assistantId); + // 刷新对话列表 loadConversations(); break; case 'error': // 显示错误 - removeMessage(progressId); - addMessage('system', '错误: ' + event.message); + addTimelineItem(timeline, 'error', { + title: '❌ 错误', + message: event.message, + data: event.data + }); break; case 'done': - // 完成,确保进度消息已移除 - if (progressElement && progressElement.parentNode) { - removeMessage(progressId); + // 完成,更新进度标题(如果进度消息还存在) + const doneTitle = document.querySelector(`#${progressId} .progress-title`); + if (doneTitle) { + doneTitle.textContent = '✅ 渗透测试完成'; } // 更新对话ID if (event.data && event.data.conversationId) { @@ -153,13 +406,72 @@ function handleStreamEvent(event, progressElement, progressBubble, progressId, } break; } + + // 自动滚动到底部 + const messagesDiv = document.getElementById('chat-messages'); + messagesDiv.scrollTop = messagesDiv.scrollHeight; +} + +// 添加时间线项目 +function addTimelineItem(timeline, type, options) { + const item = document.createElement('div'); + item.className = `timeline-item timeline-item-${type}`; + + const time = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + + let content = ` +
+ ${time} + ${options.title} +
+ `; + + // 根据类型添加详细内容 + if (type === 'thinking' && options.message) { + content += `
${formatMarkdown(options.message)}
`; + } else if (type === 'tool_call' && options.data) { + const data = options.data; + const args = data.argumentsObj || (data.arguments ? JSON.parse(data.arguments) : {}); + content += ` +
+
+
+ 参数: +
${JSON.stringify(args, null, 2)}
+
+
+
+ `; + } else if (type === 'tool_result' && options.data) { + const data = options.data; + const isError = data.isError || !data.success; + const result = data.result || data.error || '无结果'; + content += ` +
+
+ 执行结果: +
${escapeHtml(result)}
+ ${data.executionId ? `
执行ID: ${data.executionId}
` : ''} +
+
+ `; + } + + item.innerHTML = content; + timeline.appendChild(item); + + // 自动展开详情 + const expanded = timeline.classList.contains('expanded'); + if (!expanded && (type === 'tool_call' || type === 'tool_result')) { + // 对于工具调用和结果,默认显示摘要 + } } // 消息计数器,确保ID唯一 let messageCounter = 0; // 添加消息 -function addMessage(role, content, mcpExecutionIds = null) { +function addMessage(role, content, mcpExecutionIds = null, progressId = null) { const messagesDiv = document.getElementById('chat-messages'); const messageDiv = document.createElement('div'); messageCounter++; @@ -238,6 +550,17 @@ function addMessage(role, content, mcpExecutionIds = null) { buttonsContainer.appendChild(detailBtn); }); + // 如果有进度ID,添加过程详情按钮 + if (progressId) { + const progressDetailBtn = document.createElement('button'); + progressDetailBtn.className = 'mcp-detail-btn process-detail-btn'; + progressDetailBtn.innerHTML = `📋 过程详情`; + progressDetailBtn.onclick = () => toggleProcessDetails(progressId, messageDiv.id); + buttonsContainer.appendChild(progressDetailBtn); + // 存储进度ID到消息元素 + messageDiv.dataset.progressId = progressId; + } + mcpSection.appendChild(buttonsContainer); contentWrapper.appendChild(mcpSection); } @@ -248,6 +571,131 @@ function addMessage(role, content, mcpExecutionIds = null) { return id; } +// 渲染过程详情 +function renderProcessDetails(messageId, processDetails) { + if (!processDetails || processDetails.length === 0) { + return; + } + + const messageElement = document.getElementById(messageId); + if (!messageElement) { + return; + } + + // 查找或创建MCP调用区域 + let mcpSection = messageElement.querySelector('.mcp-call-section'); + if (!mcpSection) { + mcpSection = document.createElement('div'); + mcpSection.className = 'mcp-call-section'; + + const contentWrapper = messageElement.querySelector('.message-content'); + if (contentWrapper) { + contentWrapper.appendChild(mcpSection); + } else { + return; + } + } + + // 确保有标签和按钮容器(统一结构) + let mcpLabel = mcpSection.querySelector('.mcp-call-label'); + let buttonsContainer = mcpSection.querySelector('.mcp-call-buttons'); + + // 如果没有标签,创建一个(当没有工具调用时) + if (!mcpLabel && !buttonsContainer) { + mcpLabel = document.createElement('div'); + mcpLabel.className = 'mcp-call-label'; + mcpLabel.textContent = '过程详情'; + mcpSection.appendChild(mcpLabel); + } + + // 如果没有按钮容器,创建一个 + if (!buttonsContainer) { + buttonsContainer = document.createElement('div'); + buttonsContainer.className = 'mcp-call-buttons'; + mcpSection.appendChild(buttonsContainer); + } + + // 添加过程详情按钮(如果还没有) + let processDetailBtn = buttonsContainer.querySelector('.process-detail-btn'); + if (!processDetailBtn) { + processDetailBtn = document.createElement('button'); + processDetailBtn.className = 'mcp-detail-btn process-detail-btn'; + processDetailBtn.innerHTML = '📋 过程详情'; + processDetailBtn.onclick = () => toggleProcessDetails(null, messageId); + buttonsContainer.appendChild(processDetailBtn); + } + + // 创建过程详情容器(放在按钮容器之后) + const detailsId = 'process-details-' + messageId; + let detailsContainer = document.getElementById(detailsId); + + if (!detailsContainer) { + detailsContainer = document.createElement('div'); + detailsContainer.id = detailsId; + detailsContainer.className = 'process-details-container'; + // 确保容器在按钮容器之后 + if (buttonsContainer.nextSibling) { + mcpSection.insertBefore(detailsContainer, buttonsContainer.nextSibling); + } else { + mcpSection.appendChild(detailsContainer); + } + } + + // 创建时间线 + const timelineId = detailsId + '-timeline'; + let timeline = document.getElementById(timelineId); + + if (!timeline) { + const contentDiv = document.createElement('div'); + contentDiv.className = 'process-details-content'; + + timeline = document.createElement('div'); + timeline.id = timelineId; + timeline.className = 'progress-timeline'; + + contentDiv.appendChild(timeline); + detailsContainer.appendChild(contentDiv); + } + + // 清空时间线并重新渲染 + timeline.innerHTML = ''; + + // 渲染每个过程详情事件 + processDetails.forEach(detail => { + const eventType = detail.eventType || ''; + const title = detail.message || ''; + const data = detail.data || {}; + + // 根据事件类型渲染不同的内容 + let itemTitle = title; + if (eventType === 'iteration') { + itemTitle = `第 ${data.iteration || 1} 轮迭代`; + } else if (eventType === 'thinking') { + itemTitle = '🤔 AI思考'; + } else if (eventType === 'tool_calls_detected') { + itemTitle = `🔧 检测到 ${data.count || 0} 个工具调用`; + } else if (eventType === 'tool_call') { + const toolName = data.toolName || '未知工具'; + const index = data.index || 0; + const total = data.total || 0; + itemTitle = `🔧 调用工具: ${escapeHtml(toolName)} (${index}/${total})`; + } else if (eventType === 'tool_result') { + const toolName = data.toolName || '未知工具'; + const success = data.success !== false; + const statusIcon = success ? '✅' : '❌'; + itemTitle = `${statusIcon} 工具 ${escapeHtml(toolName)} 执行${success ? '完成' : '失败'}`; + } else if (eventType === 'error') { + itemTitle = '❌ 错误'; + } + + addTimelineItem(timeline, eventType, { + title: itemTitle, + message: detail.message || '', + data: data + }); + }); +} + // 移除消息 function removeMessage(id) { const messageDiv = document.getElementById(id); @@ -357,6 +805,23 @@ function escapeHtml(text) { return div.innerHTML; } +function formatMarkdown(text) { + if (typeof marked !== 'undefined') { + try { + marked.setOptions({ + breaks: true, + gfm: true, + }); + return marked.parse(text); + } catch (e) { + console.error('Markdown 解析失败:', e); + return escapeHtml(text).replace(/\n/g, '
'); + } + } else { + return escapeHtml(text).replace(/\n/g, '
'); + } +} + // 开始新对话 function startNewConversation() { currentConversationId = null; @@ -387,10 +852,14 @@ async function loadConversations() { item.classList.add('active'); } + // 创建内容容器 + const contentWrapper = document.createElement('div'); + contentWrapper.className = 'conversation-content'; + const title = document.createElement('div'); title.className = 'conversation-title'; title.textContent = conv.title || '未命名对话'; - item.appendChild(title); + contentWrapper.appendChild(title); const time = document.createElement('div'); time.className = 'conversation-time'; @@ -448,7 +917,25 @@ async function loadConversations() { } time.textContent = timeText; - item.appendChild(time); + contentWrapper.appendChild(time); + + item.appendChild(contentWrapper); + + // 创建删除按钮 + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'conversation-delete-btn'; + deleteBtn.innerHTML = ` + + + + `; + deleteBtn.title = '删除对话'; + deleteBtn.onclick = (e) => { + e.stopPropagation(); // 阻止触发对话加载 + deleteConversation(conv.id); + }; + item.appendChild(deleteBtn); item.onclick = () => loadConversation(conv.id); listContainer.appendChild(item); @@ -480,7 +967,14 @@ async function loadConversation(conversationId) { // 加载消息 if (conversation.messages && conversation.messages.length > 0) { conversation.messages.forEach(msg => { - addMessage(msg.role, msg.content, msg.mcpExecutionIds || []); + const messageId = addMessage(msg.role, msg.content, msg.mcpExecutionIds || []); + // 如果有过程详情,显示它们 + if (msg.processDetails && msg.processDetails.length > 0 && msg.role === 'assistant') { + // 延迟一下,确保消息已经渲染 + setTimeout(() => { + renderProcessDetails(messageId, msg.processDetails); + }, 100); + } }); } else { addMessage('assistant', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'); @@ -497,6 +991,38 @@ async function loadConversation(conversationId) { } } +// 删除对话 +async function deleteConversation(conversationId) { + // 确认删除 + if (!confirm('确定要删除这个对话吗?此操作不可恢复。')) { + return; + } + + try { + const response = await fetch(`/api/conversations/${conversationId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || '删除失败'); + } + + // 如果删除的是当前对话,清空对话界面 + if (conversationId === currentConversationId) { + currentConversationId = null; + document.getElementById('chat-messages').innerHTML = ''; + addMessage('assistant', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'); + } + + // 刷新对话列表 + loadConversations(); + } catch (error) { + console.error('删除对话失败:', error); + alert('删除对话失败: ' + error.message); + } +} + // 更新活动对话样式 function updateActiveConversation() { document.querySelectorAll('.conversation-item').forEach(item => {