mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-15 07:30:53 +02:00
Add files via upload
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "单条用户原文字符上限",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'\)/);
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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', '错误');
|
||||
|
||||
@@ -3847,9 +3847,29 @@
|
||||
<summary data-i18n="settingsBasic.agentAdvanced">高级设置</summary>
|
||||
<div class="settings-form" style="margin-top: 0.75rem;">
|
||||
<div class="form-group">
|
||||
<label for="summarization-user-ledger-max-runes" data-i18n="settingsBasic.userLedgerMaxRunes">用户原文账本总字符上限</label>
|
||||
<input type="number" id="summarization-user-ledger-max-runes" min="0" step="1000" placeholder="96000" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.userLedgerMaxRunesHint">压缩后注入模型上下文的原始用户输入账本总上限;0 使用后端默认值。DB 原始消息不裁剪。</small>
|
||||
<label for="eino-model-retry-max-retries" data-i18n="settingsBasic.einoModelRetryMaxRetries">Eino 原生模型最大重试次数</label>
|
||||
<input type="number" id="eino-model-retry-max-retries" min="0" step="1" placeholder="0" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.einoModelRetryMaxRetriesHint">ChatModelAgent 原生 ModelRetry 次数;0 使用后端默认 4。用于 429/5xx/网络抖动和空模型输出。</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eino-model-retry-max-backoff-sec" data-i18n="settingsBasic.einoModelRetryMaxBackoffSec">Eino 原生模型最大退避秒数</label>
|
||||
<input type="number" id="eino-model-retry-max-backoff-sec" min="0" step="1" placeholder="0" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.einoModelRetryMaxBackoffSecHint">模型原生重试的指数退避上限;0 使用后端默认 30 秒。</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eino-model-failover-channels" data-i18n="settingsBasic.einoModelFailoverChannels">Eino 模型 Failover 备用通道</label>
|
||||
<textarea id="eino-model-failover-channels" rows="3" placeholder="backup-openai backup-claude" data-i18n="settingsBasic.einoModelFailoverChannelsPlaceholder" data-i18n-attr="placeholder"></textarea>
|
||||
<small class="form-hint" data-i18n="settingsBasic.einoModelFailoverChannelsHint">主通道原生重试耗尽后按顺序切换 ai.channels 中的通道 ID;每行一个,也支持逗号分隔。</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eino-model-failover-max-retries" data-i18n="settingsBasic.einoModelFailoverMaxRetries">Eino 模型 Failover 最大切换次数</label>
|
||||
<input type="number" id="eino-model-failover-max-retries" min="0" step="1" placeholder="0" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.einoModelFailoverMaxRetriesHint">0 表示尝试全部已配置备用通道;大于 0 时限制最多切换几个通道。</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="summarization-user-ledger-max-runes" data-i18n="settingsBasic.userLedgerMaxRunes">用户原文账本总字符上限</label>
|
||||
<input type="number" id="summarization-user-ledger-max-runes" min="0" step="1000" placeholder="96000" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.userLedgerMaxRunesHint">压缩后注入模型上下文的原始用户输入账本总上限;0 使用后端默认值。DB 原始消息不裁剪。</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="summarization-user-ledger-entry-max-runes" data-i18n="settingsBasic.userLedgerEntryMaxRunes">单条用户原文字符上限</label>
|
||||
@@ -6820,10 +6840,10 @@
|
||||
<script src="/static/js/assets.js?v=20260717-7"></script>
|
||||
<script src="/static/js/agents.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/chat-scroll.js?v=20260813-6"></script>
|
||||
<script src="/static/js/monitor.js?v=20260813-9"></script>
|
||||
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
|
||||
<script src="/static/js/monitor.js?v=20260815-1"></script>
|
||||
<script src="/static/js/chat.js?v=20260813-4"></script>
|
||||
<script src="/static/js/chat-plan-progress.js?v=20260813-3"></script>
|
||||
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
|
||||
<script src="/static/js/hitl.js?v=20260811-4"></script>
|
||||
<script src="/static/js/settings.js?v=20260717-1"></script>
|
||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user