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 = '
正在加载工具...
';
mentionSuggestionsEl.style.display = 'block';
delete mentionSuggestionsEl.dataset.lastMentionQuery;
return;
}
if (!mentionFilteredTools.length) {
mentionSuggestionsEl.innerHTML = '没有匹配的工具
';
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 ? '外部' : '内置';
const nameHtml = escapeHtml(tool.name);
const description = tool.description && tool.description.length > 0 ? escapeHtml(tool.description) : '暂无描述';
const descHtml = `${description}
`;
const statusLabel = tool.enabled ? '可用' : '已禁用';
const statusClass = tool.enabled ? 'enabled' : 'disabled';
const originLabel = tool.isExternal
? (tool.externalMcp ? `来源:${escapeHtml(tool.externalMcp)}` : '来源:外部MCP')
: '来源:内置工具';
return `
`;
}).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, '
');
}
} else {
formattedContent = escapeHtml(content).replace(/\n/g, '
');
}
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 = `调用 #${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();
}
}
// 输入框事件绑定(回车发送 / @提及)
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 = '暂无历史对话
';
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 = `
`;
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 = '加载中...
';
}
// 隐藏详情面板
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 += `
关键发现:
${nodeData.metadata.findings.map(f => `- ${escapeHtml(f)}
`).join('')}
`;
}
}
// 显示目标信息(如果是目标节点)
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;
}
// 获取严重程度颜色
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); // 小延迟确保图形已渲染
}