diff --git a/web/static/css/style.css b/web/static/css/style.css index 076075cd..bb34fdf0 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -2456,16 +2456,68 @@ header { flex-shrink: 0; } -.mcp-call-buttons { +.mcp-call-buttons, +.mcp-call-toolbar { display: flex; flex-wrap: wrap; gap: 6px; + align-items: center; +} + +.mcp-tool-list { + display: none; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + width: 100%; +} + +.mcp-tool-list.expanded { + display: flex; +} + +.mcp-tools-toggle-btn { + background: rgba(25, 118, 210, 0.1) !important; + border-color: rgba(25, 118, 210, 0.35) !important; + color: #1976d2 !important; +} + +.mcp-call-toolbar .process-detail-btn, +.mcp-call-toolbar .mcp-tools-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + padding: 6px 12px; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.25; + box-sizing: border-box; + white-space: nowrap; + vertical-align: middle; +} + +.mcp-call-toolbar .process-detail-btn span, +.mcp-call-toolbar .mcp-tools-toggle-btn span { + display: inline-flex; + align-items: center; + line-height: 1.25; +} + +.mcp-tools-toggle-btn:hover { + background: rgba(25, 118, 210, 0.18) !important; + border-color: #1976d2 !important; + color: #1565c0 !important; } .process-detail-btn { background: rgba(156, 39, 176, 0.1) !important; border-color: rgba(156, 39, 176, 0.3) !important; color: #9c27b0 !important; +} + +.mcp-call-toolbar .process-detail-btn { display: inline-flex; align-items: center; gap: 6px; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 5187b0c1..30079a96 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -530,6 +530,8 @@ "noMatchTools": "No matching tools", "penetrationTestDetail": "Penetration test details", "expandDetail": "Expand details", + "toolExecutionsCount": "{{n}} tool runs", + "collapseToolExecutions": "Collapse tool runs", "noProcessDetail": "No process details (execution may be too fast or no detailed events)", "copyMessageTitle": "Copy message", "deleteTurnTitle": "Delete this turn", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index f4b6f6ec..497adab6 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -518,6 +518,8 @@ "noMatchTools": "没有匹配的工具", "penetrationTestDetail": "渗透测试详情", "expandDetail": "展开详情", + "toolExecutionsCount": "{{n}}次工具执行", + "collapseToolExecutions": "收起工具执行", "noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)", "copyMessageTitle": "复制消息内容", "deleteTurnTitle": "删除本轮对话", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 6adb9a4e..6bee7b80 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1,6 +1,39 @@ let currentConversationId = null; let loadConversationRequestSeq = 0; +/** 轻量会话 LRU 缓存:来回切换已加载会话时避免重复网络 + 全量 DOM 重建 */ +const CONVERSATION_LITE_CACHE_MAX = 12; +const conversationLiteCache = new Map(); + +function getConversationLiteFromCache(conversationId) { + if (!conversationId) return null; + const hit = conversationLiteCache.get(conversationId); + if (!hit) return null; + conversationLiteCache.delete(conversationId); + conversationLiteCache.set(conversationId, hit); + return hit; +} + +function putConversationLiteCache(conversationId, data) { + if (!conversationId || !data) return; + conversationLiteCache.delete(conversationId); + conversationLiteCache.set(conversationId, data); + while (conversationLiteCache.size > CONVERSATION_LITE_CACHE_MAX) { + const oldest = conversationLiteCache.keys().next().value; + conversationLiteCache.delete(oldest); + } +} + +function invalidateConversationLiteCache(conversationId) { + if (conversationId) { + conversationLiteCache.delete(conversationId); + } else { + conversationLiteCache.clear(); + } +} + +window.invalidateConversationLiteCache = invalidateConversationLiteCache; + // @ 提及相关状态 let mentionTools = []; let mentionToolsLoaded = false; @@ -886,6 +919,9 @@ async function sendMessage() { window.CyberStrikeChatScroll.onUserSendMessage(); } addMessage('user', displayMessage, null, null, null, { scroll: 'none' }); + if (currentConversationId) { + invalidateConversationLiteCache(currentConversationId); + } // 清除防抖定时器,防止在清空输入框后重新保存草稿 if (draftSaveTimer) { @@ -2027,31 +2063,13 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr // 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建) if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) { - const mcpSection = document.createElement('div'); - mcpSection.className = 'mcp-call-section'; - - const mcpLabel = document.createElement('div'); - mcpLabel.className = 'mcp-call-label'; - mcpLabel.textContent = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情'); - mcpSection.appendChild(mcpLabel); - - const buttonsContainer = document.createElement('div'); - buttonsContainer.className = 'mcp-call-buttons'; - - mcpExecutionIds.forEach((execId, index) => { - const detailBtn = document.createElement('button'); - detailBtn.className = 'mcp-detail-btn'; - detailBtn.dataset.execId = execId; - detailBtn.dataset.execIndex = String(index + 1); - detailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + ''; - detailBtn.onclick = () => showMCPDetail(execId); - buttonsContainer.appendChild(detailBtn); - }); - // 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求) - batchUpdateButtonToolNames(buttonsContainer, mcpExecutionIds); - - mcpSection.appendChild(buttonsContainer); - contentWrapper.appendChild(mcpSection); + if (options && options.deferMcpButtons) { + try { + messageDiv.dataset.pendingMcpExecutionIds = JSON.stringify(mcpExecutionIds); + } catch (e) { /* ignore */ } + } else { + appendMcpCallButtons(messageDiv, mcpExecutionIds); + } } messageDiv.appendChild(contentWrapper); @@ -2151,11 +2169,13 @@ function copyMessageToClipboard(messageDiv, button) { function showCopySuccess(button) { if (button) { const originalText = button.innerHTML; + button.dataset.copySuccessActive = '1'; button.innerHTML = '' + (typeof window.t === 'function' ? window.t('common.copied') : '已复制') + ''; button.style.color = '#10b981'; button.style.background = 'rgba(16, 185, 129, 0.1)'; button.style.borderColor = 'rgba(16, 185, 129, 0.3)'; setTimeout(() => { + delete button.dataset.copySuccessActive; button.innerHTML = originalText; button.style.color = ''; button.style.background = ''; @@ -2301,47 +2321,20 @@ function processDetailRowFingerprint(d) { } // 渲染过程详情 -function renderProcessDetails(messageId, processDetails) { +// options.append=true 时分页追加;options.markLoaded=false 时保留 lazy 标记(分页加载中) +function renderProcessDetails(messageId, processDetails, options) { + const renderOpts = options || {}; + const appendMode = !!renderOpts.append; + const markLoaded = renderOpts.markLoaded !== false; 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 = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情'); - mcpSection.appendChild(mcpLabel); - } else if (mcpLabel && mcpLabel.textContent !== ('📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情'))) { - // 如果标签存在但不是统一格式,更新它 - mcpLabel.textContent = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情'); - } - - // 如果没有按钮容器,创建一个 - if (!buttonsContainer) { - buttonsContainer = document.createElement('div'); - buttonsContainer.className = 'mcp-call-buttons'; - mcpSection.appendChild(buttonsContainer); - } + // 查找或创建 MCP 区域(工具栏 + 工具列表 + 迭代时间线 分区) + const chrome = ensureMcpCallSectionChrome(messageElement, messageId); + if (!chrome) return; + const { mcpSection, toolbar: buttonsContainer } = chrome; // 添加过程详情按钮(如果还没有) let processDetailBtn = buttonsContainer.querySelector('.process-detail-btn'); @@ -2352,17 +2345,20 @@ function renderProcessDetails(messageId, processDetails) { processDetailBtn.onclick = () => toggleProcessDetails(null, messageId); buttonsContainer.appendChild(processDetailBtn); } + syncMcpToolsToggleButton(messageElement); - // 创建过程详情容器(放在按钮容器之后) + // 创建过程详情容器(放在工具列表之后) const detailsId = 'process-details-' + messageId; let detailsContainer = document.getElementById(detailsId); + const toolListEl = chrome.toolList; if (!detailsContainer) { detailsContainer = document.createElement('div'); detailsContainer.id = detailsId; detailsContainer.className = 'process-details-container'; - // 确保容器在按钮容器之后 - if (buttonsContainer.nextSibling) { + if (toolListEl) { + toolListEl.after(detailsContainer); + } else if (buttonsContainer.nextSibling) { mcpSection.insertBefore(detailsContainer, buttonsContainer.nextSibling); } else { mcpSection.appendChild(detailsContainer); @@ -2391,17 +2387,21 @@ function renderProcessDetails(messageId, processDetails) { if (isLazyNotLoaded && !reasoningFromMessage) { detailsContainer.dataset.lazyNotLoaded = '1'; detailsContainer.dataset.loaded = '0'; - timeline.innerHTML = '
' + - (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + - '(点击后加载)
'; + const expandLabel = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情'; + let lazyHint = expandLabel + '(点击后加载迭代详情)'; + timeline.innerHTML = '
' + lazyHint + '
'; timeline.classList.remove('expanded'); + prefetchProcessDetailsSummaryHint(messageId, messageElement); return; } if (isLazyNotLoaded) { detailsContainer.dataset.lazyNotLoaded = '1'; detailsContainer.dataset.loaded = '0'; processDetails = []; - } else { + if (!appendMode) { + prefetchProcessDetailsSummaryHint(messageId, messageElement); + } + } else if (markLoaded) { detailsContainer.dataset.lazyNotLoaded = '0'; detailsContainer.dataset.loaded = '1'; } @@ -2413,15 +2413,16 @@ function renderProcessDetails(messageId, processDetails) { } // 如果没有processDetails或为空,显示空状态 if (!processDetails || processDetails.length === 0) { - // 显示空状态提示 - timeline.innerHTML = '
' + (typeof window.t === 'function' ? window.t('chat.noProcessDetail') : '暂无过程详情(可能执行过快或未触发详细事件)') + '
'; - // 默认折叠 - timeline.classList.remove('expanded'); + if (!appendMode) { + timeline.innerHTML = '
' + (typeof window.t === 'function' ? window.t('chat.noProcessDetail') : '暂无过程详情(可能执行过快或未触发详细事件)') + '
'; + timeline.classList.remove('expanded'); + } return; } - // 清空时间线并重新渲染 - timeline.innerHTML = ''; + if (!appendMode) { + timeline.innerHTML = ''; + } function processDetailAgentPrefix(d) { @@ -2430,14 +2431,12 @@ function renderProcessDetails(messageId, processDetails) { return s ? ('[' + s + '] ') : ''; } - // 渲染每个过程详情事件 - processDetails.forEach(detail => { + function renderOneProcessDetail(detail) { const eventType = detail.eventType || ''; const title = detail.message || ''; const data = detail.data || {}; const agPx = processDetailAgentPrefix(data); - // 根据事件类型渲染不同的内容 let itemTitle = title; if (eventType === 'iteration') { const n = data.iteration || 1; @@ -2530,15 +2529,38 @@ function renderProcessDetails(messageId, processDetails) { title: itemTitle, message: detail.message || '', data: data, - createdAt: detail.createdAt // 传递实际的事件创建时间 + createdAt: detail.createdAt }; if (eventType === 'tool_call' && data._mergedResult) { timelineOpts.mergedResult = data._mergedResult; } addTimelineItem(timeline, eventType, timelineOpts); - }); + } - if (isLazyNotLoaded && reasoningFromMessage) { + const TIMELINE_RENDER_BATCH = 40; + const renderTimelineBatch = (startIdx) => { + const endIdx = Math.min(startIdx + TIMELINE_RENDER_BATCH, processDetails.length); + for (let i = startIdx; i < endIdx; i++) { + renderOneProcessDetail(processDetails[i]); + } + if (endIdx < processDetails.length) { + requestAnimationFrame(() => renderTimelineBatch(endIdx)); + } else if (markLoaded) { + finishProcessDetailsRender(messageElement, processDetails, isLazyNotLoaded, timeline); + } + }; + if (processDetails.length > TIMELINE_RENDER_BATCH) { + renderTimelineBatch(0); + } else { + processDetails.forEach(renderOneProcessDetail); + if (markLoaded) { + finishProcessDetailsRender(messageElement, processDetails, isLazyNotLoaded, timeline); + } + } +} + +function finishProcessDetailsRender(messageElement, processDetails, isLazyNotLoaded, timeline) { + if (isLazyNotLoaded && getMessageReasoningContent(messageElement)) { const lazyHint = document.createElement('div'); lazyHint.className = 'progress-timeline-empty progress-timeline-lazy-hint'; lazyHint.textContent = (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + @@ -2546,15 +2568,12 @@ function renderProcessDetails(messageId, processDetails) { timeline.appendChild(lazyHint); } - // 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理) const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt'); const hasErrorOrCancelled = processDetails.some(d => d.eventType === 'error' || d.eventType === 'cancelled' ); if (hasErrorOrCancelled && !hasPendingHitlInDetails) { - // 确保时间线是折叠的 timeline.classList.remove('expanded'); - // 更新按钮文本为"展开详情" const processDetailBtn = messageElement.querySelector('.process-detail-btn'); if (processDetailBtn) { processDetailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + ''; @@ -2562,6 +2581,36 @@ function renderProcessDetails(messageId, processDetails) { } } +/** 懒加载折叠态:后台拉摘要,提示迭代规模而不加载全量详情 */ +function prefetchProcessDetailsSummaryHint(messageId, messageElement) { + if (!messageElement || !messageElement.dataset || !messageElement.dataset.backendMessageId) return; + const backendId = String(messageElement.dataset.backendMessageId).trim(); + if (!backendId || typeof apiFetch !== 'function') return; + const detailsContainer = document.getElementById('process-details-' + messageId); + if (!detailsContainer || detailsContainer.dataset.summaryFetched === '1') return; + detailsContainer.dataset.summaryFetched = '1'; + apiFetch('/api/messages/' + encodeURIComponent(backendId) + '/process-details?summary=1') + .then(async (res) => { + const j = await res.json().catch(() => ({})); + if (!res.ok || !j.summary) return; + const s = j.summary; + const timeline = detailsContainer.querySelector('.progress-timeline'); + if (!timeline || detailsContainer.dataset.loaded === '1') return; + const expandLabel = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情'; + let hint = expandLabel + '(点击后加载迭代详情)'; + if (s.maxIteration > 0) { + hint = expandLabel + '(共 ' + s.maxIteration + ' 轮迭代,' + (s.total || 0) + ' 条详情)'; + } else if (s.total > 0) { + hint = expandLabel + '(共 ' + (s.total || 0) + ' 条详情)'; + } + const empty = timeline.querySelector('.progress-timeline-empty'); + if (empty) { + empty.textContent = hint; + } + }) + .catch(() => {}); +} + // 移除消息 function removeMessage(id) { const messageDiv = document.getElementById(id); @@ -2623,6 +2672,201 @@ async function updateButtonWithToolName(button, executionId, index) { } } +function getPendingMcpExecutionCount(messageElement) { + if (!messageElement || !messageElement.dataset || !messageElement.dataset.pendingMcpExecutionIds) { + return 0; + } + try { + const ids = JSON.parse(messageElement.dataset.pendingMcpExecutionIds); + return Array.isArray(ids) ? ids.length : 0; + } catch (e) { + return 0; + } +} + +function getMcpExecutionCount(messageElement) { + const pending = getPendingMcpExecutionCount(messageElement); + if (pending > 0) return pending; + const toolList = messageElement && messageElement.querySelector('.mcp-tool-list'); + if (toolList) { + return toolList.querySelectorAll('.mcp-detail-btn[data-exec-id]').length; + } + return 0; +} + +function formatMcpToolsToggleLabel(count, expanded) { + if (expanded) { + if (typeof window.t === 'function') { + const s = window.t('chat.collapseToolExecutions'); + if (s && s !== 'chat.collapseToolExecutions') return s; + } + return '收起工具执行'; + } + if (typeof window.t === 'function') { + const s = window.t('chat.toolExecutionsCount', { n: count }); + if (s && s !== 'chat.toolExecutionsCount') return s; + } + return count + '次工具执行'; +} + +/** 渗透测试区:工具栏(展开详情 | N次工具执行)+ 独立工具列表 + 迭代时间线 */ +function ensureMcpCallSectionChrome(messageElement, messageId) { + const contentWrapper = messageElement && messageElement.querySelector('.message-content'); + if (!contentWrapper) return null; + + let mcpSection = messageElement.querySelector('.mcp-call-section'); + if (!mcpSection) { + mcpSection = document.createElement('div'); + mcpSection.className = 'mcp-call-section'; + const mcpLabel = document.createElement('div'); + mcpLabel.className = 'mcp-call-label'; + mcpLabel.textContent = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情'); + mcpSection.appendChild(mcpLabel); + contentWrapper.appendChild(mcpSection); + } else { + const mcpLabel = mcpSection.querySelector('.mcp-call-label'); + const labelText = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情'); + if (mcpLabel && mcpLabel.textContent !== labelText) { + mcpLabel.textContent = labelText; + } + } + + let toolbar = mcpSection.querySelector('.mcp-call-toolbar'); + const legacyButtons = mcpSection.querySelector('.mcp-call-buttons'); + if (!toolbar) { + toolbar = document.createElement('div'); + toolbar.className = 'mcp-call-toolbar'; + if (legacyButtons) { + const processBtn = legacyButtons.querySelector('.process-detail-btn'); + if (processBtn) toolbar.appendChild(processBtn); + mcpSection.replaceChild(toolbar, legacyButtons); + } else { + mcpSection.appendChild(toolbar); + } + } + + let toolList = mcpSection.querySelector('.mcp-tool-list'); + if (!toolList) { + toolList = document.createElement('div'); + toolList.className = 'mcp-tool-list'; + const detailsContainer = mcpSection.querySelector('.process-details-container'); + if (detailsContainer) { + mcpSection.insertBefore(toolList, detailsContainer); + } else { + toolbar.after(toolList); + } + } + + if (legacyButtons && legacyButtons.parentNode === mcpSection) { + legacyButtons.querySelectorAll('.mcp-detail-btn[data-exec-id]').forEach((btn) => toolList.appendChild(btn)); + legacyButtons.remove(); + } + + const clientId = messageId || messageElement.id; + if (clientId && !toolbar.querySelector('.process-detail-btn')) { + const processDetailBtn = document.createElement('button'); + processDetailBtn.className = 'mcp-detail-btn process-detail-btn'; + processDetailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + ''; + processDetailBtn.onclick = () => toggleProcessDetails(null, clientId); + toolbar.appendChild(processDetailBtn); + } + + return { mcpSection, toolbar, toolList }; +} + +function syncMcpToolsToggleButton(messageElement) { + if (!messageElement) return; + const chrome = ensureMcpCallSectionChrome(messageElement, messageElement.id); + if (!chrome) return; + const { toolbar, toolList } = chrome; + const count = getMcpExecutionCount(messageElement); + let toolsToggle = toolbar.querySelector('.mcp-tools-toggle-btn'); + if (count <= 0) { + if (toolsToggle) toolsToggle.remove(); + return; + } + if (!toolsToggle) { + toolsToggle = document.createElement('button'); + toolsToggle.type = 'button'; + toolsToggle.className = 'mcp-detail-btn mcp-tools-toggle-btn'; + toolsToggle.onclick = function (e) { + e.stopPropagation(); + toggleMcpToolList(messageElement.id); + }; + toolbar.appendChild(toolsToggle); + } + const expanded = toolList.classList.contains('expanded'); + toolsToggle.innerHTML = '' + formatMcpToolsToggleLabel(count, expanded) + ''; +} + +function toggleMcpToolList(assistantMessageId) { + const messageEl = document.getElementById(assistantMessageId); + if (!messageEl) return; + const chrome = ensureMcpCallSectionChrome(messageEl, assistantMessageId); + if (!chrome) return; + const { toolList } = chrome; + const willExpand = !toolList.classList.contains('expanded'); + if (willExpand) { + ensureMcpCallButtons(messageEl); + toolList.classList.add('expanded'); + } else { + toolList.classList.remove('expanded'); + } + syncMcpToolsToggleButton(messageEl); +} + +window.toggleMcpToolList = toggleMcpToolList; +window.syncMcpToolsToggleButton = syncMcpToolsToggleButton; +window.ensureMcpCallSectionChrome = ensureMcpCallSectionChrome; + +/** 将 MCP 工具按钮挂到独立工具列表,并批量解析工具名 */ +function appendMcpCallButtons(messageElement, executionIds) { + if (!messageElement || !Array.isArray(executionIds) || executionIds.length === 0) { + return; + } + const chrome = ensureMcpCallSectionChrome(messageElement, messageElement.id); + if (!chrome) return; + const toolList = chrome.toolList; + + executionIds.forEach((execId, index) => { + if (toolList.querySelector('.mcp-detail-btn[data-exec-id="' + CSS.escape(String(execId)) + '"]')) { + return; + } + const detailBtn = document.createElement('button'); + detailBtn.className = 'mcp-detail-btn'; + detailBtn.dataset.execId = execId; + detailBtn.dataset.execIndex = String(index + 1); + detailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + ''; + detailBtn.onclick = () => showMCPDetail(execId); + toolList.appendChild(detailBtn); + }); + batchUpdateButtonToolNames(toolList, executionIds); + syncMcpToolsToggleButton(messageElement); +} + +/** 历史会话懒加载:用户展开工具列表时再渲染工具按钮 */ +function ensureMcpCallButtons(messageElement) { + if (!messageElement || !messageElement.dataset || !messageElement.dataset.pendingMcpExecutionIds) { + return; + } + let executionIds; + try { + executionIds = JSON.parse(messageElement.dataset.pendingMcpExecutionIds); + } catch (e) { + delete messageElement.dataset.pendingMcpExecutionIds; + return; + } + if (!Array.isArray(executionIds) || executionIds.length === 0) { + delete messageElement.dataset.pendingMcpExecutionIds; + return; + } + appendMcpCallButtons(messageElement, executionIds); + delete messageElement.dataset.pendingMcpExecutionIds; +} + +window.ensureMcpCallButtons = ensureMcpCallButtons; +window.appendMcpCallButtons = appendMcpCallButtons; + // 批量获取工具名称并更新按钮(消除 N 次单独 API 请求,合并为 1 次) async function batchUpdateButtonToolNames(buttonsContainer, executionIds) { if (!executionIds || executionIds.length === 0) return; @@ -3182,40 +3426,63 @@ function getConversationGroup(dateObj, todayStart, sevenDaysCutoff, yesterdaySta } // 加载对话 -/** 轻量加载会话后,拉取最后一条助手消息的 process_details(机器人等无 SSE 场景) */ +/** 轻量加载会话后,仅对「处理中…」占位回复拉取过程详情(机器人等非 SSE 场景);已完成会话不预取全量 */ async function prefetchLastAssistantProcessDetails() { const nodes = document.querySelectorAll('#chat-messages .message.assistant'); if (!nodes.length) return; const last = nodes[nodes.length - 1]; if (!last || !last.id) return; + const bubble = last.querySelector('.message-bubble'); + const visibleText = bubble ? String(bubble.textContent || '').trim() : ''; + const isPlaceholder = visibleText === '处理中...' || visibleText === 'Processing...'; + if (!isPlaceholder) return; const container = document.getElementById('process-details-' + last.id); if (!container || container.dataset.lazyNotLoaded !== '1') return; const backendId = last.dataset && last.dataset.backendMessageId; if (!backendId || typeof apiFetch !== 'function') return; + if (typeof window.loadProcessDetailsPaginated === 'function') { + await window.loadProcessDetailsPaginated(last.id, backendId); + return; + } const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details'); const j = await res.json().catch(() => ({})); if (!res.ok || !Array.isArray(j.processDetails) || j.processDetails.length === 0) return; if (typeof renderProcessDetails === 'function') { renderProcessDetails(last.id, j.processDetails); } - if (typeof window.expandProcessDetailsTimeline === 'function') { - window.expandProcessDetailsTimeline(last.id); - } } async function loadConversation(conversationId) { const seq = ++loadConversationRequestSeq; try { - // 轻量加载:不带 processDetails,避免历史会话切换卡顿;展开详情时再按需拉取 - const response = await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`); - if (seq !== loadConversationRequestSeq) { - return; - } - const conversation = await response.json(); - - if (!response.ok) { - showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error'); - return; + const cachedConversation = getConversationLiteFromCache(conversationId); + const fetchPromise = apiFetch(`/api/conversations/${conversationId}?include_process_details=0`) + .then(async (response) => { + const data = await response.json(); + return { response, data }; + }); + + let conversation; + let response; + if (cachedConversation) { + conversation = cachedConversation; + fetchPromise.then(({ response: freshResp, data }) => { + if (freshResp.ok && data && seq === loadConversationRequestSeq && currentConversationId === conversationId) { + putConversationLiteCache(conversationId, data); + } + }).catch(() => {}); + } else { + const fetched = await fetchPromise; + response = fetched.response; + conversation = fetched.data; + if (seq !== loadConversationRequestSeq) { + return; + } + if (!response.ok) { + showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error'); + return; + } + putConversationLiteCache(conversationId, conversation); } if (seq !== loadConversationRequestSeq) { return; @@ -3265,11 +3532,15 @@ async function loadConversation(conversationId) { if (typeof refreshChatProjectSelector === 'function') { refreshChatProjectSelector(); } - if (typeof window.syncHitlConfigFromServer === 'function') { - await window.syncHitlConfigFromServer(conversationId); - } else { - refreshHitlConfigByCurrentConversation(); - } + refreshHitlConfigByCurrentConversation(); + const hitlSyncPromise = (typeof window.syncHitlConfigFromServer === 'function') + ? window.syncHitlConfigFromServer(conversationId).then(() => { + if (seq === loadConversationRequestSeq && currentConversationId === conversationId) { + refreshHitlConfigByCurrentConversation(); + } + }).catch(() => {}) + : Promise.resolve(); + void hitlSyncPromise; updateActiveConversation(); // 如果攻击链模态框打开且显示的不是当前对话,关闭它 @@ -3336,7 +3607,9 @@ async function loadConversation(conversationId) { // - user: createdAt 即可(发送后不会再更新) // - assistant: 如果后端提供 updatedAt(任务完成时写回),优先用它,避免占位消息“任务开始时间”误导 const msgTime = (msg && msg.role === 'assistant' && msg.updatedAt) ? msg.updatedAt : (msg ? msg.createdAt : null); - const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msgTime); + const mcpIds = (msg.mcpExecutionIds && Array.isArray(msg.mcpExecutionIds)) ? msg.mcpExecutionIds : []; + const addOpts = (msg.role === 'assistant' && mcpIds.length > 0) ? { deferMcpButtons: true } : null; + const messageId = addMessage(msg.role, displayContent, mcpIds, null, msgTime, addOpts); const messageEl = document.getElementById(messageId); if (messageEl && msg && msg.id) { messageEl.dataset.backendMessageId = String(msg.id); @@ -3504,6 +3777,7 @@ async function deleteConversationTurnFromUI(anchorBackendMessageId) { if (!response.ok) { throw new Error(data.error || data.message || 'delete failed'); } + invalidateConversationLiteCache(currentConversationId); await loadConversation(currentConversationId); if (typeof loadConversationsWithGroups === 'function') { loadConversationsWithGroups(); @@ -3550,6 +3824,7 @@ async function deleteConversation(conversationId, skipConfirm = false) { // 更新缓存 - 立即删除,确保后续加载时能正确识别 delete conversationGroupMappingCache[conversationId]; + invalidateConversationLiteCache(conversationId); // 同时从待保留映射中移除 delete pendingGroupMappings[conversationId]; @@ -7693,6 +7968,20 @@ function refreshChatPanelI18n() { const expanded = timeline && timeline.classList.contains('expanded'); span.textContent = expanded ? t('tasks.collapseDetail') : t('chat.expandDetail'); }); + const copyLabel = t('common.copy'); + const copyTitle = t('chat.copyMessageTitle'); + messagesEl.querySelectorAll('.message-copy-btn').forEach(function (btn) { + if (btn.dataset.copySuccessActive === '1') return; + const span = btn.querySelector('span'); + if (span) span.textContent = copyLabel; + btn.title = copyTitle; + btn.setAttribute('aria-label', copyTitle); + }); + messagesEl.querySelectorAll('.message.assistant').forEach(function (msgEl) { + if (typeof window.syncMcpToolsToggleButton === 'function') { + window.syncMcpToolsToggleButton(msgEl); + } + }); } if (isAppModalOpen('mcp-detail-modal')) { diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 5eb825c9..089b36f4 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -1259,101 +1259,60 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut return; } - // 查找或创建 MCP 区域 - let mcpSection = assistantElement.querySelector('.mcp-call-section'); - if (!mcpSection) { - mcpSection = document.createElement('div'); - mcpSection.className = 'mcp-call-section'; - const mcpLabel = document.createElement('div'); - mcpLabel.className = 'mcp-call-label'; - mcpLabel.textContent = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情'); - mcpSection.appendChild(mcpLabel); - const buttonsContainerInit = document.createElement('div'); - buttonsContainerInit.className = 'mcp-call-buttons'; - mcpSection.appendChild(buttonsContainerInit); - contentWrapper.appendChild(mcpSection); + // 查找或创建 MCP 区域(工具栏 + 工具列表 + 迭代时间线) + if (typeof window.ensureMcpCallSectionChrome === 'function') { + window.ensureMcpCallSectionChrome(assistantElement, assistantMessageId); } - - // 获取时间线内容 - const hasContent = timelineHTML.trim().length > 0; - - // 检查时间线中是否有错误项 - const hasError = timeline && timeline.querySelector('.timeline-item-error'); - - // 确保按钮容器存在 - let buttonsContainer = mcpSection.querySelector('.mcp-call-buttons'); - if (!buttonsContainer) { - buttonsContainer = document.createElement('div'); - buttonsContainer.className = 'mcp-call-buttons'; - mcpSection.appendChild(buttonsContainer); + const mcpSection = assistantElement.querySelector('.mcp-call-section'); + if (!mcpSection) { + removeMessage(progressId); + return; } - let maxExecIndex = 0; - const existingExecBtns = buttonsContainer.querySelectorAll('.mcp-detail-btn:not(.process-detail-btn)'); - existingExecBtns.forEach(function (btn) { - const n = parseInt(btn.dataset.execIndex, 10); - if (!isNaN(n) && n > maxExecIndex) maxExecIndex = n; - }); - const seenExec = new Set(); - existingExecBtns.forEach(function (btn) { - if (btn.dataset.execId) seenExec.add(String(btn.dataset.execId).trim()); - }); - let appendedAny = false; - if (mcpIds.length > 0) { - mcpIds.forEach(function (execId) { - const id = execId != null ? String(execId).trim() : ''; - if (!id || seenExec.has(id)) return; - seenExec.add(id); - maxExecIndex += 1; - appendedAny = true; - const detailBtn = document.createElement('button'); - detailBtn.className = 'mcp-detail-btn'; - detailBtn.dataset.execId = id; - detailBtn.dataset.execIndex = String(maxExecIndex); - detailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: maxExecIndex }) : '调用 #' + maxExecIndex) + ''; - detailBtn.onclick = function () { showMCPDetail(id); }; - buttonsContainer.appendChild(detailBtn); - }); - if (appendedAny && typeof batchUpdateButtonToolNames === 'function') { - batchUpdateButtonToolNames(buttonsContainer, mcpIds); - } + const hasContent = timelineHTML.trim().length > 0; + + if (mcpIds.length > 0 && typeof window.appendMcpCallButtons === 'function') { + window.appendMcpCallButtons(assistantElement, mcpIds); + const toolList = mcpSection.querySelector('.mcp-tool-list'); + if (toolList) toolList.classList.remove('expanded'); } - if (!buttonsContainer.querySelector('.process-detail-btn')) { + if (typeof window.syncMcpToolsToggleButton === 'function') { + window.syncMcpToolsToggleButton(assistantElement); + } + + const toolbar = mcpSection.querySelector('.mcp-call-toolbar'); + if (toolbar && !toolbar.querySelector('.process-detail-btn')) { const progressDetailBtn = document.createElement('button'); progressDetailBtn.className = 'mcp-detail-btn process-detail-btn'; progressDetailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + ''; progressDetailBtn.onclick = () => toggleProcessDetails(null, assistantMessageId); - buttonsContainer.appendChild(progressDetailBtn); + toolbar.appendChild(progressDetailBtn); } - - // 创建详情容器,放在MCP按钮区域下方(统一结构) + const detailsId = 'process-details-' + assistantMessageId; let detailsContainer = document.getElementById(detailsId); + const toolListEl = mcpSection.querySelector('.mcp-tool-list'); if (!detailsContainer) { detailsContainer = document.createElement('div'); detailsContainer.id = detailsId; detailsContainer.className = 'process-details-container'; - // 确保容器在按钮容器之后 - if (buttonsContainer.nextSibling) { - mcpSection.insertBefore(detailsContainer, buttonsContainer.nextSibling); + if (toolListEl) { + toolListEl.after(detailsContainer); } else { mcpSection.appendChild(detailsContainer); } } - // 设置详情内容(如果有错误,默认折叠;否则默认折叠) detailsContainer.innerHTML = `
${hasContent ? `
${timelineHTML}
` : '
' + (typeof window.t === 'function' ? window.t('chat.noProcessDetail') : '暂无过程详情(可能执行过快或未触发详细事件)') + '
'}
`; - // 确保初始状态是折叠的(默认折叠,特别是错误时) if (hasContent) { const timeline = document.getElementById(detailsId + '-timeline'); if (timeline) { - // 如果有错误,确保折叠;否则也默认折叠 timeline.classList.remove('expanded'); } @@ -1363,10 +1322,47 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut }); } - // 移除原来的进度消息(详情已快照到助手消息下的 process-details) removeMessage(progressId); } +const PROCESS_DETAILS_PAGE_SIZE = 100; + +/** + * 分页加载过程详情并增量渲染,避免数百轮迭代一次性阻塞主线程。 + */ +async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId) { + if (!assistantMessageId || !backendMessageId || typeof apiFetch !== 'function' || typeof renderProcessDetails !== 'function') { + return; + } + const PAGE = PROCESS_DETAILS_PAGE_SIZE; + let offset = 0; + let isFirst = true; + while (true) { + const res = await apiFetch( + '/api/messages/' + encodeURIComponent(String(backendMessageId)) + + '/process-details?limit=' + PAGE + '&offset=' + offset + ); + const j = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error((j && j.error) ? j.error : String(res.status)); + } + const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : []; + const hasMore = !!(j && j.hasMore); + renderProcessDetails(assistantMessageId, details, { + append: !isFirst, + markLoaded: !hasMore + }); + if (!hasMore || details.length === 0) { + break; + } + offset += details.length; + isFirst = false; + await new Promise((resolve) => requestAnimationFrame(resolve)); + } +} + +window.loadProcessDetailsPaginated = loadProcessDetailsPaginated; + // 切换过程详情显示 function toggleProcessDetails(progressId, assistantMessageId) { const detailsId = 'process-details-' + assistantMessageId; @@ -1383,26 +1379,17 @@ function toggleProcessDetails(progressId, assistantMessageId) { // 正在加载中,避免重复请求 } else { detailsContainer.dataset.loading = '1'; - // 先展开容器,显示加载态 const timeline = detailsContainer.querySelector('.progress-timeline'); if (timeline) { timeline.innerHTML = '
' + ((typeof window.t === 'function') ? window.t('common.loading') : '加载中…') + '
'; } - apiFetch(`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details`) - .then(async (res) => { - const j = await res.json().catch(() => ({})); - if (!res.ok) throw new Error((j && j.error) ? j.error : res.status); - const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : []; - // 重新渲染详情(renderProcessDetails 会清掉 lazy 标记并写入 loaded) - renderProcessDetails(assistantMessageId, details); - }) + loadProcessDetailsPaginated(assistantMessageId, backendMessageId) .catch((e) => { console.error('加载过程详情失败:', e); const tl = detailsContainer.querySelector('.progress-timeline'); if (tl) { tl.innerHTML = '
' + ((typeof window.t === 'function') ? window.t('chat.noProcessDetail') : '暂无过程详情(加载失败)') + '
'; } - // 失败时保留 lazy 状态,允许用户重试 detailsContainer.dataset.lazyNotLoaded = '1'; detailsContainer.dataset.loaded = '0'; }) @@ -2756,12 +2743,16 @@ async function restoreHitlInlineForConversation(conversationId) { if (detailsContainer.dataset.lazyNotLoaded === '1' && detailsContainer.dataset.loaded !== '1') { try { detailsContainer.dataset.loading = '1'; - const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details'); - const j = await res.json().catch(function () { return {}; }); - if (!res.ok) throw new Error((j && j.error) ? j.error : String(res.status)); - const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : []; - if (typeof renderProcessDetails === 'function') { - renderProcessDetails(clientMsgId, details); + if (typeof loadProcessDetailsPaginated === 'function') { + await loadProcessDetailsPaginated(clientMsgId, backendMsgId); + } else { + const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details'); + const j = await res.json().catch(function () { return {}; }); + if (!res.ok) throw new Error((j && j.error) ? j.error : String(res.status)); + const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : []; + if (typeof renderProcessDetails === 'function') { + renderProcessDetails(clientMsgId, details); + } } } catch (e) { console.error('加载过程详情失败(HITL 恢复):', e); @@ -5468,6 +5459,22 @@ function refreshProgressAndTimelineI18n() { const expanded = timeline && timeline.classList.contains('expanded'); span.textContent = expanded ? _t('tasks.collapseDetail') : _t('chat.expandDetail'); }); + + document.querySelectorAll('#chat-messages .message.assistant').forEach(function (msgEl) { + if (typeof window.syncMcpToolsToggleButton === 'function') { + window.syncMcpToolsToggleButton(msgEl); + } + }); + + const copyLabel = _t('common.copy'); + const copyTitle = _t('chat.copyMessageTitle'); + document.querySelectorAll('#chat-messages .message-copy-btn').forEach(function (btn) { + if (btn.dataset.copySuccessActive === '1') return; + const span = btn.querySelector('span'); + if (span) span.textContent = copyLabel; + btn.title = copyTitle; + btn.setAttribute('aria-label', copyTitle); + }); } document.addEventListener('languagechange', function () {