diff --git a/plugins/hermes-achievements/dashboard/dist/index.js b/plugins/hermes-achievements/dashboard/dist/index.js index e51227991d90..38dda3769339 100644 --- a/plugins/hermes-achievements/dashboard/dist/index.js +++ b/plugins/hermes-achievements/dashboard/dist/index.js @@ -18,24 +18,126 @@ // shim that returns en values so the bundle still renders against an older // host SDK. English fallback strings live alongside each call site. const useI18n = SDK.useI18n || function () { return { t: { achievements: null }, locale: "en" }; }; + // Module-level locale tracking: set by AchievementsPage on mount, read by api() + // to pass Accept-Language to the backend for backend-side translations. + var _pluginLocale = "en"; + + // Module-level _ui translations from API response — set after each API fetch. + // Used by tx() as a fallback when useI18n doesn't expose the achievements + // namespace, providing full Chinese UI even without SDK i18n support. + var _uiData = null; + + // Map dotted i18n paths (used in tx() calls) to _ui flat keys returned by + // the backend. Covers every call site in the dist bundle. Paths not in + // this map fall through to a simple dot-to-underscore conversion. + var PATH_TO_UI = { + "state.unlocked": "state_unlocked", + "state.secret": "state_secret", + "state.discovered": "state_discovered", + "tier.target": "tier_target", + "tier.hidden": "tier_hidden", + "tier.complete": "tier_complete", + "tier.objective": "tier_objective", + "hero.kicker": "hero_kicker", + "hero.title": "hero_title", + "hero.subtitle": "hero_desc", + "hero.scan_subtitle": "hero_desc", + "actions.rescan": "rescan", + "latest.header": "recent_unlocks", + "guide.tiers_header": "guide_tiers", + "guide.secret_header": "guide_secrets", + "guide.secret_body": "guide_secrets_desc", + "guide.scan_status_header": "loading_scan_status", + "guide.scan_status_body": "loading_scan_desc", + "guide.what_scanned_header": "loading_guide_what", + "guide.what_scanned_body": "loading_guide_what_desc", + "filters.all_categories": "filter_all_cat", + "filters.visibility_all": "filter_visibility_all", + "filters.visibility_unlocked": "filter_visibility_unlocked", + "filters.visibility_discovered": "filter_visibility_discovered", + "filters.visibility_secret": "filter_visibility_secret", + "card.share_text": "share_btn", + "card.share_label": "share_label", + "card.share_title": "share_label", + "card.how_to_reveal": "criteria_how_to_reveal", + "card.what_counts": "criteria_what_counts", + "card.evidence_label": "evidence_label", + "card.evidence_session_fallback": "evidence_empty", + "card.no_evidence": "evidence_empty", + "empty.no_secrets_header": "secret_empty_title", + "empty.no_secrets_body": "secret_empty_clue", + "stats.unlocked": "stat_unlocked", + "stats.discovered": "stat_discovered", + "stats.secrets": "stat_secrets", + "stats.highest_tier": "stat_highest", + "stats.latest": "stat_latest", + "stats.unlocked_hint": "hint_earned", + "stats.discovered_hint": "hint_known", + "stats.secrets_hint": "hint_hidden", + "stats.highest_tier_hint": "hint_tier_ladder", + "stats.latest_hint_empty": "hint_run_more", + "stats.none_yet": "hint_none", + "scan.starting_headline": "scan_pending", + "scan.building_headline": "scan_in_progress", + "scan.building_detail": "scan_detail_idle", + "scan.progress_detail": "scan_detail_templ", + "scan.idle_detail": "scan_detail_idle", + "share.rendering": "share_rendering", + "share.x_button": "share_on_x", + "share.copy_button": "share_copy", + "share.copied": "share_copied", + "share.download_button": "share_download", + "share.hint": "share_hint", + "share.x_title": "share_title_x", + "share.copy_title": "share_title_copy", + "progress.hidden": "state_secret", + "share.clipboard_unsupported": "share_hint", + "share.tweet_text": "share_hint", + "share.dialog_label": "share_label", + "share.header": "share_label", + "share.close": "share_label", + "share.card_alt": "share_label", + "share.error_generic": "share_hint", + "loading_stat_unlocked": "loading_stat_unlocked", + "loading_stat_discovered": "loading_stat_discovered", + "loading_stat_secrets": "loading_stat_secrets", + "loading_stat_highest": "loading_stat_highest", + "loading_stat_latest": "loading_stat_latest", + }; // Resolve a translation by dotted path (e.g. "card.share_text"); fall back to // the English string passed in. Used inside components after they call // useI18n() so they can still render against an older host SDK that doesn't // expose the achievements namespace yet. function tx(t, path, fallback, vars) { - let node = t && t.achievements; + // 1. Try _ui backend translations first (full coverage for zh-CN and other + // languages the backend knows about — flat keys from plugin_api.py). + if (_uiData && path) { + var uiKey = PATH_TO_UI[path] || path.replace(/\./g, "_"); + if (uiKey in _uiData) { + var str = _uiData[uiKey]; + if (vars) { + for (var k in vars) { + str = str.replace(new RegExp("\\{" + k + "\\}", "g"), vars[k]); + } + } + return str; + } + } + // 2. Fall back to SDK i18n (useI18n / t.achievements) for dashboards that + // expose the achievements translation namespace natively. + var node = t && t.achievements; if (node) { - const parts = path.split("."); - for (let i = 0; i < parts.length; i++) { + var parts = path.split("."); + for (var i = 0; i < parts.length; i++) { if (node && typeof node === "object" && parts[i] in node) { node = node[parts[i]]; } else { node = null; break; } } } - let str = (typeof node === "string") ? node : fallback; + var str = (typeof node === "string") ? node : fallback; if (vars) { - for (const k in vars) { + for (var k in vars) { str = str.replace(new RegExp("\\{" + k + "\\}", "g"), vars[k]); } } @@ -50,7 +152,18 @@ async function api(path, options) { const url = "/api/plugins/hermes-achievements" + path; - const res = await fetch(url, options || {}); + const opts = options || {}; + const headers = new Headers(opts.headers); + const token = typeof window !== "undefined" && window.__HERMES_SESSION_TOKEN__; + if (token && !headers.has("X-Hermes-Session-Token")) { + headers.set("X-Hermes-Session-Token", token); + } + // Forward the plugin's locale to the backend so achievement names, + // descriptions, criteria, and tier labels are translated server-side. + if (_pluginLocale && _pluginLocale !== "en" && !headers.has("Accept-Language")) { + headers.set("Accept-Language", _pluginLocale); + } + const res = await fetch(url, { ...opts, headers }); if (!res.ok) { const text = await res.text().catch(function () { return res.statusText; }); throw new Error(res.status + ": " + text); @@ -565,7 +678,8 @@ } function AchievementsPage() { - const { t } = useI18n(); + const { t, locale } = useI18n(); + _pluginLocale = locale || "en"; const [data, setData] = hooks.useState(null); const [loading, setLoading] = hooks.useState(true); const [error, setError] = hooks.useState(null); @@ -575,7 +689,7 @@ function load() { setLoading(true); api("/achievements") - .then(function (payload) { setData(payload); setError((payload && payload.error) || null); }) + .then(function (payload) { _uiData = (payload && payload._ui) || null; setData(payload); setError((payload && payload.error) || null); }) .catch(function (err) { setError(String(err)); }) .finally(function () { setLoading(false); }); } @@ -584,7 +698,7 @@ // with growing unlock counts instead of flashing the loading skeleton. function refresh() { api("/achievements") - .then(function (payload) { setData(payload); setError((payload && payload.error) || null); }) + .then(function (payload) { _uiData = (payload && payload._ui) || null; setData(payload); setError((payload && payload.error) || null); }) .catch(function (err) { setError(String(err)); }); } hooks.useEffect(load, []); diff --git a/plugins/hermes-achievements/dashboard/locales/zh-CN.json b/plugins/hermes-achievements/dashboard/locales/zh-CN.json new file mode 100644 index 000000000000..5385de85bef1 --- /dev/null +++ b/plugins/hermes-achievements/dashboard/locales/zh-CN.json @@ -0,0 +1,449 @@ +{ + "._meta": { + "locale": "zh-CN", + "language": "简体中文", + "description": "Hermes 成就系统中文翻译" + }, + "._tiers": [ + "青铜", + "白银", + "黄金", + "钻石", + "奥林匹亚" + ], + "._strings": { + "secret_hint": "秘密成就:Hermes 检测到匹配行为后才会揭晓。", + "requirement_prefix": "要求", + "secret_prefix": "秘密", + "tier_ladder": "等级阶梯", + "and": "且" + }, + "._metrics": { + "max_tool_calls_in_session": "单次会话工具调用次数", + "max_distinct_tools_in_session": "单次会话使用的不同工具数", + "max_terminal_calls_in_session": "单次会话终端调用次数", + "max_file_tool_calls_in_session": "单次会话文件/搜索/补丁调用次数", + "max_web_browser_calls_in_session": "单次会话网页搜索/浏览器调用次数", + "max_messages_in_session": "单次会话消息数", + "max_files_touched_in_session": "单次会话触及文件数", + "total_delegate_calls": "历史委托任务调用次数", + "total_process_calls": "历史后台进程操作次数", + "total_cron_calls": "历史定时任务操作次数", + "total_errors": "观察到的错误/失败/异常消息数", + "traceback_events": "堆栈跟踪或异常提及次数", + "log_read_events": "日志检查次数", + "port_conflict_events": "开发服务器端口冲突检测次数", + "permission_denied_events": "权限拒绝错误次数", + "install_error_events": "包安装失败次数", + "install_success_events": "包安装成功次数", + "restart_after_error_events": "错误集群后重启/重载操作次数", + "env_var_error_events": "缺失环境变量/配置/认证事件次数", + "yaml_error_events": "YAML/配置解析事故次数", + "docker_conflict_events": "Docker/容器名称冲突次数", + "frontend_activity_events": "前端/CSS/SVG/React 活动提及次数", + "css_activity_events": "CSS、样式、Tailwind 或 className 活动次数", + "git_events": "Git 工作流命令次数", + "tiny_patch_after_errors_events": "错误集群后的一字符级修复次数", + "rollback_events": "回滚或撤销操作次数", + "checkpoint_events": "状态检查点或保存操作次数", + "cache_discussion_events": "缓存失效/命中/预热讨论次数", + "skill_use_events": "技能加载或使用次数", + "total_plugin_events": "插件事件总数", + "concurrent_skills": "并发技能数", + "parallel_subagent_calls": "并行的子代理调用数", + "model_config_events": "模型或提供者配置变更提及次数", + "provider_switch_count": "提供者切换次数", + "model_switch_count": "模型切换次数", + "image_generation_count": "图像生成调用总数", + "image_analysis_count": "图像分析/描述调用总数", + "vision_usage_count": "视觉功能使用总数", + "video_analysis_count": "视频分析调用总数", + "file_read_count": "文件读取调用总数", + "file_write_count": "文件写入调用总数", + "config_file_edit_count": "配置文件编辑总数", + "session_count": "历史会话数", + "night_sessions": "深夜或凌晨启动的会话数" + }, + "._ui": { + "hero_kicker": "代理游戏分数", + "hero_title": "Hermes 成就", + "hero_desc": "从真实会话历史中收集的 Hermes 徽章。已知但未完成的成就显示为「已发现」;秘密成就保持隐藏,直到首次出现匹配的行为。", + "rescan": "重新扫描", + "stat_unlocked": "已解锁", + "stat_discovered": "已发现", + "stat_secrets": "秘密", + "stat_highest": "最高等级", + "stat_latest": "最新", + "hint_earned": "获得的徽章", + "hint_known": "已知,尚未获得", + "hint_hidden": "隐藏,直至首次触发", + "hint_tier_ladder": "青铜 → 白银 → 黄金 → 钻石 → 奥林匹亚", + "hint_none": "暂无", + "hint_run_more": "多使用 Hermes", + "scan_pending": "正在开始成就扫描…", + "scan_in_progress": "正在构建成就档案…", + "scan_detail_templ": "已扫描 {s} / {t} 个会话 · {p}%。徽章随着更多历史流入自动解锁。", + "scan_detail_idle": "正在读取会话、工具调用、模型元数据和解锁状态。徽章会自动出现在此处。", + "guide_tiers": "等级", + "guide_secrets": "秘密成就", + "guide_secrets_desc": "秘密隐藏了确切的触发条件。一旦 Hermes 检测到相关信号,卡片会变为「已发现」并显示要求。", + "filter_all_cat": "全部", + "filter_all": "全部", + "filter_visibility_all": "全部", + "filter_visibility_unlocked": "已解锁", + "filter_visibility_discovered": "已发现", + "filter_visibility_secret": "秘密", + "state_filter_all": "全部", + "state_filter_unlocked": "已解锁", + "state_filter_discovered": "已发现", + "state_filter_secret": "秘密", + "recent_unlocks": "最新解锁", + "secret_empty_title": "本次扫描未发现隐藏秘密。", + "secret_empty_clue": "提示:秘密通常源于异常失败或高级用户模式——端口冲突、权限阻拦、缺失环境变量、YAML 错误、Docker 冲突、回滚/检查点使用、缓存命中,或大量红字后的小修复。", + "state_unlocked": "已解锁", + "state_secret": "秘密", + "state_discovered": "已发现", + "tier_target": "目标 ", + "tier_hidden": "隐藏", + "tier_complete": "已完成", + "tier_objective": "目标", + "criteria_what_counts": "计数方式", + "criteria_how_to_reveal": "如何揭示", + "evidence_label": "证据", + "evidence_empty": "暂无证据", + "share_btn": "分享", + "share_label": "分享:", + "share_rendering": "渲染中…", + "share_on_x": "分享到 X", + "share_copy": "复制图片", + "share_copied": "已复制 ✓", + "share_download": "下载 PNG", + "share_title_x": "在新标签页打开 X,含预设内容", + "share_title_copy": "复制图片以便粘贴到帖子中", + "share_hint": "「分享到 X」会在新标签页打开预设好的帖子。如需在帖子中附带 1200×630 徽章图片,请先点击「复制图片」,X 允许直接在推文编辑器粘贴。「下载 PNG」可将文件保存到本地。", + "loading_stat_unlocked": "已解锁", + "loading_stat_discovered": "已发现", + "loading_stat_secrets": "秘密", + "loading_stat_highest": "最高等级", + "loading_stat_latest": "最新", + "loading_scan_status": "正在构建成就档案…", + "loading_scan_desc": "Hermes 正在本地记录中扫描一次,完成后卡片会自动出现。", + "loading_guide_title": "扫描状态", + "loading_guide_desc": "Hermes 正在本地记录中扫描一次,完成后卡片会自动出现。", + "loading_guide_what": "扫描内容", + "loading_guide_what_desc": "会话、工具调用、模型元数据、错误、成就和本地解锁状态。", + "tier_copper": "青铜", + "tier_silver": "白银", + "tier_gold": "黄金", + "tier_diamond": "钻石", + "tier_olympian": "奥林匹亚", + "Agent Autonomy": "自主代理", + "Debugging Chaos": "调试混乱", + "Vibe Coding": "氛围编码", + "Hermes Native": "Hermes 原生", + "Research/Web": "研究/网页", + "Tool Mastery": "工具掌握", + "Model Lore": "模型知识", + "Lifestyle": "生活方式" + }, + "actually_read_the_logs": { + "name": "居然真的读了日志", + "description": "检查足够多的输出和日志。", + "category": "调试混乱" + }, + "autonomous_avalanche": { + "name": "自主狂潮", + "description": "跨会话积累海量的 Hermes 工具调用。", + "category": "自主代理" + }, + "background_process_enjoyer": { + "name": "后台达人", + "description": "启动或操控足够多的后台进程。", + "category": "自主代理" + }, + "browser_possession": { + "name": "浏览器附身", + "description": "通过自动化反复操控浏览器。", + "category": "研究/网页" + }, + "cache_hit_appreciator": { + "name": "缓存命中鉴赏家", + "description": "注意到或受益于缓存行为。", + "category": "生活方式" + }, + "citation_goblin": { + "name": "引文哥布林", + "description": "提取足够多的网页。", + "category": "研究/网页" + }, + "claude_confidant": { + "name": "Claude 知己", + "description": "反复使用 Claude 风格推理。", + "category": "模型知识" + }, + "codex_conjurer": { + "name": "Codex 召唤师", + "description": "频繁召唤 Codex 风格的协助。", + "category": "模型知识" + }, + "config_surgeon": { + "name": "配置外科医生", + "description": "编辑配置文件。", + "category": "Hermes 原生" + }, + "context_dragon": { + "name": "上下文巨龙", + "description": "触及压缩、大上下文或 Token 压力。", + "category": "Hermes 原生" + }, + "cron_necromancer": { + "name": "定时任务亡灵法师", + "description": "创建足够多的定时任务。", + "category": "自主代理" + }, + "css_exorcist": { + "name": "CSS 驱魔师", + "description": "反复驱除界面中的样式恶魔。", + "category": "氛围编码" + }, + "dependency_hell_tourist": { + "name": "依赖地狱观光客", + "description": "包安装失败足够多次了。", + "category": "调试混乱" + }, + "docker_name_collision": { + "name": "Docker 名称冲突", + "description": "容器名称已存在——当然如此。", + "category": "调试混乱" + }, + "docs_archaeologist": { + "name": "文档考古学家", + "description": "反复挖掘文档来源。", + "category": "研究/网页" + }, + "file_archaeologist": { + "name": "文件考古学家", + "description": "通过读取和搜索深入挖掘文件系统。", + "category": "工具掌握" + }, + "five_model_flight": { + "name": "五模型试飞", + "description": "至少尝试五个不同的 LLM。", + "category": "模型知识" + }, + "forgot_the_env_var": { + "name": "忘设环境变量了", + "description": "遇到环境变量或配置缺失错误。", + "category": "调试混乱" + }, + "full_send": { + "name": "全力输出", + "description": "终端、文件和网页/浏览器在一次真实运行中全员参与。", + "category": "自主代理" + }, + "gateway_dweller": { + "name": "网关常客", + "description": "在网关模式下完成会话。", + "category": "Hermes 原生" + }, + "gemini_cartographer": { + "name": "Gemini 制图师", + "description": "在 Gemini 模型上完成会话。", + "category": "模型知识" + }, + "image_whisperer": { + "name": "图像低语者", + "description": "通过 Hermes 生成图像。", + "category": "工具掌握" + }, + "let_him_cook": { + "name": "放手一搏", + "description": "让 Hermes 在一次会话中自主执行一套完整的工具链。", + "category": "自主代理" + }, + "marathon_operator": { + "name": "马拉松玩家", + "description": "积累大量 Hermes 会话。", + "category": "生活方式" + }, + "memory_keeper": { + "name": "记忆守护者", + "description": "使用记忆或 Mnemosyne 保存持久化知识。", + "category": "Hermes 原生" + }, + "memory_palace": { + "name": "记忆宫殿", + "description": "建立可观的持久化记忆痕迹。", + "category": "Hermes 原生" + }, + "model_hopper": { + "name": "模型跳跃者", + "description": "足够多地切换或查看提供者/模型。", + "category": "模型知识" + }, + "model_sommelier": { + "name": "模型品鉴师", + "description": "品味足够多的模型/提供者对话。", + "category": "模型知识" + }, + "multi_model_mage": { + "name": "多模型法师", + "description": "使用多种不同模型名称。", + "category": "模型知识" + }, + "night_shift_operator": { + "name": "夜班操作员", + "description": "在深夜时段反复运行会话。", + "category": "生活方式" + }, + "one_character_fix": { + "name": "一字修复", + "description": "大量错误后的一丁点修改。痛苦,但美丽。", + "category": "氛围编码" + }, + "one_more_small_change": { + "name": "再来一个小改动", + "description": "在一次会话中做足够多的文件编辑。", + "category": "氛围编码" + }, + "open_weights_pilgrim": { + "name": "开源朝圣者", + "description": "通过 Hermes 与本地/开源模型对话。", + "category": "模型知识" + }, + "openrouter_enjoyer": { + "name": "OpenRouter 爱好者", + "description": "反复通过 OpenRouter 路由模型工作。", + "category": "模型知识" + }, + "patch_wizard": { + "name": "补丁巫师", + "description": "用定向补丁按需修改文件。", + "category": "工具掌握" + }, + "permission_denied_any_percent": { + "name": "权限拒绝 Any%", + "description": "遭遇足够多的权限拒绝。", + "category": "调试混乱" + }, + "pixel_goblin": { + "name": "像素哥布林", + "description": "持续的前端/CSS/SVG/视觉调优。", + "category": "氛围编码" + }, + "plugin_goblin": { + "name": "插件精灵", + "description": "在会话中触发插件事件。", + "category": "Hermes 原生" + }, + "port_3000_taken": { + "name": "3000 端口已被占用", + "description": "遭遇端口冲突。", + "category": "调试混乱" + }, + "provider_polyglot": { + "name": "供应商多语者", + "description": "跨多个提供者使用模型。", + "category": "模型知识" + }, + "rabbit_hole_certified": { + "name": "兔子洞认证", + "description": "搜索或提取足够多的网页内容。", + "category": "研究/网页" + }, + "rebase_acrobat": { + "name": "变基杂技演员", + "description": "处理真实的 Git 历史手术:变基、冲突、合并。", + "category": "氛围编码" + }, + "red_text_connoisseur": { + "name": "红字鉴赏家", + "description": "找到足够多的错误。", + "category": "调试混乱" + }, + "rollback_wizard": { + "name": "回滚巫师", + "description": "施展回滚/检查点恢复魔法。", + "category": "Hermes 原生" + }, + "screenshot_hunter": { + "name": "截图猎人", + "description": "抓取至少一次网页截图。", + "category": "工具掌握" + }, + "ship_first_ask_later": { + "name": "先发后问", + "description": "在严肃工具链之后进行 Git 操作。", + "category": "氛围编码" + }, + "skill_issue_skill_created": { + "name": "技能不行?那就造一个", + "description": "创建或修补持久化流程。", + "category": "Hermes 原生" + }, + "skillsmith": { + "name": "技能铁匠", + "description": "与 Hermes 技能交互足够多次。", + "category": "Hermes 原生" + }, + "stack_trace_sommelier": { + "name": "堆栈品鉴师", + "description": "阅读足够多的堆栈跟踪信息。", + "category": "调试混乱" + }, + "subagent_commander": { + "name": "分身指挥官", + "description": "协调多个代理分头工作。", + "category": "自主代理" + }, + "supposed_to_be_quick": { + "name": "说好很快的", + "description": "一个小需求变成了一趟远征。", + "category": "氛围编码" + }, + "terminal_goblin": { + "name": "终端哥布林", + "description": "在命令行环境中度过大量时间。", + "category": "工具掌握" + }, + "test_suite_tamer": { + "name": "测试驯兽师", + "description": "运行足够多的验证命令。", + "category": "工具掌握" + }, + "the_fix_was_restarting": { + "name": "重启一下就好了", + "description": "在大量错误后重启恢复局面。", + "category": "调试混乱" + }, + "toolchain_maxxer": { + "name": "全能工具箱", + "description": "在一次会话中使用种类繁多的 Hermes 工具。", + "category": "自主代理" + }, + "toolset_cartographer": { + "name": "工具集制图师", + "description": "在会话中使用各种工具套件。", + "category": "Hermes 原生" + }, + "vibe_architect": { + "name": "氛围架构师", + "description": "一次项目会话中触及广泛的范围。", + "category": "氛围编码" + }, + "voice_of_the_machine": { + "name": "机器之声", + "description": "反复使用语音或 TTS 工具。", + "category": "工具掌握" + }, + "weekend_warrior": { + "name": "周末战士", + "description": "在非标准时段启动足够多的会话。", + "category": "生活方式" + }, + "yaml_colon_incident": { + "name": "YAML 冒号事故", + "description": "配置语法回击了你。", + "category": "调试混乱" + } +} \ No newline at end of file diff --git a/plugins/hermes-achievements/dashboard/plugin_api.py b/plugins/hermes-achievements/dashboard/plugin_api.py index b419efc6c27f..4d0ba0c183f4 100644 --- a/plugins/hermes-achievements/dashboard/plugin_api.py +++ b/plugins/hermes-achievements/dashboard/plugin_api.py @@ -21,16 +21,108 @@ def get_hermes_home() -> Path: # type: ignore[misc] return Path(val) if val else Path.home() / ".hermes" try: - from fastapi import APIRouter + from fastapi import APIRouter, Request except Exception: # Allows local unit tests without dashboard dependencies. class APIRouter: # type: ignore def get(self, *_args, **_kwargs): return lambda fn: fn def post(self, *_args, **_kwargs): return lambda fn: fn + class Request: # type: ignore + pass router = APIRouter() +# ─── Locale / i18n support ───────────────────────────────── +_LOCALE_DIR = Path(__file__).parent / "locales" +_LOCALE_CACHE: Dict[str, Any] = {} + +def _load_locale(locale_code: str) -> Dict[str, Any]: + """Load a locale file from the locales/ directory.""" + path = _LOCALE_DIR / f"{locale_code}.json" + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + +def _resolve_locale_from_request(request: Any) -> str: + """Resolve locale from Accept-Language header. Falls back to 'en'.""" + if request is None: + return "en" + try: + accept = request.headers.get("accept-language", "") + if not accept: + return "en" + # Parse: "zh-CN,zh;q=0.9,en;q=0.8" → try zh-CN first + for part in accept.split(","): + lang = part.strip().split(";")[0].strip() + if lang.startswith("zh") or lang.startswith("cmn"): + return "zh-CN" + except Exception: + pass + return "en" + +def _apply_locale(achievement: Dict[str, Any], locale_code: str) -> Dict[str, Any]: + """Apply translated name/description/category to an achievement dict. + Returns the original dict unchanged when locale is 'en' or no translations found.""" + if locale_code == "en": + return achievement + locale_data = _load_locale(locale_code) + if not locale_data: + return achievement + aid = achievement.get("id") + if not aid: + return achievement + t = locale_data.get(aid) + if not t: + return achievement + result = dict(achievement) + for field in ("name", "description"): + if field in t: + result[field] = t[field] + return result + +def _locale_tier_name(name: str, locale_code: str) -> str: + """Translate a tier name (Copper/Silver/...) if locale has tier mappings.""" + if locale_code == "en": + return name + locale_data = _load_locale(locale_code) + if not locale_data: + return name + tiers = locale_data.get("._tiers") + if not isinstance(tiers, list): + return name + english_tiers = ["Copper", "Silver", "Gold", "Diamond", "Olympian"] + try: + idx = english_tiers.index(name) + if idx < len(tiers): + return tiers[idx] + except (ValueError, IndexError): + pass + return name + +def _locale_string(key: str, locale_code: str) -> str: + """Translate a fixed string key from locale file's ._strings section.""" + if locale_code == "en": + return key + locale_data = _load_locale(locale_code) + if not locale_data: + return "" + strings = locale_data.get("._strings", {}) + return strings.get(key, "") + +def _locale_metric_label(metric: str, locale_code: str) -> str: + """Translate a metric label using the locale file.""" + if locale_code == "en": + return "" + locale_data = _load_locale(locale_code) + if not locale_data: + return "" + metrics = locale_data.get("._metrics", {}) + return metrics.get(metric, "") + SNAPSHOT_TTL_SECONDS = 120 _SCAN_LOCK = threading.Lock() _SNAPSHOT_CACHE: Optional[Dict[str, Any]] = None @@ -527,33 +619,55 @@ def evaluate_boolean(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> D } -def metric_label(metric: str) -> str: +def metrics_label(metric: str, locale_code: str = "en") -> str: + """Return the metric label, translated if available.""" + if locale_code != "en": + translated = _locale_metric_label(metric, locale_code) + if translated: + return translated return METRIC_LABELS.get(metric, metric.replace("_", " ")) -def criteria_for(definition: Dict[str, Any]) -> str: - if definition.get("secret") and definition.get("state") == "secret": - return "Secret: exact requirement hidden until Hermes sees the first matching signal. Keep using Hermes across debugging, tools, memory, skills, plugins, and model workflows to reveal it." +def criteria_for(definition: Dict[str, Any], locale_code: str = "en") -> str: secret_prefix = "" + if definition.get("secret"): + hint = _locale_string("secret_hint", locale_code) + if not hint and definition.get("state") == "secret": + hint = "Secret: exact requirement hidden until Hermes sees the first matching signal. Keep using Hermes across debugging, tools, memory, skills, plugins, and model workflows to reveal it." + if hint: + secret_prefix = hint if "threshold_metric" in definition: tiers_list = sorted(definition.get("tiers", []), key=lambda t: t["threshold"]) if not tiers_list: - return secret_prefix + "Requirement: use Hermes in the matching workflow." - metric = metric_label(definition["threshold_metric"]) - ladder = ", ".join(f"{t['name']} {t['threshold']}" for t in tiers_list) - return secret_prefix + f"Requirement: {metric}. Tier ladder: {ladder}." + return secret_prefix + ("Requirement: use Hermes in the matching workflow." if locale_code == "en" else _locale_string("requirement_prefix", locale_code) or "要求: 在匹配的工作流中使用 Hermes。") + metric = metrics_label(definition["threshold_metric"], locale_code) + ladder_parts = [] + for t in tiers_list: + tn = _locale_tier_name(t["name"], locale_code) if locale_code != "en" else t["name"] + ladder_parts.append(f"{tn} {t['threshold']}") + ladder = ", ".join(ladder_parts) + prefix_text = _locale_string("requirement_prefix", locale_code) or "Requirement" + return secret_prefix + f"{prefix_text}: {metric}。等级阶梯: {ladder}。" requirements = definition.get("requirements") or [] if requirements: - parts = [f"{metric_label(r['metric'])} ≥ {int(r.get('gte', 1))}" for r in requirements] - return secret_prefix + "Requirement: " + "; ".join(parts) + "." - return secret_prefix + "Requirement: complete the matching Hermes behavior." + parts = [f"{metrics_label(r['metric'], locale_code)} ≥ {int(r.get('gte', 1))}" for r in requirements] + separator = ";" if locale_code != "en" else "; " + prefix_text = _locale_string("requirement_prefix", locale_code) or "Requirement" + return secret_prefix + f"{prefix_text}: " + separator.join(parts) + "。" + return secret_prefix + ("Requirement: complete the matching Hermes behavior." if locale_code == "en" else _locale_string("requirement_prefix", locale_code) or "要求: 完成匹配的 Hermes 行为。") -def display_achievement(item: Dict[str, Any]) -> Dict[str, Any]: +def display_achievement(item: Dict[str, Any], locale_code: str = "en") -> Dict[str, Any]: clean = dict(item) if clean.get("state") == "secret": - return {**clean, "name": "???", "description": "Secret achievement: hidden until Hermes detects the first relevant behavior in your session history.", "criteria": criteria_for(clean), "icon": "secret"} - clean["criteria"] = criteria_for(clean) + hint = _locale_string("secret_hint", locale_code) + if not hint: + hint = "Secret achievement: hidden until Hermes detects the first relevant behavior in your session history." + return {**clean, "name": "???", "description": hint, "criteria": criteria_for(clean, locale_code), "icon": "secret"} + clean["criteria"] = criteria_for(clean, locale_code) + clean = _apply_locale(clean, locale_code) + # Tier names stay in English — frontend uses them for CSS class names + # (ha-tier-copper, etc.) and color mapping (tierHex). return clean @@ -996,8 +1110,46 @@ def evaluate_all(force: bool = False) -> Dict[str, Any]: return _build_pending_snapshot(now) +def _localize_achievements(achievements: List[Dict[str, Any]], locale_code: str) -> List[Dict[str, Any]]: + """Apply locale translations to a list of fully-computed achievement dicts.""" + if locale_code == "en": + return achievements + result = [] + for a in achievements: + localized = _apply_locale(a, locale_code) + # Regenerate criteria in target locale (display_achievement already set it in English) + localized["criteria"] = criteria_for(localized, locale_code) + # Tier names stay in English for CSS class mapping (ha-tier-copper, etc.) + result.append(localized) + return result + + +def _load_ui_strings(locale_code: str) -> Dict[str, str]: + """Load UI string translations for a given locale.""" + if locale_code == "en": + return {} + locale_data = _load_locale(locale_code) + if not locale_data: + return {} + ui = locale_data.get("._ui", {}) + if isinstance(ui, dict): + return ui + return {} + +def _localize_payload(data: Dict[str, Any], locale_code: str) -> Dict[str, Any]: + """Apply locale to the achievements list inside a full payload dict.""" + if locale_code == "en": + return data + result = dict(data) + achievements = result.get("achievements", []) + result["achievements"] = _localize_achievements(achievements, locale_code) + result["_ui"] = _load_ui_strings(locale_code) + return result + + @router.get("/achievements") -async def achievements(): +async def achievements(request: Request = None): + locale = _resolve_locale_from_request(request) data = evaluate_all() payload = {k: data[k] for k in ["achievements", "unlocked_count", "discovered_count", "secret_count", "total_count", "error", "generated_at"] if k in data} payload["is_stale"] = _is_snapshot_stale(data) @@ -1005,6 +1157,7 @@ async def achievements(): **(data.get("scan_meta") or {}), "status": _scan_status_payload(), } + payload = _localize_payload(payload, locale) return payload @@ -1014,13 +1167,16 @@ async def scan_status(): @router.get("/recent-unlocks") -async def recent_unlocks(): +async def recent_unlocks(request: Request = None): + locale = _resolve_locale_from_request(request) data = evaluate_all() - return sorted([a for a in data["achievements"] if a["unlocked"]], key=lambda a: a.get("unlocked_at") or 0, reverse=True)[:20] + achievements = sorted([a for a in data["achievements"] if a["unlocked"]], key=lambda a: a.get("unlocked_at") or 0, reverse=True)[:20] + return _localize_achievements(achievements, locale) @router.get("/sessions/{session_id}/badges") -async def session_badges(session_id: str): +async def session_badges(session_id: str, request: Request = None): + locale = _resolve_locale_from_request(request) data = evaluate_all() session = next((s for s in data["sessions"] if s["session_id"] == session_id), None) if not session: @@ -1031,12 +1187,14 @@ async def session_badges(session_id: str): result = evaluate_definition(definition, aggregate) if result["unlocked"]: badges.append(display_achievement({**definition, **result})) - return {"session_id": session_id, "badges": badges} + return {"session_id": session_id, "badges": _localize_achievements(badges, locale)} @router.post("/rescan") -async def rescan(): - return {"ok": True, **evaluate_all(force=True)} +async def rescan(request: Request = None): + locale = _resolve_locale_from_request(request) + result = evaluate_all(force=True) + return {"ok": True, **_localize_payload(result, locale)} @router.post("/reset-state")