From 630981a0686a5e51d7629b55ca178a3e07962a90 Mon Sep 17 00:00:00 2001 From: Alan Date: Sun, 10 May 2026 14:29:07 +0800 Subject: [PATCH 1/9] fix: use bash 3.2 compatible variable check in ctl.sh Replace [[ -v ${key} ]] (requires bash 4.2+) with [[ -n "${!key+x}" ]] which works on bash 3.2+ and all POSIX-compatible shells. macOS ships with bash 3.2, causing: ./ctl.sh: line 42: conditional binary operator expected --- ctl.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctl.sh b/ctl.sh index a8f4d61d..62c2407d 100755 --- a/ctl.sh +++ b/ctl.sh @@ -39,7 +39,7 @@ _load_repo_dotenv_preserving_env() { key="${key#export }" key="${key//[[:space:]]/}" [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue - if [[ -v ${key} ]]; then + if [[ -n "${!key+x}" ]]; then value="${!key}" preserved+=("${key}=${value}") fi From 025f137f2898b80faa1f2d30d55c569955c66332 Mon Sep 17 00:00:00 2001 From: Alan Date: Sun, 10 May 2026 14:34:39 +0800 Subject: [PATCH 2/9] fix: guard empty CTL_BOOTSTRAP_ARGS expansion for bash 3.2 With set -u, bash 3.2 treats empty array expansion "\${CTL_BOOTSTRAP_ARGS[@]}" as an unbound variable. Use ${CTL_BOOTSTRAP_ARGS[@]+"..."} pattern to skip expansion when the array is empty. --- ctl.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctl.sh b/ctl.sh index 62c2407d..c246131f 100755 --- a/ctl.sh +++ b/ctl.sh @@ -215,7 +215,7 @@ start_cmd() { : >> "${LOG_FILE}" ( cd "${REPO_ROOT}" - exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" "${CTL_BOOTSTRAP_ARGS[@]}" + exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"} ) >> "${LOG_FILE}" 2>&1 & pid=$! From 1e1a9481b4f070e448fc53321dc80b4e824e8ca2 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 15:21:24 +0800 Subject: [PATCH 3/9] fix(i18n): localize /goal runtime status strings --- api/goals.py | 127 +++++++++++++++++++++++++++- api/streaming.py | 5 ++ static/commands.js | 14 +++- static/i18n.js | 137 ++++++++++++++++++++++++++++++- static/messages.js | 21 ++++- tests/test_goal_command_webui.py | 12 ++- 6 files changed, 305 insertions(+), 11 deletions(-) diff --git a/api/goals.py b/api/goals.py index 32d0262d..3e4e23ea 100644 --- a/api/goals.py +++ b/api/goals.py @@ -4,6 +4,7 @@ from __future__ import annotations import copy import logging +import re import time from pathlib import Path from typing import Any, Dict, Optional @@ -279,6 +280,8 @@ def _payload( error: str | None = None, kickoff_prompt: str | None = None, decision: Dict[str, Any] | None = None, + message_key: str | None = None, + message_args: list[Any] | None = None, ) -> Dict[str, Any]: body: Dict[str, Any] = { "ok": bool(ok), @@ -292,9 +295,98 @@ def _payload( body["kickoff_prompt"] = kickoff_prompt if decision is not None: body["decision"] = decision + if message_key: + body["message_key"] = message_key + if message_args is not None: + body["message_args"] = [a for a in message_args if a is not None] return body +def _goal_status_payload(state: Any, *, default_message: str | None = None) -> Dict[str, Any]: + """Build localized-status style payload fields from a goal state.""" + if default_message is None: + default_message = "No active goal. Set one with /goal ." + if state is None: + return {"message": default_message, "message_key": "goal_status_none"} + status = str(getattr(state, "status", "") or "").strip() + if status in ("cleared",): + return {"message": default_message, "message_key": "goal_status_none"} + turns_used = int(getattr(state, "turns_used", 0) or 0) + max_turns = int(getattr(state, "max_turns", 0) or 0) + goal = str(getattr(state, "goal", "") or "") + if status == "active": + return { + "message": f"⊙ Goal (active, {turns_used}/{max_turns} turns): {goal}", + "message_key": "goal_status_active", + "message_args": [turns_used, max_turns, goal], + } + if status == "paused": + reason = str(getattr(state, "paused_reason", "") or "") + return { + "message": f"⏸ Goal (paused, {turns_used}/{max_turns}{' — ' + reason if reason else ''}): {goal}", + "message_key": "goal_status_paused", + "message_args": [turns_used, max_turns, reason, goal], + } + if status == "done": + return { + "message": f"✓ Goal done ({turns_used}/{max_turns}): {goal}", + "message_key": "goal_status_done", + "message_args": [turns_used, max_turns, goal], + } + return { + "message": f"Goal ({status}, {turns_used}/{max_turns}): {goal}", + "message_args": [status, turns_used, max_turns, goal], + } + + +def _extract_goal_turns_from_message(message: str) -> tuple[int, int]: + """Best-effort extraction for continuation messages like '(1/20)'.""" + if not message: + return 0, 0 + match = re.search(r"\((\d+)\s*/\s*(\d+)\)", message) + if not match: + return 0, 0 + try: + return int(match.group(1)), int(match.group(2)) + except Exception: + return 0, 0 + + +def _goal_decision_payload( + decision: Dict[str, Any], + state: Any, +) -> Dict[str, Any]: + """Attach goal message i18n key/args to an evaluation decision.""" + if not isinstance(decision, dict): + return decision + status = str(decision.get("status") or "").strip() + reason = str(decision.get("reason") or "").strip() + turns_used = int(getattr(state, "turns_used", 0) or 0) + max_turns = int(getattr(state, "max_turns", 0) or 0) + if (turns_used, max_turns) == (0, 0): + turns_used, max_turns = _extract_goal_turns_from_message(str(decision.get("message") or "")) + + if status == "done": + return { + **decision, + "message_key": "goal_achieved", + "message_args": [reason], + } + if status == "paused": + return { + **decision, + "message_key": "goal_paused_budget_exhausted", + "message_args": [turns_used, max_turns], + } + if decision.get("should_continue"): + return { + **decision, + "message_key": "goal_continuing", + "message_args": [turns_used, max_turns, reason], + } + return decision + + def goal_state_snapshot(session_id: str, *, profile_home: str | Path | None = None) -> Any: """Return a deep copy of current goal state for rollback before kickoff.""" mgr = _manager(str(session_id or ""), profile_home=profile_home) @@ -355,24 +447,46 @@ def goal_command_payload( lower = text.lower() if not text or lower == "status": - return _payload(action="status", message=mgr.status_line(), state=getattr(mgr, "state", None)) + state = getattr(mgr, "state", None) + status_payload = _goal_status_payload(state) + return _payload(action="status", state=state, **status_payload) if lower == "pause": state = mgr.pause(reason="user-paused") if state is None: - return _payload(ok=False, action="pause", error="no_goal", message="No goal set.") - return _payload(action="pause", message=f"⏸ Goal paused: {state.goal}", state=state) + return _payload( + ok=False, + action="pause", + error="no_goal", + message="No goal set.", + message_key="goal_no_goal", + ) + return _payload( + action="pause", + message=f"⏸ Goal paused: {state.goal}", + message_key="goal_paused", + message_args=[str(state.goal)], + state=state, + ) if lower == "resume": state = mgr.resume() if state is None: - return _payload(ok=False, action="resume", error="no_goal", message="No goal to resume.") + return _payload( + ok=False, + action="resume", + error="no_goal", + message="No goal to resume.", + message_key="goal_no_goal", + ) return _payload( action="resume", message=( f"▶ Goal resumed: {state.goal}\n" "Send a new message, or type continue, to kick it off." ), + message_key="goal_resumed", + message_args=[str(state.goal)], state=state, ) @@ -382,6 +496,7 @@ def goal_command_payload( return _payload( action="clear", message="Goal cleared." if had else "No active goal.", + message_key="goal_cleared" if had else "goal_no_goal", state=getattr(mgr, "state", None), ) @@ -408,6 +523,8 @@ def goal_command_payload( "I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n" "Controls: /goal status · /goal pause · /goal resume · /goal clear" ), + message_key="goal_set", + message_args=[state.max_turns, state.goal], state=state, kickoff_prompt=state.goal, ) @@ -486,4 +603,6 @@ def evaluate_goal_after_turn( decision.setdefault("should_continue", False) decision.setdefault("continuation_prompt", None) decision.setdefault("message", "") + decision = dict(decision) + decision = _goal_decision_payload(decision, getattr(mgr, "state", None)) return decision diff --git a/api/streaming.py b/api/streaming.py index a1a8ac1b..2e37514e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -3320,6 +3320,7 @@ def _run_agent_streaming( 'session_id': session_id, 'state': 'evaluating', 'message': 'Evaluating goal progress…', + 'message_key': 'goal_evaluating_progress', }) _goal_decision = evaluate_goal_after_turn( session_id, @@ -3334,6 +3335,8 @@ def _run_agent_streaming( 'session_id': session_id, 'state': 'continuing' if decision.get('should_continue') else 'idle', 'message': _goal_message, + 'message_key': decision.get('message_key') or ('goal_continuing' if _goal_message else ''), + 'message_args': decision.get('message_args') or [], 'decision': decision, }) if decision.get('should_continue'): @@ -3347,6 +3350,8 @@ def _run_agent_streaming( 'continuation_prompt': continuation_prompt, 'text': continuation_prompt, 'message': _goal_message, + 'message_key': decision.get('message_key') or 'goal_continuing', + 'message_args': decision.get('message_args') or [], 'decision': decision, }) except Exception as _goal_exc: diff --git a/static/commands.js b/static/commands.js index 3d5aa9f0..6875135e 100644 --- a/static/commands.js +++ b/static/commands.js @@ -639,7 +639,17 @@ async function cmdGoal(args){ model_provider:S.session.model_provider||null, profile:S.activeProfile||S.session.profile||'default', })}); - const msg=String((r&&r.message)||'').trim(); + const msg = (() => { + const raw = String((r && r.message) || '').trim(); + const key = String((r && r.message_key) || '').trim(); + const args = Array.isArray(r && r.message_args) ? r.message_args : []; + if (raw.includes('\n')) return raw; + if (key && typeof t === 'function') { + const translated = String(t(key, ...args)); + if (translated && translated !== key) return translated; + } + return raw; + })(); if(msg){ S.messages.push({role:'assistant',content:msg,_ts:Date.now()/1000,_goalStatus:true,_transient:true}); renderMessages({preserveScroll:true}); @@ -649,7 +659,7 @@ async function cmdGoal(args){ S.toolCalls=[]; if(typeof clearLiveToolCards==='function')clearLiveToolCards(); appendThinking();setBusy(true); - setComposerStatus('Working toward goal…'); + setComposerStatus(t('goal_working_toward')); S.activeStreamId=r.stream_id; if(S.session&&S.session.session_id===activeSid){ S.session.active_stream_id=r.stream_id; diff --git a/static/i18n.js b/static/i18n.js index e1b2c986..540e7b07 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -195,6 +195,21 @@ const LOCALES = { no_active_session: 'No active session', cmd_queue: 'Queue a message for the next turn', cmd_goal: 'Set or inspect a persistent goal', + goal_evaluating_progress: 'Evaluating goal progress…', + goal_working_toward: 'Working toward goal…', + goal_continuing_toast: 'Continuing toward goal…', + goal_status_none: 'No active goal. Set one with /goal .', + goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, + goal_paused: (goal) => `⏸ Goal paused: ${goal}`, + goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, + goal_cleared: 'Goal cleared.', + goal_no_goal: 'No active goal.', + goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, + goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, cmd_interrupt: 'Cancel current turn and send a new message', cmd_steer: 'Inject a mid-turn correction without interrupting the agent', cmd_queue_no_msg: 'Usage: /queue ', @@ -1263,6 +1278,21 @@ const LOCALES = { no_active_session: 'アクティブなセッションがありません', cmd_queue: '次のターン用にメッセージをキュー', cmd_goal: '永続ゴールを設定または確認', + goal_evaluating_progress: 'Evaluating goal progress…', + goal_working_toward: 'Working toward goal…', + goal_continuing_toast: 'Continuing toward goal…', + goal_status_none: 'No active goal. Set one with /goal .', + goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, + goal_paused: (goal) => `⏸ Goal paused: ${goal}`, + goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, + goal_cleared: 'Goal cleared.', + goal_no_goal: 'No active goal.', + goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, + goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, cmd_interrupt: '現在のターンをキャンセルして新規メッセージを送信', cmd_steer: 'エージェントを中断せずにターン中に修正を注入', cmd_queue_no_msg: '使い方: /queue <メッセージ>', @@ -2292,6 +2322,21 @@ const LOCALES = { no_active_session: 'Нет активной сессии', cmd_queue: 'Поставить сообщение в очередь на следующий оборот', cmd_goal: 'Задать или проверить постоянную цель', + goal_evaluating_progress: 'Evaluating goal progress…', + goal_working_toward: 'Working toward goal…', + goal_continuing_toast: 'Continuing toward goal…', + goal_status_none: 'No active goal. Set one with /goal .', + goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, + goal_paused: (goal) => `⏸ Goal paused: ${goal}`, + goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, + goal_cleared: 'Goal cleared.', + goal_no_goal: 'No active goal.', + goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, + goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, cmd_interrupt: 'Прервать текущий оборот и отправить новое сообщение', cmd_steer: 'Направить агента исправлением (переходит к прерыванию)', cmd_queue_no_msg: 'Использование: /queue <сообщение>', @@ -3326,6 +3371,21 @@ const LOCALES = { no_active_session: 'No hay ninguna sesión activa', cmd_queue: 'Poner mensaje en cola para el siguiente turno', cmd_goal: 'Definir o consultar un objetivo persistente', + goal_evaluating_progress: 'Evaluating goal progress…', + goal_working_toward: 'Working toward goal…', + goal_continuing_toast: 'Continuing toward goal…', + goal_status_none: 'No active goal. Set one with /goal .', + goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, + goal_paused: (goal) => `⏸ Goal paused: ${goal}`, + goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, + goal_cleared: 'Goal cleared.', + goal_no_goal: 'No active goal.', + goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, + goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, cmd_interrupt: 'Cancelar turno actual y enviar nuevo mensaje', cmd_steer: 'Inyectar una corrección a mitad del turno sin interrumpir al agente', cmd_queue_no_msg: 'Uso: /queue ', @@ -4309,6 +4369,21 @@ const LOCALES = { model_scope_toast: 'Gilt für diesen Chat ab Ihrer nächsten Nachricht.', cmd_queue: 'Nachricht f\u00fcr den n\u00e4chsten Durchgang einreihen', cmd_goal: 'Ein dauerhaftes Ziel setzen oder prüfen', + goal_evaluating_progress: 'Evaluating goal progress…', + goal_working_toward: 'Working toward goal…', + goal_continuing_toast: 'Continuing toward goal…', + goal_status_none: 'No active goal. Set one with /goal .', + goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, + goal_paused: (goal) => `⏸ Goal paused: ${goal}`, + goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, + goal_cleared: 'Goal cleared.', + goal_no_goal: 'No active goal.', + goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, + goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, cmd_interrupt: 'Aktuellen Durchgang abbrechen und neue Nachricht senden', cmd_steer: 'Korrektursignal einf\u00fcgen ohne Unterbrechung', cmd_queue_no_msg: 'Verwendung: /queue ', @@ -5027,7 +5102,7 @@ const LOCALES = { profile_gateway_stopped: 'Gateway gestoppt', profile_active: 'Aktiv', profile_no_configuration: 'Keine Konfiguration', - profile_skill_count: '{count} Fähigkeiten', + profile_skill_count: (count) => `${count} Fähigkeit${count === 1 ? '' : 'en'}`, profile_use: 'Verwenden', profile_switch_title: 'Profil wechseln', profile_delete_title: 'Profil löschen', @@ -5329,6 +5404,21 @@ const LOCALES = { no_active_session: '\u5f53\u524d\u6ca1\u6709\u6d3b\u52a8\u4f1a\u8bdd', cmd_queue: '\u5c06\u6d88\u606f\u52a0\u5165\u4e0b\u4e00\u8f6e\u7684\u961f\u5217', cmd_goal: '设置或查看持久目标', + goal_evaluating_progress: 'Evaluating goal progress…', + goal_working_toward: 'Working toward goal…', + goal_continuing_toast: 'Continuing toward goal…', + goal_status_none: 'No active goal. Set one with /goal .', + goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, + goal_paused: (goal) => `⏸ Goal paused: ${goal}`, + goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, + goal_cleared: 'Goal cleared.', + goal_no_goal: 'No active goal.', + goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, + goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, cmd_interrupt: '\u53d6\u6d88\u5f53\u524d\u56de\u5408\u5e76\u53d1\u9001\u65b0\u6d88\u606f', cmd_steer: '\u7528\u7ea0\u6b63\u4fe1\u606f\u5f15\u5bfc\u4ee3\u7406\uff08\u56de\u9000\u4e3a\u4e2d\u65ad\uff09', cmd_queue_no_msg: '\u7528\u6cd5\uff1a/queue <\u6d88\u606f>', @@ -6846,6 +6936,21 @@ const LOCALES = { no_active_session: '\u7121\u6d3b\u8e8d\u6703\u8a71', cmd_queue: '\u5c07\u8a0a\u606f\u52a0\u5165\u4e0b\u4e00\u8f2a\u7684\u4f47\u5217', cmd_goal: '設定或查看持久目標', + goal_evaluating_progress: 'Evaluating goal progress…', + goal_working_toward: 'Working toward goal…', + goal_continuing_toast: 'Continuing toward goal…', + goal_status_none: 'No active goal. Set one with /goal .', + goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, + goal_paused: (goal) => `⏸ Goal paused: ${goal}`, + goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, + goal_cleared: 'Goal cleared.', + goal_no_goal: 'No active goal.', + goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, + goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, cmd_interrupt: '\u53d6\u6d88\u7576\u524d\u56de\u5408\u4e26\u767c\u9001\u65b0\u8a0a\u606f', cmd_steer: '\u5728\u56de\u5408\u9032\u884c\u4e2d\u6ce8\u5165\u7d3a\u6b63\uff0c\u4e0d\u4e2d\u65b7\u4ee3\u7406', cmd_queue_no_msg: '\u7528\u6cd5\uff1a/queue <\u8a0a\u606f>', @@ -7371,6 +7476,21 @@ const LOCALES = { no_active_session: 'Nenhuma sessão ativa', cmd_queue: 'Enfileirar mensagem para o próximo turno', cmd_goal: 'Definir ou consultar uma meta persistente', + goal_evaluating_progress: 'Evaluating goal progress…', + goal_working_toward: 'Working toward goal…', + goal_continuing_toast: 'Continuing toward goal…', + goal_status_none: 'No active goal. Set one with /goal .', + goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, + goal_paused: (goal) => `⏸ Goal paused: ${goal}`, + goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, + goal_cleared: 'Goal cleared.', + goal_no_goal: 'No active goal.', + goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, + goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, cmd_interrupt: 'Cancelar turno atual e enviar nova mensagem', cmd_steer: 'Injetar correção no meio do turno sem interromper', cmd_queue_no_msg: 'Uso: /queue ', @@ -8315,6 +8435,21 @@ const LOCALES = { no_active_session: '활성 세션 없음', cmd_queue: 'Queue a message for the next turn', cmd_goal: '지속 목표를 설정하거나 확인', + goal_evaluating_progress: 'Evaluating goal progress…', + goal_working_toward: 'Working toward goal…', + goal_continuing_toast: 'Continuing toward goal…', + goal_status_none: 'No active goal. Set one with /goal .', + goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`, + goal_paused: (goal) => `⏸ Goal paused: ${goal}`, + goal_resumed: (goal) => `▶ Goal resumed: ${goal}`, + goal_cleared: 'Goal cleared.', + goal_no_goal: 'No active goal.', + goal_achieved: (reason) => `✓ Goal achieved: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`, + goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`, cmd_interrupt: 'Cancel current turn and send a new message', cmd_steer: 'Inject a mid-turn correction without interrupting the agent', cmd_queue_no_msg: 'Usage: /queue ', diff --git a/static/messages.js b/static/messages.js index 75758f7c..bd586238 100644 --- a/static/messages.js +++ b/static/messages.js @@ -896,17 +896,30 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }catch(_){} }); + function _resolveGoalMessage(d){ + const key=String(d && d.message_key ? d.message_key : '').trim(); + const args=Array.isArray(d && d.message_args) ? d.message_args : []; + const raw=String(d&&d.message||'').trim(); + if(key && typeof t==='function'){ + try{ + const translated=String(t(key,...args)); + if(translated && translated!==key)return translated; + }catch(_){} + } + return raw; + } + source.addEventListener('goal',e=>{ try{ const d=JSON.parse(e.data||'{}'); if((d.session_id||activeSid)!==activeSid) return; const goalState=String(d.state||'').trim(); - const goalEvaluatingMessage='Evaluating goal progress…'; + const goalEvaluatingMessage=t('goal_evaluating_progress'); if(goalState==='evaluating'){ setComposerStatus(goalEvaluatingMessage); return; } - const msg=String(d.message||'').trim(); + const msg=_resolveGoalMessage(d); if(!msg)return; _latestGoalStatus={message:msg,decision:d.decision||null,state:goalState||null}; setComposerStatus(msg); @@ -927,7 +940,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ model_provider:S.session&&S.session.model_provider||null, profile:S.activeProfile||'default', }; - showToast('Continuing toward goal…',2200); + const toast=t('goal_continuing_toast'); + const cmsg=_resolveGoalMessage(d); + showToast((toast&&cmsg&&cmsg!==toast)?cmsg.split('\n')[0]:toast,2200); }catch(_){} }); diff --git a/tests/test_goal_command_webui.py b/tests/test_goal_command_webui.py index e1bca59b..4d27de0a 100644 --- a/tests/test_goal_command_webui.py +++ b/tests/test_goal_command_webui.py @@ -68,10 +68,18 @@ def test_goal_command_payload_matches_gateway_controls(monkeypatch): set_goal = webui_goals.goal_command_payload("sid-123", "ship the feature") assert status["message"] == "No active goal. Set one with /goal ." + assert status["message_key"] == "goal_status_none" assert pause["message"] == "⏸ Goal paused: ship the feature" + assert pause["message_key"] == "goal_paused" + assert pause["message_args"] == ["ship the feature"] assert resume["message"].startswith("▶ Goal resumed: ship the feature") + assert resume["message_key"] == "goal_resumed" + assert resume["message_args"] == ["ship the feature"] assert clear["message"] == "Goal cleared." + assert clear["message_key"] == "goal_cleared" assert set_goal["action"] == "set" + assert set_goal["message_key"] == "goal_set" + assert set_goal["message_args"] == [20, "ship the feature"] assert set_goal["kickoff_prompt"] == "ship the feature" assert "⊙ Goal set (20-turn budget): ship the feature" in set_goal["message"] assert ("set", "ship the feature") in calls @@ -145,6 +153,8 @@ def test_goal_continuation_decision_emits_status_and_normal_user_prompt(monkeypa decision = webui_goals.evaluate_goal_after_turn("sid-123", "not done yet", user_initiated=False) + assert decision["message_key"] == "goal_continuing" + assert decision["message_args"] == [1, 20, "one step remains"] assert decision["message"].startswith("↻ Continuing toward goal") assert decision["should_continue"] is True assert decision["continuation_prompt"].startswith("[Continuing toward your standing goal]") @@ -266,7 +276,7 @@ def test_frontend_has_goal_slash_command_and_status_event_handler(): def test_frontend_goal_evaluating_state_uses_calm_composer_indicator(): assert "const goalState=String(d.state||'').trim();" in MESSAGES_JS - assert "const goalEvaluatingMessage='Evaluating goal progress…';" in MESSAGES_JS + assert "t('goal_evaluating_progress')" in MESSAGES_JS assert "if(goalState==='evaluating')" in MESSAGES_JS assert "setComposerStatus(goalEvaluatingMessage);" in MESSAGES_JS assert "return;" in MESSAGES_JS From 7ee41c9b1273c0a57ee713c416709b9a406d23e5 Mon Sep 17 00:00:00 2001 From: qxxaa Date: Sun, 10 May 2026 09:02:49 +0100 Subject: [PATCH 4/9] fix: patch skills module-level caches on per-request profile switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-request profile switches (process_wide=False, introduced in #1700) update os.environ['HERMES_HOME'] but skip _set_hermes_home(), which is responsible for monkeypatching module-level caches. Both tools/skills_tool.py and tools/skill_manager_tool.py set HERMES_HOME and SKILLS_DIR once at import time. When a non-default profile is active in the WebUI, os.environ['HERMES_HOME'] is correctly updated per-turn in the _ENV_LOCK block, but the module-level constants still point at the root profile. All agent-side skill operations — skills_list(), skill_view(), skill_manage() — read and write to the wrong directory. Add the same monkeypatching that _set_hermes_home() already performs (profiles.py line ~620) to the per-turn env setup block in streaming.py, covering both skills_tool and skill_manager_tool. The WebUI display half was already fixed in #1917 via _active_skills_dir() in routes.py. This patch fixes the agent-side half so the running agent resolves skills from the correct profile. --- api/streaming.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/api/streaming.py b/api/streaming.py index a1a8ac1b..0be67093 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2046,6 +2046,23 @@ def _run_agent_streaming( os.environ['HERMES_SESSION_KEY'] = session_id if _profile_home: os.environ['HERMES_HOME'] = _profile_home + # Patch module-level caches to match the active profile. + # _set_hermes_home() does this for process-wide switches + # but per-request switches skip it (#1700). + from pathlib import Path as _P + _ph = _P(_profile_home) + try: + import tools.skills_tool as _sk + _sk.HERMES_HOME = _ph + _sk.SKILLS_DIR = _ph / 'skills' + except (ImportError, AttributeError): + pass + try: + import tools.skill_manager_tool as _sm + _sm.HERMES_HOME = _ph + _sm.SKILLS_DIR = _ph / 'skills' + except (ImportError, AttributeError): + pass # Lock released — agent runs without holding it # ── MCP Server Discovery (lazy import, idempotent) ── # MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()` From 2e6b3601bd90173deb89b617c605d93bebe19bbe Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 16:05:50 +0800 Subject: [PATCH 5/9] fix(clarify): honor clarify.timeout config in webui prompts --- api/streaming.py | 17 +++++- tests/test_sprint42.py | 128 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/api/streaming.py b/api/streaming.py index a1a8ac1b..a6c8897b 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -20,6 +20,7 @@ from typing import Optional logger = logging.getLogger(__name__) from api.config import ( + get_config, STREAMS, STREAMS_LOCK, CANCEL_FLAGS, AGENT_INSTANCES, STREAM_PARTIAL_TEXT, STREAM_REASONING_TEXT, STREAM_LIVE_TOOL_CALLS, STREAM_GOAL_RELATED, PENDING_GOAL_CONTINUATION, @@ -86,6 +87,19 @@ def _is_quota_error_text(err_text: str) -> bool: ) +def _clarify_timeout_seconds(default: int = 120) -> int: + """Resolve clarify timeout from config, with bounded fallback.""" + try: + cfg = get_config() + raw = cfg.get("clarify", {}).get("timeout", default) + timeout_seconds = int(raw) + if timeout_seconds <= 0: + return default + return timeout_seconds + except Exception: + return default + + def _classify_provider_error(err_str: str, exc=None, *, silent_failure: bool = False) -> dict: """Classify provider/agent failure text for WebUI apperror UX. @@ -2106,7 +2120,7 @@ def _run_agent_streaming( def _clarify_callback_impl(question, choices, sid, cancel_evt, put_event): """Bridge Hermes clarify prompts to the WebUI.""" - timeout = 120 + timeout = _clarify_timeout_seconds() choices_list = [str(choice) for choice in (choices or [])] data = { 'question': str(question or ''), @@ -2114,6 +2128,7 @@ def _run_agent_streaming( 'session_id': sid, 'kind': 'clarify', 'requested_at': time.time(), + 'timeout_seconds': timeout, } try: from api.clarify import submit_pending as _submit_clarify_pending, clear_pending as _clear_clarify_pending diff --git a/tests/test_sprint42.py b/tests/test_sprint42.py index 49eaacd4..79a3314b 100644 --- a/tests/test_sprint42.py +++ b/tests/test_sprint42.py @@ -9,6 +9,7 @@ Covers: - streaming.py: SessionDB init is placed before AIAgent construction """ import ast +import threading import pathlib import re import queue @@ -402,6 +403,133 @@ class TestRuntimeRouteInjection(unittest.TestCase): "interim_assistant event should carry the assistant commentary text" ) + def test_clarify_callback_passes_configured_timeout_seconds(self): + """clarify prompt data should use clarify.timeout from config when present.""" + import api.streaming as streaming + + captured = {} + submit_payloads = [] + + class FakeEntry: + def __init__(self, value): + self.result = value + self.event = threading.Event() + self.event.set() + + def fake_submit_pending(_sid, payload): + submit_payloads.append(payload) + return FakeEntry("selected") + + class CapturingAgent: + def __init__(self, model=None, provider=None, base_url=None, api_key=None, + platform=None, quiet_mode=False, enabled_toolsets=None, + fallback_model=None, session_id=None, session_db=None, + stream_delta_callback=None, reasoning_callback=None, + tool_progress_callback=None, clarify_callback=None, **kwargs): + self.clarify_callback = clarify_callback + self.session_id = session_id + captured["init_kwargs"] = { + "clarify_callback": clarify_callback, + } + + def run_conversation(self, **kwargs): + if self.clarify_callback: + captured["clarify_result"] = self.clarify_callback( + "Need user confirmation", + ["first", "second"], + ) + return { + "messages": [ + {"role": "user", "content": kwargs.get("persist_user_message", "")}, + {"role": "assistant", "content": "ok"}, + ] + } + + def interrupt(self, _message): + captured["interrupted"] = True + + class FakeSession: + session_id = "sess-clarify-timeout" + title = "clarify-timeout test" + workspace = "/tmp" + model = "gpt-5.4" + messages = [] + personality = None + input_tokens = 0 + output_tokens = 0 + estimated_cost = None + tool_calls = [] + active_stream_id = None + pending_user_message = None + pending_attachments = [] + pending_started_at = None + + def save(self, touch_updated_at=True, **_kwargs): + pass + + def compact(self): + return { + "session_id": self.session_id, + "title": self.title, + "workspace": self.workspace, + "model": self.model, + "created_at": 0, + "updated_at": 0, + "pinned": False, + "archived": False, + "project_id": None, + "profile": None, + "input_tokens": 0, + "output_tokens": 0, + "estimated_cost": None, + "personality": None, + } + + @property + def path(self): + return "/tmp/fake.json" + + fake_stream_id = "stream-clarify-timeout" + fake_queue = queue.Queue() + fake_rt_module = types.ModuleType("hermes_cli.runtime_provider") + fake_rt_module.resolve_runtime_provider = mock.Mock(return_value={ + "provider": "openai-codex", + "base_url": "https://api.openai.com/v1", + "api_key": "rt-key", + "api_mode": "codex_responses", + "command": "codex", + "args": ["exec", "--json"], + "credential_pool": object(), + }) + fake_hermes_cli = types.ModuleType("hermes_cli") + fake_hermes_cli.runtime_provider = fake_rt_module + fake_hermes_state = types.ModuleType("hermes_state") + fake_hermes_state.SessionDB = mock.Mock(return_value=object()) + + with mock.patch.object(streaming, "get_session", return_value=FakeSession()), \ + mock.patch.object(streaming, "_get_ai_agent", return_value=CapturingAgent), \ + mock.patch.object(streaming, "resolve_model_provider", return_value=("gpt-5.4", "openai-codex", None)), \ + mock.patch.object(streaming, "get_config", return_value={"clarify": {"timeout": 300}}), \ + mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \ + mock.patch("api.clarify.submit_pending", side_effect=fake_submit_pending), \ + mock.patch.dict(sys.modules, { + "hermes_cli": fake_hermes_cli, + "hermes_cli.runtime_provider": fake_rt_module, + "hermes_state": fake_hermes_state, + }): + streaming.STREAMS[fake_stream_id] = fake_queue + streaming._run_agent_streaming( + session_id="sess-clarify-timeout", + msg_text="please run task", + model="gpt-5.4", + workspace="/tmp", + stream_id=fake_stream_id, + ) + + self.assertEqual(captured["clarify_result"], "selected") + self.assertEqual(len(submit_payloads), 1) + self.assertEqual(submit_payloads[0]["timeout_seconds"], 300) + class TestSessionDBAST(unittest.TestCase): """AST-level checks: verify the try/except is not inside _ENV_LOCK (deadlock guard).""" From 9c37104c94b1c8e032030793a898e9da22094c46 Mon Sep 17 00:00:00 2001 From: eov128 Date: Sun, 10 May 2026 16:08:14 +0800 Subject: [PATCH 6/9] Add files via upload Update Chinese language translation --- static/i18n.js | 900 ++++++++++++++++++++++++------------------------- 1 file changed, 450 insertions(+), 450 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index e1b2c986..4ed889b7 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -5162,22 +5162,22 @@ const LOCALES = { _label: '\u7b80\u4f53\u4e2d\u6587', _speech: 'zh-CN', // boot.js - cancelling: '\u6b63\u5728\u53d6\u6d88...', - cancel_failed: '\u53d6\u6d88\u5931\u8d25\uff1a', - mic_denied: '\u9ea6\u514b\u98ce\u8bbf\u95ee\u88ab\u62d2\u7edd\uff0c\u8bf7\u68c0\u67e5\u6d4f\u89c8\u5668\u6743\u9650\u3002', - mic_no_speech: '\u6ca1\u6709\u68c0\u6d4b\u5230\u8bed\u97f3\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002', - mic_network: '\u8bed\u97f3\u8bc6\u522b\u5f53\u524d\u4e0d\u53ef\u7528\u3002', - mic_error: '\u8bed\u97f3\u8f93\u5165\u51fa\u9519\uff1a', - session_imported: '\u4f1a\u8bdd\u5df2\u5bfc\u5165', - import_failed: '\u5bfc\u5165\u5931\u8d25\uff1a', - import_invalid_json: 'JSON \u65e0\u6548', - image_pasted: '\u5df2\u7c98\u8d34\u56fe\u7247\uff1a', + cancelling: '正在取消...', + cancel_failed: '取消失败:', + mic_denied: '麦克风访问被拒绝,请检查浏览器权限。', + mic_no_speech: '没有检测到语音,请再试一次。', + mic_network: '语音识别当前不可用。', + mic_error: '语音输入出错:', + session_imported: '会话已导入', + import_failed: '导入失败:', + import_invalid_json: 'JSON 无效', + image_pasted: '已粘贴图片:', // messages.js - edit_message: '\u7f16\u8f91\u6d88\u606f', - regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u590d', - copy: '\u590d\u5236', - copied: '\u5df2\u590d\u5236', - copy_failed: '\u590d\u5236\u5931\u8d25', + edit_message: '编辑消息', + regenerate: '重新生成回复', + copy: '复制', + copied: '已复制', + copy_failed: '复制失败', diff_loading: '加载 diff', diff_error: '无法加载 patch 文件', @@ -5185,7 +5185,7 @@ const LOCALES = { tree_view: '树形', raw_view: '原始', parse_failed_note: '解析失败', - you: '\u4f60', + you: '你', mcp_servers_title: 'MCP 服务器', mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。', mcp_no_servers: '未配置 MCP 服务器。', @@ -5208,31 +5208,31 @@ const LOCALES = { mcp_deleted: 'MCP 服务器已删除。', mcp_delete_failed: 'MCP 服务器删除失败。', mcp_load_failed: 'MCP 服务器加载失败。', - mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.', - mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.', - mcp_status_active: 'Active', - mcp_status_configured: 'Configured', - mcp_status_disabled: 'Disabled', - mcp_status_invalid_config: 'Invalid config', - mcp_status_unknown: 'Unknown', - mcp_tool_count: '{0} tools', - mcp_enabled_yes: 'Enabled', - mcp_enabled_no: 'Disabled', - mcp_tools_title: 'MCP Tools', - mcp_tools_desc: 'Search known tools across active MCP servers.', - mcp_tools_search_placeholder: 'Search tools by name, server, or description…', - mcp_tools_no_tools: 'No MCP tools are available from the active runtime inventory.', - mcp_tools_no_matches: 'No MCP tools match your search.', - mcp_tools_load_failed: 'Failed to load MCP tools.', - mcp_tools_schema_empty: 'No schema parameters.', - mcp_tools_runtime_note: 'Tool inventory only uses already-known active MCP runtime data; the WebUI does not start or probe servers.', - thinking: '\u601d\u8003\u8fc7\u7a0b', - expand_all: '\u5168\u90e8\u5c55\u5f00', - collapse_all: '\u5168\u90e8\u6298\u53e0', - edit_failed: '\u7f16\u8f91\u5931\u8d25\uff1a', - regen_failed: '\u91cd\u65b0\u751f\u6210\u5931\u8d25\uff1a', - reconnect_active: '\u56de\u590d\u4ecd\u5728\u751f\u6210\u4e2d\uff0c\u51c6\u5907\u597d\u540e\u8981\u91cd\u65b0\u52a0\u8f7d\u5417\uff1f', - reconnect_finished: '\u4f60\u79bb\u5f00\u65f6\u6709\u56de\u590d\u6b63\u5728\u751f\u6210\uff0c\u6d88\u606f\u5185\u5bb9\u53ef\u80fd\u5df2\u7ecf\u66f4\u65b0\u3002', + mcp_restart_hint: '服务器更改目前为只读。请编辑 config.yaml 并重启 Hermes。', + mcp_toggle_followup: '在 MCP 的重新加载规范明确之前,我们有意暂缓启用/禁用的切换。', + mcp_status_active: '运行中', + mcp_status_configured: '已配置', + mcp_status_disabled: '已禁用', + mcp_status_invalid_config: '配置无效', + mcp_status_unknown: '未知', + mcp_tool_count: '{0} 个工具', + mcp_enabled_yes: '已启用', + mcp_enabled_no: '已禁用', + mcp_tools_title: 'MCP 工具', + mcp_tools_desc: '搜索活跃 MCP 服务器中的已知工具。', + mcp_tools_search_placeholder: '按名称、服务器或描述搜索工具…', + mcp_tools_no_tools: '当前运行时清单中没有可用的 MCP 工具。', + mcp_tools_no_matches: '没有匹配的 MCP 工具。', + mcp_tools_load_failed: '加载 MCP 工具失败。', + mcp_tools_schema_empty: '无参数。', + mcp_tools_runtime_note: '工具清单仅使用已知的活跃 MCP 运行时数据;WebUI 不会启动或探测服务器。', + thinking: '思考过程', + expand_all: '全部展开', + collapse_all: '全部折叠', + edit_failed: '编辑失败:', + regen_failed: '重新生成失败:', + reconnect_active: '回复仍在生成中,准备好后要重新加载吗?', + reconnect_finished: '你离开时有回复正在生成,消息内容可能已经更新。', // approval card approval_heading: '需要审批', approval_desc_prefix: '检测到危险命令', @@ -5251,8 +5251,8 @@ const LOCALES = { clarify_send: '发送', clarify_input_placeholder: '请输入你的回答…', clarify_responding: '处理中…', - untitled: '\u672a\u547d\u540d', - n_messages: (n) => `${n} \u6761\u6d88\u606f`, + untitled: '未命名', + n_messages: (n) => `${n} 条消息`, load_older_messages: '↑ 向上滚动或点击加载更早的消息', session_jump_start: '开头', session_jump_start_label: '跳转到会话开头', @@ -5261,247 +5261,247 @@ const LOCALES = { queued_label: '响应后发送', queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`, queued_cancel: '取消排队消息', - model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09', - model_unavailable_title: '\u8fd9\u4e2a\u6a21\u578b\u5df2\u7ecf\u4e0d\u5728\u5f53\u524d provider \u5217\u8868\u4e2d', - provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u65e0\u6cd5\u5728\u5f53\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u5546 (${p}) \u4e0b\u5de5\u4f5c\u3002\u76f4\u63a5\u53d1\u9001\uff0c\u6216\u5728\u7ec8\u7aef\u8fd0\u884c \`hermes model\` \u5207\u6362\u3002`, - provider_mismatch_label: '\u63d0\u4f9b\u5546\u4e0d\u5339\u914d', - model_not_found_label: '\u672a\u627e\u5230\u6a21\u578b', - model_custom_label: '\u81ea\u5b9a\u4e49\u6a21\u578b ID', - model_custom_placeholder: '\u4f8b\u5982 openai/gpt-5.4', - model_search_placeholder: '\u641c\u7d22\u6a21\u578b\u2026', - model_search_no_results: '\u672a\u627e\u5230\u6a21\u578b', + model_unavailable: '(不可用)', + model_unavailable_title: '这个模型已经不在当前 provider 列表中', + provider_mismatch_warning: (m,p)=>`\"${m}\" 可能无法在当前配置的提供商 (${p}) 下工作。直接发送,或在终端运行 \`hermes model\` 切换。`, + provider_mismatch_label: '提供商不匹配', + model_not_found_label: '未找到模型', + model_custom_label: '自定义模型 ID', + model_custom_placeholder: '例如 openai/gpt-5.4', + model_search_placeholder: '搜索模型…', + model_search_no_results: '未找到模型', model_group_configured: '已配置', ws_search_placeholder: '搜索工作区…', ws_no_results: '未找到工作区', - session_toolsets: 'Session Toolsets', // TODO: translate - session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate - session_toolsets_global: 'Global (default)', // TODO: translate - session_toolsets_custom: 'Custom', // TODO: translate + session_toolsets: 'Session 工具集', + session_toolsets_desc: '限制此会话可用工具(留空 = 使用全局配置)', + session_toolsets_global: '全局(默认)', + session_toolsets_custom: '自定义', session_toolsets_placeholder: 'tool1, tool2, \u2026', // TODO: translate - session_toolsets_apply: 'Apply', // TODO: translate - session_toolsets_clear: 'Clear (use global)', // TODO: translate - session_toolsets_applied: 'Toolsets updated', // TODO: translate - session_toolsets_cleared: 'Toolsets cleared — using global config', // TODO: translate - session_toolsets_failed: 'Failed to update toolsets: ', // TODO: translate - model_scope_advisory: '\u4ece\u4e0b\u4e00\u6761\u6d88\u606f\u8d77\u5e94\u7528\u4e8e\u5f53\u524d\u5bf9\u8bdd\u3002', - model_scope_toast: '\u4ece\u4e0b\u4e00\u6761\u6d88\u606f\u8d77\u5e94\u7528\u4e8e\u5f53\u524d\u5bf9\u8bdd\u3002', + session_toolsets_apply: '应用', + session_toolsets_clear: '清除(使用全局)', + session_toolsets_applied: '工具集已更新', + session_toolsets_cleared: '工具集已清除 — 使用全局配置', + session_toolsets_failed: '更新工具集失败:', + model_scope_advisory: '从下一条消息起应用于当前对话。', + model_scope_toast: '从下一条消息起应用于当前对话。', // commands.js - cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4', - cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f', - cmd_compress: '\u624b\u52a8\u538b\u7f29\u5bf9\u8bdd\u4e0a\u4e0b\u6587\uff08\u7528\u6cd5\uff1a/compress [\u4e3b\u9898]\uff09', - ctx_compress_hint: '\u538b\u7f29\u4e0a\u4e0b\u6587\u4ee5\u91ca\u653e\u7a7a\u95f4 →', - ctx_compress_action: '\u26a0 \u7acb\u5373\u538b\u7f29\u4ee5\u91ca\u653e\u4e0a\u4e0b\u6587', - cmd_compact_alias: '\u65e7\u522b\u540d\uff1a/compress', - cmd_model: '\u5207\u6362\u6a21\u578b\uff08\u4f8b\u5982 /model gpt-4o\uff09', - cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a', - cmd_terminal: '\u6253\u5f00\u5de5\u4f5c\u533a Terminal', - cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd', - cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a', - cmd_theme: '\u5207\u6362\u5916\u89c2\uff08\u4e3b\u9898\uff1asystem/dark/light\uff0c\u76ae\u80a4\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard\uff09', - cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe', - cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd', - available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a', - type_slash: '\u8f93\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4', - conversation_cleared: '\u5bf9\u8bdd\u5df2\u6e05\u7a7a', - command_label: '\u547d\u4ee4', - context_compaction_label: '\u4e0a\u4e0b\u6587\u538b\u7f29', - preserved_task_list_label: '\u4fdd\u7559\u7684\u4efb\u52a1\u5217\u8868', - reference_only_label: '\u4ec5\u4f9b\u53c2\u8003', - model_usage: '\u7528\u6cd5\uff1a/model ', - no_model_match: '\u6ca1\u6709\u5339\u914d\u201c', - switched_to: '\u5df2\u5207\u6362\u5230 ', - workspace_usage: '\u7528\u6cd5\uff1a/workspace ', - no_workspace_match: '\u6ca1\u6709\u5339\u914d\u201c', - switched_workspace: '\u5df2\u5207\u6362\u5de5\u4f5c\u533a\uff1a', - workspace_switch_failed: '\u5de5\u4f5c\u533a\u5207\u6362\u5931\u8d25\uff1a', - new_session: '\u5df2\u65b0\u5efa\u4f1a\u8bdd', - compressing: '\u6b63\u5728\u8bf7\u6c42\u538b\u7f29\u4e0a\u4e0b\u6587...', - compress_running_label: '\u538b\u7f29\u4e2d', - compress_complete_label: '\u538b\u7f29\u5b8c\u6210', - auto_compress_label: '\u81ea\u52a8\u538b\u7f29', - compress_failed_label: '\u538b\u7f29\u5931\u8d25', - focus_label: '\u4e3b\u9898', - token_usage_on: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5f00\u542f', - usage_personality_none: 'none', // TODO: translate - token_usage_off: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5173\u95ed', - theme_usage: '\u7528\u6cd5\uff1a/theme ', - theme_set: '\u4e3b\u9898\uff1a', - no_active_session: '\u5f53\u524d\u6ca1\u6709\u6d3b\u52a8\u4f1a\u8bdd', - cmd_queue: '\u5c06\u6d88\u606f\u52a0\u5165\u4e0b\u4e00\u8f6e\u7684\u961f\u5217', + cmd_help: '查看可用命令', + cmd_clear: '清空当前对话消息', + cmd_compress: '手动压缩对话上下文(用法:/compress [主题])', + ctx_compress_hint: '压缩上下文以释放空间 →', + ctx_compress_action: '⚠ 立即压缩以释放上下文', + cmd_compact_alias: '旧别名:/compress', + cmd_model: '切换模型(例如 /model gpt-4o)', + cmd_workspace: '按名称切换工作区', + cmd_terminal: '打开工作区 Terminal', + cmd_new: '新建聊天会话', + cmd_usage: '切换 token 用量显示', + cmd_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/slate/poseidon/sisyphus/charizard)', + cmd_personality: '切换 Agent 人设', + cmd_skills: '列出可用的 Hermes 技能', + available_commands: '可用命令:', + type_slash: '输入 / 可查看命令', + conversation_cleared: '对话已清空', + command_label: '命令', + context_compaction_label: '上下文压缩', + preserved_task_list_label: '保留的任务列表', + reference_only_label: '仅供参考', + model_usage: '用法:/model ', + no_model_match: '没有匹配“', + switched_to: '已切换到 ', + workspace_usage: '用法:/workspace ', + no_workspace_match: '没有匹配“', + switched_workspace: '已切换工作区:', + workspace_switch_failed: '工作区切换失败:', + new_session: '已新建会话', + compressing: '正在请求压缩上下文...', + compress_running_label: '压缩中', + compress_complete_label: '压缩完成', + auto_compress_label: '自动压缩', + compress_failed_label: '压缩失败', + focus_label: '主题', + token_usage_on: 'Token 用量显示已开启', + usage_personality_none: '无', + token_usage_off: 'Token 用量显示已关闭', + theme_usage: '用法:/theme ', + theme_set: '主题:', + no_active_session: '当前没有活动会话', + cmd_queue: '将消息加入下一轮的队列', cmd_goal: '设置或查看持久目标', - cmd_interrupt: '\u53d6\u6d88\u5f53\u524d\u56de\u5408\u5e76\u53d1\u9001\u65b0\u6d88\u606f', - cmd_steer: '\u7528\u7ea0\u6b63\u4fe1\u606f\u5f15\u5bfc\u4ee3\u7406\uff08\u56de\u9000\u4e3a\u4e2d\u65ad\uff09', - cmd_queue_no_msg: '\u7528\u6cd5\uff1a/queue <\u6d88\u606f>', - cmd_queue_not_busy: '\u6ca1\u6709\u6d3b\u52a8\u4efb\u52a1 \u2014 \u76f4\u63a5\u53d1\u9001\u5373\u53ef', - cmd_queue_confirm: '\u6d88\u606f\u5df2\u52a0\u5165\u961f\u5217', - cmd_interrupt_no_msg: '\u7528\u6cd5\uff1a/interrupt <\u6d88\u606f>', - cmd_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u53d1\u9001\u65b0\u6d88\u606f', - cmd_steer_no_msg: '\u7528\u6cd5\uff1a/steer <\u6d88\u606f>', - cmd_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad\u5e76\u52a0\u5165\u961f\u5217', - cmd_steer_delivered: 'Steer \u5df2\u4ea4\u4ed8 \u2014 \u4ee3\u7406\u5c06\u5728\u4e0b\u4e00\u4e2a\u5de5\u5177\u7ed3\u679c\u4e2d\u770b\u5230', - steer_leftover_queued: 'Steer \u5df2\u52a0\u5165\u4e0b\u8f6e\u961f\u5217', - busy_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad', - busy_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u53d1\u9001\u65b0\u6d88\u606f', - settings_label_busy_input_mode: '\u5fd9\u788c\u8f93\u5165\u6a21\u5f0f', - settings_desc_busy_input_mode: '\u63a7\u5236\u5728\u4ee3\u7406\u8fd0\u884c\u65f6\u53d1\u9001\u6d88\u606f\u7684\u884c\u4e3a\u3002\u961f\u5217\u7b49\u5f85\uff1b\u4e2d\u65ad\u53d6\u6d88\u5e76\u91cd\u65b0\u5f00\u59cb\uff1bSteer\u4e2d\u9014\u6ce8\u5165\u7ea0\u6b63\uff0c\u4e0d\u4e2d\u65ad\u3002', - settings_busy_input_mode_queue: '\u52a0\u5165\u961f\u5217', - settings_busy_input_mode_interrupt: '\u4e2d\u65ad\u5f53\u524d\u56de\u5408', - settings_busy_input_mode_steer: 'Steer\uff08\u4e2d\u65ad + \u53d1\u9001\uff09', + cmd_interrupt: '取消当前回合并发送新消息', + cmd_steer: '用纠正信息引导代理(回退为中断)', + cmd_queue_no_msg: '用法:/queue <消息>', + cmd_queue_not_busy: '没有活动任务 — 直接发送即可', + cmd_queue_confirm: '消息已加入队列', + cmd_interrupt_no_msg: '用法:/interrupt <消息>', + cmd_interrupt_confirm: '已中断 — 正在发送新消息', + cmd_steer_no_msg: '用法:/steer <消息>', + cmd_steer_fallback: 'Steer 不可用 — 已中断并加入队列', + cmd_steer_delivered: 'Steer 已交付 — 代理将在下一个工具结果中看到', + steer_leftover_queued: 'Steer 已加入下轮队列', + busy_steer_fallback: 'Steer 不可用 — 已中断', + busy_interrupt_confirm: '已中断 — 正在发送新消息', + settings_label_busy_input_mode: '忙碌输入模式', + settings_desc_busy_input_mode: '控制在代理运行时发送消息的行为。队列等待;中断取消并重新开始;Steer中途注入纠正,不中断。', + settings_busy_input_mode_queue: '加入队列', + settings_busy_input_mode_interrupt: '中断当前回合', + settings_busy_input_mode_steer: 'Steer(中断 + 发送)', workspace_empty_no_path: '未选择工作区。请在 设置 → 工作区 中设置工作区以浏览文件。', workspace_empty_dir: '此工作区为空。', workspace_show_hidden_files: '显示隐藏文件', - workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', - workspace_hidden_files_visible: 'hidden visible', - workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', - workspace_options: 'Workspace options', - no_personalities: '\u6ca1\u6709\u627e\u5230\u4eba\u8bbe\uff08\u53ef\u6dfb\u52a0\u5230 ~/.hermes/personalities/\uff09', - available_personalities: '\u53ef\u7528\u4eba\u8bbe\uff1a', - personality_switch_hint: '\n\n\u4f7f\u7528 `/personality ` \u5207\u6362\uff0c\u6216\u7528 `/personality none` \u6e05\u7a7a\u3002', - personalities_load_failed: '\u52a0\u8f7d\u4eba\u8bbe\u5931\u8d25', - personality_cleared: '\u4eba\u8bbe\u5df2\u6e05\u7a7a', - personality_set: '\u5f53\u524d\u4eba\u8bbe\uff1a', - failed_colon: '\u5931\u8d25\uff1a', + workspace_show_hidden_files_desc: '将 .DS_Store、.git、node_modules 以及其他隐藏文件/系统文件包含在文件树中。', + workspace_hidden_files_visible: '隐藏 显示', + workspace_hidden_files_visible_title: '隐藏文件已显示 — 点击查看选项', + workspace_options: '工作区选项', + no_personalities: '没有找到人设(可添加到 ~/.hermes/personalities/)', + available_personalities: '可用人设:', + personality_switch_hint: '\n\n使用 `/personality ` 切换,或用 `/personality none` 清空。', + personalities_load_failed: '加载人设失败', + personality_cleared: '人设已清空', + personality_set: '当前人设:', + failed_colon: '失败:', // ui.js - no_workspace: '\u672a\u9009\u62e9\u5de5\u4f5c\u533a', - terminal_open_title: '\u6253\u5f00\u5de5\u4f5c\u533a Terminal', - terminal_no_workspace_title: '\u8bf7\u5148\u9009\u62e9\u5de5\u4f5c\u533a\u518d\u6253\u5f00 Terminal', - terminal_title: 'Terminal', - terminal_clear: '\u6e05\u7a7a', - terminal_copy_output: '\u590d\u5236\u8f93\u51fa', - terminal_restart: '\u91cd\u542f', - terminal_collapse: '\u6298\u53e0', - terminal_expand: '\u5c55\u5f00', - terminal_close: '\u5173\u95ed', - terminal_input_placeholder: '\u8f93\u5165\u547d\u4ee4...', - terminal_start_failed: 'Terminal \u542f\u52a8\u5931\u8d25\uff1a', - terminal_input_failed: 'Terminal \u8f93\u5165\u5931\u8d25\uff1a', - terminal_copy_failed: '\u590d\u5236\u5931\u8d25\uff1a', - terminal_error: 'Terminal \u9519\u8bef', - dialog_confirm_title: '\u786e\u8ba4\u64cd\u4f5c', - dialog_prompt_title: '\u8f93\u5165\u5185\u5bb9', - dialog_confirm_btn: '\u786e\u8ba4', + no_workspace: '未选择工作区', + terminal_open_title: '打开工作区 Terminal', + terminal_no_workspace_title: '请先选择工作区再打开 Terminal', + terminal_title: '终端', + terminal_clear: '清屏', + terminal_copy_output: '复制输出', + terminal_restart: '重启', + terminal_collapse: '折叠', + terminal_expand: '展开', + terminal_close: '关闭', + terminal_input_placeholder: '输入命令...', + terminal_start_failed: '终端启动失败:', + terminal_input_failed: '终端输入失败:', + terminal_copy_failed: '复制失败:', + terminal_error: '终端错误', + dialog_confirm_title: '确认操作', + dialog_prompt_title: '输入内容', + dialog_confirm_btn: '确认', // workspace.js - unsaved_confirm: '\u9884\u89c8\u533a\u6709\u672a\u4fdd\u5b58\u4fee\u6539\uff0c\u8981\u653e\u5f03\u66f4\u6539\u5e76\u7ee7\u7eed\u8df3\u8f6c\u5417\uff1f', - discard: '\u653e\u5f03', - save: '\u4fdd\u5b58', - edit: '\u7f16\u8f91', - clear: '\u6e05\u7a7a', - create: '\u521b\u5efa', - remove: '\u79fb\u9664', - save_title: '\u4fdd\u5b58\u4fee\u6539', - edit_title: '\u7f16\u8f91\u6b64\u6587\u4ef6', - saved: '\u5df2\u4fdd\u5b58', - save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a', - image_load_failed: '\u56fe\u7247\u52a0\u8f7d\u5931\u8d25', - file_open_failed: '\u65e0\u6cd5\u6253\u5f00\u6587\u4ef6', - downloading: (name) => `\u6b63\u5728\u4e0b\u8f7d ${name}...`, - double_click_rename: '\u53cc\u51fb\u91cd\u547d\u540d', - renamed_to: '\u5df2\u91cd\u547d\u540d\u4e3a ', - rename_failed: '\u91cd\u547d\u540d\u5931\u8d25\uff1a', - delete_title: '\u5220\u9664', - delete_confirm: (name) => `\u8981\u5220\u9664 ${name} \u5417\uff1f`, + unsaved_confirm: '预览区有未保存修改,要放弃更改并继续跳转吗?', + discard: '放弃', + save: '保存', + edit: '编辑', + clear: '清空', + create: '创建', + remove: '移除', + save_title: '保存修改', + edit_title: '编辑此文件', + saved: '已保存', + save_failed: '保存失败:', + image_load_failed: '图片加载失败', + file_open_failed: '无法打开文件', + downloading: (name) => `正在下载 ${name}...`, + double_click_rename: '双击重命名', + renamed_to: '已重命名为 ', + rename_failed: '重命名失败:', + delete_title: '删除', + delete_confirm: (name) => `要删除 ${name} 吗?`, delete_dir_confirm: (name) => `删除文件夹 "${name}" 及其所有内容?`, rename_title: '重命名', rename_prompt: '新名称:', - deleted: '\u5df2\u5220\u9664 ', - delete_failed: '\u5220\u9664\u5931\u8d25\uff1a', - reveal_in_finder: '\u5728\u6587\u4ef6\u7ba1\u7406\u5668\u4e2d\u663e\u793a', - reveal_failed: '\u663e\u793a\u5931\u8d25\uff1a', + deleted: '已删除 ', + delete_failed: '删除失败:', + reveal_in_finder: '在文件管理器中显示', + reveal_failed: '显示失败:', copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', path_copied: '\u6587\u4ef6\u8def\u5f84\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f', path_copy_failed: '\u590d\u5236\u8def\u5f84\u5931\u8d25\uff1a', session_rename: '\u91cd\u547d\u540d\u5bf9\u8bdd', session_rename_desc: '\u7f16\u8f91\u6b64\u5bf9\u8bdd\u7684\u6807\u9898', - new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', - project_name_prompt: '\u9879\u76ee\u540d\u79f0\uff1a', - created: '\u5df2\u521b\u5efa ', - create_failed: '\u521b\u5efa\u5931\u8d25\uff1a', - new_folder_prompt: '\u65b0\u6587\u4ef6\u5939\u540d\u79f0\uff1a', - folder_created: '\u5df2\u521b\u5efa\u6587\u4ef6\u5939 ', - folder_create_failed: '\u521b\u5efa\u6587\u4ef6\u5939\u5931\u8d25\uff1a', - workspace_auto_create_folder: '\u5982\u679c\u6587\u4ef6\u5939\u4e0d\u5b58\u5728\u5219\u521b\u5efa', - folder_add_as_space_btn: '\u6dfb\u52a0\u4e3a\u5de5\u4f5c\u533a', - folder_add_as_space_msg: '\u662f\u5426\u5c06\u6b64\u6587\u4ef6\u5939\u6dfb\u52a0\u4e3a\u65b0\u7684\u5de5\u4f5c\u533a\uff1f', - folder_add_as_space_title: '\u6dfb\u52a0\u4e3a\u5de5\u4f5c\u533a\uff1f', - remove_title: '\u79fb\u9664', - empty_dir: '(\u7a7a)', - upload_failed: '\u4e0a\u4f20\u5931\u8d25\uff1a', + new_file_prompt: '新文件名(例如 notes.md):', + project_name_prompt: '项目名称:', + created: '已创建 ', + create_failed: '创建失败:', + new_folder_prompt: '新文件夹名称:', + folder_created: '已创建文件夹 ', + folder_create_failed: '创建文件夹失败:', + workspace_auto_create_folder: '如果文件夹不存在则创建', + folder_add_as_space_btn: '添加为工作区', + folder_add_as_space_msg: '是否将此文件夹添加为新的工作区?', + folder_add_as_space_title: '添加为工作区?', + remove_title: '移除', + empty_dir: '(空)', + upload_failed: '上传失败:', upload_too_large: (maxMb, fileMb) => `\u6587\u4ef6\u8fc7\u5927\uff08${fileMb} MB\uff09\u3002\u6700\u5927\u4e0a\u4f20\u5927\u5c0f\u4e3a ${maxMb} MB\u3002`, - all_uploads_failed: (n) => `${n} \u4e2a\u6587\u4ef6\u5168\u90e8\u4e0a\u4f20\u5931\u8d25`, + all_uploads_failed: (n) => `${n} 个文件全部上传失败`, // settings panel - settings_title: '\u8bbe\u7f6e', - settings_save_btn: '\u4fdd\u5b58\u8bbe\u7f6e', - settings_label_model: '\u9ed8\u8ba4\u6a21\u578b', - settings_desc_model: '\u7528\u4e8e\u65b0\u5bf9\u8bdd\u3002\u73b0\u6709\u5bf9\u8bdd\u4fdd\u6301\u5404\u81ea\u9009\u5b9a\u7684\u6a21\u578b\u3002', - settings_label_send_key: '\u53d1\u9001\u5feb\u6377\u952e', - settings_label_theme: '\u4e3b\u9898', - settings_label_skin: '\u76ae\u80a4', - settings_label_font_size: '\u5b57\u4f53\u5927\u5c0f', - font_size_small: '\u5c0f', - font_size_default: '\u9ed8\u8ba4', - font_size_large: '\u5927', - settings_autosave_saving: '\u4fdd\u5b58\u4e2d…', - settings_autosave_saved: '\u5df2\u4fdd\u5b58', - settings_autosave_failed: '\u4fdd\u5b58\u5931\u8d25', - settings_autosave_retry: '\u91cd\u8bd5', - settings_label_language: '\u8bed\u8a00', - settings_label_token_usage: '\u663e\u793a token \u7528\u91cf', + settings_title: '设置', + settings_save_btn: '保存设置', + settings_label_model: '默认模型', + settings_desc_model: '用于新对话。现有对话保持各自选定的模型。', + settings_label_send_key: '发送快捷键', + settings_label_theme: '主题', + settings_label_skin: '皮肤', + settings_label_font_size: '字体大小', + font_size_small: '小', + font_size_default: '默认', + font_size_large: '大', + settings_autosave_saving: '保存中…', + settings_autosave_saved: '已保存', + settings_autosave_failed: '保存失败', + settings_autosave_retry: '重试', + settings_label_language: '语言', + settings_label_token_usage: '显示 token 用量', settings_label_sidebar_density: '侧边栏密度', - cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status', + cmd_reasoning: '切换思维可见性(显示/隐藏)、设置工作强度或查看当前状态', settings_label_external_sessions: '显示外部会话', - settings_label_sync_insights: '\u540c\u6b65\u5230 insights', - settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0', - settings_label_bot_name: '\u52a9\u624b\u540d\u79f0', - settings_label_password: '\u8bbf\u95ee\u5bc6\u7801', - settings_saved: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58', - settings_save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a', - settings_load_failed: '\u8bbe\u7f6e\u52a0\u8f7d\u5931\u8d25\uff1a', - settings_saved_pw: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff0c\u5df2\u542f\u7528\u5bc6\u7801\u4fdd\u62a4\uff0c\u5f53\u524d\u6d4f\u89c8\u5668\u4f1a\u4fdd\u6301\u767b\u5f55', - settings_saved_pw_updated: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff0c\u5bc6\u7801\u5df2\u66f4\u65b0', + settings_label_sync_insights: '同步到 insights', + settings_label_check_updates: '检查更新', + settings_label_bot_name: '助手名称', + settings_label_password: '访问密码', + settings_saved: '设置已保存', + settings_save_failed: '保存失败:', + settings_load_failed: '设置加载失败:', + settings_saved_pw: '设置已保存,已启用密码保护,当前浏览器会保持登录', + settings_saved_pw_updated: '设置已保存,密码已更新', // login page - login_title: '\u767b\u5f55', - login_subtitle: '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528', - login_placeholder: '\u5bc6\u7801', - login_btn: '\u767b\u5f55', - login_invalid_pw: '\u5bc6\u7801\u9519\u8bef', - login_conn_failed: '\u8fde\u63a5\u5931\u8d25', + login_title: '登录', + login_subtitle: '输入密码继续使用', + login_placeholder: '密码', + login_btn: '登录', + login_invalid_pw: '密码错误', + login_conn_failed: '连接失败', // sidebar & navigation tab_chat: '聊天', tab_memory: '记忆', tab_skills: '技能', tab_tasks: '任务', - tab_kanban: 'Kanban', - kanban_board: 'Board', - kanban_visible_tasks: '{0} visible tasks', - kanban_search_tasks: 'Search tasks', - kanban_all_assignees: 'All assignees', - kanban_all_tenants: 'All tenants', - kanban_include_archived: 'Include archived', - kanban_no_matching_tasks: 'No matching tasks', - kanban_no_data: 'No Kanban data', - kanban_work_queue_hint: 'This is the Hermes Agent work queue. Create or triage a task, assign it, move it to Ready, then let the dispatcher claim it.', - kanban_unavailable: 'Kanban unavailable', - kanban_read_only: 'Read-only view', - kanban_empty: 'Empty', - kanban_task: 'Task', - kanban_no_description: 'No description', - kanban_refresh: 'Refresh', - kanban_status_triage: 'Triage', - kanban_status_todo: 'Todo', - kanban_status_ready: 'Ready', - kanban_status_running: 'Running', - kanban_status_blocked: 'Blocked', - kanban_status_done: 'Done', - kanban_comments_count: 'Comments ({0})', - kanban_events_count: 'Events ({0})', - kanban_links: 'Links', - kanban_parents: 'Parents', - kanban_children: 'Children', - kanban_runs_count: 'Runs ({0})', - kanban_no_comments: 'No comments', - kanban_no_events: 'No events', - kanban_no_runs: 'No runs', + tab_kanban: '看板', + kanban_board: '看板', + kanban_visible_tasks: '{0} 个可见任务', + kanban_search_tasks: '搜索任务', + kanban_all_assignees: '所有负责人', + kanban_all_tenants: '所有租户', + kanban_include_archived: '包含已归档', + kanban_no_matching_tasks: '没有匹配的任务', + kanban_no_data: '无看板数据', + kanban_work_queue_hint: '这是 Hermes Agent 的工作队列。创建或分类任务,分配任务,将其移至“就绪”状态,然后让调度员认领任务。', + kanban_unavailable: '看板不可用', + kanban_read_only: '只读视图', + kanban_empty: '空', + kanban_task: '任务', + kanban_no_description: '无描述', + kanban_refresh: '刷新', + kanban_status_triage: '待分类', + kanban_status_todo: '待办', + kanban_status_ready: '就绪', + kanban_status_running: '进行中', + kanban_status_blocked: '阻塞', + kanban_status_done: '完成', + kanban_comments_count: '评论 ({0})', + kanban_events_count: '事件 ({0})', + kanban_links: '链接', + kanban_parents: '父任务', + kanban_children: '子任务', + kanban_runs_count: '运行 ({0})', + kanban_no_comments: '无评论', + kanban_no_events: '无事件', + kanban_no_runs: '无运行记录', kanban_title: '标题', kanban_description: '描述', kanban_description_placeholder: '可选 — 需要做什么、验收标准、链接', @@ -5530,33 +5530,33 @@ const LOCALES = { kanban_dispatch_auto_blocked: '自动阻止', kanban_dispatch_timed_out: '超时', kanban_dispatch_crashed: '崩溃', - kanban_new_task: 'New task', - kanban_add_comment: 'Add comment', - kanban_only_mine: 'Only mine', - kanban_bulk_action: 'Bulk action', - kanban_nudge_dispatcher: 'Preview dispatcher', - kanban_stats: 'Stats', - kanban_worker_log: 'Worker log', - kanban_block: 'Block', - kanban_unblock: 'Unblock', - kanban_back_to_board: 'Back to board', - kanban_lanes_by_profile: 'Lanes by profile', - kanban_new_board: 'New board…', - kanban_rename_board: 'Rename current board…', - kanban_archive_board: 'Archive current board…', - kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.', - kanban_board_archived: 'Board archived', - kanban_board_name: 'Name', - kanban_board_slug: 'Slug (lowercase, hyphens)', - kanban_board_description: 'Description (optional)', - kanban_board_icon: 'Icon (emoji, optional)', - kanban_board_color: 'Color (optional)', - kanban_board_name_required: 'Name is required', - kanban_board_slug_required: 'Slug is required', - kanban_card_complete: 'complete', - kanban_card_archive: 'archive', - kanban_unassigned: 'unassigned', - kanban_status_archived: 'Archived', + kanban_new_task: '新建任务', + kanban_add_comment: '添加评论', + kanban_only_mine: '仅我的', + kanban_bulk_action: '批量操作', + kanban_nudge_dispatcher: '提醒调度器', + kanban_stats: '统计', + kanban_worker_log: '工作日志', + kanban_block: '阻塞', + kanban_unblock: '解除阻塞', + kanban_back_to_board: '返回看板', + kanban_lanes_by_profile: '按配置文件分列', + kanban_new_board: '新建看板…', + kanban_rename_board: '重命名当前看板…', + kanban_archive_board: '归档当前看板…', + kanban_archive_board_confirm: '归档看板 "{name}"?任务仍保存在磁盘上,并且可以从 kanban/boards/_archived/ 恢复该看板。', + kanban_board_archived: '看板已归档', + kanban_board_name: '名称', + kanban_board_slug: '标识(小写,连字符)', + kanban_board_description: '描述(可选)', + kanban_board_icon: '图标(emoji,可选)', + kanban_board_color: '颜色(可选)', + kanban_board_name_required: '名称为必填', + kanban_board_slug_required: '标识为必填', + kanban_card_complete: '完成', + kanban_card_archive: '归档', + kanban_unassigned: '未分配', + kanban_status_archived: '已归档', tab_todos: '待办', tab_insights: '统计', tab_dashboard: 'Hermes 仪表盘', @@ -5566,19 +5566,19 @@ const LOCALES = { tab_logs: '日志', tab_settings: '设置', - logs_title: 'Logs', // TODO: translate - logs_file: 'File', // TODO: translate - logs_tail: 'Tail', // TODO: translate - logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate - logs_wrap: 'Wrap lines', // TODO: translate - logs_copy_all: 'Copy all', // TODO: translate - logs_empty: 'No log lines yet.', // TODO: translate - logs_loading: 'Loading logs…', // TODO: translate - logs_load_failed: 'Logs failed to load', // TODO: translate - logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate - logs_no_mtime: 'not written yet', // TODO: translate - logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate - logs_copied: 'Logs copied', // TODO: translate + logs_title: '日志', + logs_file: '文件', + logs_tail: '末尾', + logs_auto_refresh: '自动刷新(5秒)', + logs_wrap: '自动换行', + logs_copy_all: '全部复制', + logs_empty: '暂无日志。', + logs_loading: '加载日志中…', + logs_load_failed: '日志加载失败', + logs_status_idle: '选择日志文件以查看最新内容。', + logs_no_mtime: '尚未写入', + logs_truncated_hint: '此处显示的是日志文件的末尾内容。为节省内存,已省略较早的数据。', + logs_copied: '日志已复制', new_conversation: '新建对话', filter_conversations: '筛选对话…', session_time_unknown: '未知', @@ -5619,27 +5619,27 @@ const LOCALES = { suggest_files: '这个工作区有哪些文件?', sign_out: '退出登录', // Providers panel (English fallback — native translations welcome in follow-up PRs) - providers_tab_title: 'Providers', - providers_section_title: 'Providers', - providers_section_meta: 'Manage API keys for AI providers. Changes take effect immediately.', - providers_status_configured: 'API key configured', - providers_status_not_configured: 'No API key', + providers_tab_title: '提供商', + providers_section_title: '提供商', + providers_section_meta: '管理 AI 提供商的 API 密钥。更改立即生效。', + providers_status_configured: 'API 密钥已配置', + providers_status_not_configured: '无 API 密钥', providers_status_oauth: 'OAuth', - providers_status_api_key: 'API key', - providers_status_not_configured_label: 'Not configured', - providers_oauth_hint: 'Authenticated via OAuth. No API key needed.', - providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', - providers_oauth_not_configured_hint: 'Not authenticated. Run hermes auth in the terminal to configure this provider.', - providers_save: 'Save', - providers_remove: 'Remove', - providers_saving: 'Saving…', - providers_removing: 'Removing…', - providers_enter_key: 'Please enter an API key', - providers_empty: 'No configurable providers found.', - providers_key_updated: 'API key saved', - providers_key_removed: 'API key removed', + providers_status_api_key: 'API 密钥', + providers_status_not_configured_label: '未配置', + providers_oauth_hint: '通过 OAuth 认证。无需 API 密钥。', + providers_oauth_config_yaml_hint: '通过 config.yaml 配置的令牌。如需更新,请编辑 config.yaml 中的 providers 部分或运行 hermes auth。', + providers_oauth_not_configured_hint: '未认证。在终端中运行 hermes auth 以配置此提供商。', + providers_save: '保存', + providers_remove: '移除', + providers_saving: '保存中…', + providers_removing: '移除中…', + providers_enter_key: '请输入 API 密钥', + providers_empty: '未找到可配置的提供商。', + providers_key_updated: 'API 密钥已保存', + providers_key_removed: 'API 密钥已移除', providers_key_placeholder_new: 'sk-...', - providers_key_placeholder_replace: 'Enter new key to replace…', + providers_key_placeholder_replace: '输入新密钥以替换…', password_placeholder: '输入新密码…', password_env_var_locked: '当前已设置 HERMES_WEBUI_PASSWORD 环境变量并具有优先级。请取消该变量并重启服务器,才能在此管理密码。', password_env_var_locked_placeholder: '已锁定:已设置 HERMES_WEBUI_PASSWORD 环境变量', @@ -5652,12 +5652,12 @@ const LOCALES = { settings_sidebar_density_compact: '紧凑', settings_sidebar_density_detailed: '详细', settings_desc_sidebar_density: '控制左侧会话列表展示多少元信息。', - settings_label_auto_title_refresh: '\u81ea\u9002\u5e94\u6807\u9898\u66f4\u65b0', - settings_auto_title_refresh_off: '\u5173\u95ed', - settings_auto_title_refresh_5: '\u6bcf 5 \u8f6e\u5bf9\u8bdd', - settings_auto_title_refresh_10: '\u6bcf 10 \u8f6e\u5bf9\u8bdd', - settings_auto_title_refresh_20: '\u6bcf 20 \u8f6e\u5bf9\u8bdd', - settings_desc_auto_title_refresh: '\u57fa\u4e8e\u6700\u65b0\u5bf9\u8bdd\u81ea\u52a8\u91cd\u65b0\u751f\u6210\u4f1a\u8bdd\u6807\u9898\uff0c\u4f7f\u5176\u968f\u5bf9\u8bdd\u53d1\u5c55\u4fdd\u6301\u76f8\u5173\u3002\u9700\u8981\u914d\u7f6e LLM \u6807\u9898\u751f\u6210\u6a21\u578b\u3002', + settings_label_auto_title_refresh: '自适应标题更新', + settings_auto_title_refresh_off: '关闭', + settings_auto_title_refresh_5: '每 5 轮对话', + settings_auto_title_refresh_10: '每 10 轮对话', + settings_auto_title_refresh_20: '每 20 轮对话', + settings_desc_auto_title_refresh: '基于最新对话自动重新生成会话标题,使其随对话发展保持相关。需要配置 LLM 标题生成模型。', settings_desc_external_sessions: '在会话列表中显示来自 CLI、Telegram、Discord、Slack 等渠道的对话。点击可导入并继续对话。', settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。', settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。', @@ -5718,16 +5718,16 @@ const LOCALES = { provider_category_specialized: '专业服务', onboarding_api_key_label: 'API key', onboarding_api_key_placeholder: '留空可保留已保存的 key', - onboarding_api_key_label_optional: 'API key (optional)', // TODO: translate - onboarding_api_key_placeholder_optional: 'Leave blank for keyless servers', // TODO: translate - onboarding_api_key_help_keyless: 'Most LM Studio / Ollama / vLLM installs run keyless — leave this blank if your server doesn\'t require authentication. Use the Test connection button to verify.', // TODO: translate - oauth_login_codex: 'Login with Codex (ChatGPT)', // TODO: translate - oauth_codex_step1: 'Step 1: Visit this URL and enter the code', // TODO: translate - oauth_codex_step2: 'Step 2: Enter this code on the page', // TODO: translate - oauth_codex_polling: 'Waiting for authorization...', // TODO: translate - oauth_codex_success: 'Codex OAuth login successful!', // TODO: translate - oauth_codex_error: 'OAuth login failed', // TODO: translate - oauth_codex_expired: 'Code expired, please try again', // TODO: translate + onboarding_api_key_label_optional: 'API 密钥(可选)', + onboarding_api_key_placeholder_optional: '留空用于无需密钥的服务器', + onboarding_api_key_help_keyless: '大多数 LM Studio / Ollama / vLLM 安装无需密钥即可运行。', + oauth_login_codex: '使用 Codex (ChatGPT) 登录', + oauth_codex_step1: '步骤 1:访问此 URL 并输入代码', + oauth_codex_step2: '步骤 2:在页面上输入此代码', + oauth_codex_polling: '等待授权…', + oauth_codex_success: 'Codex OAuth 登录成功!', + oauth_codex_error: 'OAuth 登录失败', + oauth_codex_expired: '代码已过期,请重试', onboarding_api_key_help_prefix: '会作为密钥保存到 Hermes .env 文件中,变量名为', onboarding_base_url_label: 'Base URL', onboarding_base_url_placeholder: 'https://your-endpoint.example/v1', @@ -5753,19 +5753,19 @@ const LOCALES = { onboarding_error_choose_model: '继续前请先选择模型。', onboarding_error_provider_required: '继续前请先选择设置模式。', onboarding_error_base_url_required: '自定义端点必须填写 Base URL。', - onboarding_probe_test_button: 'Test connection', // TODO: translate - onboarding_probe_probing: 'Testing connection…', // TODO: translate - onboarding_probe_ok: 'Connected. {n} model(s) available.', // TODO: translate - onboarding_probe_error_generic: 'Could not reach the configured base URL.', // TODO: translate - onboarding_probe_error_invalid_url: 'Base URL must start with http:// or https://.', // TODO: translate - onboarding_probe_error_dns: 'Could not resolve the host. Check the URL or use the host\'s IP address.', // TODO: translate - onboarding_probe_error_connect_refused: 'Connection refused — the server may not be running on that address. From inside Docker, try the host IP instead of localhost.', // TODO: translate - onboarding_probe_error_timeout: 'The endpoint did not respond in time. Check that the server is running and the URL is correct.', // TODO: translate - onboarding_probe_error_http_4xx: 'The endpoint returned a client error. Check authentication and the URL path (typically ends in /v1).', // TODO: translate - onboarding_probe_error_http_5xx: 'The endpoint returned a server error. Check the LM Studio / Ollama server logs.', // TODO: translate - onboarding_probe_error_parse: 'The endpoint did not return a model list in the expected shape. Verify the URL points to the OpenAI-compatible API root.', // TODO: translate - onboarding_probe_error_unreachable: 'Could not reach the configured base URL.', // TODO: translate - onboarding_error_probe_failed: 'Could not validate the configured base URL.', // TODO: translate + onboarding_probe_test_button: '测试连接', + onboarding_probe_probing: '正在测试连接…', + onboarding_probe_ok: '已连接。{n} 个模型可用。', + onboarding_probe_error_generic: '无法访问配置的基础 URL。', + onboarding_probe_error_invalid_url: '基础 URL 必须以 http:// 或 https:// 开头。', + onboarding_probe_error_dns: '无法解析主机名。请检查 URL,或在 Docker 中使用主机 IP 而非 localhost。', + onboarding_probe_error_connect_refused: '连接被拒绝 — 该地址上可能没有运行服务器。在 Docker 容器内,请使用主机 IP 而非 localhost。', + onboarding_probe_error_timeout: '端点未及时响应。请确认服务器正在运行且 URL 正确。', + onboarding_probe_error_http_4xx: '端点返回客户端错误。请检查认证信息和 URL 路径(通常以 /v1 结尾)。', + onboarding_probe_error_http_5xx: '端点返回服务器错误。请检查 LM Studio / Ollama 服务器日志。', + onboarding_probe_error_parse: '端点未返回预期的模型列表格式。请确认 URL 指向 OpenAI 兼容的 API 根路径。', + onboarding_probe_error_unreachable: '无法访问配置的基础 URL。', + onboarding_error_probe_failed: '无法验证配置的基础 URL。', onboarding_error_workspace_required: '必须填写工作区。', onboarding_error_model_required: '必须填写模型。', onboarding_complete: '引导完成', @@ -5936,8 +5936,8 @@ const LOCALES = { cron_skills_label: '技能', cron_skills_placeholder: '添加技能(可选)…', cron_skills_edit_hint: '创建后无法再编辑技能列表。', - cron_duplicate: '\u590d\u5236', - cron_duplicated: '\u4efb\u52a1\u5df2\u590d\u5236\uff08\u5df2\u6682\u505c\uff09', + cron_duplicate: '复制', + cron_duplicated: '任务已复制(已暂停)', // workspace form workspace_name_label: '名称', workspace_name_placeholder: '可选的友好名称', @@ -5951,29 +5951,29 @@ const LOCALES = { profile_api_key_label: 'API 密钥', // Session management and settings keys (en fallback — pending translation) - session_archive: 'Archive conversation', - session_archive_desc: 'Hide this conversation until archived is shown', - session_archive_failed: 'Archive failed: ', - session_archived: 'Session archived', - session_delete: 'Delete conversation', - session_delete_desc: 'Permanently remove this conversation', - session_duplicate: 'Duplicate conversation', - session_duplicate_desc: 'Create a copy with the same workspace and model', - session_duplicate_failed: 'Duplicate failed: ', - session_stop_response: 'Stop response', - session_stop_response_desc: 'Cancel the running response for this conversation', - session_duplicated: 'Session duplicated', - session_move_project: 'Move to project', - session_move_project_desc_has: 'Change the project for this conversation', - session_move_project_desc_none: 'Assign a project to this conversation', - session_pin: 'Pin conversation', - session_pin_desc: 'Keep this conversation at the top', - session_pin_failed: 'Pin failed: ', - session_restore: 'Restore conversation', - session_restore_desc: 'Bring this conversation back into the main list', - session_restored: 'Session restored', - session_unpin: 'Unpin conversation', - session_unpin_desc: 'Remove from pinned', + session_archive: '归档会话', + session_archive_desc: '隐藏此会话,直到显示归档', + session_archive_failed: '归档失败:', + session_archived: '会话已归档', + session_delete: '删除会话', + session_delete_desc: '永久删除此会话', + session_duplicate: '复制会话', + session_duplicate_desc: '用相同工作区和模型创建副本', + session_duplicate_failed: '复制失败:', + session_stop_response: '停止回复', + session_stop_response_desc: '取消此会话正在进行的回复', + session_duplicated: '会话已复制', + session_move_project: '移至项目', + session_move_project_desc_has: '更改此会话的项目', + session_move_project_desc_none: '为此会话分配项目', + session_pin: '置顶会话', + session_pin_desc: '将此会话保持在顶部', + session_pin_failed: '置顶失败:', + session_restore: '恢复会话', + session_restore_desc: '将会话恢复到主列表', + session_restored: '会话已恢复', + session_unpin: '取消置顶', + session_unpin_desc: '从置顶中移除', session_select_mode: '选择', session_select_mode_desc: '选择会话以批量管理', session_select_all: '全选', @@ -5985,19 +5985,19 @@ const LOCALES = { session_batch_delete_confirm: '删除 {0} 个会话?', session_batch_archive_confirm: '归档 {0} 个会话?', session_no_selection: '未选择任何会话', - settings_dropdown_appearance: 'Appearance', - settings_dropdown_conversation: 'Conversation', - settings_dropdown_preferences: 'Preferences', - settings_dropdown_providers: 'Providers', - settings_dropdown_system: 'System', - settings_heading_subtitle: 'Preferences, conversation tools, and system controls.', - settings_heading_title: 'Control Center', - settings_section_appearance_meta: 'Theme, accent colors, and visual style.', - settings_section_appearance_title: 'Appearance', - settings_section_conversation_title: 'Conversation', - settings_section_preferences_meta: 'Defaults and UI behavior for Hermes Web UI.', - settings_section_preferences_title: 'Preferences', - settings_section_system_meta: 'Instance version and access controls.', + settings_dropdown_appearance: '外观', + settings_dropdown_conversation: '对话', + settings_dropdown_preferences: '偏好', + settings_dropdown_providers: '提供商', + settings_dropdown_system: '系统', + settings_heading_subtitle: '偏好设置、对话工具和系统控制。', + settings_heading_title: '控制中心', + settings_section_appearance_meta: '主题、强调色和视觉风格。', + settings_section_appearance_title: '外观', + settings_section_conversation_title: '对话', + settings_section_preferences_meta: 'Hermes WebUI 的默认和 UI 行为。', + settings_section_preferences_title: '偏好', + settings_section_system_meta: '实例版本和访问控制。', settings_check_now: '立即检查', settings_checking: '检查中\u2026', settings_up_to_date: '已是最新 \u2713', @@ -6013,28 +6013,28 @@ const LOCALES = { settings_desc_session_endless_scroll: '启用后,向上滚动时会自动加载更早的消息。禁用时请使用加载更早消息按钮。', open_in_browser: '在浏览器中打开', - settings_section_system_title: 'System', - settings_tab_appearance: 'Appearance', - settings_tab_conversation: 'Conversation', - settings_tab_preferences: 'Preferences', - settings_tab_system: 'System', - status_updated: 'Updated', - status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', - status_no_tokens: 'No token data', - status_profile: 'Profile', - status_hermes_home: 'Hermes home', - status_started: 'Started', - status_tokens: 'Tokens', - status_unknown: 'Unknown', + settings_section_system_title: '系统', + settings_tab_appearance: '外观', + settings_tab_conversation: '对话', + settings_tab_preferences: '偏好', + settings_tab_system: '系统', + status_updated: '已更新', + status_ephemeral: '临时快照 — 不会保存到对话记录。', + status_no_tokens: '无令牌数据', + status_profile: '配置文件', + status_hermes_home: 'Hermes 主目录', + status_started: '开始时间', + status_tokens: '令牌', + status_unknown: '未知', cmd_yolo: 'YOLO 模式切换', yolo_no_session: '无活动会话', yolo_enabled: '⚡ YOLO 模式已开启 — 将跳过所有审批', - cmd_branch:'Fork this conversation into a new session', - cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)', - branch_forked:'Forked into new session', - branch_failed:'Fork failed: ', - fork_from_here:'Fork from here', - forked_from:'Forked from', + cmd_branch: '将此对话分叉到新会话', + cmd_branch_usage:'/branch [名称] — 分叉会话(可选择添加名称)', + branch_forked: '已创建新分叉', + branch_failed: '分叉失败:', + fork_from_here: '从此处分叉', + forked_from: '分叉自', yolo_disabled: 'YOLO 模式已关闭', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'YOLO 模式激活 — 点击关闭', @@ -6089,63 +6089,63 @@ const LOCALES = { settings_label_tts_auto_read: '自动朗读回复', settings_desc_tts_auto_read: '自动朗读助手回复', // Composer voice-mode pref (#1488) - settings_label_voice_mode: 'Hands-free voice mode button', // TODO: translate - settings_desc_voice_mode: 'Show the voice-mode button (audio waveform) next to the dictation mic. Lets you speak naturally — Hermes auto-sends after a pause and reads replies aloud. Requires a browser that supports both speech recognition and TTS.', // TODO: translate + settings_label_voice_mode: '免提语音模式按钮', + settings_desc_voice_mode: '在听写麦克风旁显示语音模式按钮(音频波形)。让您自然说话 — Hermes 会在停顿后自动发送并朗读回复。需要支持语音识别和 TTS 的浏览器。', settings_label_tts_voice: '语音', settings_desc_tts_voice: '选择语音合成声音', settings_label_tts_rate: '语速', settings_label_tts_pitch: '音调', - checkpoint_date: 'Date', // TODO: translate + checkpoint_date: '日期', checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate - checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate - checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate - checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate - checkpoint_error: 'Failed to load checkpoints', // TODO: translate - checkpoint_files: 'Files', // TODO: translate - checkpoint_loading: 'Loading checkpoints…', // TODO: translate - checkpoint_message: 'Message', // TODO: translate - checkpoint_restore: 'Restore', // TODO: translate - checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate - checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate - checkpoint_restored: 'Checkpoint restored', // TODO: translate - checkpoint_title: 'Checkpoints', // TODO: translate - checkpoint_view_diff: 'View diff', // TODO: translate - insights_activity_by_day: 'Activity by Day', // TODO: translate - insights_activity_by_hour: 'Activity by Hour', // TODO: translate - insights_cost: 'Estimated Cost', // TODO: translate - insights_daily_tokens: 'Daily Tokens', - insights_model_name: 'Model', - insights_model_sessions: 'Sessions', - insights_model_tokens: 'Tokens', - insights_model_cost: 'Cost', - insights_model_share: 'Share', - insights_no_usage_data: 'No usage data yet', - insights_footer: 'Showing data from the last {days} days', // TODO: translate - insights_input_tokens: 'Input', // TODO: translate - insights_messages: 'Messages', // TODO: translate - insights_models: 'Models', // TODO: translate - insights_no_cost: 'N/A', // TODO: translate - insights_output_tokens: 'Output', // TODO: translate - insights_peak_hour: 'Peak: {hour}', // TODO: translate - insights_sessions: 'Sessions', // TODO: translate - insights_title: 'Usage Analytics', // TODO: translate - insights_token_breakdown: 'Token Breakdown', // TODO: translate - insights_tokens: 'Tokens', // TODO: translate - insights_total: 'Total', // TODO: translate - settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate - settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate - voice_error: 'Voice not supported in this browser', // TODO: translate - voice_listening: 'Listening…', // TODO: translate - voice_mode_active: 'Voice mode on', // TODO: translate - voice_mode_off: 'Voice mode off', // TODO: translate - voice_speaking: 'Speaking…', // TODO: translate - voice_thinking: 'Thinking…', // TODO: translate + checkpoint_diff_no_changes: '此检查点与当前工作区之间无差异。', + checkpoint_diff_title: '检查点变更', + checkpoint_empty: '此工作区未找到检查点。', + checkpoint_error: '加载检查点失败', + checkpoint_files: '文件', + checkpoint_loading: '加载检查点中…', + checkpoint_message: '消息', + checkpoint_restore: '恢复', + checkpoint_restore_confirm_message: (ckpt) => `将工作区恢复到检查点 "${ckpt}"?此操作将用已保存版本覆盖文件。此检查点之后添加的文件不会被删除。`, + checkpoint_restore_confirm_title: '恢复检查点?', + checkpoint_restored: '检查点已恢复', + checkpoint_title: '检查点', + checkpoint_view_diff: '查看差异', + insights_activity_by_day: '按日活动', + insights_activity_by_hour: '按时活动', + insights_cost: '预估费用', + insights_daily_tokens: '每日令牌', + insights_model_name: '模型', + insights_model_sessions: '会话', + insights_model_tokens: '令牌', + insights_model_cost: '费用', + insights_model_share: '占比', + insights_no_usage_data: '暂无使用数据', + insights_footer: '显示最近 {days} 天的数据', + insights_input_tokens: '输入', + insights_messages: '消息', + insights_models: '模型', + insights_no_cost: 'N/A', + insights_output_tokens: '输出', + insights_peak_hour: '高峰:{hour}时', + insights_sessions: '会话', + insights_title: '使用分析', + insights_token_breakdown: '令牌分解', + insights_tokens: '令牌', + insights_total: '总计', + settings_desc_api_redact: '自托管用户可禁用以实现透明(不推荐用于共享实例)。', + settings_label_api_redact: '在 API 响应中隐藏敏感数据', + voice_error: '此浏览器不支持语音功能', + voice_listening: '正在聆听…', + voice_mode_active: '语音模式已开启', + voice_mode_off: '语音模式已关闭', + voice_speaking: '正在说话…', + voice_thinking: '思考中…', // Composer voice buttons (#1488) - voice_dictate: 'Dictate', // TODO: translate - voice_dictate_active: 'Stop dictation', // TODO: translate - voice_mode_toggle: 'Voice mode', // TODO: translate - voice_mode_toggle_active: 'Exit voice mode', // TODO: translate - subagent_children: 'Subagent sessions', // TODO: translate + voice_dictate: '听写', + voice_dictate_active: '停止听写', + voice_mode_toggle: '语音模式', + voice_mode_toggle_active: '退出语音模式', + subagent_children: '子代理会话', }, // Traditional Chinese (zh-Hant) From 84a172b57278e3d8a86593f5d5e1f08f28e92c9f Mon Sep 17 00:00:00 2001 From: vikarag Date: Sun, 10 May 2026 17:48:37 +0900 Subject: [PATCH 7/9] feat: add Xiaomi MiMo provider support Add xiaomi to _PROVIDER_DISPLAY, _PROVIDER_MODELS, and _PROVIDER_ALIASES so the WebUI recognizes Xiaomi as a first-class provider. Models included: - mimo-v2.5-pro (MiMo V2.5 Pro) - mimo-v2.5 (MiMo V2.5) - mimo-v2-pro (MiMo V2 Pro) - mimo-v2-omni (MiMo V2 Omni) - mimo-v2-flash (MiMo V2 Flash) Aliases: mimo, xiaomi-mimo -> xiaomi The hermes-agent CLI already registers xiaomi as a provider (hermes_cli/models.py, hermes_cli/auth.py) but the WebUI was missing the corresponding entries, causing the model dropdown to fall back to OpenRouter and the provider list to show 'Unsupported'. --- api/config.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/api/config.py b/api/config.py index d3df1cef..5dbfef4b 100644 --- a/api/config.py +++ b/api/config.py @@ -657,6 +657,7 @@ _PROVIDER_DISPLAY = { "qwen": "Qwen", "x-ai": "xAI", "nvidia": "NVIDIA NIM", + "xiaomi": "Xiaomi", } # Provider alias → canonical slug. Users configure providers using the @@ -707,6 +708,8 @@ _PROVIDER_ALIASES = { "nvidia-nim": "nvidia", "build-nvidia": "nvidia", "nemotron": "nvidia", + "mimo": "xiaomi", + "xiaomi-mimo": "xiaomi", # Legacy alias — earlier WebUI builds wrote ``provider: local`` for unknown # loopback endpoints, but ``local`` is not registered in # ``hermes_cli.auth.PROVIDER_REGISTRY``. Routing it through ``custom`` @@ -1066,6 +1069,14 @@ _PROVIDER_MODELS = { {"id": "nvidia/llama-3.3-nemotron-super-49b-v1.5", "label": "Llama 3.3 Nemotron Super 49B"}, {"id": "qwen/qwen3-next-80b-a3b-instruct", "label": "Qwen3 Next 80B"}, ], + # Xiaomi MiMo — direct API via api.xiaomimimo.com + "xiaomi": [ + {"id": "mimo-v2.5-pro", "label": "MiMo V2.5 Pro"}, + {"id": "mimo-v2.5", "label": "MiMo V2.5"}, + {"id": "mimo-v2-pro", "label": "MiMo V2 Pro"}, + {"id": "mimo-v2-omni", "label": "MiMo V2 Omni"}, + {"id": "mimo-v2-flash", "label": "MiMo V2 Flash"}, + ], # xAI — prefix used in OpenRouter model IDs (x-ai/grok-4-20) "x-ai": [ {"id": "grok-4.20", "label": "Grok 4.20"}, From 71aaad6e7fa30d776d100bbb9151e7e085ec4379 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 10 May 2026 17:13:52 +0000 Subject: [PATCH 8/9] fix(stage-330): broaden chinese-locale test to accept both \uXXXX and literal CJK forms (PR #2002 source-form refresh) --- tests/test_chinese_locale.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/test_chinese_locale.py b/tests/test_chinese_locale.py index cac42178..ac06bd04 100644 --- a/tests/test_chinese_locale.py +++ b/tests/test_chinese_locale.py @@ -79,18 +79,23 @@ def test_chinese_locale_block_exists(): def test_chinese_locale_includes_representative_translations(): src = read(REPO / "static" / "i18n.js") - expected = [ - "settings_title: '\\u8bbe\\u7f6e'", - "login_title: '\\u767b\\u5f55'", - "approval_heading: '需要审批'", - "tab_tasks: '任务'", - "tab_profiles: '配置'", - "session_time_bucket_today: '今天'", - "onboarding_title: '欢迎使用 Hermes Web UI'", - "onboarding_complete: '引导完成'", + # Each tuple is a list of acceptable source forms for the same translation — + # either escape-encoded `\uXXXX` form or literal CJK characters. They produce + # the same runtime string; do not pin source encoding. + expected_alternatives = [ + [r"settings_title: '\u8bbe\u7f6e'", "settings_title: '设置'"], + [r"login_title: '\u767b\u5f55'", "login_title: '登录'"], + ["approval_heading: '需要审批'"], + ["tab_tasks: '任务'"], + ["tab_profiles: '配置'"], + ["session_time_bucket_today: '今天'"], + ["onboarding_title: '欢迎使用 Hermes Web UI'"], + ["onboarding_complete: '引导完成'"], ] - for entry in expected: - assert entry in src + for alts in expected_alternatives: + assert any(alt in src for alt in alts), ( + f"None of the expected forms found in i18n.js: {alts!r}" + ) def test_chinese_locale_covers_english_keys(): From d922845bbd68210938e4b288c07bcd8ffa865ae9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 10 May 2026 17:31:32 +0000 Subject: [PATCH 9/9] chore: CHANGELOG for v0.51.36 (stage-330) --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f60fbcd..18bf4e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Hermes Web UI -- Changelog +## [v0.51.36] — 2026-05-10 — Release L (locale + provider + cross-cutting) + +### Fixed + +- **PR #1992** by @29n — `ctl.sh` line 42 used `[[ -v ${key} ]]`, which requires bash 4.2+. macOS ships with bash 3.2 → `conditional binary operator expected` error. Replaced with `[[ -n "${!key+x}" ]]` — a portable variable-set check that works on bash 3.2+, zsh, and POSIX-compatible shells. No behavior change. + +- **PR #1998** by @franksong2702 — Localized `/goal` runtime status strings. Added 13 i18n keys (`goal_evaluating_progress`, `goal_working_toward`, `goal_continuing_toast`, `goal_status_*`, `goal_set/paused/resumed/cleared/no_goal`, `goal_achieved`, `goal_paused_budget_exhausted`, `goal_continuing`) across all locales; new keys reach `static/messages.js` and `static/commands.js` so the goal UI no longer hardcodes English. Closes #1933. + +- **PR #2000** by @qxxaa — Skill tools resolve from the wrong profile after per-request profile switch. `tools/skills_tool.py` and `tools/skill_manager_tool.py` cache `HERMES_HOME` as a module-level constant at import time. The process-wide `switch_profile()` path patches both modules via `_set_hermes_home()`, but the per-request path (`switch_profile(process_wide=False)`, introduced in #1700) only updated `os.environ['HERMES_HOME']` and skipped the module patching. Result: agents on non-default profiles always saw the root profile's skills. Fix adds the same monkeypatching to the per-request branch in `api/streaming.py`. Closes the parity gap with #1700. + +- **PR #2001** by @franksong2702 — `clarify.timeout` config was ignored by WebUI clarify prompts. The callback used a hardcoded `timeout = 120`. Now reads `clarify.timeout` from `api.config.get_config()` with bounded fallback (defaults to 120 on missing/invalid config), and threads `timeout_seconds` into the `api.clarify.submit_pending` payload so the frontend countdown matches the backend timeout. Regression test in `tests/test_sprint42.py`. Closes #1999. + +- **PR #2005** by @vikarag — Added Xiaomi as a first-class provider in the WebUI's model catalog. `hermes-agent` already registered Xiaomi (verified at `hermes_cli/models.py:782` + auth entries) but `api/config.py` was missing the corresponding `_PROVIDER_DISPLAY` / `_PROVIDER_ALIASES` / `_PROVIDER_MODELS` entries, so the provider list showed Xiaomi as `Unsupported` and the model dropdown fell back to OpenRouter. Adds `xiaomi` display name, `mimo`/`xiaomi-mimo` aliases, and 5 MiMo models (V2.5 Pro/V2.5/V2 Pro/V2 Omni/V2 Flash). + +### i18n + +- **PR #2002** by @eov128 — Refreshed Simplified Chinese (zh) translation. Two kinds of changes: + - Decoded `\uXXXX` escape sequences to literal CJK characters in already-translated strings (semantically identical at runtime; improves source readability and grep-ability) + - Translated 30+ previously-untranslated strings tagged `// TODO: translate` — covering MCP server status (`mcp_status_active`, `mcp_status_configured`, ...), MCP tools panel, session toolsets, workspace hidden files, terminal pane, and personality switch hint + + **Stage 330 conflict resolution:** #1998 added new `goal_*` English keys interleaved with the `cmd_interrupt` block that #2002 was rewriting; resolved by preserving #1998's new English keys (TODO: translate) above the section while taking #2002's CJK literals for `cmd_*` / `settings_*` keys. + + **Stage 330 test fix:** `tests/test_chinese_locale.py::test_chinese_locale_includes_representative_translations` was pinned to the source-encoded `\uXXXX` form for `settings_title` and `login_title`. Broadened to accept either `\uXXXX` or literal CJK (same runtime behavior). Other source-form assertions in this test were already on literal CJK. + +### Tests + +5049 → **5049 collected, 5049 passing, 0 regressions** (one PR added new tests in `test_kanban_ui_static.py` already counted in stage 329; stage 330 net is flat). Full suite 158s on Python 3.11 with `HERMES_HOME` isolation. + +### Notes + +- `api/streaming.py` was the high-collision file (4 PRs touched it: #1998 #2000 #2001 #2006-not-in-this-stage). Stage merge clean; #2000 and #2001 each added separate ~17-LOC blocks at distinct anchor points, no overlap. +- All 6 PRs from 6 different authors except for #1998+#2001 (both @franksong2702). Disjoint themes. + ## [v0.51.34] — 2026-05-09 — Release J (kanban edit/dispatch + zh-Hant kanban i18n) ### Added