const AUTH_STORAGE_KEY = 'cyberstrike-auth'; let authToken = null; let authTokenExpiry = null; let authPromise = null; let authPromiseResolvers = []; let isAppInitialized = false; // 当前对话ID let currentConversationId = null; // 进度ID与任务信息映射 const progressTaskState = new Map(); // 活跃任务刷新定时器 let activeTaskInterval = null; const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次,提供更实时的任务状态反馈 function isTokenValid() { return !!authToken && authTokenExpiry instanceof Date && authTokenExpiry.getTime() > Date.now(); } function saveAuth(token, expiresAt) { const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt); authToken = token; authTokenExpiry = expiry; try { localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token, expiresAt: expiry.toISOString(), })); } catch (error) { console.warn('无法持久化认证信息:', error); } } function clearAuthStorage() { authToken = null; authTokenExpiry = null; try { localStorage.removeItem(AUTH_STORAGE_KEY); } catch (error) { console.warn('无法清除认证信息:', error); } } function loadAuthFromStorage() { try { const raw = localStorage.getItem(AUTH_STORAGE_KEY); if (!raw) { return false; } const stored = JSON.parse(raw); if (!stored.token || !stored.expiresAt) { clearAuthStorage(); return false; } const expiry = new Date(stored.expiresAt); if (Number.isNaN(expiry.getTime())) { clearAuthStorage(); return false; } authToken = stored.token; authTokenExpiry = expiry; return isTokenValid(); } catch (error) { console.error('读取认证信息失败:', error); clearAuthStorage(); return false; } } function resolveAuthPromises(success) { authPromiseResolvers.forEach(resolve => resolve(success)); authPromiseResolvers = []; authPromise = null; } function showLoginOverlay(message = '') { const overlay = document.getElementById('login-overlay'); const errorBox = document.getElementById('login-error'); const passwordInput = document.getElementById('login-password'); if (!overlay) { return; } overlay.style.display = 'flex'; if (errorBox) { if (message) { errorBox.textContent = message; errorBox.style.display = 'block'; } else { errorBox.textContent = ''; errorBox.style.display = 'none'; } } setTimeout(() => { if (passwordInput) { passwordInput.focus(); } }, 100); } function hideLoginOverlay() { const overlay = document.getElementById('login-overlay'); const errorBox = document.getElementById('login-error'); const passwordInput = document.getElementById('login-password'); if (overlay) { overlay.style.display = 'none'; } if (errorBox) { errorBox.textContent = ''; errorBox.style.display = 'none'; } if (passwordInput) { passwordInput.value = ''; } } function ensureAuthPromise() { if (!authPromise) { authPromise = new Promise(resolve => { authPromiseResolvers.push(resolve); }); } return authPromise; } async function ensureAuthenticated() { if (isTokenValid()) { return true; } showLoginOverlay(); await ensureAuthPromise(); return true; } function handleUnauthorized({ message = '认证已过期,请重新登录', silent = false } = {}) { clearAuthStorage(); authPromise = null; authPromiseResolvers = []; if (!silent) { showLoginOverlay(message); } else { showLoginOverlay(); } return false; } async function apiFetch(url, options = {}) { await ensureAuthenticated(); const opts = { ...options }; const headers = new Headers(options && options.headers ? options.headers : undefined); if (authToken && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${authToken}`); } opts.headers = headers; const response = await fetch(url, opts); if (response.status === 401) { handleUnauthorized(); throw new Error('未授权访问'); } return response; } async function submitLogin(event) { event.preventDefault(); const passwordInput = document.getElementById('login-password'); const errorBox = document.getElementById('login-error'); const submitBtn = document.querySelector('.login-submit'); if (!passwordInput) { return; } const password = passwordInput.value.trim(); if (!password) { if (errorBox) { errorBox.textContent = '请输入密码'; errorBox.style.display = 'block'; } return; } if (submitBtn) { submitBtn.disabled = true; } try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ password }), }); const result = await response.json().catch(() => ({})); if (!response.ok || !result.token) { if (errorBox) { errorBox.textContent = result.error || '登录失败,请检查密码'; errorBox.style.display = 'block'; } return; } saveAuth(result.token, result.expires_at); hideLoginOverlay(); resolveAuthPromises(true); if (!isAppInitialized) { await bootstrapApp(); } else { await refreshAppData(); } } catch (error) { console.error('登录失败:', error); if (errorBox) { errorBox.textContent = '登录失败,请稍后重试'; errorBox.style.display = 'block'; } } finally { if (submitBtn) { submitBtn.disabled = false; } } } async function refreshAppData(showTaskErrors = false) { await Promise.allSettled([ loadConversations(), loadActiveTasks(showTaskErrors), ]); } async function bootstrapApp() { if (!isAppInitialized) { initializeChatUI(); isAppInitialized = true; } await refreshAppData(); } function initializeChatUI() { const chatInputEl = document.getElementById('chat-input'); if (chatInputEl) { chatInputEl.style.height = '44px'; } const messagesDiv = document.getElementById('chat-messages'); if (messagesDiv && messagesDiv.childElementCount === 0) { addMessage('assistant', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'); } loadActiveTasks(true); if (activeTaskInterval) { clearInterval(activeTaskInterval); } activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL); } function setupLoginUI() { const loginForm = document.getElementById('login-form'); if (loginForm) { loginForm.addEventListener('submit', submitLogin); } } async function initializeApp() { setupLoginUI(); const hasStoredAuth = loadAuthFromStorage(); if (hasStoredAuth && isTokenValid()) { try { const response = await apiFetch('/api/auth/validate', { method: 'GET', }); if (response.ok) { hideLoginOverlay(); resolveAuthPromises(true); await bootstrapApp(); return; } } catch (error) { console.warn('本地会话已失效,需重新登录'); } } clearAuthStorage(); showLoginOverlay(); } document.addEventListener('DOMContentLoaded', initializeApp); function registerProgressTask(progressId, conversationId = null) { const state = progressTaskState.get(progressId) || {}; state.conversationId = conversationId !== undefined && conversationId !== null ? conversationId : (state.conversationId ?? currentConversationId); state.cancelling = false; progressTaskState.set(progressId, state); const progressElement = document.getElementById(progressId); if (progressElement) { progressElement.dataset.conversationId = state.conversationId || ''; } } function updateProgressConversation(progressId, conversationId) { if (!conversationId) { return; } registerProgressTask(progressId, conversationId); } function markProgressCancelling(progressId) { const state = progressTaskState.get(progressId); if (state) { state.cancelling = true; } } function finalizeProgressTask(progressId, finalLabel = '已完成') { const stopBtn = document.getElementById(`${progressId}-stop-btn`); if (stopBtn) { stopBtn.disabled = true; stopBtn.textContent = finalLabel; } progressTaskState.delete(progressId); } async function requestCancel(conversationId) { const response = await apiFetch('/api/agent-loop/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ conversationId }), }); const result = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(result.error || '取消失败'); } return result; } // 发送消息 async function sendMessage() { const input = document.getElementById('chat-input'); const message = input.value.trim(); if (!message) { return; } // 显示用户消息 addMessage('user', message); input.value = ''; // 创建进度消息容器(使用详细的进度展示) const progressId = addProgressMessage(); const progressElement = document.getElementById(progressId); registerProgressTask(progressId, currentConversationId); loadActiveTasks(); let assistantMessageId = null; let mcpExecutionIds = []; try { const response = await apiFetch('/api/agent-loop/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ message: message, conversationId: currentConversationId }), }); if (!response.ok) { throw new Error('请求失败: ' + response.status); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // 保留最后一个不完整的行 for (const line of lines) { if (line.startsWith('data: ')) { try { const eventData = JSON.parse(line.slice(6)); handleStreamEvent(eventData, progressElement, progressId, () => assistantMessageId, (id) => { assistantMessageId = id; }, () => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; }); } catch (e) { console.error('解析事件数据失败:', e, line); } } } } // 处理剩余的buffer if (buffer.trim()) { const lines = buffer.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const eventData = JSON.parse(line.slice(6)); handleStreamEvent(eventData, progressElement, progressId, () => assistantMessageId, (id) => { assistantMessageId = id; }, () => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; }); } catch (e) { console.error('解析事件数据失败:', e, line); } } } } } catch (error) { removeMessage(progressId); addMessage('system', '错误: ' + error.message); } } // 创建进度消息容器 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); messageDiv.dataset.conversationId = currentConversationId || ''; 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 collapseAllProgressDetails(assistantMessageId, progressId) { // 折叠集成到MCP区域的详情 if (assistantMessageId) { const detailsId = 'process-details-' + assistantMessageId; const detailsContainer = document.getElementById(detailsId); if (detailsContainer) { const timeline = detailsContainer.querySelector('.progress-timeline'); if (timeline) { // 确保移除expanded类(无论是否包含) timeline.classList.remove('expanded'); const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`); if (btn) { btn.innerHTML = '展开详情'; } } } } // 折叠独立的详情组件(通过convertProgressToDetails创建的) // 查找所有以details-开头的详情组件 const allDetails = document.querySelectorAll('[id^="details-"]'); allDetails.forEach(detail => { const timeline = detail.querySelector('.progress-timeline'); const toggleBtn = detail.querySelector('.progress-toggle'); if (timeline) { timeline.classList.remove('expanded'); if (toggleBtn) { toggleBtn.textContent = '展开详情'; } } }); // 折叠原始的进度消息(如果还存在) if (progressId) { const progressTimeline = document.getElementById(progressId + '-timeline'); const progressToggleBtn = document.querySelector(`#${progressId} .progress-toggle`); if (progressTimeline) { progressTimeline.classList.remove('expanded'); if (progressToggleBtn) { progressToggleBtn.textContent = '展开详情'; } } } } // 获取当前助手消息ID(用于done事件) function getAssistantId() { // 从最近的助手消息中获取ID const messages = document.querySelectorAll('.message.assistant'); if (messages.length > 0) { return messages[messages.length - 1].id; } return null; } // 将进度详情集成到工具调用区域 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; // 检查时间线中是否有错误项 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); } // 创建详情容器,放在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}
` : '
暂无过程详情
'}
`; // 确保初始状态是折叠的(默认折叠,特别是错误时) if (hasContent) { const timeline = document.getElementById(detailsId + '-timeline'); if (timeline) { // 如果有错误,确保折叠;否则也默认折叠 timeline.classList.remove('expanded'); } // 更新按钮文本为"展开详情"(因为默认折叠) const processDetailBtn = buttonsContainer.querySelector('.process-detail-btn'); if (processDetailBtn) { processDetailBtn.innerHTML = '展开详情'; } } // 移除原来的进度消息 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 = '收起详情'; } } // 滚动到底部以便查看展开的内容 if (timeline && timeline.classList.contains('expanded')) { setTimeout(() => { const messagesDiv = document.getElementById('chat-messages'); messagesDiv.scrollTop = messagesDiv.scrollHeight; }, 100); } } // 停止当前进度对应的任务 async function cancelProgressTask(progressId) { const state = progressTaskState.get(progressId); const stopBtn = document.getElementById(`${progressId}-stop-btn`); if (!state || !state.conversationId) { if (stopBtn) { stopBtn.disabled = true; setTimeout(() => { stopBtn.disabled = false; }, 1500); } alert('任务信息尚未同步,请稍后再试。'); return; } if (state.cancelling) { return; } markProgressCancelling(progressId); if (stopBtn) { stopBtn.disabled = true; stopBtn.textContent = '取消中...'; } try { await requestCancel(state.conversationId); loadActiveTasks(); } catch (error) { console.error('取消任务失败:', error); alert('取消任务失败: ' + error.message); if (stopBtn) { stopBtn.disabled = false; stopBtn.textContent = '停止任务'; } const currentState = progressTaskState.get(progressId); if (currentState) { currentState.cancelling = false; } } } // 将进度消息转换为可折叠的详情组件 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; // 检查时间线中是否有错误项 const hasError = timeline && timeline.querySelector('.timeline-item-error'); // 如果有错误,默认折叠;否则默认展开 const shouldExpand = !hasError; const expandedClass = shouldExpand ? 'expanded' : ''; const toggleText = shouldExpand ? '收起详情' : '展开详情'; // 总是显示详情组件,即使没有内容也显示 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, progressId, getAssistantId, setAssistantId, getMcpIds, setMcpIds) { const timeline = document.getElementById(progressId + '-timeline'); if (!timeline) return; switch (event.type) { case 'conversation': if (event.data && event.data.conversationId) { updateProgressConversation(progressId, event.data.conversationId); currentConversationId = event.data.conversationId; updateActiveConversation(); loadActiveTasks(); // 立即刷新对话列表,让新对话显示在历史记录中 loadConversations(); } break; 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': // 显示工具调用信息 const toolInfo = event.data || {}; const toolName = toolInfo.toolName || '未知工具'; const index = toolInfo.index || 0; const total = toolInfo.total || 0; addTimelineItem(timeline, 'tool_call', { title: `🔧 调用工具: ${escapeHtml(toolName)} (${index}/${total})`, message: event.message, data: toolInfo, expanded: false }); break; case 'tool_result': // 显示工具执行结果 const resultInfo = event.data || {}; const resultToolName = resultInfo.toolName || '未知工具'; const success = resultInfo.success !== false; const statusIcon = 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 'cancelled': // 显示错误 addTimelineItem(timeline, 'cancelled', { title: '⛔ 任务已取消', message: event.message, data: event.data }); // 更新进度标题为取消状态 const cancelTitle = document.querySelector(`#${progressId} .progress-title`); if (cancelTitle) { cancelTitle.textContent = '⛔ 任务已取消'; } // 更新进度容器为已完成状态(添加completed类) const cancelProgressContainer = document.querySelector(`#${progressId} .progress-container`); if (cancelProgressContainer) { cancelProgressContainer.classList.add('completed'); } // 完成进度任务(标记为已取消) if (progressTaskState.has(progressId)) { finalizeProgressTask(progressId, '已取消'); } // 如果取消事件包含messageId,说明有助手消息,需要显示取消内容 if (event.data && event.data.messageId) { // 检查助手消息是否已存在 let assistantId = event.data.messageId; let assistantElement = document.getElementById(assistantId); // 如果助手消息不存在,创建它 if (!assistantElement) { assistantId = addMessage('assistant', event.message, null, progressId); setAssistantId(assistantId); assistantElement = document.getElementById(assistantId); } else { // 如果已存在,更新内容 const bubble = assistantElement.querySelector('.message-bubble'); if (bubble) { bubble.innerHTML = escapeHtml(event.message).replace(/\n/g, '
'); } } // 将进度详情集成到工具调用区域(如果还没有) if (assistantElement) { const detailsId = 'process-details-' + assistantId; if (!document.getElementById(detailsId)) { integrateProgressToMCPSection(progressId, assistantId); } // 立即折叠详情(取消时应该默认折叠) setTimeout(() => { collapseAllProgressDetails(assistantId, progressId); }, 100); } } else { // 如果没有messageId,创建助手消息并集成详情 const assistantId = addMessage('assistant', event.message, null, progressId); setAssistantId(assistantId); // 将进度详情集成到工具调用区域 setTimeout(() => { integrateProgressToMCPSection(progressId, assistantId); // 确保详情默认折叠 collapseAllProgressDetails(assistantId, progressId); }, 100); } // 立即刷新任务状态 loadActiveTasks(); break; case 'response': // 先添加助手回复 const responseData = event.data || {}; const mcpIds = responseData.mcpExecutionIds || []; setMcpIds(mcpIds); // 更新对话ID if (responseData.conversationId) { currentConversationId = responseData.conversationId; updateActiveConversation(); updateProgressConversation(progressId, responseData.conversationId); loadActiveTasks(); } // 添加助手回复,并传入进度ID以便集成详情 const assistantId = addMessage('assistant', event.message, mcpIds, progressId); setAssistantId(assistantId); // 将进度详情集成到工具调用区域 integrateProgressToMCPSection(progressId, assistantId); // 延迟自动折叠详情(3秒后) setTimeout(() => { collapseAllProgressDetails(assistantId, progressId); }, 3000); // 刷新对话列表 loadConversations(); break; case 'error': // 显示错误 addTimelineItem(timeline, 'error', { title: '❌ 错误', message: event.message, data: event.data }); // 更新进度标题为错误状态 const errorTitle = document.querySelector(`#${progressId} .progress-title`); if (errorTitle) { errorTitle.textContent = '❌ 执行失败'; } // 更新进度容器为已完成状态(添加completed类) const progressContainer = document.querySelector(`#${progressId} .progress-container`); if (progressContainer) { progressContainer.classList.add('completed'); } // 完成进度任务(标记为失败) if (progressTaskState.has(progressId)) { finalizeProgressTask(progressId, '已失败'); } // 如果错误事件包含messageId,说明有助手消息,需要显示错误内容 if (event.data && event.data.messageId) { // 检查助手消息是否已存在 let assistantId = event.data.messageId; let assistantElement = document.getElementById(assistantId); // 如果助手消息不存在,创建它 if (!assistantElement) { assistantId = addMessage('assistant', event.message, null, progressId); setAssistantId(assistantId); assistantElement = document.getElementById(assistantId); } else { // 如果已存在,更新内容 const bubble = assistantElement.querySelector('.message-bubble'); if (bubble) { bubble.innerHTML = escapeHtml(event.message).replace(/\n/g, '
'); } } // 将进度详情集成到工具调用区域(如果还没有) if (assistantElement) { const detailsId = 'process-details-' + assistantId; if (!document.getElementById(detailsId)) { integrateProgressToMCPSection(progressId, assistantId); } // 立即折叠详情(错误时应该默认折叠) setTimeout(() => { collapseAllProgressDetails(assistantId, progressId); }, 100); } } else { // 如果没有messageId(比如任务已运行时的错误),创建助手消息并集成详情 const assistantId = addMessage('assistant', event.message, null, progressId); setAssistantId(assistantId); // 将进度详情集成到工具调用区域 setTimeout(() => { integrateProgressToMCPSection(progressId, assistantId); // 确保详情默认折叠 collapseAllProgressDetails(assistantId, progressId); }, 100); } // 立即刷新任务状态(执行失败时任务状态会更新) loadActiveTasks(); break; case 'done': // 完成,更新进度标题(如果进度消息还存在) const doneTitle = document.querySelector(`#${progressId} .progress-title`); if (doneTitle) { doneTitle.textContent = '✅ 渗透测试完成'; } // 更新对话ID if (event.data && event.data.conversationId) { currentConversationId = event.data.conversationId; updateActiveConversation(); updateProgressConversation(progressId, event.data.conversationId); } if (progressTaskState.has(progressId)) { finalizeProgressTask(progressId, '已完成'); } // 检查时间线中是否有错误项 const hasError = timeline && timeline.querySelector('.timeline-item-error'); // 立即刷新任务状态(确保任务状态同步) loadActiveTasks(); // 延迟再次刷新任务状态(确保后端已完成状态更新) setTimeout(() => { loadActiveTasks(); }, 200); // 完成时自动折叠所有详情(延迟一下确保response事件已处理) setTimeout(() => { const assistantIdFromDone = getAssistantId(); if (assistantIdFromDone) { collapseAllProgressDetails(assistantIdFromDone, progressId); } else { // 如果无法获取助手ID,尝试折叠所有详情 collapseAllProgressDetails(null, progressId); } // 如果有错误,确保详情是折叠的(错误时应该默认折叠) if (hasError) { // 再次确保折叠(延迟一点确保DOM已更新) setTimeout(() => { collapseAllProgressDetails(assistantIdFromDone || null, progressId); }, 200); } }, 500); 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} ${escapeHtml(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 += `
参数:
${escapeHtml(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 || '无结果'; // 确保 result 是字符串 const resultStr = typeof result === 'string' ? result : JSON.stringify(result); content += `
执行结果:
${escapeHtml(resultStr)}
${data.executionId ? `
执行ID: ${escapeHtml(data.executionId)}
` : ''}
`; } else if (type === 'cancelled') { content += `
${escapeHtml(options.message || '任务已取消')}
`; } 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, progressId = null) { const messagesDiv = document.getElementById('chat-messages'); const messageDiv = document.createElement('div'); messageCounter++; const id = 'msg-' + Date.now() + '-' + messageCounter + '-' + Math.random().toString(36).substr(2, 9); messageDiv.id = id; messageDiv.className = 'message ' + role; // 创建头像 const avatar = document.createElement('div'); avatar.className = 'message-avatar'; if (role === 'user') { avatar.textContent = 'U'; } else if (role === 'assistant') { avatar.textContent = 'A'; } else { avatar.textContent = 'S'; } messageDiv.appendChild(avatar); // 创建消息内容容器 const contentWrapper = document.createElement('div'); contentWrapper.className = 'message-content'; // 创建消息气泡 const bubble = document.createElement('div'); bubble.className = 'message-bubble'; // 解析 Markdown 或 HTML 格式 let formattedContent; // 先使用 DOMPurify 清理(如果可用),这样可以处理已经是 HTML 的内容 if (typeof DOMPurify !== 'undefined') { // 配置 DOMPurify 允许的标签和属性 const sanitizeConfig = { // 允许基本的 Markdown 格式化标签 ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'], ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'], ALLOW_DATA_ATTR: false, }; // 如果内容看起来已经是 HTML(包含 HTML 标签),直接清理 // 否则先用 marked.js 解析 Markdown,再清理 if (typeof marked !== 'undefined' && !/<[a-z][\s\S]*>/i.test(content)) { // 内容不包含 HTML 标签,可能是 Markdown,使用 marked.js 解析 try { marked.setOptions({ breaks: true, gfm: true, }); let parsedContent = marked.parse(content); formattedContent = DOMPurify.sanitize(parsedContent, sanitizeConfig); } catch (e) { console.error('Markdown 解析失败:', e); // 降级处理:直接清理原始内容 formattedContent = DOMPurify.sanitize(content, sanitizeConfig); } } else { // 内容包含 HTML 标签或 marked.js 不可用,直接清理 formattedContent = DOMPurify.sanitize(content, sanitizeConfig); } } else if (typeof marked !== 'undefined') { // 没有 DOMPurify,但有 marked.js try { marked.setOptions({ breaks: true, gfm: true, }); formattedContent = marked.parse(content); } catch (e) { console.error('Markdown 解析失败:', e); formattedContent = escapeHtml(content).replace(/\n/g, '
'); } } else { // 都没有,简单转义 formattedContent = escapeHtml(content).replace(/\n/g, '
'); } bubble.innerHTML = formattedContent; contentWrapper.appendChild(bubble); // 添加时间戳 const timeDiv = document.createElement('div'); timeDiv.className = 'message-time'; timeDiv.textContent = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); contentWrapper.appendChild(timeDiv); // 如果有MCP执行ID或进度ID,添加查看详情区域(统一使用"渗透测试详情"样式) 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 = '📋 渗透测试详情'; mcpSection.appendChild(mcpLabel); const buttonsContainer = document.createElement('div'); buttonsContainer.className = 'mcp-call-buttons'; // 如果有MCP执行ID,添加MCP调用详情按钮 if (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) { mcpExecutionIds.forEach((execId, index) => { const detailBtn = document.createElement('button'); detailBtn.className = 'mcp-detail-btn'; detailBtn.innerHTML = `调用 #${index + 1}`; detailBtn.onclick = () => showMCPDetail(execId); 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); } messageDiv.appendChild(contentWrapper); messagesDiv.appendChild(messageDiv); messagesDiv.scrollTop = messagesDiv.scrollHeight; 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); } else if (mcpLabel && mcpLabel.textContent !== '📋 渗透测试详情') { // 如果标签存在但不是统一格式,更新它 mcpLabel.textContent = '📋 渗透测试详情'; } // 如果没有按钮容器,创建一个 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 = '❌ 错误'; } else if (eventType === 'cancelled') { itemTitle = '⛔ 任务已取消'; } addTimelineItem(timeline, eventType, { title: itemTitle, message: detail.message || '', data: data }); }); // 检查是否有错误或取消事件,如果有,确保详情默认折叠 const hasErrorOrCancelled = processDetails.some(d => d.eventType === 'error' || d.eventType === 'cancelled' ); if (hasErrorOrCancelled) { // 确保时间线是折叠的 timeline.classList.remove('expanded'); // 更新按钮文本为"展开详情" const processDetailBtn = messageElement.querySelector('.process-detail-btn'); if (processDetailBtn) { processDetailBtn.innerHTML = '展开详情'; } } } // 移除消息 function removeMessage(id) { const messageDiv = document.getElementById(id); if (messageDiv) { messageDiv.remove(); } } // 回车发送消息,Shift+Enter 换行 const chatInput = document.getElementById('chat-input'); chatInput.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } // Shift+Enter 允许默认行为(换行) }); // 显示MCP调用详情 async function showMCPDetail(executionId) { try { const response = await apiFetch(`/api/monitor/execution/${executionId}`); const exec = await response.json(); if (response.ok) { // 填充模态框内容 document.getElementById('detail-tool-name').textContent = exec.toolName || 'Unknown'; document.getElementById('detail-execution-id').textContent = exec.id || 'N/A'; document.getElementById('detail-status').textContent = getStatusText(exec.status); document.getElementById('detail-time').textContent = new Date(exec.startTime).toLocaleString('zh-CN'); // 请求参数 const requestData = { tool: exec.toolName, arguments: exec.arguments }; document.getElementById('detail-request').textContent = JSON.stringify(requestData, null, 2); // 响应结果 if (exec.result) { const responseData = { content: exec.result.content, isError: exec.result.isError }; document.getElementById('detail-response').textContent = JSON.stringify(responseData, null, 2); document.getElementById('detail-response').className = exec.result.isError ? 'code-block error' : 'code-block'; } else { document.getElementById('detail-response').textContent = '暂无响应数据'; } // 错误信息 if (exec.error) { document.getElementById('detail-error-section').style.display = 'block'; document.getElementById('detail-error').textContent = exec.error; } else { document.getElementById('detail-error-section').style.display = 'none'; } // 显示模态框 document.getElementById('mcp-detail-modal').style.display = 'block'; } else { alert('获取详情失败: ' + (exec.error || '未知错误')); } } catch (error) { alert('获取详情失败: ' + error.message); } } // 关闭MCP详情模态框 function closeMCPDetail() { document.getElementById('mcp-detail-modal').style.display = 'none'; } // 工具函数 function getStatusText(status) { const statusMap = { 'pending': '等待中', 'running': '执行中', 'completed': '已完成', 'failed': '失败' }; return statusMap[status] || status; } function formatDuration(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}小时${minutes % 60}分钟`; } else if (minutes > 0) { return `${minutes}分钟${seconds % 60}秒`; } else { return `${seconds}秒`; } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function formatMarkdown(text) { // 配置 DOMPurify 允许的标签和属性 const sanitizeConfig = { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'], ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'], ALLOW_DATA_ATTR: false, }; if (typeof DOMPurify !== 'undefined') { // 如果内容看起来已经是 HTML(包含 HTML 标签),直接清理 // 否则先用 marked.js 解析 Markdown,再清理 if (typeof marked !== 'undefined' && !/<[a-z][\s\S]*>/i.test(text)) { // 内容不包含 HTML 标签,可能是 Markdown,使用 marked.js 解析 try { marked.setOptions({ breaks: true, gfm: true, }); let parsedContent = marked.parse(text); return DOMPurify.sanitize(parsedContent, sanitizeConfig); } catch (e) { console.error('Markdown 解析失败:', e); return DOMPurify.sanitize(text, sanitizeConfig); } } else { // 内容包含 HTML 标签或 marked.js 不可用,直接清理 return DOMPurify.sanitize(text, sanitizeConfig); } } else if (typeof marked !== 'undefined') { // 没有 DOMPurify,但有 marked.js 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; document.getElementById('chat-messages').innerHTML = ''; addMessage('assistant', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'); updateActiveConversation(); // 刷新对话列表,确保显示最新的历史对话 loadConversations(); } // 加载对话列表 async function loadConversations() { try { const response = await apiFetch('/api/conversations?limit=50'); const conversations = await response.json(); const listContainer = document.getElementById('conversations-list'); listContainer.innerHTML = ''; if (conversations.length === 0) { listContainer.innerHTML = '
暂无历史对话
'; return; } conversations.forEach(conv => { const item = document.createElement('div'); item.className = 'conversation-item'; item.dataset.conversationId = conv.id; if (conv.id === currentConversationId) { 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 || '未命名对话'; contentWrapper.appendChild(title); const time = document.createElement('div'); time.className = 'conversation-time'; // 解析时间,支持多种格式 let dateObj; if (conv.updatedAt) { dateObj = new Date(conv.updatedAt); // 检查日期是否有效 if (isNaN(dateObj.getTime())) { // 如果解析失败,尝试其他格式 console.warn('时间解析失败:', conv.updatedAt); dateObj = new Date(); } } else { dateObj = new Date(); } // 格式化时间显示 const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const messageDate = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate()); let timeText; if (messageDate.getTime() === today.getTime()) { // 今天:只显示时间 timeText = dateObj.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } else if (messageDate.getTime() === yesterday.getTime()) { // 昨天 timeText = '昨天 ' + dateObj.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } else if (now.getFullYear() === dateObj.getFullYear()) { // 今年:显示月日和时间 timeText = dateObj.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } else { // 去年或更早:显示完整日期和时间 timeText = dateObj.toLocaleString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } time.textContent = timeText; 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); }); } catch (error) { console.error('加载对话列表失败:', error); } } // 加载对话 async function loadConversation(conversationId) { try { const response = await apiFetch(`/api/conversations/${conversationId}`); const conversation = await response.json(); if (!response.ok) { alert('加载对话失败: ' + (conversation.error || '未知错误')); return; } // 更新当前对话ID currentConversationId = conversationId; updateActiveConversation(); // 清空消息区域 const messagesDiv = document.getElementById('chat-messages'); messagesDiv.innerHTML = ''; // 加载消息 if (conversation.messages && conversation.messages.length > 0) { conversation.messages.forEach(msg => { // 检查消息内容是否为"处理中...",如果是,检查processDetails中是否有错误或取消事件 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') { displayContent = detail.message || msg.content; break; } } } const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || []); // 如果有过程详情,显示它们 if (msg.processDetails && msg.processDetails.length > 0 && msg.role === 'assistant') { // 延迟一下,确保消息已经渲染 setTimeout(() => { renderProcessDetails(messageId, msg.processDetails); // 检查是否有错误或取消事件,如果有,确保详情默认折叠 const hasErrorOrCancelled = msg.processDetails.some(d => d.eventType === 'error' || d.eventType === 'cancelled' ); if (hasErrorOrCancelled) { collapseAllProgressDetails(messageId, null); } }, 100); } }); } else { addMessage('assistant', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'); } // 滚动到底部 messagesDiv.scrollTop = messagesDiv.scrollHeight; // 添加攻击链按钮 addAttackChainButton(conversationId); // 刷新对话列表 loadConversations(); } catch (error) { console.error('加载对话失败:', error); alert('加载对话失败: ' + error.message); } } // 删除对话 async function deleteConversation(conversationId) { // 确认删除 if (!confirm('确定要删除这个对话吗?此操作不可恢复。')) { return; } try { const response = await apiFetch(`/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 => { item.classList.remove('active'); if (currentConversationId && item.dataset.conversationId === currentConversationId) { item.classList.add('active'); } }); } // 加载活跃任务列表 async function loadActiveTasks(showErrors = false) { const bar = document.getElementById('active-tasks-bar'); try { const response = await apiFetch('/api/agent-loop/tasks'); const result = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(result.error || '获取活跃任务失败'); } renderActiveTasks(result.tasks || []); } catch (error) { console.error('获取活跃任务失败:', error); if (showErrors && bar) { bar.style.display = 'block'; bar.innerHTML = `
无法获取任务状态:${escapeHtml(error.message)}
`; } } } function renderActiveTasks(tasks) { const bar = document.getElementById('active-tasks-bar'); if (!bar) return; if (!tasks || tasks.length === 0) { bar.style.display = 'none'; bar.innerHTML = ''; return; } bar.style.display = 'flex'; bar.innerHTML = ''; tasks.forEach(task => { const item = document.createElement('div'); item.className = 'active-task-item'; const startedTime = task.startedAt ? new Date(task.startedAt) : null; const timeText = startedTime && !isNaN(startedTime.getTime()) ? startedTime.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : ''; // 根据任务状态显示不同的文本 const statusMap = { 'running': '执行中', 'cancelling': '取消中', 'failed': '执行失败', 'timeout': '执行超时', 'cancelled': '已取消', 'completed': '已完成' }; const statusText = statusMap[task.status] || '执行中'; const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status); item.innerHTML = `
${statusText} ${escapeHtml(task.message || '未命名任务')}
${timeText ? `${timeText}` : ''} ${!isFinalStatus ? '' : ''}
`; // 只有非最终状态的任务才显示停止按钮 if (!isFinalStatus) { const cancelBtn = item.querySelector('.active-task-cancel'); if (cancelBtn) { cancelBtn.onclick = () => cancelActiveTask(task.conversationId, cancelBtn); if (task.status === 'cancelling') { cancelBtn.disabled = true; cancelBtn.textContent = '取消中...'; } } } bar.appendChild(item); }); } async function cancelActiveTask(conversationId, button) { if (!conversationId) return; const originalText = button.textContent; button.disabled = true; button.textContent = '取消中...'; try { await requestCancel(conversationId); loadActiveTasks(); } catch (error) { console.error('取消任务失败:', error); alert('取消任务失败: ' + error.message); button.disabled = false; button.textContent = originalText; } } // 设置相关功能 let currentConfig = null; let allTools = []; // 全局工具状态映射,用于保存用户在所有页面的修改 // key: tool.name, value: { enabled: boolean, is_external: boolean, external_mcp: string } let toolStateMap = new Map(); // 从localStorage读取每页显示数量,默认为20 const getToolsPageSize = () => { const saved = localStorage.getItem('toolsPageSize'); return saved ? parseInt(saved, 10) : 20; }; let toolsPagination = { page: 1, pageSize: getToolsPageSize(), total: 0, totalPages: 0 }; // 打开设置 async function openSettings() { const modal = document.getElementById('settings-modal'); modal.style.display = 'block'; // 每次打开时清空全局状态映射,重新加载最新配置 toolStateMap.clear(); // 每次打开时重新加载最新配置 await loadConfig(); // 清除之前的验证错误状态 document.querySelectorAll('.form-group input').forEach(input => { input.classList.remove('error'); }); } // 关闭设置 function closeSettings() { const modal = document.getElementById('settings-modal'); modal.style.display = 'none'; } // 点击模态框外部关闭 window.onclick = function(event) { const settingsModal = document.getElementById('settings-modal'); const mcpModal = document.getElementById('mcp-detail-modal'); const monitorModal = document.getElementById('monitor-modal'); if (event.target === settingsModal) { closeSettings(); } if (event.target === mcpModal) { closeMCPDetail(); } if (event.target === monitorModal) { closeMonitorPanel(); } } // 加载配置 async function loadConfig() { try { const response = await apiFetch('/api/config'); if (!response.ok) { throw new Error('获取配置失败'); } currentConfig = await response.json(); // 填充OpenAI配置 document.getElementById('openai-api-key').value = currentConfig.openai.api_key || ''; document.getElementById('openai-base-url').value = currentConfig.openai.base_url || ''; document.getElementById('openai-model').value = currentConfig.openai.model || ''; // 填充Agent配置 document.getElementById('agent-max-iterations').value = currentConfig.agent.max_iterations || 30; // 设置每页显示数量(会在分页控件渲染时设置) const savedPageSize = getToolsPageSize(); toolsPagination.pageSize = savedPageSize; // 加载工具列表(使用分页) toolsSearchKeyword = ''; await loadToolsList(1, ''); } catch (error) { console.error('加载配置失败:', error); alert('加载配置失败: ' + error.message); } } // 工具搜索关键词 let toolsSearchKeyword = ''; // 加载工具列表(分页) async function loadToolsList(page = 1, searchKeyword = '') { try { // 在加载新页面之前,先保存当前页的状态到全局映射 saveCurrentPageToolStates(); const pageSize = toolsPagination.pageSize; let url = `/api/config/tools?page=${page}&page_size=${pageSize}`; if (searchKeyword) { url += `&search=${encodeURIComponent(searchKeyword)}`; } const response = await apiFetch(url); if (!response.ok) { throw new Error('获取工具列表失败'); } const result = await response.json(); allTools = result.tools || []; toolsPagination = { page: result.page || page, pageSize: result.page_size || pageSize, total: result.total || 0, totalPages: result.total_pages || 1 }; // 初始化工具状态映射(如果工具不在映射中,使用服务器返回的状态) allTools.forEach(tool => { if (!toolStateMap.has(tool.name)) { toolStateMap.set(tool.name, { enabled: tool.enabled, is_external: tool.is_external || false, external_mcp: tool.external_mcp || '' }); } }); renderToolsList(); renderToolsPagination(); } catch (error) { console.error('加载工具列表失败:', error); const toolsList = document.getElementById('tools-list'); if (toolsList) { toolsList.innerHTML = `
加载工具列表失败: ${escapeHtml(error.message)}
`; } } } // 保存当前页的工具状态到全局映射 function saveCurrentPageToolStates() { document.querySelectorAll('#tools-list .tool-item').forEach(item => { const checkbox = item.querySelector('input[type="checkbox"]'); const toolName = item.dataset.toolName; const isExternal = item.dataset.isExternal === 'true'; const externalMcp = item.dataset.externalMcp || ''; if (toolName && checkbox) { toolStateMap.set(toolName, { enabled: checkbox.checked, is_external: isExternal, external_mcp: externalMcp }); } }); } // 搜索工具 function searchTools() { const searchInput = document.getElementById('tools-search'); const keyword = searchInput ? searchInput.value.trim() : ''; toolsSearchKeyword = keyword; // 搜索时重置到第一页 loadToolsList(1, keyword); } // 清除搜索 function clearSearch() { const searchInput = document.getElementById('tools-search'); if (searchInput) { searchInput.value = ''; } toolsSearchKeyword = ''; loadToolsList(1, ''); } // 处理搜索框回车事件 function handleSearchKeyPress(event) { if (event.key === 'Enter') { searchTools(); } } // 渲染工具列表 function renderToolsList() { const toolsList = document.getElementById('tools-list'); if (!toolsList) return; // 只渲染列表部分,分页控件单独渲染 const listContainer = toolsList.querySelector('.tools-list-items') || document.createElement('div'); listContainer.className = 'tools-list-items'; listContainer.innerHTML = ''; if (allTools.length === 0) { listContainer.innerHTML = '
暂无工具
'; if (!toolsList.contains(listContainer)) { toolsList.appendChild(listContainer); } // 更新统计 updateToolsStats(); return; } allTools.forEach(tool => { const toolItem = document.createElement('div'); toolItem.className = 'tool-item'; toolItem.dataset.toolName = tool.name; // 保存原始工具名称 toolItem.dataset.isExternal = tool.is_external ? 'true' : 'false'; toolItem.dataset.externalMcp = tool.external_mcp || ''; // 从全局状态映射获取工具状态,如果不存在则使用服务器返回的状态 const toolState = toolStateMap.get(tool.name) || { enabled: tool.enabled, is_external: tool.is_external || false, external_mcp: tool.external_mcp || '' }; // 外部工具标签 const externalBadge = toolState.is_external ? '外部' : ''; toolItem.innerHTML = `
${escapeHtml(tool.name)} ${externalBadge}
${escapeHtml(tool.description || '无描述')}
`; listContainer.appendChild(toolItem); }); if (!toolsList.contains(listContainer)) { toolsList.appendChild(listContainer); } // 更新统计 updateToolsStats(); } // 渲染工具列表分页控件 function renderToolsPagination() { const toolsList = document.getElementById('tools-list'); if (!toolsList) return; // 移除旧的分页控件 const oldPagination = toolsList.querySelector('.tools-pagination'); if (oldPagination) { oldPagination.remove(); } // 如果只有一页或没有数据,不显示分页 if (toolsPagination.totalPages <= 1) { return; } const pagination = document.createElement('div'); pagination.className = 'tools-pagination'; const { page, totalPages, total } = toolsPagination; const startItem = (page - 1) * toolsPagination.pageSize + 1; const endItem = Math.min(page * toolsPagination.pageSize, total); const savedPageSize = getToolsPageSize(); pagination.innerHTML = `
显示 ${startItem}-${endItem} / 共 ${total} 个工具${toolsSearchKeyword ? ` (搜索: "${escapeHtml(toolsSearchKeyword)}")` : ''}
第 ${page} / ${totalPages} 页
`; toolsList.appendChild(pagination); } // 处理工具checkbox状态变化 function handleToolCheckboxChange(toolName, enabled) { // 更新全局状态映射 const toolItem = document.querySelector(`.tool-item[data-tool-name="${toolName}"]`); if (toolItem) { const isExternal = toolItem.dataset.isExternal === 'true'; const externalMcp = toolItem.dataset.externalMcp || ''; toolStateMap.set(toolName, { enabled: enabled, is_external: isExternal, external_mcp: externalMcp }); } updateToolsStats(); } // 全选工具 function selectAllTools() { document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => { checkbox.checked = true; // 更新全局状态映射 const toolItem = checkbox.closest('.tool-item'); if (toolItem) { const toolName = toolItem.dataset.toolName; const isExternal = toolItem.dataset.isExternal === 'true'; const externalMcp = toolItem.dataset.externalMcp || ''; if (toolName) { toolStateMap.set(toolName, { enabled: true, is_external: isExternal, external_mcp: externalMcp }); } } }); updateToolsStats(); } // 全不选工具 function deselectAllTools() { document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => { checkbox.checked = false; // 更新全局状态映射 const toolItem = checkbox.closest('.tool-item'); if (toolItem) { const toolName = toolItem.dataset.toolName; const isExternal = toolItem.dataset.isExternal === 'true'; const externalMcp = toolItem.dataset.externalMcp || ''; if (toolName) { toolStateMap.set(toolName, { enabled: false, is_external: isExternal, external_mcp: externalMcp }); } } }); updateToolsStats(); } // 改变每页显示数量 async function changeToolsPageSize() { // 尝试从两个位置获取选择器(顶部或分页区域) const pageSizeSelect = document.getElementById('tools-page-size') || document.getElementById('tools-page-size-pagination'); if (!pageSizeSelect) return; const newPageSize = parseInt(pageSizeSelect.value, 10); if (isNaN(newPageSize) || newPageSize < 1) { return; } // 保存到localStorage localStorage.setItem('toolsPageSize', newPageSize.toString()); // 更新分页配置 toolsPagination.pageSize = newPageSize; // 同步更新另一个选择器(如果存在) const otherSelect = document.getElementById('tools-page-size') || document.getElementById('tools-page-size-pagination'); if (otherSelect && otherSelect !== pageSizeSelect) { otherSelect.value = newPageSize; } // 重新加载第一页 await loadToolsList(1, toolsSearchKeyword); } // 更新工具统计信息 async function updateToolsStats() { const statsEl = document.getElementById('tools-stats'); if (!statsEl) return; // 先保存当前页的状态到全局映射 saveCurrentPageToolStates(); // 计算当前页的启用工具数 const currentPageEnabled = Array.from(document.querySelectorAll('#tools-list input[type="checkbox"]:checked')).length; const currentPageTotal = document.querySelectorAll('#tools-list input[type="checkbox"]').length; // 计算所有工具的启用数 let totalEnabled = 0; let totalTools = toolsPagination.total || 0; try { // 如果有搜索关键词,只统计搜索结果 if (toolsSearchKeyword) { totalTools = allTools.length; totalEnabled = allTools.filter(tool => { // 优先使用全局状态映射,否则使用checkbox状态,最后使用服务器返回的状态 const savedState = toolStateMap.get(tool.name); if (savedState !== undefined) { return savedState.enabled; } const checkbox = document.getElementById(`tool-${tool.name}`); return checkbox ? checkbox.checked : tool.enabled; }).length; } else { // 没有搜索时,需要获取所有工具的状态 // 先使用全局状态映射和当前页的checkbox状态 const localStateMap = new Map(); // 从当前页的checkbox获取状态(如果全局映射中没有) allTools.forEach(tool => { const savedState = toolStateMap.get(tool.name); if (savedState !== undefined) { localStateMap.set(tool.name, savedState.enabled); } else { const checkbox = document.getElementById(`tool-${tool.name}`); if (checkbox) { localStateMap.set(tool.name, checkbox.checked); } else { // 如果checkbox不存在(不在当前页),使用工具原始状态 localStateMap.set(tool.name, tool.enabled); } } }); // 如果总工具数大于当前页,需要获取所有工具的状态 if (totalTools > allTools.length) { // 遍历所有页面获取完整状态 let page = 1; let hasMore = true; const pageSize = 100; // 使用较大的页面大小以减少请求次数 while (hasMore && page <= 10) { // 限制最多10页,避免无限循环 const url = `/api/config/tools?page=${page}&page_size=${pageSize}`; const pageResponse = await apiFetch(url); if (!pageResponse.ok) break; const pageResult = await pageResponse.json(); pageResult.tools.forEach(tool => { // 优先使用全局状态映射,否则使用服务器返回的状态 if (!localStateMap.has(tool.name)) { const savedState = toolStateMap.get(tool.name); localStateMap.set(tool.name, savedState ? savedState.enabled : tool.enabled); } }); if (page >= pageResult.total_pages) { hasMore = false; } else { page++; } } } // 计算启用的工具数 totalEnabled = Array.from(localStateMap.values()).filter(enabled => enabled).length; } } catch (error) { console.warn('获取工具统计失败,使用当前页数据', error); // 如果获取失败,使用当前页的数据 totalTools = totalTools || currentPageTotal; totalEnabled = currentPageEnabled; } statsEl.innerHTML = ` ✅ 当前页已启用: ${currentPageEnabled} / ${currentPageTotal} 📊 总计已启用: ${totalEnabled} / ${totalTools} `; } // 过滤工具(已废弃,现在使用服务端搜索) // 保留此函数以防其他地方调用,但实际功能已由searchTools()替代 function filterTools() { // 不再使用客户端过滤,改为触发服务端搜索 // 可以保留为空函数或移除oninput事件 } // 应用设置 async function applySettings() { try { // 清除之前的验证错误状态 document.querySelectorAll('.form-group input').forEach(input => { input.classList.remove('error'); }); // 验证必填字段 const apiKey = document.getElementById('openai-api-key').value.trim(); const baseUrl = document.getElementById('openai-base-url').value.trim(); const model = document.getElementById('openai-model').value.trim(); let hasError = false; if (!apiKey) { document.getElementById('openai-api-key').classList.add('error'); hasError = true; } if (!baseUrl) { document.getElementById('openai-base-url').classList.add('error'); hasError = true; } if (!model) { document.getElementById('openai-model').classList.add('error'); hasError = true; } if (hasError) { alert('请填写所有必填字段(标记为 * 的字段)'); return; } // 收集配置 const config = { openai: { api_key: apiKey, base_url: baseUrl, model: model }, agent: { max_iterations: parseInt(document.getElementById('agent-max-iterations').value) || 30 }, tools: [] }; // 收集工具启用状态 // 先保存当前页的状态到全局映射 saveCurrentPageToolStates(); // 获取所有工具列表以获取完整状态(遍历所有页面) // 注意:无论是否在搜索状态下,都要获取所有工具的状态,以确保完整保存 try { const allToolsMap = new Map(); let page = 1; let hasMore = true; const pageSize = 100; // 使用合理的页面大小 // 遍历所有页面获取所有工具(不使用搜索关键词,获取全部工具) while (hasMore) { const url = `/api/config/tools?page=${page}&page_size=${pageSize}`; const pageResponse = await apiFetch(url); if (!pageResponse.ok) { throw new Error('获取工具列表失败'); } const pageResult = await pageResponse.json(); // 将工具添加到映射中 // 优先使用全局状态映射中的状态(用户修改过的),否则使用服务器返回的状态 pageResult.tools.forEach(tool => { const savedState = toolStateMap.get(tool.name); allToolsMap.set(tool.name, { name: tool.name, enabled: savedState ? savedState.enabled : tool.enabled, is_external: savedState ? savedState.is_external : (tool.is_external || false), external_mcp: savedState ? savedState.external_mcp : (tool.external_mcp || '') }); }); // 检查是否还有更多页面 if (page >= pageResult.total_pages) { hasMore = false; } else { page++; } } // 将所有工具添加到配置中 allToolsMap.forEach(tool => { config.tools.push({ name: tool.name, enabled: tool.enabled, is_external: tool.is_external, external_mcp: tool.external_mcp }); }); } catch (error) { console.warn('获取所有工具列表失败,仅使用全局状态映射', error); // 如果获取失败,使用全局状态映射 toolStateMap.forEach((toolData, toolName) => { config.tools.push({ name: toolName, enabled: toolData.enabled, is_external: toolData.is_external, external_mcp: toolData.external_mcp }); }); } // 更新配置 const updateResponse = await apiFetch('/api/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }); if (!updateResponse.ok) { const error = await updateResponse.json(); throw new Error(error.error || '更新配置失败'); } // 应用配置 const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' }); if (!applyResponse.ok) { const error = await applyResponse.json(); throw new Error(error.error || '应用配置失败'); } alert('配置已成功应用!'); closeSettings(); } catch (error) { console.error('应用配置失败:', error); alert('应用配置失败: ' + error.message); } } function resetPasswordForm() { const currentInput = document.getElementById('auth-current-password'); const newInput = document.getElementById('auth-new-password'); const confirmInput = document.getElementById('auth-confirm-password'); [currentInput, newInput, confirmInput].forEach(input => { if (input) { input.value = ''; input.classList.remove('error'); } }); } async function changePassword() { const currentInput = document.getElementById('auth-current-password'); const newInput = document.getElementById('auth-new-password'); const confirmInput = document.getElementById('auth-confirm-password'); const submitBtn = document.querySelector('.change-password-submit'); [currentInput, newInput, confirmInput].forEach(input => input && input.classList.remove('error')); const currentPassword = currentInput?.value.trim() || ''; const newPassword = newInput?.value.trim() || ''; const confirmPassword = confirmInput?.value.trim() || ''; let hasError = false; if (!currentPassword) { currentInput?.classList.add('error'); hasError = true; } if (!newPassword || newPassword.length < 8) { newInput?.classList.add('error'); hasError = true; } if (newPassword !== confirmPassword) { confirmInput?.classList.add('error'); hasError = true; } if (hasError) { alert('请正确填写当前密码和新密码,新密码至少 8 位且需要两次输入一致。'); return; } if (submitBtn) { submitBtn.disabled = true; } try { const response = await apiFetch('/api/auth/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oldPassword: currentPassword, newPassword: newPassword }) }); const result = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(result.error || '修改密码失败'); } alert('密码已更新,请使用新密码重新登录。'); resetPasswordForm(); handleUnauthorized({ message: '密码已更新,请使用新密码重新登录。', silent: false }); closeSettings(); } catch (error) { console.error('修改密码失败:', error); alert('修改密码失败: ' + error.message); } finally { if (submitBtn) { submitBtn.disabled = false; } } } // 监控面板状态 const monitorState = { executions: [], stats: {}, lastFetchedAt: null, pagination: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }; function openMonitorPanel() { const modal = document.getElementById('monitor-modal'); if (!modal) { return; } modal.style.display = 'block'; // 重置显示状态 const statsContainer = document.getElementById('monitor-stats'); const execContainer = document.getElementById('monitor-executions'); if (statsContainer) { statsContainer.innerHTML = '
加载中...
'; } if (execContainer) { execContainer.innerHTML = '
加载中...
'; } const statusFilter = document.getElementById('monitor-status-filter'); if (statusFilter) { statusFilter.value = 'all'; } // 重置分页状态 monitorState.pagination = { page: 1, pageSize: 20, total: 0, totalPages: 0 }; refreshMonitorPanel(1); } function closeMonitorPanel() { const modal = document.getElementById('monitor-modal'); if (modal) { modal.style.display = 'none'; } } async function refreshMonitorPanel(page = null) { const statsContainer = document.getElementById('monitor-stats'); const execContainer = document.getElementById('monitor-executions'); try { // 如果指定了页码,使用指定页码,否则使用当前页码 const currentPage = page !== null ? page : monitorState.pagination.page; const pageSize = monitorState.pagination.pageSize; const response = await apiFetch(`/api/monitor?page=${currentPage}&page_size=${pageSize}`, { method: 'GET' }); const result = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(result.error || '获取监控数据失败'); } monitorState.executions = Array.isArray(result.executions) ? result.executions : []; monitorState.stats = result.stats || {}; monitorState.lastFetchedAt = new Date(); // 更新分页信息 if (result.total !== undefined) { monitorState.pagination = { page: result.page || currentPage, pageSize: result.page_size || pageSize, total: result.total || 0, totalPages: result.total_pages || 1 }; } renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt); renderMonitorExecutions(monitorState.executions); renderMonitorPagination(); } catch (error) { console.error('刷新监控面板失败:', error); if (statsContainer) { statsContainer.innerHTML = `
无法加载统计信息:${escapeHtml(error.message)}
`; } if (execContainer) { execContainer.innerHTML = `
无法加载执行记录:${escapeHtml(error.message)}
`; } } } function applyMonitorFilters() { const statusFilter = document.getElementById('monitor-status-filter'); const status = statusFilter ? statusFilter.value : 'all'; renderMonitorExecutions(monitorState.executions, status); } function renderMonitorStats(statsMap = {}, lastFetchedAt = null) { const container = document.getElementById('monitor-stats'); if (!container) { return; } const entries = Object.values(statsMap); if (entries.length === 0) { container.innerHTML = '
暂无统计数据
'; return; } // 计算总体汇总 const totals = entries.reduce( (acc, item) => { acc.total += item.totalCalls || 0; acc.success += item.successCalls || 0; acc.failed += item.failedCalls || 0; const lastCall = item.lastCallTime ? new Date(item.lastCallTime) : null; if (lastCall && (!acc.lastCallTime || lastCall > acc.lastCallTime)) { acc.lastCallTime = lastCall; } return acc; }, { total: 0, success: 0, failed: 0, lastCallTime: null } ); const successRate = totals.total > 0 ? ((totals.success / totals.total) * 100).toFixed(1) : '0.0'; const lastUpdatedText = lastFetchedAt ? lastFetchedAt.toLocaleString('zh-CN') : 'N/A'; const lastCallText = totals.lastCallTime ? totals.lastCallTime.toLocaleString('zh-CN') : '暂无调用'; let html = `

