From e691c5d9c6fe4394e60e9c7d3575c11dfc7feea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:33:33 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 30 ++++ web/static/i18n/en-US.json | 18 ++ web/static/i18n/zh-CN.json | 18 ++ web/static/js/chat-plan-progress.js | 52 +++++- web/static/js/chat-scroll-refresh.test.cjs | 57 +++++- web/static/js/chat-scroll.js | 23 ++- web/static/js/chat.js | 64 ++++++- web/static/js/eino-usage-summary-ui.test.cjs | 53 ++++++ web/static/js/hitl-approval-ui.test.cjs | 9 + web/static/js/monitor.js | 162 +++++++++++++++++- .../js/settings-eino-resilience.test.cjs | 53 ++++++ web/static/js/settings.js | 33 ++++ web/static/js/turn-duration-refresh.test.cjs | 67 ++++++++ web/static/js/webshell.js | 64 ++++++- web/templates/index.html | 32 +++- 15 files changed, 716 insertions(+), 19 deletions(-) create mode 100644 web/static/js/eino-usage-summary-ui.test.cjs create mode 100644 web/static/js/settings-eino-resilience.test.cjs diff --git a/web/static/css/style.css b/web/static/css/style.css index 175971b8..21da5159 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -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); diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 19b45ee6..24834b4d 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 6caedb5a..6729c35a 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -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": "单条用户原文字符上限", diff --git a/web/static/js/chat-plan-progress.js b/web/static/js/chat-plan-progress.js index 2f8d4f30..2c2ec887 100644 --- a/web/static/js/chat-plan-progress.js +++ b/web/static/js/chat-plan-progress.js @@ -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); diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs index 44a5af50..7c3c905c 100644 --- a/web/static/js/chat-scroll-refresh.test.cjs +++ b/web/static/js/chat-scroll-refresh.test.cjs @@ -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'\)/); diff --git a/web/static/js/chat-scroll.js b/web/static/js/chat-scroll.js index a79ecad8..285f4404 100644 --- a/web/static/js/chat-scroll.js +++ b/web/static/js/chat-scroll.js @@ -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); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index fcfce803..ed6645bc 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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) { diff --git a/web/static/js/eino-usage-summary-ui.test.cjs b/web/static/js/eino-usage-summary-ui.test.cjs new file mode 100644 index 00000000..c794e622 --- /dev/null +++ b/web/static/js/eino-usage-summary-ui.test.cjs @@ -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`); + }); +}); diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index 8d42ebb2..8b25bc9b 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -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\(/); diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index fd984ebf..129dc5f2 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -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 += `
${escapeHtml(options.message)}' + escapeHtml(detail.message) + '
' + escapeHtml(formatWebshellEinoUsageSummaryMessage(data)) + '
' + escapeHtml(resultStr) + '' + (data.executionId ? '
' + escapeHtml(String(data.executionId)) + '' + escapeHtml(message) + '
' + escapeHtml(usageText) + '