From 1e1a9481b4f070e448fc53321dc80b4e824e8ca2 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 10 May 2026 15:21:24 +0800 Subject: [PATCH] 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