feat: add configurable tool call blocking and monitoring

This commit is contained in:
Ed1s0nZ
2026-09-08 09:44:32 +08:00
parent c70da22de7
commit 6ad9ea2d13
54 changed files with 4635 additions and 152 deletions
+122 -43
View File
@@ -3640,13 +3640,15 @@ function handleStreamEvent(event, progressElement, progressId,
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 success = getToolResultDisplayState(resultInfo).success;
const resultDisplayState = getToolResultDisplayState(resultInfo, { rawText: event.message || '' });
const backgroundRunning = resultDisplayState.kind === 'background_running';
const statusIcon = backgroundRunning ? '⏳' : (success ? '✅' : '❌');
const statusIcon = resultDisplayState.kind === 'blocked' ? '🛡' : (backgroundRunning ? '⏳' : (success ? '✅' : '❌'));
const resultToolCallId = resultInfo.toolCallId || null;
const resultStatusForCall = backgroundRunning ? 'background_running' : (success ? 'completed' : 'failed');
const resultExecText = backgroundRunning
const resultStatusForCall = toolDisplayStatusFromState(resultDisplayState);
const resultExecText = resultDisplayState.kind === 'blocked'
? (typeof window.t === 'function' ? window.t('chat.toolExecBlocked', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 已拦截')
: backgroundRunning
? (getBackgroundRunningToolLabel() + ': ' + escapeHtml(resultToolName))
: (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) + ' 执行失败'));
@@ -5887,9 +5889,41 @@ function collectToolResultTextParts(value, parts, depth) {
if (value.content != null) collectToolResultTextParts(value.content, parts, depth + 1);
}
// Older records did not have a structured marker. Only recognize the exact
// guard prefix at the start of a result, never a quoted mention in ordinary output.
function isToolGuardBlockedResult(value, depth, allowLegacy) {
depth = depth || 0;
allowLegacy = allowLegacy !== false;
if (value == null || depth > 5) return false;
if (typeof value === 'string') {
const text = value.trimStart();
if (allowLegacy && /^工具调用已被安全规则拦截(?:[:\r\n]|$)/.test(text)) return true;
if (text.startsWith('{')) {
try { return isToolGuardBlockedResult(JSON.parse(text), depth + 1, allowLegacy); } catch (e) { /* plain text */ }
}
return false;
}
if (typeof value !== 'object') return false;
if (Array.isArray(value)) return value.some(function (part) { return isToolGuardBlockedResult(part, depth + 1, allowLegacy); });
if (value.blocked === true || value.status === 'blocked' || value.displayStatus === 'blocked') return true;
if (value._meta && value._meta['cyberstrike.ai/blocked'] === true) return true;
if (value.success === true || value.isError === false || value.status === 'completed') allowLegacy = false;
return ['result', 'error', 'content', 'text', 'resultPreview'].some(function (key) {
return isToolGuardBlockedResult(value[key], depth + 1, allowLegacy);
});
}
function getToolExecutionDisplayStatus(execution) {
return isToolGuardBlockedResult(execution) ? 'blocked' : String(execution && execution.status || 'unknown').toLowerCase();
}
function getToolResultDisplayState(data, opts) {
opts = opts || {};
data = data || {};
const allowLegacyBlock = data.success !== true && data.isError !== false && data.status !== 'completed';
if (isToolGuardBlockedResult(data) || isToolGuardBlockedResult(opts.rawText, 0, allowLegacyBlock)) {
return { kind: 'blocked', isError: true, success: false };
}
const toolName = String(data.toolName || data.name || '').trim().toLowerCase();
const isObservationTool = toolName === 'wait_tool_execution' || toolName === 'get_tool_execution';
const explicitStatus = String(data.displayStatus || data.status || '').toLowerCase();
@@ -5937,6 +5971,7 @@ function toolDisplayStatusFromState(displayState) {
if (!displayState) return 'completed';
if (displayState.kind === 'background_running') return 'background_running';
if (displayState.kind === 'cancelled') return 'cancelled';
if (displayState.kind === 'blocked') return 'blocked';
return displayState.isError ? 'failed' : 'completed';
}
@@ -5961,7 +5996,7 @@ function buildToolResultSectionHtml(data, opts) {
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
const rawText = opts.rawText != null ? String(opts.rawText) : resultStr;
const displayState = getToolResultDisplayState(data, { rawText: rawText });
const sectionClass = displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success');
const sectionClass = displayState.kind === 'blocked' ? 'blocked' : (displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success'));
return (
'<div class="tool-result-section ' + sectionClass + '">' +
'<strong data-i18n="timeline.executionResult">' + escapeHtml(execResultLabel) + '</strong>' +
@@ -6185,8 +6220,8 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-blocked');
item.classList.add(getToolCallStatusPresentation(toolDisplayStatusFromState(displayState)).itemClass);
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
return true;
}
@@ -6199,7 +6234,7 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (!section) return false;
section.classList.remove('pending');
section.className = 'tool-result-section ' + (backgroundRunning ? 'pending' : (displayState.isError ? 'error' : 'success'));
section.className = 'tool-result-section ' + (displayState.kind === 'blocked' ? 'blocked' : (backgroundRunning ? 'pending' : (displayState.isError ? 'error' : 'success')));
const pre = section.querySelector('pre.tool-result');
if (pre) {
pre.classList.remove('tool-result-pending');
@@ -6228,8 +6263,8 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-blocked');
item.classList.add(getToolCallStatusPresentation(toolDisplayStatusFromState(displayState)).itemClass);
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
return true;
}
@@ -6368,6 +6403,7 @@ window.mergeToolResultIntoCallItem = mergeToolResultIntoCallItem;
window.formatToolCallTimelineTitle = formatToolCallTimelineTitle;
window.parseToolCallArgsFromData = parseToolCallArgsFromData;
window.getToolResultDisplayState = getToolResultDisplayState;
window.getToolExecutionDisplayStatus = getToolExecutionDisplayStatus;
window.getBackgroundRunningToolLabel = getBackgroundRunningToolLabel;
window.buildToolResultSectionHtml = buildToolResultSectionHtml;
@@ -6390,6 +6426,9 @@ function getToolCallStatusPresentation(status) {
if (normalized === 'failed') {
return { status: normalized, itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('timeline.execFailed', '执行失败'), icon: '❌ ' };
}
if (normalized === 'blocked') {
return { status: normalized, itemClass: 'tool-call-blocked', badgeClass: 'tool-status-blocked', label: translate('timeline.blocked', '已拦截'), icon: '🛡 ' };
}
if (normalized === 'cancelled' || normalized === 'canceled') {
return { status: 'cancelled', itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('tasks.statusCancelled', '已取消'), icon: '⛔ ' };
}
@@ -6406,7 +6445,7 @@ function applyToolCallStatus(item, status) {
const titleElement = item.querySelector('.timeline-item-title');
if (!titleElement) return;
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-incomplete');
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-blocked', 'tool-call-incomplete');
const previousBadge = titleElement.querySelector('.tool-status-badge');
if (previousBadge) previousBadge.remove();
if (!presentation) {
@@ -6625,7 +6664,7 @@ function addTimelineItem(timeline, type, options) {
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
const terminalStatus = String(options.toolStatus || '').toLowerCase();
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
const forcedStatus = mergedDisplayState && mergedDisplayState.kind === 'blocked' ? 'blocked' : (terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
: '';
if (merged) {
@@ -6635,14 +6674,14 @@ function addTimelineItem(timeline, type, options) {
if (merged.executionId != null && String(merged.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(merged.executionId).trim();
}
item.classList.add(item.dataset.toolDisplayStatus === 'background_running' ? 'tool-call-running' : (item.dataset.toolDisplayStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
item.classList.add(getToolCallStatusPresentation(item.dataset.toolDisplayStatus).itemClass);
if (d._mergedResultDetailId) {
item.dataset.toolResultDetailId = String(d._mergedResultDetailId);
}
} else if (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled') {
} else if (terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled') {
item.dataset.toolSuccess = terminalStatus === 'completed' ? '1' : '0';
item.dataset.toolDisplayStatus = terminalStatus === 'canceled' ? 'cancelled' : terminalStatus;
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
item.classList.add(getToolCallStatusPresentation(terminalStatus).itemClass);
} else if (terminalStatus === 'result_missing') {
item.dataset.toolDisplayStatus = 'result_missing';
item.classList.add('tool-call-incomplete');
@@ -6745,16 +6784,16 @@ function addTimelineItem(timeline, type, options) {
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
const terminalStatus = String(options.toolStatus || '').toLowerCase();
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
const forcedStatus = mergedDisplayState && mergedDisplayState.kind === 'blocked' ? 'blocked' : (terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
: '';
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled';
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled';
const hasHistoricalStatus = hasTerminalStatus || terminalStatus === 'result_missing';
if (merged) {
const statusForClass = forcedStatus || toolDisplayStatusFromState(mergedDisplayState);
item.classList.add(statusForClass === 'background_running' ? 'tool-call-running' : (statusForClass === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
item.classList.add(getToolCallStatusPresentation(statusForClass).itemClass);
} else if (hasTerminalStatus) {
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
item.classList.add(getToolCallStatusPresentation(terminalStatus).itemClass);
} else if (terminalStatus === 'result_missing') {
item.classList.add('tool-call-incomplete');
} else if (!options.skipPendingResult) {
@@ -6818,7 +6857,7 @@ function addTimelineItem(timeline, type, options) {
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.add(displayState.kind === 'background_running' ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.classList.add(getToolCallStatusPresentation(toolDisplayStatusFromState(displayState)).itemClass);
} else if (type === 'cancelled') {
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
content += `
@@ -7734,11 +7773,13 @@ function buildMonitorTotals(summary) {
const total = s.totalCalls || 0;
const success = s.successCalls || 0;
const failed = s.failedCalls || 0;
const blocked = s.blockedCalls || 0;
return {
total,
success,
failed,
neutral: Math.max(0, total - success - failed),
blocked,
neutral: Math.max(0, total - success - failed - blocked),
lastCallTime: s.lastCallTime ? new Date(s.lastCallTime) : null,
};
}
@@ -7767,6 +7808,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
const plotH = H - padT - padB;
const maxVal = Math.max(1, ...points.map((p) => p.total || 0));
const hasFailed = points.some((p) => (p.failed || 0) > 0);
const hasBlocked = points.some((p) => (p.blocked || 0) > 0);
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const barGap = points.length > 48 ? 1 : 2;
const barW = Math.max(1.6, Math.min(8, (plotW / Math.max(1, points.length)) - barGap));
@@ -7789,6 +7831,11 @@ function buildMcpTimelineSvg(points, rangeKey) {
}).join(' ');
}
const blockedPath = hasBlocked ? coords.map((c, i) => {
const y = padT + plotH - ((c.p.blocked || 0) / maxVal) * plotH;
return `${i === 0 ? 'M' : 'L'} ${c.x.toFixed(2)} ${y.toFixed(2)}`;
}).join(' ') : '';
let peakIdx = 0;
points.forEach((p, i) => {
if ((p.total || 0) >= (points[peakIdx].total || 0)) peakIdx = i;
@@ -7823,21 +7870,26 @@ function buildMcpTimelineSvg(points, rangeKey) {
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 2 : 1.5}"
data-time="${escapeAttrLocal(tipTime)}"
data-total="${c.p.total || 0}"
data-failed="${c.p.failed || 0}" />`;
data-failed="${c.p.failed || 0}"
data-blocked="${c.p.blocked || 0}" />`;
}).join('');
const bars = coords.map((c) => {
const total = c.p.total || 0;
const failed = c.p.failed || 0;
const blocked = c.p.blocked || 0;
const h = total > 0 ? Math.max(3, (total / maxVal) * plotH) : 1;
const y = baseY - h;
const failedH = failed > 0 ? Math.max(2, (failed / maxVal) * plotH) : 0;
const failedH = total > 0 ? h * (failed / total) : 0;
const blockedH = total > 0 ? h * (blocked / total) : 0;
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
return `<g class="mcp-stats-timeline-bar-group">
<rect class="mcp-stats-timeline-bar${total > 0 ? ' is-active' : ''}" x="${(c.x - barW / 2).toFixed(2)}" y="${y.toFixed(2)}" width="${barW.toFixed(2)}" height="${h.toFixed(2)}" rx="1.6"
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" />
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" data-blocked="${blocked}" />
${failedH > 0 ? `<rect class="mcp-stats-timeline-bar-fail" x="${(c.x - barW / 2).toFixed(2)}" y="${(baseY - failedH).toFixed(2)}" width="${barW.toFixed(2)}" height="${failedH.toFixed(2)}" rx="1.6"
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" />` : ''}
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" data-blocked="${blocked}" />` : ''}
${blockedH > 0 ? `<rect class="mcp-stats-timeline-bar-blocked" x="${(c.x - barW / 2).toFixed(2)}" y="${(baseY - failedH - blockedH).toFixed(2)}" width="${barW.toFixed(2)}" height="${blockedH.toFixed(2)}" rx="1.6"
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" data-blocked="${blocked}" />` : ''}
</g>`;
}).join('');
@@ -7865,6 +7917,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
${peakMarker}
<path class="mcp-stats-timeline-line" d="${linePath}" stroke="url(#mcpTimelineLineStroke)" />
${hasFailed ? `<path class="mcp-stats-timeline-line mcp-stats-timeline-line--fail" d="${failPath}" />` : ''}
${hasBlocked ? `<path class="mcp-stats-timeline-line mcp-stats-timeline-line--blocked" d="${blockedPath}" />` : ''}
${dots}
${xLabels}
</svg>`;
@@ -7893,22 +7946,23 @@ function bindMcpStatsTimelineEvents() {
}
root.addEventListener('mousemove', function (e) {
const dot = e.target.closest('.mcp-stats-timeline-dot, .mcp-stats-timeline-bar, .mcp-stats-timeline-bar-fail');
const dot = e.target.closest('.mcp-stats-timeline-dot, .mcp-stats-timeline-bar, .mcp-stats-timeline-bar-fail, .mcp-stats-timeline-bar-blocked');
if (!dot || !mcpTimelineTooltipEl) {
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover, .mcp-stats-timeline-bar-blocked.is-hover').forEach((d) => d.classList.remove('is-hover'));
mcpTimelineTooltipEl.style.display = 'none';
return;
}
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover, .mcp-stats-timeline-bar-blocked.is-hover').forEach((d) => d.classList.remove('is-hover'));
dot.classList.add('is-active');
dot.classList.add('is-hover');
const time = dot.getAttribute('data-time') || '';
const total = dot.getAttribute('data-total') || '0';
const failed = dot.getAttribute('data-failed') || '0';
const tip = mcpMonitorT('timelineTooltip', { time, total, failed })
|| `${time}${total} 次(失败 ${failed}`;
const blocked = dot.getAttribute('data-blocked') || '0';
const tip = mcpMonitorT('timelineTooltip', { time, total, failed, blocked })
|| monitorFallback(`${time}${total} 次(失败 ${failed},安全拦截 ${blocked}`, `${time}: ${total} calls (${failed} failed, ${blocked} blocked)`);
mcpTimelineTooltipEl.textContent = tip;
mcpTimelineTooltipEl.style.display = 'block';
mcpTimelineTooltipEl.style.left = `${e.clientX}px`;
@@ -7919,7 +7973,7 @@ function bindMcpStatsTimelineEvents() {
if (!e.target.closest || !e.target.closest('.mcp-stats-combined__timeline, .mcp-stats-timeline')) return;
if (e.relatedTarget && root.contains(e.relatedTarget)) return;
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover, .mcp-stats-timeline-bar-blocked.is-hover').forEach((d) => d.classList.remove('is-hover'));
if (mcpTimelineTooltipEl) mcpTimelineTooltipEl.style.display = 'none';
});
@@ -7997,10 +8051,13 @@ function renderMcpTimelineActiveMoments(points, rangeKey) {
const time = formatMcpTimelineLabel(p.t, rangeKey, locale);
const failed = p.failed || 0;
const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`;
const blocked = p.blocked || 0;
const blockedLabel = mcpMonitorT('blockedCount', { n: blocked }) || monitorFallback(`安全拦截 ${blocked}`, `Blocked ${blocked}`);
return `<span class="mcp-stats-timeline-moment" title="${escapeHtml(time)}">
<span class="mcp-stats-timeline-moment__time">${escapeHtml(time)}</span>
<span class="mcp-stats-timeline-moment__count">${p.total || 0}</span>
${failed > 0 ? `<span class="mcp-stats-timeline-moment__fail">${escapeHtml(failedLabel)}</span>` : ''}
${blocked > 0 ? `<span class="mcp-stats-timeline-moment__blocked">${escapeHtml(blockedLabel)}</span>` : ''}
</span>`;
}).join('');
const moreChip = hiddenCount > 0
@@ -8080,7 +8137,9 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi
const chartSvg = buildMcpTimelineSvg(points, rangeKey);
const totalLegend = mcpMonitorT('timelineTotalLegend') || '总调用';
const failLegend = mcpMonitorT('timelineFailedLegend') || '失败';
const blockedLegend = mcpMonitorT('timelineBlockedLegend') || monitorFallback('安全拦截', 'Blocked');
const hasFailed = points.some((p) => (p.failed || 0) > 0);
const hasBlocked = points.some((p) => (p.blocked || 0) > 0);
const sparseHint = buildTimelineSparseHint(points, timeline);
const momentsHtml = renderMcpTimelineActiveMoments(points, rangeKey);
const sparseHtml = sparseHint
@@ -8095,6 +8154,7 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi
<div class="mcp-stats-timeline__legend">
<span class="mcp-stats-timeline__legend-item">${escapeHtml(totalLegend)}</span>
${hasFailed ? `<span class="mcp-stats-timeline__legend-item mcp-stats-timeline__legend-item--fail">${escapeHtml(failLegend)}</span>` : ''}
${hasBlocked ? `<span class="mcp-stats-timeline__legend-item mcp-stats-timeline__legend-item--blocked">${escapeHtml(blockedLegend)}</span>` : ''}
</div>`;
}
@@ -8635,8 +8695,13 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
const lastCallLabel = mcpMonitorT('lastCall') || monitorFallback('最近一次调用', 'Last call');
const successPill = mcpMonitorT('successCount', { n: totals.success }) || monitorFallback(`成功 ${totals.success}`, `Success ${totals.success}`);
const failedPill = mcpMonitorT('failedCount', { n: totals.failed }) || monitorFallback(`失败 ${totals.failed}`, `Failed ${totals.failed}`);
const blockedPill = mcpMonitorT('blockedCount', { n: totals.blocked }) || monitorFallback(`安全拦截 ${totals.blocked}`, `Blocked ${totals.blocked}`);
const neutralPill = mcpMonitorT('neutralCount', { n: totals.neutral }) || monitorFallback(`终止 ${totals.neutral}`, `Stopped ${totals.neutral}`);
const rateHint = mcpMonitorT('rateExcludesBlocked') || monitorFallback('成功率仅统计成功和失败的调用,不包含安全拦截和终止', 'Success rate includes only successful and failed calls; blocked and stopped calls are excluded');
const rateValue = hasCalls ? `${successRate}%` : successRate;
const blockedChip = totals.blocked > 0
? `<span class="mcp-stats-kpi__chip is-blocked">${escapeHtml(blockedPill)}</span>`
: '';
const neutralChip = totals.neutral > 0
? `<span class="mcp-stats-kpi__chip is-neutral">${escapeHtml(neutralPill)}</span>`
: '';
@@ -8651,6 +8716,7 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
<div class="mcp-stats-kpi__meta">
<span class="mcp-stats-kpi__chip is-ok">${escapeHtml(successPill)}</span>
<span class="mcp-stats-kpi__chip is-fail">${escapeHtml(failedPill)}</span>
${blockedChip}
${neutralChip}
</div>
</div>
@@ -8658,7 +8724,7 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
<article class="mcp-stats-kpi__item mcp-stats-kpi__item--rate">
<span class="mcp-stats-kpi__accent" aria-hidden="true"></span>
<div class="mcp-stats-kpi__content">
<span class="mcp-stats-kpi__label">${escapeHtml(successRateLabel)}</span>
<span class="mcp-stats-kpi__label" title="${escapeAttrLocal(rateHint)}">${escapeHtml(successRateLabel)}</span>
<span class="mcp-stats-kpi__value mcp-stats-kpi__value--rate ${rateTone}">${rateValue}</span>
<span class="mcp-stats-kpi__status ${rateTone}">${escapeHtml(rateSubText)}</span>
</div>
@@ -8687,16 +8753,21 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
const total = tool.totalCalls || 0;
const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0;
const blocked = tool.blockedCalls || 0;
const effectiveTotal = success + failed;
const toolRateNum = effectiveTotal > 0 ? (success / effectiveTotal) * 100 : 0;
const toolRate = toolRateNum.toFixed(1);
const rateText = effectiveTotal > 0 ? `${toolRate}%` : '-';
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum);
const rateClass = effectiveTotal > 0 ? getMcpToolRateClass(toolRateNum) : 'is-muted';
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|| `${name}${total} 次调用,成功率 ${toolRate}%`;
const blockedLabel = mcpMonitorT('blockedCount', { n: blocked }) || monitorFallback(`安全拦截 ${blocked}`, `Blocked ${blocked}`);
const rowAria = (effectiveTotal > 0
? (mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate }) || `${name}${total} 次调用,成功率 ${toolRate}%`)
: (mcpMonitorT('toolRowNoCompletedAriaLabel', { name, total }) || monitorFallback(`${name}${total} 次调用,暂无完成结果,点击查看执行记录`, `${name}, ${total} calls, no completed outcomes, click to view records`)))
+ (blocked > 0 ? ` · ${blockedLabel}` : '');
rowsHtml += `
<tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
data-tool-name="${escapeAttrLocal(rawName)}"
@@ -8712,8 +8783,9 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
<td class="col-num">${total}</td>
<td class="col-share">${sharePct}%</td>
<td class="col-rate">
<span class="mcp-stats-rate ${rateClass}">${toolRate}%</span>
<span class="mcp-stats-rate ${rateClass}">${rateText}</span>
${failed > 0 ? `<span class="mcp-stats-fail-note">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>` : ''}
${blocked > 0 ? `<span class="mcp-stats-blocked-note">${escapeHtml(blockedLabel)}</span>` : ''}
</td>
</tr>`;
});
@@ -8766,17 +8838,22 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const total = tool.totalCalls || 0;
const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0;
const blocked = tool.blockedCalls || 0;
const effectiveTotal = success + failed;
const toolRateNum = effectiveTotal > 0 ? (success / effectiveTotal) * 100 : 0;
const toolRate = toolRateNum.toFixed(1);
const rateText = effectiveTotal > 0 ? `${toolRate}%` : '-';
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const barPct = maxCalls > 0 ? ((total / maxCalls) * 100).toFixed(1) : '0';
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum);
const rateClass = effectiveTotal > 0 ? getMcpToolRateClass(toolRateNum) : 'is-muted';
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|| `${name}${total} 次,成功率 ${toolRate}%`;
const blockedLabel = mcpMonitorT('blockedCount', { n: blocked }) || monitorFallback(`安全拦截 ${blocked}`, `Blocked ${blocked}`);
const rowAria = (effectiveTotal > 0
? (mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate }) || `${name}${total} 次,成功率 ${toolRate}%`)
: (mcpMonitorT('toolRowNoCompletedAriaLabel', { name, total }) || monitorFallback(`${name}${total} 次调用,暂无完成结果,点击查看执行记录`, `${name}, ${total} calls, no completed outcomes, click to view records`)))
+ (blocked > 0 ? ` · ${blockedLabel}` : '');
const failNote = failed > 0
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
: '';
@@ -8801,7 +8878,8 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
<div class="mcp-stats-tool-item__bottom">
<span class="mcp-stats-tool-item__pill is-success">${escapeHtml(successLabel)}</span>
<span class="mcp-stats-tool-item__pill${failed > 0 ? ' is-danger' : ''}">${escapeHtml(failedLabel)}</span>
<span class="mcp-stats-tool-item__rate ${rateClass}">${toolRate}%${failNote}</span>
${blocked > 0 ? `<span class="mcp-stats-tool-item__pill is-blocked">${escapeHtml(blockedLabel)}</span>` : ''}
<span class="mcp-stats-tool-item__rate ${rateClass}">${rateText}${failNote}</span>
</div>
</li>`;
}).join('');
@@ -8995,6 +9073,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
running: 'statusRunning',
completed: 'statusCompleted',
failed: 'statusFailed',
blocked: 'statusBlocked',
cancelled: 'statusCancelled',
hard_timeout: 'statusHardTimeout',
orphaned: 'statusOrphaned'
@@ -9002,7 +9081,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
const rowEntries = executions
.map(exec => {
const status = (exec.status || 'unknown').toLowerCase();
const status = getToolExecutionDisplayStatus(exec);
const statusClass = `monitor-status-chip ${status}`;
const statusKey = statusKeyMap[status];
const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
@@ -9542,8 +9621,8 @@ function refreshProgressAndTimelineI18n() {
const displayStatus = item.dataset.toolDisplayStatus || '';
const backgroundRunning = displayStatus === 'background_running';
const success = item.dataset.toolSuccess === '1';
const icon = backgroundRunning ? '\u23F3 ' : (success ? '\u2705 ' : '\u274C ');
titleSpan.textContent = ap + icon + (backgroundRunning ? (getBackgroundRunningToolLabel() + ': ' + name) : (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name })));
const icon = displayStatus === 'blocked' ? '🛡 ' : (backgroundRunning ? '\u23F3 ' : (success ? '\u2705 ' : '\u274C '));
titleSpan.textContent = ap + icon + (displayStatus === 'blocked' ? _t('chat.toolExecBlocked', { name: name }) : backgroundRunning ? (getBackgroundRunningToolLabel() + ': ' + name) : (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 === 'eino_usage_summary') {