diff --git a/internal/database/conversation.go b/internal/database/conversation.go index 432f870d..61c9fc79 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -103,6 +103,37 @@ func (db *DB) GetConversationByWebshellConnectionID(connectionID string) (*Conve return nil, fmt.Errorf("加载消息失败: %w", err) } conv.Messages = messages + + // 加载过程详情并附加到对应消息(与 GetConversation 一致,便于刷新后仍可查看执行过程) + processDetailsMap, err := db.GetProcessDetailsByConversation(conv.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 { + 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 } diff --git a/web/static/css/style.css b/web/static/css/style.css index a92a1acf..b4862a92 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -51,11 +51,14 @@ body { flex: 1; overflow: hidden; min-height: 0; + /* 主侧边栏与右侧内容之间预留水平间距,避免导航项文字贴到内容边框 */ + column-gap: 12px; } /* 主侧边栏样式 - 紧凑宽度,参考常见后台 200~220px */ .main-sidebar { - width: 208px; + /* 稍微拉宽侧边栏,给多语言菜单文案更多缓冲空间 */ + width: 224px; background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%); color: var(--text-primary); display: flex; @@ -164,7 +167,8 @@ body { display: flex; align-items: center; gap: 10px; - padding: 10px 16px; + /* 侧边栏导航项:左 16px 对齐图标,右 32px 预留更大安全间距,避免长文案贴边 */ + padding: 10px 32px 10px 16px; cursor: pointer; transition: all 0.2s ease; color: var(--text-primary); @@ -240,6 +244,9 @@ body { font-size: 0.9375rem; font-weight: 400; white-space: nowrap; + /* 防止长标题顶到边界:在右侧内边距内做省略而不是越界 */ + overflow: hidden; + text-overflow: ellipsis; opacity: 1; transition: opacity 0.2s ease; } @@ -9230,6 +9237,35 @@ header { max-height: 120px; overflow-y: auto; } +.webshell-ai-process-block.process-details-container { + margin-top: 8px; + margin-bottom: 8px; +} +.webshell-ai-process-toggle { + display: block; + width: 100%; + padding: 8px 12px; + text-align: left; + font-size: 0.9rem; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; +} +.webshell-ai-process-toggle:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} +.webshell-ai-process-block .process-details-content .progress-timeline { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} +.webshell-ai-process-block .process-details-content .progress-timeline.expanded { + max-height: 2000px; + overflow-y: auto; +} .webshell-ai-old-conv { width: 100%; margin-bottom: 8px; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 68dda4ac..401a3a78 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -171,7 +171,9 @@ "lastIterSummary": "Last iteration: generating summary and next steps...", "summaryDone": "Summary complete", "generatingFinalReply": "Generating final reply...", - "maxIterSummary": "Max iterations reached, generating summary..." + "maxIterSummary": "Max iterations reached, generating summary...", + "analyzingRequestShort": "Analyzing your request...", + "analyzingRequestPlanning": "Analyzing your request and planning test strategy..." }, "timeline": { "params": "Parameters:", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 33af68e9..f20a110d 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -171,7 +171,9 @@ "lastIterSummary": "最后一次迭代:正在生成总结和下一步计划...", "summaryDone": "总结生成完成", "generatingFinalReply": "正在生成最终回复...", - "maxIterSummary": "达到最大迭代次数,正在生成总结..." + "maxIterSummary": "达到最大迭代次数,正在生成总结...", + "analyzingRequestShort": "正在分析您的请求...", + "analyzingRequestPlanning": "开始分析请求并制定测试策略" }, "timeline": { "params": "参数:", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 99caad01..f50870b6 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2243,8 +2243,16 @@ async function deleteConversation(conversationId, skipConfirm = false) { await loadGroupConversations(currentGroupId); } - // 刷新对话列表 - loadConversations(); + // 刷新对话列表(使用分组接口以与其他入口一致) + if (typeof loadConversationsWithGroups === 'function') { + loadConversationsWithGroups(); + } else if (typeof loadConversations === 'function') { + loadConversations(); + } + // 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致 + try { + document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } })); + } catch (e) { /* ignore */ } } catch (error) { console.error('删除对话失败:', error); alert('删除对话失败: ' + error.message); @@ -6284,4 +6292,23 @@ document.addEventListener('DOMContentLoaded', async () => { } } }); + + // 任意入口删除对话后同步:若删除的是当前对话则清空主区,并刷新侧边栏列表(如从 WebShell AI 助手删除) + document.addEventListener('conversation-deleted', (e) => { + const id = e.detail && e.detail.conversationId; + if (!id) return; + if (id === currentConversationId) { + currentConversationId = null; + const messagesDiv = document.getElementById('chat-messages'); + if (messagesDiv) messagesDiv.innerHTML = ''; + const readyMsg = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; + addMessage('assistant', readyMsg, null, null, null, { systemReadyMessage: true }); + addAttackChainButton(null); + } + if (typeof loadConversationsWithGroups === 'function') { + loadConversationsWithGroups(); + } else if (typeof loadConversations === 'function') { + loadConversations(); + } + }); }); diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 7843ec0d..9a04cb88 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -36,12 +36,16 @@ function translateProgressMessage(message) { '总结生成完成': 'progress.summaryDone', '正在生成最终回复...': 'progress.generatingFinalReply', '达到最大迭代次数,正在生成总结...': 'progress.maxIterSummary', + '正在分析您的请求...': 'progress.analyzingRequestShort', + '开始分析请求并制定测试策略': 'progress.analyzingRequestPlanning', // 英文(与 en-US.json 一致,避免后端/缓存已是英文时无法随语言切换) 'Calling AI model...': 'progress.callingAI', 'Last iteration: generating summary and next steps...': 'progress.lastIterSummary', 'Summary complete': 'progress.summaryDone', 'Generating final reply...': 'progress.generatingFinalReply', - 'Max iterations reached, generating summary...': 'progress.maxIterSummary' + 'Max iterations reached, generating summary...': 'progress.maxIterSummary', + 'Analyzing your request...': 'progress.analyzingRequestShort', + 'Analyzing your request and planning test strategy...': 'progress.analyzingRequestPlanning' }; if (map[trim]) return window.t(map[trim]); const callingToolPrefixCn = '正在调用工具: '; diff --git a/web/static/js/roles.js b/web/static/js/roles.js index 129343c8..65d0e65b 100644 --- a/web/static/js/roles.js +++ b/web/static/js/roles.js @@ -57,7 +57,11 @@ async function loadRoles() { return roles; } catch (error) { console.error('加载角色失败:', error); - showNotification(_t('roles.loadFailed') + ': ' + error.message, 'error'); + // 提示文案使用 i18n;若此时 i18n 尚未初始化,则回退为可读中文,而不是暴露 key(roles.loadFailed) + var loadFailedLabel = (typeof window !== 'undefined' && typeof window.t === 'function') + ? window.t('roles.loadFailed') + : '加载角色失败'; + showNotification(loadFailedLabel + ': ' + error.message, 'error'); return []; } } diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 7642922b..fe615c1c 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -287,6 +287,80 @@ function formatWebshellAiConvDate(updatedAt) { return (d.getMonth() + 1) + '/' + d.getDate(); } +// 根据后端保存的 processDetail 构建一条时间线项的 HTML(与 appendTimelineItem 展示一致) +function buildWebshellTimelineItemFromDetail(detail) { + var eventType = detail.eventType || ''; + var title = detail.message || ''; + var data = detail.data || {}; + if (eventType === 'iteration') { + title = (typeof window.t === 'function') ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : ('第 ' + (data.iteration || 1) + ' 轮迭代'); + } else if (eventType === 'thinking') { + title = '🤔 ' + ((typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考'); + } else if (eventType === 'tool_calls_detected') { + title = '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : ('检测到 ' + (data.count || 0) + ' 个工具调用')); + } else if (eventType === 'tool_call') { + var tn = data.toolName || ((typeof window.t === 'function') ? window.t('chat.unknownTool') : '未知工具'); + var idx = data.index || 0; + var total = data.total || 0; + title = '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : ''))); + } else if (eventType === 'tool_result') { + var success = data.success !== false; + var tname = data.toolName || '工具'; + title = (success ? '✅ ' : '❌ ') + ((typeof window.t === 'function') ? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname })) : (tname + (success ? ' 执行完成' : ' 执行失败'))); + } else if (eventType === 'progress') { + title = (typeof window.translateProgressMessage === 'function') ? window.translateProgressMessage(detail.message || '') : (detail.message || ''); + } + var html = '' + escapeHtml(title || '') + ''; + if (eventType === 'tool_call' && data && (data.argumentsObj || data.arguments)) { + try { + var args = data.argumentsObj || (data.arguments ? JSON.parse(data.arguments) : null); + if (args && typeof args === 'object') { + var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:'; + html += '
' + escapeHtml(paramsLabel) + '
' + escapeHtml(JSON.stringify(args, null, 2)) + '
'; + } + } catch (e) {} + } else if (eventType === 'tool_result' && data) { + var isError = data.isError || data.success === false; + var noResultText = (typeof window.t === 'function') ? window.t('timeline.noResult') : '无结果'; + var result = data.result != null ? data.result : (data.error != null ? data.error : noResultText); + var resultStr = (typeof result === 'string') ? result : JSON.stringify(result); + var execResultLabel = (typeof window.t === 'function') ? window.t('timeline.executionResult') : '执行结果:'; + var execIdLabel = (typeof window.t === 'function') ? window.t('timeline.executionId') : '执行ID:'; + html += '
' + escapeHtml(execResultLabel) + '
' + escapeHtml(resultStr) + '
' + (data.executionId ? '
' + escapeHtml(execIdLabel) + ' ' + escapeHtml(String(data.executionId)) + '
' : '') + '
'; + } else if (detail.message && detail.message !== title) { + html += '
' + escapeHtml(detail.message) + '
'; + } + return html; +} + +// 渲染「执行过程及调用工具」折叠块(默认折叠,刷新后加载历史时保留并可展开) +function renderWebshellProcessDetailsBlock(processDetails, defaultCollapsed) { + if (!processDetails || processDetails.length === 0) return null; + var expandLabel = (typeof window.t === 'function') ? window.t('chat.expandDetail') : '展开详情'; + var collapseLabel = (typeof window.t === 'function') ? window.t('tasks.collapseDetail') : '收起详情'; + var headerLabel = (typeof window.t === 'function') ? (window.t('chat.penetrationTestDetail') || '执行过程及调用工具') : '执行过程及调用工具'; + var wrapper = document.createElement('div'); + wrapper.className = 'process-details-container webshell-ai-process-block'; + var collapsed = defaultCollapsed !== false; + wrapper.innerHTML = '
'; + var timeline = wrapper.querySelector('.progress-timeline'); + processDetails.forEach(function (d) { + var item = document.createElement('div'); + item.className = 'webshell-ai-timeline-item webshell-ai-timeline-' + (d.eventType || ''); + item.innerHTML = buildWebshellTimelineItemFromDetail(d); + timeline.appendChild(item); + }); + var toggleBtn = wrapper.querySelector('.webshell-ai-process-toggle'); + var toggleIcon = wrapper.querySelector('.ws-toggle-icon'); + toggleBtn.addEventListener('click', function () { + var isExpanded = timeline.classList.contains('expanded'); + timeline.classList.toggle('expanded'); + toggleBtn.setAttribute('aria-expanded', !isExpanded); + if (toggleIcon) toggleIcon.textContent = isExpanded ? '▶' : '▼'; + }); + return wrapper; +} + function fetchAndRenderWebshellAiConvList(conn, listEl) { if (!conn || !conn.id || !listEl || typeof apiFetch !== 'function') return Promise.resolve(); return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-conversations', { method: 'GET' }) @@ -313,15 +387,19 @@ function fetchAndRenderWebshellAiConvList(conn, listEl) { delBtn.addEventListener('click', function (e) { e.stopPropagation(); if (!confirm(wsT('webshell.aiDeleteConversationConfirm') || '确定删除该对话?')) return; - apiFetch('/api/conversations/' + encodeURIComponent(item.id), { method: 'DELETE' }) + var deletedId = item.id; + apiFetch('/api/conversations/' + encodeURIComponent(deletedId), { method: 'DELETE' }) .then(function (r) { if (r.ok) { - if (webshellAiConvMap[conn.id] === item.id) { + if (webshellAiConvMap[conn.id] === deletedId) { delete webshellAiConvMap[conn.id]; var msgs = document.getElementById('webshell-ai-messages'); if (msgs) msgs.innerHTML = ''; } fetchAndRenderWebshellAiConvList(conn, listEl); + try { + document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId: deletedId } })); + } catch (err) { /* ignore */ } } }) .catch(function (e) { console.warn('删除对话失败', e); }); @@ -361,6 +439,10 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) { } } messagesContainer.appendChild(div); + if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) { + var block = renderWebshellProcessDetailsBlock(msg.processDetails, true); + if (block) messagesContainer.appendChild(block); + } }); if (list.length === 0) { var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; @@ -539,7 +621,7 @@ function selectWebshell(id) { initWebshellTerminal(conn); } -// 加载 WebShell 连接的 AI 助手对话历史(持久化展示),返回 Promise 供 .then 更新工具栏等 +// 加载 WebShell 连接的 AI 助手对话历史(持久化展示),返回 Promise 供 .then 更新工具栏等;含 processDetails 时渲染折叠的「执行过程及调用工具」 function loadWebshellAiHistory(conn, messagesContainer) { if (!conn || !conn.id || !messagesContainer) return Promise.resolve(); if (typeof apiFetch !== 'function') return Promise.resolve(); @@ -564,6 +646,10 @@ function loadWebshellAiHistory(conn, messagesContainer) { } } messagesContainer.appendChild(div); + if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) { + var block = renderWebshellProcessDetailsBlock(msg.processDetails, true); + if (block) messagesContainer.appendChild(block); + } }); if (list.length === 0) { var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; @@ -702,11 +788,13 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { try { var eventData = JSON.parse(line.slice(6)); if (eventData.type === 'conversation' && eventData.data && eventData.data.conversationId) { - webshellAiConvMap[conn.id] = eventData.data.conversationId; + // 先把 conversationId 拿出来,避免后续异步回调里 eventData 被后续事件覆盖导致 undefined 报错 + var convId = eventData.data.conversationId; + webshellAiConvMap[conn.id] = convId; var listEl = document.getElementById('webshell-ai-conv-list'); if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () { listEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) { - el.classList.toggle('active', el.dataset.convId === eventData.data.conversationId); + el.classList.toggle('active', el.dataset.convId === convId); }); }); } else if (eventData.type === 'response') { @@ -733,7 +821,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { var iterTitle = (typeof window.t === 'function') ? window.t('chat.iterationRound', { n: iterN || 1 }) : (iterN ? ('第 ' + iterN + ' 轮迭代') : (eventData.message || '迭代')); - appendTimelineItem('iteration', '🔍 ' + iterTitle, eventData.message || '', eventData.data); + var iterMessage = eventData.message || ''; + if (iterMessage && typeof window.translateProgressMessage === 'function') { + iterMessage = window.translateProgressMessage(iterMessage); + } + appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, eventData.data); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'thinking' && eventData.message) { var thinkLabel = (typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考'; @@ -779,7 +871,38 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { }).then(function () { webshellAiSending = false; if (sendBtn) sendBtn.disabled = false; - if (assistantDiv.textContent === '…' && !streamingTarget) assistantDiv.textContent = '无回复内容'; + if (assistantDiv.textContent === '…' && !streamingTarget) { + // 没有任何 response 内容,保持纯文本提示 + assistantDiv.textContent = '无回复内容'; + } else if (streamingTarget) { + // 流式结束:先终止当前打字机循环,避免后续 tick 把 HTML 覆盖回纯文本 + webshellStreamingTypingId += 1; + // 再使用 Markdown 渲染完整内容 + if (typeof formatMarkdown === 'function') { + assistantDiv.innerHTML = formatMarkdown(streamingTarget); + } else { + assistantDiv.textContent = streamingTarget; + } + } + // 生成结果后:将执行过程折叠并保留,供后续查看;统一放在「助手回复下方」(与刷新后加载历史一致,最佳实践) + if (timelineContainer && timelineContainer.classList.contains('has-items') && !timelineContainer.closest('.webshell-ai-process-block')) { + var headerLabel = (typeof window.t === 'function') ? (window.t('chat.penetrationTestDetail') || '执行过程及调用工具') : '执行过程及调用工具'; + var wrap = document.createElement('div'); + wrap.className = 'process-details-container webshell-ai-process-block'; + wrap.innerHTML = '
'; + var contentDiv = wrap.querySelector('.process-details-content'); + contentDiv.appendChild(timelineContainer); + timelineContainer.classList.add('progress-timeline'); + messagesContainer.insertBefore(wrap, assistantDiv.nextSibling); + var toggleBtn = wrap.querySelector('.webshell-ai-process-toggle'); + var toggleIcon = wrap.querySelector('.ws-toggle-icon'); + toggleBtn.addEventListener('click', function () { + var isExpanded = timelineContainer.classList.contains('expanded'); + timelineContainer.classList.toggle('expanded'); + toggleBtn.setAttribute('aria-expanded', !isExpanded); + if (toggleIcon) toggleIcon.textContent = isExpanded ? '▶' : '▼'; + }); + } messagesContainer.scrollTop = messagesContainer.scrollHeight; }); } @@ -1569,6 +1692,19 @@ document.addEventListener('languagechange', function () { refreshWebshellUIOnLanguageChange(); }); +// 任意入口删除对话后同步:若当前在 WebShell AI 助手且已选连接,则刷新对话列表(与 Chat 侧边栏删除保持一致) +document.addEventListener('conversation-deleted', function (e) { + var id = e.detail && e.detail.conversationId; + if (!id || !currentWebshellId || !webshellCurrentConn) return; + var listEl = document.getElementById('webshell-ai-conv-list'); + if (listEl) fetchAndRenderWebshellAiConvList(webshellCurrentConn, listEl); + if (webshellAiConvMap[webshellCurrentConn.id] === id) { + delete webshellAiConvMap[webshellCurrentConn.id]; + var msgs = document.getElementById('webshell-ai-messages'); + if (msgs) msgs.innerHTML = ''; + } +}); + // 测试连通性(不保存,仅用当前表单参数请求 Shell 执行 echo 1) function testWebshellConnection() { var url = (document.getElementById('webshell-url') || {}).value;