mingdao-harness 0.6.18 → 0.6.20
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.
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONFIG.md +21 -0
- package/docs/DEVELOPER.md +12 -0
- package/package.json +1 -1
- package/src/web/i18n.js +349 -0
- package/src/web/server.js +25 -0
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -131,7 +131,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
131
131
|
- `web/constants.js` —— 前端与服务端共享常量:并发上限 8(`MAX_CONCURRENT`)、附件数与体积上限、单文件读取上限 —— 数值只在此定义,防客户端预检/服务端校验/工具读取三处漂移。
|
|
132
132
|
- `web/attachments.js` —— 聊天附件构造(纯函数):图片转 `data:` URL(仅视觉模型,单张 ≤5MB、最多 4 个),文本文件拼接进消息(≤200KB)—— 服务端按同一组常量校验。
|
|
133
133
|
- `web/web-io.js` —— WebUI 的 io 适配器:把 Agent 的 io 事件翻译为 SSE 事件,权限确认/选择类交互经 `askHandler` 转发到浏览器模态框并等待 `POST /api/permission` 应答。
|
|
134
|
-
- `web/util.js` / `web/app.js` —— 前端:`util.js` 是零状态工具(选择器/转义/Markdown/格式化),`app.js`
|
|
134
|
+
- `web/util.js` / `web/app.js` / `web/i18n.js` —— 前端:`util.js` 是零状态工具(选择器/转义/Markdown/格式化),`app.js` 是页面主脚本(消息渲染与轨迹、设置面板、自绘气泡与模态框),`i18n.js` 是零依赖 UI 文案表(zh/en 同键、默认跟随 `navigator.language`、`localStorage` 覆盖)—— 地址带 `?token=` 时记入 `sessionStorage` 并从地址栏移除,此后请求统一附加 `X-MingDao-Token`。
|
|
135
135
|
|
|
136
136
|
### 2.8 CLI 层(13)
|
|
137
137
|
|
package/docs/CONFIG.md
CHANGED
|
@@ -29,6 +29,27 @@
|
|
|
29
29
|
`stream_options` 时设 `false`)、`autoTitle`(自动生成会话标题,默认开)、`notify`(任务桌面通知,默认开)、
|
|
30
30
|
`autoCompact`(上下文自动压缩,默认开,见下)、`audit`(工具调用审计,默认开,见下)。
|
|
31
31
|
|
|
32
|
+
### WebUI 设置项的「默认值」列(只读,不落盘)
|
|
33
|
+
|
|
34
|
+
设置面板每个可改项显示 **默认 / 当前 / 影响** 三列。默认值来自 `src/config.js` 的内置默认表,
|
|
35
|
+
`GET /api/state` 以**只读字段 `defaults`** 透出(不接受写入,也不参与任何判定):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
"defaults": {
|
|
39
|
+
"permission": "ask", "sandbox": "off", "routing": false,
|
|
40
|
+
"contextBudget": 128000, "timeout": { "firstTokenMs": null, "streamIdleMs": null, "totalMs": null },
|
|
41
|
+
"autostart": false, "notify": true, "reasoningEffort": "high",
|
|
42
|
+
"theme": "system", "language": "auto"
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- `timeout.*` 为 `null` 表示**按模型自适应**(与「留空」同义)。
|
|
47
|
+
- `theme` / `language` 是浏览器侧偏好:`theme` 落 `mingdao-theme`、`language` 落 `mingdao-lang`;
|
|
48
|
+
`language: "auto"` 表示跟随 `navigator.language`。
|
|
49
|
+
- 「影响」一句话写在前端映射表(`src/web/app.js` 的 `SETTING_IMPACT`,英文文案在
|
|
50
|
+
`src/web/i18n.js` 的 `impact.*`),说明「改了会怎样」,例如改小 `contextBudget` 会减少可容纳历史、
|
|
51
|
+
长任务更易触发压缩。
|
|
52
|
+
|
|
32
53
|
## 工具调用审计(P3-5)
|
|
33
54
|
|
|
34
55
|
每个工具调用(含被拒/被钩子阻止/参数解析失败)自动落 `~/.mingdao/audit.jsonl`(600 权限):
|
package/docs/DEVELOPER.md
CHANGED
|
@@ -215,3 +215,15 @@ console.log(res.text, res.usage, res.perf);
|
|
|
215
215
|
- 预设/工具的扩展点沿用既有安全链路(权限引擎、审计、脱敏、沙箱),**不提供绕过入口**。
|
|
216
216
|
- 公共 API 变更必须过测试门禁(smoke 含「公共 API 导出面」断言)+ 发布前自检。
|
|
217
217
|
- 自定义 Provider 模块(非 OpenAI 兼容协议)见 [PROVIDERS.md](PROVIDERS.md)。
|
|
218
|
+
|
|
219
|
+
## 六、UI 改动必须先用 `node scripts/ui-shot.mjs` 截图自查
|
|
220
|
+
|
|
221
|
+
> 背景:v0.6.19 前端约 2,561 行改动只有字符串/桩 DOM 断言、没有渲染真实页面,真机出现界面凌乱/按钮失灵(§3.79/§3.80)。改 `src/web/**` 的人必须先留下真实 Electron 截图。
|
|
222
|
+
|
|
223
|
+
1. 跑一次默认截图:起真实 `src/cli.js web`、用 Electron 打开带 token 的 URL,产出 `home.png` / `settings.png` / `palette.png` / `session.png`;临时 `MINGDAO_HOME` 在退出时自动删除。
|
|
224
|
+
2. 至少覆盖三档宽度与暗/亮两主题:`--viewport wide|mid|narrow`(1440/1000/880)+ `--theme dark|light`;窄屏必须单独跑一次 `--viewport narrow`。
|
|
225
|
+
3. 逐张打开 `/tmp/mdh-shots/*.png` 检查布局、遮挡、白底白字与按钮可见性;截图不对就不要提交 UI 改动(工具对服务/Electron/页面报错/截图失败分别以非 0 退出)。
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
node scripts/ui-shot.mjs --viewport narrow --theme light --out /tmp/mdh-shots-narrow-light
|
|
229
|
+
```
|
package/package.json
CHANGED
package/src/web/i18n.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// WebUI 文案表(P2-4 / v0.6.19 §3.76)。
|
|
2
|
+
//
|
|
3
|
+
// 只收 **UI 文案**:按钮、标题、设置项、确认框、空态与列头。
|
|
4
|
+
// 不收日志、错误原文、模型返回内容——那些必须原样透出,翻译会破坏排障证据。
|
|
5
|
+
//
|
|
6
|
+
// 约束(与 index.html 的 CSP / 零依赖一致):
|
|
7
|
+
// · 零依赖、无构建链、ES Module;
|
|
8
|
+
// · 不在模块顶层碰 document / navigator / localStorage(Node 测试可直接 import);
|
|
9
|
+
// · 默认语言跟随 navigator.language,localStorage 的 mingdao-lang 可覆盖;
|
|
10
|
+
// · 缺失键回退 zh;zh 也没有时返回空串(**绝不把 undefined 渲染到界面**)。
|
|
11
|
+
|
|
12
|
+
export const LANG_STORAGE_KEY = 'mingdao-lang';
|
|
13
|
+
export const SUPPORTED_LANGS = ['zh', 'en'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* [key, zh, en]。单表构建保证 zh/en 键集合同源(test/ui-p2.js ④ 逐键比对)。
|
|
17
|
+
* @type {[string, string, string][]}
|
|
18
|
+
*/
|
|
19
|
+
const TABLE = [
|
|
20
|
+
// —— 顶栏 ——
|
|
21
|
+
['header.paletteLabel', '搜索或运行', 'Search or run'],
|
|
22
|
+
['header.settingsTitle', '设置', 'Settings'],
|
|
23
|
+
['header.run', '运行', 'Run'],
|
|
24
|
+
['header.theme', '切换明暗主题', 'Toggle theme'],
|
|
25
|
+
['header.wsTitle', '工作空间(切换 / 新建,目录自动创建)', 'Workspace (switch / create; folder is auto-created)'],
|
|
26
|
+
['header.searchPlaceholder', '搜索会话…', 'Search sessions…'],
|
|
27
|
+
['header.searchTitle', '关键词检索历史会话', 'Search session history by keyword'],
|
|
28
|
+
['header.sessions', '历史会话', 'Sessions'],
|
|
29
|
+
['header.tasks', '任务面板', 'Tasks'],
|
|
30
|
+
['header.sessMgrTitle', '管理会话(重命名/删除)', 'Manage sessions (rename / delete)'],
|
|
31
|
+
['header.newChat', '新对话', 'New chat'],
|
|
32
|
+
['header.costTitle', '省钱仪表盘(点击展开)', 'Cost dashboard (click to open)'],
|
|
33
|
+
['header.collapseRun', '收起右栏', 'Collapse panel'],
|
|
34
|
+
|
|
35
|
+
// —— 输入区 / 底栏 ——
|
|
36
|
+
['footer.attachTitle', '上传图片或文本文件(图片需支持视觉的模型)', 'Attach an image or text file (images need a vision-capable model)'],
|
|
37
|
+
['footer.inputPlaceholder', '输入任务…(Enter 发送,Shift+Enter 换行)', 'Describe a task… (Enter to send, Shift+Enter for a new line)'],
|
|
38
|
+
['footer.sessSettingsTitle', '会话设置:模型 / 预设 / 权限(含生效值与来源)', 'Session settings: model / preset / permission (effective value and source)'],
|
|
39
|
+
['footer.send', '发送', 'Send'],
|
|
40
|
+
['footer.stop', '■ 停止', '■ Stop'],
|
|
41
|
+
['footer.journal', '📌 带上文(最近会话日志,默认关=新会话全新开始)', '📌 Carry context (recent session log; off by default = fresh session)'],
|
|
42
|
+
|
|
43
|
+
// —— 设置 ——
|
|
44
|
+
['settings.title', '⚙ 设置', '⚙ Settings'],
|
|
45
|
+
['settings.searchPlaceholder', '搜索设置项(标题 / 关键字,如 沙箱、超时、Key)…', 'Search settings (title / keyword, e.g. sandbox, timeout, key)…'],
|
|
46
|
+
['settings.cancel', '取消', 'Cancel'],
|
|
47
|
+
['settings.save', '保存', 'Save'],
|
|
48
|
+
['settings.close', '关闭', 'Close'],
|
|
49
|
+
['settings.colDefault', '默认', 'Default'],
|
|
50
|
+
['settings.colCurrent', '当前', 'Current'],
|
|
51
|
+
['settings.colImpact', '影响', 'Impact'],
|
|
52
|
+
['settings.on', '开', 'On'],
|
|
53
|
+
['settings.off', '关', 'Off'],
|
|
54
|
+
['settings.auto', '自适应(留空)', 'Auto (blank)'],
|
|
55
|
+
['settings.followSystem', '跟随系统', 'Follow system'],
|
|
56
|
+
['settings.language', '界面语言', 'Interface language'],
|
|
57
|
+
['settings.languageHint', '默认跟随系统语言(navigator.language);选择后写入 localStorage(mingdao-lang)。只影响界面文案,不改日志与错误原文。', 'Defaults to the system language (navigator.language); your choice is stored in localStorage (mingdao-lang). UI copy only — logs and raw errors are never translated.'],
|
|
58
|
+
['settings.langAuto', '跟随系统', 'Follow system'],
|
|
59
|
+
['settings.langZh', '中文', '中文'],
|
|
60
|
+
['settings.langEn', 'English', 'English'],
|
|
61
|
+
['settings.reset', '恢复默认配置', 'Reset to defaults'],
|
|
62
|
+
['settings.resetHint', '把权限、沙箱、路由、上下文预算、超时、自启、通知恢复到内置默认值;不改模型与 API Key。', 'Restore permission, sandbox, routing, context budget, timeouts, autostart and notifications to built-in defaults; model and API keys are untouched.'],
|
|
63
|
+
['settings.resetDone', '✓ 已恢复默认配置', '✓ Defaults restored'],
|
|
64
|
+
['settings.resetFail', '✖ 恢复失败:', '✖ Reset failed: '],
|
|
65
|
+
['settings.memClear', '清空记忆', 'Clear memory'],
|
|
66
|
+
['settings.memClearHint', '清空长期记忆文件;不影响会话记录与 config.json,清空后不可恢复。', 'Clear the long-term memory file; session history and config.json are unaffected. This cannot be undone.'],
|
|
67
|
+
['settings.memCleared', '✓ 记忆已清空', '✓ Memory cleared'],
|
|
68
|
+
['settings.memClearFail', '✖ 清空失败:', '✖ Clear failed: '],
|
|
69
|
+
['settings.defaultSource', '默认值来自 config.js 内置默认表(/api/state.defaults 只读透出)', 'Defaults come from the built-in table in config.js (read-only via /api/state.defaults)'],
|
|
70
|
+
|
|
71
|
+
// —— 设置分组 ——
|
|
72
|
+
['nav.models', '🔌 模型与端点', '🔌 Models & endpoints'],
|
|
73
|
+
['nav.runtime', '⏱️ 运行与超时', '⏱️ Runtime & timeouts'],
|
|
74
|
+
['nav.permissions', '🛡️ 权限与沙箱', '🛡️ Permission & sandbox'],
|
|
75
|
+
['nav.appearance', '🎨 外观', '🎨 Appearance'],
|
|
76
|
+
['nav.workspace', '📁 工作空间', '📁 Workspaces'],
|
|
77
|
+
['nav.schedule', '⏰ 调度队列', '⏰ Schedule'],
|
|
78
|
+
['nav.sync', '☁️ 云同步与协作', '☁️ Sync & sharing'],
|
|
79
|
+
['nav.memory', '🧠 长期记忆', '🧠 Long-term memory'],
|
|
80
|
+
['nav.mcp', '🔌 MCP 生态', '🔌 MCP'],
|
|
81
|
+
['nav.skills', '🧩 技能库', '🧩 Skills'],
|
|
82
|
+
|
|
83
|
+
// —— 会话设置 ——
|
|
84
|
+
['sess.title', '⚙ 会话设置', '⚙ Session settings'],
|
|
85
|
+
['sess.sub', '模型 / 预设 / 权限', 'Model / preset / permission'],
|
|
86
|
+
['sess.model', '模型', 'Model'],
|
|
87
|
+
['sess.preset', '预设', 'Preset'],
|
|
88
|
+
['sess.permission', '权限', 'Permission'],
|
|
89
|
+
['sess.reasoning', '推理等级', 'Reasoning effort'],
|
|
90
|
+
['sess.close', '关闭', 'Close'],
|
|
91
|
+
['sess.done', '完成', 'Done'],
|
|
92
|
+
['sess.scopeModel', '全局 · 下一轮生效', 'Global · next turn'],
|
|
93
|
+
['sess.scopePreset', '仅下一轮', 'Next turn only'],
|
|
94
|
+
['sess.scopePerm', '全局 + 本轮', 'Global + this turn'],
|
|
95
|
+
['sess.scopeGlobal', '全局', 'Global'],
|
|
96
|
+
['sess.modelSource', '来源:设置 › 模型与密钥 · 写入 config.json,下一轮生效', 'Source: Settings › Models & keys · written to config.json, applied next turn'],
|
|
97
|
+
['sess.presetSource', '来源:你的选择 · 只影响下一轮(工具白名单 / 参数);不改变权限来源。', 'Source: your choice · next turn only (tool allow-list / parameters); permission source is unchanged.'],
|
|
98
|
+
['sess.footNote', '每项右侧标了作用域;模型/预设/权限都只在这里改', 'Scope is shown next to each field; model / preset / permission are changed only here'],
|
|
99
|
+
['sess.effective', '本轮实际生效', 'Effective this turn'],
|
|
100
|
+
['sess.aliasHint', '显示名来自 config.modelLabels,请求使用 API id。', 'Display name comes from config.modelLabels; requests still use the API id.'],
|
|
101
|
+
|
|
102
|
+
// —— 命令面板 / 快捷键表 ——
|
|
103
|
+
['palette.placeholder', '搜索会话、工作空间、模型、预设、权限、设置项…', 'Search sessions, workspaces, models, presets, permissions, settings…'],
|
|
104
|
+
['palette.empty', '没有匹配项 —— 试试「模型 / 会话 / 权限 / 设置」', 'No matches — try “model / session / permission / settings”'],
|
|
105
|
+
['palette.select', '选择', 'Select'],
|
|
106
|
+
['palette.run', '执行', 'Run'],
|
|
107
|
+
['palette.close', '关闭', 'Close'],
|
|
108
|
+
['palette.scopeNote', '作用域标签 = 改动影响范围', 'Scope tag = what this change affects'],
|
|
109
|
+
['palette.title', '命令面板', 'Command palette'],
|
|
110
|
+
['shortcut.title', '⌨️ 键盘快捷键', '⌨️ Keyboard shortcuts'],
|
|
111
|
+
['shortcut.close', '关闭', 'Close'],
|
|
112
|
+
|
|
113
|
+
// —— 危险操作二次确认 ——
|
|
114
|
+
['danger.title', '确认危险操作', 'Confirm a risky action'],
|
|
115
|
+
['danger.confirm', '确认', 'Confirm'],
|
|
116
|
+
['danger.cancel', '取消', 'Cancel'],
|
|
117
|
+
['danger.note', '需要点击「确认」才会执行;按回车不会确认,Esc 取消。', 'Click “Confirm” to proceed; Enter does not confirm, Esc cancels.'],
|
|
118
|
+
['danger.removeWorkspace.title', '删除工作空间', 'Delete workspace'],
|
|
119
|
+
['danger.removeWorkspace.consequence', '将从工作空间列表中移除该登记项;目录本身与其中的文件不会被删除,但依赖该空间的历史会话会失去一键切换入口。', 'Removes this entry from the workspace registry. The folder and its files are not deleted, but sessions that rely on it lose one-click switching.'],
|
|
120
|
+
['danger.deleteSession.title', '删除会话', 'Delete session'],
|
|
121
|
+
['danger.deleteSession.consequence', '会话记录文件将被永久删除,无法恢复;若删的是当前会话,界面会回到空态。', 'The session file is permanently deleted and cannot be recovered. If it is the current session, the chat returns to the empty state.'],
|
|
122
|
+
['danger.clearMemory.title', '清空记忆', 'Clear memory'],
|
|
123
|
+
['danger.clearMemory.consequence', '长期记忆文件将被清空,注入每次会话的偏好与约定全部丢失,且不可恢复。', 'The long-term memory file is cleared: every preference and convention injected into sessions is lost permanently.'],
|
|
124
|
+
['danger.resetConfig.title', '恢复默认配置', 'Reset configuration'],
|
|
125
|
+
['danger.resetConfig.consequence', '权限、沙箱、路由、上下文预算、超时、开机自启、通知将回到内置默认值;模型与 API Key 不受影响。', 'Permission, sandbox, routing, context budget, timeouts, autostart and notifications return to built-in defaults. Model and API keys are untouched.'],
|
|
126
|
+
['danger.permissionAuto.title', '切换到「自动」权限', 'Switch permission to auto'],
|
|
127
|
+
['danger.permissionAuto.consequence', '之后写文件与执行命令不再逐次确认;模型可能在你未看清内容时修改文件或运行命令。', 'Writes and command execution will no longer ask each time; the model may edit files or run commands before you have reviewed them.'],
|
|
128
|
+
['danger.sandboxOff.title', '关闭沙箱', 'Turn the sandbox off'],
|
|
129
|
+
['danger.sandboxOff.consequence', 'bash 将不再受只读/断网限制,直接在本机执行;沙箱提供的最外层隔离同时失效。', 'bash runs directly on this machine without read-only or no-network limits; the outermost sandbox isolation is gone.'],
|
|
130
|
+
|
|
131
|
+
// —— 设置项「影响」一句话(key 与 SETTING_IMPACT 同名) ——
|
|
132
|
+
['impact.permission', '切到 auto 会跳过逐次确认,工具直接执行;切到 readonly 会拒绝所有写操作。', 'auto skips per-action confirmation and runs tools directly; readonly rejects every write.'],
|
|
133
|
+
['impact.sandbox', '关掉沙箱后 bash 直接在本机执行;readonly/safe 会限制只读与断网,但需要 Linux + bubblewrap。', 'With the sandbox off, bash runs directly on the host; readonly/safe restrict writes and network, but require Linux + bubblewrap.'],
|
|
134
|
+
['impact.routing', '开启后规划类任务走 pro、执行类走 flash;关闭则始终用当前模型,成本与延迟更可预测。', 'On: planning turns use pro and execution turns use flash. Off: always the current model, with more predictable cost and latency.'],
|
|
135
|
+
['impact.contextBudget', '改小 contextBudget 会减少可容纳历史,长任务更易触发压缩;改大受模型窗口与实际引擎上限约束。', 'A smaller contextBudget holds less history and triggers compaction sooner; a larger one is still capped by the model window and engine limit.'],
|
|
136
|
+
['impact.timeout', '首 token / 流式空闲 / 总量调小会更早判定超时;留空表示按本地/云端模型自适应。', 'Smaller first-token / stream-idle / total values fail earlier; blank means adaptive per model.'],
|
|
137
|
+
['impact.autostart', '开启后登录系统即自动启动本地服务;关闭后需要手动运行 mingdao web。', 'On: the local server starts at login. Off: run mingdao web manually.'],
|
|
138
|
+
['impact.notify', '关闭后后台任务完成或失败不再发桌面通知,只能回到界面查看。', 'Off: background task completion/failure no longer raises desktop notifications.'],
|
|
139
|
+
['impact.theme', '只改变界面明暗,不影响模型与成本;选「跟随系统」时随系统偏好切换。', 'Only changes UI light/dark; no effect on model or cost. “Follow system” tracks the OS preference.'],
|
|
140
|
+
['impact.language', '只改变界面文案语言,不改变日志、错误原文与会话内容。', 'Only changes UI copy; logs, raw errors and session content are not translated.'],
|
|
141
|
+
['impact.memory', '长期记忆会在每次会话中注入;清空后偏好与约定全部丢失,且不可恢复。', 'Long-term memory is injected into every session; clearing it loses all preferences and conventions permanently.'],
|
|
142
|
+
|
|
143
|
+
// —— 无障碍 ——
|
|
144
|
+
['a11y.dialogLabel', '对话框', 'Dialog'],
|
|
145
|
+
['a11y.livePrefix', '本轮状态:', 'Run status: '],
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
/** @type {{zh: Record<string,string>, en: Record<string,string>}} */
|
|
149
|
+
export const MESSAGES = (() => {
|
|
150
|
+
/** @type {Record<string,string>} */
|
|
151
|
+
const zh = {};
|
|
152
|
+
/** @type {Record<string,string>} */
|
|
153
|
+
const en = {};
|
|
154
|
+
for (const [key, zhText, enText] of TABLE) {
|
|
155
|
+
zh[key] = zhText;
|
|
156
|
+
en[key] = enText;
|
|
157
|
+
}
|
|
158
|
+
return { zh, en };
|
|
159
|
+
})();
|
|
160
|
+
|
|
161
|
+
/** 当前语言(模块内状态;由 setLang/initI18n 写入)。 @type {'zh'|'en'} */
|
|
162
|
+
let currentLang = 'zh';
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 把任意输入收敛成受支持的语言;非法值返回空串。
|
|
166
|
+
* @param {any} value
|
|
167
|
+
* @returns {'zh'|'en'|''}
|
|
168
|
+
*/
|
|
169
|
+
export function normalizeLang(value) {
|
|
170
|
+
const v = String(value == null ? '' : value).toLowerCase();
|
|
171
|
+
if (v === 'zh' || v.startsWith('zh-')) return 'zh';
|
|
172
|
+
if (v === 'en' || v.startsWith('en-')) return 'en';
|
|
173
|
+
return '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 按 navigator.language 选择默认语言:英文环境 → en,其余 → zh。
|
|
178
|
+
* @param {any} navLang
|
|
179
|
+
* @returns {'zh'|'en'}
|
|
180
|
+
*/
|
|
181
|
+
export function detectLang(navLang) {
|
|
182
|
+
return normalizeLang(navLang) === 'en' ? 'en' : 'zh';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 读取 localStorage 里的覆盖值;非法/不可读一律返回空串(视为未覆盖)。
|
|
187
|
+
* @param {any} storage
|
|
188
|
+
* @returns {'zh'|'en'|''}
|
|
189
|
+
*/
|
|
190
|
+
export function storedLang(storage) {
|
|
191
|
+
try {
|
|
192
|
+
if (!storage || typeof storage.getItem !== 'function') return '';
|
|
193
|
+
return normalizeLang(storage.getItem(LANG_STORAGE_KEY));
|
|
194
|
+
} catch {
|
|
195
|
+
return '';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 解析最终语言:localStorage 覆盖 > navigator.language > zh。
|
|
201
|
+
* @param {{stored?: any, navLang?: any}} [input]
|
|
202
|
+
* @returns {'zh'|'en'}
|
|
203
|
+
*/
|
|
204
|
+
export function resolveLang(input) {
|
|
205
|
+
const o = input || {};
|
|
206
|
+
return normalizeLang(o.stored) || detectLang(o.navLang);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 取当前语言。
|
|
211
|
+
* @returns {'zh'|'en'}
|
|
212
|
+
*/
|
|
213
|
+
export function getLang() {
|
|
214
|
+
return currentLang === 'en' ? 'en' : 'zh';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 直接设置模块内当前语言(不碰 DOM / localStorage;测试与启动初始化用)。
|
|
219
|
+
* @param {any} lang
|
|
220
|
+
* @returns {'zh'|'en'}
|
|
221
|
+
*/
|
|
222
|
+
export function setCurrentLang(lang) {
|
|
223
|
+
currentLang = normalizeLang(lang) === 'en' ? 'en' : 'zh';
|
|
224
|
+
return currentLang;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {string} text
|
|
229
|
+
* @param {Record<string, any>|undefined} vars
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
function interpolate(text, vars) {
|
|
233
|
+
if (!vars) return text;
|
|
234
|
+
return String(text).replace(/\{([a-zA-Z0-9_]+)\}/g, (whole, name) => {
|
|
235
|
+
const v = vars[name];
|
|
236
|
+
return v == null ? whole : String(v);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 纯翻译函数(不依赖模块状态):未知语言回退 zh;键缺失回退 zh;两者都没有 → 空串。
|
|
242
|
+
* @param {any} lang
|
|
243
|
+
* @param {string} key
|
|
244
|
+
* @param {Record<string, any>} [vars]
|
|
245
|
+
* @returns {string}
|
|
246
|
+
*/
|
|
247
|
+
export function translate(lang, key, vars) {
|
|
248
|
+
const normal = normalizeLang(lang) || 'zh';
|
|
249
|
+
const table = MESSAGES[normal] || MESSAGES.zh;
|
|
250
|
+
let text = table[key];
|
|
251
|
+
if (text == null) text = MESSAGES.zh[key];
|
|
252
|
+
if (text == null) return '';
|
|
253
|
+
return interpolate(text, vars);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 当前语言下的翻译(界面渲染用)。
|
|
258
|
+
* @param {string} key
|
|
259
|
+
* @param {Record<string, any>} [vars]
|
|
260
|
+
* @returns {string}
|
|
261
|
+
*/
|
|
262
|
+
export function t(key, vars) {
|
|
263
|
+
return translate(currentLang, key, vars);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 把一棵 DOM 子树里的 data-i18n* 属性落到可见文案。
|
|
268
|
+
* 支持:data-i18n(textContent)、data-i18n-placeholder、data-i18n-title、data-i18n-aria-label。
|
|
269
|
+
* 找不到 root 时静默返回(桩 DOM / Node 测试可用)。
|
|
270
|
+
* @param {any} root
|
|
271
|
+
* @param {any} [lang]
|
|
272
|
+
* @returns {number} 实际更新的节点数
|
|
273
|
+
*/
|
|
274
|
+
export function applyI18n(root, lang) {
|
|
275
|
+
const normal = normalizeLang(lang) || currentLang;
|
|
276
|
+
if (!root || typeof root.querySelectorAll !== 'function') return 0;
|
|
277
|
+
/** @type {any[]} */
|
|
278
|
+
let nodes = [];
|
|
279
|
+
try {
|
|
280
|
+
nodes = Array.from(root.querySelectorAll('[data-i18n],[data-i18n-placeholder],[data-i18n-title],[data-i18n-aria-label]'));
|
|
281
|
+
} catch {
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
let count = 0;
|
|
285
|
+
for (const el of nodes) {
|
|
286
|
+
const key = el.getAttribute && el.getAttribute('data-i18n');
|
|
287
|
+
if (key) {
|
|
288
|
+
const text = translate(normal, key);
|
|
289
|
+
if (text) { el.textContent = text; count += 1; }
|
|
290
|
+
}
|
|
291
|
+
const pairs = [
|
|
292
|
+
['data-i18n-placeholder', 'placeholder'],
|
|
293
|
+
['data-i18n-title', 'title'],
|
|
294
|
+
['data-i18n-aria-label', 'aria-label'],
|
|
295
|
+
];
|
|
296
|
+
for (const [attr, target] of pairs) {
|
|
297
|
+
const k = el.getAttribute && el.getAttribute(attr);
|
|
298
|
+
if (!k) continue;
|
|
299
|
+
const text = translate(normal, k);
|
|
300
|
+
if (text) { el.setAttribute(target, text); count += 1; }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return count;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 设置语言并落盘(浏览器入口):写 localStorage、更新 <html lang>、刷新静态文案。
|
|
308
|
+
* 不传 storage/root 时只改模块状态(测试与 Node 环境)。
|
|
309
|
+
* @param {any} lang
|
|
310
|
+
* @param {{storage?: any, root?: any, navLang?: any}} [deps]
|
|
311
|
+
* @returns {'zh'|'en'}
|
|
312
|
+
*/
|
|
313
|
+
export function setLang(lang, deps) {
|
|
314
|
+
const o = deps || {};
|
|
315
|
+
const normal = normalizeLang(lang) || detectLang(o.navLang);
|
|
316
|
+
currentLang = normal;
|
|
317
|
+
if (o.storage && typeof o.storage.setItem === 'function') {
|
|
318
|
+
try { o.storage.setItem(LANG_STORAGE_KEY, normal); } catch { /* 隐私模式 / 配额满:不阻断界面 */ }
|
|
319
|
+
}
|
|
320
|
+
if (o.root && typeof o.root.setAttribute === 'function') {
|
|
321
|
+
try { o.root.setAttribute('lang', normal === 'en' ? 'en' : 'zh-CN'); } catch { /* 桩 DOM */ }
|
|
322
|
+
applyI18n(o.root, normal);
|
|
323
|
+
}
|
|
324
|
+
return normal;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 启动时解析语言并应用一次(默认跟随 navigator.language,localStorage 覆盖)。
|
|
329
|
+
* @param {{storage?: any, root?: any, navLang?: any}} [deps]
|
|
330
|
+
* @returns {'zh'|'en'}
|
|
331
|
+
*/
|
|
332
|
+
export function initI18n(deps) {
|
|
333
|
+
const o = deps || {};
|
|
334
|
+
const lang = resolveLang({ stored: storedLang(o.storage), navLang: o.navLang });
|
|
335
|
+
currentLang = lang;
|
|
336
|
+
if (o.root && typeof o.root.setAttribute === 'function') {
|
|
337
|
+
try { o.root.setAttribute('lang', lang === 'en' ? 'en' : 'zh-CN'); } catch { /* 桩 DOM */ }
|
|
338
|
+
try { applyI18n(o.root, lang); } catch { /* 桩 DOM */ }
|
|
339
|
+
}
|
|
340
|
+
return lang;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 两套键集合(供测试逐键比对;运行时不使用)。
|
|
345
|
+
* @returns {{zh: string[], en: string[]}}
|
|
346
|
+
*/
|
|
347
|
+
export function messageKeys() {
|
|
348
|
+
return { zh: Object.keys(MESSAGES.zh).sort(), en: Object.keys(MESSAGES.en).sort() };
|
|
349
|
+
}
|
package/src/web/server.js
CHANGED
|
@@ -85,6 +85,29 @@ const PERM_HINT = /** @type {Record<string, string>} */ ({
|
|
|
85
85
|
auto: '不再逐次确认,工具直接执行',
|
|
86
86
|
readonly: '仅只读工具直接放行,其余逐次询问是否本次放行',
|
|
87
87
|
});
|
|
88
|
+
/**
|
|
89
|
+
* v0.6.19 §3.76(P2):设置项「默认值」列的只读数据源。
|
|
90
|
+
*
|
|
91
|
+
* 为什么放在这一层:`/api/state` 的响应体由 `routes/domains/config.js` 组装,本轮允许的后端改动面
|
|
92
|
+
* 只有本文件;这里按路径补一个**纯只读**字段(不参与任何判定、不写入 config.json)。
|
|
93
|
+
* 字段与 `src/config.js` 的内置默认表保持同一口径:
|
|
94
|
+
* permission=ask / sandbox=off / routing=false / contextBudget=128000 /
|
|
95
|
+
* timeout 未设置(= 按模型自适应)/ autostart=false / notify=true。
|
|
96
|
+
* 前端把它显示在设置项的「默认」列;读不到时前端有同值兜底(诚实标注来源)。
|
|
97
|
+
* @type {Readonly<Record<string, any>>}
|
|
98
|
+
*/
|
|
99
|
+
const SETTING_DEFAULTS = Object.freeze({
|
|
100
|
+
permission: 'ask',
|
|
101
|
+
sandbox: 'off',
|
|
102
|
+
routing: false,
|
|
103
|
+
contextBudget: 128000,
|
|
104
|
+
timeout: Object.freeze({ firstTokenMs: null, streamIdleMs: null, totalMs: null }),
|
|
105
|
+
autostart: false,
|
|
106
|
+
notify: true,
|
|
107
|
+
reasoningEffort: 'high',
|
|
108
|
+
theme: 'system',
|
|
109
|
+
language: 'auto',
|
|
110
|
+
});
|
|
88
111
|
/**
|
|
89
112
|
* v0.6.17 §3.64:`resolveTurnPermission().source` → 账本 `run.start.permissionSource`。
|
|
90
113
|
* 两套名字必须分开:`webui` 是「本模块内部从哪来」的措辞(既有测试与 banner 都依赖它),
|
|
@@ -1185,6 +1208,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
1185
1208
|
const server = http.createServer((req, res) => {
|
|
1186
1209
|
// v0.6.18 §3.68:/api/state 的响应体在配置域(routes/domains/config.js)里组装,而本轮允许的
|
|
1187
1210
|
// 后端改动面只有本文件——在这一层按路径补 permissionSource,避免与并行执行者争同一个文件。
|
|
1211
|
+
// v0.6.19 §3.76(P2):同时补只读的 defaults(设置项「默认值」列的数据源;不参与判定)。
|
|
1188
1212
|
// 只补 GET /api/state 的成功响应;其余 API/静态资源原样通过。
|
|
1189
1213
|
if (req.method === 'GET' && new URL(String(req.url || '/'), 'http://localhost').pathname === '/api/state') {
|
|
1190
1214
|
const rawEnd = res.end.bind(res);
|
|
@@ -1198,6 +1222,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
1198
1222
|
const body = JSON.parse(String(chunk));
|
|
1199
1223
|
if (body && body.ok === true && Array.isArray(body.permissions)) {
|
|
1200
1224
|
body.permissionSource = statePermissionSource();
|
|
1225
|
+
body.defaults = SETTING_DEFAULTS;
|
|
1201
1226
|
chunk = JSON.stringify(body);
|
|
1202
1227
|
}
|
|
1203
1228
|
}
|