Add files via upload

This commit is contained in:
公明
2026-08-15 01:33:33 +08:00
committed by GitHub
parent 20c9e871af
commit e691c5d9c6
15 changed files with 716 additions and 19 deletions
+30
View File
@@ -7712,6 +7712,11 @@ html[data-theme="dark"] .login-card .login-submit:disabled {
background: rgba(96, 125, 139, 0.08);
}
.timeline-item-eino_usage_summary {
border-left-color: #0f766e;
background: rgba(20, 184, 166, 0.07);
}
.timeline-item-warning {
border-left-color: #f57c00;
background: rgba(245, 124, 0, 0.09);
@@ -23333,6 +23338,12 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
font-weight: 600;
}
.webshell-ai-timeline-eino_usage_summary {
border-left: 3px solid #0f766e;
padding-left: 10px;
background: rgba(20, 184, 166, 0.06);
}
.webshell-ai-timeline-msg {
margin-top: 4px;
padding-left: 0;
@@ -23484,6 +23495,14 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
.webshell-ai-process-block .webshell-ai-timeline-error {
border-left-color: var(--error-color);
}
.webshell-ai-process-block .webshell-ai-timeline-warning {
border-left-color: #f57c00;
background: rgba(245, 124, 0, 0.08);
}
.webshell-ai-process-block .webshell-ai-timeline-eino_usage_summary {
border-left-color: #0f766e;
background: rgba(20, 184, 166, 0.06);
}
.webshell-ai-messages {
flex: 1;
min-height: 0;
@@ -36561,6 +36580,11 @@ html[data-theme="dark"] .timeline-item-progress {
background: rgba(148, 163, 184, 0.075);
}
html[data-theme="dark"] .timeline-item-eino_usage_summary {
border-left-color: #2dd4bf;
background: rgba(45, 212, 191, 0.085);
}
html[data-theme="dark"] .timeline-item-eino_agent_reply {
background: rgba(168, 85, 247, 0.1);
}
@@ -36652,6 +36676,12 @@ html[data-theme="dark"] .webshell-ai-timeline-finalization_check .webshell-ai-ti
color: #fbbf24;
}
html[data-theme="dark"] .webshell-ai-timeline-eino_usage_summary,
html[data-theme="dark"] .webshell-ai-process-block .webshell-ai-timeline-eino_usage_summary {
border-left-color: #2dd4bf;
background: rgba(45, 212, 191, 0.08);
}
html[data-theme="dark"] .message.assistant:not(.progress-message):not(.assistant-turn-with-process) .message-bubble {
background: transparent;
color: var(--text-primary);
+18
View File
@@ -699,6 +699,15 @@
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
"einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.",
"einoRunRetryTitle": "🔁 Transient error retry",
"einoModelRetryTitle": "🔁 Model call retry",
"einoModelFailoverTitle": "🔀 Switching fallback model",
"einoUsageSummaryTitle": "Token usage summary",
"einoUsageModelCalls": "Model calls",
"einoUsagePromptTokens": "Prompt tokens",
"einoUsageCompletionTokens": "Completion tokens",
"einoUsageTotalTokens": "Total tokens",
"einoUsageCachedTokens": "Cached tokens",
"einoUsageReasoningTokens": "Reasoning tokens",
"einoEmptyResponseContinueTitle": "🔁 Auto resume (no assistant text)",
"einoEmptyResponseContinueMessage": "Session ended without captured assistant text; resuming from trace…",
"einoRunRetryPlan": "Retry progress: attempt {{attempt}}/{{maxAttempts}}, waiting {{backoffSec}}s",
@@ -2821,6 +2830,15 @@
"multiAgentRobotMode": "Default conversation mode for bots",
"multiAgentRobotModeHint": "Execution mode for WeCom / DingTalk / Lark bot messages. Deep / Plan-Execute / Supervisor require multi-agent to be enabled.",
"agentAdvanced": "Advanced settings",
"einoModelRetryMaxRetries": "Eino native model retry limit",
"einoModelRetryMaxRetriesHint": "ChatModelAgent native ModelRetry attempts. 0 uses the backend default of 4. Covers 429/5xx/network jitter and empty model output.",
"einoModelRetryMaxBackoffSec": "Eino native model max backoff seconds",
"einoModelRetryMaxBackoffSecHint": "Upper bound for exponential backoff in native model retry. 0 uses the backend default of 30 seconds.",
"einoModelFailoverChannels": "Eino model failover channels",
"einoModelFailoverChannelsPlaceholder": "backup-openai\nbackup-claude",
"einoModelFailoverChannelsHint": "After the primary channel exhausts native retries, switch through these ai.channels IDs in order. One per line; commas are also accepted.",
"einoModelFailoverMaxRetries": "Eino model failover switch limit",
"einoModelFailoverMaxRetriesHint": "0 tries all configured fallback channels. A positive value limits how many fallback channels may be tried.",
"userLedgerMaxRunes": "User input ledger total character limit",
"userLedgerMaxRunesHint": "Total limit for the original user input ledger injected after compaction; 0 uses the backend default. DB-stored original messages are not truncated.",
"userLedgerEntryMaxRunes": "Per-message user input character limit",
+18
View File
@@ -687,6 +687,15 @@
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}}",
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
"einoRunRetryTitle": "🔁 临时错误重试",
"einoModelRetryTitle": "🔁 模型调用重试",
"einoModelFailoverTitle": "🔀 切换备用模型",
"einoUsageSummaryTitle": "Token 用量汇总",
"einoUsageModelCalls": "模型调用",
"einoUsagePromptTokens": "输入 tokens",
"einoUsageCompletionTokens": "输出 tokens",
"einoUsageTotalTokens": "总 tokens",
"einoUsageCachedTokens": "缓存 tokens",
"einoUsageReasoningTokens": "推理 tokens",
"einoEmptyResponseContinueTitle": "🔁 自动续跑(无助手正文)",
"einoEmptyResponseContinueMessage": "会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…",
"einoRunRetryPlan": "重试进度:第 {{attempt}}/{{maxAttempts}} 次,等待 {{backoffSec}} 秒",
@@ -2809,6 +2818,15 @@
"multiAgentRobotMode": "机器人默认对话模式",
"multiAgentRobotModeHint": "企业微信 / 钉钉 / 飞书机器人每条消息使用的执行模式;Deep / Plan-Execute / Supervisor 需启用多代理。",
"agentAdvanced": "高级设置",
"einoModelRetryMaxRetries": "Eino 原生模型最大重试次数",
"einoModelRetryMaxRetriesHint": "ChatModelAgent 原生 ModelRetry 次数;0 使用后端默认 4。用于 429/5xx/网络抖动和空模型输出。",
"einoModelRetryMaxBackoffSec": "Eino 原生模型最大退避秒数",
"einoModelRetryMaxBackoffSecHint": "模型原生重试的指数退避上限;0 使用后端默认 30 秒。",
"einoModelFailoverChannels": "Eino 模型 Failover 备用通道",
"einoModelFailoverChannelsPlaceholder": "backup-openai\nbackup-claude",
"einoModelFailoverChannelsHint": "主通道原生重试耗尽后按顺序切换 ai.channels 中的通道 ID;每行一个,也支持逗号分隔。",
"einoModelFailoverMaxRetries": "Eino 模型 Failover 最大切换次数",
"einoModelFailoverMaxRetriesHint": "0 表示尝试全部已配置备用通道;大于 0 时限制最多切换几个通道。",
"userLedgerMaxRunes": "用户原文账本总字符上限",
"userLedgerMaxRunesHint": "压缩后注入模型上下文的原始用户输入账本总上限;0 使用后端默认值。DB 原始消息不裁剪。",
"userLedgerEntryMaxRunes": "单条用户原文字符上限",
+46 -6
View File
@@ -2,7 +2,8 @@
'use strict';
const PLAN_TOOL_NAMES = new Set(['taskcreate', 'taskupdate', 'tasklist', 'taskget']);
const POLL_INTERVAL_MS = 1500;
const ACTIVE_POLL_INTERVAL_MS = 1500;
const ERROR_RETRY_INTERVAL_MS = 4000;
const FINAL_STATE_HOLD_MS = 2400;
function normalizeTask(raw, index) {
@@ -62,7 +63,6 @@
expanded: false,
requestSequence: 0,
abortController: null,
pollTimer: null,
refreshTimer: null,
finalHoldUntil: 0,
taskCalls: new Map()
@@ -208,6 +208,28 @@
return String(root.currentConversationId || '').trim();
}
function isCurrentConversationRunning() {
const conversationId = currentConversationId();
if (!conversationId) return false;
try {
const checker = typeof root.isConversationTaskRunning === 'function'
? root.isConversationTaskRunning
: (typeof isConversationTaskRunning === 'function' ? isConversationTaskRunning : null);
return !!(checker && checker(conversationId));
} catch (e) {
return false;
}
}
function shouldContinuePolling(payload) {
if (!currentConversationId() || root.document.hidden) return false;
if (payload && payload.running === false) return false;
if (payload && payload.running === true) {
return state.tasks.length > 0 || Date.now() < state.finalHoldUntil;
}
return isCurrentConversationRunning() && state.tasks.length > 0;
}
function setConversation(conversationId) {
const next = String(conversationId || '').trim();
if (next === state.conversationId) return false;
@@ -233,13 +255,15 @@
if (state.abortController) state.abortController.abort();
const controller = new AbortController();
state.abortController = controller;
let payload = null;
let retryAfterError = false;
try {
const fetcher = typeof root.apiFetch === 'function' ? root.apiFetch : root.fetch.bind(root);
const response = await fetcher('/api/conversations/' + encodeURIComponent(conversationId) + '/plan-tasks', {
signal: controller.signal
});
if (!response.ok) throw new Error('HTTP ' + response.status);
const payload = await response.json();
payload = await response.json();
if (sequence !== state.requestSequence || conversationId !== state.conversationId) return;
if (payload && payload.running === false) {
state.tasks = [];
@@ -254,10 +278,16 @@
render(false);
} catch (error) {
if (error && error.name === 'AbortError') return;
retryAfterError = true;
// A task list is supplemental UI; a transient poll failure must not
// interfere with the chat or erase the last known task state.
} finally {
if (sequence === state.requestSequence) state.abortController = null;
if (sequence === state.requestSequence) {
state.abortController = null;
if (shouldContinuePolling(payload)) {
scheduleRefresh(retryAfterError ? ERROR_RETRY_INTERVAL_MS : ACTIVE_POLL_INTERVAL_MS);
}
}
}
}
@@ -291,13 +321,24 @@
scheduleRefresh(100);
}
function handleConversationTaskStateChanged(event) {
const detail = event && event.detail && typeof event.detail === 'object' ? event.detail : {};
const conversationId = String(detail.conversationId || '').trim();
const currentId = currentConversationId();
if (conversationId && conversationId !== currentId) return;
if (isCurrentConversationRunning() || state.tasks.length) {
scheduleRefresh(0);
}
}
root.addEventListener('agent-plan-task-event', handlePlanToolEvent);
root.addEventListener('conversation-task-state-changed', handleConversationTaskStateChanged);
root.addEventListener('conversation-changed', (event) => {
setConversation(event && event.detail ? event.detail.conversationId : currentConversationId());
scheduleRefresh(0);
});
root.document.addEventListener('visibilitychange', () => {
if (!root.document.hidden) scheduleRefresh(0);
if (!root.document.hidden && (isCurrentConversationRunning() || state.tasks.length)) scheduleRefresh(0);
});
root.document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !state.expanded) return;
@@ -312,5 +353,4 @@
setConversation(currentConversationId());
fetchPlanTasks();
state.pollTimer = root.setInterval(fetchPlanTasks, POLL_INTERVAL_MS);
})(typeof window !== 'undefined' ? window : globalThis);
+55 -2
View File
@@ -8,6 +8,7 @@ const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
const webshell = fs.readFileSync('web/static/js/webshell.js', 'utf8');
const html = fs.readFileSync('web/templates/index.html', 'utf8');
function functionSource(source, name, nextName) {
@@ -79,6 +80,7 @@ function createScrollRuntime() {
return {
api: window.CyberStrikeChatScroll,
chatEl,
returnLatest,
listeners,
flushAnimationFrames() {
while (rafQueue.size) {
@@ -118,6 +120,21 @@ test('向上滚动立即解除粘底,只有滚到真实底部才恢复', () =>
assert.equal(runtime.chatEl.scrollTop, 1200, '恢复后新增输出继续请求滚到最底部');
});
test('用户离开最新位置后回到底部按钮稳定显示到真实底部', () => {
const runtime = createScrollRuntime();
runtime.flushAnimationFrames();
runtime.listeners.get('wheel')({ deltaY: -20 });
runtime.chatEl.scrollTop = 455;
runtime.listeners.get('scroll')();
assert.equal(runtime.returnLatest.hidden, false, '120px 阈值内仍应显示回到最新入口');
runtime.listeners.get('wheel')({ deltaY: 20 });
runtime.chatEl.scrollTop = 499;
runtime.listeners.get('scroll')();
assert.equal(runtime.returnLatest.hidden, true, '滚到真实底部后才隐藏');
});
test('刷新重建详情引起的布局上移不会误判为用户上滑', () => {
const runtime = createScrollRuntime();
runtime.flushAnimationFrames();
@@ -307,8 +324,8 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
});
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260813-6');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260813-9');
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260815-1');
assert.notEqual(scrollIndex, -1);
assert.notEqual(monitorIndex, -1);
@@ -327,6 +344,30 @@ test('直接点击项目对话也会写入 hash 以便刷新后恢复并补流',
assert.match(streamSource, /window\.syncChatConversationHash\(cid\)/);
});
test('任务计划进度按事件唤醒,空闲时不固定轮询 plan-tasks', () => {
const planSource = fs.readFileSync('web/static/js/chat-plan-progress.js', 'utf8');
assert.doesNotMatch(planSource, /setInterval\(fetchPlanTasks/);
assert.match(planSource, /ACTIVE_POLL_INTERVAL_MS = 1500/);
assert.match(planSource, /function shouldContinuePolling\(payload\)/);
assert.match(planSource, /payload && payload\.running === false/);
assert.match(planSource, /payload && payload\.running === true[\s\S]*?state\.tasks\.length > 0/);
assert.match(planSource, /isCurrentConversationRunning\(\) && state\.tasks\.length > 0/);
assert.match(planSource, /conversation-task-state-changed/);
assert.match(monitor, /window\.dispatchEvent\(new CustomEvent\('conversation-task-state-changed'/);
assert.match(monitor, /window\.isConversationTaskRunning = isConversationTaskRunning/);
assert.match(html, /chat-plan-progress\.js\?v=20260815-1/);
});
test('任务计划进度事件在活跃任务列表变化和新任务开始时发出', () => {
const notifySource = functionSource(monitor, 'notifyConversationTaskStarted', 'initChatTaskSyncChannel');
const renderSource = functionSource(monitor, 'renderActiveTasks', 'reconcileHitlApprovalStateWithActiveTasks');
assert.match(notifySource, /conversation-task-state-changed/);
assert.match(notifySource, /detail: \{ conversationId: id, running: true \}/);
assert.match(renderSource, /conversationExecutionTracker\.update\(normalizedTasks\)/);
assert.match(renderSource, /detail: \{ tasks: normalizedTasks \}/);
});
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
@@ -356,6 +397,18 @@ test('刷新运行中回复会复用已持久化 planning 并继续追加未来
assert.match(monitor, /item\.dataset\.responseStreamId = String\(options\.data\.streamId\)/);
});
test('Eino 原生模型 retry/failover 事件在主聊天和 WebShell 中可见', () => {
const handleSource = functionSource(monitor, 'handleStreamEvent', 'hitlApprovalTranslate');
assert.match(handleSource, /case 'eino_model_retry'/);
assert.match(handleSource, /formatEinoModelRetryTitle/);
assert.match(handleSource, /case 'eino_model_failover'/);
assert.match(handleSource, /formatEinoModelFailoverTitle/);
assert.match(monitor, /function formatEinoModelRetryMessage/);
assert.match(monitor, /function formatEinoModelFailoverMessage/);
assert.match(webshell, /_et === 'eino_model_retry'/);
assert.match(webshell, /_et === 'eino_model_failover'/);
});
test('非仪表盘 hash 首屏在路由确定前隐藏默认仪表盘', () => {
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
assert.match(html, /document\.documentElement\.classList\.add\('initial-route-pending'\)/);
+21 -2
View File
@@ -328,6 +328,22 @@
}
}
function isVisibleConversationTaskActive() {
if (isStreamActive()) return true;
try {
const visibleConversationId = typeof window.currentConversationId === 'string'
? window.currentConversationId.trim()
: '';
if (!visibleConversationId) return false;
const taskChecker = typeof window.isConversationTaskRunning === 'function'
? window.isConversationTaskRunning
: (typeof isConversationTaskRunning === 'function' ? isConversationTaskRunning : null);
return !!(taskChecker && taskChecker(visibleConversationId));
} catch (e) {
return false;
}
}
function distanceFromBottom(el) {
if (!el) return 0;
const { scrollTop, scrollHeight, clientHeight } = el;
@@ -427,8 +443,11 @@
const messagesEl = getChatMessagesEl();
if (!button || !messagesEl) return;
const scrollable = messagesEl.scrollHeight > messagesEl.clientHeight + 2;
const shouldShow = scrollable && !isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
const streaming = shouldShow && isStreamActive();
const atLatest = isNearBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX);
const readingDetachedHistory = scrollMode === 'detached' && !atLatest;
const farFromLatestFallback = !isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
const shouldShow = scrollable && (readingDetachedHistory || farFromLatestFallback);
const streaming = shouldShow && isVisibleConversationTaskActive();
button.hidden = !shouldShow;
button.classList.toggle('is-streaming', streaming);
button.classList.toggle('has-pending-new', shouldShow && hasPendingNewBelow);
+63 -1
View File
@@ -3648,10 +3648,71 @@ function isEinoAgentHeartbeatProgress(detail) {
return /^\[Eino\]\s+\S/.test(msg);
}
function hasModelOutputRecoveryMarker(value) {
if (!value) return false;
let obj = value;
if (typeof obj === 'string') {
const text = obj.trim();
if (!text || text.indexOf('_cyberstrike_model_output_recovery') === -1) return false;
try {
obj = JSON.parse(text);
} catch (e) {
return false;
}
}
return !!(obj && typeof obj === 'object' && obj._cyberstrike_model_output_recovery);
}
function isModelOutputRecoveryToolCallDetail(detail) {
if (!detail || detail.eventType !== 'tool_call') return false;
const data = detail.data && typeof detail.data === 'object' ? detail.data : {};
return hasModelOutputRecoveryMarker(data.argumentsObj) || hasModelOutputRecoveryMarker(data.arguments);
}
function isAnonymousTaskFragmentToolCallDetail(detail) {
if (!detail || detail.eventType !== 'tool_call') return false;
const data = detail.data && typeof detail.data === 'object' ? detail.data : {};
const toolName = String(data.toolName || '').trim().toLowerCase();
if (toolName && toolName !== 'task' && toolName !== 'unknown') return false;
let raw = null;
const argsObj = data.argumentsObj && typeof data.argumentsObj === 'object' ? data.argumentsObj : null;
if (argsObj) {
const keys = Object.keys(argsObj);
if (keys.length === 1 && keys[0] === '_raw') {
raw = String(argsObj._raw != null ? argsObj._raw : '').trim();
}
}
if (raw == null && typeof data.arguments === 'string') {
raw = data.arguments.trim();
}
if (raw == null) return false;
if (!raw) return true;
return !(raw.startsWith('{') && raw.endsWith('}'));
}
function isInternalEinoDiagnosticDetail(detail) {
if (!detail) return false;
if (detail.eventType === 'model_output_rejected') return true;
if (detail.eventType !== 'progress') return false;
const msg = String(detail.message != null ? detail.message : '').trim();
if (msg === 'Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。' ||
msg === 'Eino TurnLoop 已在安全点切换到用户补充后的下一轮。' ||
msg === '已将用户补充推入 Eino TurnLoop,正在等待安全点切换…') {
return true;
}
const data = detail.data && typeof detail.data === 'object' ? detail.data : {};
const kind = String(data.kind || '').trim();
return kind === 'turn_loop_takeover' || kind === 'turn_loop_preempted';
}
function filterNoiseProcessDetails(details) {
if (!Array.isArray(details)) return details;
return details.filter(function (d) {
if (isEinoAgentHeartbeatProgress(d)) return false;
if (isModelOutputRecoveryToolCallDetail(d)) return false;
if (isAnonymousTaskFragmentToolCallDetail(d)) return false;
if (isInternalEinoDiagnosticDetail(d)) return false;
return !(d && d.eventType === 'tool_calls_detected');
});
}
@@ -4130,11 +4191,12 @@ function renderProcessDetails(messageId, processDetails, options) {
}
}
const processDetailId = detail.id || data.processDetailId || '';
const timelineOpts = {
title: itemTitle,
message: detail.message || '',
data: data,
processDetailId: detail.id || '',
processDetailId: processDetailId,
createdAt: detail.createdAt
};
if (eventType === 'tool_call' && data._mergedResult) {
@@ -0,0 +1,53 @@
const fs = require('node:fs');
const test = require('node:test');
const assert = require('node:assert/strict');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const webshell = fs.readFileSync('web/static/js/webshell.js', 'utf8');
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8'));
test('Eino token usage summary is rendered as a first-class timeline event', () => {
assert.match(monitor, /case 'eino_usage_summary'/);
assert.match(monitor, /formatEinoUsageSummaryTitle\(d\)/);
assert.match(monitor, /formatEinoUsageSummaryMessage\(d\)/);
assert.match(monitor, /addTimelineItem\(timeline, 'eino_usage_summary'/);
assert.match(styles, /\.timeline-item-eino_usage_summary/);
assert.match(styles, /html\[data-theme="dark"\] \.timeline-item-eino_usage_summary/);
});
test('Eino token usage summary is visible in WebShell live and restored timelines', () => {
assert.match(webshell, /_et === 'eino_usage_summary'/);
assert.match(webshell, /appendTimelineItem\('eino_usage_summary'/);
assert.match(webshell, /function formatWebshellEinoUsageSummaryTitle/);
assert.match(webshell, /function formatWebshellEinoUsageSummaryMessage/);
assert.match(webshell, /eventType === 'eino_usage_summary'/);
assert.match(styles, /\.webshell-ai-timeline-eino_usage_summary/);
assert.match(styles, /html\[data-theme="dark"\] \.webshell-ai-timeline-eino_usage_summary/);
});
test('Eino token usage summary supports language refresh', () => {
assert.match(monitor, /type === 'eino_usage_summary'/);
assert.match(monitor, /item\.dataset\.modelCalls/);
assert.match(monitor, /setTimelineItemContentStreamPlain\(contentEl, formatEinoUsageSummaryMessage\(usageData\)\)/);
});
test('Eino resilience and usage timeline labels have zh/en translations', () => {
[
'einoModelRetryTitle',
'einoModelFailoverTitle',
'einoUsageSummaryTitle',
'einoUsageModelCalls',
'einoUsagePromptTokens',
'einoUsageCompletionTokens',
'einoUsageTotalTokens',
'einoUsageCachedTokens',
'einoUsageReasoningTokens'
].forEach((key) => {
assert.equal(typeof zh.chat[key], 'string', `zh chat.${key}`);
assert.ok(zh.chat[key].length > 0, `zh chat.${key} is empty`);
assert.equal(typeof en.chat[key], 'string', `en chat.${key}`);
assert.ok(en.chat[key].length > 0, `en chat.${key} is empty`);
});
});
+9
View File
@@ -164,6 +164,15 @@ test('项目文件夹汇总始终为绿色且只有具体对话按剩余时间
assert.equal(urgencyLevel(0, false), 'normal');
});
test('工具详情延迟 payload 使用实时事件中的 processDetailId 回补参数', () => {
assert.match(chat, /const processDetailId = detail\.id \|\| data\.processDetailId \|\| ''/);
assert.match(chat, /processDetailId: processDetailId/);
assert.match(monitor, /resultDetailId: data\._mergedResultDetailId \|\| \(merged && merged\.processDetailId\) \|\| ''/);
assert.match(monitor, /if \(state\.payloadDeferred && !state\.payloadLoaded && \(state\.processDetailId \|\| state\.resultDetailId\)\)/);
assert.match(monitor, /const fullCall = await fetchFullProcessDetailData\(state\.processDetailId\)/);
assert.match(monitor, /state\.args = parseToolCallArgsFromData\(fullCall\)/);
});
test('切换对话后主按钮只读取当前可见对话的运行状态', () => {
assert.match(chat, /function getVisibleChatConversationId\(\)/);
assert.match(chat, /function shouldTreatLiveChatTaskAsCurrent\(/);
+161 -1
View File
@@ -1104,6 +1104,9 @@ function notifyConversationTaskStarted(conversationId) {
const id = String(conversationId || '').trim();
if (!id) return false;
conversationExecutionTracker.markRunning(id);
window.dispatchEvent(new CustomEvent('conversation-task-state-changed', {
detail: { conversationId: id, running: true }
}));
if (typeof window.updateChatPrimaryActionState === 'function') {
window.updateChatPrimaryActionState();
}
@@ -1171,6 +1174,7 @@ const hitlPendingInterruptTracker = {
function isConversationTaskRunning(conversationId) {
return conversationExecutionTracker.isRunning(conversationId);
}
window.isConversationTaskRunning = isConversationTaskRunning;
function setHitlApprovalInterruptedVisualState(panel, interrupted) {
if (!panel || panel.classList.contains('hitl-inline-done')) return;
@@ -2592,6 +2596,104 @@ function formatEinoRunRetryTitle(data) {
return base;
}
function formatEinoModelRetryTitle(data) {
const d = data && typeof data === 'object' ? data : {};
const base = typeof window.t === 'function'
? window.t('chat.einoModelRetryTitle')
: '🔁 模型调用重试';
const attempt = Number(d.attempt || 0);
if (Number.isFinite(attempt) && attempt > 0) {
return base + '' + attempt + '';
}
return base;
}
function formatEinoModelRetryMessage(message, data) {
const d = data && typeof data === 'object' ? data : {};
const lines = [];
const base = String(message || '').trim();
if (base) lines.push(base);
if (d.reason != null && String(d.reason).trim() !== '') {
lines.push('原因:' + String(d.reason).trim());
}
if (d.error != null && String(d.error).trim() !== '') {
lines.push('错误详情:' + String(d.error).trim());
}
return lines.join('\n');
}
function formatEinoModelFailoverTitle(data) {
const d = data && typeof data === 'object' ? data : {};
const base = typeof window.t === 'function'
? window.t('chat.einoModelFailoverTitle')
: '🔀 切换备用模型';
const attempt = Number(d.attempt || 0);
if (Number.isFinite(attempt) && attempt > 0) {
return base + '' + attempt + '';
}
return base;
}
function formatEinoModelFailoverMessage(message, data) {
const d = data && typeof data === 'object' ? data : {};
const lines = [];
const base = String(message || '').trim();
if (base) lines.push(base);
if (d.channel != null && String(d.channel).trim() !== '') {
lines.push('通道:' + String(d.channel).trim());
}
if (d.model != null && String(d.model).trim() !== '') {
lines.push('模型:' + String(d.model).trim());
}
return lines.join('\n');
}
function formatCompactInteger(value) {
const n = Number(value || 0);
if (!Number.isFinite(n)) return '0';
try {
return Math.max(0, Math.trunc(n)).toLocaleString(getCurrentTimeLocale());
} catch (e) {
return String(Math.max(0, Math.trunc(n)));
}
}
function formatEinoUsageSummaryTitle(data) {
const d = data && typeof data === 'object' ? data : {};
const base = typeof window.t === 'function'
? window.t('chat.einoUsageSummaryTitle')
: 'Token 用量汇总';
const total = Number(d.totalTokens || 0);
if (Number.isFinite(total) && total > 0) {
return base + ' · ' + formatCompactInteger(total);
}
return base;
}
function formatEinoUsageSummaryMessage(data) {
const d = data && typeof data === 'object' ? data : {};
const label = function (key, fallback) {
if (typeof window.t !== 'function') return fallback;
const translated = window.t(key);
return translated && translated !== key ? translated : fallback;
};
const rows = [
[label('chat.einoUsageModelCalls', '模型调用'), d.modelCalls],
[label('chat.einoUsagePromptTokens', '输入 tokens'), d.promptTokens],
[label('chat.einoUsageCompletionTokens', '输出 tokens'), d.completionTokens],
[label('chat.einoUsageTotalTokens', '总 tokens'), d.totalTokens]
];
if (Number(d.cachedTokens || 0) > 0) {
rows.push([label('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]);
}
if (Number(d.reasoningTokens || 0) > 0) {
rows.push([label('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]);
}
return rows.map(function (row) {
return row[0] + ': ' + formatCompactInteger(row[1]);
}).join('\n');
}
function dispatchAgentPlanTaskEvent(event, fallbackConversationId) {
if (!event || (event.type !== 'tool_call' && event.type !== 'tool_result')) return;
const data = event.data && typeof event.data === 'object' ? event.data : {};
@@ -3230,6 +3332,37 @@ function handleStreamEvent(event, progressElement, progressId,
break;
}
case 'eino_model_retry': {
const d = event.data || {};
addTimelineItem(timeline, 'warning', {
title: formatEinoModelRetryTitle(d),
message: formatEinoModelRetryMessage(event.message, d),
data: d
});
break;
}
case 'eino_model_failover': {
const d = event.data || {};
addTimelineItem(timeline, 'warning', {
title: formatEinoModelFailoverTitle(d),
message: formatEinoModelFailoverMessage(event.message, d),
data: d
});
break;
}
case 'eino_usage_summary': {
const d = event.data || {};
addTimelineItem(timeline, 'eino_usage_summary', {
title: formatEinoUsageSummaryTitle(d),
message: formatEinoUsageSummaryMessage(d),
data: d,
expanded: false
});
break;
}
case 'iteration_limit_reached': {
addTimelineItem(timeline, 'warning', {
title: typeof window.t === 'function' ? window.t('chat.iterationLimitReachedTitle') : '⛔ 达到迭代上限',
@@ -6209,6 +6342,14 @@ function addTimelineItem(timeline, type, options) {
item.dataset.toolSuccess = (!displayState.isError && displayState.kind !== 'background_running') ? '1' : '0';
item.dataset.toolDisplayStatus = displayState.kind === 'background_running' ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
}
if (type === 'eino_usage_summary' && options.data) {
const d = options.data;
['modelCalls', 'promptTokens', 'completionTokens', 'totalTokens', 'cachedTokens', 'reasoningTokens'].forEach(function (key) {
if (d[key] != null) {
item.dataset[key] = String(d[key]);
}
});
}
if (options.data && options.data.einoAgent != null && String(options.data.einoAgent).trim() !== '') {
item.dataset.einoAgent = String(options.data.einoAgent).trim();
}
@@ -6285,7 +6426,7 @@ function addTimelineItem(timeline, type, options) {
resultData: merged || null,
pending: !merged && !hasHistoricalStatus && !options.skipPendingResult,
processDetailId: options.processDetailId || '',
resultDetailId: data._mergedResultDetailId || '',
resultDetailId: data._mergedResultDetailId || (merged && merged.processDetailId) || '',
payloadDeferred: data._payloadDeferred === true || (merged && merged._payloadDeferred === true),
payloadLoaded: !(data._payloadDeferred === true || (merged && merged._payloadDeferred === true))
});
@@ -6348,6 +6489,8 @@ function addTimelineItem(timeline, type, options) {
? formatTimelineStreamBody(options.message, options.data)
: options.message;
content += `<div class="timeline-item-content timeline-stream-plain">${formatTimelinePlainTextHtml(streamBody)}</div>`;
} else if (type === 'eino_usage_summary' && options.message) {
content += `<div class="timeline-item-content timeline-stream-plain timeline-usage-summary">${formatTimelinePlainTextHtml(options.message)}</div>`;
} else if (type === 'progress' && options.message) {
content += `<div class="timeline-item-content timeline-eino-trace"><pre class="tool-result">${escapeHtml(options.message)}</pre></div>`;
} else if (type === 'user_interrupt_continue' && options.message) {
@@ -6522,6 +6665,9 @@ function renderActiveTasks(tasks) {
const normalizedTasks = Array.isArray(tasks) ? tasks : [];
conversationExecutionTracker.update(normalizedTasks);
window.dispatchEvent(new CustomEvent('conversation-task-state-changed', {
detail: { tasks: normalizedTasks }
}));
syncHitlApprovalTaskAvailability();
reconcileHitlApprovalStateWithActiveTasks(normalizedTasks);
if (typeof window.updateChatPrimaryActionState === 'function') {
@@ -9009,6 +9155,20 @@ function refreshProgressAndTimelineI18n() {
titleSpan.textContent = ap + icon + (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') {
const usageData = {
modelCalls: item.dataset.modelCalls,
promptTokens: item.dataset.promptTokens,
completionTokens: item.dataset.completionTokens,
totalTokens: item.dataset.totalTokens,
cachedTokens: item.dataset.cachedTokens,
reasoningTokens: item.dataset.reasoningTokens
};
titleSpan.textContent = formatEinoUsageSummaryTitle(usageData);
const contentEl = item.querySelector('.timeline-usage-summary');
if (contentEl) {
setTimelineItemContentStreamPlain(contentEl, formatEinoUsageSummaryMessage(usageData));
}
} else if (type === 'cancelled') {
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
} else if (type === 'user_interrupt_continue') {
@@ -0,0 +1,53 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = process.cwd();
const settings = fs.readFileSync(path.join(root, 'web/static/js/settings.js'), 'utf8');
const template = fs.readFileSync(path.join(root, 'web/templates/index.html'), 'utf8');
const zh = JSON.parse(fs.readFileSync(path.join(root, 'web/static/i18n/zh-CN.json'), 'utf8'));
const en = JSON.parse(fs.readFileSync(path.join(root, 'web/static/i18n/en-US.json'), 'utf8'));
test('Eino 模型 retry/failover 设置页读写链路完整', () => {
[
'eino-model-retry-max-retries',
'eino-model-retry-max-backoff-sec',
'eino-model-failover-channels',
'eino-model-failover-max-retries'
].forEach((id) => {
assert.match(template, new RegExp(`id="${id}"`));
assert.match(settings, new RegExp(`getElementById\\('${id}'\\)`));
});
[
'model_retry_max_retries',
'model_retry_max_backoff_sec',
'model_failover_channels',
'model_failover_max_retries'
].forEach((key) => {
assert.match(settings, new RegExp(`${key}`));
});
assert.match(settings, /failoverChannelsRaw\.split\(\s*\/\[\\n,\]\//);
assert.match(settings, /Array\.from\(new Set\(/);
});
test('Eino 模型 retry/failover 设置项有中英文文案', () => {
[
'einoModelRetryMaxRetries',
'einoModelRetryMaxRetriesHint',
'einoModelRetryMaxBackoffSec',
'einoModelRetryMaxBackoffSecHint',
'einoModelFailoverChannels',
'einoModelFailoverChannelsPlaceholder',
'einoModelFailoverChannelsHint',
'einoModelFailoverMaxRetries',
'einoModelFailoverMaxRetriesHint'
].forEach((key) => {
assert.equal(typeof zh.settingsBasic[key], 'string', `zh ${key}`);
assert.ok(zh.settingsBasic[key].length > 0, `zh ${key} is empty`);
assert.equal(typeof en.settingsBasic[key], 'string', `en ${key}`);
assert.ok(en.settingsBasic[key].length > 0, `en ${key} is empty`);
});
});
+33
View File
@@ -859,6 +859,26 @@ async function loadConfig(loadTools = true, options = {}) {
maRobotMode.value = mode;
syncRobotAgentModeSelectOptions(ma.enabled === true);
}
const modelRetryMaxEl = document.getElementById('eino-model-retry-max-retries');
if (modelRetryMaxEl) {
const v = ma.model_retry_max_retries;
modelRetryMaxEl.value = (v !== undefined && v !== null && !Number.isNaN(Number(v))) ? String(Number(v)) : '0';
}
const modelRetryBackoffEl = document.getElementById('eino-model-retry-max-backoff-sec');
if (modelRetryBackoffEl) {
const v = ma.model_retry_max_backoff_sec;
modelRetryBackoffEl.value = (v !== undefined && v !== null && !Number.isNaN(Number(v))) ? String(Number(v)) : '0';
}
const modelFailoverChannelsEl = document.getElementById('eino-model-failover-channels');
if (modelFailoverChannelsEl) {
const channels = ma.model_failover_channels;
modelFailoverChannelsEl.value = Array.isArray(channels) ? channels.join('\n') : '';
}
const modelFailoverMaxEl = document.getElementById('eino-model-failover-max-retries');
if (modelFailoverMaxEl) {
const v = ma.model_failover_max_retries;
modelFailoverMaxEl.value = (v !== undefined && v !== null && !Number.isNaN(Number(v))) ? String(Number(v)) : '0';
}
const userLedgerMaxEl = document.getElementById('summarization-user-ledger-max-runes');
if (userLedgerMaxEl) {
const v = ma.summarization_user_intent_ledger_max_runes;
@@ -2044,11 +2064,24 @@ async function applySettings() {
if (!maEnabled && ['deep', 'plan_execute', 'supervisor'].indexOf(robotMode) >= 0) {
robotMode = 'eino_single';
}
const parseNonNegativeInt = function (id) {
const raw = document.getElementById(id)?.value;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? 0 : Math.max(0, parsed);
};
const failoverChannelsRaw = document.getElementById('eino-model-failover-channels')?.value || '';
const failoverChannels = Array.from(new Set(
failoverChannelsRaw.split(/[\n,]/).map(s => s.trim()).filter(Boolean)
));
return {
enabled: maEnabled,
robot_default_agent_mode: robotMode,
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
plan_execute_loop_max_iterations: peLoop,
model_retry_max_retries: parseNonNegativeInt('eino-model-retry-max-retries'),
model_retry_max_backoff_sec: parseNonNegativeInt('eino-model-retry-max-backoff-sec'),
model_failover_channels: failoverChannels,
model_failover_max_retries: parseNonNegativeInt('eino-model-failover-max-retries'),
summarization_user_intent_ledger_max_runes: ledgerMax,
summarization_user_intent_ledger_entry_max_runes: ledgerEntryMax,
latest_user_message_max_runes: latestMax,
@@ -13,6 +13,14 @@ function timingSource(source) {
return source.slice(start, end);
}
function processDetailFilterSource(source) {
const start = source.indexOf('function isEinoAgentHeartbeatProgress(');
const end = source.indexOf('function dedupeConsecutiveProcessDetailRows(', start);
assert.notEqual(start, -1, 'process detail filter should exist');
assert.notEqual(end, -1, 'dedupe boundary should exist');
return source.slice(start, end);
}
function createClassList() {
return {
toggle() {},
@@ -116,3 +124,62 @@ test('历史占位消息存在取消事件时不会再判定为运行中', () =>
assert.match(chat, /const isRunning = isAssistantPlaceholder && !terminalState/);
assert.match(chat, /status: status/);
});
test('过程详情隐藏 Eino 内部诊断但保留真实工具调用', () => {
const context = {};
vm.runInNewContext(
`${processDetailFilterSource(chat)}; this.filterNoiseProcessDetails = filterNoiseProcessDetails;`,
context
);
const filtered = context.filterNoiseProcessDetails([
{
eventType: 'tool_call',
data: {
toolName: 'task',
argumentsObj: {
_cyberstrike_model_output_recovery: {
reason: 'invalid_tool_arguments_json',
repair_attempt: 1,
},
},
},
},
{
eventType: 'model_output_rejected',
message: '模型工具调用不完整或参数不安全,已阻止执行并要求重写。',
data: { reason: 'invalid_tool_arguments_json' },
},
{
eventType: 'progress',
message: 'Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。',
data: { kind: 'turn_loop_takeover' },
},
{
eventType: 'tool_call',
data: {
toolName: 'task',
argumentsObj: { _raw: 'command' },
arguments: 'command',
},
},
{
eventType: 'tool_call',
data: {
toolName: 'task',
argumentsObj: { _raw: '"' },
arguments: '"',
},
},
{
eventType: 'tool_call',
data: {
toolName: 'exec',
arguments: '{"command":"ls"}',
},
},
]);
assert.equal(filtered.length, 1);
assert.equal(filtered[0].data.toolName, 'exec');
});
+63 -1
View File
@@ -2168,6 +2168,46 @@ function webshellAgentPx(data) {
return s ? ('[' + s + '] ') : '';
}
function formatWebshellCompactInteger(value) {
var n = Number(value || 0);
if (!Number.isFinite(n)) return '0';
try {
var locale = (typeof getCurrentTimeLocale === 'function') ? getCurrentTimeLocale() : undefined;
return Math.max(0, Math.trunc(n)).toLocaleString(locale);
} catch (e) {
return String(Math.max(0, Math.trunc(n)));
}
}
function formatWebshellEinoUsageSummaryTitle(data) {
var d = data && typeof data === 'object' ? data : {};
var base = wsTOr('chat.einoUsageSummaryTitle', 'Token 用量汇总');
var total = Number(d.totalTokens || 0);
if (Number.isFinite(total) && total > 0) {
return base + ' · ' + formatWebshellCompactInteger(total);
}
return base;
}
function formatWebshellEinoUsageSummaryMessage(data) {
var d = data && typeof data === 'object' ? data : {};
var rows = [
[wsTOr('chat.einoUsageModelCalls', '模型调用'), d.modelCalls],
[wsTOr('chat.einoUsagePromptTokens', '输入 tokens'), d.promptTokens],
[wsTOr('chat.einoUsageCompletionTokens', '输出 tokens'), d.completionTokens],
[wsTOr('chat.einoUsageTotalTokens', '总 tokens'), d.totalTokens]
];
if (Number(d.cachedTokens || 0) > 0) {
rows.push([wsTOr('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]);
}
if (Number(d.reasoningTokens || 0) > 0) {
rows.push([wsTOr('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]);
}
return rows.map(function (row) {
return row[0] + ': ' + formatWebshellCompactInteger(row[1]);
}).join('\n');
}
// 根据后端保存的 processDetail 构建一条时间线项的 HTML(与 appendTimelineItem 展示一致)
function buildWebshellTimelineItemFromDetail(detail) {
var eventType = detail.eventType || '';
@@ -2207,6 +2247,8 @@ function buildWebshellTimelineItemFromDetail(detail) {
title = ap + wsIcon + wsLabel;
} else if (eventType === 'eino_agent_reply') {
title = ap + '💬 ' + ((typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
} else if (eventType === 'eino_usage_summary') {
title = ap + '📊 ' + formatWebshellEinoUsageSummaryTitle(data);
} else if (eventType === 'progress') {
title = (typeof window.translateProgressMessage === 'function') ? window.translateProgressMessage(detail.message || '') : (detail.message || '');
}
@@ -2214,6 +2256,9 @@ function buildWebshellTimelineItemFromDetail(detail) {
if (eventType === 'eino_agent_reply' && detail.message) {
html += '<div class="webshell-ai-timeline-msg"><pre style="white-space:pre-wrap;">' + escapeHtml(detail.message) + '</pre></div>';
}
if (eventType === 'eino_usage_summary') {
html += '<div class="webshell-ai-timeline-msg"><pre style="white-space:pre-wrap;">' + escapeHtml(formatWebshellEinoUsageSummaryMessage(data)) + '</pre></div>';
}
if (eventType === 'tool_call' && data && (data.argumentsObj || data.arguments)) {
try {
var args = data.argumentsObj;
@@ -2243,7 +2288,7 @@ function buildWebshellTimelineItemFromDetail(detail) {
var execIdLabel = (typeof window.t === 'function') ? window.t('timeline.executionId') : '执行ID:';
var sectionClass = displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success');
html += '<div class="webshell-ai-timeline-msg"><div class="tool-result-section ' + sectionClass + '"><strong>' + escapeHtml(execResultLabel) + '</strong><pre class="tool-result">' + escapeHtml(resultStr) + '</pre>' + (data.executionId ? '<div class="tool-execution-id"><span>' + escapeHtml(execIdLabel) + '</span> <code>' + escapeHtml(String(data.executionId)) + '</code></div>' : '') + '</div></div>';
} else if (detail.message && detail.message !== title) {
} else if (eventType !== 'eino_usage_summary' && detail.message && detail.message !== title) {
html += '<div class="webshell-ai-timeline-msg">' + escapeHtml(detail.message) + '</div>';
}
return html;
@@ -3396,6 +3441,9 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
}
} else if (type === 'eino_agent_reply' && message) {
html += '<div class="webshell-ai-timeline-msg"><pre style="white-space:pre-wrap;">' + escapeHtml(message) + '</pre></div>';
} else if (type === 'eino_usage_summary') {
var usageText = message || formatWebshellEinoUsageSummaryMessage(data);
html += '<div class="webshell-ai-timeline-msg"><pre style="white-space:pre-wrap;">' + escapeHtml(usageText) + '</pre></div>';
} else if (type === 'tool_result' && data) {
// 工具调用出参
var noResultText = (typeof window.t === 'function') ? window.t('timeline.noResult') : '无结果';
@@ -3569,6 +3617,20 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
appendTimelineItem('finalization_check', finalizationOk ? '最终回复检查通过' : '最终回复检查未通过', finalizationOk ? (_em || '最终回复检查通过。') : webshellFinalizationNotice(_ed || {}, _em, true), _ed);
} else if (_et === 'finalization_auto_continue') {
appendTimelineItem('progress', '继续验证', _em, _ed);
} else if (_et === 'eino_model_retry') {
var retryAttempt = _ed && _ed.attempt ? ('' + _ed.attempt + '') : '';
var retryMsg = _em || '模型调用遇到临时问题,Eino 正在原生重试…';
if (_ed && _ed.reason) retryMsg += '\n原因:' + _ed.reason;
if (_ed && _ed.error) retryMsg += '\n错误详情:' + _ed.error;
appendTimelineItem('warning', '🔁 模型调用重试' + retryAttempt, retryMsg, _ed);
} else if (_et === 'eino_model_failover') {
var failoverAttempt = _ed && _ed.attempt ? ('' + _ed.attempt + '') : '';
var failoverMsg = _em || '主模型重试耗尽,正在切换备用模型。';
if (_ed && _ed.channel) failoverMsg += '\n通道:' + _ed.channel;
if (_ed && _ed.model) failoverMsg += '\n模型:' + _ed.model;
appendTimelineItem('warning', '🔀 切换备用模型' + failoverAttempt, failoverMsg, _ed);
} else if (_et === 'eino_usage_summary') {
appendTimelineItem('eino_usage_summary', '📊 ' + formatWebshellEinoUsageSummaryTitle(_ed), formatWebshellEinoUsageSummaryMessage(_ed), _ed);
} else if (_et === 'error' && _em) {
streamingTypingId += 1;
var errLabel = wsTOr('chat.error', '错误');