Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4de4ed9a15 | ||
|
|
04ed0ff43d | ||
|
|
2beebaa6a2 | ||
|
|
0f8fec7ccd | ||
|
|
12a60faaee | ||
|
|
2acee7fc34 | ||
|
|
acc14f2f0b | ||
|
|
9948fcf1db | ||
|
|
6a1dda4082 | ||
|
|
56944cc0ab | ||
|
|
7f69155904 | ||
|
|
54181d1a07 | ||
|
|
9542639a90 |
83
CHANGELOG.md
83
CHANGELOG.md
@@ -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`.
|
||||
|
||||
94
README.md
94
README.md
@@ -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 `<code>` 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.
|
||||
|
||||
29
ROADMAP.md
29
ROADMAP.md
@@ -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.36–v0.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.0–v0.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.40–v0.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.45–v0.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.47–v0.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.48–v0.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.49–v0.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.5–v0.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.11–v0.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.13–v0.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.16–v0.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.18–v0.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.20–v0.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 |
|
||||
|
||||
---
|
||||
|
||||
26
SPRINTS.md
26
SPRINTS.md
@@ -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),
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
@@ -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})?$")
|
||||
|
||||
@@ -50,7 +50,7 @@ def _security_headers(handler):
|
||||
)
|
||||
handler.send_header(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(), geolocation=()'
|
||||
'camera=(), microphone=(self), geolocation=()'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
207
static/boot.js
207
static/boot.js
@@ -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';
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);}
|
||||
|
||||
155
static/ui.js
155
static/ui.js
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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){
|
||||
|
||||
@@ -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
322
tests/test_issue336.py
Normal 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})
|
||||
@@ -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.
|
||||
|
||||
|
||||
184
tests/test_onboarding_network.py
Normal file
184
tests/test_onboarding_network.py
Normal 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}"
|
||||
70
tests/test_opencode_providers.py
Normal file
70
tests/test_opencode_providers.py
Normal 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")
|
||||
63
tests/test_profile_path_security.py
Normal file
63
tests/test_profile_path_security.py
Normal 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()
|
||||
@@ -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"
|
||||
|
||||
66
tests/test_session_summary_redaction.py
Normal file
66
tests/test_session_summary_redaction.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
87
tests/test_voice_transcribe_endpoint.py
Normal file
87
tests/test_voice_transcribe_endpoint.py
Normal 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"
|
||||
Reference in New Issue
Block a user