总调用次数

${totals.total}
成功 ${totals.success} / 失败 ${totals.failed}

成功率

${successRate}%
统计自全部工具调用

最近一次调用

${lastCallText}
最后刷新时间:${lastUpdatedText}
`; // 显示最多前4个工具的统计(过滤掉 totalCalls 为 0 的工具) const topTools = entries .filter(tool => (tool.totalCalls || 0) > 0) .slice() .sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0)) .slice(0, 4); topTools.forEach(tool => { const toolSuccessRate = tool.totalCalls > 0 ? ((tool.successCalls || 0) / tool.totalCalls * 100).toFixed(1) : '0.0'; html += `

${escapeHtml(tool.toolName || '未知工具')}

${tool.totalCalls || 0}
成功 ${tool.successCalls || 0} / 失败 ${tool.failedCalls || 0} · 成功率 ${toolSuccessRate}%
`; }); container.innerHTML = `
${html}
`; } function renderMonitorExecutions(executions = [], statusFilter = 'all') { const container = document.getElementById('monitor-executions'); if (!container) { return; } if (!Array.isArray(executions) || executions.length === 0) { container.innerHTML = '
暂无执行记录
'; return; } const normalizedStatus = statusFilter === 'all' ? null : statusFilter; const filtered = normalizedStatus ? executions.filter(exec => (exec.status || '').toLowerCase() === normalizedStatus) : executions; if (filtered.length === 0) { container.innerHTML = '
当前筛选条件下暂无记录
'; return; } const rows = filtered .map(exec => { const status = (exec.status || 'unknown').toLowerCase(); const statusClass = `monitor-status-chip ${status}`; const statusLabel = getStatusText(status); const startTime = exec.startTime ? new Date(exec.startTime).toLocaleString('zh-CN') : '未知'; const duration = formatExecutionDuration(exec.startTime, exec.endTime); const toolName = escapeHtml(exec.toolName || '未知工具'); const executionId = escapeHtml(exec.id || ''); return ` ${toolName} ${statusLabel} ${startTime} ${duration}
`; }) .join(''); // 先移除旧的表格容器和加载提示(保留分页控件) const oldTableContainer = container.querySelector('.monitor-table-container'); if (oldTableContainer) { oldTableContainer.remove(); } // 清除"加载中..."等提示信息 const oldEmpty = container.querySelector('.monitor-empty'); if (oldEmpty) { oldEmpty.remove(); } // 创建表格容器 const tableContainer = document.createElement('div'); tableContainer.className = 'monitor-table-container'; tableContainer.innerHTML = ` ${rows}
工具 状态 开始时间 耗时 操作
`; // 在分页控件之前插入表格(如果存在分页控件) const existingPagination = container.querySelector('.monitor-pagination'); if (existingPagination) { container.insertBefore(tableContainer, existingPagination); } else { container.appendChild(tableContainer); } } // 渲染监控面板分页控件 function renderMonitorPagination() { const container = document.getElementById('monitor-executions'); if (!container) return; // 移除旧的分页控件 const oldPagination = container.querySelector('.monitor-pagination'); if (oldPagination) { oldPagination.remove(); } const { page, totalPages, total, pageSize } = monitorState.pagination; // 始终显示分页控件 const pagination = document.createElement('div'); pagination.className = 'monitor-pagination'; // 处理没有数据的情况 const startItem = total === 0 ? 0 : (page - 1) * pageSize + 1; const endItem = total === 0 ? 0 : Math.min(page * pageSize, total); pagination.innerHTML = `
显示 ${startItem}-${endItem} / 共 ${total} 条记录
第 ${page} / ${totalPages || 1} 页
`; container.appendChild(pagination); } // 删除执行记录 async function deleteExecution(executionId) { if (!executionId) { return; } // 确认删除 if (!confirm('确定要删除此执行记录吗?此操作不可恢复。')) { return; } try { const response = await apiFetch(`/api/monitor/execution/${executionId}`, { method: 'DELETE' }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || '删除执行记录失败'); } // 删除成功后刷新当前页面 const currentPage = monitorState.pagination.page; await refreshMonitorPanel(currentPage); alert('执行记录已删除'); } catch (error) { console.error('删除执行记录失败:', error); alert('删除执行记录失败: ' + error.message); } } function formatExecutionDuration(start, end) { if (!start) { return '未知'; } const startTime = new Date(start); const endTime = end ? new Date(end) : new Date(); if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) { return '未知'; } const diffMs = Math.max(0, endTime - startTime); const seconds = Math.floor(diffMs / 1000); if (seconds < 60) { return `${seconds} 秒`; } const minutes = Math.floor(seconds / 60); if (minutes < 60) { const remain = seconds % 60; return remain > 0 ? `${minutes} 分 ${remain} 秒` : `${minutes} 分`; } const hours = Math.floor(minutes / 60); const remainMinutes = minutes % 60; return remainMinutes > 0 ? `${hours} 小时 ${remainMinutes} 分` : `${hours} 小时`; } // ==================== 外部MCP管理 ==================== let currentEditingMCPName = null; // 加载外部MCP列表 async function loadExternalMCPs() { try { const response = await apiFetch('/api/external-mcp'); if (!response.ok) { throw new Error('获取外部MCP列表失败'); } const data = await response.json(); renderExternalMCPList(data.servers || {}); renderExternalMCPStats(data.stats || {}); } catch (error) { console.error('加载外部MCP列表失败:', error); const list = document.getElementById('external-mcp-list'); if (list) { list.innerHTML = `
加载失败: ${escapeHtml(error.message)}
`; } } } // 渲染外部MCP列表 function renderExternalMCPList(servers) { const list = document.getElementById('external-mcp-list'); if (!list) return; if (Object.keys(servers).length === 0) { list.innerHTML = '
📋 暂无外部MCP配置
点击"添加外部MCP"按钮开始配置
'; return; } let html = '
'; for (const [name, server] of Object.entries(servers)) { const status = server.status || 'disconnected'; const statusClass = status === 'connected' ? 'status-connected' : status === 'connecting' ? 'status-connecting' : status === 'error' ? 'status-error' : status === 'disabled' ? 'status-disabled' : 'status-disconnected'; const statusText = status === 'connected' ? '已连接' : status === 'connecting' ? '连接中...' : status === 'error' ? '连接失败' : status === 'disabled' ? '已禁用' : '未连接'; const transport = server.config.transport || (server.config.command ? 'stdio' : 'http'); const transportIcon = transport === 'stdio' ? '⚙️' : '🌐'; html += `

