From 507977a68b355d57cc73643d5fcdc9354a240c29 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 26 Aug 2026 13:59:10 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20gui=20renderer=20Batch=201=20remainder?= =?UTF-8?q?=20=E2=80=94=20i18n=20full=20dictionary=20(373=20keys)=20+=20ma?= =?UTF-8?q?rkdown=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i18n-dicts.ts: full zh/en dictionary auto-generated from vanilla i18n.js (373 keys, strict zh/en parity guard test) - i18n.tsx: vanilla detectLocale/getLocale/setLocale/t logic ported; zh-fallback + localStorage 'emrg.locale' override; React shell keys kept as provider-level extension (vanilla dict stays pure) - markdown.ts: marked/DOMPurify/hljs injectable wrapper (renderMarkdown, block-projection streamProject/streamFinalize, fence-closed heuristic) - vitest: +11 i18n (mirrors old node:test assertions + parity guard), +11 markdown (dual-state fence, escaping, hljs, streaming) → 56 total - Agent.md: renderer 34→56 vitest, Batch 1 remainder documented --- Agent.md | 4 +- emrg/gui/renderer/src/lib/i18n-dicts.ts | 760 +++++++++++++++++++++ emrg/gui/renderer/src/lib/i18n.test.ts | 100 +++ emrg/gui/renderer/src/lib/i18n.tsx | 88 ++- emrg/gui/renderer/src/lib/markdown.test.ts | 163 +++++ emrg/gui/renderer/src/lib/markdown.ts | 225 ++++++ 6 files changed, 1324 insertions(+), 16 deletions(-) create mode 100644 emrg/gui/renderer/src/lib/i18n-dicts.ts create mode 100644 emrg/gui/renderer/src/lib/i18n.test.ts create mode 100644 emrg/gui/renderer/src/lib/markdown.test.ts create mode 100644 emrg/gui/renderer/src/lib/markdown.ts diff --git a/Agent.md b/Agent.md index 9485b0c0..b55b8924 100644 --- a/Agent.md +++ b/Agent.md @@ -86,7 +86,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - Unit tests `npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - - React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t) + - React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t); Batch 1 remainder: i18n.js full dictionary (373 keys zh/en parity, auto-generated `lib/i18n-dicts.ts` + detectLocale/getLocale/setLocale/t) + markdown.js → `lib/markdown.ts` (marked/DOMPurify/hljs injectable, fence-closed heuristic + block-projection streaming) - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -120,7 +120,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` Python: `uv run pytest tests/ -v` (1095) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (34 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting) + `npm run build` → `renderer/dist/` +GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (56 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/src/lib/i18n-dicts.ts b/emrg/gui/renderer/src/lib/i18n-dicts.ts new file mode 100644 index 00000000..53cbb0eb --- /dev/null +++ b/emrg/gui/renderer/src/lib/i18n-dicts.ts @@ -0,0 +1,760 @@ +// Auto-generated from renderer/js/i18n.js (Batch 1 remainder — full dictionary port). +// Source of truth: the vanilla DICTS object (373 keys, zh/en parity). Do not hand-edit; +// regenerate by extracting the DICTS literal from i18n.js. + +/** Full zh dictionary (port of i18n.js DICTS.zh) */ +export const ZH_DICT: Record = { + "sidebar.newChat": "+ 新对话", + "sidebar.newChatTitle": "新对话 (⌘N)", + "sidebar.openChat": "打开会话", + "sidebar.openChatTitle": "打开会话(跨项目)", + "sidebar.statusTitle": "连接状态", + "sidebar.backToBottom": "回到底部", + "nav.sessions": "会话", + "nav.projects": "项目", + "nav.tasks": "任务", + "nav.rants": "Rant", + "nav.settings": "设置", + "projects.title": "项目管理", + "projects.add": "+ 添加项目", + "projects.empty": "暂无项目(点击「添加项目」注册工作目录)", + "projects.added": "项目已添加:{path}", + "projects.addFailed": "添加项目失败:{msg}", + "projects.autoEvolve": "演化", + "projects.viewSessions": "查看会话", + "projects.noSessions": "该项目暂无会话", + "projects.sessionsOf": "{project} 的会话", + "projects.back": "← 返回项目列表", + "projects.current": "当前", + "empty.hello": "你好,我是 EMRG", + "empty.sub": "你的 AI 工作伙伴。问我任何事——写东西、整理文件、规划任务,都可以。", + "empty.example1": "帮我写一份本周的工作周报", + "empty.example2": "帮我整理这个文件夹里的文件", + "empty.example3": "帮我规划一次周末旅行", + "composer.modelLoading": "加载中…", + "composer.switchModel": "切换模型", + "composer.sandboxTitle": "沙箱档位:read-only 只读 / workspace-write 可写工作区 / danger-full-access 全放行", + "composer.sandboxReadOnly": "read-only(只读)", + "composer.sandboxWorkspaceWrite": "workspace-write(可写工作区,默认)", + "composer.sandboxFullAccess": "danger-full-access(全放行)", + "composer.placeholder": "发消息给 EMRG… (Enter 发送 / Shift+Enter 换行)", + "composer.send": "发送", + "composer.stop": "停止回复", + "composer.hint": "EMRG 可能会犯错,请核对重要信息", + "result.collapse": "折叠/展开 (⌘\\)", + "result.empty": "还没有产物,对话中生成的文件会出现在这里", + "result.tabFiles": "文件", + "result.tabArtifacts": "产物", + "result.filesEmpty": "文件浏览器即将推出", + "result.resize": "拖拽调整宽度", + "result.treeLoading": "加载中…", + "result.treeLoadFailed": "加载失败", + "result.viewerLoading": "读取文件…", + "result.viewerBinary": "二进制文件,用系统工具打开", + "result.viewerError": "无法读取文件(可能已被删除或无权限)", + "result.viewerOpen": "用系统工具打开", + "result.htmlPreview": "HTML 预览(内嵌浏览器)", + "settings.title": "设置", + "settings.groupModel": "模型服务", + "settings.apiKey": "API Key", + "settings.baseUrl": "接口地址", + "settings.availableModels": "可用模型", + "settings.addModel": "+ 添加模型", + "settings.name": "名称", + "settings.modelNamePlaceholder": "如 deepseek-v3", + "settings.modelId": "模型 ID(选填)", + "settings.modelIdPlaceholder": "默认同名称", + "settings.vision": "支持图片", + "settings.cancel": "取消", + "settings.aboutTitle": "关于", + "settings.aboutDesc": "EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。", + "settings.recentTitle": "最近改进", + "tasks.title": "任务管理", + "settings.tasksHint": "管理定时任务与自定义任务类型(内置类型只读)", + "settings.taskAdd": "+ 添加任务", + "settings.taskTemplates": "自定义类型", + "settings.taskName": "任务名", + "settings.taskType": "类型", + "settings.taskProject": "项目", + "settings.taskInterval": "间隔(秒,≥60)", + "settings.taskEnabled": "启用", + "settings.taskRepo": "仓库(选填 owner/repo)", + "settings.taskSandbox": "沙箱", + "settings.taskSave": "保存任务", + "settings.taskTrigger": "触发", + "settings.taskEdit": "编辑", + "settings.taskDelete": "删除", + "settings.taskOpenSession": "打开会话", + "settings.taskEmpty": "暂无任务", + "settings.taskBuiltin": "内置", + "settings.taskCustom": "自定义", + "settings.taskDeleteConfirm": "删除任务 {name}?", + "settings.templateName": "类型名", + "settings.templatePrompt": "提示词模板", + "settings.templateSave": "保存类型", + "settings.templateEdit": "编辑", + "settings.templateView": "查看", + "settings.templateDelete": "删除", + "settings.templateEmpty": "暂无自定义类型(内置类型只读)", + "settings.templateDeleteConfirm": "删除自定义类型 {name}?", + "settings.taskSaved": "任务已保存", + "settings.taskDeleted": "任务已删除", + "settings.taskIntervalInvalid": "间隔必须 ≥ 60 秒", + "settings.templateSaved": "类型已保存", + "settings.templateDeleted": "类型已删除", + "settings.templateAdd": "+ 添加类型", + "settings.templateInvalid": "类型名与提示词模板不能为空", + "settings.templateSaveFailed": "类型保存失败:{msg}", + "settings.templateDeleteFailed": "类型删除失败:{msg}", + "settings.saveModel": "保存模型", + "settings.defaultHint": "点左侧圆点可设为默认模型,切换后下一条消息即生效", + "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}", + "settings.groupGithub": "GitHub 连接", + "settings.githubTokenPlaceholder": "GitHub Personal Access Token", + "settings.githubConnect": "连接", + "settings.githubDisconnect": "断开", + "settings.githubHint": "用于自进化推送 PR;授权后自动执行 gh auth setup-git,git 操作不再弹 GCM", + "settings.githubChecking": "检查中…", + "settings.githubConnectedStatus": "已连接 @{user}", + "settings.githubNotConnected": "未连接", + "settings.githubStatusFailed": "状态获取失败", + "settings.githubConnecting": "连接中…", + "settings.githubConnected": "已连接 GitHub:@{user}(gh auth setup-git 已执行)", + "settings.githubConnectFailed": "GitHub 连接失败:{msg}", + "settings.githubDisconnected": "已断开 GitHub 连接", + "settings.githubDisconnectFailed": "断开失败:{msg}", + "settings.githubDeviceTitle": "连接 GitHub", + "settings.githubDeviceDesc": "在浏览器中打开 GitHub 并输入下方一次性代码以完成授权。", + "settings.githubDeviceOpen": "打开浏览器", + "settings.githubDeviceCancel": "取消", + "settings.githubDeviceWait": "等待浏览器中确认…", + "settings.githubDeviceFailed": "GitHub 连接失败:{msg}", + "settings.githubBannerMsg": "启用自进化需连接 GitHub →", + "settings.githubBannerConnect": "去连接", + "settings.githubBannerDismiss": "关闭", + "welcome.title": "欢迎使用 EMRG", + "welcome.sub": "我是你的 AI 工作伙伴。先做两步简单设置,马上就能开始。", + "welcome.step1": "第一步:告诉我 API Key", + "welcome.step1Hint": "用于连接你的 AI 模型服务", + "welcome.baseUrl": "接口地址", + "welcome.defaultModel": "默认模型", + "welcome.saveStart": "保存并开始", + "confirm.title": "确认", + "confirm.delete": "删除", + "rename.title": "重命名对话", + "rename.label": "给这段对话起个好记的名字", + "rename.placeholder": "例如:周报草稿", + "help.title": "/ 指令帮助", + "help.desc": "以 / 开头的指令在输入框执行,或点击补全菜单选择。", + "help.close": "关闭", + "openSession.title": "打开会话", + "openSession.desc": "选择项目后选择要打开的会话(跨项目多开)。", + "openSession.titleProject": "打开会话 — {project}", + "openSession.noProjects": "还没有项目。点下方「新建项目…」选择一个文件夹。", + "openSession.noSessions": "该项目还没有会话,发送第一条消息会自动创建。", + "openSession.loadFailed": "加载失败:{msg}", + "openSession.newProject": "+ 新建项目…", + "openSession.projectCreated": "项目已注册:{path}", + "openSession.projectFailed": "新建项目失败:{msg}", + "newSession.openButton": "+ 新建会话…", + "newSession.title": "新建会话", + "newSession.desc": "选择项目新建会话(跨项目多开)。", + "newSession.newProject": "+ 新建项目…", + "newSession.loadFailed": "加载失败:{msg}", + "newSession.projectFailed": "新建会话失败:{msg}", + "deleteProject.delete": "删除", + "deleteProject.title": "删除项目", + "deleteProject.body": "删除项目「{name}」?只删除项目记录,磁盘数据保留(可重新注册)。", + "deleteProject.protectedBody": "「{name}」是系统项目,不可删除。", + "deleteProject.removed": "项目已删除:{name}", + "deleteProject.failed": "删除项目失败:{msg}", + "relTime.justNow": "刚刚", + "relTime.minutesAgo": "{n} 分钟前", + "relTime.hoursAgo": "{n} 小时前", + "relTime.daysAgo": "{n} 天前", + "rewind.title": "回退到历史消息点", + "rewind.desc": "选择要保留到的消息点,之后的对话将被移除。", + "rewind.cancel": "取消", + "memory.title": "记忆", + "memory.desc": "EMRG 记住的长期信息,点击查看详情。", + "skills.title": "技能", + "skills.desc": "EMRG 已加载的技能。", + "rants.title": "Rant 管理", + "rants.colTime": "时间", + "rants.colProject": "项目", + "rants.colStatus": "状态", + "rants.colProgress": "进度", + "rants.colContent": "内容", + "rants.filterAll": "全部", + "rants.filterPending": "待处理", + "rants.filterInProgress": "进行中", + "rants.filterCompleted": "已完成", + "rants.new": "+ 新建 Rant", + "rants.message": "内容", + "rants.placeholder": "给 EMRG 的反馈或改进建议…", + "rants.submit": "发送", + "rants.project": "项目(选填)", + "rants.empty": "暂无 Rant", + "rants.emptyFiltered": "该状态下暂无 Rant", + "rants.sent": "Rant 已提交(共 {count} 条)", + "rants.sendFailed": "Rant 提交失败:{msg}", + "rants.loadFailed": "Rant 列表加载失败:{msg}", + "rants.statusPending": "待处理", + "rants.statusInProgress": "进行中", + "rants.statusCompleted": "已完成", + "rants.noProgress": "(无进度说明)", + "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.open.hint": "打开会话(跨项目)", + "cmd.model.hint": "切换模型", + "cmd.memory.hint": "浏览记忆", + "cmd.skills.hint": "查看已加载技能", + "cmd.rant.hint": "驱动 EMRG 进化", + "cmd.trigger.hint": "触发后台任务", + "copy.disconnected": "连接中断了,正在重新连接…", + "copy.reconnected": "回来了,我们继续 ✦", + "copy.sessionBusy": "我还在处理上一条,稍等一下哦", + "copy.sendFailed": "没发送成功,你的话我还留着,再试一次?", + "copy.deleteConfirmTitle": "删除这段对话?", + "copy.deleteConfirmBody": "删除后无法恢复。", + "copy.noSessions": "还没有对话", + "copy.aboutEvolution": "EMRG 已自我成长 {n} 次,感谢你的每一次反馈", + "copy.aboutEvolutionEmpty": "EMRG 正在成长中", + "copy.growthCardTitle": "EMRG 每次自我改进都会自动汇报", + "copy.growthCount": "已自我进化 {n} 次", + "copy.growthCountPrefix": "已自我进化", + "copy.times": "次", + "copy.growthNote": "边工作边学习,越用越懂你", + "copy.evolutionToastTitle": "EMRG 刚刚完成一次自我进化!", + "copy.evolutionToastMsg": "这是它的第 {n} 次自我改进,现在更好用了。", + "copy.evolutionToastMsgStatic": "它学会了新的东西,变得更好用了。", + "copy.evolutionToastSee": "去看看", + "copy.evolutionToastDismiss": "知道了", + "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": "这一步没成功,我换个方法试试", + "chat.copyCode": "复制", + "chat.copied": "已复制 ✓", + "chat.copyFailed": "复制失败", + "chat.fromOtherClient": "(来自其他客户端)", + "chat.timeoutWarn": "⚠️ 响应超时了,我还没说完——可以再试一次。", + "chat.maxRoundsHint": "⚠️ 这次任务因为工具调用次数上限中断了,结果可能不完整——输入“继续”我可以接着做。", + "chat.elapsed": "耗时 {s}", + "chat.expand": "展开全文", + "chat.toolGroupSummary": "{count} 个工具执行 · {time}", + "md.copyCode": "复制代码", + "dlg.notConfigured": "未配置", + "dlg.noOtherModels": "暂无其他模型,点下方按钮添加", + "dlg.currentDefault": "当前默认模型", + "dlg.setDefault": "设为默认", + "dlg.defaultBadge": "默认", + "dlg.supportsImages": "🖼 支持图片", + "dlg.edit": "编辑", + "dlg.delete": "删除", + "dlg.deleteModelTitle": "删除这个模型?", + "dlg.deleteModelBody": "「{name}」将从可用模型里移除。", + "dlg.stepTitle": "还差一步", + "dlg.nameRequiredBody": "模型名称是必填的哦。", + "dlg.duplicateTitle": "重名了", + "dlg.duplicateBody": "这个名称已经有模型了,换个名字吧。", + "dlg.duplicateDefaultBody": "这个名称是默认模型,请在默认行编辑。", + "dlg.gotIt": "知道了", + "app.authKeyRequired": "API Key 是连接模型服务必需的,填上才能继续哦。", + "dlg.saved": "设置已保存。", + "dlg.loading": "加载中…", + "dlg.starting": "设置完成,正在启动…", + "dlg.initFailed": "初始化失败了:{msg}", + "dlg.renameFailed": "重命名失败了:{msg}", + "app.bootFailed": "启动遇到了问题:{msg}", + "app.needSession": "请先创建一个对话。", + "app.queued": "⏳ 已排队(位置 {pos})— 当前回合结束后处理。", + "app.queuedResent": "→ 已重新发送 {n} 条排队消息。", + "app.queuedCancelled": "⏹ 排队消息已取消。", + "app.recentImprovements": "最近改进", + "app.noImprovements": "还没有改进记录,输入 /rant 驱动第一次进化吧", + "app.cmdUnknown": "指令 {cmd} 暂未开放。", + "app.cleared": "已清空当前对话。", + "app.compacted": "已压缩当前对话历史。", + "app.imagePaste": "请直接粘贴图片到输入框(Ctrl+V / ⌘V)。", + "app.cmdFailed": "指令 {cmd} 执行失败:{msg}", + "app.unnamed": "(未命名)", + "app.current": "当前", + "app.noHistory": "没有可回退的历史消息。", + "app.historyFailed": "加载历史失败:{msg}", + "app.historyLoadMore": "↑ 加载更早消息", + "app.historyNoMore": "没有更多历史", + "app.rewound": "已回退到消息点 #{index},移除了 {n} 条记录。", + "app.rewindFailed": "回退失败:{msg}", + "app.noMemories": "还没有{scope}记忆。", + "app.sessionMem": "会话", + "app.projectMem": "项目", + "app.readMemFailed": "读取记忆失败:{msg}", + "app.memFailed": "加载记忆失败:{msg}", + "app.noSkills": "还没有加载技能。", + "app.skillsFailed": "加载技能失败:{msg}", + "app.rantEmpty": "写点内容再提交吧。", + "app.rantReceived": "✓ 收到!EMRG 会据此进化。", + "app.rantCount": "(已累计 {n} 条反馈)", + "app.rantFailed": "提交失败了:{msg}", + "app.taskDisabled": "已停用", + "app.taskInterval": "间隔 {n}s", + "app.taskNextRun": "下次运行 {n}", + "app.taskPendingBadge": "待运行", + "app.taskIdleBadge": "待调度", + "app.tasksFailed": "加载任务失败:{msg}", + "app.triggerFailed": "触发失败:{msg}", + "app.triggered": "已触发任务 {n}。", + "app.taskRunning": "任务 {n} 正在运行中(无法重复触发)", + "app.taskRunningBadge": "运行中", + "app.taskRunningDuration": "已运行 {n}", + "app.taskLastRun": "上次运行:{n}", + "app.taskNoRunYet": "尚未运行", + "app.taskThrottled": "已降频 · heartbeat {m}s", + "app.taskRunsEmpty": "暂无运行记录", + "app.taskRunsColTime": "时间", + "app.taskRunsColDone": "干了什么", + "app.taskRunsColThrottle": "降频", + "app.taskRunsColReason": "原因", + "app.taskRunThrottle": "建议降频", + "app.deletedSwitch": "这个对话已被删除,已帮你切到最近的对话。", + "app.switched": "已切换对话。", + "app.sessionDisconnected": "该会话连接已断开,正在自动重连…", + "app.switchFailed": "切换对话失败了:{msg}", + "app.tooManyOpenSessions": "打开的会话已达上限(20),请先关闭一些再打开。", + "app.newFailed": "新建对话失败了:{msg}", + "app.deleteFailed": "删除失败了:{msg}", + "app.rename": "✏️ 重命名", + "app.deleteConv": "🗑 删除对话", + "app.closeSession": "❌ 关闭会话(保留数据)", + "app.closeFailed": "关闭会话失败了:{msg}", + "sidebar.openSessions": "打开的会话", + "app.noModels": "还没有配置模型", + "app.goSettings": "去设置添加", + "app.modelSwitchFailed": "切换模型失败了:{msg}", + "app.modelListFailed": "读取模型列表失败了:{msg}", + "app.upgradeBannerMsg": "EMRG 已升级到 {version},重启后生效", + "app.upgradeBannerMsgFromTo": "EMRG 已从 {from} 升级到 {to},重启后生效", + "app.upgradeRestartFailed": "重启失败:{msg}", + "app.upgradeRestartBtn": "重启生效", + "app.unknownResult": "结果未知——连接中断", + "app.error": "出了点问题:{msg}", + "app.unknownError": "未知错误", + "app.unknown": "未知", + "app.authFailed": "认证失败了,请检查设置里的 API Key。", + "app.daemonStopped": "daemon 启动失败(已停止自动重试)。请在终端运行 `emrg server` 排查;\n{msg}", + "app.versionInfo": "EMRG GUI v{ver} · 实例 {id} · 模型 {model} · 已进化 {n} 次", + "errorBoundary.title": "渲染器出错", + "errorBoundary.copy": "复制错误", + "errorBoundary.reload": "重新加载" +}; + +/** Full en dictionary (port of i18n.js DICTS.en) */ +export const EN_DICT: Record = { + "sidebar.newChat": "+ New chat", + "sidebar.newChatTitle": "New chat (⌘N)", + "sidebar.openChat": "Open session", + "sidebar.openChatTitle": "Open session (cross-project)", + "sidebar.statusTitle": "Connection status", + "sidebar.backToBottom": "Back to bottom", + "nav.sessions": "Sessions", + "nav.projects": "Projects", + "nav.tasks": "Tasks", + "nav.rants": "Rant", + "nav.settings": "Settings", + "projects.title": "Project Management", + "projects.add": "+ Add project", + "projects.empty": "No projects yet (click “Add project” to register a working directory)", + "projects.added": "Project added: {path}", + "projects.addFailed": "Failed to add project: {msg}", + "projects.autoEvolve": "Evolve", + "projects.viewSessions": "Sessions", + "projects.noSessions": "No sessions for this project yet", + "projects.sessionsOf": "Sessions of {project}", + "projects.back": "← Back to project list", + "projects.current": "Current", + "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.modelLoading": "Loading…", + "composer.switchModel": "Switch model", + "composer.sandboxTitle": "Sandbox tier: read-only / workspace-write / danger-full-access", + "composer.sandboxReadOnly": "read-only", + "composer.sandboxWorkspaceWrite": "workspace-write (default)", + "composer.sandboxFullAccess": "danger-full-access", + "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.collapse": "Collapse/expand (⌘\\)", + "result.empty": "No artifacts yet — files generated in the conversation will appear here", + "result.tabFiles": "Files", + "result.tabArtifacts": "Artifacts", + "result.filesEmpty": "File browser coming soon", + "result.resize": "Drag to resize", + "result.treeLoading": "Loading…", + "result.treeLoadFailed": "Failed to load", + "result.viewerLoading": "Reading file…", + "result.viewerBinary": "Binary file — open with system tool", + "result.viewerError": "Cannot read file (deleted or no permission)", + "result.viewerOpen": "Open with system tool", + "result.htmlPreview": "HTML preview (embedded browser)", + "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.modelNamePlaceholder": "e.g. deepseek-v3", + "settings.modelId": "Model ID (optional)", + "settings.modelIdPlaceholder": "Defaults to name", + "settings.vision": "Supports images", + "settings.cancel": "Cancel", + "settings.aboutTitle": "About", + "settings.aboutDesc": "EMRG is a self-evolving AI agent — it reports every improvement, and you can watch it grow right here.", + "settings.recentTitle": "Recent improvements", + "tasks.title": "Task Management", + "settings.tasksHint": "Manage scheduled tasks and custom task types (builtin types are read-only)", + "settings.taskAdd": "+ Add task", + "settings.taskTemplates": "Custom types", + "settings.taskName": "Task name", + "settings.taskType": "Type", + "settings.taskProject": "Project", + "settings.taskInterval": "Interval (sec, ≥60)", + "settings.taskEnabled": "Enabled", + "settings.taskRepo": "Repo (optional owner/repo)", + "settings.taskSandbox": "Sandbox", + "settings.taskSave": "Save task", + "settings.taskTrigger": "Trigger", + "settings.taskEdit": "Edit", + "settings.taskDelete": "Delete", + "settings.taskOpenSession": "Open session", + "settings.taskEmpty": "No tasks", + "settings.taskBuiltin": "builtin", + "settings.taskCustom": "custom", + "settings.taskDeleteConfirm": "Delete task {name}?", + "settings.templateName": "Type name", + "settings.templatePrompt": "Prompt template", + "settings.templateSave": "Save type", + "settings.templateEdit": "Edit", + "settings.templateView": "View", + "settings.templateDelete": "Delete", + "settings.templateEmpty": "No custom types (builtin types are read-only)", + "settings.templateDeleteConfirm": "Delete custom type {name}?", + "settings.taskSaved": "Task saved", + "settings.taskDeleted": "Task deleted", + "settings.taskIntervalInvalid": "Interval must be at least 60 seconds", + "settings.templateSaved": "Type saved", + "settings.templateDeleted": "Type deleted", + "settings.templateAdd": "+ Add type", + "settings.templateInvalid": "Type name and prompt template must not be empty", + "settings.templateSaveFailed": "Failed to save type: {msg}", + "settings.templateDeleteFailed": "Failed to delete type: {msg}", + "settings.saveModel": "Save model", + "settings.defaultHint": "Click the dot on the left to set the default model — takes effect on the next message", + "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}", + "settings.groupGithub": "GitHub connection", + "settings.githubTokenPlaceholder": "GitHub Personal Access Token", + "settings.githubConnect": "Connect", + "settings.githubDisconnect": "Disconnect", + "settings.githubHint": "Used for self-evolution PR pushes; runs gh auth setup-git after auth so git operations never pop up GCM", + "settings.githubChecking": "Checking…", + "settings.githubConnectedStatus": "Connected as @{user}", + "settings.githubNotConnected": "Not connected", + "settings.githubStatusFailed": "Failed to load status", + "settings.githubConnecting": "Connecting…", + "settings.githubConnected": "Connected to GitHub: @{user} (gh auth setup-git done)", + "settings.githubConnectFailed": "GitHub connect failed: {msg}", + "settings.githubDisconnected": "Disconnected from GitHub", + "settings.githubDisconnectFailed": "Disconnect failed: {msg}", + "settings.githubDeviceTitle": "Connect GitHub", + "settings.githubDeviceDesc": "Open GitHub in your browser and enter the one-time code below to finish authorization.", + "settings.githubDeviceOpen": "Open browser", + "settings.githubDeviceCancel": "Cancel", + "settings.githubDeviceWait": "Waiting for confirmation in browser…", + "settings.githubDeviceFailed": "GitHub connect failed: {msg}", + "settings.githubBannerMsg": "Connect GitHub to enable self-evolution →", + "settings.githubBannerConnect": "Connect", + "settings.githubBannerDismiss": "Dismiss", + "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.saveStart": "Save & start", + "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", + "help.title": "/ Commands help", + "help.desc": "Type a / command in the input box, or pick one from the autocomplete menu.", + "help.close": "Close", + "openSession.title": "Open session", + "openSession.desc": "Pick a project, then pick a session to open (multi-project tabs).", + "openSession.titleProject": "Open session — {project}", + "openSession.noProjects": "No projects yet. Use \"+ New project…\" below to pick a folder.", + "openSession.noSessions": "No sessions in this project yet — the first message creates one.", + "openSession.loadFailed": "Failed to load: {msg}", + "openSession.newProject": "+ New project…", + "openSession.projectCreated": "Project registered: {path}", + "openSession.projectFailed": "Failed to create project: {msg}", + "newSession.openButton": "+ New session…", + "newSession.title": "New session", + "newSession.desc": "Pick a project to create a new session (multi-project tabs).", + "newSession.newProject": "+ New project…", + "newSession.loadFailed": "Failed to load: {msg}", + "newSession.projectFailed": "Failed to create session: {msg}", + "deleteProject.delete": "Delete", + "deleteProject.title": "Delete project", + "deleteProject.body": "Delete project \"{name}\"? Only the project record is removed; on-disk data is kept (re-registerable).", + "deleteProject.protectedBody": "\"{name}\" is a system project and cannot be deleted.", + "deleteProject.removed": "Project removed: {name}", + "deleteProject.failed": "Failed to delete project: {msg}", + "relTime.justNow": "just now", + "relTime.minutesAgo": "{n} min ago", + "relTime.hoursAgo": "{n} h ago", + "relTime.daysAgo": "{n} d ago", + "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.", + "rants.title": "Rant Management", + "rants.colTime": "Time", + "rants.colProject": "Project", + "rants.colStatus": "Status", + "rants.colProgress": "Progress", + "rants.colContent": "Content", + "rants.filterAll": "All", + "rants.filterPending": "Pending", + "rants.filterInProgress": "In progress", + "rants.filterCompleted": "Completed", + "rants.new": "+ New rant", + "rants.message": "Message", + "rants.placeholder": "Feedback or improvement ideas for EMRG…", + "rants.submit": "Send", + "rants.project": "Project (optional)", + "rants.empty": "No rants yet", + "rants.emptyFiltered": "No rants in this state", + "rants.sent": "Rant submitted ({count} total)", + "rants.sendFailed": "Failed to submit rant: {msg}", + "rants.loadFailed": "Failed to load rants: {msg}", + "rants.statusPending": "Pending", + "rants.statusInProgress": "In progress", + "rants.statusCompleted": "Completed", + "rants.noProgress": "(no progress note)", + "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.open.hint": "Open session (cross-project)", + "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", + "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", + "copy.growthCardTitle": "EMRG reports every self-improvement automatically", + "copy.growthCount": "Self-evolved {n} times", + "copy.growthCountPrefix": "Self-evolved", + "copy.times": "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.evolutionToastMsgStatic": "It learned something new and is even better now.", + "copy.evolutionToastSee": "See details", + "copy.evolutionToastDismiss": "Got it", + "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", + "chat.copyCode": "Copy", + "chat.copied": "Copied ✓", + "chat.copyFailed": "Copy failed", + "chat.fromOtherClient": "(from another client)", + "chat.timeoutWarn": "⚠️ Response timed out — I wasn't done. Try again?", + "chat.maxRoundsHint": "⚠️ This task hit the tool-call limit and may be incomplete — type \"continue\" and I'll keep going.", + "chat.elapsed": "took {s}", + "chat.expand": "Expand full text", + "chat.toolGroupSummary": "{count} tool calls · {time}", + "md.copyCode": "Copy code", + "dlg.notConfigured": "Not configured", + "dlg.noOtherModels": "No other models — add one below", + "dlg.currentDefault": "Current default model", + "dlg.setDefault": "Set as default", + "dlg.defaultBadge": "Default", + "dlg.supportsImages": "🖼 Supports images", + "dlg.edit": "Edit", + "dlg.delete": "Delete", + "dlg.deleteModelTitle": "Delete this model?", + "dlg.deleteModelBody": "\"{name}\" will be removed from available models.", + "dlg.stepTitle": "One more step", + "dlg.nameRequiredBody": "A model name is required.", + "dlg.duplicateTitle": "Name taken", + "dlg.duplicateBody": "A model with this name already exists — try another.", + "dlg.duplicateDefaultBody": "This is the default model — edit it in the default row.", + "dlg.gotIt": "Got it", + "app.authKeyRequired": "An API Key is required to connect to the model service — fill it in to continue.", + "dlg.saved": "Settings saved.", + "dlg.loading": "Loading…", + "dlg.starting": "Settings saved — starting…", + "dlg.initFailed": "Initialization failed: {msg}", + "dlg.renameFailed": "Rename failed: {msg}", + "app.bootFailed": "Startup failed: {msg}", + "app.needSession": "Start a conversation first.", + "app.queued": "⏳ Queued (position {pos}) — will run after the current turn.", + "app.queuedResent": "→ Re-sent {n} queued message(s).", + "app.queuedCancelled": "⏹ Queued message(s) cancelled.", + "app.recentImprovements": "Recent improvements", + "app.noImprovements": "No improvements recorded yet — type /rant to drive the first evolution", + "app.cmdUnknown": "Command {cmd} is not available yet.", + "app.cleared": "Current conversation cleared.", + "app.compacted": "Conversation history compacted.", + "app.imagePaste": "Paste the image into the input (Ctrl+V / ⌘V).", + "app.cmdFailed": "Command {cmd} failed: {msg}", + "app.unnamed": "(untitled)", + "app.current": "current", + "app.noHistory": "No history points to rewind to.", + "app.historyFailed": "Failed to load history: {msg}", + "app.historyLoadMore": "↑ Load earlier messages", + "app.historyNoMore": "No more history", + "app.rewound": "Rewound to message point #{index}, removed {n} records.", + "app.rewindFailed": "Rewind failed: {msg}", + "app.noMemories": "No {scope} memories yet.", + "app.sessionMem": "conversation", + "app.projectMem": "project", + "app.readMemFailed": "Failed to read memory: {msg}", + "app.memFailed": "Failed to load memories: {msg}", + "app.noSkills": "No skills loaded yet.", + "app.skillsFailed": "Failed to load skills: {msg}", + "app.rantEmpty": "Write something first.", + "app.rantReceived": "✓ Got it! EMRG will evolve accordingly.", + "app.rantCount": "({n} feedback items so far)", + "app.rantFailed": "Submission failed: {msg}", + "app.taskDisabled": "Disabled", + "app.taskInterval": "every {n}s", + "app.taskNextRun": "next run in {n}", + "app.taskPendingBadge": "queued", + "app.taskIdleBadge": "not scheduled", + "app.tasksFailed": "Failed to load tasks: {msg}", + "app.triggerFailed": "Trigger failed: {msg}", + "app.triggered": "Task {n} triggered.", + "app.taskRunning": "Task {n} is running (cannot re-trigger)", + "app.taskRunningBadge": "running", + "app.taskRunningDuration": "running for {n}", + "app.taskLastRun": "last run: {n}", + "app.taskNoRunYet": "never ran", + "app.taskThrottled": "throttled · {m}s heartbeat", + "app.taskRunsEmpty": "No run records yet", + "app.taskRunsColTime": "Time", + "app.taskRunsColDone": "What was done", + "app.taskRunsColThrottle": "Throttle", + "app.taskRunsColReason": "Reason", + "app.taskRunThrottle": "slowdown advised", + "app.deletedSwitch": "This conversation was deleted — switched to the most recent one.", + "app.switched": "Conversation switched.", + "app.sessionDisconnected": "This session's connection is lost — reconnecting automatically…", + "app.switchFailed": "Failed to switch: {msg}", + "app.tooManyOpenSessions": "Too many open sessions (20) — close some first.", + "app.newFailed": "Failed to create conversation: {msg}", + "app.deleteFailed": "Delete failed: {msg}", + "app.rename": "✏️ Rename", + "app.deleteConv": "🗑 Delete conversation", + "app.closeSession": "❌ Close session (keep data)", + "app.closeFailed": "Failed to close session: {msg}", + "sidebar.openSessions": "Open sessions", + "app.noModels": "No models configured", + "app.goSettings": "Add in Settings", + "app.modelSwitchFailed": "Failed to switch model: {msg}", + "app.modelListFailed": "Failed to load models: {msg}", + "app.upgradeBannerMsg": "EMRG upgraded to {version} — restart to apply", + "app.upgradeBannerMsgFromTo": "EMRG upgraded from {from} to {to} — restart to apply", + "app.upgradeRestartFailed": "Restart failed: {msg}", + "app.upgradeRestartBtn": "Restart to apply", + "app.unknownResult": "Result unknown — connection lost", + "app.error": "Something went wrong: {msg}", + "app.unknownError": "Unknown error", + "app.unknown": "unknown", + "app.authFailed": "Authentication failed — check your API Key in Settings.", + "app.daemonStopped": "daemon failed to start (auto-retry stopped). Run `emrg server` in a terminal to debug;\n{msg}", + "app.versionInfo": "EMRG GUI v{ver} · Instance {id} · Model {model} · Evolved {n} times", + "errorBoundary.title": "Renderer error", + "errorBoundary.copy": "Copy error", + "errorBoundary.reload": "Reload" +}; + +/** Merged dicts keyed by locale (mirrors vanilla DICTS shape) */ +export const DICTS: Record> = { zh: ZH_DICT, en: EN_DICT }; diff --git a/emrg/gui/renderer/src/lib/i18n.test.ts b/emrg/gui/renderer/src/lib/i18n.test.ts new file mode 100644 index 00000000..bd8e3f6b --- /dev/null +++ b/emrg/gui/renderer/src/lib/i18n.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { DICTS, EN_DICT, ZH_DICT, detectLocale, getLocale, setLocale, t, LOCALE_KEY } from "./i18n"; + +/** + * i18n.test.ts — 完整词典 + 原生逻辑移植测试(Batch 1 remainder)。 + * 断言镜像旧 emrg/gui/test/i18n.test.js(node:test → Vitest), + * 并新增词典完整性守卫(373 keys zh/en 严格对齐 —— 防未来词典漂移)。 + */ + +describe("detectLocale", () => { + it("zh 前缀 → zh,其他 → en", () => { + expect(detectLocale("zh-CN")).toBe("zh"); + expect(detectLocale("zh-Hant")).toBe("zh"); + expect(detectLocale("en-US")).toBe("en"); + expect(detectLocale("ja-JP")).toBe("en"); + }); + it("无参(缺省 navigator.language)→ en 或 zh 之一,不抛错", () => { + expect(["zh", "en"]).toContain(detectLocale()); + }); +}); + +describe("t(词典取词)", () => { + it("en 语言返回英文文案", () => { + expect(t("sidebar.newChat", undefined, "en")).toBe("+ New chat"); + expect(t("copy.disconnected", undefined, "en")).toBe("Connection lost — reconnecting…"); + }); + it("zh 语言返回中文文案", () => { + expect(t("sidebar.newChat", undefined, "zh")).toBe("+ 新对话"); + expect(t("copy.disconnected", undefined, "zh")).toBe("连接中断了,正在重新连接…"); + }); + it("{var} 插值", () => { + expect(t("copy.aboutEvolution", { n: 42 }, "en")).toBe( + "EMRG has self-evolved 42 times — thanks for every bit of feedback", + ); + expect(t("copy.aboutEvolution", { n: 7 }, "zh")).toBe("EMRG 已自我成长 7 次,感谢你的每一次反馈"); + }); + it("未知 key → 原样返回", () => { + expect(t("definitely.missing.key", undefined, "en")).toBe("definitely.missing.key"); + }); +}); + +describe("getLocale / setLocale(localStorage 覆盖)", () => { + beforeEach(() => { + try { + localStorage.removeItem(LOCALE_KEY); + } catch { + /* jsdom 不可用时忽略 */ + } + }); + + it("无覆盖 → 跟随系统检测", () => { + // jsdom 默认 en-US;显式断言 getLocale 返回合法值即可 + expect(["zh", "en"]).toContain(getLocale()); + }); + + it("setLocale 持久化到 localStorage 并立即生效", () => { + const l = setLocale("en"); + expect(l).toBe("en"); + try { + expect(localStorage.getItem(LOCALE_KEY)).toBe("en"); + } catch { + /* 无 localStorage 环境跳过持久化断言 */ + } + expect(t("sidebar.newChat", undefined, getLocale())).toBe("+ New chat"); + }); + + it("空值恢复跟随系统", () => { + setLocale("en"); + setLocale(""); + const l = getLocale(); + expect(["zh", "en"]).toContain(l); + }); +}); + +describe("词典完整性守卫(防漂移)", () => { + it("zh/en 各 373 个 key 且完全对齐", () => { + const zhKeys = Object.keys(ZH_DICT); + const enKeys = Object.keys(EN_DICT); + expect(zhKeys.length).toBe(373); + expect(enKeys.length).toBe(373); + expect(zhKeys.sort()).toEqual(enKeys.sort()); + // DICTS 聚合结构 + expect(Object.keys(DICTS)).toEqual(["zh", "en"]); + }); + it("关键分区 key 存在(侧边栏/输入区/设置/工具/聊天/错误边界)", () => { + for (const key of [ + "sidebar.newChat", + "nav.sessions", + "composer.placeholder", + "settings.title", + "tool.bash.doing", + "chat.copyCode", + "md.copyCode", + "errorBoundary.title", + ]) { + expect(ZH_DICT[key]).toBeTruthy(); + expect(EN_DICT[key]).toBeTruthy(); + } + }); +}); diff --git a/emrg/gui/renderer/src/lib/i18n.tsx b/emrg/gui/renderer/src/lib/i18n.tsx index 531d6853..8cb48563 100644 --- a/emrg/gui/renderer/src/lib/i18n.tsx +++ b/emrg/gui/renderer/src/lib/i18n.tsx @@ -1,10 +1,13 @@ import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { DICTS } from "./i18n-dicts"; /** - * i18n.ts — React i18n context(Batch 0 骨架)。 - * 设计文档 §5 Batch 0 项 3:i18n context 先就位;完整词典(934 行 zh/en) - * 原样迁移在 Batch 1(i18n.js → lib/i18n.ts),届时 dicts prop 传入真实词典。 - * 本文件仅含 Shell 占位所需的最小词典 + {n} 占位替换。 + * i18n.tsx — React i18n context(Batch 1 remainder:完整词典 + 原生逻辑移植)。 + * 设计文档 §5 Batch 1 项 3:i18n.js → lib/i18n.ts,词典原样搬,detectLocale/apply 逻辑保留。 + * - 完整词典 373 keys(zh/en 严格对齐)位于 ./i18n-dicts.ts(由 vanilla i18n.js 机械生成) + * - t(key, params):zh 兜底 + {var} 插值(与 vanilla t() 语义一致) + * - detectLocale/getLocale/setLocale:navigator 检测 + localStorage "emrg.locale" 覆盖 + * - I18nProvider 仍支持注入自定义词典(测试/主题覆盖),与默认全词典合并 */ export type TranslateFn = (key: string, params?: Record) => string; @@ -13,8 +16,14 @@ interface I18nContextValue { t: TranslateFn; } -/** Batch 0 最小词典(完整词典 Batch 1 迁移) */ -const DEFAULT_DICTS: Record> = { +export const LOCALE_KEY = "emrg.locale"; +export type Locale = "zh" | "en"; + +/** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */ +export { DICTS, ZH_DICT, EN_DICT } from "./i18n-dicts"; + +/** React 骨架专属 key(vanilla 词典无此分区;Batch 2+ 聊天区落地后并入组件词典) */ +const SHELL_DICT: Record> = { zh: { "shell.batch0Notice": "EMRG React 骨架(Batch 0)— 迁移进行中", "shell.placeholder": "React 组件树迁移分 6 批进行(Batch 0–5),当前为基建批次。", @@ -25,11 +34,62 @@ const DEFAULT_DICTS: Record> = { }, }; +/** 默认全词典 = vanilla 373 keys + React 骨架扩展 */ +export const DEFAULT_DICTS: Record> = { + zh: { ...DICTS.zh, ...SHELL_DICT.zh }, + en: { ...DICTS.en, ...SHELL_DICT.en }, +}; + const I18nContext = createContext(null); -function detectLang(lang?: string): "zh" | "en" { - const l = (lang ?? (typeof navigator !== "undefined" ? navigator.language : "zh-CN")) || ""; - return l.toLowerCase().startsWith("zh") ? "zh" : "en"; +/** navigator.language 检测(zh* → 中文,其他 → 英文);navigator 异常 → zh 兜底(vanilla 同语义) */ +export function detectLocale(lang?: string): Locale { + try { + const l = (lang ?? (typeof navigator !== "undefined" ? navigator.language : "")) || ""; + return /^zh/i.test(l) ? "zh" : "en"; + } catch { + return "zh"; + } +} + +/** localStorage 手动覆盖(""=跟随系统);不可用时回退系统检测 */ +export function getLocale(): Locale { + let saved: string | null = null; + try { + if (typeof localStorage !== "undefined") saved = localStorage.getItem(LOCALE_KEY); + } catch { + /* ignore */ + } + if (saved === "zh" || saved === "en") return saved; + return detectLocale(); +} + +/** 手动覆盖语言:""=跟随系统 / "zh" / "en";返回生效的 locale */ +export function setLocale(loc: "" | Locale): Locale { + try { + if (typeof localStorage !== "undefined") localStorage.setItem(LOCALE_KEY, loc || ""); + } catch { + /* ignore */ + } + return getLocale(); +} + +/** 取词:t(key, {var: value});当前语言缺失 → zh 兜底 → 原样返回 key(vanilla 同语义) */ +export function t( + key: string, + params?: Record, + lang?: string, + dicts: Record> = DICTS, +): string { + const locale = (lang as Locale) || getLocale(); + const dict = dicts[locale] || 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; } function interpolate(template: string, params?: Record): string { @@ -44,16 +104,16 @@ export function I18nProvider({ }: { /** 语言提示(缺省用 navigator.language;测试可注入) */ lang?: string; - /** 自定义词典(Batch 1 起传入完整 zh/en 词典;与默认词典合并) */ + /** 自定义词典(与默认全词典合并;测试/主题覆盖用) */ dicts?: Record>; children: ReactNode; }) { - const l = detectLang(lang); + const l = detectLocale(lang); const value = useMemo(() => { const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) }; return { lang: l, - t: (key, params) => interpolate(dict[key] ?? key, params), + t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params), }; }, [l, dicts]); return {children}; @@ -63,10 +123,10 @@ export function I18nProvider({ export function useI18n(): I18nContextValue { const ctx = useContext(I18nContext); if (ctx) return ctx; - const l = detectLang(); + const l = detectLocale(); const dict = DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en; return { lang: l, - t: (key, params) => interpolate(dict[key] ?? key, params), + t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params), }; } diff --git a/emrg/gui/renderer/src/lib/markdown.test.ts b/emrg/gui/renderer/src/lib/markdown.test.ts new file mode 100644 index 00000000..bb1a5b7e --- /dev/null +++ b/emrg/gui/renderer/src/lib/markdown.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { FENCE_END_RE, isFenceClosed, createMarkdownRenderer, type MarkedLike } from "./markdown"; + +/** + * markdown.test.ts — marked/DOMPurify/highlight 封装纯逻辑测试(Batch 1 remainder)。 + * 覆盖:围栏闭合判定(开/闭双态)、无 marked 降级转义、注入 fake marked 渲染 + DOMPurify 消毒、 + * code renderer 转义(无 hljs)/高亮(有 hljs)、块投影流式(jsdom)。 + */ + +/** 最小 fake marked:code renderer 走 use() 注册,lexer 返回稳定块 + live 块 */ +function makeFakeMarked(opts?: { hljs?: unknown; dompurify?: unknown }): { + marked: MarkedLike; + dompurify: { sanitize: (h: string) => string }; + hljs: { value: string }; +} { + let codeRenderer: ((code: string, infostring: string, escaped: boolean) => string) | null = null; + const hljs = { value: "x" }; + const dompurify = { sanitize: (h: string) => h.replace(//gi, "") }; + const marked: MarkedLike = { + parse(text, _opts) { + // 模拟 marked:```lang\nbody``` → codeRenderer(body, lang, false);否则段落 + if (codeRenderer) { + const m = /^```([\w-]*)[ \t]*\n?([\s\S]*?)(?:```)?$/.exec(text); + if (m) return `
${codeRenderer(m[2], m[1] ?? "", false)}
`; + return `
${codeRenderer(text, "", false)}
`; + } + return `

${text}

`; + }, + lexer(text) { + // 两个块:稳定段落 + 尾部 live 代码块 + return [ + { type: "paragraph", raw: "hello", text: "hello" }, + { type: "code", raw: "```js\nconst a=1;\n```", text: "const a=1;" }, + ]; + }, + parser(tokens) { + return tokens + .map((tok) => { + if (tok.type === "code") { + return codeRenderer + ? codeRenderer(String(tok.text ?? ""), "js", false) + : `
${String(tok.text ?? "")}
`; + } + return `

${String(tok.text ?? "")}

`; + }) + .join(""); + }, + use(opts) { + if (opts.renderer?.code) codeRenderer = opts.renderer.code; + return undefined; + }, + }; + return { marked, dompurify, hljs }; +} + +describe("FENCE_END_RE / isFenceClosed(双态验证)", () => { + it("闭合围栏 → true(``` 与 ~~~)", () => { + expect(FENCE_END_RE.test("```js\nconst a = 1;\n```")).toBe(true); + expect(FENCE_END_RE.test("~~~\nline\n~~~")).toBe(true); + expect(FENCE_END_RE.test("```")).toBe(true); + }); + it("未闭合围栏 → false(纯文本不高亮,与 TUI fence_count%2 启发式一致)", () => { + expect(FENCE_END_RE.test("```js\nconst a = 1;")).toBe(false); + expect(isFenceClosed({ raw: "```js\nconst a = 1;" })).toBe(false); + expect(isFenceClosed({ raw: "```js\nconst a = 1;\n```" })).toBe(true); + }); + it("非代码 token(如 paragraph)不算围栏闭合", () => { + expect(isFenceClosed({ raw: "hello world" })).toBe(false); + }); +}); + +describe("createMarkdownRenderer:无 marked → 转义降级", () => { + it("renderMarkdown 返回转义后的原文(防注入)", async () => { + const r = createMarkdownRenderer({}); + const out = await r.renderMarkdown(""); + expect(out).toBe("<script>alert(1)</script>"); + }); +}); + +describe("createMarkdownRenderer:注入 marked + DOMPurify", () => { + it("renderMarkdown 走 marked.parse + DOMPurify.sanitize", async () => { + const fake = makeFakeMarked(); + const r = createMarkdownRenderer({ marked: fake.marked, DOMPurify: fake.dompurify }); + const out = await r.renderMarkdown("hello "); + // DOMPurify 移除 script;marked 包裹 div + expect(out).not.toContain("