Compare commits

...

13 Commits

Author SHA1 Message Date
nesquena-hermes
4de4ed9a15 fix(sessions): redact sensitive titles in session list and search responses (#405)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sessions): redact titles in list and search responses

* docs: v0.50.26 release — version badge and CHANGELOG

---------

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:20:21 -07:00
nesquena-hermes
04ed0ff43d v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: restore mobile chat scrolling and drawer close (#397)

- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites

Original PR by @Jordan-SkyLF

* fix: preserve imported session timestamps (#395)

- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block

Original PR by @Jordan-SkyLF

* fix(profiles): block path traversal in profile switch and delete flows (#399)

Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.

- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
  ^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
  candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
  both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes

Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)

* feat: add desktop microphone transcription fallback (#396)

Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).

- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
  keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
  MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
  into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
  transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
  returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
  allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks

Original PR by @Jordan-SkyLF

* docs: v0.50.25 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:11:45 -07:00
nesquena-hermes
2beebaa6a2 feat: opt-in chat bubble layout (closes #336) (#403)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(ui): opt-in chat bubble layout

Closes #336.

Adds a settings toggle that right-aligns user messages and left-aligns
assistant replies. Off by default - the current full-width layout is
friendlier to code blocks and tool output, so bubbles are strictly
opt-in per the maintainer note on the issue.

Wiring follows the existing token-usage / cli-sessions pattern:

- api/config.py: new bubble_layout bool in _SETTINGS_DEFAULTS and
  _SETTINGS_BOOL_KEYS, validated + persisted like the rest.
- static/style.css: .bubble-layout gated selectors using :has() to
  tag msg-rows by .msg-role.user / .msg-role.assistant without any JS
  changes to message creation. User rows get align-self: flex-end,
  max-width: 75%, and a row-reverse header; assistant rows flex-start.
  A 700px media query widens the max to 92% on narrow screens.
- static/index.html: new checkbox with i18n keys next to the existing
  token-usage toggle.
- static/panels.js: loads the setting into the checkbox, saves it
  back, and toggles body.bubble-layout immediately on save.
- static/boot.js: applies the class on initial load so refreshed
  tabs honor the persisted setting without a flash.
- static/i18n.js: English label + description.

Test suite errors are environmental (test server fails to start on
port 8788 on main as well).

* i18n(es): add Spanish translations for bubble_layout setting

* fix+test: boot.js bubble-layout reset on failure; add 22 tests for issue #336

* docs: v0.50.24 release — version badge and CHANGELOG

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 21:42:01 -07:00
nesquena-hermes
0f8fec7ccd docs: v0.50.23 release — version badge and CHANGELOG (#393)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:46:51 -07:00
nesquena-hermes
12a60faaee fix: add OpenCode Zen and Go provider support (closes #362) (#392)
* Add OpenCode Zen and OpenCode Go provider support

The webui model dropdown had no knowledge of these providers.
When hermes_cli detected them as authenticated, they fell through
to the unknown-provider fallback showing wrong models.

Changes:
- Add opencode-zen and opencode-go to _PROVIDER_DISPLAY
- Add model lists for both to _PROVIDER_MODELS
- Add OPENCODE_ZEN_API_KEY and OPENCODE_GO_API_KEY to env-var fallback detection
- Fix custom:* provider IDs (e.g. custom:my-server) displaying raw ID instead of "Custom"

* Add tests for OpenCode provider registration and detection

---------

Co-authored-by: David Case <david.case@shruggr.cloud>
2026-04-13 18:46:11 -07:00
nesquena-hermes
2acee7fc34 fix: onboarding unblocked for reverse proxy / SSH tunnel deployments (fixes #390) (#391)
Some checks failed
Release & Docker / release (push) Has been cancelled
- Read X-Forwarded-For and X-Real-IP before falling back to raw socket IP
- Add HERMES_WEBUI_ONBOARDING_OPEN=1 env var escape hatch for remote servers
- Error message now includes the env var hint
- 18 new tests (TestOnboardingIPLogic + TestOnboardingSetupEndpoint)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 17:52:07 -07:00
nesquena-hermes
acc14f2f0b docs: update ROADMAP, SPRINTS, TESTING to v0.50.21 (961 tests)
ROADMAP.md:
- Header: v0.49.1/700 → v0.50.21/961
- Sprint history table: 12 new rows covering v0.40 → v0.50.21 (500+ commits)
- Architecture block: updated line counts and module list

SPRINTS.md:
- Header state: v0.36/433 → v0.50.21/961
- 'Where we are now' section updated with parity status
- Historical planning content preserved as reference

TESTING.md:
- Version reference: v0.36.2 → v0.50.21
- Test count: 700 → 961 (two places)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 17:43:16 -07:00
nesquena-hermes
9948fcf1db docs: fix CHANGELOG ordering + README architecture counts
- CHANGELOG: reorder v0.50.19/v0.50.20/v0.50.21 to correct newest-first
  (v0.50.19 was mistakenly at the top above v0.50.21 and v0.50.20)
- README: fix architecture block test count 51 files/802 functions → 61 files/961
- README: update line counts to actual wc -l values:
  routes.py ~2250, streaming.py ~660, ui.js ~1740,
  messages.js ~655, sessions.js ~800

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 17:34:22 -07:00
nesquena-hermes
6a1dda4082 docs: add remaining contributors — Argonaut790, indigokarasu, zenc-cp (complete to 33)
- @Argonaut790 (#239): HTML entity decode fix + Traditional Chinese locale
  (fix shipped in v0.46.0; zh-Hant locale added same PR)
- @indigokarasu (#213): CSS-only visual redesign proposal — design token system
  + icon rail + 7 themes (influenced v0.50.0 design language)
- @zenc-cp (#133): Anti-hallucination guard for ReAct loop — streaming token
  buffer + post-run scrub pattern

README now has 33 contributors covering full project history.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:52:39 -07:00
nesquena-hermes
56944cc0ab docs: update contributors, test count, line counts (v0.50.21)
- Add 21 new contributor entries covering v0.50.x era and all external
  contributions that were incorporated via review branches
- Fix test count: 802 → 961
- Fix line counts for routes.py, streaming.py, ui.js, messages.js, sessions.js
  (all grew significantly from live reasoning, reload recovery, CSRF fixes etc.)
- New major tier: Jordan-SkyLF (live streaming + session recovery)
- New feature tier: gabogabucho, bergeouss, ccqqlo, betamod, TaraTheStar,
  thadreber-web, deboste
- New bug/security tier: Hinotoi-agent, lawrencel1ng, lx3133584, DelightRun,
  shaoxianbilly, huangzt, kcclaw001, mbac, andrewy-wizard, mmartial,
  vCillusion, carlytwozero, mangodxd

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:47:28 -07:00
nesquena-hermes
7f69155904 docs: v0.50.21 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:26:48 -07:00
nesquena-hermes
54181d1a07 fix: durable inflight reload snapshots via localStorage (#367)
* fix: persist durable inflight reload snapshots

* fix: remove duplicate loadInflightState stub, update CHANGELOG test count

The stub added in the previous review branch is superseded by the author's
real localStorage-backed implementation in the cherry-picked commit 36051c0.
Remove the duplicate. Update CHANGELOG to 961 tests and document the durable
inflight state feature.

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:25:31 -07:00
nesquena-hermes
9542639a90 fix: live reasoning, tool progress, in-flight session recovery (#367)
* fix: preserve live session output across chat switches

(cherry picked from commit 401e3b643d25e8dad8c06883b478b3c3073f07a5)

* fix: preserve todo state after session reload

(cherry picked from commit 7ee093ba19978af23b79148df2f2347e2f1e5bde)

* fix: preserve live assistant anchor across rerenders

* fix: stream live reasoning and tool progress

* fix: recover inflight session state after reload

* fix: add loadInflightState stub + CHANGELOG v0.50.21

- static/ui.js: add loadInflightState() function (currently returns null —
  the typeof guard in sessions.js means reload recovery works via the
  else-path attachLiveStream call; this stub satisfies the guard cleanly
  and documents the extension point for future localStorage-backed state)
- CHANGELOG.md: v0.50.21 entry; 960 tests (up from 949)

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:18:15 -07:00
31 changed files with 2190 additions and 205 deletions

View File

@@ -1,14 +1,86 @@
# Hermes Web UI -- Changelog
## [v0.50.26] fix(sessions): redact sensitive titles in session list and search responses [SECURITY] (#400)
- `api/routes.py`: apply `_redact_text()` to session titles in all four response paths — `/api/sessions` merged list, `/api/sessions/search` empty-q, title-match, and content-match; use `dict(s)` copy before mutating to avoid corrupting the in-memory session cache
- `tests/test_session_summary_redaction.py`: 2 integration tests verifying `sk-` prefixed secrets in session titles are redacted from both list and search endpoint responses
- Original PR by @Hinotoi-agent (note: fix commit had a display artifact — `sk-` prefix was visually rendered as `***` in terminal output but the actual bytes were correct and the token was recognized by the redaction engine)
- 1022 tests total (up from 1020)
## [v0.50.25] Multi-PR batch: mobile scroll, import timestamps, profile security, mic fallback
### fix: restore mobile chat scrolling and drawer close (#397)
- `static/style.css`: `min-height:0` on `.layout` and `.main` (flex shrink chain fix); `-webkit-overflow-scrolling:touch`, `touch-action:pan-y`, `overscroll-behavior-y:contain` on `.messages`
- `static/boot.js`: call `closeMobileSidebar()` on new-conversation button and Ctrl+K shortcut so the transcript is visible immediately after starting a chat
- `tests/test_mobile_layout.py`: 41 new lines covering CSS fixes and both JS call sites
- Original PR by @Jordan-SkyLF
### fix: preserve imported session timestamps (#395)
- `api/models.py`: `Session.save(touch_updated_at=True)` — new flag; `import_cli_session()` accepts `created_at`/`updated_at` kwargs and saves with `touch_updated_at=False`
- `api/routes.py`: extract `created_at`/`updated_at` from `get_cli_sessions()` metadata and forward to import; post-import save also uses `touch_updated_at=False`
- `tests/test_gateway_sync.py`: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly; also fix session file cleanup in test finally block
- Original PR by @Jordan-SkyLF
### fix(profiles): block path traversal in profile switch and delete flows (#399) [SECURITY]
- `api/profiles.py`: new `_resolve_named_profile_home(name)` — validates name via `^[a-z0-9][a-z0-9_-]{0,63}$` regex then enforces path containment via `candidate.resolve().relative_to(profiles_root)`; use in `switch_profile()`
- `api/profiles.py`: add `_validate_profile_name()` call to `delete_profile_api()` entry
- `api/routes.py`: add `_validate_profile_name()` at HTTP handler level for both `/api/profile/switch` and `/api/profile/delete`
- `tests/test_profile_path_security.py`: 3 new tests — traversal rejected, valid name passes (cherry-picked from @Hinotoi-agent's PR, which was 62 commits behind master)
### feat: add desktop microphone transcription fallback (#396)
- `static/boot.js`: detect `_canRecordAudio`; keep mic button enabled when MediaRecorder available even without SpeechRecognition; full MediaRecorder recording → `/api/transcribe` fallback path with proper cleanup and error handling
- `api/upload.py`: add `transcribe_audio()` helper — temp file, calls transcription_tools, always cleans up
- `api/routes.py`: add `/api/transcribe` POST handler — CSRF-protected, auth-gated, 20MB limit
- `api/helpers.py`: change `Permissions-Policy` `microphone=()``microphone=(self)` (required for getUserMedia)
- `tests/test_voice_transcribe_endpoint.py`: 87 new lines (3 tests with mocked transcription)
- `tests/test_sprint19.py`: regression guard for microphone Permissions-Policy
- `tests/test_sprint20.py`: 3 updated tests for new fallback capability checks
- Original PR by @Jordan-SkyLF
- 1020 tests total (up from 1003)
## [v0.50.24] feat: opt-in chat bubble layout (closes #336)
- `api/config.py`: Add `bubble_layout` bool to `_SETTINGS_DEFAULTS` (default `False`) and `_SETTINGS_BOOL_KEYS` — new setting is opt-in, server-persisted, and coerced to bool on save
- `static/style.css`: 11 lines of CSS-only bubble layout — user rows `align-self:flex-end` / max-width 75%, assistant rows `flex-start`, all gated on `body.bubble-layout` class so the default full-width canvas is untouched; 700px responsive rule widens to 92%
- `static/boot.js`: Apply `body.bubble-layout` class from settings on page load; explicitly remove the class in the catch path so the feature stays off on API failure
- `static/panels.js`: Load checkbox state in `loadSettingsPanel`; write `body.bubble_layout` in `saveSettings` and immediately toggle `body.bubble-layout` class for live preview without a page reload
- `static/index.html`: Checkbox in the Appearance settings group, positioned between Show token usage and Show agent sessions
- `static/i18n.js`: English label + description keys; Spanish translations included in the same PR
- `tests/test_issue336.py`: 22 new tests covering config registration, JS class management in boot and panels, CSS selectors, HTML structure, i18n coverage for en+es, and API round-trip (default false, persist true/false, bool coercion)
- 1003 tests total (up from 981)
## [v0.50.23] Add OpenCode Zen and Go provider support (fixes #362)
- `api/config.py`: Add `opencode-zen` and `opencode-go` to `_PROVIDER_DISPLAY` — providers now show human-readable names in the UI instead of raw IDs
- `api/config.py`: Add full model catalogs for both providers to `_PROVIDER_MODELS` — Zen (pay-as-you-go credits, 32 models) and Go (flat-rate $10/month, 7 models) now show the correct model list in the dropdown instead of falling through to the unknown-provider fallback
- `api/config.py`: Add `OPENCODE_ZEN_API_KEY` / `OPENCODE_GO_API_KEY` to the env-var fallback detection path — providers are correctly detected as authenticated when keys are set in `.env`
- `tests/test_opencode_providers.py`: 6 new tests covering display registration, model catalog registration, and env-var detection for both providers
- 985 tests total (up from 979)
## [v0.50.22] Onboarding unblocked for reverse proxy / SSH tunnel deployments (fixes #390)
- `api/routes.py`: Onboarding setup endpoint now reads `X-Forwarded-For` and `X-Real-IP` headers before falling back to raw socket IP — reverse proxy (nginx/Caddy/Traefik) and SSH tunnel users are no longer incorrectly blocked
- Added `HERMES_WEBUI_ONBOARDING_OPEN=1` env var escape hatch for operators on remote servers who control network access themselves
- Error message now includes the env var hint so users know how to unblock themselves
- 18 new tests covering all IP resolution paths (`TestOnboardingIPLogic`, `TestOnboardingSetupEndpoint`)
> Living document. Updated at the end of every sprint.
> Repository: https://github.com/nesquena/hermes-webui
---
## [v0.50.19] Fix UnicodeEncodeError when downloading files with non-ASCII filenames (PR #378)
## [v0.50.21] Live reasoning, tool progress, and in-flight session recovery (PR #367)
- **Workspace file downloads no longer crash for Unicode filenames** (`api/routes.py`): Clicking a PDF or other file with Chinese, Japanese, Arabic, or other non-ASCII characters in its name caused a `UnicodeEncodeError` because Python's HTTP server requires header values to be latin-1 encodable. A new `_content_disposition_value(disposition, filename)` helper centralises `Content-Disposition` generation: it strips CR/LF (injection guard), builds an ASCII fallback for the legacy `filename=` parameter (non-ASCII chars replaced with `_`), and preserves the full UTF-8 name in `filename*=UTF-8''...` per RFC 5987. Both `attachment` and `inline` responses use it.
- 2 new integration tests in `tests/test_sprint29.py` covering Chinese filenames for both download and inline responses, verifying the header is latin-1 encodable and `filename*=UTF-8''` is present; 924 tests total (up from 922)
- **Durable inflight reload recovery** (`static/ui.js`, `static/messages.js`): `saveInflightState` / `loadInflightState` / `clearInflightState` backed by `localStorage` (`hermes-webui-inflight-state` key, per-session, 10-minute TTL). Snapshots are saved on every token, tool event, and tool completion, and cleared when the run ends/errors/cancels. On a full page reload with an active stream, `loadSession()` hydrates from the snapshot before calling `attachLiveStream(..., {reconnecting:true})` — partial messages, live tool cards, and reasoning text all survive the reload.
- **Live reasoning cards during streaming** (`static/ui.js`, `static/messages.js`): The generic thinking spinner now upgrades to a live reasoning card when the backend streams reasoning text. `_thinkingMarkup(text)` and `updateThinking(text)` centralize the markup so the spinner and card share the same DOM slot. Works with models that emit reasoning via the agent's `reasoning_callback` or `tool_progress_callback`.
- **`tool_complete` SSE events** (`api/streaming.py`, `static/messages.js`): Tool progress callback now accepts the current agent signature `on_tool(*cb_args, **cb_kwargs)` — handles both the old 3-arg `(name, preview, args)` form and the new 4-arg `(event_type, name, preview, args)` form. `tool.completed` events transition live tool cards from running to done cleanly.
- **In-flight session state stable across switches** (`static/messages.js`, `static/sessions.js`): `attachLiveStream` refactored out of `send()` into a standalone function; partial assistant text mirrored into `INFLIGHT` state on every token; `data-live-assistant` DOM anchor preserved across `renderMessages()` calls so switching away and back doesn't lose or duplicate live output.
- **Reload recovery** (`api/models.py`, `api/routes.py`, `api/streaming.py`, `static/sessions.js`): `active_stream_id`, `pending_user_message`, `pending_attachments`, and `pending_started_at` now persisted on the session object before streaming starts and cleared on completion (or exception). `/api/session` returns these fields. After a page reload or session switch, `loadSession()` detects `active_stream_id` and calls `attachLiveStream(..., {reconnecting:true})` to reattach to the live SSE stream.
- **Session-scoped message queue** (`static/ui.js`, `static/messages.js`): Global `MSG_QUEUE` replaced with `SESSION_QUEUES` keyed by session ID. Queued follow-up messages are associated with the session they were typed in and only drained when that session becomes idle — no cross-session bleed.
- **`newSession()` idle reset** (`static/sessions.js`): Sets `S.busy=false`, `S.activeStreamId=null`, clears the cancel button, resets composer status — ensures a fresh chat is immediately usable even if another session's stream is still running.
- **Todos survive session reload** (`static/panels.js`): `loadTodos()` now reads from `S.session.messages` (raw, includes tool-role messages) rather than `S.messages` (filtered display), so todo state reconstructed from tool outputs survives reloads.
- 12 new regression tests in `tests/test_regressions.py`; 961 tests total (up from 949)
## [v0.50.20] Silent error fix, stale model cleanup, live model fetching (fixes #373, #374, #375)
@@ -28,6 +100,11 @@
- 25 new tests in `tests/test_issues_373_374_375.py`; 949 tests total (up from 924)
## [v0.50.19] Fix UnicodeEncodeError when downloading files with non-ASCII filenames (PR #378)
- **Workspace file downloads no longer crash for Unicode filenames** (`api/routes.py`): Clicking a PDF or other file with Chinese, Japanese, Arabic, or other non-ASCII characters in its name caused a `UnicodeEncodeError` because Python's HTTP server requires header values to be latin-1 encodable. A new `_content_disposition_value(disposition, filename)` helper centralises `Content-Disposition` generation: it strips CR/LF (injection guard), builds an ASCII fallback for the legacy `filename=` parameter (non-ASCII chars replaced with `_`), and preserves the full UTF-8 name in `filename*=UTF-8''...` per RFC 5987. Both `attachment` and `inline` responses use it.
- 2 new integration tests in `tests/test_sprint29.py` covering Chinese filenames for both download and inline responses, verifying the header is latin-1 encodable and `filename*=UTF-8''` is present; 924 tests total (up from 922)
## [v0.50.18] Recover from invalid default workspace paths (PR #366)
- **WebUI no longer breaks when the configured default workspace is unavailable** (`api/config.py`): The workspace resolution path was refactored into three composable functions — `_workspace_candidates()`, `_ensure_workspace_dir()`, and `resolve_default_workspace()`. When the configured workspace (from env var, settings file, or passed path) cannot be created or accessed, the server falls back through an ordered priority list: `HERMES_WEBUI_DEFAULT_WORKSPACE` env var → `~/workspace` (if exists) → `~/work` (if exists) → `~/workspace` (create it) → `STATE_DIR/workspace`.

View File

@@ -339,8 +339,8 @@ Or using the agent venv explicitly:
```
Tests run against an isolated server on port 8788 with a separate state directory.
Production data and real cron jobs are never touched. Current count: **802 tests**
across 51 test files.
Production data and real cron jobs are never touched. Current count: **961 tests**
across 53 test files.
---
@@ -470,25 +470,25 @@ api/
models.py Session model + CRUD + CLI bridge (~377 lines)
onboarding.py First-run onboarding wizard, OAuth provider support (~507 lines)
profiles.py Profile state management, hermes_cli wrapper (~411 lines)
routes.py All GET + POST route handlers (~1996 lines)
routes.py All GET + POST route handlers (~2250 lines)
state_sync.py /insights sync — message_count to state.db (~113 lines)
streaming.py SSE engine, run_agent, cancel support (~545 lines)
streaming.py SSE engine, run_agent, cancel support (~660 lines)
updates.py Self-update check and release notes (~257 lines)
upload.py Multipart parser, file upload handler (~82 lines)
workspace.py File ops, workspace helpers, git detection (~288 lines)
static/
index.html HTML template (~600 lines)
style.css All CSS incl. mobile responsive, themes (~1050 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1496 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1740 lines)
workspace.js File preview, file ops, git badge (~286 lines)
sessions.js Session CRUD, collapsible groups, search (~752 lines)
messages.js send(), SSE handlers, rAF throttle (~487 lines)
sessions.js Session CRUD, collapsible groups, search, reload recovery (~800 lines)
messages.js send(), SSE handlers, live streaming, session recovery (~655 lines)
panels.js Cron, skills, memory, profiles, settings (~1438 lines)
commands.js Slash command autocomplete (~267 lines)
boot.js Mobile nav, voice input, boot IIFE (~524 lines)
tests/
conftest.py Isolated test server (port 8788)
51 test files 802 test functions
61 test files 961 test functions
Dockerfile python:3.12-slim container image
docker-compose.yml Compose with named volume and optional auth
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag
@@ -524,18 +524,60 @@ Six consecutive security and reliability PRs: session memory leak fix (expired t
**[@DavidSchuchert](https://github.com/DavidSchuchert)** — German translation (PR #190)
Complete German locale (`de`) covering all UI strings, settings labels, commands, and system messages — and in doing so, stress-tested the i18n system and exposed several elements that weren't yet translatable, which got fixed as part of the same PR.
**[@Jordan-SkyLF](https://github.com/Jordan-SkyLF)** — Live streaming, session recovery, workspace fallback (PRs #366, #367)
Three interlocking improvements: workspace fallback resolution so the server recovers gracefully when the configured workspace is deleted or unavailable; live reasoning cards that upgrade the generic thinking spinner to a real-time reasoning display as the model thinks; and durable session state recovery via `localStorage` so in-flight tool cards, partial assistant output, and the live SSE stream all survive a full page reload or session switch.
### Feature contributions
**[@gabogabucho](https://github.com/gabogabucho)** — Spanish locale + onboarding wizard (PRs #275, #285)
Full Spanish (`es`) locale covering all 175 UI strings, plus the one-shot bootstrap onboarding wizard that guides new users through provider setup on first launch — the feature most responsible for new users actually getting started.
**[@bergeouss](https://github.com/bergeouss)** — Real-time gateway session sync (PR #274)
Bridged the gateway session database (Telegram, Discord, Slack, etc.) into the WebUI sidebar with live SSE polling. Gateway sessions now appear alongside WebUI sessions in real time, without any changes to hermes-agent.
**[@ccqqlo](https://github.com/ccqqlo)** — Terminal approval UX + custom model discovery + mobile close button (PRs #224, #225, #238, #333)
A run of focused quality-of-life improvements: terminal tool approval prompts that stay visible long enough to actually be read, restored custom model API key discovery, and the redundant mobile close button fix that had been confusing users on narrow screens.
**[@kevin-ho](https://github.com/kevin-ho)** — OLED theme (PR #168)
Added the 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk. Small diff, big impact for anyone on an OLED display.
**[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button (PR #265)
Added the Profiles tab to the mobile bottom navigation bar, making profile switching reachable on phones without digging into the sidebar.
**[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button + Android Chrome fixes (PRs #253, #263, #265)
Added the Profiles tab to the mobile bottom navigation bar, making profile switching reachable on phones, plus a set of Android Chrome-specific fixes for the profile dropdown.
**[@franksong2702](https://github.com/franksong2702)** — Session title guard + breadcrumb nav (PRs #301, #302)
Two clean bug fixes / features: the session title guard that stops `title_from()` from overwriting user-renamed sessions after every turn, and clickable breadcrumb navigation in the workspace file preview panel.
### Bug fix contributions
**[@betamod](https://github.com/betamod)** — Security hardening (PR #171)
A comprehensive security audit PR covering CSRF protection, SSRF guards, XSS escaping improvements, and the env race condition between concurrent agent sessions — foundational security work that shipped in v0.39.0.
**[@TaraTheStar](https://github.com/TaraTheStar)** — Bot name + thinking blocks + login refactor (PRs #132, #176, #181)
Made the assistant display name configurable throughout the UI, added thinking/reasoning block display in chat, and refactored the login page to use template variables instead of inline string replacement.
**[@thadreber-web](https://github.com/thadreber-web)** — CLI session bridge (PR #56)
The original CLI session bridge: reads CLI sessions from the agent's SQLite state store and surfaces them in the WebUI sidebar. This was the first bridge between the CLI and WebUI session worlds.
**[@deboste](https://github.com/deboste)** — Reverse proxy auth + mobile responsive layout + model routing (PRs #3, #4, #5)
Three of the very first community PRs: fixed EventSource/fetch to use the URL origin for reverse proxy setups, corrected model provider routing from config, and added mobile responsive layout with dvh viewport fix. Early foundation work.
### Bug fix and security contributions
**[@Hinotoi-agent](https://github.com/Hinotoi-agent)** — Profile .env secret isolation (PR #351)
Fixed API key leakage between profiles on switch — switching from a profile with `OPENAI_API_KEY` to one without it left the key in the process environment for the duration of the session, effectively leaking credentials. A subtle and important security fix.
**[@lawrencel1ng](https://github.com/lawrencel1ng)** — Bandit security fixes B310/B324/B110 + QuietHTTPServer (PR #354)
Systematic bandit security scan fixes: URL scheme validation before `urlopen`, MD5 `usedforsecurity=False`, and 40+ bare `except: pass` blocks replaced with proper logging — plus `QuietHTTPServer` to stop client-disconnect log spam from SSE streams.
**[@lx3133584](https://github.com/lx3133584)** — CSRF fix for reverse proxy on non-standard ports (PR #360)
Fixed CSRF rejection for deployments behind Nginx Proxy Manager or similar on non-standard ports — a real-world blocker for anyone hosting on a port other than 80/443.
**[@DelightRun](https://github.com/DelightRun)** — session_search fix for WebUI sessions (PR #356)
The `session_search` tool silently returned "Session database not available" in every WebUI session. Tracked down the missing `SessionDB` injection in the streaming path and fixed it.
**[@shaoxianbilly](https://github.com/shaoxianbilly)** — Unicode filename downloads (PR #378)
Fixed `UnicodeEncodeError` crashes when downloading workspace files with Chinese, Japanese, or other non-ASCII names. Implemented proper `Content-Disposition` header with RFC 5987 `filename*=UTF-8''...` encoding.
**[@huangzt](https://github.com/huangzt)** — Cancel interrupts agent (PR #244)
Made the Cancel button actually interrupt the running agent and clean up UI state, rather than just hiding the button while the agent kept running.
**[@tgaalman](https://github.com/tgaalman)** — Thinking card fix (PR #169)
Fixed top-level reasoning fields being missed in the thinking card display — an edge case in how Claude's extended thinking blocks surface in the API response.
@@ -546,6 +588,36 @@ Fixed model routing for slash-prefixed custom provider models, which were being
**[@jeffscottward](https://github.com/jeffscottward)** — Claude Haiku model ID fix (PR #145)
Caught and corrected the Claude Haiku model ID (`3-5``4-5`) immediately after the Anthropic release — the kind of quick community catch that keeps the model dropdown accurate.
**[@kcclaw001](https://github.com/kcclaw001)** — Credential redaction in API responses (PR #243)
Added credential redaction to all API response paths so API keys, tokens, and other secrets in session data or error messages are masked before reaching the browser.
**[@mbac](https://github.com/mbac)** — Phantom "Custom" provider group fix (PR #191)
Removed the phantom "Custom" optgroup that appeared in the model dropdown even when no custom provider was configured — a small but consistently confusing UI noise issue.
**[@andrewy-wizard](https://github.com/andrewy-wizard)** — Chinese localization (PR #177)
Added Simplified Chinese (`zh`) locale to the WebUI. One of the first non-English locales and the most-used non-English locale in the codebase.
**[@mmartial](https://github.com/mmartial)** — Docker UID/GID matching (PR #237)
Added Docker support for running as an arbitrary UID/GID matching the host user, eliminating permission issues with bind-mounted volumes — essential for Docker deployments where the host user isn't UID 1000.
**[@vCillusion](https://github.com/vCillusion)** — pip package resolution fix (PR #76)
Fixed agent dependency resolution to prefer packages from the venv's site-packages over the agent directory itself, preventing shadowing bugs when developing locally.
**[@carlytwozero](https://github.com/carlytwozero)** — API key pass-through for non-Anthropic providers (PR #78)
Fixed `api_key` not being passed to `AIAgent` for non-Anthropic `/anthropic` providers — a quiet regression that silently broke any non-default provider.
**[@mangodxd](https://github.com/mangodxd)** — Type hints cleanup (PR #115)
Added missing type hints across 10 files and corrected 9 inaccurate existing ones — the kind of maintenance work that makes the codebase easier to reason about.
**[@Argonaut790](https://github.com/Argonaut790)** — HTML entity decode + Traditional Chinese locale (PR #239)
Fixed double-escaping of HTML entities in `renderMd()` — LLM output containing `&lt;code&gt;` was being escaped a second time, rendering as literal text instead of the intended markdown. The same PR also completed the Simplified Chinese translation (40+ missing keys) and added a full Traditional Chinese (`zh-Hant`) locale.
**[@indigokarasu](https://github.com/indigokarasu)** — Visual redesign proposal: icon rail + design token system + 7 themes (PR #213)
A CSS-only redesign of the full UI — proper design tokens (`--bg-primary`, `--text-info`, spacing scale), an icon rail sidebar replacing the emoji tab strip, consistent form cards, breadcrumb nav, and 7 built-in themes as custom properties. The PR didn't merge as-is but directly shaped the design language and theme architecture that shipped in v0.50.0.
**[@zenc-cp](https://github.com/zenc-cp)** — Anti-hallucination guard for ReAct loop (PR #133)
Added a streaming token buffer and post-run message scrub to `streaming.py` to detect and strip fake tool execution JSON that weaker models write inline instead of calling tools properly. A three-layer approach: ephemeral anti-hallucination prompt, live token filtering, and session history cleanup. The pattern influenced later streaming.py improvements.
---
Want to contribute? See [ARCHITECTURE.md](ARCHITECTURE.md) for the codebase layout and [TESTING.md](TESTING.md) for how to run the test suite. The best contributions are focused, well-tested, and solve a real problem — exactly what every person on this list did.

View File

@@ -3,9 +3,10 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.49.1 (April 12, 2026) — 700 tests, 700 passing
> Onboarding MVP now writes real Hermes provider config from the Web UI for OpenRouter, Anthropic, OpenAI, and custom OpenAI-compatible endpoints.
> Tests: 700 total (700 passing, 0 failures)
> Last updated: v0.50.21 (April 13, 2026) — 961 tests, 961 passing
> Full production-ready: onboarding wizard, multi-profile support, KaTeX math rendering,
> live reasoning cards with localStorage reload recovery, CSRF reverse proxy fixes, Docker improvements.
> Tests: 961 total (961 passing, 0 failures)
> Source: <repo>/
---
@@ -61,6 +62,18 @@
| v0.36v0.37 | Model routing, personality config, tool card reload, duplicate model fixes | Model routing by provider prefix, personality via config.yaml, tool cards reload on page refresh | 466 |
| v0.38.0v0.38.6 | Model selector, custom endpoints, OLED theme, reasoning display, insights sync | Custom endpoint URL fix, OLED theme, top-level reasoning field fix, message_count sync to state.db | 466 |
| v0.39.0 | Security hardening (Sprint 29) | CSRF, PBKDF2, rate limiting, session ID validation, SSRF, ENV_LOCK, XSS, HMAC, skills traversal, secure cookie, error sanitization, startup warning | 499 |
| v0.40v0.44.2 | Approval card + Lucide icons + sprint auth | Approval prompt surfaced in UI, emoji icons → Lucide SVG, login CSP inline fix, update diagnostics | 579 |
| v0.45v0.46 | Custom endpoints + security + i18n + cancel | Custom endpoint Base URL + API key on profile create, credential redaction (PR #243), Docker UID/GID (PR #237), HTML entity decode + zh/zh-Hant i18n, cancel interrupts agent | 624 |
| v0.47v0.47.1 | Dialogs + session menu + skills + mobile QA + Spanish | Shared app dialogs, session ⋯ menu, /skills command, mobile QA suite, Android Chrome fixes, Spanish locale (@gabogabucho) | 648 |
| v0.48v0.48.2 | Gateway session sync + table formatting + provider warnings | Real-time Telegram/Discord/Slack sessions in sidebar (@bergeouss), inlineMd() in table cells, provider/model mismatch toast | 679 |
| v0.49v0.49.1 | Onboarding wizard + Docker two-container | One-shot bootstrap + guided setup wizard, OpenRouter/Anthropic/OpenAI/Custom provider config, two-container Docker compose, mobile Profiles button | 700 |
| v0.50.0 | v0.50.0 UI overhaul (Sprint 34) | Composer-centric controls, Hermes Control Center modal, workspace panel state machine, collapsible date groups, rAF streaming throttle, context ring indicator (@aronprins) | 742 |
| v0.50.5v0.50.10 | Think-tag edge cases + onboarding hardening + mobile fixes | MiniMax M2.5 leading-whitespace think-tag fix, skip-onboarding env var, OAuth provider path, Docker bridge networks fix, model dropdown dedup, title auto-generation fix, mobile close button | 802 |
| v0.50.11v0.50.12 | Chat table styles + URL autolink + profile env isolation | .msg-body table borders, plain URL auto-linking, profile .env secret isolation on switch (prevents API key leakage across profiles, @Hinotoi-agent) | 815 |
| v0.50.13v0.50.15 | session_search + security sweep + KaTeX math | SessionDB injection for session_search in WebUI (@DelightRun), bandit B310/B324/B110 + QuietHTTPServer (@lawrencel1ng), KaTeX math rendering with fence-before-math fix | 871 |
| v0.50.16v0.50.17 | CSRF reverse proxy + Docker uv pre-install | Scheme-aware CSRF port normalization for non-standard ports (@lx3133584), Docker uv pre-installed at build time as root (fixes air-gapped startup, @mmartial-pattern) | 900 |
| v0.50.18v0.50.19 | Workspace fallback + Unicode filenames | Cascading workspace path recovery (@Jordan-SkyLF), Unicode Content-Disposition headers with RFC 5987 filename* (@shaoxianbilly), silent auth error surfacing, stale model cleanup | 924 |
| v0.50.20v0.50.21 | Silent errors + live model fetching + durable streaming recovery | apperror on empty agent response, /api/models/live endpoint with SSRF guard, live reasoning cards, tool_complete SSE events, SESSION_QUEUES, localStorage reload recovery (@Jordan-SkyLF) | 961 |
---
@@ -68,14 +81,14 @@
| Layer | Location | Status |
|-------|----------|--------|
| Python server | <repo>/server.py (~81 lines) + api/ modules (~3210 lines) | Thin shell + auth middleware + business logic in api/ |
| HTML template | <repo>/static/index.html (~364 lines) | Served from disk |
| CSS | <repo>/static/style.css (~670 lines) | Served from disk, incl. mobile responsive |
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3610 lines total |
| Python server | <repo>/server.py (~165 lines) + api/ modules (~5000 lines) | Thin shell + QuietHTTPServer + auth middleware + business logic in api/ |
| HTML template | <repo>/static/index.html (~600 lines) | Served from disk |
| CSS | <repo>/static/style.css (~1050 lines) | Served from disk, incl. mobile responsive, KaTeX, table styles |
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands,icons,i18n,login}.js | 10 modules, ~7100 lines total |
| Docker | Dockerfile, docker-compose.yml, .dockerignore | python:3.12-slim, multi-arch (amd64+arm64) |
| CI/CD | .github/workflows/release.yml | Auto-release + GHCR publish on tag push |
| Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files |
| Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run |
| Test server | Port 8788 (conftest.py), port 8789 (browser sanity) | Isolated, wiped per run |
| Production server | Port 8787 | SSH tunnel from Mac |
---

View File

@@ -1,22 +1,28 @@
# Hermes Web UI -- Forward Sprint Plan
> Current state: v0.36 | 433 tests | Daily driver ready
> This document plans the path from here to two targets:
> Current state: v0.50.21 | 961 tests | Full daily driver — CLI parity achieved
>
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
> terminal, you can do from the browser)
> NOTE: Most planned work in this document has now shipped. This file is preserved
> as a historical planning record. Current sprint state and version history live
> in CHANGELOG.md and ROADMAP.md.
>
> Target B: 1:1 parity with Claude's reproducible features (the full Claude
> browser UI experience, minus things only Anthropic can build)
> Target A (CLI parity): ✅ Complete — all core tools, workspace, cron, skills,
> memory, sessions, profiles, model routing, streaming, voice, mobile.
>
> Sprints are ordered by impact. Each builds on the one before.
> Past sprint history lives in CHANGELOG.md.
> Target B (Claude parity): ~90% — thinking display, math rendering (KaTeX),
> tool cards, workspace preview, onboarding, settings panel all done.
> Remaining: full subagent transparency UI, file diff viewer.
>
> Last meaningful update: v0.50.21 (April 13, 2026). See CHANGELOG.md for full history.
---
## Where we are now (v0.36)
## Where we are now (v0.50.21 — updated April 2026)
**CLI parity: ~95% complete.** Core agent loop, all tools visible, workspace
> The sections below describe the state as of v0.36 for historical reference.
> See ROADMAP.md for the current sprint history table (v0.36 → v0.50.21).
**CLI parity: ✅ Complete** as of v0.50.x. Core agent loop, all tools visible, workspace
file ops with tree view and git detection, cron/skills/memory CRUD, session
management, streaming with rAF throttle, cancel, multi-provider models, custom
endpoint discovery, slash commands (help/clear/model/workspace/new/usage/theme/compact),

View File

@@ -1,14 +1,14 @@
# Hermes Web UI: Browser Testing Plan
> This document is for manual browser testing by you or by a Claude browser agent.
> It covers user-facing features of the UI through Sprint 26 (v0.36.2) and later releases.
> It covers user-facing features of the UI through v0.50.21 and later releases.
> Each section is written as a step-by-step test procedure with expected outcomes.
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
>
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated tests: 700 total (700 passing, 0 skipped, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), and the `/api/onboarding/*` backend.
> Automated tests: 961 total (961 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), and the `/api/onboarding/*` backend.
> Run: `pytest tests/ -v --timeout=60`
---

View File

@@ -448,6 +448,8 @@ _PROVIDER_DISPLAY = {
"huggingface": "HuggingFace",
"alibaba": "Alibaba",
"ollama": "Ollama",
"opencode-zen": "OpenCode Zen",
"opencode-go": "OpenCode Go",
"lmstudio": "LM Studio",
}
@@ -509,6 +511,51 @@ _PROVIDER_MODELS = {
{"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
],
# OpenCode Zen — curated models via opencode.ai/zen (pay-as-you-go credits)
"opencode-zen": [
{"id": "gpt-5.4-pro", "label": "GPT-5.4 Pro"},
{"id": "gpt-5.4", "label": "GPT-5.4"},
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
{"id": "gpt-5.4-nano", "label": "GPT-5.4 Nano"},
{"id": "gpt-5.3-codex", "label": "GPT-5.3 Codex"},
{"id": "gpt-5.3-codex-spark", "label": "GPT-5.3 Codex Spark"},
{"id": "gpt-5.2", "label": "GPT-5.2"},
{"id": "gpt-5.2-codex", "label": "GPT-5.2 Codex"},
{"id": "gpt-5.1", "label": "GPT-5.1"},
{"id": "gpt-5.1-codex", "label": "GPT-5.1 Codex"},
{"id": "gpt-5.1-codex-max", "label": "GPT-5.1 Codex Max"},
{"id": "gpt-5.1-codex-mini", "label": "GPT-5.1 Codex Mini"},
{"id": "gpt-5", "label": "GPT-5"},
{"id": "gpt-5-codex", "label": "GPT-5 Codex"},
{"id": "gpt-5-nano", "label": "GPT-5 Nano"},
{"id": "claude-opus-4-6", "label": "Claude Opus 4.6"},
{"id": "claude-opus-4-5", "label": "Claude Opus 4.5"},
{"id": "claude-opus-4-1", "label": "Claude Opus 4.1"},
{"id": "claude-sonnet-4-6", "label": "Claude Sonnet 4.6"},
{"id": "claude-sonnet-4-5", "label": "Claude Sonnet 4.5"},
{"id": "claude-sonnet-4", "label": "Claude Sonnet 4"},
{"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"},
{"id": "claude-3-5-haiku", "label": "Claude 3.5 Haiku"},
{"id": "gemini-3.1-pro", "label": "Gemini 3.1 Pro"},
{"id": "gemini-3-flash", "label": "Gemini 3 Flash"},
{"id": "glm-5.1", "label": "GLM-5.1"},
{"id": "glm-5", "label": "GLM-5"},
{"id": "kimi-k2.5", "label": "Kimi K2.5"},
{"id": "minimax-m2.5", "label": "MiniMax M2.5"},
{"id": "minimax-m2.5-free", "label": "MiniMax M2.5 Free"},
{"id": "nemotron-3-super-free", "label": "Nemotron 3 Super Free"},
{"id": "big-pickle", "label": "Big Pickle"},
],
# OpenCode Go — flat-rate models via opencode.ai/go ($10/month)
"opencode-go": [
{"id": "glm-5.1", "label": "GLM-5.1"},
{"id": "glm-5", "label": "GLM-5"},
{"id": "kimi-k2.5", "label": "Kimi K2.5"},
{"id": "mimo-v2-pro", "label": "MiMo V2 Pro"},
{"id": "mimo-v2-omni", "label": "MiMo V2 Omni"},
{"id": "minimax-m2.7", "label": "MiniMax M2.7"},
{"id": "minimax-m2.5", "label": "MiniMax M2.5"},
],
# 'gemini' is the hermes_cli provider ID for Google AI Studio
"gemini": [
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
@@ -710,6 +757,8 @@ def get_available_models() -> dict:
"GLM_API_KEY",
"KIMI_API_KEY",
"DEEPSEEK_API_KEY",
"OPENCODE_ZEN_API_KEY",
"OPENCODE_GO_API_KEY",
):
val = os.getenv(k)
if val:
@@ -730,6 +779,10 @@ def get_available_models() -> dict:
detected_providers.add("minimax")
if all_env.get("DEEPSEEK_API_KEY"):
detected_providers.add("deepseek")
if all_env.get("OPENCODE_ZEN_API_KEY"):
detected_providers.add("opencode-zen")
if all_env.get("OPENCODE_GO_API_KEY"):
detected_providers.add("opencode-go")
# 3. Fetch models from custom endpoint if base_url is configured
auto_detected_models = []
@@ -1064,6 +1117,7 @@ _SETTINGS_DEFAULTS = {
), # display name for the assistant
"sound_enabled": False, # play notification sound when assistant finishes
"notifications_enabled": False, # browser notification when tab is in background
"bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
}
@@ -1093,6 +1147,7 @@ _SETTINGS_BOOL_KEYS = {
"check_for_updates",
"sound_enabled",
"notifications_enabled",
"bubble_layout",
}
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")

View File

@@ -50,7 +50,7 @@ def _security_headers(handler):
)
handler.send_header(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
'camera=(), microphone=(self), geolocation=()'
)

View File

@@ -44,6 +44,10 @@ class Session:
project_id: str=None, profile=None,
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
personality=None,
active_stream_id: str=None,
pending_user_message: str=None,
pending_attachments=None,
pending_started_at=None,
**kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title
@@ -61,13 +65,18 @@ class Session:
self.output_tokens = output_tokens or 0
self.estimated_cost = estimated_cost
self.personality = personality
self.active_stream_id = active_stream_id
self.pending_user_message = pending_user_message
self.pending_attachments = pending_attachments or []
self.pending_started_at = pending_started_at
@property
def path(self):
return SESSION_DIR / f'{self.session_id}.json'
def save(self) -> None:
self.updated_at = time.time()
def save(self, touch_updated_at: bool = True) -> None:
if touch_updated_at:
self.updated_at = time.time()
self.path.write_text(
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
encoding='utf-8',
@@ -203,7 +212,15 @@ def save_projects(projects) -> None:
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
def import_cli_session(session_id: str, title: str, messages, model: str='unknown', profile=None):
def import_cli_session(
session_id: str,
title: str,
messages,
model: str='unknown',
profile=None,
created_at=None,
updated_at=None,
):
"""Create a new WebUI session populated with CLI messages.
Returns the Session object.
"""
@@ -214,8 +231,10 @@ def import_cli_session(session_id: str, title: str, messages, model: str='unknow
model=model,
messages=messages,
profile=profile,
created_at=created_at,
updated_at=updated_at,
)
s.save()
s.save(touch_updated_at=False)
return s

View File

@@ -196,7 +196,7 @@ def switch_profile(name: str) -> dict:
if name == 'default':
home = _DEFAULT_HERMES_HOME
else:
home = _DEFAULT_HERMES_HOME / 'profiles' / name
home = _resolve_named_profile_home(name)
if not home.is_dir():
raise ValueError(f"Profile '{name}' does not exist.")
@@ -287,6 +287,24 @@ def _validate_profile_name(name: str):
)
def _profiles_root() -> Path:
"""Return the canonical root that contains named profiles."""
return (_DEFAULT_HERMES_HOME / 'profiles').resolve()
def _resolve_named_profile_home(name: str) -> Path:
"""Resolve a named profile to a directory under the profiles root.
Validates *name* as a logical profile identifier first, then resolves the
final filesystem path and enforces containment under ~/.hermes/profiles.
"""
_validate_profile_name(name)
profiles_root = _profiles_root()
candidate = (profiles_root / name).resolve()
candidate.relative_to(profiles_root)
return candidate
def _create_profile_fallback(name: str, clone_from: str = None,
clone_config: bool = False) -> Path:
"""Create a profile directory without hermes_cli (Docker/standalone fallback)."""
@@ -405,6 +423,7 @@ def delete_profile_api(name: str) -> dict:
"""Delete a profile. Switches to default first if it's the active one."""
if name == 'default':
raise ValueError("Cannot delete the default profile.")
_validate_profile_name(name)
# If deleting the active profile, switch to default first
if _active_profile == name:
@@ -422,7 +441,7 @@ def delete_profile_api(name: str) -> dict:
except ImportError:
# Manual fallback: just remove the directory
import shutil
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
profile_dir = _resolve_named_profile_home(name)
if profile_dir.is_dir():
shutil.rmtree(str(profile_dir))
else:

View File

@@ -181,7 +181,7 @@ from api.workspace import (
read_file_content,
safe_resolve_ws,
)
from api.upload import handle_upload
from api.upload import handle_upload, handle_transcribe
from api.streaming import _sse, _run_agent_streaming, cancel_stream
from api.onboarding import (
apply_onboarding_setup,
@@ -365,6 +365,10 @@ def handle_get(handler, parsed) -> bool:
raw = s.compact() | {
"messages": s.messages,
"tool_calls": getattr(s, "tool_calls", []),
"active_stream_id": getattr(s, "active_stream_id", None),
"pending_user_message": getattr(s, "pending_user_message", None),
"pending_attachments": getattr(s, "pending_attachments", []),
"pending_started_at": getattr(s, "pending_started_at", None),
}
return j(handler, {"session": redact_session_data(raw)})
except KeyError:
@@ -406,11 +410,13 @@ def handle_get(handler, parsed) -> bool:
deduped_cli = []
merged = webui_sessions + deduped_cli
merged.sort(key=lambda s: s.get("updated_at", 0) or 0, reverse=True)
# Redact credentials from session titles before returning
safe_merged = []
for s in merged:
if isinstance(s.get("title"), str):
s["title"] = _redact_text(s["title"])
return j(handler, {"sessions": merged, "cli_count": len(deduped_cli)})
item = dict(s)
if isinstance(item.get("title"), str):
item["title"] = _redact_text(item["title"])
safe_merged.append(item)
return j(handler, {"sessions": safe_merged, "cli_count": len(deduped_cli)})
if parsed.path == "/api/projects":
return j(handler, {"projects": load_projects()})
@@ -626,6 +632,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/upload":
return handle_upload(handler)
if parsed.path == "/api/transcribe":
return handle_transcribe(handler)
body = read_body(handler)
if parsed.path == "/api/session/new":
@@ -841,8 +850,10 @@ def handle_post(handler, parsed) -> bool:
if not name:
return bad(handler, "name is required")
try:
from api.profiles import switch_profile
from api.profiles import switch_profile, _validate_profile_name
if name != 'default':
_validate_profile_name(name)
result = switch_profile(name)
return j(handler, result)
except (ValueError, FileNotFoundError) as e:
@@ -889,8 +900,9 @@ def handle_post(handler, parsed) -> bool:
if not name:
return bad(handler, "name is required")
try:
from api.profiles import delete_profile_api
from api.profiles import delete_profile_api, _validate_profile_name
_validate_profile_name(name)
result = delete_profile_api(name)
return j(handler, result)
except (ValueError, FileNotFoundError) as e:
@@ -910,16 +922,26 @@ def handle_post(handler, parsed) -> bool:
# Writing API keys to disk - restrict to local/private networks unless auth is active.
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
# even when the user accesses via localhost:8787 on the host.
# Behind a reverse proxy (nginx/Caddy/Traefik) or SSH tunnel, X-Forwarded-For
# carries the real origin IP — read it first before falling back to the raw socket addr.
# HERMES_WEBUI_ONBOARDING_OPEN=1 lets operators on remote servers explicitly bypass
# the check when they control network access themselves (e.g. firewall + VPN).
from api.auth import is_auth_enabled
if not is_auth_enabled():
import os as _os
if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"):
import ipaddress
try:
addr = ipaddress.ip_address(handler.client_address[0])
# Prefer forwarded headers set by reverse proxies
_xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip()
_xri = handler.headers.get("X-Real-IP", "").strip()
_raw = handler.client_address[0]
_ip_str = _xff or _xri or _raw
addr = ipaddress.ip_address(_ip_str)
is_local = addr.is_loopback or addr.is_private
except ValueError:
is_local = False
if not is_local:
return bad(handler, "Onboarding setup is only available from local networks when auth is not enabled.", 403)
return bad(handler, "Onboarding setup is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403)
try:
return j(handler, apply_onboarding_setup(body))
except ValueError as e:
@@ -1172,12 +1194,21 @@ def _handle_sessions_search(handler, parsed):
content_search = qs.get("content", ["1"])[0] == "1"
depth = int(qs.get("depth", ["5"])[0])
if not q:
return j(handler, {"sessions": all_sessions()})
safe_sessions = []
for s in all_sessions():
item = dict(s)
if isinstance(item.get("title"), str):
item["title"] = _redact_text(item["title"])
safe_sessions.append(item)
return j(handler, {"sessions": safe_sessions})
results = []
for s in all_sessions():
title_match = q in (s.get("title") or "").lower()
if title_match:
results.append(dict(s, match_type="title"))
item = dict(s, match_type="title")
if isinstance(item.get("title"), str):
item["title"] = _redact_text(item["title"])
results.append(item)
continue
if content_search:
try:
@@ -1192,7 +1223,10 @@ def _handle_sessions_search(handler, parsed):
if isinstance(p, dict) and p.get("type") == "text"
)
if q in str(c).lower():
results.append(dict(s, match_type="content"))
item = dict(s, match_type="content")
if isinstance(item.get("title"), str):
item["title"] = _redact_text(item["title"])
results.append(item)
break
except (KeyError, Exception):
pass
@@ -1683,11 +1717,15 @@ def _handle_chat_start(handler, body):
attachments = [str(a) for a in (body.get("attachments") or [])][:20]
workspace = str(Path(body.get("workspace") or s.workspace).expanduser().resolve())
model = body.get("model") or s.model
stream_id = uuid.uuid4().hex
s.workspace = workspace
s.model = model
s.active_stream_id = stream_id
s.pending_user_message = msg
s.pending_attachments = attachments
s.pending_started_at = time.time()
s.save()
set_last_workspace(workspace)
stream_id = uuid.uuid4().hex
q = queue.Queue()
with STREAMS_LOCK:
STREAMS[stream_id] = q
@@ -2191,18 +2229,30 @@ def _handle_session_import_cli(handler, body):
title = title_from(msgs, "CLI Session")
model = "unknown"
# Get profile and model from CLI session metadata
# Get profile, model, and timestamps from CLI session metadata
profile = None
created_at = None
updated_at = None
for cs in get_cli_sessions():
if cs["session_id"] == sid:
profile = cs.get("profile")
model = cs.get("model", "unknown")
created_at = cs.get("created_at")
updated_at = cs.get("updated_at")
break
s = import_cli_session(sid, title, msgs, model, profile=profile)
s = import_cli_session(
sid,
title,
msgs,
model,
profile=profile,
created_at=created_at,
updated_at=updated_at,
)
s.is_cli_session = True
s._cli_origin = sid
s.save()
s.save(touch_updated_at=False)
return j(
handler,
{

View File

@@ -172,23 +172,70 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
_token_sent = True
put('token', {'text': text})
def on_tool(name, preview, args):
def on_reasoning(text):
if text is None:
return
put('reasoning', {'text': str(text)})
def on_tool(*cb_args, **cb_kwargs):
event_type = None
name = None
preview = None
args = None
if len(cb_args) >= 4:
event_type, name, preview, args = cb_args[:4]
elif len(cb_args) == 3:
name, preview, args = cb_args
event_type = 'tool.started'
elif len(cb_args) == 2:
event_type, name = cb_args
elif len(cb_args) == 1:
name = cb_args[0]
event_type = 'tool.started'
if event_type in ('reasoning.available', '_thinking'):
reason_text = preview if event_type == 'reasoning.available' else name
if reason_text:
put('reasoning', {'text': str(reason_text)})
return
args_snap = {}
if isinstance(args, dict):
for k, v in list(args.items())[:4]:
s2 = str(v); args_snap[k] = s2[:120]+('...' if len(s2)>120 else '')
put('tool', {'name': name, 'preview': preview, 'args': args_snap})
# Fallback: poll for pending approval in case notify_cb wasn't
# registered (e.g. older approval module without gateway support).
try:
from tools.approval import has_pending as _has_pending, _pending, _lock
if _has_pending(session_id):
with _lock:
p = dict(_pending.get(session_id, {}))
if p:
put('approval', p)
except ImportError:
pass
s2 = str(v)
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
if event_type in (None, 'tool.started'):
put('tool', {
'event_type': event_type or 'tool.started',
'name': name,
'preview': preview,
'args': args_snap,
})
# Fallback: poll for pending approval in case notify_cb wasn't
# registered (e.g. older approval module without gateway support).
try:
from tools.approval import has_pending as _has_pending, _pending, _lock
if _has_pending(session_id):
with _lock:
p = dict(_pending.get(session_id, {}))
if p:
put('approval', p)
except ImportError:
pass
return
if event_type == 'tool.completed':
put('tool_complete', {
'event_type': event_type,
'name': name,
'preview': preview,
'args': args_snap,
'duration': cb_kwargs.get('duration'),
'is_error': bool(cb_kwargs.get('is_error', False)),
})
return
_AIAgent = _get_ai_agent()
if _AIAgent is None:
@@ -252,6 +299,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
session_id=session_id,
session_db=_session_db,
stream_delta_callback=on_token,
reasoning_callback=on_reasoning,
tool_progress_callback=on_tool,
)
@@ -458,6 +506,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
'assistant_msg_idx': asst_idx, 'args': args_snap,
})
s.tool_calls = tool_calls
s.active_stream_id = None
s.pending_user_message = None
s.pending_attachments = []
s.pending_started_at = None
# Tag the matching user message with attachment filenames for display on reload
# Only tag a user message whose content relates to this turn's text
# (msg_text is the full message including the [Attached files: ...] suffix)
@@ -516,6 +568,15 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
except Exception as e:
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
if s is not None:
s.active_stream_id = None
s.pending_user_message = None
s.pending_attachments = []
s.pending_started_at = None
try:
s.save()
except Exception:
pass
err_str = str(e)
# Detect rate limit errors specifically so the client can show a helpful card
# rather than the generic "Connection lost" message

View File

@@ -3,6 +3,7 @@ Hermes Web UI -- File upload: multipart parser and upload handler.
"""
import re as _re
import email.parser
import tempfile
from pathlib import Path
from api.config import MAX_UPLOAD_BYTES
@@ -50,8 +51,15 @@ def parse_multipart(rfile, content_type, content_length) -> tuple:
return fields, files
def _sanitize_upload_name(filename: str) -> str:
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
if not safe_name or safe_name.strip('.') == '':
raise ValueError('Invalid filename')
return safe_name
def handle_upload(handler):
import re as _re, traceback as _tb
import traceback as _tb
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
@@ -69,14 +77,55 @@ def handle_upload(handler):
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
# Reject names that are purely dots (path traversal: ".." survives regex)
if not safe_name or safe_name.strip('.') == '':
return j(handler, {'error': 'Invalid filename'}, status=400)
# Verify the resolved path stays within the workspace
safe_name = _sanitize_upload_name(filename)
dest = safe_resolve_ws(workspace, safe_name)
dest.write_bytes(file_bytes)
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
except Exception as e:
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
print('[webui] upload error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Upload failed'}, status=500)
def handle_transcribe(handler):
import traceback as _tb
temp_path = None
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
if content_length > MAX_UPLOAD_BYTES:
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
fields, files = parse_multipart(handler.rfile, content_type, content_length)
if 'file' not in files:
return j(handler, {'error': 'No file field in request'}, status=400)
filename, file_bytes = files['file']
if not filename:
return j(handler, {'error': 'No filename in upload'}, status=400)
safe_name = _sanitize_upload_name(filename)
suffix = Path(safe_name).suffix or '.webm'
with tempfile.NamedTemporaryFile(prefix='webui-stt-', suffix=suffix, delete=False) as tmp:
temp_path = tmp.name
tmp.write(file_bytes)
try:
from tools.transcription_tools import transcribe_audio
except ImportError:
return j(handler, {'error': 'Speech-to-text is unavailable on this server'}, status=503)
result = transcribe_audio(temp_path)
if not result.get('success'):
msg = str(result.get('error') or 'Transcription failed')
status = 503 if 'unavailable' in msg.lower() or 'not configured' in msg.lower() else 400
return j(handler, {'error': msg}, status=status)
transcript = str(result.get('transcript') or '').strip()
return j(handler, {'ok': True, 'transcript': transcript})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
print('[webui] transcribe error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Transcription failed'}, status=500)
finally:
if temp_path:
try:
Path(temp_path).unlink(missing_ok=True)
except Exception:
pass

View File

@@ -172,24 +172,32 @@ function mobileSwitchPanel(name){
});
}
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
$('btnSend').onclick=()=>{
if(window._micActive){
window._micPendingSend=true;
_stopMic();
return;
}
send();
};
$('btnAttach').onclick=()=>$('fileInput').click();
// ── Voice input (Web Speech API) ─────────────────────────────────────────
// ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
(function(){
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden
const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
const btn=$('btnMic');
const status=$('micStatus');
const ta=$('msg');
btn.style.display=''; // Show button — browser supports speech
const recognition=new SpeechRecognition();
recognition.continuous=false;
recognition.interimResults=true;
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
const statusText=status?status.querySelector('.status-text'):null;
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
let recognition=SpeechRecognition?new SpeechRecognition():null;
let mediaRecorder=null;
let mediaStream=null;
let audioChunks=[];
let _finalText='';
let _prefix='';
@@ -197,67 +205,162 @@ $('btnAttach').onclick=()=>$('fileInput').click();
window._micActive=on;
btn.classList.toggle('recording',on);
status.style.display=on?'':'none';
if(statusText) statusText.textContent=on?'Listening':'Listening';
if(!on){ _finalText=''; _prefix=''; }
}
recognition.onstart=()=>{ _finalText=''; };
recognition.onresult=(event)=>{
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
// Append to whatever was already in the textarea before mic started
ta.value=_prefix+(final||interim);
autoResize();
};
recognition.onend=()=>{
// Commit: prefix + final transcription; trim trailing space if prefix was non-empty
const committed=_finalText
function _commitTranscript(text){
const clean=(text||'').trim();
const committed=clean
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
? _prefix+' '+_finalText.trimStart()
: _prefix+_finalText)
: ta.value; // no speech detected — leave whatever is there
_setRecording(false);
? _prefix+' '+clean.trimStart()
: _prefix+clean)
: ta.value;
ta.value=committed;
autoResize();
};
if(window._micPendingSend){
window._micPendingSend=false;
send();
}
}
recognition.onerror=(event)=>{
_setRecording(false);
const msgs={
'not-allowed':t('mic_denied'),
'no-speech':t('mic_no_speech'),
'network':t('mic_network'),
};
showToast(msgs[event.error]||t('mic_error')+event.error);
};
async function _transcribeBlob(blob){
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
const form=new FormData();
form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
setComposerStatus('Transcribing…');
try{
const res=await fetch('/api/transcribe',{method:'POST',body:form});
const data=await res.json().catch(()=>({}));
if(!res.ok) throw new Error(data.error||'Transcription failed');
_commitTranscript(data.transcript||'');
}catch(err){
window._micPendingSend=false;
showToast(err.message||t('mic_network'));
}finally{
setComposerStatus('');
}
}
function _stopTracks(){
if(mediaStream){
mediaStream.getTracks().forEach(track=>track.stop());
mediaStream=null;
}
}
function _stopMic(){
if(window._micActive){ recognition.stop(); }
if(!window._micActive) return;
if(recognition){
recognition.stop();
return;
}
if(mediaRecorder&&mediaRecorder.state!=='inactive'){
mediaRecorder.stop();
return;
}
_setRecording(false);
_stopTracks();
}
window._stopMic=_stopMic; // expose for send-guard above
btn.onclick=()=>{
if(recognition){
recognition.continuous=false;
recognition.interimResults=true;
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
recognition.onstart=()=>{ _finalText=''; };
recognition.onresult=(event)=>{
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
ta.value=_prefix+(final||interim);
autoResize();
};
recognition.onend=()=>{
const committed=_finalText
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
? _prefix+' '+_finalText.trimStart()
: _prefix+_finalText)
: ta.value;
_setRecording(false);
ta.value=committed;
autoResize();
if(window._micPendingSend){
window._micPendingSend=false;
send();
}
};
recognition.onerror=(event)=>{
_setRecording(false);
window._micPendingSend=false;
const msgs={
'not-allowed':t('mic_denied'),
'no-speech':t('mic_no_speech'),
'network':t('mic_network'),
};
showToast(msgs[event.error]||t('mic_error')+event.error);
};
}
btn.onclick=async()=>{
if(window._micActive){
recognition.stop();
// _setRecording(false) will be called by onend
} else {
_finalText='';
// Snapshot existing textarea content so we append rather than replace
_prefix=ta.value;
_stopMic();
return;
}
_finalText='';
_prefix=ta.value;
if(recognition){
recognition.start();
_setRecording(true);
return;
}
if(!_canRecordAudio){
showToast(t('mic_network'));
return;
}
try{
mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
audioChunks=[];
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
mediaRecorder.onerror=()=>{
_setRecording(false);
window._micPendingSend=false;
_stopTracks();
showToast(t('mic_network'));
};
mediaRecorder.onstop=async()=>{
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
_setRecording(false);
_stopTracks();
if(blob.size){ await _transcribeBlob(blob); }
else if(window._micPendingSend){
window._micPendingSend=false;
}
};
mediaRecorder.start();
_setRecording(true);
}catch(err){
window._micPendingSend=false;
_stopTracks();
showToast(t('mic_denied'));
}
};
})();
window._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();};
$('btnDownload').onclick=()=>{
if(!S.session)return;
const blob=new Blob([transcript()],{type:'text/markdown'});
@@ -374,7 +477,7 @@ document.addEventListener('keydown',async e=>{
}
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
e.preventDefault();
if(!S.busy){await newSession();await renderSessionList();$('msg').focus();}
if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}
}
if(e.key==='Escape'){
// Close settings overlay if open
@@ -479,7 +582,7 @@ function applyBotName(){
(async()=>{
// Load send key preference
let _bootSettings={};
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};}
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);document.body.classList.toggle('bubble-layout',!!s.bubble_layout);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};document.body.classList.remove('bubble-layout');}
// Non-blocking update check (fire-and-forget, once per tab session)
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';

View File

@@ -131,6 +131,7 @@ const LOCALES = {
settings_label_theme: 'Theme',
settings_label_language: 'Language',
settings_label_token_usage: 'Show token usage',
settings_label_bubble_layout: 'Chat bubble layout',
settings_label_cli_sessions: 'Show agent sessions',
settings_label_sync_insights: 'Sync to insights',
settings_label_check_updates: 'Check for updates',
@@ -183,6 +184,7 @@ const LOCALES = {
settings_label_notifications: 'Browser notifications',
settings_desc_notifications: 'Show a system notification when a response completes while the tab is in the background.',
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
settings_desc_bubble_layout: 'Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.',
settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.',
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.',
@@ -396,6 +398,7 @@ const LOCALES = {
settings_label_theme: 'Tema',
settings_label_language: 'Idioma',
settings_label_token_usage: 'Mostrar uso de tokens',
settings_label_bubble_layout: 'Disposición en burbujas',
settings_label_cli_sessions: 'Mostrar sesiones de CLI',
settings_label_sync_insights: 'Sincronizar con insights',
settings_label_check_updates: 'Buscar actualizaciones',
@@ -448,6 +451,7 @@ const LOCALES = {
settings_label_notifications: 'Notificaciones del navegador',
settings_desc_notifications: 'Muestra una notificación del sistema cuando una respuesta termina mientras la pestaña está en segundo plano.',
settings_desc_token_usage: 'Muestra el conteo de tokens de entrada/salida debajo de cada respuesta del asistente. También se puede alternar con /usage.',
settings_desc_bubble_layout: 'Alinea los mensajes del usuario a la derecha y las respuestas del asistente a la izquierda. Desactivado por defecto para mantener los bloques de código y la salida de herramientas a ancho completo.',
settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversació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.',

View File

@@ -494,6 +494,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsBubbleLayout" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_bubble_layout">Chat bubble layout</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_bubble_layout">Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
@@ -528,7 +535,7 @@
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.20</span>
<span class="settings-version-badge">v0.50.26</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -10,10 +10,12 @@ async function send(){
// If busy, queue the message instead of dropping it
if(S.busy){
if(text){
MSG_QUEUE.push(text);
if(!S.session){await newSession();await renderSessionList();}
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles]});
$('msg').value='';autoResize();
updateQueueBadge();
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'\u2026':''}"`,2000);
S.pendingFiles=[];renderTray();
updateQueueBadge(S.session.session_id);
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'…':''}"`,2000);
}
return;
}
@@ -37,7 +39,10 @@ async function send(){
S.toolCalls=[]; // clear tool calls from previous turn
clearLiveToolCards(); // clear any leftover live cards from last turn
S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true);
INFLIGHT[activeSid]={messages:[...S.messages],uploaded};
INFLIGHT[activeSid]={messages:[...S.messages],uploaded,toolCalls:[]};
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded,toolCalls:[]});
}
startApprovalPolling(activeSid);
S.activeStreamId = null; // will be set after stream starts
@@ -67,6 +72,9 @@ async function send(){
streamId=startData.stream_id;
S.activeStreamId = streamId;
markInflight(activeSid, streamId);
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded,toolCalls:INFLIGHT[activeSid].toolCalls||[]});
}
// Show Cancel button
const cancelBtn=$('btnCancel');
if(cancelBtn) cancelBtn.style.display='inline-flex';
@@ -81,7 +89,32 @@ async function send(){
}
// Open SSE stream and render tokens live
attachLiveStream(activeSid, streamId, uploaded);
}
const LIVE_STREAMS={};
function closeLiveStream(sessionId, streamId){
const live=LIVE_STREAMS[sessionId];
if(!live) return;
if(streamId&&live.streamId!==streamId) return;
try{live.source.close();}catch(_){ }
delete LIVE_STREAMS[sessionId];
}
function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!activeSid||!streamId) return;
const reconnecting=!!options.reconnecting;
closeLiveStream(activeSid);
if(!INFLIGHT[activeSid]) INFLIGHT[activeSid]={messages:[...S.messages],uploaded:[...uploaded],toolCalls:[]};
else {
if(uploaded.length) INFLIGHT[activeSid].uploaded=[...uploaded];
if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[];
}
let assistantText='';
let reasoningText='';
let assistantRow=null;
let assistantBody=null;
// Thinking tag patterns for streaming display
@@ -90,8 +123,57 @@ async function send(){
{open:'<|channel>thought\n',close:'<channel|>'}
];
function _isActiveSession(){
return !!(S.session&&S.session.session_id===activeSid);
}
function persistInflightState(){
const inflight=INFLIGHT[activeSid];
if(!inflight||typeof saveInflightState!=='function') return;
saveInflightState(activeSid,{
streamId,
messages:inflight.messages||[],
uploaded:inflight.uploaded||[...uploaded],
toolCalls:inflight.toolCalls||[],
});
}
function _closeSource(){
closeLiveStream(activeSid, streamId);
}
function syncInflightAssistantMessage(){
const inflight=INFLIGHT[activeSid];
if(!inflight) return;
if(!Array.isArray(inflight.messages)) inflight.messages=[];
let assistantIdx=-1;
for(let i=inflight.messages.length-1;i>=0;i--){
const msg=inflight.messages[i];
if(msg&&msg.role==='assistant'&&msg._live){assistantIdx=i;break;}
}
const ts=Date.now()/1000;
if(assistantIdx>=0){
inflight.messages[assistantIdx].content=assistantText;
inflight.messages[assistantIdx].reasoning=reasoningText||undefined;
inflight.messages[assistantIdx]._ts=inflight.messages[assistantIdx]._ts||ts;
persistInflightState();
return;
}
inflight.messages.push({role:'assistant',content:assistantText,reasoning:reasoningText||undefined,_live:true,_ts:ts});
persistInflightState();
}
function ensureAssistantRow(){
if(assistantRow)return;
if(!_isActiveSession()) return;
if(assistantRow&&!assistantRow.isConnected){assistantRow=null;assistantBody=null;}
if(!assistantRow){
const existing=$('msgInner').querySelector('.msg-row[data-live-assistant="1"]');
if(existing){
assistantRow=existing;
assistantBody=existing.querySelector('.msg-body');
}
}
if(assistantRow){
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
return;
}
removeThinking();
const tr=$('toolRunningRow');if(tr)tr.remove();
$('emptyState').style.display='none';
@@ -115,6 +197,7 @@ async function send(){
// and hiding content still inside an open thinking block.
function _streamDisplay(){
const raw=assistantText;
if(reasoningText) return raw;
for(const {open,close} of _thinkPairs){
// Trim leading whitespace before checking for the open tag — some models
// (e.g. MiniMax) emit newlines before <think>.
@@ -134,15 +217,52 @@ async function send(){
}
return raw;
}
function _parseStreamState(){
const raw=assistantText;
if(reasoningText){
return {thinkingText:reasoningText, displayText:_streamDisplay(), inThinking:false};
}
for(const {open,close} of _thinkPairs){
const trimmed=raw.trimStart();
if(trimmed.startsWith(open)){
const ci=trimmed.indexOf(close,open.length);
if(ci!==-1){
return {
thinkingText: trimmed.slice(open.length, ci).trim(),
displayText: trimmed.slice(ci+close.length).replace(/^\s+/,''),
inThinking:false,
};
}
return {
thinkingText: trimmed.slice(open.length).trim(),
displayText:'',
inThinking:true,
};
}
if(open.startsWith(trimmed)){
return {thinkingText:'', displayText:'', inThinking:true};
}
}
return {thinkingText:'', displayText:raw, inThinking:false};
}
function _renderLiveThinking(parsed){
const text=(parsed&&parsed.thinkingText)||'';
if(text||(parsed&&parsed.inThinking)){
if(typeof updateThinking==='function') updateThinking(text||'Thinking…');
else appendThinking();
return;
}
removeThinking();
}
function _scheduleRender(){
if(_renderPending) return;
_renderPending=true;
requestAnimationFrame(()=>{
_renderPending=false;
const parsed=_parseStreamState();
_renderLiveThinking(parsed);
if(assistantBody){
const txt=_streamDisplay();
const isThinking=!txt&&assistantText.length>0;
assistantBody.innerHTML=txt?renderMd(txt):(isThinking?'<span style="color:var(--muted);font-size:13px">Thinking\u2026</span>':'');
assistantBody.innerHTML=parsed.displayText?renderMd(parsed.displayText):'';
}
scrollIfPinned();
});
@@ -153,17 +273,61 @@ async function send(){
if(!S.session||S.session.session_id!==activeSid) return;
const d=JSON.parse(e.data);
assistantText+=d.text;
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
ensureAssistantRow();
_scheduleRender();
});
source.addEventListener('reasoning',e=>{
const d=JSON.parse(e.data);
reasoningText += d.text || '';
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
_scheduleRender();
});
source.addEventListener('tool',e=>{
const d=JSON.parse(e.data);
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false, tid:d.tid||`live-${Date.now()}-${Math.random().toString(36).slice(2,8)}`};
if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[];
INFLIGHT[activeSid].toolCalls.push(tc);
S.toolCalls=INFLIGHT[activeSid].toolCalls;
persistInflightState();
if(!S.session||S.session.session_id!==activeSid) return;
removeThinking();
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false};
S.toolCalls.push(tc);
appendLiveToolCard(tc);
scrollIfPinned();
});
source.addEventListener('tool_complete',e=>{
const d=JSON.parse(e.data);
const inflight=INFLIGHT[activeSid];
if(!inflight) return;
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
let tc=null;
for(let i=inflight.toolCalls.length-1;i>=0;i--){
const cur=inflight.toolCalls[i];
if(cur&&cur.done===false&&(!d.name||cur.name===d.name)){
tc=cur;
break;
}
}
if(!tc){
tc={name:d.name||'tool', preview:d.preview||'', args:d.args||{}, snippet:'', done:true};
inflight.toolCalls.push(tc);
}
tc.preview=d.preview||tc.preview||'';
tc.args=d.args||tc.args||{};
tc.done=true;
tc.is_error=!!d.is_error;
if(d.duration!==undefined) tc.duration=d.duration;
S.toolCalls=inflight.toolCalls;
persistInflightState();
if(!S.session||S.session.session_id!==activeSid) return;
appendLiveToolCard(tc);
scrollIfPinned();
});
@@ -180,7 +344,7 @@ async function send(){
source.close();
const d=JSON.parse(e.data);
delete INFLIGHT[activeSid];
clearInflight();
clearInflight();clearInflightState(activeSid);
stopApprovalPolling();
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);
if(S.session&&S.session.session_id===activeSid){
@@ -229,7 +393,7 @@ async function send(){
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
source.close();
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
@@ -290,7 +454,7 @@ async function send(){
source.addEventListener('cancel',e=>{
source.close();
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
@@ -305,16 +469,15 @@ async function send(){
}
function _handleStreamError(){
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();
_closeSource();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking();
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages();
}else{
// User switched away — show background error banner
if(typeof trackBackgroundError==='function'){
// Look up session title from the session list cache so the banner names it correctly
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
trackBackgroundError(activeSid,_errTitle,'Connection lost');
}

View File

@@ -308,10 +308,11 @@ async function cronDelete(id) {
function loadTodos() {
const panel = $('todoPanel');
if (!panel) return;
const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;
// Parse the most recent todo state from message history
let todos = [];
for (let i = S.messages.length - 1; i >= 0; i--) {
const m = S.messages[i];
for (let i = sourceMessages.length - 1; i >= 0; i--) {
const m = sourceMessages[i];
if (m && m.role === 'tool') {
try {
const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
@@ -1224,6 +1225,8 @@ async function loadSettingsPanel(){
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});}
const notifCb=$('settingsNotificationsEnabled');
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});}
const bubbleCb=$('settingsBubbleLayout');
if(bubbleCb){bubbleCb.checked=!!settings.bubble_layout;bubbleCb.addEventListener('change',_markSettingsDirty,{once:false});}
// Bot name
const botNameField=$('settingsBotName');
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
@@ -1266,6 +1269,8 @@ async function saveSettings(andClose){
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
body.bubble_layout=!!($('settingsBubbleLayout')||{}).checked;
document.body.classList.toggle('bubble-layout', body.bubble_layout);
const botName=(($('settingsBotName')||{}).value||'').trim();
body.bot_name=botName||'Hermes';
// Password: only act if the field has content; blank = leave auth unchanged

View File

@@ -11,7 +11,7 @@ const ICONS={
};
async function newSession(flash){
MSG_QUEUE.length=0;updateQueueBadge();
updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
// Use profile default workspace for new sessions after a profile switch (one-shot),
@@ -20,9 +20,19 @@ async function newSession(flash){
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
S.session=data.session;S.messages=data.session.messages||[];
S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id);
syncTopbar();await loadDir('.');renderMessages();
// Reset per-session visual state: a fresh chat is idle even if another
// conversation is still streaming in the background.
S.busy=false;
S.activeStreamId=null;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
setComposerStatus('');
updateQueueBadge(S.session.session_id);
syncTopbar();renderMessages();loadDir('.');
// don't call renderSessionList here - callers do it when needed
}
@@ -30,40 +40,74 @@ async function loadSession(sid){
stopApprovalPolling();hideApprovalCard();
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
S.session=data.session;
S.lastUsage={...(data.session.last_usage||{})};
localStorage.setItem('hermes-webui-session',S.session.session_id);
// B9: sanitize empty assistant messages that can appear when agent only ran tool calls
data.session.messages=(data.session.messages||[]).filter(m=>{
if(!m||!m.role)return false;
if(m.role==='tool')return false;
if(m.role==='assistant'){let c=m.content||'';if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('');return String(c).trim().length>0;}
return true;
});
const activeStreamId=data.session.active_stream_id||null;
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
const stored=loadInflightState(sid, activeStreamId);
if(stored){
INFLIGHT[sid]={
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[...(data.session.messages||[])],
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[...(data.session.pending_attachments||[])],
toolCalls:Array.isArray(stored.toolCalls)?stored.toolCalls:[],
reattach:true,
};
}
}
// Keep raw session.messages intact so side panels (e.g. Todos) can still
// reconstruct state from tool outputs after reload. Visible transcript rows
// are filtered later by renderMessages().
if(INFLIGHT[sid]){
S.messages=INFLIGHT[sid].messages;
// Restore live tool cards for this in-flight session
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
S.busy=true;
syncTopbar();renderMessages();appendThinking();loadDir('.');
clearLiveToolCards();
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
for(const tc of (S.toolCalls||[])){
if(tc&&tc.name) appendLiveToolCard(tc);
}
syncTopbar();await loadDir('.');renderMessages();appendThinking();
setBusy(true);setComposerStatus('');
startApprovalPolling(sid);
S.activeStreamId=activeStreamId;
const _cb=$('btnCancel');if(_cb&&activeStreamId)_cb.style.display='inline-flex';
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
INFLIGHT[sid].reattach=false;
attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
}
}else{
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
updateQueueBadge(sid);
S.messages=data.session.messages||[];
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
if(pendingMsg) S.messages.push(pendingMsg);
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
// Reset per-session visual state: the viewed session is idle even if another
// session's stream is still running in the background.
// We directly update the DOM instead of calling setBusy(false), because
// setBusy(false) drains MSG_QUEUE which we don't want here.
S.busy=false;
S.activeStreamId=null;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
setComposerStatus('');
clearLiveToolCards();
syncTopbar();await loadDir('.');renderMessages();highlightCode();
if(activeStreamId){
S.busy=true;
S.activeStreamId=activeStreamId;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='inline-flex';
setStatus('');
setComposerStatus('');
syncTopbar();renderMessages();appendThinking();loadDir('.');
updateQueueBadge(sid);
startApprovalPolling(sid);
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
}else{
// Reset per-session visual state: the viewed session is idle even if another
// session's stream is still running in the background.
// We directly update the DOM instead of calling setBusy(false), because
// setBusy(false) drains the viewed session's queued follow-up turns.
S.busy=false;
S.activeStreamId=null;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
setComposerStatus('');
updateQueueBadge(sid);
syncTopbar();renderMessages();highlightCode();loadDir('.');
}
}
// Sync context usage indicator from session data
const _s=S.session;

View File

@@ -114,7 +114,7 @@
--input-bg:rgba(255,255,255,.03);--hover-bg:rgba(255,255,255,.05);
}
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
.layout{display:flex;width:100%;height:100vh;height:100dvh;min-height:0;}
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
.sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,#e8a030,var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px rgba(233,69,96,.3);}
@@ -334,7 +334,7 @@
.sm-btn{flex:1;padding:8px 0;border-radius:8px;font-size:11px;font-weight:500;background:var(--input-bg);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
.sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);}
.sm-btn:disabled{opacity:.45;cursor:not-allowed;}
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:var(--main-bg);}
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;min-height:0;background:var(--main-bg);}
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
.topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
@@ -344,12 +344,23 @@
.workspace-toggle-btn.active{color:var(--blue);border-color:rgba(124,185,255,.35);background:rgba(124,185,255,.1);}
.workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;}
.chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);}
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;}
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;}
.messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
@media(min-width:1400px){.messages-inner{max-width:1100px;}}
@media(min-width:1800px){.messages-inner{max-width:1200px;}}
.msg-row{padding:10px 0;}
.msg-row+.msg-row{border-top:none;}
/* Bubble layout (issue #336): opt-in chat-bubble look with user messages right-aligned
and assistant messages left-aligned. Uses :has() to tag rows by role without JS
changes. Full-width by default -- enabled via body.bubble-layout from settings. */
body.bubble-layout .msg-row:has(.msg-role.user){align-self:flex-end;max-width:75%;}
body.bubble-layout .msg-row:has(.msg-role.user) .msg-body{padding-left:0;padding-right:30px;max-width:none;}
body.bubble-layout .msg-row:has(.msg-role.user) .msg-role{flex-direction:row-reverse;}
body.bubble-layout .msg-row:has(.msg-role.assistant){align-self:flex-start;max-width:75%;}
@media(max-width:700px){
body.bubble-layout .msg-row:has(.msg-role.user),
body.bubble-layout .msg-row:has(.msg-role.assistant){max-width:92%;}
}
.msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;}
.msg-role.user{color:rgba(124,185,255,0.65);}
.msg-role.assistant{color:rgba(201,168,76,0.6);}

View File

@@ -1,7 +1,28 @@
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'};
const INFLIGHT={}; // keyed by session_id while request in-flight
const MSG_QUEUE=[]; // messages queued while a request is in-flight
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
const $=id=>document.getElementById(id);
function _getSessionQueue(sid, create=false){
if(!sid) return [];
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
return SESSION_QUEUES[sid]||[];
}
function queueSessionMessage(sid, payload){
if(!sid||!payload) return 0;
const q=_getSessionQueue(sid,true);
q.push(payload);
return q.length;
}
function shiftQueuedSessionMessage(sid){
const q=_getSessionQueue(sid,false);
if(!q.length) return null;
const next=q.shift();
if(!q.length) delete SESSION_QUEUES[sid];
return next;
}
function getQueuedSessionCount(sid){
return _getSessionQueue(sid,false).length;
}
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
@@ -513,28 +534,37 @@ function setBusy(v){
setComposerStatus('');
// Always hide Cancel button when not busy
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
updateQueueBadge();
// Drain one queued message after UI settles
if(MSG_QUEUE.length>0){
const next=MSG_QUEUE.shift();
updateQueueBadge();
setTimeout(()=>{ $('msg').value=next; send(); }, 120);
const sid=S.session&&S.session.session_id;
updateQueueBadge(sid);
// Drain one queued message for the currently viewed session after UI settles
const next=sid?shiftQueuedSessionMessage(sid):null;
if(next){
updateQueueBadge(sid);
setTimeout(()=>{
$('msg').value=next.text||'';
S.pendingFiles=Array.isArray(next.files)?[...next.files]:[];
autoResize();
renderTray();
send();
},120);
}
}
}
function updateQueueBadge(){
function updateQueueBadge(sessionId){
const sid=sessionId||(S.session&&S.session.session_id);
const count=sid?getQueuedSessionCount(sid):0;
let badge=$('queueBadge');
if(MSG_QUEUE.length>0){
if(count>0){
if(!badge){
badge=document.createElement('div');
badge.id='queueBadge';
badge.style.cssText='position:fixed;bottom:80px;right:24px;background:rgba(124,185,255,.18);border:1px solid rgba(124,185,255,.4);color:var(--blue);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;z-index:50;pointer-events:none;backdrop-filter:blur(8px);';
document.body.appendChild(badge);
}
badge.textContent=MSG_QUEUE.length===1?'1 message queued':`${MSG_QUEUE.length} messages queued`;
} else {
if(badge) badge.remove();
badge.textContent=count===1?'1 message queued':`${count} messages queued`;
} else if(badge) {
badge.remove();
}
}
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
@@ -693,6 +723,47 @@ function copyMsg(btn){
// ── Reconnect banner (B4/B5: reload resilience) ──
const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking
const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'; // localStorage snapshots for mid-stream reload recovery
function _readInflightStateMap(){
try{
const raw=localStorage.getItem(INFLIGHT_STATE_KEY);
const parsed=raw?JSON.parse(raw):{};
return parsed&&typeof parsed==='object'?parsed:{};
}catch(_){
return {};
}
}
function saveInflightState(sid, state){
if(!sid||!state) return;
try{
const all=_readInflightStateMap();
all[sid]={...state,updated_at:Date.now()};
localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all));
}catch(_){ }
}
function loadInflightState(sid, streamId){
if(!sid) return null;
const all=_readInflightStateMap();
const entry=all[sid];
if(!entry) return null;
if(streamId&&entry.streamId&&entry.streamId!==streamId) return null;
if(entry.updated_at&&Date.now()-entry.updated_at>10*60*1000){
clearInflightState(sid);
return null;
}
return entry;
}
function clearInflightState(sid){
if(!sid) return;
try{
const all=_readInflightStateMap();
if(!(sid in all)) return;
delete all[sid];
if(Object.keys(all).length) localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all));
else localStorage.removeItem(INFLIGHT_STATE_KEY);
}catch(_){ }
}
function markInflight(sid, streamId) {
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()}));
@@ -714,11 +785,11 @@ async function refreshSession() {
try {
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
S.session = data.session;
S.messages = (data.session.messages || []).filter(m => {
if (!m || !m.role || m.role === 'tool') return false;
if (m.role === 'assistant') { let c = m.content || ''; if (Array.isArray(c)) c = c.map(p => p.text||'').join(''); return String(c).trim().length > 0; }
return true;
});
S.messages = data.session.messages || [];
const pendingMsg=getPendingSessionMessage(data.session);
if(pendingMsg) S.messages.push(pendingMsg);
S.activeStreamId=data.session.active_stream_id||null;
syncTopbar(); renderMessages();
showToast('Conversation refreshed');
} catch(e) { setStatus('Refresh failed: ' + e.message); }
@@ -764,12 +835,34 @@ async function applyUpdates(){
}
}
function getPendingSessionMessage(session){
const text=String(session?.pending_user_message||'').trim();
if(!text) return null;
const attachments=Array.isArray(session?.pending_attachments)?session.pending_attachments.filter(Boolean):[];
const messages=Array.isArray(session?.messages)?session.messages:[];
const lastUser=[...messages].reverse().find(m=>m&&m.role==='user');
if(lastUser){
const lastText=String(msgContent(lastUser)||'').trim();
if(lastText===text){
if(attachments.length&&!lastUser.attachments?.length) lastUser.attachments=attachments;
return null;
}
}
return {
role:'user',
content:text,
attachments:attachments.length?attachments:undefined,
_ts:session?.pending_started_at||Date.now()/1000,
_pending:true,
};
}
async function checkInflightOnBoot(sid) {
const raw = localStorage.getItem(INFLIGHT_KEY);
if (!raw) return;
try {
const {sid: inflightSid, streamId, ts} = JSON.parse(raw);
if (inflightSid !== sid) { clearInflight(); return; }
if (S.activeStreamId && S.activeStreamId === streamId) return;
// Only show banner if the in-flight entry is less than 10 minutes old
if (Date.now() - ts > 10 * 60 * 1000) { clearInflight(); return; }
// Check if stream is still active
@@ -915,6 +1008,7 @@ function renderMessages(){
}
const row=document.createElement('div');row.className='msg-row';
row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant';
if(m._live) row.setAttribute('data-live-assistant','1');
let filesHtml='';
if(m.attachments&&m.attachments.length)
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">${li('paperclip',12)} ${esc(f)}</div>`).join('')}</div>`;
@@ -1357,12 +1451,29 @@ function renderKatexBlocks(){
});
}
function appendThinking(){
$('emptyState').style.display='none';
const row=document.createElement('div');row.className='msg-row';row.id='thinkingRow';
row.innerHTML=`<div class="msg-role assistant"><div class="role-icon assistant">H</div>Hermes</div><div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
$('msgInner').appendChild(row);scrollToBottom();
function _thinkingMarkup(text=''){
const _bn=window._botName||'Hermes';
const icon=esc(_bn.charAt(0).toUpperCase());
const label=esc(_bn);
const body=(text&&String(text).trim())
? `<div class="thinking-card open"><div class="thinking-card-header"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span></div><div class="thinking-card-body"><pre>${esc(String(text).trim())}</pre></div></div>`
: `<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
return `<div class="msg-role assistant"><div class="role-icon assistant">${icon}</div>${label}</div>${body}`;
}
function appendThinking(text=''){
$('emptyState').style.display='none';
let row=$('thinkingRow');
if(!row){
row=document.createElement('div');
row.className='msg-row';
row.id='thinkingRow';
$('msgInner').appendChild(row);
}
row.className=(text&&String(text).trim())?'msg-row thinking-card-row':'msg-row';
row.innerHTML=_thinkingMarkup(text);
scrollToBottom();
}
function updateThinking(text=''){appendThinking(text);}
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
function fileIcon(name, type){

View File

@@ -275,6 +275,64 @@ def test_gateway_session_messages_readable():
post('/api/settings', {'show_cli_sessions': False})
def test_importing_older_gateway_session_preserves_original_timestamps_and_order():
"""Importing an older gateway session should not bump it above newer WebUI sessions."""
conn = _ensure_state_db()
older_started_at = time.time() - 1800
imported_sid = 'gw_import_old_001'
newer_webui_sid = None
try:
newer_webui, status = post('/api/session/new', {'model': 'openai/gpt-5'})
assert status == 200, newer_webui
newer_webui_sid = newer_webui['session']['session_id']
rename, rename_status = post(
'/api/session/rename',
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
)
assert rename_status == 200, rename
_insert_gateway_session(
conn,
session_id=imported_sid,
source='discord',
title='Older imported gateway session',
started_at=older_started_at,
)
post('/api/settings', {'show_cli_sessions': True})
imported, imported_status = post('/api/session/import_cli', {'session_id': imported_sid})
assert imported_status == 200, imported
imported_session = imported['session']
assert abs(imported_session['created_at'] - older_started_at) < 2, imported_session
assert abs(imported_session['updated_at'] - older_started_at) < 5, imported_session
sessions_payload, sessions_status = get('/api/sessions')
assert sessions_status == 200, sessions_payload
ordered_ids = [item['session_id'] for item in sessions_payload.get('sessions', [])]
assert newer_webui_sid in ordered_ids, ordered_ids
assert imported_sid in ordered_ids, ordered_ids
assert ordered_ids.index(newer_webui_sid) < ordered_ids.index(imported_sid), ordered_ids
finally:
try:
_remove_test_sessions(conn, imported_sid)
conn.close()
except Exception:
pass
if imported_sid:
try:
post('/api/session/delete', {'session_id': imported_sid})
except Exception:
pass
if newer_webui_sid:
try:
post('/api/session/delete', {'session_id': newer_webui_sid})
except Exception:
pass
post('/api/settings', {'show_cli_sessions': False})
def test_gateway_sse_stream_endpoint_exists():
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
# The SSE endpoint requires show_cli_sessions to be enabled

322
tests/test_issue336.py Normal file
View File

@@ -0,0 +1,322 @@
"""
Tests for issue #336 — opt-in chat bubble layout (PR #398).
Covers:
- api/config.py: bubble_layout present in _SETTINGS_DEFAULTS with default False
- api/config.py: bubble_layout present in _SETTINGS_BOOL_KEYS
- api/config.py: bubble_layout not in password-filtered keys (safe to expose)
- static/boot.js: boot path applies bubble-layout class from settings
- static/boot.js: catch path removes bubble-layout class on API failure
- static/panels.js: loadSettingsPanel reads bubble_layout checkbox
- static/panels.js: saveSettings writes bubble_layout and toggles body class
- static/style.css: body.bubble-layout CSS selectors present
- static/style.css: responsive max-width rule for bubble layout
- static/index.html: settingsBubbleLayout checkbox element present
- static/index.html: i18n keys wired on label and description
- static/i18n.js: English label and description keys present
- static/i18n.js: Spanish label and description keys present
- Integration: bubble_layout default is False in GET /api/settings
- Integration: bubble_layout persists via POST /api/settings
- Integration: non-bool value is coerced to bool on POST
"""
import json
import pathlib
import re
import unittest
import urllib.error
import urllib.request
REPO_ROOT = pathlib.Path(__file__).parent.parent
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text()
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text()
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text()
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text()
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text()
BASE = "http://127.0.0.1:8788"
def _get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def _post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path, data=data, headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
# ── config.py static checks ───────────────────────────────────────────────
class TestBubbleLayoutConfig(unittest.TestCase):
"""Verify bubble_layout is correctly registered in config.py."""
def test_bubble_layout_in_settings_defaults(self):
"""bubble_layout must appear in _SETTINGS_DEFAULTS."""
self.assertIn(
'"bubble_layout"',
CONFIG_PY,
"bubble_layout key missing from _SETTINGS_DEFAULTS in api/config.py",
)
def test_bubble_layout_default_is_false(self):
"""bubble_layout default value must be False (opt-in, off by default)."""
# Match "bubble_layout": False with optional spacing
self.assertRegex(
CONFIG_PY,
r'"bubble_layout"\s*:\s*False',
"bubble_layout default must be False in _SETTINGS_DEFAULTS",
)
def test_bubble_layout_in_bool_keys(self):
"""bubble_layout must be in _SETTINGS_BOOL_KEYS for coercion."""
# Find the _SETTINGS_BOOL_KEYS block and verify membership
bool_keys_match = re.search(
r"_SETTINGS_BOOL_KEYS\s*=\s*\{([^}]+)\}", CONFIG_PY, re.DOTALL
)
self.assertIsNotNone(
bool_keys_match, "_SETTINGS_BOOL_KEYS block not found in config.py"
)
self.assertIn(
'"bubble_layout"',
bool_keys_match.group(1),
"bubble_layout missing from _SETTINGS_BOOL_KEYS",
)
# ── boot.js static checks ────────────────────────────────────────────────
class TestBubbleLayoutBootJS(unittest.TestCase):
"""Verify bubble-layout class management in boot.js."""
def test_boot_applies_bubble_layout_class(self):
"""boot.js success path must toggle body.bubble-layout from settings."""
self.assertIn(
"classList.toggle('bubble-layout',!!s.bubble_layout)",
BOOT_JS,
"boot.js must call classList.toggle('bubble-layout', ...) on settings load",
)
def test_boot_catch_removes_bubble_layout_class(self):
"""boot.js catch path must remove bubble-layout (default off on API failure)."""
self.assertIn(
"classList.remove('bubble-layout')",
BOOT_JS,
"boot.js catch block must call classList.remove('bubble-layout') on API failure",
)
# ── panels.js static checks ──────────────────────────────────────────────
class TestBubbleLayoutPanelsJS(unittest.TestCase):
"""Verify settings panel wires the bubble_layout checkbox."""
def test_load_settings_reads_bubble_layout_checkbox(self):
"""loadSettingsPanel must read the settingsBubbleLayout checkbox state."""
self.assertIn(
"settingsBubbleLayout",
PANELS_JS,
"panels.js must reference settingsBubbleLayout checkbox",
)
def test_save_settings_writes_bubble_layout(self):
"""saveSettings must write body.bubble_layout from the checkbox."""
self.assertIn(
"body.bubble_layout",
PANELS_JS,
"saveSettings must set body.bubble_layout from checkbox",
)
def test_save_settings_toggles_body_class(self):
"""saveSettings must apply body class toggle for live preview."""
self.assertIn(
"classList.toggle('bubble-layout', body.bubble_layout)",
PANELS_JS,
"saveSettings must toggle 'bubble-layout' on document.body for live preview",
)
# ── style.css static checks ──────────────────────────────────────────────
class TestBubbleLayoutCSS(unittest.TestCase):
"""Verify CSS selectors for bubble layout are present and gated on body class."""
def test_user_row_right_align_selector_present(self):
"""CSS must right-align user message rows when bubble-layout is active."""
self.assertIn(
"body.bubble-layout .msg-row:has(.msg-role.user)",
STYLE_CSS,
"CSS selector for user bubble alignment missing from style.css",
)
def test_assistant_row_left_align_selector_present(self):
"""CSS must left-align assistant message rows when bubble-layout is active."""
self.assertIn(
"body.bubble-layout .msg-row:has(.msg-role.assistant)",
STYLE_CSS,
"CSS selector for assistant bubble alignment missing from style.css",
)
def test_bubble_layout_responsive_rule_present(self):
"""A responsive max-width rule for narrow screens must be present."""
# Both selectors must appear inside a @media block
self.assertRegex(
STYLE_CSS,
r"@media\([^)]*700px[^)]*\)[^{]*\{[^}]*bubble-layout",
"Responsive bubble-layout rule (700px breakpoint) missing from style.css",
)
# ── index.html static checks ─────────────────────────────────────────────
class TestBubbleLayoutHTML(unittest.TestCase):
"""Verify the settings checkbox is present and correctly wired in index.html."""
def test_settings_checkbox_present(self):
"""The settingsBubbleLayout checkbox must exist in index.html."""
self.assertIn(
'id="settingsBubbleLayout"',
INDEX_HTML,
"settingsBubbleLayout checkbox missing from index.html",
)
def test_settings_label_i18n_key_wired(self):
"""Label span must carry the settings_label_bubble_layout i18n key."""
self.assertIn(
'data-i18n="settings_label_bubble_layout"',
INDEX_HTML,
"settings_label_bubble_layout i18n key not wired on label span",
)
def test_settings_desc_i18n_key_wired(self):
"""Description div must carry the settings_desc_bubble_layout i18n key."""
self.assertIn(
'data-i18n="settings_desc_bubble_layout"',
INDEX_HTML,
"settings_desc_bubble_layout i18n key not wired on description div",
)
# ── i18n.js static checks ────────────────────────────────────────────────
class TestBubbleLayoutI18N(unittest.TestCase):
"""Verify English and Spanish locale keys are present in i18n.js."""
def _extract_locale_block(self, lang_start_marker, lang_end_marker):
"""Extract the content between two locale markers."""
start = I18N_JS.find(lang_start_marker)
end = I18N_JS.find(lang_end_marker, start)
self.assertGreater(start, -1, f"Start marker '{lang_start_marker}' not found")
self.assertGreater(end, start, f"End marker '{lang_end_marker}' not found after start")
return I18N_JS[start:end]
def test_english_label_key_present(self):
"""English locale must have settings_label_bubble_layout."""
en_block = self._extract_locale_block("\n en: {", "\n es: {")
self.assertIn(
"settings_label_bubble_layout",
en_block,
"settings_label_bubble_layout missing from English locale",
)
def test_english_desc_key_present(self):
"""English locale must have settings_desc_bubble_layout."""
en_block = self._extract_locale_block("\n en: {", "\n es: {")
self.assertIn(
"settings_desc_bubble_layout",
en_block,
"settings_desc_bubble_layout missing from English locale",
)
def test_spanish_label_key_present(self):
"""Spanish locale must have settings_label_bubble_layout."""
es_block = self._extract_locale_block("\n es: {", "\n de: {")
self.assertIn(
"settings_label_bubble_layout",
es_block,
"settings_label_bubble_layout missing from Spanish locale",
)
def test_spanish_desc_key_present(self):
"""Spanish locale must have settings_desc_bubble_layout."""
es_block = self._extract_locale_block("\n es: {", "\n de: {")
self.assertIn(
"settings_desc_bubble_layout",
es_block,
"settings_desc_bubble_layout missing from Spanish locale",
)
# ── Integration tests (require live server on port 8788) ─────────────────
class TestBubbleLayoutSettingsAPI(unittest.TestCase):
"""Integration tests: bubble_layout via GET/POST /api/settings."""
def test_bubble_layout_default_is_false(self):
"""GET /api/settings must return bubble_layout: false by default."""
try:
d, status = _get("/api/settings")
except OSError:
self.skipTest("Server not running on port 8788")
self.assertEqual(status, 200)
self.assertIn(
"bubble_layout",
d,
"bubble_layout missing from GET /api/settings response",
)
self.assertFalse(
d["bubble_layout"],
"bubble_layout default must be False (opt-in feature)",
)
def test_bubble_layout_persists_true(self):
"""POST /api/settings with bubble_layout:true must persist and round-trip."""
try:
_, status = _post("/api/settings", {"bubble_layout": True})
except OSError:
self.skipTest("Server not running on port 8788")
self.assertEqual(status, 200)
d, _ = _get("/api/settings")
self.assertTrue(d["bubble_layout"], "bubble_layout=True must persist after POST")
# Restore
_post("/api/settings", {"bubble_layout": False})
def test_bubble_layout_persists_false(self):
"""POST /api/settings with bubble_layout:false must persist and round-trip."""
try:
_post("/api/settings", {"bubble_layout": True})
_post("/api/settings", {"bubble_layout": False})
except OSError:
self.skipTest("Server not running on port 8788")
d, _ = _get("/api/settings")
self.assertFalse(d["bubble_layout"], "bubble_layout=False must persist after POST")
def test_bubble_layout_truthy_string_coerced_to_bool(self):
"""Non-bool truthy value must be coerced to bool by _SETTINGS_BOOL_KEYS logic."""
try:
_post("/api/settings", {"bubble_layout": "1"})
except OSError:
self.skipTest("Server not running on port 8788")
d, _ = _get("/api/settings")
self.assertIsInstance(
d["bubble_layout"],
bool,
"bubble_layout must be a bool in API response (bool coercion via _SETTINGS_BOOL_KEYS)",
)
# Restore
_post("/api/settings", {"bubble_layout": False})

View File

@@ -133,6 +133,21 @@ def test_toggle_mobile_files_js_defined():
"toggleMobileFiles() must toggle mobile-open class on the right panel"
def test_new_conversation_closes_mobile_sidebar():
"""New conversation must close the mobile drawer so the chat pane is visible immediately."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
click_line = next((ln for ln in boot_js.splitlines() if "$('btnNewChat').onclick" in ln), "")
assert click_line, "btnNewChat onclick handler missing from static/boot.js"
assert "closeMobileSidebar" in click_line, \
"btnNewChat handler must closeMobileSidebar() after creating the new session"
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+4])
assert "closeMobileSidebar" in shortcut_block, \
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"
# ── Viewport and scroll safety ────────────────────────────────────────────────
def test_body_overflow_hidden():
@@ -143,6 +158,32 @@ def test_body_overflow_hidden():
"body must have overflow:hidden to prevent double scrollbars"
def test_flex_parents_allow_message_scroller_to_shrink():
"""The top-level flex containers must opt into min-height:0 so .messages can scroll on mobile.
Mobile Safari/Chrome can trap scroll when a flex child with overflow:auto sits inside
parents whose min-height remains auto. Both .layout and .main need min-height:0.
"""
assert re.search(r'\.layout\{[^}]*min-height:0', CSS), \
".layout must set min-height:0 so the chat column can shrink and scroll"
assert re.search(r'\.main\{[^}]*min-height:0', CSS), \
".main must set min-height:0 so .messages remains scrollable while busy"
def test_messages_touch_scrolling_hints_present():
"""The messages scroller must advertise touch-friendly scrolling behavior.
On mobile browsers, momentum scrolling and explicit pan-y/overscroll behavior help
prevent the chat area from feeling locked while the app body itself stays overflow:hidden.
"""
assert re.search(r'\.messages\{[^}]*-webkit-overflow-scrolling:\s*touch', CSS), \
".messages must enable -webkit-overflow-scrolling:touch for mobile momentum scroll"
assert re.search(r'\.messages\{[^}]*touch-action:\s*pan-y', CSS), \
".messages must set touch-action:pan-y so vertical swipe gestures scroll the transcript"
assert re.search(r'\.messages\{[^}]*overscroll-behavior-y:\s*contain', CSS), \
".messages must contain vertical overscroll so the transcript keeps the gesture"
def test_100dvh_viewport_height():
"""Layout must use 100dvh (dynamic viewport height) for correct mobile sizing.

View File

@@ -0,0 +1,184 @@
"""
Tests: onboarding /api/onboarding/setup network restriction logic (issue #390).
Covers:
1. Request from 127.0.0.1 (loopback) is allowed without auth
2. Request from RFC-1918 private IP (172.x, 192.168.x, 10.x) is allowed without auth
3. Request from public IP is blocked without auth → 403
4. X-Forwarded-For loopback IP is trusted → allowed
5. X-Forwarded-For private IP is trusted → allowed
6. X-Forwarded-For public IP → still blocked
7. X-Real-IP loopback → allowed
8. HERMES_WEBUI_ONBOARDING_OPEN=1 bypasses the check entirely
9. Auth enabled → check skipped, any IP allowed
"""
import json
import os
import pathlib
import sys
import unittest.mock
import urllib.error
import urllib.request
import pytest
REPO = pathlib.Path(__file__).parent.parent
BASE = "http://127.0.0.1:8788"
# ---------------------------------------------------------------------------
# Unit tests — directly test the IP-resolution + guard logic in routes.py
# without needing a live server. We replicate the logic to keep tests fast
# and independent of server startup.
# ---------------------------------------------------------------------------
def _is_local_from_handler(
raw_ip: str,
xff: str = "",
xri: str = "",
auth_enabled: bool = False,
open_env: bool = False,
) -> bool | str:
"""
Mirror of the onboarding IP check in api/routes.py.
Returns True if the request would be allowed, False if blocked,
or the error message string if blocked.
"""
import ipaddress
if auth_enabled or open_env:
return True
_xff = xff.split(",")[0].strip() if xff else ""
_xri = xri.strip()
_ip_str = _xff or _xri or raw_ip
try:
addr = ipaddress.ip_address(_ip_str)
is_local = addr.is_loopback or addr.is_private
except ValueError:
is_local = False
return is_local
class TestOnboardingIPLogic:
"""Unit tests for the IP-resolution logic (no live server needed)."""
def test_loopback_allowed(self):
assert _is_local_from_handler("127.0.0.1") is True
def test_ipv6_loopback_allowed(self):
assert _is_local_from_handler("::1") is True
def test_private_172_allowed(self):
"""Docker bridge addresses (172.17.x.x) must be allowed."""
assert _is_local_from_handler("172.17.0.1") is True
def test_private_192168_allowed(self):
assert _is_local_from_handler("192.168.1.100") is True
def test_private_10_allowed(self):
assert _is_local_from_handler("10.0.0.5") is True
def test_public_ip_blocked(self):
assert _is_local_from_handler("8.8.8.8") is False
def test_xff_loopback_trusted(self):
"""Reverse proxy sets X-Forwarded-For to 127.0.0.1 — should be allowed."""
assert _is_local_from_handler("172.20.0.1", xff="127.0.0.1") is True
def test_xff_private_trusted(self):
"""Reverse proxy sets X-Forwarded-For to LAN IP — should be allowed."""
assert _is_local_from_handler("172.20.0.1", xff="192.168.1.50") is True
def test_xff_public_blocked(self):
"""Public IP in X-Forwarded-For should still be blocked."""
assert _is_local_from_handler("172.20.0.1", xff="8.8.8.8") is False
def test_xff_first_entry_used(self):
"""X-Forwarded-For may have multiple IPs; only the first (client) is used."""
# First entry is private → allowed
assert _is_local_from_handler("172.20.0.1", xff="10.0.0.1, 172.20.0.1") is True
# First entry is public → blocked
assert _is_local_from_handler("172.20.0.1", xff="8.8.8.8, 172.20.0.1") is False
def test_xreal_ip_loopback_trusted(self):
"""X-Real-IP loopback → allowed."""
assert _is_local_from_handler("172.20.0.1", xri="127.0.0.1") is True
def test_xreal_ip_private_trusted(self):
assert _is_local_from_handler("172.20.0.1", xri="10.1.2.3") is True
def test_xff_takes_priority_over_xri(self):
"""X-Forwarded-For wins over X-Real-IP when both present."""
# XFF says public, XRI says local → blocked (XFF takes priority)
assert _is_local_from_handler("172.20.0.1", xff="8.8.8.8", xri="127.0.0.1") is False
def test_open_env_bypasses_check(self):
"""HERMES_WEBUI_ONBOARDING_OPEN=1 allows any IP."""
assert _is_local_from_handler("8.8.8.8", open_env=True) is True
def test_auth_enabled_bypasses_check(self):
"""When auth is enabled, IP check is skipped entirely."""
assert _is_local_from_handler("8.8.8.8", auth_enabled=True) is True
def test_invalid_ip_blocked(self):
"""Malformed IP in header → treated as non-local → blocked."""
assert _is_local_from_handler("not-an-ip") is False
# ---------------------------------------------------------------------------
# Integration tests — hit the live test server at port 8788
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestOnboardingSetupEndpoint:
"""
Integration tests for /api/onboarding/setup.
These require the test server running on port 8788.
"""
def _post(self, path: str, data: dict, headers: dict | None = None) -> tuple[int, dict]:
payload = json.dumps(data).encode()
req = urllib.request.Request(
BASE + path,
data=payload,
method="POST",
headers={"Content-Type": "application/json", **(headers or {})},
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def test_loopback_request_allowed(self):
"""
Requests from 127.0.0.1 (which is what the test server sees) should
pass the IP check. We confirm no 403 is returned.
"""
# The test server runs on 127.0.0.1:8788 so client_address[0] is 127.0.0.1.
# A valid setup payload with a mock provider should not be rejected for IP reasons.
# We patch apply_onboarding_setup to avoid actually writing any config.
import unittest.mock
with unittest.mock.patch("api.onboarding.apply_onboarding_setup", return_value={"ok": True}):
status, body = self._post(
"/api/onboarding/setup",
{"provider": "anthropic", "model": "claude-sonnet-4.6", "api_key": "test-key"},
)
# Should not be 403 (IP blocked). May be 200 or another error from apply logic.
assert status != 403, f"Got 403 — IP check incorrectly blocked loopback. Body: {body}"
def test_xff_loopback_header_respected(self):
"""
Simulated reverse proxy: raw TCP is 127.0.0.1 but X-Forwarded-For is also
127.0.0.1. Should be allowed.
"""
import unittest.mock
with unittest.mock.patch("api.onboarding.apply_onboarding_setup", return_value={"ok": True}):
status, body = self._post(
"/api/onboarding/setup",
{"provider": "anthropic", "model": "claude-sonnet-4.6", "api_key": "test-key"},
headers={"X-Forwarded-For": "127.0.0.1"},
)
assert status != 403, f"Got 403 with XFF=127.0.0.1. Body: {body}"

View File

@@ -0,0 +1,70 @@
"""
Tests for OpenCode Zen and OpenCode Go provider support.
Verifies provider registration in display/model catalogs and
env-var fallback detection.
"""
import os
import sys
import types
import api.config as config
# ── Provider registration ─────────────────────────────────────────────
def test_opencode_zen_in_provider_display():
assert "opencode-zen" in config._PROVIDER_DISPLAY
assert config._PROVIDER_DISPLAY["opencode-zen"] == "OpenCode Zen"
def test_opencode_go_in_provider_display():
assert "opencode-go" in config._PROVIDER_DISPLAY
assert config._PROVIDER_DISPLAY["opencode-go"] == "OpenCode Go"
def test_opencode_zen_in_provider_models():
assert "opencode-zen" in config._PROVIDER_MODELS
ids = [m["id"] for m in config._PROVIDER_MODELS["opencode-zen"]]
assert "claude-opus-4-6" in ids
assert "gpt-5.4-pro" in ids
assert "glm-5.1" in ids
def test_opencode_go_in_provider_models():
assert "opencode-go" in config._PROVIDER_MODELS
ids = [m["id"] for m in config._PROVIDER_MODELS["opencode-go"]]
assert "glm-5.1" in ids
assert "glm-5" in ids
assert "mimo-v2-pro" in ids
# ── Env-var fallback detection ────────────────────────────────────────
def _models_with_env_key(monkeypatch, env_var, expected_provider_display):
"""Helper: fake hermes_cli unavailable, set an env var, check detection."""
# Force the env-var fallback path by making hermes_cli import fail
fake_mod = types.ModuleType("hermes_cli.models")
fake_mod.list_available_providers = None # will raise on call
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_mod)
monkeypatch.delattr(fake_mod, "list_available_providers")
old_cfg = dict(config.cfg)
config.cfg["model"] = {}
config.cfg.pop("custom_providers", None)
monkeypatch.setenv(env_var, "test-key")
try:
result = config.get_available_models()
providers = [g["provider"] for g in result["groups"]]
assert expected_provider_display in providers, (
f"Expected {expected_provider_display} in {providers}"
)
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
def test_opencode_zen_detected_via_env_key(monkeypatch):
_models_with_env_key(monkeypatch, "OPENCODE_ZEN_API_KEY", "OpenCode Zen")
def test_opencode_go_detected_via_env_key(monkeypatch):
_models_with_env_key(monkeypatch, "OPENCODE_GO_API_KEY", "OpenCode Go")

View File

@@ -0,0 +1,63 @@
import importlib
import os
import sys
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
def _reload_profiles_module(base_home: Path):
os.environ["HERMES_BASE_HOME"] = str(base_home)
os.environ["HERMES_HOME"] = str(base_home)
for name in ["api.config", "api.profiles"]:
if name in sys.modules:
del sys.modules[name]
profiles = importlib.import_module("api.profiles")
return profiles
def test_switch_profile_rejects_path_traversal():
with tempfile.TemporaryDirectory() as td:
temp_root = Path(td)
base = temp_root / ".hermes"
(base / "profiles").mkdir(parents=True)
(temp_root / "escape-target").mkdir()
profiles = _reload_profiles_module(base)
with pytest.raises(ValueError):
profiles.switch_profile("../../escape-target")
def test_delete_profile_rejects_path_traversal():
with tempfile.TemporaryDirectory() as td:
temp_root = Path(td)
base = temp_root / ".hermes"
(base / "profiles").mkdir(parents=True)
(temp_root / "escape-target").mkdir()
profiles = _reload_profiles_module(base)
with pytest.raises(ValueError):
profiles.delete_profile_api("../../escape-target")
def test_switch_profile_allows_valid_profile_name():
with tempfile.TemporaryDirectory() as td:
temp_root = Path(td)
base = temp_root / ".hermes"
profile_dir = base / "profiles" / "demo"
profile_dir.mkdir(parents=True)
profiles = _reload_profiles_module(base)
result = profiles.switch_profile("demo")
assert result["active"] == "demo"
assert Path(os.environ["HERMES_HOME"]).resolve() == profile_dir.resolve()

View File

@@ -241,6 +241,24 @@ def test_done_handler_guards_setbusy_with_inflight_check(cleanup_test_sessions):
assert "INFLIGHT[S.session.session_id]" in src, "messages.js must guard setBusy(false) with INFLIGHT check for current session"
def test_refresh_handler_does_not_drop_tool_messages_needed_by_todos(cleanup_test_sessions):
"""Todo panel state must survive session reload/refresh.
The UI can hide tool-role messages from the visible transcript, but it must not
destroy the raw session messages because loadTodos reconstructs state from the
latest todo tool output.
"""
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
ui_src = (REPO_ROOT / "static/ui.js").read_text()
panels_src = (REPO_ROOT / "static/panels.js").read_text()
assert "data.session.messages=(data.session.messages||[]).filter(" not in sessions_src, \
"sessions.js must not overwrite raw session.messages when filtering transcript display"
assert "S.messages = (data.session.messages || []).filter(" not in ui_src, \
"ui.js refreshSession must not rebuild S.messages by discarding tool messages from the raw session payload"
assert "const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;" in panels_src, \
"loadTodos must prefer raw S.session.messages so todo state survives reloads"
def test_cancel_button_not_cleared_across_sessions(cleanup_test_sessions):
"""R7c: The Cancel button and activeStreamId must only be cleared when the
done/error event belongs to the currently viewed session.
@@ -440,7 +458,166 @@ def test_newSession_clears_live_tool_cards(cleanup_test_sessions):
assert "clearLiveToolCards" in new_sess_body, "newSession() must call clearLiveToolCards() to clear stale live cards"
# ── R16: Stack traces must not leak to clients in 500 responses ────────────
def test_newSession_resets_busy_state_for_fresh_chat(cleanup_test_sessions):
"""R15b: newSession() must reset the viewed chat to idle state.
Without this, starting a second chat while another session is streaming leaves
S.busy=true, so the first send in the new chat gets incorrectly queued.
"""
src = (REPO_ROOT / "static/sessions.js").read_text()
new_sess_idx = src.find("async function newSession(")
assert new_sess_idx >= 0
next_fn = src.find("async function ", new_sess_idx + 10)
new_sess_body = src[new_sess_idx:next_fn]
assert "S.busy=false;" in new_sess_body, \
"newSession() must clear S.busy so a fresh chat is immediately sendable"
assert "S.activeStreamId=null;" in new_sess_body, \
"newSession() must clear the active stream id for the newly viewed chat"
assert "updateQueueBadge(S.session.session_id);" in new_sess_body, \
"newSession() must refresh the badge for the new session rather than leaving the old session's queue badge visible"
def test_session_scoped_message_queue_frontend_wiring(cleanup_test_sessions):
"""R15bb: queued follow-ups must stay attached to their originating session.
The frontend should use a session-keyed queue store and drain only the active
session's queued messages when that session becomes idle.
"""
ui_src = (REPO_ROOT / "static/ui.js").read_text()
messages_src = (REPO_ROOT / "static/messages.js").read_text()
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
assert "const SESSION_QUEUES" in ui_src
assert "function queueSessionMessage" in ui_src
assert "function shiftQueuedSessionMessage" in ui_src
assert "const sid=S.session&&S.session.session_id;" in ui_src
assert "const next=sid?shiftQueuedSessionMessage(sid):null;" in ui_src
assert "queueSessionMessage(S.session.session_id" in messages_src
assert "updateQueueBadge(S.session.session_id);" in messages_src
assert "updateQueueBadge(sid);" in sessions_src
def test_chat_start_persists_pending_turn_metadata_for_reload_recovery(cleanup_test_sessions):
"""R15c: chat/start must expose enough pending-turn metadata for a reload to
rebuild the in-flight conversation instead of showing a blank session.
"""
routes_src = (REPO_ROOT / "api/routes.py").read_text()
assert 's.active_stream_id = stream_id' in routes_src
assert 's.pending_user_message = msg' in routes_src
assert 's.pending_attachments = attachments' in routes_src
assert '"active_stream_id": getattr(s, "active_stream_id", None)' in routes_src
assert '"pending_user_message": getattr(s, "pending_user_message", None)' in routes_src
def test_reload_path_restores_pending_message_and_reattaches_live_stream(cleanup_test_sessions):
"""R15d: the frontend reload path must show the pending user turn and
reattach to the live SSE stream after loadSession().
"""
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
ui_src = (REPO_ROOT / "static/ui.js").read_text()
messages_src = (REPO_ROOT / "static/messages.js").read_text()
assert 'getPendingSessionMessage' in ui_src
assert 'pending_user_message' in ui_src
assert 'function attachLiveStream' in messages_src
assert 'const pendingMsg=typeof getPendingSessionMessage' in sessions_src
assert 'const activeStreamId=data.session.active_stream_id||null;' in sessions_src
assert 'attachLiveStream(sid, activeStreamId' in sessions_src
assert 'if (S.activeStreamId && S.activeStreamId === streamId) return;' in ui_src
# ── R16: Switching away/back must preserve live partial assistant output ─────
def test_live_stream_tokens_persist_partial_assistant_for_session_switch(cleanup_test_sessions):
"""R16: in-flight assistant text must be mirrored into INFLIGHT session state,
and the live stream must rebind to the rebuilt DOM after switching away and back.
Without this, partial assistant output disappears until the final done payload lands.
"""
messages_src = (REPO_ROOT / "static/messages.js").read_text()
ui_src = (REPO_ROOT / "static/ui.js").read_text()
assert "content:assistantText" in messages_src, \
"messages.js must persist the partial assistant text into INFLIGHT state"
assert "_live:true" in messages_src, \
"messages.js must mark the persisted in-flight assistant row so renderMessages can re-anchor it"
assert "syncInflightAssistantMessage();" in messages_src, \
"token handler must update INFLIGHT state before checking the active session"
assert "assistantRow&&!assistantRow.isConnected" in messages_src, \
"live stream must drop stale detached assistant DOM references after session switches"
assert "data-live-assistant" in ui_src, \
"renderMessages must preserve a live-assistant DOM anchor when rebuilding the thread"
def test_inflight_session_state_tracks_live_tool_cards_per_session(cleanup_test_sessions):
"""R16b: live tool cards must be stored on the in-flight session, not only in the
global S.toolCalls array, so switching chats does not lose or misattach them.
"""
messages_src = (REPO_ROOT / "static/messages.js").read_text()
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
assert "INFLIGHT[activeSid].toolCalls.push(tc);" in messages_src, \
"tool SSE handler must persist live tool calls onto the in-flight session"
assert "S.toolCalls=(INFLIGHT[sid].toolCalls||[]);" in sessions_src, \
"loadSession() must restore live tool calls from the in-flight session state"
def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessions):
"""R16c: loading an in-flight session must mark it busy before renderMessages().
Otherwise renderMessages() treats S.toolCalls as settled history cards and the
same tool call appears once inline and once in the live tool host after a
session switch.
"""
src = (REPO_ROOT / "static/sessions.js").read_text()
inflight_idx = src.find("if(INFLIGHT[sid]){")
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
inflight_block = src[inflight_idx:inflight_idx+700]
busy_pos = inflight_block.find("S.busy=true;")
render_pos = inflight_block.find("renderMessages();appendThinking();")
assert busy_pos >= 0, "loadSession INFLIGHT branch must set S.busy=true"
assert render_pos >= 0, "loadSession INFLIGHT branch must call renderMessages()"
assert busy_pos < render_pos, \
"loadSession must set S.busy=true before renderMessages() to avoid duplicate tool cards"
def test_streaming_bridge_accepts_current_tool_progress_callback_signature(cleanup_test_sessions):
"""R17: api/streaming.py must accept the current Hermes agent callback contract.
The agent now calls tool_progress_callback(event_type, name, preview, args, **kwargs).
If the WebUI bridge only accepts (name, preview, args), live tool updates silently vanish.
"""
src = (REPO_ROOT / "api/streaming.py").read_text()
assert "def on_tool(*cb_args, **cb_kwargs):" in src, \
"streaming.py must accept variable callback args for tool progress events"
assert "reasoning_callback=on_reasoning" in src, \
"streaming.py must wire the agent's reasoning callback into the SSE bridge"
assert "put('tool_complete'" in src or 'put("tool_complete"' in src, \
"streaming.py must emit live tool completion SSE events"
def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_sessions):
"""R18: messages.js must render live reasoning and react to tool completion events.
Without these handlers, the operator only sees generic Thinking… or nothing
until the final done snapshot redraws the whole turn.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
assert "let reasoningText=''" in src, \
"messages.js must track streamed reasoning text separately from assistant text"
assert "source.addEventListener('reasoning'" in src or 'source.addEventListener("reasoning"' in src, \
"messages.js must listen for live reasoning SSE events"
assert "source.addEventListener('tool_complete'" in src or 'source.addEventListener("tool_complete"' in src, \
"messages.js must listen for live tool completion SSE events"
assert "function _parseStreamState()" in src, \
"messages.js must parse live stream state into reasoning + visible answer"
def test_ui_js_can_upgrade_thinking_spinner_into_live_reasoning_card(cleanup_test_sessions):
"""R19: ui.js must be able to replace the placeholder thinking spinner with
streamed reasoning text while a turn is in progress.
"""
src = (REPO_ROOT / "static/ui.js").read_text()
assert "function _thinkingMarkup(text='')" in src or 'function _thinkingMarkup(text="")' in src, \
"ui.js must centralize thinking row markup so it can switch between spinner and live text"
assert "function updateThinking(text=''){appendThinking(text);}" in src or 'function updateThinking(text=""){appendThinking(text);}' in src, \
"ui.js must expose an updateThinking helper for live reasoning rendering"
# ── R17: Stack traces must not leak to clients in 500 responses ────────────
def test_500_response_has_no_trace_field():
"""R16: HTTP 500 responses must not include a 'trace' field.
@@ -493,3 +670,24 @@ def test_skills_slash_command_defined():
# 3. i18n key cmd_skills must be referenced (wired to COMMANDS entry)
assert "cmd_skills" in src, \
"cmd_skills i18n key must be referenced in commands.js"
def test_reload_recovery_persists_durable_inflight_state(cleanup_test_sessions):
"""Reload recovery must persist a durable per-session inflight snapshot.
Without these helpers, loadSession() references loadInflightState() but a full
browser reload has no saved state to hydrate, so recovery silently no-ops.
"""
ui_src = (REPO_ROOT / "static/ui.js").read_text()
messages_src = (REPO_ROOT / "static/messages.js").read_text()
sessions_src = (REPO_ROOT / "static/sessions.js").read_text()
assert "const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'" in ui_src
assert "function saveInflightState(sid, state)" in ui_src
assert "function loadInflightState(sid, streamId)" in ui_src
assert "function clearInflightState(sid)" in ui_src
assert "saveInflightState(activeSid" in messages_src, \
"messages.js must persist live stream snapshots while a turn is in flight"
assert "clearInflightState(activeSid)" in messages_src, \
"messages.js must clear durable inflight snapshots when the run ends/errors/cancels"
assert "const stored=loadInflightState(sid, activeStreamId);" in sessions_src, \
"loadSession() must hydrate in-flight state from durable browser storage on reload"

View File

@@ -0,0 +1,66 @@
import json
import pathlib
import sys
import time
import urllib.parse
import urllib.request
import uuid
import pytest
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent))
_needs_server = pytest.mark.usefixtures("test_server")
BASE = "http://127.0.0.1:8788"
_FULL_SECRET = "sk-" + ("B" * 24)
def _get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read())
def _write_session_with_secret_title():
from tests.conftest import TEST_STATE_DIR
sid = "sec_summary_" + uuid.uuid4().hex[:8]
sessions_dir = TEST_STATE_DIR / "sessions"
sessions_dir.mkdir(parents=True, exist_ok=True)
now = time.time()
(sessions_dir / f"{sid}.json").write_text(json.dumps({
"session_id": sid,
"title": f"session with {_FULL_SECRET}",
"workspace": "/tmp",
"model": "test",
"created_at": now,
"updated_at": now,
"pinned": False,
"archived": False,
"project_id": None,
"profile": "default",
"input_tokens": 0,
"output_tokens": 0,
"estimated_cost": None,
"personality": None,
"messages": [],
"tool_calls": [],
}))
return sid
@_needs_server
def test_api_sessions_search_redacts_titles(test_server):
sid = _write_session_with_secret_title()
data = _get("/api/sessions/search?q=" + urllib.parse.quote("B" * 24))
dump = json.dumps(data)
assert sid in dump
assert _FULL_SECRET not in dump
@_needs_server
def test_api_sessions_list_redacts_secret_titles(test_server):
sid = _write_session_with_secret_title()
data = _get("/api/sessions")
dump = json.dumps(data)
assert sid in dump
assert _FULL_SECRET not in dump

View File

@@ -80,6 +80,16 @@ def test_security_headers_on_health():
assert headers.get("X-Content-Type-Options") == "nosniff"
def test_permissions_policy_does_not_disable_microphone():
"""Permissions-Policy must not hard-disable microphone access for same-origin voice input."""
_, status, headers = get("/health")
assert status == 200
policy = headers.get("Permissions-Policy", "")
assert policy, "Permissions-Policy header missing"
assert "microphone=()" not in policy, \
"Permissions-Policy must not block microphone access or desktop/mobile voice input cannot work"
def test_cache_control_no_store():
"""API responses should have Cache-Control: no-store."""
d, status, headers = get("/api/sessions")

View File

@@ -8,6 +8,7 @@ the browser with no server-side component.
import re
import urllib.request
import json
import pathlib
BASE = "http://127.0.0.1:8788"
@@ -315,15 +316,31 @@ def test_boot_js_iife_guard():
assert '(function(){' in js or '(function () {' in js
def test_boot_js_browser_unsupported_return():
"""boot.js must bail out (return) early when SpeechRecognition is unavailable."""
def test_boot_js_browser_unsupported_guard_uses_fallback_capabilities():
"""boot.js must keep the mic available when either speech recognition OR recorder capture exists."""
js, _ = get_text("/static/boot.js")
# The IIFE should have an early return when SpeechRecognition is falsy
assert 'if(!SpeechRecognition)' in js or 'if (!SpeechRecognition)' in js
assert 'navigator.mediaDevices' in js
assert 'getUserMedia' in js
assert 'MediaRecorder' in js
assert '_canRecordAudio' in js or 'canRecordAudio' in js, \
"boot.js should compute a recorder fallback instead of bailing only on SpeechRecognition"
def test_boot_js_shows_mic_button_when_supported():
"""boot.js must set display='' on btnMic when SpeechRecognition is available."""
def test_boot_js_media_recorder_fallback_posts_to_transcribe_api():
"""Desktop fallback must send recorded audio to /api/transcribe for transcription."""
js, _ = get_text("/static/boot.js")
assert '/api/transcribe' in js
assert 'fetch(' in js
def test_routes_define_transcribe_endpoint():
"""Server routes must expose /api/transcribe for MediaRecorder fallback uploads."""
routes = pathlib.Path(__file__).parent.parent.joinpath("api/routes.py").read_text(encoding="utf-8")
assert '"/api/transcribe"' in routes
def test_boot_js_shows_mic_button_when_any_voice_path_is_supported():
"""boot.js must reveal btnMic when speech recognition or recorder fallback is available."""
js, _ = get_text("/static/boot.js")
assert "btn.style.display=''" in js or 'btn.style.display = ""' in js

View File

@@ -0,0 +1,87 @@
import io
import json
import sys
import types
from api.upload import handle_transcribe
def _multipart_body(fields=None, files=None, boundary=b"voiceboundary"):
fields = fields or {}
files = files or {}
body = b""
for name, value in fields.items():
body += b"--" + boundary + b"\r\n"
body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
body += str(value).encode() + b"\r\n"
for name, (filename, data, content_type) in files.items():
body += b"--" + boundary + b"\r\n"
body += (
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
f'Content-Type: {content_type}\r\n\r\n'
).encode()
body += data + b"\r\n"
body += b"--" + boundary + b"--\r\n"
return body, f"multipart/form-data; boundary={boundary.decode()}"
class _FakeHandler:
def __init__(self, body: bytes, content_type: str):
self.rfile = io.BytesIO(body)
self.wfile = io.BytesIO()
self.headers = {
"Content-Type": content_type,
"Content-Length": str(len(body)),
}
self.status = None
self.sent_headers = {}
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.sent_headers[key] = value
def end_headers(self):
pass
def payload(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
def test_handle_transcribe_requires_file_field():
body, content_type = _multipart_body(fields={"note": "missing file"})
handler = _FakeHandler(body, content_type)
handle_transcribe(handler)
assert handler.status == 400
assert handler.payload()["error"] == "No file field in request"
def test_handle_transcribe_returns_transcript(monkeypatch):
fake_mod = types.ModuleType("tools.transcription_tools")
fake_mod.transcribe_audio = lambda path: {"success": True, "transcript": "hello from audio"}
monkeypatch.setitem(sys.modules, "tools.transcription_tools", fake_mod)
body, content_type = _multipart_body(
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
)
handler = _FakeHandler(body, content_type)
handle_transcribe(handler)
assert handler.status == 200
assert handler.payload() == {"ok": True, "transcript": "hello from audio"}
def test_handle_transcribe_surfaces_provider_error(monkeypatch):
fake_mod = types.ModuleType("tools.transcription_tools")
fake_mod.transcribe_audio = lambda path: {"success": False, "error": "STT not configured"}
monkeypatch.setitem(sys.modules, "tools.transcription_tools", fake_mod)
body, content_type = _multipart_body(
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
)
handler = _FakeHandler(body, content_type)
handle_transcribe(handler)
assert handler.status == 503
assert handler.payload()["error"] == "STT not configured"