From 54b9e2e2fad029010b23f240c86f9683c8d1d55d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=AC=E6=98=8E?=
<83812544+Ed1s0nZ@users.noreply.github.com>
Date: Thu, 9 Apr 2026 20:11:25 +0800
Subject: [PATCH] Add files via upload
---
web/static/js/chat.js | 180 ++++++++++++++++++++++-----------------
web/static/js/monitor.js | 9 +-
2 files changed, 108 insertions(+), 81 deletions(-)
diff --git a/web/static/js/chat.js b/web/static/js/chat.js
index b6f988f4..bd1f5544 100644
--- a/web/static/js/chat.js
+++ b/web/static/js/chat.js
@@ -1494,11 +1494,14 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
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);
- updateButtonWithToolName(detailBtn, execId, index + 1);
});
+ // 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求)
+ batchUpdateButtonToolNames(buttonsContainer, mcpExecutionIds);
mcpSection.appendChild(buttonsContainer);
contentWrapper.appendChild(mcpSection);
@@ -1861,6 +1864,34 @@ async function updateButtonWithToolName(button, executionId, index) {
}
}
+// 批量获取工具名称并更新按钮(消除 N 次单独 API 请求,合并为 1 次)
+async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
+ if (!executionIds || executionIds.length === 0) return;
+ try {
+ const response = await apiFetch('/api/monitor/executions/names', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ids: executionIds }),
+ });
+ if (!response.ok) return;
+ const nameMap = await response.json(); // { execId: toolName }
+ // 更新对应按钮的文本
+ const buttons = buttonsContainer.querySelectorAll('.mcp-detail-btn[data-exec-id]');
+ buttons.forEach(btn => {
+ const execId = btn.dataset.execId;
+ const index = btn.dataset.execIndex;
+ const toolName = nameMap[execId];
+ if (toolName) {
+ const displayToolName = toolName.includes('::') ? toolName.split('::')[1] : toolName;
+ const span = btn.querySelector('span');
+ if (span) span.textContent = `${displayToolName} #${index}`;
+ }
+ });
+ } catch (error) {
+ console.error('批量获取工具名称失败:', error);
+ }
+}
+
// 显示MCP调用详情
async function showMCPDetail(executionId) {
try {
@@ -2380,15 +2411,14 @@ async function loadConversation(conversationId) {
}
// 获取当前对话所属的分组ID(用于高亮显示)
- // 确保分组映射已加载
+ // 确保分组映射已加载(使用缓存避免重复请求)
if (Object.keys(conversationGroupMappingCache).length === 0) {
await loadConversationGroupMapping();
}
currentConversationGroupId = conversationGroupMappingCache[conversationId] || null;
-
- // 无论是否在分组详情页面,都刷新分组列表,确保高亮状态正确
- // 这样可以清除之前分组的高亮状态,确保UI状态一致
- await loadGroups();
+
+ // 异步刷新分组列表高亮状态(不阻塞消息渲染)
+ loadGroups();
// 更新当前对话ID
currentConversationId = conversationId;
@@ -2430,13 +2460,15 @@ async function loadConversation(conversationId) {
}
}
- // 加载消息
+ // 加载消息 — 分批渲染避免长时间阻塞主线程
if (conversation.messages && conversation.messages.length > 0) {
- conversation.messages.forEach(msg => {
- // 检查消息内容是否为"处理中...",如果是,检查processDetails中是否有错误或取消事件
+ const FIRST_BATCH = 20; // 首批同步渲染(用户可见区域)
+ const BATCH_SIZE = 10; // 后续每批条数
+
+ // 渲染单条消息的辅助函数
+ const renderOneMessage = (msg) => {
let displayContent = msg.content;
if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) {
- // 查找最后一个error或cancelled事件
for (let i = msg.processDetails.length - 1; i >= 0; i--) {
const detail = msg.processDetails[i];
if (detail.eventType === 'error' || detail.eventType === 'cancelled') {
@@ -2445,47 +2477,63 @@ async function loadConversation(conversationId) {
}
}
}
-
- // 传递消息的创建时间
+
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msg.createdAt);
- // 绑定后端 messageId,供按需加载过程详情使用
const messageEl = document.getElementById(messageId);
if (messageEl && msg && msg.id) {
messageEl.dataset.backendMessageId = String(msg.id);
attachDeleteTurnButton(messageEl);
}
- // 对于助手消息,总是渲染过程详情(即使没有processDetails也要显示展开详情按钮)
if (msg.role === 'assistant') {
- // 延迟一下,确保消息已经渲染
- setTimeout(() => {
- // 如果后端未返回 processDetails 字段,传 null 表示“尚未加载,点击展开时再请求”
- const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
- renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
- // 如果有过程详情,检查是否有错误或取消事件,如果有,确保详情默认折叠
- if (msg.processDetails && msg.processDetails.length > 0) {
- const hasErrorOrCancelled = msg.processDetails.some(d =>
- d.eventType === 'error' || d.eventType === 'cancelled'
- );
- if (hasErrorOrCancelled) {
- collapseAllProgressDetails(messageId, null);
- }
+ const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
+ renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
+ if (msg.processDetails && msg.processDetails.length > 0) {
+ const hasErrorOrCancelled = msg.processDetails.some(d =>
+ d.eventType === 'error' || d.eventType === 'cancelled'
+ );
+ if (hasErrorOrCancelled) {
+ collapseAllProgressDetails(messageId, null);
}
- }, 100);
+ }
}
- });
+ };
+
+ const msgs = conversation.messages;
+ const firstBatch = msgs.slice(0, FIRST_BATCH);
+ const rest = msgs.slice(FIRST_BATCH);
+
+ // 首批同步渲染
+ firstBatch.forEach(renderOneMessage);
+
+ // 剩余消息通过 requestAnimationFrame 分批渲染,避免阻塞 UI
+ if (rest.length > 0) {
+ const savedConvId = conversationId;
+ let offset = 0;
+ const renderNextBatch = () => {
+ // 如果用户已经切换到其他对话,停止渲染
+ if (currentConversationId !== savedConvId) return;
+ const batch = rest.slice(offset, offset + BATCH_SIZE);
+ batch.forEach(renderOneMessage);
+ offset += BATCH_SIZE;
+ if (offset < rest.length) {
+ requestAnimationFrame(renderNextBatch);
+ } else {
+ // 所有消息渲染完毕,滚动到底部
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
+ }
+ };
+ requestAnimationFrame(renderNextBatch);
+ }
} else {
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true });
}
-
- // 滚动到底部
+
+ // 滚动到底部(首批渲染后立即滚动,剩余批次渲染后会再次滚动)
messagesDiv.scrollTop = messagesDiv.scrollHeight;
-
+
// 添加攻击链按钮
addAttackChainButton(conversationId);
-
- // 刷新对话列表
- loadConversations();
} catch (error) {
console.error('加载对话失败:', error);
alert('加载对话失败: ' + error.message);
@@ -4421,20 +4469,17 @@ async function loadGroups() {
async function loadConversationsWithGroups(searchQuery = '') {
const loadSeq = ++conversationsListLoadSeq;
try {
- // 总是重新加载分组列表和分组映射,确保缓存是最新的
- // 这样可以正确处理分组被删除后的情况
- await loadGroups();
- if (loadSeq !== conversationsListLoadSeq) return;
- await loadConversationGroupMapping();
- if (loadSeq !== conversationsListLoadSeq) return;
-
- // 如果有搜索关键词,使用更大的limit以获取所有匹配结果
- const limit = (searchQuery && searchQuery.trim()) ? 1000 : 100;
+ // 并行加载分组列表、分组映射和对话列表(消除串行等待)
+ const limit = (searchQuery && searchQuery.trim()) ? 100 : 100;
let url = `/api/conversations?limit=${limit}`;
if (searchQuery && searchQuery.trim()) {
url += '&search=' + encodeURIComponent(searchQuery.trim());
}
- const response = await apiFetch(url);
+ const [,, response] = await Promise.all([
+ loadGroups(),
+ loadConversationGroupMapping(),
+ apiFetch(url),
+ ]);
if (loadSeq !== conversationsListLoadSeq) return;
const listContainer = document.getElementById('conversations-list');
@@ -5432,48 +5477,27 @@ async function removeConversationFromGroup(convId, groupId) {
// 加载对话分组映射
async function loadConversationGroupMapping() {
try {
- // 获取所有分组,然后获取每个分组的对话
- let groups;
- if (Array.isArray(groupsCache) && groupsCache.length > 0) {
- groups = groupsCache;
- } else {
- const response = await apiFetch('/api/groups');
- if (!response.ok) {
- // 如果API请求失败,使用空数组,不打印警告(这是正常错误处理)
- groups = [];
- } else {
- groups = await response.json();
- // 确保groups是有效数组,只在真正异常时才打印警告
- if (!Array.isArray(groups)) {
- // 只在返回的不是数组且不是null/undefined时才打印警告(可能是后端返回了错误格式)
- if (groups !== null && groups !== undefined) {
- console.warn('loadConversationGroupMapping: groups不是有效数组,使用空数组', groups);
- }
- groups = [];
- }
- }
- }
-
+ // 使用批量 API 一次性获取所有映射(消除 N+1 串行请求)
+ const response = await apiFetch('/api/groups/mappings');
+
// 保存待保留的映射
const preservedMappings = { ...pendingGroupMappings };
-
+
conversationGroupMappingCache = {};
- for (const group of groups) {
- const response = await apiFetch(`/api/groups/${group.id}/conversations`);
- const conversations = await response.json();
- // 确保conversations是有效数组
- if (Array.isArray(conversations)) {
- conversations.forEach(conv => {
- conversationGroupMappingCache[conv.id] = group.id;
+ if (response.ok) {
+ const mappings = await response.json();
+ if (Array.isArray(mappings)) {
+ mappings.forEach(m => {
+ conversationGroupMappingCache[m.conversationId] = m.groupId;
// 如果这个对话在待保留映射中,从待保留映射中移除(因为已经从后端加载了)
- if (preservedMappings[conv.id] === group.id) {
- delete pendingGroupMappings[conv.id];
+ if (preservedMappings[m.conversationId] === m.groupId) {
+ delete pendingGroupMappings[m.conversationId];
}
});
}
}
-
+
// 恢复待保留的映射(这些是后端API尚未同步的映射)
Object.assign(conversationGroupMappingCache, preservedMappings);
} catch (error) {
diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js
index 27d99833..957a0be6 100644
--- a/web/static/js/monitor.js
+++ b/web/static/js/monitor.js
@@ -460,13 +460,16 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
mcpIds.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);
- if (typeof updateButtonWithToolName === 'function') {
- updateButtonWithToolName(detailBtn, execId, index + 1);
- }
});
+ // 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求)
+ if (typeof batchUpdateButtonToolNames === 'function') {
+ batchUpdateButtonToolNames(buttonsContainer, mcpIds);
+ }
}
if (!buttonsContainer.querySelector('.process-detail-btn')) {
const progressDetailBtn = document.createElement('button');