diff --git a/.gitignore b/.gitignore index b4ee8a54..0edd66af 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ docs/* graphify-out/ .graphify_cached.json .graphify_uncached.txt + +.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 39abf9c3..80eddd22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,66 @@ 5049 → **5057 collected, 5057 passing, 0 regressions** (+8 net new). Full suite 154s on Python 3.11 with `HERMES_HOME` isolation. +## [v0.51.37] — 2026-05-10 — Release M (compression / lineage backend) + +### Fixed + +- **PR #2004** by @franksong2702 — Persisted compression boundary summary for reload UI. Both manual `/session/compress` and auto-compression paths now persist `compression_anchor_summary`, `compression_anchor_visible_idx`, and `compression_anchor_message_key` so the compression card renders correctly after a page reload. Closes #1833. + +- **PR #2006** by @qxxaa — Stamp profile on continuation session after context compression. In multi-profile deployments, memory writes after auto-compression silently targeted the **default profile's** `MEMORY.md`, regardless of which profile the browser session was using. Root cause: the compression migration block in `_periodic_checkpoint` did not carry `s.profile` across to the continuation session, so subsequent requests fell back to the default profile's `HERMES_HOME`. Fix resolves the profile name from `s.profile` (or `get_active_profile_name()` while TLS still holds) at streaming-thread start, then stamps `s.profile = _resolved_profile_name` on the continuation session. Verified evidence: session `0dfefb` had read the wrong profile's `MEMORY.md` (16% / 4 entries) instead of the troubleshooting profile's bank (72-77% / 5000+ chars). + +- **PR #2011** by @ai-ag2026 — Sidebar lineage collapse: prefer the latest compressed segment when a parent row is touched. Previously the sidebar collapse helper picked representatives by timestamp only, which could surface a touched-parent row instead of the newer compressed tip. Now keys on `_compression_segment_count` so the highest-count segment wins. Regression test added. + +- **PR #2014** by @ai-ag2026 — Keep explicit `/api/session/branch` forks out of compression-lineage collapse. Forked sessions now mark `session_source="fork"` on creation, and the sidebar lineage helper guards against folding fork rows into the compression-collapse path even when the parent isn't currently in the rendered window. Backend marker test + sidebar guard test added. + +- **PR #2015** by @Jellypowered — Stitch continuation-lineage transcripts in WebUI. Sessions split by continuation events (compression boundary, CLI-close) could show only the latest segment in the WebUI message history. `get_cli_session_messages()` now walks the valid continuation lineage and stitches messages across sessions so the full conversation is visible. + +### Added + +- **PR #2012** by @dso2ng — New read-only `/api/session/lineage-report/` endpoint exposing a bounded JSON diagnostic of a session's compression/branching lineage. Pure backend probe — no client UI changes. The sidebar lineage UI (#1906/#1943) already covers user-facing affordances; this fills the bounded backend probe gap for CLI/scripting use. + +### Tests + +5049 → **5058 collected, 5058 passing, 0 regressions** (+9 net new across `test_session_lineage_collapse.py`, `test_session_lineage_full_transcript.py`, `test_session_lineage_report.py`, `test_465_session_branching.py`, `test_auto_compression_card.py`, `test_sprint46.py`). Full suite 157s on Python 3.11 with `HERMES_HOME` isolation. + +### Notes + +- `api/routes.py` (4 PRs touched it) and `api/streaming.py` (2 PRs) were the multi-PR files. All hunks at distinct anchors; stage merge clean with no conflicts. +- Theme coherence: every PR in this batch addresses session compression, lineage, or continuation-stitching — the same conceptual surface from different angles. + +## [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.35] — 2026-05-10 — Release K (kanban polish + i18n DE pluralization) ### Fixed diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 7d65bc57..dce28853 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -439,6 +439,163 @@ def read_importable_agent_session_rows( +def _lineage_report_row(row: dict, role: str) -> dict: + updated_at = row.get('ended_at') if row.get('ended_at') is not None else row.get('started_at') + return { + 'session_id': row.get('id'), + 'role': role, + 'title': row.get('title'), + 'source': row.get('source'), + 'started_at': row.get('started_at'), + 'updated_at': updated_at, + 'end_reason': row.get('end_reason'), + 'active': row.get('ended_at') is None, + 'archived': False, + } + + +def _empty_lineage_report(session_id: str, *, found: bool = False) -> dict: + return { + 'mutation': False, + 'found': found, + 'session_id': session_id, + 'lineage_key': session_id, + 'tip_session_id': session_id, + 'total_segments': 0, + 'materialized_segments': 0, + 'segments': [], + 'children': [], + 'manual_review': False, + } + + +def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: int = 20) -> dict: + """Return a bounded, read-only lifecycle report for a session lineage. + + This helper intentionally reports only facts that can be derived from + ``state.db.sessions`` without mutating WebUI JSON, archiving rows, or + deleting historical segments. It mirrors the sidebar continuation rules so + a future UI/PR can explain which rows are hidden compression/cli-close + segments and which child-session branches remain distinct. + """ + sid = str(session_id or '').strip() + if not sid: + return _empty_lineage_report('') + db_path = Path(db_path) + if not db_path.exists(): + return _empty_lineage_report(sid) + + try: + with closing(sqlite3.connect(str(db_path))) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute("PRAGMA table_info(sessions)") + session_cols = {row[1] for row in cur.fetchall()} + required = {'id', 'parent_session_id', 'end_reason'} + if not required.issubset(session_cols): + return _empty_lineage_report(sid) + + source_expr = _optional_col('source', session_cols) + title_expr = _optional_col('title', session_cols) + started_expr = _optional_col('started_at', session_cols, '0') + ended_expr = _optional_col('ended_at', session_cols) + end_reason_expr = _optional_col('end_reason', session_cols) + parent_expr = _optional_col('parent_session_id', session_cols) + + def fetch_one(row_id: str | None) -> dict | None: + if not row_id: + return None + cur.execute( + f""" + SELECT s.id, + {source_expr}, + {title_expr}, + {started_expr}, + {parent_expr}, + {ended_expr}, + {end_reason_expr} + FROM sessions s + WHERE s.id = ? + """, + (row_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + target = fetch_one(sid) + if not target: + return _empty_lineage_report(sid) + + segments = [target] + current = target + seen = {sid} + manual_review = False + for _hop in range(max(0, int(max_hops))): + parent_id = current.get('parent_session_id') + parent = fetch_one(parent_id) + if not parent or parent_id in seen: + manual_review = bool(parent_id and parent_id in seen) + break + if not _is_continuation_session(parent, current): + break + segments.append(parent) + seen.add(parent_id) + current = parent + else: + manual_review = True + + segment_ids = {row['id'] for row in segments} + child_rows: list[dict] = [] + for parent in segments: + cur.execute( + f""" + SELECT s.id, + {source_expr}, + {title_expr}, + {started_expr}, + {parent_expr}, + {ended_expr}, + {end_reason_expr} + FROM sessions s + WHERE s.parent_session_id = ? + ORDER BY s.started_at DESC + """, + (parent['id'],), + ) + for child_row in cur.fetchall(): + child = dict(child_row) + if child['id'] in segment_ids: + continue + if _is_continuation_session(parent, child): + # A continuation outside the selected path means the + # lineage is branched or the caller selected an older + # segment. Report manual review rather than proposing + # destructive cleanup candidates. + manual_review = True + continue + child_rows.append(child) + except Exception: + return _empty_lineage_report(sid) + + root_id = segments[-1]['id'] if segments else sid + tip_id = segments[0]['id'] if segments else sid + return { + 'mutation': False, + 'found': True, + 'session_id': sid, + 'lineage_key': root_id, + 'tip_session_id': tip_id, + 'total_segments': len(segments), + 'materialized_segments': len(segments), + 'segments': [ + _lineage_report_row(row, 'tip' if idx == 0 else 'hidden_segment') + for idx, row in enumerate(segments) + ], + 'children': [_lineage_report_row(row, 'child_session') for row in child_rows], + 'manual_review': manual_review, + } + + def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[str]) -> dict[str, dict]: """Return compression-lineage metadata for known WebUI sidebar sessions. 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"}, 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/models.py b/api/models.py index 1aac37a5..62099f05 100644 --- a/api/models.py +++ b/api/models.py @@ -329,6 +329,7 @@ class Session: context_messages=None, compression_anchor_visible_idx=None, compression_anchor_message_key=None, + compression_anchor_summary=None, context_length=None, threshold_tokens=None, last_prompt_tokens=None, gateway_routing=None, gateway_routing_history=None, @@ -361,6 +362,7 @@ class Session: self.context_messages = context_messages if isinstance(context_messages, list) else [] self.compression_anchor_visible_idx = compression_anchor_visible_idx self.compression_anchor_message_key = compression_anchor_message_key + self.compression_anchor_summary = compression_anchor_summary self.context_length = context_length self.threshold_tokens = threshold_tokens self.last_prompt_tokens = last_prompt_tokens @@ -411,6 +413,7 @@ class Session: 'personality', 'active_stream_id', 'pending_user_message', 'pending_attachments', 'pending_started_at', 'compression_anchor_visible_idx', 'compression_anchor_message_key', + 'compression_anchor_summary', 'context_length', 'threshold_tokens', 'last_prompt_tokens', 'gateway_routing', 'gateway_routing_history', 'llm_title_generated', 'parent_session_id', @@ -572,6 +575,7 @@ class Session: 'personality': self.personality, 'compression_anchor_visible_idx': self.compression_anchor_visible_idx, 'compression_anchor_message_key': self.compression_anchor_message_key, + 'compression_anchor_summary': self.compression_anchor_summary, 'context_length': self.context_length, 'threshold_tokens': self.threshold_tokens, 'last_prompt_tokens': self.last_prompt_tokens, @@ -1662,7 +1666,9 @@ def get_cli_session_messages(sid) -> list: Preserve tool-call/result and reasoning metadata from the agent state.db so CLI-origin transcripts render with the same tool cards as WebUI-native - sessions. Returns empty list on any error. + sessions. When the requested session is the tip of a compression/CLI-close + continuation chain, return the stitched full transcript across all segments + in chronological order. Returns empty list on any error. """ import os if str(sid or '').startswith(f'{CLAUDE_CODE_SOURCE}_'): @@ -1701,12 +1707,56 @@ def get_cli_session_messages(sid) -> list: 'codex_message_items', ] selected = ['role', 'content', 'timestamp'] + [c for c in optional if c in available] + + cur.execute("PRAGMA table_info(sessions)") + session_cols = {str(row['name']) for row in cur.fetchall()} + session_chain = [str(sid)] + if {'parent_session_id', 'end_reason', 'started_at', 'source'}.issubset(session_cols): + cur.execute( + """ + SELECT id, source, started_at, parent_session_id, ended_at, end_reason + FROM sessions + WHERE id = ? + """, + (sid,), + ) + rows_by_id = {} + row = cur.fetchone() + if row: + rows_by_id[str(row['id'])] = dict(row) + current_id = str(row['id']) + seen = {current_id} + for _ in range(20): + current = rows_by_id.get(current_id) + parent_id = current.get('parent_session_id') if current else None + if not parent_id or parent_id in seen: + break + cur.execute( + """ + SELECT id, source, started_at, parent_session_id, ended_at, end_reason + FROM sessions + WHERE id = ? + """, + (parent_id,), + ) + parent_row = cur.fetchone() + if not parent_row: + break + parent_dict = dict(parent_row) + rows_by_id[str(parent_row['id'])] = parent_dict + if not _is_continuation_session(parent_dict, current): + break + session_chain.insert(0, str(parent_row['id'])) + current_id = str(parent_row['id']) + seen.add(current_id) + + placeholders = ', '.join('?' for _ in session_chain) cur.execute(f""" - SELECT {', '.join(selected)} + SELECT {', '.join(selected)}, session_id FROM messages - WHERE session_id = ? - ORDER BY timestamp ASC - """, (sid,)) + WHERE session_id IN ({placeholders}) + ORDER BY timestamp ASC, id ASC + """, session_chain) msgs = [] for row in cur.fetchall(): msg = { diff --git a/api/routes.py b/api/routes.py index cdf9e12a..33567d0d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -26,6 +26,7 @@ from api.agent_sessions import ( MESSAGING_SOURCES, is_cli_session_row, is_cli_session_row_visible, + read_session_lineage_report, ) logger = logging.getLogger(__name__) @@ -3028,8 +3029,31 @@ def handle_get(handler, parsed) -> bool: # longer visible conversation than the single state.db # segment for this messaging session id. Prefer the longer # sidecar so repaired WebUI history is not hidden behind the - # canonical per-segment transcript. - _all_msgs = sidecar_messages if len(sidecar_messages) > len(cli_messages) else cli_messages + # canonical per-segment transcript. When both sources carry + # different slices of the same stitched conversation, merge + # them chronologically and dedupe exact repeats. + if sidecar_messages and sidecar_messages != cli_messages: + merged_messages = [] + seen_message_keys = set() + for msg in sorted(list(cli_messages) + list(sidecar_messages), key=lambda m: ( + float(m.get("timestamp") or 0), + str(m.get("role") or ""), + str(m.get("content") or ""), + )): + key = ( + str(msg.get("role") or ""), + str(msg.get("content") or ""), + str(msg.get("timestamp") or ""), + str(msg.get("tool_call_id") or ""), + str(msg.get("tool_name") or msg.get("name") or ""), + ) + if key in seen_message_keys: + continue + seen_message_keys.add(key) + merged_messages.append(msg) + _all_msgs = merged_messages + else: + _all_msgs = sidecar_messages if len(sidecar_messages) > len(cli_messages) else cli_messages else: _all_msgs = s.messages else: @@ -3184,6 +3208,15 @@ def handle_get(handler, parsed) -> bool: return j(handler, {"session": redact_session_data(sess)}) return bad(handler, "Session not found", 404) + if parsed.path == "/api/session/lineage/report": + sid = parse_qs(parsed.query).get("session_id", [""])[0] + if not sid: + return bad(handler, "session_id required", 400) + report = read_session_lineage_report(_active_state_db_path(), sid) + if not report.get("found"): + return bad(handler, "Session not found", 404) + return j(handler, report) + if parsed.path == "/api/session/status": sid = parse_qs(parsed.query).get("session_id", [""])[0] if not sid: @@ -4232,6 +4265,7 @@ def handle_post(handler, parsed) -> bool: title=branch_title, messages=forked_messages, parent_session_id=source.session_id, + session_source="fork", ) with LOCK: SESSIONS[branch.session_id] = branch @@ -7505,6 +7539,38 @@ def _handle_session_compress(handler, body): return None return {"role": role, "ts": ts, "text": norm, "attachments": attach_count} + def _compression_summary_from_messages(messages): + text = None + for m in reversed(messages or []): + if not isinstance(m, dict): + continue + role = str(m.get("role") or "").lower() + if role != "assistant": + continue + if not isinstance(m.get("content"), str): + continue + content = str(m.get("content") or "").strip() + if not content: + continue + norm = re.sub(r"\s+", " ", content).strip() + if ( + "context compaction" in norm.lower() + or "context compression" in norm.lower() + ): + return norm + return None + + def _compact_summary_text(raw_text): + if not isinstance(raw_text, str): + return None + txt = raw_text.strip() + if not txt: + return None + txt = re.sub(r"\s+", " ", txt) + if len(txt) > 320: + txt = f"{txt[:314]}…" + return txt + try: require(body, "session_id") except ValueError as e: @@ -7691,6 +7757,12 @@ def _handle_session_compress(handler, body): visible_after = _visible_messages_for_anchor(compressed) s.compression_anchor_visible_idx = max(0, len(visible_after) - 1) if visible_after else None s.compression_anchor_message_key = _anchor_message_key(visible_after[-1]) if visible_after else None + summary_text = None + if isinstance(summary, dict): + summary_text = summary.get("reference_message") or summary.get("token_line") or summary.get("headline") + s.compression_anchor_summary = _compact_summary_text( + summary_text or _compression_summary_from_messages(compressed) or "" + ) s.save() session_payload = redact_session_data( diff --git a/api/streaming.py b/api/streaming.py index a1a8ac1b..b42a4501 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. @@ -1536,6 +1550,87 @@ def _is_context_compression_marker(msg): ) +def _compact_summary_text(raw_text: str | None, limit: int = 320) -> str | None: + """Normalize a text blob used in compression summary cards.""" + if not isinstance(raw_text, str): + return None + txt = raw_text.strip() + if not txt: + return None + txt = re.sub(r"\s+", " ", txt).strip() + if len(txt) > limit: + txt = f"{txt[: limit - 6]}…" + return txt + + +def _compression_anchor_message_key(message): + if not isinstance(message, dict): + return None + role = str(message.get('role') or '') + if not role or role == 'tool': + return None + content = message.get('content', '') + text = _message_text(content) + if len(text) > 160: + text = text[:160] + ts = message.get('_ts') or message.get('timestamp') + attachments = message.get('attachments') + attach_count = len(attachments) if isinstance(attachments, list) else 0 + if not text and not attach_count and not ts: + return None + return {'role': role, 'ts': ts, 'text': text, 'attachments': attach_count} + + +def _visible_messages_for_compression_anchor(messages): + out = [] + for m in messages or []: + if not isinstance(m, dict): + continue + role = m.get('role') + if not role or role == 'tool': + continue + content = m.get('content', '') + has_attachments = bool(m.get('attachments')) + has_tool_calls = bool(isinstance(m.get('tool_calls'), list) and m.get('tool_calls')) + has_tool_use = False + has_reasoning = bool(m.get('reasoning')) + if isinstance(content, list): + text = '\n'.join( + str(p.get('text') or p.get('content') or '') + for p in content + if isinstance(p, dict) + and p.get('type') in {'text', 'input_text', 'output_text'} + ).strip() + for part in content: + if not isinstance(part, dict): + continue + if part.get('type') == 'tool_use': + has_tool_use = True + if not text: + has_reasoning = has_reasoning or any( + isinstance(part, dict) + and part.get('type') in {'thinking', 'reasoning'} + for part in content + ) + else: + text = str(content or '').strip() + if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning: + out.append(m) + return out + + +def _compression_summary_from_messages(messages): + for m in reversed(messages or []): + if not isinstance(m, dict): + continue + if not _is_context_compression_marker(m): + continue + text = _message_text(m.get('content')) + if text: + return text + return None + + def _find_current_user_turn(messages, msg_text): needle = " ".join(str(msg_text or '').split()) fallback = None @@ -2022,7 +2117,24 @@ def _run_agent_streaming( except ImportError: _profile_home = os.environ.get('HERMES_HOME', '') _profile_runtime_env = {} - + + # Capture the resolved profile name now, while profile context is + # reliable. Used in the compression migration block to stamp s.profile + # on the continuation session. We resolve it here rather than calling + # get_active_profile_name() at compression time because that function + # reads thread-local storage (_tls.profile) set by set_request_profile() + # on the HTTP handler thread. The streaming thread is a separate + # threading.Thread and does not inherit TLS. At compression time, + # get_active_profile_name() would fall back to the process-global + # _active_profile, which may belong to a different concurrent tab. + _resolved_profile_name = getattr(s, 'profile', None) + if not _resolved_profile_name: + try: + from api.profiles import get_active_profile_name + _resolved_profile_name = get_active_profile_name() + except Exception: + _resolved_profile_name = None + _thread_env = _build_agent_thread_env( _profile_runtime_env, str(s.workspace), @@ -2046,6 +2158,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()` @@ -2106,7 +2235,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 +2243,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 @@ -2972,6 +3102,22 @@ def _run_agent_streaming( old_path = SESSION_DIR / f'{old_sid}.json' new_path = SESSION_DIR / f'{new_sid}.json' s.session_id = new_sid + # Carry profile identity across the compression boundary. + # Without this, s.profile stays None on the continuation + # session. On the next request, _run_agent_streaming calls + # get_hermes_home_for_profile(getattr(s, 'profile', None)) + # which falls back to the default profile's HERMES_HOME. + # Memory writes then land in the wrong profile's MEMORY.md. + # Stamping here also ensures s.save() persists a non-null + # profile field to the continuation session's JSON file, + # covering the case where the session is later evicted from + # SESSIONS and reconstructed from disk via Session.load(). + if not s.profile and _resolved_profile_name: + s.profile = _resolved_profile_name + logger.info( + "Stamped profile=%r on continuation session %s after compression", + _resolved_profile_name, new_sid, + ) with LOCK: if old_sid in SESSIONS: SESSIONS[new_sid] = SESSIONS.pop(old_sid) @@ -3001,6 +3147,17 @@ def _run_agent_streaming( _compressed = True # Notify the frontend that compression happened if _compressed: + visible_after = _visible_messages_for_compression_anchor(s.messages) + s.compression_anchor_visible_idx = ( + max(0, len(visible_after) - 1) if visible_after else None + ) + s.compression_anchor_message_key = ( + _compression_anchor_message_key(visible_after[-1]) if visible_after else None + ) + s.compression_anchor_summary = _compact_summary_text( + _compression_summary_from_messages(s.messages) + or _compression_summary_from_messages(s.context_messages) + ) put('compressed', { 'message': 'Context auto-compressed to continue the conversation', }) @@ -3320,6 +3477,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 +3492,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 +3507,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/ctl.sh b/ctl.sh index a8f4d61d..c246131f 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 @@ -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=$! 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 6d691189..a625dcd9 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 ', @@ -1264,6 +1279,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 <メッセージ>', @@ -2294,6 +2324,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 <сообщение>', @@ -3329,6 +3374,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 ', @@ -4313,6 +4373,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 ', @@ -5167,22 +5242,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 文件', @@ -5190,7 +5265,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 服务器。', @@ -5213,31 +5288,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: '检测到危险命令', @@ -5256,8 +5331,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: '跳转到会话开头', @@ -5266,248 +5341,263 @@ 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', + 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 <消息>', + 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_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.', - 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_status_original_hint: '实际状态:{0}。此对话框仅支持编辑 Triage/Todo/Ready。', + 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: '可选 — 需要做什么、验收标准、链接', @@ -5536,33 +5626,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 仪表盘', @@ -5572,19 +5662,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: '未知', @@ -5625,27 +5715,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 环境变量', @@ -5658,12 +5748,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。', @@ -5724,16 +5814,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', @@ -5759,19 +5849,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: '引导完成', @@ -5942,8 +6032,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: '可选的友好名称', @@ -5957,29 +6047,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: '全选', @@ -5991,19 +6081,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', @@ -6019,28 +6109,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 模式激活 — 点击关闭', @@ -6095,63 +6185,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) @@ -6853,6 +6943,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>', @@ -7378,6 +7483,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 ', @@ -8323,6 +8443,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 52402e5d..03524638 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/static/sessions.js b/static/sessions.js index cfbf752f..54fb5a1c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1978,6 +1978,7 @@ function _isChildSession(s){ function _sessionLineageKey(s, sessionIdsInList){ if(!s||!s.session_id) return null; if(_isChildSession(s)) return null; + if(s.session_source==='fork') return null; const lineageKey=s._lineage_root_id||s.lineage_root_id||null; if(lineageKey) return lineageKey; // If parent_session_id points to another session in the current list, @@ -2102,7 +2103,14 @@ function _collapseSessionLineageForSidebar(sessions){ } for(const [key,items] of groups.entries()){ if(items.length<=1){result.push(items[0]);continue;} - const sorted=[...items].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a)); + const sorted=[...items].sort((a,b)=>{ + const bSeg=Number(b&&b._compression_segment_count||0); + const aSeg=Number(a&&a._compression_segment_count||0); + if(bSeg||aSeg){ + if(bSeg!==aSeg) return bSeg-aSeg; + } + return _sessionTimestampMs(b)-_sessionTimestampMs(a); + }); const chosen=sorted[0]; result.push({...chosen,_lineage_key:key,_lineage_collapsed_count:items.length,_lineage_segments:sorted}); } diff --git a/static/ui.js b/static/ui.js index 1e6b3eee..54ec23b4 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4866,6 +4866,9 @@ function renderMessages(options){ const sessionCompressionAnchorKey=( S.session && S.session.compression_anchor_message_key && typeof S.session.compression_anchor_message_key==='object' ) ? S.session.compression_anchor_message_key : null; + const sessionCompressionSummary=( + S.session && typeof S.session.compression_anchor_summary==='string' + ) ? S.session.compression_anchor_summary.trim() : ''; const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages); const vis=S.messages.filter(m=>{ if(!m||!m.role||m.role==='tool')return false; @@ -4882,8 +4885,10 @@ function renderMessages(options){ inner.innerHTML=''; const compressionNode=compressionState?_compressionCardsNode(compressionState):null; const referenceMessage=S.messages.find(m=>_isContextCompactionMessage(m)); - const referenceText=referenceMessage?msgContent(referenceMessage)||String(referenceMessage.content||''):''; - const referenceNode=(!compressionState && referenceMessage && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey)) + const referenceText=referenceMessage + ? msgContent(referenceMessage)||String(referenceMessage.content||'') + : sessionCompressionSummary; + const referenceNode=(!compressionState && !!referenceText && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)) ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;return row.firstElementChild;})() : null; let preservedCompressionTaskCardsAttached=!!referenceNode; diff --git a/tests/test_465_session_branching.py b/tests/test_465_session_branching.py index 7a09ac61..2a3722f8 100644 --- a/tests/test_465_session_branching.py +++ b/tests/test_465_session_branching.py @@ -68,6 +68,32 @@ def test_branch_creates_session_with_parent(): "Branch handler should set parent_session_id to source session" +def test_branch_marks_explicit_forks_as_fork_sessions(): + """Explicit branches must not be mistaken for compression lineage rows.""" + with open('api/routes.py') as f: + src = f.read() + branch_match = re.search( + r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)', + src, re.DOTALL + ) + assert branch_match + block = branch_match.group(1) + assert 'session_source="fork"' in block, \ + "Branch handler should mark explicit forks with session_source='fork'" + + +def test_branch_fork_sessions_do_not_collapse_into_parent_lineage(): + """Forks remain selectable rows even if their parent is not in the current list.""" + with open('static/sessions.js') as f: + src = f.read() + fn = re.search(r'function _sessionLineageKey\(.*?\n\}', src, re.DOTALL) + assert fn, "Could not find _sessionLineageKey" + block = fn.group(0) + assert "if(s.session_source==='fork') return null;" in block, \ + "Explicit fork sessions should not collapse via parent_session_id" + assert block.index("if(s.session_source==='fork') return null;") < block.index('return s.parent_session_id || null') + + def test_branch_keep_count_support(): """Verify the branch endpoint supports keep_count parameter.""" with open('api/routes.py') as f: diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index a14bf2ef..25571b26 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -207,6 +207,16 @@ def test_preserved_task_list_renders_through_compression_card_path(): assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" in src +def test_context_anchor_reference_uses_session_summary_fallback(): + src = _read("static/ui.js") + + assert "sessionCompressionSummary" in src + assert "const sessionCompressionSummary" in src + assert "referenceText=referenceMessage" in src + assert ": sessionCompressionSummary" in src + assert "!!referenceText && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)" in src + + def test_preserved_task_list_attaches_once_per_render(): src = _read("static/ui.js") 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(): 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 diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index f9746b8f..0d14938a 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -170,6 +170,47 @@ console.log(JSON.stringify(collapsed)); assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["seg10", "seg9", "seg8", "seg7"] +def test_sidebar_lineage_collapse_prefers_highest_compression_segment_over_touched_parent(): + """A touched parent segment must not hide the newer compressed tip. + + Opening or polling an older segment can refresh its updated_at without adding + messages. The collapsed sidebar row must still pick the highest compression + segment, otherwise the visible chat jumps back to a parent that lacks the + completed assistant answer. + """ + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_sessionLineageKey')); +eval(extractFunc('_collapseSessionLineageForSidebar')); +const sessions = [ + {{session_id:'seg13', title:'Schaue dir die Release (fork)', message_count:2490, updated_at:200, last_message_at:200, _lineage_root_id:'root', _compression_segment_count:13}}, + {{session_id:'seg14', title:'Schaue dir die Release (fork)', message_count:2532, updated_at:150, last_message_at:150, _lineage_root_id:'root', _compression_segment_count:14}}, +]; +const collapsed = _collapseSessionLineageForSidebar(sessions); +console.log(JSON.stringify(collapsed)); +""" + collapsed = json.loads(_run_node(source)) + assert [row["session_id"] for row in collapsed] == ["seg14"] + assert collapsed[0]["_lineage_collapsed_count"] == 2 + assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["seg14", "seg13"] + + def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") diff --git a/tests/test_session_lineage_full_transcript.py b/tests/test_session_lineage_full_transcript.py new file mode 100644 index 00000000..7efc6d18 --- /dev/null +++ b/tests/test_session_lineage_full_transcript.py @@ -0,0 +1,61 @@ +"""Regression coverage for stitched full-transcript loading across session segments.""" + +from __future__ import annotations + +import api.routes as routes + + + +def test_session_endpoint_merges_sidecar_and_lineage_messages_for_cli_sessions(monkeypatch): + class DummySession: + def __init__(self): + self.messages = [{"role": "assistant", "content": "sidecar tail", "timestamp": 10.0}] + self.tool_calls = [] + self.active_stream_id = None + self.pending_user_message = None + self.pending_attachments = [] + self.pending_started_at = None + self.context_length = 0 + self.threshold_tokens = 0 + self.last_prompt_tokens = 0 + self.model = "openai/gpt-5" + self.session_id = "tip" + + def compact(self): + return {"session_id": "tip", "title": "Tip", "model": "openai/gpt-5"} + + captured = {} + + monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: DummySession()) + monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda s: None) + monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda sid: {"session_source": "messaging"}) + monkeypatch.setattr(routes, "_is_messaging_session_record", lambda s: True) + monkeypatch.setattr( + routes, + "get_cli_session_messages", + lambda sid: [ + {"role": "user", "content": "root user", "timestamp": 1.0}, + {"role": "assistant", "content": "tip assistant", "timestamp": 2.0}, + ], + ) + monkeypatch.setattr(routes, "_resolve_effective_session_model_for_display", lambda s: getattr(s, "model", None)) + monkeypatch.setattr(routes, "_resolve_effective_session_model_provider_for_display", lambda s: None) + monkeypatch.setattr(routes, "_merge_cli_sidebar_metadata", lambda raw, meta: raw) + monkeypatch.setattr(routes, "redact_session_data", lambda raw: raw) + monkeypatch.setattr(routes, "j", lambda handler, payload, status=200: captured.setdefault("payload", payload)) + + class Handler: + pass + + class Parsed: + path = "/api/session" + query = "session_id=tip" + + routes.handle_get(Handler(), Parsed()) + + session = captured["payload"]["session"] + assert [m["content"] for m in session["messages"]] == [ + "root user", + "tip assistant", + "sidecar tail", + ] diff --git a/tests/test_session_lineage_report.py b/tests/test_session_lineage_report.py new file mode 100644 index 00000000..a95b0bb5 --- /dev/null +++ b/tests/test_session_lineage_report.py @@ -0,0 +1,196 @@ +"""Read-only session lineage report endpoint tests.""" + +import json +import sqlite3 +import time +from types import SimpleNamespace +from urllib.parse import urlparse +from unittest.mock import patch + +import api.agent_sessions as agent_sessions +import api.routes as routes + + +def _ensure_state_db(path): + conn = sqlite3.connect(str(path)) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + title TEXT, + model TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + """ + ) + return conn + + +def _insert_state_row(conn, sid, *, parent=None, ended_at=None, end_reason=None, started_at=None, source="webui"): + conn.execute( + """ + INSERT INTO sessions + (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) + VALUES (?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?) + """, + (sid, source, sid.replace("_", " "), started_at or time.time(), parent, ended_at, end_reason), + ) + conn.commit() + + +def test_lineage_report_returns_bounded_read_only_tip_and_hidden_segments(tmp_path): + conn = _ensure_state_db(tmp_path / "state.db") + t0 = time.time() - 100 + try: + _insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression") + _insert_state_row(conn, "lineage_report_mid", parent="lineage_report_root", started_at=t0 + 6, ended_at=t0 + 12, end_reason="cli_close") + _insert_state_row(conn, "lineage_report_tip", parent="lineage_report_mid", started_at=t0 + 13) + + report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_tip") + + assert report["mutation"] is False + assert report["session_id"] == "lineage_report_tip" + assert report["lineage_key"] == "lineage_report_root" + assert report["tip_session_id"] == "lineage_report_tip" + assert report["total_segments"] == 3 + assert report["materialized_segments"] == 3 + assert [s["session_id"] for s in report["segments"]] == [ + "lineage_report_tip", + "lineage_report_mid", + "lineage_report_root", + ] + assert [s["role"] for s in report["segments"]] == ["tip", "hidden_segment", "hidden_segment"] + assert report["children"] == [] + assert report["manual_review"] is False + assert "archive_candidates" not in report + assert "delete_candidates" not in report + finally: + conn.close() + + +def test_lineage_report_keeps_cross_surface_parent_out_of_hidden_segments(tmp_path): + conn = _ensure_state_db(tmp_path / "state.db") + t0 = time.time() - 100 + try: + _insert_state_row( + conn, + "lineage_report_telegram_parent", + source="telegram", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + _insert_state_row( + conn, + "lineage_report_webui_tip", + source="webui", + parent="lineage_report_telegram_parent", + started_at=t0 + 6, + ) + + report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_webui_tip") + + assert report["lineage_key"] == "lineage_report_webui_tip" + assert report["total_segments"] == 1 + assert [s["session_id"] for s in report["segments"]] == ["lineage_report_webui_tip"] + assert report["segments"][0]["role"] == "tip" + assert report["children"] == [] + finally: + conn.close() + + +def test_lineage_report_surfaces_non_continuation_children_without_mutation(tmp_path): + conn = _ensure_state_db(tmp_path / "state.db") + t0 = time.time() - 100 + try: + _insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression") + _insert_state_row(conn, "lineage_report_tip", parent="lineage_report_root", started_at=t0 + 6, ended_at=t0 + 15, end_reason="user_stop") + _insert_state_row(conn, "lineage_report_child", parent="lineage_report_tip", started_at=t0 + 8) + + report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_tip") + + assert report["lineage_key"] == "lineage_report_root" + assert [s["session_id"] for s in report["segments"]] == ["lineage_report_tip", "lineage_report_root"] + assert report["children"] == [ + { + "session_id": "lineage_report_child", + "role": "child_session", + "title": "lineage report child", + "source": "webui", + "started_at": t0 + 8, + "updated_at": t0 + 8, + "end_reason": None, + "active": True, + "archived": False, + } + ] + assert report["mutation"] is False + finally: + conn.close() + + +def test_lineage_report_marks_bounded_parent_walk_for_manual_review(tmp_path): + conn = _ensure_state_db(tmp_path / "state.db") + t0 = time.time() - 100 + try: + _insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression") + _insert_state_row(conn, "lineage_report_mid", parent="lineage_report_root", started_at=t0 + 6, ended_at=t0 + 12, end_reason="compression") + _insert_state_row(conn, "lineage_report_tip", parent="lineage_report_mid", started_at=t0 + 13) + + report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_tip", max_hops=1) + + assert report["mutation"] is False + assert report["manual_review"] is True + assert [s["session_id"] for s in report["segments"]] == ["lineage_report_tip", "lineage_report_mid"] + assert report["total_segments"] == 2 + finally: + conn.close() + + +def test_lineage_report_endpoint_is_read_only_and_uses_active_state_db(tmp_path): + conn = _ensure_state_db(tmp_path / "state.db") + t0 = time.time() - 100 + try: + _insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression") + _insert_state_row(conn, "lineage_report_tip", parent="lineage_report_root", started_at=t0 + 6) + captured = {} + + def fake_j(handler, data, status=200, **_kwargs): + captured["status"] = status + captured["data"] = data + return data + + handler = SimpleNamespace() + parsed = urlparse("/api/session/lineage/report?session_id=lineage_report_tip") + with patch.object(routes, "_active_state_db_path", return_value=tmp_path / "state.db"), patch.object(routes, "j", side_effect=fake_j): + routes.handle_get(handler, parsed) + + assert captured["status"] == 200 + assert captured["data"]["mutation"] is False + assert captured["data"]["lineage_key"] == "lineage_report_root" + assert captured["data"]["total_segments"] == 2 + finally: + conn.close() + + +def test_lineage_report_endpoint_returns_404_for_unknown_session(tmp_path): + conn = _ensure_state_db(tmp_path / "state.db") + conn.close() + captured = {} + + def fake_bad(handler, message, status=400): + captured["status"] = status + captured["message"] = message + return {"error": message} + + handler = SimpleNamespace() + parsed = urlparse("/api/session/lineage/report?session_id=missing_lineage_report_session") + with patch.object(routes, "_active_state_db_path", return_value=tmp_path / "state.db"), patch.object(routes, "bad", side_effect=fake_bad): + routes.handle_get(handler, parsed) + + assert captured == {"status": 404, "message": "Session not found"} 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).""" diff --git a/tests/test_sprint46.py b/tests/test_sprint46.py index 0cffd289..35145c95 100644 --- a/tests/test_sprint46.py +++ b/tests/test_sprint46.py @@ -10,7 +10,7 @@ import types from api.models import Session from api.config import SESSION_DIR -from api.routes import _handle_session_compress +from api.routes import _handle_session_compress, get_session from tests._pytest_port import BASE @@ -141,6 +141,14 @@ def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions): {"role": "user", "content": "one"}, {"role": "assistant", "content": "four"}, ] + assert payload["session"]["compression_anchor_summary"] is not None + assert payload["session"]["compression_anchor_visible_idx"] == 1 + assert isinstance(payload["session"]["compression_anchor_message_key"], dict) + assert payload["session"]["compression_anchor_message_key"].get("role") == "assistant" + loaded = get_session(sid) + assert loaded.compression_anchor_summary == payload["session"]["compression_anchor_summary"] + assert loaded.compression_anchor_visible_idx == payload["session"]["compression_anchor_visible_idx"] + assert loaded.compression_anchor_message_key == payload["session"]["compression_anchor_message_key"] assert _FakeAgent.last_instance is not None assert _FakeAgent.last_instance.context_compressor.calls[0]["focus_topic"] == "database schema"