mocode-ai 1.5.8 → 1.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ // 视觉历史滑动窗口(Vision Window)
2
+ //
3
+ // 截图目前**永驻 history**,而每次请求都要重发整个 history → 图像 token **二次增长**
4
+ // (1568×882 ≈1843 tk/张;20 步 ≈387k、50 步 ≈2.35M),长 GUI 任务必然中途 compact,
5
+ // compact 把视觉历史摘要成文本 → 模型忘了自己点过哪,表现为重复点击 / 点错目标。
6
+ //
7
+ // 本模块在**存储层**按「固定张数(keep)+ 成批淘汰(batch)」把旧屏幕帧换成文本占位:
8
+ // - 图像 token 从二次增长压成线性;
9
+ // - 只有淘汰那一刻 history 才变字节 → 两次淘汰之间**前缀完全稳定** → prompt cache 可命中。
10
+ // (这也是必须「成批」而非「逐张老化」的唯一理由:逐张改前缀 = 缓存全废。)
11
+ //
12
+ // 契约(与 src/context/pipeline.ts optimizeToolResult 同款):纯函数、零 I/O、永不抛错。
13
+ // 被剪掉的帧**不可复原**(模型只能重新截图拿到"当前"屏幕),所以占位必须留下可读标注
14
+ // (帧名 + step),并在第一条占位里给出一次性恢复提示。
15
+ //
16
+ // 设计文档:design-notes/vision-window.md
17
+ import { contentTokens } from '../llm/index.js';
18
+ import { ATTACHMENT_PREAMBLE } from '../agent/tool-turn.js';
19
+ /** 附件前言的后缀;与 ATTACHMENT_PREAMBLE 一起框出"帧名清单"的位置。 */
20
+ const ATTACHMENT_TAIL = '. Analyze the attached image content directly.';
21
+ /** 查不到帧名时的兜底占位名。 */
22
+ const UNKNOWN_FRAME_NAME = 'frame';
23
+ /** 恢复提示只写进**第一条**被剪占位,避免 N 条占位重复同一句话侵蚀 token。 */
24
+ const RECOVERY_HINT = ' Call computer{screenshot} if you need the current screen.';
25
+ /**
26
+ * 白名单:参与滑动窗口的屏幕帧名前缀。
27
+ *
28
+ * 取值来自**实际产出点**(design-notes/vision-window.md §2.2 要求实施时读一次代码定稿,不凭猜):
29
+ * - ✅ `computer-`:`src/tools/builtins/computer.ts` 的 computer-result.png / computer-screenshot.png /
30
+ * computer-zoom.png —— 每次动作回灌的屏幕帧,会被反复收发,是二次增长的来源。
31
+ * - ❌ `browser`:`src/tools/builtins/browser.ts:235` 的附件名是 `${sessionId}.png`,**没有稳定前缀**,
32
+ * 按文档 §2.2 的约定移出白名单(收益小一档但不会误剪)。
33
+ * - ❌ `screenshot` / `view_image` / 用户粘贴图:读的是"文档图"或用户意图的直接载体,剪掉是净损失。
34
+ *
35
+ * 新增屏幕帧产出点必须沿用 `computer-` 前缀才会自动进窗口;若前缀不同,请在白名单里显式登记
36
+ * 并同步更新 design-notes/vision-window.md。
37
+ */
38
+ export const VISION_FRAME_NAME_PREFIXES = ['computer-'];
39
+ const telemetry = { prunes: 0, framesDropped: 0 };
40
+ function userContentParts(message) {
41
+ if (!message || typeof message !== 'object')
42
+ return null;
43
+ if (message.role !== 'user')
44
+ return null;
45
+ const content = message.content;
46
+ if (!Array.isArray(content))
47
+ return null;
48
+ const parts = [];
49
+ for (const part of content) {
50
+ if (part && typeof part === 'object')
51
+ parts.push(part);
52
+ }
53
+ return parts;
54
+ }
55
+ function imagePartCount(parts) {
56
+ let count = 0;
57
+ for (const part of parts)
58
+ if (part.type === 'image_url')
59
+ count++;
60
+ return count;
61
+ }
62
+ /** 从附件前言里解出帧名清单;不是前言返回 null。 */
63
+ function frameNamesFromText(text) {
64
+ if (!text.startsWith(ATTACHMENT_PREAMBLE))
65
+ return null;
66
+ let body = text.slice(ATTACHMENT_PREAMBLE.length);
67
+ if (body.endsWith(ATTACHMENT_TAIL))
68
+ body = body.slice(0, -ATTACHMENT_TAIL.length);
69
+ const names = body
70
+ .split(',')
71
+ .map((name) => name.trim())
72
+ .filter((name) => name.length > 0);
73
+ return names.length > 0 ? names : null;
74
+ }
75
+ function frameNames(message) {
76
+ const parts = userContentParts(message);
77
+ if (!parts)
78
+ return null;
79
+ for (const part of parts) {
80
+ if (typeof part.text === 'string') {
81
+ const names = frameNamesFromText(part.text);
82
+ if (names)
83
+ return names;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * 帧载体识别(§2.2 三条规则同时满足):
90
+ * 1. `role === 'user'` 且 content 是数组;
91
+ * 2. 数组里至少有 1 个 `image_url` part;
92
+ * 3. 前言以工具附件前言开头(从而排除用户粘贴图),且**所有**帧名命中白名单
93
+ * (混进 view_image / screenshot / 用户图时不整条淘汰 —— 那些剪掉是净损失)。
94
+ *
95
+ * 已剪过的消息不再含 image_url part → 规则 2 自然失效 → 幂等。
96
+ * **不靠自定义字段打标**:消息对象会被 OpenAI SDK 原样发出,严格兼容后端可能 400。
97
+ */
98
+ export function isFrameCarrier(message) {
99
+ try {
100
+ const parts = userContentParts(message);
101
+ if (!parts || imagePartCount(parts) === 0)
102
+ return false;
103
+ const names = frameNames(message);
104
+ if (!names)
105
+ return false;
106
+ return names.every((name) => VISION_FRAME_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)));
107
+ }
108
+ catch {
109
+ // 永不抛错契约:任何意外形状都不该让 agent 循环挂掉。
110
+ return false;
111
+ }
112
+ }
113
+ /** 统计 history 里的帧载体数量与在途图像 token(供 `/cu status`)。 */
114
+ export function visionWindowStats(messages) {
115
+ const stats = { frames: 0, images: 0, imageTokens: 0 };
116
+ if (!Array.isArray(messages))
117
+ return stats;
118
+ for (const message of messages) {
119
+ const parts = userContentParts(message);
120
+ if (!parts)
121
+ continue;
122
+ if (isFrameCarrier(message))
123
+ stats.frames++;
124
+ for (const part of parts) {
125
+ if (part.type !== 'image_url')
126
+ continue;
127
+ stats.images++;
128
+ // 复用 llm/index.ts 的口径(w*h/750),不重写面积公式,避免两套估算漂移。
129
+ stats.imageTokens += contentTokens([part]);
130
+ }
131
+ }
132
+ return stats;
133
+ }
134
+ /** 把一条帧载体消息的 content 换成纯文本占位(形状不变:仍是 role:'user' + ContentPart[])。 */
135
+ function pruneFrameMessage(message, names, step, withHint) {
136
+ const parts = userContentParts(message) ?? [];
137
+ const lines = [];
138
+ let index = 0;
139
+ for (const part of parts) {
140
+ if (part.type !== 'image_url')
141
+ continue;
142
+ lines.push(`[screenshot omitted: ${names[index] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
143
+ index++;
144
+ }
145
+ if (lines.length === 0)
146
+ lines.push(`[screenshot omitted: ${names[0] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
147
+ if (withHint)
148
+ lines[0] = `${lines[0]}${RECOVERY_HINT}`;
149
+ const content = [{ type: 'text', text: lines.join('\n') }];
150
+ return { role: 'user', content };
151
+ }
152
+ function noopResult(messages) {
153
+ // changed=false 时必须返回入参引用,调用方才能用 === 判定"零开销"。
154
+ return { changed: false, dropped: 0, messages: messages };
155
+ }
156
+ /**
157
+ * 视觉滑动窗口剪枝。**幂等**;未达阈值时返回入参引用且 `changed=false`。
158
+ *
159
+ * 算法(淘汰单元 = 附件消息,一期不拆单张图,天然不触碰 tool_call_id 配对):
160
+ * frames := 时间序(旧→新)的帧载体下标,n := |frames|
161
+ * k := floor((n - keep) / batch);k <= 0 → no-op
162
+ * drop := frames[0 .. k*batch)
163
+ *
164
+ * 由此:仅在 n 涨到 keep+batch 时一次性丢 batch 条,之后 n 落在 [keep, keep+batch-1]。
165
+ * 丢**固定批量**是关键 —— "超过 keep 就丢到剩 keep" 等于逐张老化,前缀每步都变。
166
+ */
167
+ export function applyVisionWindow(messages, opts) {
168
+ try {
169
+ if (!Array.isArray(messages))
170
+ return noopResult(messages);
171
+ const keep = Math.floor(opts?.keep ?? 0);
172
+ if (!Number.isFinite(keep) || keep <= 0)
173
+ return noopResult(messages); // keep=0 → 完全关闭
174
+ const requestedBatch = Math.floor(opts?.batch ?? 1);
175
+ const batch = Number.isFinite(requestedBatch) && requestedBatch >= 1 ? requestedBatch : 1;
176
+ const step = Number.isFinite(opts?.step) ? Math.floor(opts.step) : 0;
177
+ const frameIndexes = [];
178
+ for (let i = 0; i < messages.length; i++) {
179
+ if (isFrameCarrier(messages[i]))
180
+ frameIndexes.push(i);
181
+ }
182
+ const batches = Math.floor((frameIndexes.length - keep) / batch);
183
+ if (batches <= 0)
184
+ return noopResult(messages);
185
+ const dropCount = batches * batch;
186
+ const dropped = new Set(frameIndexes.slice(0, dropCount));
187
+ // 只对被淘汰的消息做浅拷贝,其余保持原引用。
188
+ const next = [];
189
+ let written = 0;
190
+ for (let i = 0; i < messages.length; i++) {
191
+ const message = messages[i];
192
+ if (!dropped.has(i)) {
193
+ next.push(message);
194
+ continue;
195
+ }
196
+ next.push(pruneFrameMessage(message, frameNames(message) ?? [], step, written === 0));
197
+ written++;
198
+ }
199
+ return { changed: true, dropped: dropCount, messages: next };
200
+ }
201
+ catch {
202
+ return noopResult(messages);
203
+ }
204
+ }
205
+ /** 累积一次剪枝结果(由 HistoryManager 调用)。 */
206
+ export function recordVisionWindowPrune(droppedFrames) {
207
+ if (!Number.isFinite(droppedFrames) || droppedFrames <= 0)
208
+ return;
209
+ telemetry.prunes++;
210
+ telemetry.framesDropped += Math.floor(droppedFrames);
211
+ }
212
+ /** 只读累积埋点快照。 */
213
+ export function getVisionWindowTelemetry() {
214
+ return { prunes: telemetry.prunes, framesDropped: telemetry.framesDropped };
215
+ }
216
+ /** 清空累积埋点(会话切换 / 测试隔离)。 */
217
+ export function resetVisionWindowTelemetry() {
218
+ telemetry.prunes = 0;
219
+ telemetry.framesDropped = 0;
220
+ }
@@ -43,6 +43,11 @@ const zhCN = {
43
43
  'commands.modelShow': '显示当前模型配置',
44
44
  'commands.modelUse': '按名称应用预设',
45
45
  'commands.modelDelete': '删除指定预设',
46
+ 'commands.router': '工具预路由后端(LLM / Jev)',
47
+ 'commands.routerStatus': '显示当前预路由后端与配置',
48
+ 'commands.routerLlm': '用主 LLM 做预路由(默认)',
49
+ 'commands.routerJev': '用 Jev systemone API 做预路由',
50
+ 'commands.routerTest': '探测 Jev 端点连通性',
46
51
  'commands.mode': '切换 Agent 工作模式',
47
52
  'commands.modePlan': '只读探查并产出计划',
48
53
  'commands.modeAuto': '自动执行(工具按任务动态路由)',
@@ -278,6 +283,8 @@ const zhCN = {
278
283
  'cu.changedOn': '已允许按需路由 computer-control;只有明确 GUI 操作意图才会选择,且每个动作仍需权限确认。',
279
284
  'cu.changedOff': '已禁止 computer-control;路由器和运行时都会拦截后续 computer 调用。',
280
285
  'cu.usage': '用法:/cu on|off|status',
286
+ 'cu.visionWindow': '视觉窗口 · {frames} 条帧消息 · history 内 {images} 张图 · 约 {tokens} 图像 token · keep {keep}/batch {batch} · 已淘汰 {dropped}',
287
+ 'cu.actionLedger': '动作台账 · 已记录 {count} 条 GUI 动作',
281
288
  'mcp.status': 'MCP 服务:{state}',
282
289
  'mcp.stateOn': '开启',
283
290
  'mcp.stateOff': '关闭',
@@ -327,6 +334,20 @@ const zhCN = {
327
334
  'upgrade.pkgName': '包名:mocode-ai',
328
335
  'upgrade.failed': '[upgrade] 失败:{message}',
329
336
  'upgrade.usage': '/upgrade 用法:\n /upgrade 升级到最新版本\n /upgrade now 同 /upgrade\n /upgrade check 检查当前版本与最新版本差异\n /upgrade status 显示当前本地版本',
337
+ 'router.status': '工具预路由后端:{mode}',
338
+ 'router.modeLlm': 'LLM(与主 Agent 同一后端,默认)',
339
+ 'router.modeJev': 'Jev(TypeSafe systemone API)',
340
+ 'router.jevConfig': '端点 {baseUrl} · 模型 {model} · key {key} · 阈值 {min}/{minMcp}',
341
+ 'router.source': '来源 {env}({path})',
342
+ 'router.usage': '/router 用法:\n /router 显示当前后端与配置\n /router llm 用主 LLM 做预路由(默认)\n /router jev 用 Jev systemone API 做预路由\n /router base <url> 设置 Jev 端点(本地兼容层可指向 localhost)\n /router key <key> 设置 Jev API key\n /router model <name> 设置 Jev 模型名(默认 jev-latest)\n /router min <0-1> 设置通用出簇阈值(默认 0.65)\n /router min-mcp <0-1> 设置 mcp 专用阈值(默认 0.85)\n /router test 探测 Jev 端点连通性',
343
+ 'router.changed': '已切换预路由后端 → {mode}(下一轮生效)',
344
+ 'router.set': '已设置 {field} = {value}(下一轮生效)',
345
+ 'router.invalid': '参数无效:{detail}',
346
+ 'router.testRunning': '正在探测 Jev 端点…',
347
+ 'router.testOk': 'Jev 端点可用:{latency}ms · 模型 {model}',
348
+ 'router.testFail': 'Jev 端点不可用:{error}',
349
+ 'router.saved': '已持久化到 {path}',
350
+ 'router.needsKey': '尚未配置 Jev API key;请先运行 /router key <key>。',
330
351
  };
331
352
  const en = {
332
353
  'common.none': '(none)',
@@ -373,6 +394,11 @@ const en = {
373
394
  'commands.modelShow': 'Show current model configuration',
374
395
  'commands.modelUse': 'Apply a preset by name',
375
396
  'commands.modelDelete': 'Delete a preset',
397
+ 'commands.router': 'Tool pre-routing backend (LLM / Jev)',
398
+ 'commands.routerStatus': 'Show current pre-routing backend and config',
399
+ 'commands.routerLlm': 'Route with the main LLM (default)',
400
+ 'commands.routerJev': 'Route with the Jev systemone API',
401
+ 'commands.routerTest': 'Probe Jev endpoint connectivity',
376
402
  'commands.mode': 'Switch Agent mode',
377
403
  'commands.modePlan': 'Read-only research and planning',
378
404
  'commands.modeAuto': 'Execute automatically with task-routed tools',
@@ -608,6 +634,8 @@ const en = {
608
634
  'cu.changedOn': 'Automatic routing may now select computer-control for explicit GUI intent; every action still requires permission approval.',
609
635
  'cu.changedOff': 'Computer-control is blocked; both the router and runtime reject subsequent computer calls.',
610
636
  'cu.usage': 'Usage: /cu on|off|status',
637
+ 'cu.visionWindow': 'vision window · {frames} frame msg(s) · {images} image(s) in history · ~{tokens} image tk · keep {keep}/batch {batch} · {dropped} dropped',
638
+ 'cu.actionLedger': 'action ledger · {count} gui actions logged',
611
639
  'mcp.status': 'MCP services: {state}',
612
640
  'mcp.stateOn': 'enabled',
613
641
  'mcp.stateOff': 'disabled',
@@ -657,6 +685,20 @@ const en = {
657
685
  'upgrade.pkgName': 'Package: mocode-ai',
658
686
  'upgrade.failed': '[upgrade] failed: {message}',
659
687
  'upgrade.usage': 'Usage: /upgrade\n /upgrade Upgrade to latest\n /upgrade now Same as /upgrade\n /upgrade check Check current vs latest\n /upgrade status Show current local version',
688
+ 'router.status': 'Tool pre-routing backend: {mode}',
689
+ 'router.modeLlm': 'LLM (same backend as the main agent; default)',
690
+ 'router.modeJev': 'Jev (TypeSafe systemone API)',
691
+ 'router.jevConfig': 'endpoint {baseUrl} · model {model} · key {key} · thresholds {min}/{minMcp}',
692
+ 'router.source': 'Source {env} ({path})',
693
+ 'router.usage': 'Usage: /router\n /router Show backend and config\n /router llm Route with the main LLM (default)\n /router jev Route with the Jev systemone API\n /router base <url> Set the Jev endpoint (point at localhost for a local compat server)\n /router key <key> Set the Jev API key\n /router model <name> Set the Jev model name (default jev-latest)\n /router min <0-1> Set the general activation threshold (default 0.65)\n /router min-mcp <0-1> Set the mcp-specific threshold (default 0.85)\n /router test Probe Jev endpoint connectivity',
694
+ 'router.changed': 'Pre-routing backend switched → {mode} (takes effect next turn)',
695
+ 'router.set': 'Set {field} = {value} (takes effect next turn)',
696
+ 'router.invalid': 'Invalid argument: {detail}',
697
+ 'router.testRunning': 'Probing Jev endpoint…',
698
+ 'router.testOk': 'Jev endpoint is reachable: {latency}ms · model {model}',
699
+ 'router.testFail': 'Jev endpoint unreachable: {error}',
700
+ 'router.saved': 'Persisted to {path}',
701
+ 'router.needsKey': 'Jev API key is not configured yet; run /router key <key> first.',
660
702
  };
661
703
  const resources = {
662
704
  'zh-CN': zhCN,
@@ -114,14 +114,46 @@ export function encodeAnthropicMessages(input, promptCache = config.anthropicPro
114
114
  ], cacheCandidates);
115
115
  }
116
116
  }
117
- if (promptCache) {
118
- // 最后一个稳定消息块形成会话前缀断点;动态 session reminder 明确不参与。
117
+ /** Anthropic 允许的最大 cache 断点数(硬上限,超出直接 400)。 */
118
+ const MAX_CACHE_BREAKPOINTS = 4;
119
+ /** tools 断点在 encodeAnthropicTools 里打;tool 列表通常恒非空,这里保守预留它那一份额度。 */
120
+ const TOOLS_BREAKPOINT_RESERVE = 1;
121
+ /** 文档建议「最近 3 个 tool_result」;实际额度受 MAX_CACHE_BREAKPOINTS 约束后通常是 2。 */
122
+ const MAX_TOOL_RESULT_BREAKPOINTS = 3;
123
+ /**
124
+ * 打 cache 断点。对齐 Anthropic computer use 官方实践:**system + tools + 最近若干 tool_result**。
125
+ *
126
+ * 以前只在"最后一个稳定消息块"打 1 个 —— 那通常是本轮刚回灌的 tool_result / 最新用户图,
127
+ * 每轮都不一样,既拍不到真正稳定的前缀,又浪费了额度。
128
+ *
129
+ * 前缀越长、命中率越高,所以断点优先给**靠后但仍稳定**的 tool_result(tool 结果一旦提交永不改)。
130
+ *
131
+ * 注意:断点打在**本函数新建的 block 对象**上,不会写回 history 的消息对象 →
132
+ * 天然"每轮清空",不存在跨轮累积超限的问题。
133
+ */
134
+ function markCacheBreakpoints(system, cacheCandidates) {
135
+ let used = TOOLS_BREAKPOINT_RESERVE;
136
+ if (system.length > 0) {
137
+ system[system.length - 1].cache_control = { type: 'ephemeral' };
138
+ used++;
139
+ }
140
+ const toolResults = cacheCandidates.filter((block) => block.type === 'tool_result');
141
+ const slots = Math.min(MAX_TOOL_RESULT_BREAKPOINTS, MAX_CACHE_BREAKPOINTS - used);
142
+ if (slots <= 0)
143
+ return;
144
+ if (toolResults.length > 0) {
145
+ for (const block of toolResults.slice(-slots)) {
146
+ block.cache_control = { type: 'ephemeral' };
147
+ }
148
+ return;
149
+ }
150
+ // 纯对话(没有 tool_result)时退化到旧行为:最后一个稳定消息块。
119
151
  const last = cacheCandidates[cacheCandidates.length - 1];
120
152
  if (last)
121
153
  last.cache_control = { type: 'ephemeral' };
122
- // 没有对话时仍缓存稳定 system prompt。
123
- else if (system.length > 0)
124
- system[system.length - 1].cache_control = { type: 'ephemeral' };
154
+ }
155
+ if (promptCache) {
156
+ markCacheBreakpoints(system, cacheCandidates);
125
157
  }
126
158
  return { system, messages };
127
159
  }
@@ -10,6 +10,7 @@ import { skillCommands } from './skill.js';
10
10
  import { compactCommands } from './compact.js';
11
11
  import { appearanceCommands } from './appearance.js';
12
12
  import { modelCommands } from './model.js';
13
+ import { routerCommands } from './router.js';
13
14
  import { toolGroupCommands } from './tool-group.js';
14
15
  /**
15
16
  * 命令注册表。顺序即匹配优先级 —— 更具体的分支放前面。
@@ -29,6 +30,7 @@ export const commandHandlers = [
29
30
  ...compactCommands,
30
31
  ...appearanceCommands,
31
32
  ...modelCommands,
33
+ ...routerCommands,
32
34
  ...toolGroupCommands,
33
35
  ];
34
36
  /**
@@ -0,0 +1,141 @@
1
+ /**
2
+ * /router —— 工具预路由后端切换与配置(llm | jev)。
3
+ *
4
+ * 为什么单独一个命令而不是并进 /model:
5
+ * /model 管的是「主 Agent 用哪个大模型」(OpenAI/Anthropic 兼容),而预路由后端是**另一个**
6
+ * 决策面——可以让主 Agent 用 A 模型、预路由用 Jev。二者生命周期独立,故独立命令。
7
+ *
8
+ * 即时性:所有读写都落在 process.env(见 config/index.ts 的 getRouterMode/getJevRouterConfig),
9
+ * 而 routeToolGroups 在**每个真实用户 turn** 的调用点才读它 → 改完下一轮生效,无需重启。
10
+ * 这也是为什么不走 Config 单例:const config 在模块初始化时求值,拿不到运行时切换。
11
+ *
12
+ * 持久化:~/.mocode/config(与 /model /theme 同一入口 writeConfigKeys)。
13
+ * 注意 shell 里 export 了同名 MOCODE_ROUTER_* 时,loadEnvFiles 不回填该键,下次启动仍以 shell 为准。
14
+ */
15
+ import * as layout from '../../ui/layout.js';
16
+ import { ui } from '../../ui/theme.js';
17
+ import { t } from '../../i18n/index.js';
18
+ import { getRouterMode, updateRouterMode, getJevRouterConfig, updateJevRouterConfig, isJevRouterConfigured, } from '../../config/index.js';
19
+ import { writeConfigKeys, CONFIG_PATH } from '../../config/file.js';
20
+ import { probeJev } from '../../tools/jev-client.js';
21
+ import { maskKey } from '../commands.js';
22
+ import { unhandled, next } from './types.js';
23
+ const ROUTER_ENV_KEYS = {
24
+ mode: 'MOCODE_ROUTER_MODE',
25
+ baseUrl: 'MOCODE_ROUTER_JEV_BASE_URL',
26
+ apiKey: 'MOCODE_ROUTER_JEV_API_KEY',
27
+ model: 'MOCODE_ROUTER_JEV_MODEL',
28
+ confidenceMin: 'MOCODE_ROUTER_CONFIDENCE_MIN',
29
+ confidenceMinMcp: 'MOCODE_ROUTER_CONFIDENCE_MIN_MCP',
30
+ };
31
+ export const routerCommands = [
32
+ async (ctx) => {
33
+ const { line } = ctx;
34
+ if (line !== '/router' && !line.startsWith('/router '))
35
+ return unhandled();
36
+ const rest = line.startsWith('/router ') ? line.slice('/router '.length).trim() : '';
37
+ const spaceIndex = rest.indexOf(' ');
38
+ const sub = (spaceIndex === -1 ? rest : rest.slice(0, spaceIndex)).toLowerCase();
39
+ const arg = spaceIndex === -1 ? '' : rest.slice(spaceIndex + 1).trim();
40
+ const showStatus = () => {
41
+ const mode = getRouterMode();
42
+ const label = mode === 'jev' ? t('router.modeJev') : t('router.modeLlm');
43
+ layout.contentWrite(`${ui.accent}${t('router.status', { mode: label })}${ui.reset}\n`);
44
+ const jev = getJevRouterConfig();
45
+ layout.contentWrite(`${ui.dim}${t('router.jevConfig', {
46
+ baseUrl: jev.baseUrl,
47
+ model: jev.model,
48
+ key: maskKey(jev.apiKey),
49
+ min: String(jev.confidenceMin),
50
+ minMcp: String(jev.confidenceMinMcp),
51
+ })}${ui.reset}\n`);
52
+ layout.contentWrite(`${ui.dim}${t('router.source', { env: ROUTER_ENV_KEYS.mode, path: CONFIG_PATH })}${ui.reset}\n`);
53
+ };
54
+ // /router 或 /router status
55
+ if (rest === '' || sub === 'status') {
56
+ showStatus();
57
+ return next();
58
+ }
59
+ // /router llm | /router jev —— 切换后端
60
+ if (sub === 'llm' || sub === 'jev') {
61
+ const mode = sub === 'jev' ? 'jev' : 'llm';
62
+ updateRouterMode(mode);
63
+ writeConfigKeys({ [ROUTER_ENV_KEYS.mode]: mode });
64
+ const label = mode === 'jev' ? t('router.modeJev') : t('router.modeLlm');
65
+ layout.contentWrite(`${ui.green}${t('router.changed', { mode: label })}${ui.reset}\n`);
66
+ // 切到 jev 但没配 key 是最常见的无用配置,当场提示,避免用户以为已生效。
67
+ if (mode === 'jev' && !isJevRouterConfigured()) {
68
+ layout.contentWrite(`${ui.yellow}${t('router.needsKey')}${ui.reset}\n`);
69
+ }
70
+ return next();
71
+ }
72
+ // /router base <url>
73
+ if (sub === 'base') {
74
+ if (!arg) {
75
+ layout.contentWrite(`${ui.yellow}${t('router.usage')}${ui.reset}\n`);
76
+ return next();
77
+ }
78
+ if (!/^https?:\/\//i.test(arg)) {
79
+ layout.contentWrite(`${ui.yellow}${t('router.invalid', { detail: arg })}${ui.reset}\n`);
80
+ return next();
81
+ }
82
+ const baseUrl = arg.replace(/\/+$/, '');
83
+ updateJevRouterConfig({ baseUrl });
84
+ writeConfigKeys({ [ROUTER_ENV_KEYS.baseUrl]: baseUrl });
85
+ layout.contentWrite(`${ui.green}${t('router.set', { field: 'baseUrl', value: baseUrl })}${ui.reset}\n`);
86
+ return next();
87
+ }
88
+ // /router key <key> —— 明文 key 写入 ~/.mocode/config,与 /model 对 LLM_API_KEY 的做法一致;显示只露末 4 位。
89
+ if (sub === 'key') {
90
+ if (!arg) {
91
+ layout.contentWrite(`${ui.yellow}${t('router.usage')}${ui.reset}\n`);
92
+ return next();
93
+ }
94
+ updateJevRouterConfig({ apiKey: arg });
95
+ writeConfigKeys({ [ROUTER_ENV_KEYS.apiKey]: arg });
96
+ layout.contentWrite(`${ui.green}${t('router.set', { field: 'apiKey', value: maskKey(arg) })}${ui.reset}\n`);
97
+ layout.contentWrite(`${ui.dim}${t('router.saved', { path: CONFIG_PATH })}${ui.reset}\n`);
98
+ return next();
99
+ }
100
+ // /router model <name>
101
+ if (sub === 'model') {
102
+ if (!arg) {
103
+ layout.contentWrite(`${ui.yellow}${t('router.usage')}${ui.reset}\n`);
104
+ return next();
105
+ }
106
+ updateJevRouterConfig({ model: arg });
107
+ writeConfigKeys({ [ROUTER_ENV_KEYS.model]: arg });
108
+ layout.contentWrite(`${ui.green}${t('router.set', { field: 'model', value: arg })}${ui.reset}\n`);
109
+ return next();
110
+ }
111
+ // /router min <0-1> 与 /router min-mcp <0-1>
112
+ if (sub === 'min' || sub === 'min-mcp') {
113
+ const value = Number(arg);
114
+ if (!arg || !Number.isFinite(value) || value < 0 || value > 1) {
115
+ layout.contentWrite(`${ui.yellow}${t('router.invalid', { detail: arg || '(空)' })}${ui.reset}\n`);
116
+ return next();
117
+ }
118
+ const field = sub === 'min' ? 'confidenceMin' : 'confidenceMinMcp';
119
+ const envKey = sub === 'min' ? ROUTER_ENV_KEYS.confidenceMin : ROUTER_ENV_KEYS.confidenceMinMcp;
120
+ updateJevRouterConfig({ [field]: value });
121
+ writeConfigKeys({ [envKey]: String(value) });
122
+ layout.contentWrite(`${ui.green}${t('router.set', { field: sub, value: String(value) })}${ui.reset}\n`);
123
+ return next();
124
+ }
125
+ // /router test —— 探测 Jev 端点连通性(不等下一轮,当场给结果)
126
+ if (sub === 'test') {
127
+ const jev = getJevRouterConfig();
128
+ layout.contentWrite(`${ui.dim}${t('router.testRunning')}${ui.reset}\n`);
129
+ const result = await probeJev({ baseUrl: jev.baseUrl, apiKey: jev.apiKey, model: jev.model });
130
+ if (result.ok) {
131
+ layout.contentWrite(`${ui.green}${t('router.testOk', { latency: String(result.latencyMs), model: result.model ?? jev.model })}${ui.reset}\n`);
132
+ }
133
+ else {
134
+ layout.contentWrite(`${ui.red}${t('router.testFail', { error: result.error ?? 'unknown' })}${ui.reset}\n`);
135
+ }
136
+ return next();
137
+ }
138
+ layout.contentWrite(`${ui.yellow}${t('router.usage')}${ui.reset}\n`);
139
+ return next();
140
+ },
141
+ ];
@@ -10,7 +10,7 @@ import * as layout from '../../ui/layout.js';
10
10
  import { ui } from '../../ui/theme.js';
11
11
  import { bannerLines } from '../../ui/render.js';
12
12
  import { t } from '../../i18n/index.js';
13
- import { isSubAgentRouteAllowed, updateSubAgentConfig, isFrontendRouteAllowed, updateFrontendToolsConfig, isComputerUseRouteAllowed, updateComputerUseConfig, isMcpEnabled, updateMcpConfig, isMemoryEnabled, isMemoryRouteAllowed, updateMemoryConfig, } from '../../config/index.js';
13
+ import { isSubAgentRouteAllowed, updateSubAgentConfig, isFrontendRouteAllowed, updateFrontendToolsConfig, isComputerUseRouteAllowed, updateComputerUseConfig, isMcpEnabled, updateMcpConfig, isMemoryEnabled, isMemoryRouteAllowed, updateMemoryConfig, visionKeep, visionBatch, } from '../../config/index.js';
14
14
  import { updateConfigKey, CONFIG_PATH } from '../../config/file.js';
15
15
  import { getAgentMode } from '../../agent/mode.js';
16
16
  import { refreshChatTools } from '../../llm/index.js';
@@ -117,6 +117,38 @@ export const toolGroupCommands = [
117
117
  catch {
118
118
  // 埋点不可用时不影响状态输出。
119
119
  }
120
+ // 视觉窗口:本地延时是线性项,图像二次增长才是长 GUI 任务的爆点,必须与延时分开暴露。
121
+ // 看点是「在途帧数停在 [keep, keep+batch-1] 不随步数涨」+「dropped 在涨(证明窗口真的在剪)」。
122
+ try {
123
+ const { visionWindowStats, getVisionWindowTelemetry } = await import('../../context/vision-window.js');
124
+ const vw = visionWindowStats(ctx.history);
125
+ if (vw.images > 0) {
126
+ const tel = getVisionWindowTelemetry();
127
+ layout.contentWrite(`${ui.dim}${t('cu.visionWindow', {
128
+ frames: vw.frames,
129
+ images: vw.images,
130
+ tokens: vw.imageTokens,
131
+ keep: visionKeep(),
132
+ batch: visionBatch(),
133
+ dropped: tel.framesDropped,
134
+ })}${ui.reset}\n`);
135
+ }
136
+ }
137
+ catch {
138
+ // 同上:面板指标不可用不影响状态输出。
139
+ }
140
+ // GUI 动作台账(L2):与窗口指标并列。台账是时间轴(做过什么),窗口是空间轴(最新几帧),
141
+ // 二者都不显示时说明本会话还没跑过 computer。
142
+ try {
143
+ const { countGuiActions } = await import('../../session/gui-actions.js');
144
+ const ledgerCount = countGuiActions();
145
+ if (ledgerCount > 0) {
146
+ layout.contentWrite(`${ui.dim}${t('cu.actionLedger', { count: ledgerCount })}${ui.reset}\n`);
147
+ }
148
+ }
149
+ catch {
150
+ // 台账不可读不影响状态输出。
151
+ }
120
152
  return next();
121
153
  }
122
154
  if (arg !== 'on' && arg !== 'off') {
@@ -81,6 +81,16 @@ export function buildSlashCommands() {
81
81
  { name: 'delete <name>', value: '/model delete ', submit: false, desc: d('commands.modelDelete') },
82
82
  ],
83
83
  },
84
+ {
85
+ name: '/router',
86
+ desc: d('commands.router'),
87
+ children: [
88
+ { name: 'status', value: '/router', desc: d('commands.routerStatus') },
89
+ { name: 'llm', value: '/router llm', desc: d('commands.routerLlm') },
90
+ { name: 'jev', value: '/router jev', desc: d('commands.routerJev') },
91
+ { name: 'test', value: '/router test', desc: d('commands.routerTest') },
92
+ ],
93
+ },
84
94
  {
85
95
  name: '/mode',
86
96
  desc: d('commands.mode'),
@@ -156,7 +166,7 @@ export const HELP_GROUPS = [
156
166
  { key: 'help.groupSession', names: ['/sessions', '/rollback', '/memory', '/skills', '/skill', '/init'] },
157
167
  {
158
168
  key: 'help.groupConfig',
159
- names: ['/mode', '/subagent', '/fe', '/mcp', '/theme', '/pet', '/image', '/language', '/upgrade'],
169
+ names: ['/mode', '/subagent', '/fe', '/mcp', '/router', '/theme', '/pet', '/image', '/language', '/upgrade'],
160
170
  },
161
171
  ];
162
172
  /**
@@ -9,7 +9,7 @@ import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
9
9
  import { sendState } from '../pet/bridge.js';
10
10
  import { setSandboxRoot } from '../sandbox/root.js';
11
11
  import { ui, setTheme } from '../ui/theme.js';
12
- import { bannerLines, displayWidth } from '../ui/render.js';
12
+ import { bannerLines } from '../ui/render.js';
13
13
  import * as layout from '../ui/layout.js';
14
14
  import * as mouse from '../ui/mouse.js';
15
15
  import { promptWithSlashMenu, promptTurnPicker, promptRevertChoice } from '../ui/prompt.js';
@@ -226,27 +226,26 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
226
226
  * 首次提交任何输入(消息或斜杠命令)前由 layout.dismissWelcomeBlock 整块撤掉——
227
227
  * 「一打开就能看见,开始干活就消失」。/clear 清空后重新写一次(回到空会话状态)。
228
228
  */
229
- // 欢迎块整块按终端宽度居中:先按纯文本(displayWidth 不剥 ANSI)算左缩进,再套色;
230
- // 余数列归左,与输入框提示行的居中策略一致;超宽行由 contentWrite 兜底折行。
229
+ // 这里只产出**逻辑行**(带色、不带缩进);整块按终端宽度居中由 layout.writeWelcomeBlock
230
+ // 按写入当时的列宽现算,并把逻辑行源记入 layout state——resize 后按新列宽重新居中/折行,
231
+ // 否则块停留在启动宽度,缩窄后被截断成「…」(「提示不随窗口变化」bug)。
231
232
  const welcomeLines = () => {
232
- const cols = layout.getGeo().cols;
233
- const pad = (plain) => ' '.repeat(Math.max(0, Math.floor((cols - displayWidth(plain)) / 2)));
234
233
  const ideNotice = `⚠ ${t('welcome.ideNotice')}`;
235
234
  return [
236
235
  '',
237
- `${pad(ideNotice)}${ui.yellow}${ui.bold}${ideNotice}${ui.reset}`,
236
+ `${ui.yellow}${ui.bold}${ideNotice}${ui.reset}`,
238
237
  '',
239
- `${pad(t('welcome.gettingStarted'))}${ui.accent}${ui.bold}${t('welcome.gettingStarted')}${ui.reset}`,
240
- `${pad(`· ${t('welcome.start1')}`)}${ui.dim}· ${t('welcome.start1')}${ui.reset}`,
241
- `${pad(`· ${t('welcome.start2')}`)}${ui.dim}· ${t('welcome.start2')}${ui.reset}`,
242
- `${pad(`· ${t('welcome.start3')}`)}${ui.dim}· ${t('welcome.start3')}${ui.reset}`,
238
+ `${ui.accent}${ui.bold}${t('welcome.gettingStarted')}${ui.reset}`,
239
+ `${ui.dim}· ${t('welcome.start1')}${ui.reset}`,
240
+ `${ui.dim}· ${t('welcome.start2')}${ui.reset}`,
241
+ `${ui.dim}· ${t('welcome.start3')}${ui.reset}`,
243
242
  '',
244
- `${pad(t('welcome.capabilities'))}${ui.accent}${ui.bold}${t('welcome.capabilities')}${ui.reset}`,
245
- `${pad(`· ${t('welcome.cap1')}`)}${ui.dim}· ${t('welcome.cap1')}${ui.reset}`,
246
- `${pad(`· ${t('welcome.cap2')}`)}${ui.dim}· ${t('welcome.cap2')}${ui.reset}`,
247
- `${pad(`· ${t('welcome.cap3')}`)}${ui.dim}· ${t('welcome.cap3')}${ui.reset}`,
243
+ `${ui.accent}${ui.bold}${t('welcome.capabilities')}${ui.reset}`,
244
+ `${ui.dim}· ${t('welcome.cap1')}${ui.reset}`,
245
+ `${ui.dim}· ${t('welcome.cap2')}${ui.reset}`,
246
+ `${ui.dim}· ${t('welcome.cap3')}${ui.reset}`,
248
247
  '',
249
- `${pad(t('welcome.try'))}${ui.dim}${t('welcome.try')}${ui.reset}`,
248
+ `${ui.dim}${t('welcome.try')}${ui.reset}`,
250
249
  '',
251
250
  ];
252
251
  };