Merge remote-tracking branch 'origin/master' into fix/session-list-touch-actions
No files matched your search
@@ -0,0 +1,208 @@
|
||||
name: Docker smoke
|
||||
|
||||
# Runtime smoke gate for Docker init logic.
|
||||
#
|
||||
# Background: v0.51.84 (PR #2470) shipped a startup-killing :ro mount + chown
|
||||
# interaction (EROFS under `set -e`) that 9 source-level pytest invariants +
|
||||
# 5800+ existing tests all passed. The independent reviewer caught it by eye.
|
||||
# This workflow closes that class of gap by actually `docker compose up`-ing
|
||||
# each variant against a real Docker daemon on the GHA runner.
|
||||
#
|
||||
# Scope (intentionally small for v1):
|
||||
# - 3 compose variants (single, two-container, three-container)
|
||||
# - For multi-container variants, rebuild the local Dockerfile and re-tag
|
||||
# it as ghcr.io/nesquena/hermes-webui:latest BEFORE `up` so the PR's
|
||||
# changes to docker_init.bash / Dockerfile actually execute. Without this
|
||||
# the multi-container variants would pull the previous release from GHCR
|
||||
# and silently miss every PR-level regression.
|
||||
# - Pre-flight `docker compose config` job to catch schema/interpolation drift.
|
||||
# - Reaper before each smoke run + trap on EXIT for orphan defence.
|
||||
#
|
||||
# Out of scope for v1 (per design review):
|
||||
# - HERMES_WEBUI_SMOKE_TEST env flag in docker_init.bash (production-code footgun)
|
||||
# - --user 60000:60000 (skips the chown branch we're protecting against)
|
||||
# - Hadolint / yamllint (separate lint workflow, follow-up PR)
|
||||
# - Local-runnable scripts/docker-smoke-test.sh (ship CI first, then iterate)
|
||||
# - Podman runtime smoke (defer until a podman-specific bug ships)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- 'docker_init.bash'
|
||||
- 'docker-compose*.yml'
|
||||
- '.dockerignore'
|
||||
- '.env.docker.example'
|
||||
- '.github/workflows/docker-smoke.yml'
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- 'docker_init.bash'
|
||||
- 'docker-compose*.yml'
|
||||
- '.dockerignore'
|
||||
- '.env.docker.example'
|
||||
- '.github/workflows/docker-smoke.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Fork PRs run with no secrets — that's the right model. Pin to least privilege.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
compose-config:
|
||||
name: Compose config validation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate every compose file parses
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for f in docker-compose.yml docker-compose.two-container.yml docker-compose.three-container.yml; do
|
||||
echo "::group::compose config: $f"
|
||||
docker compose -f "$f" config > /dev/null
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
smoke:
|
||||
name: Smoke ${{ matrix.variant }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: compose-config
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- single
|
||||
- two-container
|
||||
- three-container
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve compose file + project name
|
||||
id: vars
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ matrix.variant }}" in
|
||||
single)
|
||||
echo "compose_file=docker-compose.yml" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
two-container)
|
||||
echo "compose_file=docker-compose.two-container.yml" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
three-container)
|
||||
echo "compose_file=docker-compose.three-container.yml" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
# Per-run project name so concurrent jobs / reruns can't clobber each other.
|
||||
echo "project=hermes-smoke-${{ matrix.variant }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reap any prior hermes-smoke resources on this runner
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Hosted GHA runners are fresh, so this is mostly defence-in-depth for
|
||||
# self-hosted runner re-use. We rely primarily on the unique per-run
|
||||
# project name + `compose down -v --remove-orphans` in the EXIT trap
|
||||
# to clean up the resources THIS run creates; this step only sweeps
|
||||
# leftovers from prior runs that crashed before their trap fired.
|
||||
# Match by project-name prefix instead of labels (the compose files
|
||||
# don't carry hermes-smoke labels on their resources).
|
||||
for c in $(docker ps -aq --filter "name=hermes-smoke-"); do
|
||||
docker rm -f "$c" || true
|
||||
done
|
||||
for v in $(docker volume ls -q | grep "^hermes-smoke-" || true); do
|
||||
docker volume rm -f "$v" || true
|
||||
done
|
||||
for n in $(docker network ls --format '{{.Name}}' | grep "^hermes-smoke-" || true); do
|
||||
docker network rm "$n" || true
|
||||
done
|
||||
|
||||
- name: Build local Dockerfile
|
||||
# We always build the local Dockerfile so the PR's changes are tested,
|
||||
# even on the multi-container variants whose compose files reference
|
||||
# ghcr.io/nesquena/hermes-webui:latest. Without this retag, multi-container
|
||||
# smoke runs would test the previous release, not the PR.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -t ghcr.io/nesquena/hermes-webui:latest .
|
||||
|
||||
- name: Prepare ephemeral host paths
|
||||
id: paths
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STATE_DIR="$(mktemp -d -t hermes-smoke-state-XXXXXX)"
|
||||
WORK_DIR="$(mktemp -d -t hermes-smoke-work-XXXXXX)"
|
||||
echo "state_dir=$STATE_DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "work_dir=$WORK_DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "Allocated:"
|
||||
echo " HERMES_HOME = $STATE_DIR"
|
||||
echo " HERMES_WORKSPACE = $WORK_DIR"
|
||||
|
||||
- name: Smoke (up + health + log scan + down)
|
||||
env:
|
||||
COMPOSE_FILE: ${{ steps.vars.outputs.compose_file }}
|
||||
PROJECT: ${{ steps.vars.outputs.project }}
|
||||
HERMES_HOME: ${{ steps.paths.outputs.state_dir }}
|
||||
HERMES_WORKSPACE: ${{ steps.paths.outputs.work_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# ----- Trap-guaranteed cleanup, regardless of exit reason -----
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
echo "::group::Cleanup (rc=$rc)"
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --no-color --tail=200 || true
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans || true
|
||||
rm -rf "$HERMES_HOME" "$HERMES_WORKSPACE" || true
|
||||
echo "::endgroup::"
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "::group::docker compose up"
|
||||
# --wait blocks until all services report healthy OR --wait-timeout fires.
|
||||
# Compose v2 returns nonzero on either failure mode.
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait --wait-timeout 120
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::container roster"
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" ps
|
||||
echo "::endgroup::"
|
||||
|
||||
# ----- WebUI /health probe -----
|
||||
# Single-container: WebUI is on the host on 127.0.0.1:8787.
|
||||
# Two/three-container: same — both compose files publish 127.0.0.1:8787.
|
||||
echo "::group::Probe /health"
|
||||
attempts=0
|
||||
max_attempts=30
|
||||
until curl --fail --silent --max-time 5 http://127.0.0.1:8787/health > /dev/null; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge "$max_attempts" ]; then
|
||||
echo "❌ WebUI /health never returned 200 after $max_attempts attempts (~60s)"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "✅ /health = 200 after $attempts attempts"
|
||||
echo "::endgroup::"
|
||||
|
||||
# ----- Startup log scan: must not contain any known-bad signatures -----
|
||||
# These are the exact patterns that would have flagged #2470 in real time.
|
||||
# The grep -i is anchored to actual error tokens; benign log lines that
|
||||
# contain the substring 'error' in a stack-friendly context (e.g.
|
||||
# "errorless", URL paths) are improbable for these specific tokens.
|
||||
echo "::group::Startup log scan"
|
||||
LOGS="$(docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --no-color)"
|
||||
# `!! ERROR` + `!! Exiting script` are the actual strings emitted by
|
||||
# docker_init.bash's error_exit() helper — the function name itself
|
||||
# never appears in output. The literal token `error_exit` is kept as
|
||||
# a belt-and-suspenders catch for any stray debug/echo of the name.
|
||||
BAD_PATTERNS='EROFS|Read-only file system|Traceback|PermissionError|!! ERROR|!! Exiting script|error_exit|groupmod: cannot|usermod: cannot|Failed to set (UID|GID|owner|permissions|ownership)'
|
||||
if echo "$LOGS" | grep -E -i "$BAD_PATTERNS"; then
|
||||
echo "❌ Startup logs contain known-bad pattern (see above)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No known-bad patterns in startup logs"
|
||||
echo "::endgroup::"
|
||||
@@ -11,7 +11,8 @@ Before making changes, read:
|
||||
|
||||
1. `README.md`
|
||||
2. `CONTRIBUTING.md`
|
||||
3. `CHANGELOG.md`
|
||||
3. `docs/CONTRACTS.md`
|
||||
4. `CHANGELOG.md`
|
||||
|
||||
For architecture, testing, or setup work, also read the matching reference:
|
||||
|
||||
@@ -19,6 +20,10 @@ For architecture, testing, or setup work, also read the matching reference:
|
||||
- `TESTING.md` for local verification commands and manual test guidance
|
||||
- `docs/onboarding.md` for first-run onboarding behavior
|
||||
- `docs/troubleshooting.md` for diagnostic flows
|
||||
- `docs/rfcs/README.md` for larger RFCs and state/durability contracts
|
||||
|
||||
For UI or UX work, read `docs/UIUX-GUIDE.md` and `DESIGN.md` before
|
||||
changing layout, interaction flow, themes, chat rendering, or composer chrome.
|
||||
|
||||
## Onboarding and reinstall support
|
||||
|
||||
@@ -39,15 +44,22 @@ Follow that checklist's safety rules:
|
||||
|
||||
## Contribution style
|
||||
|
||||
- Keep changes focused on one logical problem.
|
||||
- Prefer the existing Python + vanilla JavaScript structure over new
|
||||
dependencies or build steps.
|
||||
- Keep one logical change per PR; split unrelated refactors or cleanup.
|
||||
- Read `docs/CONTRACTS.md` and the linked contract/RFC for the touched
|
||||
subsystem before editing.
|
||||
- Prefer the existing Python + vanilla JavaScript structure. Do not add
|
||||
dependencies, build tools, frameworks, or long-lived processes without clear
|
||||
justification and a rollback story.
|
||||
- Update docs when changing setup, onboarding, runtime behavior, architecture,
|
||||
or testing guidance.
|
||||
testing guidance, or user-facing workflows.
|
||||
- Update `CHANGELOG.md` for user-visible behavior, setup, workflow, or
|
||||
documentation changes that should be release-note ready.
|
||||
- For UI or UX changes, follow `CONTRIBUTING.md`: include before/after evidence
|
||||
and test relevant responsive states.
|
||||
- For UI or UX changes, include before/after evidence and test relevant
|
||||
desktop, narrow, and mobile states.
|
||||
- For behavior changes, add or update automated tests where practical and list
|
||||
the manual verification performed.
|
||||
- For runtime, streaming, recovery, replay, compression, or sidebar metadata
|
||||
changes, name the state layer being mutated and prove the relevant invariant.
|
||||
|
||||
## Local state and secrets
|
||||
|
||||
|
||||
@@ -1318,7 +1318,7 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3).
|
||||
/api/crons/output ?job_id=X&limit=N -> {outputs: [{filename, content}]}
|
||||
/api/skills All skills. Returns {skills: [{name, description, category}]}
|
||||
/api/skills/content ?name=X -> full skill data including SKILL.md content
|
||||
/api/memory MEMORY.md + USER.md. Returns {memory, user, *_path, *_mtime}
|
||||
/api/memory MEMORY.md + USER.md + SOUL.md. Returns {memory, user, soul, *_path, *_mtime}
|
||||
|
||||
### POST Endpoints Added in Sprint 3
|
||||
|
||||
@@ -1453,7 +1453,7 @@ fetches GET /api/skills/content and renders in the right panel using `showPrevie
|
||||
#### Memory Panel
|
||||
|
||||
`loadMemory()` fetches GET /api/memory (reads MEMORY.md + USER.md from
|
||||
~/.hermes/memories/), renders both as markdown via renderMd() with timestamps.
|
||||
~/.hermes/memories/, and SOUL.md from ~/.hermes/), renders both as markdown via renderMd() with timestamps.
|
||||
|
||||
#### New API Endpoints (Section 18 update)
|
||||
|
||||
@@ -1464,7 +1464,7 @@ fetches GET /api/skills/content and renders in the right panel using `showPrevie
|
||||
POST /api/crons/resume {job_id} -> resume_job(job_id)
|
||||
GET /api/skills All skills via tools.skills_tool.skills_list()
|
||||
GET /api/skills/content ?name=X -> full skill data via skill_view(name)
|
||||
GET /api/memory MEMORY.md + USER.md content and mtimes
|
||||
GET /api/memory MEMORY.md + USER.md + SOUL.md content and mtimes
|
||||
|
||||
#### Phase D Input Validation Applied
|
||||
|
||||
|
||||
@@ -2,6 +2,302 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [v0.51.92] — 2026-05-19 — Release BP (stage-385 — 7-PR full sweep batch — RFC Slice 3c clarification + workspace tree icon alignment + project move cache refresh + auto-compression handoff metadata + Grok OAuth provider catalog + anonymous custom endpoint picker fallback + PWA standalone reload + pull-to-refresh)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2563** by @Michaelyklam (closes #2554) — Align workspace-tree file rows with sibling directory rows by reserving the same expand/collapse toggle slot for files via a new `.file-tree-toggle-placeholder` element. Expanded directories now show child files stepped in at the same icon column as child folders. Directory toggles and file interactions are unchanged; source-level regression coverage and before/after PNGs included.
|
||||
- **PR #2561** by @nanookclaw (closes #2551) — Refresh the authoritative `_allSessions` cache when the project picker moves a session to/from a project. Previous code mutated only the shallow sidebar row copy, so `renderSessionListFromCache()` re-read the unchanged cache and repainted a stale project dot until the next `/api/sessions` poll healed the UI. Both the "Removed from project" and "Moved to <project>" branches now write the new `project_id` into `_allSessions[idx]` before re-rendering.
|
||||
- **PR #2567** by @dso2ng (refs #2477) — Surface automatic-compression handoff metadata through the `compressed` SSE event so the active browser stream keeps its completion card even after the backend rotates the session id from the origin to a compressed continuation. The event now carries both `old_session_id` and `new_session_id`/`continuation_session_id`; the frontend `compressed` listener accepts either, and the automatic-compression detail line names the compressed continuation session so the done state isn't silently dropped.
|
||||
- **PR #2568** by @Michaelyklam (closes #2545) — Add the Hermes Agent `xai-oauth` provider to the WebUI's OAuth provider catalog so Grok OAuth accounts authenticated via the Hermes CLI appear in Settings → Providers and the `/api/models` picker. The provider is treated as CLI-managed OAuth (no WebUI API-key form) and uses the live Hermes CLI model catalog when available with a Grok 4.20 static fallback.
|
||||
- **PR #2550** by @espokaos-ops (refs #2542) — Keep anonymous custom OpenAI-compatible endpoints in the model picker even when the configured `/v1/models` probe fails. Lightweight relays and llama-server-style deployments that authenticate `/v1/chat/completions` but not `/v1/models` no longer have their provider group silently dropped from the picker. Users can type a model id manually in the free-form input when no live catalog is available.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2548** by @espokaos-ops — Add a PWA-standalone reload affordance. A small refresh button appears in the app titlebar (visible only under `@media (display-mode: standalone), (display-mode: fullscreen)`) so users running the WebUI as an installed home-screen PWA can reload without re-launching the app. Adds a complementary pull-to-refresh gesture on the messages container with an 80px threshold and a smooth-scroll-to-top guard so accidental triggers while reading history feel intentional. 4-viewport screenshots (390/1280/1440/1920, light/dark, hover/idle) included under `docs/pr-media/2548/`.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **PR #2560** by @Michaelyklam (refs #1925) — Clarify the RuntimeAdapter Slice 3c state after #2544 shipped. The RFC now distinguishes shipped `/api/goal` routing through `RuntimeAdapter.update_goal(...)` from the still-staged `queue_message(...)` protocol method, and explicitly warns not to add a new server-side queue endpoint or queue scheduler merely for adapter symmetry while `/queue` remains browser-side queue/drain behavior.
|
||||
|
||||
## [v0.51.91] — 2026-05-18 — Release BO (stage-384 — 5-PR full sweep batch — reasoning-replay history fix + archive-extract per-session inbox + fallback streaming warnings + sanitized custom-provider env hints + Slice 3c queue/goal adapter routing)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2536** by @Michaelyklam (closes #2514, refs #2535) — Stop reasoning-only Thinking entries from being replayed into provider-facing history as blank assistant turns. Long WebUI sessions were accumulating duplicated stale Thinking blocks and inflated Activity/tool metadata on later turns when reasoning-only display entries (from interrupted/canceled turns) got reinserted into the restored conversation history. The fix keeps visible Thinking cards in the transcript while filtering them out of provider-facing replay. Settled compact Activity rerenders now also clear previously inserted Thinking rows before rebuilding the visible transcript.
|
||||
- **PR #2520** by @OneFat3 (refs #2247) — Route archive extraction (`/api/upload/extract`) through the per-session attachment inbox (`_session_attachment_dir`) instead of hardcoded `Path(s.workspace)`, matching the single-file upload path. Extracted archives now land at `<attachment_root>/<session_id>/<archive_stem>/` so session deletion cleanup covers them and per-session isolation is preserved when `HERMES_WEBUI_ATTACHMENT_DIR` is configured.
|
||||
- **PR #2505** by @cyberdyne187 — Surface provider fallback and rate-limit lifecycle notices as auto-clearing fallback warnings in the streaming composer status. The new bridge in `_agent_status_callback` matches agent lifecycle messages containing `rate limited` / `switching to fallback` / `falling back` / `fallback activated` / `trying fallback` and emits them as `warning` events with `type=fallback`, so the existing `static/messages.js` warning channel surfaces them with the correct auto-clear contract instead of letting them drop silently.
|
||||
- **PR #2556** by @Michaelyklam (closes #2541) — Sanitize auto-generated custom-provider API-key environment variable names so endpoint-derived provider ids such as `custom:gpu.local-8000` use POSIX-safe names like `CUSTOM_GPU_LOCAL_8000_API_KEY`. Runtime custom-provider key resolution now checks the sanitized env var first and falls back to the legacy punctuation-preserving name with a one-shot deprecation warning. Configured literal `api_key` values and explicit `key_env` config are unchanged.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **PR #2544** by @Michaelyklam (refs #1925) — Implement the first Slice 3c RuntimeAdapter control routing. `RuntimeAdapter` / `LegacyJournalRuntimeAdapter` now expose `queue_message(...)` and `update_goal(...)` as protocol-translator delegates, and the `/api/goal` route uses `update_goal(...)` only when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled while preserving the legacy-direct response shape. The change keeps `/queue`'s existing browser-side drain semantics and goal post-turn evaluation in the current agent loop; no runner/sidecar, WebUI-owned queue, goal scheduler, cached-agent table, or execution-survives-restart claim is introduced.
|
||||
|
||||
## [v0.51.90] — 2026-05-18 — Release BN (stage-383 — 10-PR full sweep batch — empty-gateway messaging history fix + previous-messaging-sessions setting + Kanban board switcher layout + UI/UX demo theme controls + Slice 3c queue/goal RFC gate + keyless custom endpoints + custom-provider remote model catalog parity + auto-compression elapsed timer + new-conversation cold-start guard + Kanban drag-drop detail open fix)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2286** by @junjunjunbong (refs #2275) — Narrow messaging stale-session filtering to active gateway sessions that are visible in the current sidebar candidate set. Older Discord/messaging history is now preserved when the gateway advertises a fresh zero-message session that hasn't yet entered the visible projection, instead of being hidden as stale. Adds a regression test for an empty active Discord gateway row preserving prior history.
|
||||
- **PR #2459** by @franksong2702 (closes #2458) — Fix the Kanban board switcher menu when a board's icon slot carries a long text label (e.g. `layout-kanban`). The icon column changed from a fixed `18px` slot to a bounded flex cell with `min-width:18px;max-width:7.5rem`, with overflow ellipsis on the icon itself so long labels render fully when space allows and truncate cleanly when not. Title and count columns keep stable spacing. Adds before/after screenshots and a CSS contract regression in `tests/test_kanban_ui_static.py`.
|
||||
- **PR #2522** by @Michaelyklam (refs #2271) — Treat named custom OpenAI-compatible endpoints with a configured `base_url` as key-optional at WebUI agent startup. Local keyless servers (llama-server / vLLM-style LAN deployments) no longer fail early with a synthetic `CUSTOM:<slug>_API_KEY` env-var prompt before the request reaches the endpoint; instead the OpenAI-compatible client initialises with a harmless placeholder key and real configured keys are still preferred when present. Refactors the three near-identical custom-provider rebuild blocks (initial agent setup + two retry/healing paths) through the existing `resolve_custom_provider_connection` helper.
|
||||
- **PR #2515** by @Michaelyklam (closes #2513) — Keep named custom-provider model pickers populated from each configured endpoint's live `/models` catalog even when `custom_providers[].model` is present. The singular `model` field now acts as a sticky/fallback entry appended *after* the remote catalog rather than collapsing the picker to just the configured model and hiding sibling named custom providers. Extracts reusable OpenAI-compatible `/models` parsing/fetching helpers and threads them through both the active-base-url and per-named-provider paths.
|
||||
- **PR #2512** by @dso2ng (refs #2477, Slice A) — Show an elapsed timer on the running automatic-compression card so long WebUI context-compression pauses no longer look frozen while the browser waits for the `compressed` event. Stamps `startedAt` on the `compressing` SSE event, ticks once per second, and switches to a `5+ min` cap label past the Slice A bound so the UI never frame-freezes at `05:00`. Browser-transient state only — no SSE contract change and no server-side resume reconstruction.
|
||||
- **PR #2528** by @Michaelyklam (closes #2518) — Guard New Conversation creation while a previous `/api/session/new` request is still in flight, so cold model/provider catalog resolution gives immediate pending feedback and rapid repeated clicks reuse the same create request instead of enqueueing duplicate blank sessions. Coalesces concurrent `newSession()` calls behind a single in-flight promise, disables the sidebar button with `aria-busy="true"`, and shows a localized `Creating new conversation…` composer status.
|
||||
- **PR #2530** by @franksong2702 (refs #2529) — Keep Kanban drag/drop status updates from also opening the task detail pane. Two failure paths were both producing detail-pane opens after drag/drop: the browser's trailing synthetic click after `drop`, and the generic task-update helper opening detail on every PATCH. The fix adds a time-windowed `_kanbanSuppressCardClickUntil` set on `ondragstart`/`ondragend`/`ondrop` and routes drag/drop status changes through a board-only update path. Explicit card click and keyboard activation remain unchanged.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2294** by @junjunjunbong — Add a `show_previous_messaging_sessions` setting so users can opt back into seeing previous messaging sessions that were replaced by `session_reset` or auto-compression. The preference is wired through boot, settings persistence, and the sidebar projection. Also adds a separate "Hide from list" action for imported messaging/CLI sessions that hides individual rows from the sidebar without deleting source history.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **PR #2511** by @franksong2702 (refs #2502 / #2503) — Update the `docs/ui-ux/` demo appearance controls to initialize as `class="dark" data-skin="slate"` instead of the deprecated `data-theme`-only buttons and legacy theme names. Brings the demo pages in line with the live Theme + Skin contract referenced from the new `docs/CONTRACTS.md` so contributors following the contract-index path don't land on stale demos.
|
||||
- **PR #2509** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 3b approval/clarify seam shipped in v0.51.89. The RFC now marks Slice 3b as shipped and defines the next Slice 3c queue/continue + goal control gate: route those controls through `RuntimeAdapter.queue_message(...)` / `update_goal(...)` only after pinning stable response contracts, bounded unavailable-control behavior, replayable lifecycle/status evidence, ordering/idempotency expectations, and explicit non-goals for runner/sidecar ownership or a WebUI-owned queue/goal scheduler. Docs + adapter-seam regression test only — no runtime/control routing changes in this PR.
|
||||
## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index)
|
||||
|
||||
### Changed
|
||||
|
||||
- **PR #2496** by @Michaelyklam (refs #1925) — Route approval and clarify responses through the default-off `RuntimeAdapter.respond_approval(...)` / `respond_clarify(...)` seam when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled. The default `legacy-direct` path still uses the existing callback helpers directly, legacy no-id responses keep their historical `ok: true` shape, and stale explicit approval ids are now bounded as not-active instead of falling back to the oldest queued command. No approval queue, clarify queue, callback registry, runner, sidecar, queue/goal migration, or cached-agent state is introduced.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2500** by @mccxj — Surface `SOUL.md` (the agent's third-person voice/persona profile, stored at `HERMES_HOME/SOUL.md` alongside `config.yaml` / `.env`) as a third section in the Memory panel next to MEMORY.md (notes) and USER.md (profile). `GET /api/memory` now returns `soul`, `soul_path`, and `soul_mtime`; `POST /api/memory/write` accepts `section="soul"` writing to `HERMES_HOME/SOUL.md` (not inside `memories/`). Redaction still applies, i18n labels (`agent_soul` / `no_soul_yet`) added across all 11 locales, new `sparkles` Lucide icon for the section header.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2499** by @franksong2702 — Keep server-idle session rows from inheriting stale local streaming fields during sidebar optimistic merging, so PWA/browser caches cannot keep a completed session's spinner alive after `/api/sessions` reports no active stream or pending user message.
|
||||
- **PR #2501** (closes #1855) — Short-circuit the `resolve_model_provider` stage in `POST /api/chat/start` (and sibling chat-handler call sites) when the request already carries an explicit `(model, model_provider)` pair and the model isn't `@provider:model`-qualified. The new fast path in `_resolve_compatible_session_model_state()` returns the inputs verbatim without calling `get_available_models()` — that catalog rebuild can do network I/O (custom OpenAI-compat `/models`, OpenRouter `/models`, LM Studio probes, credential-pool refresh) under an RLock thundering-herd guard and was observed wedging a single request for 115 seconds in a production-grade local deployment. The recurrence captured via the PR #1911 stage diagnostics confirmed the wedge sat entirely in `resolve_model_provider` while every other stage completed in <5 ms. Users behind default-60s reverse proxies (nginx / Apache / Caddy / Cloudflare) were seeing a `502 Proxy Error` while the WebUI eventually completed the run anyway, creating a duplicate-send risk if the user retried in the browser. The slow path is preserved for the inputs that genuinely need it: bare/un-qualified models without a stored `model_provider` (cross-provider repair), `@provider:model`-qualified strings (active-provider validation per #1253), and empty models (default-model lookup). 14 new regression tests in `tests/test_issue1855_resolve_model_provider_fast_path.py` cover both directions — fast-path skips, slow-path still fires — including a static check that the short-circuit precedes the catalog call in source order.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **PR #2503** by @franksong2702 (refs #2502) — Add `docs/CONTRACTS.md` as a public contributor-facing routing index that points UI/UX, runtime/state, and onboarding/setup changes to the relevant public docs (DESIGN.md, AGENTS.md, RFCs, troubleshooting) before contributors edit code or open PRs. Also adds `docs/UIUX-GUIDE.md` synthesizing the calm-developer-console UI/UX principles from DESIGN.md / README.md / THEMES.md / `docs/ui-ux/` into one contributor guide, refreshes the README and THEMES.md skin lists to cover all ten built-in skins (`catppuccin` + `nous`), and tightens the AGENTS.md / CONTRIBUTING.md contribution-style notes for state-layer and evidence requirements. Docs-only — no runtime or maintainer-policy changes.
|
||||
|
||||
## [v0.51.88] — 2026-05-18 — Release BL (stage-381 — 3-PR security + UX + lineage batch — session-bound CSRF tokens for unsafe browser requests + quoted-reply selected-text composer append + compression-continuation sidebar collapse)
|
||||
|
||||
### Security
|
||||
|
||||
- **PR #2484** by @franksong2702 (refs #1909) — Add session-bound CSRF token protection for authenticated unsafe browser requests, layered on top of the existing Origin/Referer same-origin checks. New helpers in `api/auth.py` derive a per-session HMAC token from the HttpOnly session cookie's server-side token (rotates on login, invalidates on logout/expiry). `api/routes.py` `_check_csrf()` now requires a valid `X-Hermes-CSRF-Token` (legacy `X-CSRF-Token` accepted) on authenticated POST/PATCH/DELETE/PUT requests with a browser Origin/Referer. `/api/auth/login` and `/api/csp-report` remain exempt so the bootstrap and CSP-report paths stay unauthenticated; non-browser callers (curl, MCP, agent) without Origin/Referer continue to bypass the token check. The shell template now injects the token via `__CSRF_TOKEN_JSON__` and the frontend attaches it to same-origin unsafe fetches.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2485** by @franksong2702 (refs #2481) — Add a ChatGPT-style "Reply with selection" floating button that appears when the user selects visible chat transcript text. Clicking the button appends the selection into the composer as a Markdown blockquote, with repeat selections appending additional quoted blocks for multi-selection workflows. Frontend-only slice: no new backend routing, message schema, or persistence contract — the existing composer/send path is reused. New i18n labels/title/aria text across locales including `zh-Hant`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2493** by @dso2ng (closes #2489) — Collapse WebUI auto-compression continuations into the parent session's sidebar row so long-running conversations stay visually continuous. `static/sessions.js` `_sessionLineageKey()` now accepts a `sessionsById` map and recognises the `pre_compression_snapshot` parent-chain shape as compression lineage even when the continuation lacks explicit `_lineage_root_id` / `lineage_root_id` metadata. The existing child/fork guard is preserved; only the snapshot-parent case is folded back into a single sidebar row. Regression test covers the #2489 case where parent snapshot + continuation were both visible but the continuation carried only `parent_session_id`.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **PR #2483** by @franksong2702 (refs #2364) — Add a narrow README note for the community ARM64 Android AVF field report: Hermes Agent + WebUI running inside a Debian 12 VM on a mid-range Android phone with cloud-hosted inference. The note frames the report as a compatibility signal rather than an official support baseline or provider/model benchmark, and records practical mobile caveats around first-install compile time, Android tab reloads, and battery optimization.
|
||||
- **PR #2487** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 3a cancel-control implementation shipped in v0.51.86. The RFC now marks Slice 3a as shipped and defines the next Slice 3b gate for approval/clarify controls: route them through `RuntimeAdapter.respond_approval(...)` / `respond_clarify(...)` only after pinning response-shape stability, bounded missing-prompt behavior, replayable request/resolution events, duplicate-response safety, and explicit non-goals for queue/goal and runner/sidecar work.
|
||||
|
||||
## [v0.51.87] — 2026-05-18 — Release BK (stage-380 — 2-PR Docker hygiene + CI gate — read-only mount tmpfs staging + Docker runtime smoke workflow + agent-source boundary inventory + writable-mount startup warning)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2482** by @Michaelyklam (refs #2453) — Add a durable source/API boundary inventory for the WebUI's remaining Hermes Agent source dependencies: chat execution, runtime events, profiles, goals, slash/plugin commands, provider/auth/model catalogs, redaction parity, and imported Agent/Gateway sessions. The new RFC tracks replacement API contracts before the source mount can be removed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **PR #2482** by @Michaelyklam (refs #2453) — Make the multi-container source boundary more explicit: Docker docs and README now link the boundary inventory, and `docker_init.bash` emits a startup warning when the WebUI sees a writable agent-source mount instead of the default read-only `hermes-agent-src` mount.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2490** by @nesquena-hermes — Multi-container Docker startup is no longer broken by the v0.51.84 `:ro` mount on `hermes-agent-src`. `docker_init.bash` was calling `uv pip install "$_agent_src[all]"` against the mounted source tree directly. setuptools' `egg_info` build step touches `hermes_agent.egg-info/` inside the source tree even under PEP 517 build isolation, which `EROFS`-failed on the now-read-only mount and (under `set -e`) killed startup of every multi-container deploy. The init script now stages the agent source into `/tmp/hermes-agent-build` via `rsync` (with a `cp -a` fallback for images without rsync, both excluding any pre-baked `*.egg-info`, `build`, `dist`, and `__pycache__` artifacts) and runs the install against that writable copy, leaving the underlying `:ro` mount untouched. Stage dir is removed after the install completes. This regression was caught by the new Docker runtime smoke gate (below) on its very first CI run against its own PR — 5800+ source-level pytests + the independent reviewer's eyeball had all missed it on PR #2470.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **PR #2490** by @nesquena-hermes — Add a Docker runtime smoke gate (`.github/workflows/docker-smoke.yml`) triggered on PRs and pushes to `master` that modify `Dockerfile`, `docker_init.bash`, `docker-compose*.yml`, `.dockerignore`, or `.env.docker.example`. Validates every compose file parses (`docker compose config`), then matrix-runs the single, two-container, and three-container variants end-to-end: rebuilds the local `Dockerfile` and re-tags it as `ghcr.io/nesquena/hermes-webui:latest` so the multi-container variants exercise PR-level changes rather than the previously-released registry image, `docker compose up -d --wait`s with a 120s health window, probes `/health`, and greps startup logs for known-bad signatures (`EROFS`, `Traceback`, `PermissionError`, `error_exit`, `!! ERROR`, `!! Exiting script`, `groupmod: cannot`, `usermod: cannot`, `Failed to set`). Closes the source-only-test gap that let v0.51.84's `:ro`-mount × `chown -h ... {} +` startup regression reach review with 5800+ green pytests. Workflow runs with `permissions: contents: read`, uses per-run project names and a pre-flight orphan reaper for safe concurrency, and unconditionally tears down all volumes/networks in an `EXIT` trap. Two new source-level invariants in `tests/test_docker_docs_and_readonly.py` pin the staging path so the underlying `:ro`-incompatible call doesn't regress.
|
||||
|
||||
|
||||
## [v0.51.86] — 2026-05-17 — Release BJ (stage-379 — 4-PR review-bypass batch — memory-provider session lifecycle + cross-provider /model alias + RuntimeAdapter cancel seam + Fork-from-here messaging coord)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2461** by @starship-s — Add a WebUI-side memory-provider session lifecycle for batch-extraction providers (OpenViking, etc.). The new `api/session_lifecycle.py` module tracks per-session generation, segment ownership, and an `in_flight` flag with a `threading.Condition`, so a late-finishing commit can only advance `committed_generation` against its captured generation without erasing newer turns marked during the commit. `mark_turn_completed` runs post-turn after save/cancel/completed-journal guards; `commit_session_memory` runs at session boundaries (new session, eviction, shutdown) outside cache locks and per-session mutation locks. `register_agent`/`unregister_agent` preserves dirty segment owners so failed work remains retryable even if the cache drops the current agent reference. `drain_all_on_shutdown` flushes every registered session with uncommitted work at process exit.
|
||||
- **PR #2473** by @ts2111 — `/model <alias>` now correctly routes cross-provider custom-model aliases to their `custom_providers[].name` rather than incorrectly falling through to the active provider's `config_base_url` branch. Adds a custom-providers prefix check in `resolve_model_provider()` between the explicit early-return carve-outs and the `config_base_url` catch-all, and exposes a top-level `aliases` key in `/api/models` so the frontend can resolve user-defined `/model <alias>` shortcuts. `cmdModel()` now fetches `/api/models`, resolves the alias, fuzzy-matches the dropdown, and falls back to a direct `POST /api/session/update` when no dropdown match exists.
|
||||
- **PR #2480** by @Michaelyklam (closes #2472) — Make "Fork from here" use the same merged messaging-session transcript coordinate space that `/api/session` exposes, so forking an older message no longer silently copies the full sidecar when CLI/Gateway history inflated the visible message offset. Extracts the merge logic into `_merged_session_messages_for_display(session, cli_messages)` and routes both `GET /api/session` and `POST /api/session/branch` through it. The frontend snapshots the source session id across the async full-history load (so a fast sidebar switch can't fork the wrong session), reloads the forked transcript fully after creation, and the branch handler best-effort saves the source session before slicing to keep undo/retry state coherent.
|
||||
|
||||
### Changed
|
||||
|
||||
- **PR #2479** by @Michaelyklam (refs #1925) — Route Stop Generation through the default-off `RuntimeAdapter.cancel_run(...)` seam when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled. Implements the first code slice of the Slice 3a cancel-control gate accepted in #2469 / v0.51.85. The default `legacy-direct` path still calls `cancel_stream(...)` directly; the adapter branch preserves the existing `{ok, cancelled, stream_id}` JSON response contract. No new cancellation registry, runner, sidecar, approval/clarify, queue/goal, or cached-agent state is introduced — adapter remains a pure protocol translator.
|
||||
|
||||
|
||||
## [v0.51.85] — 2026-05-17 — Release BI (stage-378 — 3-PR batch — workspace-prefix display leakage fix + release-tag update banner + Slice 3a cancel-control gate RFC)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2145** by @swftwolfzyq — Prevent internal `[Workspace::v1: …]` metadata from leaking into the visible user transcript when a failed provider/retry path echoes an optimistic draft followed by the workspace prefix and the real prompt. Adds `_looks_like_current_user_turn(msg, msg_text)` to match the current human turn even when the internal tag appears mid-text — only when the text after the sentinel exactly matches the submitted prompt — and routes the merge/dedupe/display-normalization paths in `_merge_display_messages_after_agent_result` and `_find_current_user_turn` through it. Replaces `_has_new_assistant_reply` length-delta gating in `_periodic_checkpoint` with the new `_assistant_reply_added_after_current_turn` helper, which slices result messages from the current-turn position before counting assistant deltas — silent/no-response failures are now detected from the current turn alone instead of being masked by prior assistant content.
|
||||
- **PR #2146** by @swftwolfzyq — Track WebUI update checks against the latest published release tag instead of every commit on the upstream branch, so operators who only want released versions stop seeing noisy update banners for post-release development commits. Falls back to branch-based detection when no release tags are available. Splits `git describe --dirty` into a fast base describe plus a bounded dirty probe so WSL-mounted workspaces never block version detection on a slow `--dirty` walk, keeping the base version visible even if the dirty probe times out.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **PR #2469** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 2 seam shipped by marking Slice 2 complete and defining the first Slice 3a cancel-control gate. The new gate scopes Stop Generation through `RuntimeAdapter.cancel_run(...)` only, pins behavior-preserving cancellation, journal/status coherence, idempotent duplicate cancel, and explicit non-goals for approval/clarify, queue/goal, runner/sidecar, and public chat-start response changes.
|
||||
|
||||
## [v0.51.84] — 2026-05-17 — Release BH (stage-377 — 1-PR Docker hygiene — agent-image upgrade docs + read-only WebUI source mount + chown prune widening)
|
||||
|
||||
### Changed
|
||||
|
||||
- **PR #2470** — Multi-container Docker hygiene pass. The `hermes-agent-src` named volume in `docker-compose.two-container.yml` and `docker-compose.three-container.yml` is now mounted **read-only** on the WebUI service (the WebUI only reads it to install the agent's Python dependencies at startup), bringing the actual mount mode in line with the existing `docs/docker.md` architecture diagram. To keep `docker_init.bash` startup compatible with the new read-only mount, `chown_home_hermeswebui` now prunes the entire `/home/hermeswebui/.hermes/hermes-agent` subtree from the ownership walk instead of only `.git/objects` — the WebUI never writes to the agent source, so the previous narrower carve-out was always a nicety, and on a `:ro` mount it would have returned `EROFS` and killed startup under `set -e`. The widened prune also subsumes the original #2237 macOS bind-mount case (the `.git/objects` packs are inside the now-pruned subtree). The default `${HERMES_WORKSPACE:-~/workspace}` workspace bind is changed to `${HERMES_WORKSPACE:-${HOME}/workspace}` so the path resolves consistently across Linux, macOS, WSL2, and Docker Desktop on Windows (matching the single-container `docker-compose.yml` convention). No behaviour change for users who set `HERMES_WORKSPACE` explicitly.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **PR #2470** — `docs/docker.md` gains an **"Upgrading the agent container"** section documenting the root cause of [#1416](https://github.com/nesquena/hermes-webui/issues/1416): the `hermes-agent-src` named volume caches the agent's `/opt/hermes` source tree on first run, and Docker reuses the cached volume on every subsequent `compose up` — even after `docker pull` of a newer agent image. The new section gives the canonical `down → docker volume rm → pull → up -d` recipe and the same upgrade pointer is mirrored as a comment block in both multi-container compose files. A new **"What the multi-container setup isolates (and what it doesn't)"** section explicitly frames the two/three-container setups as **process, network, and resource isolation, not filesystem isolation** — calibrating expectations for users who reach for multi-container expecting a trust boundary between the chat UI and the agent.
|
||||
|
||||
## [v0.51.83] — 2026-05-17 — Release BG (stage-376 — 12-PR contributor batch — chat-start adapter parity + populated-core journal recovery + thinking card dedup + context metadata refresh + model cache fingerprint + stream fade cap + manual cron delivery + active-session spinner + email gateway label + thinking copy button + /theme i18n + compact activity semantics)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2460** by @Michaelyklam (closes #2449) — Add a copy button to Thinking card headers so users can copy the card's reasoning text without selecting the `<pre>` manually. The button stops header-toggle propagation and shows the same short checkmark feedback pattern used by existing copy actions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2438** by @franksong2702 (fixes #2435) — Keep the default-off `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` chat-start path response-compatible with the legacy-direct path by not adding adapter-internal `run_id`, `status`, or `active_controls` fields to `/api/chat/start` responses. The adapter facade for the future #1925 runtime split stays an internal protocol-translator seam instead of expanding the public chat-start contract.
|
||||
- **PR #2439** by @franksong2702 (fixes #2434) — Recover already-journaled visible assistant text and tool cards even when restart repair first syncs a populated Hermes core transcript into an otherwise empty WebUI sidecar. The core-sync branch now merges non-duplicate run-journal output before clearing stale stream state, closing the carve-out from PR #2427 where recoverable partial output could be silently skipped. Adds `_append_journaled_partial_output(..., dedupe_existing=True)` plus helpers `_run_journal_has_visible_output`, `_find_existing_assistant_for_journal_content`, and `_journal_tool_already_present`.
|
||||
- **PR #2441** by @Michaelyklam (fixes #2440) — Compact live Thinking cards now reuse the same timeline card across sequential tool calls within a single assistant turn. `finalizeThinkingCard` clears the `data-thinking-active` marker by searching the entire assistant turn instead of only the tool group, and `appendThinking` reuses the most recent Thinking card when no active marker is set, preventing repeated Thinking cards from stacking as reasoning resumes between tool calls.
|
||||
- **PR #2444** by @franksong2702 (fixes #2442) — Refresh session context-window metadata when a session's resolved model changes during deferred hydration or when the user switches models, so high-context models do not stay stuck on a stale prior window and trigger premature compression. Adds a shared `_resolve_context_length_for_session_model` helper, updates `GET /api/session?resolve_model=1` to refresh non-zero persisted windows from current model metadata, resets context metadata on `/api/session/update` model/provider changes, and applies returned `context_length`/`threshold_tokens`/`last_prompt_tokens` in the deferred client-side resolution path with an immediate context-indicator resync.
|
||||
- **PR #2445** by @Michaelyklam (fixes #2443) — `/api/models` now fingerprints the in-module provider catalog plus the local Codex `models_cache.json` as part of its persisted cache metadata, so server-side catalog additions and Codex local catalog refreshes invalidate `models_cache.json` immediately on the next restart instead of waiting for the 24-hour TTL or manual cache deletion.
|
||||
- **PR #2450** by @Michaelyklam (fixes #2447) — Cap the optional streaming word-fade drain after the final `done` SSE event so very large or bursty completed responses render from the canonical session promptly instead of keeping the chat in a live/working state until Stop is pressed. The existing caught-up path and per-token animation wait are preserved for normal responses.
|
||||
- **PR #2452** by @Michaelyklam (fixes #2451) — Manual WebUI cron triggers now deliver the same final response or failure notice as scheduled cron runs. The manual-run wrapper reuses the scheduler delivery contract (`[SILENT]` skipping, separate `last_delivery_error` metadata, error-notice fallback) with a `TypeError` shim for legacy `mark_job_run` signatures used by older WebUI test doubles.
|
||||
- **PR #2455** by @franksong2702 (fixes #2454) — Keep the sidebar spinner in sync with server session metadata when the currently open session has finished but the browser still has stale local busy state. A new `_reconcileActiveSessionIdleStateFromList` helper clears `S.busy`, `S.activeStreamId`, the `INFLIGHT` cache, and active-session stream metadata before optimistic merging can re-mark the row as streaming.
|
||||
- **PR #2457** by @Michaelyklam (closes #2456) — Email gateway sessions imported from Hermes Agent `state.db` now normalize as messaging sessions and show an `Email` source label in the WebUI sidebar instead of falling through as unlabelled generic agent sessions. Keeps the Python source-normalization contract (`MESSAGING_SOURCES`, `SOURCE_LABELS`), gateway status platform labels, and frontend `static/sessions.js` whitelist in sync.
|
||||
- **PR #2463** by @Michaelyklam (closes #2462) — Align `/theme` command help strings in Russian, German, Simplified Chinese, Traditional Chinese, and French with the current Theme × Skin contract. The localized command descriptions now mention `system/dark/light` plus the full skin list through `nous`, and French invalid-usage text now uses the actual `/theme ` slash command prefix instead of `/thème`. Supersedes the parallel-discovery duplicate at #2464 (closed in favor of this PR).
|
||||
|
||||
### Changed
|
||||
|
||||
- **PR #2466** by @franksong2702 (closes #2465) — Clarify `Compact tool activity` semantics in Preferences: the setting now describes compact inline activity that preserves the agent timeline, matching the current long-running turn behavior where thinking cards, visible progress notes, and tool Activity bursts stay in chronological order instead of being described as one top-of-turn collapsed block. Renderer behavior is unchanged; this is a description-only correction plus the `simplified_tool_calling` default comment and regression-test wording.
|
||||
|
||||
## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2432** by @Michaelyklam (closes #2426) — Add a Catppuccin skin to Appearance settings. The single opt-in skin maps light mode to Catppuccin Latte and dark mode to Catppuccin Mocha, using Mauve as the accent while preserving the existing theme/skin persistence and no-build-step architecture.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2428** by @bengdan — Protect pipes inside parens / brackets / braces from naive `split('|')` in the Markdown table renderer. Cells like `` `(a|b)` ``, `` `Union[int|float]` ``, `` `(a|b|c)` ``, and `` `Union[int|float|str]` `` now stay in a single column instead of mis-splitting. The fix uses an iterative `_protectPipes` loop so all pipes inside one bracket pair are caught, not just the first. Also adds a `$...$` guard so a KaTeX inline-math span straddling ` | ` column separators is left alone instead of being stashed as math. Stage-fix on the contributor branch (a) swapped the literal `}` glyphs in the regex character classes for `\x7d` hex escapes (semantically identical, but the JS source no longer carries bare close-brace glyphs that confused the brace-counting `extractFunc` in `tests/test_renderer_js_behaviour.py`); (b) dropped a stray apostrophe stop that would have mis-split `('a'|'b')`-style string-literal unions; (c) dropped angle brackets `<` / `>` from the protected-bracket set, after Opus advisor flagged that `| x < 5 | y > 10 |` would otherwise collapse into a single cell (comparison-operator usage dominates content-grouping usage in real LLM table output); and (d) added `tests/test_issue2428_table_pipe_protection.py` with 12 regression cases covering single-pipe, multi-pipe-in-brackets, apostrophes-with-pipes, the KaTeX-in-table guard, and the angle-bracket comparison-operator case.
|
||||
|
||||
## [v0.51.81] — 2026-05-17 — Release BE (stage-374 — 6-PR batch — cost-history POSIX lock + prompt-cache tokens + Plugins panel i18n + pending-placeholder chat + journal-replay partial recovery + default-off RuntimeAdapter Slice 2 seam)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2424** by @Michaelyklam (refs #1925) — Add the default-off `RuntimeAdapter` Slice 2 seam. `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` now routes chat start through a `LegacyJournalRuntimeAdapter` facade over the existing legacy streaming path, while the default remains `legacy-direct`. The new adapter interface/payload classes expose start/observe/status/cancel/approval/clarify methods and delegate controls to existing handlers without introducing a runner, sidecar, new process-local queues, cached agents, cancellation registries, or callback registries.
|
||||
- **PR #2421** by @Michaelyklam (fixes #2419) — Surface provider prompt-cache read/write tokens in WebUI usage displays. Cache-miss cost issues are now visible in the context tooltip and per-turn usage footer; counters carry through session persistence, SSE usage payloads, and live snapshots so deltas remain accurate across the active turn.
|
||||
- **PR #2425** by @mccxj — Wire Settings → Plugins panel into the existing i18n system. Panel title, description, empty state, and per-plugin labels (hooks, enabled/disabled, load failures) now respect the user's language preference; 10 new keys ship in English with `TODO: translate` placeholders in 9 additional locales.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2418** by @Michaelyklam (fixes #2402) — OpenRouter cost-history snapshot updates now take a provider-specific POSIX file lock around the read-modify-write cycle, preserving the existing process-local lock while preventing lost snapshot updates if WebUI is deployed with multiple worker processes sharing one Hermes home/state directory.
|
||||
- **PR #2431** by @Michaelyklam (fixes #2429) — Chat sends now render the assistant-side pending `Thinking…` placeholder immediately after the user turn is echoed, before `/api/chat/start` returns a stream id or the first SSE event arrives. The existing stale-stream guard remains in place for ordinary reasoning updates — only the explicit pre-stream placeholder path is allowed through.
|
||||
- **PR #2427** by @franksong2702 (fixes #2423) — Recover already-journaled visible assistant text and tool cards when a WebUI process restart interrupts an in-flight browser-originated turn. The stale-stream repair path now materializes run-journal output before the explicit interrupted marker instead of collapsing the turn to "no agent output was recovered."
|
||||
|
||||
### Documentation
|
||||
|
||||
- **PR #2416** by @Michaelyklam (refs #1925) — Expand the runtime-adapter RFC with the concrete Slice 2 adapter-seam contract: minimal `RuntimeAdapter` methods, payload fields, `legacy-direct` / `legacy-journal` feature-flag rollback path, legacy-backend mapping, explicit non-goals, and adapter-seam acceptance tests. Keeps the next step scoped to a reversible protocol-translator boundary over the journaled legacy path, not a runner/sidecar or execution-ownership move.
|
||||
|
||||
## [v0.51.80] — 2026-05-17 — Release BD (stage-373 — 2-PR batch — provider config flag filter + stale compaction greeting heuristic)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2415** by @Michaelyklam (fixes #2399) — `providers.only_configured` and other scalar flags under the top-level `providers:` config mapping no longer appear as fake provider groups in the model picker. Provider detection now only seeds picker groups from known provider ids/aliases or dict-shaped provider configs, so filtering flags cannot render as `Only-Configured`. The gating contract is documented inline in `api/config.py` (within the existing `_PROVIDER_MODELS`/`_PROVIDER_DISPLAY` membership block) so the test_issue604 source-scan stays satisfied.
|
||||
- **PR #2417** by @nesquena-hermes (co-authored by @franksong2702, supersedes #2309, closes #2308) — Compressed sessions with hidden "resume active task" context no longer treat a short fresh greeting (`hi`, `hello`, plus 6 CJK greetings) as implicit permission to continue an old agent task. Explicit continuation prompts (`continue`, `resume`, plus 4 CJK continuation phrases) still keep the compacted task context. The new helpers (`_normalize_fresh_chat_text`, `_is_casual_fresh_chat_message`, `_has_task_resume_compaction_marker`, `_context_messages_for_new_turn`) require BOTH the compaction phrase AND a task-resume keyword in the SAME message before treating it as a stale-task marker (precision-preserving guard). Length cap of 24 chars + workspace-prefix normalization + exact greeting-set match prevent false positives. CJK greetings/continuation terms are stored as Python `\u`-escape sequences so `api/streaming.py` passes the `test_title_sanitization::test_title_generation_source_has_no_cjk_literals` English-only-source invariant; runtime values are unchanged. Stage-372 Opus advisor pass caught two CJK codepoint typos (`嘖→嗨`, `哈喂→哈喽`) in the maintainer rebase and corrected them; new regression test `test_all_cjk_greetings_drop_stale_compaction_context` pins all 6 CJK greetings against future codepoint drift with `U+XXXX` failure messages.
|
||||
|
||||
## [v0.51.79] — 2026-05-16 — Release BC (stage-372 — 5-PR batch — text-mode image history fix + Activity-group compression boundary + named custom provider routing + quota chip Settings toggle + RFC docs)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2413** (self-built follow-up to v0.51.78's #2082, closes the quota-chip default-on regression) — New "Show provider quota chip in composer" checkbox in Settings → Preferences, default off. When disabled (the new default), the chip is hidden at all viewports and the `/api/provider/quota` fetch is skipped entirely. When enabled, the existing `@media (max-width:1399.98px)` gate from stage-371 still restricts the chip to wide desktops only. Per Nathan's directive 2026-05-16 immediately after stage-371 shipped — users get explicit agency over an ambient composer-chrome element. Wired through `api/config.py` `_SETTINGS_DEFAULTS`, `static/boot.js`, `static/panels.js` round-trip, `static/ui.js` short-circuit-when-disabled, `static/index.html` Settings field, and 11 locales in `static/i18n.js`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2406** by @Michaelyklam (fixes #2398) — The fallback synchronous `POST /api/chat` route now passes the active WebUI config into the conversation-history sanitizer, so text-mode providers do not receive historical native `image_url` content parts when direct API callers use the legacy chat endpoint. This brings the sync route in line with the streaming chat path fixed for #2297.
|
||||
- **PR #2408** by @Michaelyklam (fixes #2404) — Auto-compression cards now close the current live Activity burst before rendering, so post-compression tools start a fresh `Activity` row instead of joining the pre-compression tool group across a real timeline/context boundary. Adds a `closeCurrentLiveActivityGroup()` helper that clears the `data-live-activity-current` marker before `appendLiveCompressionCard()` inserts the compression card. Resolves the DEFER from stage-370 Opus advisor review of PR #2390.
|
||||
- **PR #2411** by @Michaelyklam (fixes #2405) — Named `custom:*` providers no longer lose vendor-prefixed model selections when the static model picker has not hydrated that model yet. The frontend now treats named custom providers as routable aggregators for both mismatch-warning suppression and missing-dropdown fallback, and live-fetched models keep explicit `@custom:name:` provider context so selections persist instead of snapping back to the configured default.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **PR #2407** by @Michaelyklam — Document the #1925 runtime-adapter gate update: Slice 1 run-journal replay has now passed a 100-trial synthetic replay/restart validation pass on current `origin/master`, #2313's selected-session chat SSE cap is shipped, and Slice 2 is ready for a reversible adapter-seam planning PR without moving execution ownership yet.
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- New regression test `tests/test_quota_chip_settings_toggle.py` (6 cases) pins the quota-chip toggle invariants: Settings field present with i18n labels, `show_quota_chip` default-`False` in `_SETTINGS_DEFAULTS` + `_SETTINGS_BOOL_KEYS`, render/refresh both short-circuit when disabled (no wasted API calls), boot initializes `window._showQuotaChip` from settings + default-false on settings-fetch failure, full panels.js round-trip, 11 locale strings present.
|
||||
|
||||
## [v0.51.78] — 2026-05-16 — Release BB (stage-371 — stuck-PR sweep salvage — RTL chat + ambient quota chip with composer-clutter gate)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2409** (maintainer follow-up from 2026-05-16 stuck-PR sweep, co-authored by @malulian and @ai-ag2026, closes #1721 and #2082) — Two stalled contributor PRs absorbed into one self-built release after Telegram UX approval across mobile/laptop/desktop/wide viewports.
|
||||
- **Right-to-left chat layout (salvaged from #1721 by @malulian)** — New Settings → Preferences toggle, default off, flips the chat-area direction for Arabic and Hebrew users. Honors @aronprins' design review on PR #1721 (May 13 2026): drops the contributor's composer footer toggle button to keep composer real estate clean. Implementation includes a flash-prevention bootstrap `<script>` in `<head>` (applies `chat-content-rtl` class synchronously before any chat content paints), scoped CSS that only flips `.msg-row`, `.msg-body` tables, `.tool-call-group-summary`, and the composer `textarea#msg` — the sidebar, workspace panel, settings panel, and any other UI element stay left-to-right. Code blocks (`pre`, `code`, `kbd`, `samp`, `tt`, `.hljs`, `.code-block`) and tool-call group bodies force `direction:ltr; text-align:left; unicode-bidi:isolate` even under RTL, because Arabic and Hebrew developers still write English code, command lines, and JSON the same way English developers do (visually verified with embedded Python in an Arabic SSE conversation). Localized in 11 locales (en, it, ja, ru, es, de, zh-CN, zh-TW, pt, ko, fr).
|
||||
- **Ambient provider quota chip (overridden from #2082 by @ai-ag2026)** — New green pill chip in the composer footer that surfaces the active provider's remaining quota (OpenRouter credit balance shaped as `$X.YZ`, or account-limit-shaped providers as `N%`), with click-through to Settings → Providers. Fetches `/api/provider/quota` on boot and on tab visibility return. Hidden below 1400px viewport via `@media (max-width:1400px) { display:none !important }` because the composer footer at 1280px laptop and 1440px standard desktop was already tight and the chip squeezed adjacent chips (model picker truncated from `Claude Sonnet 4 7` to `Claude Sonnet 4`, workspace dropdown lost text). Mobile users find quota through the dedicated mobile-config drawer; laptop users follow the chip's click-target into Settings → Providers anyway. The chip's value proposition (ambient quota visibility) is preserved on wide displays where there's genuine composer room without trading off existing chip readability.
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- New regression test `tests/test_pr1721_rtl_salvage.py` (8 cases) pins the RTL salvage invariants: Settings field + i18n keys present, no composer footer button (negative assertion encoding @aronprins' design objection), bootstrap script runs synchronously in `<head>` before paint, CSS scoped to chat only (negative tests against `.sidebar`, `.settings-panel`, `.workspace-panel`, `html`, `body` rules), code blocks force LTR under RTL, tool-call bodies force LTR under RTL, panels.js load/save round-trip, `rtl` in `api/config.py` DEFAULTS and writable-key allow-list, 11 locale strings present.
|
||||
|
||||
## [v0.51.77] — 2026-05-16 — Release BA (stage-370 — 1-PR follow-up — live Activity grouping boundary fix)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2390** by @franksong2702 (refs #2376, #2344, #2347, #2377) — Live progress Activity grouping no longer degrades consecutive tool calls into repeated `Activity: 1 tool` rows. The frontend was using one reset helper for two different jobs — resetting where the next assistant text segment should render, and closing the current live Activity group — but those are not the same operation. Tool starts now only reset the next-text-segment anchor; the live Activity group closes only when the model emits a visible `interim_assistant` progress update (the actual timeline boundary). The flow stays:
|
||||
|
||||
```text
|
||||
Thinking card
|
||||
visible progress note
|
||||
Activity: N related tools
|
||||
visible progress note
|
||||
Activity: N related tools
|
||||
final answer
|
||||
```
|
||||
|
||||
Adds a WebUI-only ephemeral progress contract in `api/streaming.py` that asks multi-step tool-heavy turns to emit concise visible progress notes in the user's language, while explicitly forbidding exposure of hidden reasoning, chain-of-thought, scratchpads, secrets, raw logs, or long tool output. Any selected personality prompt is preserved. New regressions cover the progress-contract reach-through, the interim-assistant split boundary, and the consecutive-tools-in-one-Activity-row invariant.
|
||||
|
||||
## [v0.51.76] — 2026-05-16 — Release AZ (stage-369 — 4-PR safe-lane batch — live timeline preservation + OpenRouter cost history + chat stream cap + credential pool cache)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2195** by @Michaelyklam (refs #692) — OpenRouter cost history backend. New `GET /api/providers/openrouter/cost_history` endpoint backed by daily snapshots from OpenRouter's `/auth/key` cumulative spend. Process-local lock around the snapshot read-modify-write critical section so concurrent dashboard refreshes or multiple tabs cannot overwrite newer reads with stale ones. Delta computation handles cumulative-counter resets (key rotation, OpenRouter-side reset) by starting a fresh series and using the current value as that day's delta rather than emitting negative spend. Backend-only slice; the 7-day daily cost chart UI is a separate follow-up.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2347** by @franksong2702 (fixes #2344) — Preserve live agent timeline across session switches. Previously, switching away from an active stream and returning rebuilt the turn from the persisted `INFLIGHT` tail, which is enough to reconnect the stream but is not a full-fidelity DOM timeline — Thinking/tool grouping flattened, interim assistant text moved away from its surrounding context, auto-compression cards could project twice. The restore path now snapshots the live assistant turn DOM during the active stream and, on return, loads the persisted transcript first then merges the live snapshot back in so the on-screen scene is preserved as the user left it. Stamping `row.dataset.sessionId` at turn creation prevents the new live-turn sites from re-triggering the lossy rebuild path.
|
||||
|
||||
- **PR #2393** by @Michaelyklam (refs #2313) — Cap live chat stream transports to the selected conversation. Previously, keeping many sessions open accumulated one long-lived `/api/chat/stream` EventSource per session. New `closeOtherLiveStreams(activeSid)` helper in `static/messages.js`; `attachLiveStream()` now reuses an existing same-session transport first, closes other sessions' chat SSE transports, then opens or replaces the selected session's stream. Background sessions still reattach normally when the user selects them — only the SSE transport is pruned, not the server-side stream ownership. New regression test pins the ordering (reuse first, prune background streams next, replace active transport last).
|
||||
|
||||
- **PR #2396** by @starship-s — Preserve session agents for credential pools. The per-session `AIAgent` cache signature previously mixed stable agent identity with the volatile resolved API key, so credential-pool providers (where each request can resolve a different runtime token even when provider/model config is unchanged) missed the cache every turn and rebuilt the agent — losing warmed cross-turn state such as memory-provider prefetch results for providers like Hindsight. New credential-aware cache-signature helper uses a stable sentinel for credential-pool routes while preserving hashed API-key identity for non-pool routes; reused cached agents refresh runtime credentials in place; `AIAgent._primary_runtime` stays aligned after refresh so fallback/transport recovery cannot resurrect an old token; agents still in fallback-active state rebuild rather than mutate to avoid mixed primary/fallback runtime state. Static non-pool API keys still participate in the cache signature so explicit credential changes continue to invalidate.
|
||||
|
||||
## [v0.51.75] — 2026-05-16 — Release AY (stage-368 — 11-PR safe-lane batch — storage + i18n + run-journal parity + attachments + compression sidebar + restart-recovery + text-mode images + tables + settings i18n + German labels)
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- Stage-368 maintainer fix — pytest no longer self-loops on the `_schedule_restart` daemon thread. Several existing tests in `tests/test_update_banner_fixes.py` call `api.updates._schedule_restart()`, which spawns a daemon thread that eventually calls `os.execv()`. Those tests monkeypatch `os.execv` for the test scope, but monkeypatch teardown can win the race against the daemon thread, restoring the real `os.execv` before the thread fires it — at which point the daemon re-execs the entire pytest process with the original argv, looking from the outside like pytest hangs at 99 % then restarts the suite from 0 % in an infinite loop. `tests/conftest.py` now installs a permanent no-op wrapper on `os.execv` at module-import time so late-firing daemon threads cannot re-exec pytest. New `tests/test_pytest_execv_guard.py` pins the guard against future regressions.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2377** by @franksong2702 (refs #2283, refs #2363, refs #1925) — Run-journal replay timeline parity checks. After #2283 shipped the first run-journal replay slice and #2363 documented the cross-layer state consistency contract, this PR adds explicit parity assertions over the replayed timeline so divergences between the journal and the visible transcript (Thinking → tool calls → assistant text) surface as test failures instead of silent drift.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2391** by @Michaelyklam (fixes #2389) — Reduce browser storage pressure during service-worker updates and over long-running sessions. `static/sw.js` now calls `deleteOldShellCaches()` BEFORE `caches.open(CACHE_NAME)` in the install handler so the new ~2.2 MB shell cache no longer overlaps the old one during a version bump (especially painful on shared-origin quota accounting). A new `_clearSessionViewedCount()` helper plus extended `_clearHandoffStorageForSession()` prune `hermes-session-viewed-counts`, `hermes-session-completion-unread`, and `hermes-session-observed-streaming` on every single-session delete and batch-delete so per-session tracking maps no longer grow unbounded.
|
||||
|
||||
- **PR #2387** by @Michaelyklam (fixes #2386) — Guard `localStorage.setItem('hermes-webui-session', ...)` and workspace-panel runtime-state writes with `try { … } catch (_) {}` across `static/boot.js`, `static/sessions.js`, `static/commands.js`, and `static/messages.js`. These convenience writes were previously fatal UI operations on quota-exhausted browsers (especially Firefox public-domain setups where shared quota fills up after a service-worker shell rotation).
|
||||
|
||||
- **PR #2368** by @Michaelyklam — Hybridize background profile env routing so background title generation, manual compression, and update-summary workers honor a session's non-default profile. The pure thread-local refactor for #2321 was reverted because `hermes_cli.config.load_config()` still reads `HERMES_HOME` from process env. This PR keeps the thread-local layer for WebUI helpers and adds an `os.environ.update(runtime_env)` mirror under a narrow `_ENV_LOCK` for the worker body, with proper restore of prior values. New test asserts `OPENROUTER_API_KEY` is visible from the worker against a non-default profile.
|
||||
|
||||
- **PR #2382** by @Michaelyklam (fixes #2380) — Serve raw chat attachments from the per-session inbox in addition to the session workspace. Chat uploads were intentionally moved out of workspaces into a per-session attachment inbox in an earlier release; the transcript renderer still emits stable `api/file/raw?session_id=...&path=<filename>` URLs, but `_handle_file_raw` only checked `session.workspace` so inbox-backed uploads rendered as broken images. The URL surface is preserved and a session-attachment fallback is added with path-traversal guards intact.
|
||||
|
||||
- **PR #2385** by @franksong2702 — Keep fuller compression snapshots reachable in the sidebar. The default behavior hides `pre_compression_snapshot: true` rows so archived compression segments do not duplicate the active continuation. A real long Kanban session exposed a narrower failure: the fuller transcript was still present on disk but remained marked as `pre_compression_snapshot`, so the sidebar surfaced a shorter row and the fuller transcript became unreachable. The fix preserves discoverability without re-introducing duplication in normal cases.
|
||||
|
||||
- **PR #2371** by @franksong2702 — Clarify interrupted turn recovery after a WebUI restart. WebUI executes browser-originated agent turns inside the WebUI process; if that process restarts mid-turn, the worker dies with it. Run journal replay can only replay events that were already emitted, so the stale-pending repair path is now annotated and refined to make the post-restart state explicit (interrupted, recoverable, or terminal) instead of leaving the user with a half-rendered turn and no signal.
|
||||
|
||||
- **PR #2378** by @Michaelyklam — Strip historical images in text-only mode. Current-turn uploads already respect `agent.image_input_mode: text`, but saved conversation history still passed native `image_url` content parts back into later provider calls, breaking text-only providers on replayed turns. `_sanitize_messages_for_api()` gains a `cfg=` keyword argument so the API-history sanitizer can strip historical native image parts when the mode is text. Default `cfg=None` preserves prior behavior for callers that don't pass the new argument.
|
||||
|
||||
- **PR #2375** by @Michaelyklam — Keep Markdown tables block-level. Pipe tables were already converted to `<table>` markup, but the final paragraph pass did not treat generated tables as block-level output, occasionally wrapping them in `<p>` and breaking the surrounding layout. The fix isolates generated tables and adds `table` to the paragraph-wrap skip list so valid CommonMark tables render predictably.
|
||||
|
||||
- **PR #2372** by @mccxj — Settings → Conversation page action buttons now respect locale selection. Pre-fix, the JSON export, MD export, and Copy buttons had hardcoded English labels/titles. Adds `data-i18n` / `data-i18n-title` attributes plus the missing translation keys so non-English locales no longer see English labels stuck in the middle of a translated screen.
|
||||
|
||||
- **PR #2381** by @Michaelyklam (fixes #2379) — German relative session-time labels now interpolate the elapsed value instead of rendering the literal `{n}` placeholder in the sidebar/header. The German locale now uses function-valued translations for minutes, hours, and days, matching the other locale bundles.
|
||||
|
||||
## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n)
|
||||
|
||||
### Added
|
||||
@@ -60,6 +356,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2347** by @franksong2702 — Long tool-heavy streaming turns now preserve the live Thinking / assistant progress / Tool / Command timeline when the user switches away and back. The active stream keeps accumulating token and interim-assistant state while inactive, reloads the persisted transcript before merging the live tail, restores the live turn DOM snapshot instead of replaying tools into a flat list, and anchors automatic compression cards inside the active turn to avoid duplicate cards while an answer is still streaming.
|
||||
|
||||
- **PR #2332** by @Michaelyklam (refs #2290) — Cron run history/output cards now surface token/cost metadata when the underlying cron output markdown includes it. The backend parses optional model/token/cost/duration frontmatter from cron output files and returns it from `/api/crons/history` and `/api/crons/run`; the Tasks panel renders a compact usage strip beside run rows and below expanded output without affecting older outputs that lack usage metadata.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -4,6 +4,22 @@ Thanks for contributing.
|
||||
|
||||
Hermes WebUI is intentionally simple to work on: Python on the server, vanilla JS in the browser, no build step, no bundler, no frontend framework. The best pull requests preserve that simplicity while solving a real problem cleanly.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- Read [`AGENTS.md`](AGENTS.md) if an AI assistant is doing or helping with the
|
||||
change.
|
||||
- Read [`docs/CONTRACTS.md`](docs/CONTRACTS.md) and any linked contract/RFC for
|
||||
the subsystem you will touch.
|
||||
- For UI or UX work, read [`docs/UIUX-GUIDE.md`](docs/UIUX-GUIDE.md)
|
||||
and [`DESIGN.md`](DESIGN.md).
|
||||
- For runtime, streaming, recovery, replay, compression, context reconstruction,
|
||||
or session metadata work, start with [`docs/rfcs/README.md`](docs/rfcs/README.md)
|
||||
and the relevant RFC listed there.
|
||||
|
||||
Use those documents as review guardrails: keep the change scoped, preserve the
|
||||
no-build-step architecture, update docs/changelog when behavior changes, include
|
||||
UI evidence for UI changes, and add tests for behavior changes where practical.
|
||||
|
||||
## Two Paths to a Strong Pull Request
|
||||
|
||||
### Path 1: Small, Focused Changes
|
||||
@@ -60,7 +76,7 @@ There is currently no PR template in this repo, so include the important section
|
||||
|
||||
If the change is user-visible, include screenshots or a short video.
|
||||
|
||||
For UI or UX changes, before/after images are required. PRs that change the interface or interaction flow without before/after images will likely be ignored, or closed in a regular maintainer sweep without review.
|
||||
For UI or UX changes, before/after images are required. PRs that change the interface or interaction flow without before/after images may not receive meaningful review until that evidence is added.
|
||||
|
||||
### 4. AI Usage Disclosure
|
||||
|
||||
|
||||
@@ -208,6 +208,8 @@ docker compose -f docker-compose.three-container.yml up -d
|
||||
Both compose files use **named Docker volumes** by default, which solves the UID/GID problem by construction. If you need bind mounts to share an existing host directory, see [`docs/docker.md`](docs/docker.md) for the full migration recipe.
|
||||
|
||||
> **Known limitation (#681)**: in the two-container setup, tools triggered from the WebUI run in the **WebUI container**, not the agent container. If you need git/node/etc. on the WebUI's filesystem, either use the single-container setup, extend the WebUI Dockerfile, or use the community [all-in-one image](https://github.com/sunnysktsang/hermes-suite).
|
||||
>
|
||||
> **Source boundary note (#2453)**: the multi-container setup mounts `hermes-agent-src` read-only into the WebUI by default. This prevents WebUI-side source rewrites but is still an implementation-coupling bridge, not a stable Agent API boundary. See [`docs/rfcs/agent-source-boundary.md`](docs/rfcs/agent-source-boundary.md) for the current source/API decoupling inventory.
|
||||
|
||||
### Common failure modes
|
||||
|
||||
@@ -328,6 +330,22 @@ That's it. Traffic is encrypted end-to-end by WireGuard, and password auth
|
||||
protects the UI at the application level. You can add it to your home screen
|
||||
for an app-like experience.
|
||||
|
||||
### Community field report: ARM64 Android via AVF
|
||||
|
||||
A community report in [#2364](https://github.com/nesquena/hermes-webui/issues/2364)
|
||||
documents Hermes Agent + WebUI running on a mid-range ARM64 Android phone inside
|
||||
a Debian 12 VM via Android Virtualization Framework (AVF). The reported setup
|
||||
used a Xiaomi Redmi Note 13 Pro 4G, 3.8 GiB RAM allocated to the VM, 8 visible
|
||||
CPU cores, Chrome on Android at `localhost:8787`, and cloud-hosted inference.
|
||||
|
||||
This is not an official support baseline or provider/model benchmark, but it is
|
||||
a useful compatibility signal for mobile ARM64 experiments: the WebUI rendered
|
||||
smoothly in Chrome, ARM64 Debian worked for the agent stack, and the total local
|
||||
footprint was about 1.7 GB. Practical caveats from the report: first install can
|
||||
take longer when dependencies compile from source, Android browser tabs may
|
||||
reload when switching apps, and disabling battery optimization for the terminal
|
||||
or VM host may be needed for longer-running sessions.
|
||||
|
||||
> **Tip:** If using Docker, set `HERMES_WEBUI_HOST=0.0.0.0` in your
|
||||
> `docker-compose.yml` environment (already the default) and set
|
||||
> `HERMES_WEBUI_PASSWORD`.
|
||||
@@ -449,10 +467,13 @@ Production data and real cron jobs are never touched. Current snapshot:
|
||||
- CDN resources pinned with SRI integrity hashes
|
||||
|
||||
### Themes
|
||||
- 7 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord, OLED
|
||||
- Switch via Settings panel dropdown (instant live preview) or `/theme` command
|
||||
- Appearance is split into two axes: Theme (`system`, `dark`, `light`) and Skin
|
||||
(`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`, `charizard`,
|
||||
`sienna`, `catppuccin`, `nous`)
|
||||
- Switch via Settings -> Appearance (instant live preview) or `/theme <theme-or-skin>`
|
||||
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
|
||||
- Custom themes: define a `:root[data-theme="name"]` CSS block and it works — see [THEMES.md](THEMES.md)
|
||||
- Skins use `data-skin` plus CSS variables; dark mode resolves through the
|
||||
`.dark` class, not a `data-theme` custom-theme axis — see [THEMES.md](THEMES.md)
|
||||
|
||||
### Settings and configuration
|
||||
- **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
|
||||
@@ -538,6 +559,8 @@ State lives outside the repo at `~/.hermes/webui/` by default
|
||||
- `CHANGELOG.md` -- release notes per sprint
|
||||
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
|
||||
- `THEMES.md` -- theme system documentation, custom theme guide
|
||||
- `docs/CONTRACTS.md` -- project contract/RFC/design index for contributors and agents
|
||||
- `docs/UIUX-GUIDE.md` -- UI/UX principles sourced from existing design docs and visual inventories
|
||||
- `docs/docker.md` -- Docker compose setup, common failures, and bind-mount migration
|
||||
- `docs/supervisor.md` -- launchd, systemd, supervisord, runit, and s6 process-supervisor setup
|
||||
- `docs/onboarding.md` -- first-run wizard, provider setup, local model server Base URLs, and safe re-runs
|
||||
|
||||
@@ -4,7 +4,7 @@ Hermes Web UI splits **appearance** into two independent pickers:
|
||||
|
||||
- **Theme** — the mode: `System`, `Dark`, or `Light`. Drives the background,
|
||||
text, surface, and chrome colors.
|
||||
- **Skin** — the accent palette: eight named skins ship built-in. Drives only
|
||||
- **Skin** — the accent palette: ten named skins ship built-in. Drives only
|
||||
the `--accent` family (active states, links, focus rings, primary actions).
|
||||
|
||||
You pick one of each and they combine, so the look adapts to your environment
|
||||
@@ -15,13 +15,14 @@ without losing your favorite accent — pure CSS, no Python changes needed.
|
||||
## Switching Appearance
|
||||
|
||||
**Settings panel:** Click the gear icon → **Appearance**. The **Theme** card
|
||||
toggles Light/Dark/System; the **Skin** grid offers eight accent palettes.
|
||||
toggles Light/Dark/System; the **Skin** grid offers ten accent palettes.
|
||||
Preview is instant — the UI updates as you click.
|
||||
|
||||
**Slash command:** Type `/theme <name>` in the composer. The command accepts
|
||||
both theme names (`system`, `dark`, `light`) and skin names (`default`, `ares`,
|
||||
`mono`, `slate`, `poseidon`, `sisyphus`, `charizard`, `sienna`). It updates the
|
||||
matching axis and leaves the other one alone.
|
||||
`mono`, `slate`, `poseidon`, `sisyphus`, `charizard`, `sienna`,
|
||||
`catppuccin`, `nous`). It updates the matching axis and leaves the other one
|
||||
alone.
|
||||
|
||||
**Persistence:** Both choices are stored in `localStorage` for flicker-free
|
||||
loading, and saved server-side via `POST /api/settings` (under `theme` and
|
||||
@@ -54,6 +55,8 @@ absent for light. System mode tracks the OS preference at runtime.
|
||||
| **Sisyphus** | Vivid purple. Distinctive without being loud. |
|
||||
| **Charizard** | Warm orange. Energetic and easy on the eyes. |
|
||||
| **Sienna** | Warm clay and sand earth palette. Soft and natural. |
|
||||
| **Catppuccin** | Catppuccin Latte/Mocha palette with Mauve accent. |
|
||||
| **Nous** | Steel-blue accent with dashed technical surfaces. |
|
||||
|
||||
Each skin defines paired light + dark variants so it reads cleanly on either
|
||||
theme. The skin is applied as `data-skin="<name>"` on `<html>` (the default
|
||||
|
||||
@@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MESSAGING_SOURCES = {
|
||||
'discord',
|
||||
'email',
|
||||
'slack',
|
||||
'telegram',
|
||||
'weixin',
|
||||
@@ -22,6 +23,7 @@ SOURCE_LABELS = {
|
||||
'cli': 'CLI',
|
||||
'cron': 'Cron',
|
||||
'discord': 'Discord',
|
||||
'email': 'Email',
|
||||
'slack': 'Slack',
|
||||
'telegram': 'Telegram',
|
||||
'tool': 'Tool',
|
||||
|
||||
@@ -54,6 +54,7 @@ PUBLIC_PATHS = frozenset({
|
||||
})
|
||||
|
||||
COOKIE_NAME = 'hermes_session'
|
||||
CSRF_HEADER_NAME = 'X-Hermes-CSRF-Token'
|
||||
|
||||
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
|
||||
|
||||
@@ -366,6 +367,37 @@ def verify_session(cookie_value: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _session_token_from_cookie_value(cookie_value: str) -> str | None:
|
||||
"""Return the raw server-side session token from a signed cookie value."""
|
||||
if not cookie_value or '.' not in cookie_value:
|
||||
return None
|
||||
token, _sig = cookie_value.rsplit('.', 1)
|
||||
return token or None
|
||||
|
||||
|
||||
def csrf_token_for_session(cookie_value: str) -> str | None:
|
||||
"""Return the CSRF token bound to an authenticated WebUI session.
|
||||
|
||||
The browser can read this token from the authenticated shell and echoes it
|
||||
in ``X-Hermes-CSRF-Token`` on unsafe API requests. The token is derived
|
||||
from the HttpOnly session cookie's server-side token, so it automatically
|
||||
rotates on login and is invalidated when the auth session expires or logs
|
||||
out. Callers must still verify the auth session before trusting it.
|
||||
"""
|
||||
token = _session_token_from_cookie_value(cookie_value)
|
||||
if not token:
|
||||
return None
|
||||
return hmac.new(_signing_key(), f"csrf:{token}".encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_csrf_token(cookie_value: str, csrf_token: str) -> bool:
|
||||
"""Verify a submitted CSRF token against the authenticated session."""
|
||||
if not cookie_value or not csrf_token or not verify_session(cookie_value):
|
||||
return False
|
||||
expected = csrf_token_for_session(cookie_value)
|
||||
return bool(expected and hmac.compare_digest(str(csrf_token), expected))
|
||||
|
||||
|
||||
def invalidate_session(cookie_value) -> None:
|
||||
"""Remove a session token."""
|
||||
if cookie_value and '.' in cookie_value:
|
||||
|
||||
@@ -11,6 +11,7 @@ Discovery order for all paths:
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -691,6 +692,7 @@ _PROVIDER_DISPLAY = {
|
||||
"anthropic": "Anthropic",
|
||||
"openai": "OpenAI",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"xai-oauth": "xAI Grok OAuth",
|
||||
"copilot": "GitHub Copilot",
|
||||
"zai": "Z.AI / GLM",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
@@ -971,6 +973,49 @@ def _custom_endpoint_slugs_for_base_url(value: object) -> set[str]:
|
||||
return {f"custom:{host}:{port}", f"custom:{host}-{port}"}
|
||||
|
||||
|
||||
_LEGACY_CUSTOM_API_KEY_ENV_WARNED: set[str] = set()
|
||||
|
||||
|
||||
def _api_key_env_name(provider_id: object) -> str:
|
||||
"""Return the POSIX-safe default API-key env var for a custom provider id."""
|
||||
sanitized = re.sub(r"[^A-Za-z0-9]", "_", str(provider_id or "")).upper().strip("_")
|
||||
if not sanitized:
|
||||
sanitized = "CUSTOM"
|
||||
if not sanitized.startswith("CUSTOM_"):
|
||||
sanitized = f"CUSTOM_{sanitized}"
|
||||
return f"{sanitized}_API_KEY"
|
||||
|
||||
|
||||
def _legacy_custom_api_key_env_name(provider_id: object) -> str:
|
||||
"""Return the pre-#2541 custom-provider env hint shape, if any."""
|
||||
raw = str(provider_id or "").strip().upper()
|
||||
if not raw:
|
||||
return ""
|
||||
return f"{raw}_API_KEY"
|
||||
|
||||
|
||||
def _lookup_custom_api_key_env(provider_id: object) -> str | None:
|
||||
"""Look up sanitized custom-provider env first, then legacy broken shape."""
|
||||
env_name = _api_key_env_name(provider_id)
|
||||
api_key = os.getenv(env_name, "").strip()
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
legacy_env_name = _legacy_custom_api_key_env_name(provider_id)
|
||||
if legacy_env_name and legacy_env_name != env_name:
|
||||
legacy_key = os.getenv(legacy_env_name, "").strip()
|
||||
if legacy_key:
|
||||
if legacy_env_name not in _LEGACY_CUSTOM_API_KEY_ENV_WARNED:
|
||||
_LEGACY_CUSTOM_API_KEY_ENV_WARNED.add(legacy_env_name)
|
||||
logger.warning(
|
||||
"Custom provider API key env var %s is deprecated; use %s instead",
|
||||
legacy_env_name,
|
||||
env_name,
|
||||
)
|
||||
return legacy_key
|
||||
return None
|
||||
|
||||
|
||||
def _named_custom_provider_slug_for_base_url(
|
||||
base_url: object,
|
||||
config_obj: dict | None = None,
|
||||
@@ -1165,6 +1210,9 @@ _PROVIDER_MODELS = {
|
||||
"x-ai": [
|
||||
{"id": "grok-4.20", "label": "Grok 4.20"},
|
||||
],
|
||||
"xai-oauth": [
|
||||
{"id": "grok-4.20", "label": "Grok 4.20"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1771,6 +1819,19 @@ def resolve_model_provider(model_id: str) -> tuple:
|
||||
and prefix != config_provider
|
||||
):
|
||||
return model_id, "openrouter", None
|
||||
# Cross-provider via custom_providers: if the prefix matches a named custom
|
||||
# provider entry (e.g. "ollama-local/glm-4.7-flash:q4_k_m"), route through it
|
||||
# instead of falling back to the default config provider. MUST come BEFORE
|
||||
# the config_base_url branch because many providers have a base_url set.
|
||||
if prefix and config_provider and prefix != config_provider:
|
||||
_custom_cfg = cfg.get("custom_providers", [])
|
||||
if isinstance(_custom_cfg, list):
|
||||
for _entry in _custom_cfg:
|
||||
if isinstance(_entry, dict) and _entry.get("name", "").strip() == prefix:
|
||||
_slug = _custom_provider_slug_from_name(prefix)
|
||||
_base = (_entry.get("base_url") or "").strip()
|
||||
return model_id, _slug, _base or None
|
||||
|
||||
# If a custom endpoint base_url is configured, don't reroute through OpenRouter
|
||||
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
|
||||
# The user has explicitly pointed at a base_url, so trust their routing config.
|
||||
@@ -1827,7 +1888,7 @@ def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, st
|
||||
# cases after profile switches or runtime config edits.
|
||||
cfg_data = get_config()
|
||||
|
||||
def _resolve_key(raw_api_key, raw_key_env) -> str | None:
|
||||
def _resolve_key(raw_api_key, raw_key_env, provider_hint=None) -> str | None:
|
||||
api_key = None
|
||||
if raw_api_key is not None:
|
||||
key_text = str(raw_api_key).strip()
|
||||
@@ -1839,6 +1900,8 @@ def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, st
|
||||
key_env = str(raw_key_env or "").strip()
|
||||
if key_env:
|
||||
api_key = os.getenv(key_env, "").strip() or None
|
||||
if not api_key and provider_hint:
|
||||
api_key = _lookup_custom_api_key_env(provider_hint)
|
||||
return api_key
|
||||
|
||||
custom_providers = cfg_data.get("custom_providers", [])
|
||||
@@ -1856,7 +1919,7 @@ def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, st
|
||||
continue
|
||||
|
||||
base_url = str(entry.get("base_url") or "").strip() or None
|
||||
api_key = _resolve_key(entry.get("api_key"), entry.get("key_env"))
|
||||
api_key = _resolve_key(entry.get("api_key"), entry.get("key_env"), pid)
|
||||
return api_key, base_url
|
||||
|
||||
# If exactly one custom provider is configured, use it as a pragmatic
|
||||
@@ -1864,7 +1927,7 @@ def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, st
|
||||
if len(custom_providers) == 1 and isinstance(custom_providers[0], dict):
|
||||
entry = custom_providers[0]
|
||||
return (
|
||||
_resolve_key(entry.get("api_key"), entry.get("key_env")),
|
||||
_resolve_key(entry.get("api_key"), entry.get("key_env"), pid),
|
||||
str(entry.get("base_url") or "").strip() or None,
|
||||
)
|
||||
|
||||
@@ -1886,11 +1949,11 @@ def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, st
|
||||
|
||||
fallback_key = None
|
||||
if isinstance(provider_specific, dict):
|
||||
fallback_key = _resolve_key(provider_specific.get("api_key"), provider_specific.get("key_env"))
|
||||
fallback_key = _resolve_key(provider_specific.get("api_key"), provider_specific.get("key_env"), pid)
|
||||
if not fallback_key and isinstance(provider_custom, dict):
|
||||
fallback_key = _resolve_key(provider_custom.get("api_key"), provider_custom.get("key_env"))
|
||||
fallback_key = _resolve_key(provider_custom.get("api_key"), provider_custom.get("key_env"), pid)
|
||||
if not fallback_key and isinstance(model_cfg, dict) and model_provider in {"custom", pid, slug}:
|
||||
fallback_key = _resolve_key(model_cfg.get("api_key"), model_cfg.get("key_env"))
|
||||
fallback_key = _resolve_key(model_cfg.get("api_key"), model_cfg.get("key_env"), pid)
|
||||
|
||||
if fallback_key or fallback_base:
|
||||
return fallback_key, fallback_base or None
|
||||
@@ -2205,11 +2268,45 @@ def _models_cache_file_fingerprint(path: Path) -> dict:
|
||||
return fingerprint
|
||||
|
||||
|
||||
def _models_cache_catalog_fingerprint() -> dict:
|
||||
"""Return non-secret model-catalog identity metadata for cache invalidation.
|
||||
|
||||
The /api/models payload is not only a function of user config/auth files.
|
||||
It also depends on the provider/model catalog baked into this module and on
|
||||
small local catalogs such as Codex's models_cache.json. Keep this cheap and
|
||||
deterministic so a server restart after catalog changes does not keep
|
||||
serving an otherwise-valid persisted models_cache.json until the 24h TTL
|
||||
expires (#2443).
|
||||
"""
|
||||
catalog_payload = {
|
||||
"provider_models": _PROVIDER_MODELS,
|
||||
"provider_display": _PROVIDER_DISPLAY,
|
||||
}
|
||||
try:
|
||||
encoded = json.dumps(
|
||||
catalog_payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
provider_catalog_sha = hashlib.sha256(encoded).hexdigest()
|
||||
except Exception:
|
||||
provider_catalog_sha = "unavailable"
|
||||
|
||||
codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser()
|
||||
return {
|
||||
"provider_catalog_sha256": provider_catalog_sha,
|
||||
"codex_models_cache": _models_cache_file_fingerprint(codex_home / "models_cache.json"),
|
||||
}
|
||||
|
||||
|
||||
def _models_cache_source_fingerprint() -> dict:
|
||||
"""Return the current config/auth-store fingerprint for /api/models cache."""
|
||||
"""Return the current config/auth/catalog fingerprint for /api/models cache."""
|
||||
return {
|
||||
"config_yaml": _models_cache_file_fingerprint(_get_config_path()),
|
||||
"auth_json": _models_cache_file_fingerprint(_get_auth_store_path()),
|
||||
"catalog": _models_cache_catalog_fingerprint(),
|
||||
}
|
||||
|
||||
|
||||
@@ -2957,6 +3054,13 @@ def get_available_models() -> dict:
|
||||
# A user may configure a provider key via config.yaml providers.<name>.api_key
|
||||
# without setting the corresponding env var. (#604)
|
||||
#
|
||||
# Gating: only seed picker groups for keys whose canonical id is known
|
||||
# to ``_PROVIDER_MODELS`` / ``_PROVIDER_DISPLAY``, or whose value is a
|
||||
# dict-shaped provider config (custom/local). Scalar siblings under
|
||||
# ``providers:`` (e.g. ``providers.only_configured: true``) are config
|
||||
# flags, not providers, and must not render as phantom picker groups
|
||||
# like ``Only-Configured`` (#2399).
|
||||
#
|
||||
# Canonicalise the id slug here so a user with ``providers.opencode_go``
|
||||
# (underscore variant) doesn't see TWO provider groups in the picker —
|
||||
# one for the canonical ``opencode-go`` from active_provider detection
|
||||
@@ -2969,13 +3073,24 @@ def get_available_models() -> dict:
|
||||
# provider_cfg values (#2245).
|
||||
_canonical_to_raw_provider_key: dict[str, str] = {}
|
||||
if isinstance(_cfg_providers, dict):
|
||||
for _pid_key in _cfg_providers:
|
||||
for _pid_key, _provider_cfg in _cfg_providers.items():
|
||||
_canonical = _canonicalise_provider_id(_pid_key)
|
||||
if not _canonical:
|
||||
continue
|
||||
|
||||
# See the gating comment on the block above. ``_PROVIDER_MODELS``
|
||||
# / ``_PROVIDER_DISPLAY`` membership accepts known providers and
|
||||
# aliases; ``isinstance(_provider_cfg, dict)`` accepts custom
|
||||
# entries that supply their own models/api_key/base_url. (#2399)
|
||||
_is_known_provider = (
|
||||
_canonical in _PROVIDER_MODELS or _canonical in _PROVIDER_DISPLAY
|
||||
)
|
||||
_is_provider_config = isinstance(_provider_cfg, dict)
|
||||
if not (_is_known_provider or _is_provider_config):
|
||||
continue
|
||||
|
||||
_canonical_to_raw_provider_key.setdefault(_canonical, _pid_key)
|
||||
if _canonical in _PROVIDER_MODELS or _canonical in _cfg_providers or _pid_key in _cfg_providers:
|
||||
detected_providers.add(_canonical)
|
||||
detected_providers.add(_canonical)
|
||||
|
||||
def _configured_provider_for_base_url(base_url: object) -> str:
|
||||
target = _normalize_base_url_for_match(base_url)
|
||||
@@ -3021,101 +3136,72 @@ def get_available_models() -> dict:
|
||||
|
||||
return ""
|
||||
|
||||
# 4. Fetch models from custom endpoint if base_url is configured
|
||||
auto_detected_models = []
|
||||
auto_detected_models_by_provider: dict[str, list[dict]] = {}
|
||||
if cfg_base_url:
|
||||
def _models_endpoint_for_base_url(base_url: str) -> str:
|
||||
base = str(base_url or "").strip().rstrip("/")
|
||||
if base.endswith("/v1"):
|
||||
return base + "/models"
|
||||
return base + "/v1/models"
|
||||
|
||||
def _extract_model_entries_from_payload(data: object, provider: str) -> list[dict]:
|
||||
models_list = []
|
||||
if isinstance(data, dict):
|
||||
if "data" in data and isinstance(data["data"], list):
|
||||
models_list = data["data"]
|
||||
elif "models" in data and isinstance(data["models"], list):
|
||||
models_list = data["models"]
|
||||
models = []
|
||||
seen = set()
|
||||
for model in models_list:
|
||||
if not isinstance(model, dict):
|
||||
continue
|
||||
model_id = (
|
||||
model.get("id", "")
|
||||
or model.get("name", "")
|
||||
or model.get("model", "")
|
||||
)
|
||||
model_name = model.get("name", "") or model.get("model", "") or model_id
|
||||
model_id = str(model_id or "").strip()
|
||||
model_name = str(model_name or "").strip()
|
||||
if not model_id or not model_name or model_id in seen:
|
||||
continue
|
||||
seen.add(model_id)
|
||||
label = _format_ollama_label(model_id) if provider in ("ollama", "ollama-cloud") else model_name
|
||||
models.append({"id": model_id, "label": label})
|
||||
return models
|
||||
|
||||
def _read_custom_endpoint_models(
|
||||
base_url: object,
|
||||
provider: str,
|
||||
*,
|
||||
api_key: object = "",
|
||||
trusted_base_urls: tuple[object, ...] = (),
|
||||
) -> list[dict]:
|
||||
base = str(base_url or "").strip()
|
||||
if not base:
|
||||
return []
|
||||
try:
|
||||
import ipaddress
|
||||
import urllib.request
|
||||
|
||||
base_url = cfg_base_url.strip()
|
||||
if base_url.endswith("/v1"):
|
||||
endpoint_url = base_url + "/models"
|
||||
else:
|
||||
endpoint_url = base_url.rstrip("/") + "/v1/models"
|
||||
|
||||
configured_provider = _configured_provider_for_base_url(base_url)
|
||||
provider = configured_provider or "custom"
|
||||
provider_from_config = bool(configured_provider)
|
||||
parsed = urlparse(base_url if "://" in base_url else f"http://{base_url}")
|
||||
host = (parsed.netloc or parsed.path).lower()
|
||||
|
||||
if parsed.hostname and not provider_from_config:
|
||||
try:
|
||||
addr = ipaddress.ip_address(parsed.hostname)
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
if "ollama" in host or "127.0.0.1" in host or "localhost" in host:
|
||||
provider = "ollama"
|
||||
elif "lmstudio" in host or "lm-studio" in host:
|
||||
provider = "lmstudio"
|
||||
else:
|
||||
# Unknown loopback/private endpoint: route through
|
||||
# the generic ``custom`` provider so the agent's
|
||||
# auxiliary client (compression, vision, web
|
||||
# extraction) takes the OpenAI-compat custom path
|
||||
# with ``no-key-required`` semantics. Writing
|
||||
# ``provider: local`` here used to break
|
||||
# compression mid-conversation because ``local``
|
||||
# is not a registered provider in
|
||||
# ``hermes_cli.auth.PROVIDER_REGISTRY`` — see #1384.
|
||||
provider = "custom"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
headers = {}
|
||||
api_key = ""
|
||||
if isinstance(model_cfg, dict):
|
||||
api_key = (model_cfg.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
if isinstance(providers_cfg, dict):
|
||||
for provider_key in filter(None, [active_provider, "custom"]):
|
||||
provider_cfg = providers_cfg.get(provider_key, {})
|
||||
if isinstance(provider_cfg, dict):
|
||||
api_key = (provider_cfg.get("api_key") or "").strip()
|
||||
if api_key:
|
||||
break
|
||||
if not api_key:
|
||||
api_key_vars = (
|
||||
"HERMES_API_KEY",
|
||||
"HERMES_OPENAI_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"LOCAL_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"API_KEY",
|
||||
)
|
||||
for key in api_key_vars:
|
||||
api_key = (all_env.get(key) or os.getenv(key) or "").strip()
|
||||
if api_key:
|
||||
break
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
import socket
|
||||
|
||||
# Build set of hostnames from custom_providers config — these are
|
||||
# user-explicitly configured endpoints and should not be blocked by SSRF.
|
||||
_ssrf_trusted_hosts: set[str] = set()
|
||||
# Also trust the base_url from model config (explicitly configured by user)
|
||||
if cfg_base_url:
|
||||
_base_parsed = urlparse(cfg_base_url if "://" in cfg_base_url else f"http://{cfg_base_url}")
|
||||
if _base_parsed.hostname:
|
||||
_ssrf_trusted_hosts.add(_base_parsed.hostname.lower())
|
||||
_custom_providers_cfg = cfg.get("custom_providers", [])
|
||||
if isinstance(_custom_providers_cfg, list):
|
||||
for _cp in _custom_providers_cfg:
|
||||
if not isinstance(_cp, dict):
|
||||
continue
|
||||
_cp_base = (_cp.get("base_url") or "").strip()
|
||||
if _cp_base:
|
||||
_cp_parsed = urlparse(_cp_base if "://" in _cp_base else f"http://{_cp_base}")
|
||||
if _cp_parsed.hostname:
|
||||
_ssrf_trusted_hosts.add(_cp_parsed.hostname.lower())
|
||||
endpoint_url = _models_endpoint_for_base_url(base)
|
||||
headers = {}
|
||||
key = str(api_key or "").strip()
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
|
||||
parsed_url = urlparse(
|
||||
endpoint_url if "://" in endpoint_url else f"http://{endpoint_url}"
|
||||
)
|
||||
# User-configured custom provider endpoints are explicitly trusted,
|
||||
# but keep the same private-IP guard for non-matching targets used by
|
||||
# the legacy active model.base_url path.
|
||||
_ssrf_trusted_hosts: set[str] = set()
|
||||
for trusted in (base, *trusted_base_urls):
|
||||
_cp_parsed = urlparse(
|
||||
str(trusted) if "://" in str(trusted) else f"http://{trusted}"
|
||||
)
|
||||
if _cp_parsed.hostname:
|
||||
_ssrf_trusted_hosts.add(_cp_parsed.hostname.lower())
|
||||
|
||||
parsed_url = urlparse(endpoint_url if "://" in endpoint_url else f"http://{endpoint_url}")
|
||||
if parsed_url.scheme not in ("", "http", "https"):
|
||||
raise ValueError(f"Invalid URL scheme: {parsed_url.scheme}")
|
||||
if parsed_url.hostname:
|
||||
@@ -3124,53 +3210,106 @@ def get_available_models() -> dict:
|
||||
for _, _, _, _, addr in resolved_ips:
|
||||
addr_obj = ipaddress.ip_address(addr[0])
|
||||
if addr_obj.is_private or addr_obj.is_loopback or addr_obj.is_link_local:
|
||||
host_l = (parsed_url.hostname or "").lower()
|
||||
is_known_local = any(
|
||||
k in (parsed_url.hostname or "").lower()
|
||||
for k in (
|
||||
"ollama",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"lmstudio",
|
||||
"lm-studio",
|
||||
)
|
||||
) or (parsed_url.hostname or "").lower() in _ssrf_trusted_hosts
|
||||
k in host_l
|
||||
for k in ("ollama", "localhost", "127.0.0.1", "lmstudio", "lm-studio")
|
||||
) or host_l in _ssrf_trusted_hosts
|
||||
if not is_known_local:
|
||||
raise ValueError(
|
||||
f"SSRF: resolved hostname to private IP {addr[0]}"
|
||||
)
|
||||
raise ValueError(f"SSRF: resolved hostname to private IP {addr[0]}")
|
||||
except socket.gaierror:
|
||||
pass
|
||||
|
||||
req = urllib.request.Request(endpoint_url, method="GET")
|
||||
req.add_header("User-Agent", "OpenAI/Python 1.0")
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
with urllib.request.urlopen(req, timeout=10) as response: # nosec B310
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
models_list = []
|
||||
if "data" in data and isinstance(data["data"], list):
|
||||
models_list = data["data"]
|
||||
elif "models" in data and isinstance(data["models"], list):
|
||||
models_list = data["models"]
|
||||
|
||||
for model in models_list:
|
||||
if not isinstance(model, dict):
|
||||
continue
|
||||
model_id = (
|
||||
model.get("id", "")
|
||||
or model.get("name", "")
|
||||
or model.get("model", "")
|
||||
)
|
||||
model_name = model.get("name", "") or model.get("model", "") or model_id
|
||||
if model_id and model_name:
|
||||
label = _format_ollama_label(model_id) if provider in ("ollama", "ollama-cloud") else model_name
|
||||
auto_model = {"id": model_id, "label": label}
|
||||
auto_detected_models.append(auto_model)
|
||||
provider_key = provider.lower()
|
||||
auto_detected_models_by_provider.setdefault(provider_key, []).append(auto_model)
|
||||
detected_providers.add(provider_key)
|
||||
return _extract_model_entries_from_payload(data, provider)
|
||||
except Exception:
|
||||
logger.debug("Custom endpoint unreachable or misconfigured for provider: %s", provider)
|
||||
return []
|
||||
|
||||
# 4. Fetch models from custom endpoint if base_url is configured
|
||||
auto_detected_models = []
|
||||
auto_detected_models_by_provider: dict[str, list[dict]] = {}
|
||||
if cfg_base_url:
|
||||
base_url = cfg_base_url.strip()
|
||||
configured_provider = _configured_provider_for_base_url(base_url)
|
||||
provider = configured_provider or "custom"
|
||||
provider_from_config = bool(configured_provider)
|
||||
parsed = urlparse(base_url if "://" in base_url else f"http://{base_url}")
|
||||
host = (parsed.netloc or parsed.path).lower()
|
||||
|
||||
if parsed.hostname and not provider_from_config:
|
||||
try:
|
||||
import ipaddress
|
||||
|
||||
addr = ipaddress.ip_address(parsed.hostname)
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
if "ollama" in host or "127.0.0.1" in host or "localhost" in host:
|
||||
provider = "ollama"
|
||||
elif "lmstudio" in host or "lm-studio" in host:
|
||||
provider = "lmstudio"
|
||||
else:
|
||||
# Unknown loopback/private endpoint: route through
|
||||
# the generic ``custom`` provider so the agent's
|
||||
# auxiliary client (compression, vision, web
|
||||
# extraction) takes the OpenAI-compat custom path
|
||||
# with ``no-key-required`` semantics. Writing
|
||||
# ``provider: local`` here used to break
|
||||
# compression mid-conversation because ``local``
|
||||
# is not a registered provider in
|
||||
# ``hermes_cli.auth.PROVIDER_REGISTRY`` — see #1384.
|
||||
provider = "custom"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
api_key = ""
|
||||
if isinstance(model_cfg, dict):
|
||||
api_key = (model_cfg.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
if isinstance(providers_cfg, dict):
|
||||
for provider_key in filter(None, [active_provider, "custom"]):
|
||||
provider_cfg = providers_cfg.get(provider_key, {})
|
||||
if isinstance(provider_cfg, dict):
|
||||
api_key = (provider_cfg.get("api_key") or "").strip()
|
||||
if api_key:
|
||||
break
|
||||
if not api_key:
|
||||
api_key_vars = (
|
||||
"HERMES_API_KEY",
|
||||
"HERMES_OPENAI_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"LOCAL_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"API_KEY",
|
||||
)
|
||||
for key in api_key_vars:
|
||||
api_key = (all_env.get(key) or os.getenv(key) or "").strip()
|
||||
if api_key:
|
||||
break
|
||||
|
||||
_trusted_custom_bases: list[object] = [cfg_base_url]
|
||||
_custom_providers_for_trust = cfg.get("custom_providers", [])
|
||||
if isinstance(_custom_providers_for_trust, list):
|
||||
_trusted_custom_bases.extend(
|
||||
_cp.get("base_url")
|
||||
for _cp in _custom_providers_for_trust
|
||||
if isinstance(_cp, dict) and _cp.get("base_url")
|
||||
)
|
||||
for auto_model in _read_custom_endpoint_models(
|
||||
base_url,
|
||||
provider,
|
||||
api_key=api_key,
|
||||
trusted_base_urls=tuple(_trusted_custom_bases),
|
||||
):
|
||||
auto_detected_models.append(auto_model)
|
||||
provider_key = provider.lower()
|
||||
auto_detected_models_by_provider.setdefault(provider_key, []).append(auto_model)
|
||||
detected_providers.add(provider_key)
|
||||
|
||||
_custom_providers_cfg = cfg.get("custom_providers", [])
|
||||
_named_custom_groups: dict = {}
|
||||
@@ -3184,7 +3323,36 @@ def get_available_models() -> dict:
|
||||
if _slug and _slug not in _named_custom_groups:
|
||||
_named_custom_groups[_slug] = (_cp_name, [])
|
||||
|
||||
# Collect model IDs: singular "model" field first, then "models" dict keys
|
||||
_cp_base_url = str(_cp.get("base_url") or "").strip()
|
||||
if _slug and _cp_base_url:
|
||||
_cp_api_key = str(_cp.get("api_key") or "").strip()
|
||||
if not _cp_api_key:
|
||||
_cp_key_env = str(_cp.get("key_env") or "").strip()
|
||||
if _cp_key_env:
|
||||
_cp_api_key = str(os.getenv(_cp_key_env) or "").strip()
|
||||
_live_models = auto_detected_models_by_provider.get(_slug) or _read_custom_endpoint_models(
|
||||
_cp_base_url,
|
||||
_slug,
|
||||
api_key=_cp_api_key,
|
||||
trusted_base_urls=(_cp_base_url,),
|
||||
)
|
||||
for _live_model in _live_models:
|
||||
_live_id = str(_live_model.get("id") or "").strip()
|
||||
if not _live_id:
|
||||
continue
|
||||
_dedup_key = f"{_slug}:{_live_id}"
|
||||
if _dedup_key in _seen_custom_ids:
|
||||
continue
|
||||
_seen_custom_ids.add(_dedup_key)
|
||||
detected_providers.add(_slug)
|
||||
_cp_option_id = _live_id
|
||||
if active_provider != _slug and not _cp_option_id.startswith("@"):
|
||||
_cp_option_id = f"@{_slug}:{_cp_option_id}"
|
||||
_named_custom_groups[_slug][1].append(
|
||||
{"id": _cp_option_id, "label": _live_model.get("label") or _get_label_for_model(_live_id, [])}
|
||||
)
|
||||
|
||||
# Collect configured model IDs as a fallback/sticky entry after live discovery.
|
||||
_cp_model_ids: list[str] = []
|
||||
_cp_model = _cp.get("model", "")
|
||||
if _cp_model:
|
||||
@@ -3688,6 +3856,18 @@ def get_available_models() -> dict:
|
||||
"models": models_for_group,
|
||||
}
|
||||
)
|
||||
elif pid == "custom" and cfg_base_url:
|
||||
# Anonymous custom endpoint: /v1/models probe may have
|
||||
# failed (e.g. llama-server, lightweight relay), but the
|
||||
# chat endpoint itself may still work. Add the group
|
||||
# with an empty model list so the user can type a model
|
||||
# ID manually rather than being blocked by a silent
|
||||
# probe failure (#2542).
|
||||
groups.append({
|
||||
"provider": provider_name,
|
||||
"provider_id": pid,
|
||||
"models": [],
|
||||
})
|
||||
else:
|
||||
if default_model:
|
||||
label = _get_label_for_model(default_model, groups)
|
||||
@@ -3759,11 +3939,21 @@ def get_available_models() -> dict:
|
||||
or (g.get("provider_id") or "").startswith("custom:")
|
||||
]
|
||||
|
||||
# 12. Include model aliases so the WebUI frontend can resolve them.
|
||||
model_aliases: dict[str, str] = {}
|
||||
try:
|
||||
raw_aliases = cfg.get("model", {}).get("aliases", {})
|
||||
if isinstance(raw_aliases, dict):
|
||||
model_aliases = {str(k).strip(): str(v).strip() for k, v in raw_aliases.items() if k and v}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"active_provider": active_provider,
|
||||
"default_model": default_model,
|
||||
"configured_model_badges": _build_configured_model_badges(),
|
||||
"groups": groups,
|
||||
"aliases": model_aliases,
|
||||
}
|
||||
|
||||
# ── FAST PATH ─────────────────────────────────────────────────────────────
|
||||
@@ -3965,9 +4155,38 @@ SESSION_AGENT_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _evict_session_agent(session_id: str) -> None:
|
||||
"""Remove a cached agent for a session (on delete, clear, or model switch)."""
|
||||
"""Remove a cached agent for a session (on delete, clear, or model switch).
|
||||
|
||||
Attempts a lifecycle commit before dropping the agent handle so that
|
||||
batch-extraction memory providers can extract any pending work. If the
|
||||
commit fails or there is uncommitted work with no successful commit, the
|
||||
lifecycle entry is preserved (not unregistered) so a future commit can
|
||||
retry.
|
||||
"""
|
||||
agent = None
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
SESSION_AGENT_CACHE.pop(session_id, None)
|
||||
entry = SESSION_AGENT_CACHE.pop(session_id, None)
|
||||
if entry is not None:
|
||||
agent = entry[0] if isinstance(entry, tuple) else None
|
||||
if agent is None:
|
||||
return
|
||||
should_close = True
|
||||
try:
|
||||
from api.session_lifecycle import commit_session_memory, has_uncommitted_work, unregister_agent
|
||||
if has_uncommitted_work(session_id):
|
||||
commit_session_memory(session_id, agent=agent, wait=True)
|
||||
if not has_uncommitted_work(session_id):
|
||||
unregister_agent(session_id)
|
||||
else:
|
||||
should_close = False
|
||||
except Exception:
|
||||
should_close = False
|
||||
logger.debug("Lifecycle commit on eviction failed for %s", session_id, exc_info=True)
|
||||
if should_close and getattr(agent, '_session_db', None) is not None:
|
||||
try:
|
||||
agent._session_db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close _session_db on eviction for %s", session_id, exc_info=True)
|
||||
|
||||
# ── Thread-local env context ─────────────────────────────────────────────────
|
||||
_thread_ctx = threading.local()
|
||||
@@ -4019,14 +4238,16 @@ _SETTINGS_DEFAULTS = {
|
||||
"onboarding_completed": False,
|
||||
"send_key": "enter", # 'enter' or 'ctrl+enter'
|
||||
"show_token_usage": False, # show input/output token badge below assistant messages
|
||||
"show_quota_chip": False, # show ambient provider quota chip in composer footer (default off; wide desktop only when enabled, see style.css @media)
|
||||
"show_tps": False, # show tokens-per-second chip in assistant message headers
|
||||
"fade_text_effect": False, # animate newly streamed words with a lightweight fade-in effect
|
||||
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
|
||||
"show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments
|
||||
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
|
||||
"check_for_updates": True, # check if webui/agent repos are behind upstream
|
||||
"whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links
|
||||
"theme": "dark", # light | dark | system
|
||||
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard
|
||||
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous
|
||||
"font_size": "default", # small | default | large | xlarge
|
||||
"session_jump_buttons": False, # show Start/End transcript jump pills
|
||||
"session_endless_scroll": False, # auto-load older transcript pages while scrolling upward
|
||||
@@ -4035,9 +4256,10 @@ _SETTINGS_DEFAULTS = {
|
||||
"HERMES_WEBUI_BOT_NAME", "Hermes"
|
||||
), # display name for the assistant
|
||||
"sound_enabled": False, # play notification sound when assistant finishes
|
||||
"rtl": False, # right-to-left chat layout (chat messages + composer only)
|
||||
"notifications_enabled": False, # browser notification when tab is in background
|
||||
"show_thinking": True, # show/hide thinking/reasoning blocks in chat view
|
||||
"simplified_tool_calling": True, # group tools/thinking into one quiet activity disclosure
|
||||
"simplified_tool_calling": True, # render tools/thinking as compact inline timeline activity
|
||||
"api_redact_enabled": True, # redact sensitive data (API keys, secrets) from API responses
|
||||
"sidebar_density": "compact", # compact | detailed
|
||||
"auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges
|
||||
@@ -4055,6 +4277,7 @@ _SETTINGS_SKIN_VALUES = {
|
||||
"sisyphus",
|
||||
"charizard",
|
||||
"sienna",
|
||||
"catppuccin",
|
||||
"nous",
|
||||
}
|
||||
_SETTINGS_LEGACY_THEME_MAP = {
|
||||
@@ -4151,13 +4374,16 @@ _SETTINGS_ENUM_VALUES = {
|
||||
_SETTINGS_BOOL_KEYS = {
|
||||
"onboarding_completed",
|
||||
"show_token_usage",
|
||||
"show_quota_chip",
|
||||
"show_tps",
|
||||
"fade_text_effect",
|
||||
"show_cli_sessions",
|
||||
"show_previous_messaging_sessions",
|
||||
"sync_to_insights",
|
||||
"check_for_updates",
|
||||
"whats_new_summary_enabled",
|
||||
"sound_enabled",
|
||||
"rtl",
|
||||
"notifications_enabled",
|
||||
"show_thinking",
|
||||
"simplified_tool_calling",
|
||||
|
||||
@@ -365,6 +365,7 @@ class Session:
|
||||
tool_calls=None, pinned: bool=False, archived: bool=False,
|
||||
project_id: str=None, profile=None,
|
||||
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
||||
cache_read_tokens: int=0, cache_write_tokens: int=0,
|
||||
personality=None,
|
||||
active_stream_id: str=None,
|
||||
pending_user_message: str=None,
|
||||
@@ -403,6 +404,8 @@ class Session:
|
||||
self.input_tokens = input_tokens or 0
|
||||
self.output_tokens = output_tokens or 0
|
||||
self.estimated_cost = estimated_cost
|
||||
self.cache_read_tokens = cache_read_tokens or 0
|
||||
self.cache_write_tokens = cache_write_tokens or 0
|
||||
self.personality = personality
|
||||
self.active_stream_id = active_stream_id
|
||||
self.pending_user_message = pending_user_message
|
||||
@@ -465,6 +468,7 @@ class Session:
|
||||
'session_id', 'title', 'workspace', 'model', 'model_provider', 'created_at', 'updated_at',
|
||||
'pinned', 'archived', 'project_id', 'profile',
|
||||
'input_tokens', 'output_tokens', 'estimated_cost',
|
||||
'cache_read_tokens', 'cache_write_tokens',
|
||||
'personality', 'active_stream_id',
|
||||
'pending_user_message', 'pending_attachments', 'pending_started_at',
|
||||
'compression_anchor_visible_idx', 'compression_anchor_message_key',
|
||||
@@ -628,6 +632,8 @@ class Session:
|
||||
'input_tokens': self.input_tokens,
|
||||
'output_tokens': self.output_tokens,
|
||||
'estimated_cost': self.estimated_cost,
|
||||
'cache_read_tokens': self.cache_read_tokens,
|
||||
'cache_write_tokens': self.cache_write_tokens,
|
||||
'personality': self.personality,
|
||||
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
|
||||
'compression_anchor_message_key': self.compression_anchor_message_key,
|
||||
@@ -679,12 +685,266 @@ def _get_profile_home(profile) -> Path:
|
||||
return Path(os.environ.get('HERMES_HOME') or '~/.hermes').expanduser()
|
||||
|
||||
|
||||
def _interrupted_recovery_marker(*, recovered_output: bool = False) -> dict:
|
||||
if recovered_output:
|
||||
content = (
|
||||
'**Response interrupted.**\n\n'
|
||||
'The WebUI process restarted before this turn finished. '
|
||||
'The partial output above was recovered from the run journal, '
|
||||
'but the interrupted agent process could not continue.'
|
||||
)
|
||||
else:
|
||||
content = (
|
||||
'**Response interrupted.**\n\n'
|
||||
'The WebUI process restarted before this turn finished. '
|
||||
'The user message above was preserved, but no agent output was recovered.'
|
||||
)
|
||||
return {
|
||||
'role': 'assistant',
|
||||
'content': content,
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
'type': 'interrupted',
|
||||
}
|
||||
|
||||
|
||||
def _truncate_journal_tool_args(args, limit: int = 4) -> dict:
|
||||
if not isinstance(args, dict):
|
||||
return {}
|
||||
out = {}
|
||||
for key, value in list(args.items())[:limit]:
|
||||
text = str(value)
|
||||
out[str(key)] = text[:120] + ('...' if len(text) > 120 else '')
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_journal_recovery_text(value) -> str:
|
||||
return " ".join(str(value or "").split())
|
||||
|
||||
|
||||
def _find_existing_assistant_for_journal_content(session, content: str) -> int | None:
|
||||
candidate = _normalize_journal_recovery_text(content)
|
||||
if not candidate:
|
||||
return None
|
||||
for idx, message in enumerate(session.messages or []):
|
||||
if not isinstance(message, dict) or message.get('role') != 'assistant':
|
||||
continue
|
||||
if message.get('_error'):
|
||||
continue
|
||||
existing = _normalize_journal_recovery_text(message.get('content'))
|
||||
if not existing:
|
||||
continue
|
||||
if existing == candidate:
|
||||
return idx
|
||||
if len(candidate) >= 24 and candidate in existing:
|
||||
return idx
|
||||
return None
|
||||
|
||||
|
||||
def _journal_tool_already_present(session, name: str, preview: str) -> bool:
|
||||
candidate_name = str(name or '')
|
||||
candidate_preview = _normalize_journal_recovery_text(preview)
|
||||
for tool_call in session.tool_calls or []:
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
if str(tool_call.get('name') or '') != candidate_name:
|
||||
continue
|
||||
existing_preview = _normalize_journal_recovery_text(
|
||||
tool_call.get('preview') or tool_call.get('snippet') or ''
|
||||
)
|
||||
if existing_preview == candidate_preview:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _run_journal_has_visible_output(session, stream_id: str | None) -> bool:
|
||||
if not stream_id:
|
||||
return False
|
||||
try:
|
||||
from api.run_journal import read_run_events
|
||||
journal = read_run_events(session.session_id, stream_id)
|
||||
except Exception:
|
||||
return False
|
||||
for event in journal.get('events') or []:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
event_name = str(event.get('event') or event.get('type') or '')
|
||||
payload = event.get('payload') if isinstance(event.get('payload'), dict) else {}
|
||||
if event_name == 'token' and str(payload.get('text') or ''):
|
||||
return True
|
||||
if event_name == 'interim_assistant':
|
||||
if payload.get('already_streamed'):
|
||||
continue
|
||||
if str(payload.get('text') or '').strip():
|
||||
return True
|
||||
if event_name == 'tool':
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _append_journaled_partial_output(
|
||||
session,
|
||||
stream_id: str | None,
|
||||
*,
|
||||
dedupe_existing: bool = False,
|
||||
) -> bool:
|
||||
"""Recover already-emitted visible output from a dead stream journal.
|
||||
|
||||
This repair path is intentionally conservative: it restores user-visible
|
||||
assistant text and tool-card metadata that had already been emitted over
|
||||
SSE before the WebUI process died. It does not restore hidden reasoning and
|
||||
it does not try to continue execution.
|
||||
"""
|
||||
if not stream_id:
|
||||
return False
|
||||
|
||||
try:
|
||||
from api.run_journal import read_run_events
|
||||
journal = read_run_events(session.session_id, stream_id)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Session %s: failed to read run journal for stream %s",
|
||||
getattr(session, 'session_id', '?'),
|
||||
stream_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
events = [event for event in journal.get('events') or [] if isinstance(event, dict)]
|
||||
if not events:
|
||||
return False
|
||||
|
||||
appended_any = False
|
||||
assistant_parts: list[str] = []
|
||||
assistant_started_at: float | None = None
|
||||
current_assistant_idx: int | None = None
|
||||
recovered_tool_calls: list[dict] = []
|
||||
|
||||
def flush_assistant() -> int | None:
|
||||
nonlocal appended_any, assistant_parts, assistant_started_at, current_assistant_idx
|
||||
content = ''.join(assistant_parts).strip()
|
||||
assistant_parts = []
|
||||
if not content:
|
||||
return current_assistant_idx
|
||||
if dedupe_existing:
|
||||
existing_idx = _find_existing_assistant_for_journal_content(session, content)
|
||||
if existing_idx is not None:
|
||||
current_assistant_idx = existing_idx
|
||||
assistant_started_at = None
|
||||
return existing_idx
|
||||
timestamp = int(assistant_started_at or time.time())
|
||||
session.messages.append({
|
||||
'role': 'assistant',
|
||||
'content': content,
|
||||
'timestamp': timestamp,
|
||||
'_recovered_from_run_journal': True,
|
||||
'_recovered_stream_id': stream_id,
|
||||
})
|
||||
current_assistant_idx = len(session.messages) - 1
|
||||
assistant_started_at = None
|
||||
appended_any = True
|
||||
return current_assistant_idx
|
||||
|
||||
def ensure_assistant_anchor(created_at: float | None = None) -> int:
|
||||
nonlocal appended_any, current_assistant_idx
|
||||
idx = flush_assistant()
|
||||
if idx is not None:
|
||||
return idx
|
||||
# A stream can start with tools before any text. Keep those tools
|
||||
# visible after restart with an empty recovered assistant anchor instead
|
||||
# of inventing synthetic progress prose.
|
||||
session.messages.append({
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'timestamp': int(created_at or time.time()),
|
||||
'_recovered_from_run_journal': True,
|
||||
'_recovered_stream_id': stream_id,
|
||||
})
|
||||
current_assistant_idx = len(session.messages) - 1
|
||||
appended_any = True
|
||||
return current_assistant_idx
|
||||
|
||||
for event in events:
|
||||
event_name = str(event.get('event') or event.get('type') or '')
|
||||
payload = event.get('payload') if isinstance(event.get('payload'), dict) else {}
|
||||
created_at = event.get('created_at') if isinstance(event.get('created_at'), (int, float)) else None
|
||||
if event_name == 'token':
|
||||
text = str(payload.get('text') or '')
|
||||
if not text:
|
||||
continue
|
||||
if not assistant_parts and assistant_started_at is None:
|
||||
assistant_started_at = created_at or time.time()
|
||||
assistant_parts.append(text)
|
||||
continue
|
||||
if event_name == 'interim_assistant':
|
||||
if payload.get('already_streamed'):
|
||||
flush_assistant()
|
||||
continue
|
||||
text = str(payload.get('text') or '').strip()
|
||||
if not text:
|
||||
continue
|
||||
if not assistant_parts and assistant_started_at is None:
|
||||
assistant_started_at = created_at or time.time()
|
||||
if assistant_parts and not ''.join(assistant_parts).endswith(('\n', ' ')):
|
||||
assistant_parts.append('\n\n')
|
||||
assistant_parts.append(text)
|
||||
flush_assistant()
|
||||
continue
|
||||
if event_name == 'tool':
|
||||
anchor_idx = flush_assistant()
|
||||
if anchor_idx is None:
|
||||
anchor_idx = ensure_assistant_anchor(created_at)
|
||||
name = str(payload.get('name') or 'tool')
|
||||
preview = str(payload.get('preview') or '')
|
||||
if dedupe_existing and _journal_tool_already_present(session, name, preview):
|
||||
current_assistant_idx = anchor_idx
|
||||
continue
|
||||
recovered_tool_calls.append({
|
||||
'name': name,
|
||||
'preview': preview,
|
||||
'snippet': preview,
|
||||
'tid': f"journal-{event.get('seq') or len(recovered_tool_calls) + 1}",
|
||||
'assistant_msg_idx': anchor_idx,
|
||||
'args': _truncate_journal_tool_args(payload.get('args') or {}),
|
||||
'done': False,
|
||||
'_recovered_from_run_journal': True,
|
||||
'_recovered_stream_id': stream_id,
|
||||
})
|
||||
appended_any = True
|
||||
current_assistant_idx = anchor_idx
|
||||
continue
|
||||
if event_name == 'tool_complete':
|
||||
name = str(payload.get('name') or '')
|
||||
for tool_call in reversed(recovered_tool_calls):
|
||||
if tool_call.get('done'):
|
||||
continue
|
||||
if not name or tool_call.get('name') == name:
|
||||
tool_call['done'] = True
|
||||
if payload.get('preview'):
|
||||
tool_call['preview'] = str(payload.get('preview') or '')
|
||||
tool_call['snippet'] = str(payload.get('preview') or '')
|
||||
if payload.get('duration') is not None:
|
||||
tool_call['duration'] = payload.get('duration')
|
||||
tool_call['is_error'] = bool(payload.get('is_error', False))
|
||||
break
|
||||
continue
|
||||
if event_name in {'done', 'stream_end', 'cancel', 'apperror', 'error'}:
|
||||
flush_assistant()
|
||||
|
||||
flush_assistant()
|
||||
if recovered_tool_calls:
|
||||
session.tool_calls = list(session.tool_calls or []) + recovered_tool_calls
|
||||
appended_any = True
|
||||
return appended_any
|
||||
|
||||
|
||||
def _apply_core_sync_or_error_marker(
|
||||
session,
|
||||
core_path,
|
||||
stream_id_for_recheck=None,
|
||||
*,
|
||||
require_stream_dead=True,
|
||||
touch_updated_at=True,
|
||||
) -> bool:
|
||||
"""Inner repair logic. Must be called with the per-session lock already held.
|
||||
|
||||
@@ -741,17 +1001,16 @@ def _apply_core_sync_or_error_marker(
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
_append_recovered_turn_to_context(session, recovered)
|
||||
recovered_output = _append_journaled_partial_output(
|
||||
session,
|
||||
stream_id_for_recheck or session.active_stream_id,
|
||||
)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
session.pending_started_at = None
|
||||
session.messages.append({
|
||||
'role': 'assistant',
|
||||
'content': '**Previous turn did not complete.**',
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
})
|
||||
session.save()
|
||||
session.messages.append(_interrupted_recovery_marker(recovered_output=recovered_output))
|
||||
session.save(touch_updated_at=touch_updated_at)
|
||||
logger.info(
|
||||
"Session %s: recovered pending user turn (messages non-empty), added error marker",
|
||||
sid,
|
||||
@@ -765,19 +1024,48 @@ def _apply_core_sync_or_error_marker(
|
||||
core = json.load(f)
|
||||
core_messages = core.get('messages', [])
|
||||
if core_messages:
|
||||
_stream_id = stream_id_for_recheck or session.active_stream_id
|
||||
session.messages = core_messages
|
||||
session.tool_calls = core.get('tool_calls', [])
|
||||
for field in ('input_tokens', 'output_tokens', 'estimated_cost'):
|
||||
if core.get(field) is not None:
|
||||
setattr(session, field, core[field])
|
||||
_pending_text = _normalize_journal_recovery_text(session.pending_user_message)
|
||||
_already_checkpointed = False
|
||||
if _pending_text and session.messages:
|
||||
for _last_msg in reversed(session.messages):
|
||||
if isinstance(_last_msg, dict) and _last_msg.get('role') == 'user':
|
||||
_last_text = _normalize_journal_recovery_text(_last_msg.get('content'))
|
||||
_already_checkpointed = _last_text == _pending_text
|
||||
break
|
||||
if (
|
||||
_pending_text
|
||||
and not _already_checkpointed
|
||||
and _run_journal_has_visible_output(session, _stream_id)
|
||||
):
|
||||
_recovered_ts = int(time.time())
|
||||
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
|
||||
_recovered_ts = int(session.pending_started_at)
|
||||
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
|
||||
recovered_output = _append_journaled_partial_output(
|
||||
session,
|
||||
_stream_id,
|
||||
dedupe_existing=True,
|
||||
)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
session.pending_started_at = None
|
||||
session.save()
|
||||
if recovered_output:
|
||||
session.messages.append(
|
||||
_interrupted_recovery_marker(recovered_output=True)
|
||||
)
|
||||
session.save(touch_updated_at=touch_updated_at)
|
||||
logger.info(
|
||||
"Session %s: synced %d messages from core transcript",
|
||||
sid, len(core_messages),
|
||||
"Session %s: synced %d messages from core transcript%s",
|
||||
sid,
|
||||
len(core_messages),
|
||||
" and recovered journaled output" if recovered_output else "",
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -790,17 +1078,16 @@ def _apply_core_sync_or_error_marker(
|
||||
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
|
||||
_recovered_ts = int(session.pending_started_at)
|
||||
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
|
||||
recovered_output = _append_journaled_partial_output(
|
||||
session,
|
||||
stream_id_for_recheck or session.active_stream_id,
|
||||
)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
session.pending_started_at = None
|
||||
session.messages.append({
|
||||
'role': 'assistant',
|
||||
'content': '**Previous turn did not complete.**',
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
})
|
||||
session.save()
|
||||
session.messages.append(_interrupted_recovery_marker(recovered_output=recovered_output))
|
||||
session.save(touch_updated_at=touch_updated_at)
|
||||
logger.info("Session %s: no core transcript found, added error marker", sid)
|
||||
return True
|
||||
|
||||
@@ -811,7 +1098,7 @@ def _apply_core_sync_or_error_marker(
|
||||
# pending_user_message and STREAMS.pop(stream_id). Without this guard, any
|
||||
# fast turn (e.g. command approval) that exits the thread before the on-disk
|
||||
# pending clear has flushed gets misdiagnosed as a crashed turn, producing a
|
||||
# spurious "Previous turn did not complete." marker.
|
||||
# spurious "Response interrupted." marker.
|
||||
#
|
||||
# 30s covers the worst-case post-loop persistence window: LLM finishing a tool
|
||||
# batch + lock contention with the checkpoint thread + a multi-MB session.save.
|
||||
@@ -1012,7 +1299,110 @@ def _hide_from_default_sidebar(session: dict) -> bool:
|
||||
"""Return True for internal/background sessions hidden from the default list."""
|
||||
sid = str(session.get('session_id') or '')
|
||||
source = session.get('source_tag') or session.get('source')
|
||||
return bool(session.get('pre_compression_snapshot')) or source == 'cron' or sid.startswith('cron_')
|
||||
if source == 'cron' or sid.startswith('cron_'):
|
||||
return True
|
||||
if bool(session.get('pre_compression_snapshot')):
|
||||
return not bool(session.get('_show_pre_compression_snapshot'))
|
||||
return False
|
||||
|
||||
|
||||
def _sidebar_message_count(session: dict) -> int:
|
||||
for key in ('message_count', 'actual_message_count'):
|
||||
try:
|
||||
value = int(session.get(key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
value = 0
|
||||
if value > 0:
|
||||
return value
|
||||
return 0
|
||||
|
||||
|
||||
def _sidebar_lineage_root_id(session: dict, sessions_by_id: dict[str, dict]) -> str:
|
||||
sid = str(session.get('session_id') or '')
|
||||
root = sid
|
||||
parent = session.get('parent_session_id')
|
||||
seen = {sid}
|
||||
while parent and parent not in seen and parent in sessions_by_id:
|
||||
root = str(parent)
|
||||
seen.add(root)
|
||||
parent = sessions_by_id.get(root, {}).get('parent_session_id')
|
||||
return root
|
||||
|
||||
|
||||
def _has_live_sidebar_state(session: dict) -> bool:
|
||||
return bool(
|
||||
session.get('active_stream_id')
|
||||
or session.get('has_pending_user_message')
|
||||
or session.get('pending_user_message')
|
||||
)
|
||||
|
||||
|
||||
def _prefer_fuller_snapshots_for_sidebar(sessions: list[dict]) -> list[dict]:
|
||||
"""Expose a hidden snapshot when it is the fuller transcript for a lineage.
|
||||
|
||||
Pre-compression snapshots are normally hidden so archived compression
|
||||
segments do not duplicate the current continuation in the sidebar. If a
|
||||
snapshot row has more messages than the visible continuation for the same
|
||||
lineage, hiding it makes the conversation look truncated. In that case,
|
||||
show the fuller snapshot and suppress the shorter inactive continuation.
|
||||
"""
|
||||
sessions_by_id = {
|
||||
str(session.get('session_id')): session
|
||||
for session in sessions
|
||||
if session.get('session_id')
|
||||
}
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for session in sessions:
|
||||
sid = str(session.get('session_id') or '')
|
||||
source = session.get('source_tag') or session.get('source')
|
||||
if source == 'cron' or sid.startswith('cron_'):
|
||||
continue
|
||||
root = _sidebar_lineage_root_id(session, sessions_by_id)
|
||||
groups.setdefault(root, []).append(session)
|
||||
|
||||
snapshot_ids_to_show: set[str] = set()
|
||||
continuation_ids_to_hide: set[str] = set()
|
||||
for group in groups.values():
|
||||
visible = [session for session in group if not session.get('pre_compression_snapshot')]
|
||||
snapshots = [session for session in group if session.get('pre_compression_snapshot')]
|
||||
if not visible or not snapshots:
|
||||
continue
|
||||
if any(_has_live_sidebar_state(session) for session in visible):
|
||||
continue
|
||||
|
||||
best_visible_count = max(_sidebar_message_count(session) for session in visible)
|
||||
best_snapshot = max(
|
||||
snapshots,
|
||||
key=lambda session: (_sidebar_message_count(session), _session_sort_timestamp(session)),
|
||||
)
|
||||
if _sidebar_message_count(best_snapshot) <= best_visible_count:
|
||||
continue
|
||||
|
||||
snapshot_ids_to_show.add(str(best_snapshot.get('session_id')))
|
||||
continuation_ids_to_hide.update(
|
||||
str(session.get('session_id'))
|
||||
for session in visible
|
||||
if session.get('session_id')
|
||||
)
|
||||
|
||||
if not snapshot_ids_to_show and not continuation_ids_to_hide:
|
||||
return sessions
|
||||
|
||||
out = []
|
||||
for session in sessions:
|
||||
sid = str(session.get('session_id') or '')
|
||||
if sid in continuation_ids_to_hide:
|
||||
continue
|
||||
if sid in snapshot_ids_to_show:
|
||||
session = dict(session)
|
||||
session['_show_pre_compression_snapshot'] = True
|
||||
out.append(session)
|
||||
return out
|
||||
|
||||
|
||||
def _strip_sidebar_internal_flags(sessions: list[dict]) -> None:
|
||||
for session in sessions:
|
||||
session.pop('_show_pre_compression_snapshot', None)
|
||||
|
||||
|
||||
def _active_state_db_path() -> Path:
|
||||
@@ -1131,7 +1521,9 @@ def all_sessions(diag=None):
|
||||
and not s.get('has_pending_user_message')
|
||||
and not s.get('worktree_path')
|
||||
)]
|
||||
result = _prefer_fuller_snapshots_for_sidebar(result)
|
||||
result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
_strip_sidebar_internal_flags(result)
|
||||
# Backfill: sessions created before Sprint 22 have no profile tag.
|
||||
# Attribute them to 'default' so the client profile filter works correctly.
|
||||
for s in result:
|
||||
@@ -1167,7 +1559,9 @@ def all_sessions(diag=None):
|
||||
and not s.pending_user_message
|
||||
and not getattr(s, 'worktree_path', None)
|
||||
)]
|
||||
result = _prefer_fuller_snapshots_for_sidebar(result)
|
||||
result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
_strip_sidebar_internal_flags(result)
|
||||
for s in result:
|
||||
if not s.get('profile'):
|
||||
s['profile'] = 'default'
|
||||
|
||||
@@ -697,7 +697,8 @@ def profile_env_for_background_worker(
|
||||
return
|
||||
|
||||
try:
|
||||
# Lazy import avoids a module-load cycle: streaming imports this helper.
|
||||
# Lazy imports avoid a module-load cycle: streaming imports this helper.
|
||||
from api.config import _clear_thread_env, _set_thread_env, _thread_ctx
|
||||
from api.streaming import _ENV_LOCK
|
||||
|
||||
profile_home_path = Path(get_hermes_home_for_profile(profile))
|
||||
@@ -712,21 +713,24 @@ def profile_env_for_background_worker(
|
||||
yield
|
||||
return
|
||||
|
||||
env_keys = set(runtime_env.keys()) | {"HERMES_HOME"}
|
||||
# Stage-360 maintainer fix: narrow the _ENV_LOCK critical section to just
|
||||
# the env mutation (and the env restoration). Pre-fix, this held _ENV_LOCK
|
||||
# for the entire `yield` duration — i.e. the whole background worker's
|
||||
# runtime (title generation, compression, update summary). That caused
|
||||
# _ENV_LOCK to be held for many seconds, blocking ALL other sessions and
|
||||
# surfacing as the QA `test_third_message_completes` timeout. The fix
|
||||
# mirrors the narrow-lock pattern in _run_agent_streaming: acquire briefly
|
||||
# to set env, run worker without holding the lock, reacquire to restore.
|
||||
# See also QA `test_finally_restores_env_with_lock`.
|
||||
thread_env = dict(runtime_env)
|
||||
thread_env["HERMES_HOME"] = str(profile_home_path)
|
||||
# Hybrid profile routing: keep the broad runtime env in WebUI's thread-local
|
||||
# channel for WebUI helpers, and also mirror it into process env for the
|
||||
# worker body because several production Hermes readers still call
|
||||
# os.getenv() directly for provider credentials. Keep the _ENV_LOCK scope
|
||||
# narrow: serialize only setup/restore, not the whole worker body.
|
||||
skill_home_snapshot = None
|
||||
old_env = {}
|
||||
old_runtime_env: dict[str, Optional[str]] = {}
|
||||
old_hermes_home = None
|
||||
had_hermes_home = False
|
||||
previous_thread_env = getattr(_thread_ctx, "env", {}).copy()
|
||||
try:
|
||||
_set_thread_env(**thread_env)
|
||||
with _ENV_LOCK:
|
||||
old_env = {key: os.environ.get(key) for key in env_keys}
|
||||
old_runtime_env = {key: os.environ.get(key) for key in runtime_env}
|
||||
had_hermes_home = "HERMES_HOME" in os.environ
|
||||
old_hermes_home = os.environ.get("HERMES_HOME")
|
||||
skill_home_snapshot = snapshot_skill_home_modules()
|
||||
os.environ.update(runtime_env)
|
||||
os.environ["HERMES_HOME"] = str(profile_home_path)
|
||||
@@ -741,12 +745,20 @@ def profile_env_for_background_worker(
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
if previous_thread_env:
|
||||
_set_thread_env(**previous_thread_env)
|
||||
else:
|
||||
_clear_thread_env()
|
||||
with _ENV_LOCK:
|
||||
for key, old_value in old_env.items():
|
||||
for key, old_value in old_runtime_env.items():
|
||||
if old_value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = old_value
|
||||
if had_hermes_home:
|
||||
os.environ["HERMES_HOME"] = old_hermes_home or ""
|
||||
else:
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
if skill_home_snapshot is not None:
|
||||
restore_skill_home_modules(skill_home_snapshot)
|
||||
|
||||
|
||||
@@ -19,11 +19,17 @@ import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
try: # POSIX-only; Windows-style environments fall back to process-local locking.
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - exercised only where fcntl is unavailable
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
from api.config import (
|
||||
_PROVIDER_DISPLAY,
|
||||
_PROVIDER_MODELS,
|
||||
@@ -686,6 +692,7 @@ _OAUTH_PROVIDERS = frozenset({
|
||||
"nous",
|
||||
"openai-codex",
|
||||
"qwen-oauth",
|
||||
"xai-oauth",
|
||||
})
|
||||
|
||||
# SECTION: Helper functions
|
||||
@@ -1441,6 +1448,285 @@ def _provider_is_oauth(provider_id: str) -> bool:
|
||||
return provider_id in _OAUTH_PROVIDERS
|
||||
|
||||
|
||||
# ── OpenRouter cost-history snapshot helpers (#692) ──────────────────────────
|
||||
|
||||
_COST_SNAPSHOTS_DIR_NAME = "cost-snapshots"
|
||||
_COST_SNAPSHOT_MAX_DAYS = 365 # hard cap to prevent unbounded growth
|
||||
_COST_SNAPSHOT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _cost_snapshots_dir() -> Path:
|
||||
"""Return the directory for cost-snapshot JSON files.
|
||||
|
||||
Uses the Hermes home directory (profile-aware) so snapshots are
|
||||
isolated per profile, matching the existing STATE_DIR convention.
|
||||
"""
|
||||
return _get_hermes_home() / _COST_SNAPSHOTS_DIR_NAME
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _cost_snapshot_file_lock(provider: str):
|
||||
"""Serialize cost snapshot read-modify-write across worker processes."""
|
||||
if fcntl is None:
|
||||
with nullcontext():
|
||||
yield
|
||||
return
|
||||
|
||||
snap_dir = _cost_snapshots_dir()
|
||||
snap_dir.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = snap_dir / f"{provider}.lock"
|
||||
with lock_path.open("a", encoding="utf-8") as lock_file:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
|
||||
def _fetch_openrouter_key_usage(api_key: str) -> dict[str, Any] | None:
|
||||
"""Fetch current usage/limit from the OpenRouter ``/auth/key`` endpoint.
|
||||
|
||||
Returns a dict with ``usage``, ``limit``, ``label`` on success, or
|
||||
``None`` on any failure. Never raises; callers handle the None case.
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
_OPENROUTER_KEY_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_PROVIDER_QUOTA_TIMEOUT_SECONDS) as resp:
|
||||
raw = resp.read()
|
||||
payload = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw)
|
||||
sanitized = _sanitize_openrouter_quota(payload)
|
||||
label = None
|
||||
if isinstance(payload, dict):
|
||||
data = payload.get("data", payload)
|
||||
if isinstance(data, dict):
|
||||
label = str(data.get("label") or "").strip() or None
|
||||
return {
|
||||
"usage": sanitized.get("usage"),
|
||||
"limit": sanitized.get("limit"),
|
||||
"label": label,
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("OpenRouter key usage fetch failed for cost-history", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _read_cost_snapshots(provider: str) -> list[dict[str, Any]]:
|
||||
"""Read persisted daily snapshots for *provider* from disk.
|
||||
|
||||
Returns a list of ``{date, used, limit}`` dicts sorted by date
|
||||
ascending. Returns an empty list if the file does not exist or is
|
||||
corrupt.
|
||||
"""
|
||||
path = _cost_snapshots_dir() / f"{provider}.json"
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
snapshots = data.get("snapshots")
|
||||
if not isinstance(snapshots, list):
|
||||
return []
|
||||
# Validate and sort
|
||||
valid = []
|
||||
for entry in snapshots:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
date = str(entry.get("date") or "").strip()
|
||||
if not date:
|
||||
continue
|
||||
valid.append({
|
||||
"date": date,
|
||||
"used": _quota_number(entry.get("used")),
|
||||
"limit": _quota_number(entry.get("limit")),
|
||||
})
|
||||
valid.sort(key=lambda e: e["date"])
|
||||
return valid
|
||||
|
||||
|
||||
def _write_cost_snapshots(provider: str, snapshots: list[dict[str, Any]]) -> None:
|
||||
"""Persist daily snapshots for *provider* to disk atomically."""
|
||||
snap_dir = _cost_snapshots_dir()
|
||||
snap_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = snap_dir / f"{provider}.json"
|
||||
payload = {"provider": provider, "snapshots": snapshots}
|
||||
body = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
import tempfile as _tempfile
|
||||
_tmp_fd, _tmp_path = _tempfile.mkstemp(
|
||||
dir=str(snap_dir), prefix=f".{provider}_", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f:
|
||||
_f.write(body)
|
||||
_f.flush()
|
||||
os.fsync(_f.fileno())
|
||||
os.replace(_tmp_path, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(_tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _append_cost_snapshot(provider: str, usage: int | float | None, limit: int | float | None) -> list[dict[str, Any]]:
|
||||
"""Append today's snapshot and return the updated list.
|
||||
|
||||
If a snapshot for today already exists it is updated in-place so
|
||||
repeated calls within the same day are idempotent.
|
||||
"""
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
# Serialize the read-modify-write cycle. The atomic os.replace in
|
||||
# _write_cost_snapshots protects the file write itself, but without these
|
||||
# locks two concurrent requests can both read the same old snapshot list and
|
||||
# race to replace it with stale data. The threading lock covers current
|
||||
# single-process deployments; the file lock covers future multi-worker
|
||||
# deployments that share one Hermes home/state directory.
|
||||
with _COST_SNAPSHOT_LOCK:
|
||||
with _cost_snapshot_file_lock(provider):
|
||||
snapshots = _read_cost_snapshots(provider)
|
||||
# Update or append today's entry
|
||||
updated = False
|
||||
for entry in snapshots:
|
||||
if entry["date"] == today:
|
||||
entry["used"] = usage
|
||||
entry["limit"] = limit
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
snapshots.append({"date": today, "used": usage, "limit": limit})
|
||||
snapshots.sort(key=lambda e: e["date"])
|
||||
# Cap to _COST_SNAPSHOT_MAX_DAYS entries (keep most recent)
|
||||
if len(snapshots) > _COST_SNAPSHOT_MAX_DAYS:
|
||||
snapshots = snapshots[-_COST_SNAPSHOT_MAX_DAYS:]
|
||||
_write_cost_snapshots(provider, snapshots)
|
||||
return snapshots
|
||||
|
||||
|
||||
def _compute_deltas(snapshots: list[dict[str, Any]], window_days: int) -> list[dict[str, Any]]:
|
||||
"""Compute daily deltas from cumulative usage snapshots.
|
||||
|
||||
Each snapshot carries cumulative ``used``; the delta for a day is
|
||||
the difference between that day's cumulative value and the previous
|
||||
day's. The oldest day in the window has ``delta=None`` (no
|
||||
previous baseline). If the cumulative value drops, treat that day
|
||||
as the start of a fresh series (for example after an API-key rotation)
|
||||
and use the current value as that day's delta instead of emitting a
|
||||
negative spend bar.
|
||||
"""
|
||||
# Take only the last *window_days* entries
|
||||
window = snapshots[-window_days:] if len(snapshots) > window_days else list(snapshots)
|
||||
result: list[dict[str, Any]] = []
|
||||
for i, entry in enumerate(window):
|
||||
delta = None
|
||||
if i > 0 and entry.get("used") is not None and window[i - 1].get("used") is not None:
|
||||
delta = float(entry["used"]) - float(window[i - 1]["used"])
|
||||
if delta < 0:
|
||||
delta = float(entry["used"])
|
||||
# Rounding: avoid -0.0 and tiny floating-point noise
|
||||
if abs(delta) < 1e-9:
|
||||
delta = 0.0
|
||||
else:
|
||||
delta = round(delta, 6)
|
||||
result.append({
|
||||
"date": entry["date"],
|
||||
"used": entry.get("used"),
|
||||
"delta": delta,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_provider_cost_history(provider_id: str | None = None, days: int = 7) -> dict[str, Any]:
|
||||
"""Return daily cost-history snapshots with deltas for a provider.
|
||||
|
||||
Currently only ``openrouter`` is supported. On each call the
|
||||
endpoint fetches the current cumulative usage from the OpenRouter
|
||||
``/auth/key`` endpoint, appends/updates today's snapshot, and
|
||||
returns the last *days* snapshots with per-day deltas.
|
||||
|
||||
Returns a dict matching the existing API style (``ok``, ``provider``,
|
||||
``status``, ``message``, …).
|
||||
"""
|
||||
provider = (provider_id or "").strip().lower()
|
||||
if not provider:
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": None,
|
||||
"status": "missing_provider",
|
||||
"message": "Provider parameter is required. Use ?provider=openrouter",
|
||||
}
|
||||
|
||||
if provider != "openrouter":
|
||||
display_name = _PROVIDER_DISPLAY.get(provider, provider.replace("-", " ").title())
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": provider,
|
||||
"display_name": display_name,
|
||||
"supported": False,
|
||||
"status": "unsupported",
|
||||
"message": f"Cost history is not available for {display_name}. Only openrouter is supported in this release.",
|
||||
}
|
||||
|
||||
display_name = _PROVIDER_DISPLAY.get("openrouter", "OpenRouter")
|
||||
api_key = _get_provider_api_key("openrouter")
|
||||
if not api_key:
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": "openrouter",
|
||||
"display_name": display_name,
|
||||
"supported": True,
|
||||
"status": "no_key",
|
||||
"message": "OpenRouter cost history needs an OPENROUTER_API_KEY configured on the server.",
|
||||
}
|
||||
|
||||
# Fetch current cumulative usage from OpenRouter
|
||||
key_info = _fetch_openrouter_key_usage(api_key)
|
||||
if key_info is None:
|
||||
# Upstream failure — still return any previously persisted snapshots
|
||||
# so the chart degrades gracefully instead of going blank.
|
||||
snapshots = _read_cost_snapshots("openrouter")
|
||||
deltas = _compute_deltas(snapshots, days)
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": "openrouter",
|
||||
"display_name": display_name,
|
||||
"supported": True,
|
||||
"status": "unavailable",
|
||||
"window_days": days,
|
||||
"snapshots": deltas,
|
||||
"limit": None,
|
||||
"label": None,
|
||||
"message": "OpenRouter cost history is temporarily unavailable. Showing last known data.",
|
||||
}
|
||||
|
||||
# Persist today's snapshot
|
||||
try:
|
||||
snapshots = _append_cost_snapshot("openrouter", key_info["usage"], key_info["limit"])
|
||||
except Exception:
|
||||
logger.debug("Failed to persist cost snapshot for openrouter", exc_info=True)
|
||||
snapshots = _read_cost_snapshots("openrouter")
|
||||
|
||||
deltas = _compute_deltas(snapshots, days)
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": "openrouter",
|
||||
"display_name": display_name,
|
||||
"supported": True,
|
||||
"status": "available",
|
||||
"window_days": days,
|
||||
"snapshots": deltas,
|
||||
"limit": key_info.get("limit"),
|
||||
"label": key_info.get("label") or "OpenRouter credits",
|
||||
"message": "OpenRouter cost history loaded.",
|
||||
}
|
||||
|
||||
|
||||
# SECTION: Public API
|
||||
|
||||
|
||||
@@ -1581,6 +1867,14 @@ def get_providers() -> dict[str, Any]:
|
||||
if live_models:
|
||||
models = live_models
|
||||
models_total = len(models)
|
||||
if pid == "xai-oauth":
|
||||
live_models = _models_from_live_provider_ids(
|
||||
pid,
|
||||
_read_live_provider_model_ids("xai-oauth"),
|
||||
)
|
||||
if live_models:
|
||||
models = live_models
|
||||
models_total = len(models)
|
||||
# Nous Portal: prefer the live catalog so the providers card matches
|
||||
# the dropdown picker (#1538). Same fallback shape as the static-only
|
||||
# case below — when hermes_cli is unavailable or its lookup raises,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""RuntimeAdapter seam for WebUI-owned run execution.
|
||||
|
||||
This is the #1925 Slice 2 seam only. The default WebUI chat path remains the
|
||||
legacy direct route; enabling ``HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal``
|
||||
routes through this protocol-translator facade over the same legacy execution
|
||||
path plus the Slice 1 run journal. This module intentionally does not own
|
||||
AIAgent instances, cancellation flags, approval callbacks, clarify callbacks, or
|
||||
new long-lived queues.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Literal, Protocol
|
||||
|
||||
_RUNTIME_ADAPTER_ENV = "HERMES_WEBUI_RUNTIME_ADAPTER"
|
||||
_RUNTIME_ADAPTER_DIRECT = "legacy-direct"
|
||||
_RUNTIME_ADAPTER_JOURNAL = "legacy-journal"
|
||||
_VALID_RUNTIME_ADAPTER_MODES = {_RUNTIME_ADAPTER_DIRECT, _RUNTIME_ADAPTER_JOURNAL}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StartRunRequest:
|
||||
session_id: str
|
||||
message: str
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
workspace: str | None = None
|
||||
profile: str | None = None
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
toolsets: list[str] = field(default_factory=list)
|
||||
source: str = "webui"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunStartResult:
|
||||
run_id: str
|
||||
session_id: str
|
||||
stream_id: str
|
||||
status: str = "started"
|
||||
started_at: float | None = None
|
||||
cursor: str | None = None
|
||||
active_controls: list[str] = field(default_factory=list)
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunEventStream:
|
||||
run_id: str
|
||||
events: list[dict[str, Any]] = field(default_factory=list)
|
||||
cursor: str | None = None
|
||||
last_event_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunStatus:
|
||||
run_id: str
|
||||
session_id: str | None = None
|
||||
status: str = "unknown"
|
||||
last_event_id: str | None = None
|
||||
terminal_state: str | None = None
|
||||
active_controls: list[str] = field(default_factory=list)
|
||||
pending_approval_id: str | None = None
|
||||
pending_clarify_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, eq=True, unsafe_hash=False)
|
||||
class ControlResult:
|
||||
# NOTE: `payload: dict` makes this dataclass unhashable by design.
|
||||
# `unsafe_hash=False` makes that explicit so future maintainers don't try
|
||||
# to add `frozen=True`-implied hashability back (would silently break the
|
||||
# moment any caller adds dict / list fields). Opus advisor stage-384 followup.
|
||||
accepted: bool
|
||||
status: str = "accepted"
|
||||
event_id: str | None = None
|
||||
safe_message: str | None = None
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class RuntimeAdapter(Protocol):
|
||||
def start_run(self, request: StartRunRequest) -> RunStartResult: ...
|
||||
def observe_run(self, run_id: str, *, cursor: str | None = None) -> RunEventStream: ...
|
||||
def get_run(self, run_id: str) -> RunStatus: ...
|
||||
def cancel_run(self, run_id: str) -> ControlResult: ...
|
||||
def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult: ...
|
||||
def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult: ...
|
||||
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult: ...
|
||||
def update_goal(
|
||||
self,
|
||||
session_id: str,
|
||||
action: Literal["set", "pause", "resume", "clear", "status", "edit"],
|
||||
text: str = "",
|
||||
) -> ControlResult: ...
|
||||
|
||||
|
||||
def runtime_adapter_mode(environ: dict[str, str] | None = None) -> str:
|
||||
"""Return the configured adapter mode, defaulting safely to legacy-direct."""
|
||||
source = os.environ if environ is None else environ
|
||||
raw = str(source.get(_RUNTIME_ADAPTER_ENV, _RUNTIME_ADAPTER_DIRECT) or "").strip().lower()
|
||||
return raw if raw in _VALID_RUNTIME_ADAPTER_MODES else _RUNTIME_ADAPTER_DIRECT
|
||||
|
||||
|
||||
def runtime_adapter_enabled(environ: dict[str, str] | None = None) -> bool:
|
||||
return runtime_adapter_mode(environ) == _RUNTIME_ADAPTER_JOURNAL
|
||||
|
||||
|
||||
def _cursor_to_after_seq(cursor: str | None) -> int | None:
|
||||
if cursor in (None, ""):
|
||||
return None
|
||||
try:
|
||||
text = str(cursor)
|
||||
if ":" in text:
|
||||
text = text.rsplit(":", 1)[-1]
|
||||
return max(0, int(text))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _active_control_result(value: Any) -> ControlResult:
|
||||
"""Normalize legacy delegate responses without changing their payloads.
|
||||
|
||||
``status`` is an adapter-level summary used by current control tests and
|
||||
future runtime backends. For legacy goal payloads it may mirror the goal
|
||||
action (``set`` / ``pause`` / ``status``), while public route behavior keeps
|
||||
using the payload itself to preserve existing HTTP response shapes.
|
||||
"""
|
||||
if isinstance(value, ControlResult):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
accepted = bool(value.get("ok", True))
|
||||
return ControlResult(
|
||||
accepted=accepted,
|
||||
status=str(value.get("status") or value.get("action") or ("accepted" if accepted else "not-active")),
|
||||
safe_message=value.get("message") if not accepted else None,
|
||||
payload=dict(value),
|
||||
)
|
||||
accepted = bool(value)
|
||||
return ControlResult(
|
||||
accepted=accepted,
|
||||
status="accepted" if accepted else "not-active",
|
||||
safe_message=None if accepted else "Legacy control did not accept the request.",
|
||||
)
|
||||
|
||||
|
||||
class LegacyJournalRuntimeAdapter:
|
||||
"""Protocol-translator facade over the current legacy streaming path.
|
||||
|
||||
Delegates keep Slice 2 honest: this adapter has no worker thread, AIAgent
|
||||
cache, cancellation registry, approval queue, or clarify queue of its own.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
start_run_delegate: Callable[[StartRunRequest], dict[str, Any]] | None = None,
|
||||
cancel_delegate: Callable[[str], Any] | None = None,
|
||||
approval_delegate: Callable[[str, str, str], Any] | None = None,
|
||||
clarify_delegate: Callable[[str, str, str], Any] | None = None,
|
||||
queue_delegate: Callable[[str, str, str], Any] | None = None,
|
||||
goal_delegate: Callable[[str, str, str], Any] | None = None,
|
||||
live_stream_lookup: Callable[[str], bool] | None = None,
|
||||
session_dir: Path | None = None,
|
||||
):
|
||||
self._start_run_delegate = start_run_delegate
|
||||
self._cancel_delegate = cancel_delegate
|
||||
self._approval_delegate = approval_delegate
|
||||
self._clarify_delegate = clarify_delegate
|
||||
self._queue_delegate = queue_delegate
|
||||
self._goal_delegate = goal_delegate
|
||||
self._live_stream_lookup = live_stream_lookup or (lambda _run_id: False)
|
||||
self._session_dir = Path(session_dir) if session_dir is not None else None
|
||||
|
||||
def start_run(self, request: StartRunRequest) -> RunStartResult:
|
||||
if self._start_run_delegate is None:
|
||||
raise NotImplementedError("LegacyJournalRuntimeAdapter.start_run requires a legacy delegate")
|
||||
payload = dict(self._start_run_delegate(request) or {})
|
||||
stream_id = str(payload.get("stream_id") or payload.get("run_id") or "")
|
||||
run_id = str(payload.get("run_id") or stream_id)
|
||||
session_id = str(payload.get("session_id") or request.session_id)
|
||||
active_controls = payload.get("active_controls")
|
||||
if not isinstance(active_controls, list):
|
||||
active_controls = ["cancel"] if stream_id else []
|
||||
return RunStartResult(
|
||||
run_id=run_id,
|
||||
session_id=session_id,
|
||||
stream_id=stream_id,
|
||||
status=str(payload.get("status") or "started"),
|
||||
started_at=payload.get("started_at"),
|
||||
cursor=payload.get("cursor"),
|
||||
active_controls=active_controls,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def observe_run(self, run_id: str, *, cursor: str | None = None) -> RunEventStream:
|
||||
from api.run_journal import find_run_summary, read_run_events
|
||||
|
||||
summary = find_run_summary(run_id, session_dir=self._session_dir)
|
||||
if not summary:
|
||||
return RunEventStream(run_id=run_id, events=[], cursor=cursor, last_event_id=None)
|
||||
journal = read_run_events(
|
||||
str(summary.get("session_id") or ""),
|
||||
run_id,
|
||||
after_seq=_cursor_to_after_seq(cursor),
|
||||
session_dir=self._session_dir,
|
||||
)
|
||||
events = list(journal.get("events") or [])
|
||||
last_event_id = events[-1].get("event_id") if events else summary.get("last_event_id")
|
||||
return RunEventStream(
|
||||
run_id=run_id,
|
||||
events=events,
|
||||
cursor=str(events[-1].get("seq")) if events else cursor,
|
||||
last_event_id=last_event_id,
|
||||
)
|
||||
|
||||
def get_run(self, run_id: str) -> RunStatus:
|
||||
from api.run_journal import find_run_summary
|
||||
|
||||
live = bool(self._live_stream_lookup(run_id))
|
||||
summary = find_run_summary(run_id, session_dir=self._session_dir)
|
||||
if live:
|
||||
return RunStatus(
|
||||
run_id=run_id,
|
||||
session_id=str((summary or {}).get("session_id") or "") or None,
|
||||
status="running",
|
||||
last_event_id=(summary or {}).get("last_event_id"),
|
||||
terminal_state=None,
|
||||
active_controls=["cancel"],
|
||||
)
|
||||
if summary:
|
||||
terminal_state = summary.get("terminal_state")
|
||||
return RunStatus(
|
||||
run_id=run_id,
|
||||
session_id=str(summary.get("session_id") or "") or None,
|
||||
status=str(terminal_state or "unknown"),
|
||||
last_event_id=summary.get("last_event_id"),
|
||||
terminal_state=terminal_state,
|
||||
active_controls=[],
|
||||
)
|
||||
return RunStatus(run_id=run_id)
|
||||
|
||||
def cancel_run(self, run_id: str) -> ControlResult:
|
||||
if self._cancel_delegate is None:
|
||||
return ControlResult(False, status="unsupported", safe_message="Cancel is not wired for this adapter.")
|
||||
return _active_control_result(self._cancel_delegate(run_id))
|
||||
|
||||
def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult:
|
||||
if self._approval_delegate is None:
|
||||
return ControlResult(False, status="unsupported", safe_message="Approval is delegated to the legacy path.")
|
||||
return _active_control_result(self._approval_delegate(run_id, approval_id, choice))
|
||||
|
||||
def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult:
|
||||
if self._clarify_delegate is None:
|
||||
return ControlResult(False, status="unsupported", safe_message="Clarify is delegated to the legacy path.")
|
||||
return _active_control_result(self._clarify_delegate(run_id, clarify_id, response))
|
||||
|
||||
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult:
|
||||
if self._queue_delegate is None:
|
||||
return ControlResult(False, status="unsupported", safe_message="Queue is delegated to the legacy path.")
|
||||
return _active_control_result(self._queue_delegate(run_id, message, mode))
|
||||
|
||||
def update_goal(
|
||||
self,
|
||||
session_id: str,
|
||||
action: Literal["set", "pause", "resume", "clear", "status", "edit"],
|
||||
text: str = "",
|
||||
) -> ControlResult:
|
||||
if self._goal_delegate is None:
|
||||
return ControlResult(False, status="unsupported", safe_message="Goal is delegated to the legacy path.")
|
||||
return _active_control_result(self._goal_delegate(session_id, action, text))
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Hermes WebUI memory-provider session lifecycle.
|
||||
|
||||
Batch-extraction memory providers (OpenViking, Holographic) only extract memories
|
||||
when AIAgent.commit_memory_session() invokes provider on_session_end(). WebUI
|
||||
sessions can be reopened and continued many times, so the lifecycle must guarantee:
|
||||
|
||||
1. Only completed, non-ephemeral turns are committable.
|
||||
2. A commit finishing late must not erase work completed while it was in flight.
|
||||
3. A failed commit preserves the uncommitted generation and owning agent handle.
|
||||
4. Replacement/reopened agents cannot steal older dirty generations.
|
||||
5. Overlapping commits are serialised via a per-session in-flight guard.
|
||||
|
||||
CLI-parity semantics — post-turn marking, boundary extraction/commit:
|
||||
|
||||
- Completed turn: Hermes core still mirrors the exchange through
|
||||
run_agent.py::_sync_external_memory_for_turn(), MemoryManager sync_all(), and
|
||||
provider sync_turn() WITHOUT triggering extraction. WebUI then calls
|
||||
mark_turn_completed() after the saved/completed-turn boundary so later drains
|
||||
know the synced session has uncommitted work and which agent owns it.
|
||||
|
||||
- Session boundary: commit_session_memory() triggers
|
||||
AIAgent.commit_memory_session(), which calls provider on_session_end(),
|
||||
posting /api/v1/sessions/<sid>/commit and triggering extraction. This is
|
||||
called only at boundaries — /api/session/new with prev_session_id, explicit
|
||||
agent eviction, LRU cache eviction, and shutdown drain — matching the CLI's
|
||||
AIAgent.commit_memory_session()/shutdown_memory_provider() boundary.
|
||||
|
||||
The design uses a monotonic generation counter per session plus per-generation
|
||||
agent ownership segments. mark_turn_completed() records which agent owns the new
|
||||
generation. commit_session_memory() commits the earliest uncommitted segment and
|
||||
compare-and-clears only that captured segment after success.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_condition = threading.Condition(_lock)
|
||||
|
||||
_sessions: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _new_entry() -> dict:
|
||||
return {
|
||||
"generation": 0,
|
||||
"committed_generation": 0,
|
||||
"agent": None,
|
||||
"in_flight": False,
|
||||
"segments": [],
|
||||
}
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
with _condition:
|
||||
_sessions.clear()
|
||||
_condition.notify_all()
|
||||
|
||||
|
||||
def register_agent(session_id: str, agent) -> None:
|
||||
"""Register the current agent handle for future completed generations.
|
||||
|
||||
Existing dirty generations keep their original segment owner. This prevents
|
||||
a rebuilt/reopened agent from overwriting the handle needed to retry older
|
||||
failed memory-provider work.
|
||||
"""
|
||||
if not session_id:
|
||||
return
|
||||
with _condition:
|
||||
entry = _sessions.setdefault(session_id, _new_entry())
|
||||
entry["agent"] = agent
|
||||
_condition.notify_all()
|
||||
|
||||
|
||||
def unregister_agent(session_id: str) -> None:
|
||||
"""Clear the current future-generation agent handle.
|
||||
|
||||
Dirty segment owners are intentionally preserved so failed work remains
|
||||
retryable even if the cache drops the current agent reference.
|
||||
"""
|
||||
if not session_id:
|
||||
return
|
||||
with _condition:
|
||||
entry = _sessions.get(session_id)
|
||||
if entry is not None:
|
||||
entry["agent"] = None
|
||||
_condition.notify_all()
|
||||
|
||||
|
||||
def mark_turn_completed(session_id: str, *, agent=None) -> int:
|
||||
if not session_id:
|
||||
return 0
|
||||
with _condition:
|
||||
entry = _sessions.setdefault(session_id, _new_entry())
|
||||
if agent is not None:
|
||||
entry["agent"] = agent
|
||||
owner = agent if agent is not None else entry.get("agent")
|
||||
entry["generation"] += 1
|
||||
generation = entry["generation"]
|
||||
segments = entry["segments"]
|
||||
if segments and not entry["in_flight"] and segments[-1].get("agent") is owner:
|
||||
segments[-1]["end"] = generation
|
||||
else:
|
||||
segments.append({"start": generation, "end": generation, "agent": owner})
|
||||
_condition.notify_all()
|
||||
return generation
|
||||
|
||||
|
||||
def has_uncommitted_work(session_id: str) -> bool:
|
||||
if not session_id:
|
||||
return False
|
||||
with _lock:
|
||||
entry = _sessions.get(session_id)
|
||||
if entry is None:
|
||||
return False
|
||||
return entry["generation"] > entry["committed_generation"]
|
||||
|
||||
|
||||
def _first_uncommitted_segment(entry: dict) -> dict | None:
|
||||
committed = entry["committed_generation"]
|
||||
for segment in entry["segments"]:
|
||||
if segment["end"] > committed:
|
||||
return segment
|
||||
return None
|
||||
|
||||
|
||||
def commit_session_memory(session_id: str, agent=None, *, wait: bool = False, timeout: float | None = None) -> bool:
|
||||
if not session_id:
|
||||
return False
|
||||
deadline = time.monotonic() + timeout if timeout is not None else None
|
||||
with _condition:
|
||||
entry = _sessions.get(session_id)
|
||||
if entry is None:
|
||||
return False
|
||||
while entry["in_flight"]:
|
||||
if not wait:
|
||||
return False
|
||||
if deadline is None:
|
||||
_condition.wait()
|
||||
else:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
_condition.wait(remaining)
|
||||
entry = _sessions.get(session_id)
|
||||
if entry is None:
|
||||
return False
|
||||
if entry["generation"] <= entry["committed_generation"]:
|
||||
return False
|
||||
segment = _first_uncommitted_segment(entry)
|
||||
if segment is None:
|
||||
return False
|
||||
effective_agent = segment.get("agent")
|
||||
if effective_agent is None:
|
||||
effective_agent = agent if agent is not None else entry.get("agent")
|
||||
if effective_agent is not None:
|
||||
segment["agent"] = effective_agent
|
||||
if effective_agent is None:
|
||||
return False
|
||||
captured_generation = segment["end"]
|
||||
entry["in_flight"] = True
|
||||
|
||||
try:
|
||||
effective_agent.commit_memory_session()
|
||||
except Exception:
|
||||
logger.exception("commit_memory_session() failed for session %s", session_id)
|
||||
with _condition:
|
||||
re_entry = _sessions.get(session_id)
|
||||
if re_entry is not None:
|
||||
re_entry["in_flight"] = False
|
||||
_condition.notify_all()
|
||||
return False
|
||||
|
||||
with _condition:
|
||||
re_entry = _sessions.get(session_id)
|
||||
if re_entry is not None:
|
||||
re_entry["in_flight"] = False
|
||||
if captured_generation > re_entry["committed_generation"]:
|
||||
re_entry["committed_generation"] = captured_generation
|
||||
committed = re_entry["committed_generation"]
|
||||
segments = re_entry["segments"]
|
||||
while segments and segments[0]["end"] <= committed:
|
||||
segments.pop(0)
|
||||
if segments and segments[0]["start"] <= committed:
|
||||
segments[0]["start"] = committed + 1
|
||||
_condition.notify_all()
|
||||
return True
|
||||
|
||||
|
||||
def drain_all_on_shutdown() -> None:
|
||||
while True:
|
||||
with _lock:
|
||||
snapshot = [sid for sid, entry in _sessions.items() if entry["generation"] > entry["committed_generation"]]
|
||||
if not snapshot:
|
||||
return
|
||||
|
||||
made_progress = False
|
||||
for sid in snapshot:
|
||||
if commit_session_memory(sid, wait=True):
|
||||
made_progress = True
|
||||
if not made_progress:
|
||||
logger.debug("drain_all_on_shutdown: stopped with uncommitted sessions: %s", sorted(snapshot))
|
||||
return
|
||||
@@ -50,6 +50,56 @@ from api.turn_journal import append_turn_journal_event_for_stream
|
||||
# (api/profiles.py:715).
|
||||
_ENV_LOCK = threading.Lock()
|
||||
|
||||
_KEYLESS_CUSTOM_API_KEY = "dummy-key"
|
||||
|
||||
|
||||
def _resolve_custom_provider_runtime_overrides(
|
||||
resolved_provider: str | None,
|
||||
resolved_api_key: str | None,
|
||||
resolved_base_url: str | None,
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""Return provider/key/base_url overrides for ``custom:*`` endpoints.
|
||||
|
||||
Hermes Agent treats named custom providers as routing hints around an
|
||||
OpenAI-compatible base URL. Local OpenAI-compatible servers often run
|
||||
without authentication, so a missing key should not fail before the first
|
||||
request; pass a harmless placeholder to the SDK and let the endpoint accept
|
||||
it or return its own auth error.
|
||||
"""
|
||||
if not (isinstance(resolved_provider, str) and resolved_provider.startswith("custom:")):
|
||||
return resolved_provider, resolved_api_key, resolved_base_url
|
||||
|
||||
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
|
||||
if not resolved_api_key and _cp_key:
|
||||
resolved_api_key = _cp_key
|
||||
if not resolved_base_url and _cp_base:
|
||||
resolved_base_url = _cp_base
|
||||
if resolved_base_url:
|
||||
# Route through the generic custom OpenAI-compatible client once the
|
||||
# named provider has supplied the concrete endpoint. Keeping the
|
||||
# provider as custom:<slug> would make Agent init synthesize invalid
|
||||
# env-var hints like CUSTOM:SOMETHING-8000_API_KEY on keyless setups.
|
||||
resolved_provider = "custom"
|
||||
if not resolved_api_key:
|
||||
resolved_api_key = _KEYLESS_CUSTOM_API_KEY
|
||||
return resolved_provider, resolved_api_key, resolved_base_url
|
||||
|
||||
|
||||
def _is_fallback_lifecycle_message(kind: str, message: str) -> bool:
|
||||
"""Return True if an agent lifecycle status should surface as a fallback warning."""
|
||||
k = str(kind or '').strip().lower()
|
||||
m = str(message or '').strip().lower()
|
||||
return (
|
||||
k == 'lifecycle'
|
||||
and (
|
||||
'rate limited' in m
|
||||
or 'switching to fallback' in m
|
||||
or 'falling back' in m
|
||||
or 'fallback activated' in m
|
||||
or 'trying fallback' in m
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _prewarm_skill_tool_modules():
|
||||
"""Import tools.skills_tool and tools.skill_manager_tool outside any lock.
|
||||
@@ -137,6 +187,25 @@ def _clarify_timeout_seconds(default: int = 120) -> int:
|
||||
_CANCEL_MARKER_PATTERNS = ('task cancelled', 'task canceled', 'response interrupted')
|
||||
|
||||
|
||||
_WEBUI_VISIBLE_PROGRESS_PROMPT = """
|
||||
WebUI progress contract:
|
||||
- For multi-step work that uses tools, provide brief user-visible progress updates as normal assistant content before continuing with tool calls.
|
||||
- Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed.
|
||||
- Keep updates concise, factual, and in the user's language. One or two short sentences are enough.
|
||||
- Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output.
|
||||
- For direct answers or very short tasks, skip progress updates and answer normally.
|
||||
""".strip()
|
||||
|
||||
|
||||
def _webui_ephemeral_system_prompt(personality_prompt: Optional[str]) -> str:
|
||||
"""Build WebUI-only runtime instructions that are not persisted to history."""
|
||||
parts = []
|
||||
if personality_prompt:
|
||||
parts.append(str(personality_prompt).strip())
|
||||
parts.append(_WEBUI_VISIBLE_PROGRESS_PROMPT)
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool:
|
||||
"""Return True if *new* messages (beyond ``prev_count``) contain an
|
||||
assistant message with non-empty content.
|
||||
@@ -955,6 +1024,8 @@ def _message_text(value) -> str:
|
||||
|
||||
_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*')
|
||||
_LEGACY_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace:[^\]]+\]\s*')
|
||||
_WORKSPACE_PREFIX_ANY_RE = re.compile(r'\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*')
|
||||
_LEGACY_WORKSPACE_PREFIX_ANY_RE = re.compile(r'\[Workspace:[^\]]+\]\s*')
|
||||
|
||||
|
||||
def _escape_workspace_prefix_path(path: str) -> str:
|
||||
@@ -974,6 +1045,27 @@ def _strip_workspace_prefix(text: str, *, include_legacy: bool = False) -> str:
|
||||
return stripped.strip()
|
||||
|
||||
|
||||
def _looks_like_current_user_turn(msg, msg_text) -> bool:
|
||||
"""Match the current human turn even if an internal workspace tag leaked mid-text.
|
||||
|
||||
Normal model-facing messages start with the workspace sentinel. A failed
|
||||
retry/merge path can also return an optimistic draft followed by the
|
||||
sentinel and the real prompt. Only treat that shape as the current turn
|
||||
when the text after the sentinel exactly matches the submitted prompt.
|
||||
"""
|
||||
if not isinstance(msg, dict) or msg.get('role') != 'user':
|
||||
return False
|
||||
needle = " ".join(str(msg_text or '').split())
|
||||
if not needle:
|
||||
return False
|
||||
text = _message_text(msg.get('content', ''))
|
||||
candidates = [_strip_workspace_prefix(text, include_legacy=True)]
|
||||
for pattern in (_WORKSPACE_PREFIX_ANY_RE, _LEGACY_WORKSPACE_PREFIX_ANY_RE):
|
||||
for match in pattern.finditer(text):
|
||||
candidates.append(text[match.end():])
|
||||
return any(" ".join(str(candidate or '').split()) == needle for candidate in candidates)
|
||||
|
||||
|
||||
def _first_exchange_snippets(messages):
|
||||
"""Return (first_user_text, first_assistant_text) snippets for title generation.
|
||||
|
||||
@@ -1831,7 +1923,71 @@ def _maybe_schedule_title_refresh(session, put_event, agent):
|
||||
).start()
|
||||
|
||||
|
||||
def _sanitize_messages_for_api(messages):
|
||||
def _strip_native_image_parts_from_content(content):
|
||||
"""Return provider-safe content with native image parts removed.
|
||||
|
||||
Text-only provider endpoints (for example DeepSeek/OpenAI-compatible text
|
||||
models) reject historical OpenAI-style ``image_url`` parts before the agent
|
||||
can recover. When WebUI is configured for text-mode image handling, preserve
|
||||
textual content from mixed content arrays and drop only the native image
|
||||
blocks from replayed history.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
clean_parts = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get('type') == 'image_url' or 'image_url' in part:
|
||||
continue
|
||||
clean_parts.append(copy.deepcopy(part))
|
||||
if not clean_parts:
|
||||
return ''
|
||||
if len(clean_parts) == 1 and clean_parts[0].get('type') == 'text':
|
||||
return str(clean_parts[0].get('text') or '')
|
||||
return clean_parts
|
||||
|
||||
|
||||
def _content_has_reasoning_only_parts(content) -> bool:
|
||||
if not isinstance(content, list) or not content:
|
||||
return False
|
||||
saw_reasoning = False
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
part_type = part.get('type')
|
||||
if part_type in {'thinking', 'reasoning'}:
|
||||
text = part.get('thinking') or part.get('reasoning') or part.get('text') or ''
|
||||
if str(text).strip():
|
||||
saw_reasoning = True
|
||||
continue
|
||||
if part_type == 'text' and str(part.get('text') or part.get('content') or '').strip():
|
||||
return False
|
||||
if part_type not in {'text', 'thinking', 'reasoning'}:
|
||||
return False
|
||||
return saw_reasoning
|
||||
|
||||
|
||||
def _is_reasoning_only_assistant_message(msg) -> bool:
|
||||
"""Return True for display-only assistant Thinking entries.
|
||||
|
||||
These entries keep partial Thinking cards visible after reload/cancel, but
|
||||
they are not API-safe history: providers only see a blank assistant turn.
|
||||
Visible assistant replies that also carry reasoning metadata are kept.
|
||||
"""
|
||||
if not isinstance(msg, dict) or msg.get('role') != 'assistant':
|
||||
return False
|
||||
if msg.get('tool_calls'):
|
||||
return False
|
||||
content = msg.get('content', '')
|
||||
if _message_text(content).strip():
|
||||
return False
|
||||
if str(msg.get('reasoning') or msg.get('reasoning_content') or '').strip():
|
||||
return True
|
||||
return _content_has_reasoning_only_parts(content)
|
||||
|
||||
|
||||
def _sanitize_messages_for_api(messages, *, cfg: dict = None):
|
||||
"""Return a deep copy of messages with only API-safe fields.
|
||||
|
||||
The webui stores extra metadata on messages (attachments, timestamp, _ts)
|
||||
@@ -1843,7 +1999,14 @@ def _sanitize_messages_for_api(messages):
|
||||
(Mercury-2/Inception, newer OpenAI models) reject histories containing dangling
|
||||
tool results with a 400 error: "Message has tool role, but there was no previous
|
||||
assistant message with a tool call."
|
||||
|
||||
If ``agent.image_input_mode`` resolves to ``text``, native historical
|
||||
``image_url`` content parts are stripped too. Current-turn uploads already
|
||||
respect text mode in ``_build_native_multimodal_message``; this closes the
|
||||
remaining replay gap where an older native image in the saved transcript kept
|
||||
causing 400s on every later text-only turn (#2297).
|
||||
"""
|
||||
strip_native_images = cfg is not None and _resolve_image_input_mode(cfg) == "text"
|
||||
# First pass: collect all tool_call_ids declared by assistant messages.
|
||||
# Handles both OpenAI ('id') and Anthropic ('call_id') field names.
|
||||
valid_tool_call_ids: set = set()
|
||||
@@ -1862,6 +2025,10 @@ def _sanitize_messages_for_api(messages):
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
# Skip display-only Thinking entries. They are visible transcript
|
||||
# metadata, not provider-facing assistant turns.
|
||||
if _is_reasoning_only_assistant_message(msg):
|
||||
continue
|
||||
# Skip persisted error markers — never send them to the LLM as prior context.
|
||||
if msg.get('_error'):
|
||||
continue
|
||||
@@ -1872,6 +2039,8 @@ def _sanitize_messages_for_api(messages):
|
||||
# Orphaned tool result — skip to avoid 400 from strict providers.
|
||||
continue
|
||||
sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS}
|
||||
if strip_native_images and 'content' in sanitized:
|
||||
sanitized['content'] = _strip_native_image_parts_from_content(sanitized.get('content'))
|
||||
if sanitized.get('role'):
|
||||
clean.append(sanitized)
|
||||
return clean
|
||||
@@ -1894,6 +2063,8 @@ def _api_safe_message_positions(messages):
|
||||
for idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if _is_reasoning_only_assistant_message(msg):
|
||||
continue
|
||||
role = msg.get('role')
|
||||
if role == 'tool':
|
||||
tid = msg.get('tool_call_id') or ''
|
||||
@@ -1927,13 +2098,6 @@ def _restore_reasoning_metadata(previous_messages, updated_messages):
|
||||
return None
|
||||
return {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS and msg.get('role')}
|
||||
|
||||
def _reasoning_only_assistant(msg):
|
||||
if not isinstance(msg, dict) or msg.get('role') != 'assistant' or not msg.get('reasoning'):
|
||||
return False
|
||||
if msg.get('tool_calls'):
|
||||
return False
|
||||
return not _message_text(msg.get('content'))
|
||||
|
||||
safe_pos = 0
|
||||
while safe_pos < len(prev_safe):
|
||||
prev_idx, _ = prev_safe[safe_pos]
|
||||
@@ -1950,12 +2114,28 @@ def _restore_reasoning_metadata(previous_messages, updated_messages):
|
||||
safe_pos += 1
|
||||
continue
|
||||
|
||||
if _reasoning_only_assistant(prev_msg):
|
||||
updated_messages.insert(safe_pos, copy.deepcopy(prev_msg))
|
||||
safe_pos += 1
|
||||
continue
|
||||
|
||||
safe_pos += 1
|
||||
|
||||
return updated_messages
|
||||
|
||||
|
||||
def _restore_display_reasoning_metadata(previous_messages, updated_messages):
|
||||
"""Restore display-only thinking rows for visible transcript persistence."""
|
||||
updated_messages = _restore_reasoning_metadata(previous_messages, updated_messages)
|
||||
if not previous_messages or not updated_messages:
|
||||
return updated_messages
|
||||
prev_safe = _api_safe_message_positions(previous_messages)
|
||||
safe_indices = {idx for idx, _ in prev_safe}
|
||||
inserted_reasoning_only = 0
|
||||
for prev_idx, prev_msg in enumerate(previous_messages):
|
||||
if prev_idx in safe_indices or not _is_reasoning_only_assistant_message(prev_msg):
|
||||
continue
|
||||
safe_pos = sum(1 for idx, _ in prev_safe if idx < prev_idx) + inserted_reasoning_only
|
||||
existing = updated_messages[safe_pos] if safe_pos < len(updated_messages) else None
|
||||
if isinstance(existing, dict) and _is_reasoning_only_assistant_message(existing):
|
||||
continue
|
||||
updated_messages.insert(safe_pos, copy.deepcopy(prev_msg))
|
||||
inserted_reasoning_only += 1
|
||||
return updated_messages
|
||||
|
||||
|
||||
@@ -2060,6 +2240,8 @@ def _find_current_user_turn(messages, msg_text):
|
||||
if not isinstance(msg, dict) or msg.get('role') != 'user':
|
||||
continue
|
||||
fallback = idx
|
||||
if _looks_like_current_user_turn(msg, msg_text):
|
||||
return idx
|
||||
text = " ".join(
|
||||
_strip_workspace_prefix(
|
||||
_message_text(msg.get('content', '')),
|
||||
@@ -2082,6 +2264,90 @@ def _drop_checkpointed_current_user_from_context(messages, msg_text):
|
||||
return history
|
||||
|
||||
|
||||
def _normalize_fresh_chat_text(text):
|
||||
text = _strip_workspace_prefix(str(text or ''), include_legacy=True)
|
||||
text = re.sub(r"\s+", " ", text).strip().lower()
|
||||
return text.strip(" \t\r\n.!?。!?,,~~")
|
||||
|
||||
|
||||
def _is_casual_fresh_chat_message(msg_text):
|
||||
"""Return True for short opener messages that should not resume old tasks."""
|
||||
text = _normalize_fresh_chat_text(msg_text)
|
||||
if not text or len(text) > 24:
|
||||
return False
|
||||
continuation_terms = (
|
||||
"continue",
|
||||
"resume",
|
||||
"carry on",
|
||||
"go on",
|
||||
# CJK continuation terms (zh-CN): jixu, jiezhe, wangxia, xiayibu.
|
||||
# Encoded as Python escape sequences (not literal CJK) so api/streaming.py
|
||||
# passes tests/test_title_sanitization.py::test_title_generation_source_has_no_cjk_literals,
|
||||
# which scans this file for any U+4E00-U+9FFF code points. Runtime
|
||||
# comparisons still use the real CJK strings — Python decodes the
|
||||
# escapes at compile time.
|
||||
"\u7ee7\u7eed",
|
||||
"\u63a5\u7740",
|
||||
"\u5f80\u4e0b",
|
||||
"\u4e0b\u4e00\u6b65",
|
||||
)
|
||||
if any(term in text for term in continuation_terms):
|
||||
return False
|
||||
return text in {
|
||||
"hi",
|
||||
"hello",
|
||||
"hey",
|
||||
"hello there",
|
||||
"hi there",
|
||||
# CJK greetings (zh-CN): nihao, ninhao, hai, haluo, zaima, zaime.
|
||||
# Same escape-sequence rationale as the continuation block above.
|
||||
"\u4f60\u597d", # nihao
|
||||
"\u60a8\u597d", # ninhao
|
||||
"\u55e8", # hai (was \u5616 = "click of tongue", not a greeting)
|
||||
"\u54c8\u55bd", # haluo (was \u54c8\u5582 = uncommon "ha-wei" variant)
|
||||
"\u5728\u5417", # zaima
|
||||
"\u5728\u4e48", # zaime
|
||||
}
|
||||
|
||||
|
||||
def _has_task_resume_compaction_marker(messages):
|
||||
"""Detect compacted model context that tells the agent to resume an old task."""
|
||||
for msg in messages or []:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
text = _message_text(msg.get('content', '')).lower()
|
||||
if not text:
|
||||
continue
|
||||
if "context compaction" not in text and "context compression" not in text:
|
||||
continue
|
||||
if (
|
||||
"active task" in text
|
||||
or "resume exactly" in text
|
||||
or "current task" in text
|
||||
or "task list was preserved" in text
|
||||
or "in_progress" in text
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _context_messages_for_new_turn(session, msg_text):
|
||||
"""Return provider-facing history for a new user turn.
|
||||
|
||||
Compacted agent sessions can carry a hidden "resume the active task" summary
|
||||
long after the visible UI looks like normal chat. A short greeting should
|
||||
not silently reactivate that old task; explicit continuation prompts still
|
||||
keep the full compacted context.
|
||||
"""
|
||||
history = _drop_checkpointed_current_user_from_context(
|
||||
_session_context_messages(session),
|
||||
msg_text,
|
||||
)
|
||||
if _is_casual_fresh_chat_message(msg_text) and _has_task_resume_compaction_marker(history):
|
||||
return []
|
||||
return history
|
||||
|
||||
|
||||
def _stream_writeback_is_current(session, stream_id):
|
||||
"""Return True only while a worker still owns the session writeback.
|
||||
|
||||
@@ -2121,10 +2387,15 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
|
||||
seen = {_message_identity(m) for m in merged}
|
||||
current_user_key = _message_identity({'role': 'user', 'content': msg_text})
|
||||
current_user_in_candidates = any(
|
||||
_message_identity(m) == current_user_key for m in candidates
|
||||
_message_identity(m) == current_user_key or _looks_like_current_user_turn(m, msg_text)
|
||||
for m in candidates
|
||||
)
|
||||
current_user_already_checkpointed = bool(
|
||||
merged and _message_identity(merged[-1]) == current_user_key
|
||||
merged
|
||||
and (
|
||||
_message_identity(merged[-1]) == current_user_key
|
||||
or _looks_like_current_user_turn(merged[-1], msg_text)
|
||||
)
|
||||
)
|
||||
if (
|
||||
current_user_key is not None
|
||||
@@ -2149,11 +2420,14 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
|
||||
|
||||
for msg in candidates:
|
||||
key = _message_identity(msg)
|
||||
is_current_user_turn = _looks_like_current_user_turn(msg, msg_text)
|
||||
if (
|
||||
key is not None
|
||||
and key == current_user_key
|
||||
((key is not None and key == current_user_key) or is_current_user_turn)
|
||||
and merged
|
||||
and _message_identity(merged[-1]) == key
|
||||
and (
|
||||
_message_identity(merged[-1]) == current_user_key
|
||||
or _looks_like_current_user_turn(merged[-1], msg_text)
|
||||
)
|
||||
):
|
||||
# Eager session-save mode can checkpoint the current user turn
|
||||
# before the agent runs. When the agent returns that same user turn
|
||||
@@ -2175,7 +2449,11 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
|
||||
if _is_context_compression_marker(msg) and key is not None and key in seen:
|
||||
continue
|
||||
display_msg = msg
|
||||
if key is not None and key == current_user_key and isinstance(msg, dict) and msg.get('role') == 'user':
|
||||
if (
|
||||
((key is not None and key == current_user_key) or is_current_user_turn)
|
||||
and isinstance(msg, dict)
|
||||
and msg.get('role') == 'user'
|
||||
):
|
||||
display_msg = copy.deepcopy(msg)
|
||||
display_msg['content'] = msg_text
|
||||
merged.append(copy.deepcopy(display_msg))
|
||||
@@ -2184,6 +2462,24 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
|
||||
return merged
|
||||
|
||||
|
||||
def _assistant_reply_added_after_current_turn(result_messages, previous_context, msg_text) -> bool:
|
||||
"""Return True only when the just-finished turn produced assistant text."""
|
||||
result_messages = list(result_messages or [])
|
||||
previous_context = list(previous_context or [])
|
||||
if _messages_have_prefix(result_messages, previous_context):
|
||||
candidates = result_messages[len(previous_context):]
|
||||
else:
|
||||
current_user_idx = _find_current_user_turn(result_messages, msg_text)
|
||||
candidates = result_messages[current_user_idx + 1:] if current_user_idx is not None else result_messages
|
||||
return any(
|
||||
isinstance(m, dict)
|
||||
and m.get('role') == 'assistant'
|
||||
and not m.get('_error')
|
||||
and str(m.get('content') or '').strip()
|
||||
for m in candidates
|
||||
)
|
||||
|
||||
|
||||
_TOOL_RESULT_SNIPPET_MAX = 4000
|
||||
|
||||
|
||||
@@ -2430,6 +2726,145 @@ def _attempt_credential_self_heal(
|
||||
return None
|
||||
|
||||
|
||||
def _agent_cache_api_key_sig(resolved_api_key, credential_pool) -> str:
|
||||
"""Return the cache-signature component for runtime credentials.
|
||||
|
||||
Credential-pool providers can legitimately hand WebUI a different runtime
|
||||
token on each request (round-robin pools, OAuth refresh, auth self-heal).
|
||||
The AIAgent object is also where cross-turn memory-provider state lives, so
|
||||
using the volatile token itself in the cache signature silently defeats the
|
||||
per-session agent cache and drops warmed Hindsight prefetch results.
|
||||
"""
|
||||
if credential_pool is not None:
|
||||
return 'credential-pool'
|
||||
import hashlib as _hashlib
|
||||
return _hashlib.sha256((resolved_api_key or '').encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _refresh_cached_agent_runtime(agent, agent_kwargs: dict) -> bool:
|
||||
"""Refresh volatile runtime credentials on a reused cached AIAgent.
|
||||
|
||||
The cache key intentionally ignores credential-pool token churn, but the
|
||||
cached agent's LLM client still needs the latest selected/refreshed runtime
|
||||
key. Keep long-lived provider/session state (memory prefetch, turn counters,
|
||||
tool state) while swapping only the runtime credential/client.
|
||||
"""
|
||||
if agent is None or not isinstance(agent_kwargs, dict):
|
||||
return False
|
||||
|
||||
new_pool = agent_kwargs.get('credential_pool')
|
||||
if new_pool is not None:
|
||||
try:
|
||||
agent._credential_pool = new_pool
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_key = agent_kwargs.get('api_key') or ''
|
||||
if not new_key:
|
||||
return True
|
||||
|
||||
new_base = agent_kwargs.get('base_url') or getattr(agent, 'base_url', '') or ''
|
||||
if getattr(agent, '_fallback_activated', False):
|
||||
# Avoid mixing a refreshed primary credential into a live fallback
|
||||
# runtime. Rebuilding is safer than mutating a fallback-active agent
|
||||
# whose restore/cooldown state has not run yet for this turn.
|
||||
return False
|
||||
|
||||
if new_key == (getattr(agent, 'api_key', '') or ''):
|
||||
_refresh_cached_agent_primary_runtime_snapshot(agent)
|
||||
return True
|
||||
|
||||
try:
|
||||
if getattr(agent, 'api_mode', None) == 'anthropic_messages':
|
||||
# Native Anthropic-style clients have their own construction path;
|
||||
# switch_model() already handles token/client refresh there.
|
||||
if hasattr(agent, 'switch_model'):
|
||||
agent.switch_model(
|
||||
agent_kwargs.get('model') or getattr(agent, 'model', None),
|
||||
agent_kwargs.get('provider') or getattr(agent, 'provider', None),
|
||||
api_key=new_key,
|
||||
base_url=new_base,
|
||||
api_mode=agent_kwargs.get('api_mode') or getattr(agent, 'api_mode', ''),
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
if not hasattr(agent, '_client_kwargs') or not hasattr(agent, '_replace_primary_openai_client'):
|
||||
# Test/fake-agent fallback: keep metadata accurate even if no real
|
||||
# OpenAI client exists to rebuild.
|
||||
agent.api_key = new_key
|
||||
if new_base:
|
||||
agent.base_url = new_base
|
||||
_refresh_cached_agent_primary_runtime_snapshot(agent)
|
||||
return True
|
||||
|
||||
client_kwargs = dict(getattr(agent, '_client_kwargs', {}) or {})
|
||||
client_kwargs['api_key'] = new_key
|
||||
if new_base:
|
||||
client_kwargs['base_url'] = new_base
|
||||
agent._client_kwargs = client_kwargs
|
||||
agent.api_key = new_key
|
||||
if new_base:
|
||||
agent.base_url = new_base
|
||||
if hasattr(agent, '_apply_client_headers_for_base_url'):
|
||||
agent._apply_client_headers_for_base_url(agent.base_url)
|
||||
rebuilt = bool(agent._replace_primary_openai_client(reason='webui_credential_refresh'))
|
||||
if rebuilt:
|
||||
_refresh_cached_agent_primary_runtime_snapshot(agent)
|
||||
return rebuilt
|
||||
except Exception:
|
||||
logger.debug('[webui] Failed to refresh cached agent runtime credentials', exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _refresh_cached_agent_primary_runtime_snapshot(agent) -> None:
|
||||
"""Keep AIAgent's primary-runtime snapshot aligned with refreshed creds.
|
||||
|
||||
Long-lived AIAgent instances use `_primary_runtime` to restore the preferred
|
||||
provider after fallback/transport recovery. If WebUI refreshes a cached
|
||||
agent's runtime token but leaves that snapshot stale, a later restore can
|
||||
resurrect the old credential and undo the refresh.
|
||||
"""
|
||||
rt = getattr(agent, '_primary_runtime', None)
|
||||
if not isinstance(rt, dict):
|
||||
return
|
||||
|
||||
base_url = getattr(agent, 'base_url', rt.get('base_url'))
|
||||
api_key = getattr(agent, 'api_key', rt.get('api_key', ''))
|
||||
client_kwargs = dict(getattr(agent, '_client_kwargs', None) or rt.get('client_kwargs', {}) or {})
|
||||
|
||||
rt['base_url'] = base_url
|
||||
rt['api_key'] = api_key
|
||||
rt['client_kwargs'] = client_kwargs
|
||||
|
||||
# The default context compressor usually tracks the primary runtime too;
|
||||
# keep both the live compressor fields and the fallback-restoration
|
||||
# snapshot aligned when those attributes exist.
|
||||
cc = getattr(agent, 'context_compressor', None)
|
||||
if cc is not None:
|
||||
if hasattr(cc, 'base_url'):
|
||||
cc.base_url = base_url
|
||||
if hasattr(cc, 'api_key'):
|
||||
cc.api_key = api_key
|
||||
if 'compressor_base_url' in rt:
|
||||
rt['compressor_base_url'] = getattr(cc, 'base_url', base_url)
|
||||
if 'compressor_api_key' in rt:
|
||||
rt['compressor_api_key'] = getattr(cc, 'api_key', api_key)
|
||||
else:
|
||||
if 'compressor_base_url' in rt:
|
||||
rt['compressor_base_url'] = base_url
|
||||
if 'compressor_api_key' in rt:
|
||||
rt['compressor_api_key'] = api_key
|
||||
|
||||
if getattr(agent, 'api_mode', None) == 'anthropic_messages':
|
||||
if hasattr(agent, '_anthropic_api_key'):
|
||||
rt['anthropic_api_key'] = getattr(agent, '_anthropic_api_key')
|
||||
if hasattr(agent, '_anthropic_base_url'):
|
||||
rt['anthropic_base_url'] = getattr(agent, '_anthropic_base_url')
|
||||
if hasattr(agent, '_is_anthropic_oauth'):
|
||||
rt['is_anthropic_oauth'] = getattr(agent, '_is_anthropic_oauth')
|
||||
|
||||
|
||||
def _run_agent_streaming(
|
||||
session_id,
|
||||
msg_text,
|
||||
@@ -2551,6 +2986,8 @@ def _run_agent_streaming(
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'estimated_cost': 0,
|
||||
'cache_read_tokens': 0,
|
||||
'cache_write_tokens': 0,
|
||||
'context_length': 0,
|
||||
'threshold_tokens': 0,
|
||||
'last_prompt_tokens': 0,
|
||||
@@ -2566,6 +3003,8 @@ def _run_agent_streaming(
|
||||
_usage['input_tokens'] = getattr(_agent, 'session_prompt_tokens', 0) or 0
|
||||
_usage['output_tokens'] = getattr(_agent, 'session_completion_tokens', 0) or 0
|
||||
_usage['estimated_cost'] = getattr(_agent, 'session_estimated_cost_usd', 0) or 0
|
||||
_usage['cache_read_tokens'] = getattr(_agent, 'session_cache_read_tokens', 0) or 0
|
||||
_usage['cache_write_tokens'] = getattr(_agent, 'session_cache_write_tokens', 0) or 0
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
@@ -2578,7 +3017,7 @@ def _run_agent_streaming(
|
||||
pass
|
||||
|
||||
if _session_obj is not None:
|
||||
for _field in ('input_tokens', 'output_tokens', 'estimated_cost', 'context_length', 'threshold_tokens', 'last_prompt_tokens'):
|
||||
for _field in ('input_tokens', 'output_tokens', 'estimated_cost', 'cache_read_tokens', 'cache_write_tokens', 'context_length', 'threshold_tokens', 'last_prompt_tokens'):
|
||||
if not _usage.get(_field):
|
||||
try:
|
||||
_usage[_field] = getattr(_session_obj, _field, 0) or 0
|
||||
@@ -2643,7 +3082,12 @@ def _run_agent_streaming(
|
||||
logger.debug("Failed to put event to queue")
|
||||
|
||||
def _agent_status_callback(kind, message):
|
||||
"""Bridge Agent lifecycle compression status into WebUI SSE."""
|
||||
"""Bridge Agent lifecycle status into WebUI SSE.
|
||||
|
||||
Passes compression events as 'compressing' events and rate-limit/fallback
|
||||
events as 'warning' events so the frontend can surface them to the user.
|
||||
All other lifecycle messages are dropped silently.
|
||||
"""
|
||||
_message = str(message or '').strip()
|
||||
_kind = str(kind or '').strip().lower()
|
||||
if not _message:
|
||||
@@ -2658,12 +3102,17 @@ def _run_agent_streaming(
|
||||
or 'context too large' in _lower
|
||||
)
|
||||
)
|
||||
if not _is_compression_start:
|
||||
if _is_compression_start:
|
||||
put('compressing', {
|
||||
'session_id': session_id,
|
||||
'message': 'Auto-compressing context to continue...',
|
||||
})
|
||||
return
|
||||
put('compressing', {
|
||||
'session_id': session_id,
|
||||
'message': 'Auto-compressing context to continue...',
|
||||
})
|
||||
# Pass through rate-limit and fallback messages so the frontend can
|
||||
# show them as warnings via the existing messages.js 'warning' listener.
|
||||
_is_fallback_notice = _is_fallback_lifecycle_message(_kind, _message)
|
||||
if _is_fallback_notice:
|
||||
put('warning', {'type': 'fallback', 'message': _message})
|
||||
|
||||
# Initialised here (before any code that may raise) so the outer `finally`
|
||||
# block can safely check `if _checkpoint_stop is not None` even when an
|
||||
@@ -3132,12 +3581,9 @@ def _run_agent_streaming(
|
||||
# Named custom providers (custom:slug) may not be resolvable by
|
||||
# hermes_cli.runtime_provider directly. Fall back to config.yaml
|
||||
# custom_providers[] so WebUI can pass explicit creds/base_url.
|
||||
if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"):
|
||||
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
|
||||
if not resolved_api_key and _cp_key:
|
||||
resolved_api_key = _cp_key
|
||||
if not resolved_base_url and _cp_base:
|
||||
resolved_base_url = _cp_base
|
||||
resolved_provider, resolved_api_key, resolved_base_url = _resolve_custom_provider_runtime_overrides(
|
||||
resolved_provider, resolved_api_key, resolved_base_url
|
||||
)
|
||||
|
||||
# Read per-profile config at call time (not module-level snapshot)
|
||||
from api.config import get_config as _get_config
|
||||
@@ -3315,11 +3761,16 @@ def _run_agent_streaming(
|
||||
import hashlib as _hashlib
|
||||
import json as _json
|
||||
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK
|
||||
_credential_pool = _rt.get('credential_pool')
|
||||
_sig_blob = _json.dumps([
|
||||
resolved_model or '',
|
||||
_hashlib.sha256((resolved_api_key or '').encode()).hexdigest()[:16],
|
||||
_agent_cache_api_key_sig(resolved_api_key, _credential_pool),
|
||||
resolved_base_url or '',
|
||||
resolved_provider or '',
|
||||
_rt.get('api_mode') or '',
|
||||
_rt.get('command') or '',
|
||||
_rt.get('args') or [],
|
||||
bool(_credential_pool),
|
||||
_max_iterations_cfg or '',
|
||||
_max_tokens_cfg or '',
|
||||
_fallback_resolved or {},
|
||||
@@ -3342,6 +3793,30 @@ def _run_agent_streaming(
|
||||
agent = _cached[0]
|
||||
SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used
|
||||
logger.debug('[webui] Reusing cached agent for session %s', session_id)
|
||||
# Reopened/cache-hit sessions must register the agent
|
||||
# so later lifecycle commits can find it.
|
||||
try:
|
||||
from api.session_lifecycle import register_agent
|
||||
register_agent(session_id, agent)
|
||||
except Exception:
|
||||
logger.debug("Lifecycle register_agent failed for cached session %s", session_id, exc_info=True)
|
||||
|
||||
if agent is not None:
|
||||
# Refresh volatile runtime credentials selected from provider
|
||||
# pools without discarding cross-turn agent/provider state.
|
||||
if not _refresh_cached_agent_runtime(agent, _agent_kwargs):
|
||||
logger.warning(
|
||||
'[webui] Cached agent runtime could not be safely refreshed; rebuilding agent for session %s',
|
||||
session_id,
|
||||
)
|
||||
try:
|
||||
if getattr(agent, '_session_db', None) is not None:
|
||||
agent._session_db.close()
|
||||
except Exception:
|
||||
pass
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
SESSION_AGENT_CACHE.pop(session_id, None)
|
||||
agent = None
|
||||
|
||||
if agent is not None:
|
||||
# Refresh per-turn callbacks — these close over request-scoped
|
||||
@@ -3381,25 +3856,47 @@ def _run_agent_streaming(
|
||||
agent._interrupt_message = None
|
||||
else:
|
||||
agent = _AIAgent(**_agent_kwargs)
|
||||
# Register the new agent with the memory lifecycle so
|
||||
# its commit_memory_session() can be found later.
|
||||
try:
|
||||
from api.session_lifecycle import register_agent
|
||||
register_agent(session_id, agent)
|
||||
except Exception:
|
||||
logger.debug("Lifecycle register_agent failed for new session %s", session_id, exc_info=True)
|
||||
_evicted_items = []
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)
|
||||
SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used
|
||||
from api.config import SESSION_AGENT_CACHE_MAX
|
||||
while len(SESSION_AGENT_CACHE) > SESSION_AGENT_CACHE_MAX:
|
||||
evicted_sid, evicted_entry = SESSION_AGENT_CACHE.popitem(last=False)
|
||||
# Same FD-leak shape as the cached-agent reuse path
|
||||
# in #1421: the evicted agent's _session_db won't be
|
||||
# released until GC finalizes the agent, which on a
|
||||
# long-running server may be never. Close it
|
||||
# explicitly so the WAL handles release immediately.
|
||||
# (Opus pre-release follow-up to #1421.)
|
||||
try:
|
||||
_evicted_agent = evicted_entry[0] if isinstance(evicted_entry, tuple) else None
|
||||
if _evicted_agent is not None and getattr(_evicted_agent, '_session_db', None) is not None:
|
||||
_evicted_agent._session_db.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug('[webui] Evicted LRU agent from cache: %s', evicted_sid)
|
||||
_evicted_items.append((evicted_sid, evicted_entry))
|
||||
# Commit and close evicted agents outside the cache lock so
|
||||
# concurrent cache users are not blocked by provider I/O.
|
||||
for _evicted_sid, _evicted_entry in _evicted_items:
|
||||
try:
|
||||
_evicted_agent = _evicted_entry[0] if isinstance(_evicted_entry, tuple) else None
|
||||
_should_close_evicted_agent = True
|
||||
if _evicted_agent is not None:
|
||||
try:
|
||||
from api.session_lifecycle import (
|
||||
commit_session_memory as _lifecycle_commit,
|
||||
has_uncommitted_work as _lifecycle_has_uncommitted_work,
|
||||
unregister_agent as _lifecycle_unregister_agent,
|
||||
)
|
||||
_lifecycle_commit(_evicted_sid, agent=_evicted_agent, wait=True)
|
||||
if not _lifecycle_has_uncommitted_work(_evicted_sid):
|
||||
_lifecycle_unregister_agent(_evicted_sid)
|
||||
else:
|
||||
_should_close_evicted_agent = False
|
||||
except Exception:
|
||||
_should_close_evicted_agent = False
|
||||
logger.debug("Lifecycle commit on eviction failed for %s", _evicted_sid, exc_info=True)
|
||||
if _should_close_evicted_agent and _evicted_agent is not None and getattr(_evicted_agent, '_session_db', None) is not None:
|
||||
_evicted_agent._session_db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close evicted agent for session %s", _evicted_sid, exc_info=True)
|
||||
logger.debug('[webui] Evicted LRU agent from cache: %s', _evicted_sid)
|
||||
logger.debug('[webui] Created new agent for session %s', session_id)
|
||||
|
||||
# Store agent instance for cancel/interrupt propagation
|
||||
@@ -3449,9 +3946,11 @@ def _run_agent_streaming(
|
||||
_personality_prompt = '\n'.join(p for p in _parts if p)
|
||||
else:
|
||||
_personality_prompt = str(_pval)
|
||||
# Pass personality via ephemeral_system_prompt (agent's own mechanism)
|
||||
if _personality_prompt:
|
||||
agent.ephemeral_system_prompt = _personality_prompt
|
||||
# Pass WebUI-only runtime guidance via ephemeral_system_prompt
|
||||
# (agent's own mechanism). This preserves any selected personality
|
||||
# while making long tool runs emit real user-visible interim text
|
||||
# through interim_assistant_callback instead of frontend guesses.
|
||||
agent.ephemeral_system_prompt = _webui_ephemeral_system_prompt(_personality_prompt)
|
||||
_pending_started_at = getattr(s, 'pending_started_at', None)
|
||||
# Normal chat-start sets pending_started_at before spawning this thread;
|
||||
# fallback to now only for recovered/legacy flows where that marker is absent
|
||||
@@ -3459,10 +3958,7 @@ def _run_agent_streaming(
|
||||
# Truthy-check covers None, missing-attr, and 0 uniformly.
|
||||
_turn_started_at = _pending_started_at if _pending_started_at else time.time()
|
||||
_previous_messages = list(s.messages or [])
|
||||
_previous_context_messages = _drop_checkpointed_current_user_from_context(
|
||||
_session_context_messages(s),
|
||||
msg_text,
|
||||
)
|
||||
_previous_context_messages = _context_messages_for_new_turn(s, msg_text)
|
||||
_pre_compression_count = getattr(
|
||||
getattr(agent, 'context_compressor', None),
|
||||
'compression_count', 0,
|
||||
@@ -3515,7 +4011,7 @@ def _run_agent_streaming(
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
)
|
||||
@@ -3619,7 +4115,7 @@ def _run_agent_streaming(
|
||||
s.messages = _merge_display_messages_after_agent_result(
|
||||
_previous_messages,
|
||||
_previous_context_messages,
|
||||
_restore_reasoning_metadata(_previous_messages, _result_messages),
|
||||
_restore_display_reasoning_metadata(_previous_messages, _result_messages),
|
||||
msg_text,
|
||||
)
|
||||
# Strip XML tool-call blocks from assistant message content.
|
||||
@@ -3643,13 +4139,19 @@ def _run_agent_streaming(
|
||||
# an empty final_response without raising — the stream would end with
|
||||
# a done event containing zero assistant messages, leaving the user with
|
||||
# no feedback. Emit an apperror so the client shows an inline error.
|
||||
#
|
||||
# Only check NEW messages added by this turn — result.get('messages')
|
||||
# includes the full conversation history, so checking all of them would
|
||||
# match assistant content from prior turns and mask the real failure.
|
||||
# Keep the current-turn assistant detection aligned with the
|
||||
# display-merge logic. A compacted or replayed result payload
|
||||
# is not always a simple append-only suffix, so use the
|
||||
# workspace-aware helper from this branch while still
|
||||
# preserving the pre-turn length for downstream self-heal
|
||||
# checks introduced on master.
|
||||
_all_result_messages = result.get('messages') or []
|
||||
_prev_len = len(_previous_context_messages)
|
||||
_assistant_added = _has_new_assistant_reply(_all_result_messages, _prev_len)
|
||||
_assistant_added = _assistant_reply_added_after_current_turn(
|
||||
_all_result_messages,
|
||||
_previous_context_messages,
|
||||
msg_text,
|
||||
)
|
||||
# _token_sent tracks whether on_token() was called (any streamed text)
|
||||
if not _assistant_added and not _token_sent:
|
||||
if cancel_event.is_set():
|
||||
@@ -3699,12 +4201,9 @@ def _run_agent_streaming(
|
||||
resolved_provider = _heal_rt.get('provider')
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = _heal_rt.get('base_url')
|
||||
if isinstance(resolved_provider, str) and resolved_provider.startswith('custom:'):
|
||||
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
|
||||
if not resolved_api_key and _cp_key:
|
||||
resolved_api_key = _cp_key
|
||||
if not resolved_base_url and _cp_base:
|
||||
resolved_base_url = _cp_base
|
||||
resolved_provider, resolved_api_key, resolved_base_url = _resolve_custom_provider_runtime_overrides(
|
||||
resolved_provider, resolved_api_key, resolved_base_url
|
||||
)
|
||||
# Rebuild agent kwargs and create a fresh agent
|
||||
_agent_kwargs['api_key'] = resolved_api_key
|
||||
_agent_kwargs['base_url'] = resolved_base_url
|
||||
@@ -3726,7 +4225,7 @@ def _run_agent_streaming(
|
||||
_heal_result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
)
|
||||
@@ -3846,11 +4345,15 @@ def _run_agent_streaming(
|
||||
# reference stays alive even after the dict entry is removed.
|
||||
# Concurrent readers that already looked up the old ID will still
|
||||
# see the same Lock object until they release it.
|
||||
_compression_origin_session_id = session_id
|
||||
_compression_continuation_session_id = None
|
||||
_agent_sid = getattr(agent, 'session_id', None)
|
||||
_compressed = False
|
||||
if _agent_sid and _agent_sid != session_id:
|
||||
old_sid = session_id
|
||||
new_sid = _agent_sid
|
||||
_compression_origin_session_id = old_sid
|
||||
_compression_continuation_session_id = new_sid
|
||||
s.session_id = new_sid
|
||||
# Carry profile identity across the compression boundary.
|
||||
# Without this, s.profile stays None on the continuation
|
||||
@@ -3927,8 +4430,13 @@ def _run_agent_streaming(
|
||||
_compression_summary_from_messages(s.messages)
|
||||
or _compression_summary_from_messages(s.context_messages)
|
||||
)
|
||||
if _compression_continuation_session_id is None:
|
||||
_compression_continuation_session_id = s.session_id
|
||||
put('compressed', {
|
||||
'session_id': s.session_id,
|
||||
'session_id': _compression_origin_session_id,
|
||||
'old_session_id': _compression_origin_session_id,
|
||||
'new_session_id': _compression_continuation_session_id,
|
||||
'continuation_session_id': _compression_continuation_session_id,
|
||||
'message': 'Context auto-compressed to continue the conversation',
|
||||
'usage': _live_usage_snapshot(),
|
||||
})
|
||||
@@ -3964,12 +4472,18 @@ def _run_agent_streaming(
|
||||
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0
|
||||
output_tokens = getattr(agent, 'session_completion_tokens', 0) or 0
|
||||
estimated_cost = getattr(agent, 'session_estimated_cost_usd', None)
|
||||
cache_read_tokens = getattr(agent, 'session_cache_read_tokens', 0) or 0
|
||||
cache_write_tokens = getattr(agent, 'session_cache_write_tokens', 0) or 0
|
||||
if input_tokens > 0:
|
||||
s.input_tokens = input_tokens
|
||||
if output_tokens > 0:
|
||||
s.output_tokens = output_tokens
|
||||
if estimated_cost is not None:
|
||||
s.estimated_cost = estimated_cost
|
||||
if cache_read_tokens > 0:
|
||||
s.cache_read_tokens = cache_read_tokens
|
||||
if cache_write_tokens > 0:
|
||||
s.cache_write_tokens = cache_write_tokens
|
||||
# Persist tool-call summaries even when the final message history only
|
||||
# kept bare tool rows and omitted explicit assistant tool_call IDs.
|
||||
tool_calls = _extract_tool_calls_from_messages(
|
||||
@@ -4177,6 +4691,23 @@ def _run_agent_streaming(
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to append completed turn journal event", exc_info=True)
|
||||
if not ephemeral:
|
||||
# ── Memory-provider lifecycle: mark turn completed (CLI parity) ──
|
||||
# Completed, non-ephemeral turns are marked dirty/uncommitted so
|
||||
# boundary drains know there is work. Per CLI semantics, the
|
||||
# actual memory extraction/commit happens only at session boundaries
|
||||
# (new session creation, LRU eviction, shutdown drain) — NOT after
|
||||
# every completed turn. This mirrors Hermes CLI where
|
||||
# run_agent.py::_sync_external_memory_for_turn() records messages
|
||||
# but only AIAgent.commit_memory_session()/shutdown_memory_provider()
|
||||
# trigger extraction via provider on_session_end(). The mark is
|
||||
# in-memory bookkeeping, not provider I/O, so keep it inside the
|
||||
# per-session writeback lock to preserve completed-turn ordering.
|
||||
try:
|
||||
from api.session_lifecycle import mark_turn_completed
|
||||
mark_turn_completed(s.session_id, agent=agent)
|
||||
except Exception:
|
||||
logger.debug("Memory lifecycle mark failed for session %s", s.session_id, exc_info=True)
|
||||
# Sync to state.db for /insights (opt-in setting)
|
||||
try:
|
||||
from api.config import load_settings as _load_settings
|
||||
@@ -4197,6 +4728,8 @@ def _run_agent_streaming(
|
||||
'input_tokens': input_tokens,
|
||||
'output_tokens': output_tokens,
|
||||
'estimated_cost': estimated_cost,
|
||||
'cache_read_tokens': cache_read_tokens,
|
||||
'cache_write_tokens': cache_write_tokens,
|
||||
'duration_seconds': round(_turn_duration_seconds, 3),
|
||||
}
|
||||
if _turn_tps is not None:
|
||||
@@ -4478,12 +5011,9 @@ def _run_agent_streaming(
|
||||
resolved_provider = _heal_rt.get('provider')
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = _heal_rt.get('base_url')
|
||||
if isinstance(resolved_provider, str) and resolved_provider.startswith('custom:'):
|
||||
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
|
||||
if not resolved_api_key and _cp_key:
|
||||
resolved_api_key = _cp_key
|
||||
if not resolved_base_url and _cp_base:
|
||||
resolved_base_url = _cp_base
|
||||
resolved_provider, resolved_api_key, resolved_base_url = _resolve_custom_provider_runtime_overrides(
|
||||
resolved_provider, resolved_api_key, resolved_base_url
|
||||
)
|
||||
# Build a fresh agent with the new credentials
|
||||
_heal_kwargs = dict(_agent_kwargs) if '_agent_kwargs' in dir() else {}
|
||||
_heal_kwargs['api_key'] = resolved_api_key
|
||||
@@ -4505,7 +5035,7 @@ def _run_agent_streaming(
|
||||
_heal_result = _heal_agent.run_conversation(
|
||||
user_message=user_message,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Hermes Web UI -- Self-update checker.
|
||||
|
||||
Checks if the webui and hermes-agent git repos are behind their upstream
|
||||
branches. Results are cached server-side (30-min TTL) so git fetch runs
|
||||
Checks if the webui and hermes-agent git repos are behind their latest
|
||||
release tags. Results are cached server-side (30-min TTL) so git fetch runs
|
||||
at most twice per hour regardless of client count.
|
||||
|
||||
Skips repos that are not git checkouts (e.g. Docker baked images where
|
||||
@@ -86,6 +86,24 @@ def _run_git(args, cwd, timeout=10):
|
||||
return f'git failed to start: {exc}', False
|
||||
|
||||
|
||||
def _dirty_suffix(path: Path, timeout=1) -> str:
|
||||
"""Return a best-effort ``-dirty`` suffix without blocking version display."""
|
||||
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
|
||||
if ok:
|
||||
return ""
|
||||
# diff-index exits 1 with no output for a dirty tree. Timeouts and real git
|
||||
# failures include a diagnostic; skip the suffix so the base version remains.
|
||||
return "-dirty" if not out else ""
|
||||
|
||||
|
||||
def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None:
|
||||
"""Return a fast git version string for a checkout, if available."""
|
||||
out, ok = _run_git(['describe', '--tags', '--always'], path, timeout=timeout)
|
||||
if not (ok and out):
|
||||
return None
|
||||
return out + _dirty_suffix(path, timeout=dirty_timeout)
|
||||
|
||||
|
||||
def _detect_webui_version() -> str:
|
||||
"""Detect the running WebUI version from git or a baked-in fallback file.
|
||||
|
||||
@@ -101,8 +119,8 @@ def _detect_webui_version() -> str:
|
||||
"""
|
||||
# Timeout capped at 3s: git describe on a healthy local repo is <50ms;
|
||||
# a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable.
|
||||
out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], REPO_ROOT, timeout=3)
|
||||
if ok and out:
|
||||
out = _describe_git_version(REPO_ROOT)
|
||||
if out:
|
||||
return out
|
||||
|
||||
# Docker / baked-image fallback: api/_version.py written by CI at build time.
|
||||
@@ -145,8 +163,8 @@ def _detect_agent_version() -> str:
|
||||
# Symmetric with _detect_webui_version() above — `--dirty` flags a
|
||||
# locally-modified checkout so operators can see when their agent has
|
||||
# uncommitted changes vs a clean tag. Per Opus advisor on stage-293.
|
||||
out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], _AGENT_DIR, timeout=3)
|
||||
if ok and out:
|
||||
out = _describe_git_version(Path(_AGENT_DIR))
|
||||
if out:
|
||||
return out
|
||||
|
||||
return 'not detected'
|
||||
@@ -211,15 +229,65 @@ def _detect_default_branch(path):
|
||||
return 'master'
|
||||
|
||||
|
||||
def _check_repo(path, name):
|
||||
"""Check if a git repo is behind its upstream. Returns dict or None."""
|
||||
if path is None or not (path / '.git').exists():
|
||||
def _release_tags(path):
|
||||
"""Return release tags newest-first, using the repo's version-sort order."""
|
||||
out, ok = _run_git(['tag', '--list', 'v*', '--sort=-v:refname'], path)
|
||||
if not (ok and out):
|
||||
return []
|
||||
return [line.strip() for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _current_release_tag(path):
|
||||
"""Return the latest release tag reachable from HEAD, if one exists."""
|
||||
out, ok = _run_git(['describe', '--tags', '--abbrev=0'], path)
|
||||
return out if ok and out else None
|
||||
|
||||
|
||||
def _release_gap(tags, current, latest):
|
||||
"""Count release tags between current and latest in a newest-first list."""
|
||||
if not latest or current == latest:
|
||||
return 0
|
||||
if current in tags:
|
||||
return tags.index(current)
|
||||
return 1
|
||||
|
||||
|
||||
def _check_repo_release(path, name):
|
||||
"""Check if a git repo is behind its latest published release tag."""
|
||||
tags = _release_tags(path)
|
||||
if not tags:
|
||||
return None
|
||||
|
||||
latest_tag = tags[0]
|
||||
current_tag = _current_release_tag(path)
|
||||
behind = _release_gap(tags, current_tag, latest_tag)
|
||||
|
||||
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
|
||||
remote_url = _normalize_remote_url(remote_url)
|
||||
|
||||
return {
|
||||
'name': name,
|
||||
'behind': behind,
|
||||
# GitHub compare URLs accept tag names, and tag-to-tag links are the
|
||||
# clearest "what changed in this release?" view for operators.
|
||||
'current_sha': current_tag,
|
||||
'latest_sha': latest_tag,
|
||||
'branch': latest_tag,
|
||||
'repo_url': remote_url,
|
||||
'release_based': True,
|
||||
'current_version': current_tag,
|
||||
'latest_version': latest_tag,
|
||||
}
|
||||
|
||||
|
||||
def _check_repo_branch(path, name, *, fetch=True):
|
||||
"""Fallback: check if a git repo is behind its upstream branch."""
|
||||
|
||||
# Fetch latest from origin (network call, cached by TTL)
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
|
||||
if fetch:
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
|
||||
|
||||
# Use the current branch's upstream tracking branch, not the repo default.
|
||||
# This avoids false "N updates behind" alerts when the user is on a feature
|
||||
@@ -283,6 +351,24 @@ def _check_repo(path, name):
|
||||
}
|
||||
|
||||
|
||||
def _check_repo(path, name):
|
||||
"""Check if a git repo is behind its latest release. Returns dict or None."""
|
||||
if path is None or not (path / '.git').exists():
|
||||
return None
|
||||
|
||||
# Fetch tags first so update prompts track published releases, not every
|
||||
# development commit that lands on master/main after the latest release.
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
|
||||
|
||||
release_info = _check_repo_release(path, name)
|
||||
if release_info is not None:
|
||||
return release_info
|
||||
|
||||
return _check_repo_branch(path, name, fetch=False)
|
||||
|
||||
|
||||
def check_for_updates(force=False):
|
||||
"""Return cached update status for webui and agent repos."""
|
||||
global _check_in_progress
|
||||
|
||||
@@ -258,8 +258,9 @@ def handle_upload_extract(handler):
|
||||
s = get_session(session_id)
|
||||
except KeyError:
|
||||
return j(handler, {'error': 'Session not found'}, status=404)
|
||||
workspace = Path(s.workspace)
|
||||
result = extract_archive(file_bytes, filename, workspace)
|
||||
session_dir = _session_attachment_dir(session_id)
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = extract_archive(file_bytes, filename, session_dir)
|
||||
return j(handler, {'ok': True, **result})
|
||||
except ValueError as e:
|
||||
return j(handler, {'error': str(e)}, status=400)
|
||||
|
||||
@@ -109,13 +109,15 @@ services:
|
||||
# Same hermes home as the agent — shares config, sessions, state
|
||||
- hermes-home:/home/hermeswebui/.hermes
|
||||
# Agent source mounted where docker_init.bash expects it.
|
||||
# At startup the init script runs:
|
||||
# uv pip install /home/hermeswebui/.hermes/hermes-agent
|
||||
# which installs the agent and all its Python dependencies.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
|
||||
# Mounted read-only — the WebUI only reads this volume to install
|
||||
# the agent's Python dependencies at startup (`uv pip install`).
|
||||
# Read-only enforces that defence-in-depth at the kernel layer.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
|
||||
# Workspace directory — browse and edit files from the WebUI.
|
||||
# Adapt the host path to your project directory.
|
||||
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
|
||||
# ${HOME} is used rather than `~` so the default resolves the same way
|
||||
# across Linux, macOS, WSL2, and Docker Desktop on Windows.
|
||||
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
|
||||
environment:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
@@ -150,5 +152,17 @@ networks:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
# IMPORTANT — upgrading the agent image:
|
||||
# The `hermes-agent-src` volume is initialised from the agent image's
|
||||
# `/opt/hermes` on first `up`, and Docker reuses the volume verbatim on
|
||||
# later runs — even after `docker pull` of a newer agent image. After
|
||||
# upgrading the agent image, run:
|
||||
#
|
||||
# docker compose -f docker-compose.three-container.yml down
|
||||
# docker volume rm <project>_hermes-agent-src
|
||||
# docker compose -f docker-compose.three-container.yml pull
|
||||
# docker compose -f docker-compose.three-container.yml up -d
|
||||
#
|
||||
# The full procedure (and why) is documented in docs/docker.md.
|
||||
hermes-home:
|
||||
hermes-agent-src:
|
||||
@@ -91,14 +91,16 @@ services:
|
||||
# Same hermes home as the agent — shares config, sessions, state
|
||||
- hermes-home:/home/hermeswebui/.hermes
|
||||
# Agent source mounted where docker_init.bash expects it.
|
||||
# At startup the init script runs:
|
||||
# uv pip install /home/hermeswebui/.hermes/hermes-agent
|
||||
# which installs the agent and all its Python dependencies.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
|
||||
# Mounted read-only — the WebUI only reads this volume to install
|
||||
# the agent's Python dependencies at startup (`uv pip install`).
|
||||
# Read-only enforces that defence-in-depth at the kernel layer.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
|
||||
# Workspace directory — browse and edit files from the WebUI.
|
||||
# Adapt the host path to your project directory.
|
||||
# Override with: HERMES_WORKSPACE=/your/path docker compose up
|
||||
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
|
||||
# ${HOME} is used rather than `~` so the default resolves the same way
|
||||
# across Linux, macOS, WSL2, and Docker Desktop on Windows.
|
||||
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
|
||||
environment:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
@@ -129,5 +131,17 @@ networks:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
# IMPORTANT — upgrading the agent image:
|
||||
# The `hermes-agent-src` volume is initialised from the agent image's
|
||||
# `/opt/hermes` on first `up`, and Docker reuses the volume verbatim on
|
||||
# later runs — even after `docker pull` of a newer agent image. After
|
||||
# upgrading the agent image, run:
|
||||
#
|
||||
# docker compose down
|
||||
# docker volume rm <project>_hermes-agent-src
|
||||
# docker compose pull
|
||||
# docker compose up -d
|
||||
#
|
||||
# The full procedure (and why) is documented in docs/docker.md.
|
||||
hermes-home:
|
||||
hermes-agent-src:
|
||||
@@ -185,8 +185,17 @@ chown_home_hermeswebui() {
|
||||
# macOS Docker bind mounts can expose hermes-agent git object packs as
|
||||
# read-only host files. The runtime only needs to read those existing objects;
|
||||
# requiring chown on them makes startup fail before WebUI can run (#2237).
|
||||
#
|
||||
# Multi-container compose (#2470) additionally mounts the entire
|
||||
# hermes-agent-src volume read-only on the WebUI side because the WebUI only
|
||||
# reads it for `uv pip install`. On a :ro mount, chown returns EROFS for any
|
||||
# file inside the subtree, which would propagate to `set -e` and kill startup
|
||||
# before the WebUI can run. Either way, the WebUI never writes to the agent
|
||||
# source — prune the entire hermes-agent path from the chown walk so a
|
||||
# read-only or partially-read-only mount doesn't break the rest of the home
|
||||
# ownership alignment.
|
||||
find /home/hermeswebui \
|
||||
-path "/home/hermeswebui/.hermes/hermes-agent/.git/objects" -prune \
|
||||
-path "/home/hermeswebui/.hermes/hermes-agent" -prune \
|
||||
-o -exec chown -h "${WANTED_UID}:${WANTED_GID}" {} +
|
||||
}
|
||||
|
||||
@@ -382,7 +391,49 @@ else
|
||||
fi
|
||||
done
|
||||
if [ -n "$_agent_src" ]; then
|
||||
uv pip install "$_agent_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hermes-agent's requirements"
|
||||
if [ -w "$_agent_src" ]; then
|
||||
echo ""
|
||||
echo "!! WARNING: hermes-agent source mount is writable from the WebUI container."
|
||||
echo "!! Path: $_agent_src"
|
||||
echo "!! The multi-container compose defaults use a read-only mount for defence-in-depth."
|
||||
echo "!! If this is not an intentional local development checkout, switch the WebUI"
|
||||
echo "!! agent source volume/bind mount to read-only. See docs/rfcs/agent-source-boundary.md."
|
||||
echo ""
|
||||
fi
|
||||
# The agent source can be mounted read-only (see docker-compose.two-container.yml
|
||||
# / docker-compose.three-container.yml — the WebUI only reads this volume to
|
||||
# install the agent's Python dependencies and never writes to it). setuptools'
|
||||
# `egg_info` build step, however, touches `hermes_agent.egg-info/` inside the
|
||||
# source tree even under PEP 517 build isolation, which `EROFS`-fails on a
|
||||
# `:ro` mount and (under `set -e`) kills startup of every multi-container
|
||||
# deploy. Stage the source into a writable tmpfs copy so the build can write
|
||||
# its metadata side-by-side without touching the underlying mount.
|
||||
#
|
||||
# The copy excludes any pre-baked `*.egg-info` / `build` / `dist` artifacts
|
||||
# to avoid the timestamp-update path setuptools takes when one is present,
|
||||
# and `--reflink=auto` makes the copy near-free on overlay2/btrfs where
|
||||
# supported. We rebuild on every container start (the agent source can
|
||||
# change across volume re-init); cost is one rsync of ~10MB of Python source.
|
||||
_stage_src="/tmp/hermes-agent-build"
|
||||
rm -rf "$_stage_src"
|
||||
mkdir -p "$_stage_src"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a \
|
||||
--exclude='*.egg-info' --exclude='build' --exclude='dist' \
|
||||
--exclude='__pycache__' --exclude='.git' \
|
||||
"$_agent_src"/ "$_stage_src"/ \
|
||||
|| error_exit "Failed to stage hermes-agent source to writable build dir"
|
||||
else
|
||||
# Fallback when rsync isn't in the image — straight cp -a, then drop
|
||||
# the build artifacts that would trip setuptools.
|
||||
cp -a "$_agent_src"/. "$_stage_src"/ \
|
||||
|| error_exit "Failed to copy hermes-agent source to writable build dir"
|
||||
rm -rf "$_stage_src"/*.egg-info "$_stage_src"/build "$_stage_src"/dist 2>/dev/null || true
|
||||
find "$_stage_src" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
uv pip install "$_stage_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org \
|
||||
|| error_exit "Failed to install hermes-agent's requirements"
|
||||
rm -rf "$_stage_src"
|
||||
else
|
||||
echo ""
|
||||
echo "!! WARNING: hermes-agent source not found."
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# Project Contracts
|
||||
|
||||
This document is a contributor-facing index for existing Hermes WebUI contracts,
|
||||
RFCs, design constraints, and review expectations. It does not replace the
|
||||
source documents and it does not mark proposals as implemented. Follow each
|
||||
linked document's status and scope.
|
||||
|
||||
Use this file when starting a change so the relevant public contract is visible
|
||||
before code is edited. This first version focuses on documentation routing; it
|
||||
does not change runtime behavior, maintainer policy, bot behavior, or CI gates.
|
||||
|
||||
## Start here
|
||||
|
||||
- [`AGENTS.md`](../AGENTS.md): repository entry point for AI assistants,
|
||||
public-safety rules, and the short redline checklist.
|
||||
- [`CONTRIBUTING.md`](../CONTRIBUTING.md): contribution style, verification,
|
||||
PR description expectations, UI evidence, and project-specific constraints.
|
||||
- [`README.md`](../README.md): product overview, quick start, architecture map,
|
||||
feature inventory, and docs index.
|
||||
- [`CHANGELOG.md`](../CHANGELOG.md): release-note-ready history. Update it when
|
||||
maintainers should carry the change into release notes.
|
||||
|
||||
## Runtime, durability, and state contracts
|
||||
|
||||
- [`docs/rfcs/webui-run-state-consistency-contract.md`](rfcs/webui-run-state-consistency-contract.md):
|
||||
proposed consistency rules for current WebUI streaming, recovery, replay,
|
||||
model-context reconstruction, compression, UI scene/cache, and sidebar metadata
|
||||
repairs. Start here for narrow fixes that keep the existing WebUI execution
|
||||
path.
|
||||
- [`docs/rfcs/hermes-run-adapter-contract.md`](rfcs/hermes-run-adapter-contract.md):
|
||||
proposed event/control contract, runtime-state ownership matrix,
|
||||
acceptance-test catalog, and reversible migration gates for moving WebUI
|
||||
execution behind an adapter boundary. Use this for adapter-seam, control-plane,
|
||||
runner, sidecar, or execution-ownership work; do not treat it as authorization
|
||||
to implement those slices.
|
||||
- [`docs/rfcs/turn-journal.md`](rfcs/turn-journal.md): proposed crash-safe
|
||||
write-ahead journal for browser-originated chat turns.
|
||||
- [`docs/rfcs/README.md`](rfcs/README.md): RFC conventions and current RFC index.
|
||||
|
||||
When a change touches streaming, recovery, replay, compression, context
|
||||
reconstruction, cancellation, approval/clarify, session metadata, or run state,
|
||||
read the relevant RFC before editing. In the PR description, name the state layer
|
||||
or event/control surface affected and include a regression test or manual
|
||||
verification for the relevant invariant.
|
||||
|
||||
Proposed RFCs are review guardrails, not implementation authorization. Do not
|
||||
implement RFC fragments unless the task or tracking issue explicitly asks for
|
||||
that slice.
|
||||
|
||||
## UI, UX, and theme contracts
|
||||
|
||||
- [`DESIGN.md`](../DESIGN.md): design tokens and the current calm-console
|
||||
direction: conversation first, quiet metadata, restrained accents, and
|
||||
progressive disclosure for debugging detail.
|
||||
- [`docs/UIUX-GUIDE.md`](UIUX-GUIDE.md): contributor-facing synthesis of the
|
||||
repository's UI/UX principles, sourced from existing project docs and code
|
||||
comments.
|
||||
- [`docs/ui-ux/index.html`](ui-ux/index.html): message-area inventory wired to
|
||||
the real app stylesheet.
|
||||
- [`docs/ui-ux/two-stage-proposal.html`](ui-ux/two-stage-proposal.html):
|
||||
existing two-stage chat UX proposal for issue #536.
|
||||
- [`THEMES.md`](../THEMES.md): theme and skin guidance; the core palette
|
||||
variable contract lives in `static/style.css`.
|
||||
|
||||
Current appearance has a theme axis (`light`, `dark`, `system`) and a separate
|
||||
skin axis (`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`,
|
||||
`charizard`, `sienna`, `catppuccin`, `nous`) in `static/boot.js` and
|
||||
`static/style.css`. Do not follow stale `data-theme`-only theme guidance unless
|
||||
the current code and tests prove that model still applies.
|
||||
|
||||
For UI or UX work, include before/after evidence, verify relevant responsive
|
||||
states, and prefer stable class/data hooks over one-off visual behavior.
|
||||
|
||||
## Choosing the relevant contract
|
||||
|
||||
Before editing, identify which contract family the task exercises. This is a
|
||||
routing check, not a request to read every document in the repository. Read the
|
||||
documents that match the touched subsystem.
|
||||
|
||||
Use this lightweight note in an issue comment, draft PR, task note, or AI-agent
|
||||
handoff when it helps clarify scope:
|
||||
|
||||
```markdown
|
||||
## Contract Routing
|
||||
|
||||
Task type:
|
||||
Touched areas:
|
||||
Relevant public docs:
|
||||
- `AGENTS.md`
|
||||
- `CONTRIBUTING.md`
|
||||
- `docs/CONTRACTS.md`
|
||||
- <subsystem-specific documents>
|
||||
Scope boundaries:
|
||||
Evidence needed before claiming done:
|
||||
```
|
||||
|
||||
For small, obvious fixes, keep this short. The goal is to avoid routing mistakes,
|
||||
not to create process overhead.
|
||||
|
||||
## PR preparation checklist
|
||||
|
||||
Before opening or updating a PR, verify `CONTRIBUTING.md` against the actual PR
|
||||
body. This checklist applies even when code and tests are already done.
|
||||
|
||||
Required checks:
|
||||
|
||||
- The PR solves one logical problem.
|
||||
- The PR body contains all required sections from `CONTRIBUTING.md`:
|
||||
`Thinking Path`, `What Changed`, `Why It Matters`, `Verification`,
|
||||
`Risks / Follow-ups`, and `Model Used`.
|
||||
- `Model Used` discloses provider/model and notable agent/tool use, or says
|
||||
`None -- human-authored`.
|
||||
- UI/UX changes include before/after evidence and responsive-state coverage.
|
||||
- Runtime/streaming changes name the state layer or invariant being changed and
|
||||
list the regression or manual invariant check.
|
||||
- Onboarding/setup validation used isolated `HERMES_HOME` and
|
||||
`HERMES_WEBUI_STATE_DIR`, unless the human operator explicitly requested real
|
||||
state.
|
||||
- Docs and `CHANGELOG.md` updates are either included or explicitly not needed.
|
||||
- After the GitHub write, read the PR back and verify the headings rendered as
|
||||
intended.
|
||||
|
||||
Green CI plus a focused diff is not sufficient if the PR description or evidence
|
||||
does not match the touched subsystem.
|
||||
|
||||
## Setup, onboarding, and operational references
|
||||
|
||||
- [`TESTING.md`](../TESTING.md): automated test command and manual browser test
|
||||
plan.
|
||||
- [`ARCHITECTURE.md`](../ARCHITECTURE.md): API, module layout, and design
|
||||
constraints.
|
||||
- [`docs/onboarding.md`](onboarding.md): first-run wizard and provider setup.
|
||||
- [`docs/onboarding-agent-checklist.md`](onboarding-agent-checklist.md): safety
|
||||
rules for assistant-led install, reinstall, bootstrap, provider setup, local
|
||||
model setup, Docker onboarding, and WSL onboarding.
|
||||
- [`docs/docker.md`](docker.md): Docker compose setup, common failures, and
|
||||
bind-mount migration.
|
||||
- [`docs/troubleshooting.md`](troubleshooting.md): diagnostic flows for common
|
||||
failures.
|
||||
- [`docs/EXTENSIONS.md`](EXTENSIONS.md): administrator-controlled WebUI
|
||||
extension injection.
|
||||
|
||||
## Quick redline checklist
|
||||
|
||||
Before opening a change for review, confirm:
|
||||
|
||||
- The change solves one logical problem; unrelated refactors are split out.
|
||||
- `AGENTS.md`, this index, and any linked contract for the touched subsystem were
|
||||
read before editing.
|
||||
- Behavior, setup, architecture, testing, or workflow changes update the relevant
|
||||
docs; release-note-ready changes update `CHANGELOG.md`.
|
||||
- UI/UX changes include before/after evidence and cover relevant desktop,
|
||||
narrow, and mobile states.
|
||||
- Runtime, streaming, recovery, replay, compression, or sidebar changes state
|
||||
which layer they mutate and include a regression for the invariant.
|
||||
- New dependencies, build tools, frameworks, or long-lived processes are avoided
|
||||
unless the benefit and rollback story are explicit.
|
||||
- Onboarding/setup validation uses isolated `HERMES_HOME` and
|
||||
`HERMES_WEBUI_STATE_DIR` unless the human operator explicitly asks to use real
|
||||
state.
|
||||
- Secrets, private paths, local-only workflows, and personal notes stay out of
|
||||
tracked docs and examples.
|
||||
|
||||
## Future evolution
|
||||
|
||||
This index is not intended to make the first contract set final. Future PRs may
|
||||
add, revise, split, or retire contracts when real issues, implementation changes,
|
||||
RFC decisions, contributor feedback, or review experience show that guidance is
|
||||
incomplete or stale.
|
||||
|
||||
Potential follow-up areas include session import/export, cron, extensions,
|
||||
security boundaries, Docker/runtime isolation, and lightweight checks that keep
|
||||
key contract links from drifting.
|
||||
@@ -0,0 +1,191 @@
|
||||
# UI/UX Guide
|
||||
|
||||
This document summarizes UI/UX principles that are already visible in the
|
||||
repository. It is a contributor guide, not a new design proposal. Source
|
||||
documents include [`DESIGN.md`](../DESIGN.md), [`README.md`](../README.md),
|
||||
[`THEMES.md`](../THEMES.md), [`docs/ui-ux/index.html`](ui-ux/index.html),
|
||||
[`docs/ui-ux/two-stage-proposal.html`](ui-ux/two-stage-proposal.html), and
|
||||
design comments in `static/style.css`.
|
||||
|
||||
Use this guide when a change touches layout, chat rendering, composer chrome,
|
||||
navigation, theme/skin behavior, responsive behavior, or visual hierarchy. For
|
||||
purely backend changes, use the runtime/state contracts instead.
|
||||
|
||||
## Product shape
|
||||
|
||||
Hermes WebUI is a browser workbench for Hermes Agent with near-CLI parity and a
|
||||
simple implementation shape: Python on the server, vanilla JavaScript in the
|
||||
browser, no build step, no bundler, and no frontend framework.
|
||||
|
||||
The primary layout is three-panel:
|
||||
|
||||
- left sidebar for sessions and navigation,
|
||||
- center panel for chat,
|
||||
- right panel for workspace file browsing and previews.
|
||||
|
||||
Model, profile, workspace, attachments, voice input, context usage, Stop, and
|
||||
Send controls live in the composer footer. Settings and session-level tools live
|
||||
in the Hermes Control Center. Preserve this shape unless the change explicitly
|
||||
justifies a different interaction model.
|
||||
|
||||
## Core feeling: calm developer console
|
||||
|
||||
The main artifact is the conversation. Tool calls, thinking traces, context
|
||||
compaction records, token usage, runtime status, and other internals are useful,
|
||||
but they are transcript metadata. They should sit below user and assistant prose
|
||||
in visual priority.
|
||||
|
||||
Prefer:
|
||||
|
||||
- quiet surfaces,
|
||||
- clear spacing,
|
||||
- restrained accent use,
|
||||
- progressive disclosure for debugging detail,
|
||||
- legible text over decorative chrome.
|
||||
|
||||
Avoid turning the interface into a demo page of colorful cards. Errors,
|
||||
approvals, and other action-required states may be prominent because the user
|
||||
must notice and respond to them.
|
||||
|
||||
## Conversation hierarchy
|
||||
|
||||
A chat turn should read as one coherent story:
|
||||
|
||||
1. User message: right-aligned, compact bubble.
|
||||
2. Assistant content: left-aligned, prose-first, not a heavy bubble.
|
||||
3. Tool, thinking, progress, and context traces: quiet disclosure rows inside or
|
||||
adjacent to the assistant turn.
|
||||
4. Raw logs and verbose details: hidden until explicitly expanded.
|
||||
|
||||
Do not render every internal event as a first-class chat card. A turn that used
|
||||
many tools should summarize the work as inspectable activity, not make the user
|
||||
read a stack of unrelated-looking cards.
|
||||
|
||||
## Tool, thinking, and activity traces
|
||||
|
||||
Tool cards are debug event rows, not chat messages. Show the icon, name, short
|
||||
target or preview, and status first. Arguments, result snippets, and long logs
|
||||
belong behind expansion, with result snippets truncated and full output behind a
|
||||
show-more affordance where needed.
|
||||
|
||||
Thinking and context cards should share the quiet metadata visual family. They
|
||||
should not overpower assistant prose. Collapsed activity summaries should be
|
||||
terse, for example `Activity: 4 tools`, and should not duplicate the thinking
|
||||
area, list every tool name in the summary, or add redundant trailing count
|
||||
badges.
|
||||
|
||||
The existing two-stage proposal in `docs/ui-ux/two-stage-proposal.html` records a
|
||||
compatible direction for long turns: live work can be grouped as a worklog, then
|
||||
settled history can collapse while the final answer reads as the calm
|
||||
conclusion. Treat that page as an existing proposal, not as shipped behavior
|
||||
unless the code and tests prove it is implemented.
|
||||
|
||||
## Typography and content
|
||||
|
||||
Use split typography intentionally:
|
||||
|
||||
- assistant prose uses the same system sans stack as the rest of the UI by
|
||||
default (`--font-ui` in `static/style.css`),
|
||||
- editorial serif assistant prose is historical/proposal or skin-scoped only;
|
||||
do not reintroduce a global assistant serif without explicit design approval
|
||||
plus code and test evidence,
|
||||
- user bubbles and functional UI also use the system sans stack unless a scoped
|
||||
skin intentionally overrides typography,
|
||||
- monospace is for code, file paths, commands, tool names, and compact metadata.
|
||||
|
||||
Keep scale tight. Avoid introducing near-duplicate one-off font sizes, colors,
|
||||
radius values, or spacing values when an existing token works.
|
||||
|
||||
## Color, depth, and shape
|
||||
|
||||
Use one accent at a time. Semantic colors are for semantic state: success,
|
||||
warning, error, and info. Do not mix many bright colors decoratively in the same
|
||||
viewport.
|
||||
|
||||
Use almost no shadows in the transcript. Reserve shadows for popovers,
|
||||
dropdowns, modals, and floating controls. Chat cards should usually use either a
|
||||
subtle border or a subtle tint, not aggressive combinations of both.
|
||||
|
||||
Avoid stacks of nested rounded rectangles. Rows and list items should feel
|
||||
compact; panels and cards may be slightly rounder; true pills are reserved for
|
||||
chips and badges.
|
||||
|
||||
## Composer and controls
|
||||
|
||||
The composer is the command surface. Keep it legible, stable, and focused:
|
||||
|
||||
- no theatrical hover scaling for routine controls,
|
||||
- no ambient chrome that crowds the model/workspace/profile controls,
|
||||
- no new footer buttons on tight layouts without a clear value tradeoff,
|
||||
- keep Stop/Send and context feedback easy to find while composing.
|
||||
|
||||
When adding a control, consider where users will find it on both wide desktop and
|
||||
mobile. If a setting or quota/control surface does not fit in the composer, route
|
||||
it through the appropriate Control Center panel instead of squeezing the footer.
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
Mobile is not an afterthought. The repository documents a responsive layout with
|
||||
a hamburger sidebar, mobile-accessible top tabs, a right-edge file slide-over,
|
||||
full-height chat/composer behavior on phones, and touch-friendly controls.
|
||||
|
||||
For UI changes, verify the relevant states:
|
||||
|
||||
- wide desktop,
|
||||
- ordinary laptop width,
|
||||
- narrow/mobile width,
|
||||
- open and closed side panels when relevant,
|
||||
- long chat content and live streaming when relevant.
|
||||
|
||||
Controls should remain usable at touch sizes, and mobile navigation should not
|
||||
steal chat height unnecessarily.
|
||||
|
||||
## Themes and skins
|
||||
|
||||
Theme and skin work should use the existing variable system. `THEMES.md` points
|
||||
to the core palette variables in `static/style.css`; skin comments in the CSS
|
||||
show the expected pattern for full palette rewrites and accent-only changes.
|
||||
|
||||
Current implementation has two appearance axes, sourced from `static/boot.js`:
|
||||
`theme` is only `light`, `dark`, or `system` and resolves to the `.dark` class
|
||||
for dark mode; `skin` is a separate axis applied with `data-skin` and currently
|
||||
includes `default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`,
|
||||
`charizard`, `sienna`, `catppuccin`, and `nous`. `slate` is both an active skin
|
||||
and a legacy theme-name migration target; `solarized`, `monokai`, `nord`, and
|
||||
`oled` are legacy theme names mapped to current theme/skin pairs. Do not follow
|
||||
stale `data-theme`-only guidance without first proving the current
|
||||
`static/boot.js`, `static/index.html`, and `static/style.css` contracts still
|
||||
support it.
|
||||
|
||||
Do not hardcode new colors, radii, shadows, or typography values into isolated
|
||||
components when a token or existing variable can carry the intent. If a token is
|
||||
missing, explain why a new one is needed.
|
||||
|
||||
## Evidence expected for UI changes
|
||||
|
||||
For any interface or interaction change:
|
||||
|
||||
- include before/after images or a short video,
|
||||
- mention the tested viewport sizes and responsive states,
|
||||
- reference the affected visual inventory or design source when applicable,
|
||||
- add or update tests for behavior, state persistence, or regression-prone DOM
|
||||
structure where practical,
|
||||
- keep stable class or data hooks when they help future visual regression tests.
|
||||
|
||||
## Do / don't summary
|
||||
|
||||
Do:
|
||||
|
||||
- keep the conversation primary,
|
||||
- collapse noisy internals by default when settled,
|
||||
- make debugging details accessible without making them visually dominant,
|
||||
- use existing tokens, variables, and component patterns,
|
||||
- protect action-required states such as errors and approvals.
|
||||
|
||||
Don't:
|
||||
|
||||
- make every tool call look like a separate chat message,
|
||||
- add decorative color or motion without a user-facing reason,
|
||||
- introduce a frontend framework, bundler, or build step for ordinary UI work,
|
||||
- hide important recovery, error, or approval state,
|
||||
- treat proposal mockups as shipped behavior without code/test evidence.
|
||||
@@ -42,7 +42,14 @@ docker compose up -d
|
||||
open http://localhost:8787
|
||||
```
|
||||
|
||||
That's it. Your existing `~/.hermes` directory is mounted, your `~/workspace` is browsable, and the WebUI auto-detects your UID/GID from the mounted volume.
|
||||
That's it for a real personal Docker install. Your existing `~/.hermes`
|
||||
directory is mounted, your `~/workspace` is browsable, and the WebUI
|
||||
auto-detects your UID/GID from the mounted volume.
|
||||
|
||||
For troubleshooting, reinstall, or onboarding reproduction trials, do not mount
|
||||
your real `~/.hermes` unless you intentionally want to test real state. Use an
|
||||
isolated Hermes home and follow
|
||||
[`docs/onboarding-agent-checklist.md`](onboarding-agent-checklist.md) instead.
|
||||
|
||||
## What goes wrong (and how to fix it)
|
||||
|
||||
@@ -166,7 +173,52 @@ The two- and three-container setups use **named Docker volumes** (not bind mount
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
The WebUI container doesn't ship with the agent's Python deps — at startup it runs `uv pip install /home/hermeswebui/.hermes/hermes-agent` to install them from the shared volume.
|
||||
The WebUI container doesn't ship with the agent's Python deps — at startup it runs `uv pip install /home/hermeswebui/.hermes/hermes-agent` to install them from the shared volume. The WebUI mount is read-only; the agent container is the only writer.
|
||||
|
||||
## Upgrading the agent container
|
||||
|
||||
The `hermes-agent-src` named volume is initialised from the agent image's `/opt/hermes` on first `up`. Docker reuses the volume verbatim on every subsequent `up` — **even after `docker pull` of a newer agent image**. The cached volume content masks the new image's source tree, so a fresh `docker pull` of `nousresearch/hermes-agent:latest` does not by itself give you the new agent code, dependencies, or entrypoint.
|
||||
|
||||
This is the root cause of [#1416](https://github.com/nesquena/hermes-webui/issues/1416): the symptom looked like a missing entrypoint, but the entrypoint was actually present in the new image and hidden behind the stale named volume.
|
||||
|
||||
To upgrade the agent image cleanly, drop the source volume before recreating:
|
||||
|
||||
```bash
|
||||
# Two-container setup
|
||||
docker compose -f docker-compose.two-container.yml down
|
||||
docker volume rm <project>_hermes-agent-src
|
||||
docker compose -f docker-compose.two-container.yml pull
|
||||
docker compose -f docker-compose.two-container.yml up -d
|
||||
|
||||
# Three-container setup
|
||||
docker compose -f docker-compose.three-container.yml down
|
||||
docker volume rm <project>_hermes-agent-src
|
||||
docker compose -f docker-compose.three-container.yml pull
|
||||
docker compose -f docker-compose.three-container.yml up -d
|
||||
```
|
||||
|
||||
Replace `<project>` with your Compose project name (the parent directory by default; check with `docker volume ls`). The `hermes-home` volume (config, sessions, state) is left untouched — only `hermes-agent-src` (the agent's installed Python source) is recreated.
|
||||
|
||||
> The single-container setup (`docker-compose.yml`) does not use `hermes-agent-src` and is not affected by this upgrade pattern — pulling a newer WebUI image and `docker compose up -d --force-recreate` is sufficient.
|
||||
|
||||
## What the multi-container setup isolates (and what it doesn't)
|
||||
|
||||
The two- and three-container setups give you **process, network, and resource isolation** between the gateway and the chat UI:
|
||||
|
||||
- Each service has its own PID namespace and lifecycle — the agent process can crash without taking down the chat UI and vice versa.
|
||||
- The gateway API (port 8642) is bound by the agent service only; the WebUI cannot bind it. Other containers reach the gateway via the `hermes-net` Docker network.
|
||||
- Resource limits (`deploy.resources.limits` in `docker-compose.three-container.yml`) apply per service, so you can cap the agent independently of the dashboard.
|
||||
- Restart policies, log streams, and container health checks are scoped per service.
|
||||
|
||||
What multi-container does **not** isolate:
|
||||
|
||||
- **Filesystem boundary.** Both services share `hermes-home` (config, sessions, state), and the WebUI mounts the agent's installed source from `hermes-agent-src`. The WebUI mount is read-only (since v0.51.84), but the agent service still has write access, and both services share the home volume.
|
||||
- **UID/GID boundary.** Both services default to `${UID:-1000}` so files written by one are readable by the other. If you align them to different UIDs you'll get permission errors on the shared volume.
|
||||
- **Trust boundary on the agent source.** The WebUI installs Python dependencies from the shared `hermes-agent-src` volume at startup. The read-only mount means a compromised WebUI cannot rewrite the agent source, but it does run code from that volume.
|
||||
|
||||
If you need **filesystem isolation** between the chat UI and the agent (e.g. you don't trust the WebUI to read agent state), the multi-container setup is not enough — run the agent on a separate host and connect the WebUI to it via the gateway HTTP API. If you don't need any boundary, the single-container setup is simpler.
|
||||
|
||||
The direct source mount is a compatibility bridge, not the long-term API contract. The current source/API boundary inventory and decoupling task list live in [`docs/rfcs/agent-source-boundary.md`](rfcs/agent-source-boundary.md) for [#2453](https://github.com/nesquena/hermes-webui/issues/2453). If you customize the compose files with bind mounts, keep the WebUI-side agent source mount read-only unless you are intentionally doing local development; `docker_init.bash` warns at startup when that path is writable.
|
||||
|
||||
## Bind-mount migration (advanced)
|
||||
|
||||
@@ -205,6 +257,7 @@ volumes:
|
||||
|
||||
## Related issues
|
||||
|
||||
- #1416 — agent-image upgrade requires removing `hermes-agent-src` named volume (see [Upgrading the agent container](#upgrading-the-agent-container))
|
||||
- #1389 — `HERMES_HOME_MODE` override (fixed in v0.50.254 — agent honors `HERMES_SKIP_CHMOD` and `HERMES_HOME_MODE`)
|
||||
- #1399 — UID alignment in compose files (fixed in v0.50.260 via PR #1428 + this guide)
|
||||
- #858 — two-container `/opt/hermes` path confusion
|
||||
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,70 @@
|
||||
# Agent Source Boundary and API Decoupling Inventory
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Created:** 2026-05-17
|
||||
- **Tracking issue:** [#2453](https://github.com/nesquena/hermes-webui/issues/2453)
|
||||
|
||||
## Problem
|
||||
|
||||
The WebUI currently depends on Hermes Agent Python source being importable at
|
||||
runtime. In local installs this usually means a neighboring checkout; in the
|
||||
multi-container Docker setup it means the WebUI reads the `hermes-agent-src`
|
||||
volume that the agent container also uses.
|
||||
|
||||
That source mount is a compatibility bridge, not the desired long-term contract.
|
||||
Even when mounted read-only on the WebUI side, it couples WebUI releases to
|
||||
Hermes Agent internal module layout and makes the multi-container setup look more
|
||||
isolated than it really is.
|
||||
|
||||
## Current safety posture
|
||||
|
||||
- The multi-container compose files mount `hermes-agent-src` read-only into the
|
||||
WebUI service by default.
|
||||
- `docker_init.bash` prunes the agent source subtree from `chown` so read-only
|
||||
mounts do not break startup.
|
||||
- If an operator overrides the compose files with a mutable agent-source mount,
|
||||
startup now emits a notable warning. The WebUI still starts because local dev
|
||||
checkouts and custom deployments may intentionally be writable, but the warning
|
||||
makes the reduced boundary explicit.
|
||||
|
||||
## Source-access inventory
|
||||
|
||||
These are the current WebUI capabilities that still rely on Agent source or
|
||||
`hermes_cli`/`agent` modules being importable. Each item should eventually move
|
||||
behind an explicit, versioned Agent API or a packaged library contract that does
|
||||
not require mounting the live source checkout.
|
||||
|
||||
| WebUI capability | Current dependency | Desired API / contract | Notes |
|
||||
|---|---|---|---|
|
||||
| Browser chat execution | `run_agent.AIAgent` imported by `api/streaming.py` | Run lifecycle API: start, observe, status, cancel, approval, clarify, final usage | Covered by the runtime-adapter migration in [#1925](https://github.com/nesquena/hermes-webui/issues/1925), but still source-backed today. |
|
||||
| Runtime event rendering | WebUI callbacks around Agent token/reasoning/tool events | Stable event envelope for tokens, reasoning, progress, tool lifecycle, approvals, clarify, errors, and final usage | The existing run-adapter RFC describes the browser-facing shape; Agent still needs a durable producer contract. |
|
||||
| Profile list/create/delete/seed | `hermes_cli.profiles` from `api/profiles.py` | Profile management API with profile metadata, env/runtime context, seed/delete operations, and validation errors | WebUI has fallback filesystem handling for some operations, but feature parity follows Hermes CLI internals. |
|
||||
| Goal command state | `hermes_cli.goals` from `api/goals.py` | Goal CRUD/control API: get, save, pause/resume/clear, and status | Should preserve current `/goal` WebUI behavior without direct module import. |
|
||||
| Slash command registry and plugin commands | `hermes_cli.commands` and `hermes_cli.plugins` from `api/commands.py` | Command/plugin capability discovery API scoped by active profile | WebUI should render command help from a stable capability response. |
|
||||
| Provider/auth/model catalogs | `hermes_cli.models`, `hermes_cli.auth`, and `agent.credential_pool` from `api/config.py` | Provider registry, model catalog, auth status, OAuth/credential-pool status APIs | WebUI has static fallbacks, but exact parity and custom provider state come from Agent internals. |
|
||||
| Redaction helper parity | `agent.redact.redact_sensitive_text` from `api/helpers.py` | Redaction service/library contract with signature/version compatibility | WebUI keeps a fallback redactor because this import has changed before. |
|
||||
| CLI/Gateway session bridge | Agent `state.db` schema and gateway metadata read by sidebar/session helpers | Session listing/transcript/metadata API for non-WebUI-originated sessions | Direct SQLite/schema coupling should narrow over time, especially for messaging/email/gateway sessions. |
|
||||
|
||||
## Decoupling task list
|
||||
|
||||
1. Keep the Docker default safe: WebUI-side `hermes-agent-src` stays read-only in
|
||||
two- and three-container compose files.
|
||||
2. Keep documenting the boundary honestly: multi-container isolates process,
|
||||
network, and resources, not filesystem/source compatibility.
|
||||
3. Warn loudly when the WebUI container sees a writable agent-source mount in
|
||||
Docker, because that weakens the defense-in-depth posture.
|
||||
4. Convert runtime execution first through the #1925 RuntimeAdapter path instead
|
||||
of adding new direct imports.
|
||||
5. For each inventory row, file or link a follow-up that defines the Agent API
|
||||
response shape before replacing the import.
|
||||
6. Do not claim the source mount can be removed until chat execution, provider
|
||||
catalogs/auth status, profiles, goals, commands/plugins, redaction, and
|
||||
imported Agent/Gateway sessions all have stable replacement contracts.
|
||||
|
||||
## Non-goals for this slice
|
||||
|
||||
- Do not remove `HERMES_WEBUI_AGENT_DIR`.
|
||||
- Do not break local source-checkout development.
|
||||
- Do not fail startup solely because the agent source is writable.
|
||||
- Do not replace the runtime adapter or Hermes Agent API in this document-only
|
||||
inventory slice.
|
||||
@@ -4,7 +4,7 @@
|
||||
- **Author:** @Michaelyklam
|
||||
- **Updated by:** @franksong2702
|
||||
- **Created:** 2026-05-11
|
||||
- **Revised:** 2026-05-14
|
||||
- **Revised:** 2026-05-17
|
||||
- **Tracking issue:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925)
|
||||
|
||||
## Credit and Scope
|
||||
@@ -25,8 +25,11 @@ runtime boundary instead of remaining scattered through the main WebUI request
|
||||
process.
|
||||
|
||||
This document is intentionally a reviewable spec and migration gate. It should be
|
||||
accepted before any implementation PR changes the streaming hot path, introduces a
|
||||
runner process, or moves cancellation / approval / clarify control flow.
|
||||
accepted before any implementation PR for this adapter direction changes the
|
||||
streaming hot path, introduces a runner process, or moves a new approval /
|
||||
clarify / queue / goal control path. Narrow current-path bug fixes that do not
|
||||
introduce a new runtime boundary can still proceed under the WebUI run-state
|
||||
consistency contract and the relevant issue scope.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -49,6 +52,53 @@ The immediate goal is not to build a sidecar. The immediate goal is to define th
|
||||
browser contract, classify current runtime state, and gate the first reversible
|
||||
journal slice.
|
||||
|
||||
## Current Gate State — 2026-05-18
|
||||
|
||||
Slice 1 is now past the first active validation gate:
|
||||
|
||||
- #2283 shipped the run-journal replay layer in v0.51.71.
|
||||
- A 100-trial synthetic replay/restart validation pass against current
|
||||
`origin/master` passed on 2026-05-16. The matrix covered completed-run replay,
|
||||
interrupted stale-pending recovery, fresh-pending grace handling, StreamChannel
|
||||
reconnect ordering, duplicate-prevention merge behavior, many-session recovery,
|
||||
large-journal derivation, and stream-to-turn-id lifecycle linking.
|
||||
- The focused regression set
|
||||
`tests/test_turn_journal.py tests/test_turn_journal_lifecycle.py tests/test_stale_stream_pending_recovery.py`
|
||||
also passed on the same worktree.
|
||||
- #2393, shipped through v0.51.76, capped live chat token SSE transports to the
|
||||
selected conversation pane. Background sessions now rely on existing
|
||||
status/replay/reattach behavior instead of keeping one live `/api/chat/stream`
|
||||
EventSource per active session.
|
||||
|
||||
This evidence did not prove the future runner/sidecar path. It did unblock the
|
||||
adapter-seam work:
|
||||
|
||||
- #2416 shipped the Slice 2 RuntimeAdapter seam contract.
|
||||
- #2424 shipped the default-off `legacy-journal` RuntimeAdapter seam in
|
||||
v0.51.81.
|
||||
- #2438 shipped the response-shape parity follow-up in v0.51.83, keeping the
|
||||
adapter flag from expanding `/api/chat/start`'s public JSON contract.
|
||||
- #2469 shipped the Slice 3a cancel-control gate in v0.51.85.
|
||||
- #2479 shipped the first Slice 3a implementation in v0.51.86, routing Stop
|
||||
Generation through `RuntimeAdapter.cancel_run(...)` only when
|
||||
`HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled.
|
||||
- #2487 shipped the Slice 3b approval/clarify gate, and #2496 shipped approval /
|
||||
clarify response routing through the adapter seam in v0.51.89.
|
||||
- #2509 shipped the Slice 3c queue/continue + goal gate in v0.51.90.
|
||||
- #2544 shipped the first Slice 3c implementation in v0.51.91. The goal
|
||||
route now uses `RuntimeAdapter.update_goal(...)` only when
|
||||
`HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled, while preserving the
|
||||
legacy-direct response shape and leaving post-turn goal evaluation in the
|
||||
existing agent loop.
|
||||
|
||||
The next gate is still not the runner/sidecar by default. Slice 3c's goal route
|
||||
is shipped, and `queue_message(...)` remains a staged protocol method. Queue /
|
||||
continue routing needs an explicit follow-up contract because the legacy `/queue`
|
||||
path is browser-side queue/drain behavior today; no new server-side queue endpoint
|
||||
or queue scheduler should be added just for adapter symmetry. If maintainers want
|
||||
queue/continue to move before Slice 4, that follow-up should specify the exact
|
||||
legacy entry point, response shape, and ordering/idempotency contract first.
|
||||
|
||||
## Goals
|
||||
|
||||
- Preserve the current rich WebUI workbench experience.
|
||||
@@ -269,6 +319,12 @@ Success criterion:
|
||||
|
||||
### Slice 2: Adapter interface over the journaled legacy path
|
||||
|
||||
Status as of 2026-05-17: shipped. PR #2416 defined the adapter-seam contract,
|
||||
PR #2424 added the default-off `LegacyJournalRuntimeAdapter` seam, and PR #2438
|
||||
kept `/api/chat/start` response shape identical between `legacy-direct` and the
|
||||
flagged `legacy-journal` path. Slice 2 remains a reversible boundary change, not
|
||||
a sidecar or execution-ownership move.
|
||||
|
||||
Scope:
|
||||
|
||||
- introduce the `RuntimeAdapter` interface only after Slice 1 proves replay,
|
||||
@@ -279,8 +335,137 @@ Scope:
|
||||
|
||||
Revert path: switch the feature flag back to direct legacy path.
|
||||
|
||||
#### Slice 2 interface contract
|
||||
|
||||
The Slice 2 seam should introduce a deliberately small `RuntimeAdapter` boundary
|
||||
without changing the execution backend. The first implementation is a
|
||||
`LegacyJournalRuntimeAdapter` that delegates to the existing WebUI-owned
|
||||
streaming path and reads the Slice 1 journal for status/replay. That makes the
|
||||
adapter a protocol translator over the current backend, not a new runtime owner.
|
||||
|
||||
Minimal interface shape:
|
||||
|
||||
```python
|
||||
class RuntimeAdapter:
|
||||
def start_run(self, request: StartRunRequest) -> RunStartResult: ...
|
||||
def observe_run(self, run_id: str, *, cursor: str | None = None) -> RunEventStream: ...
|
||||
def get_run(self, run_id: str) -> RunStatus: ...
|
||||
def cancel_run(self, run_id: str) -> ControlResult: ...
|
||||
def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult: ...
|
||||
def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult: ...
|
||||
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult: ...
|
||||
def update_goal(
|
||||
self,
|
||||
session_id: str,
|
||||
action: Literal["set", "pause", "resume", "clear", "status", "edit"],
|
||||
text: str | None = None,
|
||||
) -> ControlResult: ...
|
||||
```
|
||||
|
||||
`queue_message` is named for the legacy queued-message payload shape: it accepts
|
||||
follow-up chat text rather than arbitrary runtime input. The method name does not
|
||||
require a new HTTP route. Today `/queue` is primarily browser-side queue/drain
|
||||
behavior; the adapter method enters the protocol so a later queue/continue slice
|
||||
has a typed control surface, but route wiring remains deliberately staged until
|
||||
the exact legacy entry point and ordering/idempotency contract are explicit.
|
||||
|
||||
For `update_goal`, the `action` argument is the bounded adapter capability label.
|
||||
During the legacy-journal slice, the legacy goal parser still receives the full
|
||||
`text` payload and remains authoritative for details such as the body of
|
||||
`set <goal text>`. Future slices must not route goal semantics from `action`
|
||||
alone; doing so would drop the goal body and change `/api/goal` behavior.
|
||||
|
||||
Required data classes / payload fields:
|
||||
|
||||
| Type | Required fields | Notes |
|
||||
|---|---|---|
|
||||
| `StartRunRequest` | `session_id`, `message`, `attachments`, `workspace`, `profile`, `provider`, `model`, `toolsets`, `source`, `metadata` | Mirrors current `/api/chat/start` inputs without introducing new behavior. |
|
||||
| `RunStartResult` | `run_id`, `session_id`, `stream_id`, `status`, `started_at`, `cursor`, `active_controls` | `stream_id` may remain the legacy stream id during Slice 2. |
|
||||
| `RunStatus` | `run_id`, `session_id`, `status`, `last_event_id`, `terminal_state`, `active_controls`, `pending_approval_id`, `pending_clarify_id` | Backed by live legacy state plus journal/session metadata. |
|
||||
| `RunEventStream` | ordered events matching Artifact 1, resumable from cursor | Can be implemented by existing SSE + journal replay at first. |
|
||||
| `ControlResult` | `accepted`, `status`, `event_id`, `safe_message`, optional internal `payload` | Controls may still call existing handlers in Slice 2. Public HTTP responses must not leak adapter-only fields unless a later RFC expands them. |
|
||||
|
||||
The interface is intentionally narrower than a runner. It does not own `AIAgent`,
|
||||
tool execution, callback queues, cancellation flags, approval callbacks, or
|
||||
clarify callbacks in Slice 2. Those remain in the legacy path until their
|
||||
individual migration slices.
|
||||
|
||||
#### Slice 2 feature flag and revert contract
|
||||
|
||||
Slice 2 should be guarded by one WebUI-local setting/environment flag, for
|
||||
example `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` with default
|
||||
`legacy-direct` until the seam is proven. The flag selects only the route/adapter
|
||||
entry point:
|
||||
|
||||
```text
|
||||
legacy-direct -> current /api/chat/start and /api/chat/stream path
|
||||
legacy-journal -> RuntimeAdapter facade over the same legacy execution path + journal
|
||||
```
|
||||
|
||||
Reverting must be operationally boring:
|
||||
|
||||
1. set the flag back to `legacy-direct`,
|
||||
2. restart WebUI if needed,
|
||||
3. existing session transcripts and journal files remain readable,
|
||||
4. no migration or data deletion is required.
|
||||
|
||||
The PR that introduces the seam should include a source-level regression that
|
||||
the default path remains `legacy-direct` and that the adapter flag is the only
|
||||
way the new entry point is selected.
|
||||
|
||||
#### Slice 2 backend mapping
|
||||
|
||||
| Adapter method | Slice 2 backend | Explicit non-goal |
|
||||
|---|---|---|
|
||||
| `start_run` | call the existing chat-start preparation and legacy `_run_agent_streaming` path | do not move `AIAgent` construction or thread ownership |
|
||||
| `observe_run` | combine existing live SSE fan-out with journal replay cursor semantics | do not build a second renderer or event protocol |
|
||||
| `get_run` | derive status from session metadata, live stream presence, and journal terminal state | do not make `STREAMS` authoritative for durable run existence |
|
||||
| `cancel_run` | delegate to existing cancel handler/control path | do not redesign cancellation semantics yet |
|
||||
| `respond_approval` | delegate to existing approval response path | do not persist approval callbacks in the main server as a new adapter-owned queue |
|
||||
| `respond_clarify` | delegate to existing clarify response path | do not persist clarify callbacks in the main server as a new adapter-owned queue |
|
||||
| `queue_message` | delegate to existing queue/continue path when that slice is accepted | do not invent a parallel continuation buffer or run scheduler |
|
||||
| `update_goal` | delegate to existing goal command/control path when that slice is accepted | do not move goal evaluation or continuation ownership into the adapter |
|
||||
|
||||
Any implementation that needs a new long-lived queue, agent cache, cancellation
|
||||
registry, or callback registry inside the main WebUI process is out of scope for
|
||||
Slice 2 and should become a spec amendment before code lands.
|
||||
|
||||
#### Slice 2 acceptance tests
|
||||
|
||||
Before the seam is enabled by default, tests should prove at least:
|
||||
|
||||
- the `RuntimeAdapter` interface exists and all methods are implemented by the
|
||||
`LegacyJournalRuntimeAdapter`;
|
||||
- the default route remains the legacy direct path unless the adapter flag is
|
||||
explicitly enabled;
|
||||
- `start_run` returns the same browser-facing `stream_id` / `session_id` shape as
|
||||
`/api/chat/start` for a synthetic request;
|
||||
- `observe_run(..., cursor=...)` preserves the existing journal replay ordering
|
||||
and duplicate-prevention behavior;
|
||||
- `get_run` distinguishes live stream, completed, failed, cancelled, and stale /
|
||||
interrupted states using current live state plus journal/session metadata;
|
||||
- `cancel_run` delegates to the current cancellation path and still emits one
|
||||
terminal result;
|
||||
- approval and clarify methods are present but documented as delegated legacy
|
||||
controls until their migration slices;
|
||||
- disabling the flag returns to the old route path without changing session or
|
||||
journal data.
|
||||
|
||||
These tests are adapter-seam tests, not runner-survives-restart tests. The
|
||||
execution-survives-WebUI-restart gate remains deferred to Slice 4.
|
||||
|
||||
### Slice 3: Control migration
|
||||
|
||||
Status as of 2026-05-18: Slice 3a cancel routing shipped in v0.51.86 via #2479,
|
||||
Slice 3b approval/clarify routing shipped in v0.51.89 via #2496 / #2507, and
|
||||
the Slice 3c queue/continue + goal gate shipped in v0.51.90 via #2509.
|
||||
Cancel was the smallest control-plane migration because it already had one clear
|
||||
browser affordance, one active-run target, and an existing legacy handler to
|
||||
delegate to. Approval and clarify then proved the same protocol-translator shape
|
||||
for user-mediated callback controls. Queue/continue and goal are the final
|
||||
pre-runner control migration because they can change run lifecycle semantics
|
||||
rather than just resolve an already-pending control.
|
||||
|
||||
Scope:
|
||||
|
||||
- move cancel first,
|
||||
@@ -292,6 +477,197 @@ Scope:
|
||||
Revert path: per-control feature flags or route-level fallback to legacy control
|
||||
handlers.
|
||||
|
||||
#### Slice 3a: Cancel control gate
|
||||
|
||||
The first control migration should route Stop Generation through the
|
||||
`RuntimeAdapter.cancel_run(...)` seam while preserving the current legacy cancel
|
||||
semantics. It should not introduce a new cancellation registry, worker-owned
|
||||
signal table, or sidecar boundary. During this slice, `cancel_run` is still a
|
||||
protocol translator over the existing cancellation path.
|
||||
|
||||
Acceptance properties:
|
||||
|
||||
1. **Same visible result as legacy Stop Generation.** A cancelled turn still
|
||||
emits one terminal cancelled/interrupted state and preserves any already
|
||||
streamed partial assistant content according to the existing cancellation
|
||||
contract.
|
||||
2. **Adapter flag is behavior-preserving.** With
|
||||
`HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal`, Stop Generation uses
|
||||
`RuntimeAdapter.cancel_run(...)`; with the default `legacy-direct` path, the
|
||||
current route remains available as fallback.
|
||||
3. **No new runtime-surrogate state.** The implementation must not add a second
|
||||
`CANCEL_FLAGS`-like map, cached `AIAgent` table, long-lived queue, or local
|
||||
callback registry inside the main WebUI process.
|
||||
4. **Journal/status coherence.** After cancellation, replay and session reload
|
||||
classify the turn as cancelled/interrupted rather than stale/unknown, and the
|
||||
terminal state is visible through the same journal/session diagnostic surface
|
||||
used by Slice 1.
|
||||
5. **Idempotent duplicate cancel.** Repeating cancel for the same run should be
|
||||
safe: one terminal result is recorded, later attempts return a bounded
|
||||
`ControlResult` such as `not-active` rather than creating extra terminal
|
||||
events or resurrecting stale stream state.
|
||||
|
||||
Suggested regression coverage:
|
||||
|
||||
- a route/source test proving the flagged cancel path calls the adapter seam and
|
||||
the default path remains the legacy route;
|
||||
- an adapter unit test proving `cancel_run` delegates exactly once and returns a
|
||||
bounded `ControlResult` for unsupported/not-active runs;
|
||||
- an existing cancellation preservation suite run (for example the partial-output
|
||||
and cancelled-turn status tests) under the adapter flag or an equivalent
|
||||
synthetic harness;
|
||||
- a replay/session-load assertion that a cancelled run's terminal state remains
|
||||
classifiable from the journal/session surface after reload.
|
||||
|
||||
Non-goals for Slice 3a:
|
||||
|
||||
- no approval or clarify migration;
|
||||
- no queue/continue or goal migration;
|
||||
- no runner process, sidecar, or execution-survives-WebUI-restart claim;
|
||||
- no public `/api/chat/start` response-shape expansion for adapter-only fields.
|
||||
|
||||
#### Slice 3b: Approval and clarify control gate
|
||||
|
||||
The next control migration should cover approval and clarify together as one
|
||||
gate, but not necessarily one implementation commit. They are distinct browser
|
||||
widgets, but architecturally they share the same high-risk shape: the agent loop
|
||||
pauses on a live callback, the browser presents a user-mediated decision, and the
|
||||
runtime must resume from a bounded response without orphaning callback state.
|
||||
|
||||
During Slice 3b, `RuntimeAdapter.respond_approval(...)` and
|
||||
`RuntimeAdapter.respond_clarify(...)` remain protocol translators over the
|
||||
existing legacy callback paths. They must not create a second approval queue,
|
||||
clarify queue, callback registry, pending-prompt table, or runner-owned wait loop
|
||||
inside the main WebUI process.
|
||||
|
||||
Acceptance properties:
|
||||
|
||||
1. **Same visible result as legacy approval / clarify.** Existing approval cards,
|
||||
clarify prompts, choices, denial paths, and resumed-agent behavior remain
|
||||
unchanged for users. The adapter flag changes only the route/control entry
|
||||
point.
|
||||
2. **Stable response contracts.** Existing approval and clarify HTTP endpoints
|
||||
keep their current browser-facing response shapes. Adapter-only fields such as
|
||||
internal status strings, callback ids, or active-control metadata must not leak
|
||||
into public responses unless a later RFC explicitly expands the contract.
|
||||
3. **Bounded missing-prompt behavior.** Responding to a non-existent, already
|
||||
resolved, stale, or expired approval/clarify id returns a bounded
|
||||
`ControlResult` such as `not-active` / `expired` / `unsupported`; it must not
|
||||
block the request, recreate a callback, or synthesize a success path.
|
||||
4. **Replayable request and resolution events.** Approval/clarify request and
|
||||
resolution events remain journal-visible so reload/reconnect can show the last
|
||||
safe state. Slice 3b does not have to make pending approvals survive a WebUI
|
||||
process restart while execution is still in-process; that property belongs to
|
||||
the runner/sidecar gate.
|
||||
5. **No new runtime-surrogate state.** The implementation must not add new
|
||||
process-local global maps, long-lived queues, or callback registries under
|
||||
adapter-specific names. If the existing legacy callback path needs more state
|
||||
to satisfy the route, stop and amend this RFC before landing code.
|
||||
6. **Idempotent duplicate responses.** Repeating the same approve/deny/clarify
|
||||
response is safe: the runtime accepts at most one response for the pending
|
||||
request, records one resolution event, and later attempts return bounded
|
||||
not-active/expired status without resuming the run twice.
|
||||
|
||||
Suggested regression coverage:
|
||||
|
||||
- route/source tests proving flagged approval and clarify response paths call the
|
||||
adapter seam while the default path remains the existing legacy handler;
|
||||
- adapter unit tests proving `respond_approval` and `respond_clarify` delegate
|
||||
exactly once, return accepted/not-active/unsupported `ControlResult` values, and
|
||||
never expose unsafe internal strings to the browser response;
|
||||
- journal/session-load assertions that request and resolution events remain
|
||||
replayable and renderable after reconnect;
|
||||
- duplicate-response tests for approval and clarify ids;
|
||||
- existing approval/clarify UI/static tests under default legacy mode to prove no
|
||||
browser contract drift.
|
||||
|
||||
Non-goals for Slice 3b:
|
||||
|
||||
- no queue/continue or goal migration;
|
||||
- no runner process, sidecar, or execution-survives-WebUI-restart claim;
|
||||
- no persistence of pending approval/clarify callbacks outside the current legacy
|
||||
callback model;
|
||||
- no change to approval risk classification, allowed choices, or clarify prompt
|
||||
UX;
|
||||
- no public chat-start/status response-shape expansion for adapter-only fields.
|
||||
|
||||
#### Slice 3c: Queue/continue and goal control gate
|
||||
|
||||
The next control migration should specify queue/continue and goal before any code
|
||||
routes those actions through `RuntimeAdapter`. They may ship as separate
|
||||
implementation PRs, but they should share one gate because both affect what the
|
||||
agent does after the current user turn instead of merely resolving a pending
|
||||
prompt. Queue/continue controls append or schedule follow-up input against live
|
||||
or resumable work; goal controls set, pause, resume, clear, or inspect a
|
||||
standing cross-turn objective. Both can accidentally create a second continuation
|
||||
model if WebUI buffers or evaluates them independently.
|
||||
|
||||
During Slice 3c, `RuntimeAdapter.queue_message(...)` and
|
||||
`RuntimeAdapter.update_goal(...)` should remain protocol translators over the
|
||||
existing legacy queue/goal paths. They must not create a WebUI-owned run queue,
|
||||
goal evaluator, continuation scheduler, agent loop, or sidecar substitute inside
|
||||
the main WebUI process.
|
||||
|
||||
`RuntimeAdapter.update_goal(...)` controls goal state mutations only. Post-turn
|
||||
goal evaluation and the decision to continue remain in the existing agent
|
||||
conversation loop until the later runner/sidecar slice moves execution
|
||||
ownership; Slice 3c must not move that evaluator into WebUI or the adapter.
|
||||
|
||||
Acceptance properties:
|
||||
|
||||
1. **Same visible result as legacy queue/continue and goal.** Existing `/queue`
|
||||
and `/goal` semantics, browser status affordances, paused/resumed states, and
|
||||
post-turn continuation behavior remain unchanged for users. The adapter flag
|
||||
changes only the route/control entry point.
|
||||
2. **Stable response contracts.** Existing queue/continue and goal HTTP or
|
||||
command responses keep their current browser-facing shapes. Adapter-only run
|
||||
metadata, internal status, or capability details must not leak into public
|
||||
responses unless a later RFC explicitly expands the contract.
|
||||
3. **Bounded unavailable-control behavior.** Requests for a missing run,
|
||||
unsupported profile, inactive session, paused/cleared goal, or stale queued
|
||||
continuation return bounded `ControlResult` states such as `not-active`,
|
||||
`unsupported`, or `conflict`; they must not create a phantom run, resurrect a
|
||||
dead stream, or silently enqueue work against the wrong session.
|
||||
4. **Replayable lifecycle/status evidence.** Queue/continue submission, goal
|
||||
status changes, and resulting post-turn continuation decisions remain visible
|
||||
through the journal/session diagnostic surface where the legacy path already
|
||||
emits equivalent state. Slice 3c does not have to make queued follow-ups or
|
||||
goals survive a WebUI process restart while execution is still in-process;
|
||||
that stronger property belongs to the runner/sidecar gate.
|
||||
5. **No new runtime-surrogate state.** The implementation must not add a second
|
||||
process-local queue, goal table, scheduler, cached-agent registry, or
|
||||
continuation loop under adapter-specific names. If the existing legacy path
|
||||
cannot support the route without new ownership state, stop and amend this RFC
|
||||
before landing code.
|
||||
6. **Ordering and idempotency are explicit.** Repeating the same queue/continue
|
||||
request should not duplicate follow-up work unless the legacy path already
|
||||
defines that behavior. Goal pause/resume/clear/status operations should be
|
||||
safe to repeat and should report one coherent state.
|
||||
|
||||
Suggested regression coverage:
|
||||
|
||||
- route/source tests proving flagged queue/continue and goal paths call the
|
||||
adapter seam while the default path remains the existing legacy handler;
|
||||
- adapter unit tests proving `queue_message` and `update_goal` delegate exactly
|
||||
once, return accepted/not-active/unsupported/conflict `ControlResult` values,
|
||||
and do not expose unsafe internal strings to browser responses;
|
||||
- ordering/idempotency tests for repeated queue/continue and repeated goal
|
||||
pause/resume/clear/status operations;
|
||||
- journal/session-load assertions that queue/goal state remains diagnosable after
|
||||
reconnect where the legacy path currently emits state;
|
||||
- existing queue/goal UI/static tests under default legacy mode to prove no
|
||||
browser contract drift.
|
||||
|
||||
Non-goals for Slice 3c:
|
||||
|
||||
- no runner process, sidecar, or execution-survives-WebUI-restart claim;
|
||||
- no durable WebUI-owned queue or goal scheduler;
|
||||
- no migration of `AIAgent` construction, post-turn goal evaluation, or the
|
||||
agent continuation loop out of the legacy path;
|
||||
- no change to `/goal` command semantics, queue ordering semantics, or supported
|
||||
capability metadata;
|
||||
- no public chat-start/status response-shape expansion for adapter-only fields.
|
||||
|
||||
### Slice 4: Runner process / sidecar boundary
|
||||
|
||||
Explicitly deferred until Slice 1 has worked in production for at least one
|
||||
|
||||
@@ -78,7 +78,10 @@ while WebUI still has multiple overlapping state stores.
|
||||
assistant just acted.
|
||||
5. **Replay is idempotent.** Replaying a run from a cursor must not duplicate
|
||||
transcript rows, thinking content, interim assistant text, tool cards, or
|
||||
compression cards.
|
||||
compression cards. Replayed long-task events should enter the same
|
||||
browser-facing timeline renderer as live SSE events so recovery does not
|
||||
downgrade a structured Thinking / progress / tool / compression turn into a
|
||||
separate flattened presentation.
|
||||
6. **Compression is not current intent.** Automatic compression summaries and
|
||||
reference cards are recovery/handoff material. They must not be treated as a
|
||||
new user request, active-turn content, or the default visible explanation for
|
||||
@@ -102,6 +105,8 @@ context reconstruction, or session metadata:
|
||||
- What happens after browser refresh, session switch, SSE reconnect, and WebUI
|
||||
restart?
|
||||
- Does replay rebuild the same scene without duplicates?
|
||||
- Does replay use the same timeline-rendering path as live SSE for thinking,
|
||||
interim assistant text, tool cards, compression cards, and terminal states?
|
||||
- Can this change move a session in the sidebar without meaningful user or
|
||||
assistant activity?
|
||||
- Can automatic compression or recovery text become visible active-turn content?
|
||||
@@ -147,4 +152,3 @@ The two documents should be read together:
|
||||
4. If #1925 introduces a new adapter-backed runtime layer, update this RFC or
|
||||
replace it with the accepted implementation contract so these invariants do
|
||||
not live only in historical discussion.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Concrete diagnostic flows for the most common failure modes when running Hermes WebUI. Each entry has the symptom, the diagnostic commands you should run *before* opening an issue, and the fix that has worked for past reporters.
|
||||
|
||||
If your symptom isn't listed and the diagnostics don't narrow it down, file a bug at https://github.com/nesquena/hermes-webui/issues — include the **full output** of every command in the relevant section.
|
||||
If your symptom isn't listed and the diagnostics don't narrow it down, file a bug at https://github.com/nesquena/hermes-webui/issues — include the relevant command output after redacting secrets, private paths, full `.env` files, full `auth.json` files, cookies, tokens, and password hashes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="slate">
|
||||
<html lang="en" class="dark" data-skin="slate">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hermes WebUI — Messages UI Inventory</title>
|
||||
@@ -54,14 +54,21 @@
|
||||
<div class="doc-title">Hermes WebUI — Messages UI Inventory<small>Every message-area element & combination, wired to the real <code>static/style.css</code>. · <a href="./two-stage-proposal.html" style="color:var(--blue);text-decoration:none;">Two-stage proposal (#536) →</a></small></div>
|
||||
<div class="doc-toggles">
|
||||
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
|
||||
<button data-theme-btn="default">Default</button>
|
||||
<button data-theme-btn="slate" class="on">Slate</button>
|
||||
<button data-theme-btn="light">Light</button>
|
||||
<button data-theme-btn="solarized">Solarized</button>
|
||||
<button data-theme-btn="monokai">Monokai</button>
|
||||
<button data-theme-btn="nord">Nord</button>
|
||||
<button data-theme-btn="oled">OLED</button>
|
||||
<button data-mode-btn="system">System</button>
|
||||
<button data-mode-btn="dark" class="on">Dark</button>
|
||||
<button data-mode-btn="light">Light</button>
|
||||
<span style="width:1px;height:18px;background:var(--border);margin:0 4px;align-self:center;"></span>
|
||||
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Skin</strong>
|
||||
<button data-skin-btn="default">Default</button>
|
||||
<button data-skin-btn="ares">Ares</button>
|
||||
<button data-skin-btn="mono">Mono</button>
|
||||
<button data-skin-btn="slate" class="on">Slate</button>
|
||||
<button data-skin-btn="poseidon">Poseidon</button>
|
||||
<button data-skin-btn="sisyphus">Sisyphus</button>
|
||||
<button data-skin-btn="charizard">Charizard</button>
|
||||
<button data-skin-btn="sienna">Sienna</button>
|
||||
<button data-skin-btn="catppuccin">Catppuccin</button>
|
||||
<button data-skin-btn="nous">Nous</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -817,15 +824,33 @@ Run typecheck to confirm, then patch.</pre></div>
|
||||
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false},{left:'$',right:'$',display:false}],throwOnError:false});"></script>
|
||||
|
||||
<script>
|
||||
// Theme picker
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
|
||||
// Appearance picker (matches the real app contract: theme class + skin data attribute)
|
||||
let docTheme = 'dark';
|
||||
let docSkin = 'slate';
|
||||
const docThemeMq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
function applyDocAppearance() {
|
||||
const resolvedTheme = docTheme === 'system' ? (docThemeMq.matches ? 'dark' : 'light') : docTheme;
|
||||
document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
|
||||
if (docSkin === 'default') document.documentElement.removeAttribute('data-skin');
|
||||
else document.documentElement.dataset.skin = docSkin;
|
||||
}
|
||||
document.querySelectorAll('[data-mode-btn]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const t = btn.dataset.themeBtn;
|
||||
if (t === 'default') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
docTheme = btn.dataset.modeBtn;
|
||||
applyDocAppearance();
|
||||
document.querySelectorAll('[data-mode-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-skin-btn]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
docSkin = btn.dataset.skinBtn;
|
||||
applyDocAppearance();
|
||||
document.querySelectorAll('[data-skin-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
});
|
||||
});
|
||||
if (docThemeMq.addEventListener) {
|
||||
docThemeMq.addEventListener('change', () => { if (docTheme === 'system') applyDocAppearance(); });
|
||||
}
|
||||
// Bubble-layout toggle
|
||||
|
||||
// Thinking / tool-card click-to-toggle (so the demo feels live)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="slate">
|
||||
<html lang="en" class="dark" data-skin="slate">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hermes WebUI — Two-Stage Chat Proposal (Issue #536)</title>
|
||||
@@ -167,13 +167,21 @@
|
||||
</div>
|
||||
<div class="doc-toggles">
|
||||
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
|
||||
<button data-theme-btn="default">Default</button>
|
||||
<button data-theme-btn="slate" class="on">Slate</button>
|
||||
<button data-theme-btn="light">Light</button>
|
||||
<button data-theme-btn="solarized">Solarized</button>
|
||||
<button data-theme-btn="monokai">Monokai</button>
|
||||
<button data-theme-btn="nord">Nord</button>
|
||||
<button data-theme-btn="oled">OLED</button>
|
||||
<button data-mode-btn="system">System</button>
|
||||
<button data-mode-btn="dark" class="on">Dark</button>
|
||||
<button data-mode-btn="light">Light</button>
|
||||
<span style="width:1px;height:18px;background:var(--border);margin:0 4px;align-self:center;"></span>
|
||||
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Skin</strong>
|
||||
<button data-skin-btn="default">Default</button>
|
||||
<button data-skin-btn="ares">Ares</button>
|
||||
<button data-skin-btn="mono">Mono</button>
|
||||
<button data-skin-btn="slate" class="on">Slate</button>
|
||||
<button data-skin-btn="poseidon">Poseidon</button>
|
||||
<button data-skin-btn="sisyphus">Sisyphus</button>
|
||||
<button data-skin-btn="charizard">Charizard</button>
|
||||
<button data-skin-btn="sienna">Sienna</button>
|
||||
<button data-skin-btn="catppuccin">Catppuccin</button>
|
||||
<button data-skin-btn="nous">Nous</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -698,15 +706,33 @@ tool_calls get attached before save.</pre></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Theme picker (matches index.html)
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
|
||||
// Appearance picker (matches the real app contract: theme class + skin data attribute)
|
||||
let docTheme = 'dark';
|
||||
let docSkin = 'slate';
|
||||
const docThemeMq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
function applyDocAppearance() {
|
||||
const resolvedTheme = docTheme === 'system' ? (docThemeMq.matches ? 'dark' : 'light') : docTheme;
|
||||
document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
|
||||
if (docSkin === 'default') document.documentElement.removeAttribute('data-skin');
|
||||
else document.documentElement.dataset.skin = docSkin;
|
||||
}
|
||||
document.querySelectorAll('[data-mode-btn]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const t = btn.dataset.themeBtn;
|
||||
if (t === 'default') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
docTheme = btn.dataset.modeBtn;
|
||||
applyDocAppearance();
|
||||
document.querySelectorAll('[data-mode-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-skin-btn]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
docSkin = btn.dataset.skinBtn;
|
||||
applyDocAppearance();
|
||||
document.querySelectorAll('[data-skin-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
});
|
||||
});
|
||||
if (docThemeMq.addEventListener) {
|
||||
docThemeMq.addEventListener('change', () => { if (docTheme === 'system') applyDocAppearance(); });
|
||||
}
|
||||
|
||||
// Existing thinking/tool cards click-to-toggle.
|
||||
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
|
||||
|
||||
@@ -441,6 +441,12 @@ def main() -> None:
|
||||
stop_watcher()
|
||||
except Exception:
|
||||
logger.debug("Failed to stop gateway watcher during shutdown")
|
||||
# Drain pending memory-provider lifecycle commits before exit
|
||||
try:
|
||||
from api.session_lifecycle import drain_all_on_shutdown
|
||||
drain_all_on_shutdown()
|
||||
except Exception:
|
||||
logger.debug("Failed to drain lifecycle on shutdown", exc_info=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -101,7 +101,7 @@ function _setWorkspacePanelMode(mode){
|
||||
// Persist open/closed across refreshes (browse/preview → open; closed → closed)
|
||||
// Do NOT overwrite the user's "keep open" preference — only track runtime state
|
||||
// so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting.
|
||||
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
|
||||
try{localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');}catch(_){}
|
||||
layout.classList.toggle('workspace-panel-collapsed',!open);
|
||||
if(_isCompactWorkspaceViewport()){
|
||||
panel.classList.toggle('mobile-open',open);
|
||||
@@ -907,6 +907,25 @@ function clearPreview(opts={}){
|
||||
}
|
||||
$('btnClearPreview').onclick=handleWorkspaceClose;
|
||||
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
|
||||
function _applySessionContextMetadataUpdate(data){
|
||||
if(!S.session||!data||!data.session)return;
|
||||
S.session.context_length=data.session.context_length||0;
|
||||
S.session.threshold_tokens=data.session.threshold_tokens||0;
|
||||
S.session.last_prompt_tokens=data.session.last_prompt_tokens||0;
|
||||
if(typeof _syncCtxIndicator==='function'){
|
||||
const u=S.lastUsage||{};
|
||||
const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
|
||||
_syncCtxIndicator({
|
||||
input_tokens:_pick(u.input_tokens,S.session.input_tokens),
|
||||
output_tokens:_pick(u.output_tokens,S.session.output_tokens),
|
||||
estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost),
|
||||
context_length:S.session.context_length||0,
|
||||
last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens),
|
||||
threshold_tokens:S.session.threshold_tokens||0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$('modelSelect').onchange=async()=>{
|
||||
if(!S.session)return;
|
||||
const selectedModel=$('modelSelect').value;
|
||||
@@ -916,7 +935,11 @@ $('modelSelect').onchange=async()=>{
|
||||
if(typeof closeModelDropdown==='function') closeModelDropdown();
|
||||
if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
|
||||
else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({
|
||||
// Clarify scope: composer model changes are session-local, not the global default.
|
||||
if(typeof showToast==='function'){
|
||||
showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
|
||||
}
|
||||
const data=await api('/api/session/update',{method:'POST',body:JSON.stringify({
|
||||
session_id:S.session.session_id,
|
||||
workspace:S.session.workspace,
|
||||
model:modelState.model,
|
||||
@@ -926,10 +949,7 @@ $('modelSelect').onchange=async()=>{
|
||||
S.session.model_provider=modelState.model_provider||null;
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
syncTopbar();
|
||||
// Clarify scope: composer model changes are session-local, not the global default.
|
||||
if(typeof showToast==='function'){
|
||||
showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
|
||||
}
|
||||
_applySessionContextMetadataUpdate(data);
|
||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||
if(typeof _checkProviderMismatch==='function'){
|
||||
const warn=_checkProviderMismatch(selectedModel);
|
||||
@@ -1179,6 +1199,7 @@ const _SKINS=[
|
||||
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
|
||||
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
|
||||
{name:'Sienna', colors:['#D97757','#C06A49','#9A523A']},
|
||||
{name:'Catppuccin',colors:['#CBA6F7','#B4BEFE','#8839EF']},
|
||||
{name:'Nous', colors:['#4682B4','#3A6E9A','#2C5F88']},
|
||||
];
|
||||
const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
|
||||
@@ -1387,9 +1408,11 @@ function applyBotName(){
|
||||
_bootSettings=s;
|
||||
window._sendKey=s.send_key||'enter';
|
||||
window._showTokenUsage=!!s.show_token_usage;
|
||||
window._showQuotaChip=s.show_quota_chip===true;
|
||||
window._showTps=!!s.show_tps;
|
||||
window._fadeTextEffect=!!s.fade_text_effect;
|
||||
window._showCliSessions=!!s.show_cli_sessions;
|
||||
window._showPreviousMessagingSessions=!!s.show_previous_messaging_sessions;
|
||||
window._soundEnabled=!!s.sound_enabled;
|
||||
window._notificationsEnabled=!!s.notifications_enabled;
|
||||
// Persist default workspace so the blank new-chat page can show it
|
||||
@@ -1452,6 +1475,7 @@ function applyBotName(){
|
||||
}catch(e){
|
||||
window._sendKey='enter';
|
||||
window._showTokenUsage=false;
|
||||
window._showQuotaChip=false;
|
||||
window._showTps=false;
|
||||
window._fadeTextEffect=false;
|
||||
window._showCliSessions=false;
|
||||
@@ -1527,6 +1551,7 @@ function applyBotName(){
|
||||
const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
|
||||
// Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load)
|
||||
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
|
||||
if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
|
||||
const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
|
||||
const savedLocal=localStorage.getItem('hermes-webui-session');
|
||||
const saved=urlSession||savedLocal;
|
||||
|
||||
@@ -326,12 +326,65 @@ async function cmdModel(args){
|
||||
if(!args){showToast(t('model_usage'));return;}
|
||||
const sel=$('modelSelect');
|
||||
if(!sel)return;
|
||||
const q=args.toLowerCase();
|
||||
// Fuzzy match: find first option whose label or value contains the query
|
||||
let match=null;
|
||||
for(const opt of sel.options){
|
||||
if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){
|
||||
match=opt.value;break;
|
||||
let q=args.toLowerCase();
|
||||
// Resolve alias before fuzzy matching the dropdown.
|
||||
// Fetch /api/models which now includes an "aliases" key.
|
||||
try {
|
||||
const resp=await fetch('/api/models');
|
||||
if(resp.ok){
|
||||
const data=await resp.json();
|
||||
const aliases=data.aliases||{};
|
||||
for(const [alias,modelId] of Object.entries(aliases)){
|
||||
if(alias.toLowerCase()===q){
|
||||
q=modelId.toLowerCase(); // resolve alias to real model id e.g. "deepseek/deepseek-v4-flash"
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(_){/* non-critical, fall through to fuzzy match */}
|
||||
// First: try exact match within active provider's optgroup.
|
||||
// Use _findModelInDropdown (ui.js) which supports preferredProviderId.
|
||||
const preferred=(S&&S.session&&S.session.model_provider)||window._activeProvider||null;
|
||||
let match=(typeof _findModelInDropdown==='function')?_findModelInDropdown(q,sel,preferred):null;
|
||||
// Fallback: fuzzy match across all options
|
||||
if(!match){
|
||||
for(const opt of sel.options){
|
||||
if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){
|
||||
match=opt.value;break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: if q has provider/ prefix (e.g. "deepseek/deepseek-v4-flash"),
|
||||
// try the bare model name (which is how options appear for the active provider)
|
||||
if(!match && q.includes('/')){
|
||||
const bare=q.slice(q.lastIndexOf('/')+1);
|
||||
for(const opt of sel.options){
|
||||
if(opt.value.toLowerCase().includes(bare)||opt.textContent.toLowerCase().includes(bare)){
|
||||
match=opt.value;break;
|
||||
}
|
||||
}
|
||||
// Cross-provider fallback: if still no match, the model is from a
|
||||
// different provider not in the dropdown. Call /api/session/update directly.
|
||||
if(!match && S&&S.session&&S.session.session_id){
|
||||
const provider=q.slice(0,q.indexOf('/'));
|
||||
try{
|
||||
const resp=await fetch('/api/session/update',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({
|
||||
session_id:S.session.session_id,
|
||||
model:q,
|
||||
model_provider:provider,
|
||||
}),
|
||||
});
|
||||
if(resp.ok){
|
||||
S.session.model=q;
|
||||
S.session.model_provider=provider;
|
||||
if(typeof syncTopbar==='function') syncTopbar();
|
||||
showToast(t('switched_to')+q);
|
||||
return;
|
||||
}
|
||||
}catch(_){/* fall through to "no model match" */}
|
||||
}
|
||||
}
|
||||
if(!match){showToast(t('no_model_match')+`"${args}"`);return;}
|
||||
@@ -424,7 +477,7 @@ async function _applyManualCompressionResult(data, focusTopic, visibleCount, com
|
||||
S.messages=data.session.messages||[];
|
||||
S.toolCalls=data.session.tool_calls||[];
|
||||
clearLiveToolCards();
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
@@ -1188,6 +1241,7 @@ async function cmdBranch(args){
|
||||
// which resets _oldestIdx to 0 after its wholesale replace. See #2184.
|
||||
async function forkFromMessage(msgIdx){
|
||||
if(!S.session||S.busy)return;
|
||||
const initialSid = S.session.session_id;
|
||||
// Capture the absolute keep_count before any async work that may
|
||||
// reset _oldestIdx. _oldestIdx is 0 when the full transcript is
|
||||
// already loaded, so short/already-full sessions send msgIdx unchanged.
|
||||
@@ -1197,16 +1251,18 @@ async function forkFromMessage(msgIdx){
|
||||
if(typeof _ensureAllMessagesLoaded==='function'){
|
||||
await _ensureAllMessagesLoaded();
|
||||
}
|
||||
if(!S.session || S.session.session_id !== initialSid) return;
|
||||
try{
|
||||
const data=await api('/api/session/branch',{
|
||||
method:'POST',
|
||||
body:JSON.stringify({
|
||||
session_id:S.session.session_id,
|
||||
session_id:initialSid,
|
||||
keep_count:absoluteKeepCount,
|
||||
}),
|
||||
});
|
||||
if(data&&data.session_id){
|
||||
await loadSession(data.session_id);
|
||||
if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded();
|
||||
if(typeof renderSessionList==='function') await renderSessionList();
|
||||
showToast(t('branch_forked'));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ const LI_PATHS = {
|
||||
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
|
||||
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
||||
'brain': '<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/>',
|
||||
'sparkles': '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>',
|
||||
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
'grip-vertical': '<circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Hermes">
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="static/apple-touch-icon.png">
|
||||
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,nous:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
|
||||
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,nous:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
|
||||
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
|
||||
<!-- theme-color: surfaces the active app chrome color to native status bars (Safari status bar, PWA, native WKWebView wrappers). Updated dynamically by boot.js when theme/skin changes. The light/dark default values match style.css :root --sidebar / :root.dark --sidebar. -->
|
||||
<meta name="theme-color" content="#FAF7F0" media="(prefers-color-scheme: light)">
|
||||
@@ -26,7 +26,46 @@
|
||||
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#141425':'#FAF7F0';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
|
||||
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
||||
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
|
||||
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__};</script>
|
||||
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__,csrfToken:__CSRF_TOKEN_JSON__};</script>
|
||||
<script>(function(){
|
||||
var cfg=window.__HERMES_CONFIG__||{},token=cfg.csrfToken||'';
|
||||
if(!token||!window.fetch)return;
|
||||
var originalFetch=window.fetch.bind(window);
|
||||
function sameOriginUnsafe(input,init){
|
||||
var method=((init&&init.method)||(input&&input.method)||'GET').toUpperCase();
|
||||
if(!/^(POST|PUT|PATCH|DELETE)$/.test(method))return false;
|
||||
try{
|
||||
var raw=(typeof input==='string'||input instanceof URL)?input:(input&&input.url)||'';
|
||||
var url=new URL(raw,document.baseURI||location.href);
|
||||
if(url.origin!==location.origin)return false;
|
||||
return !/\/api\/(auth\/login|csp-report)$/.test(url.pathname);
|
||||
}catch(_){return false;}
|
||||
}
|
||||
window.fetch=function(input,init){
|
||||
if(sameOriginUnsafe(input,init)){
|
||||
var opts=init?Object.assign({},init):{};
|
||||
var headers=new Headers(opts.headers!==undefined?opts.headers:((input&&input.headers)||{}));
|
||||
if(!headers.has('X-Hermes-CSRF-Token'))headers.set('X-Hermes-CSRF-Token',token);
|
||||
opts.headers=headers;
|
||||
return originalFetch(input,opts);
|
||||
}
|
||||
return originalFetch(input,init);
|
||||
};
|
||||
if(navigator.sendBeacon){
|
||||
var originalBeacon=navigator.sendBeacon.bind(navigator);
|
||||
navigator.sendBeacon=function(url,data){
|
||||
try{
|
||||
if(sameOriginUnsafe(url,{method:'POST'})){
|
||||
var headers={'X-Hermes-CSRF-Token':token};
|
||||
if(data&&data.type)headers['Content-Type']=data.type;
|
||||
originalFetch(url,{method:'POST',credentials:'include',headers:headers,body:data,keepalive:true});
|
||||
return true;
|
||||
}
|
||||
}catch(_){}
|
||||
return originalBeacon(url,data);
|
||||
};
|
||||
}
|
||||
})()</script>
|
||||
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
|
||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||
@@ -62,6 +101,18 @@
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// Salvaged from PR #1721 (@malulian) — RTL bootstrap.
|
||||
// Apply saved RTL state synchronously before any chat content paints, so
|
||||
// RTL users don't see an LTR flash on initial load.
|
||||
(function(){
|
||||
try{
|
||||
if(localStorage.getItem('hermes-rtl')==='true'){
|
||||
document.documentElement.classList.add('chat-content-rtl');
|
||||
}
|
||||
}catch(_){}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-titlebar" role="banner">
|
||||
@@ -87,6 +138,12 @@
|
||||
<span class="app-titlebar-sub" id="appTitlebarSub" hidden></span>
|
||||
</div>
|
||||
<div class="app-titlebar-spacer" aria-hidden="true"></div>
|
||||
<button class="app-titlebar-reload" id="btnReload" onclick="window.location.reload()" type="button" aria-label="Reload" title="Reload page">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<nav class="rail" aria-label="Primary navigation">
|
||||
@@ -569,6 +626,10 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<button class="provider-quota-chip" id="providerQuotaChip" type="button" title="Provider quota" onclick="switchPanel('settings');switchSettingsSection('providers')" hidden>
|
||||
<span class="provider-quota-chip-dot" aria-hidden="true"></span>
|
||||
<span class="provider-quota-chip-label" id="providerQuotaChipLabel"></span>
|
||||
</button>
|
||||
<div class="composer-reasoning-wrap" id="composerReasoningWrap" style="display:none">
|
||||
<button class="composer-reasoning-chip" id="composerReasoningChip" type="button" onclick="toggleReasoningDropdown()" title="Reasoning effort level">
|
||||
<span class="composer-reasoning-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.46 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.46 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/></svg></span>
|
||||
@@ -816,9 +877,9 @@
|
||||
</div>
|
||||
<div class="hermes-action-grid">
|
||||
<button class="settings-action-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <span data-i18n="transcript">Transcript</span></button>
|
||||
<button class="settings-action-btn" id="btnExportJSON" title="Export full session as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> JSON</button>
|
||||
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
|
||||
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> Clear</button>
|
||||
<button class="settings-action-btn" id="btnExportJSON" title="Export full session as JSON" data-i18n-title="export_session_json_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> <span data-i18n="export_session_json">JSON</span></button>
|
||||
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON" data-i18n-title="import_session_json_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
|
||||
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation" data-i18n-title="clear_conversation_btn_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> <span data-i18n="clear">Clear</span></button>
|
||||
</div>
|
||||
<input type="file" id="importFileInput" accept=".json" style="display:none">
|
||||
</div>
|
||||
@@ -935,6 +996,13 @@
|
||||
<label for="settingsLanguage" data-i18n="settings_label_language">Language</label>
|
||||
<select id="settingsLanguage" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsRtl" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_rtl">Right-to-left chat layout</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_rtl">Flips alignment of chat messages and the composer input for languages like Arabic or Hebrew. Affects only the chat area — sidebar and other panels stay left-to-right.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSoundEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
@@ -998,6 +1066,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="settingsShowQuotaChip" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_quota_chip">Show provider quota chip in composer</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_quota_chip">Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Default off. Only visible on wide displays (≥1400px) when enabled, to keep the composer uncluttered on laptop and standard desktop widths.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowTps" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
@@ -1017,7 +1092,7 @@
|
||||
<input type="checkbox" id="settingsSimplifiedToolCalling" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span>Compact tool activity</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Group thinking and tool calls into one collapsed activity section per assistant turn.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show thinking and tool calls as compact inline activity while preserving the agent timeline.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
@@ -1060,6 +1135,13 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_external_sessions">Show conversations from CLI, Telegram, Discord, Slack, and other channels in the session list. Click to import and continue.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowPreviousMessagingSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_previous_messaging_sessions">Show previous messaging sessions</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_previous_messaging_sessions">Show older Discord, Telegram, Slack, and Weixin sessions that were replaced by reset or compression.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
@@ -1106,14 +1188,14 @@
|
||||
<div class="settings-pane" id="settingsPanePlugins">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">Plugins</div>
|
||||
<div class="settings-section-meta">View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.</div>
|
||||
<div class="settings-section-title" data-i18n="settings_plugins_title">Plugins</div>
|
||||
<div class="settings-section-meta" data-i18n="settings_plugins_meta">View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pluginsList" style="display:flex;flex-direction:column;margin-top:4px">
|
||||
<!-- Populated dynamically by loadPluginsPanel() -->
|
||||
</div>
|
||||
<div id="pluginsEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px">
|
||||
<div id="pluginsEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px" data-i18n="settings_plugins_empty">
|
||||
No Hermes plugins are currently visible. Install or enable plugins from the Hermes CLI/config to see them here.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,137 @@ const _msgEl=document.getElementById('msg');
|
||||
if(_msgEl) _msgEl.addEventListener('focus', ()=>{ if('speechSynthesis' in window && speechSynthesis.speaking) speechSynthesis.pause(); });
|
||||
if(_msgEl) _msgEl.addEventListener('blur', ()=>{ if('speechSynthesis' in window && speechSynthesis.paused) speechSynthesis.resume(); });
|
||||
|
||||
let _selectedTextReplyBtn=null;
|
||||
let _selectedTextReplyText='';
|
||||
let _selectedTextReplyRaf=0;
|
||||
|
||||
function _selectedTextReplyT(key, fallback){
|
||||
try{
|
||||
const val=(typeof t==='function')?t(key):'';
|
||||
return val&&val!==key?val:fallback;
|
||||
}catch(_err){
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function _selectedTextReplyRoot(){
|
||||
if(typeof $==='function') return $('messages')||$('msgInner');
|
||||
return document.getElementById('messages')||document.getElementById('msgInner');
|
||||
}
|
||||
|
||||
function _selectedTextReplyNodeInChat(node, root){
|
||||
if(!node||!root)return false;
|
||||
const el=node.nodeType===Node.ELEMENT_NODE?node:node.parentElement;
|
||||
return !!(el&&root.contains(el));
|
||||
}
|
||||
|
||||
function _selectedTextReplySelection(){
|
||||
if(!window.getSelection)return null;
|
||||
const selection=window.getSelection();
|
||||
if(!selection||selection.isCollapsed||!selection.rangeCount)return null;
|
||||
const root=_selectedTextReplyRoot();
|
||||
if(!root)return null;
|
||||
const range=selection.getRangeAt(0);
|
||||
if(!_selectedTextReplyNodeInChat(range.startContainer, root)||!_selectedTextReplyNodeInChat(range.endContainer, root))return null;
|
||||
const text=selection.toString().replace(/\u00a0/g,' ').trim();
|
||||
if(!text)return null;
|
||||
const rect=range.getBoundingClientRect();
|
||||
if(!rect||(!rect.width&&!rect.height))return null;
|
||||
return {text, rect};
|
||||
}
|
||||
|
||||
function _formatSelectedTextReplyQuote(text){
|
||||
const normalized=String(text||'').replace(/\r\n?/g,'\n').replace(/\n{3,}/g,'\n\n').trim();
|
||||
if(!normalized)return '';
|
||||
return normalized.split('\n').map(line=>`> ${line}`).join('\n');
|
||||
}
|
||||
|
||||
function _appendSelectedTextReplyToComposer(text){
|
||||
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
|
||||
if(!composer)return false;
|
||||
const quote=_formatSelectedTextReplyQuote(text);
|
||||
if(!quote)return false;
|
||||
const current=String(composer.value||'');
|
||||
composer.value=current.trim()?`${current.replace(/\s+$/,'')}\n\n${quote}\n\n`:`${quote}\n\n`;
|
||||
composer.focus();
|
||||
try{ composer.setSelectionRange(composer.value.length, composer.value.length); }catch(_err){}
|
||||
composer.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
if(typeof autoResize==='function') autoResize();
|
||||
if(typeof showToast==='function') showToast(_selectedTextReplyT('selected_text_reply_appended', 'Selected text added to composer'), 1600);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _selectedTextReplyButton(){
|
||||
if(_selectedTextReplyBtn)return _selectedTextReplyBtn;
|
||||
const btn=document.createElement('button');
|
||||
btn.type='button';
|
||||
btn.id='selectedTextReplyBtn';
|
||||
btn.className='selected-text-reply-btn';
|
||||
btn.setAttribute('data-i18n', 'selected_text_reply');
|
||||
btn.setAttribute('data-i18n-title', 'selected_text_reply_title');
|
||||
btn.setAttribute('data-i18n-aria-label', 'selected_text_reply_title');
|
||||
btn.textContent=_selectedTextReplyT('selected_text_reply', 'Reply with selection');
|
||||
btn.title=_selectedTextReplyT('selected_text_reply_title', 'Append selected chat text as quoted context');
|
||||
btn.setAttribute('aria-label', btn.title);
|
||||
btn.addEventListener('mousedown', e=>e.preventDefault());
|
||||
btn.addEventListener('click', e=>{
|
||||
e.preventDefault();
|
||||
if(_appendSelectedTextReplyToComposer(_selectedTextReplyText)){
|
||||
_hideSelectedTextReplyButton();
|
||||
const selection=window.getSelection&&window.getSelection();
|
||||
if(selection&&selection.removeAllRanges)selection.removeAllRanges();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(btn);
|
||||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
||||
_selectedTextReplyBtn=btn;
|
||||
return btn;
|
||||
}
|
||||
|
||||
function _hideSelectedTextReplyButton(){
|
||||
_selectedTextReplyText='';
|
||||
if(_selectedTextReplyBtn)_selectedTextReplyBtn.classList.remove('visible');
|
||||
}
|
||||
|
||||
function _positionSelectedTextReplyButton(info){
|
||||
const btn=_selectedTextReplyButton();
|
||||
_selectedTextReplyText=info.text;
|
||||
btn.classList.add('visible');
|
||||
const gap=8;
|
||||
const btnRect=btn.getBoundingClientRect();
|
||||
const width=btnRect.width||150;
|
||||
const height=btnRect.height||32;
|
||||
const left=Math.min(Math.max(gap, info.rect.left+(info.rect.width/2)-(width/2)), Math.max(gap, window.innerWidth-width-gap));
|
||||
const top=Math.max(gap, info.rect.top-height-gap);
|
||||
btn.style.left=`${left}px`;
|
||||
btn.style.top=`${top}px`;
|
||||
}
|
||||
|
||||
function _updateSelectedTextReplyButton(){
|
||||
if(_selectedTextReplyRaf)return;
|
||||
_selectedTextReplyRaf=window.requestAnimationFrame(()=>{
|
||||
_selectedTextReplyRaf=0;
|
||||
const info=_selectedTextReplySelection();
|
||||
if(!info){
|
||||
_hideSelectedTextReplyButton();
|
||||
return;
|
||||
}
|
||||
_positionSelectedTextReplyButton(info);
|
||||
});
|
||||
}
|
||||
|
||||
if(typeof document!=='undefined'){
|
||||
document.addEventListener('selectionchange', _updateSelectedTextReplyButton);
|
||||
document.addEventListener('mouseup', e=>{
|
||||
if(e.target&&e.target.closest&&e.target.closest('.selected-text-reply-btn'))return;
|
||||
_updateSelectedTextReplyButton();
|
||||
});
|
||||
document.addEventListener('keyup', e=>{
|
||||
if(e.key&&/Arrow|Shift|Control|Meta|Alt/.test(e.key))_updateSelectedTextReplyButton();
|
||||
});
|
||||
window.addEventListener('resize', _hideSelectedTextReplyButton);
|
||||
}
|
||||
|
||||
// Guard against concurrent send() calls. Without this, two rapid sends
|
||||
// (e.g. queue drain + user click) can both pass the S.busy check because
|
||||
// setBusy(true) is only called after the first await inside send().
|
||||
@@ -273,7 +404,7 @@ async function send(){
|
||||
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000};
|
||||
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);
|
||||
S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);
|
||||
// First optimistic pass: make the local user turn visible before /api/chat/start
|
||||
// can save pending state on the server.
|
||||
if(typeof upsertActiveSessionForLocalTurn==='function'){
|
||||
@@ -410,6 +541,16 @@ function closeLiveStream(sessionId, streamId){
|
||||
delete LIVE_STREAMS[sessionId];
|
||||
}
|
||||
|
||||
function closeOtherLiveStreams(activeSid){
|
||||
// Keep the live token SSE connection scoped to the conversation pane the user
|
||||
// is actually viewing. Background sessions still show running/finished state
|
||||
// through the session list and can reattach when selected, but they should not
|
||||
// keep one EventSource each and exhaust the browser connection pool (#2313).
|
||||
for(const sid of Object.keys(LIVE_STREAMS)){
|
||||
if(sid!==activeSid) closeLiveStream(sid);
|
||||
}
|
||||
}
|
||||
|
||||
function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(!activeSid||!streamId) return;
|
||||
const reconnecting=!!options.reconnecting;
|
||||
@@ -427,6 +568,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
){
|
||||
return;
|
||||
}
|
||||
closeOtherLiveStreams(activeSid);
|
||||
closeLiveStream(activeSid);
|
||||
|
||||
let assistantText='';
|
||||
@@ -513,6 +655,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
toolCalls:inflight.toolCalls||[],
|
||||
});
|
||||
}
|
||||
function snapshotLiveTurn(){
|
||||
if(typeof snapshotLiveTurnHtmlForSession==='function') snapshotLiveTurnHtmlForSession(activeSid);
|
||||
}
|
||||
// Throttled variant for token-by-token updates. persistInflightState()
|
||||
// calls saveInflightState() which does JSON.parse + JSON.stringify + write
|
||||
// on the entire inflight map every call. On a fast model at 60 tok/s with
|
||||
@@ -683,6 +828,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const _STREAM_FADE_MAX_MS=350;
|
||||
const _STREAM_FADE_STAGGER_MS=16;
|
||||
const _STREAM_FADE_DONE_MAX_MS=320;
|
||||
const _STREAM_FADE_DONE_DRAIN_MAX_MS=900;
|
||||
const _streamFadeEnabledForStream=window._fadeTextEffect===true;
|
||||
|
||||
// rAF-throttled rendering: buffer tokens, render at most once per frame
|
||||
@@ -1072,6 +1218,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
: _stripXmlToolCalls(assistantText.slice(segmentStart));
|
||||
}
|
||||
function _drainStreamFadeBeforeDone(onDone){
|
||||
const drainStartedAt=performance.now();
|
||||
let forcedDone=false;
|
||||
const step=()=>{
|
||||
if(!assistantBody){onDone();return;}
|
||||
const target=_streamFadeCurrentDisplayText();
|
||||
@@ -1087,11 +1235,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
setTimeout(onDone, Math.min(remainingAnimationMs, _STREAM_FADE_DONE_MAX_MS));
|
||||
return;
|
||||
}
|
||||
// Final SSE `done` means the canonical completed session is available.
|
||||
// The optional word-fade playout must not keep that completed answer
|
||||
// hidden behind the live Thinking state for large/bursty responses.
|
||||
if(!forcedDone&&performance.now()-drainStartedAt>=_STREAM_FADE_DONE_DRAIN_MAX_MS){
|
||||
forcedDone=true;
|
||||
if(_smdParser) _smdEndParser();
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
setTimeout(()=>requestAnimationFrame(step), 33);
|
||||
};
|
||||
step();
|
||||
}
|
||||
function _closeCurrentLiveActivityGroup(){
|
||||
const turn=$('liveAssistantTurn');
|
||||
if(turn){
|
||||
turn.querySelectorAll('.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]').forEach(group=>{
|
||||
group.removeAttribute('data-live-activity-current');
|
||||
});
|
||||
}
|
||||
}
|
||||
function _resetAssistantSegment(){
|
||||
const options=arguments[0]||{};
|
||||
const closeActivity=!!(options&&options.closeActivity);
|
||||
if(closeActivity) _closeCurrentLiveActivityGroup();
|
||||
assistantRow=null;
|
||||
assistantBody=null;
|
||||
segmentStart=assistantText.length;
|
||||
@@ -1170,6 +1338,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
}
|
||||
scrollIfPinned();
|
||||
snapshotLiveTurn();
|
||||
};
|
||||
const frameIntervalMs=_shouldUseStreamFade()?33:66;
|
||||
if(sinceLastMs>=frameIntervalMs){
|
||||
@@ -1197,19 +1366,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('token',e=>{
|
||||
if(_terminalStateReached||_streamFinalized) return;
|
||||
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;
|
||||
const parsed=_parseStreamState();
|
||||
if(_freshSegment&&window._showThinking!==false) appendThinking(_liveThinkingText());
|
||||
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
|
||||
_scheduleRender();
|
||||
});
|
||||
|
||||
source.addEventListener('interim_assistant',e=>{
|
||||
if(_terminalStateReached||_streamFinalized) return;
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
const d=JSON.parse(e.data);
|
||||
const visible=String(d&&d.text?d.text:'').trim();
|
||||
const alreadyStreamed=!!(d&&d.already_streamed);
|
||||
@@ -1217,19 +1385,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return;
|
||||
}
|
||||
if(alreadyStreamed){
|
||||
_resetAssistantSegment();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
_resetAssistantSegment({closeActivity:true});
|
||||
return;
|
||||
}
|
||||
assistantText+=visible;
|
||||
assistantText += assistantText ? `\n\n${visible}` : visible;
|
||||
visibleInterimSnippets.push(visible);
|
||||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
const parsed=_parseStreamState();
|
||||
if(window._showThinking!==false){
|
||||
if(typeof updateThinking==='function') updateThinking(_liveThinkingText());
|
||||
else appendThinking(_liveThinkingText());
|
||||
}
|
||||
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
|
||||
ensureAssistantRow(true);
|
||||
_resetAssistantSegment({closeActivity:true});
|
||||
_scheduleRender();
|
||||
});
|
||||
|
||||
@@ -1274,6 +1443,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
liveReasoningText='';
|
||||
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
|
||||
appendLiveToolCard(tc);
|
||||
snapshotLiveTurn();
|
||||
// Reset the live assistant row reference so that any text tokens arriving
|
||||
// after this tool call create a NEW segment appended below the tool card,
|
||||
// rather than updating the old segment that sits above it in the DOM.
|
||||
@@ -1310,6 +1480,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
persistInflightState();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
appendLiveToolCard(tc);
|
||||
snapshotLiveTurn();
|
||||
scrollIfPinned();
|
||||
});
|
||||
|
||||
@@ -1452,9 +1623,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const _prevIn=(S.session&&S.session.input_tokens)||0;
|
||||
const _prevOut=(S.session&&S.session.output_tokens)||0;
|
||||
const _prevCost=(S.session&&S.session.estimated_cost)||0;
|
||||
const _prevCacheRead=(S.session&&S.session.cache_read_tokens)||0;
|
||||
const _prevCacheWrite=(S.session&&S.session.cache_write_tokens)||0;
|
||||
S.session=d.session;S.messages=d.session.messages||[];if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
|
||||
if(S.session&&S.session.session_id){
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
}
|
||||
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
|
||||
@@ -1481,12 +1654,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const curIn=d.usage.input_tokens||0;
|
||||
const curOut=d.usage.output_tokens||0;
|
||||
const curCost=d.usage.estimated_cost||0;
|
||||
const curCacheRead=d.usage.cache_read_tokens||0;
|
||||
const curCacheWrite=d.usage.cache_write_tokens||0;
|
||||
// Only set delta if values actually increased (skip no-op turns)
|
||||
if(curIn>prevIn||curOut>prevOut){
|
||||
if(curIn>prevIn||curOut>prevOut||curCacheRead>_prevCacheRead||curCacheWrite>_prevCacheWrite){
|
||||
lastAsst._turnUsage={
|
||||
input_tokens:Math.max(0,curIn-prevIn),
|
||||
output_tokens:Math.max(0,curOut-prevOut),
|
||||
estimated_cost:Math.max(0,curCost-prevCost),
|
||||
cache_read_tokens:Math.max(0,curCacheRead-_prevCacheRead),
|
||||
cache_write_tokens:Math.max(0,curCacheWrite-_prevCacheWrite),
|
||||
};
|
||||
}
|
||||
if(typeof d.usage.duration_seconds==='number'){
|
||||
@@ -1603,14 +1780,26 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
|
||||
if(d.session_id&&d.session_id!==activeSid) return;
|
||||
if(typeof setCompressionUi==='function'){
|
||||
setCompressionUi({
|
||||
const state={
|
||||
sessionId:activeSid,
|
||||
phase:'running',
|
||||
automatic:true,
|
||||
message:d.message||'Auto-compressing context...',
|
||||
});
|
||||
startedAt:Date.now()/1000,
|
||||
};
|
||||
setCompressionUi(state);
|
||||
const liveAnswerStarted=!!(assistantRow||String(((_parseStreamState&&_parseStreamState())||{}).displayText||'').trim());
|
||||
if(liveAnswerStarted&&typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state)){
|
||||
// The live card is now anchored in the turn. Keeping the same running
|
||||
// state in global transient UI makes later renderMessages() calls insert
|
||||
// a duplicate Automatic Compression card.
|
||||
window._compressionUi=null;
|
||||
snapshotLiveTurn();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(typeof renderMessages==='function') renderMessages({preserveScroll:true});
|
||||
snapshotLiveTurn();
|
||||
});
|
||||
|
||||
source.addEventListener('compressed',e=>{
|
||||
@@ -1620,20 +1809,32 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
let d={};
|
||||
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
|
||||
if(d.session_id&&d.session_id!==activeSid) return;
|
||||
const eventSid=d.old_session_id||d.session_id||activeSid;
|
||||
if(eventSid!==activeSid && d.new_session_id!==activeSid && d.continuation_session_id!==activeSid) return;
|
||||
const continuationSid=d.new_session_id||d.continuation_session_id||'';
|
||||
const message=String(d.message||'Context auto-compressed to continue the conversation').trim();
|
||||
if(d.usage&&typeof _syncCtxIndicator==='function'){
|
||||
S.lastUsage={...(S.lastUsage||{}),...d.usage};
|
||||
_syncCtxIndicator(S.lastUsage);
|
||||
}
|
||||
if(typeof setCompressionUi==='function'){
|
||||
setCompressionUi({
|
||||
const state={
|
||||
sessionId:activeSid,
|
||||
phase:'done',
|
||||
automatic:true,
|
||||
message,
|
||||
summary:{headline:message},
|
||||
});
|
||||
continuationSessionId:continuationSid,
|
||||
};
|
||||
setCompressionUi(state);
|
||||
const appended=typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state);
|
||||
if(appended){
|
||||
// The live card is now anchored in the turn. Do not keep the automatic
|
||||
// completion state as global transient UI, otherwise every subsequent
|
||||
// render projects the same Auto Compression card again.
|
||||
window._compressionUi=null;
|
||||
snapshotLiveTurn();
|
||||
}
|
||||
}
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
if(!S.busy&&typeof renderMessages==='function') renderMessages();
|
||||
@@ -1824,7 +2025,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role);
|
||||
if(S.session&&S.session.session_id){
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
}
|
||||
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
|
||||
|
||||
@@ -13,6 +13,7 @@ let _kanbanCurrentBoard = null;
|
||||
let _kanbanBoardsList = null;
|
||||
let _kanbanBoardMenuOpen = false;
|
||||
let _kanbanIsDispatching = false;
|
||||
let _kanbanSuppressCardClickUntil = 0;
|
||||
// SSE event stream — replaces the 30s polling cadence with a long-lived
|
||||
// /api/kanban/events/stream connection. Falls back to polling when the
|
||||
// EventSource fails to connect (proxy that strips text/event-stream, etc).
|
||||
@@ -1264,12 +1265,33 @@ async function quickKanbanCardAction(event, taskId, status){
|
||||
return updateKanbanTask(taskId, {status});
|
||||
}
|
||||
|
||||
function _kanbanSuppressNextCardClick(){
|
||||
_kanbanSuppressCardClickUntil = Date.now() + 700;
|
||||
}
|
||||
|
||||
function dragKanbanTask(event, taskId){
|
||||
_kanbanSuppressNextCardClick();
|
||||
if (!event.dataTransfer) return;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', taskId);
|
||||
}
|
||||
|
||||
function finishKanbanDrag(event){
|
||||
if (event) _kanbanSuppressNextCardClick();
|
||||
}
|
||||
|
||||
function openKanbanCard(event, taskId){
|
||||
if (Date.now() < _kanbanSuppressCardClickUntil) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
loadKanbanTask(taskId);
|
||||
return false;
|
||||
}
|
||||
|
||||
function allowKanbanDrop(event){
|
||||
// Don't accept drops into the 'running' column. Entering 'running' is owned
|
||||
// by the dispatcher/claim_task path (sets claim_lock + claim_expires +
|
||||
@@ -1290,10 +1312,13 @@ function clearKanbanDrop(event){
|
||||
}
|
||||
|
||||
async function dropKanbanTask(event, status){
|
||||
_kanbanSuppressNextCardClick();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearKanbanDrop(event);
|
||||
const taskId = event.dataTransfer ? event.dataTransfer.getData('text/plain') : '';
|
||||
if (taskId && status) await updateKanbanTask(taskId, {status});
|
||||
if (taskId && status) await updateKanbanTask(taskId, {status}, {openDetail: false});
|
||||
_kanbanSuppressNextCardClick();
|
||||
}
|
||||
|
||||
function _kanbanLaneNames(columns){
|
||||
@@ -1356,7 +1381,7 @@ function _kanbanCard(task, status){
|
||||
const stale = _kanbanCardStalenessClass(task);
|
||||
const body = _kanbanTaskBody(task);
|
||||
const assignee = task.assignee ? `<span class="kanban-card-assignee">@${esc(task.assignee)}</span>` : `<span class="kanban-card-unassigned">${esc(t('kanban_unassigned'))}</span>`;
|
||||
return `<article class="kanban-card ${esc(stale)}" data-kanban-task-id="${esc(task.id)}" draggable="true" ondragstart="dragKanbanTask(event, '${esc(task.id)}')" onclick="loadKanbanTask('${esc(task.id)}')" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();loadKanbanTask('${esc(task.id)}')}">
|
||||
return `<article class="kanban-card ${esc(stale)}" data-kanban-task-id="${esc(task.id)}" draggable="true" ondragstart="dragKanbanTask(event, '${esc(task.id)}')" ondragend="finishKanbanDrag(event)" onclick="return openKanbanCard(event, '${esc(task.id)}')" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();loadKanbanTask('${esc(task.id)}')}">
|
||||
<div class="kanban-card-topline"><span class="kanban-card-id">${esc(task.id || '')}</span>${priority ? `<span class="kanban-badge priority">P${priority}</span>` : ''}${task.tenant ? `<span class="kanban-badge tenant">${esc(task.tenant)}</span>` : ''}</div>
|
||||
<div class="kanban-card-title">${esc(_kanbanTaskTitle(task))}</div>
|
||||
${body ? `<div class="kanban-card-body">${_kanbanRenderMarkdown(body)}</div>` : ''}
|
||||
@@ -2269,15 +2294,16 @@ async function submitKanbanTaskModal(){
|
||||
}
|
||||
}
|
||||
|
||||
async function updateKanbanTask(taskId, patch){
|
||||
async function updateKanbanTask(taskId, patch, opts){
|
||||
if (!taskId || !patch) return;
|
||||
try {
|
||||
const openDetail = !opts || opts.openDetail !== false;
|
||||
const updated = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + _kanbanBoardQuery(), {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
await loadKanban(true);
|
||||
await loadKanbanTask((updated && updated.task && updated.task.id) || taskId);
|
||||
if (openDetail) await loadKanbanTask((updated && updated.task && updated.task.id) || taskId);
|
||||
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
|
||||
}
|
||||
|
||||
@@ -3563,12 +3589,13 @@ async function deleteCurrentSkill() {
|
||||
|
||||
// ── Memory (main view) ──
|
||||
let _memoryData = null;
|
||||
let _currentMemorySection = null; // 'memory' | 'user'
|
||||
let _currentMemorySection = null; // 'memory' | 'user' | 'soul'
|
||||
let _memoryMode = 'empty'; // 'empty' | 'read' | 'edit'
|
||||
|
||||
const MEMORY_SECTIONS = [
|
||||
{ key: 'memory', labelKey: 'my_notes', emptyKey: 'no_notes_yet', iconKey: 'brain' },
|
||||
{ key: 'user', labelKey: 'user_profile', emptyKey: 'no_profile_yet', iconKey: 'user' },
|
||||
{ key: 'soul', labelKey: 'agent_soul', emptyKey: 'no_soul_yet', iconKey: 'sparkles' },
|
||||
];
|
||||
|
||||
function _memorySectionMeta(key) {
|
||||
@@ -3577,12 +3604,16 @@ function _memorySectionMeta(key) {
|
||||
|
||||
function _memorySectionContent(key) {
|
||||
if (!_memoryData) return '';
|
||||
return key === 'user' ? (_memoryData.user || '') : (_memoryData.memory || '');
|
||||
if (key === 'user') return _memoryData.user || '';
|
||||
if (key === 'soul') return _memoryData.soul || '';
|
||||
return _memoryData.memory || '';
|
||||
}
|
||||
|
||||
function _memorySectionMtime(key) {
|
||||
if (!_memoryData) return 0;
|
||||
return key === 'user' ? (_memoryData.user_mtime || 0) : (_memoryData.memory_mtime || 0);
|
||||
if (key === 'user') return _memoryData.user_mtime || 0;
|
||||
if (key === 'soul') return _memoryData.soul_mtime || 0;
|
||||
return _memoryData.memory_mtime || 0;
|
||||
}
|
||||
|
||||
function _setMemoryHeaderButtons(mode) {
|
||||
@@ -5255,6 +5286,8 @@ function _preferencesPayloadFromUi(){
|
||||
if(langSel) payload.language=langSel.value;
|
||||
const showUsageCb=$('settingsShowTokenUsage');
|
||||
if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
|
||||
const showQuotaChipCb=$('settingsShowQuotaChip');
|
||||
if(showQuotaChipCb) payload.show_quota_chip=showQuotaChipCb.checked;
|
||||
const showTpsCb=$('settingsShowTps');
|
||||
if(showTpsCb) payload.show_tps=showTpsCb.checked;
|
||||
const fadeTextCb=$('settingsFadeTextEffect');
|
||||
@@ -5265,6 +5298,8 @@ function _preferencesPayloadFromUi(){
|
||||
if(apiRedactCb) payload.api_redact_enabled=apiRedactCb.checked;
|
||||
const showCliCb=$('settingsShowCliSessions');
|
||||
if(showCliCb) payload.show_cli_sessions=showCliCb.checked;
|
||||
const showPreviousMessagingCb=$('settingsShowPreviousMessagingSessions');
|
||||
if(showPreviousMessagingCb) payload.show_previous_messaging_sessions=showPreviousMessagingCb.checked;
|
||||
const syncCb=$('settingsSyncInsights');
|
||||
if(syncCb) payload.sync_to_insights=syncCb.checked;
|
||||
const updateCb=$('settingsCheckUpdates');
|
||||
@@ -5273,6 +5308,8 @@ function _preferencesPayloadFromUi(){
|
||||
if(whatsNewSummaryCb) payload.whats_new_summary_enabled=whatsNewSummaryCb.checked;
|
||||
const soundCb=$('settingsSoundEnabled');
|
||||
if(soundCb) payload.sound_enabled=soundCb.checked;
|
||||
const rtlCb=$('settingsRtl');
|
||||
if(rtlCb) payload.rtl=rtlCb.checked;
|
||||
const notifCb=$('settingsNotificationsEnabled');
|
||||
if(notifCb) payload.notifications_enabled=notifCb.checked;
|
||||
const sidebarDensitySel=$('settingsSidebarDensity');
|
||||
@@ -5493,6 +5530,18 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
const showUsageCb=$('settingsShowTokenUsage');
|
||||
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
// Ambient provider quota chip toggle — default off; only shows at ≥1400px viewport
|
||||
// when enabled (see style.css @media (max-width:1399.98px) rule).
|
||||
const showQuotaChipCb=$('settingsShowQuotaChip');
|
||||
if(showQuotaChipCb){
|
||||
showQuotaChipCb.checked=settings.show_quota_chip===true;
|
||||
window._showQuotaChip=showQuotaChipCb.checked;
|
||||
showQuotaChipCb.addEventListener('change',()=>{
|
||||
window._showQuotaChip=showQuotaChipCb.checked;
|
||||
if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
|
||||
_schedulePreferencesAutosave();
|
||||
},{once:false});
|
||||
}
|
||||
const showTpsCb=$('settingsShowTps');
|
||||
if(showTpsCb){showTpsCb.checked=!!settings.show_tps;showTpsCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const fadeTextCb=$('settingsFadeTextEffect');
|
||||
@@ -5503,6 +5552,8 @@ async function loadSettingsPanel(){
|
||||
if(apiRedactCb){apiRedactCb.checked=settings.api_redact_enabled!==false;apiRedactCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const showCliCb=$('settingsShowCliSessions');
|
||||
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const showPreviousMessagingCb=$('settingsShowPreviousMessagingSessions');
|
||||
if(showPreviousMessagingCb){showPreviousMessagingCb.checked=!!settings.show_previous_messaging_sessions;showPreviousMessagingCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const syncCb=$('settingsSyncInsights');
|
||||
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const updateCb=$('settingsCheckUpdates');
|
||||
@@ -5511,6 +5562,20 @@ async function loadSettingsPanel(){
|
||||
if(whatsNewSummaryCb){whatsNewSummaryCb.checked=!!settings.whats_new_summary_enabled;whatsNewSummaryCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const soundCb=$('settingsSoundEnabled');
|
||||
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
// Right-to-left chat layout (#1721 salvage) — Settings-only, no composer button.
|
||||
const rtlCb=$('settingsRtl');
|
||||
if(rtlCb){
|
||||
const saved=!!settings.rtl || localStorage.getItem('hermes-rtl')==='true';
|
||||
rtlCb.checked=saved;
|
||||
try{localStorage.setItem('hermes-rtl',saved?'true':'false');}catch(_){}
|
||||
document.documentElement.classList.toggle('chat-content-rtl',saved);
|
||||
rtlCb.addEventListener('change',()=>{
|
||||
const on=rtlCb.checked;
|
||||
try{localStorage.setItem('hermes-rtl',on?'true':'false');}catch(_){}
|
||||
document.documentElement.classList.toggle('chat-content-rtl',on);
|
||||
_schedulePreferencesAutosave();
|
||||
},{once:false});
|
||||
}
|
||||
// TTS settings (localStorage-only, no server round-trip needed)
|
||||
const ttsEnabledCb=$('settingsTtsEnabled');
|
||||
if(ttsEnabledCb){ttsEnabledCb.checked=localStorage.getItem('hermes-tts-enabled')==='true';ttsEnabledCb.onchange=function(){localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);};}
|
||||
@@ -5655,7 +5720,7 @@ async function loadPluginsPanel(){
|
||||
list.appendChild(_buildPluginCard(plugin));
|
||||
}
|
||||
}catch(e){
|
||||
list.innerHTML='<div style="color:var(--error);padding:12px;font-size:13px">Failed to load plugins: '+esc(e.message||String(e))+'</div>';
|
||||
list.innerHTML='<div style="color:var(--error);padding:12px;font-size:13px">'+t('plugins_load_failed')+esc(e.message||String(e))+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5666,21 +5731,21 @@ function _buildPluginCard(plugin){
|
||||
const hooks=Array.isArray(plugin&&plugin.hooks)?plugin.hooks:[];
|
||||
const hookHtml=hooks.length
|
||||
? hooks.map(h=>`<span class="plugin-hook-badge">${esc(h)}</span>`).join('')
|
||||
: '<span class="plugin-hook-empty">No registered lifecycle hooks</span>';
|
||||
const version=(plugin&&plugin.version)?` · v${esc(plugin.version)}`:'';
|
||||
const desc=(plugin&&plugin.description)?esc(plugin.description):'No description provided.';
|
||||
: '<span class="plugin-hook-empty">'+t('plugins_no_hooks')+'</span>';
|
||||
const version=(plugin&&plugin.version)?' · v'+esc(plugin.version):'';
|
||||
const desc=(plugin&&plugin.description)?esc(plugin.description):t('plugins_no_description');
|
||||
const enabled=plugin&&plugin.enabled!==false;
|
||||
card.innerHTML=`
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
<div class="provider-card-info">
|
||||
<div class="provider-card-name">${esc((plugin&&plugin.name)||'Unnamed plugin')}</div>
|
||||
<div class="provider-card-name">${esc((plugin&&plugin.name)||t('plugins_unnamed'))}</div>
|
||||
<div class="provider-card-meta">${esc((plugin&&plugin.key)||'plugin')}${version}</div>
|
||||
</div>
|
||||
<span class="provider-card-badge ${enabled?'':'plugin-card-badge-disabled'}">${enabled?'Enabled':'Disabled'}</span>
|
||||
<span class="provider-card-badge ${enabled?'':'plugin-card-badge-disabled'}">${enabled?t('plugins_enabled'):t('plugins_disabled')}</span>
|
||||
</div>
|
||||
<div class="provider-card-body plugin-card-body">
|
||||
<div class="provider-card-hint">${desc}</div>
|
||||
<div class="provider-card-label">Registered hooks</div>
|
||||
<div class="provider-card-label">${t('plugins_registered_hooks')}</div>
|
||||
<div class="plugin-hook-list">${hookHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -6245,12 +6310,14 @@ function _setSettingsAuthButtonsVisible(active){
|
||||
}
|
||||
|
||||
function _applySavedSettingsUi(saved, body, opts){
|
||||
const {sendKey,showTokenUsage,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
|
||||
const {sendKey,showTokenUsage,showQuotaChip,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
|
||||
window._sendKey=sendKey||'enter';
|
||||
window._showTokenUsage=showTokenUsage;
|
||||
window._showQuotaChip=showQuotaChip===true;
|
||||
window._showTps=showTps;
|
||||
window._fadeTextEffect=!!fadeTextEffect;
|
||||
window._showCliSessions=showCliSessions;
|
||||
window._showPreviousMessagingSessions=!!body.show_previous_messaging_sessions;
|
||||
window._soundEnabled=body.sound_enabled;
|
||||
window._notificationsEnabled=body.notifications_enabled;
|
||||
window._whatsNewSummaryEnabled=!!body.whats_new_summary_enabled;
|
||||
@@ -6342,9 +6409,11 @@ async function saveSettings(andClose){
|
||||
const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||'');
|
||||
const sendKey=($('settingsSendKey')||{}).value;
|
||||
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
||||
const showQuotaChip=!!($('settingsShowQuotaChip')||{}).checked;
|
||||
const showTps=!!($('settingsShowTps')||{}).checked;
|
||||
const fadeTextEffect=!!($('settingsFadeTextEffect')||{}).checked;
|
||||
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
||||
const showPreviousMessagingSessions=!!($('settingsShowPreviousMessagingSessions')||{}).checked;
|
||||
const pw=($('settingsPassword')||{}).value;
|
||||
const theme=($('settingsTheme')||{}).value||'dark';
|
||||
const skin=($('settingsSkin')||{}).value||'default';
|
||||
@@ -6362,15 +6431,18 @@ async function saveSettings(andClose){
|
||||
body.session_endless_scroll=!!($('settingsSessionEndlessScroll')||{}).checked;
|
||||
body.language=language;
|
||||
body.show_token_usage=showTokenUsage;
|
||||
body.show_quota_chip=showQuotaChip===true;
|
||||
body.show_tps=showTps;
|
||||
body.fade_text_effect=fadeTextEffect;
|
||||
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
|
||||
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
body.show_previous_messaging_sessions=showPreviousMessagingSessions;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
body.whats_new_summary_enabled=!!($('settingsWhatsNewSummary')||{}).checked;
|
||||
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
|
||||
body.rtl=!!($('settingsRtl')||{}).checked;
|
||||
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
|
||||
body.show_thinking=window._showThinking!==false;
|
||||
body.sidebar_density=sidebarDensity;
|
||||
@@ -6390,7 +6462,7 @@ async function saveSettings(andClose){
|
||||
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
|
||||
}
|
||||
}
|
||||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||||
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
|
||||
_settingsDirty=false;
|
||||
_resetSettingsPanelState();
|
||||
@@ -6409,7 +6481,7 @@ async function saveSettings(andClose){
|
||||
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
|
||||
}
|
||||
}
|
||||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||||
showToast(t('settings_saved'));
|
||||
_settingsDirty=false;
|
||||
_resetSettingsPanelState();
|
||||
|
||||
@@ -170,6 +170,14 @@ function _clearSessionCompletionUnread(sid) {
|
||||
_saveSessionCompletionUnread();
|
||||
}
|
||||
|
||||
function _clearSessionViewedCount(sid) {
|
||||
if (!sid) return;
|
||||
const counts = _getSessionViewedCounts();
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, sid)) return;
|
||||
delete counts[sid];
|
||||
_saveSessionViewedCounts();
|
||||
}
|
||||
|
||||
function _hasSessionCompletionUnread(sid) {
|
||||
if (!sid) return false;
|
||||
return Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), sid);
|
||||
@@ -248,6 +256,36 @@ function _isSessionEffectivelyStreaming(s) {
|
||||
return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s)));
|
||||
}
|
||||
|
||||
function _isServerIdleSessionRow(s) {
|
||||
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message);
|
||||
}
|
||||
|
||||
function _reconcileActiveSessionIdleStateFromList(serverRows) {
|
||||
if (!S || !S.session || !S.session.session_id) return false;
|
||||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress) return false;
|
||||
if (!Array.isArray(serverRows)) return false;
|
||||
const sid=S.session.session_id;
|
||||
const serverRow=serverRows.find(s=>s&&s.session_id===sid);
|
||||
if (!serverRow) return false;
|
||||
if (!_isServerIdleSessionRow(serverRow)) return false;
|
||||
let changed=false;
|
||||
if (S.busy) { S.busy=false; changed=true; }
|
||||
if (S.activeStreamId) { S.activeStreamId=null; changed=true; }
|
||||
if (INFLIGHT&&INFLIGHT[sid]) {
|
||||
delete INFLIGHT[sid];
|
||||
if (typeof clearInflightState==='function') clearInflightState(sid);
|
||||
changed=true;
|
||||
}
|
||||
if (S.session) {
|
||||
S.session.active_stream_id=null;
|
||||
S.session.pending_user_message=null;
|
||||
}
|
||||
_sessionStreamingById.set(sid, false);
|
||||
_forgetObservedStreamingSession(sid);
|
||||
if (changed&&typeof updateSendBtn==='function') updateSendBtn();
|
||||
return changed;
|
||||
}
|
||||
|
||||
function _purgeStaleInflightEntries() {
|
||||
// Clean up INFLIGHT entries for sessions the server confirms are NOT
|
||||
// streaming. This prevents the in-memory cache from growing unbounded
|
||||
@@ -379,73 +417,104 @@ function _markPollingCompletionUnreadTransitions(sessions) {
|
||||
}
|
||||
}
|
||||
|
||||
let _newSessionInFlight=null;
|
||||
const _newSessionPendingText=()=>t('new_session_creating')||'Creating new conversation…';
|
||||
function _setNewSessionPending(pending){
|
||||
const btn=$('btnNewChat');
|
||||
if(btn){
|
||||
btn.disabled=!!pending;
|
||||
btn.setAttribute('aria-busy',pending?'true':'false');
|
||||
}
|
||||
const statusEl=$('composerStatus');
|
||||
const pendingText=_newSessionPendingText();
|
||||
if(pending){
|
||||
setComposerStatus(pendingText);
|
||||
}else if(statusEl&&statusEl.textContent===pendingText){
|
||||
setComposerStatus('');
|
||||
}
|
||||
}
|
||||
|
||||
async function newSession(flash, options={}){
|
||||
updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
// One-shot profile-switch workspace: applied to the first new session after a profile
|
||||
// switch, then cleared. Use a dedicated flag so S._profileDefaultWorkspace (the
|
||||
// persistent boot/settings default) is not consumed and remains available for the
|
||||
// blank-page display on all subsequent returns to the empty state (#823).
|
||||
const switchWs=S._profileSwitchWorkspace;
|
||||
S._profileSwitchWorkspace=null;
|
||||
const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null);
|
||||
// Use the saved default model for new sessions (#872). The user's saved
|
||||
// default_model (from Settings) takes priority over the chat-header dropdown
|
||||
// value, which reflects the *previous* session's model. Fall back to the
|
||||
// dropdown value only when no default_model is configured.
|
||||
const modelSel=$('modelSelect');
|
||||
const selectedDefaultModel=window._defaultModel||(modelSel&&modelSel.value)||'';
|
||||
let defaultApplied=false;
|
||||
if(window._defaultModel&&modelSel&&typeof _applyModelToDropdown==='function'){
|
||||
defaultApplied=!!_applyModelToDropdown(window._defaultModel,modelSel,window._activeProvider||null);
|
||||
if(_newSessionInFlight){
|
||||
if(typeof showToast==='function') showToast(_newSessionPendingText(),1500);
|
||||
return _newSessionInFlight;
|
||||
}
|
||||
const canQualify=!window._defaultModel||defaultApplied||(modelSel&&modelSel.value===selectedDefaultModel);
|
||||
const newModelState=(canQualify&&typeof _modelStateForSelect==='function')
|
||||
? _modelStateForSelect(modelSel,selectedDefaultModel)
|
||||
: {model:selectedDefaultModel,model_provider:null};
|
||||
const reqBody={
|
||||
model:newModelState.model,
|
||||
model_provider:newModelState.model_provider||null,
|
||||
workspace:inheritWs,
|
||||
profile:S.activeProfile||'default',
|
||||
};
|
||||
if(options&&options.worktree) reqBody.worktree=true;
|
||||
if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject;
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
|
||||
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);
|
||||
_setActiveSessionUrl(S.session.session_id);
|
||||
_setSessionViewedCount(S.session.session_id, S.session.message_count || 0);
|
||||
// Sync chat-header dropdown to the session's model so the UI reflects
|
||||
// the default model the server actually used (#872).
|
||||
if(S.session.model && S.session.model!==$('modelSelect').value && typeof _applyModelToDropdown==='function'){
|
||||
_applyModelToDropdown(S.session.model,$('modelSelect'),S.session.model_provider||null);
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
_setNewSessionPending(true);
|
||||
_newSessionInFlight=(async()=>{
|
||||
updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
// One-shot profile-switch workspace: applied to the first new session after a profile
|
||||
// switch, then cleared. Use a dedicated flag so S._profileDefaultWorkspace (the
|
||||
// persistent boot/settings default) is not consumed and remains available for the
|
||||
// blank-page display on all subsequent returns to the empty state (#823).
|
||||
const switchWs=S._profileSwitchWorkspace;
|
||||
S._profileSwitchWorkspace=null;
|
||||
const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null);
|
||||
// Use the saved default model for new sessions (#872). The user's saved
|
||||
// default_model (from Settings) takes priority over the chat-header dropdown
|
||||
// value, which reflects the *previous* session's model. Fall back to the
|
||||
// dropdown value only when no default_model is configured.
|
||||
const modelSel=$('modelSelect');
|
||||
const selectedDefaultModel=window._defaultModel||(modelSel&&modelSel.value)||'';
|
||||
let defaultApplied=false;
|
||||
if(window._defaultModel&&modelSel&&typeof _applyModelToDropdown==='function'){
|
||||
defaultApplied=!!_applyModelToDropdown(window._defaultModel,modelSel,window._activeProvider||null);
|
||||
}
|
||||
const canQualify=!window._defaultModel||defaultApplied||(modelSel&&modelSel.value===selectedDefaultModel);
|
||||
const newModelState=(canQualify&&typeof _modelStateForSelect==='function')
|
||||
? _modelStateForSelect(modelSel,selectedDefaultModel)
|
||||
: {model:selectedDefaultModel,model_provider:null};
|
||||
const reqBody={
|
||||
model:newModelState.model,
|
||||
model_provider:newModelState.model_provider||null,
|
||||
workspace:inheritWs,
|
||||
profile:S.activeProfile||'default',
|
||||
};
|
||||
if(S.session&&S.session.session_id) reqBody.prev_session_id=S.session.session_id;
|
||||
if(options&&options.worktree) reqBody.worktree=true;
|
||||
if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject;
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
|
||||
S.session=data.session;S.messages=data.session.messages||[];
|
||||
S.lastUsage={...(data.session.last_usage||{})};
|
||||
if(flash)S.session._flash=true;
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
_setActiveSessionUrl(S.session.session_id);
|
||||
_setSessionViewedCount(S.session.session_id, S.session.message_count || 0);
|
||||
// Sync chat-header dropdown to the session's model so the UI reflects
|
||||
// the default model the server actually used (#872).
|
||||
if(S.session.model && S.session.model!==$('modelSelect').value && typeof _applyModelToDropdown==='function'){
|
||||
_applyModelToDropdown(S.session.model,$('modelSelect'),S.session.model_provider||null);
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
}
|
||||
// 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();
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null);
|
||||
if(typeof _syncCtxIndicator==='function'){
|
||||
_syncCtxIndicator({
|
||||
input_tokens:data.session.input_tokens||0,
|
||||
output_tokens:data.session.output_tokens||0,
|
||||
estimated_cost:data.session.estimated_cost||0,
|
||||
context_length:data.session.context_length||0,
|
||||
last_prompt_tokens:data.session.last_prompt_tokens||0,
|
||||
threshold_tokens:data.session.threshold_tokens||0,
|
||||
});
|
||||
}
|
||||
updateQueueBadge(S.session.session_id);
|
||||
syncTopbar();renderMessages();loadDir('.');
|
||||
// don't call renderSessionList here - callers do it when needed
|
||||
})();
|
||||
try{
|
||||
return await _newSessionInFlight;
|
||||
}finally{
|
||||
_newSessionInFlight=null;
|
||||
_setNewSessionPending(false);
|
||||
}
|
||||
// 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();
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null);
|
||||
if(typeof _syncCtxIndicator==='function'){
|
||||
_syncCtxIndicator({
|
||||
input_tokens:data.session.input_tokens||0,
|
||||
output_tokens:data.session.output_tokens||0,
|
||||
estimated_cost:data.session.estimated_cost||0,
|
||||
context_length:data.session.context_length||0,
|
||||
last_prompt_tokens:data.session.last_prompt_tokens||0,
|
||||
threshold_tokens:data.session.threshold_tokens||0,
|
||||
});
|
||||
}
|
||||
updateQueueBadge(S.session.session_id);
|
||||
syncTopbar();renderMessages();loadDir('.');
|
||||
// don't call renderSessionList here - callers do it when needed
|
||||
}
|
||||
|
||||
async function loadSession(sid){
|
||||
@@ -526,7 +595,7 @@ async function loadSession(sid){
|
||||
if(typeof syncTopbar==='function') syncTopbar();
|
||||
_setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0));
|
||||
_clearSessionCompletionUnread(S.session.session_id);
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
_setActiveSessionUrl(S.session.session_id);
|
||||
|
||||
const activeStreamId=S.session.active_stream_id||null;
|
||||
@@ -550,8 +619,10 @@ async function loadSession(sid){
|
||||
return true;
|
||||
}
|
||||
|
||||
// Phase 2a: If session is streaming, restore from INFLIGHT cache before
|
||||
// loading full messages (INFLIGHT state is self-contained and sufficient).
|
||||
// Phase 2a: If session is streaming, restore the persisted transcript first,
|
||||
// then merge the local INFLIGHT live tail. INFLIGHT is a recovery tail, not a
|
||||
// complete transcript; treating it as the full source makes long sessions look
|
||||
// like they lost history after switching away and back.
|
||||
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
|
||||
const stored=loadInflightState(sid, activeStreamId);
|
||||
if(stored){
|
||||
@@ -565,8 +636,15 @@ async function loadSession(sid){
|
||||
}
|
||||
|
||||
if(INFLIGHT[sid]){
|
||||
// Streaming session: use cached INFLIGHT messages (already has pending assistant output).
|
||||
S.messages=INFLIGHT[sid].messages;
|
||||
const inflightMessages=INFLIGHT[sid].messages||[];
|
||||
S.messages=[];
|
||||
S.toolCalls=[];
|
||||
try {
|
||||
await _ensureMessagesLoaded(sid);
|
||||
} catch(e) {
|
||||
S.messages=inflightMessages;
|
||||
}
|
||||
S.messages=_mergeInflightTailMessages(S.messages,inflightMessages);
|
||||
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
|
||||
if(_mergePendingSessionMessage(S.session,S.messages)){
|
||||
INFLIGHT[sid].messages=S.messages;
|
||||
@@ -576,12 +654,17 @@ async function loadSession(sid){
|
||||
// replaying persisted live tools so the compact Activity count survives
|
||||
// switching away from and back to an active chat (#1715).
|
||||
S.activeStreamId=activeStreamId;
|
||||
syncTopbar();renderMessages();appendThinking();loadDir('.');
|
||||
clearLiveToolCards();
|
||||
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||||
for(const tc of (S.toolCalls||[])){
|
||||
if(tc&&tc.name) appendLiveToolCard(tc);
|
||||
syncTopbar();renderMessages();
|
||||
const restoredLiveTurn=typeof restoreLiveTurnHtmlForSession==='function'&&restoreLiveTurnHtmlForSession(sid);
|
||||
if(!restoredLiveTurn){
|
||||
appendThinking();
|
||||
clearLiveToolCards();
|
||||
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||||
for(const tc of (S.toolCalls||[])){
|
||||
if(tc&&tc.name) appendLiveToolCard(tc);
|
||||
}
|
||||
}
|
||||
loadDir('.');
|
||||
setBusy(true);setComposerStatus('');
|
||||
startApprovalPolling(sid);
|
||||
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
|
||||
@@ -685,9 +768,9 @@ async function loadSession(sid){
|
||||
input_tokens: _pick(u.input_tokens, _s.input_tokens),
|
||||
output_tokens: _pick(u.output_tokens, _s.output_tokens),
|
||||
estimated_cost: _pick(u.estimated_cost, _s.estimated_cost),
|
||||
context_length: _pick(u.context_length, _s.context_length),
|
||||
context_length: _pick(_s.context_length, u.context_length),
|
||||
last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens),
|
||||
threshold_tokens: _pick(u.threshold_tokens, _s.threshold_tokens),
|
||||
threshold_tokens: _pick(_s.threshold_tokens, u.threshold_tokens),
|
||||
});
|
||||
}
|
||||
if(typeof _renderPendingPromptsForActiveSession==='function') _renderPendingPromptsForActiveSession();
|
||||
@@ -720,12 +803,13 @@ const _HANDOFF_THRESHOLD = 10; // conversation rounds
|
||||
const _HANDOFF_STORAGE_PREFIX = 'handoff:';
|
||||
const _HANDOFF_SUFFIX_DISMISSED_AT = 'dismissed_at';
|
||||
const _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT = 'summary_handled_at';
|
||||
const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack']);
|
||||
const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack', 'email']);
|
||||
const _MESSAGING_SOURCE_LABELS = {
|
||||
weixin: 'WeChat',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
email: 'Email',
|
||||
};
|
||||
|
||||
function _isMessagingSession(session) {
|
||||
@@ -810,6 +894,12 @@ function _clearHandoffStorageForSession(sid) {
|
||||
_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, null);
|
||||
_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, null);
|
||||
} catch {}
|
||||
// Session deletion should also prune per-session tracking maps. Otherwise
|
||||
// heavy users accumulate one localStorage entry per deleted session forever,
|
||||
// which increases quota pressure and can make future UI persistence fail.
|
||||
try { _clearSessionViewedCount(sid); } catch {}
|
||||
try { _clearSessionCompletionUnread(sid); } catch {}
|
||||
try { _forgetObservedStreamingSession(sid); } catch {}
|
||||
}
|
||||
|
||||
function _getHandoffDismissedAt(sid) {
|
||||
@@ -1074,8 +1164,23 @@ function _resolveSessionModelForDisplaySoon(sid){
|
||||
if(!model||!S.session||S.session.session_id!==sid) return;
|
||||
S.session.model=model;
|
||||
S.session.model_provider=provider||null;
|
||||
S.session.context_length=data.session.context_length||0;
|
||||
S.session.threshold_tokens=data.session.threshold_tokens||0;
|
||||
S.session.last_prompt_tokens=data.session.last_prompt_tokens||0;
|
||||
S.session._modelResolutionDeferred=false;
|
||||
syncTopbar();
|
||||
if(typeof _syncCtxIndicator==='function'){
|
||||
const u=S.lastUsage||{};
|
||||
const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
|
||||
_syncCtxIndicator({
|
||||
input_tokens:_pick(u.input_tokens,S.session.input_tokens),
|
||||
output_tokens:_pick(u.output_tokens,S.session.output_tokens),
|
||||
estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost),
|
||||
context_length:data.session.context_length||0,
|
||||
last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens),
|
||||
threshold_tokens:data.session.threshold_tokens||0,
|
||||
});
|
||||
}
|
||||
}catch(_){
|
||||
// Keep session switching non-blocking; the next load can try again.
|
||||
}
|
||||
@@ -1128,6 +1233,40 @@ async function _ensureMessagesLoaded(sid) {
|
||||
}
|
||||
}
|
||||
|
||||
function _messageComparableText(m){
|
||||
if(!m) return '';
|
||||
if(typeof msgContent==='function'){
|
||||
try{return String(msgContent(m)||'').trim();}
|
||||
catch(_){}
|
||||
}
|
||||
return String(m.content||'').trim();
|
||||
}
|
||||
|
||||
function _sameTranscriptMessage(a,b){
|
||||
return !!(a&&b) &&
|
||||
String(a.role||'')===String(b.role||'') &&
|
||||
_messageComparableText(a)===_messageComparableText(b);
|
||||
}
|
||||
|
||||
function _mergeInflightTailMessages(baseMessages, inflightMessages){
|
||||
const base=Array.isArray(baseMessages)?baseMessages:[];
|
||||
const inflight=Array.isArray(inflightMessages)?inflightMessages:[];
|
||||
let liveIdx=-1;
|
||||
for(let i=inflight.length-1;i>=0;i--){
|
||||
if(inflight[i]&&inflight[i]._live){liveIdx=i;break;}
|
||||
}
|
||||
if(liveIdx<0) return base;
|
||||
let start=liveIdx;
|
||||
if(liveIdx>0&&inflight[liveIdx-1]&&inflight[liveIdx-1].role==='user') start=liveIdx-1;
|
||||
const tail=inflight.slice(start).filter(m=>m&&m.role);
|
||||
const merged=[...base];
|
||||
for(const msg of tail){
|
||||
const duplicate=merged.slice(-Math.max(5,tail.length+2)).some(existing=>_sameTranscriptMessage(existing,msg));
|
||||
if(!duplicate) merged.push(msg);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Load older messages when the user scrolls to the top of the conversation.
|
||||
// Prepends them to S.messages and re-renders, preserving scroll position.
|
||||
let _loadingOlder = false;
|
||||
@@ -1742,6 +1881,23 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
await _archiveSession(session,!session.archived);
|
||||
}
|
||||
));
|
||||
if(isExternalSession && !session.archived){
|
||||
menu.appendChild(_buildSessionAction(
|
||||
t('session_hide_external'),
|
||||
t('session_hide_external_desc'),
|
||||
ICONS.archive,
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
try{
|
||||
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:true})});
|
||||
session.archived=true;
|
||||
if(S.session&&S.session.session_id===session.session_id) S.session.archived=true;
|
||||
await renderSessionList();
|
||||
showToast(t('session_hidden'));
|
||||
}catch(err){showToast(t('session_archive_failed')+err.message);}
|
||||
}
|
||||
));
|
||||
}
|
||||
if(!isExternalSession){
|
||||
_appendSessionDuplicateAction(menu, session);
|
||||
}
|
||||
@@ -1840,6 +1996,7 @@ function _mergeOptimisticFirstTurnSessions(fetchedSessions){
|
||||
const idx=bySid.has(sid)?bySid.get(sid):-1;
|
||||
if(idx>=0){
|
||||
const fetched=merged[idx]||{};
|
||||
const fetchedIsServerIdle=_isServerIdleSessionRow(fetched);
|
||||
const localCount=Number(local.message_count||0);
|
||||
const fetchedCount=Number(fetched.message_count||0);
|
||||
const localTs=Number(local.last_message_at||local.updated_at||0);
|
||||
@@ -1850,10 +2007,10 @@ function _mergeOptimisticFirstTurnSessions(fetchedSessions){
|
||||
message_count:Math.max(localCount,fetchedCount),
|
||||
last_message_at:Math.max(localTs,fetchedTs),
|
||||
updated_at:Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs),
|
||||
active_stream_id:fetched.active_stream_id||local.active_stream_id||null,
|
||||
pending_user_message:fetched.pending_user_message||local.pending_user_message||null,
|
||||
pending_started_at:fetched.pending_started_at||local.pending_started_at||null,
|
||||
is_streaming:Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local)),
|
||||
active_stream_id:fetchedIsServerIdle?null:(fetched.active_stream_id||local.active_stream_id||null),
|
||||
pending_user_message:fetchedIsServerIdle?null:(fetched.pending_user_message||local.pending_user_message||null),
|
||||
pending_started_at:fetchedIsServerIdle?null:(fetched.pending_started_at||local.pending_started_at||null),
|
||||
is_streaming:fetchedIsServerIdle?false:Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local)),
|
||||
};
|
||||
}else{
|
||||
merged.push({...local,is_streaming:true});
|
||||
@@ -1895,9 +2052,6 @@ function _applySessionListPayload(sessData, projData){
|
||||
// active profile so the "Show N from other profiles" toggle can render
|
||||
// without a second round-trip. Stashed on the module for renderSessionListFromCache.
|
||||
_otherProfileCount = sessData.other_profile_count || 0;
|
||||
_allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]);
|
||||
_clearLineageReportCache();
|
||||
_allProjects = projData.projects||[];
|
||||
// Capture server clock for clock-skew compensation (issue #1144).
|
||||
// server_time is epoch seconds from the server's time.time().
|
||||
// _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta)
|
||||
@@ -1908,6 +2062,10 @@ function _applySessionListPayload(sessData, projData){
|
||||
if (typeof sessData.server_tz === 'string') {
|
||||
_serverTz = sessData.server_tz;
|
||||
}
|
||||
_reconcileActiveSessionIdleStateFromList(sessData.sessions||[]);
|
||||
_allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]);
|
||||
_clearLineageReportCache();
|
||||
_allProjects = projData.projects||[];
|
||||
_markPollingCompletionUnreadTransitions(_allSessions);
|
||||
const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming));
|
||||
if (isStreaming) {
|
||||
@@ -2220,17 +2378,31 @@ function _isChildSession(s){
|
||||
return !!(s&&s.parent_session_id&&s.relationship_type==='child_session');
|
||||
}
|
||||
|
||||
function _sessionLineageKey(s, sessionIdsInList){
|
||||
function _sessionLineageKey(s, sessionIdsInList, sessionsById){
|
||||
if(!s||!s.session_id) return null;
|
||||
if(_isChildSession(s)) return null;
|
||||
if(s.session_source==='fork') return null;
|
||||
const lineageKey=s._lineage_root_id||s.lineage_root_id||null;
|
||||
if(lineageKey) return lineageKey;
|
||||
// WebUI-native context compression may only persist parent_session_id:
|
||||
// the preserved parent snapshot is marked pre_compression_snapshot while
|
||||
// the new continuation points at it. When both rows are in the sidebar
|
||||
// payload, still collapse them into one conversation (#2489).
|
||||
const parent=s.parent_session_id&&sessionsById?sessionsById.get(s.parent_session_id):null;
|
||||
if(s.pre_compression_snapshot||parent&&parent.pre_compression_snapshot){
|
||||
let root=s;
|
||||
const seen=new Set();
|
||||
while(root&&root.parent_session_id&&sessionsById&&sessionsById.has(root.parent_session_id)&&!seen.has(root.parent_session_id)){
|
||||
const next=sessionsById.get(root.parent_session_id);
|
||||
if(!next||_isChildSession(next)||next.session_source==='fork'||!(root.pre_compression_snapshot||next.pre_compression_snapshot)) break;
|
||||
seen.add(root.session_id);
|
||||
root=next;
|
||||
}
|
||||
return root&&root.session_id||s.parent_session_id||s.session_id;
|
||||
}
|
||||
// If parent_session_id points to another session in the current list,
|
||||
// this is a subagent/fork child without compression metadata — don't
|
||||
// collapse it into lineage (#494). Compression continuations carry an
|
||||
// explicit lineage root, even when stale optimistic rows leave parent
|
||||
// segments in the browser cache during active compression.
|
||||
// collapse it into lineage (#494).
|
||||
if(s.parent_session_id && sessionIdsInList && sessionIdsInList.has(s.parent_session_id)){
|
||||
return null;
|
||||
}
|
||||
@@ -2406,9 +2578,10 @@ function _syncSidebarExpansionForActiveSession(rows, activeSid){
|
||||
function _collapseSessionLineageForSidebar(sessions){
|
||||
const result=[];
|
||||
const sessionIdsInList=new Set((sessions||[]).map(s=>s.session_id));
|
||||
const sessionsById=new Map((sessions||[]).filter(s=>s&&s.session_id).map(s=>[s.session_id,s]));
|
||||
const groups=new Map();
|
||||
for(const s of sessions||[]){
|
||||
const key=_sessionLineageKey(s, sessionIdsInList);
|
||||
const key=_sessionLineageKey(s, sessionIdsInList, sessionsById);
|
||||
if(!key){result.push(s);continue;}
|
||||
if(!groups.has(key)) groups.set(key,[]);
|
||||
groups.get(key).push(s);
|
||||
@@ -3680,7 +3853,12 @@ function _showProjectPicker(session, anchorEl){
|
||||
picker.remove();
|
||||
document.removeEventListener('click',close);
|
||||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})});
|
||||
session.project_id=null;
|
||||
// Sidebar rows are shallow copies of _allSessions entries (see
|
||||
// _attachChildSessionsToSidebarRows), so mutating `session` only updates
|
||||
// the discarded copy. Write into the authoritative cache so the next
|
||||
// renderSessionListFromCache() reflects the move. (#2551)
|
||||
const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id);
|
||||
if(idx>=0) _allSessions[idx].project_id=null;
|
||||
renderSessionListFromCache();
|
||||
showToast('Removed from project');
|
||||
};
|
||||
@@ -3702,7 +3880,9 @@ function _showProjectPicker(session, anchorEl){
|
||||
picker.remove();
|
||||
document.removeEventListener('click',close);
|
||||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})});
|
||||
session.project_id=p.project_id;
|
||||
// See #2551 — write to _allSessions, not the shallow sidebar copy.
|
||||
const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id);
|
||||
if(idx>=0) _allSessions[idx].project_id=p.project_id;
|
||||
renderSessionListFromCache();
|
||||
showToast('Moved to '+p.name);
|
||||
};
|
||||
|
||||
@@ -175,6 +175,51 @@
|
||||
:root[data-skin="sienna"] .session-item.active{border-left:2px solid var(--accent);}
|
||||
:root[data-skin="sienna"] .composer-box{border-radius:18px;}
|
||||
|
||||
/* ── Skin: Catppuccin (Latte light / Mocha dark) ──
|
||||
Full palette rewrite using the Catppuccin mauve accent. Light mode maps
|
||||
to Latte, dark mode maps to Mocha, matching the Sienna-style single-skin
|
||||
pattern without adding a separate theme axis. Opt-in via Settings → Skin. */
|
||||
:root[data-skin="catppuccin"]{
|
||||
--bg:#EFF1F5;--sidebar:#E6E9EF;--surface:#FFFFFF;
|
||||
--border:#CCD0DA;--border2:#BCC0CC;
|
||||
--text:#4C4F69;--muted:#7C7F93;--strong:#1E1E2E;--em:#5C5F77;
|
||||
--accent:#8839EF;--accent-hover:#6C26D6;--accent-text:#8839EF;
|
||||
--accent-bg:rgba(136,57,239,0.09);--accent-bg-strong:rgba(136,57,239,0.18);
|
||||
--code-bg:#E6E9EF;--code-inline-bg:rgba(30,30,46,0.06);--code-text:#8839EF;--pre-text:#4C4F69;
|
||||
--topbar-bg:rgba(239,241,245,0.96);--main-bg:rgba(239,241,245,0.55);
|
||||
--input-bg:rgba(30,30,46,0.035);--hover-bg:rgba(30,30,46,0.05);
|
||||
--focus-ring:rgba(136,57,239,0.3);--focus-glow:rgba(136,57,239,0.1);
|
||||
--blue:#1E66F5;--gold:#DF8E1D;
|
||||
--user-bubble-bg:#E6E9EF;--user-bubble-border:#CCD0DA;
|
||||
--user-bubble-text:#4C4F69;--user-bubble-placeholder:#7C7F93;
|
||||
--user-selection-bg:rgba(136,57,239,0.24);--user-selection-text:#1E1E2E;
|
||||
}
|
||||
:root.dark[data-skin="catppuccin"]{
|
||||
--bg:#1E1E2E;--sidebar:#181825;--surface:#313244;
|
||||
--border:#45475A;--border2:#585B70;
|
||||
--text:#CDD6F4;--muted:#A6ADC8;--strong:#F5F5F5;--em:#BAC2DE;
|
||||
--accent:#CBA6F7;--accent-hover:#B4BEFE;--accent-text:#CBA6F7;
|
||||
--accent-bg:rgba(203,166,247,0.12);--accent-bg-strong:rgba(203,166,247,0.22);
|
||||
--code-bg:#181825;--code-inline-bg:rgba(255,255,255,0.07);--code-text:#CBA6F7;--pre-text:#CDD6F4;
|
||||
--topbar-bg:rgba(30,30,46,0.96);--main-bg:rgba(30,30,46,0.55);
|
||||
--input-bg:rgba(255,255,255,0.045);--hover-bg:rgba(255,255,255,0.07);
|
||||
--focus-ring:rgba(203,166,247,0.35);--focus-glow:rgba(203,166,247,0.1);
|
||||
--blue:#89B4FA;--gold:#F9E2AF;
|
||||
--user-bubble-bg:#313244;--user-bubble-border:#45475A;
|
||||
--user-bubble-text:#CDD6F4;--user-bubble-placeholder:#A6ADC8;
|
||||
--user-selection-bg:rgba(203,166,247,0.3);--user-selection-text:#1E1E2E;
|
||||
}
|
||||
:root[data-skin="catppuccin"]:not(.dark) .new-chat-btn,
|
||||
:root.dark[data-skin="catppuccin"] .new-chat-btn{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;box-shadow:0 1px 3px rgba(136,57,239,0.24);}
|
||||
:root[data-skin="catppuccin"]:not(.dark) .new-chat-btn:hover,
|
||||
:root.dark[data-skin="catppuccin"] .new-chat-btn:hover{background:var(--accent-hover);border-color:var(--accent-hover);color:#fff;}
|
||||
:root[data-skin="catppuccin"] .tool-card{background:rgba(30,30,46,0.025);border-color:var(--border);}
|
||||
:root.dark[data-skin="catppuccin"] .tool-card{background:rgba(255,255,255,0.03);}
|
||||
:root[data-skin="catppuccin"] .tool-card-running{background:var(--accent-bg);border-color:var(--accent-bg-strong);}
|
||||
:root[data-skin="catppuccin"] .tool-arg-key,
|
||||
:root[data-skin="catppuccin"] .tool-card-more{color:var(--accent-text);}
|
||||
:root[data-skin="catppuccin"] .tool-card-running-dot{background:var(--accent);}
|
||||
|
||||
/* ── Skin: Nous Research (steel blue, monospace, sharp corners, deep navy dark) ──
|
||||
Full palette rewrite inspired by the Nous Research visual identity.
|
||||
Monochromatic steel blue accent, monospace typography, near-sharp corners,
|
||||
@@ -379,6 +424,18 @@
|
||||
@media (display-mode: standalone), (display-mode: fullscreen){
|
||||
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
|
||||
}
|
||||
/* Pull-to-refresh indicator for PWA standalone mode */
|
||||
.pull-to-refresh-indicator{position:absolute;top:0;left:0;right:0;z-index:100;display:flex;align-items:center;justify-content:center;gap:8px;padding:12px;font-size:13px;color:var(--muted);pointer-events:none;opacity:0;transform:translateY(-100%);transition:opacity .2s,transform .2s;background:var(--bg);border-bottom:1px solid var(--border);}
|
||||
.pull-to-refresh-indicator.active{opacity:1;transform:translateY(0);}
|
||||
.pull-to-refresh-indicator .ptr-icon{display:inline-block;transition:transform .2s;font-size:16px;}
|
||||
.pull-to-refresh-indicator .ptr-icon.ready{transform:rotate(180deg);color:var(--accent);}
|
||||
/* Reload button — visible only in PWA standalone / fullscreen */
|
||||
.app-titlebar-reload{display:none;align-items:center;justify-content:center;width:32px;height:32px;flex-shrink:0;background:none;border:none;color:var(--muted);border-radius:8px;cursor:pointer;padding:0;-webkit-tap-highlight-color:transparent;transition:background-color .15s,color .15s;}
|
||||
.app-titlebar-reload:hover{background:var(--hover-bg);color:var(--text);}
|
||||
@media (display-mode: standalone), (display-mode: fullscreen){
|
||||
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
|
||||
.app-titlebar-reload{display:inline-flex;}
|
||||
}
|
||||
}
|
||||
html{background:var(--sidebar);}
|
||||
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;flex-direction:column;}
|
||||
@@ -1222,6 +1279,28 @@
|
||||
.composer-model-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.composer-model-icon,.composer-model-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
|
||||
.composer-model-select{position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;pointer-events:none!important;}
|
||||
.provider-quota-chip{display:inline-flex;align-items:center;gap:6px;height:34px;max-width:150px;padding:7px 10px;border:1px solid var(--border2);border-radius:999px;background:rgba(34,197,94,.10);color:var(--text);font-size:11px;font-weight:650;line-height:1;white-space:nowrap;cursor:pointer;transition:background .12s,border-color .12s,color .12s;}
|
||||
.provider-quota-chip[hidden]{display:none!important;}
|
||||
.provider-quota-chip:hover{background:rgba(34,197,94,.16);border-color:rgba(34,197,94,.35);}
|
||||
.provider-quota-chip-dot{width:6px;height:6px;border-radius:999px;background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.12);flex:0 0 auto;}
|
||||
.provider-quota-chip-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
/* Hide the ambient quota chip below 1400px — the composer footer already
|
||||
has profile + workspace + model picker + reasoning + media + send and
|
||||
was getting cramped (model picker truncating from "Claude Sonnet 4 7"
|
||||
to "Claude Sonnet 4", workspace dropping more text) once the chip
|
||||
squeezed in. Mobile users get provider info via the dedicated
|
||||
mobile-config drawer; laptop desktop users can find quota in
|
||||
Settings → Providers (the chip's onclick target). Show only on wide
|
||||
desktop where there's genuine room. Aron's standing position on
|
||||
composer real estate (PR #1721, May 13 2026) reinforces this.
|
||||
|
||||
Using 1399.98px rather than 1400px to avoid the one-pixel boundary
|
||||
overlap with the existing @media (min-width:1400px) `.messages-inner`
|
||||
rule at line 893 — at exactly 1400px both would fire, so the chip
|
||||
was hidden AT the wide-desktop boundary where it should first appear. */
|
||||
@media (max-width:1399.98px){
|
||||
.provider-quota-chip{display:none!important;}
|
||||
}
|
||||
/* Increased specificity (.icon-btn.composer-mobile-config-btn) to win the cascade
|
||||
against the later-in-source `.icon-btn { display:flex }` rule that this element
|
||||
also matches. Without this, the button shows at desktop width — see #1381 review. */
|
||||
@@ -1437,6 +1516,7 @@
|
||||
.file-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.file-item.active{background:var(--accent-bg);color:var(--accent-text);}
|
||||
.file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:10px;text-align:center;line-height:1;}
|
||||
.file-tree-toggle-placeholder{display:inline-block;flex:0 0 10px;width:10px;line-height:1;}
|
||||
.file-item.file-empty{color:var(--muted);opacity:.5;font-style:italic;cursor:default;font-size:11px;}
|
||||
.file-item.file-empty:hover{background:none;color:var(--muted);}
|
||||
.preview-area{flex:1;overflow:auto;padding:14px;flex-direction:column;gap:8px;display:none;opacity:0;transition:opacity .15s;}
|
||||
@@ -1868,6 +1948,10 @@
|
||||
.msg-row:hover .msg-actions{opacity:1;}
|
||||
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
|
||||
.msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);}
|
||||
.selected-text-reply-btn{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:2px solid var(--accent);border-radius:999px;background:var(--bg);color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);font-size:12px;font-weight:700;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease,background .12s ease,border-color .12s ease;}
|
||||
.selected-text-reply-btn.visible{opacity:1;pointer-events:auto;transform:translateY(0);}
|
||||
.selected-text-reply-btn:hover{background:var(--accent-bg);border-color:var(--accent-hover);}
|
||||
.selected-text-reply-btn:focus-visible{background:var(--accent-bg);border-color:var(--accent-hover);outline:2px solid var(--focus-ring);outline-offset:2px;}
|
||||
/* TTS speaker button: hidden by default, shown when TTS is enabled.
|
||||
* Use body-class selector instead of JS inline-style so the rule survives
|
||||
* subsequent renderMd() passes and is not subject to inline-style cascade
|
||||
@@ -2562,6 +2646,8 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
.panel-head-actions{display:flex;align-items:center;gap:4px;text-transform:none;letter-spacing:normal;}
|
||||
.panel-head-btn{width:24px;height:24px;padding:0;display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;border-radius:6px;color:var(--muted);cursor:pointer;transition:background .15s,color .15s;flex-shrink:0;}
|
||||
.panel-head-btn:hover{background:var(--accent-bg);color:var(--accent-text);}
|
||||
.panel-head-btn:disabled,.panel-head-btn[aria-busy="true"]{opacity:.5;cursor:wait;}
|
||||
.panel-head-btn:disabled:hover,.panel-head-btn[aria-busy="true"]:hover{background:transparent;color:var(--muted);}
|
||||
.panel-head-btn svg{width:14px;height:14px;display:block;}
|
||||
.panel-head-sub{padding:6px 12px 8px;font-size:11px;color:var(--muted);flex-shrink:0;}
|
||||
.side-menu{display:flex;flex-direction:column;gap:2px;padding:8px;overflow-y:auto;}
|
||||
@@ -3078,7 +3164,10 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
Only sub-element selectors that the consolidated block doesn't cover
|
||||
(label, toggle, rotate animation) are kept here. ── */
|
||||
.thinking-card-label{font-weight:600;letter-spacing:.02em;}
|
||||
.thinking-card-toggle{margin-left:auto;font-size:10px;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
|
||||
.thinking-card-btn-row{margin-left:auto;display:inline-flex;align-items:center;gap:6px;}
|
||||
.thinking-copy-btn{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--accent-text);opacity:.72;cursor:pointer;transition:background .15s,color .15s,opacity .15s;}
|
||||
.thinking-copy-btn:hover,.thinking-copy-btn:focus-visible{background:var(--accent-bg-strong);opacity:1;outline:none;}
|
||||
.thinking-card-toggle{font-size:10px;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
|
||||
.thinking-card.open .thinking-card-toggle{transform:rotate(90deg);}
|
||||
|
||||
.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;}
|
||||
@@ -3831,7 +3920,14 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-board-switcher-item:hover,
|
||||
.kanban-board-switcher-item:focus{background:var(--accent-bg);outline:none;}
|
||||
.kanban-board-switcher-item.is-current{font-weight:650;}
|
||||
.kanban-board-switcher-item-icon{font-size:14px;line-height:1;flex-shrink:0;width:18px;text-align:center;}
|
||||
.kanban-board-switcher-item-icon{
|
||||
display:block;
|
||||
font-size:14px;line-height:1.15;
|
||||
flex:0 1 auto;
|
||||
min-width:18px;max-width:7.5rem;
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
text-align:center;
|
||||
}
|
||||
.kanban-board-switcher-item-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.kanban-board-switcher-item-count{
|
||||
flex-shrink:0;font-size:10px;color:var(--muted);
|
||||
@@ -4106,3 +4202,81 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
@media (prefers-reduced-motion: reduce){.stream-fade-word.is-new{animation:none;will-change:auto;}}
|
||||
[data-live-assistant="1"]:last-child .msg-body.stream-fade-active > :last-child::after,
|
||||
[data-live-assistant="1"]:last-child .msg-body.stream-fade-active:not(:has(> *))::after{display:none;content:none;}
|
||||
|
||||
/* ── RTL chat layout (salvaged from PR #1721 by @malulian) ────────────────────
|
||||
* Triggered by .chat-content-rtl on <html>. Affects ONLY the chat messages
|
||||
* area and composer input, not the sidebar, workspace panel, settings panel,
|
||||
* or any other UI element. Direction objection from design review (PR #1721
|
||||
* comment by @aronprins, May 13 2026) honored: no composer button — the
|
||||
* toggle lives in Settings → Preferences only.
|
||||
*
|
||||
* Code blocks, inline code, and pre-formatted blocks stay LTR even when RTL
|
||||
* is active — Arabic/Hebrew users still write English code, command lines,
|
||||
* URLs, and JSON the same way English users do. Flipping those would render
|
||||
* code mirrored (e.g. `()fn()` instead of `fn()`) which is the salvage's
|
||||
* critical bug fix vs the original PR #1721 implementation.
|
||||
*/
|
||||
.chat-content-rtl .msg-row{
|
||||
direction:rtl;
|
||||
text-align:right;
|
||||
unicode-bidi:plaintext;
|
||||
}
|
||||
.chat-content-rtl .msg-body{
|
||||
text-align:start;
|
||||
}
|
||||
.chat-content-rtl .msg-body th,
|
||||
.chat-content-rtl .csv-table th,
|
||||
.chat-content-rtl .csv-table td{
|
||||
text-align:right;
|
||||
}
|
||||
.chat-content-rtl .tool-call-group-summary{
|
||||
text-align:right;
|
||||
}
|
||||
.chat-content-rtl textarea#msg,
|
||||
.chat-content-rtl #msg{
|
||||
direction:rtl;
|
||||
text-align:right;
|
||||
unicode-bidi:plaintext;
|
||||
}
|
||||
/* Force code/pre/tt back to LTR — Arabic users still write English code. */
|
||||
.chat-content-rtl .msg-body pre,
|
||||
.chat-content-rtl .msg-body code,
|
||||
.chat-content-rtl .msg-body kbd,
|
||||
.chat-content-rtl .msg-body samp,
|
||||
.chat-content-rtl .msg-body tt,
|
||||
.chat-content-rtl .msg-body .hljs,
|
||||
.chat-content-rtl .msg-body .code-block,
|
||||
.chat-content-rtl .msg-body .code-block *,
|
||||
.chat-content-rtl .msg-body pre *{
|
||||
direction:ltr;
|
||||
text-align:left;
|
||||
unicode-bidi:isolate;
|
||||
}
|
||||
/* Force LTR for content classes that render English-by-design even in chat:
|
||||
KaTeX equations (math is LTR-only in standard notation), diff hunks
|
||||
(file paths + line numbers), CSV tables (column order must stay
|
||||
left-to-right), and skill file paths. */
|
||||
.chat-content-rtl .msg-body .katex,
|
||||
.chat-content-rtl .msg-body .katex-block,
|
||||
.chat-content-rtl .msg-body .katex-display,
|
||||
.chat-content-rtl .msg-body .katex-html,
|
||||
.chat-content-rtl .msg-body .katex-inline,
|
||||
.chat-content-rtl .msg-body .diff-block,
|
||||
.chat-content-rtl .msg-body .diff-block *,
|
||||
.chat-content-rtl .msg-body .csv-table-wrap,
|
||||
.chat-content-rtl .msg-body .csv-table,
|
||||
.chat-content-rtl .msg-body .csv-table *,
|
||||
.chat-content-rtl .msg-body .skill-file-path{
|
||||
direction:ltr;
|
||||
text-align:left;
|
||||
unicode-bidi:isolate;
|
||||
}
|
||||
/* Tool-call content (commands, paths, JSON) stays LTR. */
|
||||
.chat-content-rtl .tool-call-group-body,
|
||||
.chat-content-rtl .tool-call-group-body *,
|
||||
.chat-content-rtl .tool-result,
|
||||
.chat-content-rtl .tool-result *{
|
||||
direction:ltr;
|
||||
text-align:left;
|
||||
unicode-bidi:isolate;
|
||||
}
|
||||
@@ -39,28 +39,35 @@ const SHELL_ASSETS = [
|
||||
'./manifest.json',
|
||||
];
|
||||
|
||||
// Install: pre-cache the app shell
|
||||
function deleteOldShellCaches() {
|
||||
return caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Install: prune old shell caches first, then pre-cache the app shell. Doing
|
||||
// this before caches.open(CACHE_NAME) avoids a temporary double-cache window on
|
||||
// quota-sensitive browsers during frequent version bumps.
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(SHELL_ASSETS).catch((err) => {
|
||||
// Non-fatal: if any asset fails, still activate
|
||||
console.warn('[sw] Shell pre-cache partial failure:', err);
|
||||
});
|
||||
})
|
||||
deleteOldShellCaches().then(() =>
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(SHELL_ASSETS).catch((err) => {
|
||||
// Non-fatal: if any asset fails, still activate
|
||||
console.warn('[sw] Shell pre-cache partial failure:', err);
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean up old caches
|
||||
// Activate: keep the old-cache cleanup as a safety net in case install was
|
||||
// interrupted or an older worker was already waiting.
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
event.waitUntil(deleteOldShellCaches());
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
|
||||
@@ -634,6 +634,91 @@ if(document.readyState==='loading') document.addEventListener('DOMContentLoaded'
|
||||
else _initMediaPlaybackObserver();
|
||||
setTimeout(_initMediaPlaybackObserver,0);
|
||||
|
||||
// ── Ambient provider quota indicator (#1766) ────────────────────────────────
|
||||
let _providerQuotaRefreshInFlight=false;
|
||||
|
||||
function _formatQuotaMoneyShort(value){
|
||||
const n=Number(value);
|
||||
if(!Number.isFinite(n)) return '';
|
||||
if(Math.abs(n)>=100) return '$'+n.toFixed(0);
|
||||
if(Math.abs(n)>=10) return '$'+n.toFixed(1);
|
||||
return '$'+n.toFixed(2);
|
||||
}
|
||||
function _formatQuotaPercentShort(value){
|
||||
const n=Number(value);
|
||||
if(!Number.isFinite(n)) return '';
|
||||
return Math.max(0,Math.min(100,n)).toFixed(0)+'%';
|
||||
}
|
||||
function _providerQuotaIndicatorText(status){
|
||||
if(!status||status.status!=='available') return null;
|
||||
const provider=status.display_name||status.provider||'Provider';
|
||||
const accountLimits=status.account_limits||null;
|
||||
if(accountLimits&&Array.isArray(accountLimits.windows)&&accountLimits.windows.length){
|
||||
const w=accountLimits.windows.find(x=>x&&Number.isFinite(Number(x.remaining_percent)))||accountLimits.windows[0];
|
||||
const remaining=_formatQuotaPercentShort(w&&w.remaining_percent);
|
||||
if(remaining) return {label:provider+' '+remaining, title:(status.message||'Provider usage loaded')+' — '+remaining+' remaining'};
|
||||
}
|
||||
const quota=status.quota||null;
|
||||
if(quota){
|
||||
const remaining=_formatQuotaMoneyShort(quota.limit_remaining);
|
||||
const used=_formatQuotaMoneyShort(quota.usage);
|
||||
const limit=_formatQuotaMoneyShort(quota.limit);
|
||||
if(remaining){
|
||||
const parts=[];
|
||||
if(used) parts.push('used '+used);
|
||||
if(limit) parts.push('limit '+limit);
|
||||
return {label:provider+' '+remaining, title:(status.message||'Provider quota loaded')+(parts.length?' — '+parts.join(' · '):'')};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function renderProviderQuotaIndicator(status){
|
||||
const chip=$('providerQuotaChip');
|
||||
const label=$('providerQuotaChipLabel');
|
||||
if(!chip||!label) return;
|
||||
// Hide entirely when the user has disabled the ambient quota chip in Settings.
|
||||
// Default is off (window._showQuotaChip defaults to false in boot.js) so users
|
||||
// never see the chip unless they opt in.
|
||||
if(window._showQuotaChip!==true){
|
||||
chip.hidden=true;
|
||||
label.textContent='';
|
||||
chip.removeAttribute('title');
|
||||
return;
|
||||
}
|
||||
const text=_providerQuotaIndicatorText(status);
|
||||
if(!text||status.status!=='available'||(!status.quota&&!status.account_limits)){
|
||||
chip.hidden=true;
|
||||
label.textContent='';
|
||||
chip.removeAttribute('title');
|
||||
return;
|
||||
}
|
||||
label.textContent=text.label;
|
||||
chip.title=text.title;
|
||||
chip.hidden=false;
|
||||
}
|
||||
async function refreshProviderQuotaIndicator(){
|
||||
// Short-circuit before the fetch when the chip is disabled — no point asking
|
||||
// the server for quota data the UI will throw away.
|
||||
if(window._showQuotaChip!==true){
|
||||
const chip=$('providerQuotaChip');
|
||||
if(chip){chip.hidden=true;chip.removeAttribute('title');}
|
||||
return;
|
||||
}
|
||||
if(_providerQuotaRefreshInFlight) return;
|
||||
_providerQuotaRefreshInFlight=true;
|
||||
try{
|
||||
const status=await api('/api/provider/quota');
|
||||
renderProviderQuotaIndicator(status);
|
||||
}catch(_e){
|
||||
renderProviderQuotaIndicator(null);
|
||||
}finally{
|
||||
_providerQuotaRefreshInFlight=false;
|
||||
}
|
||||
}
|
||||
window.addEventListener('visibilitychange',()=>{
|
||||
if(document.visibilityState==='visible'&&typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
|
||||
});
|
||||
|
||||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||||
let _dynamicModelLabels={};
|
||||
window._configuredModelBadges=window._configuredModelBadges||{};
|
||||
@@ -660,6 +745,18 @@ function _providerFromModelValue(modelId){
|
||||
if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
|
||||
return '';
|
||||
}
|
||||
function _providerSkipsModelMismatchWarning(providerId){
|
||||
const p=String(providerId||'').toLowerCase();
|
||||
return !p||p==='custom'||p.startsWith('custom:')||p==='openrouter';
|
||||
}
|
||||
function _providerDefersMissingModelFallback(providerId){
|
||||
const p=String(providerId||'').toLowerCase();
|
||||
// Named custom providers and OpenRouter can legitimately route vendor-prefixed
|
||||
// model IDs that are not present in the current static catalog. Do not
|
||||
// silently rewrite those sessions to the default just because the option has
|
||||
// not been hydrated yet (#2405).
|
||||
return p.startsWith('custom:')||p==='openrouter';
|
||||
}
|
||||
function _modelStateForSelect(sel, modelId){
|
||||
const value=String(modelId||'').trim();
|
||||
if(!value) return {model:'',model_provider:null};
|
||||
@@ -921,21 +1018,20 @@ function _addLiveModelsToSelect(provider, models, sel){
|
||||
sel.appendChild(providerGroup);
|
||||
}
|
||||
const existingIds=new Set([...sel.options].map(o=>o.value));
|
||||
// Normalized dedup: strip @provider: prefix and unify separators so
|
||||
// Normalized dedup: strip one @provider: prefix and namespace so
|
||||
// 'minimax/minimax-m2.7' matches '@nous:minimax/minimax-m2.7' (#907).
|
||||
// Strip ONLY the first colon — Ollama tag IDs are multi-colon
|
||||
// (e.g. '@ollama-cloud:qwen3-vl:235b-instruct') and split(':',2) would
|
||||
// truncate the tag suffix in JS (the limit arg discards extras, unlike Python).
|
||||
const _normId=id=>{
|
||||
let s=String(id||'');
|
||||
if(s.startsWith('@')&&s.includes(':')) s=s.substring(s.indexOf(':')+1); // strip only @provider:
|
||||
s=s.split('/').pop(); // strip namespace prefix
|
||||
if(s.startsWith('@')&&s.includes(':')) s=s.substring(s.indexOf(':')+1);
|
||||
s=s.split('/').pop();
|
||||
return s.replace(/-/g,'.').toLowerCase();
|
||||
};
|
||||
const existingNorm=new Set([...sel.options].map(o=>_normId(o.value)));
|
||||
let added=0;
|
||||
const _ap=(window._activeProvider||'').toLowerCase();
|
||||
const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && _ap!=='openai-codex' && provider===_ap;
|
||||
const _providerLower=String(provider||'').toLowerCase();
|
||||
const _isNamedCustomActiveProvider=_ap.startsWith('custom:');
|
||||
const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && _ap!=='openai-codex' && (_providerLower===_ap||_isNamedCustomActiveProvider&&_providerLower===_ap);
|
||||
for(const m of models){
|
||||
let mid=m.id;
|
||||
if(_isPortalFetch && !mid.startsWith('@')){
|
||||
@@ -1006,7 +1102,7 @@ async function _fetchLiveModels(provider, sel){
|
||||
*/
|
||||
function _checkProviderMismatch(modelId){
|
||||
const ap=(window._activeProvider||'').toLowerCase();
|
||||
if(!ap||ap==='custom'||ap.startsWith('custom:')||ap==='openrouter') return null; // can't reliably check
|
||||
if(_providerSkipsModelMismatchWarning(ap)) return null; // can't reliably check
|
||||
// @provider: prefixed IDs came from that provider's live model list — no mismatch possible
|
||||
if(modelId.startsWith('@')) return null;
|
||||
const slash=modelId.indexOf('/');
|
||||
@@ -1818,7 +1914,69 @@ if(typeof document!=='undefined'){
|
||||
// prevent the new chat's first scroll comparing against the previous chat's
|
||||
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
|
||||
function _resetScrollDirectionTracker(){ _lastScrollTop=null; }
|
||||
if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetScrollDirectionTracker;
|
||||
if(typeof window!=='undefined') window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
|
||||
/* ── Pull-to-refresh for PWA standalone (Android) ── */
|
||||
(function(){
|
||||
if(typeof document==='undefined') return;
|
||||
const isStandalone=window.navigator?.standalone||matchMedia('(display-mode:standalone),(display-mode:fullscreen)').matches;
|
||||
if(!isStandalone) return;
|
||||
const el=document.getElementById('messages');
|
||||
if(!el) return;
|
||||
let _ptrState=0; // 0=idle, 1=pulling, 2=ready
|
||||
let _ptrStartY=0;
|
||||
let _ptrCurrentY=0;
|
||||
const THRESHOLD=80;
|
||||
let _indicator=null;
|
||||
function _ptrCreateIndicator(){
|
||||
if(_indicator) return;
|
||||
_indicator=document.createElement('div');
|
||||
_indicator.className='pull-to-refresh-indicator';
|
||||
_indicator.innerHTML='<span class="ptr-icon">↓</span> <span class="ptr-text">Pull to refresh</span>';
|
||||
el.parentNode.insertBefore(_indicator,el);
|
||||
}
|
||||
function _ptrUpdate(progress){
|
||||
_ptrCreateIndicator();
|
||||
const pulling=progress<1;
|
||||
_indicator.classList.toggle('active',progress>0);
|
||||
const icon=_indicator.querySelector('.ptr-icon');
|
||||
const text=_indicator.querySelector('.ptr-text');
|
||||
if(icon) icon.classList.toggle('ready',!pulling);
|
||||
if(text) text.textContent=pulling?'Pull to refresh':'Release to refresh';
|
||||
}
|
||||
function _ptrReset(){
|
||||
_ptrState=0;
|
||||
_ptrStartY=0;
|
||||
_ptrCurrentY=0;
|
||||
if(_indicator) _indicator.classList.remove('active');
|
||||
}
|
||||
el.addEventListener('touchstart',function(e){
|
||||
if(el.scrollTop>0||_ptrState!==0) return;
|
||||
_ptrStartY=e.touches[0].clientY;
|
||||
_ptrState=1;
|
||||
},{passive:true});
|
||||
el.addEventListener('touchmove',function(e){
|
||||
if(_ptrState!==1) return;
|
||||
_ptrCurrentY=e.touches[0].clientY;
|
||||
const pull=_ptrCurrentY-_ptrStartY;
|
||||
if(pull<0){ _ptrReset(); return; }
|
||||
/* If not at the top, smooth-scroll to top first.
|
||||
Next pull gesture will trigger the refresh. */
|
||||
if(el.scrollTop>0){
|
||||
el.scrollTo({top:0,behavior:'smooth'});
|
||||
_ptrReset();
|
||||
return;
|
||||
}
|
||||
const progress=Math.min(pull/THRESHOLD,1);
|
||||
_ptrUpdate(progress);
|
||||
_ptrState=progress>=1?2:1;
|
||||
if(progress>0.3) e.preventDefault();
|
||||
},{passive:false});
|
||||
el.addEventListener('touchend',function(){
|
||||
if(_ptrState===2){ window.location.reload(); return; }
|
||||
_ptrReset();
|
||||
},{passive:true});
|
||||
el.addEventListener('touchcancel',_ptrReset,{passive:true});
|
||||
})();
|
||||
(function(){
|
||||
const el=document.getElementById('messages');
|
||||
if(!el) return;
|
||||
@@ -1874,6 +2032,46 @@ function _formatActiveElapsedTimer(seconds){
|
||||
const s=total%60;
|
||||
return`${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
}
|
||||
const _COMPRESSION_ELAPSED_MAX_SECONDS=5*60;
|
||||
let _compressionElapsedTimer=null;
|
||||
function _compressionElapsedStartedAt(state){const n=Number(state&&state.startedAt);return Number.isFinite(n)&&n>0?n:null;}
|
||||
function _compressionElapsedLabel(state){
|
||||
const started=_compressionElapsedStartedAt(state);
|
||||
if(!started)return'';
|
||||
const elapsed=Math.max(0,(Date.now()/1000)-started);
|
||||
if(elapsed>=_COMPRESSION_ELAPSED_MAX_SECONDS)return '5+ min';
|
||||
return _formatActiveElapsedTimer(elapsed);
|
||||
}
|
||||
function _compressionElapsedExpired(state){const started=_compressionElapsedStartedAt(state);return !!(started&&((Date.now()/1000)-started)>=_COMPRESSION_ELAPSED_MAX_SECONDS);}
|
||||
function _compressionLiveCardNode(){return document.querySelector('[data-live-compression-card="1"][data-compression-started-at]');}
|
||||
function _compressionLiveCardState(){
|
||||
const node=_compressionLiveCardNode();
|
||||
const started=Number(node&&node.getAttribute('data-compression-started-at'));
|
||||
if(!node||!S.session||!Number.isFinite(started)||started<=0)return null;
|
||||
return {sessionId:S.session.session_id,phase:'running',automatic:true,message:node.getAttribute('data-compression-message')||'Auto-compressing context...',startedAt:started};
|
||||
}
|
||||
function _updateCompressionElapsedCards(state){
|
||||
if(!state)return false;
|
||||
const preview=_autoCompressionPreviewText(state), detail=_autoCompressionDetailText(state);
|
||||
let updated=false;
|
||||
document.querySelectorAll('.tool-card-compress-auto.tool-card-compress-running').forEach(card=>{
|
||||
const previewEl=card.querySelector('.tool-card-preview');
|
||||
const detailEl=card.querySelector('.tool-card-result pre');
|
||||
if(previewEl) previewEl.textContent=preview;
|
||||
if(detailEl) detailEl.textContent=detail;
|
||||
updated=true;
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
function _updateCompressionElapsedTimer(){
|
||||
const state=_compressionStateForCurrentSession()||_compressionLiveCardState();
|
||||
if(state&&state.automatic&&state.phase==='running'){
|
||||
_updateCompressionElapsedCards(state);
|
||||
if(_compressionElapsedExpired(state)) _clearCompressionElapsedTimer();
|
||||
}else _clearCompressionElapsedTimer();
|
||||
}
|
||||
function _startCompressionElapsedTimer(){if(!_compressionElapsedTimer)_compressionElapsedTimer=setInterval(_updateCompressionElapsedTimer,1000);}
|
||||
function _clearCompressionElapsedTimer(){if(_compressionElapsedTimer){clearInterval(_compressionElapsedTimer);_compressionElapsedTimer=null;}}
|
||||
let _activityElapsedTimer=null;
|
||||
let _activityElapsedTimerGroup=null;
|
||||
function _activityElapsedStartedAt(group){
|
||||
@@ -2024,12 +2222,14 @@ function _syncCtxIndicator(usage){
|
||||
// branch below — honest "no data" instead of misleading "890% used".
|
||||
const promptTok=usage.last_prompt_tokens||0;
|
||||
const totalTok=(usage.input_tokens||0)+(usage.output_tokens||0);
|
||||
const cacheReadTok=usage.cache_read_tokens||0;
|
||||
const cacheWriteTok=usage.cache_write_tokens||0;
|
||||
// Default context window to 128K when not provided by backend
|
||||
const DEFAULT_CTX=128*1024;
|
||||
const ctxWindow=usage.context_length||DEFAULT_CTX;
|
||||
const cost=usage.estimated_cost;
|
||||
// Show indicator whenever we have any usage data (tokens or cost)
|
||||
if(!promptTok&&!totalTok&&!cost){
|
||||
if(!promptTok&&!totalTok&&!cost&&!cacheReadTok&&!cacheWriteTok){
|
||||
if(wrap) wrap.style.display='none';
|
||||
_syncMobileCtxDisplay({visible:false});
|
||||
return;
|
||||
@@ -2062,9 +2262,13 @@ function _syncCtxIndicator(usage){
|
||||
const compressText=pct>=75?t('ctx_compress_action'):(pct>=50?t('ctx_compress_hint'):'');
|
||||
if(compressWrap) compressWrap.style.display=compressText?'':'none';
|
||||
_setCtxCompressButton(compressBtn,compressText);
|
||||
const cacheTotalTok=cacheReadTok+cacheWriteTok;
|
||||
const cacheHitPct=cacheTotalTok?Math.round((cacheReadTok/cacheTotalTok)*100):null;
|
||||
const cacheText=cacheTotalTok?`cache: ${cacheHitPct}% hit (${_fmtTokens(cacheReadTok)} read / ${_fmtTokens(cacheWriteTok)} write)`:'';
|
||||
let label=hasPromptTok?`Context window ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`;
|
||||
if(!hasExplicitCtx&&hasPromptTok) label+=' (est. 128K)';
|
||||
if(cost) label+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
|
||||
if(cacheText) label+=` \u00b7 ${cacheText}`;
|
||||
el.setAttribute('aria-label',label);
|
||||
const usageText=hasPromptTok?(overflowed?`${rawPct}% used (context exceeded)`:`${pct}% used (${100-pct}% left)`):`${_fmtTokens(totalTok)} tokens used`;
|
||||
const tokensText=hasPromptTok?`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:`In: ${_fmtTokens(usage.input_tokens||0)} \u00b7 Out: ${_fmtTokens(usage.output_tokens||0)}`;
|
||||
@@ -2086,6 +2290,11 @@ function _syncCtxIndicator(usage){
|
||||
if(costLine){
|
||||
if(cost){
|
||||
costText=`Estimated cost: $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
|
||||
if(cacheText) costText+=` \u00b7 ${cacheText}`;
|
||||
costLine.style.display='';
|
||||
costLine.textContent=costText;
|
||||
}else if(cacheText){
|
||||
costText=cacheText;
|
||||
costLine.style.display='';
|
||||
costLine.textContent=costText;
|
||||
}else{
|
||||
@@ -2480,7 +2689,7 @@ function renderMd(raw){
|
||||
s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Inline math: $...$ — require non-space at boundaries to avoid false positives
|
||||
// e.g. "costs $5 and $10" should not trigger (space after opening $)
|
||||
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Also stash \(...\) LaTeX delimiters.
|
||||
// Match a single literal backslash before the delimiter (the common LLM form).
|
||||
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
@@ -2610,11 +2819,26 @@ function renderMd(raw){
|
||||
if(rows.length<2)return block;
|
||||
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
|
||||
if(!isSep(rows[1]))return block;
|
||||
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${inlineMd(c.trim())}</td>`).join('');
|
||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${inlineMd(c.trim())}</th>`).join('');
|
||||
// _protectPipes: temporarily swap pipes inside matching bracket pairs for a
|
||||
// sentinel before split('|'), then restore. Iterates until no more matches
|
||||
// so all pipes inside one pair are caught.
|
||||
// Note: both opening and closing brace literals in the character classes
|
||||
// are written as hex escapes (\x7b and \x7d) so the JS source contains no
|
||||
// bare brace glyphs that would confuse the brace-counting extractFunc in
|
||||
// tests/test_renderer_js_behaviour.py. Regex semantics are identical.
|
||||
// Bracket set is paren / square / curly only -- NOT angle brackets, since
|
||||
// angle brackets are overwhelmingly comparison operators in real LLM table
|
||||
// output (`| x < 5 | y > 10 |`) and treating them as a pair collapses cells.
|
||||
const _protectPipes=r=>{let prev;do{prev=r;r=r.replace(/([([\x7b][^)\]\x7d]*)[|]([^)\]\x7d]*[)\]\x7d])/g,(_,a,b)=>a+'\x00PIPE\x00'+b);}while(r!==prev);return r;};
|
||||
const _restorePipes=s=>s.replace(/\x00PIPE\x00/g,'|');
|
||||
const parseRow=r=>{r=_protectPipes(r);return r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${inlineMd(_restorePipes(c.trim()))}</td>`).join('');};
|
||||
const parseHeader=r=>{r=_protectPipes(r);return r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${inlineMd(_restorePipes(c.trim()))}</th>`).join('');};
|
||||
const header=`<tr>${parseHeader(rows[0])}</tr>`;
|
||||
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
|
||||
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
||||
// Surround with blank lines so the final paragraph splitter treats the
|
||||
// generated table as its own block even when the regex consumes one of the
|
||||
// markdown block's trailing newlines.
|
||||
return `\n\n<table><thead>${header}</thead><tbody>${body}</tbody></table>\n\n`;
|
||||
});
|
||||
// #487: Outer image pass — handles  in plain paragraphs (outside tables/lists).
|
||||
// Runs AFTER the table pass (images in table cells are handled by inlineMd() above).
|
||||
@@ -2757,7 +2981,7 @@ function renderMd(raw){
|
||||
return '\x00E'+(_pre_stash.length-1)+'\x00';
|
||||
});
|
||||
const parts=s.split(/\n{2,}/);
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|table|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]);
|
||||
// ── Restore MEDIA stash → inline images or download links ─────────────────
|
||||
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{
|
||||
@@ -3560,6 +3784,19 @@ function copyMsg(btn){
|
||||
setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500);
|
||||
}).catch(()=>showToast(t('copy_failed')));
|
||||
}
|
||||
function _copyThinkingText(btn){
|
||||
const card=btn&&btn.closest?btn.closest('.thinking-card'):null;
|
||||
if(!card)return;
|
||||
const pre=card.querySelector('.thinking-card-body pre');
|
||||
const text=pre?pre.textContent:'';
|
||||
if(!text)return;
|
||||
_copyText(text).then(()=>{
|
||||
const orig=btn.innerHTML;
|
||||
btn.innerHTML=li('check',12);
|
||||
btn.style.color='var(--accent)';
|
||||
setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500);
|
||||
}).catch(()=>showToast(t('copy_failed')));
|
||||
}
|
||||
|
||||
// ── TTS: Text-to-Speech via Web Speech API (#499) ──
|
||||
// Strips markdown, code blocks, and MEDIA: paths for clean speech output.
|
||||
@@ -3717,6 +3954,67 @@ function clearInflightState(sid){
|
||||
}catch(_){ }
|
||||
}
|
||||
|
||||
function snapshotLiveTurnHtmlForSession(sid){
|
||||
// Keep the DOM snapshot memory-only. Persisted INFLIGHT state intentionally
|
||||
// stores structured stream state, not outerHTML, so a hard reload still uses
|
||||
// the safer flat replay path instead of reviving stale nodes/listeners.
|
||||
if(!sid||!INFLIGHT[sid]) return;
|
||||
const turn=$('liveAssistantTurn');
|
||||
if(!turn) return;
|
||||
if(turn.dataset&&turn.dataset.sessionId&&turn.dataset.sessionId!==sid) return;
|
||||
INFLIGHT[sid].liveTurnHtml=turn.outerHTML;
|
||||
}
|
||||
|
||||
function _liveAssistantSegmentTextLength(seg){
|
||||
if(!seg) return 0;
|
||||
const body=seg.querySelector('.msg-body')||seg;
|
||||
return String(body.textContent||'').trim().length;
|
||||
}
|
||||
|
||||
function _mergeRestoredLiveAssistantSegment(restored, existing){
|
||||
if(!restored||!existing) return;
|
||||
const existingLive=existing.querySelector('[data-live-assistant="1"]');
|
||||
if(!existingLive) return;
|
||||
const restoredLive=restored.querySelector('[data-live-assistant="1"]');
|
||||
const existingLen=_liveAssistantSegmentTextLength(existingLive);
|
||||
const restoredLen=_liveAssistantSegmentTextLength(restoredLive);
|
||||
if(existingLen<=restoredLen) return;
|
||||
const replacement=existingLive.cloneNode(true);
|
||||
if(restoredLive){
|
||||
restoredLive.replaceWith(replacement);
|
||||
return;
|
||||
}
|
||||
const blocks=_assistantTurnBlocks(restored);
|
||||
if(!blocks) return;
|
||||
const anchor=Array.from(blocks.children).filter(el=>
|
||||
el.matches('.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row,[data-live-assistant="1"]')
|
||||
).pop();
|
||||
if(anchor) anchor.insertAdjacentElement('afterend', replacement);
|
||||
else blocks.appendChild(replacement);
|
||||
}
|
||||
|
||||
function restoreLiveTurnHtmlForSession(sid){
|
||||
const inflight=INFLIGHT[sid];
|
||||
if(!sid||!inflight||!inflight.liveTurnHtml) return false;
|
||||
const inner=$('msgInner');
|
||||
if(!inner) return false;
|
||||
const template=document.createElement('template');
|
||||
template.innerHTML=String(inflight.liveTurnHtml||'').trim();
|
||||
const restored=template.content.firstElementChild;
|
||||
if(!restored) return false;
|
||||
restored.id='liveAssistantTurn';
|
||||
if(S.session) restored.dataset.sessionId=S.session.session_id;
|
||||
const existing=$('liveAssistantTurn');
|
||||
_mergeRestoredLiveAssistantSegment(restored, existing);
|
||||
if(existing) existing.replaceWith(restored);
|
||||
else inner.appendChild(restored);
|
||||
const liveGroup=restored.querySelector('.tool-call-group[data-live-tool-call-group="1"]');
|
||||
if(liveGroup&&typeof _startActivityElapsedTimer==='function') _startActivityElapsedTimer(liveGroup);
|
||||
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||||
requestAnimationFrame(()=>postProcessRenderedMessages(restored));
|
||||
return true;
|
||||
}
|
||||
|
||||
function markInflight(sid, streamId) {
|
||||
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()}));
|
||||
}
|
||||
@@ -3923,8 +4221,11 @@ async function refreshSession() {
|
||||
// ── Update banner ──
|
||||
function _formatUpdateTargetStatus(label,info){
|
||||
if(!info||!(info.behind>0)) return null;
|
||||
const branch=info.branch?` (${info.branch})`:'';
|
||||
return `${label}${branch}: ${info.behind} update${info.behind>1?'s':''}`;
|
||||
const release=(info.release_based&&info.latest_version)
|
||||
?` (${info.current_version||'unknown'} -> ${info.latest_version})`
|
||||
:(info.branch?` (${info.branch})`:'');
|
||||
const noun=info.release_based?'release':'update';
|
||||
return `${label}${release}: ${info.behind} ${noun}${info.behind>1?'s':''}`;
|
||||
}
|
||||
function _isSafeUpdateCompareUrl(url){
|
||||
if(!url||!/^https?:\/\//i.test(url)) return false;
|
||||
@@ -4441,13 +4742,16 @@ function syncTopbar(){
|
||||
// default rather than silently retaining the previous chat's selection (#1771).
|
||||
if(!applied){
|
||||
const deferModelCorrection=Boolean(S.session._modelResolutionDeferred);
|
||||
const missingModelIsRoutable=_providerDefersMissingModelFallback(S.session.model_provider||window._activeProvider||null);
|
||||
// Also defer if a live model fetch is still in flight — the model may be
|
||||
// in the list once the fetch completes. Persisting now would corrupt the
|
||||
// session with the wrong model before live models arrive (#1169).
|
||||
const liveStillPending=window._activeProvider&&_liveModelFetchPending.has(window._activeProvider);
|
||||
if(liveStillPending){
|
||||
if(liveStillPending||missingModelIsRoutable){
|
||||
// Live fetch in flight — don't touch sel.value or S.session.model yet.
|
||||
// _addLiveModelsToSelect() will re-apply S.session.model once done (#1169).
|
||||
// Named custom providers/OpenRouter can also route vendor-prefixed IDs
|
||||
// outside the static catalog, so preserve the user's explicit choice.
|
||||
} else {
|
||||
const fallback=_applySessionModelFallback(modelSel);
|
||||
if(fallback&&!deferModelCorrection){
|
||||
@@ -4537,23 +4841,26 @@ function _createAssistantTurn(tsTitle='', tpsText=''){
|
||||
const row=document.createElement('div');
|
||||
row.className='msg-row assistant-turn';
|
||||
row.dataset.role='assistant';
|
||||
if(S.session) row.dataset.sessionId=S.session.session_id;
|
||||
row.innerHTML=`${_assistantRoleHtml(tsTitle, tpsText)}<div class="assistant-turn-blocks"></div>`;
|
||||
return row;
|
||||
}
|
||||
function _assistantTurnBlocks(turn){
|
||||
return turn?turn.querySelector('.assistant-turn-blocks'):null;
|
||||
}
|
||||
function _thinkingCardHtml(text){
|
||||
function _thinkingCardHtml(text, open){
|
||||
const clean=_sanitizeThinkingDisplayText(text);
|
||||
return `<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(clean)}</pre></div></div>`;
|
||||
const copyBtn=`<button class="thinking-copy-btn" onclick="event.stopPropagation();_copyThinkingText(this)" title="${t('copy')}" aria-label="${t('copy')}">${li('copy',12)}</button>`;
|
||||
const classes=`thinking-card${open?' open':''}`;
|
||||
return `<div class="${classes}"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-btn-row">${copyBtn}<span class="thinking-card-toggle">${li('chevron-right',12)}</span></span></div><div class="thinking-card-body"><pre>${esc(clean)}</pre></div></div>`;
|
||||
}
|
||||
function isSimplifiedToolCalling(){
|
||||
return window._simplifiedToolCalling!==false;
|
||||
}
|
||||
function _thinkingActivityNode(text){
|
||||
function _thinkingActivityNode(text, open){
|
||||
const row=document.createElement('div');
|
||||
row.className='agent-activity-thinking';
|
||||
row.innerHTML=_thinkingCardHtml(text);
|
||||
row.innerHTML=_thinkingCardHtml(text, open);
|
||||
return row;
|
||||
}
|
||||
// ── Activity-group user expand intent (#1298) ──────────────────────────────
|
||||
@@ -4618,7 +4925,7 @@ function ensureActivityGroup(inner, opts){
|
||||
if(!inner) return null;
|
||||
const live=!!opts.live;
|
||||
const activityKey=opts.activityKey||(live?_activityKeyForLiveTurn():null);
|
||||
const selector=live?'.tool-call-group[data-live-tool-call-group="1"]':'.tool-call-group[data-agent-activity-group="1"]';
|
||||
const selector=live?'.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]':'.tool-call-group[data-agent-activity-group="1"]';
|
||||
let group=inner.querySelector(selector);
|
||||
if(!group){
|
||||
group=document.createElement('div');
|
||||
@@ -4635,7 +4942,10 @@ function ensureActivityGroup(inner, opts){
|
||||
group.setAttribute('data-tool-call-group','1');
|
||||
group.setAttribute('data-agent-activity-group','1');
|
||||
if(activityKey) group.setAttribute('data-activity-disclosure-key',activityKey);
|
||||
if(live) group.setAttribute('data-live-tool-call-group','1');
|
||||
if(live){
|
||||
group.setAttribute('data-live-tool-call-group','1');
|
||||
group.setAttribute('data-live-activity-current','1');
|
||||
}
|
||||
group.innerHTML=`<button type="button" class="tool-call-group-summary" aria-expanded="${collapsed?'false':'true'}" onclick="_toggleActivityGroup(this)"><span class="tool-call-group-chevron">${li('chevron-right',12)}</span><span class="tool-call-group-label">Activity</span><span class="tool-call-group-duration"></span></button><div class="tool-call-group-body"></div>`;
|
||||
const anchor=opts.anchor||null;
|
||||
if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group);
|
||||
@@ -4648,6 +4958,13 @@ function ensureActivityGroup(inner, opts){
|
||||
if(live) _startActivityElapsedTimer(group);
|
||||
return group;
|
||||
}
|
||||
function closeCurrentLiveActivityGroup(){
|
||||
const turn=$('liveAssistantTurn');
|
||||
if(!turn) return;
|
||||
turn.querySelectorAll('.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]').forEach(group=>{
|
||||
group.removeAttribute('data-live-activity-current');
|
||||
});
|
||||
}
|
||||
function _compressionStateForCurrentSession(){
|
||||
const state=window._compressionUi;
|
||||
if(!state||!S.session||state.sessionId!==S.session.session_id) return null;
|
||||
@@ -4660,6 +4977,7 @@ function isCompressionUiRunning(){
|
||||
}
|
||||
function clearCompressionUi(){
|
||||
window._compressionUi=null;
|
||||
_clearCompressionElapsedTimer();
|
||||
_setCompressionSessionLock(null);
|
||||
renderCompressionUi();
|
||||
}
|
||||
@@ -4668,8 +4986,14 @@ function setCompressionUi(state){
|
||||
clearCompressionUi();
|
||||
return;
|
||||
}
|
||||
window._compressionUi={...state};
|
||||
if(state.sessionId) _setCompressionSessionLock(state.sessionId);
|
||||
const nextState={...state};
|
||||
if(nextState.automatic&&nextState.phase==='running'&&!_compressionElapsedStartedAt(nextState)){
|
||||
nextState.startedAt=Date.now()/1000;
|
||||
}
|
||||
window._compressionUi=nextState;
|
||||
if(nextState.sessionId) _setCompressionSessionLock(nextState.sessionId);
|
||||
if(nextState.automatic&&nextState.phase==='running') _startCompressionElapsedTimer();
|
||||
else _clearCompressionElapsedTimer();
|
||||
renderCompressionUi();
|
||||
}
|
||||
function _compressionCardsHtml(state){
|
||||
@@ -4735,19 +5059,44 @@ function _compressionCardsHtml(state){
|
||||
</div>
|
||||
${referenceHtml}`;
|
||||
}
|
||||
function _autoCompressionCardsHtml(state){
|
||||
function _autoCompressionBaseDetail(state){
|
||||
const fallback='Context auto-compressed to continue the conversation';
|
||||
const detail=String(state.message||fallback).trim()||fallback;
|
||||
const preview=String(state.summary?.headline||detail).trim()||detail;
|
||||
const running=state&&state.phase==='running';
|
||||
return running
|
||||
? (String(state.message||'Auto-compressing context...').trim()||'Auto-compressing context...')
|
||||
: (String(state&&state.message||fallback).trim()||fallback);
|
||||
}
|
||||
function _autoCompressionPreviewText(state){
|
||||
const running=state&&state.phase==='running';
|
||||
const detail=_autoCompressionBaseDetail(state);
|
||||
if(!running) return (String(state&&state.summary?.headline||detail).trim()||detail);
|
||||
const elapsedLabel=_compressionElapsedLabel(state);
|
||||
return [detail, elapsedLabel].filter(Boolean).join(' · ');
|
||||
}
|
||||
function _autoCompressionDetailText(state){
|
||||
const running=state&&state.phase==='running';
|
||||
const base=_autoCompressionBaseDetail(state);
|
||||
const elapsedLabel=running?_compressionElapsedLabel(state):'';
|
||||
if(running)return elapsedLabel?`Elapsed: ${elapsedLabel}`:base;
|
||||
const continuation=String(state&&state.continuationSessionId||'').trim();
|
||||
const handoff=continuation?`Continued in compressed session: ${continuation}`:'';
|
||||
return [base,handoff].filter(Boolean).join('\n');
|
||||
}
|
||||
function _autoCompressionCardsHtml(state){
|
||||
const running=state&&state.phase==='running';
|
||||
const preview=_autoCompressionPreviewText(state);
|
||||
const cardDetail=_autoCompressionDetailText(state);
|
||||
return `
|
||||
<div class="tool-card-row compression-card-row" data-compression-card="1">
|
||||
${_compressionStatusCardHtml({
|
||||
statusLabel: t('auto_compress_label'),
|
||||
previewText: preview,
|
||||
detail,
|
||||
icon: li('check',13),
|
||||
open: false,
|
||||
variantClass: 'tool-card-compress-complete tool-card-compress-auto',
|
||||
detail: cardDetail,
|
||||
icon: running ? '<span class="tool-card-running-dot"></span>' : li('check',13),
|
||||
open: running,
|
||||
variantClass: running
|
||||
? 'tool-card-compress-running tool-card-compress-auto'
|
||||
: 'tool-card-compress-complete tool-card-compress-auto',
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
@@ -4757,6 +5106,33 @@ function _compressionCardsNode(state){
|
||||
wrap.innerHTML=`<div class="compression-turn-blocks">${_compressionCardsHtml(state)}</div>`;
|
||||
return wrap;
|
||||
}
|
||||
function appendLiveCompressionCard(state){
|
||||
if(!S.session||!S.activeStreamId||!state) return false;
|
||||
let turn=$('liveAssistantTurn');
|
||||
if(!turn){
|
||||
turn=_createAssistantTurn();
|
||||
turn.id='liveAssistantTurn';
|
||||
if(S.session) turn.dataset.sessionId=S.session.session_id;
|
||||
$('msgInner').appendChild(turn);
|
||||
}
|
||||
const inner=_assistantTurnBlocks(turn);
|
||||
if(!inner) return false;
|
||||
closeCurrentLiveActivityGroup();
|
||||
const node=_compressionCardsNode(state);
|
||||
if(!node) return false;
|
||||
node.setAttribute('data-live-compression-card','1');
|
||||
if(state.automatic&&state.phase==='running'){
|
||||
const started=_compressionElapsedStartedAt(state)||Date.now()/1000;
|
||||
node.setAttribute('data-compression-started-at',String(started));
|
||||
node.setAttribute('data-compression-message',String(state.message||'Auto-compressing context...'));
|
||||
_startCompressionElapsedTimer();
|
||||
}
|
||||
const existing=inner.querySelector('[data-live-compression-card="1"]');
|
||||
if(existing) existing.replaceWith(node);
|
||||
else inner.appendChild(node);
|
||||
if(typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
return true;
|
||||
}
|
||||
function _isHandoffSummaryToolPayload(value){
|
||||
if(!value||typeof value!=='object'||Array.isArray(value)) return false;
|
||||
return value._handoff_summary_card === true;
|
||||
@@ -5684,7 +6060,7 @@ function renderMessages(options){
|
||||
if(derived.length) S.toolCalls=derived;
|
||||
}
|
||||
if(!S.busy){
|
||||
inner.querySelectorAll('.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card])').forEach(el=>el.remove());
|
||||
inner.querySelectorAll('.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card]),.agent-activity-thinking:not([data-live-thinking="1"])').forEach(el=>el.remove());
|
||||
const byAssistant = {};
|
||||
for(const tc of (S.toolCalls||[])){
|
||||
const key = tc.assistant_msg_idx !== undefined ? tc.assistant_msg_idx : -1;
|
||||
@@ -5705,14 +6081,18 @@ function renderMessages(options){
|
||||
}
|
||||
if(!anchorRow) continue;
|
||||
const anchorParent=anchorRow.parentElement;
|
||||
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
|
||||
let insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
|
||||
const thinkingText=assistantThinking.get(aIdx);
|
||||
if(thinkingText){
|
||||
const thinkingNode=_thinkingActivityNode(thinkingText, false);
|
||||
anchorParent.insertBefore(thinkingNode, anchorRow);
|
||||
}
|
||||
if(!cards.length) continue;
|
||||
const group=ensureActivityGroup(anchorParent,{collapsed:true,anchor:insertAfterNode,activityKey:`assistant:${aIdx}`});
|
||||
const sourceMsg=S.messages[aIdx]||{};
|
||||
if(sourceMsg._turnDuration!==undefined) group.setAttribute('data-turn-duration', String(sourceMsg._turnDuration));
|
||||
const body=group&&group.querySelector('.tool-call-group-body');
|
||||
if(!body) continue;
|
||||
const thinkingText=assistantThinking.get(aIdx);
|
||||
if(thinkingText) body.appendChild(_thinkingActivityNode(thinkingText));
|
||||
for(const tc of cards){
|
||||
body.appendChild(buildToolCard(tc));
|
||||
}
|
||||
@@ -5818,8 +6198,12 @@ function renderMessages(options){
|
||||
const inTok=msg._turnUsage.input_tokens||0;
|
||||
const outTok=msg._turnUsage.output_tokens||0;
|
||||
const cost=msg._turnUsage.estimated_cost;
|
||||
const cacheRead=msg._turnUsage.cache_read_tokens||0;
|
||||
const cacheWrite=msg._turnUsage.cache_write_tokens||0;
|
||||
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
|
||||
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
|
||||
const cacheTotal=cacheRead+cacheWrite;
|
||||
if(cacheTotal) text+=` · cache ${Math.round((cacheRead/cacheTotal)*100)}% hit`;
|
||||
usage.textContent=text;
|
||||
fragments.push(usage);
|
||||
}
|
||||
@@ -6805,16 +7189,17 @@ function finalizeThinkingCard(){
|
||||
const summary=group.querySelector('.tool-call-group-summary');
|
||||
if(summary) summary.setAttribute('aria-expanded','false');
|
||||
}
|
||||
const active=group.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||||
const active=turn.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||||
if(active) active.removeAttribute('data-thinking-active');
|
||||
_syncToolCallGroupSummary(group);
|
||||
}
|
||||
}
|
||||
function appendThinking(text=''){
|
||||
function appendThinking(text='', options){
|
||||
// Guard: ignore if session was switched during an async SSE stream.
|
||||
// The old stream's reasoning events can still fire after switch;
|
||||
// without this check they would pollute the new session's DOM.
|
||||
if(!S.session||!S.activeStreamId) return;
|
||||
const allowPendingPlaceholder=!!(options&&options.pending===true);
|
||||
if(!S.session||(!S.activeStreamId&&!allowPendingPlaceholder)) return;
|
||||
$('emptyState').style.display='none';
|
||||
let turn=$('liveAssistantTurn');
|
||||
if(!turn){
|
||||
@@ -6857,31 +7242,30 @@ function appendThinking(text=''){
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(!String(text||'').trim()){
|
||||
scrollIfPinned();
|
||||
return;
|
||||
}
|
||||
const allChildren=Array.from(blocks.children);
|
||||
const anchor=allChildren.filter(el=>
|
||||
el.id!=='toolRunningRow' &&
|
||||
el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')
|
||||
).pop();
|
||||
const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()});
|
||||
const body=group&&group.querySelector('.tool-call-group-body');
|
||||
if(!body) return;
|
||||
let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||||
const thinkingText=String(text||'').trim()||'Thinking…';
|
||||
let row=blocks.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||||
if(!row){
|
||||
row=document.createElement('div');
|
||||
row.className='agent-activity-thinking';
|
||||
const thinkingCards=Array.from(blocks.querySelectorAll('.agent-activity-thinking'));
|
||||
row=thinkingCards.filter(el=>el.closest('.assistant-turn-blocks')===blocks).pop()||null;
|
||||
if(row) row.setAttribute('data-thinking-active','1');
|
||||
}
|
||||
if(!row){
|
||||
row=_thinkingActivityNode(thinkingText, false);
|
||||
row.setAttribute('data-thinking-active','1');
|
||||
body.insertBefore(row, body.firstChild);
|
||||
const allChildren=Array.from(blocks.children);
|
||||
const anchor=allChildren.filter(el=>
|
||||
el.id!=='toolRunningRow' &&
|
||||
el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')
|
||||
).pop();
|
||||
if(anchor) anchor.insertAdjacentElement('afterend', row);
|
||||
else blocks.appendChild(row);
|
||||
}else{
|
||||
_renderThinkingInto(row,thinkingText);
|
||||
}
|
||||
_renderThinkingInto(row,text);
|
||||
_syncToolCallGroupSummary(group);
|
||||
scrollIfPinned();
|
||||
if(_scrollPinned){
|
||||
const thinkingBody=row&&row.querySelector('.thinking-card-body');
|
||||
if(thinkingBody) thinkingBody.scrollTop=thinkingBody.scrollHeight;
|
||||
const body=row&&row.querySelector('.thinking-card-body');
|
||||
if(body) body.scrollTop=body.scrollHeight;
|
||||
}
|
||||
}
|
||||
function updateThinking(text=''){appendThinking(text);}
|
||||
@@ -7246,6 +7630,13 @@ function _renderTreeItems(container, entries, depth){
|
||||
const isExpanded=S._expandedDirs.has(item.path);
|
||||
arrow.textContent=isExpanded?'\u25BE':'\u25B8';
|
||||
el.appendChild(arrow);
|
||||
}else{
|
||||
// Keep file icons aligned with sibling directories that occupy this
|
||||
// slot with the expand/collapse toggle. #2554
|
||||
const spacer=document.createElement('span');
|
||||
spacer.className='file-tree-toggle-placeholder';
|
||||
spacer.setAttribute('aria-hidden','true');
|
||||
el.appendChild(spacer);
|
||||
}
|
||||
|
||||
// Icon
|
||||
|
||||
@@ -171,6 +171,36 @@ def pytest_configure(config):
|
||||
# imports trigger botocore initialisation.
|
||||
os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true")
|
||||
|
||||
# ── Permanent os.execv guard for the pytest session ────────────────────────
|
||||
# Several tests in tests/test_update_banner_fixes.py exercise
|
||||
# api.updates._schedule_restart(), which spawns a DAEMON thread that sleeps
|
||||
# for a short delay and then calls ``os.execv(sys.executable, sys.argv)``.
|
||||
# Those tests monkeypatch ``os.execv`` to a no-op for the test scope, but
|
||||
# monkeypatch teardown happens at test exit — if the daemon thread has not
|
||||
# yet woken up by then (system load, GC pause, _apply_lock contention), the
|
||||
# real ``os.execv`` is restored before the thread fires it. The daemon then
|
||||
# REPLACES the pytest process image with a fresh ``pytest tests/ -q ...``
|
||||
# invocation, looking from the outside like pytest "hangs at 99%" and then
|
||||
# restarts the entire suite from 0% — a self-perpetuating loop.
|
||||
#
|
||||
# Daemon threads cannot be reliably joined from a test fixture (they live in
|
||||
# ``api.updates`` module scope), so the only safe answer is to render
|
||||
# ``os.execv`` permanently inert for the pytest session. Production code is
|
||||
# unaffected because production never imports this conftest.
|
||||
#
|
||||
# Tests that need to verify execv WAS called still monkeypatch it themselves
|
||||
# — their patched version takes precedence over this no-op wrapper for the
|
||||
# test's lifetime, and the no-op only kicks in after teardown for daemon
|
||||
# threads that wake up late.
|
||||
_real_execv = os.execv
|
||||
|
||||
def _pytest_session_safe_execv(_exe, _args): # pragma: no cover — never called in prod
|
||||
# Drop the call on the floor. A late-firing daemon thread from
|
||||
# _schedule_restart() must not be able to re-exec the pytest process.
|
||||
return None
|
||||
|
||||
os.execv = _pytest_session_safe_execv
|
||||
|
||||
# ── Hermetic network isolation ─────────────────────────────────────────────
|
||||
# Tests must not reach the public internet. Outbound to Anthropic / OpenAI /
|
||||
# Amazon / OpenRouter / etc. is forbidden by default. The test suite already
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Mirrors the structure of test_1003_appearance_autosave.py to verify the
|
||||
preferences-panel autosave pattern is wired correctly:
|
||||
|
||||
- All 14 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
|
||||
- All 15 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
|
||||
- Password field MUST still call _markSettingsDirty (security: never autosave)
|
||||
- _preferencesPayloadFromUi covers all 14 fields
|
||||
- _setPreferencesAutosaveStatus uses the shared i18n keys
|
||||
@@ -41,6 +41,7 @@ PREFERENCE_FIELDS_AUTOSAVE = [
|
||||
("settingsShowTps", "show_tps"),
|
||||
("settingsSimplifiedToolCalling", "simplified_tool_calling"),
|
||||
("settingsShowCliSessions", "show_cli_sessions"),
|
||||
("settingsShowPreviousMessagingSessions", "show_previous_messaging_sessions"),
|
||||
("settingsSyncInsights", "sync_to_insights"),
|
||||
("settingsCheckUpdates", "check_for_updates"),
|
||||
("settingsSoundEnabled", "sound_enabled"),
|
||||
@@ -52,8 +53,8 @@ PREFERENCE_FIELDS_AUTOSAVE = [
|
||||
]
|
||||
|
||||
|
||||
def test_all_14_preference_fields_have_autosave_payload_entries():
|
||||
"""_preferencesPayloadFromUi must include all 14 preference fields."""
|
||||
def test_all_15_preference_fields_have_autosave_payload_entries():
|
||||
"""_preferencesPayloadFromUi must include all 15 preference fields."""
|
||||
block = _function_block(PANELS_JS, "_preferencesPayloadFromUi")
|
||||
for dom_id, field in PREFERENCE_FIELDS_AUTOSAVE:
|
||||
assert f"$('{dom_id}')" in block, \
|
||||
@@ -63,7 +64,7 @@ def test_all_14_preference_fields_have_autosave_payload_entries():
|
||||
|
||||
|
||||
def test_preference_fields_use_schedule_autosave_not_mark_dirty():
|
||||
"""All 13 listener attachments (excluding bot_name's debounce wrapper) must
|
||||
"""All 14 listener attachments (excluding bot_name's debounce wrapper) must
|
||||
use _schedulePreferencesAutosave. bot_name uses a wrapper but still
|
||||
eventually calls _schedulePreferencesAutosave."""
|
||||
panel = _load_settings_panel_block()
|
||||
|
||||
@@ -23,12 +23,12 @@ class TestSidebarCancelAction:
|
||||
"""Running sessions need a context-menu cancel action even when not active pane."""
|
||||
# Window bumped from 3200 → 4400 in #1764 to accommodate the new
|
||||
# Rename action item, then to 5200 in #2111 for response-aware archive
|
||||
# toast handling inside _openSessionActionMenu before the stop/delete
|
||||
# actions.
|
||||
# toast handling, then to 6400 in #2294 for the new "Hide from list"
|
||||
# action prepended for external sessions.
|
||||
# The `session.active_stream_id` / cancelSessionStream / delete checks
|
||||
# are positional further down in the function, so growing the prefix
|
||||
# required growing this read window.
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200)
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 6400)
|
||||
assert "session.active_stream_id" in body, (
|
||||
"sidebar action menu must detect per-session active_stream_id instead of S.activeStreamId"
|
||||
)
|
||||
@@ -75,8 +75,9 @@ class TestSidebarCancelAction:
|
||||
def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self):
|
||||
"""Session action menu should hide duplicate/delete for CLI-origin sessions."""
|
||||
# Window bumped 3600 → 4800 in #1764 (Rename action prepended), then
|
||||
# to 5200 in #2111 for response-aware archive toast handling.
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200)
|
||||
# to 5200 in #2111 for response-aware archive toast handling, then
|
||||
# to 6400 in #2294 for the "Hide from list" action on external sessions.
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 6400)
|
||||
assert "const isCliSession = _isCliSession(session);" in body
|
||||
assert "const isExternalSession = isMessagingSession || isCliSession;" in body
|
||||
assert "if(!isExternalSession)" in body
|
||||
|
||||
@@ -186,3 +186,26 @@ def test_respond_by_approval_id_pops_correct_entry():
|
||||
# Cleanup
|
||||
with r._lock:
|
||||
r._pending.pop(sid, None)
|
||||
|
||||
|
||||
def test_stale_explicit_approval_id_does_not_pop_oldest_entry():
|
||||
"""Duplicate/stale approval responses must not resolve a different command."""
|
||||
from api import routes as r
|
||||
|
||||
sid = "test-stale-approval-id-sid"
|
||||
with r._lock:
|
||||
r._pending.pop(sid, None)
|
||||
|
||||
r.submit_pending(sid, {"command": "cmd1", "pattern_key": "p1", "pattern_keys": ["p1"], "description": "d1"})
|
||||
r.submit_pending(sid, {"command": "cmd2", "pattern_key": "p2", "pattern_keys": ["p2"], "description": "d2"})
|
||||
|
||||
accepted = r._resolve_approval_legacy(sid, "missing-approval-id", "deny")
|
||||
|
||||
assert accepted is False
|
||||
with r._lock:
|
||||
queue = r._pending.get(sid, [])
|
||||
commands = [entry["command"] for entry in queue]
|
||||
assert commands == ["cmd1", "cmd2"]
|
||||
|
||||
with r._lock:
|
||||
r._pending.pop(sid, None)
|
||||
@@ -261,6 +261,8 @@ class TestPasswordCacheInvalidation(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
if self._backup is not None:
|
||||
self._sf.write_text(self._backup, encoding='utf-8')
|
||||
elif self._sf.exists():
|
||||
self._sf.unlink()
|
||||
auth._invalidate_password_hash_cache()
|
||||
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from api.streaming import _is_fallback_lifecycle_message
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -38,7 +40,7 @@ def test_auto_compression_running_sse_uses_active_session_running_card():
|
||||
assert "message:d.message||'Auto-compressing context...'" in block
|
||||
|
||||
|
||||
def test_auto_compression_running_sse_is_emitted_from_agent_lifecycle_status():
|
||||
def test_agent_status_callback_emits_compressing_and_warning_events():
|
||||
src = _read("api/streaming.py")
|
||||
start = src.find("def _agent_status_callback")
|
||||
assert start != -1, "agent status callback bridge not found"
|
||||
@@ -46,6 +48,7 @@ def test_auto_compression_running_sse_is_emitted_from_agent_lifecycle_status():
|
||||
assert end != -1, "status callback block end marker not found"
|
||||
block = src[start:end]
|
||||
|
||||
# compressing events for compression lifecycle notices
|
||||
assert "put('compressing'" in block
|
||||
assert "'session_id': session_id" in block
|
||||
assert "'message': 'Auto-compressing context to continue...'" in block
|
||||
@@ -53,11 +56,47 @@ def test_auto_compression_running_sse_is_emitted_from_agent_lifecycle_status():
|
||||
assert "'compressing'" in block
|
||||
assert "'compacting context'" in block
|
||||
assert "'context too large'" in block
|
||||
|
||||
# warning events with type:fallback for rate-limit/fallback lifecycle notices
|
||||
assert "put('warning'" in block
|
||||
assert "'type': 'fallback'" in block
|
||||
assert "'rate limited'" in src
|
||||
assert "'switching to fallback'" in src
|
||||
assert "'falling back'" in src
|
||||
assert "'fallback activated'" in src
|
||||
assert "'trying fallback'" in src
|
||||
|
||||
# Verify callback is wired to agent
|
||||
assert "'status_callback' in _agent_params" in src
|
||||
assert "_agent_kwargs['status_callback'] = _agent_status_callback" in src
|
||||
assert "agent.status_callback = _agent_kwargs.get('status_callback')" in src
|
||||
|
||||
|
||||
def test_agent_status_callback_wiring():
|
||||
src = _read("api/streaming.py")
|
||||
assert "_agent_status_callback" in src
|
||||
assert "_agent_kwargs['status_callback'] = _agent_status_callback" in src
|
||||
|
||||
|
||||
def test_fallback_lifecycle_message_predicate_matches_agent_emitters():
|
||||
assert _is_fallback_lifecycle_message(
|
||||
"lifecycle",
|
||||
"Rate limited — switching to fallback provider...",
|
||||
)
|
||||
assert _is_fallback_lifecycle_message(
|
||||
"lifecycle",
|
||||
"Non-retryable error (HTTP 500) — trying fallback...",
|
||||
)
|
||||
assert not _is_fallback_lifecycle_message(
|
||||
"tool",
|
||||
"Rate limited — switching to fallback provider...",
|
||||
)
|
||||
assert not _is_fallback_lifecycle_message(
|
||||
"lifecycle",
|
||||
"Auto-compressing context to continue...",
|
||||
)
|
||||
|
||||
|
||||
def test_auto_compression_completion_transition_is_preserved_after_running_listener():
|
||||
src = _read("static/messages.js")
|
||||
compressing_idx = src.find("source.addEventListener('compressing'")
|
||||
@@ -67,6 +106,98 @@ def test_auto_compression_completion_transition_is_preserved_after_running_liste
|
||||
assert "phase:'done'" in _compressed_listener_block()
|
||||
|
||||
|
||||
def test_auto_compression_running_sse_stamps_elapsed_timer_start():
|
||||
block = _compressing_listener_block()
|
||||
|
||||
assert "startedAt:Date.now()/1000" in block
|
||||
assert block.index("startedAt:Date.now()/1000") < block.index("setCompressionUi(state)")
|
||||
|
||||
|
||||
def test_auto_compression_running_card_renders_elapsed_timer_and_caps_updates():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionPreviewText")
|
||||
assert start != -1, "auto compression preview helper not found"
|
||||
end = src.find("function _compressionCardsNode", start)
|
||||
assert end != -1, "compression cards node helper not found after auto helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "const _COMPRESSION_ELAPSED_MAX_SECONDS=5*60;" in src
|
||||
assert "function _compressionElapsedLabel(state)" in src
|
||||
assert "_formatActiveElapsedTimer" in src
|
||||
assert "_compressionElapsedLabel(state)" in helper
|
||||
assert "elapsedLabel" in helper
|
||||
assert "_autoCompressionPreviewText(state)" in helper
|
||||
assert "_autoCompressionDetailText(state)" in helper
|
||||
assert "function _startCompressionElapsedTimer()" in src
|
||||
assert "function _clearCompressionElapsedTimer()" in src
|
||||
assert "function _updateCompressionElapsedCards(state)" in src
|
||||
assert "_startCompressionElapsedTimer();" in src
|
||||
assert "_clearCompressionElapsedTimer();" in src
|
||||
|
||||
|
||||
def test_auto_compression_elapsed_cap_uses_non_frozen_label():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _compressionElapsedLabel")
|
||||
assert start != -1, "elapsed label helper not found"
|
||||
end = src.find("function _compressionElapsedExpired", start)
|
||||
assert end != -1, "elapsed expiry helper not found after label helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "'5+ min'" in helper
|
||||
assert "elapsed>=_COMPRESSION_ELAPSED_MAX_SECONDS" in helper
|
||||
assert "return '05:00'" not in helper
|
||||
|
||||
|
||||
def test_auto_compression_running_detail_avoids_duplicate_message_text():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionDetailText")
|
||||
assert start != -1, "auto compression detail helper not found"
|
||||
end = src.find("function _autoCompressionCardsHtml", start)
|
||||
assert end != -1, "auto compression card helper not found after detail helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "return elapsedLabel?`Elapsed: ${elapsedLabel}`:base;" in helper
|
||||
assert "${base}\\nElapsed:" not in helper
|
||||
|
||||
|
||||
def test_auto_compression_done_detail_surfaces_continuation_handoff():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionDetailText")
|
||||
assert start != -1, "auto compression detail helper not found"
|
||||
end = src.find("function _autoCompressionCardsHtml", start)
|
||||
assert end != -1, "auto compression card helper not found after detail helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "continuationSessionId" in helper
|
||||
assert "Continued in compressed session" in helper
|
||||
assert "return [base,handoff].filter(Boolean).join('\\n');" in helper
|
||||
|
||||
|
||||
def test_auto_compression_live_card_keeps_elapsed_state_for_timer_refresh():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function appendLiveCompressionCard")
|
||||
assert start != -1, "live compression card append helper not found"
|
||||
end = src.find("function _isHandoffSummaryToolPayload", start)
|
||||
assert end != -1, "handoff helper not found after live compression helper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "data-compression-started-at" in helper
|
||||
assert "data-compression-message" in helper
|
||||
assert "_compressionLiveCardState" in src
|
||||
|
||||
|
||||
def test_auto_compression_does_not_rerender_over_live_answer_text():
|
||||
block = _compressing_listener_block()
|
||||
src = _read("static/ui.js")
|
||||
|
||||
assert "const liveAnswerStarted=" in block
|
||||
assert "appendLiveCompressionCard(state)" in block
|
||||
assert block.index("appendLiveCompressionCard(state)") < block.index("renderMessages({preserveScroll:true})")
|
||||
assert "window._compressionUi=null;" in block
|
||||
assert "function appendLiveCompressionCard(state)" in src
|
||||
assert 'data-live-compression-card' in src
|
||||
|
||||
|
||||
def test_auto_compression_sse_uses_transient_card_not_fake_message():
|
||||
"""Auto compression must not inject display-only text into S.messages."""
|
||||
src = _read("static/messages.js")
|
||||
@@ -78,6 +209,9 @@ def test_auto_compression_sse_uses_transient_card_not_fake_message():
|
||||
assert "phase:'done'" in block
|
||||
assert "automatic:true" in block
|
||||
assert "_setCompressionSessionLock" in block
|
||||
assert "const appended=typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state);" in block
|
||||
assert "window._compressionUi=null;" in block
|
||||
assert block.index("appendLiveCompressionCard(state)") < block.index("window._compressionUi=null;")
|
||||
|
||||
|
||||
def test_auto_compression_sse_keeps_inactive_and_malformed_paths_safe():
|
||||
@@ -87,7 +221,27 @@ def test_auto_compression_sse_keeps_inactive_and_malformed_paths_safe():
|
||||
assert guard in block
|
||||
assert block.index(guard) < block.index("setCompressionUi")
|
||||
assert "try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }" in block
|
||||
assert "if(d.session_id&&d.session_id!==activeSid) return;" in block
|
||||
assert "const eventSid=d.old_session_id||d.session_id||activeSid;" in block
|
||||
# The listener also accepts a rotated continuation session id so journal-
|
||||
# replay reconnects after compression rotate land the done card.
|
||||
# See Opus advisor followup on stage-385 (v0.51.92).
|
||||
event_guard = "if(eventSid!==activeSid && d.new_session_id!==activeSid && d.continuation_session_id!==activeSid) return;"
|
||||
assert event_guard in block
|
||||
|
||||
|
||||
def test_auto_compression_done_accepts_rotated_continuation_session_event():
|
||||
block = _compressed_listener_block()
|
||||
|
||||
# Auto-compression can rotate the backend session id before the 'compressed'
|
||||
# event is emitted. The browser stream still belongs to the pre-compression
|
||||
# activeSid, so the listener must correlate on old_session_id and keep the
|
||||
# continuation id as display metadata instead of dropping the event.
|
||||
assert "const eventSid=d.old_session_id||d.session_id||activeSid;" in block
|
||||
assert "const continuationSid=d.new_session_id||d.continuation_session_id||'';" in block
|
||||
event_guard = "if(eventSid!==activeSid && d.new_session_id!==activeSid && d.continuation_session_id!==activeSid) return;"
|
||||
assert event_guard in block
|
||||
assert block.index("const eventSid=") < block.index(event_guard)
|
||||
assert "continuationSessionId:continuationSid" in block
|
||||
|
||||
|
||||
def test_auto_compression_done_sse_refreshes_context_indicator_usage():
|
||||
@@ -107,10 +261,28 @@ def test_auto_compression_done_payload_includes_live_usage_snapshot():
|
||||
assert end != -1, "compressed SSE payload end not found"
|
||||
block = src[start:end]
|
||||
|
||||
assert "'session_id': s.session_id" in block
|
||||
assert "'session_id': _compression_origin_session_id" in block
|
||||
assert "'old_session_id': _compression_origin_session_id" in block
|
||||
assert "'new_session_id': _compression_continuation_session_id" in block
|
||||
assert "'continuation_session_id': _compression_continuation_session_id" in block
|
||||
assert "'usage': _live_usage_snapshot()" in block
|
||||
|
||||
|
||||
def test_auto_compression_rotation_tracks_origin_and_continuation_ids_for_sse():
|
||||
src = _read("api/streaming.py")
|
||||
rotate_start = src.find("# ── Handle context compression side effects ──")
|
||||
assert rotate_start != -1, "compression side-effect block not found"
|
||||
rotate_end = src.find("# Stamp 'timestamp'", rotate_start)
|
||||
assert rotate_end != -1, "compression side-effect block end not found"
|
||||
block = src[rotate_start:rotate_end]
|
||||
|
||||
assert "_compression_origin_session_id = session_id" in block
|
||||
assert "_compression_continuation_session_id = None" in block
|
||||
assert "_compression_origin_session_id = old_sid" in block
|
||||
assert "_compression_continuation_session_id = new_sid" in block
|
||||
assert "'new_session_id': _compression_continuation_session_id" in block
|
||||
|
||||
|
||||
def test_auto_compression_card_reuses_compression_card_renderer():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _autoCompressionCardsHtml")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Catppuccin skin: Latte light / Mocha dark, opt-in via Settings → Skin."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).parent.parent
|
||||
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_catppuccin_skin_present_in_picker_list():
|
||||
"""The Catppuccin skin must be exposed in the generated picker grid."""
|
||||
assert "{name:'Catppuccin'" in BOOT_JS, "Catppuccin skin missing from _SKINS"
|
||||
assert "'#CBA6F7','#B4BEFE','#8839EF'" in BOOT_JS, (
|
||||
"Catppuccin preview swatches should use Mauve/Lavender/Mauve-light"
|
||||
)
|
||||
|
||||
|
||||
def test_catppuccin_skin_in_client_and_server_allowlists():
|
||||
"""Saved Catppuccin skin choices must survive boot and settings round trips."""
|
||||
assert "catppuccin:1" in INDEX_HTML, (
|
||||
"Catppuccin missing from early-init skin allowlist"
|
||||
)
|
||||
assert '"catppuccin"' in CONFIG_PY, (
|
||||
"Catppuccin missing from server settings skin allowlist"
|
||||
)
|
||||
assert "/catppuccin/" in I18N_JS, "Catppuccin missing from /theme help text"
|
||||
|
||||
|
||||
def test_catppuccin_skin_palette_has_latte_and_mocha_tokens():
|
||||
"""Catppuccin defines a full light/dark palette, not only an accent tint."""
|
||||
assert ':root[data-skin="catppuccin"]{' in CSS, (
|
||||
"Catppuccin Latte light palette block missing"
|
||||
)
|
||||
assert ':root.dark[data-skin="catppuccin"]{' in CSS, (
|
||||
"Catppuccin Mocha dark palette block missing"
|
||||
)
|
||||
for token in ("--bg:#EFF1F5", "--sidebar:#E6E9EF", "--accent:#8839EF"):
|
||||
assert token in CSS, f"Catppuccin Latte token missing: {token}"
|
||||
for token in ("--bg:#1E1E2E", "--sidebar:#181825", "--accent:#CBA6F7"):
|
||||
assert token in CSS, f"Catppuccin Mocha token missing: {token}"
|
||||
|
||||
|
||||
def test_catppuccin_skin_is_opt_in_and_preserves_default_dark():
|
||||
"""Adding Catppuccin must not silently migrate existing users."""
|
||||
init_script_idx = INDEX_HTML.find("var themes=")
|
||||
end_idx = INDEX_HTML.find("</script>", init_script_idx)
|
||||
init_block = INDEX_HTML[init_script_idx:end_idx]
|
||||
assert "||'dark'" in init_block, "Default theme must remain dark"
|
||||
forbidden = [
|
||||
"catppuccin-migrated",
|
||||
"skin-catppuccin-migrated",
|
||||
"skin='catppuccin'",
|
||||
'skin="catppuccin"',
|
||||
]
|
||||
for marker in forbidden:
|
||||
assert marker not in init_block, (
|
||||
f"Catppuccin must be opt-in, not force-migrated. Found {marker!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_catppuccin_new_chat_button_specificity():
|
||||
"""Solid Mauve new-chat buttons must keep readable text in both modes."""
|
||||
assert ':root[data-skin="catppuccin"]:not(.dark) .new-chat-btn' in CSS
|
||||
assert ':root.dark[data-skin="catppuccin"] .new-chat-btn' in CSS
|
||||
@@ -1,21 +1,32 @@
|
||||
"""Regression tests for manual WebUI cron runs."""
|
||||
|
||||
|
||||
|
||||
def test_manual_cron_run_saves_output_and_marks_job(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
|
||||
def _install_cron_fakes(monkeypatch, calls, deliver_result=None, silent_marker="[SILENT]"):
|
||||
cron_jobs = type("CronJobs", (), {})()
|
||||
cron_jobs.save_job_output = lambda job_id, output: calls.append(
|
||||
("save", job_id, output)
|
||||
)
|
||||
cron_jobs.mark_job_run = lambda job_id, success, error=None: calls.append(
|
||||
("mark", job_id, success, error)
|
||||
cron_jobs.mark_job_run = lambda job_id, success, error=None, delivery_error=None: calls.append(
|
||||
("mark", job_id, success, error, delivery_error)
|
||||
)
|
||||
|
||||
cron_scheduler = type("CronScheduler", (), {})()
|
||||
cron_scheduler.SILENT_MARKER = silent_marker
|
||||
if deliver_result is None:
|
||||
deliver_result = lambda job, content: calls.append(
|
||||
("deliver", job["id"], content)
|
||||
) or None
|
||||
cron_scheduler._deliver_result = deliver_result
|
||||
|
||||
monkeypatch.setitem(__import__("sys").modules, "cron.jobs", cron_jobs)
|
||||
monkeypatch.setitem(__import__("sys").modules, "cron.scheduler", cron_scheduler)
|
||||
|
||||
|
||||
def test_manual_cron_run_saves_output_delivers_and_marks_job(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
_install_cron_fakes(monkeypatch, calls)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_run_cron_job_in_profile_subprocess",
|
||||
@@ -27,25 +38,17 @@ def test_manual_cron_run_saves_output_and_marks_job(monkeypatch):
|
||||
|
||||
assert calls == [
|
||||
("save", "job123", "manual output"),
|
||||
("mark", "job123", True, None),
|
||||
("deliver", "job123", "done"),
|
||||
("mark", "job123", True, None, None),
|
||||
]
|
||||
assert routes._is_cron_running("job123") == (False, 0.0)
|
||||
|
||||
|
||||
def test_manual_cron_run_marks_empty_response_as_failure(monkeypatch):
|
||||
def test_manual_cron_run_marks_empty_response_as_failure_without_delivery(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
|
||||
cron_jobs = type("CronJobs", (), {})()
|
||||
cron_jobs.save_job_output = lambda job_id, output: calls.append(
|
||||
("save", job_id, output)
|
||||
)
|
||||
cron_jobs.mark_job_run = lambda job_id, success, error=None: calls.append(
|
||||
("mark", job_id, success, error)
|
||||
)
|
||||
|
||||
monkeypatch.setitem(__import__("sys").modules, "cron.jobs", cron_jobs)
|
||||
_install_cron_fakes(monkeypatch, calls)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_run_cron_job_in_profile_subprocess",
|
||||
@@ -58,4 +61,75 @@ def test_manual_cron_run_marks_empty_response_as_failure(monkeypatch):
|
||||
assert calls[0] == ("save", "job-empty", "manual output")
|
||||
assert calls[1][0:3] == ("mark", "job-empty", False)
|
||||
assert "empty response" in calls[1][3]
|
||||
assert calls[1][4] is None
|
||||
assert routes._is_cron_running("job-empty") == (False, 0.0)
|
||||
|
||||
|
||||
def test_manual_cron_run_records_delivery_errors_separately(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
|
||||
def fail_delivery(job, content):
|
||||
calls.append(("deliver", job["id"], content))
|
||||
return "discord not configured"
|
||||
|
||||
_install_cron_fakes(monkeypatch, calls, deliver_result=fail_delivery)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_run_cron_job_in_profile_subprocess",
|
||||
lambda job, execution_profile_home: (True, "manual output", "done", None),
|
||||
)
|
||||
|
||||
routes._mark_cron_running("job-delivery-error")
|
||||
routes._run_cron_tracked({"id": "job-delivery-error"})
|
||||
|
||||
assert calls == [
|
||||
("save", "job-delivery-error", "manual output"),
|
||||
("deliver", "job-delivery-error", "done"),
|
||||
("mark", "job-delivery-error", True, None, "discord not configured"),
|
||||
]
|
||||
assert routes._is_cron_running("job-delivery-error") == (False, 0.0)
|
||||
|
||||
|
||||
def test_manual_cron_run_skips_silent_success_delivery(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
_install_cron_fakes(monkeypatch, calls)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_run_cron_job_in_profile_subprocess",
|
||||
lambda job, execution_profile_home: (True, "manual output", "[SILENT]", None),
|
||||
)
|
||||
|
||||
routes._mark_cron_running("job-silent")
|
||||
routes._run_cron_tracked({"id": "job-silent"})
|
||||
|
||||
assert calls == [
|
||||
("save", "job-silent", "manual output"),
|
||||
("mark", "job-silent", True, None, None),
|
||||
]
|
||||
assert routes._is_cron_running("job-silent") == (False, 0.0)
|
||||
|
||||
|
||||
def test_manual_cron_run_delivers_failure_notice(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
_install_cron_fakes(monkeypatch, calls)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_run_cron_job_in_profile_subprocess",
|
||||
lambda job, execution_profile_home: (False, "manual output", "", "boom"),
|
||||
)
|
||||
|
||||
routes._mark_cron_running("job-failed")
|
||||
routes._run_cron_tracked({"id": "job-failed", "name": "Nightly check"})
|
||||
|
||||
assert calls[0] == ("save", "job-failed", "manual output")
|
||||
assert calls[1][0:2] == ("deliver", "job-failed")
|
||||
assert "Nightly check" in calls[1][2]
|
||||
assert "boom" in calls[1][2]
|
||||
assert calls[2] == ("mark", "job-failed", False, "boom", None)
|
||||
assert routes._is_cron_running("job-failed") == (False, 0.0)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Regression tests for the Docker docs+readonly hygiene PR (post v0.51.83).
|
||||
|
||||
Pins three invariants:
|
||||
|
||||
1. The `hermes-agent-src` named volume is mounted READ-ONLY on the WebUI
|
||||
service in both multi-container compose files. The WebUI only reads it to
|
||||
install agent Python deps at startup; this is defence-in-depth against a
|
||||
compromised WebUI writing into the agent's source tree (Concern raised by
|
||||
RustyLopez on #2453 and #1416).
|
||||
|
||||
2. The workspace bind-mount default uses `${HOME}/workspace` (not `~/workspace`)
|
||||
in both multi-container compose files, matching the single-container
|
||||
convention so `~`/`${HOME}` doesn't disagree across Linux, macOS, WSL2, and
|
||||
Docker Desktop on Windows.
|
||||
|
||||
3. `docs/docker.md` documents the agent-image upgrade procedure (`docker volume
|
||||
rm hermes-agent-src`) — the root cause of #1416.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
# ── 1: hermes-agent-src must be read-only on the WebUI mount ────────────────
|
||||
|
||||
|
||||
def test_two_container_webui_mounts_agent_src_readonly():
|
||||
"""The WebUI only reads the agent source to install Python deps. Mounting
|
||||
read-only enforces that at the kernel layer — a compromised WebUI process
|
||||
cannot rewrite the agent source it then imports."""
|
||||
src = (REPO / "docker-compose.two-container.yml").read_text(encoding="utf-8")
|
||||
assert (
|
||||
"hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro" in src
|
||||
), (
|
||||
"two-container: the WebUI must mount hermes-agent-src with :ro. "
|
||||
"Without :ro, a compromised WebUI process can rewrite the agent's "
|
||||
"Python source tree."
|
||||
)
|
||||
|
||||
|
||||
def test_three_container_webui_mounts_agent_src_readonly():
|
||||
src = (REPO / "docker-compose.three-container.yml").read_text(encoding="utf-8")
|
||||
assert (
|
||||
"hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro" in src
|
||||
), (
|
||||
"three-container: the WebUI must mount hermes-agent-src with :ro."
|
||||
)
|
||||
|
||||
|
||||
def test_agent_service_keeps_writable_agent_src_mount():
|
||||
"""The agent SERVICE writes the source tree to the volume on first up.
|
||||
It must stay read-write — only the WebUI side is read-only."""
|
||||
for fn in ("docker-compose.two-container.yml", "docker-compose.three-container.yml"):
|
||||
src = (REPO / fn).read_text(encoding="utf-8")
|
||||
# The agent's mount is `hermes-agent-src:/opt/hermes` (no :ro suffix).
|
||||
# Look for the line that has /opt/hermes without :ro.
|
||||
agent_lines = [
|
||||
line for line in src.splitlines()
|
||||
if "hermes-agent-src:/opt/hermes" in line
|
||||
]
|
||||
assert agent_lines, f"{fn}: agent must mount hermes-agent-src at /opt/hermes"
|
||||
for line in agent_lines:
|
||||
assert not line.rstrip().endswith(":ro"), (
|
||||
f"{fn}: agent's hermes-agent-src mount must be writable "
|
||||
f"(it populates /opt/hermes on first run): {line!r}"
|
||||
)
|
||||
|
||||
|
||||
# ── 2: ${HOME} (not ~) in workspace bind defaults ───────────────────────────
|
||||
|
||||
|
||||
def test_two_container_workspace_uses_home_env_var():
|
||||
"""Compose v2 expands `~` differently than `${HOME}` under sudo, on Docker
|
||||
Desktop on Windows, and on some NAS appliances. Use `${HOME}` to match the
|
||||
single-container `docker-compose.yml` and avoid platform drift."""
|
||||
src = (REPO / "docker-compose.two-container.yml").read_text(encoding="utf-8")
|
||||
assert "${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace" in src, (
|
||||
"two-container: workspace default must use ${HOME}/workspace, not ~/workspace, "
|
||||
"to match docker-compose.yml's single-container convention."
|
||||
)
|
||||
assert "${HERMES_WORKSPACE:-~/workspace}" not in src, (
|
||||
"two-container: tilde-form workspace default still present — change to ${HOME}/workspace."
|
||||
)
|
||||
|
||||
|
||||
def test_three_container_workspace_uses_home_env_var():
|
||||
src = (REPO / "docker-compose.three-container.yml").read_text(encoding="utf-8")
|
||||
assert "${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace" in src, (
|
||||
"three-container: workspace default must use ${HOME}/workspace, not ~/workspace."
|
||||
)
|
||||
assert "${HERMES_WORKSPACE:-~/workspace}" not in src
|
||||
|
||||
|
||||
def test_single_container_workspace_already_uses_home_env_var():
|
||||
"""Sanity: the single-container file has used ${HOME} all along; pin it
|
||||
so it doesn't drift back."""
|
||||
src = (REPO / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
assert "${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace" in src
|
||||
|
||||
|
||||
# ── 3: docs/docker.md documents the agent-image upgrade procedure ──────────
|
||||
|
||||
|
||||
def test_docker_md_documents_agent_image_upgrade():
|
||||
"""The `hermes-agent-src` named volume caches the agent source on first
|
||||
`up` and is reused verbatim on every subsequent `up`, even after a fresh
|
||||
`docker pull` of the agent image. This is the root cause of #1416. The
|
||||
docs must give users the explicit `docker volume rm` recipe so they don't
|
||||
misdiagnose 'missing entrypoint' errors."""
|
||||
docs = (REPO / "docs" / "docker.md").read_text(encoding="utf-8")
|
||||
assert "Upgrading the agent container" in docs, (
|
||||
"docs/docker.md must have an 'Upgrading the agent container' section."
|
||||
)
|
||||
assert "docker volume rm" in docs, (
|
||||
"docs/docker.md must show the `docker volume rm` step in the upgrade recipe."
|
||||
)
|
||||
assert "hermes-agent-src" in docs
|
||||
# Cross-reference to the original issue so users searching for the
|
||||
# symptom land in the right place
|
||||
assert "#1416" in docs
|
||||
|
||||
|
||||
def test_compose_files_point_to_docker_md_for_upgrades():
|
||||
"""Both multi-container compose files should reference docs/docker.md
|
||||
near the named-volumes block so anyone reading the compose file directly
|
||||
finds the upgrade procedure."""
|
||||
for fn in ("docker-compose.two-container.yml", "docker-compose.three-container.yml"):
|
||||
src = (REPO / fn).read_text(encoding="utf-8")
|
||||
assert "docs/docker.md" in src, (
|
||||
f"{fn}: must reference docs/docker.md so users reading the compose "
|
||||
f"file see the agent upgrade pointer."
|
||||
)
|
||||
assert "docker volume rm" in src, (
|
||||
f"{fn}: must show the `docker volume rm` upgrade step inline."
|
||||
)
|
||||
|
||||
|
||||
# ── 4: docs/docker.md frames the isolation model honestly ──────────────────
|
||||
|
||||
|
||||
def test_docker_md_documents_isolation_model():
|
||||
"""The multi-container setups give process + network + resource isolation
|
||||
but NOT filesystem isolation. Document that explicitly so users don't
|
||||
reach for multi-container expecting a trust boundary it doesn't provide
|
||||
(RustyLopez's concern on #2453)."""
|
||||
docs = (REPO / "docs" / "docker.md").read_text(encoding="utf-8")
|
||||
assert "What the multi-container setup isolates" in docs, (
|
||||
"docs/docker.md must have a section calibrating multi-container "
|
||||
"isolation expectations — process/network/resource isolation, NOT "
|
||||
"filesystem isolation."
|
||||
)
|
||||
|
||||
|
||||
# ── 5: docker_init.bash stages agent source to a writable build dir ─────────
|
||||
#
|
||||
# The :ro mount fixed in PR #2470 broke a second, less obvious surface:
|
||||
# `uv pip install "$_agent_src[all]"` invokes setuptools' egg_info build step,
|
||||
# which touches `hermes_agent.egg-info/` *inside the source tree* even under
|
||||
# PEP 517 build isolation. On a `:ro` mount this returns `EROFS` and (under
|
||||
# `set -e`) kills container startup. The fix: copy the source tree into a
|
||||
# writable tmpfs build dir, run the install against THAT, then clean up.
|
||||
#
|
||||
# This was caught the first time the Docker smoke gate ran on its own PR — a
|
||||
# real regression that 5800+ source-level pytests had no way to surface
|
||||
# because none of them invoked `docker_init.bash` against a real :ro mount.
|
||||
|
||||
|
||||
def test_docker_init_stages_agent_source_for_writable_install():
|
||||
"""docker_init.bash must NOT pass the raw _agent_src path to `uv pip
|
||||
install` — that hits the :ro mount and fails. It must stage the source
|
||||
into a writable build dir first (the staged path is used in the install
|
||||
invocation)."""
|
||||
src = (REPO / "docker_init.bash").read_text(encoding="utf-8")
|
||||
|
||||
# The fix uses a /tmp staging path that's clearly distinct from the
|
||||
# mounted source path. Pin the staging marker.
|
||||
assert "_stage_src=" in src, (
|
||||
"docker_init.bash must declare a _stage_src writable build dir "
|
||||
"before invoking `uv pip install` against the (potentially :ro) "
|
||||
"hermes-agent source."
|
||||
)
|
||||
|
||||
# The install line must reference the staged path, NOT the raw _agent_src
|
||||
# path. The pre-fix code was:
|
||||
# uv pip install "$_agent_src[all]" ...
|
||||
# The fixed code is:
|
||||
# uv pip install "$_stage_src[all]" ...
|
||||
install_lines = [
|
||||
line for line in src.splitlines()
|
||||
if "uv pip install" in line and "[all]" in line
|
||||
]
|
||||
assert install_lines, "expected an `uv pip install ...[all]` line in docker_init.bash"
|
||||
for line in install_lines:
|
||||
assert '"$_agent_src[all]"' not in line, (
|
||||
"docker_init.bash invokes `uv pip install $_agent_src[all]` "
|
||||
"directly — this fails with EROFS when the hermes-agent volume "
|
||||
"is mounted :ro (the production multi-container default). "
|
||||
"Use the writable $_stage_src path instead. "
|
||||
f"Offending line: {line!r}"
|
||||
)
|
||||
assert "_stage_src" in line, (
|
||||
"the `uv pip install ...[all]` line must use the staged writable "
|
||||
f"path. Offending line: {line!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_docker_init_excludes_egg_info_during_staging():
|
||||
"""The staging copy must exclude pre-baked *.egg-info / build / dist
|
||||
directories. setuptools takes a different (timestamp-update) code path
|
||||
when one is already present in the source tree, which itself hits the
|
||||
:ro mount through stat/utime calls. Excluding them keeps the build
|
||||
happily on the fresh-build code path.
|
||||
|
||||
Tight assertions on both the rsync and cp-fallback paths — a loose
|
||||
`"egg-info" in src` check would pass on a stray comment mention, so
|
||||
we require the actual exclusion mechanics to be present.
|
||||
"""
|
||||
src = (REPO / "docker_init.bash").read_text(encoding="utf-8")
|
||||
|
||||
# Find the staging block: rsync invocation OR cp-fallback. Both must
|
||||
# actually exclude *.egg-info — a comment mention is not enough.
|
||||
stage_idx = src.index("_stage_src=")
|
||||
install_idx = src.index("uv pip install", stage_idx)
|
||||
stage_block = src[stage_idx:install_idx]
|
||||
|
||||
# Rsync path must carry --exclude='*.egg-info'.
|
||||
assert "--exclude='*.egg-info'" in stage_block, (
|
||||
"docker_init.bash rsync invocation must include "
|
||||
"--exclude='*.egg-info' so setuptools' timestamp-update code path "
|
||||
"doesn't fire (which itself hits the :ro mount through stat/utime)."
|
||||
)
|
||||
|
||||
# cp-fallback path must explicitly rm the egg-info dir after copy
|
||||
# (cp -a has no --exclude flag, so the cleanup happens post-copy).
|
||||
assert "*.egg-info" in stage_block, (
|
||||
"docker_init.bash cp-fallback must remove $_stage_src/*.egg-info "
|
||||
"after copy so the install runs on the fresh-build code path."
|
||||
)
|
||||
|
||||
# Both build and dist must also be excluded — setuptools touches them
|
||||
# under different conditions but the failure mode is identical.
|
||||
assert "--exclude='build'" in stage_block, (
|
||||
"rsync staging must --exclude='build' (setuptools build artifacts)."
|
||||
)
|
||||
assert "--exclude='dist'" in stage_block, (
|
||||
"rsync staging must --exclude='dist' (setuptools build artifacts)."
|
||||
)
|
||||
assert "--exclude='__pycache__'" in stage_block, (
|
||||
"rsync staging must --exclude='__pycache__' to keep the copy minimal."
|
||||
)
|
||||
|
||||
@@ -797,6 +797,7 @@ def test_agent_session_source_normalization_contract():
|
||||
|
||||
cases = {
|
||||
'cli': ('cli', 'CLI'),
|
||||
'email': ('messaging', 'Email'),
|
||||
'weixin': ('messaging', 'Weixin'),
|
||||
'telegram': ('messaging', 'Telegram'),
|
||||
'discord': ('messaging', 'Discord'),
|
||||
@@ -818,6 +819,105 @@ def test_agent_session_source_normalization_contract():
|
||||
assert normalized['raw_source'] is None
|
||||
|
||||
|
||||
def test_sessions_js_treats_email_as_messaging_source():
|
||||
"""Email gateway sessions should receive the same sidebar metadata as other messaging channels."""
|
||||
src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "'email'" in src[src.find("_MESSAGING_RAW_SOURCES"):src.find("function _isMessagingSession")]
|
||||
assert "email: 'Email'" in src[src.find("_MESSAGING_SOURCE_LABELS"):src.find("function _isMessagingSession")]
|
||||
|
||||
|
||||
def test_empty_active_gateway_session_does_not_hide_messaging_history(monkeypatch):
|
||||
"""A zero-message active Gateway row must not hide older Discord history."""
|
||||
import api.routes as routes
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_load_gateway_session_identity_map",
|
||||
lambda: {
|
||||
"discord_empty_active": {
|
||||
"raw_source": "discord",
|
||||
"platform": "discord",
|
||||
"user_id": "user-1",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"session_id": "discord_previous_history",
|
||||
"title": "Previous Discord chat",
|
||||
"source_tag": "discord",
|
||||
"raw_source": "discord",
|
||||
"session_source": "messaging",
|
||||
"source_label": "Discord",
|
||||
"user_id": "user-1",
|
||||
"message_count": 7,
|
||||
"updated_at": 100.0,
|
||||
"end_reason": "session_reset",
|
||||
}
|
||||
]
|
||||
|
||||
kept = routes._keep_latest_messaging_session_per_source(rows)
|
||||
|
||||
assert [row["session_id"] for row in kept] == ["discord_previous_history"]
|
||||
|
||||
|
||||
def test_previous_messaging_setting_keeps_reset_history(monkeypatch):
|
||||
"""The previous-messaging toggle exposes older reset segments."""
|
||||
import api.routes as routes
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_load_gateway_session_identity_map",
|
||||
lambda: {
|
||||
"discord_active": {
|
||||
"raw_source": "discord",
|
||||
"platform": "discord",
|
||||
"user_id": "user-1",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"session_id": "discord_active",
|
||||
"title": "Current Discord chat",
|
||||
"source_tag": "discord",
|
||||
"raw_source": "discord",
|
||||
"session_source": "messaging",
|
||||
"source_label": "Discord",
|
||||
"user_id": "user-1",
|
||||
"message_count": 3,
|
||||
"updated_at": 200.0,
|
||||
},
|
||||
{
|
||||
"session_id": "discord_previous_history",
|
||||
"title": "Previous Discord chat",
|
||||
"source_tag": "discord",
|
||||
"raw_source": "discord",
|
||||
"session_source": "messaging",
|
||||
"source_label": "Discord",
|
||||
"user_id": "user-1",
|
||||
"message_count": 7,
|
||||
"updated_at": 100.0,
|
||||
"end_reason": "session_reset",
|
||||
},
|
||||
]
|
||||
|
||||
hidden = routes._keep_latest_messaging_session_per_source(rows)
|
||||
visible = routes._keep_latest_messaging_session_per_source(
|
||||
rows,
|
||||
show_previous_messaging_sessions=True,
|
||||
)
|
||||
|
||||
assert [row["session_id"] for row in hidden] == ["discord_active"]
|
||||
assert [row["session_id"] for row in visible] == [
|
||||
"discord_active",
|
||||
"discord_previous_history",
|
||||
]
|
||||
|
||||
|
||||
def test_cross_source_parent_child_is_not_collapsed_into_root_metadata(cleanup_test_sessions):
|
||||
"""A WebUI continuation from a messaging parent must keep WebUI metadata.
|
||||
|
||||
|
||||
@@ -230,6 +230,144 @@ def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(monkeypatch, tmp_path
|
||||
assert started[0]["model_provider"] == "openai-codex"
|
||||
|
||||
|
||||
def test_goal_endpoint_preserves_response_shape_under_runtime_adapter_flag(monkeypatch, tmp_path):
|
||||
"""The Slice 3c adapter path delegates /goal without adding adapter-only fields."""
|
||||
from api import goals as webui_goals
|
||||
from api import routes
|
||||
|
||||
class FakeState:
|
||||
goal = "ship the feature"
|
||||
status = "active"
|
||||
turns_used = 1
|
||||
max_turns = 20
|
||||
last_verdict = None
|
||||
last_reason = None
|
||||
paused_reason = None
|
||||
|
||||
class FakeGoalManager:
|
||||
def __init__(self, session_id, default_max_turns=20):
|
||||
self.state = FakeState()
|
||||
|
||||
class FakeSession:
|
||||
session_id = "sid-goal-route"
|
||||
profile = "default"
|
||||
workspace = str(tmp_path)
|
||||
model = "gpt-5.5"
|
||||
model_provider = "openai-codex"
|
||||
messages = []
|
||||
context_messages = []
|
||||
pending_user_message = None
|
||||
active_stream_id = None
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal")
|
||||
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
||||
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
|
||||
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200, **kwargs: {"status": status, "payload": payload})
|
||||
|
||||
result = routes._handle_goal_command(object(), {"session_id": "sid-goal-route", "args": "status"})
|
||||
|
||||
assert result["status"] == 200
|
||||
assert result["payload"]["action"] == "status"
|
||||
assert result["payload"]["message_key"] == "goal_status_active"
|
||||
assert "run_id" not in result["payload"]
|
||||
assert "active_controls" not in result["payload"]
|
||||
|
||||
|
||||
def test_goal_endpoint_adapter_keeps_full_set_text_and_legacy_payload_status(monkeypatch, tmp_path):
|
||||
"""The adapter action label must not replace legacy parsing of full goal text."""
|
||||
from api import goals as webui_goals
|
||||
from api import routes
|
||||
|
||||
set_calls = []
|
||||
|
||||
class FakeState:
|
||||
goal = ""
|
||||
status = "active"
|
||||
turns_used = 0
|
||||
max_turns = 20
|
||||
last_verdict = None
|
||||
last_reason = None
|
||||
paused_reason = None
|
||||
|
||||
class FakeGoalManager:
|
||||
def __init__(self, session_id, default_max_turns=20):
|
||||
self.state = FakeState()
|
||||
|
||||
def set(self, text):
|
||||
set_calls.append(text)
|
||||
self.state.goal = text
|
||||
return self.state
|
||||
|
||||
class FakeSession:
|
||||
session_id = "sid-goal-route"
|
||||
profile = "default"
|
||||
workspace = str(tmp_path)
|
||||
model = "gpt-5.5"
|
||||
model_provider = "openai-codex"
|
||||
messages = []
|
||||
context_messages = []
|
||||
pending_user_message = None
|
||||
active_stream_id = None
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal")
|
||||
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
||||
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
|
||||
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_resolve_compatible_session_model_state",
|
||||
lambda model, provider: (model, provider, False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_start_chat_stream_for_session",
|
||||
lambda session, **kwargs: {"stream_id": "goal-stream", "session_id": session.session_id},
|
||||
)
|
||||
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200, **kwargs: {"status": status, "payload": payload})
|
||||
|
||||
result = routes._handle_goal_command(object(), {"session_id": "sid-goal-route", "args": "set foo"})
|
||||
|
||||
assert result["status"] == 200
|
||||
assert result["payload"]["action"] == "set"
|
||||
assert result["payload"]["kickoff_prompt"] == "set foo"
|
||||
assert set_calls == ["set foo"]
|
||||
|
||||
|
||||
def test_goal_endpoint_adapter_error_payload_still_controls_http_status(monkeypatch, tmp_path):
|
||||
"""The /goal route preserves legacy error/status handling under the adapter flag."""
|
||||
from api import goals as webui_goals
|
||||
from api import routes
|
||||
|
||||
class FakeGoalManager:
|
||||
state = None
|
||||
|
||||
def __init__(self, session_id, default_max_turns=20):
|
||||
pass
|
||||
|
||||
class FakeSession:
|
||||
session_id = "sid-goal-route"
|
||||
profile = "default"
|
||||
workspace = str(tmp_path)
|
||||
model = "gpt-5.5"
|
||||
model_provider = "openai-codex"
|
||||
messages = []
|
||||
context_messages = []
|
||||
pending_user_message = None
|
||||
active_stream_id = "running-stream"
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal")
|
||||
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
||||
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
|
||||
monkeypatch.setitem(routes.STREAMS, "running-stream", {"queue": object()})
|
||||
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200, **kwargs: {"status": status, "payload": payload})
|
||||
|
||||
result = routes._handle_goal_command(object(), {"session_id": "sid-goal-route", "args": "ship it"})
|
||||
|
||||
assert result["status"] == 409
|
||||
assert result["payload"]["ok"] is False
|
||||
assert result["payload"]["error"] == "agent_running"
|
||||
|
||||
|
||||
def test_routes_register_goal_endpoint_and_kickoff_stream():
|
||||
assert 'if parsed.path == "/api/goal"' in ROUTES_PY
|
||||
assert "return _handle_goal_command(handler, body)" in ROUTES_PY
|
||||
|
||||
@@ -45,6 +45,25 @@ def test_attach_live_stream_reuses_existing_same_stream_transport():
|
||||
assert "return" in body[reuse_pos:close_pos]
|
||||
|
||||
|
||||
def test_attach_live_stream_closes_other_session_streams_before_opening_new_one():
|
||||
"""Only the selected conversation pane should hold an open chat SSE transport."""
|
||||
body = _function_body(MESSAGES_JS, "attachLiveStream")
|
||||
helper = _function_body(MESSAGES_JS, "closeOtherLiveStreams")
|
||||
|
||||
helper_compact = helper.replace(" ", "")
|
||||
assert "Object.keys(LIVE_STREAMS)" in helper
|
||||
assert "if(sid!==activeSid)closeLiveStream(sid)" in helper_compact
|
||||
|
||||
reuse_pos = body.find("const existingLive=LIVE_STREAMS[activeSid]")
|
||||
close_other_pos = body.find("closeOtherLiveStreams(activeSid)")
|
||||
close_current_pos = body.find("\n closeLiveStream(activeSid);\n")
|
||||
assert close_other_pos != -1, "attachLiveStream() should prune background chat EventSources"
|
||||
assert reuse_pos < close_other_pos < close_current_pos, (
|
||||
"same-stream reuse should happen before pruning, and pruning should happen "
|
||||
"before replacing the active session transport"
|
||||
)
|
||||
|
||||
|
||||
def test_attach_live_stream_updates_uploads_before_same_stream_reuse():
|
||||
"""Reusing transport must not skip per-session uploaded attachment state."""
|
||||
body = _function_body(MESSAGES_JS, "attachLiveStream")
|
||||
|
||||
@@ -2,6 +2,8 @@ from api.models import Session
|
||||
import contextlib
|
||||
|
||||
from api.streaming import (
|
||||
_assistant_reply_added_after_current_turn,
|
||||
_context_messages_for_new_turn,
|
||||
_merge_display_messages_after_agent_result,
|
||||
_sanitize_messages_for_api,
|
||||
_session_context_messages,
|
||||
@@ -77,6 +79,96 @@ def test_workspace_prefixed_current_user_after_compaction_is_not_duplicated():
|
||||
assert sum(1 for m in merged if m.get("role") == "user" and "Ok, mache weiter" in m.get("content", "")) == 1
|
||||
|
||||
|
||||
def test_embedded_workspace_prefixed_current_user_delta_is_deduped():
|
||||
"""A failed provider path can echo draft text before the workspace tag."""
|
||||
current = "正常来说,chrome for testing 不是有独立的profile嘛,为什么会有 user-data-dir 冲突的问题?"
|
||||
previous_display = [
|
||||
{"role": "user", "content": "older prompt"},
|
||||
{"role": "assistant", "content": "older answer"},
|
||||
{"role": "user", "content": "正常来说,chrome"},
|
||||
{"role": "user", "content": current},
|
||||
]
|
||||
previous_context = [
|
||||
{"role": "user", "content": "older prompt"},
|
||||
{"role": "assistant", "content": "older answer"},
|
||||
]
|
||||
result_messages = previous_context + [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"正常来说,chrome\n\n"
|
||||
"[Workspace::v1: /mnt/e/vscode_workspace/hermes_workspace]\n"
|
||||
f"{current}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
merged = _merge_display_messages_after_agent_result(
|
||||
previous_display,
|
||||
previous_context,
|
||||
result_messages,
|
||||
current,
|
||||
)
|
||||
|
||||
assert merged == previous_display
|
||||
assert all("Workspace::v1" not in str(m.get("content") or "") for m in merged)
|
||||
|
||||
|
||||
def test_embedded_workspace_prefixed_current_user_delta_displays_clean_prompt():
|
||||
current = "正常来说,chrome for testing 不是有独立的profile嘛,为什么会有 user-data-dir 冲突的问题?"
|
||||
previous_display = [
|
||||
{"role": "user", "content": "older prompt"},
|
||||
{"role": "assistant", "content": "older answer"},
|
||||
]
|
||||
previous_context = list(previous_display)
|
||||
result_messages = previous_context + [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"正常来说,chrome\n\n"
|
||||
"[Workspace::v1: /mnt/e/vscode_workspace/hermes_workspace]\n"
|
||||
f"{current}"
|
||||
),
|
||||
},
|
||||
{"role": "assistant", "content": "Chrome for Testing 本身没有固定独立 profile。"},
|
||||
]
|
||||
|
||||
merged = _merge_display_messages_after_agent_result(
|
||||
previous_display,
|
||||
previous_context,
|
||||
result_messages,
|
||||
current,
|
||||
)
|
||||
|
||||
assert [m["content"] for m in merged[-2:]] == [
|
||||
current,
|
||||
"Chrome for Testing 本身没有固定独立 profile。",
|
||||
]
|
||||
assert all("Workspace::v1" not in str(m.get("content") or "") for m in merged)
|
||||
|
||||
|
||||
def test_assistant_added_detection_ignores_prior_history():
|
||||
previous_context = [
|
||||
{"role": "user", "content": "older prompt"},
|
||||
{"role": "assistant", "content": "older answer"},
|
||||
]
|
||||
current = "new prompt"
|
||||
result_messages = previous_context + [
|
||||
{"role": "user", "content": f"[Workspace::v1: /tmp/project]\n{current}"},
|
||||
]
|
||||
|
||||
assert not _assistant_reply_added_after_current_turn(
|
||||
result_messages,
|
||||
previous_context,
|
||||
current,
|
||||
)
|
||||
assert _assistant_reply_added_after_current_turn(
|
||||
result_messages + [{"role": "assistant", "content": "new answer"}],
|
||||
previous_context,
|
||||
current,
|
||||
)
|
||||
|
||||
|
||||
def test_compacted_agent_result_keeps_old_prompts_and_appends_current_turn():
|
||||
previous_display = [
|
||||
{"role": "user", "content": "first prompt that must remain visible"},
|
||||
@@ -172,6 +264,91 @@ def test_session_context_falls_back_to_display_messages_for_legacy_sessions(tmp_
|
||||
assert _session_context_messages(session) == messages
|
||||
|
||||
|
||||
def test_casual_greeting_does_not_resume_stale_compaction_active_task(tmp_path):
|
||||
compacted_task_context = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted. "
|
||||
"Your current task is identified in the Active Task section — resume exactly from there. "
|
||||
"[Your active task list was preserved across context compression] "
|
||||
"- [>] 5. 更新测试:mock bridge 输出 (in_progress)"
|
||||
),
|
||||
},
|
||||
{"role": "assistant", "content": "I will inspect api/config.py next."},
|
||||
]
|
||||
session = Session(
|
||||
session_id="issue2308",
|
||||
workspace=str(tmp_path),
|
||||
messages=[
|
||||
{"role": "user", "content": "old provider/model task"},
|
||||
{"role": "assistant", "content": "old task answer"},
|
||||
],
|
||||
context_messages=compacted_task_context,
|
||||
)
|
||||
|
||||
assert _context_messages_for_new_turn(session, "你好") == []
|
||||
|
||||
|
||||
def test_explicit_continue_keeps_compacted_active_task_context(tmp_path):
|
||||
compacted_task_context = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted. "
|
||||
"Your current task is identified in the Active Task section — resume exactly from there."
|
||||
),
|
||||
},
|
||||
{"role": "assistant", "content": "I will inspect api/config.py next."},
|
||||
]
|
||||
session = Session(
|
||||
session_id="issue2308-continue",
|
||||
workspace=str(tmp_path),
|
||||
messages=[
|
||||
{"role": "user", "content": "old provider/model task"},
|
||||
{"role": "assistant", "content": "old task answer"},
|
||||
],
|
||||
context_messages=compacted_task_context,
|
||||
)
|
||||
|
||||
assert _context_messages_for_new_turn(session, "继续") == compacted_task_context
|
||||
|
||||
|
||||
def test_all_cjk_greetings_drop_stale_compaction_context(tmp_path):
|
||||
"""Pin every CJK greeting in the casual-fresh-chat set against a stale
|
||||
compaction context. Catches typos like \\u5616 (嘖, "click of tongue")
|
||||
or \\u5582 (喂, "hey on phone") slipping into the greeting set where
|
||||
\\u55c9 (嗨, "hai") and \\u55bd (喽, "luo") were intended."""
|
||||
compacted_task_context = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] active task: X — resume exactly. in_progress."
|
||||
),
|
||||
},
|
||||
{"role": "assistant", "content": "I will continue task X"},
|
||||
]
|
||||
session = Session(
|
||||
session_id="issue2308-cjk-all",
|
||||
workspace=str(tmp_path),
|
||||
messages=[
|
||||
{"role": "user", "content": "old task"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
],
|
||||
context_messages=compacted_task_context,
|
||||
)
|
||||
|
||||
# Every CJK greeting in _is_casual_fresh_chat_message must drop the stale context.
|
||||
# If a typo lands a wrong codepoint here, the user's greeting won't be recognized
|
||||
# and the stale "resume active task" prompt will silently leak back through.
|
||||
for greeting in ("你好", "您好", "嗨", "哈喽", "在吗", "在么"):
|
||||
assert _context_messages_for_new_turn(session, greeting) == [], (
|
||||
f"CJK greeting {greeting!r} (U+{ord(greeting[0]):04X}"
|
||||
f"{'+'+'U+%04X' % ord(greeting[1]) if len(greeting) > 1 else ''}) "
|
||||
f"was not recognized as a casual fresh chat — stale compaction context leaked"
|
||||
)
|
||||
|
||||
|
||||
def test_retry_truncates_model_context_when_it_is_separate(monkeypatch, tmp_path):
|
||||
import api.session_ops as session_ops
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import api.config as config
|
||||
import api.models as models
|
||||
import api.streaming as streaming
|
||||
from api.models import Session
|
||||
from api.run_journal import append_run_event
|
||||
from api.streaming import cancel_stream
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
@@ -391,14 +392,60 @@ def test_stale_stream_cleanup_materializes_pending_turn_before_clearing_state():
|
||||
assert cleared is True
|
||||
assert s.active_stream_id is None
|
||||
assert s.pending_user_message is None
|
||||
assert s.messages[-1]["role"] == "user"
|
||||
assert s.messages[-1]["content"] == "please make the GUI fully usable"
|
||||
assert s.messages[-1]["timestamp"] == 1778187755
|
||||
assert s.messages[-1]["attachments"] == [{"name": "visible-state.png"}]
|
||||
assert s.messages[-2]["role"] == "user"
|
||||
assert s.messages[-2]["content"] == "please make the GUI fully usable"
|
||||
assert s.messages[-2]["timestamp"] == 1778187755
|
||||
assert s.messages[-2]["attachments"] == [{"name": "visible-state.png"}]
|
||||
assert s.messages[-1]["role"] == "assistant"
|
||||
assert s.messages[-1].get("_error") is True
|
||||
assert s.messages[-1].get("type") == "interrupted"
|
||||
|
||||
reloaded = models.get_session(sid, metadata_only=False)
|
||||
assert reloaded.messages[-1]["role"] == "user"
|
||||
assert reloaded.messages[-1]["content"] == "please make the GUI fully usable"
|
||||
assert reloaded.messages[-2]["role"] == "user"
|
||||
assert reloaded.messages[-2]["content"] == "please make the GUI fully usable"
|
||||
assert reloaded.messages[-1]["role"] == "assistant"
|
||||
assert reloaded.messages[-1].get("type") == "interrupted"
|
||||
|
||||
|
||||
def test_stale_stream_cleanup_recovers_journaled_visible_output():
|
||||
"""The /api/session stale cleanup path can run before a full chat reload;
|
||||
it must preserve journaled partial output instead of only clearing runtime
|
||||
flags."""
|
||||
from api.routes import _clear_stale_stream_state
|
||||
|
||||
sid = "test_pending_error_d4_journal"
|
||||
s = _make_session(
|
||||
session_id=sid,
|
||||
pending_msg="please check maintainer activity",
|
||||
messages=[{"role": "assistant", "content": "previous answer"}],
|
||||
)
|
||||
append_run_event(
|
||||
sid,
|
||||
"stream_1361",
|
||||
"token",
|
||||
{"text": "I will check GitHub first."},
|
||||
)
|
||||
append_run_event(
|
||||
sid,
|
||||
"stream_1361",
|
||||
"tool",
|
||||
{"name": "terminal", "preview": "gh issue view 2423", "args": {"command": "gh issue view 2423"}},
|
||||
)
|
||||
append_run_event(
|
||||
sid,
|
||||
"stream_1361",
|
||||
"tool_complete",
|
||||
{"name": "terminal", "duration": 0.4, "is_error": False},
|
||||
)
|
||||
|
||||
cleared = _clear_stale_stream_state(s)
|
||||
|
||||
assert cleared is True
|
||||
assert any("I will check GitHub first." in (m.get("content") or "") for m in s.messages)
|
||||
assert s.tool_calls
|
||||
assert s.tool_calls[0]["name"] == "terminal"
|
||||
assert s.messages[-1].get("type") == "interrupted"
|
||||
assert "partial output above was recovered" in s.messages[-1]["content"]
|
||||
|
||||
|
||||
# ── Structural guard: pin call sites of the materialize helper at error branches ──
|
||||
|
||||
@@ -103,7 +103,7 @@ class TestIssue1436BackendFallback:
|
||||
return captured
|
||||
|
||||
def test_persisted_context_length_passed_through_unchanged(self):
|
||||
"""When Session.context_length is non-zero, return it as-is (no fallback)."""
|
||||
"""Fast metadata loads keep the persisted value to avoid catalog work."""
|
||||
s = self._stub_session(context_length=200_000, model="claude-sonnet-4.6")
|
||||
result = self._invoke_get_session(s, fallback_returns=999_999)
|
||||
body = result["data"]["session"]
|
||||
@@ -112,6 +112,94 @@ class TestIssue1436BackendFallback:
|
||||
f"got {body['context_length']}"
|
||||
)
|
||||
|
||||
def test_resolved_model_load_refreshes_stale_persisted_context_length(self):
|
||||
"""The deferred resolve_model=1 load must refresh stale context metadata.
|
||||
|
||||
Session switching first asks for messages=0&resolve_model=0 for speed,
|
||||
then follows with messages=0&resolve_model=1 to hydrate the final
|
||||
model/provider display. That second path is also where a stale
|
||||
context_length from a prior model must be corrected; otherwise a
|
||||
resumed DeepSeek 1M session can stay stuck on an old 200k window until
|
||||
the user manually toggles models.
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_j(h, data, status=200):
|
||||
captured["data"] = data
|
||||
|
||||
fake_module = MagicMock()
|
||||
fake_module.get_model_context_length = MagicMock(return_value=1_000_000)
|
||||
|
||||
s = self._stub_session(context_length=200_000, model="deepseek-v4-pro")
|
||||
handler = MagicMock()
|
||||
parsed = urlparse(
|
||||
"/api/session?session_id=test-1436&messages=0&resolve_model=1"
|
||||
)
|
||||
|
||||
with patch("api.routes.get_session", return_value=s), \
|
||||
patch("api.routes.j", side_effect=fake_j), \
|
||||
patch.dict("sys.modules", {"agent.model_metadata": fake_module}):
|
||||
routes.handle_get(handler, parsed)
|
||||
|
||||
body = captured["data"]["session"]
|
||||
assert body["context_length"] == 1_000_000, (
|
||||
"resolve_model=1 must refresh stale persisted context_length from "
|
||||
"current model metadata"
|
||||
)
|
||||
|
||||
def test_session_model_update_refreshes_context_metadata(self):
|
||||
"""Changing the session model must not keep the prior model's window."""
|
||||
import api.routes as routes
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_j(h, data, status=200):
|
||||
captured["data"] = data
|
||||
|
||||
fake_module = MagicMock()
|
||||
fake_module.get_model_context_length = MagicMock(return_value=1_000_000)
|
||||
|
||||
s = self._stub_session(context_length=200_000, model="old-model")
|
||||
s.model_provider = "old-provider"
|
||||
s.workspace = "/tmp"
|
||||
s.threshold_tokens = 100_000
|
||||
s.last_prompt_tokens = 80_000
|
||||
s.save = MagicMock()
|
||||
s.compact.return_value = {
|
||||
**s.compact.return_value,
|
||||
"model": "deepseek-v4-pro",
|
||||
"model_provider": "deepseek",
|
||||
"context_length": 1_000_000,
|
||||
"threshold_tokens": 0,
|
||||
"last_prompt_tokens": 0,
|
||||
}
|
||||
handler = MagicMock()
|
||||
parsed = urlparse("/api/session/update")
|
||||
|
||||
body = {
|
||||
"session_id": "test-1436",
|
||||
"workspace": "/tmp",
|
||||
"model": "deepseek-v4-pro",
|
||||
"model_provider": "deepseek",
|
||||
}
|
||||
with patch("api.routes._check_csrf", return_value=True), \
|
||||
patch("api.routes.read_body", return_value=body), \
|
||||
patch("api.routes.get_session", return_value=s), \
|
||||
patch("api.routes.resolve_trusted_workspace", return_value="/tmp"), \
|
||||
patch("api.routes.j", side_effect=fake_j), \
|
||||
patch.dict("sys.modules", {"agent.model_metadata": fake_module}):
|
||||
routes.handle_post(handler, parsed)
|
||||
|
||||
assert s.model == "deepseek-v4-pro"
|
||||
assert s.model_provider == "deepseek"
|
||||
assert s.context_length == 1_000_000
|
||||
assert s.threshold_tokens == 0
|
||||
assert s.last_prompt_tokens == 0
|
||||
s.save.assert_called_once()
|
||||
assert captured["data"]["session"]["context_length"] == 1_000_000
|
||||
|
||||
def test_zero_context_length_falls_back_to_model_metadata(self):
|
||||
"""Pre-#1318 sessions with context_length=0 must resolve via model_metadata."""
|
||||
s = self._stub_session(context_length=0, model="claude-opus-4-7",
|
||||
@@ -276,13 +364,19 @@ class TestIssue1436SourceMarkers:
|
||||
|
||||
def test_routes_load_path_imports_get_model_context_length(self):
|
||||
src = ROUTES.read_text(encoding="utf-8")
|
||||
# The import must appear inside the GET /api/session load-path block.
|
||||
# The session load path can call a helper, but the lazy import must
|
||||
# remain in routes.py so WebUI still works with older/missing agent
|
||||
# bundles by swallowing metadata-resolution failures.
|
||||
start = src.find('if parsed.path == "/api/session":')
|
||||
end = src.find('if parsed.path == "/api/projects":', start)
|
||||
block = src[start:end]
|
||||
assert "from agent.model_metadata import get_model_context_length" in block, (
|
||||
"GET /api/session load-path block must lazy-import "
|
||||
"get_model_context_length for the context_length=0 fallback (#1436)"
|
||||
assert "_resolve_context_length_for_session_model" in block, (
|
||||
"GET /api/session load-path block must resolve model context "
|
||||
"metadata for the context_length fallback (#1436)"
|
||||
)
|
||||
assert "from agent.model_metadata import get_model_context_length" in src, (
|
||||
"routes.py must lazy-import get_model_context_length for the "
|
||||
"context_length fallback (#1436)"
|
||||
)
|
||||
|
||||
def test_routes_load_path_marks_fix_with_issue_number(self):
|
||||
|
||||
@@ -164,7 +164,11 @@ def test_keep_latest_messaging_runs_after_profile_filter():
|
||||
block = src[handler_idx:next_handler]
|
||||
|
||||
filter_idx = block.find('_profiles_match(s.get("profile"), active_profile)')
|
||||
dedupe_idx = block.find('_keep_latest_messaging_session_per_source(scoped)')
|
||||
# The dedupe call can be either single-line `(scoped)` or multi-line
|
||||
# `(\n scoped,\n show_previous_messaging_sessions=…,\n)`; match the
|
||||
# function name + the first arg position rather than coupling to the call
|
||||
# shape. (#2294 added the keyword-arg form.)
|
||||
dedupe_idx = block.find('_keep_latest_messaging_session_per_source(')
|
||||
assert filter_idx > 0, "Profile filter not found in /api/sessions handler"
|
||||
assert dedupe_idx > 0, "Messaging dedupe must run on the scoped list"
|
||||
assert filter_idx < dedupe_idx, (
|
||||
|
||||