${transportIcon} ${escapeHtml(name)}${server.tool_count !== undefined && server.tool_count > 0 ? `🔧 ${server.tool_count}` : ''}

${statusText}
${status === 'connected' || status === 'disconnected' || status === 'error' ? `` : status === 'connecting' ? `` : ''}
${status === 'error' && server.error ? `
❌ 连接错误:${escapeHtml(server.error)}
` : ''}
传输模式 ${transportIcon} ${escapeHtml(transport.toUpperCase())}
${server.tool_count !== undefined && server.tool_count > 0 ? `
工具数量 🔧 ${server.tool_count} 个工具
` : server.tool_count === 0 && status === 'connected' ? `
工具数量 暂无工具
` : ''} ${server.config.description ? `
描述 ${escapeHtml(server.config.description)}
` : ''} ${server.config.timeout ? `
超时时间 ${server.config.timeout} 秒
` : ''} ${transport === 'stdio' && server.config.command ? `
命令 ${escapeHtml(server.config.command)}
` : ''} ${transport === 'http' && server.config.url ? `
URL ${escapeHtml(server.config.url)}
` : ''}
`; } html += '
'; list.innerHTML = html; } // 渲染外部MCP统计信息 function renderExternalMCPStats(stats) { const statsEl = document.getElementById('external-mcp-stats'); if (!statsEl) return; const total = stats.total || 0; const enabled = stats.enabled || 0; const disabled = stats.disabled || 0; const connected = stats.connected || 0; statsEl.innerHTML = ` 📊 总数: ${total} ✅ 已启用: ${enabled} ⏸ 已停用: ${disabled} 🔗 已连接: ${connected} `; } // 显示添加外部MCP模态框 function showAddExternalMCPModal() { currentEditingMCPName = null; document.getElementById('external-mcp-modal-title').textContent = '添加外部MCP'; document.getElementById('external-mcp-json').value = ''; document.getElementById('external-mcp-json-error').style.display = 'none'; document.getElementById('external-mcp-json-error').textContent = ''; document.getElementById('external-mcp-json').classList.remove('error'); document.getElementById('external-mcp-modal').style.display = 'block'; } // 关闭外部MCP模态框 function closeExternalMCPModal() { document.getElementById('external-mcp-modal').style.display = 'none'; currentEditingMCPName = null; } // 编辑外部MCP async function editExternalMCP(name) { try { const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`); if (!response.ok) { throw new Error('获取外部MCP配置失败'); } const server = await response.json(); currentEditingMCPName = name; document.getElementById('external-mcp-modal-title').textContent = '编辑外部MCP'; // 将配置转换为对象格式(key为名称) const config = { ...server.config }; // 移除tool_count、external_mcp_enable等前端字段,但保留enabled/disabled用于向后兼容 delete config.tool_count; delete config.external_mcp_enable; // 包装成对象格式:{ "name": { config } } const configObj = {}; configObj[name] = config; // 格式化JSON const jsonStr = JSON.stringify(configObj, null, 2); document.getElementById('external-mcp-json').value = jsonStr; document.getElementById('external-mcp-json-error').style.display = 'none'; document.getElementById('external-mcp-json-error').textContent = ''; document.getElementById('external-mcp-json').classList.remove('error'); document.getElementById('external-mcp-modal').style.display = 'block'; } catch (error) { console.error('编辑外部MCP失败:', error); alert('编辑失败: ' + error.message); } } // 格式化JSON function formatExternalMCPJSON() { const jsonTextarea = document.getElementById('external-mcp-json'); const errorDiv = document.getElementById('external-mcp-json-error'); try { const jsonStr = jsonTextarea.value.trim(); if (!jsonStr) { errorDiv.textContent = 'JSON不能为空'; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); return; } const parsed = JSON.parse(jsonStr); const formatted = JSON.stringify(parsed, null, 2); jsonTextarea.value = formatted; errorDiv.style.display = 'none'; jsonTextarea.classList.remove('error'); } catch (error) { errorDiv.textContent = 'JSON格式错误: ' + error.message; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); } } // 加载示例 function loadExternalMCPExample() { const example = { "hexstrike-ai": { command: "python3", args: [ "/path/to/script.py", "--server", "http://example.com" ], description: "示例描述", timeout: 300 } }; document.getElementById('external-mcp-json').value = JSON.stringify(example, null, 2); document.getElementById('external-mcp-json-error').style.display = 'none'; document.getElementById('external-mcp-json').classList.remove('error'); } // 保存外部MCP async function saveExternalMCP() { const jsonTextarea = document.getElementById('external-mcp-json'); const jsonStr = jsonTextarea.value.trim(); const errorDiv = document.getElementById('external-mcp-json-error'); if (!jsonStr) { errorDiv.textContent = 'JSON配置不能为空'; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); jsonTextarea.focus(); return; } let configObj; try { configObj = JSON.parse(jsonStr); } catch (error) { errorDiv.textContent = 'JSON格式错误: ' + error.message; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); jsonTextarea.focus(); return; } // 验证必须是对象格式 if (typeof configObj !== 'object' || Array.isArray(configObj) || configObj === null) { errorDiv.textContent = '配置错误: 必须是JSON对象格式,key为配置名称,value为配置内容'; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); return; } // 获取所有配置名称 const names = Object.keys(configObj); if (names.length === 0) { errorDiv.textContent = '配置错误: 至少需要一个配置项'; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); return; } // 验证每个配置 for (const name of names) { if (!name || name.trim() === '') { errorDiv.textContent = '配置错误: 配置名称不能为空'; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); return; } const config = configObj[name]; if (typeof config !== 'object' || Array.isArray(config) || config === null) { errorDiv.textContent = `配置错误: "${name}" 的配置必须是对象`; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); return; } // 移除 external_mcp_enable 字段(由按钮控制,但保留 enabled/disabled 用于向后兼容) delete config.external_mcp_enable; // 验证配置内容 const transport = config.transport || (config.command ? 'stdio' : config.url ? 'http' : ''); if (!transport) { errorDiv.textContent = `配置错误: "${name}" 需要指定command(stdio模式)或url(http模式)`; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); return; } if (transport === 'stdio' && !config.command) { errorDiv.textContent = `配置错误: "${name}" stdio模式需要command字段`; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); return; } if (transport === 'http' && !config.url) { errorDiv.textContent = `配置错误: "${name}" http模式需要url字段`; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); return; } } // 清除错误提示 errorDiv.style.display = 'none'; jsonTextarea.classList.remove('error'); try { // 如果是编辑模式,只更新当前编辑的配置 if (currentEditingMCPName) { if (!configObj[currentEditingMCPName]) { errorDiv.textContent = `配置错误: 编辑模式下,JSON必须包含配置名称 "${currentEditingMCPName}"`; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); return; } const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(currentEditingMCPName)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ config: configObj[currentEditingMCPName] }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '保存失败'); } } else { // 添加模式:保存所有配置 for (const name of names) { const config = configObj[name]; const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ config }), }); if (!response.ok) { const error = await response.json(); throw new Error(`保存 "${name}" 失败: ${error.error || '未知错误'}`); } } } closeExternalMCPModal(); await loadExternalMCPs(); alert('保存成功'); } catch (error) { console.error('保存外部MCP失败:', error); errorDiv.textContent = '保存失败: ' + error.message; errorDiv.style.display = 'block'; jsonTextarea.classList.add('error'); } } // 删除外部MCP async function deleteExternalMCP(name) { if (!confirm(`确定要删除外部MCP "${name}" 吗?`)) { return; } try { const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`, { method: 'DELETE', }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '删除失败'); } await loadExternalMCPs(); alert('删除成功'); } catch (error) { console.error('删除外部MCP失败:', error); alert('删除失败: ' + error.message); } } // 切换外部MCP启停 async function toggleExternalMCP(name, currentStatus) { const action = currentStatus === 'connected' ? 'stop' : 'start'; const buttonId = `btn-toggle-${name}`; const button = document.getElementById(buttonId); // 如果是启动操作,显示加载状态 if (action === 'start' && button) { button.disabled = true; button.style.opacity = '0.6'; button.style.cursor = 'not-allowed'; button.innerHTML = '⏳ 连接中...'; } try { const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}/${action}`, { method: 'POST', }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '操作失败'); } const result = await response.json(); // 如果是启动操作,先立即检查一次状态 if (action === 'start') { // 立即检查一次状态(可能已经连接) try { const statusResponse = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`); if (statusResponse.ok) { const statusData = await statusResponse.json(); const status = statusData.status || 'disconnected'; if (status === 'connected') { // 已经连接,立即刷新 await loadExternalMCPs(); return; } } } catch (error) { console.error('检查状态失败:', error); } // 如果还未连接,开始轮询 await pollExternalMCPStatus(name, 30); // 最多轮询30次(约30秒) } else { // 停止操作,直接刷新 await loadExternalMCPs(); } } catch (error) { console.error('切换外部MCP状态失败:', error); alert('操作失败: ' + error.message); // 恢复按钮状态 if (button) { button.disabled = false; button.style.opacity = '1'; button.style.cursor = 'pointer'; button.innerHTML = '▶ 启动'; } // 刷新状态 await loadExternalMCPs(); } } // 轮询外部MCP状态 async function pollExternalMCPStatus(name, maxAttempts = 30) { let attempts = 0; const pollInterval = 1000; // 1秒轮询一次 while (attempts < maxAttempts) { await new Promise(resolve => setTimeout(resolve, pollInterval)); try { const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`); if (response.ok) { const data = await response.json(); const status = data.status || 'disconnected'; // 更新按钮状态 const buttonId = `btn-toggle-${name}`; const button = document.getElementById(buttonId); if (status === 'connected') { // 连接成功,刷新列表 await loadExternalMCPs(); return; } else if (status === 'error' || status === 'disconnected') { // 连接失败,刷新列表并显示错误 await loadExternalMCPs(); if (status === 'error') { alert('连接失败,请检查配置和网络连接'); } return; } else if (status === 'connecting') { // 仍在连接中,继续轮询 attempts++; continue; } } } catch (error) { console.error('轮询状态失败:', error); } attempts++; } // 超时,刷新列表 await loadExternalMCPs(); alert('连接超时,请检查配置和网络连接'); } // 在打开设置时加载外部MCP列表 const originalOpenSettings = openSettings; openSettings = async function() { await originalOpenSettings(); await loadExternalMCPs(); }; // ==================== 攻击链可视化功能 ==================== let attackChainCytoscape = null; let currentAttackChainConversationId = null; let isAttackChainLoading = false; // 防止重复加载 // 添加攻击链按钮 function addAttackChainButton(conversationId) { // 检查是否已存在按钮 let attackChainBtn = document.getElementById('attack-chain-btn'); if (!attackChainBtn) { attackChainBtn = document.createElement('button'); attackChainBtn.id = 'attack-chain-btn'; attackChainBtn.className = 'btn-secondary'; attackChainBtn.style.marginLeft = '10px'; attackChainBtn.innerHTML = '🔗 攻击链'; attackChainBtn.onclick = () => showAttackChain(conversationId); // 在消息区域上方添加按钮容器 const chatMessages = document.getElementById('chat-messages'); if (chatMessages) { // 检查是否已有按钮容器 let btnContainer = document.getElementById('attack-chain-btn-container'); if (!btnContainer) { btnContainer = document.createElement('div'); btnContainer.id = 'attack-chain-btn-container'; btnContainer.style.padding = '10px'; btnContainer.style.borderBottom = '1px solid var(--border-color)'; btnContainer.style.background = 'var(--bg-secondary)'; chatMessages.parentNode.insertBefore(btnContainer, chatMessages); } btnContainer.innerHTML = ''; btnContainer.appendChild(attackChainBtn); } } else { attackChainBtn.onclick = () => showAttackChain(conversationId); } } // 显示攻击链模态框 async function showAttackChain(conversationId) { // 防止重复点击 if (isAttackChainLoading) { console.log('攻击链正在加载中,请稍候...'); return; } currentAttackChainConversationId = conversationId; const modal = document.getElementById('attack-chain-modal'); if (!modal) { console.error('攻击链模态框未找到'); return; } modal.style.display = 'block'; // 清空容器 const container = document.getElementById('attack-chain-container'); if (container) { container.innerHTML = '
加载中...
'; } // 隐藏详情面板 const detailsPanel = document.getElementById('attack-chain-details'); if (detailsPanel) { detailsPanel.style.display = 'none'; } // 禁用重新生成按钮 const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]'); if (regenerateBtn) { regenerateBtn.disabled = true; regenerateBtn.style.opacity = '0.5'; regenerateBtn.style.cursor = 'not-allowed'; } // 加载攻击链数据 await loadAttackChain(conversationId); } // 加载攻击链数据 async function loadAttackChain(conversationId) { if (isAttackChainLoading) { return; // 防止重复调用 } isAttackChainLoading = true; try { const response = await apiFetch(`/api/attack-chain/${conversationId}`); if (!response.ok) { // 处理 409 Conflict(正在生成中) if (response.status === 409) { const error = await response.json(); const container = document.getElementById('attack-chain-container'); if (container) { container.innerHTML = `
⏳ 攻击链正在生成中...
请稍候,生成完成后将自动显示
`; } // 5秒后自动刷新(允许刷新,但保持加载状态防止重复点击) setTimeout(() => { refreshAttackChain(); }, 5000); // 在 409 情况下,保持 isAttackChainLoading = true,防止重复点击 // 但允许 refreshAttackChain 调用 loadAttackChain 来检查状态 // 注意:不重置 isAttackChainLoading,保持加载状态 // 恢复按钮状态(虽然保持加载状态,但允许用户手动刷新) const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]'); if (regenerateBtn) { regenerateBtn.disabled = false; regenerateBtn.style.opacity = '1'; regenerateBtn.style.cursor = 'pointer'; } return; // 提前返回,不执行 finally 块中的 isAttackChainLoading = false } const error = await response.json(); throw new Error(error.error || '加载攻击链失败'); } const chainData = await response.json(); // 渲染攻击链 renderAttackChain(chainData); // 更新统计信息 updateAttackChainStats(chainData); // 成功加载后,重置加载状态 isAttackChainLoading = false; } catch (error) { console.error('加载攻击链失败:', error); const container = document.getElementById('attack-chain-container'); if (container) { container.innerHTML = `
加载失败: ${error.message}
`; } // 错误时也重置加载状态 isAttackChainLoading = false; } finally { // 恢复重新生成按钮 const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]'); if (regenerateBtn) { regenerateBtn.disabled = false; regenerateBtn.style.opacity = '1'; regenerateBtn.style.cursor = 'pointer'; } } } // 渲染攻击链 function renderAttackChain(chainData) { const container = document.getElementById('attack-chain-container'); if (!container) { return; } // 清空容器 container.innerHTML = ''; if (!chainData.nodes || chainData.nodes.length === 0) { container.innerHTML = '
暂无攻击链数据
'; return; } // 计算图的复杂度(用于动态调整布局和样式) const nodeCount = chainData.nodes.length; const edgeCount = chainData.edges.length; const isComplexGraph = nodeCount > 20 || edgeCount > 30; // 准备Cytoscape数据 const elements = []; // 添加节点,并预计算文字颜色和边框颜色 chainData.nodes.forEach(node => { const riskScore = node.risk_score || 0; // 根据风险分数计算文字颜色和边框颜色 let textColor, borderColor, textOutlineWidth, textOutlineColor; if (riskScore >= 80) { // 红色背景:白色文字,白色边框 textColor = '#fff'; borderColor = '#fff'; textOutlineWidth = 1; textOutlineColor = '#333'; } else if (riskScore >= 60) { // 橙色背景:白色文字,白色边框 textColor = '#fff'; borderColor = '#fff'; textOutlineWidth = 1; textOutlineColor = '#333'; } else if (riskScore >= 40) { // 黄色背景:深色文字,深色边框 textColor = '#333'; borderColor = '#cc9900'; textOutlineWidth = 2; textOutlineColor = '#fff'; } else { // 绿色背景:深绿色文字,深色边框 textColor = '#1a5a1a'; borderColor = '#5a8a5a'; textOutlineWidth = 2; textOutlineColor = '#fff'; } elements.push({ data: { id: node.id, label: node.label, type: node.type, riskScore: riskScore, toolExecutionId: node.tool_execution_id || '', metadata: node.metadata || {}, textColor: textColor, borderColor: borderColor, textOutlineWidth: textOutlineWidth, textOutlineColor: textOutlineColor } }); }); // 添加边 chainData.edges.forEach(edge => { elements.push({ data: { id: edge.id, source: edge.source, target: edge.target, type: edge.type || 'leads_to', weight: edge.weight || 1 } }); }); // 初始化Cytoscape attackChainCytoscape = cytoscape({ container: container, elements: elements, style: [ { selector: 'node', style: { 'label': 'data(label)', // 统一节点大小,减少布局混乱(根据复杂度调整) 'width': nodeCount > 20 ? 60 : 'mapData(riskScore, 0, 100, 45, 75)', 'height': nodeCount > 20 ? 60 : 'mapData(riskScore, 0, 100, 45, 75)', 'shape': function(ele) { const type = ele.data('type'); if (type === 'vulnerability') return 'diamond'; if (type === 'action') return 'round-rectangle'; if (type === 'target') return 'star'; return 'ellipse'; }, 'background-color': function(ele) { const riskScore = ele.data('riskScore') || 0; if (riskScore >= 80) return '#ff4444'; // 红色 if (riskScore >= 60) return '#ff8800'; // 橙色 if (riskScore >= 40) return '#ffbb00'; // 黄色 return '#88cc00'; // 绿色 }, // 使用预计算的颜色数据 'color': 'data(textColor)', 'font-size': nodeCount > 20 ? '11px' : '12px', // 复杂图使用更小字体 'font-weight': 'bold', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'wrap', 'text-max-width': nodeCount > 20 ? '80px' : '100px', // 复杂图限制文本宽度 'border-width': 2, 'border-color': 'data(borderColor)', 'overlay-padding': '4px', 'text-outline-width': 'data(textOutlineWidth)', 'text-outline-color': 'data(textOutlineColor)' } }, { selector: 'edge', style: { 'width': 'mapData(weight, 1, 5, 1.5, 3)', 'line-color': function(ele) { const type = ele.data('type'); if (type === 'discovers') return '#3498db'; // 浅蓝:action发现vulnerability if (type === 'targets') return '#0066ff'; // 蓝色:target指向action if (type === 'enables') return '#e74c3c'; // 深红:vulnerability间的因果关系 if (type === 'leads_to') return '#666'; // 灰色:action之间的逻辑顺序 return '#999'; }, 'target-arrow-color': function(ele) { const type = ele.data('type'); if (type === 'discovers') return '#3498db'; if (type === 'targets') return '#0066ff'; if (type === 'enables') return '#e74c3c'; if (type === 'leads_to') return '#666'; return '#999'; }, 'target-arrow-shape': 'triangle', 'target-arrow-size': 8, // 对于复杂图,使用straight样式减少交叉;简单图使用bezier更美观 'curve-style': isComplexGraph ? 'straight' : 'bezier', 'control-point-step-size': isComplexGraph ? 40 : 60, // bezier控制点间距 'control-point-distance': isComplexGraph ? 30 : 50, // bezier控制点距离 'opacity': isComplexGraph ? 0.5 : 0.7, // 复杂图降低不透明度,减少视觉混乱 'line-style': 'solid' } }, { selector: 'node:selected', style: { 'border-width': 4, 'border-color': '#0066ff' } } ], userPanningEnabled: true, userZoomingEnabled: true, boxSelectionEnabled: true }); // 注册dagre布局(确保依赖已加载) let layoutName = 'breadthfirst'; // 默认布局 let layoutOptions = { name: 'breadthfirst', directed: true, spacingFactor: isComplexGraph ? 2.5 : 2.0, padding: 30 }; if (typeof cytoscape !== 'undefined' && typeof cytoscapeDagre !== 'undefined') { try { cytoscape.use(cytoscapeDagre); layoutName = 'dagre'; // 根据图的复杂度调整布局参数 layoutOptions = { name: 'dagre', rankDir: 'TB', // 从上到下 spacingFactor: isComplexGraph ? 2.5 : 2.0, // 增加整体间距 nodeSep: isComplexGraph ? 80 : 60, // 增加节点间距 edgeSep: isComplexGraph ? 40 : 30, // 增加边间距 rankSep: isComplexGraph ? 120 : 100, // 增加层级间距 nodeDimensionsIncludeLabels: true, // 考虑标签大小 animate: false, padding: 40 // 增加边距 }; } catch (e) { console.warn('dagre布局注册失败,使用默认布局:', e); } } else { console.warn('dagre布局插件未加载,使用默认布局'); } // 应用布局 attackChainCytoscape.layout(layoutOptions).run(); // 布局完成后,调整视图以适应所有节点 attackChainCytoscape.fit(undefined, 50); // 50px padding // 添加点击事件 attackChainCytoscape.on('tap', 'node', function(evt) { const node = evt.target; showNodeDetails(node.data()); }); // 添加悬停效果 attackChainCytoscape.on('mouseover', 'node', function(evt) { const node = evt.target; node.style('opacity', 0.8); }); attackChainCytoscape.on('mouseout', 'node', function(evt) { const node = evt.target; node.style('opacity', 1); }); } // 显示节点详情 function showNodeDetails(nodeData) { const detailsPanel = document.getElementById('attack-chain-details'); const detailsContent = document.getElementById('attack-chain-details-content'); if (!detailsPanel || !detailsContent) { return; } detailsPanel.style.display = 'block'; let html = `
节点ID: ${nodeData.id}
类型: ${getNodeTypeLabel(nodeData.type)}
标签: ${escapeHtml(nodeData.label)}
风险评分: ${nodeData.riskScore}/100
`; // 显示action节点信息(工具执行 + AI分析) if (nodeData.type === 'action' && nodeData.metadata) { if (nodeData.metadata.tool_name) { html += `
工具名称: ${escapeHtml(nodeData.metadata.tool_name)}
`; } if (nodeData.metadata.tool_intent) { html += `
工具意图: ${escapeHtml(nodeData.metadata.tool_intent)}
`; } if (nodeData.metadata.ai_analysis) { html += `
AI分析:
${escapeHtml(nodeData.metadata.ai_analysis)}
`; } if (nodeData.metadata.findings && Array.isArray(nodeData.metadata.findings) && nodeData.metadata.findings.length > 0) { html += `
关键发现:
`; } } // 显示目标信息(如果是目标节点) if (nodeData.type === 'target' && nodeData.metadata && nodeData.metadata.target) { html += `
测试目标: ${escapeHtml(nodeData.metadata.target)}
`; } // 显示漏洞信息(如果是漏洞节点) if (nodeData.type === 'vulnerability' && nodeData.metadata) { if (nodeData.metadata.vulnerability_type) { html += `
漏洞类型: ${escapeHtml(nodeData.metadata.vulnerability_type)}
`; } if (nodeData.metadata.description) { html += `
描述: ${escapeHtml(nodeData.metadata.description)}
`; } if (nodeData.metadata.severity) { html += `
严重程度: ${escapeHtml(nodeData.metadata.severity)}
`; } if (nodeData.metadata.location) { html += `
位置: ${escapeHtml(nodeData.metadata.location)}
`; } } if (nodeData.toolExecutionId) { html += `
工具执行ID: ${nodeData.toolExecutionId}
`; } if (nodeData.metadata && Object.keys(nodeData.metadata).length > 0) { html += `
完整元数据:
${JSON.stringify(nodeData.metadata, null, 2)}
`; } detailsContent.innerHTML = html; } // 转义HTML function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // 获取严重程度颜色 function getSeverityColor(severity) { const colors = { 'critical': '#ff0000', 'high': '#ff4444', 'medium': '#ff8800', 'low': '#ffbb00' }; return colors[severity.toLowerCase()] || '#666'; } // 获取节点类型标签 function getNodeTypeLabel(type) { const labels = { 'action': '行动', 'vulnerability': '漏洞', 'target': '目标' }; return labels[type] || type; } // 更新统计信息 function updateAttackChainStats(chainData) { const statsElement = document.getElementById('attack-chain-stats'); if (statsElement) { const nodeCount = chainData.nodes ? chainData.nodes.length : 0; const edgeCount = chainData.edges ? chainData.edges.length : 0; statsElement.textContent = `节点: ${nodeCount} | 边: ${edgeCount}`; } } // 关闭攻击链模态框 function closeAttackChainModal() { const modal = document.getElementById('attack-chain-modal'); if (modal) { modal.style.display = 'none'; } // 清理Cytoscape实例 if (attackChainCytoscape) { attackChainCytoscape.destroy(); attackChainCytoscape = null; } currentAttackChainConversationId = null; } // 刷新攻击链(重新加载) // 注意:此函数允许在加载过程中调用,用于检查生成状态 function refreshAttackChain() { if (currentAttackChainConversationId) { // 临时允许刷新,即使正在加载中(用于检查生成状态) const wasLoading = isAttackChainLoading; isAttackChainLoading = false; // 临时重置,允许刷新 loadAttackChain(currentAttackChainConversationId).finally(() => { // 如果之前正在加载(409 情况),恢复加载状态 // 否则保持 false(正常完成) if (wasLoading) { // 检查是否仍然需要保持加载状态(如果还是 409,会在 loadAttackChain 中处理) // 这里我们假设如果成功加载,则重置状态 // 如果还是 409,loadAttackChain 会保持 isAttackChainLoading = true } }); } } // 重新生成攻击链 async function regenerateAttackChain() { if (!currentAttackChainConversationId) { return; } // 防止重复点击 if (isAttackChainLoading) { console.log('攻击链正在生成中,请稍候...'); return; } isAttackChainLoading = true; const container = document.getElementById('attack-chain-container'); if (container) { container.innerHTML = '
重新生成中...
'; } // 禁用重新生成按钮 const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]'); if (regenerateBtn) { regenerateBtn.disabled = true; regenerateBtn.style.opacity = '0.5'; regenerateBtn.style.cursor = 'not-allowed'; } try { // 调用重新生成接口 const response = await apiFetch(`/api/attack-chain/${currentAttackChainConversationId}/regenerate`, { method: 'POST' }); if (!response.ok) { // 处理 409 Conflict(正在生成中) if (response.status === 409) { const error = await response.json(); if (container) { container.innerHTML = `
⏳ 攻击链正在生成中...
请稍候,生成完成后将自动显示
`; } // 5秒后自动刷新 setTimeout(() => { if (isAttackChainLoading) { refreshAttackChain(); } }, 5000); return; } const error = await response.json(); throw new Error(error.error || '重新生成攻击链失败'); } const chainData = await response.json(); // 渲染攻击链 renderAttackChain(chainData); // 更新统计信息 updateAttackChainStats(chainData); } catch (error) { console.error('重新生成攻击链失败:', error); if (container) { container.innerHTML = `
重新生成失败: ${error.message}
`; } } finally { isAttackChainLoading = false; // 恢复重新生成按钮 if (regenerateBtn) { regenerateBtn.disabled = false; regenerateBtn.style.opacity = '1'; regenerateBtn.style.cursor = 'pointer'; } } } // 导出攻击链 function exportAttackChain(format) { if (!attackChainCytoscape) { alert('请先加载攻击链'); return; } // 确保图形已经渲染完成(使用小延迟) setTimeout(() => { try { if (format === 'png') { try { const pngPromise = attackChainCytoscape.png({ output: 'blob', bg: 'white', full: true, scale: 1 }); // 处理 Promise if (pngPromise && typeof pngPromise.then === 'function') { pngPromise.then(blob => { if (!blob) { throw new Error('PNG导出返回空数据'); } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `attack-chain-${currentAttackChainConversationId || 'export'}-${Date.now()}.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 100); }).catch(err => { console.error('导出PNG失败:', err); alert('导出PNG失败: ' + (err.message || '未知错误')); }); } else { // 如果不是 Promise,直接使用 const url = URL.createObjectURL(pngPromise); const a = document.createElement('a'); a.href = url; a.download = `attack-chain-${currentAttackChainConversationId || 'export'}-${Date.now()}.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 100); } } catch (err) { console.error('PNG导出错误:', err); alert('导出PNG失败: ' + (err.message || '未知错误')); } } else if (format === 'svg') { try { // Cytoscape.js 3.x 不直接支持 .svg() 方法 // 使用替代方案:从 Cytoscape 数据手动构建 SVG const container = attackChainCytoscape.container(); if (!container) { throw new Error('无法获取容器元素'); } // 获取所有节点和边 const nodes = attackChainCytoscape.nodes(); const edges = attackChainCytoscape.edges(); if (nodes.length === 0) { throw new Error('没有节点可导出'); } // 计算所有节点的实际边界(包括节点大小) let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; nodes.forEach(node => { const pos = node.position(); const nodeWidth = node.width(); const nodeHeight = node.height(); const size = Math.max(nodeWidth, nodeHeight) / 2; minX = Math.min(minX, pos.x - size); minY = Math.min(minY, pos.y - size); maxX = Math.max(maxX, pos.x + size); maxY = Math.max(maxY, pos.y + size); }); // 也考虑边的范围 edges.forEach(edge => { const sourcePos = edge.source().position(); const targetPos = edge.target().position(); minX = Math.min(minX, sourcePos.x, targetPos.x); minY = Math.min(minY, sourcePos.y, targetPos.y); maxX = Math.max(maxX, sourcePos.x, targetPos.x); maxY = Math.max(maxY, sourcePos.y, targetPos.y); }); // 添加边距 const padding = 50; minX -= padding; minY -= padding; maxX += padding; maxY += padding; const width = maxX - minX; const height = maxY - minY; // 创建 SVG 元素 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', width.toString()); svg.setAttribute('height', height.toString()); svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`); // 添加白色背景矩形 const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); bgRect.setAttribute('x', minX.toString()); bgRect.setAttribute('y', minY.toString()); bgRect.setAttribute('width', width.toString()); bgRect.setAttribute('height', height.toString()); bgRect.setAttribute('fill', 'white'); svg.appendChild(bgRect); // 创建 defs 用于箭头标记 const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); // 添加边的箭头标记(为不同类型的边创建不同的箭头) const edgeTypes = ['discovers', 'targets', 'enables', 'leads_to']; edgeTypes.forEach((type, index) => { let color = '#999'; if (type === 'discovers') color = '#3498db'; else if (type === 'targets') color = '#0066ff'; else if (type === 'enables') color = '#e74c3c'; else if (type === 'leads_to') color = '#666'; const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); marker.setAttribute('id', `arrowhead-${type}`); marker.setAttribute('markerWidth', '10'); marker.setAttribute('markerHeight', '10'); marker.setAttribute('refX', '9'); marker.setAttribute('refY', '3'); marker.setAttribute('orient', 'auto'); const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); polygon.setAttribute('points', '0 0, 10 3, 0 6'); polygon.setAttribute('fill', color); marker.appendChild(polygon); defs.appendChild(marker); }); svg.appendChild(defs); // 添加边(先绘制,这样节点会在上面) edges.forEach(edge => { const sourcePos = edge.source().position(); const targetPos = edge.target().position(); const edgeData = edge.data(); const edgeType = edgeData.type || 'leads_to'; // 获取边的样式 let lineColor = '#999'; if (edgeType === 'discovers') lineColor = '#3498db'; else if (edgeType === 'targets') lineColor = '#0066ff'; else if (edgeType === 'enables') lineColor = '#e74c3c'; else if (edgeType === 'leads_to') lineColor = '#666'; // 创建路径(支持曲线) const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); // 简单的直线路径(可以改进为曲线) const midX = (sourcePos.x + targetPos.x) / 2; const midY = (sourcePos.y + targetPos.y) / 2; const dx = targetPos.x - sourcePos.x; const dy = targetPos.y - sourcePos.y; const offset = Math.min(30, Math.sqrt(dx * dx + dy * dy) * 0.3); // 使用二次贝塞尔曲线 const controlX = midX + (dy > 0 ? -offset : offset); const controlY = midY + (dx > 0 ? offset : -offset); path.setAttribute('d', `M ${sourcePos.x} ${sourcePos.y} Q ${controlX} ${controlY} ${targetPos.x} ${targetPos.y}`); path.setAttribute('stroke', lineColor); path.setAttribute('stroke-width', '2'); path.setAttribute('fill', 'none'); path.setAttribute('marker-end', `url(#arrowhead-${edgeType})`); svg.appendChild(path); }); // 添加节点 nodes.forEach(node => { const pos = node.position(); const nodeData = node.data(); const riskScore = nodeData.riskScore || 0; const nodeWidth = node.width(); const nodeHeight = node.height(); const size = Math.max(nodeWidth, nodeHeight) / 2; // 确定节点颜色 let bgColor = '#88cc00'; let textColor = '#1a5a1a'; let borderColor = '#5a8a5a'; if (riskScore >= 80) { bgColor = '#ff4444'; textColor = '#fff'; borderColor = '#fff'; } else if (riskScore >= 60) { bgColor = '#ff8800'; textColor = '#fff'; borderColor = '#fff'; } else if (riskScore >= 40) { bgColor = '#ffbb00'; textColor = '#333'; borderColor = '#cc9900'; } // 确定节点形状 const nodeType = nodeData.type; let shapeElement; if (nodeType === 'vulnerability') { // 菱形 shapeElement = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); const points = [ `${pos.x},${pos.y - size}`, `${pos.x + size},${pos.y}`, `${pos.x},${pos.y + size}`, `${pos.x - size},${pos.y}` ].join(' '); shapeElement.setAttribute('points', points); } else if (nodeType === 'target') { // 星形(五角星) shapeElement = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); const points = []; for (let i = 0; i < 5; i++) { const angle = (i * 4 * Math.PI / 5) - Math.PI / 2; const x = pos.x + size * Math.cos(angle); const y = pos.y + size * Math.sin(angle); points.push(`${x},${y}`); } shapeElement.setAttribute('points', points.join(' ')); } else { // 圆角矩形 shapeElement = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); shapeElement.setAttribute('x', (pos.x - size).toString()); shapeElement.setAttribute('y', (pos.y - size).toString()); shapeElement.setAttribute('width', (size * 2).toString()); shapeElement.setAttribute('height', (size * 2).toString()); shapeElement.setAttribute('rx', '5'); shapeElement.setAttribute('ry', '5'); } shapeElement.setAttribute('fill', bgColor); shapeElement.setAttribute('stroke', borderColor); shapeElement.setAttribute('stroke-width', '2'); svg.appendChild(shapeElement); // 添加文本标签(使用文本描边提高可读性) const label = (nodeData.label || nodeData.id || '').toString(); const maxLength = 15; // 创建文本组,包含描边和填充 const textGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); textGroup.setAttribute('text-anchor', 'middle'); textGroup.setAttribute('dominant-baseline', 'middle'); // 处理长文本(简单换行) let lines = []; if (label.length > maxLength) { const words = label.split(' '); let currentLine = ''; words.forEach(word => { if ((currentLine + word).length <= maxLength) { currentLine += (currentLine ? ' ' : '') + word; } else { if (currentLine) lines.push(currentLine); currentLine = word; } }); if (currentLine) lines.push(currentLine); lines = lines.slice(0, 2); // 最多两行 } else { lines = [label]; } // 确定文本描边颜色(与原始渲染一致) let textOutlineColor = '#fff'; let textOutlineWidth = 2; if (riskScore >= 80 || riskScore >= 60) { // 红色/橙色背景:白色文字,白色描边,深色轮廓 textOutlineColor = '#333'; textOutlineWidth = 1; } else if (riskScore >= 40) { // 黄色背景:深色文字,白色描边 textOutlineColor = '#fff'; textOutlineWidth = 2; } else { // 绿色背景:深绿色文字,白色描边 textOutlineColor = '#fff'; textOutlineWidth = 2; } // 为每行文本创建描边和填充 lines.forEach((line, i) => { const textY = pos.y + (i - (lines.length - 1) / 2) * 16; // 描边文本(用于提高对比度,模拟text-outline效果) const strokeText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); strokeText.setAttribute('x', pos.x.toString()); strokeText.setAttribute('y', textY.toString()); strokeText.setAttribute('fill', 'none'); strokeText.setAttribute('stroke', textOutlineColor); strokeText.setAttribute('stroke-width', textOutlineWidth.toString()); strokeText.setAttribute('stroke-linejoin', 'round'); strokeText.setAttribute('stroke-linecap', 'round'); strokeText.setAttribute('font-size', '14px'); strokeText.setAttribute('font-weight', 'bold'); strokeText.setAttribute('font-family', 'Arial, sans-serif'); strokeText.setAttribute('text-anchor', 'middle'); strokeText.setAttribute('dominant-baseline', 'middle'); strokeText.textContent = line; textGroup.appendChild(strokeText); // 填充文本(实际可见的文本) const fillText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); fillText.setAttribute('x', pos.x.toString()); fillText.setAttribute('y', textY.toString()); fillText.setAttribute('fill', textColor); fillText.setAttribute('font-size', '14px'); fillText.setAttribute('font-weight', 'bold'); fillText.setAttribute('font-family', 'Arial, sans-serif'); fillText.setAttribute('text-anchor', 'middle'); fillText.setAttribute('dominant-baseline', 'middle'); fillText.textContent = line; textGroup.appendChild(fillText); }); svg.appendChild(textGroup); }); // 将 SVG 转换为字符串 const serializer = new XMLSerializer(); let svgString = serializer.serializeToString(svg); // 确保有 XML 声明 if (!svgString.startsWith('\n' + svgString; } const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `attack-chain-${currentAttackChainConversationId || 'export'}-${Date.now()}.svg`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 100); } catch (err) { console.error('SVG导出错误:', err); alert('导出SVG失败: ' + (err.message || '未知错误')); } } else { alert('不支持的导出格式: ' + format); } } catch (error) { console.error('导出失败:', error); alert('导出失败: ' + (error.message || '未知错误')); } }, 100); // 小延迟确保图形已渲染 }