Files
CyberStrikeAI/web/static/js/chat.js
2025-11-23 19:46:18 +08:00

2205 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let currentConversationId = null;
// @ 提及相关状态
let mentionTools = [];
let mentionToolsLoaded = false;
let mentionToolsLoadingPromise = null;
let mentionSuggestionsEl = null;
let mentionFilteredTools = [];
const mentionState = {
active: false,
startIndex: -1,
query: '',
selectedIndex: 0,
};
// 发送消息
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 setupMentionSupport() {
mentionSuggestionsEl = document.getElementById('mention-suggestions');
if (mentionSuggestionsEl) {
mentionSuggestionsEl.style.display = 'none';
mentionSuggestionsEl.addEventListener('mousedown', (event) => {
// 防止点击候选项时输入框失焦
event.preventDefault();
});
}
ensureMentionToolsLoaded().catch(() => {
// 忽略加载错误,稍后可重试
});
}
function ensureMentionToolsLoaded() {
if (mentionToolsLoaded) {
return Promise.resolve(mentionTools);
}
if (mentionToolsLoadingPromise) {
return mentionToolsLoadingPromise;
}
mentionToolsLoadingPromise = fetchMentionTools().finally(() => {
mentionToolsLoadingPromise = null;
});
return mentionToolsLoadingPromise;
}
async function fetchMentionTools() {
const pageSize = 100;
let page = 1;
let totalPages = 1;
const seen = new Set();
const collected = [];
try {
while (page <= totalPages && page <= 20) {
const response = await apiFetch(`/api/config/tools?page=${page}&page_size=${pageSize}`);
if (!response.ok) {
break;
}
const result = await response.json();
const tools = Array.isArray(result.tools) ? result.tools : [];
tools.forEach(tool => {
if (!tool || !tool.name || seen.has(tool.name)) {
return;
}
seen.add(tool.name);
collected.push({
name: tool.name,
description: tool.description || '',
enabled: tool.enabled !== false,
isExternal: !!tool.is_external,
externalMcp: tool.external_mcp || '',
});
});
totalPages = result.total_pages || 1;
page += 1;
if (page > totalPages) {
break;
}
}
mentionTools = collected;
mentionToolsLoaded = true;
} catch (error) {
console.warn('加载工具列表失败,@提及功能可能不可用:', error);
}
return mentionTools;
}
function handleChatInputInput(event) {
updateMentionStateFromInput(event.target);
}
function handleChatInputClick(event) {
updateMentionStateFromInput(event.target);
}
function handleChatInputKeydown(event) {
if (mentionState.active && mentionSuggestionsEl && mentionSuggestionsEl.style.display !== 'none') {
if (event.key === 'ArrowDown') {
event.preventDefault();
moveMentionSelection(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveMentionSelection(-1);
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
applyMentionSelection();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
deactivateMentionState();
return;
}
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
function updateMentionStateFromInput(textarea) {
if (!textarea) {
deactivateMentionState();
return;
}
const caret = textarea.selectionStart || 0;
const textBefore = textarea.value.slice(0, caret);
const atIndex = textBefore.lastIndexOf('@');
if (atIndex === -1) {
deactivateMentionState();
return;
}
// 限制触发字符之前必须是空白或起始位置
if (atIndex > 0) {
const boundaryChar = textBefore[atIndex - 1];
if (boundaryChar && !/\s/.test(boundaryChar) && !'([{,。,.;:!?'.includes(boundaryChar)) {
deactivateMentionState();
return;
}
}
const querySegment = textBefore.slice(atIndex + 1);
if (querySegment.includes(' ') || querySegment.includes('\n') || querySegment.includes('\t') || querySegment.includes('@')) {
deactivateMentionState();
return;
}
if (querySegment.length > 60) {
deactivateMentionState();
return;
}
mentionState.active = true;
mentionState.startIndex = atIndex;
mentionState.query = querySegment.toLowerCase();
mentionState.selectedIndex = 0;
if (!mentionToolsLoaded) {
renderMentionSuggestions({ showLoading: true });
} else {
updateMentionCandidates();
renderMentionSuggestions();
}
ensureMentionToolsLoaded().then(() => {
if (mentionState.active) {
updateMentionCandidates();
renderMentionSuggestions();
}
});
}
function updateMentionCandidates() {
if (!mentionState.active) {
mentionFilteredTools = [];
return;
}
const normalizedQuery = (mentionState.query || '').trim().toLowerCase();
let filtered = mentionTools;
if (normalizedQuery) {
filtered = mentionTools.filter(tool => {
const nameMatch = tool.name.toLowerCase().includes(normalizedQuery);
const descMatch = tool.description && tool.description.toLowerCase().includes(normalizedQuery);
return nameMatch || descMatch;
});
}
filtered = filtered.slice().sort((a, b) => {
if (normalizedQuery) {
const aStarts = a.name.toLowerCase().startsWith(normalizedQuery);
const bStarts = b.name.toLowerCase().startsWith(normalizedQuery);
if (aStarts !== bStarts) {
return aStarts ? -1 : 1;
}
}
if (a.enabled !== b.enabled) {
return a.enabled ? -1 : 1;
}
return a.name.localeCompare(b.name, 'zh-CN');
});
mentionFilteredTools = filtered;
if (mentionFilteredTools.length === 0) {
mentionState.selectedIndex = 0;
} else if (mentionState.selectedIndex >= mentionFilteredTools.length) {
mentionState.selectedIndex = 0;
}
}
function renderMentionSuggestions({ showLoading = false } = {}) {
if (!mentionSuggestionsEl || !mentionState.active) {
hideMentionSuggestions();
return;
}
const currentQuery = mentionState.query || '';
const existingList = mentionSuggestionsEl.querySelector('.mention-suggestions-list');
const canPreserveScroll = !showLoading &&
existingList &&
mentionSuggestionsEl.dataset.lastMentionQuery === currentQuery;
const previousScrollTop = canPreserveScroll ? existingList.scrollTop : 0;
if (showLoading) {
mentionSuggestionsEl.innerHTML = '<div class="mention-empty">正在加载工具...</div>';
mentionSuggestionsEl.style.display = 'block';
delete mentionSuggestionsEl.dataset.lastMentionQuery;
return;
}
if (!mentionFilteredTools.length) {
mentionSuggestionsEl.innerHTML = '<div class="mention-empty">没有匹配的工具</div>';
mentionSuggestionsEl.style.display = 'block';
mentionSuggestionsEl.dataset.lastMentionQuery = currentQuery;
return;
}
const itemsHtml = mentionFilteredTools.map((tool, index) => {
const activeClass = index === mentionState.selectedIndex ? 'active' : '';
const disabledClass = tool.enabled ? '' : 'disabled';
const badge = tool.isExternal ? '<span class="mention-item-badge">外部</span>' : '<span class="mention-item-badge internal">内置</span>';
const nameHtml = escapeHtml(tool.name);
const description = tool.description && tool.description.length > 0 ? escapeHtml(tool.description) : '暂无描述';
const descHtml = `<div class="mention-item-desc">${description}</div>`;
const statusLabel = tool.enabled ? '可用' : '已禁用';
const statusClass = tool.enabled ? 'enabled' : 'disabled';
const originLabel = tool.isExternal
? (tool.externalMcp ? `来源:${escapeHtml(tool.externalMcp)}` : '来源外部MCP')
: '来源:内置工具';
return `
<button type="button" class="mention-item ${activeClass} ${disabledClass}" data-index="${index}">
<div class="mention-item-name">
<span class="mention-item-icon">🔧</span>
<span class="mention-item-text">@${nameHtml}</span>
${badge}
</div>
${descHtml}
<div class="mention-item-meta">
<span class="mention-status ${statusClass}">${statusLabel}</span>
<span class="mention-origin">${originLabel}</span>
</div>
</button>
`;
}).join('');
const listWrapper = document.createElement('div');
listWrapper.className = 'mention-suggestions-list';
listWrapper.innerHTML = itemsHtml;
mentionSuggestionsEl.innerHTML = '';
mentionSuggestionsEl.appendChild(listWrapper);
mentionSuggestionsEl.style.display = 'block';
mentionSuggestionsEl.dataset.lastMentionQuery = currentQuery;
if (canPreserveScroll) {
listWrapper.scrollTop = previousScrollTop;
}
listWrapper.querySelectorAll('.mention-item').forEach(item => {
item.addEventListener('mousedown', (event) => {
event.preventDefault();
const idx = parseInt(item.dataset.index, 10);
if (!Number.isNaN(idx)) {
mentionState.selectedIndex = idx;
}
applyMentionSelection();
});
});
scrollMentionSelectionIntoView();
}
function hideMentionSuggestions() {
if (mentionSuggestionsEl) {
mentionSuggestionsEl.style.display = 'none';
mentionSuggestionsEl.innerHTML = '';
delete mentionSuggestionsEl.dataset.lastMentionQuery;
}
}
function deactivateMentionState() {
mentionState.active = false;
mentionState.startIndex = -1;
mentionState.query = '';
mentionState.selectedIndex = 0;
mentionFilteredTools = [];
hideMentionSuggestions();
}
function moveMentionSelection(direction) {
if (!mentionFilteredTools.length) {
return;
}
const max = mentionFilteredTools.length - 1;
let nextIndex = mentionState.selectedIndex + direction;
if (nextIndex < 0) {
nextIndex = max;
} else if (nextIndex > max) {
nextIndex = 0;
}
mentionState.selectedIndex = nextIndex;
updateMentionActiveHighlight();
}
function updateMentionActiveHighlight() {
if (!mentionSuggestionsEl) {
return;
}
const items = mentionSuggestionsEl.querySelectorAll('.mention-item');
if (!items.length) {
return;
}
items.forEach(item => item.classList.remove('active'));
let targetIndex = mentionState.selectedIndex;
if (targetIndex < 0) {
targetIndex = 0;
}
if (targetIndex >= items.length) {
targetIndex = items.length - 1;
mentionState.selectedIndex = targetIndex;
}
const activeItem = items[targetIndex];
if (activeItem) {
activeItem.classList.add('active');
scrollMentionSelectionIntoView(activeItem);
}
}
function scrollMentionSelectionIntoView(targetItem = null) {
if (!mentionSuggestionsEl) {
return;
}
const activeItem = targetItem || mentionSuggestionsEl.querySelector('.mention-item.active');
if (activeItem && typeof activeItem.scrollIntoView === 'function') {
activeItem.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'auto'
});
}
}
function applyMentionSelection() {
const textarea = document.getElementById('chat-input');
if (!textarea || mentionState.startIndex === -1 || !mentionFilteredTools.length) {
deactivateMentionState();
return;
}
const selectedTool = mentionFilteredTools[mentionState.selectedIndex] || mentionFilteredTools[0];
if (!selectedTool) {
deactivateMentionState();
return;
}
const caret = textarea.selectionStart || 0;
const before = textarea.value.slice(0, mentionState.startIndex);
const after = textarea.value.slice(caret);
const mentionText = `@${selectedTool.name}`;
const needsSpace = after.length === 0 || !/^\s/.test(after);
const insertText = mentionText + (needsSpace ? ' ' : '');
textarea.value = before + insertText + after;
const newCaret = before.length + insertText.length;
textarea.focus();
textarea.setSelectionRange(newCaret, newCaret);
deactivateMentionState();
}
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', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。');
}
addAttackChainButton(currentConversationId);
loadActiveTasks(true);
if (activeTaskInterval) {
clearInterval(activeTaskInterval);
}
activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL);
setupMentionSupport();
}
// 消息计数器确保ID唯一
let messageCounter = 0;
// 添加消息
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = 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;
const defaultSanitizeConfig = {
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,
};
const parseMarkdown = (raw) => {
if (typeof marked === 'undefined') {
return null;
}
try {
marked.setOptions({
breaks: true,
gfm: true,
});
return marked.parse(raw);
} catch (e) {
console.error('Markdown 解析失败:', e);
return null;
}
};
if (typeof DOMPurify !== 'undefined') {
let parsedContent = parseMarkdown(content);
if (!parsedContent) {
// 如果 Markdown 解析失败或 marked 不可用,则退回原始内容
parsedContent = content;
}
formattedContent = DOMPurify.sanitize(parsedContent, defaultSanitizeConfig);
} else if (typeof marked !== 'undefined') {
const parsedContent = parseMarkdown(content);
if (parsedContent) {
formattedContent = parsedContent;
} else {
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
}
} else {
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
}
bubble.innerHTML = formattedContent;
contentWrapper.appendChild(bubble);
// 添加时间戳
const timeDiv = document.createElement('div');
timeDiv.className = 'message-time';
// 如果有传入的创建时间,使用它;否则使用当前时间
let messageTime;
if (createdAt) {
// 处理字符串或Date对象
if (typeof createdAt === 'string') {
messageTime = new Date(createdAt);
} else if (createdAt instanceof Date) {
messageTime = createdAt;
} else {
messageTime = new Date(createdAt);
}
// 如果解析失败,使用当前时间
if (isNaN(messageTime.getTime())) {
messageTime = new Date();
}
} else {
messageTime = new Date();
}
timeDiv.textContent = messageTime.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 = `<span>调用 #${index + 1}</span>`;
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 = '<span>展开详情</span>';
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 = '<span>展开详情</span>';
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 = '<span>展开详情</span>';
}
}
}
// 移除消息
function removeMessage(id) {
const messageDiv = document.getElementById(id);
if (messageDiv) {
messageDiv.remove();
}
}
// 输入框事件绑定(回车发送 / @提及)
const chatInput = document.getElementById('chat-input');
if (chatInput) {
chatInput.addEventListener('keydown', handleChatInputKeydown);
chatInput.addEventListener('input', handleChatInputInput);
chatInput.addEventListener('click', handleChatInputClick);
chatInput.addEventListener('focus', handleChatInputClick);
chatInput.addEventListener('blur', () => {
setTimeout(() => {
if (!chatInput.matches(':focus')) {
deactivateMentionState();
}
}, 120);
});
}
// 显示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 startNewConversation() {
currentConversationId = null;
document.getElementById('chat-messages').innerHTML = '';
addMessage('assistant', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。');
addAttackChainButton(null);
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');
if (!listContainer) {
return;
}
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;">暂无历史对话</div>';
listContainer.innerHTML = '';
if (!Array.isArray(conversations) || conversations.length === 0) {
listContainer.innerHTML = emptyStateHtml;
return;
}
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const weekday = todayStart.getDay() === 0 ? 7 : todayStart.getDay();
const startOfWeek = new Date(todayStart);
startOfWeek.setDate(todayStart.getDate() - (weekday - 1));
const yesterdayStart = new Date(todayStart);
yesterdayStart.setDate(todayStart.getDate() - 1);
const groups = {
today: [],
yesterday: [],
thisWeek: [],
earlier: [],
};
conversations.forEach(conv => {
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
const groupKey = getConversationGroup(validDate, todayStart, startOfWeek, yesterdayStart);
groups[groupKey].push({
...conv,
_time: validDate,
_timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart),
});
});
const groupOrder = [
{ key: 'today', label: '今天' },
{ key: 'yesterday', label: '昨天' },
{ key: 'thisWeek', label: '本周' },
{ key: 'earlier', label: '更早' },
];
const fragment = document.createDocumentFragment();
let rendered = false;
groupOrder.forEach(({ key, label }) => {
const items = groups[key];
if (!items || items.length === 0) {
return;
}
rendered = true;
const section = document.createElement('div');
section.className = 'conversation-group';
const title = document.createElement('div');
title.className = 'conversation-group-title';
title.textContent = label;
section.appendChild(title);
items.forEach(itemData => {
section.appendChild(createConversationListItem(itemData));
});
fragment.appendChild(section);
});
if (!rendered) {
listContainer.innerHTML = emptyStateHtml;
return;
}
listContainer.appendChild(fragment);
updateActiveConversation();
} catch (error) {
console.error('加载对话列表失败:', error);
}
}
function createConversationListItem(conversation) {
const item = document.createElement('div');
item.className = 'conversation-item';
item.dataset.conversationId = conversation.id;
if (conversation.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 = conversation.title || '未命名对话';
contentWrapper.appendChild(title);
const time = document.createElement('div');
time.className = 'conversation-time';
time.textContent = conversation._timeText || formatConversationTimestamp(conversation._time || new Date());
contentWrapper.appendChild(time);
item.appendChild(contentWrapper);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'conversation-delete-btn';
deleteBtn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14zM10 11v6M14 11v6"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
deleteBtn.title = '删除对话';
deleteBtn.onclick = (e) => {
e.stopPropagation();
deleteConversation(conversation.id);
};
item.appendChild(deleteBtn);
item.onclick = () => loadConversation(conversation.id);
return item;
}
function formatConversationTimestamp(dateObj, todayStart, yesterdayStart) {
if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) {
return '';
}
const referenceToday = todayStart || new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate());
const referenceYesterday = yesterdayStart || new Date(referenceToday.getTime() - 24 * 60 * 60 * 1000);
const messageDate = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate());
if (messageDate.getTime() === referenceToday.getTime()) {
return dateObj.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}
if (messageDate.getTime() === referenceYesterday.getTime()) {
return '昨天 ' + dateObj.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}
if (dateObj.getFullYear() === referenceToday.getFullYear()) {
return dateObj.toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
return dateObj.toLocaleString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getConversationGroup(dateObj, todayStart, startOfWeek, yesterdayStart) {
if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) {
return 'earlier';
}
const today = new Date(todayStart.getFullYear(), todayStart.getMonth(), todayStart.getDate());
const yesterday = new Date(yesterdayStart.getFullYear(), yesterdayStart.getMonth(), yesterdayStart.getDate());
const messageDay = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate());
if (messageDay.getTime() === today.getTime() || messageDay > today) {
return 'today';
}
if (messageDay.getTime() === yesterday.getTime()) {
return 'yesterday';
}
if (messageDay >= startOfWeek && messageDay < today) {
return 'thisWeek';
}
return 'earlier';
}
// 加载对话
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 || [], null, msg.createdAt);
// 如果有过程详情,显示它们
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', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。');
addAttackChainButton(null);
}
// 刷新对话列表
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');
}
});
}
// ==================== 攻击链可视化功能 ====================
let attackChainCytoscape = null;
let currentAttackChainConversationId = null;
let isAttackChainLoading = false; // 防止重复加载
// 添加攻击链按钮
function addAttackChainButton(conversationId) {
const attackChainBtn = document.getElementById('attack-chain-btn');
if (!attackChainBtn) {
return;
}
if (conversationId) {
const isRunning = typeof isConversationTaskRunning === 'function'
? isConversationTaskRunning(conversationId)
: false;
if (isRunning) {
attackChainBtn.disabled = true;
attackChainBtn.title = '当前对话正在执行,请稍后再生成攻击链';
attackChainBtn.onclick = null;
} else {
attackChainBtn.disabled = false;
attackChainBtn.title = '查看当前对话的攻击链';
attackChainBtn.onclick = () => showAttackChain(conversationId);
}
} else {
attackChainBtn.disabled = true;
attackChainBtn.title = '请选择一个对话以查看攻击链';
attackChainBtn.onclick = null;
}
}
function updateAttackChainAvailability() {
addAttackChainButton(currentConversationId);
}
// 显示攻击链模态框
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 = '<div class="loading-spinner">加载中...</div>';
}
// 隐藏详情面板
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 = `
<div style="text-align: center; padding: 28px 24px; color: var(--text-secondary);">
<div style="display: inline-flex; align-items: center; gap: 8px; font-size: 0.95rem; color: var(--text-primary);">
<span role="presentation" aria-hidden="true">⏳</span>
<span>攻击链生成中,请稍候</span>
</div>
<button class="btn-secondary" onclick="refreshAttackChain()" style="margin-top: 12px; font-size: 0.78rem; padding: 4px 12px;">
刷新
</button>
</div>
`;
}
// 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 = `<div class="error-message">加载失败: ${error.message}</div>`;
}
// 错误时也重置加载状态
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 = '<div class="empty-message">暂无攻击链数据</div>';
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 = `
<div class="node-detail-item">
<strong>节点ID:</strong> <code>${nodeData.id}</code>
</div>
<div class="node-detail-item">
<strong>类型:</strong> ${getNodeTypeLabel(nodeData.type)}
</div>
<div class="node-detail-item">
<strong>标签:</strong> ${escapeHtml(nodeData.label)}
</div>
<div class="node-detail-item">
<strong>风险评分:</strong> ${nodeData.riskScore}/100
</div>
`;
// 显示action节点信息工具执行 + AI分析
if (nodeData.type === 'action' && nodeData.metadata) {
if (nodeData.metadata.tool_name) {
html += `
<div class="node-detail-item">
<strong>工具名称:</strong> <code>${escapeHtml(nodeData.metadata.tool_name)}</code>
</div>
`;
}
if (nodeData.metadata.tool_intent) {
html += `
<div class="node-detail-item">
<strong>工具意图:</strong> <span style="color: #0066ff; font-weight: bold;">${escapeHtml(nodeData.metadata.tool_intent)}</span>
</div>
`;
}
if (nodeData.metadata.ai_analysis) {
html += `
<div class="node-detail-item">
<strong>AI分析:</strong> <div style="margin-top: 5px; padding: 8px; background: #f5f5f5; border-radius: 4px;">${escapeHtml(nodeData.metadata.ai_analysis)}</div>
</div>
`;
}
if (nodeData.metadata.findings && Array.isArray(nodeData.metadata.findings) && nodeData.metadata.findings.length > 0) {
html += `
<div class="node-detail-item">
<strong>关键发现:</strong>
<ul style="margin: 5px 0; padding-left: 20px;">
${nodeData.metadata.findings.map(f => `<li>${escapeHtml(f)}</li>`).join('')}
</ul>
</div>
`;
}
}
// 显示目标信息(如果是目标节点)
if (nodeData.type === 'target' && nodeData.metadata && nodeData.metadata.target) {
html += `
<div class="node-detail-item">
<strong>测试目标:</strong> <code>${escapeHtml(nodeData.metadata.target)}</code>
</div>
`;
}
// 显示漏洞信息(如果是漏洞节点)
if (nodeData.type === 'vulnerability' && nodeData.metadata) {
if (nodeData.metadata.vulnerability_type) {
html += `
<div class="node-detail-item">
<strong>漏洞类型:</strong> ${escapeHtml(nodeData.metadata.vulnerability_type)}
</div>
`;
}
if (nodeData.metadata.description) {
html += `
<div class="node-detail-item">
<strong>描述:</strong> ${escapeHtml(nodeData.metadata.description)}
</div>
`;
}
if (nodeData.metadata.severity) {
html += `
<div class="node-detail-item">
<strong>严重程度:</strong> <span style="color: ${getSeverityColor(nodeData.metadata.severity)}; font-weight: bold;">${escapeHtml(nodeData.metadata.severity)}</span>
</div>
`;
}
if (nodeData.metadata.location) {
html += `
<div class="node-detail-item">
<strong>位置:</strong> <code>${escapeHtml(nodeData.metadata.location)}</code>
</div>
`;
}
}
if (nodeData.toolExecutionId) {
html += `
<div class="node-detail-item">
<strong>工具执行ID:</strong> <code>${nodeData.toolExecutionId}</code>
</div>
`;
}
if (nodeData.metadata && Object.keys(nodeData.metadata).length > 0) {
html += `
<div class="node-detail-item">
<strong>完整元数据:</strong>
<pre class="metadata-pre">${JSON.stringify(nodeData.metadata, null, 2)}</pre>
</div>
`;
}
detailsContent.innerHTML = html;
}
// 获取严重程度颜色
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 中处理)
// 这里我们假设如果成功加载,则重置状态
// 如果还是 409loadAttackChain 会保持 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 = '<div class="loading-spinner">重新生成中...</div>';
}
// 禁用重新生成按钮
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 = `
<div class="loading-spinner" style="text-align: center; padding: 40px;">
<div style="margin-bottom: 16px;">⏳ 攻击链正在生成中...</div>
<div style="color: var(--text-secondary); font-size: 0.875rem;">
请稍候,生成完成后将自动显示
</div>
<button class="btn-secondary" onclick="refreshAttackChain()" style="margin-top: 16px;">
刷新查看进度
</button>
</div>
`;
}
// 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 = `<div class="error-message">重新生成失败: ${error.message}</div>`;
}
} 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('<?xml')) {
svgString = '<?xml version="1.0" encoding="UTF-8"?>\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); // 小延迟确保图形已渲染
}