From 4d08b81026853d8b406101e87792c23a62fe199a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 6 Aug 2026 22:21:27 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20i18n=20Stage=201=20=E2=80=94=20i1?= =?UTF-8?q?8n.js=20+=20static=20text=20+=20language=20switcher=20(rant=202?= =?UTF-8?q?1:19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New emrg/gui/renderer/js/i18n.js: zero-dependency dict {zh,en}, detectLocale() (navigator.language, zh* → zh), getLocale/setLocale (localStorage "emrg.locale": ""=follow system / zh / en), t(key, params) with {var} interpolation + zh fallback, apply() for data-i18n / data-i18n-placeholder / data-i18n-title - index.html: data-i18n on sidebar/composer/empty-state/settings/welcome/ confirm/rename/help/sessions/rewind/memory/skills/rant/tasks static text (labels with embedded inputs wrapped in spans so textContent doesn't wipe inputs) - Settings dialog: new Language selector (System / 中文 / English), instant apply via I18N.setLocale + EMRG_Copy.refresh + App.refreshLocale - copywriting.js: COPY + tool phrases now resolve via i18n dict; TOOL_FAIL_TEXT becomes a getter (chat.js assigns it as a string) - commands.js: hints become i18n keys, getCompletions/hintText resolve per locale - app.js: boot applies locale; refreshLocale re-renders model switcher + cmd menu; example-card click uses localized textContent - +8 i18n unit tests (detectLocale/t/插值/setLocale/apply/词典键一致性); GUI tests 71 pass (63 + 8), Python 480 pass --- emrg/gui/renderer/index.html | 179 ++++++------ emrg/gui/renderer/js/app.js | 22 +- emrg/gui/renderer/js/commands.js | 44 +-- emrg/gui/renderer/js/copywriting.js | 87 +++--- emrg/gui/renderer/js/dialogs.js | 29 ++ emrg/gui/renderer/js/i18n.js | 403 +++++++++++++++++++++++++++ emrg/gui/test/app-commands.test.js | 2 +- emrg/gui/test/i18n.test.js | 129 +++++++++ emrg/gui/test/renderer.smoke.test.js | 2 +- 9 files changed, 756 insertions(+), 141 deletions(-) create mode 100644 emrg/gui/renderer/js/i18n.js create mode 100644 emrg/gui/test/i18n.test.js diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 107eb803..dcaed3d3 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -19,16 +19,16 @@ EMRG - +
🌱 已自我进化 0
边工作边学习,越用越懂你
- + @@ -44,40 +44,40 @@
-
- 加载中… +
+ 加载中…
-
+
- - - + + +
-
EMRG 可能会犯错,请核对重要信息
+
EMRG 可能会犯错,请核对重要信息
@@ -86,50 +86,60 @@
-

设置

+

设置

-
模型服务
- - -
可用模型
+
模型服务
+ + +
可用模型
- + -
点左侧圆点可设为默认模型,切换后下一条消息即生效
+
点左侧圆点可设为默认模型,切换后下一条消息即生效
-
工作目录
-
-
外观
-
+
+
语言
+
@@ -146,8 +156,8 @@

设置

- - + +
@@ -157,26 +167,26 @@

设置

-

欢迎使用 EMRG

-

我是你的 AI 工作伙伴。先做两步简单设置,马上就能开始。

+

欢迎使用 EMRG

+

我是你的 AI 工作伙伴。先做两步简单设置,马上就能开始。

-
@@ -184,11 +194,11 @@

欢迎使用 EMRG

-

确认

+

确认

- - + +
@@ -196,13 +206,13 @@

确认

-

重命名对话

-
@@ -216,11 +226,11 @@

重命名对话

-

/ 指令帮助

-

以 / 开头的指令在输入框执行,或点击补全菜单选择。

+

/ 指令帮助

+

以 / 开头的指令在输入框执行,或点击补全菜单选择。

- +
@@ -228,11 +238,11 @@

/ 指令帮助

-

切换对话

-

点击切换,或输入 /resume <id> 直接切换。

+

切换对话

+

点击切换,或输入 /resume <id> 直接切换。

- +
@@ -240,11 +250,11 @@

切换对话

-

回退到历史消息点

-

选择要保留到的消息点,之后的对话将被移除。

+

回退到历史消息点

+

选择要保留到的消息点,之后的对话将被移除。

- +
@@ -252,12 +262,12 @@

回退到历史消息点

-

记忆

-

EMRG 记住的长期信息,点击查看详情。

+

记忆

+

EMRG 记住的长期信息,点击查看详情。

- +
@@ -265,11 +275,11 @@

记忆

-

技能

-

EMRG 已加载的技能。

+

技能

+

EMRG 已加载的技能。

- +
@@ -277,17 +287,17 @@

技能

-

🧬 进化 — 告诉 EMRG 往哪里走

-

你的输入会驱动 EMRG 的自我进化——它会认真读,并据此改进自己。

-
@@ -295,11 +305,11 @@

🧬 进化 — 告诉 EMRG 往哪里走

-

后台任务

-

点击任务立即触发一次运行。

+

后台任务

+

点击任务立即触发一次运行。

- +
@@ -315,6 +325,7 @@

后台任务

+ diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 70bf7d02..834badf0 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -47,6 +47,7 @@ const App = (() => { updateConnectionDot(init.config_exists && init.api_key_configured ? "green" : "gray"); updateModelSwitcher(); updateGrowthCard(); + if (window.EMRG_I18N) window.EMRG_I18N.apply(); // rant 21:19:应用当前语言静态文案 if (!init.config_exists) { Dialogs.showWelcome(); // 首启引导 @@ -517,7 +518,7 @@ const App = (() => { Object.entries(Commands.COMMANDS).forEach(([cmd, meta]) => { const row = el("div", { class: "help-row" }); const name = el("span", { class: "help-cmd" }, cmd); - const hint = el("span", { class: "help-hint" }, meta.hint); + const hint = el("span", { class: "help-hint" }, Commands.hintText(cmd)); // rant 21:19:hint 经 i18n 解析 row.appendChild(name); row.appendChild(hint); list.appendChild(row); @@ -670,7 +671,17 @@ const App = (() => { // ── 模型切换器 ───────────────────────── async function updateModelSwitcher() { const label = $("model-switcher-label"); - if (label) label.textContent = state.model || "选择模型"; + if (label) label.textContent = state.model || (window.EMRG_I18N ? window.EMRG_I18N.t("composer.modelLoading") : "加载中…"); + } + + /** rant 21:19:locale 切换后重刷动态文案(i18n.apply 回调) */ + function refreshLocale() { + updateModelSwitcher(); + // 空状态示例卡片:data-i18n 已由 apply 重刷;若补全菜单打开则重建(hint 本地化) + const menu = $("cmd-menu"); + if (menu && !menu.hidden) { + showCmdMenu(($("input").value || "").trim()); + } } // ── 自进化可见化(WorkBuddy P3)────────────────── @@ -1021,9 +1032,10 @@ const App = (() => { $("stop-btn").addEventListener("click", () => window.emrg.cancel().catch(() => {})); $("new-chat-btn").addEventListener("click", newSession); $("settings-btn").addEventListener("click", () => { - loadEvolutionSummary(); // WorkBuddy P3:打开设置时加载最近改进 + loadEvolutionSummary(); // WorkBuddy P3(#502):打开设置时加载最近改进 Dialogs.showSettings(); }); + Dialogs.initLangButtons(); // rant 21:19:设置语言选择器 $("settings-cancel").addEventListener("click", () => $("settings-dialog").close()); $("settings-save").addEventListener("click", Dialogs.saveSettings); $("pick-dir-btn").addEventListener("click", async () => { @@ -1129,7 +1141,8 @@ const App = (() => { $("empty-state").addEventListener("click", (e) => { const card = e.target.closest(".example-card"); if (!card) return; - input.value = card.dataset.example || ""; + // rant 21:19:示例提示随语言走(textContent 已本地化;data-example 为中文兜底) + input.value = (card.textContent || card.dataset.example || "").trim(); input.focus(); input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 150) + "px"; @@ -1193,6 +1206,7 @@ const App = (() => { updateGrowthCard, // WorkBuddy P3:成长卡(导出供测试) maybeShowEvolutionToast, // WorkBuddy P3:进化 toast 检测 showVersionInfo, // WorkBuddy P3:/version 内容(toast "去看看" 共用) + refreshLocale, // rant 21:19:locale 切换后动态文案重刷 setMode, // WorkBuddy P2:Ask/Auto 模式(导出供测试与外部调用) loadEvolutionSummary, // WorkBuddy P3:最近改进摘要 }; diff --git a/emrg/gui/renderer/js/commands.js b/emrg/gui/renderer/js/commands.js index 9801ecd7..8e5cdd6d 100644 --- a/emrg/gui/renderer/js/commands.js +++ b/emrg/gui/renderer/js/commands.js @@ -12,22 +12,23 @@ const Commands = (() => { // 指令注册表:cmd → { hint, phase } + // hint 为 i18n 词典键(rant 21:19):显示时经 t() 解析为当前语言文案 const COMMANDS = { - "/clear": { hint: "清空当前对话", phase: 1 }, - "/compact": { hint: "压缩当前对话历史", phase: 1 }, - "/version": { hint: "显示版本与实例信息", phase: 1 }, - "/help": { hint: "查看全部指令说明", phase: 1 }, - "/image": { hint: "发送图片(请直接粘贴)", phase: 1 }, - "/delete": { hint: "删除当前对话", phase: 2 }, - "/rename": { hint: "重命名当前对话", phase: 2 }, - "/resume": { hint: "切换/恢复对话", phase: 2 }, - "/rewind": { hint: "回退到历史消息点", phase: 2 }, - "/sessions": { hint: "查看全部对话", phase: 2 }, - "/model": { hint: "切换模型", phase: 3 }, - "/memory": { hint: "浏览记忆", phase: 3 }, - "/skills": { hint: "查看已加载技能", phase: 3 }, - "/rant": { hint: "驱动 EMRG 进化", phase: 4 }, - "/trigger": { hint: "触发后台任务", phase: 4 }, + "/clear": { hint: "cmd.clear.hint", phase: 1 }, + "/compact": { hint: "cmd.compact.hint", phase: 1 }, + "/version": { hint: "cmd.version.hint", phase: 1 }, + "/help": { hint: "cmd.help.hint", phase: 1 }, + "/image": { hint: "cmd.image.hint", phase: 1 }, + "/delete": { hint: "cmd.delete.hint", phase: 2 }, + "/rename": { hint: "cmd.rename.hint", phase: 2 }, + "/resume": { hint: "cmd.resume.hint", phase: 2 }, + "/rewind": { hint: "cmd.rewind.hint", phase: 2 }, + "/sessions": { hint: "cmd.sessions.hint", phase: 2 }, + "/model": { hint: "cmd.model.hint", phase: 3 }, + "/memory": { hint: "cmd.memory.hint", phase: 3 }, + "/skills": { hint: "cmd.skills.hint", phase: 3 }, + "/rant": { hint: "cmd.rant.hint", phase: 4 }, + "/trigger": { hint: "cmd.trigger.hint", phase: 4 }, }; /** @@ -43,15 +44,24 @@ const Commands = (() => { return { type: "unknown", cmd: key }; } + /** 解析 i18n hint:词典键 → 当前语言文案 */ + function hintText(cmd) { + const meta = COMMANDS[cmd]; + if (!meta) return ""; + try { + return window.EMRG_I18N ? window.EMRG_I18N.t(meta.hint) : meta.hint; + } catch { return meta.hint; } + } + /** / 前缀补全:返回匹配的指令(含 hint),供补全菜单过滤 */ function getCompletions(prefix) { const p = String(prefix || "").toLowerCase(); return Object.entries(COMMANDS) .filter(([cmd]) => cmd.startsWith(p)) - .map(([cmd, meta]) => ({ cmd, hint: meta.hint, phase: meta.phase })); + .map(([cmd, meta]) => ({ cmd, hint: hintText(cmd), phase: meta.phase })); } - return { COMMANDS, parseInput, getCompletions }; + return { COMMANDS, parseInput, getCompletions, hintText }; })(); window.EMRG_Commands = Commands; diff --git a/emrg/gui/renderer/js/copywriting.js b/emrg/gui/renderer/js/copywriting.js index b457a285..0024c952 100644 --- a/emrg/gui/renderer/js/copywriting.js +++ b/emrg/gui/renderer/js/copywriting.js @@ -1,43 +1,62 @@ "use strict"; /** - * copywriting.js — 去黑话文案映射(非开发者友好)。 + * copywriting.js — 去黑话文案映射(非开发者友好,rant 21:19 起经 i18n 词典取词)。 * 工具名 → 友好的动词短语;系统级状态 → 口语化鼓励性文案。 + * refresh():locale 切换后重建 COPY(i18n.apply 会调用)。 + * 注意:EMRG_Copy 需在 i18n.js 之后加载(引用 EMRG_I18N.t)。 */ -/** 工具名 → 进行中 / 完成 短语(渐进披露:默认折叠,点开展示原始输出) */ -const TOOL_PHRASES = { - bash: { doing: "正在运行命令…", done: "已运行命令" }, - read: { doing: "正在读取文件…", done: "已读取文件" }, - write: { doing: "正在写入文件…", done: "已写入文件" }, - edit: { doing: "正在修改文件…", done: "已修改文件" }, - glob: { doing: "正在查找文件…", done: "已找到文件" }, - grep: { doing: "正在搜索内容…", done: "已完成搜索" }, -}; +const EMRG_Copy = (() => { + const _t = (key, params) => { + try { + return window.EMRG_I18N ? window.EMRG_I18N.t(key, params) : key; + } catch { return key; } + }; -function toolPhrases(name) { - return TOOL_PHRASES[name] || { doing: "正在处理…", done: "已完成" }; -} + /** 工具名 → 进行中 / 完成 短语(渐进披露:默认折叠,点开展示原始输出) */ + function toolPhrases(name) { + const base = name || "fallback"; + return { + doing: _t(`tool.${base}.doing`), + done: _t(`tool.${base}.done`), + }; + } -/** 工具失败(不 blame 用户,给出下一步) */ -const TOOL_FAIL_TEXT = "这一步没成功,我换个方法试试"; + /** 系统状态文案(locale 敏感:切换语言后 refresh 重建;含 #501 P3 成长卡/toast 键) */ + function buildCopy() { + return { + disconnected: _t("copy.disconnected"), + reconnected: _t("copy.reconnected"), + sessionBusy: _t("copy.sessionBusy"), + sendFailed: _t("copy.sendFailed"), + deleteConfirmTitle: _t("copy.deleteConfirmTitle"), + deleteConfirmBody: _t("copy.deleteConfirmBody"), + noSessions: _t("copy.noSessions"), + aboutEvolution: (n) => + n ? _t("copy.aboutEvolution", { n }) : _t("copy.aboutEvolutionEmpty"), + // WorkBuddy P3(#501):成长卡 + 进化 toast + growthCount: (n) => _t("copy.growthCount", { n }), + growthNote: _t("copy.growthNote"), + evolutionToastTitle: _t("copy.evolutionToastTitle"), + evolutionToastMsg: (n) => _t("copy.evolutionToastMsg", { n }), + evolutionToastSee: _t("copy.evolutionToastSee"), + evolutionToastDismiss: _t("copy.evolutionToastDismiss"), + }; + } + let COPY = buildCopy(); + let failText = _t("tool.failText"); // 工具失败文案(字符串属性,chat.js 直接赋值用) -/** 系统状态文案 */ -const COPY = { - disconnected: "连接中断了,正在重新连接…", - reconnected: "回来了,我们继续 ✦", - sessionBusy: "我还在处理上一条,稍等一下哦", - sendFailed: "没发送成功,你的话我还留着,再试一次?", - deleteConfirmTitle: "删除这段对话?", - deleteConfirmBody: "删除后无法恢复。", - noSessions: "还没有对话", - aboutEvolution: (n) => (n ? `EMRG 已自我成长 ${n} 次,感谢你的每一次反馈` : "EMRG 正在成长中"), - // WorkBuddy P3(rant 21:35):自进化可见化 - growthCount: (n) => `已自我进化 ${n} 次`, - growthNote: "边工作边学习,越用越懂你", - evolutionToastTitle: "EMRG 刚刚完成一次自我进化!", - evolutionToastMsg: (n) => `这是它的第 ${n} 次自我改进,现在更好用了。`, - evolutionToastSee: "去看看", - evolutionToastDismiss: "知道了", -}; + function refresh() { + COPY = buildCopy(); + failText = _t("tool.failText"); + } -window.EMRG_Copy = { toolPhrases, TOOL_FAIL_TEXT, COPY }; + return { + toolPhrases, + COPY, + refresh, + get TOOL_FAIL_TEXT() { return failText; }, + }; +})(); + +window.EMRG_Copy = EMRG_Copy; diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index a1140db5..7a79a3fb 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -31,6 +31,32 @@ const Dialogs = (() => { }); } + // ── 语言(rant 21:19:i18n 手动覆盖,即时生效)── + function renderLangOptions() { + const wrap = $("lang-options"); + if (!wrap) return; + // 原始 localStorage 值:"" = 跟随系统(高亮第一项) + let saved = ""; + try { + if (typeof localStorage !== "undefined") saved = localStorage.getItem("emrg.locale") || ""; + } catch { /* ignore */ } + for (const btn of wrap.querySelectorAll(".theme-option")) { + btn.classList.toggle("active", (btn.dataset.lang || "") === saved); + } + } + + function initLangButtons() { + const wrap = $("lang-options"); + if (!wrap) return; + wrap.addEventListener("click", (e) => { + const btn = e.target.closest(".theme-option"); + if (!btn) return; + const loc = btn.dataset.lang || ""; + if (window.EMRG_I18N) window.EMRG_I18N.setLocale(loc); // 立即重刷全界面 + renderLangOptions(); + }); + } + // ── 多模型管理 ─────────────────────────── // defaultName:当前默认模型(llm.model);extraModels:[[llm.models]] 条目(含与默认同名的配置项) let defaultName = ""; @@ -189,6 +215,7 @@ const Dialogs = (() => { $("set-project-dir").value = s.projectDir || ""; if (s.theme) theme = s.theme; renderThemeOptions(); + renderLangOptions(); // rant 21:19:语言选择器高亮当前值 defaultName = s.model || ""; extraModels = (s.modelDetails || []).map((m) => ({ name: m.name || "", @@ -357,6 +384,8 @@ const Dialogs = (() => { return { initThemeButtons, + initLangButtons, + renderLangOptions, initModelForm, initRenameDialog, showRename, diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js new file mode 100644 index 00000000..a1cedb75 --- /dev/null +++ b/emrg/gui/renderer/js/i18n.js @@ -0,0 +1,403 @@ +"use strict"; +/** + * i18n.js — 轻量国际化(零依赖,rant 21:19 Stage 1)。 + * - 词典 { zh, en },键为文案 ID + * - detectLocale():navigator.language 检测(zh* → 中文,其他 → 英文) + * - getLocale()/setLocale():localStorage "emrg.locale" 手动覆盖(""=跟随系统) + * - t(key, params):取词,支持 {var} 插值;zh 兜底 + * - apply():应用 data-i18n / data-i18n-placeholder / data-i18n-title 静态文案, + * 并刷新 EMRG_Copy 词典与 App.refreshLocale()(动态文案) + * 测试环境(vm 沙箱)可能没有 navigator / localStorage → 全部守卫。 + */ + +const I18N = (() => { + const LOCALE_KEY = "emrg.locale"; + + // ── 词典 ──────────────────────────────── + const DICTS = { + zh: { + // 侧边栏 + "sidebar.newChat": "+ 新对话", + "sidebar.newChatTitle": "新对话 (⌘N)", + "sidebar.settings": "设置", + + // 空状态欢迎屏 + "empty.hello": "你好,我是 EMRG", + "empty.sub": "你的 AI 工作伙伴。问我任何事——写东西、整理文件、规划任务,都可以。", + "empty.example1": "帮我写一份本周的工作周报", + "empty.example2": "帮我整理这个文件夹里的文件", + "empty.example3": "帮我规划一次周末旅行", + + // 输入区 + "composer.modelLoading": "加载中…", + "composer.switchModel": "切换模型", + "composer.modeTitle": "工作模式:Ask 只对话 / Auto 自动执行", + "composer.modeAsk": "Ask", + "composer.modeAuto": "Auto", + "composer.placeholder": "发消息给 EMRG… (Enter 发送 / Shift+Enter 换行)", + "composer.send": "发送", + "composer.stop": "停止回复", + "composer.hint": "EMRG 可能会犯错,请核对重要信息", + + // 结果面板 + "result.title": "产物", + "result.collapse": "折叠/展开 (⌘\\)", + "result.empty": "还没有产物,对话中生成的文件会出现在这里", + + // 设置 + "settings.title": "设置", + "settings.groupModel": "模型服务", + "settings.apiKey": "API Key", + "settings.baseUrl": "接口地址", + "settings.availableModels": "可用模型", + "settings.addModel": "+ 添加模型", + "settings.name": "名称", + "settings.modelId": "模型 ID(选填)", + "settings.modelIdPlaceholder": "默认同名称", + "settings.vision": "支持图片", + "settings.cancel": "取消", + "settings.saveModel": "保存模型", + "settings.defaultHint": "点左侧圆点可设为默认模型,切换后下一条消息即生效", + "settings.groupWorkdir": "工作目录", + "settings.workdirDesc": "EMRG 在哪里帮你干活", + "settings.choose": "选择…", + "settings.groupAppearance": "外观", + "settings.theme": "主题", + "settings.themeLight": "浅色", + "settings.themeDark": "深色", + "settings.themeSystem": "跟随系统", + "settings.groupLanguage": "语言", + "settings.langFollowSystem": "跟随系统", + "settings.langZh": "中文", + "settings.langEn": "English", + "settings.save": "保存", + "settings.readFailed": "读取设置失败了:{msg}", + "settings.saveFailed": "保存失败了:{msg}", + + // 首启引导 + "welcome.title": "欢迎使用 EMRG", + "welcome.sub": "我是你的 AI 工作伙伴。先做两步简单设置,马上就能开始。", + "welcome.step1": "第一步:告诉我 API Key", + "welcome.step1Hint": "用于连接你的 AI 模型服务", + "welcome.baseUrl": "接口地址", + "welcome.defaultModel": "默认模型", + "welcome.step2": "第二步:选择工作目录(可跳过)", + "welcome.projectPlaceholder": "默认 ~/.emrg/evolution", + "welcome.step2Hint": "EMRG 会在这个目录里帮你干活", + "welcome.saveStart": "保存并开始", + + // 确认 / 重命名 + "confirm.title": "确认", + "confirm.delete": "删除", + "rename.title": "重命名对话", + "rename.label": "给这段对话起个好记的名字", + "rename.placeholder": "例如:周报草稿", + + // 指令帮助 + "help.title": "/ 指令帮助", + "help.desc": "以 / 开头的指令在输入框执行,或点击补全菜单选择。", + "help.close": "关闭", + + // 会话 / 回退 / 记忆 / 技能对话框 + "sessions.title": "切换对话", + "sessions.desc": "点击切换,或输入 /resume 直接切换。", + "rewind.title": "回退到历史消息点", + "rewind.desc": "选择要保留到的消息点,之后的对话将被移除。", + "rewind.cancel": "取消", + "memory.title": "记忆", + "memory.desc": "EMRG 记住的长期信息,点击查看详情。", + "skills.title": "技能", + "skills.desc": "EMRG 已加载的技能。", + + // 进化 / 任务 + "rant.title": "🧬 进化 — 告诉 EMRG 往哪里走", + "rant.desc": "你的输入会驱动 EMRG 的自我进化——它会认真读,并据此改进自己。", + "rant.project": "项目(可选)", + "rant.message": "你的想法", + "rant.placeholder": "哪里不好用、想要什么新功能、或者希望它怎么改进…", + "rant.submit": "驱动进化", + "tasks.title": "后台任务", + "tasks.desc": "点击任务立即触发一次运行。", + + // / 指令 hint(commands.js 补全菜单) + "cmd.clear.hint": "清空当前对话", + "cmd.compact.hint": "压缩当前对话历史", + "cmd.version.hint": "显示版本与实例信息", + "cmd.help.hint": "查看全部指令说明", + "cmd.image.hint": "发送图片(请直接粘贴)", + "cmd.delete.hint": "删除当前对话", + "cmd.rename.hint": "重命名当前对话", + "cmd.resume.hint": "切换/恢复对话", + "cmd.rewind.hint": "回退到历史消息点", + "cmd.sessions.hint": "查看全部对话", + "cmd.model.hint": "切换模型", + "cmd.memory.hint": "浏览记忆", + "cmd.skills.hint": "查看已加载技能", + "cmd.rant.hint": "驱动 EMRG 进化", + "cmd.trigger.hint": "触发后台任务", + + // 系统状态文案(copywriting.js) + "copy.disconnected": "连接中断了,正在重新连接…", + "copy.reconnected": "回来了,我们继续 ✦", + "copy.sessionBusy": "我还在处理上一条,稍等一下哦", + "copy.sendFailed": "没发送成功,你的话我还留着,再试一次?", + "copy.deleteConfirmTitle": "删除这段对话?", + "copy.deleteConfirmBody": "删除后无法恢复。", + "copy.noSessions": "还没有对话", + "copy.aboutEvolution": "EMRG 已自我成长 {n} 次,感谢你的每一次反馈", + "copy.aboutEvolutionEmpty": "EMRG 正在成长中", + // WorkBuddy P3(#501):成长卡 + 进化 toast + "copy.growthCount": "已自我进化 {n} 次", + "copy.growthNote": "边工作边学习,越用越懂你", + "copy.evolutionToastTitle": "EMRG 刚刚完成一次自我进化!", + "copy.evolutionToastMsg": "这是它的第 {n} 次自我改进,现在更好用了。", + "copy.evolutionToastSee": "去看看", + "copy.evolutionToastDismiss": "知道了", + + // 工具短语(copywriting.js) + "tool.bash.doing": "正在运行命令…", + "tool.bash.done": "已运行命令", + "tool.read.doing": "正在读取文件…", + "tool.read.done": "已读取文件", + "tool.write.doing": "正在写入文件…", + "tool.write.done": "已写入文件", + "tool.edit.doing": "正在修改文件…", + "tool.edit.done": "已修改文件", + "tool.glob.doing": "正在查找文件…", + "tool.glob.done": "已找到文件", + "tool.grep.doing": "正在搜索内容…", + "tool.grep.done": "已完成搜索", + "tool.fallback.doing": "正在处理…", + "tool.fallback.done": "已完成", + "tool.failText": "这一步没成功,我换个方法试试", + }, + + en: { + // Sidebar + "sidebar.newChat": "+ New chat", + "sidebar.newChatTitle": "New chat (⌘N)", + "sidebar.settings": "Settings", + + // Empty state + "empty.hello": "Hi, I'm EMRG", + "empty.sub": "Your AI work partner. Ask me anything — write, organize files, plan tasks.", + "empty.example1": "Write this week's work report", + "empty.example2": "Organize the files in this folder", + "empty.example3": "Plan a weekend trip", + + // Composer + "composer.modelLoading": "Loading…", + "composer.switchModel": "Switch model", + "composer.modeTitle": "Work mode: Ask (chat only) / Auto (run tools)", + "composer.modeAsk": "Ask", + "composer.modeAuto": "Auto", + "composer.placeholder": "Message EMRG… (Enter to send / Shift+Enter for new line)", + "composer.send": "Send", + "composer.stop": "Stop reply", + "composer.hint": "EMRG may make mistakes — please verify important information", + + // Result panel + "result.title": "Artifacts", + "result.collapse": "Collapse/expand (⌘\\)", + "result.empty": "No artifacts yet — files generated in the conversation will appear here", + + // Settings + "settings.title": "Settings", + "settings.groupModel": "Model service", + "settings.apiKey": "API Key", + "settings.baseUrl": "Base URL", + "settings.availableModels": "Available models", + "settings.addModel": "+ Add model", + "settings.name": "Name", + "settings.modelId": "Model ID (optional)", + "settings.modelIdPlaceholder": "Defaults to name", + "settings.vision": "Supports images", + "settings.cancel": "Cancel", + "settings.saveModel": "Save model", + "settings.defaultHint": "Click the dot on the left to set the default model — takes effect on the next message", + "settings.groupWorkdir": "Working directory", + "settings.workdirDesc": "Where EMRG does work for you", + "settings.choose": "Choose…", + "settings.groupAppearance": "Appearance", + "settings.theme": "Theme", + "settings.themeLight": "Light", + "settings.themeDark": "Dark", + "settings.themeSystem": "System", + "settings.groupLanguage": "Language", + "settings.langFollowSystem": "System", + "settings.langZh": "中文", + "settings.langEn": "English", + "settings.save": "Save", + "settings.readFailed": "Failed to read settings: {msg}", + "settings.saveFailed": "Failed to save settings: {msg}", + + // Welcome / onboarding + "welcome.title": "Welcome to EMRG", + "welcome.sub": "I'm your AI work partner. Two quick steps and you're ready to go.", + "welcome.step1": "Step 1: enter your API Key", + "welcome.step1Hint": "Used to connect to your AI model service", + "welcome.baseUrl": "Base URL", + "welcome.defaultModel": "Default model", + "welcome.step2": "Step 2: choose a working directory (optional)", + "welcome.projectPlaceholder": "Default: ~/.emrg/evolution", + "welcome.step2Hint": "EMRG will work for you in this directory", + "welcome.saveStart": "Save & start", + + // Confirm / rename + "confirm.title": "Confirm", + "confirm.delete": "Delete", + "rename.title": "Rename conversation", + "rename.label": "Give this conversation a memorable name", + "rename.placeholder": "e.g. Weekly report draft", + + // Commands help + "help.title": "/ Commands help", + "help.desc": "Type a / command in the input box, or pick one from the autocomplete menu.", + "help.close": "Close", + + // Sessions / rewind / memory / skills dialogs + "sessions.title": "Switch conversation", + "sessions.desc": "Click to switch, or type /resume to switch directly.", + "rewind.title": "Rewind to a history point", + "rewind.desc": "Choose the message point to keep — later messages will be removed.", + "rewind.cancel": "Cancel", + "memory.title": "Memory", + "memory.desc": "Long-term information EMRG remembers. Click to view details.", + "skills.title": "Skills", + "skills.desc": "Skills EMRG has loaded.", + + // Evolution / tasks + "rant.title": "🧬 Evolve — tell EMRG where to go", + "rant.desc": "Your input drives EMRG's self-evolution — it reads carefully and improves itself.", + "rant.project": "Project (optional)", + "rant.message": "Your thoughts", + "rant.placeholder": "What's not working, what features you want, or how it should improve…", + "rant.submit": "Evolve", + "tasks.title": "Background tasks", + "tasks.desc": "Click a task to trigger it immediately.", + + // / command hints + "cmd.clear.hint": "Clear current conversation", + "cmd.compact.hint": "Compact current conversation history", + "cmd.version.hint": "Show version & instance info", + "cmd.help.hint": "View all commands", + "cmd.image.hint": "Send an image (just paste it)", + "cmd.delete.hint": "Delete current conversation", + "cmd.rename.hint": "Rename current conversation", + "cmd.resume.hint": "Switch / resume a conversation", + "cmd.rewind.hint": "Rewind to a history point", + "cmd.sessions.hint": "View all conversations", + "cmd.model.hint": "Switch model", + "cmd.memory.hint": "Browse memory", + "cmd.skills.hint": "View loaded skills", + "cmd.rant.hint": "Drive EMRG's evolution", + "cmd.trigger.hint": "Trigger a background task", + + // System status copy + "copy.disconnected": "Connection lost — reconnecting…", + "copy.reconnected": "I'm back, let's continue ✦", + "copy.sessionBusy": "Still working on the previous message — one moment", + "copy.sendFailed": "Message not sent — I kept your text, try again?", + "copy.deleteConfirmTitle": "Delete this conversation?", + "copy.deleteConfirmBody": "This cannot be undone.", + "copy.noSessions": "No conversations yet", + "copy.aboutEvolution": "EMRG has self-evolved {n} times — thanks for every bit of feedback", + "copy.aboutEvolutionEmpty": "EMRG is still growing", + // WorkBuddy P3 (#501): growth card + evolution toast + "copy.growthCount": "Self-evolved {n} times", + "copy.growthNote": "Learning as I work — getting better with use", + "copy.evolutionToastTitle": "EMRG just completed a self-evolution!", + "copy.evolutionToastMsg": "That's its {n}th self-improvement — it's even better now.", + "copy.evolutionToastSee": "See details", + "copy.evolutionToastDismiss": "Got it", + + // Tool phrases + "tool.bash.doing": "Running a command…", + "tool.bash.done": "Command run", + "tool.read.doing": "Reading a file…", + "tool.read.done": "File read", + "tool.write.doing": "Writing a file…", + "tool.write.done": "File written", + "tool.edit.doing": "Editing a file…", + "tool.edit.done": "File edited", + "tool.glob.doing": "Finding files…", + "tool.glob.done": "Files found", + "tool.grep.doing": "Searching content…", + "tool.grep.done": "Search done", + "tool.fallback.doing": "Working on it…", + "tool.fallback.done": "Done", + "tool.failText": "That step didn't work — let me try another approach", + }, + }; + + // ── 工具函数 ───────────────────────────── + function detectLocale() { + try { + const lang = (typeof navigator !== "undefined" && navigator.language) || ""; + return /^zh/i.test(lang) ? "zh" : "en"; + } catch { return "zh"; } + } + + function getLocale() { + let saved = null; + try { + if (typeof localStorage !== "undefined") saved = localStorage.getItem(LOCALE_KEY); + } catch { /* ignore */ } + if (saved === "zh" || saved === "en") return saved; + return detectLocale(); + } + + /** 手动覆盖语言:""=跟随系统 / "zh" / "en" */ + function setLocale(loc) { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(LOCALE_KEY, loc || ""); + } + } catch { /* ignore */ } + apply(); + } + + /** 取词:t(key, {var: value});zh 兜底 */ + function t(key, params) { + const dict = DICTS[getLocale()] || DICTS.zh; + let s = key in dict ? dict[key] : (DICTS.zh[key] ?? key); + if (params) { + for (const [k, v] of Object.entries(params)) { + s = s.replaceAll(`{${k}}`, String(v)); + } + } + return s; + } + + /** 应用静态文案:data-i18n / data-i18n-placeholder / data-i18n-title,并刷新动态文案 */ + function apply() { + try { + if (typeof document === "undefined" || !document.querySelectorAll) return; + document.querySelectorAll("[data-i18n]").forEach((node) => { + const key = node.getAttribute("data-i18n"); + if (key) node.textContent = t(key); + }); + document.querySelectorAll("[data-i18n-placeholder]").forEach((node) => { + const key = node.getAttribute("data-i18n-placeholder"); + if (key) node.placeholder = t(key); + }); + document.querySelectorAll("[data-i18n-title]").forEach((node) => { + const key = node.getAttribute("data-i18n-title"); + if (key) node.title = t(key); + }); + } catch { /* 沙箱/低版本环境忽略 */ } + // 刷新动态文案(copywriting 词典重建 + app 动态渲染) + try { + if (typeof window !== "undefined" && window.EMRG_Copy && window.EMRG_Copy.refresh) { + window.EMRG_Copy.refresh(); + } + if (typeof window !== "undefined" && window.App && window.App.refreshLocale) { + window.App.refreshLocale(); + } + } catch { /* ignore */ } + } + + return { DICTS, detectLocale, getLocale, setLocale, t, apply }; +})(); + +window.EMRG_I18N = I18N; diff --git a/emrg/gui/test/app-commands.test.js b/emrg/gui/test/app-commands.test.js index a123f674..832cc0d6 100644 --- a/emrg/gui/test/app-commands.test.js +++ b/emrg/gui/test/app-commands.test.js @@ -125,7 +125,7 @@ function makeSandbox(overrides = {}) { win.window = win; win.document = document; const ctx = vm.createContext(win); - for (const f of ["utils", "commands", "markdown", "copywriting", "chat", "sidebar", "dialogs", "result-panel", "app"]) { + for (const f of ["utils", "i18n", "commands", "markdown", "copywriting", "chat", "sidebar", "dialogs", "result-panel", "app"]) { const code = fs.readFileSync(path.join(RENDERER_JS, f + ".js"), "utf8"); vm.runInContext(code, ctx, { filename: "renderer/js/" + f + ".js" }); } diff --git a/emrg/gui/test/i18n.test.js b/emrg/gui/test/i18n.test.js new file mode 100644 index 00000000..cb5f8646 --- /dev/null +++ b/emrg/gui/test/i18n.test.js @@ -0,0 +1,129 @@ +"use strict"; +/** + * i18n.test.js — 轻量国际化单元测试(rant 21:19 Stage 1)。 + * 覆盖:detectLocale(zh 前缀/其他/无 navigator 兜底)、t()(跟随系统/手动覆盖/插值/未知 key 回退)、 + * setLocale 持久化、apply() 应用 data-i18n 静态文案。 + * 方法:vm.createContext 加载 i18n.js(与 renderer.smoke 同模式)。 + */ + +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const RENDERER_JS = path.join(__dirname, "..", "renderer", "js"); + +/** 构造 i18n 沙箱;默认 en-US 系统 */ +function makeSandbox(overrides = {}) { + const store = new Map(); + const win = { + console, + localStorage: { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + }, + navigator: { language: "en-US" }, + document: { querySelectorAll: () => [], getElementById: () => null }, + ...overrides, + }; + win.window = win; + const ctx = vm.createContext(win); + const code = fs.readFileSync(path.join(RENDERER_JS, "i18n.js"), "utf8"); + vm.runInContext(code, ctx, { filename: "renderer/js/i18n.js" }); + return { ctx, win, store }; +} + +/** 运行表达式并取回值(跨 Realm 逐个字段断言) */ +function evalIn(ctx, expr) { + return vm.runInContext(expr, ctx); +} + +test("detectLocale:zh 前缀 → zh,其他 → en,navigator 异常 → zh 兜底", () => { + const { ctx } = makeSandbox({ navigator: { language: "zh-CN" } }); + assert.strictEqual(evalIn(ctx, "I18N.detectLocale()"), "zh"); + const { ctx: ctx2 } = makeSandbox({ navigator: { language: "en-US" } }); + assert.strictEqual(evalIn(ctx2, "I18N.detectLocale()"), "en"); + // navigator 存在但不可用(null)→ 抛错 → zh 兜底(对应无 navigator 环境) + const { ctx: ctx3 } = makeSandbox({ navigator: null }); + assert.strictEqual(evalIn(ctx3, "I18N.detectLocale()"), "zh"); +}); + +test("t:en 系统默认返回英文文案", () => { + const { ctx } = makeSandbox(); + assert.strictEqual(evalIn(ctx, 'I18N.t("sidebar.newChat")'), "+ New chat"); + assert.strictEqual(evalIn(ctx, 'I18N.t("copy.disconnected")'), "Connection lost — reconnecting…"); +}); + +test("t:zh 系统默认返回中文文案", () => { + const { ctx } = makeSandbox({ navigator: { language: "zh-CN" } }); + assert.strictEqual(evalIn(ctx, 'I18N.t("sidebar.newChat")'), "+ 新对话"); + assert.strictEqual(evalIn(ctx, 'I18N.t("copy.disconnected")'), "连接中断了,正在重新连接…"); +}); + +test("setLocale:手动覆盖 en/zh,localStorage 持久化;空值恢复跟随系统", () => { + const { ctx, store } = makeSandbox({ navigator: { language: "zh-CN" } }); + // zh 系统默认中文 + assert.strictEqual(evalIn(ctx, 'I18N.t("sidebar.newChat")'), "+ 新对话"); + // 手动切英文 → 立即生效 + evalIn(ctx, 'I18N.setLocale("en")'); + assert.strictEqual(evalIn(ctx, 'I18N.t("sidebar.newChat")'), "+ New chat"); + assert.strictEqual(store.get("emrg.locale"), "en", "localStorage 应持久化 en"); + // 切回中文 + evalIn(ctx, 'I18N.setLocale("zh")'); + assert.strictEqual(evalIn(ctx, 'I18N.t("sidebar.newChat")'), "+ 新对话"); + // 恢复跟随系统 → 回到 zh(系统是 zh) + evalIn(ctx, 'I18N.setLocale("")'); + assert.strictEqual(evalIn(ctx, 'I18N.t("sidebar.newChat")'), "+ 新对话"); +}); + +test("t:{var} 插值", () => { + const { ctx } = makeSandbox(); + assert.strictEqual( + evalIn(ctx, 'I18N.t("copy.aboutEvolution", { n: 42 })'), + "EMRG has self-evolved 42 times — thanks for every bit of feedback" + ); + const { ctx: zh } = makeSandbox({ navigator: { language: "zh-CN" } }); + assert.strictEqual( + evalIn(zh, 'I18N.t("copy.aboutEvolution", { n: 7 })'), + "EMRG 已自我成长 7 次,感谢你的每一次反馈" + ); +}); + +test("t:未知 key 回退 zh 词典,仍缺失则原样返回", () => { + const { ctx } = makeSandbox(); + // zh 有而 en 没有的 key(此处所有 key 双语齐全 → 用 zh 兜底语义验证) + assert.strictEqual(evalIn(ctx, 'I18N.t("definitely.missing.key")'), "definitely.missing.key"); +}); + +test("apply:data-i18n / data-i18n-placeholder / data-i18n-title 应用当前语言", () => { + const nodes = { + text: { attrs: { "data-i18n": "sidebar.newChat" }, textContent: "", placeholder: "", title: "", getAttribute(k) { return this.attrs[k] ?? null; } }, + ph: { attrs: { "data-i18n-placeholder": "composer.placeholder" }, textContent: "", placeholder: "", title: "", getAttribute(k) { return this.attrs[k] ?? null; } }, + ti: { attrs: { "data-i18n-title": "composer.send" }, textContent: "", placeholder: "", title: "", getAttribute(k) { return this.attrs[k] ?? null; } }, + }; + const document = { + querySelectorAll(sel) { + if (sel === "[data-i18n]") return [nodes.text]; + if (sel === "[data-i18n-placeholder]") return [nodes.ph]; + if (sel === "[data-i18n-title]") return [nodes.ti]; + return []; + }, + getElementById: () => null, + }; + const { ctx } = makeSandbox({ document, navigator: { language: "en-US" } }); + evalIn(ctx, "I18N.apply()"); + assert.strictEqual(nodes.text.textContent, "+ New chat"); + assert.strictEqual(nodes.ph.placeholder, "Message EMRG… (Enter to send / Shift+Enter for new line)"); + assert.strictEqual(nodes.ti.title, "Send"); +}); + +test("词典完整性:zh 与 en 键集合一致", () => { + const { ctx } = makeSandbox(); + const zhKeys = evalIn(ctx, "Object.keys(I18N.DICTS.zh).sort().join(',')"); + const enKeys = evalIn(ctx, "Object.keys(I18N.DICTS.en).sort().join(',')"); + assert.strictEqual(zhKeys, enKeys, "zh/en 词典键必须一一对应(缺失键会导致 t() 静默回退)"); + const n = zhKeys.split(",").length; + assert.ok(n > 60, `词典应覆盖全部 Stage 1 文案(实际 ${n} 键)`); +}); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 9165e2b3..c540b548 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -136,7 +136,7 @@ function makeSandbox(overrides = {}) { win.window = win; win.document = document; const ctx = vm.createContext(win); - for (const f of ["utils", "commands", "markdown", "copywriting", "chat", "sidebar", "dialogs", "result-panel", "app"]) { + for (const f of ["utils", "i18n", "commands", "markdown", "copywriting", "chat", "sidebar", "dialogs", "result-panel", "app"]) { const code = fs.readFileSync(path.join(RENDERER_JS, f + ".js"), "utf8"); vm.runInContext(code, ctx, { filename: "renderer/js/" + f + ".js" }); }