Files
CyberStrikeAI/web/static/js/monitor.js
2026-03-25 21:12:16 +08:00

2327 lines
105 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.
const progressTaskState = new Map();
let activeTaskInterval = null;
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
// 当前界面语言对应的 BCP 47 标签(与时间格式化一致)
function getCurrentTimeLocale() {
if (typeof window.__locale === 'string' && window.__locale.length) {
return window.__locale.startsWith('zh') ? 'zh-CN' : 'en-US';
}
if (typeof i18next !== 'undefined' && i18next.language) {
return (i18next.language || '').startsWith('zh') ? 'zh-CN' : 'en-US';
}
return 'zh-CN';
}
// toLocaleTimeString 选项:中文用 24 小时制,避免仍显示 AM/PM
function getTimeFormatOptions() {
const loc = getCurrentTimeLocale();
const base = { hour: '2-digit', minute: '2-digit', second: '2-digit' };
if (loc === 'zh-CN') {
base.hour12 = false;
}
return base;
}
// 将后端下发的进度文案转为当前语言的翻译(中英双向映射,切换语言后能跟上)
function translateProgressMessage(message) {
if (!message || typeof message !== 'string') return message;
if (typeof window.t !== 'function') return message;
const trim = message.trim();
const map = {
// 中文
'正在调用AI模型...': 'progress.callingAI',
'最后一次迭代:正在生成总结和下一步计划...': 'progress.lastIterSummary',
'总结生成完成': 'progress.summaryDone',
'正在生成最终回复...': 'progress.generatingFinalReply',
'达到最大迭代次数,正在生成总结...': 'progress.maxIterSummary',
'正在分析您的请求...': 'progress.analyzingRequestShort',
'开始分析请求并制定测试策略': 'progress.analyzingRequestPlanning',
'正在启动 Eino DeepAgent...': 'progress.startingEinoDeepAgent',
// 英文(与 en-US.json 一致,避免后端/缓存已是英文时无法随语言切换)
'Calling AI model...': 'progress.callingAI',
'Last iteration: generating summary and next steps...': 'progress.lastIterSummary',
'Summary complete': 'progress.summaryDone',
'Generating final reply...': 'progress.generatingFinalReply',
'Max iterations reached, generating summary...': 'progress.maxIterSummary',
'Analyzing your request...': 'progress.analyzingRequestShort',
'Analyzing your request and planning test strategy...': 'progress.analyzingRequestPlanning',
'Starting Eino DeepAgent...': 'progress.startingEinoDeepAgent'
};
if (map[trim]) return window.t(map[trim]);
const einoAgentRe = /^\[Eino\]\s*(.+)$/;
const einoM = trim.match(einoAgentRe);
if (einoM) {
return window.t('progress.einoAgent', { name: einoM[1] });
}
const callingToolPrefixCn = '正在调用工具: ';
const callingToolPrefixEn = 'Calling tool: ';
if (trim.indexOf(callingToolPrefixCn) === 0) {
const name = trim.slice(callingToolPrefixCn.length);
return window.t('progress.callingTool', { name: name });
}
if (trim.indexOf(callingToolPrefixEn) === 0) {
const name = trim.slice(callingToolPrefixEn.length);
return window.t('progress.callingTool', { name: name });
}
return message;
}
if (typeof window !== 'undefined') {
window.translateProgressMessage = translateProgressMessage;
}
// 存储工具调用ID到DOM元素的映射用于更新执行状态
const toolCallStatusMap = new Map();
// 模型流式输出缓存progressId -> { assistantId, buffer }
const responseStreamStateByProgressId = new Map();
// AI 思考流式输出progressId -> Map(streamId -> { itemId, buffer })
const thinkingStreamStateByProgressId = new Map();
// Eino 子代理回复流式progressId -> Map(streamId -> { itemId, buffer })
const einoAgentReplyStreamStateByProgressId = new Map();
// 工具输出流式增量progressId::toolCallId -> { itemId, buffer }
const toolResultStreamStateByKey = new Map();
function toolResultStreamKey(progressId, toolCallId) {
return String(progressId) + '::' + String(toolCallId);
}
/** Eino 多代理:时间线标题前加 [agentId],标明哪一代理产生该工具调用/结果/回复 */
function timelineAgentBracketPrefix(data) {
if (!data || data.einoAgent == null) return '';
const s = String(data.einoAgent).trim();
return s ? ('[' + s + '] ') : '';
}
// markdown 渲染(用于最终合并渲染;流式增量阶段用纯转义避免部分语法不稳定)
const assistantMarkdownSanitizeConfig = {
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,
};
function escapeHtmlLocal(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
function formatAssistantMarkdownContent(text) {
const raw = text == null ? '' : String(text);
if (typeof marked !== 'undefined') {
try {
marked.setOptions({ breaks: true, gfm: true });
const parsed = marked.parse(raw);
if (typeof DOMPurify !== 'undefined') {
return DOMPurify.sanitize(parsed, assistantMarkdownSanitizeConfig);
}
return parsed;
} catch (e) {
return escapeHtmlLocal(raw).replace(/\n/g, '<br>');
}
}
return escapeHtmlLocal(raw).replace(/\n/g, '<br>');
}
function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdown) {
const assistantElement = document.getElementById(assistantMessageId);
if (!assistantElement) return;
const bubble = assistantElement.querySelector('.message-bubble');
if (!bubble) return;
// 保留复制按钮addMessage 会把按钮 append 在 message-bubble 里
const copyBtn = bubble.querySelector('.message-copy-btn');
if (copyBtn) copyBtn.remove();
const newContent = content == null ? '' : String(content);
const html = renderMarkdown
? formatAssistantMarkdownContent(newContent)
: escapeHtmlLocal(newContent).replace(/\n/g, '<br>');
bubble.innerHTML = html;
// 更新原始内容(给复制功能用)
assistantElement.dataset.originalContent = newContent;
if (typeof wrapTablesInBubble === 'function') {
wrapTablesInBubble(bubble);
}
if (copyBtn) bubble.appendChild(copyBtn);
}
const conversationExecutionTracker = {
activeConversations: new Set(),
update(tasks = []) {
this.activeConversations.clear();
tasks.forEach(task => {
if (
task &&
task.conversationId &&
!TASK_FINAL_STATUSES.has(task.status)
) {
this.activeConversations.add(task.conversationId);
}
});
},
isRunning(conversationId) {
return !!conversationId && this.activeConversations.has(conversationId);
}
};
function isConversationTaskRunning(conversationId) {
return conversationExecutionTracker.isRunning(conversationId);
}
function registerProgressTask(progressId, conversationId = null) {
const state = progressTaskState.get(progressId) || {};
state.conversationId = conversationId !== undefined && conversationId !== null
? conversationId
: (state.conversationId ?? currentConversationId);
state.cancelling = false;
progressTaskState.set(progressId, state);
const progressElement = document.getElementById(progressId);
if (progressElement) {
progressElement.dataset.conversationId = state.conversationId || '';
}
}
function updateProgressConversation(progressId, conversationId) {
if (!conversationId) {
return;
}
registerProgressTask(progressId, conversationId);
}
function markProgressCancelling(progressId) {
const state = progressTaskState.get(progressId);
if (state) {
state.cancelling = true;
}
}
function finalizeProgressTask(progressId, finalLabel) {
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
if (stopBtn) {
stopBtn.disabled = true;
if (finalLabel !== undefined && finalLabel !== '') {
stopBtn.textContent = finalLabel;
} else {
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成';
}
}
progressTaskState.delete(progressId);
}
async function requestCancel(conversationId) {
const response = await apiFetch('/api/agent-loop/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ conversationId }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.cancelFailed') : '取消失败'));
}
return result;
}
function addProgressMessage() {
const messagesDiv = document.getElementById('chat-messages');
const messageDiv = document.createElement('div');
messageCounter++;
const id = 'progress-' + Date.now() + '-' + messageCounter;
messageDiv.id = id;
messageDiv.className = 'message system progress-message';
const contentWrapper = document.createElement('div');
contentWrapper.className = 'message-content';
const bubble = document.createElement('div');
bubble.className = 'message-bubble progress-container';
const progressTitleText = typeof window.t === 'function' ? window.t('chat.progressInProgress') : '渗透测试进行中...';
const stopTaskText = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
const collapseDetailText = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
bubble.innerHTML = `
<div class="progress-header">
<span class="progress-title">🔍 ${progressTitleText}</span>
<div class="progress-actions">
<button class="progress-stop" id="${id}-stop-btn" onclick="cancelProgressTask('${id}')">${stopTaskText}</button>
<button class="progress-toggle" onclick="toggleProgressDetails('${id}')">${collapseDetailText}</button>
</div>
</div>
<div class="progress-timeline expanded" id="${id}-timeline"></div>
`;
contentWrapper.appendChild(bubble);
messageDiv.appendChild(contentWrapper);
messageDiv.dataset.conversationId = currentConversationId || '';
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
return id;
}
// 切换进度详情显示
function toggleProgressDetails(progressId) {
const timeline = document.getElementById(progressId + '-timeline');
const toggleBtn = document.querySelector(`#${progressId} .progress-toggle`);
if (!timeline || !toggleBtn) return;
if (timeline.classList.contains('expanded')) {
timeline.classList.remove('expanded');
toggleBtn.textContent = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
} else {
timeline.classList.add('expanded');
toggleBtn.textContent = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
}
}
// 编排器开始输出最终回复时隐藏整条进度消息(迭代阶段保持展开可见;此处整行收起而非仅折叠时间线)
function hideProgressMessageForFinalReply(progressId) {
if (!progressId) return;
const el = document.getElementById(progressId);
if (el) {
el.style.display = 'none';
}
}
// 折叠所有进度详情
function collapseAllProgressDetails(assistantMessageId, progressId) {
// 折叠集成到MCP区域的详情
if (assistantMessageId) {
const detailsId = 'process-details-' + assistantMessageId;
const detailsContainer = document.getElementById(detailsId);
if (detailsContainer) {
const timeline = detailsContainer.querySelector('.progress-timeline');
if (timeline) {
// 确保移除expanded类无论是否包含
timeline.classList.remove('expanded');
const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`);
if (btn) {
btn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '</span>';
}
}
}
}
// 折叠独立的详情组件通过convertProgressToDetails创建的
// 查找所有以details-开头的详情组件
const allDetails = document.querySelectorAll('[id^="details-"]');
allDetails.forEach(detail => {
const timeline = detail.querySelector('.progress-timeline');
const toggleBtn = detail.querySelector('.progress-toggle');
if (timeline) {
timeline.classList.remove('expanded');
if (toggleBtn) {
toggleBtn.textContent = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
}
}
});
// 折叠原始的进度消息(如果还存在)
if (progressId) {
const progressTimeline = document.getElementById(progressId + '-timeline');
const progressToggleBtn = document.querySelector(`#${progressId} .progress-toggle`);
if (progressTimeline) {
progressTimeline.classList.remove('expanded');
if (progressToggleBtn) {
progressToggleBtn.textContent = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
}
}
}
}
// 获取当前助手消息ID用于done事件
function getAssistantId() {
// 从最近的助手消息中获取ID
const messages = document.querySelectorAll('.message.assistant');
if (messages.length > 0) {
return messages[messages.length - 1].id;
}
return null;
}
// 将进度详情集成到工具调用区域(流式阶段助手消息不挂 mcp 条,结束时在此创建,避免图二整行 MCP 芯片样式)
function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecutionIds) {
const progressElement = document.getElementById(progressId);
if (!progressElement) return;
const mcpIds = Array.isArray(mcpExecutionIds) ? mcpExecutionIds : [];
// 获取时间线内容
const timeline = document.getElementById(progressId + '-timeline');
let timelineHTML = '';
if (timeline) {
timelineHTML = timeline.innerHTML;
}
// 获取助手消息元素
const assistantElement = document.getElementById(assistantMessageId);
if (!assistantElement) {
removeMessage(progressId);
return;
}
const contentWrapper = assistantElement.querySelector('.message-content');
if (!contentWrapper) {
removeMessage(progressId);
return;
}
// 查找或创建 MCP 区域
let mcpSection = assistantElement.querySelector('.mcp-call-section');
if (!mcpSection) {
mcpSection = document.createElement('div');
mcpSection.className = 'mcp-call-section';
const mcpLabel = document.createElement('div');
mcpLabel.className = 'mcp-call-label';
mcpLabel.textContent = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情');
mcpSection.appendChild(mcpLabel);
const buttonsContainerInit = document.createElement('div');
buttonsContainerInit.className = 'mcp-call-buttons';
mcpSection.appendChild(buttonsContainerInit);
contentWrapper.appendChild(mcpSection);
}
// 获取时间线内容
const hasContent = timelineHTML.trim().length > 0;
// 检查时间线中是否有错误项
const hasError = timeline && timeline.querySelector('.timeline-item-error');
// 确保按钮容器存在
let buttonsContainer = mcpSection.querySelector('.mcp-call-buttons');
if (!buttonsContainer) {
buttonsContainer = document.createElement('div');
buttonsContainer.className = 'mcp-call-buttons';
mcpSection.appendChild(buttonsContainer);
}
const hasExecBtns = buttonsContainer.querySelector('.mcp-detail-btn:not(.process-detail-btn)');
if (mcpIds.length > 0 && !hasExecBtns) {
mcpIds.forEach((execId, index) => {
const detailBtn = document.createElement('button');
detailBtn.className = 'mcp-detail-btn';
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
detailBtn.onclick = () => showMCPDetail(execId);
buttonsContainer.appendChild(detailBtn);
if (typeof updateButtonWithToolName === 'function') {
updateButtonWithToolName(detailBtn, execId, index + 1);
}
});
}
if (!buttonsContainer.querySelector('.process-detail-btn')) {
const progressDetailBtn = document.createElement('button');
progressDetailBtn.className = 'mcp-detail-btn process-detail-btn';
progressDetailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '</span>';
progressDetailBtn.onclick = () => toggleProcessDetails(null, assistantMessageId);
buttonsContainer.appendChild(progressDetailBtn);
}
// 创建详情容器放在MCP按钮区域下方统一结构
const detailsId = 'process-details-' + assistantMessageId;
let detailsContainer = document.getElementById(detailsId);
if (!detailsContainer) {
detailsContainer = document.createElement('div');
detailsContainer.id = detailsId;
detailsContainer.className = 'process-details-container';
// 确保容器在按钮容器之后
if (buttonsContainer.nextSibling) {
mcpSection.insertBefore(detailsContainer, buttonsContainer.nextSibling);
} else {
mcpSection.appendChild(detailsContainer);
}
}
// 设置详情内容(如果有错误,默认折叠;否则默认折叠)
detailsContainer.innerHTML = `
<div class="process-details-content">
${hasContent ? `<div class="progress-timeline" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">' + (typeof window.t === 'function' ? window.t('chat.noProcessDetail') : '暂无过程详情(可能执行过快或未触发详细事件)') + '</div>'}
</div>
`;
// 确保初始状态是折叠的(默认折叠,特别是错误时)
if (hasContent) {
const timeline = document.getElementById(detailsId + '-timeline');
if (timeline) {
// 如果有错误,确保折叠;否则也默认折叠
timeline.classList.remove('expanded');
}
const processDetailBtn = buttonsContainer.querySelector('.process-detail-btn');
if (processDetailBtn) {
processDetailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '</span>';
}
}
// 移除原来的进度消息
removeMessage(progressId);
}
// 切换过程详情显示
function toggleProcessDetails(progressId, assistantMessageId) {
const detailsId = 'process-details-' + assistantMessageId;
const detailsContainer = document.getElementById(detailsId);
if (!detailsContainer) return;
const content = detailsContainer.querySelector('.process-details-content');
const timeline = detailsContainer.querySelector('.progress-timeline');
const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`);
const expandT = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
const collapseT = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
if (content && timeline) {
if (timeline.classList.contains('expanded')) {
timeline.classList.remove('expanded');
if (btn) btn.innerHTML = '<span>' + expandT + '</span>';
} else {
timeline.classList.add('expanded');
if (btn) btn.innerHTML = '<span>' + collapseT + '</span>';
}
} else if (timeline) {
if (timeline.classList.contains('expanded')) {
timeline.classList.remove('expanded');
if (btn) btn.innerHTML = '<span>' + expandT + '</span>';
} else {
timeline.classList.add('expanded');
if (btn) btn.innerHTML = '<span>' + collapseT + '</span>';
}
}
// 滚动到展开的详情位置,而不是滚动到底部
if (timeline && timeline.classList.contains('expanded')) {
setTimeout(() => {
// 使用 scrollIntoView 滚动到详情容器位置
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
}
}
// 停止当前进度对应的任务
async function cancelProgressTask(progressId) {
const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
if (!state || !state.conversationId) {
if (stopBtn) {
stopBtn.disabled = true;
setTimeout(() => {
stopBtn.disabled = false;
}, 1500);
}
alert(typeof window.t === 'function' ? window.t('tasks.taskInfoNotSynced') : '任务信息尚未同步,请稍后再试。');
return;
}
if (state.cancelling) {
return;
}
markProgressCancelling(progressId);
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
}
try {
await requestCancel(state.conversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
if (stopBtn) {
stopBtn.disabled = false;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
}
const currentState = progressTaskState.get(progressId);
if (currentState) {
currentState.cancelling = false;
}
}
}
// 将进度消息转换为可折叠的详情组件
function convertProgressToDetails(progressId, assistantMessageId) {
const progressElement = document.getElementById(progressId);
if (!progressElement) return;
// 获取时间线内容
const timeline = document.getElementById(progressId + '-timeline');
// 即使时间线不存在,也创建详情组件(显示空状态)
let timelineHTML = '';
if (timeline) {
timelineHTML = timeline.innerHTML;
}
// 获取助手消息元素
const assistantElement = document.getElementById(assistantMessageId);
if (!assistantElement) {
removeMessage(progressId);
return;
}
// 创建详情组件
const detailsId = 'details-' + Date.now() + '-' + messageCounter++;
const detailsDiv = document.createElement('div');
detailsDiv.id = detailsId;
detailsDiv.className = 'message system progress-details';
const contentWrapper = document.createElement('div');
contentWrapper.className = 'message-content';
const bubble = document.createElement('div');
bubble.className = 'message-bubble progress-container completed';
// 获取时间线HTML内容
const hasContent = timelineHTML.trim().length > 0;
// 检查时间线中是否有错误项
const hasError = timeline && timeline.querySelector('.timeline-item-error');
// 如果有错误,默认折叠;否则默认展开
const shouldExpand = !hasError;
const expandedClass = shouldExpand ? 'expanded' : '';
const collapseDetailText = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
const expandDetailText = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
const toggleText = shouldExpand ? collapseDetailText : expandDetailText;
const penetrationDetailText = typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情';
const noProcessDetailText = typeof window.t === 'function' ? window.t('chat.noProcessDetail') : '暂无过程详情(可能执行过快或未触发详细事件)';
bubble.innerHTML = `
<div class="progress-header">
<span class="progress-title">📋 ${penetrationDetailText}</span>
${hasContent ? `<button class="progress-toggle" onclick="toggleProgressDetails('${detailsId}')">${toggleText}</button>` : ''}
</div>
${hasContent ? `<div class="progress-timeline ${expandedClass}" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">' + noProcessDetailText + '</div>'}
`;
contentWrapper.appendChild(bubble);
detailsDiv.appendChild(contentWrapper);
// 将详情组件插入到助手消息之后
const messagesDiv = document.getElementById('chat-messages');
// assistantElement 是消息div需要插入到它的下一个兄弟节点之前
if (assistantElement.nextSibling) {
messagesDiv.insertBefore(detailsDiv, assistantElement.nextSibling);
} else {
// 如果没有下一个兄弟节点,直接追加
messagesDiv.appendChild(detailsDiv);
}
// 移除原来的进度消息
removeMessage(progressId);
// 滚动到底部
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
// 处理流式事件
function handleStreamEvent(event, progressElement, progressId,
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
const timeline = document.getElementById(progressId + '-timeline');
if (!timeline) return;
// 终态事件error/cancelled优先复用现有助手消息避免重复追加相同报错
const upsertTerminalAssistantMessage = (message, preferredMessageId = null) => {
const preferredIds = [];
if (preferredMessageId) preferredIds.push(preferredMessageId);
const existingAssistantId = typeof getAssistantId === 'function' ? getAssistantId() : null;
if (existingAssistantId && !preferredIds.includes(existingAssistantId)) {
preferredIds.push(existingAssistantId);
}
for (const id of preferredIds) {
const element = document.getElementById(id);
if (element) {
updateAssistantBubbleContent(id, message, true);
setAssistantId(id);
return { assistantId: id, assistantElement: element };
}
}
const assistantId = addMessage('assistant', message, null, progressId);
setAssistantId(assistantId);
return { assistantId: assistantId, assistantElement: document.getElementById(assistantId) };
};
switch (event.type) {
case 'conversation':
if (event.data && event.data.conversationId) {
// 在更新之前先获取任务对应的原始对话ID
const taskState = progressTaskState.get(progressId);
const originalConversationId = taskState?.conversationId;
// 更新任务状态
updateProgressConversation(progressId, event.data.conversationId);
// 如果用户已经开始了新对话currentConversationId 为 null
// 且这个 conversation 事件来自旧对话,就不更新 currentConversationId
if (currentConversationId === null && originalConversationId !== null) {
// 用户已经开始了新对话,忽略旧对话的 conversation 事件
// 但仍然更新任务状态,以便正确显示任务信息
break;
}
// 更新当前对话ID
currentConversationId = event.data.conversationId;
updateActiveConversation();
addAttackChainButton(currentConversationId);
loadActiveTasks();
// 延迟刷新对话列表确保用户消息已保存updated_at已更新
// 这样新对话才能正确显示在最近对话列表的顶部
// 使用loadConversationsWithGroups确保分组映射缓存正确加载无论是否有分组都能立即显示
setTimeout(() => {
if (typeof loadConversationsWithGroups === 'function') {
loadConversationsWithGroups();
} else if (typeof loadConversations === 'function') {
loadConversations();
}
}, 200);
}
break;
case 'iteration':
// 添加迭代标记data 属性供语言切换时重算标题)
addTimelineItem(timeline, 'iteration', {
title: typeof window.t === 'function' ? window.t('chat.iterationRound', { n: event.data?.iteration || 1 }) : '第 ' + (event.data?.iteration || 1) + ' 轮迭代',
message: event.message,
data: event.data,
iterationN: event.data?.iteration || 1
});
break;
case 'thinking_stream_start': {
const d = event.data || {};
const streamId = d.streamId || null;
if (!streamId) break;
let state = thinkingStreamStateByProgressId.get(progressId);
if (!state) {
state = new Map();
thinkingStreamStateByProgressId.set(progressId, state);
}
// 若已存在,重置 buffer
const thinkBase = typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考';
const title = timelineAgentBracketPrefix(d) + '🤔 ' + thinkBase;
const itemId = addTimelineItem(timeline, 'thinking', {
title: title,
message: ' ',
data: d
});
state.set(streamId, { itemId, buffer: '' });
break;
}
case 'thinking_stream_delta': {
const d = event.data || {};
const streamId = d.streamId || null;
if (!streamId) break;
const state = thinkingStreamStateByProgressId.get(progressId);
if (!state || !state.has(streamId)) break;
const s = state.get(streamId);
const delta = event.message || '';
s.buffer += delta;
const item = document.getElementById(s.itemId);
if (item) {
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(s.buffer);
} else {
contentEl.textContent = s.buffer;
}
}
}
break;
}
case 'thinking':
// 如果本 thinking 是由 thinking_stream_* 聚合出来的(带 streamId避免重复创建 timeline item
if (event.data && event.data.streamId) {
const streamId = event.data.streamId;
const state = thinkingStreamStateByProgressId.get(progressId);
if (state && state.has(streamId)) {
const s = state.get(streamId);
s.buffer = event.message || '';
const item = document.getElementById(s.itemId);
if (item) {
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
// contentEl.innerHTML 用于兼容 Markdown 展示
if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(s.buffer);
} else {
contentEl.textContent = s.buffer;
}
}
}
break;
}
}
addTimelineItem(timeline, 'thinking', {
title: timelineAgentBracketPrefix(event.data) + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'),
message: event.message,
data: event.data
});
break;
case 'tool_calls_detected':
addTimelineItem(timeline, 'tool_calls_detected', {
title: timelineAgentBracketPrefix(event.data) + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: event.data?.count || 0 }) : '检测到 ' + (event.data?.count || 0) + ' 个工具调用'),
message: event.message,
data: event.data
});
break;
case 'warning':
addTimelineItem(timeline, 'warning', {
title: '⚠️',
message: event.message,
data: event.data
});
break;
case 'tool_call':
const toolInfo = event.data || {};
const toolName = toolInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const index = toolInfo.index || 0;
const total = toolInfo.total || 0;
const toolCallId = toolInfo.toolCallId || null;
const toolCallTitle = typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')';
const toolCallItemId = addTimelineItem(timeline, 'tool_call', {
title: timelineAgentBracketPrefix(toolInfo) + '🔧 ' + toolCallTitle,
message: event.message,
data: toolInfo,
expanded: false
});
// 如果有toolCallId存储映射关系以便后续更新状态
if (toolCallId && toolCallItemId) {
toolCallStatusMap.set(toolCallId, {
itemId: toolCallItemId,
timeline: timeline
});
// 添加执行中状态指示器
updateToolCallStatus(toolCallId, 'running');
}
break;
case 'tool_result_delta': {
const deltaInfo = event.data || {};
const toolCallId = deltaInfo.toolCallId || null;
if (!toolCallId) break;
const key = toolResultStreamKey(progressId, toolCallId);
let state = toolResultStreamStateByKey.get(key);
const toolNameDelta = deltaInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const deltaText = event.message || '';
if (!deltaText) break;
if (!state) {
// 首次增量:创建一个 tool_result 占位条目,后续不断更新 pre 内容
const runningLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
const title = timelineAgentBracketPrefix(deltaInfo) + '⏳ ' + (typeof window.t === 'function'
? window.t('timeline.running')
: runningLabel) + ' ' + (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtmlLocal(toolNameDelta), index: deltaInfo.index || 0, total: deltaInfo.total || 0 }) : toolNameDelta);
const itemId = addTimelineItem(timeline, 'tool_result', {
title: title,
message: '',
data: {
toolName: toolNameDelta,
success: true,
isError: false,
result: deltaText,
toolCallId: toolCallId,
index: deltaInfo.index,
total: deltaInfo.total,
iteration: deltaInfo.iteration,
einoAgent: deltaInfo.einoAgent,
source: deltaInfo.source
},
expanded: false
});
state = { itemId, buffer: '' };
toolResultStreamStateByKey.set(key, state);
}
state.buffer += deltaText;
const item = document.getElementById(state.itemId);
if (item) {
const pre = item.querySelector('pre.tool-result');
if (pre) {
pre.textContent = state.buffer;
}
}
break;
}
case 'tool_result':
const resultInfo = event.data || {};
const resultToolName = resultInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const success = resultInfo.success !== false;
const statusIcon = success ? '✅' : '❌';
const resultToolCallId = resultInfo.toolCallId || null;
const resultExecText = success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行失败');
// 若此 tool 已经流式推送过增量,则复用占位条目并更新最终结果,避免重复添加一条
if (resultToolCallId) {
const key = toolResultStreamKey(progressId, resultToolCallId);
const state = toolResultStreamStateByKey.get(key);
if (state && state.itemId) {
const item = document.getElementById(state.itemId);
if (item) {
const pre = item.querySelector('pre.tool-result');
const resultVal = resultInfo.result || resultInfo.error || '';
if (pre) pre.textContent = typeof resultVal === 'string' ? resultVal : JSON.stringify(resultVal);
const section = item.querySelector('.tool-result-section');
if (section) {
section.className = 'tool-result-section ' + (success ? 'success' : 'error');
}
const titleEl = item.querySelector('.timeline-item-title');
if (titleEl) {
if (resultInfo.einoAgent != null && String(resultInfo.einoAgent).trim() !== '') {
item.dataset.einoAgent = String(resultInfo.einoAgent).trim();
}
titleEl.textContent = timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText;
}
}
toolResultStreamStateByKey.delete(key);
// 同时更新 tool_call 的状态
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(resultToolCallId);
}
break;
}
}
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(resultToolCallId);
}
addTimelineItem(timeline, 'tool_result', {
title: timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText,
message: event.message,
data: resultInfo,
expanded: false
});
break;
case 'eino_agent_reply_stream_start': {
const d = event.data || {};
const streamId = d.streamId || null;
if (!streamId) break;
let stateMap = einoAgentReplyStreamStateByProgressId.get(progressId);
if (!stateMap) {
stateMap = new Map();
einoAgentReplyStreamStateByProgressId.set(progressId, stateMap);
}
const streamingLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
const itemId = addTimelineItem(timeline, 'eino_agent_reply', {
title: timelineAgentBracketPrefix(d) + '💬 ' + replyTitleBase + ' · ' + streamingLabel,
message: ' ',
data: d,
expanded: false
});
stateMap.set(streamId, { itemId, buffer: '' });
break;
}
case 'eino_agent_reply_stream_delta': {
const d = event.data || {};
const streamId = d.streamId || null;
if (!streamId) break;
const delta = event.message || '';
if (!delta) break;
const stateMap = einoAgentReplyStreamStateByProgressId.get(progressId);
if (!stateMap || !stateMap.has(streamId)) break;
const s = stateMap.get(streamId);
s.buffer += delta;
const item = document.getElementById(s.itemId);
if (item) {
let contentEl = item.querySelector('.timeline-item-content');
if (!contentEl) {
const header = item.querySelector('.timeline-item-header');
if (header) {
contentEl = document.createElement('div');
contentEl.className = 'timeline-item-content';
item.appendChild(contentEl);
}
}
if (contentEl) {
if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(s.buffer);
} else {
contentEl.textContent = s.buffer;
}
}
}
break;
}
case 'eino_agent_reply_stream_end': {
const d = event.data || {};
const streamId = d.streamId || null;
const stateMap = einoAgentReplyStreamStateByProgressId.get(progressId);
if (streamId && stateMap && stateMap.has(streamId)) {
const s = stateMap.get(streamId);
const full = (event.message != null && event.message !== '') ? String(event.message) : s.buffer;
s.buffer = full;
const item = document.getElementById(s.itemId);
if (item) {
const titleEl = item.querySelector('.timeline-item-title');
if (titleEl) {
const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
titleEl.textContent = timelineAgentBracketPrefix(d) + '💬 ' + replyTitleBase;
}
let contentEl = item.querySelector('.timeline-item-content');
if (!contentEl) {
contentEl = document.createElement('div');
contentEl.className = 'timeline-item-content';
item.appendChild(contentEl);
}
if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(full);
} else {
contentEl.textContent = full;
}
if (d.einoAgent != null && String(d.einoAgent).trim() !== '') {
item.dataset.einoAgent = String(d.einoAgent).trim();
}
}
stateMap.delete(streamId);
}
break;
}
case 'eino_agent_reply': {
const replyData = event.data || {};
const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
addTimelineItem(timeline, 'eino_agent_reply', {
title: timelineAgentBracketPrefix(replyData) + '💬 ' + replyTitleBase,
message: event.message || '',
data: replyData,
expanded: false
});
break;
}
case 'progress':
const progressTitle = document.querySelector(`#${progressId} .progress-title`);
if (progressTitle) {
// 保存原文,语言切换时可用 translateProgressMessage 重新套当前语言
const progressEl = document.getElementById(progressId);
if (progressEl) {
progressEl.dataset.progressRawMessage = event.message || '';
}
const progressMsg = translateProgressMessage(event.message);
progressTitle.textContent = '🔍 ' + progressMsg;
}
break;
case 'cancelled':
const taskCancelledText = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
addTimelineItem(timeline, 'cancelled', {
title: '⛔ ' + taskCancelledText,
message: event.message,
data: event.data
});
const cancelTitle = document.querySelector(`#${progressId} .progress-title`);
if (cancelTitle) {
cancelTitle.textContent = '⛔ ' + taskCancelledText;
}
const cancelProgressContainer = document.querySelector(`#${progressId} .progress-container`);
if (cancelProgressContainer) {
cancelProgressContainer.classList.add('completed');
}
if (progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCancelled') : '已取消');
}
// 复用已有助手消息(若有),避免终态事件重复插入消息
{
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
if (assistantElement) {
const detailsId = 'process-details-' + assistantId;
if (!document.getElementById(detailsId)) {
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
}
setTimeout(() => {
collapseAllProgressDetails(assistantId, progressId);
}, 100);
}
}
// 立即刷新任务状态
loadActiveTasks();
break;
case 'response_start': {
const responseTaskState = progressTaskState.get(progressId);
const responseOriginalConversationId = responseTaskState?.conversationId;
const responseData = event.data || {};
const mcpIds = responseData.mcpExecutionIds || [];
setMcpIds(mcpIds);
if (responseData.conversationId) {
// 如果用户已经开始了新对话currentConversationId 为 null且这个事件来自旧对话则忽略
if (currentConversationId === null && responseOriginalConversationId !== null) {
updateProgressConversation(progressId, responseData.conversationId);
break;
}
currentConversationId = responseData.conversationId;
updateActiveConversation();
addAttackChainButton(currentConversationId);
updateProgressConversation(progressId, responseData.conversationId);
loadActiveTasks();
}
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
// 创建时间线条目用于显示迭代过程中的输出
const agentPrefix = timelineAgentBracketPrefix(responseData);
const title = agentPrefix + '📝 ' + (typeof window.t === 'function' ? window.t('chat.planning') : '规划中');
const itemId = addTimelineItem(timeline, 'thinking', {
title: title,
message: ' ',
data: responseData
});
responseStreamStateByProgressId.set(progressId, { itemId: itemId, buffer: '' });
break;
}
case 'response_delta': {
const responseData = event.data || {};
const responseTaskState = progressTaskState.get(progressId);
const responseOriginalConversationId = responseTaskState?.conversationId;
if (responseData.conversationId) {
if (currentConversationId === null && responseOriginalConversationId !== null) {
updateProgressConversation(progressId, responseData.conversationId);
break;
}
}
// 多代理模式下,迭代过程中的输出只显示在时间线中
// 更新时间线条目内容
let state = responseStreamStateByProgressId.get(progressId);
if (!state) {
state = { itemId: null, buffer: '' };
responseStreamStateByProgressId.set(progressId, state);
}
const deltaContent = event.message || '';
state.buffer += deltaContent;
// 更新时间线条目内容
if (state.itemId) {
const item = document.getElementById(state.itemId);
if (item) {
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(state.buffer);
} else {
contentEl.textContent = state.buffer;
}
}
}
}
break;
}
case 'response':
// 在更新之前先获取任务对应的原始对话ID
const responseTaskState = progressTaskState.get(progressId);
const responseOriginalConversationId = responseTaskState?.conversationId;
// 先更新 mcp ids
const responseData = event.data || {};
const mcpIds = responseData.mcpExecutionIds || [];
setMcpIds(mcpIds);
// 更新对话ID
if (responseData.conversationId) {
if (currentConversationId === null && responseOriginalConversationId !== null) {
updateProgressConversation(progressId, responseData.conversationId);
break;
}
currentConversationId = responseData.conversationId;
updateActiveConversation();
addAttackChainButton(currentConversationId);
updateProgressConversation(progressId, responseData.conversationId);
loadActiveTasks();
}
// 如果之前已经在 response_start/response_delta 阶段创建过占位,则复用该消息更新最终内容
const streamState = responseStreamStateByProgressId.get(progressId);
const existingAssistantId = streamState?.assistantId || getAssistantId();
let assistantIdFinal = existingAssistantId;
if (!assistantIdFinal) {
assistantIdFinal = addMessage('assistant', event.message, mcpIds, progressId);
setAssistantId(assistantIdFinal);
} else {
setAssistantId(assistantIdFinal);
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
}
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
hideProgressMessageForFinalReply(progressId);
// 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整)
integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds);
responseStreamStateByProgressId.delete(progressId);
setTimeout(() => {
collapseAllProgressDetails(assistantIdFinal, progressId);
}, 3000);
setTimeout(() => {
loadConversations();
}, 200);
break;
case 'error':
// 显示错误
addTimelineItem(timeline, 'error', {
title: '❌ ' + (typeof window.t === 'function' ? window.t('chat.error') : '错误'),
message: event.message,
data: event.data
});
// 更新进度标题为错误状态
const errorTitle = document.querySelector(`#${progressId} .progress-title`);
if (errorTitle) {
errorTitle.textContent = '❌ ' + (typeof window.t === 'function' ? window.t('chat.executionFailed') : '执行失败');
}
// 更新进度容器为已完成状态添加completed类
const progressContainer = document.querySelector(`#${progressId} .progress-container`);
if (progressContainer) {
progressContainer.classList.add('completed');
}
// 完成进度任务(标记为失败)
if (progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusFailed') : '执行失败');
}
// 复用已有助手消息(若有),避免终态事件重复插入消息
{
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
if (assistantElement) {
const detailsId = 'process-details-' + assistantId;
if (!document.getElementById(detailsId)) {
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
}
setTimeout(() => {
collapseAllProgressDetails(assistantId, progressId);
}, 100);
}
}
// 立即刷新任务状态(执行失败时任务状态会更新)
loadActiveTasks();
break;
case 'done':
// 清理流式输出状态
responseStreamStateByProgressId.delete(progressId);
thinkingStreamStateByProgressId.delete(progressId);
einoAgentReplyStreamStateByProgressId.delete(progressId);
// 清理工具流式输出占位
const prefix = String(progressId) + '::';
for (const key of Array.from(toolResultStreamStateByKey.keys())) {
if (String(key).startsWith(prefix)) {
toolResultStreamStateByKey.delete(key);
}
}
// 完成,更新进度标题(如果进度消息还存在)
const doneTitle = document.querySelector(`#${progressId} .progress-title`);
if (doneTitle) {
doneTitle.textContent = '✅ ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestComplete') : '渗透测试完成');
}
// 更新对话ID
if (event.data && event.data.conversationId) {
currentConversationId = event.data.conversationId;
updateActiveConversation();
addAttackChainButton(currentConversationId);
updateProgressConversation(progressId, event.data.conversationId);
}
if (progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
}
// 检查时间线中是否有错误项
const hasError = timeline && timeline.querySelector('.timeline-item-error');
// 立即刷新任务状态(确保任务状态同步)
loadActiveTasks();
// 延迟再次刷新任务状态(确保后端已完成状态更新)
setTimeout(() => {
loadActiveTasks();
}, 200);
// 完成时自动折叠所有详情延迟一下确保response事件已处理
setTimeout(() => {
const assistantIdFromDone = getAssistantId();
if (assistantIdFromDone) {
collapseAllProgressDetails(assistantIdFromDone, progressId);
} else {
// 如果无法获取助手ID尝试折叠所有详情
collapseAllProgressDetails(null, progressId);
}
// 如果有错误,确保详情是折叠的(错误时应该默认折叠)
if (hasError) {
// 再次确保折叠延迟一点确保DOM已更新
setTimeout(() => {
collapseAllProgressDetails(assistantIdFromDone || null, progressId);
}, 200);
}
}, 500);
break;
}
// 自动滚动到底部
const messagesDiv = document.getElementById('chat-messages');
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
// 更新工具调用状态
function updateToolCallStatus(toolCallId, status) {
const mapping = toolCallStatusMap.get(toolCallId);
if (!mapping) return;
const item = document.getElementById(mapping.itemId);
if (!item) return;
const titleElement = item.querySelector('.timeline-item-title');
if (!titleElement) return;
// 移除之前的状态类
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
const runningLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
const completedLabel = typeof window.t === 'function' ? window.t('timeline.completed') : '已完成';
const failedLabel = typeof window.t === 'function' ? window.t('timeline.execFailed') : '执行失败';
let statusText = '';
if (status === 'running') {
item.classList.add('tool-call-running');
statusText = ' <span class="tool-status-badge tool-status-running">' + escapeHtml(runningLabel) + '</span>';
} else if (status === 'completed') {
item.classList.add('tool-call-completed');
statusText = ' <span class="tool-status-badge tool-status-completed">✅ ' + escapeHtml(completedLabel) + '</span>';
} else if (status === 'failed') {
item.classList.add('tool-call-failed');
statusText = ' <span class="tool-status-badge tool-status-failed">❌ ' + escapeHtml(failedLabel) + '</span>';
}
// 更新标题(保留原有文本,追加状态)
const originalText = titleElement.innerHTML;
// 移除之前可能存在的状态标记
const cleanText = originalText.replace(/\s*<span class="tool-status-badge[^>]*>.*?<\/span>/g, '');
titleElement.innerHTML = cleanText + statusText;
}
// 添加时间线项目
function addTimelineItem(timeline, type, options) {
const item = document.createElement('div');
// 生成唯一ID
const itemId = 'timeline-item-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
item.id = itemId;
item.className = `timeline-item timeline-item-${type}`;
// 记录类型与参数,便于 languagechange 时刷新标题文案
item.dataset.timelineType = type;
if (type === 'iteration') {
const n = options.iterationN != null ? options.iterationN : (options.data && options.data.iteration != null ? options.data.iteration : 1);
item.dataset.iterationN = String(n);
}
if (type === 'progress' && options.message) {
item.dataset.progressMessage = options.message;
}
if (type === 'tool_calls_detected' && options.data && options.data.count != null) {
item.dataset.toolCallsCount = String(options.data.count);
}
if (type === 'tool_call' && options.data) {
const d = options.data;
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
item.dataset.toolIndex = (d.index != null) ? String(d.index) : '0';
item.dataset.toolTotal = (d.total != null) ? String(d.total) : '0';
}
if (type === 'tool_result' && options.data) {
const d = options.data;
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
item.dataset.toolSuccess = d.success !== false ? '1' : '0';
}
if (options.data && options.data.einoAgent != null && String(options.data.einoAgent).trim() !== '') {
item.dataset.einoAgent = String(options.data.einoAgent).trim();
}
// 使用传入的createdAt时间如果没有则使用当前时间向后兼容
let eventTime;
if (options.createdAt) {
// 处理字符串或Date对象
if (typeof options.createdAt === 'string') {
eventTime = new Date(options.createdAt);
} else if (options.createdAt instanceof Date) {
eventTime = options.createdAt;
} else {
eventTime = new Date(options.createdAt);
}
// 如果解析失败,使用当前时间
if (isNaN(eventTime.getTime())) {
eventTime = new Date();
}
} else {
eventTime = new Date();
}
// 保存事件时间 ISO语言切换时可重算时间格式
try {
item.dataset.createdAtIso = eventTime.toISOString();
} catch (e) { /* ignore */ }
const timeLocale = getCurrentTimeLocale();
const timeOpts = getTimeFormatOptions();
const time = eventTime.toLocaleTimeString(timeLocale, timeOpts);
let content = `
<div class="timeline-item-header">
<span class="timeline-item-time">${time}</span>
<span class="timeline-item-title">${escapeHtml(options.title || '')}</span>
</div>
`;
// 根据类型添加详细内容
if (type === 'thinking' && options.message) {
content += `<div class="timeline-item-content">${formatMarkdown(options.message)}</div>`;
} else if (type === 'tool_call' && options.data) {
const data = options.data;
let args = data.argumentsObj;
if (args == null && data.arguments != null && String(data.arguments).trim() !== '') {
try {
args = JSON.parse(String(data.arguments));
} catch (e) {
args = { _raw: String(data.arguments) };
}
}
if (args == null || typeof args !== 'object') {
args = {};
}
const paramsLabel = typeof window.t === 'function' ? window.t('timeline.params') : '参数:';
content += `
<div class="timeline-item-content">
<div class="tool-details">
<div class="tool-arg-section">
<strong data-i18n="timeline.params">${escapeHtml(paramsLabel)}</strong>
<pre class="tool-args">${escapeHtml(JSON.stringify(args, null, 2))}</pre>
</div>
</div>
</div>
`;
} else if (type === 'eino_agent_reply' && options.message) {
content += `<div class="timeline-item-content">${formatMarkdown(options.message)}</div>`;
} else if (type === 'tool_result' && options.data) {
const data = options.data;
const isError = data.isError || !data.success;
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
const result = data.result || data.error || noResultText;
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
const execResultLabel = typeof window.t === 'function' ? window.t('timeline.executionResult') : '执行结果:';
const execIdLabel = typeof window.t === 'function' ? window.t('timeline.executionId') : '执行ID:';
content += `
<div class="timeline-item-content">
<div class="tool-result-section ${isError ? 'error' : 'success'}">
<strong data-i18n="timeline.executionResult">${escapeHtml(execResultLabel)}</strong>
<pre class="tool-result">${escapeHtml(resultStr)}</pre>
${data.executionId ? `<div class="tool-execution-id"><span data-i18n="timeline.executionId">${escapeHtml(execIdLabel)}</span> <code>${escapeHtml(data.executionId)}</code></div>` : ''}
</div>
</div>
`;
} else if (type === 'cancelled') {
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
content += `
<div class="timeline-item-content">
${escapeHtml(options.message || taskCancelledLabel)}
</div>
`;
}
item.innerHTML = content;
timeline.appendChild(item);
// 自动展开详情
const expanded = timeline.classList.contains('expanded');
if (!expanded && (type === 'tool_call' || type === 'tool_result')) {
// 对于工具调用和结果,默认显示摘要
}
// 返回item ID以便后续更新
return itemId;
}
// 加载活跃任务列表
async function loadActiveTasks(showErrors = false) {
const bar = document.getElementById('active-tasks-bar');
try {
const response = await apiFetch('/api/agent-loop/tasks');
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.loadActiveTasksFailed') : '获取活跃任务失败'));
}
renderActiveTasks(result.tasks || []);
} catch (error) {
console.error('获取活跃任务失败:', error);
if (showErrors && bar) {
bar.style.display = 'block';
const cannotGetStatus = typeof window.t === 'function' ? window.t('tasks.cannotGetTaskStatus') : '无法获取任务状态:';
bar.innerHTML = `<div class="active-task-error">${escapeHtml(cannotGetStatus)}${escapeHtml(error.message)}</div>`;
}
}
}
function renderActiveTasks(tasks) {
const bar = document.getElementById('active-tasks-bar');
if (!bar) return;
const normalizedTasks = Array.isArray(tasks) ? tasks : [];
conversationExecutionTracker.update(normalizedTasks);
if (typeof updateAttackChainAvailability === 'function') {
updateAttackChainAvailability();
}
if (normalizedTasks.length === 0) {
bar.style.display = 'none';
bar.innerHTML = '';
return;
}
bar.style.display = 'flex';
bar.innerHTML = '';
normalizedTasks.forEach(task => {
const item = document.createElement('div');
item.className = 'active-task-item';
const startedTime = task.startedAt ? new Date(task.startedAt) : null;
const taskTimeLocale = getCurrentTimeLocale();
const timeOpts = getTimeFormatOptions();
const timeText = startedTime && !isNaN(startedTime.getTime())
? startedTime.toLocaleTimeString(taskTimeLocale, timeOpts)
: '';
const _t = function (k) { return typeof window.t === 'function' ? window.t(k) : k; };
const statusMap = {
'running': _t('tasks.statusRunning'),
'cancelling': _t('tasks.statusCancelling'),
'failed': _t('tasks.statusFailed'),
'timeout': _t('tasks.statusTimeout'),
'cancelled': _t('tasks.statusCancelled'),
'completed': _t('tasks.statusCompleted')
};
const statusText = statusMap[task.status] || _t('tasks.statusRunning');
const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status);
const unnamedTaskText = _t('tasks.unnamedTask');
const stopTaskBtnText = _t('tasks.stopTask');
item.innerHTML = `
<div class="active-task-info">
<span class="active-task-status">${statusText}</span>
<span class="active-task-message">${escapeHtml(task.message || unnamedTaskText)}</span>
</div>
<div class="active-task-actions">
${timeText ? `<span class="active-task-time">${timeText}</span>` : ''}
${!isFinalStatus ? '<button class="active-task-cancel">' + stopTaskBtnText + '</button>' : ''}
</div>
`;
// 只有非最终状态的任务才显示停止按钮
if (!isFinalStatus) {
const cancelBtn = item.querySelector('.active-task-cancel');
if (cancelBtn) {
cancelBtn.onclick = () => cancelActiveTask(task.conversationId, cancelBtn);
if (task.status === 'cancelling') {
cancelBtn.disabled = true;
cancelBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
}
}
}
bar.appendChild(item);
});
}
async function cancelActiveTask(conversationId, button) {
if (!conversationId) return;
const originalText = button.textContent;
button.disabled = true;
button.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
try {
await requestCancel(conversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
button.disabled = false;
button.textContent = originalText;
}
}
// 监控面板状态
const monitorState = {
executions: [],
stats: {},
lastFetchedAt: null,
pagination: {
page: 1,
pageSize: (() => {
// 从 localStorage 读取保存的每页显示数量,默认为 20
const saved = localStorage.getItem('monitorPageSize');
return saved ? parseInt(saved, 10) : 20;
})(),
total: 0,
totalPages: 0
}
};
function openMonitorPanel() {
// 切换到MCP监控页面
if (typeof switchPage === 'function') {
switchPage('mcp-monitor');
}
// 初始化每页显示数量选择器
initializeMonitorPageSize();
}
// 初始化每页显示数量选择器
function initializeMonitorPageSize() {
const pageSizeSelect = document.getElementById('monitor-page-size');
if (pageSizeSelect) {
pageSizeSelect.value = monitorState.pagination.pageSize;
}
}
// 改变每页显示数量
function changeMonitorPageSize() {
const pageSizeSelect = document.getElementById('monitor-page-size');
if (!pageSizeSelect) {
return;
}
const newPageSize = parseInt(pageSizeSelect.value, 10);
if (isNaN(newPageSize) || newPageSize <= 0) {
return;
}
// 保存到 localStorage
localStorage.setItem('monitorPageSize', newPageSize.toString());
// 更新状态
monitorState.pagination.pageSize = newPageSize;
monitorState.pagination.page = 1; // 重置到第一页
// 刷新数据
refreshMonitorPanel(1);
}
function closeMonitorPanel() {
// 不再需要关闭功能,因为现在是页面而不是模态框
// 如果需要,可以切换回对话页面
if (typeof switchPage === 'function') {
switchPage('chat');
}
}
async function refreshMonitorPanel(page = null) {
const statsContainer = document.getElementById('monitor-stats');
const execContainer = document.getElementById('monitor-executions');
try {
// 如果指定了页码,使用指定页码,否则使用当前页码
const currentPage = page !== null ? page : monitorState.pagination.page;
const pageSize = monitorState.pagination.pageSize;
// 获取当前的筛选条件
const statusFilter = document.getElementById('monitor-status-filter');
const toolFilter = document.getElementById('monitor-tool-filter');
const currentStatusFilter = statusFilter ? statusFilter.value : 'all';
const currentToolFilter = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
// 构建请求 URL
let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`;
if (currentStatusFilter && currentStatusFilter !== 'all') {
url += `&status=${encodeURIComponent(currentStatusFilter)}`;
}
if (currentToolFilter && currentToolFilter !== 'all') {
url += `&tool=${encodeURIComponent(currentToolFilter)}`;
}
const response = await apiFetch(url, { method: 'GET' });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || '获取监控数据失败');
}
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
monitorState.stats = result.stats || {};
monitorState.lastFetchedAt = new Date();
// 更新分页信息
if (result.total !== undefined) {
monitorState.pagination = {
page: result.page || currentPage,
pageSize: result.page_size || pageSize,
total: result.total || 0,
totalPages: result.total_pages || 1
};
}
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions, currentStatusFilter);
renderMonitorPagination();
// 初始化每页显示数量选择器
initializeMonitorPageSize();
} catch (error) {
console.error('刷新监控面板失败:', error);
if (statsContainer) {
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}${escapeHtml(error.message)}</div>`;
}
if (execContainer) {
execContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadExecutionsError') : '无法加载执行记录')}${escapeHtml(error.message)}</div>`;
}
}
}
// 处理工具搜索输入(防抖)
let toolFilterDebounceTimer = null;
function handleToolFilterInput() {
// 清除之前的定时器
if (toolFilterDebounceTimer) {
clearTimeout(toolFilterDebounceTimer);
}
// 设置新的定时器500ms后执行筛选
toolFilterDebounceTimer = setTimeout(() => {
applyMonitorFilters();
}, 500);
}
async function applyMonitorFilters() {
const statusFilter = document.getElementById('monitor-status-filter');
const toolFilter = document.getElementById('monitor-tool-filter');
const status = statusFilter ? statusFilter.value : 'all';
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
// 当筛选条件改变时,从后端重新获取数据
await refreshMonitorPanelWithFilter(status, tool);
}
async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter = 'all') {
const statsContainer = document.getElementById('monitor-stats');
const execContainer = document.getElementById('monitor-executions');
try {
const currentPage = 1; // 筛选时重置到第一页
const pageSize = monitorState.pagination.pageSize;
// 构建请求 URL
let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`;
if (statusFilter && statusFilter !== 'all') {
url += `&status=${encodeURIComponent(statusFilter)}`;
}
if (toolFilter && toolFilter !== 'all') {
url += `&tool=${encodeURIComponent(toolFilter)}`;
}
const response = await apiFetch(url, { method: 'GET' });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || '获取监控数据失败');
}
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
monitorState.stats = result.stats || {};
monitorState.lastFetchedAt = new Date();
// 更新分页信息
if (result.total !== undefined) {
monitorState.pagination = {
page: result.page || currentPage,
pageSize: result.page_size || pageSize,
total: result.total || 0,
totalPages: result.total_pages || 1
};
}
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions, statusFilter);
renderMonitorPagination();
// 初始化每页显示数量选择器
initializeMonitorPageSize();
} catch (error) {
console.error('刷新监控面板失败:', error);
if (statsContainer) {
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}${escapeHtml(error.message)}</div>`;
}
if (execContainer) {
execContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadExecutionsError') : '无法加载执行记录')}${escapeHtml(error.message)}</div>`;
}
}
}
function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
const container = document.getElementById('monitor-stats');
if (!container) {
return;
}
const entries = Object.values(statsMap);
if (entries.length === 0) {
const noStats = typeof window.t === 'function' ? window.t('mcpMonitor.noStatsData') : '暂无统计数据';
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
return;
}
// 计算总体汇总
const totals = entries.reduce(
(acc, item) => {
acc.total += item.totalCalls || 0;
acc.success += item.successCalls || 0;
acc.failed += item.failedCalls || 0;
const lastCall = item.lastCallTime ? new Date(item.lastCallTime) : null;
if (lastCall && (!acc.lastCallTime || lastCall > acc.lastCallTime)) {
acc.lastCallTime = lastCall;
}
return acc;
},
{ total: 0, success: 0, failed: 0, lastCallTime: null }
);
const successRate = totals.total > 0 ? ((totals.success / totals.total) * 100).toFixed(1) : '0.0';
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
const lastUpdatedText = lastFetchedAt ? (lastFetchedAt.toLocaleString ? lastFetchedAt.toLocaleString(locale || 'en-US') : String(lastFetchedAt)) : 'N/A';
const noCallsYet = typeof window.t === 'function' ? window.t('mcpMonitor.noCallsYet') : '暂无调用';
const lastCallText = totals.lastCallTime ? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale || 'en-US') : String(totals.lastCallTime)) : noCallsYet;
const totalCallsLabel = typeof window.t === 'function' ? window.t('mcpMonitor.totalCalls') : '总调用次数';
const successFailedLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successFailed', { success: totals.success, failed: totals.failed }) : `成功 ${totals.success} / 失败 ${totals.failed}`;
const successRateLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successRate') : '成功率';
const statsFromAll = typeof window.t === 'function' ? window.t('mcpMonitor.statsFromAllTools') : '统计自全部工具调用';
const lastCallLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastCall') : '最近一次调用';
const lastRefreshLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastRefreshTime') : '最后刷新时间';
let html = `
<div class="monitor-stat-card">
<h4>${escapeHtml(totalCallsLabel)}</h4>
<div class="monitor-stat-value">${totals.total}</div>
<div class="monitor-stat-meta">${escapeHtml(successFailedLabel)}</div>
</div>
<div class="monitor-stat-card">
<h4>${escapeHtml(successRateLabel)}</h4>
<div class="monitor-stat-value">${successRate}%</div>
<div class="monitor-stat-meta">${escapeHtml(statsFromAll)}</div>
</div>
<div class="monitor-stat-card">
<h4>${escapeHtml(lastCallLabel)}</h4>
<div class="monitor-stat-value" style="font-size:1rem;">${escapeHtml(lastCallText)}</div>
<div class="monitor-stat-meta">${escapeHtml(lastRefreshLabel)}${escapeHtml(lastUpdatedText)}</div>
</div>
`;
// 显示最多前4个工具的统计过滤掉 totalCalls 为 0 的工具)
const topTools = entries
.filter(tool => (tool.totalCalls || 0) > 0)
.slice()
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
.slice(0, 4);
const unknownToolLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknownTool') : '未知工具';
topTools.forEach(tool => {
const toolSuccessRate = tool.totalCalls > 0 ? ((tool.successCalls || 0) / tool.totalCalls * 100).toFixed(1) : '0.0';
const toolMeta = typeof window.t === 'function' ? window.t('mcpMonitor.successFailedRate', { success: tool.successCalls || 0, failed: tool.failedCalls || 0, rate: toolSuccessRate }) : `成功 ${tool.successCalls || 0} / 失败 ${tool.failedCalls || 0} · 成功率 ${toolSuccessRate}%`;
html += `
<div class="monitor-stat-card">
<h4>${escapeHtml(tool.toolName || unknownToolLabel)}</h4>
<div class="monitor-stat-value">${tool.totalCalls || 0}</div>
<div class="monitor-stat-meta">
${escapeHtml(toolMeta)}
</div>
</div>
`;
});
container.innerHTML = `<div class="monitor-stats-grid">${html}</div>`;
}
function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const container = document.getElementById('monitor-executions');
if (!container) {
return;
}
if (!Array.isArray(executions) || executions.length === 0) {
// 根据是否有筛选条件显示不同的提示
const toolFilter = document.getElementById('monitor-tool-filter');
const currentToolFilter = toolFilter ? toolFilter.value : 'all';
const hasFilter = (statusFilter && statusFilter !== 'all') || (currentToolFilter && currentToolFilter !== 'all');
const noRecordsFilter = typeof window.t === 'function' ? window.t('mcpMonitor.noRecordsWithFilter') : '当前筛选条件下暂无记录';
const noExecutions = typeof window.t === 'function' ? window.t('mcpMonitor.noExecutions') : '暂无执行记录';
if (hasFilter) {
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noRecordsFilter) + '</div>';
} else {
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noExecutions) + '</div>';
}
// 隐藏批量操作栏
const batchActions = document.getElementById('monitor-batch-actions');
if (batchActions) {
batchActions.style.display = 'none';
}
return;
}
// 由于筛选已经在后端完成,这里直接使用所有传入的执行记录
// 不再需要前端再次筛选,因为后端已经返回了筛选后的数据
const unknownLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknown') : '未知';
const unknownToolLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknownTool') : '未知工具';
const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情';
const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除';
const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录';
const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed' };
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
const rows = executions
.map(exec => {
const status = (exec.status || 'unknown').toLowerCase();
const statusClass = `monitor-status-chip ${status}`;
const statusKey = statusKeyMap[status];
const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
const duration = formatExecutionDuration(exec.startTime, exec.endTime);
const toolName = escapeHtml(exec.toolName || unknownToolLabel);
const executionId = escapeHtml(exec.id || '');
return `
<tr>
<td>
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" onchange="updateBatchActionsState()" />
</td>
<td>${toolName}</td>
<td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td>
<td>${escapeHtml(startTime)}</td>
<td>${escapeHtml(duration)}</td>
<td>
<div class="monitor-execution-actions">
<button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button>
<button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button>
</div>
</td>
</tr>
`;
})
.join('');
// 先移除旧的表格容器和加载提示(保留分页控件)
const oldTableContainer = container.querySelector('.monitor-table-container');
if (oldTableContainer) {
oldTableContainer.remove();
}
// 清除"加载中..."等提示信息
const oldEmpty = container.querySelector('.monitor-empty');
if (oldEmpty) {
oldEmpty.remove();
}
// 创建表格容器
const tableContainer = document.createElement('div');
tableContainer.className = 'monitor-table-container';
const colTool = typeof window.t === 'function' ? window.t('mcpMonitor.columnTool') : '工具';
const colStatus = typeof window.t === 'function' ? window.t('mcpMonitor.columnStatus') : '状态';
const colStartTime = typeof window.t === 'function' ? window.t('mcpMonitor.columnStartTime') : '开始时间';
const colDuration = typeof window.t === 'function' ? window.t('mcpMonitor.columnDuration') : '耗时';
const colActions = typeof window.t === 'function' ? window.t('mcpMonitor.columnActions') : '操作';
tableContainer.innerHTML = `
<table class="monitor-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="monitor-select-all" onchange="toggleSelectAll(this)" />
</th>
<th>${escapeHtml(colTool)}</th>
<th>${escapeHtml(colStatus)}</th>
<th>${escapeHtml(colStartTime)}</th>
<th>${escapeHtml(colDuration)}</th>
<th>${escapeHtml(colActions)}</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
// 在分页控件之前插入表格(如果存在分页控件)
const existingPagination = container.querySelector('.monitor-pagination');
if (existingPagination) {
container.insertBefore(tableContainer, existingPagination);
} else {
container.appendChild(tableContainer);
}
// 更新批量操作状态
updateBatchActionsState();
}
// 渲染监控面板分页控件
function renderMonitorPagination() {
const container = document.getElementById('monitor-executions');
if (!container) return;
// 移除旧的分页控件
const oldPagination = container.querySelector('.monitor-pagination');
if (oldPagination) {
oldPagination.remove();
}
const { page, totalPages, total, pageSize } = monitorState.pagination;
// 始终显示分页控件
const pagination = document.createElement('div');
pagination.className = 'monitor-pagination';
// 处理没有数据的情况
const startItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
const endItem = total === 0 ? 0 : Math.min(page * pageSize, total);
const paginationInfoText = typeof window.t === 'function' ? window.t('mcpMonitor.paginationInfo', { start: startItem, end: endItem, total: total }) : `显示 ${startItem}-${endItem} / 共 ${total} 条记录`;
const perPageLabel = typeof window.t === 'function' ? window.t('mcpMonitor.perPageLabel') : '每页显示';
const firstPageLabel = typeof window.t === 'function' ? window.t('mcp.firstPage') : '首页';
const prevPageLabel = typeof window.t === 'function' ? window.t('mcp.prevPage') : '上一页';
const pageInfoText = typeof window.t === 'function' ? window.t('mcp.pageInfo', { page: page, total: totalPages || 1 }) : `${page} / ${totalPages || 1}`;
const nextPageLabel = typeof window.t === 'function' ? window.t('mcp.nextPage') : '下一页';
const lastPageLabel = typeof window.t === 'function' ? window.t('mcp.lastPage') : '末页';
pagination.innerHTML = `
<div class="pagination-info">
<span>${escapeHtml(paginationInfoText)}</span>
<label class="pagination-page-size">
${escapeHtml(perPageLabel)}
<select id="monitor-page-size" onchange="changeMonitorPageSize()">
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
</select>
</label>
</div>
<div class="pagination-controls">
<button class="btn-secondary" onclick="refreshMonitorPanel(1)" ${page === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(firstPageLabel)}</button>
<button class="btn-secondary" onclick="refreshMonitorPanel(${page - 1})" ${page === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(prevPageLabel)}</button>
<span class="pagination-page">${escapeHtml(pageInfoText)}</span>
<button class="btn-secondary" onclick="refreshMonitorPanel(${page + 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(nextPageLabel)}</button>
<button class="btn-secondary" onclick="refreshMonitorPanel(${totalPages || 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(lastPageLabel)}</button>
</div>
`;
container.appendChild(pagination);
// 初始化每页显示数量选择器
initializeMonitorPageSize();
}
// 删除执行记录
async function deleteExecution(executionId) {
if (!executionId) {
return;
}
const deleteConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecConfirmSingle') : '确定要删除此执行记录吗?此操作不可恢复。';
if (!confirm(deleteConfirmMsg)) {
return;
}
try {
const response = await apiFetch(`/api/monitor/execution/${executionId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const deleteFailedMsg = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecFailed') : '删除执行记录失败';
throw new Error(error.error || deleteFailedMsg);
}
// 删除成功后刷新当前页面
const currentPage = monitorState.pagination.page;
await refreshMonitorPanel(currentPage);
const execDeletedMsg = typeof window.t === 'function' ? window.t('mcpMonitor.execDeleted') : '执行记录已删除';
alert(execDeletedMsg);
} catch (error) {
console.error('删除执行记录失败:', error);
const deleteFailedMsg = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecFailed') : '删除执行记录失败';
alert(deleteFailedMsg + ': ' + error.message);
}
}
// 更新批量操作状态
function updateBatchActionsState() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
const selectedCount = checkboxes.length;
const batchActions = document.getElementById('monitor-batch-actions');
const selectedCountSpan = document.getElementById('monitor-selected-count');
if (selectedCount > 0) {
if (batchActions) {
batchActions.style.display = 'flex';
}
} else {
if (batchActions) {
batchActions.style.display = 'none';
}
}
if (selectedCountSpan) {
selectedCountSpan.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: selectedCount }) : '已选择 ' + selectedCount + ' 项';
}
// 更新全选复选框状态
const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) {
const allCheckboxes = document.querySelectorAll('.monitor-execution-checkbox');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
}
}
// 切换全选
function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBatchActionsState();
}
// 全选
function selectAllExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => {
cb.checked = true;
});
const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
}
updateBatchActionsState();
}
// 取消全选
function deselectAllExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => {
cb.checked = false;
});
const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
updateBatchActionsState();
}
// 批量删除执行记录
async function batchDeleteExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
if (checkboxes.length === 0) {
const selectFirstMsg = typeof window.t === 'function' ? window.t('mcpMonitor.selectExecFirst') : '请先选择要删除的执行记录';
alert(selectFirstMsg);
return;
}
const ids = Array.from(checkboxes).map(cb => cb.value);
const count = ids.length;
const batchConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteConfirm', { count: count }) : `确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`;
if (!confirm(batchConfirmMsg)) {
return;
}
try {
const response = await apiFetch('/api/monitor/executions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids: ids })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const batchFailedMsg = typeof window.t === 'function' ? window.t('mcp.batchDeleteFailed') : '批量删除执行记录失败';
throw new Error(error.error || batchFailedMsg);
}
const result = await response.json().catch(() => ({}));
const deletedCount = result.deleted || count;
// 删除成功后刷新当前页面
const currentPage = monitorState.pagination.page;
await refreshMonitorPanel(currentPage);
const batchSuccessMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteSuccess', { count: deletedCount }) : `成功删除 ${deletedCount} 条执行记录`;
alert(batchSuccessMsg);
} catch (error) {
console.error('批量删除执行记录失败:', error);
const batchFailedMsg = typeof window.t === 'function' ? window.t('mcp.batchDeleteFailed') : '批量删除执行记录失败';
alert(batchFailedMsg + ': ' + error.message);
}
}
function formatExecutionDuration(start, end) {
const unknownLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknown') : '未知';
if (!start) {
return unknownLabel;
}
const startTime = new Date(start);
const endTime = end ? new Date(end) : new Date();
if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) {
return unknownLabel;
}
const diffMs = Math.max(0, endTime - startTime);
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) {
return typeof window.t === 'function' ? window.t('mcpMonitor.durationSeconds', { n: seconds }) : seconds + ' 秒';
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
const remain = seconds % 60;
if (remain > 0) {
return typeof window.t === 'function' ? window.t('mcpMonitor.durationMinutes', { minutes: minutes, seconds: remain }) : minutes + ' 分 ' + remain + ' 秒';
}
return typeof window.t === 'function' ? window.t('mcpMonitor.durationMinutesOnly', { minutes: minutes }) : minutes + ' 分';
}
const hours = Math.floor(minutes / 60);
const remainMinutes = minutes % 60;
if (remainMinutes > 0) {
return typeof window.t === 'function' ? window.t('mcpMonitor.durationHours', { hours: hours, minutes: remainMinutes }) : hours + ' 小时 ' + remainMinutes + ' 分';
}
return typeof window.t === 'function' ? window.t('mcpMonitor.durationHoursOnly', { hours: hours }) : hours + ' 小时';
}
/**
* 语言切换后刷新对话页已渲染的进度条、时间线标题与时间格式(避免仍显示英文或 AM/PM
*/
function refreshProgressAndTimelineI18n() {
const _t = function (k, o) {
return typeof window.t === 'function' ? window.t(k, o) : k;
};
const timeLocale = getCurrentTimeLocale();
const timeOpts = getTimeFormatOptions();
// 进度块内停止按钮:未禁用时统一为当前语言的「停止任务」(避免仍显示 Stop task
document.querySelectorAll('.progress-message .progress-stop').forEach(function (btn) {
if (!btn.disabled && btn.id && btn.id.indexOf('-stop-btn') !== -1) {
const cancelling = _t('tasks.cancelling');
if (btn.textContent !== cancelling) {
btn.textContent = _t('tasks.stopTask');
}
}
});
document.querySelectorAll('.progress-toggle').forEach(function (btn) {
const timeline = btn.closest('.progress-container, .message-bubble') &&
btn.closest('.progress-container, .message-bubble').querySelector('.progress-timeline');
const expanded = timeline && timeline.classList.contains('expanded');
btn.textContent = expanded ? _t('tasks.collapseDetail') : _t('chat.expandDetail');
});
document.querySelectorAll('.progress-message').forEach(function (msgEl) {
const raw = msgEl.dataset.progressRawMessage;
const titleEl = msgEl.querySelector('.progress-title');
if (titleEl && raw) {
titleEl.textContent = '\uD83D\uDD0D ' + translateProgressMessage(raw);
}
});
// 转换后的详情区顶栏「渗透测试详情」:仅刷新不在 .progress-message 内的 progress 标题
document.querySelectorAll('.progress-container .progress-header .progress-title').forEach(function (titleEl) {
if (titleEl.closest('.progress-message')) return;
titleEl.textContent = '\uD83D\uDCCB ' + _t('chat.penetrationTestDetail');
});
// 时间线项:按类型重算标题,并重绘时间戳
document.querySelectorAll('.timeline-item').forEach(function (item) {
const type = item.dataset.timelineType;
const titleSpan = item.querySelector('.timeline-item-title');
const timeSpan = item.querySelector('.timeline-item-time');
if (!titleSpan) return;
const ap = (item.dataset.einoAgent && item.dataset.einoAgent !== '') ? ('[' + item.dataset.einoAgent + '] ') : '';
if (type === 'iteration' && item.dataset.iterationN) {
const n = parseInt(item.dataset.iterationN, 10) || 1;
titleSpan.textContent = ap + _t('chat.iterationRound', { n: n });
} else if (type === 'thinking') {
titleSpan.textContent = ap + '\uD83E\uDD14 ' + _t('chat.aiThinking');
} else if (type === 'tool_calls_detected' && item.dataset.toolCallsCount != null) {
const count = parseInt(item.dataset.toolCallsCount, 10) || 0;
titleSpan.textContent = ap + '\uD83D\uDD27 ' + _t('chat.toolCallsDetected', { count: count });
} else if (type === 'tool_call' && (item.dataset.toolName !== undefined || item.dataset.toolIndex !== undefined)) {
const name = (item.dataset.toolName != null && item.dataset.toolName !== '') ? item.dataset.toolName : _t('chat.unknownTool');
const index = parseInt(item.dataset.toolIndex, 10) || 0;
const total = parseInt(item.dataset.toolTotal, 10) || 0;
titleSpan.textContent = ap + '\uD83D\uDD27 ' + _t('chat.callTool', { name: name, index: index, total: total });
} else if (type === 'tool_result' && (item.dataset.toolName !== undefined || item.dataset.toolSuccess !== undefined)) {
const name = (item.dataset.toolName != null && item.dataset.toolName !== '') ? item.dataset.toolName : _t('chat.unknownTool');
const success = item.dataset.toolSuccess === '1';
const icon = success ? '\u2705 ' : '\u274C ';
titleSpan.textContent = ap + icon + (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name }));
} else if (type === 'eino_agent_reply') {
titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle');
} else if (type === 'cancelled') {
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
} else if (type === 'progress' && item.dataset.progressMessage !== undefined) {
titleSpan.textContent = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(item.dataset.progressMessage) : item.dataset.progressMessage;
}
if (timeSpan && item.dataset.createdAtIso) {
const d = new Date(item.dataset.createdAtIso);
if (!isNaN(d.getTime())) {
timeSpan.textContent = d.toLocaleTimeString(timeLocale, timeOpts);
}
}
});
// 详情区「展开/收起」按钮
document.querySelectorAll('.process-detail-btn span').forEach(function (span) {
const btn = span.closest('.process-detail-btn');
const assistantId = btn && btn.closest('.message.assistant') && btn.closest('.message.assistant').id;
if (!assistantId) return;
const detailsId = 'process-details-' + assistantId;
const timeline = document.getElementById(detailsId) && document.getElementById(detailsId).querySelector('.progress-timeline');
const expanded = timeline && timeline.classList.contains('expanded');
span.textContent = expanded ? _t('tasks.collapseDetail') : _t('chat.expandDetail');
});
}
document.addEventListener('languagechange', function () {
updateBatchActionsState();
loadActiveTasks();
refreshProgressAndTimelineI18n();
});