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
-
+
-
+
@@ -44,40 +44,40 @@
-
✦ 你好,我是 EMRG
-
你的 AI 工作伙伴。问我任何事——写东西、整理文件、规划任务,都可以。
+
✦ 你好,我是 EMRG
+
你的 AI 工作伙伴。问我任何事——写东西、整理文件、规划任务,都可以。
-
-
-
+
+
+
-
-
加载中…
+
+ 加载中…
⌄
-
+
-
-
-
+
+
+
-
EMRG 可能会犯错,请核对重要信息
+
EMRG 可能会犯错,请核对重要信息
@@ -86,50 +86,60 @@
@@ -157,26 +167,26 @@
设置
@@ -184,11 +194,11 @@
欢迎使用 EMRG
@@ -196,13 +206,13 @@
确认
@@ -216,11 +226,11 @@
重命名对话
@@ -228,11 +238,11 @@
/ 指令帮助
@@ -240,11 +250,11 @@
切换对话
@@ -252,12 +262,12 @@
回退到历史消息点
@@ -265,11 +275,11 @@
记忆
@@ -277,17 +287,17 @@
技能
@@ -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" });
}