diff --git a/.github/workflows/native-windows-startup.yml b/.github/workflows/native-windows-startup.yml new file mode 100644 index 00000000..4345e2a0 --- /dev/null +++ b/.github/workflows/native-windows-startup.yml @@ -0,0 +1,132 @@ +name: Native Windows startup + +# Runs on PRs that touch start.ps1 (or this workflow). Validates the +# native-Windows launch script catches the bug classes the recent +# Windows-only batch caught manually (#2805 WOW64 ProgramFiles redirect, +# #2806 venv-portability claim, #2807 port-parse + finally-cleanup). +# +# Scope (per nesquena-hermes comment on #2811 — option 1, mock-only): +# hermes-agent is not published to PyPI, so we cannot pip-install it on +# the runner. Instead we stub a hermes_cli/ directory next to a sibling +# hermes-agent/ folder — just enough for start.ps1's existence guard to +# pass. The workflow then runs start.ps1 for a few seconds and asserts +# that none of start.ps1's own Write-Error guards fired. Server-boot +# regressions remain covered by the Linux jobs and docker-smoke.yml. + +on: + pull_request: + paths: + - 'start.ps1' + - '.github/workflows/native-windows-startup.yml' + workflow_dispatch: + +jobs: + native-windows-startup: + name: start.ps1 path discovery (mock hermes-agent) + runs-on: windows-latest + timeout-minutes: 8 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + # Create the WebUI venv. start.ps1 prefers $AgentDir\venv if it + # exists, then falls back to the python on PATH. We create a + # WebUI-local venv to mirror the README's documented native path + # and to give start.ps1 a real python.exe to invoke. + - name: Create venv (README path) + shell: pwsh + run: | + python -m venv venv + if (-not (Test-Path venv\Scripts\python.exe)) { + throw "venv\Scripts\python.exe missing after venv create" + } + + # Mock-only hermes-agent provisioning. We can't pip-install + # hermes-agent (not on PyPI), so we stub the minimum that + # start.ps1's `Test-Path hermes_cli -PathType Container` guard + # needs to pass. server.py would crash on this stub at import + # time — we deliberately do NOT probe /health below. + - name: Stub hermes-agent (mock hermes_cli only) + shell: pwsh + run: | + $agentDir = Join-Path (Split-Path -Parent $PWD) 'hermes-agent' + $cliDir = Join-Path $agentDir 'hermes_cli' + New-Item -ItemType Directory -Force -Path $cliDir | Out-Null + Set-Content -Path (Join-Path $cliDir '__init__.py') -Value '# stub for CI path-discovery test only' + "HERMES_WEBUI_AGENT_DIR=$agentDir" >> $env:GITHUB_ENV + Write-Host "Stub hermes-agent provisioned at $agentDir" + + # Run start.ps1 and verify it passes its own discovery guards + # without erroring out. server.py will exit non-zero on the stub + # (no real CLI code) — that's expected and not asserted against. + # We only fail if start.ps1's own Write-Error guards fire. + - name: Run start.ps1 + verify path discovery + shell: pwsh + run: | + $stdout = Join-Path $env:RUNNER_TEMP 'start-ps1.out' + $stderr = Join-Path $env:RUNNER_TEMP 'start-ps1.err' + $proc = Start-Process -FilePath 'pwsh' ` + -ArgumentList '-NoLogo','-File','.\start.ps1' ` + -WorkingDirectory $PWD ` + -PassThru ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr + "SERVER_PID=$($proc.Id)" >> $env:GITHUB_ENV + Write-Host "Spawned start.ps1 wrapper PID $($proc.Id)" + + # Path discovery is sub-second; the 8s buffer lets the python + # launch land in the logs (and immediately exit on the stub). + Start-Sleep -Seconds 8 + + Write-Host "===== start.ps1 stdout =====" + $stdoutContent = if (Test-Path $stdout) { Get-Content $stdout -Raw } else { '' } + Write-Host $stdoutContent + Write-Host "===== start.ps1 stderr =====" + $stderrContent = if (Test-Path $stderr) { Get-Content $stderr -Raw } else { '' } + Write-Host $stderrContent + + # Pattern set: every Write-Error message start.ps1 can emit on + # its own discovery path. If any of these appear in stderr, + # path discovery regressed and the job must fail. + $guardErrors = @( + 'Python 3 is required', + 'hermes-agent not found', + 'HERMES_WEBUI_AGENT_DIR is set to', + 'is not a valid integer port', + 'is out of TCP-port range', + 'server.py not found' + ) + foreach ($msg in $guardErrors) { + if ($stderrContent -and $stderrContent -match [regex]::Escape($msg)) { + throw "REGRESSION: start.ps1 errored on guard '$msg' - path discovery failed." + } + } + Write-Host "OK: start.ps1 path discovery - all guards passed." + + # taskkill /T walks the process tree, /F forces. taskkill returns + # 128 ("process not found") if the PID is already gone — that's + # the expected steady state for this mock-only workflow because + # server.py exits immediately on the stub hermes_cli. Reset + # $LASTEXITCODE so the step never fails on the cleanup itself. + - name: Stop background server (tree-kill) + if: always() + shell: pwsh + run: | + if ($env:SERVER_PID) { + & taskkill /PID $env:SERVER_PID /T /F 2>&1 | Out-Host + $global:LASTEXITCODE = 0 + } + # Belt-and-suspenders: kill anything still bound to 8787. + $hanging = Get-NetTCPConnection -LocalPort 8787 -State Listen -ErrorAction SilentlyContinue + if ($hanging) { + foreach ($c in $hanging) { + try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction Stop } catch {} + } + } + exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index f48fee4b..c9569bc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,224 @@ - Add a default-off, read-only Third-party notes drawer to the Memory panel that lists configured note/knowledge MCP sources such as Joplin, Obsidian, Notion, and llm-wiki when explicitly enabled with `webui_external_notes_sources` or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`, while leaving automatic session recall unchanged. +## [v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab) + +### Fixed + +- **PR #2827** by @Koraji95-coder — Profile state-sync TLS-vs-thread fix (closes #2762). When switching profiles via the WebUI cookie selector, session token-usage and title were being written to the *previously-active* profile's `state.db` instead of the cookie-switched one (sidecar messages + workspace files were already routed correctly; only the `state.db` sidecar sync leaked). Root cause: the cookie middleware sets `_tls.profile = ''` on the HTTP request thread, but the daemon thread spawned in `_run_agent_streaming` doesn't inherit that TLS. When the streaming worker calls `sync_session_usage`, `_get_state_db → get_active_hermes_home → get_active_profile_name` finds no TLS profile, falls through to `_active_profile` (the process default), and opens the wrong DB. Fix plumbs the session's own `profile` field through `sync_session_usage(..., profile=...)` and `_get_state_db(profile=...)` rather than leaning on TLS that doesn't exist on the worker thread. Keeps the existing TLS path for callers that don't pass `profile=` explicitly, so external integrations don't regress. Also adds defensive `_validate_profile_name` rejecting `../etc`, leading-dash, whitespace, and over-long names (prevents path traversal via cookie tampering). Adds 11 regression tests covering explicit-profile honors, multi-thread profile preservation, unknown-profile-name fallback path, invalid-name refusal, and legacy-call-shape compatibility. + +- **PR #2726** by @starship-s — Boot model-default precedence follow-up (refines #2709). The original v0.51.105 fix correctly preferred the profile/server default on fresh boot, but the implementation had two over-broad side effects flagged in post-merge review: (1) boot unconditionally cleared the persisted browser model state, even on restored sessions where that state should remain authoritative, and (2) `populateModelDropdown()` reapplied the default on every repopulate when no session model was present, which clobbered the in-page selection during ordinary dropdown refreshes. Fix is to gate the default-reapply behind an opt-in `{preferProfileDefaultOnFreshBoot: true}` parameter so boot keeps profile-default precedence, restored sessions keep their session model, and non-boot dropdown refreshes preserve the loaded session's model or the current in-page selection. Browser model state is no longer deleted just because the profile default wins this boot. Expanded the regression test coverage with a Node `select` / DOM shim that exercises the real `populateModelDropdown()` path for boot-default, restored-session, current-selection, and removed-model scenarios (+306 LOC tests). + +### Added + +- **PR #2673** by @AJV20 — Workspace Artifacts tab (closes #2655). New tab in the workspace panel that lists likely files mentioned, edited, or created during the active session. Prioritizes structured tool-call paths (file_write, edit, patch, etc.), filters dependency/build noise (node_modules, `__pycache__`, `.git`, lock files), and refreshes while live tool calls arrive. Artifact entries open through the existing workspace file preview flow. The MVP is frontend-scoped — backend ingestion uses the existing tool-call event stream rather than a new persistence path — so the maintainer can evaluate the UX before deciding whether artifact tracking should grow into a backend-backed feature. Refreshes alongside the file tree in `loadDir()` via a `typeof renderSessionArtifacts==='function'` guard so it composes cleanly with #2716's session stale-guard pattern. Adds `tests/test_issue2655_frontend.py`. + +### Notes + +- **In-stage cherry-pick mechanics**: All 3 PRs were on stale-base merge-bases (master had advanced through 3 releases). Used `git apply --3way` of each PR's net delta vs its merge-base onto current stage HEAD, then resolved 2 small JS conflicts manually: + - `static/boot.js` (#2726 vs post-#2716 master): kept PR's parameterized `populateModelDropdown({preferProfileDefaultOnFreshBoot:true})` call (the whole point of #2726) on top of master's #2716 hydration flow. + - `static/workspace.js` (#2673 vs post-#2716 master): kept master's `sessionId`-capture stale-session guard (closure-scoped sessionId check after `await`) AND added PR's `renderSessionArtifacts()` call to refresh the new Artifacts tab when the file tree updates. Wrapped in `typeof === 'function'` guard for defense-in-depth. +- **In-stage test fixes**: Patched 3 brittle source-string assertions to accept both pre-#2716 and post-#2716 JS shapes (variable names changed during the cherry-pick, semantics preserved). Patched 1 schema mismatch in `tests/test_issue2762_state_sync_profile_kwarg.py::_read_session` helper — it queried `sessions.session_id` but the real `state.db` schema has `sessions.id` as primary key. Fix is mechanical: `SELECT id AS session_id` + `WHERE id = ?` so the helper queries the actual schema. +- Full pytest: pending re-run on this finalized stage. Touched-tests gate: 41 passed (covering #2827 + #2726 + #2673 surface areas). +- Agent self-verified: profile= kwarg threading on `_get_state_db` + `sync_session_usage`, production call site in `api/streaming.py:5078` passes `profile=getattr(s, 'profile', None)`, `populateModelDropdown` opt-in parameterization present, boot.js calls with `preferProfileDefaultOnFreshBoot:true`, workspace `renderSessionArtifacts()` defined + called. + +## [v0.51.129] — 2026-05-24 — Release DA (stage-batch11 — 4-PR feature + perf batch) + +### Performance + +- **PR #2836** by @v2psv — HTTP/1.1 keep-alive for WebUI responses. Bumps `Handler.protocol_version` from the HTTP/1.0 default to `HTTP/1.1` so browsers can reuse TCP connections across normal API and static-file requests. Adds explicit `Content-Length` headers to hand-written responses that weren't already using shared `j()` / `t()` helpers. Adds `Content-Length: 0` to empty redirect / range-error responses. Switches SSE-style streaming endpoints from `Connection: keep-alive` to `Connection: close` (keep-alive is only safe when the response body is framed; SSE bodies have no fixed length). Significant first-paint / session-open improvements on high-RTT / VPN / proxied paths — author reports ~47% faster first paint and ~30-40% improvements on panel-load flows on a typical remote-host setup. + + **Opus pre-release advisor caught one missing framing site** in the on-the-fly folder ZIP download path (`/api/folder/download`): the body has no known length, doesn't use chunked encoding, and was relying on HTTP/1.0 connection-close-equals-EOF. Under HTTP/1.1 this would have left clients hanging waiting for the next response after the central-directory bytes finished. Patched inline before tag: add `Connection: close` header to mirror the SSE-endpoint pattern. Opus verified this was the ONLY remaining streaming response in the codebase that needed the header — all 12 hand-written response paths + 8 SSE streams + j()/t() helpers + auth flow were already correctly framed by the PR. + +### Added + +- **PR #2680** by @mccxj — Auxiliary Models settings card in Settings → Preferences. Lets users configure per-task model routing for 9 canonical side-task slots: vision, web extract, compression, session search, approval, MCP tool reasoning, title generation, skills hub, curator. Each slot exposes a provider dropdown + model dropdown plus an "auto (use main model)" / "auto (use provider default)" pair so users can keep aux routing implicit when they don't care. New endpoints: `GET /api/model/auxiliary` returns current assignments; `POST /api/model/set` writes assignments (`scope=auxiliary` for aux slots, `scope=main` for the default chat model) and supports `task="__reset__"` to reset all slots back to auto. 16 new i18n keys added across all 12 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr — Turkish translations added in-stage to cover the sibling-PR collision with v0.51.127's Turkish locale baseline). 24 source-level test assertions covering HTML structure, JS logic, i18n parity, and route registration. + +- **PR #2842** by @AJV20 — PWA polish for installed launches. New `static/pwa-startup.js` is loaded synchronously in `` before the main UI bundle, so the page knows whether it's running standalone / in-browser / on iOS / offline before first paint. Marks `pwa-standalone`, `pwa-browser`, `pwa-ios`, `pwa-offline`, and short-lived `pwa-resumed` classes on ``. Exposes `window.HermesPWA.{isStandalone, syncMode, launchAction, promptInstall}` helpers and captures `beforeinstallprompt` / `appinstalled` early enough that any future install-prompt UI can chain off them. Manifest gains app identity / scope / `display_override` (`window-controls-overlay` → `standalone` → `minimal-ui`) and a "New conversation" PWA App Shortcut. Service worker pre-caches the startup helper, switches navigation and shell-asset fetches to `cache: 'no-store'` before falling back to CacheStorage. Boot path wires `?source=pwa&action=new-chat` to start a fresh chat instead of reopening the last saved session. The viewport meta now sets `maximum-scale=1, user-scalable=no` for native-feel — acknowledged trade-off against WCAG 2.1 1.4.4 (Resize text), intentionally kept for the PWA-installed feel of this user base. + +- **PR #2794** by @Michaelyklam — Runtime adapter route selection harness. Routes explicit adapter-mode chat starts through `build_runtime_adapter(...)` and keeps `legacy-direct` as the default `/api/chat/start` path. Continues the #1925 RFC slice progression: this is slice 4e, the default-off chat-start route-selection seam. Returns a bounded `501 Not Configured` response when `runner-local` is explicitly selected before a supervised runner client exists, instead of silently starting a legacy WebUI-owned run. New `_chat_start_response_from_run_start(...)` helper whitelists legacy-compatible chat-start response fields and keeps adapter-internal `run_id`, `status`, and `active_controls` out of public responses. Updates `docs/rfcs/hermes-run-adapter-contract.md` to mark #2744 shipped and define slice 4e. + +### Notes + +- Full pytest: **6,467 passed / 6 skipped / 3 xpassed / 8 subtests passed**. +- Opus pre-release advisor reviewed all 7 risk areas (HTTP framing surface completeness, PWA startup ordering, sibling-PR `api/routes.py` interaction, service worker cache invalidation, viewport-meta trade-off, runtime adapter response shape, locale-counter brittleness). Verdict: **1 MUST-FIX patched inline** (folder ZIP `Connection: close` header), **0 inline SHOULD-FIX**, 1 follow-up suggested (`set_auxiliary_model` could validate `task` against `AUX_TASK_SLOTS` whitelist — auth-gated, low severity, filing as follow-up). +- Agent self-verified: protocol_version bumped, SSE Connection-close + Content-Length plumbing, Auxiliary Models API surface (config + endpoints + frontend), PWA helpers + manifest shortcuts + display_override, Runtime adapter wiring + whitelisting, i18n parity for all 12 locales on the 16 new aux keys. +- Browser-verified at 1920×1080: Auxiliary Models card renders correctly under Settings → Preferences, 9 task slots with provider/model dropdowns, "Reset all to auto" button, layout consistent with surrounding Settings cards, no clutter or clipping. PWA classes populate on `` and HermesPWA namespace populates with 4 helpers as expected. +- In-stage commits added Turkish translations for #2680's 16 `settings_aux_*` / `settings_label_auxiliary_models` / `settings_desc_auxiliary_models` keys to close the sibling-collision gap with v0.51.127's Turkish locale (#2772). Bumped `test_auxiliary_models_settings.py::test_all_locales_have_auxiliary_keys` from `count == 11` to `count == 12` (the locale set grew when Turkish landed). + +## [v0.51.128] — 2026-05-24 — Release CZ (stage-batch10 — 2-PR perf + correctness batch) + +### Fixed + +- **PR #2830** by @franksong2702 — Pin state synchronization between persisted index and in-memory sessions (closes #2821). Three coupled bugs: + - **Bug A (load-bearing):** `/api/session/pin` pre-snapshot used `getattr(session, "pinned", False)` which always returned `False` for dict-backed index rows from `all_sessions()`. With ~55-session profiles and LRU eviction churn, pinned counts routinely under-counted because the persisted snapshot was effectively empty. New `_session_field(session, field, default)` helper resolves both dict-backed and Session-object snapshots correctly. + - **Bug B:** Removed stale client-side `pinLimitReached` short-circuit in the sidebar action menu that could block pin clicks before the server saw them, based on `_allSessions` data that was stale mid-render. Server now enforces the cap; the toast surfaces the 400 response. + - **Bug C recovery:** Pin/unpin failure path (4xx response from `/api/session/pin`) now triggers `renderSessionList()` to refresh `_allSessions` from the server, so the sidebar never gets stuck on stale optimistic state. + + Adds `tests/test_issue2821_session_pin_state_sync.py` (70 LOC) covering the `_session_field` helper, the persisted-pinned snapshot, the removed `pinLimitReached` reference, and the failure-catch refresh path. Companion fix to #2782 (server-side 404→200 transition for missing CLI-synced sessions) which remains out of scope. + +### Performance + +- **PR #2716** by @dobby-d-elf — Six independent perf nudges plus one correctness fix. nesquena-APPROVED on 2026-05-22 after a deep-review iteration; cherry-picked onto post-v0.51.127 master via 3-way apply with sibling-PR composition resolution. + + - **Metadata-only `/api/session` correctness fix.** Refactors the prior inline reconciliation into `_metadata_only_message_summary(sid, profile=None)` helper that runs the full `merge_session_messages_append_only()` path. Pre-fix shortcut could over-count stale state.db replay rows that the merge intentionally filters out, producing false "transcript newer than loaded conversation" signals (same bug class as #2705 / #2686). The new helper threads `profile=` through to `get_state_db_session_messages` to preserve #2827's TLS-vs-thread profile fix on background-thread reads. + - **Batched persisted-session checks in sidebar indexing.** One `SESSION_DIR.glob('*.json')` snapshot per call replaces per-row `_index_entry_exists()` filesystem lookups during `all_sessions()` pruning. Fallback to the per-row helper preserved when the glob raises. + - **Deferred render-cache signature.** `cachedRenderSignature` closes over the lookup-time signature so the cache STORE path reuses it without recomputing. `_messageRenderCacheSignature()` continues to include the content hash per #2692, preserving the cache-invalidation invariant. + - **Hoisted assistant tool-activity index.** Footer-rendering loop now uses an `O(1)` Set lookup instead of `S.toolCalls.some(...)` per message — ~30× fewer comparisons for a 100-message conversation with 30 tool calls. + - **Workspace stale-session guards.** `loadDir` and `_refreshGitBadge` in `static/workspace.js` capture `sessionId` at call time and check it after each `await` (including the catch path of `_refreshGitBadge` — without it, a late 404 from the previous session would hide the git badge on the current session). + - **Background model-catalog prime.** `_startBootModelDropdown` fires fire-and-forget on boot via `setTimeout(0)` so the live catalog hydrates without blocking. The existing `await` on the saved-session restore path is preserved (re-applies the saved session's model after hydration so the chip never shows the stale static default). + - **Failed hydration retryable.** `window._modelDropdownReady = null; throw e;` lets the next caller refetch instead of being stuck on a permanent failure. + + Adds 76 LOC of new tests across `test_session_metadata_fast_path.py`, `test_webui_state_db_reconciliation.py`, `test_session_index.py`, `test_issue1539_provider_removal_dropdown_invalidation.py`, `test_issue1785_workspace_preview_breadcrumb.py`, `test_parallel_session_switch.py`. + +### Notes + +- PR #2716 had been pending merge since 2026-05-22 due to a rebase blocker against the rapidly-advancing master (10+ intervening releases). Cherry-picked via `git apply --3way` of the PR's net delta vs its original merge-base (`f9302601`); 12 of 14 files applied cleanly. Two files had genuine conflicts requiring resolution: `api/routes.py` (took the PR's helper extraction AND added `profile=` threading to preserve #2827's fix), and `tests/test_webui_state_db_reconciliation.py` (kept BOTH master's pre-existing `test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant` AND the PR's new `test_metadata_fast_path_matches_reconciliation_for_restamped_replays` — they pin different invariants). +- Opus pre-release advisor reviewed all 6 risk areas (helper extraction correctness, sibling-PR composition, `Session.load` profile-safety, test coverage, deferred Bug D, stale-line-number cleanup nit). Verdict: **SHIP AS-IS** — no MUST-FIX, no inline SHOULD-FIX. Two follow-up issues to file post-tag (Bug D startup index rebuild perf; multi-profile state.db test for the `profile=` threading invariant). +- Full pytest: **6,434 passed / 6 skipped / 3 xpassed / 8 subtests passed** in 2m43s. +- Agent self-verified the producer→consumer channel for `_metadata_only_message_summary` with unmocked invocation against a real session-load path (per skill rule Trigger A + E for mocked-consumer test patterns). +- Closes: #2821 (pin state sync), and `get_state_db_session_summary` dead-code removed (#2716). + +## [v0.51.127] — 2026-05-24 — Release CY (stage-batch9 — 7-PR low-risk batch — brick-class Linux + brick-class update apply + composer wide-screen + Turkish locale + MCP toggle + SSE settlement + Windows CI) + +### Fixed + +- **PR #2854** by @nesquena-hermes — Embedded terminal opens then immediately closes with `[terminal closed]` on every Linux install past `71d8a8fb`. Root cause: `_terminal_shell_preexec_fn` set `PR_SET_PDEATHSIG=SIGTERM` on the PTY shell so orphans would die when WebUI crashed, but `PR_SET_PDEATHSIG` is **per-thread**, not per-process. WebUI uses `ThreadingHTTPServer`, so each HTTP request runs in its own short-lived worker thread; when the request handler returns and the worker thread exits, the kernel sees the pdeathsig-parent thread has died and SIGTERMs the PTY shell within ~10ms. macOS users were unaffected because `libc.prctl` doesn't exist there. Fix: drop the `preexec_fn` entirely; rely on `atexit.register(close_all_terminals)` for graceful shutdown and explicit `close_terminal` for user-driven close. Adds `tests/test_terminal_process_cleanup.py::test_pty_shell_survives_when_spawning_thread_exits` (real PTY shell spawned via worker thread, asserts shell alive after 500ms grace) plus static-check that `preexec_fn` cannot be re-introduced. Closes #2853. + +- **PR #2855** by @nesquena-hermes — "Update Now" loops for every user past the latest tag (#2846). After #2758 the update check correctly fell through to branch comparison when `HEAD` had moved past the latest `v*` tag, but `_select_apply_compare_ref` still returned `tags[0]` — so `git pull --ff-only v2026.5.16` no-op'd, the server bounced, and the banner reappeared unchanged. `apply_force_update` had the same bug except worse (would `git reset --hard v2026.5.16` and rewind the checkout 254 commits). Fix: extract `_head_is_past_latest_tag(path, current_tag)` and have both check and apply paths consult it. Opus pre-release review caught a "case D" parameter-asymmetry drift (HEAD on older tag + commits + newer tag exists → predicate flipped between the two callsites) and patched the apply-side predicate to use `current_tag` + a `behind == 0` gate, exactly mirroring the check-side rule. Adds `test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists`. Closes #2846. + +- **PR #2852** by @ai-ag2026 — Chat `stream_end` handler now settles from the persisted session when `done` was not received or replayed, instead of leaving the active pane with live `Thinking` / assistant DOM and inflight state projected indefinitely. Reconnect / journal / replay paths can deliver `stream_end` without preceding `done`; the prior code treated `stream_end` as transport-only close. Duplicate / replayed `done` events are also made idempotent before completion sound / final render side effects. Opus pre-release review added a post-await race guard inside `_restoreSettledSession` to catch the case where a late `done` event runs the finalize path while the settlement is awaiting the `/api/session` roundtrip. Adds 4 new regression tests across `tests/test_1694_terminal_cleanup_ownership.py` covering both `stream_end`-without-`done` and duplicate-`done` paths. + +- **PR #2811** by @Koraji95-coder — Native-Windows startup E2E workflow now self-tests on PR push (closes the post-#2783 gap where Windows-only regressions like the WOW64 ProgramFiles redirect could only be caught after release). Reworked per maintainer feedback to use a stub `hermes_cli/__init__.py` next to a sibling `hermes-agent/` folder rather than `pip install hermes-agent` (which is not on PyPI). Workflow runs `start.ps1` for 8s and asserts none of its `Write-Error` guards fired (no Python, no agent dir, bad port, missing `hermes_cli`, missing `server.py`). PowerShell syntax + path discovery is the testable surface; the server can't actually boot on a stub. `taskkill` exit-128 swallowed when the stub process is already gone. + +### Changed + +- **PR #2812** by @Koraji95-coder — Composer max-width is now responsive on wide displays. Pre-change `.composer-box` had a fixed `max-width: 780px` that pinched footer chips (workspace name, model picker, reasoning chip, context ring) against each other on 1440p+ monitors. Switched to `max-width: clamp(780px, 60vw, 1100px)` — the 780px floor preserves byte-identical layout at 1280px (Aron's laptop reference width); 1440px viewports gain ~84px (864px composer); 1920px viewports gain ~320px (1100px composer cap). Mobile responsive logic untouched. Single-line CSS change in `static/style.css`. + +### Added + +- **PR #2772** by @vaur94 — Complete Turkish (`tr`) locale across `static/i18n.js` (~1,182 keys matching existing locale coverage). Adds Turkish login page strings in `api/routes.py` `_LOGIN_LOCALE`. Settings → Language now offers **Türkçe**; speech recognition uses `tr-TR`. Stage build absorbed a sibling-PR i18n collision with #2776 below (9 missing keys: `mcp_enable_server`, `mcp_disable_server`, `mcp_enabled_toast`, `mcp_disabled_toast`, `mcp_toggle_failed`, `open_in_vscode`, `open_in_vscode_failed`, `settings_label_ignore_agent_updates`, `settings_desc_ignore_agent_updates`) — Turkish translations added in-stage so locale-parity test passes. Closes #2537 as superseded (byzuzayli's earlier Turkish PR with narrower scope). + +- **PR #2776** by @roryford — New `PATCH /api/mcp/servers/{name}` endpoint accepts `{"enabled": bool}`, writes `mcp_servers..enabled` to `config.yaml`, calls `reload_config()`, returns `{"ok": true, "name": "", "enabled": }`. Each MCP server row in the panel now shows a clickable Enabled/Disabled toggle. Also fixes a pre-existing bug: `_handle_mcp_server_delete` and `_handle_mcp_server_update` were defined at line ~11656 but never wired into the HTTP router — DELETE wired into `handle_delete`, PUT wired via new `handle_put` / `do_PUT` in `server.py`. CORS preflight `Access-Control-Allow-Methods` updated to include `PUT` (Opus pre-release review nit). Adds 5 i18n keys to all 11 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr via in-stage parity fix). 7 new tests covering enable, disable, 404, empty-name, missing-field, response payload, URL-decoded names. + +### Notes + +- Two PRs (#2854, #2855) are brick-class fixes — every Linux install was unable to use the embedded terminal, and every install past the latest agent tag was stuck in an Update Now loop. They land in the same low-risk batch as cosmetic / locale / CI changes because both fixes are mechanical, well-tested, and the brick-class severity made deferring impossible. +- Opus pre-release advisor reviewed all 5 risk areas (PR_SET_PDEATHSIG removal, update apply path symmetry, MCP toggle wiring, composer clamp, stream_end settlement). 1 MUST-FIX + 3 SHOULD-FIX all addressed inline before tag. Net: +69/-9 across 5 files for the Opus fixes. +- Full pytest: 6,424 passed / 6 skipped / 3 xpassed / 8 subtests passed. +- UX evidence for #2812 captured at 1280/1440/1920/mobile (iPhone 14 emulation); Telegram-approved. +- File a follow-up issue for pdeathsig-on-supervisor-thread hardening (#2854 deferred Option B) and French-locale `open_in_vscode` parity gap (predates this batch, Opus advisor flagged). + +## [v0.51.126] — 2026-05-24 — Release CX (stage-batch8 — 2-PR low-risk batch — kanban markdown + live activity timeline) + +### Added + +- **PR #2819** by @humayunak — Kanban task descriptions and comments now render as full GFM Markdown instead of plain-text. `_kanbanRenderMarkdown()` in `static/panels.js` rewrote the line-per-`

` wrapper as a block-parsing pipeline supporting headings, code blocks (fenced + indented), ordered/unordered lists, task lists with checkboxes, tables, blockquotes, horizontal rules, and strikethrough. `_kanbanRenderMarkdownInline()` gains `~~strikethrough~~` and tightens the italic regex to avoid mid-identifier `*` matches. CSS adds table borders, code-block background, checkbox styling, blockquote accent, and heading sizing scoped to `.hermes-kanban-md`. Frontend-only, scoped to the kanban panel. 95 existing kanban tests pass. + +### Changed + +- **PR #2847** by @AJV20 — Live chat Activity disclosure now shows observable run telemetry instead of an empty `Thinking…` placeholder when no reasoning text is available (squashed from 2 author commits). New baseline rows surface run-start metadata (model, profile), `Waiting on model` / `Waiting on tool result` / `Working for …` status, tool start/finish in the timeline alongside the existing compact tool cards, and a `No recent activity for …` state after quiet periods. Frontend-only telemetry derived from existing stream events — no new backend event types. Adds `tests/test_live_activity_timeline.py` (4 tests). The compact/calm default Activity disclosure is preserved; it only becomes informative when expanded. + +## [v0.51.125] — 2026-05-24 — Release CW (stage-batch7 — 10-PR low-risk batch — UI/UX polish + bug fixes + diagnostics) + +### Fixed + +- **PR #2839** by @tn801534 — Kanban worker log endpoint constructed URLs with a double query string (`?board=?tail=65536`) when a non-default board was active. The frontend was appending `?tail=65536` directly to a URL that already had `?board=...` from `_kanbanBoardQuery()`. Fix: pass `{tail: 65536}` as the `extra` argument to `_kanbanBoardQuery()` so it composes both params into a single valid query string. One-line, narrow scope. + +- **PR #2832** by @franksong2702 — Malformed HTTP request logging in `server.py` falls back to `"-"` for missing `command` or `path` instead of raising `AttributeError`. Defensive `getattr(self, 'command', None) or '-'` matches the pattern already used for `_req_t0` elsewhere in the handler. Adds `tests/test_issue2775_log_request.py` covering the malformed-request-before-path-assigned case. + +- **PR #2818** by @humayunak — Approval and clarify cards no longer steal focus from the composer textarea (`#msg`) when the user is mid-type. `showApprovalCard()` and `showClarifyCard()` now guard the `focus()` call on `document.activeElement !== $('msg')`, matching the pattern already used elsewhere for focus-sensitive paths. The clarify card also moves the focus call out of `setTimeout` for snappier UX. Silently dropped keystrokes during streaming are eliminated. + +- **PR #2826** by @Koraji95-coder — Composer footer chip wraps no longer overlap at narrow widths (closes #2740). The five chip wraps (`.composer-profile-wrap`, `.composer-ws-wrap`, `.composer-model-wrap`, `.composer-reasoning-wrap`, `.composer-toolsets-wrap`) had `flex: 0 1 auto` + `min-width: 0` so they would compress past their content's natural width when the composer narrowed, causing visual overlap of the profile / workspace / model / reasoning chips. Switched to `flex: 0 0 auto` via a single grouped selector. Each chip now keeps its natural width and the existing `overflow-x: auto` on `.composer-left` handles overflow via horizontal scroll. Default-width layout unchanged; only affects the overflow regime. Mobile-specific rules (already `flex: 0 0 auto`) untouched. + +- **PR #2829** by @franksong2702 — Workspace Markdown previews fall back to plain text for very large files (>64 KB or >1500 lines) instead of synchronously running the full rich Markdown renderer on the browser main thread, which could lock up the tab for several seconds on multi-megabyte `.md` files. Plain-text preview shows file size + line count in the status line so users know why rich rendering was bypassed; Edit mode still shows raw content as before. Closes #2823. Supersedes #2828 (same scope, less polished). + +- **PR #2837** by @franksong2702 — CSRF rejections now distinguish origin/proxy mismatches from expired session tokens, so provider-key removal and other protected requests show actionable diagnostics instead of the generic "Cross-origin request rejected" error. Adds `tests/test_issue2572_csrf_diagnostics.py` covering both failure modes. + +- **PR #2834** by @franksong2702 — Workspace Markdown `mailto:` and `tel:` links now render as clickable links, and sandboxed HTML preview links open outside the iframe (via injected ``) instead of navigating the preview into a browser-blocked page. Adds `tests/test_issue2768_workspace_links.py`. + +- **PR #2838** by @franksong2702 — Tasks panel surfaces a warning when the Hermes gateway is not configured or not running, so Docker users know scheduled jobs need the gateway daemon to tick while away. The single-container Docker boundary is also clarified in `docs/docker.md`. Adds `tests/test_issue2785_gateway_cron_guidance.py`. + +### Added + +- **PR #2820** by @tangerine-fan — Clarify user choice is now echoed as a visible message in the conversation transcript. After the user responds to a clarify prompt, a synthetic user message with the chosen value is inserted into `S.messages` (marked `_clarify_response: true` so downstream consumers can filter if needed). Previously the choice was only visible in the transient clarify card; now the chat history preserves the decision. + +- **PR #2843** by @AJV20 — New Settings preference "Ignore Agent updates" keeps WebUI update notices, banners, and update actions enabled while suppressing Hermes Agent update checks. Default `False` (current behavior). Useful when running an unreleased agent build or pinning to a specific agent commit. + +## [v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening) + +### Added + +- **PR #2805** by @Koraji95-coder — `start.ps1`: expand hermes-agent candidate paths for Windows installers. The launcher now searches `$env:USERPROFILE\.hermes\hermes-agent`, the dev-checkout sibling, and the Windows installer roots (`$env:LOCALAPPDATA\hermes\hermes-agent`, `${env:ProgramW6432}\hermes\hermes-agent`, `${env:ProgramFiles}\hermes\hermes-agent`, `${env:ProgramFiles(x86)}\hermes\hermes-agent`) with `Select-Object -Unique` to collapse WOW64 ProgramFiles redirection collisions on 32-bit PowerShell processes. Adds `-PathType Container` to the `HERMES_WEBUI_AGENT_DIR` guard so a file named `hermes_cli` doesn't false-positive. Null-guards `${env:ProgramFiles(x86)}` for constrained environments where it's missing. Zero impact on Linux/macOS — file is `start.ps1`, never loaded by `start.sh` or `bootstrap.py`. + +### Documentation + +- **PR #2806** by @Koraji95-coder — Native Windows venv path corrected in `start.ps1` doc-comment and `README.md`. The previous text suggested "run bootstrap.py inside WSL2 once to create the venv, then this script can use that venv" — but a WSL2-created venv is `venv/bin/python` (ELF) and cannot be invoked by native Windows Python. The corrected guidance is to create a Windows venv natively (`python -m venv venv` from PowerShell), then `start.ps1` auto-discovers `venv\Scripts\python.exe`. WSL2 remains useful as a parallel install for the full `bootstrap.py` + Linux runtime path. + +### Hardened + +- **PR #2807** by @Koraji95-coder — `start.ps1`: `HERMES_WEBUI_PORT` env-var parsing uses `[int]::TryParse` + range guard (1-65535) instead of a bare `[int]` cast that threw `InvalidCastException` with no context on typos or accidental shell expansion. Server-process exit code is captured into `$script:serverExitCode` and emitted via `exit` AFTER the `try/finally` cleanup, so `Pop-Location` always runs (avoids leaving the caller stuck at `$RepoRoot` in interactive or dot-sourced sessions). Also drops a non-functional `@args` splat that PowerShell doesn't populate under `[CmdletBinding()]` — the launcher's existing use case is env-var-driven, no pass-through args needed. + +## [v0.51.123] — 2026-05-24 — Release CU (stage-batch5 — 2-PR low-risk batch — gzip+ETag static caching / Open in VS Code) + +### Performance + +- **PR #2779** by @v2psv — Static asset serving negotiates gzip, emits ETags, and uses `immutable` cache headers for fingerprinted URLs. `_serve_static()` in `api/routes.py` previously sent every `/static/*` response with `Cache-Control: no-store` and no `Content-Encoding`, so a page reload over a slow link re-downloaded the full ~2.4 MB JS+CSS shell on every visit. The fix layers three changes inside the same function: (1) gzip the body when the client opts in via `Accept-Encoding`, gated to compressible MIME types and files >1 KB; (2) emit a weak ETag derived from `(size, mtime_ns)` and short-circuit conditional GETs to `304 Not Modified`; (3) send `Cache-Control: public, max-age=31536000, immutable` when the URL carries a non-empty `?v=…` fingerprint (the `__WEBUI_VERSION__` token already substituted by the index template and referenced from `static/sw.js`'s `SHELL_ASSETS`), falling back to `public, max-age=300` otherwise. Raw bytes, compressed bytes, and ETags are cached in-process keyed by `(size, mtime_ns)` so a redeploy is picked up without a restart, while missing/random paths never enter the cache and image/font types skip gzip to avoid wasted CPU on already-compressed payloads. Measured against an asyncio TCP proxy that injects RTT + bandwidth caps for representative VPN scenarios: cold loads improve 2.7-3.1× (e.g. 80 ms RTT / 10 Mbps WireGuard goes from 4.0 s to 1.3 s), warm reloads improve 3.3-4.0× via 304 responses, and bytes-on-the-wire drop 74% on cold loads. Loopback (already fast) still benefits 2.4×. Scope is strictly `/static/*`: `/api/*`, `/stream`, `/`, `/index.html`, `/session/*`, and login/auth routes are served by independent handlers and continue to send `no-store` exactly as before — no change to CSRF, session payloads, SSE buffering, or login flows. 11 regression tests pin gzip negotiation, ETag/304 round-trip including `Vary: Accept-Encoding`, fingerprint-driven cache policy including empty `?v=`, image/tiny-file skip rules, redeploy invalidation, and the existing path-traversal sandbox. + +### Added + +- **PR #2787** by @munim — "Open in VS Code" action in workspace file browser (resolves #2735). Right-clicking any file, folder, or the workspace root now shows an **Open in VS Code** menu item alongside the existing Reveal in File Manager action. The action calls a new `POST /api/file/open-vscode` endpoint which resolves the workspace-relative path via the existing `safe_resolve` traversal guard, then launches VS Code via `subprocess.Popen` (fire-and-forget, consistent with `_handle_file_reveal`). The endpoint resolves the executable via `shutil.which()` first, then falls back to a hardcoded list of common install locations (macOS: `/usr/local/bin/code` and the app-bundle CLI; Linux: `/usr/bin/code`, `/snap/bin/code`; Windows: `%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd` and the `%PROGRAMFILES%` variants) so the action works even when the server process inherits a minimal PATH. Configurable via a new optional `vscode` block in `config.yaml`: `command` overrides the default `code` executable; `host_path_prefix` + `container_path_prefix` enable Docker/container host-path translation. If the command cannot be found anywhere, a descriptive error is returned instead of a bare OS error. i18n keys `open_in_vscode` and `open_in_vscode_failed` added with full translations in all 10 locales. 26 new tests in `tests/test_2735_open_in_vscode.py` pin source wiring, command-resolution logic, i18n completeness, translated strings, and live endpoint error paths. + +## [v0.51.122] — 2026-05-24 — Release CT (stage-batch4 — 4-PR low-risk batch — stale cache tail / inflight UI / segment flush / reasoning accumulator) + +### Fixed + +- **PR #2802** by @ai-ag2026 — Drop stale inactive cached user tails when `/api/session` reloads a conversation whose saved sidecar already ends on an assistant answer. Supersedes #2733 (held due to async-compression interaction): the new guard adds a `len(cached_messages) <= len(disk_messages)` filter so it never fires when the cache has genuine new concurrent edits beyond the disk state — only when the cache has an unsaved user row past the saved assistant tail. Adds `api/models._inactive_cache_tail_needs_disk_check()` + `_cache_has_stale_unsaved_user_tail()` helpers and 5 new tests in `tests/test_webui_state_db_reconciliation.py`. Previously-held test `test_session_compress_async_reports_stale_session_guard` now passes (verified). Closes umbrella #2361 partially. + +- **PR #2796** by @ai-ag2026 — Clear stale inflight UI state before starting a new send so blocked composer busy-state from failed/incomplete prior turns doesn't divert new turns into the invisible queue. Five-commit squashed fix: (1) drop stale optimistic sidebar rows once canonical session data arrives, (2) clear stale busy state before send via `_clearStaleBusyStateBeforeSend()`, (3) preserve server idle rows over stale optimistic local rows, (4) let `/api/chat/start` survive non-fatal pre-start UI errors via `_runOptionalPreStartUiStep()`, (5) keep those warnings console-only instead of throwing. Adds `_shouldKeepLocalOnlyOptimisticSessionRow()` in `static/sessions.js` and 8 new tests in `tests/test_inflight_send_start_race.py`. Closes #2795. Authorship preserved via `--author`. + +- **PR #2777** by @b3nw — Flush pending render before segment reset at tool/interim_assistant boundaries so live tokens that arrived in the 66ms rAF throttle window don't get lost from the DOM when `_resetAssistantSegment()` clears `assistantBody`. New `_flushPendingSegmentRender()` helper writes via `smd`, `renderMd`, or `esc` fallback (same paths as `_doRender`) only when `_renderPending` is true. Completed transcripts were never affected — `renderMessages` rebuilds from the full `assistantText` accumulator on `done`. Adds `tests/test_issue2713_streaming_segment_flush.py`. Closes #2713. + +- **PR #2778** by @b3nw — Reset reasoning accumulator per turn and prefer `reasoning_content` over `reasoning` on read. Two related bugs: (1) `reasoningText` was initialized once when the SSE stream opened and never reset between turns, so the `done` event would assign the union of every turn's reasoning to the last assistant message in multi-turn agent sessions; now reset at both turn boundaries (`tool` + `interim_assistant`). (2) `static/ui.js renderMessages` preferred `m.reasoning` (potentially corrupted by bug 1) over `m.reasoning_content` (the clean per-turn backend value); the fallback now reads `m.reasoning_content || m.reasoning`. Updates `tests/test_streaming_race_fix.py` to scope the reconnect-accumulator guard to the `_wireSSE` preamble only (turn-boundary resets inside event listeners are intentional). Adds `tests/test_issue2565_reasoning_accumulation.py`. Closes #2565. + +## [v0.51.121] — 2026-05-24 — Release CS (stage-batch3 — 4-PR low-risk batch — state.db merge / display counts / compression marker / Windows launcher) + +### Fixed + +- **PR #2788** by @Carry00 — Prevent `state.db` messages being silently dropped during sidecar merge. Two related bugs were combining to discard historical messages: (1) `get_state_db_session_messages()` was selecting `role, content, timestamp` but NOT `id`, so every row was assigned a `("legacy", ...)` merge key instead of `("message_id", ...)`; (2) when a WebUI-origin session was continued via another Hermes surface (Gateway, CLI), the reader was always hitting the *active* profile's `state.db` rather than the session's own profile. Symptom: a 189-message session showed only 50 in the WebUI. Fix: include `id` in the SELECT when the column exists, and accept an optional `profile=` arg so cross-profile reads use the right database. Both callers in `api/routes.py handle_get` now thread `profile=getattr(s, 'profile', None)` through. + +- **PR #2797** by @ai-ag2026 — Align messaging session display counts with deduped display messages. The `message_count` returned by `/api/session` is the display coordinate space used for pagination and the header badge. Messaging-thread `state.db` metadata can carry raw duplicate transport rows (blank assistant separators between Discord/Slack thread turns) that `_merged_session_messages_for_display()` intentionally dedupes for rendering. The advertised count was the raw row count, so the frontend expected phantom messages after dedupe — `len(display_msgs) < message_count` triggered "load older" UI states that immediately returned nothing. Fix: `raw["message_count"] = _merged_message_count` for messaging sessions, computed from the same merge that produced the displayed messages. Adds `tests/test_gateway_sync.py::test_messaging_session_message_count_matches_deduped_display_messages` covering the regression. + +- **PR #2803** by @simjak — Compression-summary cards no longer use ordinary tool output that merely mentions context compression. The streaming auto-compression path was using a local broad substring matcher that fired on any message containing the strings "context compaction" / "context compression" / "context was auto-compressed" / "active task list was preserved across context compression", including skill/tool JSON output and ordinary user discussion about compaction. The strict predicate at `api/compression_anchor._is_context_compression_marker()` was already correctly scoped to synthetic marker prefixes on non-tool messages. Fix: expose the strict predicate as `is_context_compression_marker()` (public name) and route `api/streaming._is_context_compression_marker` through it as a backward-compatible alias. Tool/skill output that mentions compression no longer seeds `compression_anchor_summary` cards. + +### Added + +- **PR #2783** by @Koraji95-coder — Native Windows launcher and community-guide README link (squashed from 3 commits). `start.ps1` is a PowerShell equivalent of `start.sh` that bypasses `bootstrap.py`'s `ensure_supported_platform()` refusal and invokes `server.py` directly on native Windows. It mirrors `start.sh`'s discovery (load optional `.env` with the same readonly-var filter for `UID`/`GID`/`EUID`/`EGID`/`PPID`, find Python via `HERMES_WEBUI_PYTHON` env → `python3` → `python` → `py`, validate `HERMES_WEBUI_AGENT_DIR` on disk before use, prefer the agent's `venv\Scripts\python.exe`, set `HERMES_WEBUI_HOST` / `HERMES_WEBUI_PORT` / `HERMES_WEBUI_STATE_DIR` / `HERMES_HOME` defaults). The README adds a community-maintained native Windows setup section pointing to @markwang2658's `hermes-windows-native-guide` and `hermes-windows-native` repos with the documented memory delta (~330 MB native vs ~1080 MB WSL2+Docker). Closes both halves of #1952. Assumes Python + agent venv are already set up — first-time setup still needs WSL2 once to create the venv (`bootstrap.py` still refuses on native Windows). + +## [v0.51.120] — 2026-05-24 — Release CR (stage-batch2 — 3-PR low-risk batch — Bedrock provider / update check past-tag / CORS preflight) + +### Added + +- **PR #2786** by @munim — Surface AWS Bedrock as a configurable provider in the WebUI model picker. `api/config.py` registers `"bedrock": "AWS Bedrock"` in `PROVIDER_LABELS`, adds 6 default Bedrock model IDs (Claude Opus 4.7 / 4.6 / 4.5, Sonnet 4.6 / 4.5, Haiku 4.5) to `DEFAULT_MODELS["bedrock"]`, and teaches `_build_configured_model_badges()` to detect Bedrock when both `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are present (IAM-style auth, not single-API-key). Static fallback list is overridden at runtime by `hermes_cli.models.provider_model_ids("bedrock")` when the live AWS model list is reachable. Adds `tests/test_issue2720_bedrock_model_picker.py` with 11 test cases covering registry, defaults, env-detection, and runtime override. Resolves #2720. + +### Fixed + +- **PR #2789** by @munim — Update check no longer falsely reports "Up to date" when HEAD has moved hundreds of commits past the latest tag. The hermes-agent repository keeps committing to master between tagged releases, and the old `_check_repo_release()` returned `behind=0` (since `current_tag == latest_tag`) and stopped — so the user saw "Up to date" while the working tree was hundreds of commits behind. The fix: when `behind == 0`, run `git describe --tags --always`; if the result contains the `-N-gSHA` suffix (HEAD past tag), return `None` so `_check_repo_branch()` runs and reports the real commit gap. Adds 8 new test cases in `tests/test_updates.py` covering past-tag detection, equal-tag-and-HEAD pass-through, untagged-repo behavior, and the agent-cadence #2653 scenario. Resolves #2653. + +- **PR #2790** by @weidzhou — Add `do_OPTIONS()` handler in `server.py` so CORS preflight requests return `200 OK` with appropriate `Access-Control-Allow-*` headers instead of `501 Not Implemented`. Browsers sending a preflight OPTIONS for cross-origin API calls previously hit the BaseHTTPRequestHandler default and the entire CORS exchange was blocked. The handler narrowly responds only to OPTIONS — no broader CORS posture change to other endpoints. Resubmit of closed #2750 (which bundled unrelated session-index changes); this PR is the minimal preflight-only split that @nesquena-hermes and @AJV20 requested. + +## [v0.51.119] — 2026-05-24 — Release CQ (stage-batch1 — 3-PR low-risk batch — tool cards / 404 recovery / Hepburn skin) + +### Fixed + +- **PR #2801** by @ai-ag2026 — Preserve settled tool cards across stream completion. The streaming `done` handler now derives anchored settled tool cards from message-level tool metadata (`message.tool_calls`, `message._partial_tool_calls`, or `content[].type === 'tool_use'`) when present, instead of unconditionally falling back to session-level `d.session.tool_calls`. The fallback could overwrite the per-message anchors after pagination/windowing because session-level coordinates may not line up with the active message array, causing tool cards to disappear on the final `done` render. Fixes #2613, complements #2777 (which covers pending-segment flushes at tool/interim boundaries). Adds `tests/test_streaming_markdown.py::test_done_handler_prefers_message_tool_metadata_for_settled_render` to lock the precedence. + +- **PR #2808** by @chouzz — Recover deterministically from boot-time `/session/{id}` 404s (Option A for #2798). When `loadSession()` hits a 404 during boot-time restore (`!currentSid`), `static/sessions.js` now always clears `localStorage['hermes-webui-session']`, strips the stale URL with `history.replaceState(null, '', '/')`, and rethrows so boot falls through to empty-state recovery. The previous condition required the stale id to match `localStorage`, so a stale `/session/{id}` URL with empty `localStorage` (post state-reset) could leave the UI stuck on "Session not available in web UI." Fixes #2798. + +### Added + +- **PR #2799** by @gavinssr — Add Hepburn skin (magenta-rose palette derived from the Hepburn TUI theme). Full light + dark palette under `:root[data-skin="hepburn"]` / `:root.dark[data-skin="hepburn"]`, registered in `static/boot.js` `_SKINS` and whitelisted in `static/index.html`'s inline skin gate. As part of this PR `loadSettingsPanel()` in `static/panels.js` now prefers `localStorage.getItem('hermes-skin')` over `settings.skin` when populating the skin picker (DOM truth → settings fallback), so the picker matches what the user actually sees after the inline gate has already resolved legacy aliases. + ## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored) ### Fixed @@ -31,7 +249,6 @@ - **PR #2731** by @Michaelyklam — Clarification prompts now include a compact Collapse/Expand control so users can temporarily shrink a blocking decision card and reread the chat context behind it before responding. The toggle uses Lucide chevron icons (chevron-down expanded → click to collapse, chevron-up collapsed → click to expand) and a small circular pill matching the existing composer-button design language. The collapsed card sits cleanly above the composer at every tested viewport (desktop 1920×1080, mobile iPhone 14 390×844) without edge clipping. New clarification prompts still open expanded so users notice them. - ## [v0.51.114] — 2026-05-22 — Release CL (stage-407 — 1-PR — update-check recovery from remote re-tags) ### Fixed @@ -237,7 +454,6 @@ PR #2636 went through the full multi-viewport screenshot gate (390 mobile, 1280 - **PR #2521** by @intellectronica — Add the Geist Contrast skin to the appearance picker. New light + dark variant pair with a high-contrast yellow-on-black accent and Geist editorial typography. Default unchanged — opt-in via Settings → Appearance → Skin → Geist Contrast. Slash command `/theme geist-contrast` now resolves correctly because the lookup matches against `skin.value` rather than `skin.name`. Documented in `THEMES.md` with a forward-compatible skin count (no hard-coded value). - **PR #2524** by @AJV20 — Add non-sensitive SSE stream runtime diagnostics to deep health checks (`/health?deep=1`), including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. Read-only telemetry; existing surfaces unchanged. - ## [v0.51.94] — 2026-05-19 — Release BR (stage-387 — 10-PR full sweep batch — Slice 4b runner adapter facade + folder zip download + partial recovery marker dedupe + browser api() client-side timeout + auto-compression card rotation finish + composer draft rollback fix + metadata count reconciliation + active-session refresh on external sidecar updates + indexed context metadata + gateway-queues approval peek) ### Fixed @@ -4082,6 +4298,11 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co - **`/reasoning` toast aligned with BRAIN prefix** — success toast now reads `🧠 Reasoning effort: ` consistent with the command's other toasts. (`static/commands.js`) [#939] - **Bootstrap Python discovery finds `.venv/` layout** — `discover_launcher_python` now checks both `venv/` and `.venv/` inside the agent directory, covering installations that use a leading-dot venv layout. (`bootstrap.py`) [#941] +## v0.50.185 — 2026-04-24 + +### Fixed +- **`/btw` `stream_end` now sets `_streamDone`** — defense-in-depth improvement per Opus code review: the `stream_end` SSE handler now sets `_streamDone=true` before closing the connection, guarding against any server flow that emits `stream_end` without a preceding `done`/`apperror` event. (`static/messages.js`) + ## v0.50.184 — 2026-04-24 ### Fixed diff --git a/README.md b/README.md index f0074e2b..ccaca241 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,14 @@ The bootstrap will: > Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2. > For Windows / WSL auto-start at login, see [`docs/wsl-autostart.md`](docs/wsl-autostart.md). -> A community-maintained native Windows guide is tracked in [#1952](https://github.com/nesquena/hermes-webui/issues/1952). + +A community-maintained native Windows setup is documented at [@markwang2658/hermes-windows-native-guide](https://github.com/markwang2658/hermes-windows-native-guide) (companion setup repo: [@markwang2658/hermes-windows-native](https://github.com/markwang2658/hermes-windows-native)). Notes from the community report in [#1952](https://github.com/nesquena/hermes-webui/issues/1952): + +- **Memory:** community-measured ~330 MB native vs ~1080 MB with WSL2+Docker (varies by configuration). +- **What works:** chat, workspace browser, session management, all themes. +- **Known limitations:** some POSIX-style file paths surface in the workspace browser; bash-assuming agent tools may not work natively. +- **Native Windows setup:** install Python 3.11+, then from the hermes-agent root in PowerShell: `python -m venv venv` → `pip install -r requirements.txt` → `pwsh .\start.ps1` (it auto-discovers `venv\Scripts\python.exe`). +- **WSL2 relationship:** not a prerequisite — a WSL2-built venv (`venv/bin/python`, ELF) isn't invokable by native Windows Python, so use the native setup above. WSL2 stays useful as a parallel install if you want the full `bootstrap.py` + Linux runtime. If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser. For a step-by-step walkthrough of the wizard, provider choices, local model server Base URLs, and safe re-runs, see [`docs/onboarding.md`](docs/onboarding.md). diff --git a/api/auth.py b/api/auth.py index df5f0e4c..5a49516f 100644 --- a/api/auth.py +++ b/api/auth.py @@ -435,10 +435,12 @@ def check_auth(handler, parsed) -> bool: return True # Not authorized if parsed.path.startswith('/api/'): + body = b'{"error":"Authentication required"}' handler.send_response(401) handler.send_header('Content-Type', 'application/json') + handler.send_header('Content-Length', str(len(body))) handler.end_headers() - handler.wfile.write(b'{"error":"Authentication required"}') + handler.wfile.write(body) else: handler.send_response(302) # Pass the original path as ?next= so login.js redirects back after auth. @@ -468,6 +470,7 @@ def check_auth(handler, parsed) -> bool: # `?`, `&`, `=`) gets percent-encoded. _next = _urlparse.quote(_path_with_query, safe='/') handler.send_header('Location', 'login?next=' + _next) + handler.send_header('Content-Length', '0') handler.end_headers() return False diff --git a/api/compression_anchor.py b/api/compression_anchor.py index f251851c..12d96415 100644 --- a/api/compression_anchor.py +++ b/api/compression_anchor.py @@ -53,7 +53,7 @@ def _content_has_part_type(content, part_types): ) -def _is_context_compression_marker(message): +def is_context_compression_marker(message): """Return true for synthetic compression/reference cards, not user turns.""" if not isinstance(message, dict): return False @@ -71,6 +71,11 @@ def _is_context_compression_marker(message): ) +def _is_context_compression_marker(message): + """Backward-compatible alias for callers that have not switched yet.""" + return is_context_compression_marker(message) + + def visible_messages_for_anchor(messages, *, auto_compression: bool = False): """Return transcript messages that can anchor compression UI metadata. diff --git a/api/config.py b/api/config.py index 54c2bce5..66bcb570 100644 --- a/api/config.py +++ b/api/config.py @@ -713,6 +713,7 @@ _PROVIDER_DISPLAY = { "x-ai": "xAI", "nvidia": "NVIDIA NIM", "xiaomi": "Xiaomi", + "bedrock": "AWS Bedrock", } # Provider alias → canonical slug. Users configure providers using the @@ -1213,6 +1214,16 @@ _PROVIDER_MODELS = { "xai-oauth": [ {"id": "grok-4.20", "label": "Grok 4.20"}, ], + # AWS Bedrock — static fallback list; live model list is fetched via + # hermes_cli.models.provider_model_ids("bedrock") when available (#2720). + "bedrock": [ + {"id": "global.anthropic.claude-opus-4-7", "label": "Global Anthropic Claude Opus 4.7"}, + {"id": "global.anthropic.claude-opus-4-6-v1", "label": "Global Anthropic Claude Opus 4.6"}, + {"id": "global.anthropic.claude-sonnet-4-6", "label": "Global Anthropic Claude Sonnet 4.6"}, + {"id": "global.anthropic.claude-opus-4-5-20251101-v1:0", "label": "GLOBAL Anthropic Claude Opus 4.5"}, + {"id": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", "label": "Global Claude Sonnet 4.5"}, + {"id": "global.anthropic.claude-haiku-4-5-20251001-v1:0", "label": "Global Anthropic Claude Haiku 4.5"}, + ], } @@ -2174,6 +2185,112 @@ def set_hermes_default_model(model_id: str) -> dict: return {"ok": True, "model": persisted_model} +# ── Auxiliary model configuration ────────────────────────────────────────── + +# Canonical auxiliary task slots. Keep in sync with hermes_cli/config.py +# DEFAULT_CONFIG["auxiliary"] and hermes_cli/web_server.py _AUX_TASK_SLOTS. +AUX_TASK_SLOTS: tuple[str, ...] = ( + "vision", + "web_extract", + "compression", + "session_search", + "skills_hub", + "approval", + "mcp", + "title_generation", + "curator", +) + + +def get_auxiliary_models() -> dict: + """Return current auxiliary task assignments from config.yaml. + + Shape: + { + "tasks": [ + {"task": "vision", "provider": "auto", "model": "", "base_url": ""}, + ... + ], + "main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"}, + } + """ + reload_config() + model_cfg = cfg.get("model", {}) + if not isinstance(model_cfg, dict): + model_cfg = {} + main_provider = str(model_cfg.get("provider") or "").strip() + main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip() + + aux_cfg = cfg.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + + tasks = [] + for slot in AUX_TASK_SLOTS: + entry = aux_cfg.get(slot, {}) + if not isinstance(entry, dict): + entry = {} + tasks.append({ + "task": slot, + "provider": str(entry.get("provider") or "auto").strip(), + "model": str(entry.get("model") or "").strip(), + "base_url": str(entry.get("base_url") or "").strip(), + }) + + return { + "tasks": tasks, + "main": {"provider": main_provider, "model": main_model}, + } + + +def set_auxiliary_model(task: str, provider: str, model: str) -> dict: + """Persist an auxiliary model assignment in config.yaml. + + Special case: task='__reset__' clears all auxiliary slots. + """ + config_path = _get_config_path() + with _cfg_lock: + config_data = _load_yaml_config_file(config_path) + + if task == "__reset__": + # Per-slot reset: set each slot to auto, preserving extra fields + # (timeout, extra_body, api_key, base_url, download_timeout, etc.) + aux_cfg = config_data.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + for slot in AUX_TASK_SLOTS: + slot_cfg = aux_cfg.get(slot, {}) + if not isinstance(slot_cfg, dict): + slot_cfg = {} + slot_cfg["provider"] = "auto" + slot_cfg["model"] = "" + aux_cfg[slot] = slot_cfg + config_data["auxiliary"] = aux_cfg + else: + aux_cfg = config_data.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + slot_cfg = aux_cfg.get(task, {}) + if not isinstance(slot_cfg, dict): + slot_cfg = {} + slot_cfg["provider"] = provider or "auto" + slot_cfg["model"] = model or "" + if provider and (provider.startswith("custom:") or provider == "custom"): + try: + _, _, resolved_base_url = resolve_model_provider(model) + if resolved_base_url: + slot_cfg["base_url"] = str(resolved_base_url).strip().rstrip("/") + except Exception: + pass + aux_cfg[task] = slot_cfg + config_data["auxiliary"] = aux_cfg + + _save_yaml_config_file(config_path, config_data) + + reload_config() + return {"ok": True, "task": task, "provider": provider, "model": model} + + # ── TTL cache for get_available_models() ───────────────────────────────────── _available_models_cache: dict | None = None _available_models_cache_ts: float = 0.0 @@ -3007,6 +3124,8 @@ def get_available_models() -> dict: "MINIMAX_CN_API_KEY", "XAI_API_KEY", "MISTRAL_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", ): val = os.getenv(k) if val: @@ -3046,6 +3165,10 @@ def get_available_models() -> dict: detected_providers.add("opencode-zen") if all_env.get("OPENCODE_GO_API_KEY"): detected_providers.add("opencode-go") + # AWS Bedrock uses IAM credentials rather than a single API key. + # Detect when both access key and secret are available (#2720). + if all_env.get("AWS_ACCESS_KEY_ID") and all_env.get("AWS_SECRET_ACCESS_KEY"): + detected_providers.add("bedrock") # LM Studio: detect via LM_API_KEY + LM_BASE_URL in ~/.hermes/.env if all_env.get("LM_API_KEY") and all_env.get("LM_BASE_URL"): detected_providers.add("lmstudio") @@ -4348,6 +4471,7 @@ _SETTINGS_DEFAULTS = { "show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments "sync_to_insights": False, # mirror WebUI token usage to state.db for /insights "check_for_updates": True, # check if webui/agent repos are behind upstream + "ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks "whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links "theme": "dark", # light | dark | system "skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous @@ -4507,6 +4631,7 @@ _SETTINGS_BOOL_KEYS = { "show_previous_messaging_sessions", "sync_to_insights", "check_for_updates", + "ignore_agent_updates", "whats_new_summary_enabled", "sound_enabled", "rtl", diff --git a/api/kanban_bridge.py b/api/kanban_bridge.py index 63bef9cd..f0d5d261 100644 --- a/api/kanban_bridge.py +++ b/api/kanban_bridge.py @@ -1022,7 +1022,7 @@ def _handle_events_sse_stream(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() # Send an initial frame so the client knows the connection is open diff --git a/api/models.py b/api/models.py index 652bde3f..3c8d895d 100644 --- a/api/models.py +++ b/api/models.py @@ -1723,6 +1723,89 @@ def _repair_stale_pending(session) -> bool: return False +def _last_non_tool_role(messages) -> str: + if not isinstance(messages, list): + return '' + for message in reversed(messages): + role = _message_role(message) + if role and role != 'tool': + return role + return '' + + +def _last_non_tool_message(messages): + if not isinstance(messages, list): + return None + for message in reversed(messages): + role = _message_role(message) + if role and role != 'tool': + return message + return None + + +def _message_content_text(message) -> str: + if not isinstance(message, dict): + return '' + content = message.get('content') + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and isinstance(item.get('text'), str): + parts.append(item['text']) + return ''.join(parts) + return '' + + +def _inactive_cache_tail_needs_disk_check(cached) -> bool: + if cached is None: + return False + if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None): + return False + return _last_non_tool_role(getattr(cached, 'messages', None) or []) == 'user' + + +def _cache_has_stale_unsaved_user_tail(cached, disk_session) -> bool: + """Return True when an inactive cached session has an unsaved user tail. + + A completed turn is saved to the sidecar before the browser reloads it. In + rare compaction/reconnect paths the in-process cache can retain a recovered + or optimistic user row after the saved assistant tail even though the row was + never persisted. If /api/session serves that cache entry, the visible + transcript appears to end on the old prompt and the saved assistant answer + looks missing until a fork/reload resets the cache. + """ + if cached is None or disk_session is None: + return False + if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None): + return False + cached_messages = getattr(cached, 'messages', None) or [] + disk_messages = getattr(disk_session, 'messages', None) or [] + if len(cached_messages) <= len(disk_messages): + return False + if _last_non_tool_role(cached_messages) != 'user': + return False + if _last_non_tool_role(disk_messages) != 'assistant': + return False + + cached_tail = _last_non_tool_message(cached_messages) + previous_disk_user = None + for message in reversed(disk_messages): + if _message_role(message) == 'user': + previous_disk_user = message + break + if previous_disk_user is None: + return False + + # Only drop tails that look like a duplicated optimistic/recovered user row. + # A genuinely new concurrent user edit must stay in memory so stale-session + # guards can report and preserve it. + return _message_content_text(cached_tail) == _message_content_text(previous_disk_user) + + def get_session(sid, metadata_only=False): """Load a session, optionally with metadata only (skipping the messages array). @@ -1736,6 +1819,19 @@ def get_session(sid, metadata_only=False): if cached is not None: SESSIONS.move_to_end(sid) # LRU: mark as recently used if cached is not None: + if not metadata_only and _inactive_cache_tail_needs_disk_check(cached): + try: + disk_session = Session.load(sid) + if _cache_has_stale_unsaved_user_tail(cached, disk_session): + with LOCK: + SESSIONS[sid] = disk_session + SESSIONS.move_to_end(sid) + cached = disk_session + except Exception: + logger.debug( + "stale cached user-tail check failed for session %s", + sid, exc_info=True, + ) if not metadata_only and _session_has_pending_journal_retry(cached): try: _try_retry_journal_recovery_in_place(cached) @@ -2057,9 +2153,27 @@ def all_sessions(diag=None): _diag_stage(diag, "all_sessions.prune_index") with LOCK: in_memory_ids = set(SESSIONS.keys()) + try: + persisted_ids = { + p.stem + for p in SESSION_DIR.glob('*.json') + if not p.name.startswith('_') + } + except Exception: + persisted_ids = None index = [ s for s in index - if _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids) + if ( + str(s.get('session_id') or '') in in_memory_ids + or ( + persisted_ids is not None + and str(s.get('session_id') or '') in persisted_ids + ) + or ( + persisted_ids is None + and _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids) + ) + ) ] backfilled = [] for i, s in enumerate(index): @@ -2815,21 +2929,28 @@ def _json_loads_if_string(value): return value -def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> list: - """Read messages for a Hermes session from the active profile's state.db. +def get_state_db_session_messages(sid, *, stitch_continuations: bool = False, profile=None) -> list: + """Read messages for a Hermes session from state.db. - This generic reader intentionally works for any session source, including - WebUI-origin sessions that were later updated through another Hermes surface - such as the Gateway API Server. When ``stitch_continuations`` is true it - preserves the historical CLI/external-agent behavior of walking compatible - compression/close parent segments before reading messages. + When *profile* is supplied, reads from that profile's state.db; otherwise + falls back to the active profile's state.db. This generic reader works for + any session source, including WebUI-origin sessions that were later updated + through another Hermes surface such as the Gateway API Server. When + ``stitch_continuations`` is true it preserves the historical CLI/external-agent + behavior of walking compatible compression/close parent segments before reading + messages. """ try: import sqlite3 except ImportError: return [] - db_path = _active_state_db_path() + if isinstance(profile, str) and profile: + db_path = _get_profile_home(profile) / 'state.db' + if not db_path.exists(): + db_path = _active_state_db_path() + else: + db_path = _active_state_db_path() if not db_path.exists(): return [] @@ -2852,7 +2973,8 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> 'reasoning_content', 'codex_message_items', ] - selected = ['role', 'content', 'timestamp'] + [c for c in optional if c in available] + id_col = ['id'] if 'id' in available else [] + selected = id_col + ['role', 'content', 'timestamp'] + [c for c in optional if c in available] session_chain = [str(sid)] if stitch_continuations: @@ -2928,55 +3050,6 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> return msgs -def get_state_db_session_summary(sid) -> dict: - """Return cheap message count/max timestamp for one state.db session. - - This is intentionally narrower than ``get_state_db_session_messages`` for - metadata-only WebUI polling: callers only need a staleness signal, not a - fully materialized transcript with tool/reasoning metadata. - """ - import os - try: - import sqlite3 - except ImportError: - return {} - - db_path = _active_state_db_path() - if not sid or not db_path.exists(): - return {} - - try: - with closing(sqlite3.connect(str(db_path))) as conn: - conn.row_factory = sqlite3.Row - cur = conn.cursor() - cur.execute("PRAGMA table_info(messages)") - available = {str(row['name']) for row in cur.fetchall()} - if not {'session_id', 'timestamp'}.issubset(available): - return {} - cur.execute( - """ - SELECT COUNT(*) AS message_count, MAX(timestamp) AS last_message_at - FROM messages - WHERE session_id = ? - """, - (str(sid),), - ) - row = cur.fetchone() - if not row: - return {} - count = int(row['message_count'] or 0) - last_message_at = row['last_message_at'] - result = {'message_count': count} - if last_message_at not in (None, ''): - try: - result['last_message_at'] = float(last_message_at) - except (TypeError, ValueError): - pass - return result - except Exception: - return {} - - def _normalized_message_timestamp_for_key(value): if value is None or value == "": return "" diff --git a/api/routes.py b/api/routes.py index bb083ae2..b2aef7f7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6,6 +6,7 @@ Extracted from server.py (Sprint 11) so server.py is a thin shell. import html as _html import copy import io +import gzip import json import logging import os @@ -76,6 +77,12 @@ _CSP_REPORT_RATE_LIMIT_MAX = 100 _CSP_REPORT_MAX_BODY_BYTES = 64 * 1024 +def _session_field(session, field, default=None): + if isinstance(session, dict): + return session.get(field, default) + return getattr(session, field, default) + + # ── Profile-scoped session/project filtering (#1611, #1614) ──────────────── # # Sessions and projects are stored in the WebUI sidecar without per-row @@ -1259,8 +1266,37 @@ def _csrf_exempt_path(path: str) -> bool: return path in {"/api/auth/login", "/api/csp-report"} +_CSRF_FAILURE_ATTR = "_hermes_csrf_failure_reason" + + +def _set_csrf_failure_reason(handler, reason: str) -> bool: + try: + setattr(handler, _CSRF_FAILURE_ATTR, reason) + except Exception: + pass + return False + + +def _clear_csrf_failure_reason(handler) -> None: + try: + if hasattr(handler, _CSRF_FAILURE_ATTR): + delattr(handler, _CSRF_FAILURE_ATTR) + except Exception: + pass + + +def _csrf_rejection_error(handler) -> str: + reason = getattr(handler, _CSRF_FAILURE_ATTR, "") + if reason == "origin_mismatch": + return "Cross-origin mismatch - check reverse proxy headers" + if reason == "token_mismatch": + return "Session expired - reload the page" + return "Cross-origin request rejected" + + def _check_csrf(handler) -> bool: """Reject cross-origin or tokenless authenticated browser unsafe requests.""" + _clear_csrf_failure_reason(handler) origin = handler.headers.get("Origin", "") referer = handler.headers.get("Referer", "") host = handler.headers.get("Host", "") @@ -1270,7 +1306,7 @@ def _check_csrf(handler) -> bool: # Extract host:port from origin/referer m = _re.match(r"^https?://([^/]+)", target) if not m: - return False + return _set_csrf_failure_reason(handler, "origin_mismatch") origin_host = m.group(1) origin_scheme = m.group(0).split('://')[0].lower() # 'http' or 'https' origin_name, origin_port = _normalize_host_port(origin_host) @@ -1298,7 +1334,7 @@ def _check_csrf(handler) -> bool: origin_allowed = True break if not origin_allowed: - return False + return _set_csrf_failure_reason(handler, "origin_mismatch") from api.auth import CSRF_HEADER_NAME, is_auth_enabled, parse_cookie, verify_csrf_token @@ -1306,7 +1342,9 @@ def _check_csrf(handler) -> bool: return True cookie_val = parse_cookie(handler) submitted = handler.headers.get(CSRF_HEADER_NAME) or handler.headers.get("X-CSRF-Token") - return verify_csrf_token(cookie_val or "", submitted or "") + if verify_csrf_token(cookie_val or "", submitted or ""): + return True + return _set_csrf_failure_reason(handler, "token_mismatch") def _client_ip_for_rate_limit(handler) -> str: @@ -2004,6 +2042,37 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list: return sidecar_messages +def _message_summary(messages) -> dict: + messages = list(messages or []) + last_message_at = 0.0 + for msg in messages: + if not isinstance(msg, dict): + continue + try: + last_message_at = max(last_message_at, float(msg.get("timestamp") or 0)) + except (TypeError, ValueError): + pass + return {"message_count": len(messages), "last_message_at": last_message_at} + + +def _metadata_only_message_summary(sid: str, profile: str | None = None) -> dict: + """Return the reconciled message summary used by metadata-only session loads. + + Threads ``profile=`` through to ``get_state_db_session_messages`` so + background-thread reads land on the correct profile's state.db (per the + cookie-bound profile selector — fixes the same TLS-vs-thread race the + #2762 fix addressed for write paths). + """ + sidecar_session = Session.load(sid) + sidecar_messages = [] + if sidecar_session: + sidecar_messages = getattr(sidecar_session, "messages", []) or [] + state_db_messages = get_state_db_session_messages(sid, profile=profile) + return _message_summary( + merge_session_messages_append_only(sidecar_messages, state_db_messages) + ) + + def _session_requires_cli_metadata_lookup(session) -> bool: """Return True when a sidecar/session row still needs CLI metadata. @@ -2529,6 +2598,15 @@ _LOGIN_LOCALE = { "invalid_pw": "\ube44\ubc00\ubc88\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", "conn_failed": "\uc5f0\uacb0 \uc2e4\ud328", }, + "tr": { + "lang": "tr-TR", + "title": "Oturum a\u00e7", + "subtitle": "Devam etmek i\u00e7in \u015fifrenizi girin", + "placeholder": "\u015eifre", + "btn": "Oturum a\u00e7", + "invalid_pw": "Ge\u00e7ersiz \u015fifre", + "conn_failed": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z", + }, } @@ -3609,6 +3687,11 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/models/live": return _handle_live_models(handler, parsed) + # ── Auxiliary models (GET/POST) ── + if parsed.path == "/api/model/auxiliary": + from api.config import get_auxiliary_models + return j(handler, get_auxiliary_models()) + if parsed.path == "/api/dashboard/status": from api import dashboard_probe @@ -3751,24 +3834,19 @@ def handle_get(handler, parsed) -> bool: is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta) cli_messages = [] state_db_messages = [] - sidecar_metadata_messages = None + metadata_summary = None + _session_profile = getattr(s, 'profile', None) or None if is_messaging_session: cli_messages = get_cli_session_messages(sid) elif load_messages: - state_db_messages = get_state_db_session_messages(sid) + state_db_messages = get_state_db_session_messages(sid, profile=_session_profile) elif not is_messaging_session: # Metadata-only callers still need the same append-only - # reconciliation contract as full loads. A raw state.db summary - # can count stale rows that the merge intentionally filters out, - # which makes sidebar polling think the transcript is always - # newer than the loaded conversation. - state_db_messages = get_state_db_session_messages(sid) - sidecar_metadata_session = Session.load(sid) - sidecar_metadata_messages = ( - getattr(sidecar_metadata_session, "messages", []) or [] - if sidecar_metadata_session - else [] - ) + # reconciliation contract as full loads so stale/replayed + # state.db rows do not make sidebar polling think the + # transcript is always newer. Helper threads profile= to + # honor #2827's TLS-vs-thread fix. + metadata_summary = _metadata_only_message_summary(sid, profile=_session_profile) _t2 = _time.monotonic() effective_model = ( _resolve_effective_session_model_for_display(s) @@ -3798,12 +3876,16 @@ def handle_get(handler, parsed) -> bool: sidecar_messages = getattr(s, "messages", []) or [] _all_msgs = merge_session_messages_append_only(cli_messages, sidecar_messages) else: - _metadata_sidecar = sidecar_metadata_messages - if _metadata_sidecar is None: - _metadata_sidecar = getattr(s, "messages", []) or [] - _all_msgs = merge_session_messages_append_only(_metadata_sidecar, state_db_messages) + if metadata_summary is None: + metadata_summary = _message_summary(getattr(s, "messages", []) or []) + _summary_message_count = metadata_summary["message_count"] + _summary_last_message_at = metadata_summary["last_message_at"] + _all_msgs = [] if not load_messages: - _summary_message_count = len(_all_msgs) + if metadata_summary is None: + metadata_summary = _message_summary(_all_msgs) + _summary_message_count = metadata_summary["message_count"] + _summary_last_message_at = metadata_summary["last_message_at"] if _summary_message_count == 0: # Legacy session with no loaded sidecar and no state.db summary — # fall back to the persisted metadata count from session JSON. @@ -3816,14 +3898,6 @@ def handle_get(handler, parsed) -> bool: _summary_message_count = max(0, int(metadata_count)) except (TypeError, ValueError): pass - try: - _summary_last_message_at = max( - float((m or {}).get("timestamp") or 0) - for m in _all_msgs - if isinstance(m, dict) - ) if _all_msgs else 0 - except (TypeError, ValueError): - _summary_last_message_at = 0 else: _summary_message_count = None _summary_last_message_at = None @@ -3926,6 +4000,13 @@ def handle_get(handler, parsed) -> bool: ) if cli_meta and _is_messaging_session_record(cli_meta): raw = _merge_cli_sidebar_metadata(raw, cli_meta) + # ``message_count`` in /api/session is the display coordinate + # space used for pagination and the header badge. Messaging + # state.db metadata can include raw duplicate transport rows that + # _merged_session_messages_for_display() intentionally dedupes; + # keep the raw count available as ``actual_message_count`` but + # do not let it make the frontend expect phantom messages. + raw["message_count"] = _merged_message_count # Signal to the frontend that older messages were omitted. # For msg_before paging, compare against the filtered set, # not the full list — otherwise we signal truncation even when @@ -4251,6 +4332,7 @@ def handle_get(handler, parsed) -> bool: settings = load_settings() if not settings.get("check_for_updates", True): return j(handler, {"disabled": True}) + include_agent_updates = not bool(settings.get("ignore_agent_updates")) qs = parse_qs(parsed.query) force = qs.get("force", ["0"])[0] == "1" # ?simulate=1 returns fake behind counts for UI testing (localhost only) @@ -4272,7 +4354,8 @@ def handle_get(handler, parsed) -> bool: }, "agent": { "name": "agent", - "behind": 1, + "behind": 1 if include_agent_updates else 0, + "ignored": not include_agent_updates, "current_sha": "aaa0001", "latest_sha": "bbb0002", "branch": "master", @@ -4284,7 +4367,7 @@ def handle_get(handler, parsed) -> bool: ) from api.updates import check_for_updates - return j(handler, check_for_updates(force=force)) + return j(handler, check_for_updates(force=force, include_agent=include_agent_updates)) if parsed.path == "/api/chat/stream/status": stream_id = parse_qs(parsed.query).get("stream_id", [""])[0] @@ -4612,7 +4695,7 @@ def handle_post(handler, parsed) -> bool: diag.stage("csrf") if not _csrf_exempt_path(parsed.path) and not _check_csrf(handler): try: - return j(handler, {"error": "Cross-origin request rejected"}, status=403) + return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) finally: if diag: diag.finish() @@ -4786,6 +4869,25 @@ def handle_post(handler, parsed) -> bool: except RuntimeError as e: return bad(handler, str(e), 500) + # ── Auxiliary model set (POST) ── + if parsed.path == "/api/model/set": + scope = str(body.get("scope") or "").strip() + task = str(body.get("task") or "").strip() + provider = str(body.get("provider") or "auto").strip() + model = str(body.get("model") or "").strip() + if scope == "auxiliary": + from api.config import set_auxiliary_model + try: + return j(handler, set_auxiliary_model(task, provider, model)) + except Exception as exc: + return bad(handler, str(exc), status=400) + if scope == "main": + try: + return j(handler, set_hermes_default_model(model)) + except ValueError as exc: + return bad(handler, str(exc), status=400) + return bad(handler, f"unknown scope: {scope}", status=400) + # ── Providers (POST) ── if parsed.path == "/api/providers": provider_id = (body.get("provider") or "").strip().lower() @@ -5454,6 +5556,9 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/file/path": return _handle_file_path(handler, body) + if parsed.path == "/api/file/open-vscode": + return _handle_file_open_vscode(handler, body) + # ── Workspace management (POST) ── if parsed.path == "/api/workspaces/add": return _handle_workspace_add(handler, body) @@ -5769,8 +5874,8 @@ def handle_post(handler, parsed) -> bool: # Pre-snapshot from persisted index (acquires LOCK internally, # so must run outside our own LOCK acquire below). persisted_pinned_ids = { - getattr(existing, "session_id", None) for existing in all_sessions() - if getattr(existing, "pinned", False) and not getattr(existing, "archived", False) + _session_field(existing, "session_id", None) for existing in all_sessions() + if _session_field(existing, "pinned", False) and not _session_field(existing, "archived", False) } with LOCK: # Final authoritative count: merge persisted-pinned with the @@ -6142,13 +6247,15 @@ def handle_post(handler, parsed) -> bool: _record_login_attempt(client_ip) return bad(handler, "Invalid password", 401) cookie_val = create_session() + body = json.dumps({"ok": True}).encode() handler.send_response(200) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") _security_headers(handler) set_auth_cookie(handler, cookie_val) handler.end_headers() - handler.wfile.write(json.dumps({"ok": True}).encode()) + handler.wfile.write(body) return True if parsed.path == "/api/auth/logout": @@ -6157,13 +6264,15 @@ def handle_post(handler, parsed) -> bool: cookie_val = parse_cookie(handler) if cookie_val: invalidate_session(cookie_val) + body = json.dumps({"ok": True}).encode() handler.send_response(200) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") _security_headers(handler) clear_auth_cookie(handler) handler.end_headers() - handler.wfile.write(json.dumps({"ok": True}).encode()) + handler.wfile.write(body) return True # ── Checkpoints / Rollback (POST) ── @@ -6189,8 +6298,11 @@ def handle_post(handler, parsed) -> bool: def handle_patch(handler, parsed) -> bool: """Handle all PATCH routes. Returns True if handled, False for 404.""" if not _check_csrf(handler): - return j(handler, {"error": "Cross-origin request rejected"}, status=403) + return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_toggle(handler, name, body) if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_patch @@ -6204,8 +6316,11 @@ def handle_patch(handler, parsed) -> bool: def handle_delete(handler, parsed) -> bool: """Handle all DELETE routes. Returns True if handled, False for 404.""" if not _check_csrf(handler): - return j(handler, {"error": "Cross-origin request rejected"}, status=403) + return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_delete(handler, name) if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_delete @@ -6215,6 +6330,17 @@ def handle_delete(handler, parsed) -> bool: return True return False + +def handle_put(handler, parsed) -> bool: + """Handle all PUT routes. Returns True if handled, False for 404.""" + if not _check_csrf(handler): + return j(handler, {"error": "Cross-origin request rejected"}, status=403) + body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_update(handler, name, body) + return False + # ── GET route helpers ───────────────────────────────────────────────────────── # MIME types for static file serving. Hoisted to module scope to avoid @@ -6236,6 +6362,20 @@ _STATIC_MIME = { # MIME types that are text-based and should carry charset=utf-8 _TEXT_MIME_TYPES = {"text/css", "application/javascript", "text/html", "image/svg+xml", "text/plain"} +# MIME types worth gzipping. Image and font formats (png/jpg/webp/woff2) are +# already compressed; gzip would only add CPU and a few bytes of framing. +_COMPRESSIBLE_MIME = { + "text/css", "application/javascript", "text/html", "image/svg+xml", + "application/json", "text/plain", +} + +# In-process cache for raw bytes, compressed bytes, and ETag. The cache is keyed +# by absolute path and invalidated on (size, high-precision mtime) change, so a +# redeploy is picked up without a process restart. Missing/random paths never +# enter the cache; memory cost is bounded by the static/ tree's served files. +_STATIC_CACHE: dict = {} +_STATIC_CACHE_LOCK = threading.Lock() + def _serve_static(handler, parsed): static_root = (Path(__file__).parent.parent / "static").resolve() @@ -6251,13 +6391,63 @@ def _serve_static(handler, parsed): ext = static_file.suffix.lower() ct = _STATIC_MIME.get(ext.lstrip("."), "text/plain") ct_header = f"{ct}; charset=utf-8" if ct in _TEXT_MIME_TYPES else ct + + # Look up or populate the per-file cache (raw, optional gzip, ETag). + # Keyed by absolute path; invalidated by (size, nanosecond mtime). + st = static_file.stat() + sig = (st.st_size, st.st_mtime_ns) + cache_key = str(static_file) + raw = gz = etag = None + with _STATIC_CACHE_LOCK: + cached = _STATIC_CACHE.get(cache_key) + if cached and cached[0] == sig: + _, raw, gz, etag = cached + if raw is None: + raw = static_file.read_bytes() + # Weak ETag: equality semantics, derived from filesystem identity. + etag = f'W/"{sig[0]:x}-{sig[1]:x}"' + gz = (gzip.compress(raw, compresslevel=6) + if ct in _COMPRESSIBLE_MIME and len(raw) > 1024 + else None) + with _STATIC_CACHE_LOCK: + _STATIC_CACHE[cache_key] = (sig, raw, gz, etag) + + # The page template substitutes __WEBUI_VERSION__ at request time (see the + # `/`/`/index.html`/`/session/` branch above), and static/sw.js's + # SHELL_ASSETS list relies on the same convention. So a fingerprinted URL + # is safe to cache aggressively: any redeploy changes the URL. + version_values = parse_qs(parsed.query, keep_blank_values=True).get("v", [""]) + has_fingerprint = bool(version_values[0]) + cache_control = ( + "public, max-age=31536000, immutable" if has_fingerprint + else "public, max-age=300" + ) + + # 304 short-circuit on conditional GET. + if handler.headers.get("If-None-Match") == etag: + handler.send_response(304) + handler.send_header("ETag", etag) + handler.send_header("Cache-Control", cache_control) + if gz is not None: + handler.send_header("Vary", "Accept-Encoding") + handler.end_headers() + return True + + accept_enc = (handler.headers.get("Accept-Encoding") or "").lower() + use_gzip = gz is not None and "gzip" in accept_enc + body = gz if use_gzip else raw + handler.send_response(200) handler.send_header("Content-Type", ct_header) - handler.send_header("Cache-Control", "no-store") - raw = static_file.read_bytes() - handler.send_header("Content-Length", str(len(raw))) + handler.send_header("Content-Length", str(len(body))) + handler.send_header("ETag", etag) + handler.send_header("Cache-Control", cache_control) + if gz is not None: + handler.send_header("Vary", "Accept-Encoding") + if use_gzip: + handler.send_header("Content-Encoding", "gzip") handler.end_headers() - handler.wfile.write(raw) + handler.wfile.write(body) return True @@ -6422,7 +6612,7 @@ def _handle_sse_stream(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() try: _replay_run_journal(handler, stream_id, _parse_run_journal_after_seq(qs)) @@ -6434,7 +6624,7 @@ def _handle_sse_stream(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() try: while True: @@ -6565,7 +6755,7 @@ def _handle_terminal_output(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() try: while True: @@ -6643,7 +6833,7 @@ def _handle_gateway_sse_stream(handler, parsed): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() q = watcher.subscribe() @@ -6676,7 +6866,7 @@ def _handle_session_events_stream(handler): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() q = subscribe_session_events() @@ -6762,6 +6952,7 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_ handler.send_response(416) handler.send_header("Content-Range", f"bytes */{file_size}") handler.send_header("Accept-Ranges", "bytes") + handler.send_header("Content-Length", "0") _security_headers(handler) handler.end_headers() return True @@ -6806,6 +6997,51 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_ return True +def _html_preview_with_blank_base(raw: bytes) -> bytes: + base = '' + text = raw.decode("utf-8", errors="replace") + if re.search(r"]*)?>", text, flags=re.IGNORECASE): + text = re.sub(r"(]*>)", r"\1" + base, text, count=1, flags=re.IGNORECASE) + elif re.search(r"]*>", text, flags=re.IGNORECASE): + text = re.sub( + r"(]*>)", + r"\1" + base + "", + text, + count=1, + flags=re.IGNORECASE, + ) + else: + text = "" + base + "" + text + return text.encode("utf-8") + + +def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp: str): + """Serve sandboxed workspace HTML preview with links targeting a new tab.""" + try: + body = _html_preview_with_blank_base(target.read_bytes()) + except PermissionError: + return bad(handler, "Permission denied", 403) + except Exception: + return bad(handler, "Could not read file", 500) + + handler.send_response(200) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Accept-Ranges", "none") + handler.send_header("Cache-Control", cache_control) + handler.send_header("Content-Disposition", _content_disposition_value("inline", target.name)) + handler.send_header("Content-Security-Policy", csp) + handler.send_header("X-Content-Type-Options", "nosniff") + handler.send_header("Referrer-Policy", "same-origin") + handler.send_header( + "Permissions-Policy", + "camera=(), microphone=(self), geolocation=(), clipboard-write=(self)", + ) + handler.end_headers() + handler.wfile.write(body) + return True + + def _handle_media(handler, parsed): """Serve a local file by absolute path for inline display in the chat. @@ -6827,10 +7063,12 @@ def _handle_media(handler, parsed): if is_auth_enabled(): cv = parse_cookie(handler) if not (cv and verify_session(cv)): + body = b'{"error":"Authentication required"}' handler.send_response(401) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) handler.end_headers() - handler.wfile.write(b'{"error":"Authentication required"}') + handler.wfile.write(body) return qs = parse_qs(parsed.query) @@ -7064,6 +7302,15 @@ def _handle_folder_download(handler, parsed): _content_disposition_value("attachment", zip_name), ) handler.send_header("Cache-Control", "no-store") + # Under HTTP/1.1 (Handler.protocol_version, see server.py post-#2836) + # a response with no Content-Length and no Transfer-Encoding requires + # Connection: close so the client knows the body ends at FIN. The ZIP + # is built on-the-fly so we cannot send Content-Length up front; mirror + # the SSE-endpoint pattern #2836 uses. Without this header the client + # hangs waiting for the next pipelined response after the central + # directory bytes finish. Caught by Opus pre-release advisor on + # stage-batch11. + handler.send_header("Connection", "close") handler.end_headers() written = 0 @@ -7111,8 +7358,10 @@ def _handle_file_raw(handler, parsed): # CSP sandbox directive applies the same isolation server-side: without # allow-same-origin, the document is treated as a unique opaque origin and # cannot read WebUI cookies, localStorage, or postMessage to the parent. - csp = "sandbox allow-scripts" if html_inline_ok else None + csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" if html_inline_ok else None # _serve_file_bytes sends Content-Security-Policy when csp is set. + if html_inline_ok: + return _serve_inline_html_preview(handler, target, "no-store", csp=csp) return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp) @@ -7187,7 +7436,7 @@ def _handle_approval_sse_stream(handler, parsed): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() from api.streaming import _sse @@ -7288,7 +7537,7 @@ def _handle_clarify_sse_stream(handler, parsed): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() from api.streaming import _sse @@ -8262,6 +8511,41 @@ def _start_chat_stream_for_session( return response +def _runtime_runner_client_factory(): + """Return the runner-local client when a supervised backend exists. + + Slice 4d wires the `/api/chat/start` selection point without silently falling + back to the legacy in-process runtime when `runner-local` is explicitly + requested. The supervised runner backend itself is intentionally not created + in this helper yet; a later slice can replace this factory with the concrete + client while keeping the route contract stable. + """ + raise NotImplementedError("runner-local chat backend is not configured") + + +def _chat_start_response_from_run_start(result): + """Expose only the legacy browser-facing chat-start response fields.""" + payload = dict(getattr(result, "payload", {}) or {}) + response = {} + for key in ( + "stream_id", + "session_id", + "pending_started_at", + "turn_id", + "title", + "effective_model", + "effective_model_provider", + "error", + "active_stream_id", + "_status", + ): + if key in payload: + response[key] = payload[key] + response.setdefault("stream_id", result.stream_id) + response.setdefault("session_id", result.session_id) + return response + + def _runtime_adapter_goal_action(goal_args: str) -> str: """Return the bounded RuntimeAdapter goal action for WebUI /goal args.""" action = str(goal_args or "").strip().lower() @@ -8471,10 +8755,12 @@ def _handle_chat_start(handler, body, diag=None): from api.runtime_adapter import ( LegacyJournalRuntimeAdapter, StartRunRequest, + build_runtime_adapter, runtime_adapter_enabled, + runtime_adapter_runner_enabled, ) - if runtime_adapter_enabled(): + if runtime_adapter_enabled() or runtime_adapter_runner_enabled(): def _legacy_start_run(request: StartRunRequest) -> dict: return _start_chat_stream_for_session( s, @@ -8487,23 +8773,32 @@ def _handle_chat_start(handler, body, diag=None): diag=diag, ) - adapter = LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run) - result = adapter.start_run( - StartRunRequest( - session_id=s.session_id, - message=msg, - attachments=attachments, - workspace=workspace, - profile=getattr(s, "profile", None), - provider=model_provider, - model=model, - source="webui", - metadata={"route": "/api/chat/start"}, + def _legacy_adapter_factory(): + return LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run) + + try: + adapter = build_runtime_adapter( + legacy_adapter_factory=_legacy_adapter_factory, + runner_client_factory=_runtime_runner_client_factory, ) - ) - response = dict(result.payload) - response.setdefault("stream_id", result.stream_id) - response.setdefault("session_id", result.session_id) + if adapter is None: + raise NotImplementedError("runtime adapter selection returned no adapter") + result = adapter.start_run( + StartRunRequest( + session_id=s.session_id, + message=msg, + attachments=attachments, + workspace=workspace, + profile=getattr(s, "profile", None), + provider=model_provider, + model=model, + source="webui", + metadata={"route": "/api/chat/start"}, + ) + ) + except NotImplementedError as exc: + return j(handler, {"error": str(exc)}, status=501) + response = _chat_start_response_from_run_start(result) else: response = _start_chat_stream_for_session( s, @@ -8725,6 +9020,12 @@ def _handle_chat_sync(handler, body): model=s.model, title=s.title, message_count=len(s.messages), + # #2762 / #2827 parity with api/streaming.py:5078: pass the + # session's profile explicitly so a future refactor that + # backgrounds this handler doesn't silently leak writes to + # the wrong profile's state.db. HTTP thread today, but + # defense-in-depth. Opus pre-release advisor MUST-FIX. + profile=getattr(s, 'profile', None), ) except Exception: logger.debug("Failed to update session cost tracking") @@ -9526,6 +9827,90 @@ def _handle_file_path(handler, body): return bad(handler, _sanitize_error(e)) +def _handle_file_open_vscode(handler, body): + """Open a workspace file or folder in VS Code (#2735). + + Reads optional ``vscode`` config block from config.yaml: + + vscode: + command: code # executable on PATH; defaults to "code" + host_path_prefix: /home/user/projects # Docker host path + container_path_prefix: /app/workspace # matching container path + + If ``host_path_prefix`` and ``container_path_prefix`` are both set, + paths that begin with ``container_path_prefix`` are translated to the + host prefix before being handed to VS Code. This lets users running + Hermes WebUI inside Docker still open files in their local editor. + """ + try: + require(body, "session_id", "path") + except ValueError as e: + return bad(handler, str(e)) + try: + s = get_session(body["session_id"]) + except KeyError: + return bad(handler, "Session not found", 404) + try: + target = safe_resolve(Path(s.workspace), body["path"]) + if not target.exists(): + return bad(handler, f"File not found: {target}", 404) + + target_str = str(target) + + # Optional Docker host/container path translation + from api.config import get_config as _get_cfg # noqa: PLC0415 + vscode_cfg = _get_cfg().get("vscode", {}) + if not isinstance(vscode_cfg, dict): + vscode_cfg = {} + container_prefix = vscode_cfg.get("container_path_prefix", "") + host_prefix = vscode_cfg.get("host_path_prefix", "") + if container_prefix and host_prefix and target_str.startswith(container_prefix): + target_str = host_prefix + target_str[len(container_prefix):] + + cmd = vscode_cfg.get("command", "code") + # Resolve the command to an absolute path so subprocess.Popen finds it + # even when the server process inherits a minimal PATH (e.g. when + # launched via start.sh on macOS where /usr/local/bin may be absent). + resolved_cmd = shutil.which(cmd) + if resolved_cmd is None: + # Try common VS Code installation paths as fallback. + # macOS: /usr/local/bin/code (symlink) or app bundle CLI + # Linux: /usr/bin/code or snap + # Windows: user-install under %LOCALAPPDATA%, system-install under %PROGRAMFILES% + _local_app_data = os.environ.get("LOCALAPPDATA", "") + _prog_files = os.environ.get("PROGRAMFILES", "C:\\Program Files") + _prog_files_x86 = os.environ.get("PROGRAMFILES(X86)", "C:\\Program Files (x86)") + _vscode_fallbacks = [ + # macOS + "/usr/local/bin/code", + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + # Linux + "/usr/bin/code", + "/snap/bin/code", + # Windows (user install) + os.path.join(_local_app_data, "Programs", "Microsoft VS Code", "bin", "code.cmd"), + # Windows (system install) + os.path.join(_prog_files, "Microsoft VS Code", "bin", "code.cmd"), + os.path.join(_prog_files_x86, "Microsoft VS Code", "bin", "code.cmd"), + ] + for fb in _vscode_fallbacks: + if fb and Path(fb).exists(): + resolved_cmd = fb + break + if resolved_cmd is None: + return bad( + handler, + f"VS Code command not found: {cmd!r}. " + "Install VS Code and ensure the 'code' CLI is on PATH, " + "or set vscode.command in config.yaml to the full path.", + ) + subprocess.Popen([resolved_cmd, target_str]) + + return j(handler, {"ok": True, "path": body["path"]}) + except (ValueError, PermissionError, OSError) as e: + return bad(handler, _sanitize_error(e)) + + def _handle_workspace_add(handler, body): # Strip surrounding paired quotes BEFORE any further processing — macOS # Finder's "Copy as Pathname" wraps paths in single quotes, and users @@ -12099,7 +12484,7 @@ def _handle_mcp_servers_list(handler): ] return j(handler, { "servers": result, - "toggle_supported": False, + "toggle_supported": True, "reload_required": True, }) @@ -12123,6 +12508,30 @@ def _handle_mcp_server_delete(handler, name): return j(handler, {"ok": True, "deleted": name}) +def _handle_mcp_server_toggle(handler, name, body): + """Toggle enabled state for an MCP server (PATCH /api/mcp/servers/{name}).""" + from urllib.parse import unquote + name = unquote(name) + if not name: + return bad(handler, "name is required") + if "enabled" not in body: + return bad(handler, "enabled field is required") + enabled = bool(body["enabled"]) + cfg = get_config() + servers = cfg.get("mcp_servers", {}) + if not isinstance(servers, dict): + servers = {} + if name not in servers: + return bad(handler, f"MCP server '{name}' not found", 404) + if not isinstance(servers[name], dict): + return bad(handler, f"MCP server '{name}' has invalid config", 400) + servers[name]["enabled"] = enabled + cfg["mcp_servers"] = servers + _save_yaml_config_file(_get_config_path(), cfg) + reload_config() + return j(handler, {"ok": True, "name": name, "enabled": enabled}) + + _MASKED_PLACEHOLDER = "••••••" diff --git a/api/state_sync.py b/api/state_sync.py index fa9c9b33..dbb2873f 100644 --- a/api/state_sync.py +++ b/api/state_sync.py @@ -20,22 +20,78 @@ from pathlib import Path logger = logging.getLogger(__name__) -def _get_state_db(): - """Get a SessionDB instance for the active profile's state.db. - Returns None if hermes_state is not importable or DB is unavailable. - Each caller is responsible for calling db.close() when done. +def _get_state_db(profile: str=None): + """Get a SessionDB instance for a profile's state.db. + + When ``profile`` is provided the function resolves *that* profile's + home directory directly (via ``_resolve_profile_home_for_name``). + If resolution fails (unknown profile name, IO error, etc.) the + function returns ``None`` rather than silently falling back to + ``HERMES_HOME`` — silently routing the write to the wrong DB + would defeat the point of the explicit-profile path (#2762). + + When ``profile`` is None it falls back to the TLS-based + ``get_active_hermes_home()`` lookup for backward compatibility, + with a final ``HERMES_HOME`` fallback only on that path. TLS may be + unset in background/worker threads, in which case the lookup falls + through to the process-global active profile and can write to the + wrong DB. Callers that know the session's profile (e.g. + ``sync_session_usage`` after a stream completes on a background + thread) should pass it explicitly to avoid that race. + + Returns None if hermes_state is not importable, the explicit + profile cannot be resolved, or the DB is unavailable. Each caller + is responsible for calling db.close() when done. """ try: from hermes_state import SessionDB except ImportError: return None - try: - from api.profiles import get_active_hermes_home - hermes_home = Path(get_active_hermes_home()).expanduser().resolve() - except Exception: - logger.debug("Failed to resolve hermes home, using default") - hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes'))) + if profile is not None: + # Explicit-profile path — a resolution failure here MUST NOT + # silently fall back to HERMES_HOME or the caller's "write to + # the named profile" contract is broken (the original #2762 + # symptom: writes leaking into the wrong profile's state.db). + # + # Defense-in-depth (per #2827 maintainer review): validate the + # name shape BEFORE handing it to ``_resolve_profile_home_for_name``. + # The resolver itself rarely raises — for an invalid-but-non- + # malicious name (e.g. one that fails ``_PROFILE_ID_RE``) it + # quietly returns ``_DEFAULT_HERMES_HOME``, which is the exact + # leak we're trying to prevent on the explicit-profile path. + # Validating up-front turns that quiet leak into an explicit + # "refuse + log + return None" so the contract is "write to + # the EXACT named profile, or write nowhere." + try: + from api.profiles import ( + _resolve_profile_home_for_name, + _PROFILE_ID_RE, + _is_root_profile, + ) + if not (_is_root_profile(profile) or _PROFILE_ID_RE.fullmatch(profile)): + logger.warning( + "state_sync: refusing invalid profile name %r — skipping " + "write rather than leaking to the default state.db (#2762).", + profile, + ) + return None + hermes_home = Path(_resolve_profile_home_for_name(profile)).expanduser().resolve() + except Exception: + logger.warning( + "state_sync: could not resolve profile %r — skipping write rather " + "than leaking to the active profile (#2762).", profile, + ) + return None + else: + # Implicit / TLS-fallback path — preserves pre-#2762 behavior + # for any caller that doesn't pass profile= explicitly. + try: + from api.profiles import get_active_hermes_home + hermes_home = Path(get_active_hermes_home()).expanduser().resolve() + except Exception: + logger.debug("Failed to resolve hermes home, using default") + hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes'))) db_path = hermes_home / 'state.db' if not db_path.exists(): @@ -48,11 +104,16 @@ def _get_state_db(): return None -def sync_session_start(session_id: str, model=None) -> None: +def sync_session_start(session_id: str, model=None, profile: str=None) -> None: """Register a WebUI session in state.db (idempotent). Called when a session's first message is sent. + + ``profile`` lets the caller name the target state.db explicitly, + avoiding the TLS-vs-background-thread mismatch in #2762. When + omitted, the active profile is resolved from TLS (then process + globals) as before. """ - db = _get_state_db() + db = _get_state_db(profile=profile) if not db: return try: @@ -72,12 +133,20 @@ def sync_session_start(session_id: str, model=None) -> None: def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0, estimated_cost=None, model=None, title: str=None, - message_count: int=None) -> None: + message_count: int=None, profile: str=None) -> None: """Update token usage and title for a WebUI session in state.db. Called after each turn completes. Uses absolute=True to set totals (the WebUI Session already accumulates across turns). + + ``profile`` lets the caller name the target state.db explicitly, + which is what fixes #2762: this function is invoked from the + agent streaming worker thread, where the request-thread's TLS + profile context has not been propagated. Without an explicit + profile, the TLS lookup falls back to the process-global active + profile and writes the session's usage to the wrong state.db + (e.g. ``hiyuki``'s instead of the cookie-switched ``maiko``'s). """ - db = _get_state_db() + db = _get_state_db(profile=profile) if not db: return try: diff --git a/api/streaming.py b/api/streaming.py index 663d84d1..cd6cb960 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -35,7 +35,7 @@ from api.config import ( load_settings, ) from api.helpers import redact_session_data, _redact_text -from api.compression_anchor import visible_messages_for_anchor +from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor from api.metering import meter from api.run_journal import RunJournalWriter from api.turn_journal import append_turn_journal_event_for_stream @@ -2299,15 +2299,7 @@ def _dedupe_replayed_active_context(previous_context, result_messages): def _is_context_compression_marker(msg): - if not isinstance(msg, dict): - return False - text = _message_text(msg.get('content', '')).lower() - return ( - 'context compaction' in text - or 'context compression' in text - or 'context was auto-compressed' in text - or 'active task list was preserved across context compression' in text - ) + return is_context_compression_marker(msg) def _compact_summary_text(raw_text: str | None, limit: int = 320) -> str | None: @@ -5078,6 +5070,12 @@ def _run_agent_streaming( model=model, title=s.title, message_count=len(s.messages), + # #2762: pass the session's profile explicitly so the + # background-thread state.db lookup doesn't fall + # through to the process-global active profile and + # write to the wrong DB (TLS profile is set on the + # HTTP thread but not propagated to this worker). + profile=getattr(s, 'profile', None), ) except Exception: logger.debug("Failed to sync session to insights") diff --git a/api/terminal.py b/api/terminal.py index 5ac2c741..bb857e16 100644 --- a/api/terminal.py +++ b/api/terminal.py @@ -70,18 +70,17 @@ _TERMINALS: dict[str, TerminalSession] = {} _LOCK = threading.RLock() -def _terminal_shell_preexec_fn() -> None: - """Ask Linux to terminate the PTY shell when the WebUI parent dies.""" - try: - import ctypes - - libc = ctypes.CDLL(None) - libc.prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG=1, SIGTERM=15 - except Exception: - # Non-Linux platforms or restricted runtimes should still be able to - # open an embedded terminal; they just do not get the Linux pdeathsig - # hardening. - pass +# NOTE on parent-death-signal: a previous version of this module set +# PR_SET_PDEATHSIG via a preexec_fn to terminate orphaned PTY shells when the +# WebUI process crashed. That broke every Linux user (#2853): WebUI runs a +# ThreadingHTTPServer, so the Popen call happens on a short-lived per-request +# thread, and PR_SET_PDEATHSIG is per-thread. The PTY shell registered the +# spawning thread as its "parent" and was killed with SIGTERM the instant that +# thread joined — within ~10 ms of opening the terminal — surfacing as the +# `[terminal closed]` banner. The graceful path is covered by +# `atexit.register(close_all_terminals)` and the explicit `close_terminal` +# call sites; hard kills of the WebUI process leak the shell, which is the +# tradeoff for working on Linux at all. def _decode_terminal_output(decoder, data: bytes) -> str: @@ -193,7 +192,6 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = stdout=slave_fd, stderr=slave_fd, close_fds=True, - preexec_fn=_terminal_shell_preexec_fn, start_new_session=True, ) os.close(slave_fd) diff --git a/api/updates.py b/api/updates.py index 9f19ba89..d7793dc3 100644 --- a/api/updates.py +++ b/api/updates.py @@ -29,7 +29,7 @@ try: except ImportError: _AGENT_DIR = None -_update_cache = {'webui': None, 'agent': None, 'checked_at': 0} +_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True} _SUMMARY_CACHE_MAX = 16 _summary_cache: OrderedDict = OrderedDict() _cache_lock = threading.Lock() @@ -351,6 +351,21 @@ def _release_gap(tags, current, latest): return 1 +def _head_is_past_latest_tag(path, current_tag): + """Return True when HEAD has moved past the latest reachable release tag. + + `git describe --tags --always` returns the bare tag name (e.g. ``v2026.5.16``) + when HEAD is exactly on the tag, and a ``v2026.5.16-608-g1d22b9c2`` suffix + when HEAD has moved 608 commits past it. Used by both the update check and + the update apply path so they agree on which ref to advance to — see #2653 + (check side) and #2846 (apply side). + """ + if not current_tag: + return False + full_desc, ok = _run_git(['describe', '--tags', '--always'], path) + return bool(ok and full_desc and full_desc != current_tag) + + def _select_apply_compare_ref(path): """Return the same remote ref family that the update check reports. @@ -358,10 +373,31 @@ def _select_apply_compare_ref(path): an update must therefore advance to the latest release tag too; otherwise a checkout on a local/fork tracking branch can report release updates, pull a different branch that is already current, restart, and still remain behind. + + When HEAD is past the latest tag (the agent repo's day-to-day state between + tagged releases), the check side falls through to the branch comparison via + `_check_repo_release` returning None. The apply side must mirror that + decision — otherwise we run `git pull --ff-only ` against a + checkout that's already past the tag, no-op, restart, and the banner + re-appears with the same N commits available. See #2846. """ tags = _release_tags(path) if tags: - return tags[0] + latest_tag = tags[0] + current_tag = _current_release_tag(path) + behind = _release_gap(tags, current_tag, latest_tag) + # Mirror the check side exactly: only fall through when behind == 0 + # AND HEAD has moved past its nearest tag (case A: bench between + # tagged releases). Otherwise the tag is correct — including the + # case where HEAD is on an older release tag with commits on top + # AND a newer tag exists (case D), where `behind > 0` means the + # user is genuinely behind the latest release and should advance + # to it. Pre-#2855 the apply path only consulted `latest_tag` + # without the `behind`/`current_tag` predicate, so case D fell + # through to `origin/` and the pull landed past the + # advertised tag. See #2846 + Opus pre-release review for #2855. + if not (behind == 0 and _head_is_past_latest_tag(path, current_tag)): + return latest_tag upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path) if ok and upstream: @@ -381,6 +417,15 @@ def _check_repo_release(path, name): current_tag = _current_release_tag(path) behind = _release_gap(tags, current_tag, latest_tag) + # If behind == 0 but HEAD has moved past the tag (e.g. the agent repo + # keeps committing to master between tagged releases), the release check + # would report "Up to date" even though hundreds of commits are missing. + # Fall through to _check_repo_branch so the real commit count is reported + # instead. The same predicate is used by _select_apply_compare_ref so the + # check and apply sides cannot drift again. See #2653 (check), #2846 (apply). + if behind == 0 and _head_is_past_latest_tag(path, current_tag): + return None + remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) remote_url = _normalize_remote_url(remote_url) @@ -509,11 +554,21 @@ def _check_repo(path, name): return _check_repo_branch(path, name, fetch=False) -def check_for_updates(force=False): +def _ignored_agent_update_info() -> dict: + """Return a stable update-check payload for intentionally ignored Agent updates.""" + return {'name': 'agent', 'behind': 0, 'ignored': True} + + +def check_for_updates(force=False, *, include_agent=True): """Return cached update status for webui and agent repos.""" global _check_in_progress + include_agent = bool(include_agent) with _cache_lock: - if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL: + if ( + not force + and _update_cache.get('include_agent') == include_agent + and time.time() - _update_cache['checked_at'] < CACHE_TTL + ): return dict(_update_cache) if _check_in_progress: return dict(_update_cache) # another thread is already checking @@ -522,12 +577,13 @@ def check_for_updates(force=False): try: # Run checks outside the lock (network I/O) webui_info = _check_repo(REPO_ROOT, 'webui') - agent_info = _check_repo(_AGENT_DIR, 'agent') + agent_info = _check_repo(_AGENT_DIR, 'agent') if include_agent else _ignored_agent_update_info() with _cache_lock: _update_cache['webui'] = webui_info _update_cache['agent'] = agent_info _update_cache['checked_at'] = time.time() + _update_cache['include_agent'] = include_agent return dict(_update_cache) finally: _check_in_progress = False diff --git a/docs/docker.md b/docs/docker.md index da5bb702..faa799fa 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -46,6 +46,13 @@ That's it for a real personal Docker install. Your existing `~/.hermes` directory is mounted, your `~/workspace` is browsable, and the WebUI auto-detects your UID/GID from the mounted volume. +The single-container setup runs the WebUI only. It can create cron jobs and run +them manually from the Tasks panel. In Docker, scheduled jobs require the Hermes gateway daemon +to tick while you are away. If System Settings shows `Gateway not configured`, +use `docker-compose.two-container.yml`, +`docker-compose.three-container.yml`, or run `hermes gateway` separately before +relying on offline scheduled runs. + For troubleshooting, reinstall, or onboarding reproduction trials, do not mount your real `~/.hermes` unless you intentionally want to test real state. Use an isolated Hermes home and follow diff --git a/docs/rfcs/hermes-run-adapter-contract.md b/docs/rfcs/hermes-run-adapter-contract.md index 9732044e..71ef4da2 100644 --- a/docs/rfcs/hermes-run-adapter-contract.md +++ b/docs/rfcs/hermes-run-adapter-contract.md @@ -4,7 +4,7 @@ - **Author:** @Michaelyklam - **Updated by:** @franksong2702 - **Created:** 2026-05-11 -- **Revised:** 2026-05-21 +- **Revised:** 2026-05-23 - **Tracking issue:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925) ## Credit and Scope @@ -52,7 +52,7 @@ The immediate goal is not to build a sidecar. The immediate goal is to define th browser contract, classify current runtime state, and gate the first reversible journal slice. -## Current Gate State — 2026-05-21 +## Current Gate State — 2026-05-23 Slice 1 is now past the first active validation gate: @@ -104,11 +104,14 @@ adapter-seam work: `runner-local` adapter selection point and `build_runtime_adapter(...)` factory wiring around an injected runner client. Live browser chat routes still stay on the legacy backend, and no supervised runner process exists yet. -- The next implementation gate is a supervised/local runner backend proposal and - route-selection harness. It must stay default-off, keep legacy fallback intact, - pass explicit profile/workspace/model payloads instead of mutating WebUI - process globals, and avoid recreating `STREAMS` / `CANCEL_FLAGS` / approval - queues / clarify queues under new names. +- #2744 shipped the Slice 4d supervised runner route gate in v0.51.108. +- The next implementation slice is a default-off runner route-selection harness + for `/api/chat/start`. It should only engage when `runner-local` is explicitly + selected, return a bounded not-configured error until a supervised runner + client exists, keep `legacy-direct` / `legacy-journal` fallback intact, pass + explicit profile/workspace/model payloads instead of mutating WebUI process + globals, and avoid recreating `STREAMS` / `CANCEL_FLAGS` / approval queues / + clarify queues under new names. The next gate is runner-backend plumbing, not queue implementation by default. Queue / continue routing should only move before Slice 4 if a future @@ -843,6 +846,10 @@ Non-goals for Slice 4c: #### Slice 4d: Supervised runner backend route gate +Status as of 2026-05-23: shipped in v0.51.108 via #2744. The gate remains a +docs/test contract: it defines the default-off route-selection requirements but +does not itself route live chat to a runner backend. + After `runner-local` selection exists, the next reviewable gate should define the first supervised/local runner backend and the route-selection harness before live browser chat can use it. This is still a contract/test slice first: no default-on @@ -896,6 +903,51 @@ Non-goals for Slice 4d: - no broad UI/product surface migration; WebUI remains the rich workbench while only execution ownership moves. +#### Slice 4e: Default-off runner chat-start route-selection harness + +The first implementation after the Slice 4d gate should wire the +`/api/chat/start` selection point to the existing `RuntimeAdapter` factory +without adding a supervised runner process yet. The harness must make the +selection behavior explicit: `legacy-direct` stays default, `legacy-journal` +continues to delegate to the legacy in-process stream path, and `runner-local` +does not silently fall back to legacy when no runner client is configured. + +Scope: + +- route `/api/chat/start` through `build_runtime_adapter(...)` when an adapter + mode is explicitly selected; +- keep the successful browser response whitelisted to legacy-compatible fields + such as `stream_id`, `session_id`, `pending_started_at`, `turn_id`, `title`, + and effective model/provider metadata; +- return a bounded not-configured error for `runner-local` until a supervised + runner client/backend lands; +- pass the existing explicit `StartRunRequest` payload fields across the seam. + +Acceptance tests for Slice 4e: + +1. **Default remains legacy-direct.** With no adapter env var, `/api/chat/start` + keeps using `_start_chat_stream_for_session(...)` directly. +2. **Legacy-journal remains behavior-preserving.** The flagged legacy adapter + still delegates to the same stream-start helper and preserves the public + response shape. +3. **Runner-local does not fallback silently.** If `runner-local` is selected but + no runner client exists, the route returns a bounded error instead of starting + a WebUI-owned legacy run behind the operator's back. +4. **No adapter-internal response drift.** `run_id`, `status`, and + `active_controls` remain internal until a later contract explicitly exposes + them. +5. **No runtime-surrogate globals.** The harness does not add runner-owned stream, + cancel, approval, clarify, cached-agent, goal, or queue maps to the main WebUI + process. + +Non-goals for Slice 4e: + +- no supervised runner process yet; +- no default-on runner mode; +- no execution-survives-WebUI-restart claim for production chat turns; +- no removal of `legacy-direct` or `legacy-journal`; +- no server-side queue endpoint or queue scheduler just for adapter symmetry. + ## First Meaningful Success Criteria The first meaningful milestones are deliberately split. diff --git a/server.py b/server.py index 5cfed32e..e6a2c65a 100644 --- a/server.py +++ b/server.py @@ -115,7 +115,7 @@ from api.auth import check_auth from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import j, get_profile_cookie from api.profiles import set_request_profile, clear_request_profile -from api.routes import handle_delete, handle_get, handle_patch, handle_post +from api.routes import handle_delete, handle_get, handle_patch, handle_post, handle_put from api.startup import auto_install_agent_deps, fix_credential_permissions from api.updates import WEBUI_VERSION @@ -170,6 +170,13 @@ class QuietHTTPServer(ThreadingHTTPServer): class Handler(BaseHTTPRequestHandler): + # HTTP/1.1 enables keep-alive connection reuse — major latency win on + # high-RTT links where every saved TCP handshake is 2×RTT. Each response + # MUST declare framing (Content-Length, Transfer-Encoding: chunked, or + # Connection: close) so the client knows where the message ends. Helpers + # j()/t() emit Content-Length; SSE/streaming endpoints emit + # Connection: close because the body has no terminator. See PR notes. + protocol_version = "HTTP/1.1" timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion def setup(self): @@ -232,8 +239,8 @@ class Handler(BaseHTTPRequestHandler): duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1) record = _json.dumps({ 'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), - 'method': self.command or '-', - 'path': self.path or '-', + 'method': getattr(self, 'command', None) or '-', + 'path': getattr(self, 'path', None) or '-', 'status': int(code) if str(code).isdigit() else code, 'ms': duration_ms, }) @@ -286,9 +293,21 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: self._handle_write(handle_post) + def do_PUT(self) -> None: + self._handle_write(handle_put) + def do_PATCH(self) -> None: self._handle_write(handle_patch) + def do_OPTIONS(self) -> None: + """Handle CORS preflight requests.""" + self._req_t0 = time.time() + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.end_headers() + def do_DELETE(self) -> None: self._handle_write(handle_delete) diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 00000000..a8aeb2d4 --- /dev/null +++ b/start.ps1 @@ -0,0 +1,206 @@ +<# +.SYNOPSIS + Native Windows launcher for Hermes WebUI - PowerShell equivalent + of start.sh, bypassing bootstrap.py's platform refusal. + +.DESCRIPTION + Mirrors start.sh's discovery: load optional .env, find Python, + locate the hermes-agent install, set sensible env defaults, then + invoke server.py directly. The bootstrap.py path is skipped + because it currently raises on platform.system() == 'Windows'; + server.py itself runs cleanly on native Windows. + + Assumes Python + hermes-agent + the WebUI Python deps are already + installed natively on Windows - same assumption start.sh makes + when invoked outside a fresh bootstrap. For first-time setup, the + native Windows path is to install Python 3.11+, then create a + Windows venv (`python -m venv venv`) and `pip install -r + requirements.txt` from the hermes-agent root in PowerShell - this + script then finds `venv\Scripts\python.exe` automatically. A venv + created inside WSL2 is a Linux virtual environment (`venv/bin/python`) + and cannot be used by native Windows Python, so the bootstrap.py- + inside-WSL2 path produces a venv `start.ps1` can't invoke. + +.PARAMETER Port + TCP port the WebUI binds to. Overrides HERMES_WEBUI_PORT env. + Default: 8787. + +.PARAMETER BindHost + Bind address. Overrides HERMES_WEBUI_HOST env. + Default: 127.0.0.1. + +.EXAMPLE + .\start.ps1 + # Bind to 127.0.0.1:8787, foreground. + +.EXAMPLE + .\start.ps1 -Port 9000 + # Bind to 127.0.0.1:9000. + +.EXAMPLE + $env:HERMES_WEBUI_HOST = '0.0.0.0' + .\start.ps1 + # Bind to all interfaces (set a password first via env or Settings). + +.LINK + https://github.com/nesquena/hermes-webui/issues/1952 +#> + +[CmdletBinding()] +param( + [int]$Port = 0, + [string]$BindHost = '' +) + +$ErrorActionPreference = 'Stop' +$RepoRoot = Split-Path -Parent $PSCommandPath + +# === Load .env (mirroring start.sh's filtering) ======================== +$envFile = Join-Path $RepoRoot '.env' +if (Test-Path $envFile) { + foreach ($line in Get-Content $envFile -Encoding UTF8) { + $trimmed = $line.Trim() + if (-not $trimmed -or $trimmed.StartsWith('#') -or -not $trimmed.Contains('=')) { continue } + $kv = $trimmed -split '=', 2 + $key = ($kv[0].Trim() -replace '^export\s+', '') + # Filter out shell-readonly vars (UID, GID, EUID, EGID, PPID) per start.sh + if ($key -in @('UID', 'GID', 'EUID', 'EGID', 'PPID')) { continue } + if ($key -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { continue } + # Explicit $null check — an env var explicitly set to '' should still + # be considered "set" and NOT overridden by .env (empty string is + # falsey in PowerShell, so a plain truthy check would mis-skip). + if ($null -ne [Environment]::GetEnvironmentVariable($key)) { continue } + $val = $kv[1] + if ($val -match '^"(.*)"$') { $val = $Matches[1] } + elseif ($val -match "^'(.*)'$") { $val = $Matches[1] } + [Environment]::SetEnvironmentVariable($key, $val) + } +} + +# === Find Python (matches start.sh order) ============================== +$Python = $env:HERMES_WEBUI_PYTHON +if (-not $Python) { + foreach ($candidate in @('python3', 'python', 'py')) { + $cmd = Get-Command $candidate -ErrorAction SilentlyContinue + if ($cmd) { $Python = $cmd.Source; break } + } +} +if (-not $Python) { + Write-Error 'Python 3 is required to run server.py (set HERMES_WEBUI_PYTHON or add python to PATH).' + exit 1 +} + +# === Find Hermes Agent dir (server.py imports from it) ================= +# When HERMES_WEBUI_AGENT_DIR is set we still validate it on disk — +# an explicit override pointing at a missing dir should fail FAST +# with a clear message, not silently progress into a python3 launch +# that's about to crash on missing imports. Smoke-test feedback on +# PR #2783: nesquena/hermes-webui requested this guard. +$AgentDir = $env:HERMES_WEBUI_AGENT_DIR +if ($AgentDir -and -not (Test-Path (Join-Path $AgentDir 'hermes_cli') -PathType Container)) { + Write-Error "HERMES_WEBUI_AGENT_DIR is set to '$AgentDir' but no hermes_cli/ folder exists there. Unset the variable to fall back to auto-discovery, or fix the path." + exit 1 +} +if (-not $AgentDir) { + # Build candidate list incrementally — ${env:ProgramFiles(x86)} is null on + # 32-bit Windows and in some constrained environments, and Join-Path throws + # on a null Path. Skip any system-wide root that isn't set so the launcher + # stays robust across Windows variants. USERPROFILE is always set so it + # stays unguarded; the dev-checkout sibling is path-derived, not env-based. + $candidates = @() + $candidates += (Join-Path $env:USERPROFILE '.hermes\hermes-agent') + foreach ($root in @($env:LOCALAPPDATA, ${env:ProgramW6432}, ${env:ProgramFiles}, ${env:ProgramFiles(x86)})) { + if ($root) { $candidates += (Join-Path $root 'hermes\hermes-agent') } + } + $candidates += (Join-Path (Split-Path -Parent $RepoRoot) 'hermes-agent') + # De-dup: when running in a WOW64 (32-bit-on-64-bit) PowerShell process, + # $env:ProgramFiles is redirected to C:\Program Files (x86), so without + # $env:ProgramW6432 (the canonical 64-bit override) we'd miss the real + # C:\Program Files\hermes\hermes-agent AND duplicate the x86 entry. + # Select-Object -Unique collapses any collisions regardless of cause. + $candidates = $candidates | Select-Object -Unique + foreach ($c in $candidates) { + if (Test-Path (Join-Path $c 'hermes_cli') -PathType Container) { $AgentDir = $c; break } + } +} +if (-not $AgentDir) { + $searched = $candidates -join ', ' + Write-Error "hermes-agent not found. Searched: $searched. Set HERMES_WEBUI_AGENT_DIR explicitly to override." + exit 1 +} + +# === Prefer the agent's venv Python if available ======================= +$agentVenvPython = Join-Path $AgentDir 'venv\Scripts\python.exe' +if (Test-Path $agentVenvPython) { + $Python = $agentVenvPython +} + +# === Resolve bind + state defaults ===================================== +$BindHostFinal = if ($BindHost) { $BindHost } elseif ($env:HERMES_WEBUI_HOST) { $env:HERMES_WEBUI_HOST } else { '127.0.0.1' } +$PortFinal = if ($Port) { + $Port +} elseif ($env:HERMES_WEBUI_PORT) { + # TryParse + range guard on the env var. A plain [int] cast on the + # env var throws InvalidCastException with no actionable context when + # the env var is set to a non-integer (typo, accidental shell + # expansion, etc.) — surface a targeted error message instead. + $parsedPort = 0 + if (-not [int]::TryParse($env:HERMES_WEBUI_PORT, [ref]$parsedPort)) { + Write-Error "HERMES_WEBUI_PORT='$($env:HERMES_WEBUI_PORT)' is not a valid integer port. Unset the variable to use the default (8787), or set it to a number 1-65535." + exit 1 + } + if ($parsedPort -lt 1 -or $parsedPort -gt 65535) { + Write-Error "HERMES_WEBUI_PORT=$parsedPort is out of TCP-port range. Must be 1-65535." + exit 1 + } + $parsedPort +} else { + 8787 +} +$env:HERMES_WEBUI_HOST = $BindHostFinal +$env:HERMES_WEBUI_PORT = "$PortFinal" +if (-not $env:HERMES_WEBUI_STATE_DIR) { + $env:HERMES_WEBUI_STATE_DIR = Join-Path $env:USERPROFILE '.hermes\webui' +} +if (-not $env:HERMES_HOME) { + $env:HERMES_HOME = Join-Path $env:USERPROFILE '.hermes' +} + +# === Ensure dirs exist ================================================= +New-Item -ItemType Directory -Force -Path $env:HERMES_HOME | Out-Null +New-Item -ItemType Directory -Force -Path $env:HERMES_WEBUI_STATE_DIR | Out-Null + +# === Launch (foreground, matches start.sh) ============================= +Write-Host "[start.ps1] Hermes WebUI native Windows launcher" -ForegroundColor Cyan +Write-Host "[start.ps1] Python: $Python" +Write-Host "[start.ps1] Agent dir: $AgentDir" +Write-Host "[start.ps1] State dir: $env:HERMES_WEBUI_STATE_DIR" +Write-Host "[start.ps1] Binding: ${BindHostFinal}:${PortFinal}" +Write-Host "" + +$serverPath = Join-Path $RepoRoot 'server.py' +if (-not (Test-Path $serverPath)) { + Write-Error "server.py not found at $serverPath - is this the hermes-webui repo root?" + exit 1 +} + +# Capture exit code, let finally{} run Pop-Location, exit AFTER the try. +# Plain `exit $LASTEXITCODE` inside the try block can prevent the finally +# from running in some termination paths (especially when dot-sourced or +# in interactive sessions), leaving the caller's working directory stuck +# at $RepoRoot. +$script:serverExitCode = 0 +Push-Location $RepoRoot +try { + # @args was non-functional here — PowerShell does NOT populate $args when the + # script declares [CmdletBinding()] with an explicit param() block (Copilot's + # finding on PR #2807). Dropped rather than added a ValueFromRemainingArguments + # parameter, because the existing tracked use case is the launcher running + # server.py with the env-var-driven config — no pass-through args are needed. + # If pass-through becomes a requirement later, add a [Parameter(ValueFromRemainingArguments=$true)] [string[]]$ServerArgs and splat that. + & $Python $serverPath + $script:serverExitCode = $LASTEXITCODE +} finally { + Pop-Location +} +exit $script:serverExitCode diff --git a/static/boot.js b/static/boot.js index 3cc6445d..5a7a21de 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1238,6 +1238,7 @@ const _SKINS=[ {name:'Charizard',colors:['#FB923C','#F97316','#EA580C']}, {name:'Sienna', colors:['#D97757','#C06A49','#9A523A']}, {name:'Catppuccin',colors:['#CBA6F7','#B4BEFE','#8839EF']}, + {name:'Hepburn', colors:['#c6246a','#ec5597','#f2abca']}, {name:'Nous', colors:['#4682B4','#3A6E9A','#2C5F88']}, {name:'Geist Contrast', value:'geist-contrast', colors:['#000000','#ffffff','#FFF175']}, ]; @@ -1476,12 +1477,8 @@ function applyBotName(){ if(sel&&typeof _applyModelToDropdown==='function'){ // Fresh page boot must prefer the profile/server default over stale // browser-persisted model state. A restored session can still apply its - // own persisted model later through loadSession(). - if(typeof _clearPersistedModelState==='function') _clearPersistedModelState(); - else { - localStorage.removeItem('hermes-webui-model'); - localStorage.removeItem('hermes-webui-model-state'); - } + // own persisted model later through loadSession(). Preserve the browser + // keys for legacy/no-default fallback paths instead of deleting them. const existingDefaultOpt=Array.from(sel.options).find(o=>o.value===s.default_model); if(existingDefaultOpt&&window._activeProvider&&!existingDefaultOpt.dataset.provider){ existingDefaultOpt.dataset.provider=window._activeProvider; @@ -1589,7 +1586,7 @@ function applyBotName(){ // Fetch available models without blocking session restore. The static HTML // options are enough for first paint; the dynamic provider list can settle // after the saved session is visible. - const _hydrateBootModelDropdown=()=>populateModelDropdown().then(()=>{ + const _hydrateBootModelDropdown=()=>populateModelDropdown({preferProfileDefaultOnFreshBoot:true}).then(()=>{ const sessionModelState=S.session&&S.session.model ? {model:S.session.model,model_provider:S.session.model_provider||null} : null; @@ -1623,7 +1620,10 @@ function applyBotName(){ else if(typeof syncModelChip==='function') syncModelChip(); } if(S.session) syncTopbar(); - }).catch(()=>{}); + }).catch(e=>{ + window._modelDropdownReady=null; + throw e; + }); const _startBootModelDropdown=()=>{ const ready=window._modelDropdownReady; if(ready&&typeof ready.then==='function') return ready; @@ -1633,6 +1633,9 @@ function applyBotName(){ }; window._modelDropdownReady=null; window._ensureModelDropdownReady=_startBootModelDropdown; + setTimeout(()=>{ + try{Promise.resolve(_startBootModelDropdown()).catch(()=>{});}catch(_){} + },0); // Start independent boot fetches without holding the conversation list behind // them. The sidebar can render from /api/sessions while workspace/onboarding // metadata settles in parallel. @@ -1655,6 +1658,17 @@ function applyBotName(){ if(typeof fetchReasoningChip==='function') fetchReasoningChip(); if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator(); const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; + const pwaLaunchAction=(window.HermesPWA&&typeof window.HermesPWA.launchAction==='function') + ? window.HermesPWA.launchAction() + : null; + if(pwaLaunchAction==='new-chat'){ + try{ + await newSession(true); + if(S.session) await _startBootModelDropdown(); + S._bootReady=true; + syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();return; + }catch(e){console.warn('[pwa] new-chat launch action failed', e);} + } const savedLocal=localStorage.getItem('hermes-webui-session'); const saved=urlSession||savedLocal; if(saved){ diff --git a/static/i18n.js b/static/i18n.js index dbabf5c2..460a837a 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -87,6 +87,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -402,10 +407,12 @@ const LOCALES = { rename_prompt: 'New name:', deleted: 'Deleted ', delete_failed: 'Delete failed: ', - reveal_in_finder: 'Reveal in File Manager', - reveal_failed: 'Failed to reveal: ', - copy_file_path: 'Copy file path', - download_folder: 'Download Folder', + reveal_in_finder: 'Reveal in File Manager', + reveal_failed: 'Failed to reveal: ', + copy_file_path: 'Copy file path', + open_in_vscode: 'Open in VS Code', + open_in_vscode_failed: 'Failed to open in VS Code: ', + download_folder: 'Download Folder', path_copied: 'File path copied to clipboard', path_copy_failed: 'Failed to copy path: ', session_rename: 'Rename conversation', @@ -541,6 +548,22 @@ const LOCALES = { settings_save_btn: 'Save Settings', settings_label_model: 'Default Model', settings_desc_model: 'Used for new conversations. Existing conversations keep their selected model.', + settings_label_auxiliary_models: 'Auxiliary Models', + settings_desc_auxiliary_models: 'Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.', + settings_btn_reset_aux_models: 'Reset all to auto', + settings_btn_apply_aux_models: 'Apply changes', + settings_aux_provider_auto: 'use main model', + settings_aux_model_auto: 'auto (use provider default)', + settings_aux_model_custom: 'Custom model…', + settings_aux_model_custom_prompt: 'Enter model ID:', + settings_aux_loading: 'Loading auxiliary models…', + settings_aux_load_failed: 'Could not load auxiliary model settings. Make sure the agent API is available.', + settings_aux_reset_confirm_title: 'Reset auxiliary models?', + settings_aux_reset_confirm_msg: 'This will set all auxiliary tasks to auto (use main model).', + settings_aux_reset_done: 'Auxiliary models reset to auto', + settings_aux_save_failed: 'Failed to save auxiliary model', + settings_aux_saved: 'Auxiliary models updated', + settings_aux_no_changes: 'No changes to apply', settings_label_send_key: 'Send Key', settings_label_theme: 'Theme', settings_label_skin: 'Skin', @@ -565,6 +588,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Show previous messaging sessions', settings_label_sync_insights: 'Sync to insights', settings_label_check_updates: 'Check for updates', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Default assistant name', settings_label_password: 'Access Password', @@ -804,6 +828,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Show older Discord, Telegram, Slack, and Weixin sessions that were replaced by reset or compression.', settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.', settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Used for the default profile only. Other profiles use their own profile names.', settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.', @@ -1334,6 +1359,11 @@ const LOCALES = { mcp_tool_count: '{0} strumenti', mcp_enabled_yes: 'Abilitato', mcp_enabled_no: 'Disabilitato', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'Strumenti MCP', mcp_tools_desc: 'Cerca strumenti noti tra i server MCP attivi.', mcp_tools_search_placeholder: 'Cerca strumenti per nome, server o descrizione…', @@ -1649,10 +1679,12 @@ const LOCALES = { rename_prompt: 'Nuovo nome:', deleted: 'Eliminato ', delete_failed: 'Eliminazione fallita: ', - reveal_in_finder: 'Mostra nel File Manager', - reveal_failed: 'Mostra fallito: ', - copy_file_path: 'Copia percorso file', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostra nel File Manager', + reveal_failed: 'Mostra fallito: ', + copy_file_path: 'Copia percorso file', + open_in_vscode: 'Apri in VS Code', + open_in_vscode_failed: 'Apertura in VS Code fallita: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Percorso file copiato negli appunti', path_copy_failed: 'Copia percorso fallita: ', session_rename: 'Rinomina conversazione', @@ -1788,6 +1820,22 @@ const LOCALES = { settings_save_btn: 'Salva Impostazioni', settings_label_model: 'Modello Predefinito', settings_desc_model: 'Usato per le nuove conversazioni. Le conversazioni esistenti mantengono il modello selezionato.', + settings_label_auxiliary_models: 'Modelli Ausiliari', + settings_desc_auxiliary_models: 'Routing per attività secondarie come visione, compressione, generazione titoli, ecc. "Auto" utilizza il modello chat principale.', + settings_btn_reset_aux_models: 'Ripristina tutto ad auto', + settings_btn_apply_aux_models: 'Applica modifiche', + settings_aux_provider_auto: 'usa modello principale', + settings_aux_model_auto: 'auto (usa predefinito del provider)', + settings_aux_model_custom: 'Modello personalizzato…', + settings_aux_model_custom_prompt: 'Inserisci ID modello:', + settings_aux_loading: 'Caricamento modelli ausiliari…', + settings_aux_load_failed: 'Impossibile caricare le impostazioni dei modelli ausiliari. Assicurati che l’API dell’agent sia disponibile.', + settings_aux_reset_confirm_title: 'Ripristinare modelli ausiliari?', + settings_aux_reset_confirm_msg: 'Questo imposterà tutte le attività ausiliarie ad auto (usa modello principale).', + settings_aux_reset_done: 'Modelli ausiliari ripristinati ad auto', + settings_aux_save_failed: 'Salvataggio del modello ausiliario non riuscito', + settings_aux_saved: 'Modelli ausiliari aggiornati', + settings_aux_no_changes: 'Nessuna modifica da applicare', settings_label_send_key: 'Tasto Invio', settings_label_theme: 'Tema', settings_label_skin: 'Skin', @@ -1812,6 +1860,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostra sessioni di messaggistica precedenti', settings_label_sync_insights: 'Sincronizza con insights', settings_label_check_updates: 'Verifica aggiornamenti', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nome assistente predefinito', settings_label_password: 'Password di Accesso', @@ -2043,6 +2092,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Mostra sessioni Discord, Telegram, Slack e Weixin più vecchie sostituite da reset o compressione.', settings_desc_sync_insights: 'Rispecchia l\'uso token WebUI su state.db così hermes /insights include i dati delle sessioni browser. Disattivato per impostazione predefinita.', settings_desc_check_updates: 'Mostra un banner quando sono disponibili versioni più recenti della WebUI o dell\'Agente. Esegue un git fetch in background periodicamente.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Usato solo per il profilo predefinito. Gli altri profili usano i propri nomi.', settings_desc_password: 'Inserisci una nuova password per impostarla o cambiarla. Lascia vuoto per mantenere l\'impostazione attuale.', @@ -2573,6 +2623,11 @@ const LOCALES = { mcp_tool_count: '{0} 個のツール', mcp_enabled_yes: '有効', mcp_enabled_no: '無効', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP ツール', mcp_tools_desc: 'アクティブな MCP サーバー全体から既知のツールを検索します。', mcp_tools_search_placeholder: '名前、サーバー、説明でツールを検索…', @@ -2888,10 +2943,12 @@ const LOCALES = { rename_prompt: '新しい名前:', deleted: '削除しました: ', delete_failed: '削除失敗: ', - reveal_in_finder: 'ファイルマネージャーで表示', - reveal_failed: '表示に失敗しました: ', - copy_file_path: 'ファイルパスをコピー', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'ファイルマネージャーで表示', + reveal_failed: '表示に失敗しました: ', + copy_file_path: 'ファイルパスをコピー', + open_in_vscode: 'VS Codeで開く', + open_in_vscode_failed: 'VS Codeで開けませんでした: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'ファイルパスをクリップボードにコピーしました', path_copy_failed: 'パスのコピーに失敗しました: ', session_rename: '会話の名前を変更', @@ -3027,6 +3084,22 @@ const LOCALES = { settings_save_btn: '設定を保存', settings_label_model: 'デフォルトモデル', settings_desc_model: '新しい会話で使用されます。既存の会話は選択済みモデルを保持します。', + settings_label_auxiliary_models: '補助モデル', + settings_desc_auxiliary_models: 'ビジョン、圧縮、タイトル生成などの補助タスクのルーティング。「自動」はメインチャットモデルを使用します。', + settings_btn_reset_aux_models: 'すべて自動にリセット', + settings_btn_apply_aux_models: '変更を適用', + settings_aux_provider_auto: 'メインモデルを使用', + settings_aux_model_auto: '自動(プロバイダーのデフォルトを使用)', + settings_aux_model_custom: 'カスタムモデル…', + settings_aux_model_custom_prompt: 'モデルIDを入力:', + settings_aux_loading: '補助モデルを読み込み中…', + settings_aux_load_failed: '補助モデル設定を読み込めませんでした。エージェントAPIが利用可能であることを確認してください。', + settings_aux_reset_confirm_title: '補助モデルをリセットしますか?', + settings_aux_reset_confirm_msg: 'すべての補助タスクを自動(メインモデルを使用)に設定します。', + settings_aux_reset_done: '補助モデルを自動にリセットしました', + settings_aux_save_failed: '補助モデルの保存に失敗しました', + settings_aux_saved: '補助モデルを更新しました', + settings_aux_no_changes: '適用する変更はありません', settings_label_send_key: '送信キー', settings_label_theme: 'テーマ', settings_label_skin: 'スキン', @@ -3051,6 +3124,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '以前のメッセージングセッションを表示', settings_label_sync_insights: 'インサイトに同期', settings_label_check_updates: 'アップデートを確認', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'デフォルトのアシスタント名', settings_label_password: 'アクセスパスワード', @@ -3287,6 +3361,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'reset または compression によって置き換えられた以前の Discord、Telegram、Slack、Weixin セッションを表示します。', settings_desc_sync_insights: 'WebUI のトークン使用量を state.db にミラーし、hermes /insights にブラウザセッションのデータを含めます。デフォルトはオフ。', settings_desc_check_updates: 'WebUI または Agent の新しいバージョンが利用可能な時にバナーを表示します。バックグラウンドで定期的に git fetch を実行します。', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'デフォルトプロファイルでのみ使用されます。他のプロファイルはそれぞれのプロファイル名を使用します。', settings_desc_password: '新しいパスワードを入力すると設定または変更します。空欄なら現在の設定を維持。', @@ -3814,6 +3889,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -4053,10 +4133,12 @@ const LOCALES = { rename_prompt: 'Новое имя:', deleted: 'Удалено ', delete_failed: 'Не удалось удалить: ', - reveal_in_finder: 'Показать в файловом менеджере', - reveal_failed: 'Не удалось открыть: ', - copy_file_path: 'Копировать путь к файлу', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Показать в файловом менеджере', + reveal_failed: 'Не удалось открыть: ', + copy_file_path: 'Копировать путь к файлу', + open_in_vscode: 'Открыть в VS Code', + open_in_vscode_failed: 'Не удалось открыть в VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Путь к файлу скопирован в буфер обмена', path_copy_failed: 'Не удалось скопировать путь: ', session_rename: 'Переименовать беседу', @@ -4082,6 +4164,22 @@ const LOCALES = { settings_save_btn: 'Сохранить настройки', settings_label_model: 'Модель по умолчанию', settings_desc_model: 'Используется для новых бесед. Существующие беседы сохраняют выбранную модель.', + settings_label_auxiliary_models: 'Вспомогательные модели', + settings_desc_auxiliary_models: 'Маршрутизация побочных задач: зрение, сжатие, генерация заголовков и т.д. «Авто» использует основную модель чата.', + settings_btn_reset_aux_models: 'Сбросить всё на авто', + settings_btn_apply_aux_models: 'Применить изменения', + settings_aux_provider_auto: 'использовать основную модель', + settings_aux_model_auto: 'авто (по умолчанию провайдера)', + settings_aux_model_custom: 'Пользовательская модель…', + settings_aux_model_custom_prompt: 'Введите ID модели:', + settings_aux_loading: 'Загрузка вспомогательных моделей…', + settings_aux_load_failed: 'Не удалось загрузить настройки вспомогательных моделей. Убедитесь, что API агента доступен.', + settings_aux_reset_confirm_title: 'Сбросить вспомогательные модели?', + settings_aux_reset_confirm_msg: 'Все вспомогательные задачи будут установлены на «авто» (использовать основную модель).', + settings_aux_reset_done: 'Вспомогательные модели сброшены на авто', + settings_aux_save_failed: 'Не удалось сохранить вспомогательную модель', + settings_aux_saved: 'Вспомогательные модели обновлены', + settings_aux_no_changes: 'Нет изменений для применения', settings_label_send_key: 'Клавиша отправки', settings_label_theme: 'Тема', settings_label_language: 'Язык', @@ -4096,6 +4194,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Показывать предыдущие сеансы обмена сообщениями', settings_label_sync_insights: 'Синхронизировать с Insights', settings_label_check_updates: 'Проверять обновления', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Имя помощника по умолчанию', settings_label_password: 'Пароль доступа', @@ -4281,6 +4380,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Показывать предыдущие сеансы Discord, Telegram, Slack и Weixin, замененные сбросом или сжатием.', settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.', settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Используется только для профиля по умолчанию. Другие профили используют свои имена.', settings_desc_password: 'Введите новый пароль, чтобы задать или изменить его. Оставьте пустым, чтобы сохранить текущую настройку.', @@ -4987,6 +5087,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -5211,10 +5316,12 @@ const LOCALES = { rename_prompt: 'Nuevo nombre:', deleted: 'Eliminado ', delete_failed: 'Error al eliminar: ', - reveal_in_finder: 'Mostrar en el gestor de archivos', - reveal_failed: 'Error al mostrar: ', - copy_file_path: 'Copiar ruta del archivo', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostrar en el gestor de archivos', + reveal_failed: 'Error al mostrar: ', + copy_file_path: 'Copiar ruta del archivo', + open_in_vscode: 'Abrir en VS Code', + open_in_vscode_failed: 'Error al abrir en VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Ruta del archivo copiada al portapapeles', path_copy_failed: 'Error al copiar la ruta: ', session_rename: 'Renombrar conversación', @@ -5240,6 +5347,22 @@ const LOCALES = { settings_save_btn: 'Guardar configuración', settings_label_model: 'Modelo predeterminado', settings_desc_model: 'Se usa para conversaciones nuevas. Las conversaciones existentes conservan su modelo seleccionado.', + settings_label_auxiliary_models: 'Modelos auxiliares', + settings_desc_auxiliary_models: 'Enrutamiento para tareas secundarias como visión, compresión, generación de títulos, etc. «Auto» usa el modelo de chat principal.', + settings_btn_reset_aux_models: 'Restablecer todo a auto', + settings_btn_apply_aux_models: 'Aplicar cambios', + settings_aux_provider_auto: 'usar modelo principal', + settings_aux_model_auto: 'auto (usar predeterminado del proveedor)', + settings_aux_model_custom: 'Modelo personalizado…', + settings_aux_model_custom_prompt: 'Ingrese ID del modelo:', + settings_aux_loading: 'Cargando modelos auxiliares…', + settings_aux_load_failed: 'No se pudieron cargar las configuraciones de modelos auxiliares. Asegúrese de que la API del agente esté disponible.', + settings_aux_reset_confirm_title: '¿Restablecer modelos auxiliares?', + settings_aux_reset_confirm_msg: 'Esto establecerá todas las tareas auxiliares en auto (usar modelo principal).', + settings_aux_reset_done: 'Modelos auxiliares restablecidos a auto', + settings_aux_save_failed: 'Error al guardar el modelo auxiliar', + settings_aux_saved: 'Modelos auxiliares actualizados', + settings_aux_no_changes: 'Sin cambios para aplicar', settings_label_send_key: 'Tecla de envío', settings_label_theme: 'Tema', settings_label_skin: 'Piel', @@ -5264,6 +5387,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostrar sesiones de mensajería anteriores', settings_label_sync_insights: 'Sincronizar con insights', settings_label_check_updates: 'Buscar actualizaciones', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nombre predeterminado del asistente', settings_label_password: 'Contraseña de acceso', @@ -5460,6 +5584,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Mostrar sesiones antiguas de Discord, Telegram, Slack y Weixin reemplazadas por reset o compresión.', settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.', settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Solo se usa para el perfil predeterminado. Los otros perfiles usan sus propios nombres.', settings_desc_password: 'Introduce una nueva contraseña para establecerla o cambiarla. Déjalo en blanco para mantener la configuración actual.', @@ -6163,6 +6288,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -6372,10 +6502,12 @@ const LOCALES = { rename_prompt: 'Neuer Name:', deleted: 'Gelöscht ', delete_failed: 'Löschen fehlgeschlagen: ', - reveal_in_finder: 'Im Dateimanager anzeigen', - reveal_failed: 'Anzeige fehlgeschlagen: ', - copy_file_path: 'Dateipfad kopieren', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Im Dateimanager anzeigen', + reveal_failed: 'Anzeige fehlgeschlagen: ', + copy_file_path: 'Dateipfad kopieren', + open_in_vscode: 'In VS Code öffnen', + open_in_vscode_failed: 'In VS Code öffnen fehlgeschlagen: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Dateipfad in die Zwischenablage kopiert', path_copy_failed: 'Pfad konnte nicht kopiert werden: ', session_rename: 'Unterhaltung umbenennen', @@ -6401,6 +6533,22 @@ const LOCALES = { settings_save_btn: 'Einstellungen speichern', settings_label_model: 'Standard-Modell', settings_desc_model: 'Wird für neue Chats verwendet. Bestehende Chats behalten ihr ausgewähltes Modell.', + settings_label_auxiliary_models: 'Hilfsmodelle', + settings_desc_auxiliary_models: 'Routing für Nebenaufgaben wie Vision, Komprimierung, Titelgenerierung usw. „Auto" verwendet das Haupt-Chat-Modell.', + settings_btn_reset_aux_models: 'Alle auf auto zurücksetzen', + settings_btn_apply_aux_models: 'Änderungen anwenden', + settings_aux_provider_auto: 'Hauptmodell verwenden', + settings_aux_model_auto: 'auto (Provider-Standard verwenden)', + settings_aux_model_custom: 'Benutzerdefiniertes Modell…', + settings_aux_model_custom_prompt: 'Modell-ID eingeben:', + settings_aux_loading: 'Hilfsmodelle werden geladen…', + settings_aux_load_failed: 'Hilfsmodelle-Einstellungen konnten nicht geladen werden. Stellen Sie sicher, dass die Agent-API verfügbar ist.', + settings_aux_reset_confirm_title: 'Hilfsmodelle zurücksetzen?', + settings_aux_reset_confirm_msg: 'Dies setzt alle Hilfsaufgaben auf auto (Hauptmodell verwenden).', + settings_aux_reset_done: 'Hilfsmodelle auf auto zurückgesetzt', + settings_aux_save_failed: 'Hilfsmodell konnte nicht gespeichert werden', + settings_aux_saved: 'Hilfsmodelle aktualisiert', + settings_aux_no_changes: 'Keine Änderungen anzuwenden', settings_label_send_key: 'Sende-Taste', settings_label_theme: 'Theme', settings_label_skin: 'Skin', @@ -6425,6 +6573,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Vorherige Messaging-Sitzungen anzeigen', settings_label_sync_insights: 'Mit Insights synchronisieren', settings_label_check_updates: 'Nach Updates suchen', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Standard-Assistentenname', settings_label_password: 'Zugangspasswort', @@ -6611,6 +6760,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Zeigt ältere Discord-, Telegram-, Slack- und Weixin-Sitzungen, die durch Reset oder Compression ersetzt wurden.', settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.', settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Wird nur für das Standardprofil verwendet. Andere Profile verwenden ihre eigenen Namen.', settings_desc_password: 'Geben Sie ein neues Passwort ein, um es zu setzen oder zu ändern. Leer lassen, um die aktuelle Einstellung beizubehalten.', @@ -7343,6 +7493,11 @@ const LOCALES = { mcp_tool_count: '{0} 个工具', mcp_enabled_yes: '已启用', mcp_enabled_no: '已禁用', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP 工具', mcp_tools_desc: '搜索活跃 MCP 服务器中的已知工具。', mcp_tools_search_placeholder: '按名称、服务器或描述搜索工具…', @@ -7585,10 +7740,12 @@ const LOCALES = { rename_prompt: '新名称:', deleted: '已删除 ', delete_failed: '删除失败:', - reveal_in_finder: '在文件管理器中显示', - reveal_failed: '显示失败:', - copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '在文件管理器中显示', + reveal_failed: '显示失败:', + copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', + open_in_vscode: '在VS Code中打开', + open_in_vscode_failed: '在VS Code中打开失败:', + download_folder: 'Download Folder', // TODO: translate 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', @@ -7614,6 +7771,22 @@ const LOCALES = { settings_save_btn: '保存设置', settings_label_model: '默认模型', settings_desc_model: '用于新对话。现有对话保持各自选定的模型。', + settings_label_auxiliary_models: '辅助模型', + settings_desc_auxiliary_models: '视觉分析、上下文压缩、标题生成等辅助任务的路由。"自动"表示使用主聊天模型。', + settings_btn_reset_aux_models: '全部重置为自动', + settings_btn_apply_aux_models: '应用更改', + settings_aux_provider_auto: '使用主模型', + settings_aux_model_auto: '自动(使用提供商默认)', + settings_aux_model_custom: '自定义模型…', + settings_aux_model_custom_prompt: '输入模型 ID:', + settings_aux_loading: '正在加载辅助模型…', + settings_aux_load_failed: '无法加载辅助模型设置,请确保 Agent API 可用。', + settings_aux_reset_confirm_title: '重置辅助模型?', + settings_aux_reset_confirm_msg: '这将把所有辅助任务设置为自动(使用主模型)。', + settings_aux_reset_done: '辅助模型已重置为自动', + settings_aux_save_failed: '辅助模型保存失败', + settings_aux_saved: '辅助模型已更新', + settings_aux_no_changes: '没有需要应用的更改', settings_label_send_key: '发送快捷键', settings_label_theme: '主题', settings_label_skin: '皮肤', @@ -7638,6 +7811,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '显示以前的消息会话', settings_label_sync_insights: '同步到 insights', settings_label_check_updates: '检查更新', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '默认助手名称', settings_label_password: '访问密码', @@ -7897,6 +8071,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: '显示被 reset 或 compression 替换的较旧的 Discord、Telegram、Slack 和 Weixin 会话。', settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。', settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: '仅用于默认个人资料。其他个人资料会使用各自的名称。', settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。', @@ -8510,6 +8685,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -8667,10 +8847,12 @@ const LOCALES = { rename_prompt: '新名稱:', deleted: '\u5df2\u522a\u9664 ', delete_failed: '\u522a\u9664\u5931\u6557\uff1a', - reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', - reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', - copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', + reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', + copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91', + open_in_vscode: '在VS Code中開啟', + open_in_vscode_failed: '在VS Code中開啟失敗:', + download_folder: 'Download Folder', // TODO: translate path_copied: '\u6a94\u6848\u8def\u5f91\u5df2\u8907\u88fd\u5230\u526a\u8cbc\u7c3f', path_copy_failed: '\u8907\u88fd\u8def\u5f91\u5931\u6557\uff1a', session_rename: '\u91cd\u65b0\u547d\u540d\u5c0d\u8a71', @@ -8804,6 +8986,22 @@ const LOCALES = { settings_save_btn: '\u5132\u5b58\u8a2d\u5b9a', settings_label_model: '\u9ed8\u8a8d\u6a21\u578b', settings_desc_model: '\u7528\u65bc\u65b0\u6703\u8a71\u3002\u73fe\u6709\u6703\u8a71\u6703\u4fdd\u7559\u5404\u81ea\u9078\u5b9a\u7684\u6a21\u578b\u3002', + settings_label_auxiliary_models: '\u8f14\u52a9\u6a21\u578b', + settings_desc_auxiliary_models: '\u8996\u89ba\u5206\u6790\u3001\u4e0a\u4e0b\u6587\u58d3\u7e2e\u3001\u6a19\u984c\u7522\u751f\u7b49\u8f14\u52a9\u4efb\u52d9\u7684\u8def\u7531\u3002\u300c\u81ea\u52d5\u300d\u8868\u793a\u4f7f\u7528\u4e3b\u804a\u5929\u6a21\u578b\u3002', + settings_btn_reset_aux_models: '\u5168\u90e8\u91cd\u7f6e\u70ba\u81ea\u52d5', + settings_btn_apply_aux_models: '\u61c9\u7528\u8b8a\u66f4', + settings_aux_provider_auto: '\u4f7f\u7528\u4e3b\u6a21\u578b', + settings_aux_model_auto: '\u81ea\u52d5\uff08\u4f7f\u7528\u63d0\u4f9b\u5546\u9810\u8a2d\uff09', + settings_aux_model_custom: '\u81ea\u8a02\u6a21\u578b\u2026', + settings_aux_model_custom_prompt: '\u8f38\u5165\u6a21\u578b ID\uff1a', + settings_aux_loading: '\u6b63\u5728\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u2026', + settings_aux_load_failed: '\u7121\u6cd5\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u8a2d\u5b9a\uff0c\u8acb\u78ba\u4fdd Agent API \u53ef\u7528\u3002', + settings_aux_reset_confirm_title: '\u91cd\u7f6e\u8f14\u52a9\u6a21\u578b\uff1f', + settings_aux_reset_confirm_msg: '\u9019\u5c07\u628a\u6240\u6709\u8f14\u52a9\u4efb\u52d9\u8a2d\u5b9a\u70ba\u81ea\u52d5\uff08\u4f7f\u7528\u4e3b\u6a21\u578b\uff09\u3002', + settings_aux_reset_done: '\u8f14\u52a9\u6a21\u578b\u5df2\u91cd\u7f6e\u70ba\u81ea\u52d5', + settings_aux_save_failed: '\u8f14\u52a9\u6a21\u578b\u5132\u5b58\u5931\u6557', + settings_aux_saved: '\u8f14\u52a9\u6a21\u578b\u5df2\u66f4\u65b0', + settings_aux_no_changes: '\u6c92\u6709\u9700\u8981\u61c9\u7528\u7684\u8b8a\u66f4', settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375', settings_label_theme: '\u4e3b\u984c', settings_label_skin: '佈景', @@ -8828,6 +9026,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '顯示以前的訊息對話', settings_label_sync_insights: '\u540c\u6b65\u5230 insights', settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '預設助手名稱', settings_label_password: '\u8a2a\u554f\u5bc6\u78bc', @@ -9011,6 +9210,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: '顯示被 reset 或 compression 替換的較舊的 Discord、Telegram、Slack 和 Weixin 對話。', settings_desc_sync_insights: '將 WebUI token 使用情況同步到 state.db,使 hermes /insights 包含瀏覽器會話數據。預設未啟用。', settings_desc_check_updates: '當有更新的 WebUI 或助手版本時顯示標記。將在後台正常執行 Git-Fetch。', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: '僅用於預設個人檔案。其他個人檔案會使用各自的名稱。', settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002', @@ -9979,10 +10179,12 @@ const LOCALES = { delete_confirm: (name) => `Excluir ${name}?`, deleted: 'Excluído ', delete_failed: 'Falha ao excluir: ', - reveal_in_finder: 'Mostrar no gerenciador de arquivos', - reveal_failed: 'Falha ao mostrar: ', - copy_file_path: 'Copiar caminho do arquivo', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostrar no gerenciador de arquivos', + reveal_failed: 'Falha ao mostrar: ', + copy_file_path: 'Copiar caminho do arquivo', + open_in_vscode: 'Abrir no VS Code', + open_in_vscode_failed: 'Falha ao abrir no VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Caminho do arquivo copiado para a área de transferência', path_copy_failed: 'Falha ao copiar caminho: ', session_rename: 'Renomear conversa', @@ -10113,6 +10315,22 @@ const LOCALES = { settings_save_btn: 'Salvar Configurações', settings_label_model: 'Modelo Padrão', settings_desc_model: 'Usado para novas conversas. Conversas existentes mantêm o modelo selecionado.', + settings_label_auxiliary_models: 'Modelos auxiliares', + settings_desc_auxiliary_models: 'Roteamento para tarefas secundárias como visão, compressão, geração de títulos, etc. "Auto" usa o modelo de chat principal.', + settings_btn_reset_aux_models: 'Restaurar tudo para auto', + settings_btn_apply_aux_models: 'Aplicar alterações', + settings_aux_provider_auto: 'usar modelo principal', + settings_aux_model_auto: 'auto (usar padrão do provedor)', + settings_aux_model_custom: 'Modelo personalizado…', + settings_aux_model_custom_prompt: 'Insira o ID do modelo:', + settings_aux_loading: 'Carregando modelos auxiliares…', + settings_aux_load_failed: 'Não foi possível carregar as configurações de modelos auxiliares. Certifique-se de que a API do agente esteja disponível.', + settings_aux_reset_confirm_title: 'Restaurar modelos auxiliares?', + settings_aux_reset_confirm_msg: 'Isso definirá todas as tarefas auxiliares para auto (usar modelo principal).', + settings_aux_reset_done: 'Modelos auxiliares restaurados para auto', + settings_aux_save_failed: 'Falha ao salvar o modelo auxiliar', + settings_aux_saved: 'Modelos auxiliares atualizados', + settings_aux_no_changes: 'Nenhuma alteração para aplicar', settings_label_send_key: 'Tecla de Envio', settings_label_theme: 'Tema', settings_label_skin: 'Skin', @@ -10137,6 +10355,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostrar sessões de mensagens anteriores', settings_label_sync_insights: 'Sincronizar para insights', settings_label_check_updates: 'Verificar atualizações', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nome padrão do assistente', settings_label_password: 'Senha de Acesso', @@ -10326,6 +10545,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Mostrar sessões antigas de Discord, Telegram, Slack e Weixin substituídas por reset ou compressão.', settings_desc_sync_insights: 'Espelha uso de tokens para state.db.', settings_desc_check_updates: 'Mostrar banner quando versões mais novas estiverem disponíveis.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Usado apenas para o perfil padrão. Outros perfis usam seus próprios nomes.', settings_desc_password: 'Digite nova senha para definir ou trocar. Deixe em branco para manter.', @@ -10822,6 +11042,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', 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…', @@ -11116,10 +11341,12 @@ const LOCALES = { rename_prompt: '새 이름:', deleted: '삭제됨: ', delete_failed: '삭제 실패: ', - reveal_in_finder: '파일 관리자에서 열기', - reveal_failed: '표시 실패: ', - copy_file_path: '파일 경로 복사', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '파일 관리자에서 열기', + reveal_failed: '표시 실패: ', + copy_file_path: '파일 경로 복사', + open_in_vscode: 'VS Code에서 열기', + open_in_vscode_failed: 'VS Code에서 열기 실패: ', + download_folder: 'Download Folder', // TODO: translate path_copied: '파일 경로가 클립보드에 복사되었습니다', path_copy_failed: '경로 복사 실패: ', session_rename: '대화 이름 변경', @@ -11255,6 +11482,22 @@ const LOCALES = { settings_save_btn: '설정 저장', settings_label_model: '기본 모델', settings_desc_model: '새 대화에 사용됩니다. 기존 대화는 선택된 모델을 유지합니다.', + settings_label_auxiliary_models: '보조 모델', + settings_desc_auxiliary_models: '비전, 압축, 제목 생성 등 보조 작업 라우팅. "자동"은 기본 채팅 모델을 사용합니다.', + settings_btn_reset_aux_models: '모두 자동으로 재설정', + settings_btn_apply_aux_models: '변경 사항 적용', + settings_aux_provider_auto: '기본 모델 사용', + settings_aux_model_auto: '자동 (제공자 기본값 사용)', + settings_aux_model_custom: '사용자 정의 모델…', + settings_aux_model_custom_prompt: '모델 ID 입력:', + settings_aux_loading: '보조 모델 로딩 중…', + settings_aux_load_failed: '보조 모델 설정을 로드할 수 없습니다. 에이전트 API가 사용 가능한지 확인하세요.', + settings_aux_reset_confirm_title: '보조 모델을 재설정하시겠습니까?', + settings_aux_reset_confirm_msg: '모든 보조 작업이 자동(기본 모델 사용)으로 설정됩니다.', + settings_aux_reset_done: '보조 모델이 자동으로 재설정됨', + settings_aux_save_failed: '보조 모델 저장 실패', + settings_aux_saved: '보조 모델 업데이트됨', + settings_aux_no_changes: '적용할 변경 사항 없음', settings_label_send_key: '전송 키', settings_label_theme: '테마', settings_label_skin: '스킨', @@ -11279,6 +11522,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '이전 메시징 세션 표시', settings_label_sync_insights: 'Insights에 동기화', settings_label_check_updates: '업데이트 확인', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '기본 Assistant 이름', settings_label_password: '접근 비밀번호', @@ -11467,6 +11711,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'reset 또는 compression으로 교체된 이전 Discord, Telegram, Slack, Weixin 세션을 표시합니다.', settings_desc_sync_insights: 'WebUI 토큰 사용량을 state.db에 반영하여 hermes /insights에 브라우저 세션 데이터가 포함되도록 합니다. 기본값은 꺼짐입니다.', settings_desc_check_updates: 'WebUI 또는 Agent의 새 버전이 있으면 배너를 표시합니다. 백그라운드에서 주기적으로 git fetch를 실행합니다.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: '기본 프로필에만 사용됩니다. 다른 프로필은 각 프로필 이름을 사용합니다.', settings_desc_password: '새 비밀번호를 설정하거나 변경하려면 입력하세요. 현재 설정을 유지하려면 비워 두세요.', @@ -12058,6 +12303,11 @@ const LOCALES = { mcp_tool_count: '{0} outils', mcp_enabled_yes: 'Activé', mcp_enabled_no: 'Désactivé', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'Outils MCP', mcp_tools_desc: 'Recherchez des outils connus sur les serveurs MCP actifs.', mcp_tools_search_placeholder: 'Outils de recherche par nom, serveur ou description…', @@ -12412,6 +12662,22 @@ const LOCALES = { settings_save_btn: 'Enregistrer les paramètres', settings_label_model: 'Modèle par défaut', settings_desc_model: 'Utilisé pour les nouvelles conversations. Les conversations existantes conservent leur modèle sélectionné.', + settings_label_auxiliary_models: 'Modèles auxiliaires', + settings_desc_auxiliary_models: 'Routage des tâches secondaires : vision, compression, génération de titres, etc. « Auto » utilise le modèle de chat principal.', + settings_btn_reset_aux_models: 'Tout réinitialiser à auto', + settings_btn_apply_aux_models: 'Appliquer les modifications', + settings_aux_provider_auto: 'utiliser le modèle principal', + settings_aux_model_auto: 'auto (utiliser la valeur par défaut du fournisseur)', + settings_aux_model_custom: 'Modèle personnalisé…', + settings_aux_model_custom_prompt: 'Entrez l\u2019ID du modèle :', + settings_aux_loading: 'Chargement des modèles auxiliaires…', + settings_aux_load_failed: 'Impossible de charger les paramètres des modèles auxiliaires. Vérifiez que l\u2019API de l\u2019agent est disponible.', + settings_aux_reset_confirm_title: 'Réinitialiser les modèles auxiliaires ?', + settings_aux_reset_confirm_msg: 'Cela définira toutes les tâches auxiliaires sur auto (utiliser le modèle principal).', + settings_aux_reset_done: 'Modèles auxiliaires réinitialisés à auto', + settings_aux_save_failed: 'Échec de la sauvegarde du modèle auxiliaire', + settings_aux_saved: 'Modèles auxiliaires mis à jour', + settings_aux_no_changes: 'Aucune modification à appliquer', settings_label_send_key: 'Envoyer la clé', settings_label_theme: 'Thème', settings_label_skin: 'Peau', @@ -12436,6 +12702,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Afficher les sessions de messagerie précédentes', settings_label_sync_insights: 'Synchroniser avec les insights', settings_label_check_updates: 'Vérifier les mises à jour', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nom par défaut de l\'assistant', settings_label_password: 'Mot de passe d\'accès', @@ -12634,6 +12901,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Affichez les anciennes sessions Discord, Telegram, Slack et Weixin remplacées par reset ou compression.', settings_desc_sync_insights: 'Met en miroir l\'utilisation du jeton WebUI dans state.db afin que Hermes /insights inclut les données de session du navigateur. Désactivé par défaut.', settings_desc_check_updates: 'Afficher une bannière lorsque des versions plus récentes de WebUI ou de l\'agent sont disponibles. Exécute périodiquement une récupération git en arrière-plan.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Utilisé uniquement pour le profil par défaut. Les autres profils utilisent leurs propres noms.', settings_desc_password: 'Saisissez un nouveau mot de passe pour le définir ou le modifier. Laissez vide pour conserver le paramètre actuel.', @@ -13148,6 +13416,1253 @@ const LOCALES = { usage_total: 'Total de tokens', usage_unknown: 'inconnu', workspace_auto_create_folder: 'Créer le dossier s\'il n\'existe pas', + }, + + tr: { + + + + + offline_title: 'Bağlantı kesildi', + offline_browser_detail: 'Tarayıcınız bu cihazın çevrimdışı olduğunu bildiriyor.', + offline_network_detail: 'Hermes\'e şu anda bu tarayıcıdan ulaşılamıyor.', + offline_autorefresh: 'Hermes\'e tekrar ulaşılabilir olduğunda bu sayfayı otomatik olarak yenileyeceğim.', + offline_check_now: 'Şimdi kontrol et', + offline_checking: 'Kontrol ediliyor\u2026', + offline_stream_waiting: 'Bağlantı kesildi. Yenilenmesi bekleniyor\u2026', + _lang: 'tr', + _label: 'Türkçe', + _speech: 'tr-TR', + // boot.js + cancelling: 'İptal ediliyor\u2026', + cancel_failed: 'İptal başarısız oldu:', + mic_denied: 'Mikrofon erişimi reddedildi. Tarayıcı izinlerini kontrol edin.', + mic_no_speech: 'Konuşma algılanmadı. Tekrar deneyin.', + mic_network: 'Konuşma tanıma kullanılamıyor.', + mic_error: 'Ses girişi hatası:', + voice_dictate: 'Dikte', + voice_dictate_active: 'Dikteyi durdur', + voice_mode_toggle: 'Ses modu', + voice_mode_toggle_active: 'Ses modundan çık', + voice_listening: 'Dinleniyor\u2026', + voice_speaking: 'Konuşuyor\u2026', + voice_thinking: 'Düşünüyor\u2026', + voice_error: 'Ses bu tarayıcıda desteklenmiyor', + voice_mode_active: 'Ses modu açık', + voice_mode_off: 'Ses modu kapalı', + session_imported: 'Oturum içe aktarıldı', + import_failed: 'İçe aktarma başarısız oldu:', + import_invalid_json: 'Geçersiz JSON', + image_pasted: 'Yapıştırılan resim:', + // messages.js + edit_message: 'Mesajı düzenle', + regenerate: 'Yanıtı yeniden oluştur', + copy: 'Kopyala', + copied: 'Kopyalandı!', + copy_failed: 'Kopyalama başarısız oldu', + selected_text_reply: 'Seçimle yanıtla', + selected_text_reply_title: 'Seçilen sohbet metnini alıntılanan bağlam olarak ekle', + selected_text_reply_appended: 'Seçilen metin besteciye eklendi', + + diff_loading: 'Fark yükleniyor', + diff_error: 'Yama dosyası yüklenemedi', + diff_too_large: 'Yama dosyası satır içi görüntülenemeyecek kadar büyük', + tree_view: 'Ağaç', + raw_view: 'Çiğ', + parse_failed_note: 'ayrıştırma başarısız oldu', + you: 'Sen', + mcp_servers_title: 'MCP Sunucuları', + mcp_servers_desc: 'config.yaml dosyasında yapılandırılmış MCP sunucularını görüntüleyin.', + mcp_no_servers: 'Hiçbir MCP sunucusu yapılandırılmadı.', + mcp_add_server: '+ Sunucu Ekle', + mcp_field_name: 'Sunucu Adı', + mcp_transport_label: 'Taşıma Türü', + mcp_field_command: 'Emretmek', + mcp_field_args: 'Bağımsız Değişkenler (virgülle ayrılmış)', + mcp_field_url: 'URL\'si', + mcp_field_timeout: 'Zaman aşımı (saniye)', + mcp_save: 'Kaydetmek', + mcp_cancel: 'İptal etmek', + mcp_name_required: 'Sunucu adı gerekli.', + mcp_url_required: 'HTTP aktarımı için URL gereklidir.', + mcp_command_required: 'Stdio aktarımı için komut gereklidir.', + mcp_saved: 'MCP sunucusu kaydedildi.', + mcp_save_failed: 'MCP sunucusu kaydedilemedi.', + mcp_delete_confirm_title: 'MCP Sunucusunu Sil', + mcp_delete_confirm_message: '"{0}" MCP sunucusu silinsin mi? Bu eylem geri alınamaz.', + mcp_deleted: 'MCP sunucusu silindi.', + mcp_delete_failed: 'MCP sunucusu silinemedi.', + mcp_load_failed: 'MCP sunucuları yüklenemedi.', + mcp_restart_hint: 'Sunucu değişiklikleri şimdilik burada salt okunurdur. Değişikliklerin etkili olması için config.yaml dosyasını düzenleyin ve Hermes\'i yeniden başlatın.', + mcp_enable_server: 'Bu MCP sunucusunu etkinleştir', + mcp_disable_server: 'Bu MCP sunucusunu devre dışı bırak', + mcp_enabled_toast: (name) => `"${name}" MCP sunucusu etkinleştirildi.`, + mcp_disabled_toast: (name) => `"${name}" MCP sunucusu devre dışı bırakıldı.`, + mcp_toggle_failed: 'MCP sunucusu güncellenemedi.', + open_in_vscode: 'VS Code\'da aç', + open_in_vscode_failed: 'VS Code\'da açılamadı: ', + settings_label_ignore_agent_updates: 'Agent güncellemelerini yoksay', + settings_desc_ignore_agent_updates: 'WebUI güncelleme denetimlerini açık tutun, ancak Agent güncelleme bildirimlerini gizleyin ve Agent güncelleme getirme işlemlerini atlayın.', + mcp_toggle_followup: 'Etkinleştirme/devre dışı bırakma kontrolleri, MCP yeniden yükleme semantiği açıkça ortaya çıkana kadar kasıtlı olarak ertelenir.', + mcp_status_active: 'Aktif', + mcp_status_configured: 'Yapılandırılmış', + mcp_status_disabled: 'Engelli', + mcp_status_invalid_config: 'Geçersiz yapılandırma', + mcp_status_unknown: 'Bilinmiyor', + mcp_tool_count: '{0} araç', + mcp_enabled_yes: 'Etkinleştirilmiş', + mcp_enabled_no: 'Engelli', + mcp_tools_title: 'MCP Araçları', + mcp_tools_desc: 'Aktif MCP sunucularında bilinen araçları arayın.', + mcp_tools_search_placeholder: 'Araçlarda ada, sunucuya veya açıklamaya göre arama yapın\u2026', + mcp_tools_no_tools: 'Etkin çalışma zamanı envanterinde hiçbir MCP aracı mevcut değil.', + mcp_tools_no_matches: 'Aramanızla eşleşen MCP aracı yok.', + mcp_tools_load_failed: 'MCP araçları yüklenemedi.', + mcp_tools_schema_empty: 'Şema parametresi yok.', + mcp_tools_runtime_note: 'Araç envanteri yalnızca önceden bilinen etkin MCP çalışma zamanı verilerini kullanır; WebUI sunucuları başlatmaz veya sorgulamaz.', + mcp_tools_summary_no_matches: (query,total) => `“${query}” ile eşleşen MCP aracı yok (toplam ${total} MCP araç).`, + mcp_tools_summary_none: 'Gösterilecek MCP aracı yok.', + mcp_tools_summary_matching: (query) => `"${query}" ile eşleşen`, + mcp_tools_summary_total_note: (total) => `(${total} toplam MCP araç)`, + mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `${start}-${end} / ${filtered} MCPtools${searchNote}${totalNote} gösteriliyor. Sayfa ${page} / ${pages}.`, + mcp_tools_page_size_prefix: 'Göstermek', + mcp_tools_page_size_suffix: 'sayfa başına', + mcp_tools_per_page_aria: 'Sayfa başına MCP araçları', + mcp_tools_inactive_configured_servers: (servers) => `Yapılandırılmış ancak bu WebUI çalışma zamanında etkin değil: ${servers}.`, + mcp_tools_pagination_label: 'MCP araçları sayfalandırması', + mcp_tools_previous_page: '‹ Önceki', + mcp_tools_previous_page_aria: 'Önceki MCP araçları sayfası', + mcp_tools_next_page: 'Sonraki >', + mcp_tools_next_page_aria: 'Sonraki MCP araçları sayfası', + thinking: 'Düşünme', + expand_all: 'Tümünü genişlet', + collapse_all: 'Tümünü daralt', + edit_failed: 'Düzenleme başarısız oldu:', + regen_failed: 'Yeniden oluşturma başarısız oldu:', + reconnect_active: 'Halen bir yanıt oluşturuluyor. Hazır olduğunuzda yeniden yüklensin mi?', + reconnect_finished: 'En son ayrıldığınızda bir yanıt sürüyordu. Mesajlar güncellenmiş olabilir.', + // approval card + approval_heading: 'Onay gerekli', + approval_desc_prefix: 'Tehlikeli komut algılandı', + approval_btn_once: 'Bir kez izin ver', + approval_btn_once_title: 'Bu tek komuta izin ver (Enter)', + approval_btn_session: 'Oturuma izin ver', + approval_btn_session_title: 'Bu konuşma oturumuna izin ver', + approval_btn_always: 'Her zaman izin ver', + approval_btn_always_title: 'Bu komut düzenine her zaman izin ver', + approval_btn_deny: 'Reddetmek', + approval_btn_deny_title: 'Reddet — bu komutu çalıştırma', + approval_responding: 'Yanıt veriliyor\u2026', + clarify_heading: 'Açıklama gerekli', + clarify_hint: 'Bir seçim yapın veya kendi cevabınızı aşağıya yazın.', + clarify_other: 'Diğer', + clarify_send: 'Göndermek', + clarify_input_placeholder: 'Yanıtınızı yazın\u2026', + clarify_responding: 'Yanıt veriliyor\u2026', + untitled: 'İsimsiz', + n_messages: (n) => `${n}개 메시지`, + load_older_messages: '↑ Eski mesajları yüklemek için yukarı kaydırın veya tıklayın', + session_jump_start: 'Başlangıç', + session_jump_start_label: 'Oturumun başlangıcına atla', + session_jump_end: 'Son', + session_jump_end_label: 'Oturumun sonuna atla', + jump_to_question: 'sorgulamak', + jump_to_question_label: 'Bu yanıt için soruya geçin', + queued_label: 'Yanıttan sonra gönderilir', + queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`, + queued_cancel: 'Sıraya alınmış mesajı iptal et', + model_unavailable: '(mevcut değil)', + model_unavailable_title: 'Bu model artık mevcut sağlayıcı listenizde değil', + provider_mismatch_warning: (m,p) => `"${m}" yapılandırılmış sağlayıcınızla (${p}) çalışmayabilir. Yine de gönderin veya geçiş yapmak için terminalinizde \`hermes model\` komutunu çalıştırın.`, + provider_mismatch_label: 'Sağlayıcı uyumsuzluğu', + model_not_found_label: 'Model bulunamadı', + model_custom_label: 'Özel model kimliği', + model_custom_placeholder: 'örneğin openai/gpt-5.4', + model_search_placeholder: 'Modelleri ara\u2026', + session_toolsets: 'Oturum araç setleri', + session_toolsets_desc: 'Bu oturum için kullanılabilir araçları kısıtlayın (boş = genel yapılandırma)', + session_toolsets_global: 'Genel (varsayılan)', + session_toolsets_custom: 'Özel', + session_toolsets_placeholder: 'araç1, araç2, \u2026', + session_toolsets_apply: 'Uygula', + session_toolsets_clear: 'Temizle (geneli kullan)', + session_toolsets_applied: 'Araç setleri güncellendi', + session_toolsets_cleared: 'Araç setleri temizlendi — genel yapılandırma kullanılıyor', + session_toolsets_failed: 'Araç setleri güncellenemedi: ', + model_search_no_results: 'Hiçbir model bulunamadı', + model_group_configured: 'Yapılandırılmış', + ws_search_placeholder: 'Çalışma alanlarını arayın\u2026', + ws_no_results: 'Çalışma alanı bulunamadı', + workspace_new_worktree_conversation: 'Worktree\'de yeni konuşma', + workspace_new_worktree_conversation_meta: 'Bu çalışma alanı için yalıtılmış bir git çalışma ağacı oluşturun.', + workspace_worktree_created: 'Worktree görüşmesi oluşturuldu', + workspace_worktree_failed: 'Çalışma ağacı oluşturma işlemi başarısız oldu:', + session_worktree_badge: 'Çalışma Ağacı', + model_scope_advisory: 'Bir sonraki mesajınızdan itibaren bu görüşmeye uygulanır.', + model_scope_toast: 'Bir sonraki mesajınızdan itibaren bu görüşmeye uygulanır.', + // commands.js + cmd_clear: 'Konuşma mesajlarını temizle', + cmd_compress: 'Konuşma içeriğini manuel olarak sıkıştırın (kullanım: /compress [konuya odaklan])', + ctx_compress_hint: 'Yer açmak için bağlamı sıkıştırın →', + ctx_compress_action: '⚠ Şimdi serbest bağlama sıkıştırın', + cmd_compact_alias: '/compress için eski takma ad', + cmd_model: 'Modeli değiştir (ör. /model gpt-4o)', + cmd_workspace: 'Çalışma alanını ada göre değiştir', + cmd_terminal: 'Çalışma alanı terminalini açın', + cmd_new: 'Yeni bir sohbet oturumu başlatın', + cmd_usage: 'Token kullanımı ekranını aç/kapat', + cmd_theme: 'Görünümü değiştir (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)', + cmd_personality: 'Temsilci kişiliğini değiştir', + cmd_skills: 'Mevcut Hermes becerilerini listele', + available_commands: 'Mevcut komutlar:', + type_slash: 'Komutları görmek için / yazın', + conversation_cleared: 'Görüşme temizlendi', + command_label: 'Komut', + context_compaction_label: 'Bağlam sıkıştırma', + retrieval_context_label: 'Dizine alınmış bağlam', + retrieval_context_preview: 'Daha önceki mesajlar depolanır ve bağlam araçlarıyla alınabilir', + preserved_task_list_label: 'Korunmuş görev listesi', + reference_only_label: 'Yalnızca referans', + model_usage: 'Kullanım: /model ', + no_model_match: 'Eşleşen model yok: "', + switched_to: 'Şuraya geçildi:', + workspace_usage: 'Kullanım: /workspace ', + no_workspace_match: 'Eşleşen çalışma alanı yok: "', + switched_workspace: 'Çalışma alanına geçildi:', + workspace_switch_failed: 'Çalışma alanı anahtarı başarısız oldu:', + new_session: 'Yeni oturum oluşturuldu', + new_session_creating: 'Yeni görüşme oluşturuluyor\u2026', + compressing: 'Bağlam sıkıştırması isteniyor...', + compress_running_label: 'Sıkıştırma', + compress_complete_label: 'Sıkıştırma tamamlandı', + auto_compress_label: 'Otomatik sıkıştırma', + compress_failed_label: 'Sıkıştırma başarısız oldu', + focus_label: 'Odak', + token_usage_on: 'Jeton kullanımı açık', + token_usage_off: 'Jeton kullanımı kapalı', + usage_cache_hit_detail: 'Önbellek: {0}% isabet ({1} okuma / {2} yazma)', + usage_cached_percent: '{0}% önbelleğe alındı', + theme_usage: 'Kullanım: /theme ', + theme_set: 'Tema:', + no_active_session: 'Aktif oturum yok', + cmd_queue: 'Bir sonraki dönüş için bir mesajı sıraya koy', + cmd_goal: 'Kalıcı bir hedef belirleyin veya inceleyin', + goal_evaluating_progress: 'Hedef ilerlemesi değerlendiriliyor\u2026', + goal_working_toward: 'Hedefe doğru çalışmak\u2026', + goal_continuing_toast: 'Hedefe doğru devam\u2026', + goal_status_none: 'Aktif hedef yok. /goal ile bir tane ayarlayın.', + goal_status_active: (turns, max_turns, goal) => `⊙ Hedef (aktif, ${turns}/${max_turns} tur): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Hedef (duraklatıldı, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Hedef tamamlandı (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Hedef belirlendi (${turns}-dönüş bütçesi): ${goal}`, + goal_paused: (goal) => `⏸ Hedef duraklatıldı: ${goal}`, + goal_resumed: (goal) => `▶ Hedef devam ettirildi: ${goal}`, + goal_cleared: 'Hedef temizlendi.', + goal_no_goal: 'Aktif hedef yok.', + goal_achieved: (reason) => `✓ Hedefe ulaşıldı: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Hedef duraklatıldı — ${turns}/${max_turns} tur kullanıldı. Devam etmek için /goal devamını, durdurmak için /goal clear komutunu kullanın.`, + goal_continuing: (turns, max_turns, reason) => `↻ Hedefe doğru devam ediyoruz (${turns}/${max_turns}): ${reason}`, + cmd_interrupt: 'Mevcut dönüşü iptal et ve yeni bir mesaj gönder', + cmd_steer: 'Temsilciyi kesintiye uğratmadan dönüş ortası düzeltmesi enjekte edin', + cmd_queue_no_msg: 'Kullanım: /queue ', + cmd_queue_not_busy: 'Etkin görev yok; yalnızca normal şekilde gönderin', + cmd_queue_confirm: 'İleti sıraya alındı', + cmd_interrupt_no_msg: 'Kullanım: /interrupt ', + cmd_interrupt_confirm: 'Kesintiye uğradı — yeni mesaj gönderilir', + cmd_steer_no_msg: 'Kullanım: /steer ', + cmd_steer_fallback: 'Yönlendirme kullanılamıyor - bunun yerine bir sonraki dönüş için sıraya alındı', + cmd_steer_delivered: 'Yönlendirme teslim edildi — temsilci bunu bir sonraki takım sonucunda görecek', + steer_leftover_queued: 'Direksiyon bir sonraki dönüş için kuyruğa alındı', + busy_steer_fallback: 'Yönlendirme kullanılamıyor — bir sonraki dönüş için kuyruğa alındı', + busy_interrupt_confirm: 'Kesintiye uğradı — yeni mesaj gönderilir', + settings_label_busy_input_mode: 'Meşgul giriş modu', + settings_desc_busy_input_mode: 'Aracı çalışırken bir mesaj gönderdiğinizde ne olacağını kontrol eder. Sıra bekler; Interrupt iptal eder ve yeniden başlar; Steer, dönüşün ortasında kesintiye uğramadan bir düzeltme enjekte eder (aracı veya akış mevcut olmadığında kuyruğa geri döner).', + settings_label_fade_text_effect: 'Metin efektini soldur', + settings_desc_fade_text_effect: 'Asistan yanıt verirken yeni aktarılan sözcüklerin geçişini sağlayın. OpenWebUI\'ye benzer; Maksimum performans için varsayılan olarak kapalıdır.', + settings_busy_input_mode_queue: 'Sıra takibi', + settings_busy_input_mode_interrupt: 'Mevcut dönüşü kes', + settings_busy_input_mode_steer: 'Yönlendirme (dönüş ortası düzeltme)', + + slash_skill_badge: 'Yetenek', + slash_skill_desc: 'Bu beceriyi çağır', + cmd_stop: 'Mevcut yanıtı durdur', + cmd_title: 'Oturum başlığını alın veya ayarlayın', + cmd_retry: 'Son mesajı tekrar gönder', + cmd_undo: 'Son değişimi kaldır', + cmd_btw: 'Bir yan soru sorun (geçici)', + cmd_btw_usage: '/btw — oturum bağlamını kullanarak bir yan soru sorun', + cmd_background: 'Arka planda bir istem çalıştır', + cmd_background_usage: '/background — engellemeden paralel olarak çalıştır', + btw_asking: 'Yan soruyu sormak...', + btw_label: 'Yan soru – tarihte değil', + btw_done: 'Yan soru cevaplandı', + btw_no_answer: 'Cevap alınmadı.', + btw_failed: 'Yan soru başarısız oldu:', + bg_running: 'Arka planda çalışıyor...', + bg_complete: 'Arka plan görevi tamamlandı', + bg_label: 'Arka plan sonucu:', + bg_no_answer: '(cevap yok)', + bg_failed: 'Arka plan görevi başarısız oldu:', + undo_exchange: 'Son değişimi geri al', + cmd_status: 'Oturum bilgilerini göster', + cmd_voice: 'Mikrofon girişini değiştir', + stream_stopped: 'Yanıt durduruldu.', + no_active_task: 'Durdurulacak etkin görev yok.', + cancel_unavailable: 'İptal kullanılamıyor.', + retry_failed: 'Yeniden deneme başarısız oldu:', + undo_failed: 'Geri alma başarısız oldu:', + undid_n_messages: 'Kaldırıldı', + undid_messages_suffix: 'mesaj(lar).', + status_heading: 'Oturum Durumu', + status_session_id: 'Oturum Kimliği', + status_title: 'Başlık', + status_model: 'Modeli', + status_provider: 'sağlayıcı', + status_workspace: 'Çalışma alanı', + status_personality: 'Kişilik', + status_messages: 'Mesajlar', + status_agent_running: 'Aracı çalışıyor', + status_profile: 'Profil', + status_hermes_home: 'Hermes\'in evi', + status_started: 'Başlatıldı', + status_tokens: 'Jetonlar', + status_updated: 'Güncellendi', + status_ephemeral: 'Geçici anlık görüntü — transkript geçmişine kaydedilmez.', + status_no_tokens: 'Hiç jeton kullanılmadı', + status_unknown: 'Bilinmiyor', + status_yes: 'Evet', + status_no: 'HAYIR', + status_load_failed: 'Durum yüklenemedi:', + title_current: 'Mevcut başlık', + title_change_hint: 'Yeniden adlandırmak için `/title ` kullanın.', + title_set: 'Başlık şu şekilde ayarlandı:', + cmd_webui_only_session: 'Bu komut CLI\'den içe aktarılan oturumlar için kullanılamaz.', + cmd_voice_use_mic: 'Bestecideki mikrofon düğmesine tıklayın.', + usage_heading: 'Jeton Kullanımı', + usage_default_model: 'varsayılan', + usage_unknown: 'bilinmiyor', + usage_input_tokens: 'Giriş jetonları', + usage_output_tokens: 'Çıkış jetonları', + usage_total: 'Toplam jeton', + usage_estimated_cost: 'Tahmini maliyet', + usage_settings_tip: 'Not: Maliyet tahminleri yaklaşık değerlerdir.', + usage_load_failed: 'Kullanım yüklenemedi:', + usage_personality_none: 'hiçbiri', + no_personalities: 'Hiç kişilik bulunamadı (onları ~/.hermes/personalities/ dosyasına ekleyin)', + available_personalities: 'Mevcut kişilikler:', + personality_switch_hint: 'Geçiş yapmak için `/kişilik ` kullanın veya temizlemek için `/kişilik yok` kullanın.', + personalities_load_failed: 'Kişilikler yüklenemedi', + personality_cleared: 'Kişilik temizlendi', + personality_set: 'Kişilik:', + failed_colon: 'Arızalı:', + // ui.js + no_workspace: 'Çalışma alanı yok', + terminal_open_title: 'Çalışma alanı terminalini aç', + terminal_no_workspace_title: 'Terminali açmak için bir çalışma alanı seçin', + terminal_title: 'terminal', + terminal_clear: 'Temizle', + terminal_copy_output: 'Çıktıyı kopyala', + terminal_restart: 'Tekrar başlat', + terminal_collapse: 'Yıkılmak', + terminal_expand: 'Genişletmek', + terminal_close: 'Kapalı', + terminal_input_placeholder: 'Bir komut çalıştır...', + terminal_start_failed: 'Terminal başlatma başarısız oldu:', + terminal_input_failed: 'Terminal girişi başarısız oldu:', + terminal_copy_failed: 'Kopyalama başarısız oldu:', + terminal_error: 'Terminal hatası', + workspace_empty_no_path: 'Çalışma alanı seçilmedi. Dosyalara göz atmak için Ayarlar \u2192 Çalışma Alanı\'nda bir çalışma alanı ayarlayın.', + workspace_empty_dir: 'Bu çalışma alanı boş.', + workspace_show_hidden_files: 'Gizli dosyaları göster', + workspace_show_hidden_files_desc: 'Dosya ağacına .DS_Store, .git, node_modules ve diğer gizli / sistem dosyalarını ekleyin.', + workspace_hidden_files_visible: 'gizli görünür', + workspace_hidden_files_visible_title: 'Gizli dosyalar görünür — seçenekler için tıklayın', + workspace_options: 'Çalışma alanı seçenekleri', + dialog_confirm_title: 'İşlemi onayla', + dialog_prompt_title: 'Bir değer girin', + dialog_confirm_btn: 'Onaylamak', + // workspace.js + unsaved_confirm: 'Önizlemede kaydedilmemiş değişiklikleriniz var. Atılıp gezinilsin mi?', + discard: 'At', + save: 'Kaydetmek', + edit: 'Düzenlemek', + clear: 'Temizle', + create: 'Oluştur', + remove: 'Kaldır', + save_title: 'Değişiklikleri kaydet', + edit_title: 'Bu dosyayı düzenle', + saved: 'Kaydedildi', + save_failed: 'Kaydetme başarısız oldu:', + image_load_failed: 'Resim yüklenemedi', + file_open_failed: 'Dosya açılamadı', + downloading: (name) => `${name} indiriliyor\u2026`, + double_click_rename: 'Yeniden adlandırmak için çift tıklayın', + renamed_to: 'Yeniden adlandırıldı', + rename_failed: 'Yeniden adlandırma başarısız oldu:', + delete_title: 'Sil', + delete_confirm: (name) => `${name} silinsin mi?`, + delete_dir_confirm: (name) => `"${name}" klasörü ve tüm içeriği silinsin mi?`, + rename_title: 'Yeniden isimlendirmek', + rename_prompt: 'Yeni isim:', + deleted: 'Silindi', + delete_failed: 'Silinemedi:', + reveal_in_finder: 'Dosya Yöneticisinde Göster', + reveal_failed: 'Açıklanamadı:', + copy_file_path: 'Dosya yolunu kopyala', + download_folder: 'İndirme klasörü', + path_copied: 'Dosya yolu panoya kopyalandı', + path_copy_failed: 'Yol kopyalanamadı:', + session_rename: 'Konuşmayı yeniden adlandır', + session_rename_desc: 'Bu görüşmenin başlığını düzenleyin', + new_file_prompt: 'Yeni dosya adı (örn. Notes.md):', + project_name_prompt: 'Proje adı:', + created: 'Oluşturuldu', + create_failed: 'Oluşturma başarısız oldu:', + new_folder_prompt: 'Yeni klasör adı:', + folder_created: 'Oluşturulan klasör', + folder_create_failed: 'Klasör oluşturulamadı:', + workspace_auto_create_folder: 'Mevcut değilse klasör oluşturun', + folder_add_as_space_btn: 'Alan Olarak Ekle', + folder_add_as_space_msg: 'Bu klasör çalışma alanı listenize yeni bir alan olarak eklensin mi?', + archive_extracted: (n, c) => `${c} arşivden ${n} dosya çıkarıldı`, + folder_add_as_space_title: 'Alan olarak eklensin mi?', + remove_title: 'Kaldırmak', + empty_dir: '(boş)', + upload_failed: 'Yükleme başarısız oldu:', + upload_too_large: (maxMb, fileMb) => `Dosya çok büyük (${fileMb} MB). Maksimum yükleme boyutu ${maxMb} MB'tır.`, + all_uploads_failed: (n) => `${n} yüklemenin tümü başarısız oldu`, + session_pin: 'Görüşmeyi sabitle', + session_unpin: 'Görüşmenin sabitlemesini kaldır', + session_pin_desc: 'Bu sohbeti en üstte tut', + session_unpin_desc: 'Sabitlenenlerden kaldır', + session_pin_failed: 'Pin başarısız oldu:', + session_move_project: 'Projeye taşı', + session_move_project_desc_has: 'Bu görüşmenin projesini değiştirin', + session_move_project_desc_none: 'Bu görüşmeye bir proje atayın', + session_archive: 'Konuşmayı arşivle', + session_hide_external: 'Listeden gizle', + session_restore: 'Konuşmayı geri yükle', + session_archive_desc: 'Arşivlendi gösterilene kadar bu konuşmayı gizle', + session_archive_worktree_desc: 'Bu konuşmayı gizle; çalışma ağacını diskte tut', + session_hide_external_desc: 'Kaynak geçmişini silmeden içe aktarılan bu oturumu WebUI\'dan gizleyin.', + session_restore_desc: 'Bu görüşmeyi ana listeye geri getir', + session_archived: 'Oturum arşivlendi', + session_archived_worktree: 'Oturum arşivlendi. Worktree diskte kalır.', + session_hidden: 'Oturum listede gizlendi', + session_restored: 'Oturum geri yüklendi', + session_archive_failed: 'Arşivleme başarısız oldu:', + session_duplicate: 'Yinelenen görüşme', + session_duplicate_desc: 'Aynı çalışma alanı ve modelle bir kopya oluşturun', + session_duplicated: 'Oturum kopyalandı', + session_duplicate_failed: 'Kopyalama başarısız oldu:', + session_stop_response: 'Yanıtı durdur', + session_stop_response_desc: 'Bu görüşme için çalışan yanıtı iptal edin', + session_delete: 'Konuşmayı sil', + session_delete_desc: 'Bu görüşmeyi kalıcı olarak kaldır', + session_delete_confirm: 'Bu görüşme silinsin mi?', + session_delete_worktree_desc: 'Yalnızca WebUI görüşmesini silin; çalışma ağacını diskte tut', + session_delete_worktree_confirm: (path) => `Bu görüşme silinsin mi? ${path} worktree diske kalır.`, + session_deleted: 'Görüşme silindi', + session_deleted_worktree: 'Görüşme silindi. Worktree diskte kalır.', + session_worktree_remove: 'Çalışma ağacını kaldır', + session_worktree_remove_desc: (path) => `${path} git worktree'sini diskten siler`, + session_worktree_remove_confirm: (path) => `Git worktree diskten silinsin mi?\n\nYol: ${path}\n\nTüm worktree dizini silinir. Oturum verileri WebUI'de kalır.`, + session_worktree_remove_not_exists: (path) => `${path} worktree'si artık diskte yok.`, + session_worktree_remove_confirm_label: 'Kaldırmak', + session_worktree_removed: 'Worktree kaldırıldı.', + session_worktree_remove_failed: 'Çalışma ağacı kaldırılamadı:', + session_worktree_remove_status_failed: 'Çalışma ağacı durumu okunamadı:', + session_worktree_remove_locked_by_stream: 'Kaldırılamıyor — etkin bir akış oturumu bu çalışma ağacını kullanıyor.', + session_worktree_remove_locked_by_terminal: 'Kaldırılamıyor — etkin bir terminal oturumu bu çalışma ağacını kullanıyor.', + session_worktree_remove_unsafe_blocked: 'Bu çalışma ağacını kaldırmadan önce yerel değişiklikleri veya gönderilmemiş taahhütleri çözümleyin.', + session_worktree_remove_dirty_warning: 'UYARI: Bu çalışma ağacında kaybolacak kaydedilmemiş değişiklikler var.', + session_worktree_remove_untracked_warning: (count) => `${count} izlenmeyen dosya kalıcı olarak silinecek.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} gönderilmemiş commit kaybolacak.`, + session_select_mode: 'Seçme', + session_select_mode_desc: 'Toplu olarak yönetilecek konuşmaları seçin', + session_select_all: 'Tümünü seç', + session_deselect_all: 'Tümünün seçimini kaldır', + session_selected_count: '{0} seçildi', + session_batch_archive: 'Arşiv', + session_batch_delete: 'Silmek', + session_batch_move: 'Projeye taşı', + session_batch_delete_confirm: '{0} görüşme silinsin mi?', + session_batch_archive_confirm: '{0} ileti dizisi arşivlensin mi?', + session_batch_delete_worktree_confirm: '{0} görüşme silinsin mi? {1} çalışma ağacı destekli konuşma, çalışma ağacı dizinlerini diskte bırakacak.', + session_batch_archive_worktree_confirm: '{0} ileti dizisi arşivlensin mi? {1} çalışma ağacı destekli görüşme, çalışma ağacı dizinlerini diskte tutacak.', + session_no_selection: 'Hiçbir görüşme seçilmedi', + // settings panel + settings_heading_title: 'Kontrol Merkezi', + settings_heading_subtitle: 'Tercihler, konuşma araçları ve sistem kontrolleri.', + settings_section_conversation_title: 'Konuşma', + settings_section_appearance_title: 'Dış görünüş', + settings_section_appearance_meta: 'Tema, vurgu renkleri ve görsel stil.', + settings_section_preferences_title: 'Tercihler', + settings_section_preferences_meta: 'Hermes Web Kullanıcı Arayüzü için varsayılanlar ve Kullanıcı Arayüzü davranışı.', + settings_section_system_title: 'Sistem', + settings_section_system_meta: 'Örnek sürümü ve erişim kontrolleri.', + settings_check_now: 'Şimdi kontrol et', + settings_checking: 'Kontrol ediliyor\u2026', + settings_up_to_date: 'Güncel \u2713', + settings_updates_available: '{count} güncelleme mevcut', + settings_updates_disabled: 'Güncelleme kontrolleri devre dışı bırakıldı', + settings_update_check_failed: 'Güncelleme kontrolü başarısız oldu', + settings_label_workspace_panel_open: 'Çalışma alanı panelini varsayılan olarak açık tut', + settings_desc_workspace_panel_open: 'Etkinleştirildiğinde, çalışma alanı / dosya tarayıcı paneli her yeni oturumda otomatik olarak açılır. Yine de istediğiniz zaman manuel olarak kapatabilirsiniz.', + settings_label_session_jump_buttons: 'Oturum atlama düğmelerini göster', + settings_desc_session_jump_buttons: 'Uzun oturum geçmişlerini okurken kayan Başlat ve Bitir düğmelerini gösterin.', + + settings_label_session_endless_scroll: 'Yukarı kaydırırken eski mesajları yükle', + + settings_desc_session_endless_scroll: 'Etkinleştirildiğinde, yukarı doğru kaydırdığınızda eski mesajlar otomatik olarak yüklenir. Devre dışı bırakıldığında eski mesajlar düğmesini kullanın.', + + settings_label_tab_visibility: 'Kenar çubuğu sekmeleri', + settings_desc_tab_visibility: 'Kenar çubuğunda ve rayda hangi sekmelerin görüneceğini seçin. Sohbet ve Ayarlar her zaman görünür durumdadır.', + open_in_browser: 'Tarayıcıda aç', + settings_dropdown_conversation: 'Konuşma', + settings_dropdown_appearance: 'Dış görünüş', + settings_dropdown_preferences: 'Tercihler', + settings_dropdown_providers: 'Sağlayıcılar', + settings_dropdown_system: 'Sistem', + settings_tab_conversation: 'Konuşma', + settings_tab_appearance: 'Dış görünüş', + settings_tab_preferences: 'Tercihler', + settings_tab_plugins: 'Eklentiler', + settings_plugins_title: 'Eklentiler', + settings_plugins_meta: 'Yüklü Hermes eklentilerini ve yaşam döngüsü kancalarını görüntüleyin. Bu panel salt okunurdur.', + settings_plugins_empty: 'Görünür Hermes eklentisi yok. Burada görmek için Hermes CLI/config üzerinden yükleyin veya etkinleştirin.', + plugins_unnamed: 'Adsız eklenti', + plugins_no_description: 'Açıklama yok.', + plugins_no_hooks: 'Kayıtlı yaşam döngüsü kancası yok', + plugins_registered_hooks: 'Kayıtlı kancalar', + plugins_enabled: 'Etkin', + plugins_disabled: 'Devre dışı', + plugins_active_provider: 'Etkin (sağlayıcı)', + plugins_provider_no_hooks: 'Sağlayıcı eklentisi — agent görünürlük kancası yok', + plugins_load_failed: 'Eklentiler yüklenemedi: ', + settings_tab_system: 'Sistem', + settings_title: 'Ayarlar', + settings_save_btn: 'Ayarları Kaydet', + settings_label_model: 'Varsayılan Model', + settings_label_auxiliary_models: 'Yardımcı Modeller', + settings_desc_auxiliary_models: 'Görü, sıkıştırma, başlık oluşturma vb. yan görevler için yönlendirme. "Otomatik" ana sohbet modelinizi kullanır.', + settings_aux_loading: 'Yardımcı modeller yükleniyor…', + settings_aux_load_failed: 'Yardımcı model ayarları yüklenemedi. Agent API\'sinin kullanılabilir olduğundan emin olun.', + settings_aux_model_auto: 'otomatik (sağlayıcı varsayılanını kullan)', + settings_aux_provider_auto: 'ana modeli kullan', + settings_aux_model_custom: 'Özel model…', + settings_aux_model_custom_prompt: 'Model kimliğini girin:', + settings_aux_no_changes: 'Uygulanacak değişiklik yok', + settings_aux_saved: 'Yardımcı modeller güncellendi', + settings_aux_save_failed: 'Yardımcı model kaydedilemedi', + settings_aux_reset_confirm_title: 'Yardımcı modeller sıfırlansın mı?', + settings_aux_reset_confirm_msg: 'Bu, tüm yardımcı görevleri otomatik (ana modeli kullan) olarak ayarlayacaktır.', + settings_aux_reset_done: 'Yardımcı modeller otomatiğe sıfırlandı', + settings_btn_apply_aux_models: 'Değişiklikleri uygula', + settings_btn_reset_aux_models: 'Tümünü otomatiğe sıfırla', + settings_desc_model: 'Yeni konuşmalar için kullanılır. Mevcut konuşmalar seçilen modellerini korur.', + settings_label_send_key: 'Anahtar Gönder', + settings_label_theme: 'Tema', + settings_label_skin: 'Deri', + settings_label_font_size: 'Yazı tipi boyutu', + font_size_small: 'Küçük', + font_size_default: 'Varsayılan', + font_size_large: 'Büyük', + font_size_xlarge: 'Ekstra Büyük', + settings_autosave_saving: 'Kaydediliyor\u2026', + settings_autosave_saved: 'Kaydedildi', + settings_autosave_failed: 'Kaydetme başarısız oldu', + settings_autosave_retry: 'Yeniden dene', + settings_label_language: 'Dil', + settings_label_quota_chip: 'Bestecide sağlayıcı kota çipini göster', + settings_desc_quota_chip: 'Besteci altbilgisinde bir ortam kalan kota göstergesini (örn. OpenRouter kredi bakiyesi) görüntüler. Varsayılan kapalı. Besteciyi dizüstü bilgisayar ve standart masaüstü genişliklerinde düzenli tutmak için yalnızca etkinleştirildiğinde geniş ekranlarda (≥1400 piksel) görünür.', + settings_label_hide_suggestions: 'Yeni sohbet önerilerini gizle', + settings_desc_hide_suggestions: 'Yanlışlıkla dokunmayı önlemek için boş yeni sohbet ekranındaki üç varsayılan öneri düğmesini gizleyin.', + settings_label_token_usage: 'Jeton kullanımını göster', + settings_label_sidebar_density: 'Kenar çubuğu yoğunluğu', + cmd_reasoning: 'Düşünme görünürlüğünü değiştirin (göster/gizle), çaba düzeyini ayarlayın veya mevcut durumu kontrol edin', + settings_label_external_sessions: 'WebUI olmayan oturumları göster', + settings_label_previous_messaging_sessions: 'Önceki mesajlaşma oturumlarını göster', + settings_label_sync_insights: 'Analizlerle senkronize edin', + settings_label_check_updates: 'Güncellemeleri kontrol edin', + settings_label_whats_new_summary: "Summarize What's New with AI", + settings_label_bot_name: 'Varsayılan asistanın adı', + settings_label_password: 'Erişim Şifresi', + settings_saved: 'Ayarlar kaydedildi', + settings_save_failed: 'Kaydetme başarısız oldu:', + settings_load_failed: 'Ayarlar yüklenemedi:', + settings_saved_pw: 'Ayarlar kaydedildi - şifre koruması etkin ve bu tarayıcıda oturum açık kalıyor', + settings_saved_pw_updated: 'Ayarlar kaydedildi — şifre güncellendi', + // login page (used server-side via /api/i18n/login endpoint) + login_title: 'Oturum aç', + login_subtitle: 'Devam etmek için şifrenizi girin', + login_placeholder: 'Şifre', + login_btn: 'Oturum aç', + login_invalid_pw: 'Geçersiz şifre', + login_conn_failed: 'Bağlantı başarısız oldu', + // Sidebar & Tabs + tab_chat: 'Sohbet', + tab_tasks: 'Görevler', + tab_skills: 'Yetenekler', + tab_memory: 'Hafıza', + tab_workspaces: 'Alanlar', + tab_profiles: 'Agent profilleri', + tab_kanban: 'Kanban', + kanban_board: 'Pano', + kanban_visible_tasks: '{0} görünür görev', + kanban_search_tasks: 'Görevleri ara', + kanban_all_assignees: 'Tüm atananlar', + kanban_all_tenants: 'Tüm kiracılar', + kanban_include_archived: 'Arşivlenenleri dahil et', + kanban_no_matching_tasks: 'Eşleşen görev yok', + kanban_no_data: 'Kanban verisi yok', + kanban_work_queue_hint: 'Bu Hermes Ajanının iş kuyruğudur. Bir görev oluşturun veya önceliklendirin, atayın, Hazır\'a taşıyın ve sevk görevlisinin görevi talep etmesine izin verin.', + kanban_unavailable: 'Kanban kullanılamıyor', + kanban_read_only: 'Salt okunur görünüm', + kanban_empty: 'Boş', + kanban_task: 'Görev', + kanban_no_description: 'Açıklama yok', + kanban_refresh: 'Yenile', + kanban_status_triage: 'Triyaj', + kanban_status_todo: 'Yapılacaklar', + kanban_status_ready: 'Hazır', + kanban_status_running: 'Koşma', + kanban_status_blocked: 'Engellendi', + kanban_status_done: 'Tamamlamak', + kanban_status_original_hint: 'Gerçek durum: {0}. Bu iletişim kutusu yalnızca Triyaj/Yapılacak/Hazır düzenlemelerini destekler.', + kanban_comments_count: 'Yorumlar ({0})', + kanban_events_count: 'Etkinlikler ({0})', + kanban_links: 'Bağlantılar', + kanban_parents: 'Ebeveynler', + kanban_children: 'Çocuklar', + kanban_runs_count: 'Çalıştırmalar ({0})', + kanban_no_comments: 'Yorum yok', + kanban_no_events: 'Etkinlik yok', + kanban_no_runs: 'Koşu yok', + kanban_title: 'Başlık', + kanban_description: 'Tanım', + kanban_description_placeholder: 'İsteğe bağlı - ne olması gerekiyor, kabul kriterleri, bağlantılar', + kanban_status: 'Durum', + kanban_assignee: 'Vekil', + kanban_assignee_placeholder: 'İsteğe bağlı — herhangi bir çalışan için boş bırakın', + kanban_tenant: 'Kiracı', + kanban_tenant_placeholder: 'İsteğe bağlı – proje veya ekip bilgisi', + kanban_priority: 'Öncelik', + kanban_priority_hint: 'Önce yüksek sayılar çalıştırılır. Varsayılan 0.', + kanban_title_required: 'Başlık gerekli.', + kanban_edit_task: 'Görevi düzenle', + kanban_run_dispatcher: 'Dağıtıcıyı çalıştır', + kanban_run_dispatcher_confirm: 'Bu, bu panodaki Hazır görevleri talep edecek ve çalışan alt süreçlerini oluşturacaktır (görev başına bir, tıklama başına en fazla 8). Devam etmek?', + kanban_assignee_profiles_label: 'Hermes profilleri', + kanban_assignee_other_label: 'Diğer (CLI hatları / kaldırılan profiller)', + kanban_assignee_unassigned: '— Atanmadı (otomatik olarak çalıştırılmayacak) —', + kanban_ready_needs_assignee: 'Atanmamış + Hazır\'ı seçtiniz. Gönderici bu görevi atlayacaktır. Onaylamak için tekrar gönderin veya bir profil seçin.', + kanban_dispatch_preview_prefix: 'Önizleme:', + kanban_dispatch_run_prefix: 'Sevk edilen:', + kanban_dispatch_spawned: 'yumurtladı', + kanban_dispatch_promoted: 'terfi ettirildi', + kanban_dispatch_reclaimed: 'geri kazanılmış', + kanban_dispatch_skipped_unassigned: 'atlandı (atanan yok)', + kanban_dispatch_skipped_nonspawnable: 'atlandı (bilinmeyen profil)', + kanban_dispatch_auto_blocked: 'otomatik olarak engellendi', + kanban_dispatch_timed_out: 'zaman aşımına uğradı', + kanban_dispatch_crashed: 'çöktü', + kanban_new_task: 'Yeni görev', + kanban_add_comment: 'Yorum ekle', + kanban_only_mine: 'Sadece benim', + kanban_bulk_action: 'Toplu işlem', + kanban_nudge_dispatcher: 'Göndericiyi önizleyin', + kanban_stats: 'İstatistikler', + kanban_worker_log: 'İşçi günlüğü', + kanban_block: 'Engellemek', + kanban_unblock: 'Engellemeyi kaldır', + kanban_back_to_board: 'Panoya geri dön', + kanban_lanes_by_profile: 'Profile göre şeritler', + kanban_new_board: 'Yeni yönetim kurulu\u2026', + kanban_rename_board: 'Mevcut panoyu yeniden adlandır\u2026', + kanban_archive_board: 'Mevcut panoyu arşivle\u2026', + kanban_archive_board_confirm: 'Arşiv panosu "{name}"? Görevler diskte kalır ve pano kanban/boards/_archived/ adresinden geri yüklenebilir.', + kanban_board_archived: 'Pano arşivlendi', + kanban_board_name: 'İsim', + kanban_board_slug: 'Bilgi notu (küçük harf, kısa çizgi)', + kanban_board_description: 'Açıklama (isteğe bağlı)', + kanban_board_icon: 'Simge (emoji, isteğe bağlı)', + kanban_board_color: 'Renk (isteğe bağlı)', + kanban_board_name_required: 'Ad gerekli', + kanban_board_slug_required: 'Slug gerekli', + kanban_card_complete: 'tamamlamak', + kanban_card_archive: 'arşiv', + kanban_unassigned: 'atanmamış', + kanban_status_archived: 'Arşivlendi', + tab_todos: 'Yapılacaklar', + tab_insights: 'Analizler', + tab_dashboard: 'Hermes Kontrol Paneli', + dashboard_loopback_warning: 'Kontrol Paneli sunucuda yalnızca geri döngüye sahiptir. Ya sunucunun kendisinden göz atın ya da --host 0.0.0.0 (güvenli değil) ile yeniden başlatın.', + tab_logs: 'Günlükler', + tab_settings: 'Ayarlar', + + logs_title: 'Günlükler', + logs_file: 'Dosya', + logs_tail: 'Son satırlar', + logs_auto_refresh: 'Otomatik yenile (5 sn)', + logs_wrap: 'Satırları kaydır', + logs_copy_all: 'Tümünü kopyala', + logs_empty: 'Henüz günlük satırı yok.', + logs_loading: 'Günlükler yükleniyor\u2026', + logs_load_failed: 'Günlükler yüklenemedi', + logs_status_idle: 'Son satırları görmek için bir günlük dosyası seçin.', + logs_no_mtime: 'henüz yazılmadı', + logs_truncated_hint: 'Büyük günlük dosyasının sonu gösteriliyor; bellek sınırı için eski veriler atlandı.', + logs_copied: 'Günlükler kopyalandı', + logs_severity: 'Şiddet', + logs_severity_all: 'Tüm', + logs_severity_errors: 'Hatalar', + logs_severity_warnings: 'Uyarılar+', + logs_filter_active: 'gösteriliyor (filtre etkin)', + new_conversation: 'Yeni görüşme', + filter_conversations: 'Konuşmaları filtrele...', + session_time_unknown: 'Bilinmiyor', + session_time_minutes_ago: (n) => `${n} dk önce`, + session_time_hours_ago: (n) => `${n} sa önce`, + session_time_days_ago: (n) => `${n} gün önce`, + session_time_last_week: 'geçen hafta', + session_time_bucket_today: 'Bugün', + session_time_bucket_yesterday: 'Dün', + session_time_bucket_this_week: 'Bu hafta', + session_time_bucket_last_week: 'Geçen hafta', + session_time_bucket_older: 'daha yaşlı', + scheduled_jobs: 'Planlanmış işler', + new_job: 'Yeni iş', + loading: 'Yükleniyor...', + search_skills: 'Arama becerileri...', + new_skill: 'Yeni beceri', + personal_memory: 'Kişisel hafıza', + current_task_list: 'Mevcut görev listesi', + workspace_desc: 'Oturumlarınız için çalışma alanları ekleyin ve değiştirin.', + session_meta_messages: (n) => `${n} mesaj${n === 1 ? '' : 'S'}`, + session_meta_children: (n) => `${n} çocuk${n === 1 ? '' : 'ren'}`, + session_meta_segments: (n) => `${n} segment${n === 1 ? '' : 'S'}`, + session_lineage_segment_untitled: 'Başlıksız segment', + session_lineage_segment_open: 'Soy segmentini aç', + new_profile: 'Yeni profil', + transcript: 'Deşifre metni', + download_transcript: 'Markdown olarak indir', + import: 'İçe aktarmak', + export_session_json: 'JSON', + export_session_json_tooltip: 'Tüm oturumu JSON olarak dışa aktar', + import_session_json_tooltip: 'JSON\'dan oturumu içe aktar', + clear_conversation_btn_tooltip: 'Bu görüşmedeki tüm mesajları temizle', + // Settings detail + settings_label_rtl: 'Sağdan sola sohbet düzeni', + settings_desc_rtl: 'Arapça veya İbranice gibi diller için sohbet mesajlarının ve besteci girişinin hizalamasını değiştirir. Yalnızca sohbet alanını etkiler; kenar çubuğu ve diğer paneller soldan sağa kalır.', + settings_label_sound: 'Bildirim sesi', + settings_desc_sound: 'Asistan bir yanıtı bitirdiğinde bir ses çalın.', + settings_label_notifications: 'Tarayıcı bildirimleri', + settings_desc_notifications: 'Uygulama arka plandayken bir yanıt tamamlandığında bir sistem bildirimi gösterin.', + settings_desc_token_usage: 'Her Asistan yanıtının altında giriş/çıkış jeton sayılarını gösterir. /usage ile de değiştirilebilir.', + settings_sidebar_density_compact: 'Kompakt', + settings_sidebar_density_detailed: 'Ayrıntılı', + settings_desc_sidebar_density: 'Oturum listesinin sol kenar çubuğunda ne kadar meta veri göstereceğini kontrol eder.', + settings_label_auto_title_refresh: 'Uyarlanabilir başlık yenileme', + settings_auto_title_refresh_off: 'Kapalı', + settings_auto_title_refresh_5: 'Her 5 değişimde bir', + settings_auto_title_refresh_10: 'Her 10 değişimde bir', + settings_auto_title_refresh_20: 'Her 20 değişimde bir', + settings_desc_auto_title_refresh: 'Oturum başlıklarını en son konuşmaya göre otomatik olarak yeniden oluşturarak konuşma ilerledikçe başlıkların alakalı kalmasını sağlar. LLM başlık oluşturma modeli yapılandırması gerektirir.', + settings_desc_external_sessions: 'Oturum listesinde CLI, Telegram, Discord, Slack ve diğer kanallardan gelen konuşmaları gösterin. İçe aktarmak ve devam etmek için tıklayın.', + settings_desc_previous_messaging_sessions: 'Sıfırlama veya sıkıştırmayla değiştirilen eski Discord, Telegram, Slack ve Weixin oturumlarını gösterin.', + settings_desc_sync_insights: 'WebUI belirteci kullanımını state.db\'ye yansıtır, böylece hermes /insights tarayıcı oturum verilerini içerir. Varsayılan olarak kapalıdır.', + settings_desc_check_updates: 'WebUI veya Agent\'ın daha yeni sürümleri mevcut olduğunda bir banner gösterin. Periyodik olarak bir arka plan git getirme işlemi çalıştırır.', + settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", + settings_desc_bot_name: 'Yalnızca varsayılan profil için kullanılır. Diğer profiller kendi profil adlarını kullanır.', + settings_desc_password: 'Ayarlamak veya değiştirmek için yeni bir şifre girin. Geçerli ayarı korumak için boş bırakın.', + password_placeholder: 'Yeni şifreyi girin\u2026', + password_env_var_locked: 'HERMES_WEBUI_PASSWORD ortam değişkeni şu anda ayarlıdır ve önceliklidir. Şifreyi buradan yönetmek için ayarı kaldırın ve sunucuyu yeniden başlatın.', + password_env_var_locked_placeholder: 'Kilitli: HERMES_WEBUI_PASSWORD ortam değişkeni ayarlandı', + disable_auth: 'Kimlik Doğrulamasını Devre Dışı Bırak', + sign_out: 'Oturumu Kapat', + // Providers panel + providers_tab_title: 'Sağlayıcılar', + providers_section_title: 'Sağlayıcılar', + providers_section_meta: 'Yapay zeka sağlayıcıları için API anahtarlarını yönetin. Değişiklikler hemen yürürlüğe girer.', + providers_status_configured: 'API anahtarı yapılandırıldı', + providers_status_not_configured: 'API anahtarı yok', + providers_status_oauth: 'OAuth', + providers_status_api_key: 'API anahtarı', + providers_status_not_configured_label: 'Yapılandırılmadı', + providers_oauth_hint: 'OAuth aracılığıyla kimlik doğrulaması yapıldı. API anahtarına gerek yok.', + providers_oauth_config_yaml_hint: 'Belirteç config.yaml aracılığıyla yapılandırıldı. Güncellemek için config.yaml dosyanızdaki sağlayıcılar bölümünü düzenleyin veya hermes auth\'u çalıştırın.', + providers_oauth_not_configured_hint: 'Kimliği doğrulanmadı. Bu sağlayıcıyı yapılandırmak için terminalde Hermes Auth komutunu çalıştırın.', + providers_save: 'Kaydetmek', + providers_remove: 'Kaldırmak', + providers_saving: 'Kaydediliyor\u2026', + providers_removing: 'Kaldırılıyor\u2026', + providers_enter_key: 'Lütfen bir API anahtarı girin', + providers_empty: 'Yapılandırılabilir sağlayıcı bulunamadı.', + providers_key_updated: 'API anahtarı kaydedildi', + providers_key_removed: 'API anahtarı kaldırıldı', + providers_key_placeholder_new: 'sk-...', + providers_key_placeholder_replace: 'Değiştirilecek yeni anahtarı girin\u2026', + provider_quota_title: 'Etkin sağlayıcı kotası', + provider_quota_active_provider: 'Aktif sağlayıcı', + provider_quota_last_checked_after_refresh: 'Yenilemeden sonra son kontrol edildi', + provider_quota_last_checked: 'Son kontrol edilen {0}', + provider_quota_refresh_usage: 'Kullanımı yenile', + provider_quota_refreshing: 'Yenileniyor...', + provider_quota_refresh_title: 'Sağlayıcı kullanım sınırlarını şimdi yenileyin', + provider_quota_refresh_succeeded: 'Sağlayıcı kullanımı yenilendi', + provider_quota_refresh_failed: 'Sağlayıcı kullanımı yenileme başarısız oldu', + provider_quota_session_limit: '5 saat sınırı', + provider_quota_weekly_limit: 'Haftalık limit', + provider_quota_window_fallback: 'Pencere', + provider_quota_metric_remaining: 'Geriye kalan', + provider_quota_metric_used: 'Kullanılmış', + provider_quota_metric_limit: 'Sınır', + provider_quota_status_available: 'mevcut', + provider_quota_status_exhausted: 'yorgun', + provider_quota_status_unavailable: 'müsait değil', + provider_quota_status_failed: 'arızalı', + provider_quota_status_checked: 'kontrol edildi', + provider_quota_status_no_key: 'anahtar yok', + provider_quota_status_invalid_key: 'geçersiz anahtar', + provider_quota_status_unsupported: 'desteklenmeyen', + provider_quota_used_meta: '{0} kullanıldı', + provider_quota_resets_meta: '{0} sıfırlanır', + provider_quota_credential_pool: 'Kimlik bilgisi havuzu', + provider_quota_credential_label: 'Kimlik bilgisi {0}', + provider_quota_pool_plans: 'Planlar: {0}', + provider_quota_pool_no_windows: 'Hesap sınırı aralığı bildirilmedi.', + provider_quota_account_limits_loaded: 'Hesap limitleri yüklendi.', + provider_quota_unavailable: 'Kota durumu mevcut değil', + provider_quota_retry_after: '{0} sonrasında yeniden deneyin', + provider_quota_pool_summary_available: '{0}/{1} mevcut', + provider_quota_pool_summary_exhausted: '{0} bitkin', + provider_quota_pool_summary_failed: '{0} başarısız oldu', + provider_quota_pool_summary_checked: '{0} işaretlendi', + cancel: 'İptal etmek', + create_job: 'İş oluştur', + save_skill: 'Beceriyi kaydet', + editing: 'Düzenleme', + // Empty state + empty_title: 'Hangi konuda yardımcı olabilirim?', + empty_subtitle: 'İstediğiniz şeyi sorun, komutları çalıştırın, dosyaları keşfedin veya planlanmış görevlerinizi yönetin.', + suggest_files: 'Bu çalışma alanında hangi dosyalar var?', + suggest_schedule: 'Nerelerdesiniz?', + suggest_plan: 'Küçük bir proje planlamama yardım et.', + // onboarding + onboarding_badge: 'İLK ÇALIŞMA', + onboarding_title: 'Hermes Web Kullanıcı Arayüzüne Hoş Geldiniz', + onboarding_lead: 'Hızlı yönlendirmeli kurulum, Hermes\'i doğrulayacak, gerçek bir sağlayıcı yapılandırmasını kaydedecek, bir çalışma alanı ve modeli seçecek ve isteğe bağlı olarak uygulamayı bir parola ile koruyacaktır.', + onboarding_back: 'Geri', + onboarding_continue: 'Devam etmek', + onboarding_skip: 'Kurulumu atla', + onboarding_skipped: 'Kurulum atlandı — mevcut yapılandırma kullanılıyor.', + onboarding_open: 'Hermes\'i aç', + onboarding_step_system_title: 'Sistem kontrolü', + onboarding_step_system_desc: 'Hermes Agent\'ı ve yapılandırma görünürlüğünü doğrulayın.', + onboarding_step_setup_title: 'Sağlayıcı kurulumu', + onboarding_step_setup_desc: 'Minimum Hermes sağlayıcı yapılandırmasını kaydedin.', + onboarding_step_workspace_title: 'Çalışma alanı + modeli', + onboarding_step_workspace_desc: 'Yeni oturumlar ve sohbet için varsayılanları seçin.', + onboarding_step_password_title: 'İsteğe bağlı şifre', + onboarding_step_password_desc: 'Web kullanıcı arayüzünü paylaşmadan önce koruyun.', + onboarding_step_finish_title: 'Sona ermek', + onboarding_step_finish_desc: 'Uygulamayı inceleyin ve girin.', + onboarding_notice_system_ready: 'Hermes Agent\'a Web kullanıcı arayüzünden ulaşılabilir görünüyor.', + onboarding_notice_system_unavailable: 'Hermes Agent henüz tam olarak mevcut değil. Bootstrap bunu kurabilir ancak sağlayıcı kurulumu yine de bir terminal gerektirebilir.', + onboarding_check_agent: 'Hermes Temsilcisi', + onboarding_check_agent_ready: 'Tespit edildi ve içe aktarılabilir', + onboarding_check_agent_missing: 'Eksik veya kısmen içe aktarılabilir', + onboarding_check_password: 'Şifre', + onboarding_check_password_enabled: 'Zaten etkin', + onboarding_check_password_disabled: 'Henüz etkinleştirilmedi', + onboarding_check_provider: 'Sağlayıcı yapılandırması', + onboarding_check_provider_ready: 'Sohbete hazır', + onboarding_check_provider_partial: 'Kaydedildi ancak eksik', + onboarding_check_provider_pending: 'Doğrulama gerekiyor', + onboarding_config_file: 'Yapılandırma dosyası:', + onboarding_env_file: '.env dosyası:', + onboarding_unknown: 'Bilinmiyor', + onboarding_current_provider: 'Mevcut kurulum:', + onboarding_missing_imports: 'Eksik içe aktarmalar:', + onboarding_notice_setup_required: 'Burada basit bir sağlayıcı yolu seçin. Gelişmiş OAuth akışları şimdilik Hermes CLI\'ye ait olmaya devam ediyor.', + onboarding_notice_setup_already_ready: 'Çalışan bir Hermes sağlayıcı kurulumu zaten algılandı. Burada saklayabilir veya değiştirebilirsiniz.', + onboarding_oauth_provider_ready_title: 'Sağlayıcının kimliği zaten doğrulandı', + onboarding_oauth_provider_ready_body: 'Bu örnek, Hermes CLI aracılığıyla ayarlanmış bir OAuth sağlayıcısını ({provider}) kullanacak şekilde yapılandırılmıştır. Burada API anahtarına gerek yoktur; kurulumu tamamlamak için Devam\'a tıklayın.', + onboarding_oauth_provider_not_ready_title: 'OAuth sağlayıcısının kimliği henüz doğrulanmadı', + onboarding_oauth_provider_not_ready_body: 'Bu örnek, API anahtarı yerine OAuth kullanan {provider} sağlayıcısını kullanır. Kimlik doğrulamak için terminalde hermes auth veya hermes model çalıştırın, ardından WebUI\'yi yeniden yükleyin.', + onboarding_oauth_switch_hint: 'Veya API anahtarı kurulumuna geçmek için aşağıdan farklı bir sağlayıcı seçin:', + onboarding_notice_workspace: 'Bu değerler normal uygulamayla aynı ayar API\'lerini yeniden kullanır.', + onboarding_workspace_label: 'Çalışma alanı', + onboarding_workspace_or_path: 'Veya bir çalışma alanı yolu girin', + onboarding_workspace_placeholder: '/ev/siz/çalışma alanı', + onboarding_provider_label: 'Kurulum modu', + onboarding_quick_setup_badge: 'hızlı kurulum', + provider_category_easy_start: 'Kolay başlangıç', + provider_category_self_hosted: 'Açık / kendi kendine barındırılan', + provider_category_specialized: 'Uzmanlaşmış', + onboarding_api_key_label: 'API anahtarı', + oauth_login_codex: 'Codex (ChatGPT) ile giriş yap', + oauth_codex_step1: 'Adım 1: Bu URL\'yi ziyaret edin ve kodu girin', + oauth_codex_step2: 'Adım 2: Bu kodu sayfaya girin', + oauth_codex_polling: 'Yetkilendirme bekleniyor...', + oauth_codex_success: 'Codex OAuth girişi başarılı!', + oauth_codex_error: 'OAuth girişi başarısız', + oauth_codex_expired: 'Kodun süresi doldu, lütfen tekrar deneyin', + onboarding_api_key_placeholder: 'Mevcut kayıtlı anahtarı korumak için boş bırakın', + onboarding_api_key_label_optional: 'API anahtarı (isteğe bağlı)', + onboarding_api_key_placeholder_optional: 'Anahtarsız sunucular için boş bırakın', + onboarding_api_key_help_keyless: 'Çoğu LM Studio / Ollama / vLLM kurulumu anahtarsız çalışır — sunucunuz kimlik doğrulama gerektirmiyorsa boş bırakın. Bağlantıyı test et düğmesini kullanın.', + onboarding_api_key_help_prefix: 'Hermes .env dosyanızda gizli olarak kaydedildi', + onboarding_base_url_label: 'Temel URL', + onboarding_base_url_placeholder: 'https://uç noktanız.example/v1', + onboarding_base_url_help: 'Bunu OpenAI uyumlu yönlendiriciler, şirket içinde barındırılan sunucular, LiteLLM, Ollama, LM Studio, vLLM veya benzer uç noktalar için kullanın.', + onboarding_model_label: 'Varsayılan model', + onboarding_workspace_help: 'Kurulum tamamlandıktan sonra Hermes\'in yeni sohbetler için kullanması gereken modeli seçin.', + onboarding_custom_model_placeholder: 'modelinizin adı', + onboarding_custom_model_help: 'Özel uç noktalar için sunucunuzun beklediği tam model kimliğini girin.', + onboarding_notice_password_enabled: 'Bir parola zaten yapılandırılmıştır. Yalnızca değiştirmek istiyorsanız yeni bir tane girin.', + onboarding_notice_password_recommended: 'İsteğe bağlıdır ancak kullanıcı arayüzünü localhost\'un ötesinde gösterecekseniz önerilir.', + onboarding_password_label: 'Şifre (isteğe bağlı)', + onboarding_password_placeholder: 'Atlamak için boş bırakın', + onboarding_password_help: 'Parolalar, mevcut ayarlar API\'si aracılığıyla ve karma sunucu tarafında saklanır.', + onboarding_notice_finish: 'Bunlardan herhangi birini değiştirmek için Ayarları daha sonra yeniden açabilirsiniz.', + onboarding_not_set: 'Ayarlanmadı', + onboarding_password_will_enable: 'Etkinleştirilecek', + onboarding_password_will_replace: 'Değiştirilecek', + onboarding_password_keep_existing: 'Mevcut şifreyi koru', + onboarding_password_remains_disabled: 'Engelli kalacak', + onboarding_password_skipped: 'Şimdilik atlandı', + onboarding_finish_help: 'Bitirme, ayarlarda onboarding_completed kodunu saklar ve sizi normal uygulamaya bırakır.', + onboarding_error_choose_workspace: 'Devam etmeden önce bir çalışma alanı seçin.', + onboarding_error_choose_model: 'Devam etmeden önce bir model seçin.', + onboarding_error_provider_required: 'Devam etmeden önce bir kurulum modu seçin.', + onboarding_error_base_url_required: 'Özel uç noktalar için temel URL gereklidir.', + onboarding_probe_test_button: 'Bağlantıyı test et', + onboarding_probe_probing: 'Bağlantı test ediliyor\u2026', + onboarding_probe_ok: 'Bağlandı. {n} model mevcut.', + onboarding_probe_error_generic: 'Yapılandırılmış temel URL\'ye ulaşılamadı.', + onboarding_probe_error_invalid_url: 'Temel URL http:// veya https:// ile başlamalı.', + onboarding_probe_error_dns: 'Ana bilgisayar çözülemedi. URL\'yi kontrol edin veya IP adresini kullanın.', + onboarding_probe_error_connect_refused: 'Bağlantı reddedildi — sunucu bu adreste çalışmıyor olabilir. Docker içinden localhost yerine ana bilgisayar IP\'sini deneyin.', + onboarding_probe_error_timeout: 'Uç nokta zamanında yanıt vermedi. Sunucunun çalıştığını ve URL\'nin doğru olduğunu kontrol edin.', + onboarding_probe_error_http_4xx: 'Uç nokta istemci hatası döndürdü. Kimlik doğrulama ve URL yolunu kontrol edin (genelde /v1 ile biter).', + onboarding_probe_error_http_5xx: 'Uç nokta sunucu hatası döndürdü. LM Studio / Ollama günlüklerini kontrol edin.', + onboarding_probe_error_parse: 'Uç nokta beklenen biçimde model listesi döndürmedi. URL\'nin OpenAI uyumlu API köküne işaret ettiğini doğrulayın.', + onboarding_probe_error_unreachable: 'Yapılandırılmış temel URL\'ye ulaşılamadı.', + onboarding_error_probe_failed: 'Yapılandırılmış temel URL doğrulanamadı.', + onboarding_error_workspace_required: 'Çalışma alanı gerekli.', + onboarding_error_model_required: 'Model gerekli.', + onboarding_complete: 'İlk katılım tamamlandı', + + // panel/runtime i18n + error_prefix: 'Hata:', + not_available: 'Yok', + never: 'Asla', + add: 'Eklemek', + add_failed: 'Ekleme başarısız oldu:', + remove_failed: 'Kaldırma başarısız oldu:', + switch_failed: 'Geçiş başarısız oldu:', + name_required: 'Ad gerekli', + content_required: 'İçerik gerekli', + view: 'Görüş', + dismiss: 'Azletmek', + disable: 'Devre dışı bırakmak', + cron_no_jobs: 'Planlanmış iş bulunamadı.', + cron_status_off: 'kapalı', + cron_status_paused: 'duraklatıldı', + cron_status_error: 'hata', + cron_status_active: 'aktif', + cron_status_running: 'koşuyor\u2026', + cron_status_needs_attention: 'dikkat edilmesi gerekiyor', + cron_attention_desc: 'Bu yinelenen işin bir sonraki çalışma süresi yoktur. Zamanlayıcı bir sonraki çalıştırmayı hesaplamada başarısız olmuş olabilir.', + cron_attention_croniter_hint: 'Ağ Geçidi çalışma zamanında croniter paketi eksik olabilir. Ağ Geçidini cron desteğiyle yeniden başlatın, ardından bu işe devam edin.', + cron_attention_resume: 'Devam ettir ve yeniden hesapla', + cron_jobs_project: 'Cron İşleri', + cron_attention_run_once: 'Şimdi bir kez koş', + cron_attention_copy_diagnostics: 'Tanılamayı kopyala', + cron_diagnostics_copied: 'Cron teşhisi kopyalandı', + cron_next: 'Sonraki', + cron_last: 'Son', + cron_run_now: 'Şimdi koş', + cron_pause: 'Duraklat', + cron_resume: 'Sürdürmek', + cron_job_name_placeholder: 'İşin adı', + cron_schedule_placeholder: 'Takvim', + cron_prompt_placeholder: 'Çabuk', + cron_last_output: 'Son çıktı', + cron_expand_prompt: 'İstemi genişlet', + cron_collapse_prompt: 'İstemi daralt', + cron_expand_output: 'Çıktıyı genişlet', + cron_collapse_output: 'Çıktıyı daralt', + cron_all_runs: 'Tüm koşular', + cron_hide_runs: 'Çalıştırmaları gizle', + cron_no_runs_yet: '(henüz koşu yok)', + cron_schedule_required_example: 'Program gereklidir (ör. "0 9 * * *" veya "her 1 saatte bir")', + cron_schedule_required: 'Program gerekli', + cron_prompt_required: 'İstem gerekli', + cron_job_created: 'İş oluşturuldu', + cron_duplicate: 'Kopyalamak', + cron_duplicated: 'İş kopyalandı (duraklatıldı)', + cron_job_triggered: 'İş tetiklendi', + cron_job_paused: 'İş duraklatıldı', + cron_job_resumed: 'İş devam ettirildi', + cron_job_updated: 'İş güncellendi', + cron_delete_confirm_title: 'Cron işini sil', + cron_delete_confirm_message: 'Bu geri alınamaz.', + cron_job_deleted: 'İş silindi', + cron_completion_status: (name, status) => `Cron "${name}" ${status}`, + status_failed: 'arızalı', + status_completed: 'tamamlanmış', + todos_no_active: 'Bu oturumda etkin görev listesi yok.', + clear_conversation_title: 'Görüşmeyi temizle', + clear_conversation_message: 'Tüm mesajlar silinsin mi? Bu geri alınamaz.', + clear_failed: 'Temizleme başarısız oldu:', + skills_no_match: 'Beceri eşleşmesi yok.', + skill_enabled: 'Etkinleştirilmiş', + skill_disabled: 'Engelli', + skill_toggle_failed: 'Beceri değiştirilemedi:', + linked_files: 'Bağlantılı Dosyalar', + skill_load_failed: 'Beceri yüklenemedi:', + skill_file_load_failed: 'Dosya yüklenemedi:', + skill_name_required: 'Beceri adı gerekli', + skill_updated: 'Beceri güncellendi', + skill_created: 'Beceri oluşturuldu', + skill_deleted: 'Beceri silindi', + skill_delete_confirm: '"{0}" becerisi silinsin mi?', + skills_empty_title: 'Bir beceri seçin', + skills_empty_sub: 'İçeriğini görüntülemek için kenar çubuğundan bir beceri seçin veya yeni bir beceri oluşturun.', + skills_edit: 'Düzenlemek', + skills_delete: 'Silmek', + skills_back_to: '{0}\'a geri dön', + tasks_empty_title: 'Planlanmış bir iş seçin', + tasks_empty_sub: 'Ayrıntılarını ve çalışmalarını görüntülemek için kenar çubuğundan bir iş seçin veya yeni bir iş oluşturun.', + workspaces_empty_title: 'Bir alan seçin', + workspaces_empty_sub: 'Dosyalarını ve ayarlarını görüntülemek veya yeni bir tane eklemek için kenar çubuğundan bir alan seçin.', + profiles_empty_title: 'Bir profil seçin', + profiles_empty_sub: 'Ayarlarını görüntülemek ve düzenlemek için kenar çubuğundan bir temsilci profili seçin veya yeni bir tane oluşturun.', + memory_notes_label: 'hafıza (notlar)', + memory_saved: 'Bellek kaydedildi', + my_notes: 'Notlarım', + user_profile: 'Kullanıcı Profili', + no_notes_yet: 'Henüz not yok.', + no_profile_yet: 'Henüz profil yok.', + agent_soul: 'Ajan Ruhu', + no_soul_yet: 'Henüz tanımlanmış bir ruh yok.', + workspace_choose_path: 'Çalışma alanı yolunu seçin', + workspace_choose_path_meta: 'Doğrulanmış bir yol ekleyin ve bu görüşmeyi değiştirin', + workspace_manage: 'Çalışma alanlarını yönet', + workspace_manage_meta: 'Spaces panelini açın', + workspace_use_title: 'Geçerli oturumda kullan', + workspace_use: 'Kullanmak', + workspace_add_path_placeholder: 'Çalışma alanı yolu ekleyin (ör. /home/user/my-project)', + workspace_paths_validated_hint: 'Yollar, kaydedilmeden önce mevcut dizinler olarak doğrulanır.', + + workspace_drag_hint: 'Yeniden sıralamak için sürükleyin', + workspace_reorder_failed: 'Yeniden sıralama başarısız oldu', + workspace_added: 'Çalışma alanı eklendi', + workspace_renamed: 'Çalışma alanı yeniden adlandırıldı', + workspace_remove_confirm_title: 'Çalışma alanını kaldır', + workspace_remove_confirm_message: (path) => `"${path}" kaldırılsın mı?`, + workspace_removed: 'Çalışma alanı kaldırıldı', + workspace_switch_prompt_title: 'Çalışma alanını değiştir', + workspace_switch_prompt_message: 'Bu görüşmeyi eklemek ve bu görüşmeye geçiş yapmak için mutlak bir çalışma alanı yolu girin.', + workspace_switch_prompt_confirm: 'Anahtar', + workspace_switch_prompt_placeholder: '/Kullanıcılar/siz/proje', + workspace_not_added: 'Çalışma alanı eklenmedi', + workspace_already_saved: 'Çalışma alanı zaten kayıtlı; onu listeden seçin', + workspace_busy_switch: 'Aracı çalışırken çalışma alanı değiştirilemiyor', + discard_file_edits_title: 'Dosya düzenlemeleri silinsin mi?', + discard_file_edits_message: 'Çalışma alanlarının değiştirilmesi, önizlemedeki kaydedilmemiş dosya düzenlemelerinin silinmesine neden olur.', + workspace_switched_to: (name) => `${name}'a geçildi`, + profiles_no_profiles: 'Hiçbir profil bulunamadı.', + profile_api_keys_configured: 'API anahtarları yapılandırıldı', + profile_gateway_running: 'Ağ geçidi çalışıyor', + profile_gateway_stopped: 'Ağ geçidi durduruldu', + profile_active: 'AKTİF', + profile_no_configuration: 'Yapılandırma yok', + profile_skill_count: (count) => `${count} beceri${count === 1 ? '' : 'S'}`, + profile_use: 'Kullanmak', + profile_switch_title: 'Bu profile geç', + profile_delete_title: 'Bu profili sil', + profile_default_label: '(varsayılan)', + profile_name_placeholder: 'Profil adı (küçük harf, a-z 0-9 kısa çizgiler)', + profile_clone_label: 'Etkin profilden yapılandırmayı kopyala', + profile_model_label: 'Model / sağlayıcı', + profile_model_hint: 'Bu yeni profil için yapılandırılmış sağlayıcılar ve modeller arasından seçim yapın.', + profile_model_use_default: 'Etkin profil varsayılanını kullan', + profile_base_url_placeholder: 'Temel URL (isteğe bağlı, örneğin http://localhost:11434)', + profile_api_key_placeholder: 'API anahtarı (isteğe bağlı)', + manage_profiles: 'Profilleri yönet', + profiles_load_failed: 'Profiller yüklenemedi', + profile_switched_new_conversation: (name) => `Profile geçildi: ${name} — yeni görüşme başladı`, + profile_switched: (name) => `Profile geçildi: ${name}`, + profile_name_rule: 'Yalnızca küçük harfler, sayılar, kısa çizgiler ve alt çizgiler', + profile_base_url_rule: 'Temel URL http:// veya https:// ile başlamalıdır', + profile_created: (name) => `Profil oluşturuldu: ${name}`, + profile_delete_confirm_title: (name) => `"${name}" profili silinsin mi?`, + profile_delete_confirm_message: 'Bu profilin tüm oturumları, yapılandırmaları, becerileri ve belleği kalıcı olarak silinecek. Bu geri alınamaz.', + profile_deleted: (name) => `Profil silindi: ${name}`, + active_conversation_none: 'Aktif görüşme seçilmedi.', + active_conversation_meta: (title, count) => `${title} · ${count} mesaj${count === 1 ? '' : 'S'}`, + settings_unsaved_changes: 'Kaydedilmemiş değişiklikleriniz var.', + sign_out_failed: 'Oturumu kapatma başarısız oldu:', + disable_auth_confirm_title: 'Şifre korumasını devre dışı bırak', + disable_auth_confirm_message: 'Bu örneğe herkes erişebilecek.', + auth_disabled: 'Kimlik doğrulama devre dışı bırakıldı — şifre koruması kaldırıldı', + disable_auth_failed: 'Kimlik doğrulama devre dışı bırakılamadı:', + bg_error_single: (title) => `"${title}" bir hatayla karşılaştı`, + bg_error_multi: (count) => `${count} oturum bir hatayla karşılaştı`, + // skill form + skill_name: 'İsim', + skill_category: 'Kategori', + skill_category_placeholder: 'İsteğe bağlı, ör. devops', + skill_content: 'SKILL.md içeriği', + skill_content_placeholder: 'YAML ön maddesi + işaretleme gövdesi', + skill_rename_not_supported: 'Bir becerinin yeniden adlandırılması desteklenmiyor. Yeni bir beceri oluşturun ve yeniden adlandırmak için eskisini silin.', + skill_metadata: 'Meta veriler', + // cron form + cron_name_label: 'İsim', + cron_name_placeholder: 'İsteğe bağlı', + cron_schedule_label: 'Takvim', + cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.", + cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.", + cron_prompt_label: 'Çabuk', + cron_deliver_label: 'Çıktıyı şuraya ilet:', + cron_deliver_local: 'Yerel (yalnızca çıktıyı kaydet)', + cron_profile_label: 'Profil', + cron_profile_server_default: 'sunucu varsayılanı', + cron_profile_server_default_hint: 'Çalışma zamanında WebUI sunucusunun varsayılan profilini kullanır. Profili olmayan mevcut işler bu eski davranışı sürdürüyor.', + cron_toast_notifications_label: 'Tamamlama kadehleri', + cron_toast_notifications_hint: 'Bu cron bittiğinde kadeh kaldır. Görevler rozeti ve yeni çalıştırma işaretçisi bu kapalıyken de güncellenir.', + cron_toast_notifications_enabled: 'Etkinleştirilmiş', + cron_toast_notifications_disabled: 'Engelli', + cron_skills_label: 'Yetenekler', + cron_skills_placeholder: 'Beceri ekleyin (isteğe bağlı)\u2026', + cron_skills_edit_hint: 'Beceri listesi oluşturulduktan sonra düzenlenemez.', + // workspace form + workspace_name_label: 'İsim', + workspace_name_placeholder: 'İsteğe bağlı kolay ad', + workspace_path_label: 'Yol', + workspace_path_required: 'Yol gerekli', + workspace_path_readonly: 'Yol değiştirilemez. Yalnızca yeniden adlandırın.', + workspace_new_title: 'Yeni alan', + // profile form + profile_name_label: 'İsim', + profile_base_url_label: 'Temel URL', + profile_api_key_label: 'API anahtarı', + cmd_yolo: 'YOLO modunu değiştir (onayları atla)', + cmd_branch: 'Bu konuşmayı yeni bir oturuma aktarın', + cmd_branch_usage: '/branch [isim] - konuşmayı çatallayın (isteğe bağlı olarak bir adla)', + branch_forked: 'Yeni oturuma çatallandı', + branch_failed: 'Çatal başarısız oldu:', + fork_from_here: 'Buradan çatal', + forked_from: 'çatallı', + yolo_no_session: 'Aktif oturum yok', + yolo_enabled: '⚡ YOLO modu AÇIK — onaylar bu oturumu atladı', + yolo_disabled: 'YOLO modu KAPALI', + yolo_pill_label: 'YOLO', + yolo_pill_title_active: 'YOLO modu etkin — devre dışı bırakmak için tıklayın', + approval_skip_all: '⚡ Tüm bu oturumu atla', + approval_skip_all_title: 'Bu oturuma ilişkin tüm onay istemlerini atla', + // composer action tooltips + composer_send: 'Mesaj gönder', + composer_queue: 'Sıra mesajı', + composer_interrupt: 'Kes ve gönder', + composer_steer: 'Akım tepkisini yönlendir', + composer_stop: 'Üretimi durdur', + composer_disabled_clarify: 'Açıklama isteğine yanıt verin', + composer_disabled_compression: 'Sıkıştırmanın bitmesi bekleniyor', + composer_disabled_empty: 'Göndermek için bir mesaj yazın', + composer_mobile_workspace: 'Çalışma alanı', + composer_mobile_model: 'Modeli', + composer_mobile_reasoning: 'muhakeme', + composer_mobile_context: 'Bağlam', + + pdf_loading: 'PDF {0} yükleniyor\u2026', + pdf_too_large: 'PDF satır içi önizleme için çok büyük', + pdf_no_pages: 'PDF\'de sayfa yok', + pdf_error: 'PDF önizlemesi oluşturulamadı', + pdf_download: 'PDF\'yi indir', + html_loading: 'HTML önizlemesi yükleniyor\u2026', + html_too_large: 'HTML satır içi önizleme için çok büyük', + html_error: 'HTML önizlemesi oluşturulamadı', + html_open_full: 'Tam sayfayı aç', + html_sandbox_label: 'HTML Önizlemesi (korumalı alan)', + media_audio_label: 'Ses', + media_svg_label: 'Diyagram', + media_video_label: 'Video', + csv_loading: 'CSV yükleniyor', + csv_too_large: 'CSV dosyası satır içi oluşturma için çok büyük', + csv_no_data: 'CSV dosyasında tablo olarak oluşturmak için yeterli veri yok', + csv_error: 'CSV dosyası yüklenemedi', + csv_header_note: 'Tablo başlığı olarak gösterilen ilk satır', + excalidraw_loading: 'Yükleme diyagramı', + excalidraw_too_large: 'Excalidraw dosyası satır içi oluşturma için çok büyük', + excalidraw_invalid: 'Geçersiz Excalidraw dosya biçimi', + excalidraw_error: 'Excalidraw dosyası yüklenemedi', + excalidraw_label: 'Diyagram', + excalidraw_download: 'İndirmek', + excalidraw_empty: 'Boş diyagram', + excalidraw_render_error: 'Diyagram oluşturulamadı', + excalidraw_simplified: 'Basitleştirilmiş SVG önizlemesi — Excalidraw tuvaliyle piksel açısından aynı değil', + // TTS (#499) + tts_listen: 'Dinlemek', + tts_not_supported: 'Konuşma sentezi bu tarayıcıda desteklenmiyor.', + settings_label_tts: 'Yanıtlar için Metinden Konuşmaya', + settings_desc_tts: 'Yardım mesajlarında hoparlör düğmesini göster', + settings_label_tts_auto_read: 'Yanıtları sesli olarak otomatik oku', + settings_desc_tts_auto_read: 'Tamamlandığında her yeni asistanın yanıtını otomatik olarak söyleyin. Yazmaya başladığınızda duraklar.', + // Composer voice-mode pref (#1488) + settings_label_voice_mode: 'Eller serbest ses modu düğmesi', + settings_desc_voice_mode: 'Dikte mikrofonunun yanında ses modu düğmesini gösterir. Duraklamadan sonra Hermes otomatik gönderir ve yanıtları sesli okur. Konuşma tanıma ve TTS destekleyen tarayıcı gerektirir.', + settings_label_tts_voice: 'Ses', + settings_desc_tts_voice: 'Ses sentezi sesini seçin', + settings_label_tts_rate: 'Konuşma hızı', + settings_label_tts_pitch: 'Konuşma perdesi', + checkpoint_date: 'Tarih', + checkpoint_diff_files_changed: (n) => `${n} dosya değişti`, + checkpoint_diff_no_changes: 'Bu kontrol noktası ile mevcut çalışma alanı arasında fark bulunamadı.', + checkpoint_diff_title: 'Kontrol noktasındaki değişiklikler', + checkpoint_empty: 'Bu çalışma alanı için kontrol noktası bulunamadı.', + checkpoint_error: 'Kontrol noktaları yüklenemedi', + checkpoint_files: 'Dosyalar', + checkpoint_loading: 'Kontrol noktaları yükleniyor\u2026', + checkpoint_message: 'Mesaj', + checkpoint_restore: 'Geri yükle', + checkpoint_restore_confirm_message: (ckpt) => `Çalışma alanı "${ckpt}" kontrol noktasına geri yüklensin mi? Kayıtlı sürümlerle dosyaların üzerine yazılır. Sonradan eklenen dosyalar silinmez.`, + checkpoint_restore_confirm_title: 'Kontrol noktası geri yüklensin mi?', + checkpoint_restored: 'Kontrol noktası geri yüklendi', + checkpoint_title: 'Kontrol noktaları', + checkpoint_view_diff: 'Farkı görüntüle', + insights_activity_by_day: 'Güne göre etkinlik', + insights_activity_by_hour: 'Saate göre etkinlik', + insights_cost: 'Tahmini maliyet', + insights_daily_tokens: 'Günlük Jetonlar', + insights_model_name: 'Modeli', + insights_model_sessions: 'Oturumlar', + insights_model_tokens: 'Jetonlar', + insights_model_cost: 'Maliyet', + insights_model_share: 'Paylaşmak', + insights_no_usage_data: 'Henüz kullanım verisi yok', + insights_footer: 'Son {days} günün verileri gösteriliyor', + insights_input_tokens: 'Giriş', + insights_messages: 'Mesajlar', + insights_models: 'Modeller', + insights_no_cost: 'Yok', + insights_output_tokens: 'Çıkış', + insights_peak_hour: 'Zirve: {hour}', + insights_sessions: 'Oturumlar', + insights_title: 'Kullanım analitiği', + insights_token_breakdown: 'Jeton dağılımı', + insights_tokens: 'Jetonlar', + insights_total: 'Toplam', + settings_desc_api_redact: 'Kendi sunucunuzda şeffaflık için devre dışı bırakılabilir (paylaşımlı örneklerde önerilmez).', + settings_label_api_redact: 'API yanıtlarında hassas verileri gizle', + subagent_children: 'Alt agent oturumları', + + + } }; diff --git a/static/index.html b/static/index.html index 044190b7..722d38f7 100644 --- a/static/index.html +++ b/static/index.html @@ -2,7 +2,7 @@ - + Hermes @@ -26,6 +26,8 @@ + +