Compare commits
180 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a8c051fb | ||
|
|
44a6587e78 | ||
|
|
0a6f15d8d9 | ||
|
|
2800ebdcff | ||
|
|
3c457d178d | ||
|
|
c0019723d1 | ||
|
|
34329ad231 | ||
|
|
e62338d3a0 | ||
|
|
619646159c | ||
|
|
a4b56642d9 | ||
|
|
32276c81d1 | ||
|
|
86b20d362f | ||
|
|
8ce83b637c | ||
|
|
6333a06524 | ||
|
|
5663fb147b | ||
|
|
0217bf5cce | ||
|
|
da131b842d | ||
|
|
ef72384217 | ||
|
|
116a510ed3 | ||
|
|
ed24010e10 | ||
|
|
23b7c63198 | ||
|
|
7e17ec497c | ||
|
|
2d5c4b71cc | ||
|
|
4a882bec66 | ||
|
|
f48b157a8f | ||
|
|
a2d7f311be | ||
|
|
c06ec43f17 | ||
|
|
e5cf9c5910 | ||
|
|
70de09290c | ||
|
|
f109592cb0 | ||
|
|
d339200b5b | ||
|
|
0a91e3cb02 | ||
|
|
cb41075bd2 | ||
|
|
91703e3e54 | ||
|
|
396537c624 | ||
|
|
b072a6887c | ||
|
|
27e69c404a | ||
|
|
dbc9c910a8 | ||
|
|
23e9070fc5 | ||
|
|
e0257d81d5 | ||
|
|
533edbcae0 | ||
|
|
885f1fa349 | ||
|
|
970bc1d3fd | ||
|
|
061af78cde | ||
|
|
87d4136a43 | ||
|
|
ce9aec1640 | ||
|
|
1a9dba7844 | ||
|
|
06bedc8e23 | ||
|
|
63b0207604 | ||
|
|
9c69b646ff | ||
|
|
57222c70e7 | ||
|
|
14a1924796 | ||
|
|
36da37ff13 | ||
|
|
b14ea4f9f6 | ||
|
|
ff970ec844 | ||
|
|
b563484a56 | ||
|
|
89b0c8eb41 | ||
|
|
a3647570fb | ||
|
|
1011918d50 | ||
|
|
07caaec6ef | ||
|
|
1175ee363f | ||
|
|
5b923a9502 | ||
|
|
5082f426f2 | ||
|
|
537c8271db | ||
|
|
9dd6e3f338 | ||
|
|
4089972b09 | ||
|
|
498156a3e8 | ||
|
|
cd01e4d5ba | ||
|
|
96c97c5e0e | ||
|
|
ae7be6deba | ||
|
|
bd443c4862 | ||
|
|
b82954ee70 | ||
|
|
666d385c03 | ||
|
|
d39d30a213 | ||
|
|
62c56175b7 | ||
|
|
0f1b232c12 | ||
|
|
cc025aab79 | ||
|
|
236a116888 | ||
|
|
04b00065f9 | ||
|
|
e3607855b1 | ||
|
|
a72208eaf6 | ||
|
|
0a75b3f1d3 | ||
|
|
1a98f75005 | ||
|
|
095dbfd641 | ||
|
|
3a63fe479e | ||
|
|
96cb880a12 | ||
|
|
e151665131 | ||
|
|
558b1730a6 | ||
|
|
201235d807 | ||
|
|
256b3fbbdf | ||
|
|
5fa731ea4a | ||
|
|
d8e1f37e2b | ||
|
|
f42f1c69ca | ||
|
|
418d77443c | ||
|
|
13dbd818c9 | ||
|
|
85434dd03c | ||
|
|
db57c47ff3 | ||
|
|
9b628c27ab | ||
|
|
11fd0d8412 | ||
|
|
24fc9d4155 | ||
|
|
1239129ae2 | ||
|
|
880085a09e | ||
|
|
d4a3adb7b1 | ||
|
|
3daf2427f7 | ||
|
|
d41d05ea36 | ||
|
|
859602340e | ||
|
|
c3807482be | ||
|
|
2d8bccdd96 | ||
|
|
8f1f582caf | ||
|
|
a4d59b9e6c | ||
|
|
811424a87b | ||
|
|
f6e1612c7e | ||
|
|
081c4208d9 | ||
|
|
e05fc4e0e4 | ||
|
|
312a493a72 | ||
|
|
3246b263d9 | ||
|
|
bbc917a5c6 | ||
|
|
cbb4ba3f28 | ||
|
|
d527629281 | ||
|
|
77ab63361f | ||
|
|
3f484aec33 | ||
|
|
49ff8b3185 | ||
|
|
38e215e8f8 | ||
|
|
81072d34d6 | ||
|
|
e91325db25 | ||
|
|
629d4290ed | ||
|
|
28b4777b5a | ||
|
|
b6d335feaa | ||
|
|
a7e8b1ab83 | ||
|
|
c34892be44 | ||
|
|
98cd318413 | ||
|
|
94a04ddd40 | ||
|
|
765d8520d4 | ||
|
|
76e602af25 | ||
|
|
63f9b719bb | ||
|
|
f35ac3a727 | ||
|
|
0dd5d6f21c | ||
|
|
a8979f74d5 | ||
|
|
711d8bb6c0 | ||
|
|
a1c5c395e5 | ||
|
|
69570ca77c | ||
|
|
aa767d28d0 | ||
|
|
78c4f1e425 | ||
|
|
81ba420716 | ||
|
|
7f16a41a31 | ||
|
|
c68420d9aa | ||
|
|
aa78175cca | ||
|
|
da1fdca22c | ||
|
|
067d96bb30 | ||
|
|
e637965388 | ||
|
|
66fbfbaa2b | ||
|
|
877a32f49c | ||
|
|
0386dc261a | ||
|
|
17e965b52f | ||
|
|
d3a686a266 | ||
|
|
3cd38b2b31 | ||
|
|
d7071cd424 | ||
|
|
e0ad593801 | ||
|
|
75e4f8b201 | ||
|
|
352354790f | ||
|
|
5266ee26bd | ||
|
|
5c2840e2da | ||
|
|
75e6595e06 | ||
|
|
20a5f48a1f | ||
|
|
ad6e76e48e | ||
|
|
b49de92893 | ||
|
|
b1aa1cfa4d | ||
|
|
8c68ea8823 | ||
|
|
ec48c482e2 | ||
|
|
bded1cf906 | ||
|
|
7cb5547056 | ||
|
|
f3f23abd4e | ||
|
|
d6267f4d31 | ||
|
|
e7b8ab4d70 | ||
|
|
79428f93c6 | ||
|
|
a2ea15b557 | ||
|
|
692ba68e42 | ||
|
|
2484409b7a | ||
|
|
b608f8837e | ||
|
|
d5bea959a5 |
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -52,5 +52,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: HERMES_VERSION=${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -16,12 +16,21 @@ archive/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.claude/*
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.cursorrules
|
||||
.windsurfrules
|
||||
.aider*
|
||||
copilot-instructions.md
|
||||
|
||||
# Generated screenshots and transient artifacts
|
||||
screenshot-*.png
|
||||
full-UI.png
|
||||
|
||||
# Version file written by Docker/CI build — generated, never committed
|
||||
api/_version.py
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
53
AGENTS.md
53
AGENTS.md
@@ -1,53 +0,0 @@
|
||||
# Web UI MVP Instructions
|
||||
|
||||
Canonical source: <repo>/
|
||||
Symlink (for imports): <agent-dir>/webui-mvp -> <repo>
|
||||
Runtime state: ~/.hermes/webui-mvp/sessions/
|
||||
|
||||
Purpose:
|
||||
- Claude-style web UI for Hermes. Chat, workspace file browser, cron/skills/memory viewers.
|
||||
|
||||
Start server:
|
||||
cd <agent-dir>
|
||||
nohup venv/bin/python <repo>/server.py > /tmp/webui-mvp.log 2>&1 &
|
||||
# OR: <repo>/start.sh
|
||||
|
||||
Run tests:
|
||||
cd <agent-dir>
|
||||
venv/bin/python -m pytest <repo>/tests/ -v
|
||||
|
||||
Health check: curl http://127.0.0.1:8787/health
|
||||
Logs: tail -f /tmp/webui-mvp.log
|
||||
SSH tunnel from Mac: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>
|
||||
|
||||
Living documents (always update after a sprint):
|
||||
<repo>/ROADMAP.md
|
||||
<repo>/ARCHITECTURE.md
|
||||
<repo>/TESTING.md
|
||||
|
||||
Sprint process skill: webui-sprint-loop
|
||||
|
||||
# Workspace Convention (Web UI Sessions)
|
||||
|
||||
When running as an agent invoked from the web UI, each user message is prefixed with:
|
||||
|
||||
[Workspace: /absolute/path/to/workspace]
|
||||
|
||||
This tag is the single authoritative source of the active workspace. It reflects
|
||||
whichever workspace the user has selected in the UI at the moment they sent that message.
|
||||
It updates on every message, so if the user switches workspaces mid-session, the very
|
||||
next message will carry the new path. Always use the value from the most recent tag.
|
||||
|
||||
This tag overrides any prior workspace mentioned in the system prompt, memory, or
|
||||
conversation history. Never infer or fall back to a hardcoded path like
|
||||
~/workspace when this tag is present.
|
||||
|
||||
Apply it as the default working directory for ALL file operations:
|
||||
|
||||
- write_file: resolve relative paths against this workspace
|
||||
- read_file / search_files: resolve paths relative to this workspace
|
||||
- terminal workdir: set to this path unless the user explicitly says otherwise
|
||||
- patch: resolve file paths relative to this workspace
|
||||
|
||||
If no [Workspace: ...] tag is present (e.g., CLI sessions), fall back to
|
||||
~/workspace as the default.
|
||||
@@ -1629,3 +1629,19 @@ and #rightpanelResize. On mousemove: computes delta and clamps to min/max. On mo
|
||||
saves width to localStorage. Widths restored at boot via localStorage.getItem().
|
||||
CSS: .resize-handle with position:absolute, width:5px, cursor:col-resize.
|
||||
body.resizing added during drag to suppress text selection.
|
||||
|
||||
|
||||
## Workspace path trust levels
|
||||
|
||||
`api/workspace.py` has two distinct trust functions — do not collapse them:
|
||||
|
||||
**`validate_workspace_to_add(path)`** — used by `/api/workspaces/add` (explicit user registration).
|
||||
Permissive: blocks only non-existent, non-directory, and system root paths. The user is
|
||||
consciously registering an external path (e.g. `/mnt/d/Projects` in WSL), so we trust intent.
|
||||
|
||||
**`resolve_trusted_workspace(path)`** — used for actual file read/write operations inside
|
||||
an existing workspace. Strict: path must be under home, in the saved workspace list, or under
|
||||
`BOOT_DEFAULT_WORKSPACE`. Prevents path traversal and unauthorized file access.
|
||||
|
||||
The distinction matters because add uses permissive validation to avoid the circular
|
||||
dependency: you cannot get a path into the saved list if you need the saved list to add it.
|
||||
|
||||
12
BUGS.md
12
BUGS.md
@@ -10,6 +10,18 @@ This file tracks UI bugs and polish items. Fixed items are kept for reference.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Two-container Docker setup: tools run in WebUI container** — In the two-container setup (hermes-agent + hermes-webui as separate containers), WebUI-initiated agent sessions run tools in the WebUI container, not the agent container. This is a known architectural constraint. Workaround: use the combined single-image approach, or initiate sessions via the CLI in the agent container. (#681)
|
||||
|
||||
- **Image-in-chat vs. saved-to-workspace mismatch** — When the agent displays an inline image (from a URL) and the user asks it to save that image, the agent issues a fresh download which may return a different file if the source URL is CDN-rotated or parameterized. The WebUI correctly renders whatever URL the agent provides. Fix requires agent-side URL caching. (#641)
|
||||
|
||||
- **MCP tools not available in WebUI sessions** — MCP servers must be configured in the active profile's config.yaml under mcp_servers:. If MCP tools are not appearing, check that the profile is correct and the MCP server process is reachable from inside the WebUI container. (#628)
|
||||
|
||||
- **os.environ race condition in concurrent sessions** — Concurrent agent sessions share process-level os.environ for TERMINAL_CWD, HERMES_SESSION_KEY, and HERMES_HOME. _ENV_LOCK serializes mutations but does not fully isolate env vars during agent execution. Upstream fix pending in hermes-agent. (#195)
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### ~~Session title truncation / hover actions~~ -- Fixed (Sprint 16)
|
||||
|
||||
1802
CHANGELOG.md
1802
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
10
Dockerfile
10
Dockerfile
@@ -24,6 +24,7 @@ RUN apt-get update -y --fix-missing --no-install-recommends \
|
||||
sudo \
|
||||
curl \
|
||||
rsync \
|
||||
openssh-client \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -76,7 +77,14 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b
|
||||
|
||||
USER hermeswebuitoo
|
||||
|
||||
COPY . /apptoo
|
||||
COPY --chown=hermeswebuitoo:hermeswebuitoo . /apptoo
|
||||
|
||||
# Bake the git version tag into the image so the settings badge works even
|
||||
# when .git is not present (it is excluded by .dockerignore).
|
||||
# CI passes: --build-arg HERMES_VERSION=$(git describe --tags --always)
|
||||
# Local builds that omit the arg get "unknown" as the fallback.
|
||||
ARG HERMES_VERSION=unknown
|
||||
RUN echo "__version__ = '${HERMES_VERSION}'" > /apptoo/api/_version.py
|
||||
|
||||
# Default to binding all interfaces (required for container networking)
|
||||
ENV HERMES_WEBUI_HOST=0.0.0.0
|
||||
|
||||
85
README.md
85
README.md
@@ -13,12 +13,12 @@ the **composer footer** — always visible while composing. A circular context r
|
||||
shows token usage at a glance. All settings and session tools are in the
|
||||
**Hermes Control Center** (launcher at the sidebar bottom).
|
||||
|
||||
<img alt="Hermes Web UI — three-panel layout" width="1417" height="867" alt="image" src="https://github.com/user-attachments/assets/51adff98-53ee-4800-8508-78b6c34dd3dc" />
|
||||
<img width="2448" height="1748" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/6bf8af4c-209d-441e-8b92-6515d7a0c369" />
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Light mode with full profile support" src="https://github.com/user-attachments/assets/9b68142f-d974-4493-a8d1-fd73e622c7fd" />
|
||||
<img width="2940" height="1848" alt="Light mode with full profile support" src="https://github.com/user-attachments/assets/4ef3a59c-7a66-4705-b4e7-cb9148fe4c47" />
|
||||
<br /><sub>Light mode with full profile support</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
@@ -189,6 +189,13 @@ This starts both containers with shared volumes:
|
||||
- **`hermes-agent-src`** — the agent's source code, mounted into the WebUI
|
||||
container so it can install the agent's Python dependencies at startup
|
||||
|
||||
> **Volume type:** The compose files use named Docker volumes by default.
|
||||
> If you prefer bind mounts to an existing directory (e.g. for sharing state
|
||||
> with an agent container you already run), both containers must mount the
|
||||
> same host path — the agent writes to `/root/.hermes`, the WebUI reads from
|
||||
> `/home/hermeswebui/.hermes`. See `docker-compose.two-container.yml` for
|
||||
> a bind-mount example.
|
||||
|
||||
The WebUI's init script automatically installs hermes-agent and all its
|
||||
dependencies (openai, anthropic, etc.) into its own Python environment on
|
||||
first boot. Subsequent restarts reuse the installed packages.
|
||||
@@ -200,6 +207,75 @@ first boot. Subsequent restarts reuse the installed packages.
|
||||
|
||||
See `docker-compose.two-container.yml` for the full configuration.
|
||||
|
||||
### Running alongside hermes-dashboard (three-container setup)
|
||||
|
||||
To run the Hermes Agent, Hermes Dashboard, and the WebUI together on a
|
||||
shared volume, use the three-container Compose file:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.three-container.yml up -d
|
||||
```
|
||||
|
||||
This brings up:
|
||||
- **`hermes-agent`** — gateway API on port 8642
|
||||
- **`hermes-dashboard`** — monitoring UI on port 9119
|
||||
- **`hermes-webui`** — browser chat interface on port 8787
|
||||
|
||||
All three services share the same `hermes-home` named volume so config,
|
||||
sessions, skills, and memory are consistent across all surfaces.
|
||||
|
||||
#### Why UIDs must match
|
||||
|
||||
The `hermes-home` volume is a bind-mount in practice — all three containers
|
||||
write to the same filesystem tree under `~/.hermes`. If the containers run
|
||||
as different UIDs, whichever container creates a file first becomes its
|
||||
owner, and the others hit `PermissionError` on subsequent writes.
|
||||
|
||||
The fix is to make all containers run as **your host user's UID and GID**.
|
||||
|
||||
#### Variable name asymmetry
|
||||
|
||||
> ⚠️ **The two image families use different environment variable names** for
|
||||
> the UID/GID setting:
|
||||
>
|
||||
> | Image | Variable |
|
||||
> |---|---|
|
||||
> | `nousresearch/hermes-agent` (agent + dashboard) | `HERMES_UID` / `HERMES_GID` |
|
||||
> | `ghcr.io/nesquena/hermes-webui` | `WANTED_UID` / `WANTED_GID` |
|
||||
>
|
||||
> You must set **both pairs** when using a `.env` file.
|
||||
|
||||
#### Recommended setup
|
||||
|
||||
For a standard Linux user (UID ≥ 1000):
|
||||
|
||||
```bash
|
||||
# Create a .env file with your host UID/GID
|
||||
echo "UID=$(id -u)" >> .env
|
||||
echo "GID=$(id -g)" >> .env
|
||||
# hermes-agent / hermes-dashboard
|
||||
echo "HERMES_UID=$(id -u)" >> .env
|
||||
echo "HERMES_GID=$(id -g)" >> .env
|
||||
```
|
||||
|
||||
For NAS/Unraid deployments where a fixed service account is preferred, use
|
||||
`10000:10000` (or your NAS service UID) instead of `$(id -u)`.
|
||||
|
||||
If you get `PermissionError` on an **existing** `~/.hermes` directory, run
|
||||
the one-time ownership fix:
|
||||
|
||||
```bash
|
||||
chown -R $(id -u):$(id -g) ~/.hermes
|
||||
```
|
||||
|
||||
#### Volume mount mode
|
||||
|
||||
The dashboard container needs **read-write** access to the shared volume
|
||||
(it writes session logs and dashboard state). Do **not** add `:ro` to the
|
||||
`hermes-home` volume in `hermes-dashboard`'s `volumes:` entry.
|
||||
|
||||
See `docker-compose.three-container.yml` for the full reference configuration.
|
||||
|
||||
---
|
||||
|
||||
## What start.sh discovers automatically
|
||||
@@ -222,6 +298,7 @@ If discovery finds everything, nothing else is required.
|
||||
export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
|
||||
export HERMES_WEBUI_PYTHON=/path/to/python
|
||||
export HERMES_WEBUI_PORT=9000
|
||||
export HERMES_WEBUI_AUTO_INSTALL=1 # enable auto-install of agent deps (disabled by default)
|
||||
./start.sh
|
||||
```
|
||||
|
||||
@@ -339,7 +416,7 @@ Or using the agent venv explicitly:
|
||||
```
|
||||
|
||||
Tests run against an isolated server on port 8788 with a separate state directory.
|
||||
Production data and real cron jobs are never touched. Current count: **961 tests**
|
||||
Production data and real cron jobs are never touched. Current count: **1898 tests**
|
||||
across 53 test files.
|
||||
|
||||
---
|
||||
@@ -436,7 +513,7 @@ across 53 test files.
|
||||
|
||||
### Slash commands
|
||||
- Type `/` in the composer for autocomplete dropdown
|
||||
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`, `/compact`
|
||||
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
|
||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||
- Unrecognized commands pass through to the agent
|
||||
|
||||
|
||||
15
ROADMAP.md
15
ROADMAP.md
@@ -3,9 +3,8 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.50.44 (April 16, 2026) — 1353 tests collected
|
||||
> Local delta: enabling password from Settings keeps the current browser signed in; the former Assistant Reply Language enhancement has been removed; workspace panel closed-state now preloads in `<head>` so desktop first paint no longer flashes open before boot sync; thinking cards and tool call cards now animate both their carets and disclosure bodies smoothly on expand/collapse, and thinking cards now use the same bordered rounded panel chrome as tool cards with a gold palette.
|
||||
> Tests: 1353 collected (`pytest tests/ --collect-only -q`)
|
||||
> Last updated: v0.50.185 (April 24, 2026) — 2107 tests collected
|
||||
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -77,6 +76,16 @@
|
||||
| v0.50.37–v0.50.40 | Sprint 40 — rendering fixes + KaTeX CSP + MEDIA images | Think-tag edge cases, renderMd link double-linking fix, MEDIA: inline image rendering, KaTeX CSP font-src fix | 1117 |
|
||||
| v0.50.41–v0.50.43 | Sprint 41/42 — context ring, session polish, renderMd hardening | Context indicator live usage, session display fixes, renderMd bold+code stash, outer link pass ordering, _ob_stash, autolink double-link fixes (@multiple contributors) | 1150 |
|
||||
| v0.50.44 | Renderer formatting bug fixes (#486, #487) | CSS: inline code sizing in table cells; JS: markdown image syntax  → <img> in renderMd + inlineMd; _img_stash for autolink protection | 1195 |
|
||||
| v0.50.45–v0.50.100 | Upstream sync + contributor sprint | Sidebar declutter, SKIP_ONBOARDING, runtime route details, subpath mount, bug batch (light theme/panel/model cache/Docker), Docker UID/GID auto-detect, chat transcript redesign, favicon SVG+PNG+ICO, Docker UID-mismatch crash fix, auto-title markdown strip | 1777 |
|
||||
| v0.50.101–v0.50.139 | Contributor sprint wave | Custom providers, Russian locale, collapsed timestamps, IME composition fixes, model-switch toast, approval queue multi-slot, live model fetching SSRF guard, orphaned tool-message sanitization, profile polish sprint (model routing, workspace cross-profile, legacy session backfill), font-size CSS fix | 1777 |
|
||||
| v0.50.140–v0.50.147 | Bug batch + appearance | Font size setting visibly scales UI text (#843), slash command echoed as user message (#840), scroll selected item into view (#838), tasks refresh button (#835), font size toggle (#833), stale model fix (#829), session search clear on boot (#822), gateway SSE polling fallback (#635) | 1858 |
|
||||
| v0.50.148–v0.50.150 | Session index + read-path + profile | Prune stale _index.json ghost rows after session-id rotation (#847 @franksong2702), GET /api/session side-effect-free model resolution (#848 @franksong2702), profile switching cookie persist + syncTopbar fix (#849 @migueltavares) | 1858 |
|
||||
| v0.50.151 | credential_pool + Ollama Cloud | Providers added via auth store credential_pool now visible in model dropdown; Ollama Cloud support; ambient gh-cli token suppression; _apply_provider_prefix helper (#820 @starship-s) | 1898 |
|
||||
| v0.50.152 | Image rendering + auto-title | image_generate MEDIA: token renders all https:// URLs as img regardless of extension (closes #853); auto-title strips Qwen3-style plain-text thinking preambles (closes #857) | 1898 |
|
||||
| v0.50.153 | Portal model routing | Live-fetched models from portal providers (Nous, OpenCode) now get @provider: prefix so they route correctly instead of falling through to OpenRouter (closes #854) | 1898 |
|
||||
| v0.50.154 | Thinking card mirror fix | _streamDisplay() early return removed — thinking card and main response now show distinct content when provider double-emits (closes #852) | 1898 |
|
||||
| v0.50.155 | Honcho session stability | gateway_session_key=session_id passed to AIAgent so Honcho per-session strategy maintains one Honcho session per WebUI chat instead of one per turn (closes #855) | 1903 |
|
||||
| v0.50.156 | Auto-install security gate | auto_install_agent_deps() is now opt-in; set HERMES_WEBUI_AUTO_INSTALL=1 to enable; _trusted_agent_dir() checks ownership/permission bits before running pip (⚠️ breaking: default changed) | 1903 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.50.21 | 961 tests | Full daily driver — CLI parity achieved
|
||||
> Current state: v0.50.156 | 1903 tests | Full daily driver — CLI parity achieved
|
||||
>
|
||||
> NOTE: Most planned work in this document has now shipped. This file is preserved
|
||||
> as a historical planning record. Current sprint state and version history live
|
||||
> in CHANGELOG.md and ROADMAP.md.
|
||||
> NOTE: This file is preserved as a historical planning record. Current sprint state
|
||||
> and version history live in CHANGELOG.md and ROADMAP.md.
|
||||
>
|
||||
> Target A (CLI parity): ✅ Complete — all core tools, workspace, cron, skills,
|
||||
> memory, sessions, profiles, model routing, streaming, voice, mobile.
|
||||
|
||||
39
TESTING.md
39
TESTING.md
@@ -8,7 +8,7 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated coverage: 1353 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Automated coverage: 2107 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
>
|
||||
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
|
||||
@@ -1749,8 +1749,41 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.50.44, April 16, 2026*
|
||||
*Total automated tests collected: 1353*
|
||||
## Slash command parity (manual checklist)
|
||||
|
||||
For each batch-1 command, run via webui slash menu AND via `hermes` CLI in the
|
||||
same `HERMES_HOME` (when applicable) and verify identical effect.
|
||||
|
||||
- [ ] `/help` — dropdown lists 25+ commands; selecting `/help` posts an assistant message listing them.
|
||||
- [ ] `/new` (and alias `/reset`) — starts fresh session.
|
||||
- [ ] `/clear` — clears current transcript display (webui-only meaning, distinct from CLI's "clear screen").
|
||||
- [ ] `/title <name>` — renames active session, topbar + sidebar update; `/title` alone shows current title.
|
||||
- [ ] `/status` — assistant message shows session_id, model, workspace, message count.
|
||||
- [ ] `/usage` — assistant message shows token counts; the "show token usage" setting is unchanged (toggle still in Settings panel).
|
||||
- [ ] `/stop` — interrupts a running stream; with no active stream toasts "No active task to stop."
|
||||
- [ ] `/retry` — removes last user+assistant exchange, refills composer with last user text, resends. Final transcript has only ONE copy of the resent message.
|
||||
- [ ] `/undo` — removes last user+assistant exchange; toast confirms; repeated until empty toasts "Nothing to undo."
|
||||
- [ ] `/model <name>` — switches model dropdown.
|
||||
- [ ] `/personality` — lists personalities; `/personality <name>` switches.
|
||||
- [ ] `/skills [query]` — lists matching skills.
|
||||
- [ ] `/theme <name>` — switches webui theme.
|
||||
- [ ] `/workspace <name>` — switches workspace.
|
||||
|
||||
Unknown / deferred:
|
||||
|
||||
- [ ] `/yolo`, `/reasoning`, `/voice`, `/branch`, `/insights`, `/debug`, `/reload`, etc. — toast "Web UI 暂未实现该命令: /<name>". MUST NOT be sent as plain text to the LLM.
|
||||
- [ ] `/compact` — toast "/compress is not available in the web UI yet — use the CLI for now." (was sending free text to LLM before this batch.)
|
||||
- [ ] Made-up command (e.g. `/fhfajl`) — fall through to send as text (existing behavior preserved for typos vs. real commands).
|
||||
|
||||
Bridged CLI sessions:
|
||||
|
||||
- [ ] Open a CLI-bridged session in webui sidebar (if `show_cli_sessions` setting enabled).
|
||||
- [ ] `/retry`, `/undo` toast "该命令仅支持 Web UI 原生会话…" and do nothing.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.50.91, April 19, 2026*
|
||||
*Total automated tests collected: 2107*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
72
api/auth.py
72
api/auth.py
@@ -6,9 +6,11 @@ or configuring a password in the Settings panel.
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.cookies
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from api.config import STATE_DIR, load_settings
|
||||
@@ -24,8 +26,54 @@ PUBLIC_PATHS = frozenset({
|
||||
COOKIE_NAME = 'hermes_session'
|
||||
SESSION_TTL = 86400 # 24 hours
|
||||
|
||||
# Active sessions: token -> expiry timestamp
|
||||
_sessions = {}
|
||||
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
|
||||
|
||||
|
||||
def _load_sessions() -> dict[str, float]:
|
||||
"""Load persisted sessions from STATE_DIR, pruning expired entries.
|
||||
|
||||
Returns an empty dict on any read or parse error so startup is never
|
||||
blocked by a corrupt or missing sessions file.
|
||||
"""
|
||||
try:
|
||||
if _SESSIONS_FILE.exists():
|
||||
data = json.loads(_SESSIONS_FILE.read_text(encoding='utf-8'))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('malformed sessions file — expected dict')
|
||||
now = time.time()
|
||||
return {t: exp for t, exp in data.items()
|
||||
if isinstance(t, str) and isinstance(exp, (int, float)) and exp > now}
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load sessions file, starting fresh: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_sessions(sessions: dict[str, float]) -> None:
|
||||
"""Atomically persist sessions to STATE_DIR/.sessions.json (0600).
|
||||
|
||||
Uses a temp file + os.replace() so a crash mid-write never leaves a
|
||||
truncated file. Mirrors the same pattern as .signing_key persistence.
|
||||
"""
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=STATE_DIR, suffix='.sessions.tmp')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(sessions, f)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, _SESSIONS_FILE)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Failed to persist sessions: %s", e)
|
||||
|
||||
|
||||
# Active sessions: token -> expiry timestamp (persisted across restarts via STATE_DIR)
|
||||
_sessions = _load_sessions()
|
||||
|
||||
# ── Login rate limiter ──────────────────────────────────────────────────────
|
||||
_login_attempts = {} # ip -> [timestamp, ...]
|
||||
@@ -51,13 +99,13 @@ def _record_login_attempt(ip: str) -> None:
|
||||
def _signing_key():
|
||||
"""Return a random signing key, generating and persisting one on first call."""
|
||||
key_file = STATE_DIR / '.signing_key'
|
||||
if key_file.exists():
|
||||
try:
|
||||
try:
|
||||
if key_file.exists():
|
||||
raw = key_file.read_bytes()
|
||||
if len(raw) >= 32:
|
||||
return raw[:32]
|
||||
except Exception:
|
||||
logger.debug("Failed to read signing key from file, generating new key")
|
||||
except Exception:
|
||||
logger.debug("Failed to read or access signing key file, using in-memory key")
|
||||
# Generate a new random key
|
||||
key = secrets.token_bytes(32)
|
||||
try:
|
||||
@@ -107,6 +155,7 @@ def create_session() -> str:
|
||||
"""Create a new auth session. Returns signed cookie value."""
|
||||
token = secrets.token_hex(32)
|
||||
_sessions[token] = time.time() + SESSION_TTL
|
||||
_save_sessions(_sessions)
|
||||
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
|
||||
return f"{token}.{sig}"
|
||||
|
||||
@@ -114,8 +163,11 @@ def create_session() -> str:
|
||||
def _prune_expired_sessions():
|
||||
"""Remove all expired session entries to prevent unbounded memory growth."""
|
||||
now = time.time()
|
||||
for token in [t for t, exp in _sessions.items() if now > exp]:
|
||||
_sessions.pop(token, None)
|
||||
expired = [t for t, exp in _sessions.items() if now > exp]
|
||||
if expired:
|
||||
for token in expired:
|
||||
_sessions.pop(token, None)
|
||||
_save_sessions(_sessions)
|
||||
|
||||
|
||||
def verify_session(cookie_value) -> bool:
|
||||
@@ -138,7 +190,9 @@ def invalidate_session(cookie_value) -> None:
|
||||
"""Remove a session token."""
|
||||
if cookie_value and '.' in cookie_value:
|
||||
token = cookie_value.rsplit('.', 1)[0]
|
||||
_sessions.pop(token, None)
|
||||
if token in _sessions:
|
||||
_sessions.pop(token, None)
|
||||
_save_sessions(_sessions)
|
||||
|
||||
|
||||
def parse_cookie(handler) -> str | None:
|
||||
|
||||
87
api/background.py
Normal file
87
api/background.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Background and ephemeral task tracking for /background and /btw commands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
# parent_session_id -> list of task dicts
|
||||
_BACKGROUND_TASKS: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
# btw ephemeral session tracking: parent_sid -> {ephemeral_sid, stream_id, question}
|
||||
_BTW_TRACKING: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def track_background(parent_sid: str, bg_sid: str, stream_id: str,
|
||||
task_id: str, prompt: str) -> None:
|
||||
with _lock:
|
||||
_BACKGROUND_TASKS.setdefault(parent_sid, []).append({
|
||||
"task_id": task_id,
|
||||
"bg_session_id": bg_sid,
|
||||
"stream_id": stream_id,
|
||||
"prompt": prompt,
|
||||
"status": "running",
|
||||
"started_at": time.time(),
|
||||
"answer": None,
|
||||
"completed_at": None,
|
||||
})
|
||||
|
||||
|
||||
def track_btw(parent_sid: str, ephemeral_sid: str, stream_id: str,
|
||||
question: str) -> None:
|
||||
with _lock:
|
||||
_BTW_TRACKING[parent_sid] = {
|
||||
"ephemeral_session_id": ephemeral_sid,
|
||||
"stream_id": stream_id,
|
||||
"question": question,
|
||||
}
|
||||
|
||||
|
||||
def complete_background(parent_sid: str, task_id: str, answer: str) -> None:
|
||||
with _lock:
|
||||
for t in _BACKGROUND_TASKS.get(parent_sid, []):
|
||||
if t["task_id"] == task_id and t["status"] == "running":
|
||||
t["status"] = "done"
|
||||
t["answer"] = answer
|
||||
t["completed_at"] = time.time()
|
||||
break
|
||||
|
||||
|
||||
def get_results(parent_sid: str) -> list[dict[str, Any]]:
|
||||
"""Return completed background task results and remove only the done ones
|
||||
from tracking. Tasks still in ``status="running"`` MUST stay in the list
|
||||
so that ``complete_background()`` can still find them when the worker
|
||||
thread finishes — otherwise the first poll during a long-running task
|
||||
silently drops it and the result is lost forever.
|
||||
"""
|
||||
with _lock:
|
||||
tasks = _BACKGROUND_TASKS.get(parent_sid, [])
|
||||
done = [t for t in tasks if t["status"] == "done"]
|
||||
still_running = [t for t in tasks if t["status"] != "done"]
|
||||
if still_running:
|
||||
_BACKGROUND_TASKS[parent_sid] = still_running
|
||||
else:
|
||||
_BACKGROUND_TASKS.pop(parent_sid, None)
|
||||
return [{
|
||||
"task_id": t["task_id"],
|
||||
"prompt": t["prompt"],
|
||||
"answer": t["answer"],
|
||||
"completed_at": t["completed_at"],
|
||||
} for t in done]
|
||||
|
||||
|
||||
def get_background_tasks(parent_sid: str) -> list[dict[str, Any]]:
|
||||
"""Return all background tasks (running and done) for a parent session."""
|
||||
with _lock:
|
||||
return list(_BACKGROUND_TASKS.get(parent_sid, []))
|
||||
|
||||
|
||||
def cleanup_btw(parent_sid: str) -> dict[str, Any] | None:
|
||||
"""Remove and return btw tracking for a parent session."""
|
||||
with _lock:
|
||||
return _BTW_TRACKING.pop(parent_sid, None)
|
||||
56
api/commands.py
Normal file
56
api/commands.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Expose hermes-agent's COMMAND_REGISTRY to the webui frontend.
|
||||
|
||||
This module is the single integration point with hermes_cli.commands.
|
||||
If hermes-agent is unavailable the endpoint degrades to an empty list
|
||||
so the frontend can still load with WEBUI_ONLY commands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Commands that are gateway_only in the agent registry -- webui never
|
||||
# wants to expose them (sethome, restart, update etc.) even if a future
|
||||
# agent version drops the gateway_only flag. /commands is the agent's
|
||||
# own command-listing command; webui has its own /help that calls
|
||||
# cmdHelp() locally, so /commands would be redundant and confusing.
|
||||
_NEVER_EXPOSE: frozenset[str] = frozenset({
|
||||
'sethome', 'restart', 'update', 'commands',
|
||||
})
|
||||
|
||||
|
||||
def list_commands(_registry=None) -> list[dict[str, Any]]:
|
||||
"""Return COMMAND_REGISTRY entries as JSON-friendly dicts.
|
||||
|
||||
Returns empty list if hermes_cli is not installed (graceful
|
||||
degradation -- the frontend has its own fallback minimum set).
|
||||
|
||||
Args:
|
||||
_registry: Optional injected registry for testing. When None
|
||||
(production), imports COMMAND_REGISTRY from hermes_cli.
|
||||
"""
|
||||
if _registry is None:
|
||||
try:
|
||||
from hermes_cli.commands import COMMAND_REGISTRY as _registry
|
||||
except ImportError:
|
||||
logger.warning("hermes_cli.commands not importable -- /api/commands returns []")
|
||||
return []
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for cmd in _registry:
|
||||
if cmd.gateway_only:
|
||||
continue
|
||||
if cmd.name in _NEVER_EXPOSE:
|
||||
continue
|
||||
out.append({
|
||||
'name': cmd.name,
|
||||
'description': cmd.description,
|
||||
'category': cmd.category,
|
||||
'aliases': list(cmd.aliases),
|
||||
'args_hint': cmd.args_hint,
|
||||
'subcommands': list(cmd.subcommands),
|
||||
'cli_only': bool(cmd.cli_only),
|
||||
'gateway_only': bool(cmd.gateway_only),
|
||||
})
|
||||
return out
|
||||
847
api/config.py
847
api/config.py
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,19 @@ class GatewayWatcher:
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name='gateway-watcher')
|
||||
self._thread.start()
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Return True when the poll thread is running.
|
||||
|
||||
Public accessor used by ``/api/sessions/gateway/stream`` probe mode and
|
||||
the live SSE handler to detect a watcher instance whose poll thread
|
||||
died silently (e.g. uncaught exception in ``_poll_loop``). Callers
|
||||
use this to decide whether to return 503 and trigger the client-side
|
||||
polling fallback, instead of handing out an SSE connection that would
|
||||
never emit events.
|
||||
"""
|
||||
t = self._thread
|
||||
return t is not None and t.is_alive()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the watcher thread."""
|
||||
self._stop_event.set()
|
||||
|
||||
@@ -45,7 +45,8 @@ def _security_headers(handler):
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"img-src 'self' data:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; "
|
||||
"img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; "
|
||||
"manifest-src 'self'; "
|
||||
"base-uri 'self'; form-action 'self'"
|
||||
)
|
||||
handler.send_header(
|
||||
@@ -54,14 +55,39 @@ def _security_headers(handler):
|
||||
)
|
||||
|
||||
|
||||
def j(handler, payload, status: int=200) -> None:
|
||||
"""Send a JSON response."""
|
||||
def _accepts_gzip(handler) -> bool:
|
||||
"""Check if the client accepts gzip encoding."""
|
||||
headers = getattr(handler, 'headers', None)
|
||||
if not headers:
|
||||
return False
|
||||
ae = headers.get('Accept-Encoding', '')
|
||||
return 'gzip' in ae
|
||||
|
||||
|
||||
def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
|
||||
"""Send a JSON response.
|
||||
|
||||
*extra_headers*: optional dict of additional headers to include
|
||||
(e.g., {'Set-Cookie': '...'}). Headers are sent before end_headers().
|
||||
"""
|
||||
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
|
||||
handler.send_response(status)
|
||||
handler.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
|
||||
# Gzip-compress responses over 1KB when the client accepts it.
|
||||
# Typical JSON API responses compress 70-80%, giving a big speedup
|
||||
# for large payloads (session history, message lists).
|
||||
if _accepts_gzip(handler) and len(body) > 1024:
|
||||
import gzip
|
||||
body = gzip.compress(body, compresslevel=4)
|
||||
handler.send_header('Content-Encoding', 'gzip')
|
||||
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
if extra_headers:
|
||||
for k, v in extra_headers.items():
|
||||
handler.send_header(k, v)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
@@ -173,3 +199,50 @@ def read_body(handler) -> dict:
|
||||
return _json.loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ── Profile cookie helpers (issue #798) ─────────────────────────────────────
|
||||
|
||||
PROFILE_COOKIE_NAME = 'hermes_profile'
|
||||
|
||||
|
||||
def get_profile_cookie(handler) -> str | None:
|
||||
"""Extract the hermes_profile cookie value from the request, or None."""
|
||||
cookie_header = handler.headers.get('Cookie', '')
|
||||
if not cookie_header:
|
||||
return None
|
||||
import http.cookies as _hc
|
||||
cookie = _hc.SimpleCookie()
|
||||
try:
|
||||
cookie.load(cookie_header)
|
||||
except _hc.CookieError:
|
||||
return None
|
||||
morsel = cookie.get(PROFILE_COOKIE_NAME)
|
||||
if morsel and morsel.value:
|
||||
# Validate against profile-name pattern before trusting
|
||||
from api.profiles import _PROFILE_ID_RE
|
||||
val = morsel.value
|
||||
if val == 'default' or _PROFILE_ID_RE.fullmatch(val):
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def build_profile_cookie(name: str) -> str:
|
||||
"""Build a Set-Cookie header value for the hermes_profile cookie.
|
||||
|
||||
Always persist the selected profile in the cookie, including 'default'.
|
||||
Clearing the cookie causes the backend to fall back to process-global
|
||||
_active_profile, which can unexpectedly switch clients back to another
|
||||
profile.
|
||||
|
||||
Set HttpOnly because the UI reads the active profile from
|
||||
/api/profile/active JSON and does not need to access this cookie via
|
||||
document.cookie.
|
||||
"""
|
||||
import http.cookies as _hc
|
||||
cookie = _hc.SimpleCookie()
|
||||
cookie[PROFILE_COOKIE_NAME] = name
|
||||
cookie[PROFILE_COOKIE_NAME]['path'] = '/'
|
||||
cookie[PROFILE_COOKIE_NAME]['httponly'] = True
|
||||
cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax'
|
||||
return cookie[PROFILE_COOKIE_NAME].OutputString()
|
||||
|
||||
385
api/models.py
385
api/models.py
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
Hermes Web UI -- Session model and in-memory session store.
|
||||
"""
|
||||
"""Hermes Web UI -- Session model and in-memory session store."""
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -11,29 +11,187 @@ from pathlib import Path
|
||||
import api.config as _cfg
|
||||
from api.config import (
|
||||
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
|
||||
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME
|
||||
LOCK, STREAMS, STREAMS_LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME,
|
||||
get_effective_default_model,
|
||||
)
|
||||
from api.workspace import get_last_workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stale temp-file cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
# Both Session.save() and _write_session_index() use the atomic-write pattern:
|
||||
# write to <path>.tmp.<pid>.<tid> → os.replace() to final path
|
||||
# If the process crashes between write and replace the .tmp file is left
|
||||
# behind. Because the name embeds pid + tid, leftover files can never be
|
||||
# reused by a different process/thread, so they are safe to remove on the
|
||||
# next startup. _cleanup_stale_tmp_files() is called from the full-rebuild
|
||||
# path of _write_session_index (i.e. at first index access / startup) and
|
||||
# removes any *.tmp.* file whose mtime is older than one hour.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _write_session_index():
|
||||
"""Rebuild the session index file for O(1) future reads."""
|
||||
entries = []
|
||||
for p in SESSION_DIR.glob('*.json'):
|
||||
if p.name.startswith('_'): continue
|
||||
_STALE_TMP_AGE_SECONDS = 3600 # 1 hour
|
||||
|
||||
# Serializes index writers so concurrent Session.save() calls cannot race on
|
||||
# stale baselines while still allowing LOCK to be released before disk I/O.
|
||||
_INDEX_WRITE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _cleanup_stale_tmp_files() -> None:
|
||||
"""Best-effort removal of stale ``*.tmp.*`` files from SESSION_DIR.
|
||||
|
||||
Only files whose mtime is older than ``_STALE_TMP_AGE_SECONDS`` are
|
||||
removed so that in-flight writes from a long-running sibling process
|
||||
are not disturbed. Errors are logged and swallowed — this must never
|
||||
prevent startup.
|
||||
"""
|
||||
cutoff = time.time() - _STALE_TMP_AGE_SECONDS
|
||||
try:
|
||||
for p in SESSION_DIR.glob('*.tmp.*'):
|
||||
try:
|
||||
if p.stat().st_mtime < cutoff:
|
||||
p.unlink(missing_ok=True)
|
||||
logger.debug("Cleaned up stale tmp file: %s", p.name)
|
||||
except OSError:
|
||||
pass # best-effort
|
||||
except Exception:
|
||||
pass # SESSION_DIR may not exist yet; that's fine
|
||||
|
||||
|
||||
def _index_entry_exists(session_id: str, in_memory_ids=None) -> bool:
|
||||
"""Return True if an index entry still has backing state.
|
||||
|
||||
A session can legitimately exist either as a persisted JSON file or as an
|
||||
in-memory Session object that has not been flushed yet. This helper is used
|
||||
to prune stale `_index.json` rows left behind after session-id rotation or
|
||||
file removal.
|
||||
"""
|
||||
if not session_id:
|
||||
return False
|
||||
if in_memory_ids is None:
|
||||
with LOCK:
|
||||
in_memory_ids = set(SESSIONS.keys())
|
||||
if session_id in in_memory_ids:
|
||||
return True
|
||||
p = SESSION_DIR / f'{session_id}.json'
|
||||
return p.exists()
|
||||
|
||||
|
||||
def _write_session_index(updates=None):
|
||||
"""Update the session index file.
|
||||
|
||||
When *updates* is provided (a list of Session objects whose compact
|
||||
entries should be refreshed), this does a targeted in-place update of
|
||||
the existing index — O(1) for single-session changes. When *updates*
|
||||
is None, a full rebuild is performed (used on startup / first call).
|
||||
|
||||
LOCK protects in-memory state snapshots and payload construction only;
|
||||
disk I/O (write/flush/fsync/replace) always runs outside LOCK.
|
||||
"""
|
||||
_tmp = SESSION_INDEX_FILE.with_suffix(f'.tmp.{os.getpid()}.{threading.current_thread().ident}')
|
||||
|
||||
with _INDEX_WRITE_LOCK:
|
||||
# Lazy full-rebuild path — used when index doesn't exist yet.
|
||||
if updates is None or not SESSION_INDEX_FILE.exists():
|
||||
_cleanup_stale_tmp_files() # best-effort sweep on startup / first call
|
||||
entries = []
|
||||
for p in SESSION_DIR.glob('*.json'):
|
||||
if p.name.startswith('_'):
|
||||
continue
|
||||
try:
|
||||
s = Session.load(p.stem)
|
||||
if s:
|
||||
entries.append(s.compact())
|
||||
except Exception:
|
||||
logger.debug("Failed to load session from %s", p)
|
||||
|
||||
with LOCK:
|
||||
existing_ids = {e.get('session_id') for e in entries}
|
||||
for s in SESSIONS.values():
|
||||
if s.session_id not in existing_ids:
|
||||
entries.append(s.compact())
|
||||
entries.sort(key=lambda s: s.get('updated_at', 0), reverse=True)
|
||||
_payload = json.dumps(entries, ensure_ascii=False, indent=2)
|
||||
|
||||
try:
|
||||
with open(_tmp, 'w', encoding='utf-8') as f:
|
||||
f.write(_payload)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(_tmp, SESSION_INDEX_FILE)
|
||||
except Exception:
|
||||
# Best-effort cleanup of stale tmp on failure
|
||||
try:
|
||||
_tmp.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
return
|
||||
|
||||
# Fast path: patch existing index with updated sessions.
|
||||
# This avoids loading every session file on every single save().
|
||||
_fallback = False
|
||||
try:
|
||||
s = Session.load(p.stem)
|
||||
if s: entries.append(s.compact())
|
||||
with LOCK:
|
||||
existing = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
in_memory_ids = set(SESSIONS.keys())
|
||||
|
||||
# Avoid N filesystem exists() checks under LOCK by collecting
|
||||
# on-disk IDs once.
|
||||
on_disk_ids = {
|
||||
p.stem
|
||||
for p in SESSION_DIR.glob('*.json')
|
||||
if not p.name.startswith('_')
|
||||
}
|
||||
|
||||
existing = [
|
||||
e for e in existing
|
||||
if (e.get('session_id') in in_memory_ids or e.get('session_id') in on_disk_ids)
|
||||
]
|
||||
|
||||
# Build lookup of updated entries
|
||||
updated_map = {s.session_id: s.compact() for s in updates}
|
||||
existing_ids = {e.get('session_id') for e in existing}
|
||||
# Add any updated entries not yet in the index
|
||||
for sid, entry in updated_map.items():
|
||||
if sid not in existing_ids:
|
||||
existing.append(entry)
|
||||
# Replace matching entries in-place
|
||||
for i, e in enumerate(existing):
|
||||
sid = e.get('session_id')
|
||||
if sid in updated_map:
|
||||
existing[i] = updated_map[sid]
|
||||
existing.sort(key=lambda s: s.get('updated_at', 0), reverse=True)
|
||||
_payload = json.dumps(existing, ensure_ascii=False, indent=2)
|
||||
|
||||
try:
|
||||
with open(_tmp, 'w', encoding='utf-8') as f:
|
||||
f.write(_payload)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(_tmp, SESSION_INDEX_FILE)
|
||||
except Exception:
|
||||
try:
|
||||
_tmp.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception:
|
||||
logger.debug("Failed to load session from %s", p)
|
||||
with LOCK:
|
||||
for s in SESSIONS.values():
|
||||
if not any(e['session_id'] == s.session_id for e in entries):
|
||||
entries.append(s.compact())
|
||||
entries.sort(key=lambda s: s['updated_at'], reverse=True)
|
||||
SESSION_INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
_fallback = True
|
||||
|
||||
if _fallback:
|
||||
# Corrupt or missing index — fall back to full rebuild (called outside LOCK to avoid deadlock)
|
||||
_write_session_index(updates=None)
|
||||
|
||||
|
||||
def _active_stream_ids():
|
||||
with STREAMS_LOCK:
|
||||
return set(STREAMS.keys())
|
||||
|
||||
|
||||
def _is_streaming_session(active_stream_id, active_stream_ids):
|
||||
return bool(active_stream_id and active_stream_id in active_stream_ids)
|
||||
|
||||
|
||||
class Session:
|
||||
@@ -48,6 +206,8 @@ class Session:
|
||||
pending_user_message: str=None,
|
||||
pending_attachments=None,
|
||||
pending_started_at=None,
|
||||
compression_anchor_visible_idx=None,
|
||||
compression_anchor_message_key=None,
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
self.title = title
|
||||
@@ -69,19 +229,49 @@ class Session:
|
||||
self.pending_user_message = pending_user_message
|
||||
self.pending_attachments = pending_attachments or []
|
||||
self.pending_started_at = pending_started_at
|
||||
self.compression_anchor_visible_idx = compression_anchor_visible_idx
|
||||
self.compression_anchor_message_key = compression_anchor_message_key
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return SESSION_DIR / f'{self.session_id}.json'
|
||||
|
||||
def save(self, touch_updated_at: bool = True) -> None:
|
||||
def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
|
||||
if touch_updated_at:
|
||||
self.updated_at = time.time()
|
||||
self.path.write_text(
|
||||
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
_write_session_index()
|
||||
# Write metadata fields first so load_metadata_only() can read them
|
||||
# without parsing the full messages array (which may be 400KB+).
|
||||
# Fields are listed in the order they should appear in the JSON file.
|
||||
METADATA_FIELDS = [
|
||||
'session_id', 'title', 'workspace', 'model', 'created_at', 'updated_at',
|
||||
'pinned', 'archived', 'project_id', 'profile',
|
||||
'input_tokens', 'output_tokens', 'estimated_cost',
|
||||
'personality', 'active_stream_id',
|
||||
'pending_user_message', 'pending_attachments', 'pending_started_at',
|
||||
'compression_anchor_visible_idx', 'compression_anchor_message_key',
|
||||
]
|
||||
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
|
||||
meta['messages'] = self.messages
|
||||
meta['tool_calls'] = self.tool_calls
|
||||
# Fields not in METADATA_FIELDS (e.g. last_usage, message_count) go at the end
|
||||
extra = {k: v for k, v in self.__dict__.items()
|
||||
if k not in METADATA_FIELDS and k not in ('messages', 'tool_calls')}
|
||||
payload = json.dumps({**meta, **extra}, ensure_ascii=False, indent=2)
|
||||
tmp = self.path.with_suffix(f'.tmp.{os.getpid()}.{threading.current_thread().ident}')
|
||||
try:
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
f.write(payload)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, self.path)
|
||||
except Exception:
|
||||
try:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
if not skip_index:
|
||||
_write_session_index(updates=[self])
|
||||
|
||||
@classmethod
|
||||
def load(cls, sid):
|
||||
@@ -93,7 +283,48 @@ class Session:
|
||||
return None
|
||||
return cls(**json.loads(p.read_text(encoding='utf-8')))
|
||||
|
||||
def compact(self) -> dict:
|
||||
@classmethod
|
||||
def load_metadata_only(cls, sid):
|
||||
"""Load only the compact metadata fields, skipping the messages array.
|
||||
|
||||
Session JSON files have metadata fields (session_id, title, model, etc.)
|
||||
at the top level, before the large messages array. We read only the
|
||||
first ~1KB — enough to capture all compact() fields — then parse just
|
||||
that prefix. Falls back to load() if the prefix doesn't contain enough
|
||||
fields or if the file is unexpectedly small.
|
||||
"""
|
||||
if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
|
||||
return None
|
||||
p = SESSION_DIR / f'{sid}.json'
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
# Read just the first 1 KB — metadata comes before messages array
|
||||
with open(p, 'r', encoding='utf-8') as f:
|
||||
prefix = f.read(1024)
|
||||
if not prefix:
|
||||
return cls.load(sid)
|
||||
parsed = json.loads(prefix)
|
||||
# Verify we got the essential fields.
|
||||
# With metadata-first save() ordering, messages appears at byte ~567.
|
||||
# For sessions <= ~512 bytes total the entire messages array fits in the
|
||||
# first 1 KB and we get a valid list. For larger sessions json.loads
|
||||
# fails on the truncated buffer (unterminated string), so we fall back
|
||||
# to full load. The one exception is a truncation inside a string value
|
||||
# that happens to produce valid JSON with a truncated string — guard
|
||||
# against that by requiring messages to be a list.
|
||||
needed = {'session_id', 'title', 'created_at', 'updated_at'}
|
||||
if not needed.issubset(parsed.keys()):
|
||||
return cls.load(sid)
|
||||
if not isinstance(parsed.get('messages'), list):
|
||||
return cls.load(sid)
|
||||
return cls(**parsed)
|
||||
except Exception:
|
||||
# Corrupt prefix or decode error — fall back to full load
|
||||
return cls.load(sid)
|
||||
|
||||
def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
|
||||
active_stream_ids = active_stream_ids if active_stream_ids is not None else set()
|
||||
return {
|
||||
'session_id': self.session_id,
|
||||
'title': self.title,
|
||||
@@ -110,14 +341,29 @@ class Session:
|
||||
'output_tokens': self.output_tokens,
|
||||
'estimated_cost': self.estimated_cost,
|
||||
'personality': self.personality,
|
||||
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
|
||||
'compression_anchor_message_key': self.compression_anchor_message_key,
|
||||
'active_stream_id': self.active_stream_id,
|
||||
'is_streaming': _is_streaming_session(
|
||||
self.active_stream_id, active_stream_ids
|
||||
) if include_runtime else False,
|
||||
}
|
||||
|
||||
def get_session(sid):
|
||||
def get_session(sid, metadata_only=False):
|
||||
"""Load a session, optionally with metadata only (skipping the messages array).
|
||||
|
||||
When metadata_only=True the session is still cached so the full load on the
|
||||
next access is fast. Use this when you only need compact() metadata and not
|
||||
the actual message history (e.g., for fast sidebar switching).
|
||||
"""
|
||||
with LOCK:
|
||||
if sid in SESSIONS:
|
||||
SESSIONS.move_to_end(sid) # LRU: mark as recently used
|
||||
return SESSIONS[sid]
|
||||
s = Session.load(sid)
|
||||
if metadata_only:
|
||||
s = Session.load_metadata_only(sid)
|
||||
else:
|
||||
s = Session.load(sid)
|
||||
if s:
|
||||
with LOCK:
|
||||
SESSIONS[sid] = s
|
||||
@@ -127,14 +373,28 @@ def get_session(sid):
|
||||
return s
|
||||
raise KeyError(sid)
|
||||
|
||||
def new_session(workspace=None, model=None):
|
||||
# Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
_profile = get_active_profile_name()
|
||||
except ImportError:
|
||||
_profile = None
|
||||
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL, profile=_profile)
|
||||
def new_session(workspace=None, model=None, profile=None):
|
||||
"""Create a new in-memory session and persist it.
|
||||
|
||||
*profile* — when supplied by the caller (e.g. from the request body sent
|
||||
by the active browser tab), it is used directly so that concurrent clients
|
||||
on different profiles don't fight over a shared process-global. If not
|
||||
supplied, we fall back to the process-level active profile (the pre-#798
|
||||
behaviour, preserved for calls that originate outside a request context).
|
||||
"""
|
||||
if profile is None:
|
||||
# Fallback: read process-level global (single-client or startup path)
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
profile = get_active_profile_name()
|
||||
except ImportError:
|
||||
profile = None
|
||||
effective_model = model or get_effective_default_model()
|
||||
s = Session(
|
||||
workspace=workspace or get_last_workspace(),
|
||||
model=effective_model,
|
||||
profile=profile,
|
||||
)
|
||||
with LOCK:
|
||||
SESSIONS[s.session_id] = s
|
||||
SESSIONS.move_to_end(s.session_id)
|
||||
@@ -144,18 +404,37 @@ def new_session(workspace=None, model=None):
|
||||
return s
|
||||
|
||||
def all_sessions():
|
||||
active_stream_ids = _active_stream_ids()
|
||||
# Phase C: try index first for O(1) read; fall back to full scan
|
||||
if SESSION_INDEX_FILE.exists():
|
||||
try:
|
||||
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
index = [
|
||||
s for s in index
|
||||
if _index_entry_exists(s.get('session_id'))
|
||||
]
|
||||
for s in index:
|
||||
s['is_streaming'] = _is_streaming_session(
|
||||
s.get('active_stream_id'),
|
||||
active_stream_ids,
|
||||
)
|
||||
# Overlay any in-memory sessions that may be newer than the index
|
||||
index_map = {s['session_id']: s for s in index}
|
||||
with LOCK:
|
||||
for s in SESSIONS.values():
|
||||
index_map[s.session_id] = s.compact()
|
||||
index_map[s.session_id] = s.compact(
|
||||
include_runtime=True,
|
||||
active_stream_ids=active_stream_ids,
|
||||
)
|
||||
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
|
||||
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
|
||||
result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)]
|
||||
# Exempt sessions younger than 60 s so a brand-new session stays visible (#789)
|
||||
_now = time.time()
|
||||
result = [s for s in result if not (
|
||||
s.get('title', 'Untitled') == 'Untitled'
|
||||
and s.get('message_count', 0) == 0
|
||||
and (_now - s.get('updated_at', _now)) > 60
|
||||
)]
|
||||
# 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:
|
||||
@@ -176,7 +455,12 @@ def all_sessions():
|
||||
for s in SESSIONS.values():
|
||||
if all(s.session_id != x.session_id for x in out): out.append(s)
|
||||
out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True)
|
||||
result = [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
|
||||
_now = time.time()
|
||||
result = [s.compact(include_runtime=True, active_stream_ids=active_stream_ids) for s in out if not (
|
||||
s.title == 'Untitled'
|
||||
and len(s.messages) == 0
|
||||
and (_now - s.updated_at) > 60
|
||||
)]
|
||||
for s in result:
|
||||
if not s.get('profile'):
|
||||
s['profile'] = 'default'
|
||||
@@ -285,6 +569,21 @@ def get_cli_sessions() -> list:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
# Introspect schema to handle older hermes-agent versions that
|
||||
# may not have a 'source' column. Without this check the query raises
|
||||
# OperationalError which is silently swallowed, causing the empty-list bug.
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
_session_cols = {row[1] for row in cur.fetchall()}
|
||||
if 'source' not in _session_cols:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"get_cli_sessions(): state.db at %s has no 'source' column "
|
||||
"(older hermes-agent?). CLI sessions unavailable. "
|
||||
"Upgrade hermes-agent to fix this.",
|
||||
db_path,
|
||||
)
|
||||
return cli_sessions
|
||||
|
||||
cur.execute("""
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
@@ -320,8 +619,14 @@ def get_cli_sessions() -> list:
|
||||
'source_tag': _source,
|
||||
'is_cli_session': True,
|
||||
})
|
||||
except Exception:
|
||||
# DB schema changed, locked, or corrupted -- silently degrade
|
||||
except Exception as _cli_err:
|
||||
# DB schema changed, locked, or corrupted -- log warning so admins can diagnose.
|
||||
# Still degrade gracefully (don't crash the WebUI).
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
|
||||
db_path, _cli_err,
|
||||
)
|
||||
return []
|
||||
|
||||
return cli_sessions
|
||||
|
||||
@@ -230,34 +230,39 @@ def _provider_api_key_present(
|
||||
|
||||
|
||||
|
||||
def _oauth_payload_has_token(payload: dict) -> bool:
|
||||
"""Return True if an auth payload contains usable token material."""
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
|
||||
token_fields = (
|
||||
payload,
|
||||
payload.get("tokens") if isinstance(payload.get("tokens"), dict) else {},
|
||||
)
|
||||
for candidate in token_fields:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
if any(
|
||||
str(candidate.get(key) or "").strip()
|
||||
for key in ("access_token", "refresh_token", "api_key")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
|
||||
"""Return True if the provider has valid OAuth credentials.
|
||||
|
||||
Checks via hermes_cli.auth.get_auth_status() when available, then falls
|
||||
back to reading auth.json directly for the known OAuth provider IDs
|
||||
(openai-codex, copilot, copilot-acp, qwen-oauth, nous).
|
||||
|
||||
This covers users who authenticated via 'hermes auth' or 'hermes model'
|
||||
but whose provider is not in _SUPPORTED_PROVIDER_SETUPS because it does
|
||||
not use a plain API key.
|
||||
Reads the profile-scoped auth.json directly so onboarding respects the
|
||||
requested Hermes home. Known OAuth providers may store auth either in the
|
||||
legacy providers[provider_id] singleton state or in credential_pool entries
|
||||
used by current Hermes runtime auth resolution.
|
||||
"""
|
||||
provider = (provider or "").strip().lower()
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
# Fast path: ask hermes_cli directly — the authoritative source
|
||||
try:
|
||||
from hermes_cli.auth import get_auth_status as _gas
|
||||
|
||||
status = _gas(provider)
|
||||
if isinstance(status, dict) and status.get("logged_in"):
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("Failed to get auth status for provider %s", provider)
|
||||
|
||||
# Fallback: parse auth.json ourselves for known OAuth provider IDs.
|
||||
# Covers deployments where hermes_cli is installed but the import above
|
||||
# fails for an unexpected reason (version mismatch, import cycle, etc.).
|
||||
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
|
||||
if provider not in _known_oauth_providers:
|
||||
return False
|
||||
@@ -269,20 +274,20 @@ def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
|
||||
if not auth_path.exists():
|
||||
return False
|
||||
store = _j.loads(auth_path.read_text(encoding="utf-8"))
|
||||
|
||||
providers_store = store.get("providers")
|
||||
if not isinstance(providers_store, dict):
|
||||
return False
|
||||
state = providers_store.get(provider)
|
||||
if not isinstance(state, dict):
|
||||
return False
|
||||
# Any non-empty token is enough to confirm the user has credentials.
|
||||
# Token refresh happens at runtime inside the agent.
|
||||
has_token = bool(
|
||||
str(state.get("access_token") or "").strip()
|
||||
or str(state.get("api_key") or "").strip()
|
||||
or str(state.get("refresh_token") or "").strip()
|
||||
)
|
||||
return has_token
|
||||
if isinstance(providers_store, dict):
|
||||
state = providers_store.get(provider)
|
||||
if _oauth_payload_has_token(state):
|
||||
return True
|
||||
|
||||
pool_store = store.get("credential_pool")
|
||||
if isinstance(pool_store, dict):
|
||||
entries = pool_store.get(provider)
|
||||
if isinstance(entries, list):
|
||||
return any(_oauth_payload_has_token(entry) for entry in entries)
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -430,6 +435,25 @@ def get_onboarding_status() -> dict:
|
||||
config_exists = Path(_get_config_path()).exists()
|
||||
config_auto_completed = config_exists and bool(runtime.get("chat_ready"))
|
||||
|
||||
# Persist the flag so it survives future transient import failures (e.g. after
|
||||
# a git branch switch in the hermes-agent repo). Without this, a CLI-configured
|
||||
# user who never ran the wizard has no onboarding_completed flag — any momentary
|
||||
# imports_ok=False during restart makes chat_ready=False, config_auto_completed=False,
|
||||
# and the wizard reappears with a broken dropdown that clobbers their config.
|
||||
#
|
||||
# Best-effort: if save_settings raises (read-only FS, disk full, permission error),
|
||||
# log and continue. The `config_auto_completed` branch of `completed=` below still
|
||||
# returns True for this request, so the user sees the correct state — only the
|
||||
# persistence-across-restart guarantee is degraded. Raising here would turn every
|
||||
# /api/onboarding/status call into a 500 until disk was writable, which is worse UX
|
||||
# than losing the next-restart protection.
|
||||
if config_auto_completed and not settings.get("onboarding_completed"):
|
||||
try:
|
||||
save_settings({"onboarding_completed": True})
|
||||
settings["onboarding_completed"] = True
|
||||
except Exception:
|
||||
logger.debug("Failed to persist onboarding_completed", exc_info=True)
|
||||
|
||||
return {
|
||||
"completed": bool(settings.get("onboarding_completed")) or auto_completed or config_auto_completed,
|
||||
"settings": {
|
||||
|
||||
129
api/profiles.py
129
api/profiles.py
@@ -31,6 +31,12 @@ _active_profile = 'default'
|
||||
_profile_lock = threading.Lock()
|
||||
_loaded_profile_env_keys: set[str] = set()
|
||||
|
||||
# Thread-local profile context: set per-request by server.py, cleared after.
|
||||
# Enables per-client profile isolation (issue #798) — each HTTP request thread
|
||||
# reads its own profile from the hermes_profile cookie instead of the
|
||||
# process-global _active_profile.
|
||||
_tls = threading.local()
|
||||
|
||||
def _resolve_base_hermes_home() -> Path:
|
||||
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
|
||||
|
||||
@@ -75,7 +81,7 @@ def _read_active_profile_file() -> str:
|
||||
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
|
||||
if ap_file.exists():
|
||||
try:
|
||||
name = ap_file.read_text().strip()
|
||||
name = ap_file.read_text(encoding="utf-8").strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
@@ -86,15 +92,67 @@ def _read_active_profile_file() -> str:
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_active_profile_name() -> str:
|
||||
"""Return the currently active profile name."""
|
||||
"""Return the currently active profile name.
|
||||
|
||||
Priority:
|
||||
1. Thread-local (set per-request from hermes_profile cookie) — issue #798
|
||||
2. Process-level default (_active_profile)
|
||||
"""
|
||||
tls_name = getattr(_tls, 'profile', None)
|
||||
if tls_name is not None:
|
||||
return tls_name
|
||||
return _active_profile
|
||||
|
||||
|
||||
def set_request_profile(name: str) -> None:
|
||||
"""Set the per-request profile context for this thread.
|
||||
|
||||
Called by server.py at the start of each request when a hermes_profile
|
||||
cookie is present. Always paired with clear_request_profile() in a
|
||||
finally block so the thread-local is released after the request.
|
||||
"""
|
||||
_tls.profile = name
|
||||
|
||||
|
||||
def clear_request_profile() -> None:
|
||||
"""Clear the per-request profile context for this thread.
|
||||
|
||||
Called by server.py in the finally block of do_GET / do_POST.
|
||||
Safe to call even if set_request_profile() was never called.
|
||||
"""
|
||||
_tls.profile = None
|
||||
|
||||
|
||||
def get_active_hermes_home() -> Path:
|
||||
"""Return the HERMES_HOME path for the currently active profile."""
|
||||
if _active_profile == 'default':
|
||||
"""Return the HERMES_HOME path for the currently active profile.
|
||||
|
||||
Uses get_active_profile_name() so per-request TLS context (issue #798)
|
||||
is respected, not just the process-level global.
|
||||
"""
|
||||
name = get_active_profile_name()
|
||||
if name == 'default':
|
||||
return _DEFAULT_HERMES_HOME
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
if profile_dir.is_dir():
|
||||
return profile_dir
|
||||
return _DEFAULT_HERMES_HOME
|
||||
|
||||
|
||||
|
||||
def get_hermes_home_for_profile(name: str) -> Path:
|
||||
"""Return the HERMES_HOME Path for *name* without mutating any process state.
|
||||
|
||||
Safe to call from per-request context (streaming, session creation) because
|
||||
it reads only the filesystem — it never touches os.environ, module-level
|
||||
cached paths, or the process-level _active_profile global.
|
||||
|
||||
Falls back to _DEFAULT_HERMES_HOME (same as 'default') when *name* is None,
|
||||
empty, 'default', or does not match the profile-name format (rejects path
|
||||
traversal such as '../../etc').
|
||||
"""
|
||||
if not name or name == 'default' or not _PROFILE_ID_RE.match(name):
|
||||
return _DEFAULT_HERMES_HOME
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
if profile_dir.is_dir():
|
||||
return profile_dir
|
||||
return _DEFAULT_HERMES_HOME
|
||||
@@ -142,7 +200,7 @@ def _reload_dotenv(home: Path):
|
||||
return
|
||||
try:
|
||||
loaded_keys: set[str] = set()
|
||||
for line in env_path.read_text().splitlines():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
k, v = line.split('=', 1)
|
||||
@@ -170,12 +228,18 @@ def init_profile_state() -> None:
|
||||
_reload_dotenv(home)
|
||||
|
||||
|
||||
def switch_profile(name: str) -> dict:
|
||||
def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||
"""Switch the active profile.
|
||||
|
||||
Validates the profile exists, updates process state, patches module caches,
|
||||
reloads .env, and reloads config.yaml.
|
||||
|
||||
Args:
|
||||
name: Profile name to switch to.
|
||||
process_wide: If True (default), updates the process-global
|
||||
_active_profile. Set to False for per-client switches from the
|
||||
WebUI where the profile is managed via cookie + thread-local (#798).
|
||||
|
||||
Returns: {'profiles': [...], 'active': name}
|
||||
Raises ValueError if profile doesn't exist or agent is busy.
|
||||
"""
|
||||
@@ -201,24 +265,41 @@ def switch_profile(name: str) -> dict:
|
||||
raise ValueError(f"Profile '{name}' does not exist.")
|
||||
|
||||
with _profile_lock:
|
||||
_active_profile = name
|
||||
_set_hermes_home(home)
|
||||
_reload_dotenv(home)
|
||||
if process_wide:
|
||||
global _active_profile
|
||||
_active_profile = name
|
||||
_set_hermes_home(home)
|
||||
_reload_dotenv(home)
|
||||
|
||||
# Write sticky default for CLI consistency
|
||||
try:
|
||||
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
|
||||
ap_file.write_text(name if name != 'default' else '')
|
||||
except Exception:
|
||||
logger.debug("Failed to write active profile file")
|
||||
if process_wide:
|
||||
# Write sticky default for CLI consistency
|
||||
try:
|
||||
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
|
||||
ap_file.write_text(name if name != 'default' else '', encoding='utf-8')
|
||||
except Exception:
|
||||
logger.debug("Failed to write active profile file")
|
||||
|
||||
# Reload config.yaml from the new profile
|
||||
reload_config()
|
||||
# Reload config.yaml from the new profile
|
||||
reload_config()
|
||||
|
||||
# Return profile-specific defaults so frontend can apply them
|
||||
# Return profile-specific defaults so frontend can apply them.
|
||||
# For process_wide=False (per-client switch), read the target profile's
|
||||
# config.yaml directly from disk rather than from _cfg_cache (process-global),
|
||||
# since reload_config() was intentionally skipped.
|
||||
from api.workspace import get_last_workspace
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
if process_wide:
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
else:
|
||||
# Direct disk read — does not touch _cfg_cache
|
||||
try:
|
||||
import yaml as _yaml
|
||||
cfg_path = home / 'config.yaml'
|
||||
cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
model_cfg = cfg.get('model', {})
|
||||
default_model = None
|
||||
if isinstance(model_cfg, str):
|
||||
@@ -243,7 +324,7 @@ def list_profiles_api() -> list:
|
||||
# hermes_cli not available -- return just the default
|
||||
return [_default_profile_dict()]
|
||||
|
||||
active = _active_profile
|
||||
active = get_active_profile_name()
|
||||
result = []
|
||||
for p in infos:
|
||||
result.append({
|
||||
@@ -344,7 +425,7 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key:
|
||||
cfg = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
loaded = _yaml.safe_load(config_path.read_text())
|
||||
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
cfg = loaded
|
||||
except Exception:
|
||||
@@ -357,7 +438,7 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key:
|
||||
if api_key:
|
||||
model_section['api_key'] = api_key
|
||||
cfg['model'] = model_section
|
||||
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True))
|
||||
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
|
||||
|
||||
|
||||
def create_profile_api(name: str, clone_from: str = None,
|
||||
|
||||
331
api/providers.py
Normal file
331
api/providers.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""Hermes Web UI -- provider management endpoints.
|
||||
|
||||
Provides CRUD operations for configuring provider API keys post-onboarding.
|
||||
Closes #586 (allow provider key update) and part of #604 (model picker
|
||||
multi-provider support).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from api.config import (
|
||||
_PROVIDER_DISPLAY,
|
||||
_PROVIDER_MODELS,
|
||||
get_config,
|
||||
invalidate_models_cache,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SECTION: Provider ↔ env var mapping
|
||||
|
||||
# Maps canonical provider slug → env var name for API key.
|
||||
# Providers not listed here (OAuth/token-flow providers like copilot, nous,
|
||||
# openai-codex) cannot have their keys managed from the WebUI.
|
||||
_PROVIDER_ENV_VAR: dict[str, str] = {
|
||||
"openrouter": "OPENROUTER_API_KEY",
|
||||
"anthropic": "ANTHROPIC_API_KEY",
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"google": "GOOGLE_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"zai": "GLM_API_KEY",
|
||||
"kimi-coding": "KIMI_API_KEY",
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
"minimax": "MINIMAX_API_KEY",
|
||||
"mistralai": "MISTRAL_API_KEY",
|
||||
"x-ai": "XAI_API_KEY",
|
||||
"opencode-zen": "OPENCODE_ZEN_API_KEY",
|
||||
"opencode-go": "OPENCODE_GO_API_KEY",
|
||||
"ollama": "OLLAMA_API_KEY",
|
||||
"ollama-cloud": "OLLAMA_API_KEY",
|
||||
}
|
||||
|
||||
# Providers that use OAuth or token flows — their credentials are managed
|
||||
# through the Hermes CLI, not via API keys. The WebUI cannot set these.
|
||||
_OAUTH_PROVIDERS = frozenset({
|
||||
"copilot",
|
||||
"openai-codex",
|
||||
"nous",
|
||||
})
|
||||
|
||||
# SECTION: Helper functions
|
||||
|
||||
|
||||
def _get_hermes_home() -> Path:
|
||||
"""Return the active Hermes home directory."""
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
return get_active_hermes_home()
|
||||
except ImportError:
|
||||
return Path.home() / ".hermes"
|
||||
|
||||
|
||||
def _load_env_file(env_path: Path) -> dict[str, str]:
|
||||
"""Read key=value pairs from a .env file."""
|
||||
values: dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
return values
|
||||
try:
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
except Exception:
|
||||
return {}
|
||||
return values
|
||||
|
||||
|
||||
def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
|
||||
"""Write key=value pairs to the .env file.
|
||||
|
||||
Values of ``None`` cause the key to be removed.
|
||||
Holds ``_ENV_LOCK`` from ``api.streaming`` for the entire load → modify →
|
||||
write cycle to prevent TOCTOU races between concurrent POST /api/providers
|
||||
calls (each reading the same file baseline and overwriting the other's key).
|
||||
Also serialises os.environ mutations with streaming sessions.
|
||||
"""
|
||||
from api.streaming import _ENV_LOCK
|
||||
import stat as _stat
|
||||
|
||||
with _ENV_LOCK:
|
||||
current = _load_env_file(env_path)
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
current.pop(key, None)
|
||||
os.environ.pop(key, None)
|
||||
continue
|
||||
clean = str(value).strip()
|
||||
if not clean:
|
||||
continue
|
||||
# Reject embedded newlines/carriage returns to prevent .env injection
|
||||
if "\n" in clean or "\r" in clean:
|
||||
raise ValueError("API key must not contain newline characters.")
|
||||
current[key] = clean
|
||||
os.environ[key] = clean
|
||||
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"{key}={current[key]}" for key in sorted(current)]
|
||||
# Create at owner-only mode from the first byte (O_CREAT honours the mode
|
||||
# argument subject to umask). A trailing chmod guards pre-existing files.
|
||||
_mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
|
||||
_fd = os.open(str(env_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _mode)
|
||||
with os.fdopen(_fd, "w", encoding="utf-8") as _f:
|
||||
_f.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
try:
|
||||
env_path.chmod(_mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _provider_has_key(provider_id: str) -> bool:
|
||||
"""Check whether a provider has a configured API key.
|
||||
|
||||
Checks (in order):
|
||||
1. ``~/.hermes/.env`` for the known env var
|
||||
2. ``os.environ`` for the known env var
|
||||
3. ``config.yaml → model.api_key``
|
||||
4. ``config.yaml → providers.<id>.api_key``
|
||||
5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
|
||||
"""
|
||||
env_var = _PROVIDER_ENV_VAR.get(provider_id)
|
||||
if env_var:
|
||||
env_path = _get_hermes_home() / ".env"
|
||||
env_values = _load_env_file(env_path)
|
||||
if env_values.get(env_var):
|
||||
return True
|
||||
if os.getenv(env_var):
|
||||
return True
|
||||
|
||||
cfg = get_config()
|
||||
# Check model.api_key
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
||||
return True
|
||||
# Check providers.<id>.api_key
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
if isinstance(providers_cfg, dict):
|
||||
provider_cfg = providers_cfg.get(provider_id, {})
|
||||
if isinstance(provider_cfg, dict) and str(provider_cfg.get("api_key") or "").strip():
|
||||
return True
|
||||
# Check custom_providers
|
||||
custom_providers = cfg.get("custom_providers", [])
|
||||
if isinstance(custom_providers, list):
|
||||
for cp in custom_providers:
|
||||
if isinstance(cp, dict):
|
||||
cp_name = (cp.get("name") or "").strip().lower().replace(" ", "-")
|
||||
if f"custom:{cp_name}" == provider_id or cp.get("name", "").strip().lower() == provider_id:
|
||||
if str(cp.get("api_key") or "").strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _provider_is_oauth(provider_id: str) -> bool:
|
||||
"""Check whether a provider uses OAuth/token flows (managed by CLI)."""
|
||||
return provider_id in _OAUTH_PROVIDERS
|
||||
|
||||
|
||||
# SECTION: Public API
|
||||
|
||||
|
||||
def get_providers() -> dict[str, Any]:
|
||||
"""Return a list of all known providers with their configuration status.
|
||||
|
||||
Each entry contains:
|
||||
- ``id``: canonical provider slug
|
||||
- ``display_name``: human-readable name
|
||||
- ``has_key``: whether an API key is configured
|
||||
- ``configurable``: whether the key can be set from the WebUI
|
||||
- ``key_source``: where the key was found (``env_file``, ``env_var``,
|
||||
``config_yaml``, ``oauth``, ``none``)
|
||||
- ``models``: list of known model IDs for this provider
|
||||
"""
|
||||
providers = []
|
||||
|
||||
# Collect all known provider IDs from multiple sources
|
||||
known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
|
||||
|
||||
# Also detect providers from config.yaml providers section
|
||||
cfg = get_config()
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
if isinstance(providers_cfg, dict):
|
||||
known_ids.update(providers_cfg.keys())
|
||||
|
||||
# Add OAuth providers even if not in _PROVIDER_DISPLAY
|
||||
known_ids.update(_OAUTH_PROVIDERS)
|
||||
|
||||
for pid in sorted(known_ids):
|
||||
display_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
|
||||
is_oauth = _provider_is_oauth(pid)
|
||||
has_key = _provider_has_key(pid)
|
||||
|
||||
# Determine key source
|
||||
key_source = "none"
|
||||
if is_oauth:
|
||||
key_source = "oauth"
|
||||
# Check if actually authenticated via hermes_cli
|
||||
try:
|
||||
from hermes_cli.auth import get_auth_status as _gas
|
||||
status = _gas(pid)
|
||||
if isinstance(status, dict) and status.get("logged_in"):
|
||||
has_key = True
|
||||
key_source = status.get("key_source", "oauth")
|
||||
else:
|
||||
has_key = False
|
||||
except Exception:
|
||||
has_key = False
|
||||
elif has_key:
|
||||
env_var = _PROVIDER_ENV_VAR.get(pid)
|
||||
if env_var:
|
||||
env_path = _get_hermes_home() / ".env"
|
||||
env_values = _load_env_file(env_path)
|
||||
if env_values.get(env_var):
|
||||
key_source = "env_file"
|
||||
elif os.getenv(env_var):
|
||||
key_source = "env_var"
|
||||
else:
|
||||
key_source = "config_yaml"
|
||||
else:
|
||||
key_source = "config_yaml"
|
||||
|
||||
models = _PROVIDER_MODELS.get(pid, [])
|
||||
# Also include models from config.yaml providers section
|
||||
if isinstance(providers_cfg, dict):
|
||||
provider_cfg = providers_cfg.get(pid, {})
|
||||
if isinstance(provider_cfg, dict) and "models" in provider_cfg:
|
||||
cfg_models = provider_cfg["models"]
|
||||
if isinstance(cfg_models, dict):
|
||||
models = models + [{"id": k, "label": k} for k in cfg_models.keys()]
|
||||
elif isinstance(cfg_models, list):
|
||||
models = models + [{"id": k, "label": k} for k in cfg_models]
|
||||
|
||||
providers.append({
|
||||
"id": pid,
|
||||
"display_name": display_name,
|
||||
"has_key": has_key,
|
||||
"configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
|
||||
"key_source": key_source,
|
||||
"models": models,
|
||||
})
|
||||
|
||||
# Determine active provider
|
||||
active_provider = None
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
active_provider = model_cfg.get("provider")
|
||||
|
||||
return {
|
||||
"providers": providers,
|
||||
"active_provider": active_provider,
|
||||
}
|
||||
|
||||
|
||||
def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
|
||||
"""Set or update the API key for a provider.
|
||||
|
||||
Writes the key to ``~/.hermes/.env`` using the standard env var name.
|
||||
If ``api_key`` is None or empty, the key is removed.
|
||||
|
||||
Returns a status dict with the operation result.
|
||||
"""
|
||||
provider_id = provider_id.strip().lower()
|
||||
|
||||
if not provider_id:
|
||||
return {"ok": False, "error": "Provider ID is required."}
|
||||
|
||||
if _provider_is_oauth(provider_id):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"'{_PROVIDER_DISPLAY.get(provider_id, provider_id)}' uses OAuth authentication. "
|
||||
f"Use `hermes model` in the terminal to configure it.",
|
||||
}
|
||||
|
||||
env_var = _PROVIDER_ENV_VAR.get(provider_id)
|
||||
if not env_var:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"Cannot configure API key for '{_PROVIDER_DISPLAY.get(provider_id, provider_id)}'. "
|
||||
f"This provider does not have a known env var mapping.",
|
||||
}
|
||||
|
||||
# Validate API key format (basic sanity check)
|
||||
if api_key:
|
||||
api_key = api_key.strip()
|
||||
if "\n" in api_key or "\r" in api_key:
|
||||
return {"ok": False, "error": "API key must not contain newline characters."}
|
||||
if len(api_key) < 8:
|
||||
return {"ok": False, "error": "API key appears too short."}
|
||||
|
||||
env_path = _get_hermes_home() / ".env"
|
||||
try:
|
||||
_write_env_file(env_path, {env_var: api_key})
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to write env file for provider %s", provider_id)
|
||||
return {"ok": False, "error": f"Failed to save API key: {exc}"}
|
||||
|
||||
# Invalidate the model cache so the dropdown refreshes on next request.
|
||||
# Using invalidate_models_cache() instead of reload_config() to avoid
|
||||
# disrupting active streaming sessions that may be reading config.cfg.
|
||||
invalidate_models_cache()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": provider_id,
|
||||
"display_name": _PROVIDER_DISPLAY.get(provider_id, provider_id),
|
||||
"action": "updated" if api_key else "removed",
|
||||
}
|
||||
|
||||
|
||||
def remove_provider_key(provider_id: str) -> dict[str, Any]:
|
||||
"""Remove the API key for a provider.
|
||||
|
||||
Convenience wrapper around ``set_provider_key(id, None)``.
|
||||
"""
|
||||
return set_provider_key(provider_id, None)
|
||||
1093
api/routes.py
1093
api/routes.py
File diff suppressed because it is too large
Load Diff
161
api/session_ops.py
Normal file
161
api/session_ops.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Session-mutation operations for slash commands (/retry, /undo) and
|
||||
read-only aggregators (/status, /usage). Operates on the webui's own
|
||||
JSON Session store (api/models.py), not on hermes-agent's SQLite.
|
||||
|
||||
Behavior parity reference: gateway/run.py:_handle_*_command in
|
||||
the hermes-agent repo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from api.config import LOCK, _get_session_agent_lock
|
||||
from api.models import get_session, SESSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def retry_last(session_id: str) -> dict[str, Any]:
|
||||
"""Truncate the session to before the last user message, return its text.
|
||||
|
||||
Mirrors gateway/run.py:_handle_retry_command. Caller (webui frontend)
|
||||
is expected to put the returned text back in the composer and call
|
||||
send() to resume the conversation -- the agent's gateway calls its own
|
||||
_handle_message; the webui has no equivalent in-process pipeline.
|
||||
|
||||
Raises:
|
||||
KeyError: session not found
|
||||
ValueError: no user message in transcript
|
||||
"""
|
||||
# Acquire the per-session agent lock as the outermost lock so that the
|
||||
# read-modify-write of s.messages is serialised with the periodic
|
||||
# checkpoint thread, cancel_stream, and all other session writers.
|
||||
# Lock ordering: _agent_lock → LOCK → _write_session_index (LOCK).
|
||||
with _get_session_agent_lock(session_id):
|
||||
# get_session() and Session.save() both acquire the module-level LOCK
|
||||
# internally (the latter via _write_session_index()), and LOCK is a
|
||||
# non-reentrant threading.Lock — so they MUST be called outside our
|
||||
# own `with LOCK:` block to avoid self-deadlocking.
|
||||
#
|
||||
# The race we close is the read-modify-write of s.messages: two
|
||||
# concurrent /api/session/retry calls could otherwise both compute the
|
||||
# same last_user_idx from the same history and double-truncate. We
|
||||
# serialize just the in-memory mutation; persistence happens inside
|
||||
# the per-session lock so the checkpoint thread cannot race us.
|
||||
#
|
||||
# Stale-object guard: on a cache miss, two concurrent get_session()
|
||||
# calls can each load and cache a *different* Session instance for the
|
||||
# same session_id (the second store clobbers the first). Re-bind to
|
||||
# the canonical cached instance inside the lock so the mutation lands
|
||||
# on the object the next reader will see, not a stale parallel copy.
|
||||
s = get_session(session_id) # raises KeyError if missing
|
||||
with LOCK:
|
||||
s = SESSIONS.get(session_id, s)
|
||||
history = s.messages or []
|
||||
last_user_idx = None
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
if history[i].get('role') == 'user':
|
||||
last_user_idx = i
|
||||
break
|
||||
if last_user_idx is None:
|
||||
raise ValueError('No previous message to retry.')
|
||||
|
||||
last_user_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
s.save()
|
||||
return {'last_user_text': last_user_text, 'removed_count': removed_count}
|
||||
|
||||
|
||||
def undo_last(session_id: str) -> dict[str, Any]:
|
||||
"""Remove the most recent user message and everything after it.
|
||||
|
||||
Mirrors gateway/run.py:_handle_undo_command. Returns a preview of the
|
||||
removed text so the UI can confirm to the user.
|
||||
|
||||
Raises:
|
||||
KeyError: session not found
|
||||
ValueError: no user message in transcript
|
||||
"""
|
||||
# Acquire the per-session agent lock as the outermost lock so that the
|
||||
# read-modify-write of s.messages is serialised with the periodic
|
||||
# checkpoint thread, cancel_stream, and all other session writers.
|
||||
# Lock ordering: _agent_lock → LOCK → _write_session_index (LOCK).
|
||||
with _get_session_agent_lock(session_id):
|
||||
s = get_session(session_id) # acquires LOCK transiently
|
||||
with LOCK:
|
||||
# Stale-object guard — see retry_last for the rationale.
|
||||
s = SESSIONS.get(session_id, s)
|
||||
history = s.messages or []
|
||||
last_user_idx = None
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
if history[i].get('role') == 'user':
|
||||
last_user_idx = i
|
||||
break
|
||||
if last_user_idx is None:
|
||||
raise ValueError('Nothing to undo.')
|
||||
|
||||
removed_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
s.save() # outside LOCK -- save() re-acquires LOCK via _write_session_index()
|
||||
preview = (removed_text[:40] + '...') if len(removed_text) > 40 else removed_text
|
||||
return {
|
||||
'removed_count': removed_count,
|
||||
'removed_preview': preview,
|
||||
}
|
||||
|
||||
|
||||
def session_status(session_id: str) -> dict[str, Any]:
|
||||
"""Return a snapshot of session state for /status.
|
||||
|
||||
Webui equivalent of gateway/run.py:_handle_status_command. The agent's
|
||||
"agent_running" comes from `session_key in self._running_agents`; the
|
||||
webui equivalent is whether the session has an active stream
|
||||
(active_stream_id is set).
|
||||
"""
|
||||
s = get_session(session_id)
|
||||
return {
|
||||
'session_id': s.session_id,
|
||||
'title': s.title,
|
||||
'model': s.model,
|
||||
'workspace': s.workspace,
|
||||
'personality': s.personality,
|
||||
'message_count': len(s.messages or []),
|
||||
'created_at': s.created_at,
|
||||
'updated_at': s.updated_at,
|
||||
'agent_running': bool(getattr(s, 'active_stream_id', None)),
|
||||
}
|
||||
|
||||
|
||||
def session_usage(session_id: str) -> dict[str, Any]:
|
||||
"""Return token usage and cost for /usage.
|
||||
|
||||
Mirrors gateway/run.py:_handle_usage_command's basic counters. The
|
||||
agent shows additional fields (rate-limit headroom etc.) that depend
|
||||
on provider API responses we don't have in webui -- those are deferred.
|
||||
"""
|
||||
s = get_session(session_id)
|
||||
inp = int(s.input_tokens or 0)
|
||||
out = int(s.output_tokens or 0)
|
||||
return {
|
||||
'input_tokens': inp,
|
||||
'output_tokens': out,
|
||||
'total_tokens': inp + out,
|
||||
'estimated_cost': s.estimated_cost,
|
||||
'model': s.model,
|
||||
}
|
||||
|
||||
|
||||
def _extract_text(content: Any) -> str:
|
||||
"""Flatten message content to plain text. Agent stores either a string
|
||||
or a list of {type, text|...} parts; webui needs the user-typed text."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for p in content:
|
||||
if isinstance(p, dict) and p.get('type') == 'text':
|
||||
parts.append(p.get('text', ''))
|
||||
return ' '.join(parts)
|
||||
return str(content)
|
||||
@@ -41,11 +41,41 @@ def _agent_dir() -> Path | None:
|
||||
return p.resolve()
|
||||
return None
|
||||
|
||||
def _trusted_agent_dir(agent_dir: Path) -> bool:
|
||||
"""Return True if agent_dir passes ownership and permission checks.
|
||||
|
||||
Validates that the directory is not world- or group-writable and,
|
||||
on POSIX systems, is owned by the current process user.
|
||||
|
||||
Intentionally does NOT enforce a canonical path (i.e. does not require
|
||||
the dir to be ~/.hermes/hermes-agent), so custom HERMES_WEBUI_AGENT_DIR
|
||||
paths work correctly when HERMES_WEBUI_AUTO_INSTALL=1 is set.
|
||||
"""
|
||||
try:
|
||||
st = agent_dir.stat()
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
# World- or group-writable — untrusted
|
||||
return False
|
||||
if hasattr(os, 'getuid') and st.st_uid != os.getuid():
|
||||
# Not owned by current user (POSIX only; Windows fallback skips)
|
||||
return False
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def auto_install_agent_deps() -> bool:
|
||||
enabled = os.environ.get('HERMES_WEBUI_AUTO_INSTALL', '').strip().lower() in ('1', 'true', 'yes')
|
||||
if not enabled:
|
||||
print('[!!] Auto-install disabled. Set HERMES_WEBUI_AUTO_INSTALL=1 to enable.', flush=True)
|
||||
return False
|
||||
agent_dir = _agent_dir()
|
||||
if agent_dir is None:
|
||||
print('[!!] Auto-install skipped: agent directory not found.', flush=True)
|
||||
return False
|
||||
if not _trusted_agent_dir(agent_dir):
|
||||
print('[!!] Auto-install skipped: agent directory failed trust check (check ownership/permissions).', flush=True)
|
||||
return False
|
||||
req_file = agent_dir / 'requirements.txt'
|
||||
pyproject = agent_dir / 'pyproject.toml'
|
||||
if req_file.exists():
|
||||
|
||||
968
api/streaming.py
968
api/streaming.py
File diff suppressed because it is too large
Load Diff
178
api/updates.py
178
api/updates.py
@@ -53,6 +53,48 @@ def _run_git(args, cwd, timeout=10):
|
||||
return f'git failed to start: {exc}', False
|
||||
|
||||
|
||||
def _detect_webui_version() -> str:
|
||||
"""Detect the running WebUI version from git or a baked-in fallback file.
|
||||
|
||||
Resolution order:
|
||||
1. ``git describe --tags --always --dirty`` — works in any git checkout.
|
||||
Returns the exact tag on tagged commits (e.g. ``v0.50.124``), a
|
||||
post-tag descriptor between releases (e.g. ``v0.50.124-1-ge91325d``),
|
||||
or a bare SHA when no tags exist (shallow clones, fresh forks).
|
||||
2. ``api/_version.py`` — a fallback written by the Docker / CI release
|
||||
workflow when ``.git`` is not present in the image. Expected to define
|
||||
``__version__ = 'vX.Y.Z'``.
|
||||
3. ``'unknown'`` — last resort; displayed as-is in the settings badge.
|
||||
"""
|
||||
# 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:
|
||||
return out
|
||||
|
||||
# Docker / baked-image fallback: api/_version.py written by CI at build time.
|
||||
# Parse with regex rather than exec() — the file holds exactly one assignment
|
||||
# and regex is sufficient; exec() on a build artifact is an unnecessary surface.
|
||||
version_file = REPO_ROOT / 'api' / '_version.py'
|
||||
if version_file.exists():
|
||||
try:
|
||||
import re as _re
|
||||
m = _re.search(
|
||||
r"""__version__\s*=\s*['"]([^'"]+)['"]""",
|
||||
version_file.read_text(encoding='utf-8'),
|
||||
)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 'unknown'
|
||||
|
||||
|
||||
# Resolved once at import time — tags cannot change without a process restart.
|
||||
WEBUI_VERSION: str = _detect_webui_version()
|
||||
|
||||
|
||||
def _split_remote_ref(ref):
|
||||
"""Split 'origin/branch-name' into ('origin', 'branch-name').
|
||||
|
||||
@@ -141,6 +183,111 @@ def check_for_updates(force=False):
|
||||
_check_in_progress = False
|
||||
|
||||
|
||||
def _schedule_restart(delay: float = 2.0) -> None:
|
||||
"""Re-exec this process after *delay* seconds.
|
||||
|
||||
Called after a successful update so that the freshly-pulled code is
|
||||
loaded on the next request, rather than running with a mix of old and
|
||||
new Python modules in sys.modules.
|
||||
|
||||
os.execv() replaces the current process image with a fresh interpreter
|
||||
running the same argv — sessions are preserved on disk, the HTTP port
|
||||
is reclaimed within the delay window, and the client's own
|
||||
``setTimeout(() => location.reload(), 2500)`` lands after the restart.
|
||||
|
||||
Coordinates with ``_apply_lock``: when the user updates both webui
|
||||
and agent, the client POSTs them sequentially. Without coordination
|
||||
the restart timer scheduled by the first update's success would fire
|
||||
while the second update's git-pull is still running, killing it mid-
|
||||
stream and leaving the second repo in an unknown partial state.
|
||||
Blocking on ``_apply_lock`` before ``os.execv`` means a pending
|
||||
second update always completes before the restart happens.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
def _do():
|
||||
import time
|
||||
time.sleep(delay)
|
||||
# Hold _apply_lock through os.execv so no new update can start between
|
||||
# the lock-release and the process replacement. Any in-flight update
|
||||
# finishes first (since it holds the lock), and then the process is
|
||||
# replaced while still holding the lock — meaning no new update can
|
||||
# sneak in during the brief TOCTOU window that existed with the
|
||||
# original acquire-release-execv sequence.
|
||||
# Threads die when execv replaces the process image, so the lock is
|
||||
# released atomically by the kernel.
|
||||
with _apply_lock:
|
||||
try:
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
except Exception:
|
||||
# Last-resort: if execv fails (e.g. frozen binary), just exit
|
||||
# so the process supervisor (start.sh / Docker) restarts us.
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
|
||||
def apply_force_update(target: str) -> dict:
|
||||
"""Force-reset the target repo to the latest remote HEAD.
|
||||
|
||||
Unlike apply_update() which requires a clean working tree and refuses
|
||||
merge conflicts, this discards all local modifications (checkout .) and
|
||||
resets to origin/<branch> — equivalent to what the diverged/conflict
|
||||
error messages ask the user to run manually.
|
||||
|
||||
Should only be called when apply_update() has already returned a
|
||||
response with ``conflict: True`` or ``diverged: True`` and the user
|
||||
has confirmed they want to discard local changes.
|
||||
"""
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
return {'ok': False, 'message': 'Update already in progress'}
|
||||
try:
|
||||
if target == 'webui':
|
||||
path = REPO_ROOT
|
||||
elif target == 'agent':
|
||||
path = _AGENT_DIR
|
||||
else:
|
||||
return {'ok': False, 'message': f'Unknown target: {target}'}
|
||||
|
||||
if path is None or not (path / '.git').exists():
|
||||
return {'ok': False, 'message': 'Not a git repository'}
|
||||
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': 'Could not reach the remote repository. Check your connection.',
|
||||
}
|
||||
|
||||
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
|
||||
if ok and upstream:
|
||||
compare_ref = upstream
|
||||
else:
|
||||
branch = _detect_default_branch(path)
|
||||
compare_ref = f'origin/{branch}'
|
||||
|
||||
# Discard local modifications then reset to remote HEAD
|
||||
_run_git(['checkout', '.'], path)
|
||||
_, ok = _run_git(['reset', '--hard', compare_ref], path)
|
||||
if not ok:
|
||||
return {'ok': False, 'message': f'Force reset to {compare_ref} failed'}
|
||||
|
||||
with _cache_lock:
|
||||
_update_cache['checked_at'] = 0
|
||||
|
||||
_schedule_restart()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} force-updated to {compare_ref}',
|
||||
'target': target,
|
||||
'restart_scheduled': True,
|
||||
}
|
||||
finally:
|
||||
_apply_lock.release()
|
||||
|
||||
|
||||
def apply_update(target):
|
||||
"""Stash, pull --ff-only, pop for the given target repo."""
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
@@ -193,7 +340,16 @@ def _apply_update_inner(target):
|
||||
# Fail early on unresolved merge conflicts
|
||||
if any(line[:2] in {'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'}
|
||||
for line in status_out.splitlines()):
|
||||
return {'ok': False, 'message': 'Repository has unresolved merge conflicts'}
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'The local {target} repo has unresolved merge conflicts. '
|
||||
'To reset to the latest remote version run: '
|
||||
'git -C ' + str(path) + ' checkout . && '
|
||||
'git -C ' + str(path) + ' pull --ff-only'
|
||||
),
|
||||
'conflict': True,
|
||||
}
|
||||
stashed = False
|
||||
if status_out:
|
||||
_, ok = _run_git(['stash'], path)
|
||||
@@ -254,4 +410,22 @@ def _apply_update_inner(target):
|
||||
with _cache_lock:
|
||||
_update_cache['checked_at'] = 0
|
||||
|
||||
return {'ok': True, 'message': f'{target} updated successfully', 'target': target}
|
||||
# Schedule a self-restart so the updated code is loaded fresh. A plain
|
||||
# git pull leaves stale Python modules in sys.modules — agent imports that
|
||||
# reference new symbols (functions, classes) added in the update will fail
|
||||
# on the next request with AttributeError / ImportError. os.execv() re-
|
||||
# execs the same interpreter with the same argv, picking up the new code
|
||||
# cleanly without requiring the user to restart manually.
|
||||
#
|
||||
# The 2 s delay gives the HTTP response time to flush to the client before
|
||||
# the process replaces itself. The client already does
|
||||
# setTimeout(() => location.reload(), 1500) on success, so the page reload
|
||||
# and the restart land at roughly the same time.
|
||||
_schedule_restart()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} updated successfully',
|
||||
'target': target,
|
||||
'restart_scheduled': True,
|
||||
}
|
||||
|
||||
191
api/workspace.py
191
api/workspace.py
@@ -219,6 +219,141 @@ def set_last_workspace(path: str) -> None:
|
||||
logger.debug("Failed to set last workspace")
|
||||
|
||||
|
||||
def _workspace_blocked_roots() -> tuple[Path, ...]:
|
||||
return (
|
||||
# Linux / macOS
|
||||
Path('/etc'),
|
||||
Path('/usr'),
|
||||
Path('/var'),
|
||||
Path('/bin'),
|
||||
Path('/sbin'),
|
||||
Path('/boot'),
|
||||
Path('/proc'),
|
||||
Path('/sys'),
|
||||
Path('/dev'),
|
||||
Path('/lib'),
|
||||
Path('/lib64'),
|
||||
Path('/opt/homebrew'),
|
||||
)
|
||||
|
||||
|
||||
def _is_within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _trusted_workspace_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
|
||||
def add(candidate: str | Path | None) -> None:
|
||||
if candidate in (None, ""):
|
||||
return
|
||||
try:
|
||||
p = Path(candidate).expanduser().resolve()
|
||||
except Exception:
|
||||
return
|
||||
if not p.exists() or not p.is_dir():
|
||||
return
|
||||
if any(_is_within(p, blocked) for blocked in _workspace_blocked_roots()):
|
||||
return
|
||||
if p not in roots:
|
||||
roots.append(p)
|
||||
|
||||
add(Path.home())
|
||||
add(_BOOT_DEFAULT_WORKSPACE)
|
||||
for w in load_workspaces():
|
||||
add(w.get("path"))
|
||||
roots.sort(key=lambda p: len(str(p)))
|
||||
return roots
|
||||
|
||||
|
||||
def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
|
||||
"""Return workspace path suggestions under trusted roots only.
|
||||
|
||||
Suggestions are limited to directories under one of:
|
||||
- Path.home()
|
||||
- the boot default workspace
|
||||
- already-saved workspace roots
|
||||
|
||||
Arbitrary system prefixes return an empty list rather than an error so the
|
||||
UI can safely autocomplete while the user types.
|
||||
"""
|
||||
roots = _trusted_workspace_roots()
|
||||
if not roots:
|
||||
return []
|
||||
|
||||
raw = (prefix or "").strip()
|
||||
if not raw:
|
||||
return [str(p) for p in roots[:limit]]
|
||||
|
||||
if raw.startswith("~"):
|
||||
target = Path(raw).expanduser()
|
||||
elif Path(raw).is_absolute():
|
||||
target = Path(raw)
|
||||
else:
|
||||
target = Path.home() / raw
|
||||
|
||||
normalized = str(target)
|
||||
normalized_lower = normalized.lower()
|
||||
suggestions: list[str] = []
|
||||
|
||||
def add(path: Path) -> None:
|
||||
value = str(path)
|
||||
if value not in suggestions:
|
||||
suggestions.append(value)
|
||||
|
||||
# If the user is typing a partial trusted root like /Users/xuef..., suggest
|
||||
# the matching trusted roots without scanning arbitrary system parents.
|
||||
for root in roots:
|
||||
if str(root).lower().startswith(normalized_lower):
|
||||
add(root)
|
||||
|
||||
in_root = [
|
||||
root
|
||||
for root in roots
|
||||
if normalized == str(root) or normalized.startswith(str(root) + os.sep)
|
||||
]
|
||||
if not in_root:
|
||||
return suggestions[:limit]
|
||||
|
||||
anchor_root = max(in_root, key=lambda p: len(str(p)))
|
||||
ends_with_sep = raw.endswith(os.sep) or raw.endswith('/')
|
||||
parent = target if ends_with_sep else target.parent
|
||||
leaf = '' if ends_with_sep else target.name
|
||||
show_hidden = leaf.startswith('.')
|
||||
|
||||
try:
|
||||
parent_resolved = parent.expanduser().resolve()
|
||||
except Exception:
|
||||
return suggestions[:limit]
|
||||
|
||||
if not parent_resolved.exists() or not parent_resolved.is_dir():
|
||||
return suggestions[:limit]
|
||||
if not _is_within(parent_resolved, anchor_root):
|
||||
return suggestions[:limit]
|
||||
|
||||
leaf_lower = leaf.lower()
|
||||
try:
|
||||
children = sorted(parent_resolved.iterdir(), key=lambda p: p.name.lower())
|
||||
except OSError:
|
||||
return suggestions[:limit]
|
||||
|
||||
for child in children:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name.startswith('.') and not show_hidden:
|
||||
continue
|
||||
if leaf_lower and not child.name.lower().startswith(leaf_lower):
|
||||
continue
|
||||
add(child.resolve())
|
||||
if len(suggestions) >= limit:
|
||||
break
|
||||
return suggestions[:limit]
|
||||
|
||||
|
||||
def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
"""Resolve and validate a workspace path.
|
||||
|
||||
@@ -240,13 +375,6 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always
|
||||
trusted (it was validated at server startup).
|
||||
"""
|
||||
_BLOCKED_SYSTEM_ROOTS = {
|
||||
# Linux / macOS
|
||||
Path('/etc'), Path('/usr'), Path('/var'), Path('/bin'), Path('/sbin'),
|
||||
Path('/boot'), Path('/proc'), Path('/sys'), Path('/dev'),
|
||||
Path('/lib'), Path('/lib64'), Path('/opt/homebrew'),
|
||||
}
|
||||
|
||||
if path in (None, ""):
|
||||
return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
|
||||
|
||||
@@ -258,7 +386,7 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
raise ValueError(f"Path is not a directory: {candidate}")
|
||||
|
||||
# Block known system roots and their children
|
||||
for blocked in _BLOCKED_SYSTEM_ROOTS:
|
||||
for blocked in _workspace_blocked_roots():
|
||||
try:
|
||||
candidate.relative_to(blocked)
|
||||
raise ValueError(f"Path points to a system directory: {candidate}")
|
||||
@@ -283,12 +411,55 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# (C) Trusted if it is equal to or under the boot-time DEFAULT_WORKSPACE.
|
||||
# In Docker deployments HERMES_WEBUI_DEFAULT_WORKSPACE is often set to a
|
||||
# volume mount outside the user's home (e.g. /data/workspace). That path
|
||||
# was already validated at server startup, so any sub-path of it is safe
|
||||
# without requiring the user to add it to the workspace list manually.
|
||||
try:
|
||||
boot_default = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
|
||||
candidate.relative_to(boot_default)
|
||||
return candidate
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise ValueError(
|
||||
f"Path is outside the user home directory and not in the saved workspace "
|
||||
f"list: {candidate}. Add it via Settings → Workspaces first."
|
||||
f"Path is outside the user home directory, not in the saved workspace "
|
||||
f"list, and not under the default workspace: {candidate}. "
|
||||
f"Add it via Settings → Workspaces first."
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def validate_workspace_to_add(path: str) -> Path:
|
||||
"""Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace).
|
||||
|
||||
When a user explicitly adds a new workspace path, we trust their intent — they
|
||||
have console or filesystem access to that path and are consciously registering it.
|
||||
We only block: non-existent paths, non-directories, and known system roots.
|
||||
|
||||
The stricter ``resolve_trusted_workspace`` is used when *using* an existing workspace
|
||||
(file reads/writes) to prevent path traversal after the list is built.
|
||||
"""
|
||||
candidate = Path(path).expanduser().resolve()
|
||||
|
||||
if not candidate.exists():
|
||||
raise ValueError(f"Path does not exist: {candidate}")
|
||||
if not candidate.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {candidate}")
|
||||
|
||||
# Block known system roots and their immediate children
|
||||
for blocked in _workspace_blocked_roots():
|
||||
try:
|
||||
candidate.relative_to(blocked)
|
||||
raise ValueError(f"Path points to a system directory: {candidate}")
|
||||
except ValueError as e:
|
||||
if "system directory" in str(e):
|
||||
raise
|
||||
|
||||
return candidate
|
||||
|
||||
def safe_resolve_ws(root: Path, requested: str) -> Path:
|
||||
"""Resolve a relative path inside a workspace root, raising ValueError on traversal."""
|
||||
resolved = (root / requested).resolve()
|
||||
|
||||
46
bootstrap.py
46
bootstrap.py
@@ -19,6 +19,50 @@ from pathlib import Path
|
||||
|
||||
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _load_repo_dotenv() -> None:
|
||||
"""Load REPO_ROOT/.env into os.environ.
|
||||
|
||||
Mirrors what start.sh does via ``set -a; source .env`` so that running
|
||||
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
|
||||
Variables are set unconditionally (matching shell source semantics), so a
|
||||
value in .env overrides one already present in the shell environment.
|
||||
To keep a CLI-supplied value, unset it from .env or launch via start.sh
|
||||
and override there.
|
||||
|
||||
Only loads the webui repo .env — not ~/.hermes/.env, which the server
|
||||
loads independently at startup for provider credentials.
|
||||
|
||||
Note: does not handle the ``export FOO=bar`` prefix — strip ``export``
|
||||
from .env values if copy-pasting from a shell rc file.
|
||||
"""
|
||||
env_path = REPO_ROOT / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
try:
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
# Strip optional 'export' prefix (common in copy-pasted shell snippets)
|
||||
if k.startswith("export "):
|
||||
k = k[7:].strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
os.environ[k] = v
|
||||
except Exception as exc:
|
||||
import sys as _sys
|
||||
print(f"[bootstrap] Warning: could not load .env — {exc}", file=_sys.stderr)
|
||||
|
||||
|
||||
# Side effect: loads REPO_ROOT/.env into os.environ on import.
|
||||
# Must run before DEFAULT_HOST / DEFAULT_PORT so os.getenv() picks up
|
||||
# values from .env even when bootstrap.py is invoked directly (not via start.sh).
|
||||
_load_repo_dotenv()
|
||||
|
||||
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
|
||||
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
|
||||
# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when
|
||||
@@ -69,7 +113,7 @@ def discover_launcher_python(agent_dir: Path | None) -> str:
|
||||
if env_python:
|
||||
return env_python
|
||||
if agent_dir:
|
||||
for rel in ("venv/bin/python", "venv/Scripts/python.exe"):
|
||||
for rel in ("venv/bin/python", "venv/Scripts/python.exe", ".venv/bin/python", ".venv/Scripts/python.exe"):
|
||||
candidate = agent_dir / rel
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
121
docker-compose.three-container.yml
Normal file
121
docker-compose.three-container.yml
Normal file
@@ -0,0 +1,121 @@
|
||||
# Three-container Docker Compose: Hermes Agent + Dashboard + WebUI
|
||||
#
|
||||
# This extends the two-container setup with the Hermes Dashboard for
|
||||
# monitoring agent activity, sessions, and resource usage.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.three-container.yml up -d
|
||||
#
|
||||
# Services:
|
||||
# hermes-agent — gateway API on port 8642 (CLI, Telegram, cron, tools)
|
||||
# hermes-dashboard — monitoring dashboard on port 9119
|
||||
# hermes-webui — browser chat interface on port 8787
|
||||
#
|
||||
# All three share the same hermes-home volume so config, sessions,
|
||||
# skills, and memory are consistent across all surfaces.
|
||||
#
|
||||
# NOTE ON VOLUMES:
|
||||
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
|
||||
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
|
||||
# see the two-container compose file for a bind-mount example.
|
||||
# When using bind mounts, ALL containers must mount the same host path.
|
||||
|
||||
services:
|
||||
hermes-agent:
|
||||
image: nousresearch/hermes-agent:latest
|
||||
container_name: hermes-agent
|
||||
command: gateway run
|
||||
ports:
|
||||
- "127.0.0.1:8642:8642"
|
||||
volumes:
|
||||
# Persist config, state, sessions, skills, memory across restarts
|
||||
- hermes-home:/home/hermes/.hermes
|
||||
# Expose agent source so the WebUI can install dependencies from it
|
||||
- hermes-agent-src:/opt/hermes
|
||||
environment:
|
||||
- HERMES_HOME=/home/hermes/.hermes
|
||||
- HERMES_UID=${HERMES_UID:-10000}
|
||||
- HERMES_GID=${HERMES_GID:-10000}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
cpus: "2.0"
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
hermes-dashboard:
|
||||
image: nousresearch/hermes-agent:latest
|
||||
container_name: hermes-dashboard
|
||||
command: dashboard --host 0.0.0.0 --insecure
|
||||
ports:
|
||||
- "127.0.0.1:9119:9119"
|
||||
volumes:
|
||||
- hermes-home:/home/hermes/.hermes
|
||||
environment:
|
||||
- HERMES_HOME=/home/hermes/.hermes
|
||||
- HERMES_UID=${HERMES_UID:-10000}
|
||||
- HERMES_GID=${HERMES_GID:-10000}
|
||||
# Dashboard connects to the gateway for health/session data
|
||||
- GATEWAY_HEALTH_URL=http://hermes-agent:8642
|
||||
depends_on:
|
||||
- hermes-agent
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "0.5"
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
hermes-webui:
|
||||
image: ghcr.io/nesquena/hermes-webui:latest
|
||||
container_name: hermes-webui
|
||||
depends_on:
|
||||
- hermes-agent
|
||||
ports:
|
||||
# Expose on localhost only. Remove 127.0.0.1: to expose on all interfaces
|
||||
# (set HERMES_WEBUI_PASSWORD if doing so).
|
||||
- "127.0.0.1:8787:8787"
|
||||
volumes:
|
||||
# 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
|
||||
# Workspace directory — browse and edit files from the WebUI.
|
||||
# Adapt the host path to your project directory.
|
||||
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
|
||||
environment:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
|
||||
# Match your host user's UID/GID for correct file permissions.
|
||||
# Run `id -u` and `id -g` to find your values.
|
||||
# On macOS, UIDs start at 501 (not 1000) — set these in a .env file:
|
||||
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
|
||||
- WANTED_UID=${UID:-1000}
|
||||
- WANTED_GID=${GID:-1000}
|
||||
# NOTE: When using bind-mount volumes shared across containers, ALL containers
|
||||
# that write to the same host directory must run as the same UID/GID.
|
||||
# If hermes-agent initialises the state dir as root (UID 0), hermes-webui
|
||||
# will get a PermissionError accessing those paths — including a crash on every
|
||||
# HTTP request if the auth signing-key file is unreadable. Either set WANTED_UID
|
||||
# to match the agent container's UID, or use a named Docker volume (preferred).
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
networks:
|
||||
hermes-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
hermes-home:
|
||||
hermes-agent-src:
|
||||
@@ -10,19 +10,44 @@
|
||||
# The agent container runs the gateway (CLI, Telegram, cron, etc.).
|
||||
# The WebUI container serves the browser interface on port 8787.
|
||||
# Both share ~/.hermes for config, sessions, and state.
|
||||
#
|
||||
# NOTE ON VOLUMES:
|
||||
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
|
||||
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
|
||||
# replace the named volumes at the bottom. Example for hermes-agent-src:
|
||||
#
|
||||
# hermes-agent-src:
|
||||
# driver: local
|
||||
# driver_opts:
|
||||
# type: none
|
||||
# o: bind
|
||||
# device: /opt/hermes-agent
|
||||
#
|
||||
# When using bind mounts, BOTH containers must mount the same host path.
|
||||
# The agent exposes source at /opt/hermes, the WebUI reads it from
|
||||
# /home/hermeswebui/.hermes/hermes-agent — as long as both point to the
|
||||
# same host directory, the paths align correctly.
|
||||
|
||||
services:
|
||||
hermes-agent:
|
||||
image: nousresearch/hermes-agent:latest
|
||||
container_name: hermes-agent
|
||||
command: gateway run
|
||||
ports:
|
||||
# Gateway API — exposed on localhost only.
|
||||
# Other containers on hermes-net reach it via http://hermes-agent:8642.
|
||||
# Remove 127.0.0.1: to expose on the host network (e.g. for remote clients).
|
||||
- "127.0.0.1:8642:8642"
|
||||
volumes:
|
||||
# Persist config, state, sessions, skills, memory across restarts
|
||||
- hermes-home:/root/.hermes
|
||||
- hermes-home:/home/hermes/.hermes
|
||||
# Expose agent source so the WebUI can install dependencies from it
|
||||
- hermes-agent-src:/opt/hermes
|
||||
environment:
|
||||
- HERMES_HOME=/root/.hermes
|
||||
- HERMES_HOME=/home/hermes/.hermes
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
hermes-webui:
|
||||
image: ghcr.io/nesquena/hermes-webui:latest
|
||||
@@ -41,17 +66,29 @@ services:
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
|
||||
# Workspace directory — browse and edit files from the WebUI.
|
||||
# Adapt the host path to your project directory.
|
||||
- ~/workspace:/workspace
|
||||
# Override with: HERMES_WORKSPACE=/your/path docker compose up
|
||||
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
|
||||
environment:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp
|
||||
# Match your host user's UID/GID for correct file permissions
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
|
||||
# Match your host user's UID/GID for correct file permissions.
|
||||
# In two-container setups the WebUI auto-detects UID/GID from the shared
|
||||
# hermes-home volume, but you can override explicitly if needed (#668):
|
||||
# Run `id -u` and `id -g` to find your values.
|
||||
# On macOS, UIDs start at 501 — set these in a .env file:
|
||||
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
|
||||
- WANTED_UID=${UID:-1000}
|
||||
- WANTED_GID=${GID:-1000}
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
# - HERMES_WEBUI_PASSWORD=***
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hermes-net
|
||||
|
||||
networks:
|
||||
hermes-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
hermes-home:
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
# macOS note: set UID and GID below to match your user ID (run `id -u` and `id -g`).
|
||||
- ${HERMES_HOME:-${HOME}/.hermes}:/home/hermeswebui/.hermes
|
||||
# Your workspace directory shown on first launch (adapt if yours is different, the container will use the mounted /workspace)
|
||||
- ${HERMES_HOME:-${HOME}}/workspace:/workspace
|
||||
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
|
||||
environment:
|
||||
# Set to your host user ID: run `id -u` and `id -g` to find them.
|
||||
# On macOS, UIDs start at 501 (not 1000), so set UID and GID in a .env file:
|
||||
@@ -27,8 +27,8 @@ services:
|
||||
# Required: bind address and port
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp
|
||||
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui)
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
|
||||
# Default workspace directory shown on first launch
|
||||
# - HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace
|
||||
# Optional: set a password for remote access
|
||||
|
||||
@@ -59,12 +59,26 @@ it=$itdir/hermeswebui_user_uid
|
||||
if [ -z "${WANTED_UID+x}" ]; then
|
||||
if [ -f $it ]; then WANTED_UID=$(cat $it); fi
|
||||
fi
|
||||
# Auto-detect from mounted workspace if still unset (#569).
|
||||
# Auto-detect from mounted volumes if still unset (#569, #668).
|
||||
# On macOS, host UIDs start at 501. Using the wrong UID means the container
|
||||
# user cannot read the bind-mounted files, making the workspace appear empty.
|
||||
# Prefer the workspace mount UID over the hardcoded default of 1024.
|
||||
# In two-container setups (hermes-agent + hermes-webui), the shared hermes-home
|
||||
# volume may be owned by the agent container's UID — detect from there first.
|
||||
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
|
||||
# Use /workspace — the standard bind-mount point — to read the host UID.
|
||||
# Priority 1: hermes-home shared volume — covers two-container Zeabur/Compose setups (#668)
|
||||
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
|
||||
if [ -d "$_probe_dir" ]; then
|
||||
_detected_uid=$(stat -c '%u' "$_probe_dir" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
|
||||
echo "-- Auto-detected UID: $_detected_uid (from $_probe_dir)"
|
||||
WANTED_UID=$_detected_uid
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
|
||||
# Priority 2: /workspace bind-mount — the standard single-container mount point
|
||||
if [ -d "/workspace" ]; then
|
||||
_detected_uid=$(stat -c '%u' "/workspace" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
|
||||
@@ -81,8 +95,22 @@ it=$itdir/hermeswebui_user_gid
|
||||
if [ -z "${WANTED_GID+x}" ]; then
|
||||
if [ -f $it ]; then WANTED_GID=$(cat $it); fi
|
||||
fi
|
||||
# Auto-detect GID from mounted workspace to match (#569)
|
||||
# Auto-detect GID from mounted volumes to match (#569, #668)
|
||||
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
|
||||
# Priority 1: hermes-home shared volume
|
||||
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
|
||||
if [ -d "$_probe_dir" ]; then
|
||||
_detected_gid=$(stat -c '%g' "$_probe_dir" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
|
||||
echo "-- Auto-detected GID: $_detected_gid (from $_probe_dir)"
|
||||
WANTED_GID=$_detected_gid
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
|
||||
# Priority 2: /workspace bind-mount
|
||||
if [ -d "/workspace" ]; then
|
||||
_detected_gid=$(stat -c '%g' "/workspace" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
|
||||
@@ -211,13 +239,20 @@ rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_STATE_DIR"
|
||||
echo ""; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: Default workspace directory shown on first launch"
|
||||
if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then echo "HERMES_WEBUI_DEFAULT_WORKSPACE not set, setting to /workspace"; export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi;
|
||||
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||
# Use sudo for mkdir/chown — Docker may auto-create bind-mount directories as root,
|
||||
# leaving them unwritable by the hermeswebui user (#357).
|
||||
sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||
sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to set owner of $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||
# Use sudo for mkdir — Docker may auto-create bind-mount directories as root (#357).
|
||||
# Skip mkdir if the directory already exists (e.g. a read-only mount — #670).
|
||||
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
|
||||
sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||
fi
|
||||
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
|
||||
it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it || error_exit "Failed to verify default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||
rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_DEFAULT_WORKSPACE"
|
||||
# Only chown and write-test if the workspace is writable. Read-only bind-mounts
|
||||
# (:ro) are valid — the workspace is used for browsing, not writing by the server.
|
||||
if [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
|
||||
sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || echo "!! WARNING: Could not chown $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
|
||||
it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it && rm -f $it || echo "!! WARNING: Could not write to $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
|
||||
else
|
||||
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE is read-only — skipping chown/write check (read-only workspace is supported)"
|
||||
fi
|
||||
|
||||
echo ""; echo "==================="
|
||||
echo ""; echo "== Installing uv and creating a new virtual environment for hermes-webui"
|
||||
@@ -259,14 +294,29 @@ else
|
||||
test -x /app/venv/bin/pip
|
||||
|
||||
echo ""; echo "== Adding hermes-agent's pyproject.toml base dependencies to the virtual environment"
|
||||
if [ -d "/home/hermeswebui/.hermes/hermes-agent" ] && [ -f "/home/hermeswebui/.hermes/hermes-agent/pyproject.toml" ]; then
|
||||
uv pip install "/home/hermeswebui/.hermes/hermes-agent[honcho]" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hermes-agent's requirements"
|
||||
_agent_paths=(
|
||||
"/home/hermeswebui/.hermes/hermes-agent"
|
||||
"/opt/hermes"
|
||||
)
|
||||
_agent_src=""
|
||||
for _p in "${_agent_paths[@]}"; do
|
||||
if [ -d "$_p" ] && [ -f "$_p/pyproject.toml" ]; then
|
||||
_agent_src="$_p"
|
||||
break
|
||||
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"
|
||||
else
|
||||
echo ""
|
||||
echo "!! WARNING: hermes-agent source not found at /home/hermeswebui/.hermes/hermes-agent"
|
||||
echo "!! WARNING: hermes-agent source not found."
|
||||
echo "!! Looked in: ${_agent_paths[0]}"
|
||||
echo "!! ${_agent_paths[1]}"
|
||||
echo "!! The WebUI will start with reduced functionality (no model auto-detection,"
|
||||
echo "!! no personality routing, no CLI session imports)."
|
||||
echo "!! To fix: mount the agent source volume into the container. See:"
|
||||
echo "!! To fix: mount the agent source volume into the container:"
|
||||
echo "!! -v /path/to/hermes-agent:/home/hermeswebui/.hermes/hermes-agent"
|
||||
echo "!! Or see the two-container compose example:"
|
||||
echo "!! https://github.com/nesquena/hermes-webui/blob/master/docker-compose.two-container.yml"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
<button data-theme-btn="nord">Nord</button>
|
||||
<button data-theme-btn="oled">OLED</button>
|
||||
<span style="width:1px;height:18px;background:var(--border);margin:0 4px;align-self:center;"></span>
|
||||
<button id="toggleBubble">Bubble layout: off</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -664,24 +663,7 @@ Run typecheck to confirm, then patch.</pre></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">11 · Bubble layout</div>
|
||||
<h2 class="doc-h">Opt-in via <code>body.bubble-layout</code> — extra bubble padding for assistant too</h2>
|
||||
<p class="doc-note">The default layout already right-aligns user messages (the redesign adopted it globally), so this toggle mostly affects additional padding / boundary handling. Flip the <strong>Bubble layout</strong> toggle in the header to see the mode applied.</p>
|
||||
<div class="doc-card"><span class="doc-label">Conversation sample</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user"><div class="msg-body"><p>Can you add a retry button next to the regenerate one?</p></div></div>
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body"><p>Yes — it can share <code>.msg-action-btn</code> and live in the same <code>.msg-actions</code> container. I'll wire it up on <code>_lastError</code>.</p></div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="msg-row" data-role="user"><div class="msg-body"><p>Perfect, go for it.</p></div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
@@ -845,13 +827,7 @@ Run typecheck to confirm, then patch.</pre></div>
|
||||
});
|
||||
});
|
||||
// Bubble-layout toggle
|
||||
const bubbleBtn = document.getElementById('toggleBubble');
|
||||
bubbleBtn.addEventListener('click', () => {
|
||||
document.body.classList.toggle('bubble-layout');
|
||||
const on = document.body.classList.contains('bubble-layout');
|
||||
bubbleBtn.textContent = 'Bubble layout: ' + (on ? 'on' : 'off');
|
||||
bubbleBtn.classList.toggle('on', on);
|
||||
});
|
||||
|
||||
// Thinking / tool-card click-to-toggle (so the demo feels live)
|
||||
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
|
||||
h.addEventListener('click', () => h.parentElement.classList.toggle('open'));
|
||||
|
||||
19
server.py
19
server.py
@@ -15,9 +15,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from api.auth import check_auth
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import j
|
||||
from api.helpers import j, get_profile_cookie
|
||||
from api.profiles import set_request_profile, clear_request_profile
|
||||
from api.routes import handle_get, handle_post
|
||||
from api.startup import auto_install_agent_deps, fix_credential_permissions
|
||||
from api.updates import WEBUI_VERSION
|
||||
|
||||
|
||||
class QuietHTTPServer(ThreadingHTTPServer):
|
||||
@@ -44,7 +46,8 @@ class QuietHTTPServer(ThreadingHTTPServer):
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
|
||||
server_version = 'HermesWebUI/0.50.38'
|
||||
_ver_suffix = WEBUI_VERSION.removeprefix('v')
|
||||
server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI'
|
||||
def log_message(self, fmt, *args): pass # suppress default Apache-style log
|
||||
|
||||
def log_request(self, code: str='-', size: str='-') -> None:
|
||||
@@ -62,6 +65,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._req_t0 = time.time()
|
||||
# Per-request profile context from cookie (issue #798)
|
||||
cookie_profile = get_profile_cookie(self)
|
||||
if cookie_profile:
|
||||
set_request_profile(cookie_profile)
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
@@ -71,9 +78,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
finally:
|
||||
clear_request_profile()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._req_t0 = time.time()
|
||||
# Per-request profile context from cookie (issue #798)
|
||||
cookie_profile = get_profile_cookie(self)
|
||||
if cookie_profile:
|
||||
set_request_profile(cookie_profile)
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
@@ -83,6 +96,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
finally:
|
||||
clear_request_profile()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
268
static/boot.js
268
static/boot.js
@@ -185,18 +185,23 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
||||
const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
|
||||
if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
|
||||
|
||||
// Persist SR failure across reloads (e.g. Tailscale/network error)
|
||||
const _micForceMediaRecorderKey='mic_force_mediarecorder';
|
||||
let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1';
|
||||
|
||||
const btn=$('btnMic');
|
||||
const status=$('micStatus');
|
||||
const ta=$('msg');
|
||||
const statusText=status?status.querySelector('.status-text'):null;
|
||||
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
|
||||
|
||||
let recognition=SpeechRecognition?new SpeechRecognition():null;
|
||||
let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null;
|
||||
let mediaRecorder=null;
|
||||
let mediaStream=null;
|
||||
let audioChunks=[];
|
||||
let _finalText='';
|
||||
let _prefix='';
|
||||
let _isRecording=false;
|
||||
|
||||
function _setRecording(on){
|
||||
window._micActive=on;
|
||||
@@ -261,7 +266,7 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
||||
}
|
||||
window._stopMic=_stopMic; // expose for send-guard above
|
||||
|
||||
if(recognition){
|
||||
if(recognition && !_forceMediaRecorder){
|
||||
recognition.continuous=false;
|
||||
recognition.interimResults=true;
|
||||
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
|
||||
@@ -298,6 +303,13 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
||||
recognition.onerror=(event)=>{
|
||||
_setRecording(false);
|
||||
window._micPendingSend=false;
|
||||
_isRecording=false;
|
||||
if(event.error==='network'||event.error==='not-allowed'){
|
||||
// Persist SR failure: next reload will skip SpeechRecognition
|
||||
localStorage.setItem(_micForceMediaRecorderKey,'1');
|
||||
_forceMediaRecorder=true;
|
||||
recognition=null;
|
||||
}
|
||||
const msgs={
|
||||
'not-allowed':t('mic_denied'),
|
||||
'no-speech':t('mic_no_speech'),
|
||||
@@ -308,18 +320,26 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
||||
}
|
||||
|
||||
btn.onclick=async()=>{
|
||||
// Race-condition guard: ignore rapid double-clicks
|
||||
if(_isRecording){
|
||||
_stopMic();
|
||||
_isRecording=false;
|
||||
return;
|
||||
}
|
||||
if(window._micActive){
|
||||
_stopMic();
|
||||
return;
|
||||
}
|
||||
_isRecording=true;
|
||||
_finalText='';
|
||||
_prefix=ta.value;
|
||||
if(recognition){
|
||||
if(recognition && !_forceMediaRecorder){
|
||||
recognition.start();
|
||||
_setRecording(true);
|
||||
return;
|
||||
}
|
||||
if(!_canRecordAudio){
|
||||
_isRecording=false;
|
||||
showToast(t('mic_network'));
|
||||
return;
|
||||
}
|
||||
@@ -331,12 +351,14 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
||||
audioChunks=[];
|
||||
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
|
||||
mediaRecorder.onerror=()=>{
|
||||
_isRecording=false;
|
||||
_setRecording(false);
|
||||
window._micPendingSend=false;
|
||||
_stopTracks();
|
||||
showToast(t('mic_network'));
|
||||
};
|
||||
mediaRecorder.onstop=async()=>{
|
||||
_isRecording=false;
|
||||
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
|
||||
_setRecording(false);
|
||||
_stopTracks();
|
||||
@@ -348,6 +370,7 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
||||
mediaRecorder.start();
|
||||
_setRecording(true);
|
||||
}catch(err){
|
||||
_isRecording=false;
|
||||
window._micPendingSend=false;
|
||||
_stopTracks();
|
||||
showToast(t('mic_denied'));
|
||||
@@ -382,8 +405,7 @@ $('importFileInput').onchange=async(e)=>{
|
||||
if(res.ok&&res.session){
|
||||
await loadSession(res.session.session_id);
|
||||
await renderSessionList();
|
||||
const overlay=$('settingsOverlay');
|
||||
if(overlay) overlay.style.display='none';
|
||||
if(_currentPanel==='settings') switchPanel('chat');
|
||||
showToast(t('session_imported'));
|
||||
}
|
||||
}catch(err){
|
||||
@@ -431,9 +453,17 @@ $('msg').addEventListener('input',()=>{
|
||||
updateSendBtn();
|
||||
const text=$('msg').value;
|
||||
if(text.startsWith('/')&&text.indexOf('\n')===-1){
|
||||
const prefix=text.slice(1);
|
||||
const matches=getMatchingCommands(prefix);
|
||||
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
|
||||
if(typeof getSlashAutocompleteMatches==='function'){
|
||||
getSlashAutocompleteMatches(text).then(matches=>{
|
||||
if(($('msg').value||'')!==text) return;
|
||||
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
|
||||
});
|
||||
}else{
|
||||
const prefix=text.slice(1);
|
||||
const matches=getMatchingCommands(prefix);
|
||||
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
|
||||
}
|
||||
if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
|
||||
} else {
|
||||
hideCmdDropdown();
|
||||
}
|
||||
@@ -493,9 +523,8 @@ document.addEventListener('keydown',async e=>{
|
||||
if(typeof skipOnboarding==='function') skipOnboarding();
|
||||
return;
|
||||
}
|
||||
// Close settings overlay if open
|
||||
const settingsOverlay=$('settingsOverlay');
|
||||
if(settingsOverlay&&settingsOverlay.style.display!=='none'){_closeSettingsPanel();return;}
|
||||
// Close settings panel if active
|
||||
if(_currentPanel==='settings'){_closeSettingsPanel();return;}
|
||||
// Close workspace dropdown
|
||||
closeWsDropdown();
|
||||
// Clear session search
|
||||
@@ -579,29 +608,162 @@ window.addEventListener('resize',()=>{
|
||||
};
|
||||
})();
|
||||
|
||||
// ── System theme helper ──────────────────────────────────────────────────────
|
||||
// ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
|
||||
const _SKINS=[
|
||||
{name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']},
|
||||
{name:'Ares', colors:['#FF4444','#CC3333','#992222']},
|
||||
{name:'Mono', colors:['#CCCCCC','#999999','#666666']},
|
||||
{name:'Slate', colors:['#334155','#475569','#64748b']},
|
||||
{name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
|
||||
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
|
||||
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
|
||||
];
|
||||
const _VALID_THEMES=new Set(['system','dark','light']);
|
||||
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
|
||||
const _LEGACY_THEME_MAP={
|
||||
slate:{theme:'dark',skin:'slate'},
|
||||
solarized:{theme:'dark',skin:'poseidon'},
|
||||
monokai:{theme:'dark',skin:'sisyphus'},
|
||||
nord:{theme:'dark',skin:'slate'},
|
||||
oled:{theme:'dark',skin:'default'},
|
||||
};
|
||||
let _systemThemeMq=null;
|
||||
let _onSystemThemeChange=null;
|
||||
|
||||
function _normalizeAppearance(theme,skin){
|
||||
const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():'';
|
||||
const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():'';
|
||||
const legacy=_LEGACY_THEME_MAP[rawTheme];
|
||||
const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark');
|
||||
const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default');
|
||||
return {theme:nextTheme,skin:nextSkin};
|
||||
}
|
||||
|
||||
function _setResolvedTheme(isDark){
|
||||
document.documentElement.classList.toggle('dark',!!isDark);
|
||||
const link=document.getElementById('prism-theme');
|
||||
if(!link) return;
|
||||
const want=isDark
|
||||
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
|
||||
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
|
||||
const wantIntegrity=isDark
|
||||
?'sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A'
|
||||
:'sha384-rCCjoCPCsizaAAYVoz1Q0CmCTvnctK0JkfCSjx7IIxexTBg+uCKtFYycedUjMyA2';
|
||||
if(link.href!==want){ link.integrity=wantIntegrity; link.href=want; }
|
||||
}
|
||||
|
||||
function _applyTheme(name){
|
||||
const resolved=(name==='system')
|
||||
?(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light')
|
||||
:name;
|
||||
document.documentElement.dataset.theme=resolved||'dark';
|
||||
// Swap Prism syntax-highlighting theme to match UI theme
|
||||
(function(){
|
||||
const link=document.getElementById('prism-theme');
|
||||
if(!link) return;
|
||||
const isDark=(resolved!=='light');
|
||||
const want=isDark
|
||||
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
|
||||
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
|
||||
if(link.href!==want){ link.href=want; }
|
||||
})();
|
||||
// Re-register OS change listener whenever system theme is active
|
||||
if(name==='system'){
|
||||
const mq=window.matchMedia('(prefers-color-scheme:dark)');
|
||||
const _onOsChange=()=>{ document.documentElement.dataset.theme=mq.matches?'dark':'light'; };
|
||||
mq.removeEventListener('change',_onOsChange);
|
||||
mq.addEventListener('change',_onOsChange);
|
||||
const normalized=_normalizeAppearance(name,'default');
|
||||
if(_systemThemeMq&&_onSystemThemeChange){
|
||||
_systemThemeMq.removeEventListener('change',_onSystemThemeChange);
|
||||
_systemThemeMq=null;
|
||||
_onSystemThemeChange=null;
|
||||
}
|
||||
if(normalized.theme==='system'){
|
||||
_systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)');
|
||||
_onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches);
|
||||
_setResolvedTheme(_systemThemeMq.matches);
|
||||
_systemThemeMq.addEventListener('change',_onSystemThemeChange);
|
||||
return;
|
||||
}
|
||||
_setResolvedTheme(normalized.theme==='dark');
|
||||
}
|
||||
|
||||
function _applySkin(name){
|
||||
const key=(name||'default').toLowerCase();
|
||||
if(key==='default') delete document.documentElement.dataset.skin;
|
||||
else document.documentElement.dataset.skin=key;
|
||||
}
|
||||
|
||||
function _pickTheme(name){
|
||||
const currentSkin=localStorage.getItem('hermes-skin');
|
||||
const appearance=_normalizeAppearance(name,currentSkin);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applyTheme(appearance.theme);
|
||||
_applySkin(appearance.skin);
|
||||
_syncThemePicker(appearance.theme);
|
||||
_syncSkinPicker(appearance.skin);
|
||||
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
|
||||
const hidden=$('settingsTheme');
|
||||
if(hidden) hidden.value=appearance.theme;
|
||||
const skinHidden=$('settingsSkin');
|
||||
if(skinHidden) skinHidden.value=appearance.skin;
|
||||
}
|
||||
|
||||
function _pickSkin(name){
|
||||
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applyTheme(appearance.theme);
|
||||
_applySkin(appearance.skin);
|
||||
_syncThemePicker(appearance.theme);
|
||||
_syncSkinPicker(appearance.skin);
|
||||
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
|
||||
const hidden=$('settingsSkin');
|
||||
if(hidden) hidden.value=appearance.skin;
|
||||
const themeHidden=$('settingsTheme');
|
||||
if(themeHidden) themeHidden.value=appearance.theme;
|
||||
}
|
||||
|
||||
function _syncThemePicker(active){
|
||||
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.themeVal===active;
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _syncSkinPicker(active){
|
||||
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.skinVal===active;
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _applyFontSize(size){
|
||||
if(size&&size!=='default'){
|
||||
document.documentElement.dataset.fontSize=size;
|
||||
} else {
|
||||
delete document.documentElement.dataset.fontSize;
|
||||
}
|
||||
}
|
||||
|
||||
function _pickFontSize(size){
|
||||
localStorage.setItem('hermes-font-size',size);
|
||||
_applyFontSize(size);
|
||||
_syncFontSizePicker(size);
|
||||
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
|
||||
const hidden=$('settingsFontSize');
|
||||
if(hidden) hidden.value=size;
|
||||
}
|
||||
|
||||
function _syncFontSizePicker(active){
|
||||
document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.fontSizeVal===(active||'default');
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _buildSkinPicker(activeSkin){
|
||||
const grid=$('skinPickerGrid');
|
||||
if(!grid) return;
|
||||
grid.innerHTML='';
|
||||
for(const skin of _SKINS){
|
||||
const key=skin.name.toLowerCase();
|
||||
const btn=document.createElement('button');
|
||||
btn.type='button';
|
||||
btn.className='skin-pick-btn';
|
||||
btn.dataset.skinVal=key;
|
||||
btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
|
||||
btn.onclick=()=>_pickSkin(skin.name);
|
||||
const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
|
||||
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
|
||||
grid.appendChild(btn);
|
||||
}
|
||||
_syncSkinPicker((activeSkin||'default').toLowerCase());
|
||||
}
|
||||
|
||||
function applyBotName(){
|
||||
@@ -628,11 +790,17 @@ function applyBotName(){
|
||||
window._showCliSessions=!!s.show_cli_sessions;
|
||||
window._soundEnabled=!!s.sound_enabled;
|
||||
window._notificationsEnabled=!!s.notifications_enabled;
|
||||
window._showThinking=s.show_thinking!==false;
|
||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
const _theme=s.theme||'dark';
|
||||
localStorage.setItem('hermes-theme',_theme);
|
||||
_applyTheme(_theme);
|
||||
document.body.classList.toggle('bubble-layout',!!s.bubble_layout);
|
||||
// Persist default workspace so the blank new-chat page can show it
|
||||
// and workspace actions (New file/folder) work before the first session (#804).
|
||||
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
|
||||
const appearance=_normalizeAppearance(s.theme,s.skin);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
_applyTheme(appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applySkin(appearance.skin);
|
||||
if(typeof setLocale==='function'){
|
||||
const _lang=typeof resolvePreferredLocale==='function'
|
||||
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
|
||||
@@ -647,9 +815,10 @@ function applyBotName(){
|
||||
window._showCliSessions=false;
|
||||
window._soundEnabled=false;
|
||||
window._notificationsEnabled=false;
|
||||
window._showThinking=true;
|
||||
window._sidebarDensity='compact';
|
||||
window._botName='Hermes';
|
||||
_bootSettings={check_for_updates:false};
|
||||
document.body.classList.remove('bubble-layout');
|
||||
if(typeof setLocale==='function'){
|
||||
const _lang=typeof resolvePreferredLocale==='function'
|
||||
? resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))
|
||||
@@ -679,6 +848,7 @@ function applyBotName(){
|
||||
$('modelSelect').value=savedModel;
|
||||
// If the value didn't take (model not in list), clear the bad pref
|
||||
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
|
||||
else if(typeof syncModelChip==='function') syncModelChip();
|
||||
}
|
||||
// Pre-load workspace list so sidebar name is correct from first render
|
||||
await loadWorkspaceList();
|
||||
@@ -686,6 +856,11 @@ function applyBotName(){
|
||||
_initResizePanels();
|
||||
// Workspace panel restore happens AFTER loadSession so we know if
|
||||
// the session has a workspace — prevents the snap-open-then-closed flash (#576).
|
||||
// Fix #822: clear any browser-restored value before first render. This
|
||||
// covers fresh page loads and reloads. The bfcache restore case is handled
|
||||
// separately below by a `pageshow` listener — the async IIFE here does NOT
|
||||
// re-run when the browser restores the page from bfcache.
|
||||
const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
|
||||
const saved=localStorage.getItem('hermes-webui-session');
|
||||
if(saved){
|
||||
try{
|
||||
@@ -695,10 +870,12 @@ function applyBotName(){
|
||||
if(S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')==='open'){
|
||||
_workspacePanelMode='browse';
|
||||
}
|
||||
syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
|
||||
S._bootReady=true;
|
||||
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
|
||||
catch(e){localStorage.removeItem('hermes-webui-session');}
|
||||
}
|
||||
// no saved session - show empty state, wait for user to hit +
|
||||
S._bootReady=true;
|
||||
syncTopbar();
|
||||
syncWorkspacePanelState();
|
||||
$('emptyState').style.display='';
|
||||
@@ -706,3 +883,18 @@ function applyBotName(){
|
||||
// Start real-time gateway session sync if setting is enabled
|
||||
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
||||
})();
|
||||
|
||||
// Fix #822 (bfcache path): when the browser restores the page from the
|
||||
// back-forward cache, the async boot IIFE above does NOT re-run, but the
|
||||
// DOM — including any stale value in #sessionSearch — IS restored. A
|
||||
// prior search string would silently hide all sessions via the filter in
|
||||
// renderSessionListFromCache(). Clear the field and re-render whenever
|
||||
// the page is restored from cache (`event.persisted === true`).
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (!event.persisted) return; // fresh loads are handled by the IIFE above
|
||||
const _srch = document.getElementById('sessionSearch');
|
||||
if (_srch) _srch.value = '';
|
||||
if (typeof renderSessionListFromCache === 'function') {
|
||||
try { renderSessionListFromCache(); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,18 +3,35 @@
|
||||
// (no round-trip to the agent) and shows feedback via toast or local message.
|
||||
|
||||
const COMMANDS=[
|
||||
// noEcho:true = action-only commands that don't produce a chat response.
|
||||
// Commands without noEcho get a user message echoed to the chat (#840).
|
||||
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
|
||||
{name:'clear', desc:t('cmd_clear'), fn:cmdClear},
|
||||
{name:'compact', desc:t('cmd_compact'), fn:cmdCompact},
|
||||
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name'},
|
||||
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'},
|
||||
{name:'new', desc:t('cmd_new'), fn:cmdNew},
|
||||
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage},
|
||||
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name'},
|
||||
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name'},
|
||||
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
|
||||
{name:'clear', desc:t('cmd_clear'), fn:cmdClear, noEcho:true},
|
||||
{name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]', noEcho:true},
|
||||
{name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact, noEcho:true},
|
||||
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models', noEcho:true},
|
||||
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name', noEcho:true},
|
||||
{name:'new', desc:t('cmd_new'), fn:cmdNew, noEcho:true},
|
||||
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage, noEcho:true},
|
||||
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true},
|
||||
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
|
||||
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
|
||||
{name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true},
|
||||
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
|
||||
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
|
||||
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
|
||||
{name:'btw', desc:t('cmd_btw'), fn:cmdBtw, arg:'question', noEcho:true},
|
||||
{name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true},
|
||||
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
|
||||
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
|
||||
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
|
||||
];
|
||||
|
||||
const SLASH_SUBARG_SOURCES={
|
||||
model:{desc:t('cmd_model'), subArgs:'models'},
|
||||
personality:{desc:t('cmd_personality'), subArgs:'personalities'},
|
||||
};
|
||||
|
||||
function parseCommand(text){
|
||||
if(!text.startsWith('/'))return null;
|
||||
const parts=text.slice(1).split(/\s+/);
|
||||
@@ -25,23 +42,172 @@ function parseCommand(text){
|
||||
|
||||
function executeCommand(text){
|
||||
const parsed=parseCommand(text);
|
||||
if(!parsed)return false;
|
||||
if(!parsed)return null;
|
||||
const cmd=COMMANDS.find(c=>c.name===parsed.name);
|
||||
if(!cmd)return false;
|
||||
cmd.fn(parsed.args);
|
||||
return true;
|
||||
if(!cmd)return null;
|
||||
// A handler may return `false` to opt out of interception — e.g. /reasoning
|
||||
// with an effort level falls through so the agent's own handler sees it,
|
||||
// preserving the pre-existing pass-through behaviour for that subcommand.
|
||||
if(cmd.fn(parsed.args)===false)return null;
|
||||
// Return noEcho flag so send() knows whether to echo the command as a user message (#840).
|
||||
return {noEcho:!!cmd.noEcho};
|
||||
}
|
||||
|
||||
function getMatchingCommands(prefix){
|
||||
const q=prefix.toLowerCase();
|
||||
return COMMANDS.filter(c=>c.name.startsWith(q));
|
||||
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
|
||||
const seen=new Set(matches.map(c=>c.name));
|
||||
for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){
|
||||
if(!name.startsWith(q)||seen.has(name))continue;
|
||||
matches.push({
|
||||
name,
|
||||
desc:spec.desc,
|
||||
arg:'name',
|
||||
source:'subarg-command',
|
||||
});
|
||||
seen.add(name);
|
||||
}
|
||||
for(const skill of _skillCommandCache){
|
||||
if(!skill.name.startsWith(q)||seen.has(skill.name))continue;
|
||||
matches.push(skill);
|
||||
seen.add(skill.name);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
let _slashModelCache=null;
|
||||
let _slashModelCachePromise=null;
|
||||
let _slashPersonalityCache=null;
|
||||
let _slashPersonalityCachePromise=null;
|
||||
|
||||
function _normalizeSlashSubArg(value){
|
||||
return String(value||'').trim();
|
||||
}
|
||||
|
||||
function _getSlashModelSubArgsFromDom(){
|
||||
const sel=$('modelSelect');
|
||||
if(!sel) return [];
|
||||
const values=[];
|
||||
for(const opt of Array.from(sel.options||[])){
|
||||
const value=_normalizeSlashSubArg(opt.value||opt.textContent||'');
|
||||
if(value) values.push(value);
|
||||
}
|
||||
return Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function _loadSlashModelSubArgs(force=false){
|
||||
const domValues=_getSlashModelSubArgsFromDom();
|
||||
if(domValues.length&&!force){
|
||||
_slashModelCache=domValues;
|
||||
return domValues;
|
||||
}
|
||||
if(_slashModelCache&&!force) return _slashModelCache;
|
||||
if(_slashModelCachePromise&&!force) return _slashModelCachePromise;
|
||||
_slashModelCachePromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/models');
|
||||
const values=[];
|
||||
for(const group of (data&&data.groups)||[]){
|
||||
for(const model of (group&&group.models)||[]){
|
||||
const id=_normalizeSlashSubArg(model&&model.id);
|
||||
if(id) values.push(id);
|
||||
}
|
||||
}
|
||||
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
|
||||
_slashModelCache=deduped;
|
||||
return deduped;
|
||||
}catch(_){
|
||||
_slashModelCache=domValues;
|
||||
return domValues;
|
||||
}finally{
|
||||
_slashModelCachePromise=null;
|
||||
}
|
||||
})();
|
||||
return _slashModelCachePromise;
|
||||
}
|
||||
|
||||
async function _loadSlashPersonalitySubArgs(force=false){
|
||||
if(_slashPersonalityCache&&!force) return _slashPersonalityCache;
|
||||
if(_slashPersonalityCachePromise&&!force) return _slashPersonalityCachePromise;
|
||||
_slashPersonalityCachePromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/personalities');
|
||||
const values=['none'];
|
||||
for(const p of (data&&data.personalities)||[]){
|
||||
const name=_normalizeSlashSubArg(p&&p.name);
|
||||
if(name) values.push(name);
|
||||
}
|
||||
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
|
||||
_slashPersonalityCache=deduped;
|
||||
return deduped;
|
||||
}catch(_){
|
||||
_slashPersonalityCache=['none'];
|
||||
return _slashPersonalityCache;
|
||||
}finally{
|
||||
_slashPersonalityCachePromise=null;
|
||||
}
|
||||
})();
|
||||
return _slashPersonalityCachePromise;
|
||||
}
|
||||
|
||||
function _getSlashSubArgOptions(spec){
|
||||
if(Array.isArray(spec)) return Promise.resolve(spec.slice());
|
||||
if(spec==='models') return _loadSlashModelSubArgs();
|
||||
if(spec==='personalities') return _loadSlashPersonalitySubArgs();
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
function _parseSlashAutocomplete(text){
|
||||
if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null;
|
||||
const raw=text.slice(1);
|
||||
const hasSpace=/\s/.test(raw);
|
||||
const parts=raw.split(/\s+/);
|
||||
const cmdName=(parts[0]||'').toLowerCase();
|
||||
const command=COMMANDS.find(c=>c.name===cmdName);
|
||||
const subArgSource=(command&&command.subArgs)?command:SLASH_SUBARG_SOURCES[cmdName];
|
||||
if(!hasSpace||!subArgSource){
|
||||
return {kind:'commands', query:raw};
|
||||
}
|
||||
const argText=raw.slice(cmdName.length).replace(/^\s+/,'');
|
||||
return {kind:'subargs', command:{name:cmdName, desc:subArgSource.desc, subArgs:subArgSource.subArgs}, query:argText.toLowerCase(), rawQuery:argText};
|
||||
}
|
||||
|
||||
async function getSlashAutocompleteMatches(text){
|
||||
const parsed=_parseSlashAutocomplete(text);
|
||||
if(!parsed) return [];
|
||||
if(parsed.kind==='commands') return getMatchingCommands(parsed.query);
|
||||
const options=await _getSlashSubArgOptions(parsed.command.subArgs);
|
||||
return options
|
||||
.filter(opt=>String(opt).toLowerCase().startsWith(parsed.query))
|
||||
.map(opt=>({
|
||||
name:parsed.command.name,
|
||||
value:String(opt),
|
||||
desc:parsed.command.desc,
|
||||
source:'subarg',
|
||||
parent:parsed.command.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function _compressionAnchorMessageKey(m){
|
||||
if(!m||!m.role||m.role==='tool') return null;
|
||||
let content='';
|
||||
try{
|
||||
content=typeof msgContent==='function' ? String(msgContent(m)||'') : String(m.content||'');
|
||||
}catch(_){
|
||||
content=String(m.content||'');
|
||||
}
|
||||
const norm=content.replace(/\s+/g,' ').trim().slice(0,160);
|
||||
const ts=m._ts||m.timestamp||null;
|
||||
const attachments=Array.isArray(m.attachments)?m.attachments.length:0;
|
||||
if(!norm && !attachments && !ts) return null;
|
||||
return {role:String(m.role||''), ts, text:norm, attachments};
|
||||
}
|
||||
|
||||
// ── Command handlers ────────────────────────────────────────────────────────
|
||||
|
||||
function cmdHelp(){
|
||||
const lines=COMMANDS.map(c=>{
|
||||
const usage=c.arg?` <${c.arg}>`:'';
|
||||
const usage=c.arg ? (String(c.arg).startsWith('[') ? ` ${c.arg}` : ` <${c.arg}>`) : '';
|
||||
return ` /${c.name}${usage} — ${c.desc}`;
|
||||
});
|
||||
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
|
||||
@@ -54,6 +220,7 @@ function cmdClear(){
|
||||
if(!S.session)return;
|
||||
S.messages=[];S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
renderMessages();
|
||||
$('emptyState').style.display='';
|
||||
showToast(t('conversation_cleared'));
|
||||
@@ -92,19 +259,137 @@ async function cmdWorkspace(args){
|
||||
}
|
||||
|
||||
async function cmdNew(){
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
await newSession();
|
||||
await renderSessionList();
|
||||
$('msg').focus();
|
||||
showToast(t('new_session'));
|
||||
}
|
||||
|
||||
function cmdCompact(){
|
||||
// Send as a regular message to the agent -- the agent's run_conversation
|
||||
// preflight will detect the high token count and trigger _compress_context.
|
||||
// We send a user message so it appears in the conversation.
|
||||
$('msg').value='Please compress and summarize the conversation context to free up space.';
|
||||
send();
|
||||
showToast(t('compressing'));
|
||||
async function _runManualCompression(focusTopic){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
let visibleCount=0;
|
||||
try{
|
||||
const sid=S.session.session_id;
|
||||
// Preflight: verify the viewed session still exists before compressing.
|
||||
// This avoids a confusing "not found" toast when the UI is stale.
|
||||
try{
|
||||
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
||||
if(!live||!live.session||live.session.session_id!==sid){
|
||||
throw new Error('session no longer available');
|
||||
}
|
||||
S.session=live.session;
|
||||
S.messages=live.session.messages||[];
|
||||
S.toolCalls=live.session.tool_calls||[];
|
||||
}catch(preflightErr){
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
showToast('Compression failed: '+(preflightErr.message||'session no longer available'));
|
||||
return;
|
||||
}
|
||||
if(typeof setBusy==='function') setBusy(true);
|
||||
const body={session_id:sid};
|
||||
if(focusTopic) body.focus_topic=focusTopic;
|
||||
const visibleMessages=(S.messages||[]).filter(m=>{
|
||||
if(!m||!m.role||m.role==='tool') return false;
|
||||
if(m.role==='assistant'){
|
||||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||||
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
||||
if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true;
|
||||
}
|
||||
return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length;
|
||||
});
|
||||
visibleCount=visibleMessages.length;
|
||||
const anchorVisibleIdx=Math.max(0, visibleCount - 1);
|
||||
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
|
||||
const commandText=focusTopic?`/compress ${focusTopic}`:'/compress';
|
||||
if(typeof setCompressionUi==='function'){
|
||||
setCompressionUi({
|
||||
sessionId:S.session.session_id,
|
||||
phase:'running',
|
||||
focusTopic:focusTopic||'',
|
||||
commandText,
|
||||
beforeCount:visibleCount,
|
||||
anchorVisibleIdx,
|
||||
anchorMessageKey,
|
||||
});
|
||||
}
|
||||
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
|
||||
renderMessages();
|
||||
const data=await api('/api/session/compress',{method:'POST',body:JSON.stringify(body)});
|
||||
if(data&&data.session){
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
if(data.session.session_id&&data.session.session_id!==currentSid){
|
||||
await loadSession(data.session.session_id);
|
||||
}else{
|
||||
S.session=data.session;
|
||||
S.messages=data.session.messages||[];
|
||||
S.toolCalls=data.session.tool_calls||[];
|
||||
clearLiveToolCards();
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
await renderSessionList();
|
||||
updateQueueBadge(S.session.session_id);
|
||||
}
|
||||
}
|
||||
const summary=data&&data.summary;
|
||||
if(typeof setCompressionUi==='function'&&S.session){
|
||||
const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m));
|
||||
const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';
|
||||
const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : '';
|
||||
// Prefer the persisted compaction handoff when it already exists in session state.
|
||||
// The short summary fallback is only for environments where that message is unavailable.
|
||||
const referenceText=messageRef || summaryRef;
|
||||
const effectiveFocus=(data&&data.focus_topic)||focusTopic||'';
|
||||
setCompressionUi({
|
||||
sessionId:S.session.session_id,
|
||||
phase:'done',
|
||||
focusTopic:effectiveFocus,
|
||||
commandText:effectiveFocus?`/compress ${effectiveFocus}`:'/compress',
|
||||
beforeCount:visibleCount,
|
||||
summary:summary||null,
|
||||
referenceText,
|
||||
anchorVisibleIdx: data?.session?.compression_anchor_visible_idx,
|
||||
anchorMessageKey: data?.session?.compression_anchor_message_key||null,
|
||||
});
|
||||
}
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
}catch(e){
|
||||
if(typeof setCompressionUi==='function'){
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
setCompressionUi({
|
||||
sessionId:currentSid||'',
|
||||
phase:'error',
|
||||
focusTopic:(focusTopic||'').trim(),
|
||||
commandText:focusTopic?`/compress ${focusTopic}`:'/compress',
|
||||
beforeCount:(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length,
|
||||
errorText:`Compression failed: ${e.message}`,
|
||||
anchorVisibleIdx: Math.max(0, visibleCount - 1),
|
||||
anchorMessageKey:null,
|
||||
});
|
||||
}
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
showToast('Compression failed: '+e.message);
|
||||
return;
|
||||
}
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
}
|
||||
|
||||
async function cmdCompress(args){
|
||||
await _runManualCompression((args||'').trim());
|
||||
}
|
||||
|
||||
async function cmdCompact(args){
|
||||
await _runManualCompression((args||'').trim());
|
||||
}
|
||||
|
||||
async function cmdUsage(){
|
||||
@@ -121,19 +406,48 @@ async function cmdUsage(){
|
||||
}
|
||||
|
||||
async function cmdTheme(args){
|
||||
const themes=['system','dark','light','slate','solarized','monokai','nord','oled'];
|
||||
if(!args||!themes.includes(args.toLowerCase())){
|
||||
showToast(t('theme_usage')+themes.join('|'));
|
||||
const themes=['system','dark','light'];
|
||||
const skins=(_SKINS||[]).map(s=>s.name.toLowerCase());
|
||||
const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});
|
||||
const val=(args||'').toLowerCase().trim();
|
||||
// Check if it's a theme
|
||||
if(themes.includes(val)||legacyThemes.includes(val)){
|
||||
const appearance=_normalizeAppearance(
|
||||
val,
|
||||
legacyThemes.includes(val)?null:localStorage.getItem('hermes-skin')
|
||||
);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applyTheme(appearance.theme);
|
||||
_applySkin(appearance.skin);
|
||||
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
|
||||
const sel=$('settingsTheme');
|
||||
if(sel)sel.value=appearance.theme;
|
||||
const skinSel=$('settingsSkin');
|
||||
if(skinSel)skinSel.value=appearance.skin;
|
||||
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
|
||||
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
|
||||
showToast(t('theme_set')+appearance.theme+(legacyThemes.includes(val)?` + ${appearance.skin}`:''));
|
||||
return;
|
||||
}
|
||||
const themeName=args.toLowerCase();
|
||||
localStorage.setItem('hermes-theme',themeName);
|
||||
_applyTheme(themeName);
|
||||
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){}
|
||||
// Update settings dropdown if panel is open
|
||||
const sel=$('settingsTheme');
|
||||
if(sel)sel.value=themeName;
|
||||
showToast(t('theme_set')+themeName);
|
||||
// Check if it's a skin
|
||||
if(skins.includes(val)){
|
||||
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),val);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applyTheme(appearance.theme);
|
||||
_applySkin(appearance.skin);
|
||||
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
|
||||
const sel=$('settingsSkin');
|
||||
if(sel)sel.value=appearance.skin;
|
||||
const themeSel=$('settingsTheme');
|
||||
if(themeSel)themeSel.value=appearance.theme;
|
||||
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
|
||||
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
|
||||
showToast(t('theme_set')+appearance.skin);
|
||||
return;
|
||||
}
|
||||
showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|'));
|
||||
}
|
||||
|
||||
async function cmdSkills(args){
|
||||
@@ -205,10 +519,201 @@ async function cmdPersonality(args){
|
||||
}
|
||||
try{
|
||||
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
|
||||
S.messages.push({role:'assistant',content:t('personality_set')+`**${name}**`});
|
||||
renderMessages();
|
||||
showToast(t('personality_set')+name);
|
||||
}catch(e){showToast(t('failed_colon')+e.message);}
|
||||
}
|
||||
|
||||
async function cmdStop(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
if(!S.activeStreamId){showToast(t('no_active_task'));return;}
|
||||
if(typeof cancelStream==='function'){await cancelStream();showToast(t('stream_stopped'));}
|
||||
else showToast(t('cancel_unavailable'));
|
||||
}
|
||||
async function cmdTitle(args){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
const name=(args||'').trim();
|
||||
if(!name){
|
||||
S.messages.push({role:'assistant',content:`${t('title_current')}: **${S.session.title||t('untitled')}**\n\n${t('title_change_hint')}`});
|
||||
renderMessages();return;
|
||||
}
|
||||
try{
|
||||
const r=await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,title:name})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
S.session.title=(r&&r.session&&r.session.title)||name;
|
||||
if(typeof syncTopbar==='function')syncTopbar();
|
||||
if(typeof renderSessionList==='function')renderSessionList();
|
||||
showToast(`${t('title_set')} "${S.session.title}"`);
|
||||
S.messages.push({role:'assistant',content:`${t('title_set')} **${S.session.title}**`});
|
||||
renderMessages();
|
||||
}catch(e){showToast(t('failed_colon')+e.message);}
|
||||
}
|
||||
async function cmdRetry(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
|
||||
const activeSid=S.session.session_id;
|
||||
try{
|
||||
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
if(!S.session||S.session.session_id!==activeSid)return;
|
||||
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
|
||||
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();}
|
||||
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();await send();
|
||||
}catch(e){showToast(t('retry_failed')+e.message);}
|
||||
}
|
||||
async function cmdUndo(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
|
||||
const activeSid=S.session.session_id;
|
||||
try{
|
||||
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
if(!S.session||S.session.session_id!==activeSid)return;
|
||||
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
|
||||
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();}
|
||||
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
|
||||
}catch(e){showToast(t('undo_failed')+e.message);}
|
||||
}
|
||||
async function undoLastExchange(){await cmdUndo();}
|
||||
async function cmdBtw(args){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
const question=(args||'').trim();
|
||||
if(!question){showToast(t('cmd_btw_usage'));return;}
|
||||
showToast(t('btw_asking'));
|
||||
const activeSid=S.session.session_id;
|
||||
try{
|
||||
const r=await api('/api/btw',{method:'POST',body:JSON.stringify({session_id:activeSid,question})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
// Connect to the ephemeral SSE stream
|
||||
const streamId=r.stream_id;
|
||||
const parentSid=r.parent_session_id;
|
||||
if(typeof attachBtwStream==='function') attachBtwStream(parentSid,streamId,question);
|
||||
}catch(e){showToast(t('btw_failed')+e.message);}
|
||||
}
|
||||
async function cmdBackground(args){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
const prompt=(args||'').trim();
|
||||
if(!prompt){showToast(t('cmd_background_usage'));return;}
|
||||
showToast(t('bg_running'));
|
||||
const activeSid=S.session.session_id;
|
||||
try{
|
||||
const r=await api('/api/background',{method:'POST',body:JSON.stringify({session_id:activeSid,prompt})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
// Show background badge and start polling
|
||||
if(typeof showBackgroundBadge==='function') showBackgroundBadge(r.task_id);
|
||||
if(typeof startBackgroundPolling==='function') startBackgroundPolling(activeSid,r.task_id,prompt);
|
||||
}catch(e){showToast(t('bg_failed')+e.message);}
|
||||
}
|
||||
async function cmdStatus(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
try{
|
||||
const r=await api('/api/session/status?session_id='+encodeURIComponent(S.session.session_id));
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
S.messages.push({role:'assistant',content:[`**${t('status_heading')}**`,'',`**${t('status_session_id')}:** \`${r.session_id}\``,`**${t('status_title')}:** ${r.title||t('untitled')}`,`**${t('status_model')}:** ${r.model||t('usage_default_model')}`,`**${t('status_workspace')}:** ${r.workspace}`,`**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`,`**${t('status_messages')}:** ${r.message_count}`,`**${t('status_agent_running')}:** ${r.agent_running?t('status_yes'):t('status_no')}`,].join('\n')});
|
||||
renderMessages();
|
||||
}catch(e){showToast(t('status_load_failed')+e.message);}
|
||||
}
|
||||
function cmdReasoning(args){
|
||||
const arg=(args||'').trim().toLowerCase();
|
||||
const BRAIN='\uD83E\uDDE0';
|
||||
// Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity).
|
||||
const EFFORTS=['none','minimal','low','medium','high','xhigh'];
|
||||
// Shared status renderer used by the no-args branch and as a fallback.
|
||||
function _fmtStatus(st){
|
||||
const vis=(st && st.show_reasoning===false)?'off':'on';
|
||||
const eff=(st && st.reasoning_effort)||'default';
|
||||
return BRAIN+' Reasoning effort: '+eff+' \u00B7 display: '+vis
|
||||
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh';
|
||||
}
|
||||
if(!arg){
|
||||
// Status — read from the same config.yaml keys the CLI uses.
|
||||
api('/api/reasoning').then(function(st){showToast(_fmtStatus(st));})
|
||||
.catch(function(){showToast(BRAIN+' /reasoning — status unavailable');});
|
||||
return true;
|
||||
}
|
||||
if(arg==='show'||arg==='on'||arg==='hide'||arg==='off'){
|
||||
const on=(arg==='show'||arg==='on');
|
||||
// Update the UI render gate immediately for responsiveness.
|
||||
window._showThinking=on;
|
||||
if(typeof renderMessages==='function') renderMessages();
|
||||
// Persist via /api/reasoning → config.yaml display.show_reasoning
|
||||
// (CLI reads the same key). Also mirror into WebUI settings.json
|
||||
// show_thinking so boot.js picks it up on reload without hitting
|
||||
// /api/reasoning on every page load.
|
||||
api('/api/reasoning',{method:'POST',body:JSON.stringify({display:arg})}).catch(function(){});
|
||||
api('/api/settings',{method:'POST',body:JSON.stringify({show_thinking:on})}).catch(function(){});
|
||||
showToast(BRAIN+' Thinking blocks: '+(on?'on':'off')+' (saved)');
|
||||
return true;
|
||||
}
|
||||
if(EFFORTS.includes(arg)){
|
||||
// Persist via /api/reasoning → config.yaml agent.reasoning_effort.
|
||||
// Takes effect on the NEXT session/turn (agent re-reads config at
|
||||
// construction time), matching CLI semantics where `/reasoning high`
|
||||
// also forces an agent re-init.
|
||||
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
|
||||
.then(function(st){
|
||||
const eff=(st && st.reasoning_effort)||arg;
|
||||
showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)');
|
||||
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff);
|
||||
})
|
||||
.catch(function(e){
|
||||
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
showToast('Unknown argument: '+arg+' \u2014 use show|hide|'+EFFORTS.join('|'));
|
||||
return true;
|
||||
}
|
||||
function cmdVoice(){
|
||||
const mic=document.getElementById('btnMic');
|
||||
if(mic&&mic.style.display!=='none'&&!mic.disabled){try{mic.click();return;}catch(_){}}
|
||||
showToast(t('cmd_voice_use_mic'));
|
||||
}
|
||||
let _skillCommandCache=[];
|
||||
let _skillCommandLoadPromise=null;
|
||||
let _skillCommandCacheReady=false;
|
||||
function _skillCommandSlug(name){
|
||||
const raw=String(name||'').trim().toLowerCase();
|
||||
if(!raw)return'';
|
||||
return raw.replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-{2,}/g,'-').replace(/^-+|-+$/g,'');
|
||||
}
|
||||
function _buildSkillCommandEntry(skill){
|
||||
const skillName=String(skill&&skill.name||'').trim();
|
||||
const slug=_skillCommandSlug(skillName);
|
||||
if(!slug)return null;
|
||||
if(COMMANDS.some(c=>c.name===slug)) return null;
|
||||
return{name:slug,desc:String(skill&&skill.description||'').trim()||t('slash_skill_desc'),source:'skill',skillName};
|
||||
}
|
||||
async function loadSkillCommands(force=false){
|
||||
if(_skillCommandCacheReady&&!force)return _skillCommandCache;
|
||||
if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise;
|
||||
_skillCommandLoadPromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/skills');
|
||||
const deduped=new Map();
|
||||
for(const skill of (data&&data.skills)||[]){const entry=_buildSkillCommandEntry(skill);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);}
|
||||
_skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name));
|
||||
}catch(_){_skillCommandCache=[];}
|
||||
finally{_skillCommandCacheReady=true;_skillCommandLoadPromise=null;}
|
||||
return _skillCommandCache;
|
||||
})();
|
||||
return _skillCommandLoadPromise;
|
||||
}
|
||||
function refreshSlashCommandDropdown(){
|
||||
const ta=$('msg');if(!ta)return;
|
||||
const text=ta.value||'';
|
||||
if(!text.startsWith('/')||text.indexOf('\n')!==-1){hideCmdDropdown();return;}
|
||||
getSlashAutocompleteMatches(text).then(matches=>{
|
||||
if(($('msg').value||'')!==text) return;
|
||||
if(matches.length)showCmdDropdown(matches);else hideCmdDropdown();
|
||||
});
|
||||
}
|
||||
function ensureSkillCommandsLoadedForAutocomplete(){
|
||||
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
|
||||
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
|
||||
}
|
||||
|
||||
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
||||
|
||||
let _cmdSelectedIdx=-1;
|
||||
@@ -217,19 +722,36 @@ function showCmdDropdown(matches){
|
||||
const dd=$('cmdDropdown');
|
||||
if(!dd)return;
|
||||
dd.innerHTML='';
|
||||
_cmdSelectedIdx=-1;
|
||||
_cmdSelectedIdx=matches.length?0:-1;
|
||||
for(let i=0;i<matches.length;i++){
|
||||
const c=matches[i];
|
||||
const el=document.createElement('div');
|
||||
el.className='cmd-item';
|
||||
if(i===_cmdSelectedIdx) el.classList.add('selected');
|
||||
el.dataset.idx=i;
|
||||
const usage=c.arg?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
|
||||
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
|
||||
const isSubArg=c.source==='subarg';
|
||||
const usage=(!isSubArg&&c.arg)?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
|
||||
const badge=c.source==='skill'?`<span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`:'';
|
||||
if(c.source==='skill') el.classList.add('cmd-item-skill');
|
||||
const nameHtml=isSubArg
|
||||
? `<div class="cmd-item-name"><span class="cmd-item-parent">/${esc(c.parent)}</span> <span class="cmd-item-subarg">${esc(c.value)}</span></div>`
|
||||
: `<div class="cmd-item-name">/${esc(c.name)}${usage}${badge}</div>`;
|
||||
const descHtml=`<div class="cmd-item-desc">${esc(c.desc)}</div>`;
|
||||
el.innerHTML=`${nameHtml}${descHtml}`;
|
||||
el.onmousedown=(e)=>{
|
||||
e.preventDefault();
|
||||
$('msg').value='/'+c.name+(c.arg?' ':'');
|
||||
hideCmdDropdown();
|
||||
const nextValue=isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':''));
|
||||
$('msg').value=nextValue;
|
||||
$('msg').focus();
|
||||
if(!isSubArg&&c.source!=='skill'&&nextValue.endsWith(' ')&&typeof getSlashAutocompleteMatches==='function'){
|
||||
getSlashAutocompleteMatches(nextValue).then(matches=>{
|
||||
if(($('msg').value||'')!==nextValue) return;
|
||||
if(matches.length) showCmdDropdown(matches);
|
||||
else hideCmdDropdown();
|
||||
});
|
||||
}else{
|
||||
hideCmdDropdown();
|
||||
}
|
||||
};
|
||||
dd.appendChild(el);
|
||||
}
|
||||
@@ -252,6 +774,9 @@ function navigateCmdDropdown(dir){
|
||||
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
|
||||
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
|
||||
items[_cmdSelectedIdx].classList.add('selected');
|
||||
// Scroll the newly highlighted item into view so it stays visible when the
|
||||
// dropdown overflows and the user navigates with keyboard (#838).
|
||||
items[_cmdSelectedIdx].scrollIntoView({block:'nearest'});
|
||||
}
|
||||
|
||||
function selectCmdDropdownItem(){
|
||||
@@ -265,3 +790,9 @@ function selectCmdDropdownItem(){
|
||||
}
|
||||
hideCmdDropdown();
|
||||
}
|
||||
|
||||
// ── Handler aliases (for test-discoverable command registration) ──────────────
|
||||
// The COMMANDS array above is the authoritative dispatch table. These aliases
|
||||
// allow tooling and tests to discover command handlers by name independently.
|
||||
const HANDLERS = {};
|
||||
HANDLERS.skills = cmdSkills;
|
||||
|
||||
BIN
static/favicon-32.png
Normal file
BIN
static/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
20
static/favicon.svg
Normal file
20
static/favicon.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="12" fill="#1a1a1a"/>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#g)"/>
|
||||
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
|
||||
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
|
||||
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
|
||||
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
|
||||
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42"
|
||||
fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42"
|
||||
fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
|
||||
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1608
static/i18n.js
1608
static/i18n.js
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ const LI_PATHS = {
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
'undo': '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>',
|
||||
'check': '<polyline points="20 6 9 17 4 12"/>',
|
||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
|
||||
@@ -4,179 +4,203 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hermes</title>
|
||||
<link rel="icon" type="image/svg+xml" href="static/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
|
||||
<link rel="shortcut icon" href="static/favicon.ico">
|
||||
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<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" href="static/favicon.svg">
|
||||
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
|
||||
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
|
||||
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</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},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>
|
||||
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
|
||||
<!-- streaming-markdown: incremental DOM-building markdown parser for live streams -->
|
||||
<!-- Self-hosted from npm:streaming-markdown@0.2.15 — no CDN dependency. -->
|
||||
<!-- sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa -->
|
||||
<!-- ES module imports do not support the integrity= attribute (W3C limitation); -->
|
||||
<!-- version is pinned in the vendored file path; hash documented above for audit. -->
|
||||
<script type="module">
|
||||
import * as smd from '/static/vendor/smd.min.js';
|
||||
// SRI verification happens at the ES module level via importmap or SW; pinning version in URL.
|
||||
// sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa
|
||||
window.smd = smd;
|
||||
</script>
|
||||
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
||||
<link id="prism-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
|
||||
<!-- PWA service worker registration -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('sw.js').catch(function(err) {
|
||||
console.warn('[pwa] Service worker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-titlebar" role="banner">
|
||||
<button class="app-titlebar-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" type="button" title="Menu" aria-label="Menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="app-titlebar-inner">
|
||||
<span class="app-titlebar-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64" width="16" height="16" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="app-titlebar-gold" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#F5C542"/>
|
||||
<stop offset="100%" style="stop-color:#D4961C"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#app-titlebar-gold)"/>
|
||||
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
|
||||
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
|
||||
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>
|
||||
<span class="app-titlebar-sub" id="appTitlebarSub" hidden></span>
|
||||
</div>
|
||||
<div class="app-titlebar-spacer" aria-hidden="true"></div>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<nav class="rail" aria-label="Primary navigation">
|
||||
<button class="rail-btn nav-tab active" data-panel="chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat" aria-label="Chat"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks" aria-label="Tasks"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills" aria-label="Skills"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory" aria-label="Memory"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="workspaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces" aria-label="Spaces"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles" aria-label="Agent profiles"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
<div class="rail-spacer"></div>
|
||||
<button class="rail-btn nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
</nav>
|
||||
<aside class="sidebar">
|
||||
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
|
||||
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
|
||||
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg></button>
|
||||
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg></button>
|
||||
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
|
||||
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
|
||||
<button class="nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
</div>
|
||||
<!-- Chat panel -->
|
||||
<div class="panel-view active" id="panelChat">
|
||||
<div class="sidebar-section">
|
||||
<button class="new-chat-btn" id="btnNewChat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<span data-i18n="new_conversation">New conversation</span> <span style="font-size:10px;opacity:.5;margin-left:4px">Cmd+K</span>
|
||||
</button>
|
||||
<div class="panel-head">
|
||||
<span data-i18n="tab_chat">Chat</span>
|
||||
<div class="panel-head-actions">
|
||||
<button class="panel-head-btn" id="btnNewChat" title="New conversation (Cmd+K)" data-i18n-title="new_conversation" aria-label="New conversation">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()"></div>
|
||||
<div class="session-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()" autocomplete="off"></div>
|
||||
<div class="session-list" id="sessionList"></div>
|
||||
</div>
|
||||
<!-- Tasks (cron) panel -->
|
||||
<div class="panel-view" id="panelTasks">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
|
||||
</div>
|
||||
<!-- Create job form (hidden by default) -->
|
||||
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<input id="cronFormSchedule" placeholder="Schedule: '0 9 * * *' or 'every 1h'" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<textarea id="cronFormPrompt" rows="3" placeholder="Prompt (must be self-contained)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:6px"></textarea>
|
||||
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<option value="local">Local (save output only)</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<div class="skill-picker-wrap" style="margin-bottom:8px">
|
||||
<input id="cronFormSkillSearch" placeholder="Add skills (optional)..." style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none" autocomplete="off">
|
||||
<div id="cronFormSkillDropdown" class="skill-picker-dropdown" style="display:none"></div>
|
||||
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
|
||||
<div class="panel-head">
|
||||
<span data-i18n="scheduled_jobs">Scheduled jobs</span>
|
||||
<div class="panel-head-actions">
|
||||
<button class="panel-head-btn" id="cronRefreshBtn" onclick="loadCrons(true)" title="Refresh job list" aria-label="Refresh job list"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
|
||||
<button class="panel-head-btn" onclick="openCronCreate()" title="New job" data-i18n-title="new_job" aria-label="New job"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()" data-i18n="create_job">Create job</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Skills panel -->
|
||||
<div class="panel-view" id="panelSkills">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ <span data-i18n="new_skill">New skill</span></button>
|
||||
</div>
|
||||
<!-- Skill create/edit form (hidden by default) -->
|
||||
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
<input id="skillFormName" placeholder="Skill name (e.g. my-skill)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()" data-i18n="save_skill">Save skill</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()" data-i18n="cancel">Cancel</button>
|
||||
<div class="panel-head">
|
||||
<span data-i18n="tab_skills">Skills</span>
|
||||
<div class="panel-head-actions">
|
||||
<button class="panel-head-btn" onclick="openSkillCreate()" title="New skill" data-i18n-title="new_skill" aria-label="New skill"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div class="skills-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
|
||||
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Memory panel -->
|
||||
<div class="panel-view" id="panelMemory">
|
||||
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
|
||||
<span style="font-size:11px;color:var(--muted)" data-i18n="personal_memory">Personal memory</span>
|
||||
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> <span data-i18n="edit">Edit</span></button>
|
||||
</div>
|
||||
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
<!-- Memory edit form (hidden by default) -->
|
||||
<div id="memoryEditForm" style="display:none;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px"><span data-i18n="editing">Editing</span>: <span id="memEditSection">memory</span></div>
|
||||
<textarea id="memEditContent" rows="10" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:11px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;box-sizing:border-box;margin-bottom:6px;line-height:1.5"></textarea>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()" data-i18n="save">Save</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
<div class="panel-head">
|
||||
<span data-i18n="personal_memory">Personal memory</span>
|
||||
</div>
|
||||
<div class="side-menu" id="memoryPanel"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Todo panel -->
|
||||
<div class="panel-view" id="panelTodos">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0" data-i18n="current_task_list">Current task list</div>
|
||||
<div class="panel-head">
|
||||
<span data-i18n="current_task_list">Current task list</span>
|
||||
</div>
|
||||
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
|
||||
</div>
|
||||
<!-- Workspaces panel -->
|
||||
<div class="panel-view" id="panelWorkspaces">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
<div class="panel-head">
|
||||
<span data-i18n="tab_workspaces">Spaces</span>
|
||||
<div class="panel-head-actions">
|
||||
<button class="panel-head-btn" onclick="openWorkspaceCreate()" title="Add space" data-i18n-title="workspace_add_title" aria-label="Add space"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-head-sub" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:8px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Profiles panel -->
|
||||
<div class="panel-view" id="panelProfiles">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="font-size:11px;color:var(--muted)" data-i18n="tab_profiles">Agent profiles</div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ <span data-i18n="new_profile">New profile</span></button>
|
||||
</div>
|
||||
<!-- Profile create form (hidden by default) -->
|
||||
<div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
<input id="profileFormName" placeholder="Profile name (lowercase, a-z 0-9 hyphens)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);margin-bottom:8px;cursor:pointer">
|
||||
<input type="checkbox" id="profileFormClone" style="accent-color:var(--accent)"> Clone config from active profile
|
||||
</label>
|
||||
<input id="profileFormBaseUrl" placeholder="Base URL (optional, e.g. http://localhost:11434)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<input id="profileFormApiKey" type="password" placeholder="API key (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitProfileCreate()">Create</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleProfileForm()">Cancel</button>
|
||||
<div class="panel-head">
|
||||
<span data-i18n="tab_profiles">Agent profiles</span>
|
||||
<div class="panel-head-actions">
|
||||
<button class="panel-head-btn" onclick="openProfileCreate()" title="New profile" data-i18n-title="new_profile" aria-label="New profile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
<div id="profileFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<div style="flex:1;overflow-y:auto;padding:8px" id="profilesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<div class="sidebar-bottom">
|
||||
<button class="hermes-launch-btn" id="btnHermesPanel" onclick="toggleSettings()" title="Open Hermes control center">
|
||||
<span class="hermes-launch-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="hermes-gold-sidebar" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#hermes-gold-sidebar)"/>
|
||||
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
|
||||
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
|
||||
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
|
||||
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
|
||||
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42" fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42" fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
|
||||
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
|
||||
</svg></span>
|
||||
<span class="hermes-launch-copy">
|
||||
<span class="hermes-launch-title">Hermes WebUI</span>
|
||||
<span class="hermes-launch-meta">Preferences, imports, exports</span>
|
||||
</span>
|
||||
<span class="hermes-launch-chevron" 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"><polyline points="9 18 15 12 9 6"/></svg></span>
|
||||
</button>
|
||||
<!-- Settings panel (menu list; actual panes render in .main) -->
|
||||
<div class="panel-view" id="panelSettings">
|
||||
<div class="panel-head">
|
||||
<span data-i18n="tab_settings">Settings</span>
|
||||
</div>
|
||||
<div class="side-menu" id="settingsMenu">
|
||||
<button type="button" class="side-menu-item active" data-settings-section="conversation" onclick="switchSettingsSection('conversation')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>Conversation</span>
|
||||
</button>
|
||||
<button type="button" class="side-menu-item" data-settings-section="appearance" onclick="switchSettingsSection('appearance')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
<span>Appearance</span>
|
||||
</button>
|
||||
<button type="button" class="side-menu-item" data-settings-section="preferences" onclick="switchSettingsSection('preferences')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
|
||||
<span>Preferences</span>
|
||||
</button>
|
||||
<button type="button" class="side-menu-item" data-settings-section="providers" onclick="switchSettingsSection('providers')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
<span data-i18n="providers_tab_title">Providers</span>
|
||||
</button>
|
||||
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
|
||||
<span>System</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resize-handle" id="sidebarResize"></div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<button class="mobile-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" title="Menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
|
||||
<div class="topbar-chips">
|
||||
<button class="chip workspace-toggle-btn" id="btnWorkspacePanelToggle" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg><span class="workspace-toggle-label">Files</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainChat" class="main-view">
|
||||
<div class="messages" id="messages">
|
||||
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" aria-label="Scroll to bottom" onclick="scrollToBottom()" style="display:none">↓</button>
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
|
||||
<defs>
|
||||
@@ -204,13 +228,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-inner" id="msgInner"></div>
|
||||
<div id="liveCompressionCards" class="live-compression-cards"></div>
|
||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||
</div>
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<span id="updateMsg"></span>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
|
||||
<span id="updateMsg"></span>
|
||||
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
|
||||
<button class="update-btn" onclick="dismissUpdate()">Later</button>
|
||||
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
|
||||
<button class="update-btn" id="btnForceUpdate" style="display:none;background:var(--error,#e05);color:#fff;border-color:var(--error,#e05)" onclick="forceUpdate(this)">Force update</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconnect-banner" id="reconnectBanner">
|
||||
@@ -221,7 +250,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-wrap" id="composerWrap">
|
||||
<div class="cmd-dropdown" id="cmdDropdown"></div>
|
||||
<div class="composer-flyout">
|
||||
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
|
||||
<div class="approval-inner">
|
||||
@@ -270,6 +298,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-box" id="composerBox">
|
||||
<div class="cmd-dropdown" id="cmdDropdown"></div>
|
||||
<div class="drop-hint" id="dropHint">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
|
||||
Drop files to upload to workspace
|
||||
@@ -300,16 +329,20 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-ws-wrap">
|
||||
<button class="composer-workspace-chip ws-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
|
||||
<span class="composer-workspace-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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<span class="composer-workspace-label" id="composerWorkspaceLabel">Workspace</span>
|
||||
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
<div class="composer-workspace-group ws-chip" id="composerWorkspaceGroup" role="group" aria-label="Workspace controls">
|
||||
<button class="composer-workspace-files-btn" id="btnWorkspacePanelToggle" type="button" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false" aria-label="Toggle workspace files panel">
|
||||
<span class="composer-workspace-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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
||||
</button>
|
||||
<button class="composer-workspace-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
|
||||
<span class="composer-workspace-label" id="composerWorkspaceLabel"></span>
|
||||
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-model-wrap">
|
||||
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
|
||||
<span class="composer-model-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"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
|
||||
<span class="composer-model-label" id="composerModelLabel">Model</span>
|
||||
<span class="composer-model-label" id="composerModelLabel"></span>
|
||||
<span class="composer-model-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
<select id="modelSelect" class="composer-model-select" title="Conversation model" aria-hidden="true" tabindex="-1">
|
||||
@@ -325,12 +358,20 @@
|
||||
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option>
|
||||
</optgroup>
|
||||
<optgroup label="Other">
|
||||
<option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option>
|
||||
<option value="google/gemini-3.1-pro-preview">Gemini 3.1 Pro Preview</option>
|
||||
<option value="google/gemini-3-flash-preview">Gemini 3 Flash Preview</option>
|
||||
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option>
|
||||
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
<span class="composer-reasoning-label" id="composerReasoningLabel"></span>
|
||||
<span class="composer-reasoning-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-right">
|
||||
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
||||
@@ -355,102 +396,122 @@
|
||||
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" style="display:none" title="Stop generation" aria-label="Stop generation">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
|
||||
</button>
|
||||
<span class="bg-badge" id="bgBadge" style="display:none" title="Background tasks running">0</span>
|
||||
<button class="send-btn" id="btnSend" title="Send message" disabled>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="profile-dropdown" id="profileDropdown"></div>
|
||||
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
|
||||
<div class="composer-reasoning-dropdown" id="composerReasoningDropdown">
|
||||
<div class="reasoning-option" data-effort="none">None</div>
|
||||
<div class="reasoning-option" data-effort="minimal">Minimal</div>
|
||||
<div class="reasoning-option" data-effort="low">Low</div>
|
||||
<div class="reasoning-option" data-effort="medium">Medium</div>
|
||||
<div class="reasoning-option" data-effort="high">High</div>
|
||||
<div class="reasoning-option" data-effort="xhigh">Extra High</div>
|
||||
</div>
|
||||
<div class="model-dropdown" id="composerModelDropdown"></div>
|
||||
</div>
|
||||
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<aside class="rightpanel">
|
||||
<div class="resize-handle" id="rightpanelResize"></div>
|
||||
<div class="panel-header">
|
||||
<span>Workspace</span>
|
||||
<span class="git-badge" id="gitBadge" style="display:none"></span>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button class="panel-icon-btn mobile-close-btn" onclick="handleWorkspaceClose()" title="Close" aria-label="Close workspace panel">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
||||
<div class="file-tree" id="fileTree"></div>
|
||||
<div class="preview-area" id="previewArea">
|
||||
<div class="preview-path" id="previewPath">
|
||||
<span id="previewPathText"></span>
|
||||
<span class="preview-badge" id="previewBadge"></span>
|
||||
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:inline-flex;align-items:center;gap:4px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer"><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> Download</button>
|
||||
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><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="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
|
||||
</div>
|
||||
<pre class="preview-code" id="previewCode"></pre>
|
||||
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
|
||||
<div class="preview-md" id="previewMd" style="display:none"></div>
|
||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:#e2e8f0;border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
|
||||
<div class="onboarding-card">
|
||||
<div class="onboarding-shell">
|
||||
<div class="onboarding-sidebar">
|
||||
<div class="onboarding-badge" data-i18n="onboarding_badge">FIRST RUN</div>
|
||||
<h2 id="onboardingTitle" data-i18n="onboarding_title">Welcome to Hermes Web UI</h2>
|
||||
<p id="onboardingLead" data-i18n="onboarding_lead">A quick guided setup will check your Hermes install, choose a workspace and model, and optionally protect the app with a password.</p>
|
||||
<div class="onboarding-steps" id="onboardingSteps"></div>
|
||||
</div>
|
||||
<div class="onboarding-main">
|
||||
<div class="onboarding-status" id="onboardingNotice"></div>
|
||||
<div class="onboarding-body" id="onboardingBody"></div>
|
||||
<div class="onboarding-actions">
|
||||
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" style="display:none" data-i18n="onboarding_back">Back</button>
|
||||
<button class="sm-btn" id="onboardingSkipBtn" onclick="skipOnboarding()" style="margin-right:auto;opacity:.7" data-i18n="onboarding_skip">Skip setup</button>
|
||||
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
|
||||
</div><!-- /#mainChat -->
|
||||
<div id="mainSkills" class="main-view">
|
||||
<div class="main-view-header">
|
||||
<div class="main-view-title" id="skillDetailTitle"></div>
|
||||
<div class="main-view-actions">
|
||||
<button id="btnEditSkillDetail" class="panel-head-btn" title="Edit" data-i18n-title="skills_edit" onclick="editCurrentSkill()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button id="btnDeleteSkillDetail" class="panel-head-btn" title="Delete" data-i18n-title="skills_delete" onclick="deleteCurrentSkill()" style="display:none"><svg 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 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
<button id="btnCancelSkillDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelSkillForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button id="btnSaveSkillDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="saveSkillForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-overlay" id="settingsOverlay" style="display:none">
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header">
|
||||
<div class="settings-heading">
|
||||
<div class="settings-kicker">Hermes WebUI</div>
|
||||
<h3 style="margin:0;font-size:18px">Control Center</h3>
|
||||
<div class="settings-subtitle">Preferences, conversation tools, and system controls.</div>
|
||||
<div class="main-view-body" id="skillDetailBody" style="display:none"></div>
|
||||
<div class="main-view-empty" id="skillDetailEmpty">
|
||||
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<div class="main-view-empty-title" data-i18n="skills_empty_title">Select a skill</div>
|
||||
<div class="main-view-empty-sub" data-i18n="skills_empty_sub">Pick a skill from the sidebar to view its contents, or create a new one.</div>
|
||||
</div>
|
||||
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="settings-shell">
|
||||
<div class="settings-tabs" role="tablist" aria-label="Hermes control center sections">
|
||||
<button class="settings-tab active" id="settingsTabConversation" type="button" role="tab" aria-selected="true" aria-controls="settingsPaneConversation" onclick="switchSettingsSection('conversation')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span class="settings-tab-title">Conversation</span>
|
||||
</button>
|
||||
<button class="settings-tab" id="settingsTabPreferences" type="button" role="tab" aria-selected="false" aria-controls="settingsPanePreferences" onclick="switchSettingsSection('preferences')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
|
||||
<span class="settings-tab-title">Preferences</span>
|
||||
</button>
|
||||
<button class="settings-tab" id="settingsTabSystem" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneSystem" onclick="switchSettingsSection('system')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
|
||||
<span class="settings-tab-title">System</span>
|
||||
</button>
|
||||
<div id="mainMemory" class="main-view">
|
||||
<div class="main-view-header">
|
||||
<div class="main-view-title" id="memoryDetailTitle"></div>
|
||||
<div class="main-view-actions">
|
||||
<button id="btnEditMemoryDetail" class="panel-head-btn" title="Edit" aria-label="Edit" data-i18n-title="edit" onclick="editCurrentMemory()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button id="btnCancelMemoryDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelMemoryEdit()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button id="btnSaveMemoryDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="submitMemorySave()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
|
||||
</div>
|
||||
<div class="settings-main">
|
||||
<div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation">
|
||||
</div>
|
||||
<div class="main-view-body" id="memoryDetailBody" style="display:none"></div>
|
||||
<div class="main-view-empty" id="memoryDetailEmpty">
|
||||
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg>
|
||||
<div class="main-view-empty-title" data-i18n="memory_empty_title">Select a memory section</div>
|
||||
<div class="main-view-empty-sub" data-i18n="memory_empty_sub">Pick a section from the sidebar to view or edit its contents.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainTasks" class="main-view">
|
||||
<div class="main-view-header">
|
||||
<div class="main-view-title" id="taskDetailTitle"></div>
|
||||
<div class="main-view-actions">
|
||||
<button id="btnRunTaskDetail" class="panel-head-btn" title="Run now" data-i18n-title="cron_run_now" onclick="runCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
||||
<button id="btnPauseTaskDetail" class="panel-head-btn" title="Pause" data-i18n-title="cron_pause" onclick="pauseCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg></button>
|
||||
<button id="btnResumeTaskDetail" class="panel-head-btn" title="Resume" data-i18n-title="cron_resume" onclick="resumeCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
||||
<button id="btnEditTaskDetail" class="panel-head-btn" title="Edit" data-i18n-title="edit" onclick="editCurrentCron()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button id="btnDeleteTaskDetail" class="panel-head-btn" title="Delete" data-i18n-title="delete_title" onclick="deleteCurrentCron()" style="display:none"><svg 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 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
<button id="btnCancelTaskDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelCronForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button id="btnSaveTaskDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="saveCronForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-view-body" id="taskDetailBody" style="display:none"></div>
|
||||
<div class="main-view-empty" id="taskDetailEmpty">
|
||||
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<div class="main-view-empty-title" data-i18n="tasks_empty_title">Select a scheduled job</div>
|
||||
<div class="main-view-empty-sub" data-i18n="tasks_empty_sub">Pick a job from the sidebar to view its details and runs, or create a new one.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainWorkspaces" class="main-view">
|
||||
<div class="main-view-header">
|
||||
<div class="main-view-title" id="workspaceDetailTitle"></div>
|
||||
<div class="main-view-actions">
|
||||
<button id="btnActivateWorkspaceDetail" class="panel-head-btn" title="Use this space" data-i18n-title="workspace_use_title" onclick="activateCurrentWorkspace()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
|
||||
<button id="btnEditWorkspaceDetail" class="panel-head-btn" title="Rename" data-i18n-title="edit" onclick="editCurrentWorkspace()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||||
<button id="btnDeleteWorkspaceDetail" class="panel-head-btn" title="Remove" data-i18n-title="remove" onclick="deleteCurrentWorkspace()" style="display:none"><svg 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 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
<button id="btnCancelWorkspaceDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelWorkspaceForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button id="btnSaveWorkspaceDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="saveWorkspaceForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-view-body" id="workspaceDetailBody" style="display:none"></div>
|
||||
<div class="main-view-empty" id="workspaceDetailEmpty">
|
||||
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<div class="main-view-empty-title" data-i18n="workspaces_empty_title">Select a space</div>
|
||||
<div class="main-view-empty-sub" data-i18n="workspaces_empty_sub">Pick a space from the sidebar to view its files and settings, or add a new one.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainProfiles" class="main-view">
|
||||
<div class="main-view-header">
|
||||
<div class="main-view-title" id="profileDetailTitle"></div>
|
||||
<div class="main-view-actions">
|
||||
<button id="btnActivateProfileDetail" class="panel-head-btn" title="Activate" data-i18n-title="profile_switch_title" onclick="activateCurrentProfile()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
|
||||
<button id="btnDeleteProfileDetail" class="panel-head-btn" title="Delete" data-i18n-title="profile_delete_title" onclick="deleteCurrentProfile()" style="display:none"><svg 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 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
<button id="btnCancelProfileDetail" class="panel-head-btn" title="Cancel" data-i18n-title="cancel" onclick="cancelProfileForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button id="btnSaveProfileDetail" class="panel-head-btn primary" title="Save" data-i18n-title="save" onclick="saveProfileForm()" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-view-body" id="profileDetailBody" style="display:none"></div>
|
||||
<div class="main-view-empty" id="profileDetailEmpty">
|
||||
<svg class="main-view-empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<div class="main-view-empty-title" data-i18n="profiles_empty_title">Select a profile</div>
|
||||
<div class="main-view-empty-sub" data-i18n="profiles_empty_sub">Pick an agent profile from the sidebar to view and edit its settings, or create a new one.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainSettings" class="main-view">
|
||||
<div class="settings-main">
|
||||
<div class="settings-pane active" id="settingsPaneConversation">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">Conversation</div>
|
||||
<div class="settings-section-meta" id="hermesSessionMeta">No active conversation selected.</div>
|
||||
<div class="settings-section-title" data-i18n="settings_section_conversation_title">Conversation</div>
|
||||
<div class="settings-section-meta" id="hermesSessionMeta" data-i18n="active_conversation_none">No active conversation selected.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hermes-action-grid">
|
||||
@@ -461,11 +522,74 @@
|
||||
</div>
|
||||
<input type="file" id="importFileInput" accept=".json" style="display:none">
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPanePreferences" role="tabpanel" aria-labelledby="settingsTabPreferences">
|
||||
<div class="settings-pane" id="settingsPaneAppearance">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">Preferences</div>
|
||||
<div class="settings-section-meta">Defaults and UI behavior for Hermes Web UI.</div>
|
||||
<div class="settings-section-title" data-i18n="settings_section_appearance_title">Appearance</div>
|
||||
<div class="settings-section-meta" data-i18n="settings_section_appearance_meta">Theme, accent colors, and visual style.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label data-i18n="settings_label_theme">Theme</label>
|
||||
<div id="themePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
|
||||
<button type="button" data-theme-val="light" onclick="_pickTheme('light')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:#fff;border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" fill="none" stroke="#999" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)">Light</span>
|
||||
</button>
|
||||
<button type="button" data-theme-val="dark" onclick="_pickTheme('dark')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:#1a1a2e;border:1px solid rgba(255,255,255,.1);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" fill="none" stroke="#666" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/></svg>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)">Dark</span>
|
||||
</button>
|
||||
<button type="button" data-theme-val="system" onclick="_pickTheme('system')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:linear-gradient(to right,#fff,#1a1a2e);border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" fill="none" stroke="#888" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)">System</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="settingsTheme" value="dark">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label data-i18n="settings_label_skin">Skin</label>
|
||||
<div id="skinPickerGrid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:4px">
|
||||
</div>
|
||||
<input type="hidden" id="settingsSkin" value="default">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label data-i18n="settings_label_font_size">Font size</label>
|
||||
<div id="fontSizePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
|
||||
<button type="button" data-font-size-val="small" onclick="_pickFontSize('small')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<span style="font-size:10px;font-weight:600;color:var(--muted)">Aa</span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_small">Small</span>
|
||||
</button>
|
||||
<button type="button" data-font-size-val="default" onclick="_pickFontSize('default')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--muted)">Aa</span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_default">Default</span>
|
||||
</button>
|
||||
<button type="button" data-font-size-val="large" onclick="_pickFontSize('large')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<span style="font-size:17px;font-weight:600;color:var(--muted)">Aa</span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_large">Large</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="settingsFontSize" value="default">
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPanePreferences">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title" data-i18n="settings_section_preferences_title">Preferences</div>
|
||||
<div class="settings-section-meta" data-i18n="settings_section_preferences_meta">Defaults and UI behavior for Hermes Web UI.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
@@ -479,19 +603,6 @@
|
||||
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label>
|
||||
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="_applyTheme(this.value)">
|
||||
<option value="system">System (auto)</option>
|
||||
<option value="dark">Dark (default)</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="slate">Slate (charcoal)</option>
|
||||
<option value="solarized">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="oled">OLED</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<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>
|
||||
@@ -518,11 +629,12 @@
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsBubbleLayout" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_bubble_layout">Chat bubble layout</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_bubble_layout">Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.</div>
|
||||
<label for="settingsSidebarDensity" data-i18n="settings_label_sidebar_density">Sidebar density</label>
|
||||
<select id="settingsSidebarDensity" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="compact" data-i18n="settings_sidebar_density_compact">Compact</option>
|
||||
<option value="detailed" data-i18n="settings_sidebar_density_detailed">Detailed</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sidebar_density">Controls how much metadata the session list shows in the left sidebar.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
@@ -552,15 +664,29 @@
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneSystem" role="tabpanel" aria-labelledby="settingsTabSystem">
|
||||
<div class="settings-pane" id="settingsPaneProviders">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">System</div>
|
||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||
<div class="settings-section-title" data-i18n="providers_section_title">Providers</div>
|
||||
<div class="settings-section-meta" data-i18n="providers_section_meta">Manage API keys for AI providers. Changes take effect immediately.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">v0.50.69</span>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<div id="providersList" style="display:flex;flex-direction:column;margin-top:4px">
|
||||
<!-- Populated dynamically by loadProvidersPanel() -->
|
||||
</div>
|
||||
<div id="providersEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px" data-i18n="providers_empty">
|
||||
No configurable providers found.
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneSystem">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title" data-i18n="settings_section_system_title">System</div>
|
||||
<div class="settings-section-meta" data-i18n="settings_section_system_meta">Instance version and access controls.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">—</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
@@ -568,8 +694,58 @@
|
||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
|
||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<aside class="rightpanel">
|
||||
<div class="resize-handle" id="rightpanelResize"></div>
|
||||
<div class="panel-header">
|
||||
<span>Workspace</span>
|
||||
<span class="git-badge" id="gitBadge" style="display:none"></span>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button class="panel-icon-btn mobile-close-btn" onclick="handleWorkspaceClose()" title="Close" aria-label="Close workspace panel">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
||||
<div class="file-tree" id="fileTree"></div>
|
||||
<div id="wsEmptyState" style="display:none;flex:1;align-items:center;justify-content:center;padding:24px 16px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6"></div>
|
||||
<div class="preview-area" id="previewArea">
|
||||
<div class="preview-path" id="previewPath">
|
||||
<span id="previewPathText"></span>
|
||||
<span class="preview-badge" id="previewBadge"></span>
|
||||
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:inline-flex;align-items:center;gap:4px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer"><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> Download</button>
|
||||
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><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="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
|
||||
</div>
|
||||
<pre class="preview-code" id="previewCode"></pre>
|
||||
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
|
||||
<div class="preview-md" id="previewMd" style="display:none"></div>
|
||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:var(--pre-text);border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
|
||||
<div class="onboarding-card">
|
||||
<div class="onboarding-shell">
|
||||
<div class="onboarding-sidebar">
|
||||
<div class="onboarding-badge" data-i18n="onboarding_badge">FIRST RUN</div>
|
||||
<h2 id="onboardingTitle" data-i18n="onboarding_title">Welcome to Hermes Web UI</h2>
|
||||
<p id="onboardingLead" data-i18n="onboarding_lead">A quick guided setup will check your Hermes install, choose a workspace and model, and optionally protect the app with a password.</p>
|
||||
<div class="onboarding-steps" id="onboardingSteps"></div>
|
||||
</div>
|
||||
<div class="onboarding-main">
|
||||
<div class="onboarding-status" id="onboardingNotice"></div>
|
||||
<div class="onboarding-body" id="onboardingBody"></div>
|
||||
<div class="onboarding-actions">
|
||||
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" style="display:none" data-i18n="onboarding_back">Back</button>
|
||||
<button class="sm-btn" id="onboardingSkipBtn" onclick="skipOnboarding()" style="margin-right:auto;opacity:.7" data-i18n="onboarding_skip">Skip setup</button>
|
||||
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -592,15 +768,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="static/i18n.js"></script>
|
||||
<script src="static/icons.js"></script>
|
||||
<script src="static/ui.js"></script>
|
||||
<script src="static/workspace.js"></script>
|
||||
<script src="static/sessions.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/messages.js"></script>
|
||||
<script src="static/panels.js"></script>
|
||||
<script src="static/onboarding.js"></script>
|
||||
<script src="static/boot.js"></script>
|
||||
<script src="static/i18n.js" defer></script>
|
||||
<script src="static/icons.js" defer></script>
|
||||
<script src="static/ui.js" defer></script>
|
||||
<script src="static/workspace.js" defer></script>
|
||||
<script src="static/sessions.js" defer></script>
|
||||
<script src="static/commands.js" defer></script>
|
||||
<script src="static/messages.js" defer></script>
|
||||
<script src="static/panels.js" defer></script>
|
||||
<script src="static/onboarding.js" defer></script>
|
||||
<script src="static/boot.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
23
static/manifest.json
Normal file
23
static/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Hermes",
|
||||
"short_name": "Hermes",
|
||||
"description": "Hermes AI Agent Web UI",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#1a1a1a",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "static/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "static/favicon-32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
function _markSessionViewed(sid, messageCount) {
|
||||
if(typeof _setSessionViewedCount!=='function' || !sid) return;
|
||||
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||||
_setSessionViewedCount(sid, next);
|
||||
}
|
||||
|
||||
async function send(){
|
||||
const text=$('msg').value.trim();
|
||||
if(!text&&!S.pendingFiles.length)return;
|
||||
// Slash command intercept -- local commands handled without agent round-trip
|
||||
if(text.startsWith('/')&&!S.pendingFiles.length&&executeCommand(text)){
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
// Don't send while an inline message edit is active
|
||||
if(document.querySelector('.msg-edit-area'))return;
|
||||
// If busy, queue the message instead of dropping it
|
||||
if(S.busy){
|
||||
const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning();
|
||||
// If busy or a manual compression is still running, queue the message instead
|
||||
if(S.busy||compressionRunning){
|
||||
if(text){
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles]});
|
||||
@@ -19,6 +22,35 @@ async function send(){
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Slash command intercept -- local commands handled without agent round-trip.
|
||||
// We push the user message BEFORE running the handler for echo-worthy
|
||||
// commands so chat order is correct: some handlers (e.g. cmdHelp) push
|
||||
// their assistant response synchronously. If we pushed AFTER, S.messages
|
||||
// would be [assistant, user] and the chat would show the response above
|
||||
// the user's own input — reverse chronological order (#840 ordering bug).
|
||||
if(text.startsWith('/')&&!S.pendingFiles.length){
|
||||
const _parsedCmd=parseCommand(text);
|
||||
const _cmd=_parsedCmd?COMMANDS.find(c=>c.name===_parsedCmd.name):null;
|
||||
if(_cmd){
|
||||
let _pushedUser=false;
|
||||
if(!_cmd.noEcho){
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
|
||||
_pushedUser=true;
|
||||
renderMessages();
|
||||
}
|
||||
// Run the handler directly (we already looked it up). If it returns
|
||||
// false it's opting out — e.g. /reasoning <level> falls through so the
|
||||
// agent sees the raw text. Roll back the echo push in that case so
|
||||
// the normal send path doesn't duplicate it.
|
||||
if(_cmd.fn(_parsedCmd.args)===false){
|
||||
if(_pushedUser){S.messages.pop();renderMessages();}
|
||||
// Fall through to normal send path
|
||||
} else {
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
|
||||
const activeSid=S.session.session_id;
|
||||
@@ -28,20 +60,22 @@ async function send(){
|
||||
try{uploaded=await uploadPendingFiles();}
|
||||
catch(e){if(!text){setComposerStatus(`Upload error: ${e.message}`);return;}}
|
||||
|
||||
const uploadedNames=uploaded.map(u=>u.name||u);
|
||||
const uploadedPaths=uploaded.map(u=>u.path||u.name||u);
|
||||
let msgText=text;
|
||||
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploaded.join(', ')}`;
|
||||
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploaded.join(', ')}]`;
|
||||
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploadedPaths.join(', ')}`;
|
||||
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploadedPaths.join(', ')}]`;
|
||||
if(!msgText){setComposerStatus('Nothing to send');return;}
|
||||
|
||||
$('msg').value='';autoResize();
|
||||
const displayText=text||(uploaded.length?`Uploaded: ${uploaded.join(', ')}`:'(file upload)');
|
||||
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploaded:undefined,_ts:Date.now()/1000};
|
||||
const displayText=text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)');
|
||||
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);
|
||||
INFLIGHT[activeSid]={messages:[...S.messages],uploaded,toolCalls:[]};
|
||||
INFLIGHT[activeSid]={messages:[...S.messages],uploaded:uploadedNames,toolCalls:[]};
|
||||
if(typeof saveInflightState==='function'){
|
||||
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded,toolCalls:[]});
|
||||
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]});
|
||||
}
|
||||
startApprovalPolling(activeSid);
|
||||
startClarifyPolling(activeSid);
|
||||
@@ -68,13 +102,27 @@ async function send(){
|
||||
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
|
||||
session_id:activeSid,message:msgText,
|
||||
model:S.session.model||$('modelSelect').value,workspace:S.session.workspace,
|
||||
attachments:uploaded.length?uploaded:undefined
|
||||
attachments:uploaded.length?uploadedNames:undefined
|
||||
})});
|
||||
if(startData.effective_model && S.session){
|
||||
S.session.model=startData.effective_model;
|
||||
localStorage.setItem('hermes-webui-model', startData.effective_model);
|
||||
if($('modelSelect')) _applyModelToDropdown(startData.effective_model, $('modelSelect'));
|
||||
if(typeof syncTopbar==='function') syncTopbar();
|
||||
}
|
||||
streamId=startData.stream_id;
|
||||
S.activeStreamId = streamId;
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.session.active_stream_id = streamId;
|
||||
}
|
||||
markInflight(activeSid, streamId);
|
||||
if(typeof saveInflightState==='function'){
|
||||
saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded,toolCalls:INFLIGHT[activeSid].toolCalls||[]});
|
||||
saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:INFLIGHT[activeSid].toolCalls||[]});
|
||||
}
|
||||
// Refresh session list so background streaming indicators appear immediately for the
|
||||
// session that was just started and any others that may already be running.
|
||||
if(typeof renderSessionList === 'function') {
|
||||
void renderSessionList();
|
||||
}
|
||||
// Show Cancel button
|
||||
const cancelBtn=$('btnCancel');
|
||||
@@ -107,12 +155,12 @@ async function send(){
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking();
|
||||
if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true);
|
||||
S.messages.push({role:'assistant',content:`**Error:** ${errMsg}`});
|
||||
renderMessages();setBusy(false);setComposerStatus(`Error: ${errMsg}`);
|
||||
_queueDrainSid=activeSid;renderMessages();setBusy(false);setComposerStatus(`Error: ${errMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open SSE stream and render tokens live
|
||||
attachLiveStream(activeSid, streamId, uploaded);
|
||||
attachLiveStream(activeSid, streamId, uploadedNames);
|
||||
|
||||
}
|
||||
|
||||
@@ -141,10 +189,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
let liveReasoningText='';
|
||||
let assistantRow=null;
|
||||
let assistantBody=null;
|
||||
let segmentStart=0; // char offset in assistantText where current segment begins
|
||||
let _freshSegment=false; // true after a tool call — forces a new DOM segment
|
||||
// streaming-markdown state: incremental DOM-building parser per segment
|
||||
let _smdParser=null; // current smd parser instance (null until first content)
|
||||
let _smdWrittenLen=0; // how many chars of displayText have been fed to smd parser
|
||||
let _smdWrittenText=''; // exact displayText snapshot used for prefix-alignment checks
|
||||
// On reconnect, the assistantBody already has partial smd-rendered content.
|
||||
// We clear it on first new token and restart the parser from the reconnect point.
|
||||
let _smdReconnect=reconnecting;
|
||||
// Thinking tag patterns for streaming display
|
||||
const _thinkPairs=[
|
||||
{open:'<think>',close:'</think>'},
|
||||
{open:'<|channel>thought\n',close:'<channel|>'}
|
||||
{open:'<|channel>thought\n',close:'<channel|>'},
|
||||
{open:'<|turn|>thinking\n',close:'<turn|>'} // Gemma 4
|
||||
];
|
||||
|
||||
function _isActiveSession(){
|
||||
@@ -160,6 +218,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
toolCalls:inflight.toolCalls||[],
|
||||
});
|
||||
}
|
||||
// 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
|
||||
// a 10KB messages array this is ~36MB of JSON churn per second — a major
|
||||
// GC pressure source that causes the renderer to crash under load.
|
||||
// State transitions (tool events, done, error) still call persistInflightState()
|
||||
// directly so no more than 2s of progress is lost on a crash.
|
||||
let _persistTimer=null;
|
||||
function _throttledPersist(){
|
||||
if(_persistTimer) return;
|
||||
_persistTimer=setTimeout(()=>{_persistTimer=null;persistInflightState();},2000);
|
||||
}
|
||||
function _closeSource(){
|
||||
closeLiveStream(activeSid, streamId);
|
||||
}
|
||||
@@ -177,11 +247,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
inflight.messages[assistantIdx].content=assistantText;
|
||||
inflight.messages[assistantIdx].reasoning=reasoningText||undefined;
|
||||
inflight.messages[assistantIdx]._ts=inflight.messages[assistantIdx]._ts||ts;
|
||||
persistInflightState();
|
||||
_throttledPersist();
|
||||
return;
|
||||
}
|
||||
inflight.messages.push({role:'assistant',content:assistantText,reasoning:reasoningText||undefined,_live:true,_ts:ts});
|
||||
persistInflightState();
|
||||
_throttledPersist();
|
||||
}
|
||||
function ensureAssistantRow(force=false){
|
||||
if(!_isActiveSession()) return;
|
||||
@@ -198,10 +268,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const blocks=(typeof _assistantTurnBlocks==='function')?_assistantTurnBlocks(turn):null;
|
||||
if(!blocks) return;
|
||||
if(!assistantRow){
|
||||
const existing=blocks.querySelector('[data-live-assistant="1"]');
|
||||
if(existing){
|
||||
assistantRow=existing;
|
||||
assistantBody=existing.querySelector('.msg-body');
|
||||
// Only reuse an existing segment on the very first creation (e.g. reconnect).
|
||||
// After a tool call _freshSegment=true, so we always create a new segment
|
||||
// below the tool card rather than re-attaching to the old one above it.
|
||||
if(!_freshSegment){
|
||||
const existing=blocks.querySelector('[data-live-assistant="1"]');
|
||||
if(existing){
|
||||
assistantRow=existing;
|
||||
assistantBody=existing.querySelector('.msg-body');
|
||||
}
|
||||
}
|
||||
}
|
||||
if(assistantRow){
|
||||
@@ -217,19 +292,48 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
assistantBody=document.createElement('div');assistantBody.className='msg-body';
|
||||
assistantRow.appendChild(assistantBody);
|
||||
blocks.appendChild(assistantRow);
|
||||
_freshSegment=false; // consumed — next reuse check is normal again
|
||||
}
|
||||
|
||||
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
|
||||
let _reconnectAttempted=false;
|
||||
let _terminalStateReached=false;
|
||||
|
||||
// Bug A fix (#631): track whether the stream has been finalized so any rAF
|
||||
// scheduled by a trailing 'token'/'reasoning' event that arrives in the same
|
||||
// microtask batch as 'done' does not fire after renderMessages() has already
|
||||
// settled the DOM — which was causing the thinking card to reappear below
|
||||
// the final answer or the response to render twice.
|
||||
let _streamFinalized=false;
|
||||
let _pendingRafHandle=null;
|
||||
|
||||
// rAF-throttled rendering: buffer tokens, render at most once per frame
|
||||
let _renderPending=false;
|
||||
// Extract display text from assistantText, stripping completed thinking blocks
|
||||
// and hiding content still inside an open thinking block.
|
||||
function _stripXmlToolCalls(s){
|
||||
// Strip <function_calls>...</function_calls> blocks (DeepSeek XML tool syntax).
|
||||
// These are processed as tool calls server-side; showing them raw in the bubble
|
||||
// looks broken. Also handles orphaned opening tags mid-stream. (#702)
|
||||
// Also handles DSML-prefixed variants from DeepSeek/Bedrock, including
|
||||
// spacing variants like "<|DSML |function_calls" and truncated prefixes.
|
||||
if(!s) return s;
|
||||
const lo=String(s).toLowerCase();
|
||||
if(lo.indexOf('function_calls')===-1 && lo.indexOf('dsml')===-1) return s;
|
||||
// Support both plain <function_calls> and DSML-prefixed variants.
|
||||
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls>[\s\S]*?<\/(?:\s*|\s*DSML\s*[||]\s*)?function_calls>/gi,'');
|
||||
// Also remove truncated opening tags (missing closing ">" at stream tail).
|
||||
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls(?:>|$)[\s\S]*$/i,'');
|
||||
// Remove malformed DSML tag fragments like "<|DSML |" that can leak in tokens.
|
||||
s=s.replace(/<\s*|\s*DSML\s*[||]\s*/gi,'');
|
||||
return s.trim();
|
||||
}
|
||||
function _streamDisplay(){
|
||||
const raw=assistantText;
|
||||
if(reasoningText) return raw;
|
||||
const raw=_stripXmlToolCalls(assistantText);
|
||||
// Always run think-block stripping even when reasoningText is populated.
|
||||
// Some providers emit reasoning content via on_reasoning AND wrap it in
|
||||
// <think> tags in the token stream — the early-return caused the thinking
|
||||
// card and main response to show identical content (closes #852).
|
||||
for(const {open,close} of _thinkPairs){
|
||||
// Trim leading whitespace before checking for the open tag — some models
|
||||
// (e.g. MiniMax) emit newlines before <think>.
|
||||
@@ -250,7 +354,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return raw;
|
||||
}
|
||||
function _parseStreamState(){
|
||||
const raw=assistantText;
|
||||
const raw=_stripXmlToolCalls(assistantText);
|
||||
if(reasoningText){
|
||||
return {thinkingText:liveReasoningText, displayText:_streamDisplay(), inThinking:false};
|
||||
}
|
||||
@@ -278,29 +382,153 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return {thinkingText:'', displayText:raw, inThinking:false};
|
||||
}
|
||||
function _renderLiveThinking(parsed){
|
||||
if(window._showThinking===false){removeThinking();return;}
|
||||
const text=(parsed&&parsed.thinkingText)||'';
|
||||
if(text||(parsed&&parsed.inThinking)){
|
||||
if(typeof updateThinking==='function') updateThinking(text||'Thinking…');
|
||||
else appendThinking();
|
||||
return;
|
||||
}
|
||||
removeThinking();
|
||||
// Only remove thinking if we're not in an active reasoning phase.
|
||||
// When reasoningText is set but liveReasoningText was just reset (post-tool),
|
||||
// don't wipe the finalized thinking card — it has no id anymore so
|
||||
// removeThinking() won't find it anyway, but guard explicitly.
|
||||
if(!reasoningText) removeThinking();
|
||||
}
|
||||
// Helper: create (or recreate) the smd parser bound to a given DOM element.
|
||||
// Called when assistantBody is first created and after each tool-call segment reset.
|
||||
function _smdNewParser(el){
|
||||
_smdWrittenLen=0;
|
||||
_smdWrittenText='';
|
||||
if(!window.smd){_smdParser=null;return;}
|
||||
const renderer=window.smd.default_renderer(el);
|
||||
_smdParser=window.smd.parser(renderer);
|
||||
}
|
||||
// Helper: end the current smd parser (flushes remaining state) and null it out.
|
||||
function _smdEndParser(){
|
||||
if(_smdParser&&window.smd){
|
||||
try{window.smd.parser_end(_smdParser);}catch(_){}
|
||||
// parser_end may flush remaining markdown that creates new links/images —
|
||||
// re-sanitize the body before the DOM is handed off to highlightCode / renderMessages.
|
||||
if(assistantBody){_sanitizeSmdLinks(assistantBody);}
|
||||
}
|
||||
_smdParser=null;
|
||||
_smdWrittenLen=0;
|
||||
_smdWrittenText='';
|
||||
}
|
||||
// Helper: feed new displayText delta to the smd parser.
|
||||
// Only feeds chars beyond what has already been written (_smdWrittenLen).
|
||||
function _smdWrite(displayText){
|
||||
if(!_smdParser||!window.smd) return;
|
||||
displayText=String(displayText||'');
|
||||
// Self-heal desyncs: if displayText no longer starts with what we've already
|
||||
// written (e.g. due to stream sanitization/tag stripping), incremental slicing
|
||||
// can skip characters. Rebuild parser from the full current displayText.
|
||||
if(_smdWrittenText && !displayText.startsWith(_smdWrittenText)){
|
||||
_smdParser=null;
|
||||
_smdWrittenLen=0;
|
||||
_smdWrittenText='';
|
||||
if(assistantBody) assistantBody.innerHTML='';
|
||||
_smdNewParser(assistantBody);
|
||||
if(!_smdParser) return;
|
||||
}
|
||||
const delta=displayText.slice(_smdWrittenText.length);
|
||||
if(!delta) return;
|
||||
try{window.smd.parser_write(_smdParser,delta);}catch(_){}
|
||||
_smdWrittenLen=displayText.length;
|
||||
_smdWrittenText=displayText;
|
||||
// streaming-markdown does NOT sanitize URL schemes — `[click](javascript:...)`
|
||||
// and `` survive as href/src. Strip any unsafe schemes
|
||||
// from anchors/images that were just added to the live DOM. The existing
|
||||
// renderMd() path filters these via its http(s)-only regex; we need a matching
|
||||
// guard here so the live-stream path isn't an XSS vector for agent-echoed
|
||||
// prompt-injection content. The final renderMessages() call at `done` uses
|
||||
// renderMd which is already safe, but during streaming the user could click
|
||||
// a malicious link before that replacement happens.
|
||||
if(assistantBody){_sanitizeSmdLinks(assistantBody);}
|
||||
}
|
||||
// Allowed URL schemes for anchors and images rendered from agent-streamed markdown.
|
||||
// Matches the effective allowlist of renderMd() (http/https via regex + relative).
|
||||
const _SMD_SAFE_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i;
|
||||
function _sanitizeSmdLinks(root){
|
||||
if(!root||!root.querySelectorAll) return;
|
||||
const _a=root.querySelectorAll('a[href]');
|
||||
for(let i=0;i<_a.length;i++){
|
||||
const n=_a[i],v=n.getAttribute('href')||'';
|
||||
if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('href');n.setAttribute('data-blocked-scheme','1');}
|
||||
}
|
||||
const _im=root.querySelectorAll('img[src]');
|
||||
for(let i=0;i<_im.length;i++){
|
||||
const n=_im[i],v=n.getAttribute('src')||'';
|
||||
if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('src');n.setAttribute('data-blocked-scheme','1');}
|
||||
}
|
||||
}
|
||||
let _lastRenderMs=0;
|
||||
function _scheduleRender(){
|
||||
if(_renderPending) return;
|
||||
if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized
|
||||
_renderPending=true;
|
||||
requestAnimationFrame(()=>{
|
||||
// Cap render rate to ~15fps. The browser's rAF fires at 60fps, but each DOM
|
||||
// update takes 50-150ms on large sessions. During GC pauses, rAF callbacks
|
||||
// accumulate and then execute all at once, blocking the main thread for
|
||||
// multi-second stretches and crashing the renderer (Chrome error code 4/5).
|
||||
// Throttling to 66ms intervals prevents this pileup without noticeable
|
||||
// visual degradation — streaming text updates still feel immediate.
|
||||
// performance.now() is monotonic so tab suspend/resume and NTP adjustments
|
||||
// can't produce negative or enormous deltas.
|
||||
const sinceLastMs=performance.now()-_lastRenderMs;
|
||||
const _doRender=()=>{
|
||||
_pendingRafHandle=null;
|
||||
_renderPending=false;
|
||||
// Guard: a pending setTimeout+rAF can outlive stream finalization.
|
||||
if(_streamFinalized) return;
|
||||
_lastRenderMs=performance.now();
|
||||
const parsed=_parseStreamState();
|
||||
_renderLiveThinking(parsed);
|
||||
if(assistantBody){
|
||||
assistantBody.innerHTML=parsed.displayText?renderMd(parsed.displayText):'';
|
||||
const displayText = segmentStart===0
|
||||
? parsed.displayText // first segment: uses think-tag stripping
|
||||
: _stripXmlToolCalls(assistantText.slice(segmentStart));
|
||||
if(!_smdParser&&window.smd){
|
||||
// On reconnect: prior content in assistantBody came from a different smd parser run.
|
||||
// Clear it and start fresh — renderMessages() on done will restore the full content.
|
||||
if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;}
|
||||
_smdNewParser(assistantBody);
|
||||
}
|
||||
if(_smdParser){
|
||||
_smdWrite(displayText);
|
||||
} else {
|
||||
// Fallback: smd not loaded yet, reconnect session, or smd unavailable — use renderMd
|
||||
assistantBody.innerHTML = (segmentStart===0
|
||||
? parsed.displayText
|
||||
: renderMd ? renderMd(assistantText.slice(segmentStart)) : assistantText.slice(segmentStart)) || '';
|
||||
}
|
||||
}
|
||||
scrollIfPinned();
|
||||
});
|
||||
};
|
||||
if(sinceLastMs>=66){
|
||||
_pendingRafHandle=requestAnimationFrame(_doRender);
|
||||
} else {
|
||||
_pendingRafHandle=setTimeout(()=>requestAnimationFrame(_doRender), 66-sinceLastMs);
|
||||
}
|
||||
}
|
||||
|
||||
function _wireSSE(source){
|
||||
// Note on #631 Bug B: the original PR description stated the server
|
||||
// "replays buffered token events" on reconnect, and proposed resetting
|
||||
// the accumulators here so the re-sent tokens wouldn't double the prefix.
|
||||
// That is NOT how the server actually works — api/routes._handle_sse_stream
|
||||
// reads a one-shot queue.Queue() that delivers each event to exactly one
|
||||
// consumer; a reconnect picks up from the current queue position and gets
|
||||
// only events produced during the outage. Resetting the accumulators here
|
||||
// would wipe the already-displayed content and restart the response from
|
||||
// the first post-reconnect token — a real data-loss regression.
|
||||
//
|
||||
// The "doubled response" / "stuck cursor" symptom is fully explained by
|
||||
// Bug A (trailing rAF after `done` inserting a new live-turn wrapper) —
|
||||
// the fixes below (_streamFinalized guard + cancelAnimationFrame in the
|
||||
// terminal handlers) address it without needing a reset here.
|
||||
|
||||
source.addEventListener('token',e=>{
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
const d=JSON.parse(e.data);
|
||||
@@ -318,6 +546,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
liveReasoningText += d.text || '';
|
||||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
// Render thinking card synchronously — not via rAF — so the DOM is
|
||||
// up-to-date before a 'tool' event in the same microtask batch calls
|
||||
// finalizeThinkingCard(). The old rAF-only path caused a race where
|
||||
// the thinking row was still a spinner when finalized.
|
||||
if(window._showThinking!==false){
|
||||
if(typeof updateThinking==='function') updateThinking(liveReasoningText||'Thinking…');
|
||||
else appendThinking(liveReasoningText);
|
||||
}
|
||||
_scheduleRender();
|
||||
});
|
||||
|
||||
@@ -344,6 +580,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
liveReasoningText='';
|
||||
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
|
||||
appendLiveToolCard(tc);
|
||||
// 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.
|
||||
assistantRow=null;
|
||||
assistantBody=null;
|
||||
segmentStart=assistantText.length; // new segment starts at current text length
|
||||
_freshSegment=true; // prevent reuse of old DOM node
|
||||
_smdEndParser(); // finalize current smd parser; new one created on next token
|
||||
scrollIfPinned();
|
||||
});
|
||||
|
||||
@@ -428,6 +672,26 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('done',e=>{
|
||||
_terminalStateReached=true;
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
// Bug A fix: cancel any pending rAF and mark stream finalized before
|
||||
// the DOM is settled by renderMessages, so no trailing token/reasoning rAF
|
||||
// can reintroduce a stale thinking card or duplicate content.
|
||||
_streamFinalized=true;
|
||||
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
// Finalize smd parser — flushes any remaining buffered markdown state
|
||||
// and runs Prism + copy buttons on the live segment before the DOM is replaced
|
||||
if(assistantBody){
|
||||
const _finBody=assistantBody;
|
||||
_smdEndParser();
|
||||
requestAnimationFrame(()=>{
|
||||
if(typeof highlightCode==='function') highlightCode(_finBody);
|
||||
if(typeof addCopyButtons==='function') addCopyButtons(_finBody);
|
||||
if(typeof renderKatexBlocks==='function') renderKatexBlocks();
|
||||
});
|
||||
} else {
|
||||
_smdEndParser();
|
||||
}
|
||||
const d=JSON.parse(e.data);
|
||||
delete INFLIGHT[activeSid];
|
||||
clearInflight();clearInflightState(activeSid);
|
||||
@@ -461,9 +725,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
S.busy=false;
|
||||
// No-reply guard (#373): if agent returned nothing, show inline error
|
||||
if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
|
||||
_markSessionViewed(activeSid, d.session.message_count ?? S.messages.length);
|
||||
syncTopbar();renderMessages();loadDir('.');
|
||||
}
|
||||
renderSessionList();setBusy(false);setStatus('');
|
||||
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setStatus('');
|
||||
setComposerStatus('');
|
||||
playNotificationSound();
|
||||
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
|
||||
@@ -491,6 +756,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('apperror',e=>{
|
||||
_terminalStateReached=true;
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_streamFinalized=true;
|
||||
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
|
||||
_smdEndParser();
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
|
||||
// This is distinct from the SSE network 'error' event below.
|
||||
source.close();
|
||||
@@ -503,14 +773,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
const isRateLimit=d.type==='rate_limit';
|
||||
const isQuotaExhausted=d.type==='quota_exhausted';
|
||||
const isAuthMismatch=d.type==='auth_mismatch';
|
||||
const isNoResponse=d.type==='no_response';
|
||||
const label=isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isNoResponse?'No response received':'Error';
|
||||
const label=isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isNoResponse?'No response received':'Error';
|
||||
const hint=d.hint?`\n\n*${d.hint}*`:'';
|
||||
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`});
|
||||
}catch(_){
|
||||
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
|
||||
}
|
||||
_markSessionViewed(activeSid, S.messages.length);
|
||||
renderMessages();
|
||||
}else if(typeof trackBackgroundError==='function'){
|
||||
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
|
||||
@@ -518,6 +790,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
|
||||
}
|
||||
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
|
||||
renderSessionList(); // clear streaming indicator immediately on apperror
|
||||
});
|
||||
|
||||
source.addEventListener('warning',e=>{
|
||||
@@ -534,7 +807,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('error',async e=>{
|
||||
source.close();
|
||||
if(_terminalStateReached){
|
||||
if(_terminalStateReached || _streamFinalized){
|
||||
_closeSource();
|
||||
return;
|
||||
}
|
||||
@@ -562,6 +835,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('cancel',e=>{
|
||||
_terminalStateReached=true;
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_streamFinalized=true;
|
||||
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
|
||||
_smdEndParser();
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
source.close();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
@@ -569,10 +847,28 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
|
||||
}
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages();
|
||||
}
|
||||
// Fetch latest session from server to get accurate message list (includes cancel status)
|
||||
// This ensures messages stay in sync with server, fixing race condition where local
|
||||
// "*Task cancelled.*" message gets lost when done event overwrites S.messages
|
||||
(async()=>{
|
||||
try{
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
|
||||
if(data&&data.session&&S.session&&S.session.session_id===activeSid){
|
||||
S.session=data.session;
|
||||
S.messages=(data.session.messages||[]).filter(m=>m&&m.role);
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
_markSessionViewed(activeSid, data.session.message_count ?? S.messages.length);
|
||||
renderMessages();
|
||||
}
|
||||
}catch(_){
|
||||
// Fallback to local cancel message if API fails
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages();
|
||||
_markSessionViewed(activeSid, S.messages.length);
|
||||
}
|
||||
}
|
||||
})();
|
||||
renderSessionList();
|
||||
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
|
||||
});
|
||||
@@ -603,9 +899,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}else{
|
||||
S.toolCalls=[];
|
||||
}
|
||||
_markSessionViewed(activeSid, session.message_count ?? S.messages.length);
|
||||
syncTopbar();renderMessages();
|
||||
}
|
||||
renderSessionList();setBusy(false);setComposerStatus('');
|
||||
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus('');
|
||||
return true;
|
||||
}catch(_){
|
||||
return false;
|
||||
@@ -613,6 +910,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
|
||||
function _handleStreamError(){
|
||||
// Opus review Q1: mirror done/apperror/cancel finalization so any pending rAF
|
||||
// cannot fire after renderMessages() has settled the DOM with the error message.
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_streamFinalized=true;
|
||||
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||
_closeSource();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
@@ -621,6 +924,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages();
|
||||
_markSessionViewed(activeSid, S.messages.length);
|
||||
}else{
|
||||
if(typeof trackBackgroundError==='function'){
|
||||
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
|
||||
@@ -649,7 +953,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
||||
clearLiveToolCards();
|
||||
removeThinking();
|
||||
setBusy(false);
|
||||
_queueDrainSid=activeSid;setBusy(false);
|
||||
setComposerStatus('');
|
||||
renderMessages();
|
||||
renderSessionList();
|
||||
@@ -1079,4 +1383,115 @@ function sendBrowserNotification(title,body){
|
||||
}
|
||||
}
|
||||
|
||||
// ── /btw ephemeral stream ────────────────────────────────────────────────────
|
||||
// Connects to the ephemeral SSE stream from /api/btw and renders the answer
|
||||
// in a visually distinct bubble that is NOT persisted to session history.
|
||||
|
||||
function attachBtwStream(parentSid, streamId, question){
|
||||
if(!parentSid||!streamId) return;
|
||||
const src=new EventSource('/api/chat/stream?stream_id='+encodeURIComponent(streamId));
|
||||
let answer='';
|
||||
let btwRow=null;
|
||||
let _streamDone=false;
|
||||
function _ensureBtwRow(){
|
||||
if(btwRow&&btwRow.isConnected) return;
|
||||
const inner=$('msgInner');
|
||||
if(!inner) return;
|
||||
btwRow=document.createElement('div');
|
||||
btwRow.className='msg-row msg-row-btw';
|
||||
btwRow.dataset.role='assistant';
|
||||
btwRow.dataset.btw='1';
|
||||
const labelEl=document.createElement('div');
|
||||
labelEl.className='msg-btw-label';
|
||||
labelEl.textContent=t('btw_label');
|
||||
const qEl=document.createElement('div');
|
||||
qEl.className='msg-body';
|
||||
qEl.textContent=question;
|
||||
const ansEl=document.createElement('div');
|
||||
ansEl.className='msg-body msg-btw-answer';
|
||||
ansEl.textContent='...';
|
||||
btwRow.appendChild(labelEl);
|
||||
btwRow.appendChild(qEl);
|
||||
btwRow.appendChild(ansEl);
|
||||
inner.appendChild(btwRow);
|
||||
btwRow.scrollIntoView({behavior:'smooth',block:'end'});
|
||||
}
|
||||
src.addEventListener('token',e=>{
|
||||
try{answer+=JSON.parse(e.data).text||'';}catch(_){}
|
||||
_ensureBtwRow();
|
||||
const ansEl=btwRow&&btwRow.querySelector('.msg-btw-answer');
|
||||
if(ansEl) ansEl.innerHTML=renderMd(answer);
|
||||
});
|
||||
src.addEventListener('done',e=>{
|
||||
_streamDone=true;
|
||||
src.close();
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
if(d.answer&&!answer) answer=d.answer;
|
||||
}catch(_){}
|
||||
if(S.session&&S.session.session_id===parentSid) _ensureBtwRow();
|
||||
if(btwRow&&btwRow.isConnected){
|
||||
const ansEl=btwRow.querySelector('.msg-btw-answer');
|
||||
if(ansEl) ansEl.innerHTML=renderMd(answer||t('btw_no_answer'));
|
||||
}
|
||||
showToast(t('btw_done'));
|
||||
});
|
||||
src.addEventListener('apperror',e=>{
|
||||
_streamDone=true;
|
||||
src.close();
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
showToast(t('btw_failed')+(d.message||''));
|
||||
}catch(_){showToast(t('btw_failed'));}
|
||||
if(btwRow&&btwRow.isConnected) btwRow.remove();
|
||||
});
|
||||
src.addEventListener('stream_end',()=>{_streamDone=true;src.close();});
|
||||
src.onerror=()=>{src.close();if(!_streamDone&&btwRow&&btwRow.isConnected) btwRow.remove();};
|
||||
}
|
||||
|
||||
// ── /background task tracking ────────────────────────────────────────────────
|
||||
|
||||
let _bgPollTimers={};
|
||||
let _bgActiveTasks=new Set();
|
||||
|
||||
function showBackgroundBadge(taskId){
|
||||
_bgActiveTasks.add(taskId);
|
||||
const badge=$('bgBadge');
|
||||
if(badge){
|
||||
badge.textContent=String(_bgActiveTasks.size);
|
||||
badge.style.display=_bgActiveTasks.size?'':'none';
|
||||
}
|
||||
}
|
||||
function hideBackgroundBadge(taskId){
|
||||
_bgActiveTasks.delete(taskId);
|
||||
const badge=$('bgBadge');
|
||||
if(badge){
|
||||
badge.textContent=String(_bgActiveTasks.size);
|
||||
badge.style.display=_bgActiveTasks.size?'':'none';
|
||||
}
|
||||
}
|
||||
function startBackgroundPolling(parentSid, taskId, prompt){
|
||||
if(_bgPollTimers[taskId]) return;
|
||||
async function _poll(){
|
||||
try{
|
||||
const r=await api('/api/background/status?session_id='+encodeURIComponent(parentSid));
|
||||
if(r&&r.results){
|
||||
for(const res of r.results){
|
||||
if(res.task_id===taskId){
|
||||
hideBackgroundBadge(taskId);
|
||||
delete _bgPollTimers[taskId];
|
||||
const msg={role:'assistant',content:`**${t('bg_label')}** ${prompt.slice(0,80)}\n\n${res.answer||t('bg_no_answer')}`,'_background':true,_ts:Date.now()/1000};
|
||||
S.messages.push(msg);
|
||||
renderMessages();
|
||||
showToast(t('bg_complete'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch(_){}
|
||||
_bgPollTimers[taskId]=setTimeout(_poll,3000);
|
||||
}
|
||||
_poll();
|
||||
}
|
||||
|
||||
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──
|
||||
|
||||
@@ -321,7 +321,8 @@ async function _saveOnboardingDefaults(){
|
||||
if(!known){
|
||||
await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:workspace})});
|
||||
}
|
||||
const body={default_workspace:workspace,default_model:model};
|
||||
// Model persisted by /api/onboarding/setup — no /api/default-model call needed here
|
||||
const body={default_workspace:workspace};
|
||||
if(password) body._set_password=password;
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||
if(ONBOARDING.status){
|
||||
|
||||
2078
static/panels.js
2078
static/panels.js
File diff suppressed because it is too large
Load Diff
@@ -10,19 +10,75 @@ const ICONS={
|
||||
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
|
||||
};
|
||||
|
||||
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
|
||||
let _sessionViewedCounts = null;
|
||||
|
||||
function _getSessionViewedCounts() {
|
||||
if (_sessionViewedCounts !== null) return _sessionViewedCounts;
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}');
|
||||
_sessionViewedCounts = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (_){
|
||||
_sessionViewedCounts = {};
|
||||
}
|
||||
return _sessionViewedCounts;
|
||||
}
|
||||
|
||||
function _saveSessionViewedCounts() {
|
||||
try {
|
||||
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(_getSessionViewedCounts()));
|
||||
} catch (_){
|
||||
// Ignore localStorage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
function _setSessionViewedCount(sid, messageCount = 0) {
|
||||
if (!sid) return;
|
||||
const counts = _getSessionViewedCounts();
|
||||
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||||
counts[sid] = next;
|
||||
_saveSessionViewedCounts();
|
||||
}
|
||||
|
||||
function _hasUnreadForSession(s) {
|
||||
if (!s || !s.session_id) return false;
|
||||
const counts = _getSessionViewedCounts();
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, s.session_id)) {
|
||||
_setSessionViewedCount(s.session_id, Number(s.message_count || 0));
|
||||
return false;
|
||||
}
|
||||
if (!Number.isFinite(s.message_count)) return false;
|
||||
return s.message_count > Number(counts[s.session_id] || 0);
|
||||
}
|
||||
|
||||
async function newSession(flash){
|
||||
updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
// Use profile default workspace for new sessions after a profile switch (one-shot),
|
||||
// otherwise inherit from the current session (or let server pick the default)
|
||||
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
|
||||
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
|
||||
// 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 newModel=window._defaultModel||$('modelSelect').value;
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:newModel,workspace:inheritWs,profile:S.activeProfile||'default'})});
|
||||
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);
|
||||
_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'));
|
||||
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;
|
||||
@@ -40,30 +96,51 @@ async function loadSession(sid){
|
||||
stopApprovalPolling();hideApprovalCard();
|
||||
if(typeof stopClarifyPolling==='function') stopClarifyPolling();
|
||||
if(typeof hideClarifyCard==='function') hideClarifyCard();
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
||||
// Show loading indicator immediately for responsiveness.
|
||||
// Cleared by renderMessages() once full session data arrives.
|
||||
const currentSid = S.session ? S.session.session_id : null;
|
||||
if (currentSid !== sid) {
|
||||
S.messages = [];
|
||||
S.toolCalls = [];
|
||||
const _msgInner = $('msgInner');
|
||||
if (_msgInner) _msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Loading conversation...</div>';
|
||||
}
|
||||
// Phase 1: Load metadata only (~1KB) for fast session switching.
|
||||
// Guard against network/server failures to prevent a permanently stuck loading state.
|
||||
let data;
|
||||
try {
|
||||
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0`);
|
||||
} catch(e) {
|
||||
const _msgInner = $('msgInner');
|
||||
if (_msgInner) {
|
||||
_msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Failed to load session. Try switching sessions or refreshing.</div>';
|
||||
}
|
||||
if (typeof showToast === 'function') showToast('Failed to load session', 3000, 'error');
|
||||
return;
|
||||
}
|
||||
S.session=data.session;
|
||||
S.lastUsage={...(data.session.last_usage||{})};
|
||||
_setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0));
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
data.session.messages = (data.session.messages || []).filter(m => m && m.role);
|
||||
const hasMessageToolMetadata = (data.session.messages || []).some(m => {
|
||||
if (!m || m.role !== 'assistant') return false;
|
||||
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
|
||||
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
|
||||
return hasTc || hasTu;
|
||||
});
|
||||
const activeStreamId=data.session.active_stream_id||null;
|
||||
|
||||
const activeStreamId=S.session.active_stream_id||null;
|
||||
|
||||
// Phase 2a: If session is streaming, restore from INFLIGHT cache before
|
||||
// loading full messages (INFLIGHT state is self-contained and sufficient).
|
||||
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
|
||||
const stored=loadInflightState(sid, activeStreamId);
|
||||
if(stored){
|
||||
INFLIGHT[sid]={
|
||||
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[...(data.session.messages||[])],
|
||||
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[...(data.session.pending_attachments||[])],
|
||||
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[],
|
||||
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[],
|
||||
toolCalls:Array.isArray(stored.toolCalls)?stored.toolCalls:[],
|
||||
reattach:true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if(INFLIGHT[sid]){
|
||||
// Streaming session: use cached INFLIGHT messages (already has pending assistant output).
|
||||
S.messages=INFLIGHT[sid].messages;
|
||||
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
|
||||
S.busy=true;
|
||||
@@ -80,22 +157,64 @@ async function loadSession(sid){
|
||||
const _cb=$('btnCancel');if(_cb&&activeStreamId)_cb.style.display='inline-flex';
|
||||
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||||
INFLIGHT[sid].reattach=false;
|
||||
attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
|
||||
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
}
|
||||
}else{
|
||||
updateQueueBadge(sid);
|
||||
S.messages=data.session.messages||[];
|
||||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
|
||||
if(pendingMsg) S.messages.push(pendingMsg);
|
||||
// Prefer reconstructing cards from per-message tool metadata when available.
|
||||
// Fall back to persisted session summaries for older sessions that only
|
||||
// saved session.tool_calls and bare role=tool results.
|
||||
if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length){
|
||||
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
|
||||
}else{
|
||||
S.toolCalls=[];
|
||||
// Phase 2b: Idle session — load full messages lazily for rendering.
|
||||
// _ensureMessagesLoaded is idempotent; it skips if S.messages already populated.
|
||||
try {
|
||||
await _ensureMessagesLoaded(sid);
|
||||
} catch (e) {
|
||||
// Network errors, server failures, or SSE drops (Chrome error codes 4/5)
|
||||
// can cause _ensureMessagesLoaded to throw. Without a try/catch here the
|
||||
// "Loading conversation..." div injected at the top of loadSession would
|
||||
// persist forever with no recovery path.
|
||||
const _msgInner = $('msgInner');
|
||||
if (_msgInner) {
|
||||
_msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Failed to load messages. Try switching sessions or refreshing.</div>';
|
||||
}
|
||||
if (typeof showToast === 'function') showToast('Failed to load conversation messages', 3000, 'error');
|
||||
return;
|
||||
}
|
||||
clearLiveToolCards();
|
||||
|
||||
// Restore any queued message that survived page refresh via sessionStorage.
|
||||
if(typeof queueSessionMessage==='function'){
|
||||
try{
|
||||
const _storedQ=sessionStorage.getItem('hermes-queue-'+sid);
|
||||
if(_storedQ){
|
||||
const _entries=JSON.parse(_storedQ);
|
||||
if(Array.isArray(_entries)&&_entries.length){
|
||||
const _lastMsg=S.messages.slice().reverse()
|
||||
.find(m=>m&&m.role==='assistant');
|
||||
const _lastAsst=_lastMsg?(_lastMsg.timestamp||_lastMsg._ts||0)*1000:0;
|
||||
const _fresh=_entries.filter(e=>!e._queued_at||e._queued_at>_lastAsst);
|
||||
if(_fresh.length){
|
||||
const _first=_fresh[0];
|
||||
const _msg=$&&$('msg');
|
||||
if(_msg&&_first.text&&!_msg.value){
|
||||
_msg.value=_first.text||'';
|
||||
if(typeof autoResize==='function') autoResize();
|
||||
if(typeof showToast==='function') showToast((_fresh.length>1?`${_fresh.length} queued messages restored (showing first)`:'Queued message restored')+' — review and send when ready');
|
||||
}
|
||||
sessionStorage.removeItem('hermes-queue-'+sid);
|
||||
} else {
|
||||
sessionStorage.removeItem('hermes-queue-'+sid);
|
||||
}
|
||||
} else {
|
||||
sessionStorage.removeItem('hermes-queue-'+sid);
|
||||
}
|
||||
}
|
||||
}catch(_){sessionStorage.removeItem('hermes-queue-'+sid);}
|
||||
}
|
||||
|
||||
// Reconstruct tool calls from message metadata, or fall back to session-level summary.
|
||||
// (hasMessageToolMetadata already computed inside _ensureMessagesLoaded; S.toolCalls set there.)
|
||||
updateQueueBadge(sid);
|
||||
|
||||
// Attach pending user message if one is queued.
|
||||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(S.session):null;
|
||||
if(pendingMsg) S.messages.push(pendingMsg);
|
||||
|
||||
if(activeStreamId){
|
||||
S.busy=true;
|
||||
S.activeStreamId=activeStreamId;
|
||||
@@ -107,13 +226,9 @@ async function loadSession(sid){
|
||||
updateQueueBadge(sid);
|
||||
startApprovalPolling(sid);
|
||||
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
|
||||
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
|
||||
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
||||
}else{
|
||||
// Reset per-session visual state: the viewed session is idle even if another
|
||||
// session's stream is still running in the background.
|
||||
// We directly update the DOM instead of calling setBusy(false), because
|
||||
// setBusy(false) drains the viewed session's queued follow-up turns.
|
||||
S.busy=false;
|
||||
S.activeStreamId=null;
|
||||
updateSendBtn();
|
||||
@@ -124,6 +239,7 @@ async function loadSession(sid){
|
||||
syncTopbar();renderMessages();highlightCode();loadDir('.');
|
||||
}
|
||||
}
|
||||
|
||||
// Sync context usage indicator from session data
|
||||
const _s=S.session;
|
||||
if(_s&&typeof _syncCtxIndicator==='function'){
|
||||
@@ -140,6 +256,34 @@ async function loadSession(sid){
|
||||
}
|
||||
}
|
||||
|
||||
// Load session messages if not already present.
|
||||
// Called after loadSession fetches metadata (messages=0).
|
||||
// Idempotent: if messages are already in S.messages, resolves immediately.
|
||||
// Handles streaming sessions specially: restores from INFLIGHT cache or API.
|
||||
async function _ensureMessagesLoaded(sid) {
|
||||
// Already have messages? (e.g. from INFLIGHT restore path, already set)
|
||||
if (S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) {
|
||||
return;
|
||||
}
|
||||
// Fetch full session with messages
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1`);
|
||||
const msgs = (data.session.messages || []).filter(m => m && m.role);
|
||||
// Check for tool-call metadata on messages (for tool-call card rendering)
|
||||
const hasMessageToolMetadata = msgs.some(m => {
|
||||
if (!m || m.role !== 'assistant') return false;
|
||||
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
|
||||
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
|
||||
return hasTc || hasTu;
|
||||
});
|
||||
if (!hasMessageToolMetadata && data.session.tool_calls && data.session.tool_calls.length) {
|
||||
S.toolCalls = data.session.tool_calls.map(tc => ({...tc, done: true}));
|
||||
} else {
|
||||
S.toolCalls = [];
|
||||
}
|
||||
clearLiveToolCards();
|
||||
S.messages = msgs;
|
||||
}
|
||||
|
||||
let _allSessions = []; // cached for search filter
|
||||
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
|
||||
let _showArchived = false; // toggle to show archived sessions
|
||||
@@ -211,8 +355,8 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
const menu=document.createElement('div');
|
||||
menu.className='session-action-menu open';
|
||||
menu.appendChild(_buildSessionAction(
|
||||
session.pinned?'Unpin conversation':'Pin conversation',
|
||||
session.pinned?'Remove from the pinned section':'Keep this conversation at the top',
|
||||
session.pinned?t('session_unpin'):t('session_pin'),
|
||||
session.pinned?t('session_unpin_desc'):t('session_pin_desc'),
|
||||
session.pinned?ICONS.pin:ICONS.unpin,
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
@@ -222,13 +366,13 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
session.pinned=newPinned;
|
||||
if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;
|
||||
renderSessionList();
|
||||
}catch(err){showToast('Pin failed: '+err.message);}
|
||||
}catch(err){showToast(t('session_pin_failed')+err.message);}
|
||||
},
|
||||
session.pinned?'is-active':''
|
||||
));
|
||||
menu.appendChild(_buildSessionAction(
|
||||
'Move to project',
|
||||
session.project_id?'Change which project this conversation belongs to':'Assign this conversation to a project',
|
||||
t('session_move_project'),
|
||||
session.project_id?t('session_move_project_desc_has'):t('session_move_project_desc_none'),
|
||||
ICONS.folder,
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
@@ -236,8 +380,8 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
}
|
||||
));
|
||||
menu.appendChild(_buildSessionAction(
|
||||
session.archived?'Restore conversation':'Archive conversation',
|
||||
session.archived?'Bring this conversation back into the main list':'Hide this conversation until archived is shown',
|
||||
session.archived?t('session_restore'):t('session_archive'),
|
||||
session.archived?t('session_restore_desc'):t('session_archive_desc'),
|
||||
session.archived?ICONS.unarchive:ICONS.archive,
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
@@ -246,13 +390,13 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
session.archived=!session.archived;
|
||||
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
|
||||
await renderSessionList();
|
||||
showToast(session.archived?'Session archived':'Session restored');
|
||||
}catch(err){showToast('Archive failed: '+err.message);}
|
||||
showToast(session.archived?t('session_archived'):t('session_restored'));
|
||||
}catch(err){showToast(t('session_archive_failed')+err.message);}
|
||||
}
|
||||
));
|
||||
menu.appendChild(_buildSessionAction(
|
||||
'Duplicate conversation',
|
||||
'Create a copy with the same workspace and model',
|
||||
t('session_duplicate'),
|
||||
t('session_duplicate_desc'),
|
||||
ICONS.dup,
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
@@ -262,14 +406,14 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:res.session.session_id,title:(session.title||'Untitled')+' (copy)'})});
|
||||
await loadSession(res.session.session_id);
|
||||
await renderSessionList();
|
||||
showToast('Session duplicated');
|
||||
showToast(t('session_duplicated'));
|
||||
}
|
||||
}catch(err){showToast('Duplicate failed: '+err.message);}
|
||||
}catch(err){showToast(t('session_duplicate_failed')+err.message);}
|
||||
}
|
||||
));
|
||||
menu.appendChild(_buildSessionAction(
|
||||
'Delete conversation',
|
||||
'Permanently remove this conversation',
|
||||
t('session_delete'),
|
||||
t('session_delete_desc'),
|
||||
ICONS.trash,
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
@@ -314,12 +458,90 @@ async function renderSessionList(){
|
||||
]);
|
||||
_allSessions = sessData.sessions||[];
|
||||
_allProjects = projData.projects||[];
|
||||
const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming));
|
||||
if (isStreaming) {
|
||||
startStreamingPoll();
|
||||
} else {
|
||||
stopStreamingPoll();
|
||||
}
|
||||
ensureSessionTimeRefreshPoll();
|
||||
renderSessionListFromCache(); // no-ops if rename is in progress
|
||||
}catch(e){console.warn('renderSessionList',e);}
|
||||
}
|
||||
|
||||
// ── Gateway session SSE (real-time sync for agent sessions) ──
|
||||
let _gatewaySSE = null;
|
||||
let _gatewayPollTimer = null;
|
||||
let _gatewayProbeInFlight = false;
|
||||
let _gatewaySSEWarningShown = false;
|
||||
const _gatewayFallbackPollMs = 30000;
|
||||
const _streamingPollMs = 5000;
|
||||
const _sessionTimeRefreshMs = 60000;
|
||||
let _streamingPollTimer = null;
|
||||
let _sessionTimeRefreshTimer = null;
|
||||
|
||||
function startStreamingPoll(){
|
||||
if(_streamingPollTimer) return;
|
||||
_streamingPollTimer = setInterval(() => {
|
||||
void renderSessionList();
|
||||
}, _streamingPollMs);
|
||||
}
|
||||
|
||||
function stopStreamingPoll(){
|
||||
if(!_streamingPollTimer) return;
|
||||
clearInterval(_streamingPollTimer);
|
||||
_streamingPollTimer = null;
|
||||
}
|
||||
|
||||
function ensureSessionTimeRefreshPoll(){
|
||||
if(_sessionTimeRefreshTimer) return;
|
||||
_sessionTimeRefreshTimer = setInterval(() => {
|
||||
renderSessionListFromCache();
|
||||
}, _sessionTimeRefreshMs);
|
||||
}
|
||||
|
||||
function startGatewayPollFallback(ms){
|
||||
const intervalMs = Math.max(5000, Number(ms) || _gatewayFallbackPollMs);
|
||||
if(_gatewayPollTimer) clearInterval(_gatewayPollTimer);
|
||||
_gatewayPollTimer = setInterval(() => { renderSessionList(); }, intervalMs);
|
||||
}
|
||||
|
||||
function stopGatewayPollFallback(){
|
||||
if(_gatewayPollTimer){
|
||||
clearInterval(_gatewayPollTimer);
|
||||
_gatewayPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeGatewaySSEStatus(){
|
||||
if(_gatewayProbeInFlight || !window._showCliSessions) return;
|
||||
_gatewayProbeInFlight = true;
|
||||
try{
|
||||
const resp = await fetch('/api/sessions/gateway/stream?probe=1', { credentials:'same-origin' });
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if(resp.ok && data.watcher_running){
|
||||
stopGatewayPollFallback();
|
||||
_gatewaySSEWarningShown = false;
|
||||
return;
|
||||
}
|
||||
if(resp.status === 503 || data.watcher_running === false){
|
||||
startGatewayPollFallback(data.fallback_poll_ms || _gatewayFallbackPollMs);
|
||||
renderSessionList();
|
||||
if(!_gatewaySSEWarningShown && typeof showToast === 'function'){
|
||||
showToast('Gateway sync unavailable — falling back to periodic refresh.', 5000);
|
||||
_gatewaySSEWarningShown = true;
|
||||
}
|
||||
}
|
||||
}catch(e){
|
||||
// Network error during probe — server may be unreachable.
|
||||
// Start fallback polling as a safe default; it will self-cancel
|
||||
// when the SSE connection recovers and sessions_changed fires.
|
||||
startGatewayPollFallback(_gatewayFallbackPollMs);
|
||||
renderSessionList();
|
||||
}finally{
|
||||
_gatewayProbeInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startGatewaySSE(){
|
||||
stopGatewaySSE();
|
||||
@@ -330,14 +552,42 @@ function startGatewaySSE(){
|
||||
try{
|
||||
const data = JSON.parse(ev.data);
|
||||
if(data.sessions){
|
||||
stopGatewayPollFallback();
|
||||
_gatewaySSEWarningShown = false;
|
||||
renderSessionList(); // re-fetch and re-render
|
||||
// If the active session received new gateway messages, refresh the conversation view.
|
||||
// S.busy check prevents stomping on an in-progress WebUI response.
|
||||
// is_cli_session check ensures we only poll import_cli for CLI-originated sessions.
|
||||
if(S.session && !S.busy && S.session.is_cli_session){
|
||||
const changedIds = new Set((data.sessions||[]).map(s=>s.session_id));
|
||||
if(changedIds.has(S.session.session_id)){
|
||||
// Capture active session ID before async fetch — race guard.
|
||||
// If the user switches sessions while the fetch is in-flight, discard the result.
|
||||
const activeSid = S.session.session_id;
|
||||
api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:activeSid})})
|
||||
.then(res=>{
|
||||
if(!S.session || S.session.session_id !== activeSid) return;
|
||||
if(res && res.session && Array.isArray(res.session.messages)){
|
||||
const prev = S.messages.length;
|
||||
S.messages = res.session.messages.filter(m=>m&&m.role);
|
||||
if(S.messages.length !== prev){
|
||||
renderMessages();
|
||||
if(typeof highlightCode==='function') highlightCode();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(()=>{ /* ignore — next poll will retry */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch(e){ /* ignore parse errors */ }
|
||||
});
|
||||
_gatewaySSE.onerror = () => {
|
||||
// EventSource auto-reconnects; no action needed
|
||||
void probeGatewaySSEStatus();
|
||||
};
|
||||
}catch(e){ /* SSE not available */ }
|
||||
}catch(e){
|
||||
void probeGatewaySSEStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function stopGatewaySSE(){
|
||||
@@ -345,6 +595,9 @@ function stopGatewaySSE(){
|
||||
_gatewaySSE.close();
|
||||
_gatewaySSE = null;
|
||||
}
|
||||
stopGatewayPollFallback();
|
||||
_gatewayProbeInFlight = false;
|
||||
_gatewaySSEWarningShown = false;
|
||||
}
|
||||
|
||||
let _searchDebounceTimer = null;
|
||||
@@ -574,7 +827,9 @@ function renderSessionListFromCache(){
|
||||
function _renderOneSession(s){
|
||||
const el=document.createElement('div');
|
||||
const isActive=S.session&&s.session_id===S.session.session_id;
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'');
|
||||
const isStreaming=Boolean(s.is_streaming);
|
||||
const hasUnread=_hasUnreadForSession(s)&&!isActive;
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'');
|
||||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||||
const rawTitle=s.title||'Untitled';
|
||||
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
|
||||
@@ -587,13 +842,41 @@ function renderSessionListFromCache(){
|
||||
sessionText.className='session-text';
|
||||
const titleRow=document.createElement('div');
|
||||
titleRow.className='session-title-row';
|
||||
if(s.pinned){
|
||||
const pinInd=document.createElement('span');
|
||||
pinInd.className='session-pin-indicator';
|
||||
pinInd.innerHTML=ICONS.pin;
|
||||
titleRow.appendChild(pinInd);
|
||||
}
|
||||
const state=document.createElement('span');
|
||||
state.className='session-state-indicator'+(isStreaming?' is-streaming':(hasUnread?' is-unread':''));
|
||||
titleRow.appendChild(state); // always reserve slot — prevents title shift when indicator appears
|
||||
const title=document.createElement('span');
|
||||
title.className='session-title';
|
||||
title.textContent=cleanTitle||'Untitled';
|
||||
title.title='Double-click to rename';
|
||||
const tsMs=_sessionTimestampMs(s);
|
||||
const ts=document.createElement('span');
|
||||
ts.className='session-time';
|
||||
ts.textContent=_formatRelativeSessionTime(tsMs);
|
||||
titleRow.appendChild(title);
|
||||
titleRow.appendChild(ts);
|
||||
sessionText.appendChild(titleRow);
|
||||
const density=(window._sidebarDensity==='detailed'?'detailed':'compact');
|
||||
if(density==='detailed'){
|
||||
const metaBits=[];
|
||||
const msgCount=typeof s.message_count==='number'?s.message_count:0;
|
||||
const msgLabel=(typeof t==='function')
|
||||
? t('session_meta_messages', msgCount)
|
||||
: `${msgCount} msg${msgCount===1?'':'s'}`;
|
||||
metaBits.push(msgLabel);
|
||||
if(s.model) metaBits.push(s.model);
|
||||
if(_showAllProfiles&&s.profile) metaBits.push(s.profile);
|
||||
const meta=document.createElement('div');
|
||||
meta.className='session-meta';
|
||||
meta.textContent=metaBits.join(' · ');
|
||||
sessionText.appendChild(meta);
|
||||
}
|
||||
// Append tag chips after the title text
|
||||
for(const tag of tags){
|
||||
const chip=document.createElement('span');
|
||||
@@ -647,13 +930,6 @@ function renderSessionListFromCache(){
|
||||
setTimeout(()=>{inp.focus();inp.select();},10);
|
||||
};
|
||||
|
||||
// Pin indicator (inline, only when pinned — no space reserved otherwise)
|
||||
if(s.pinned){
|
||||
const pinInd=document.createElement('span');
|
||||
pinInd.className='session-pin-indicator';
|
||||
pinInd.innerHTML=ICONS.pin;
|
||||
el.appendChild(pinInd);
|
||||
}
|
||||
// Project indicator: colored dot appended after the title
|
||||
if(s.project_id){
|
||||
const proj=_allProjects.find(p=>p.project_id===s.project_id);
|
||||
@@ -734,11 +1010,13 @@ async function deleteSession(sid){
|
||||
if(remaining.sessions&&remaining.sessions.length){
|
||||
await loadSession(remaining.sessions[0].session_id);
|
||||
}else{
|
||||
$('topbarTitle').textContent=window._botName||'Hermes';
|
||||
$('topbarMeta').textContent='Start a new conversation';
|
||||
const _tt=$('topbarTitle');if(_tt)_tt.textContent=window._botName||'Hermes';
|
||||
const _tm=$('topbarMeta');if(_tm)_tm.textContent='Start a new conversation';
|
||||
$('msgInner').innerHTML='';
|
||||
$('emptyState').style.display='';
|
||||
$('fileTree').innerHTML='';
|
||||
if(typeof S!=='undefined') S.session=null;
|
||||
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
|
||||
}
|
||||
}
|
||||
showToast('Conversation deleted');
|
||||
|
||||
1702
static/style.css
1702
static/style.css
File diff suppressed because it is too large
Load Diff
106
static/sw.js
Normal file
106
static/sw.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Hermes WebUI Service Worker
|
||||
* Minimal PWA service worker — enables "Add to Home Screen".
|
||||
* No offline caching of API responses (the UI requires a live backend).
|
||||
* Caches only static shell assets so the app shell loads fast on repeat visits.
|
||||
*/
|
||||
|
||||
// Cache version is injected by the server at request time (routes.py /sw.js handler).
|
||||
// Bumps automatically whenever the git commit changes — no manual edits needed.
|
||||
const CACHE_NAME = 'hermes-shell-__CACHE_VERSION__';
|
||||
|
||||
// Static assets that form the app shell
|
||||
const SHELL_ASSETS = [
|
||||
'./',
|
||||
'./static/style.css',
|
||||
'./static/boot.js',
|
||||
'./static/ui.js',
|
||||
'./static/messages.js',
|
||||
'./static/sessions.js',
|
||||
'./static/panels.js',
|
||||
'./static/commands.js',
|
||||
'./static/icons.js',
|
||||
'./static/i18n.js',
|
||||
'./static/workspace.js',
|
||||
'./static/onboarding.js',
|
||||
'./static/favicon.svg',
|
||||
'./static/favicon-32.png',
|
||||
'./manifest.json',
|
||||
];
|
||||
|
||||
// Install: pre-cache the app shell
|
||||
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);
|
||||
});
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch strategy:
|
||||
// - API calls (/api/*, /stream) → always network (never cache)
|
||||
// - Shell assets → cache-first with network fallback
|
||||
// - Everything else → network-first, fall back to offline page
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Never intercept cross-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// API and streaming endpoints — always go to network
|
||||
if (
|
||||
url.pathname.startsWith('/api/') ||
|
||||
url.pathname.includes('/stream') ||
|
||||
url.pathname.startsWith('/health')
|
||||
) {
|
||||
return; // let browser handle normally
|
||||
}
|
||||
|
||||
// Shell assets: cache-first
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then((response) => {
|
||||
// Cache successful GET responses for shell assets
|
||||
if (
|
||||
event.request.method === 'GET' &&
|
||||
response.status === 200
|
||||
) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {
|
||||
// Offline fallback for navigation requests.
|
||||
// Note: caches.match() returns a Promise (always truthy in a `||` check),
|
||||
// so we must await/then to unwrap it — otherwise the `new Response(...)`
|
||||
// branch is dead code and the browser falls back to its default offline page.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('./').then((cached) => cached || new Response(
|
||||
'<html><body style="font-family:sans-serif;padding:2rem;background:#1a1a1a;color:#ccc">' +
|
||||
'<h2>You are offline</h2>' +
|
||||
'<p>Hermes requires a server connection. Please check your network and try again.</p>' +
|
||||
'</body></html>',
|
||||
{ headers: { 'Content-Type': 'text/html' } }
|
||||
));
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
1033
static/ui.js
1033
static/ui.js
File diff suppressed because it is too large
Load Diff
29
static/vendor/smd.min.js
vendored
Normal file
29
static/vendor/smd.min.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
var D=2,C=3,h=4,b=5,B=6,U=7,G=8,S=9,x=10,m=11,H=12,K=13,M=14,Q=15,w=16,q=17,W=18,P=19,Y=20,y=21,F=22,$=23,v=24,X=25,j=26,z=27,J=28,V=29,Z=30,p=31;var I=1,k=2,L=4,T=8,f=16;function ee(e){switch(e){case I:return"href";case k:return"src";case L:return"class";case T:return"checked";case f:return"start"}}var ne=e=>{switch(e){case 1:return 3;case 2:return 4;case 3:return 5;case 4:return 6;case 5:return 7;default:return 8}},te=ne;var O=24;function ae(e){let c=new Uint32Array(O);return c[0]=1,{renderer:e,text:"",pending:"",tokens:c,len:0,token:1,fence_end:0,blockquote_idx:0,hr_char:"",hr_chars:0,fence_start:0,spaces:new Uint8Array(O),indent:"",indent_len:0,table_state:0}}function ce(e){e.pending.length>0&&o(e,`
|
||||
`)}function a(e){e.text.length!==0&&(e.renderer.add_text(e.renderer.data,e.text),e.text="")}function _(e){e.len-=1,e.token=e.tokens[e.len],e.renderer.end_token(e.renderer.data)}function i(e,c){(e.tokens[e.len]===24||e.tokens[e.len]===23)&&c!==25&&_(e),e.len+=1,e.tokens[e.len]=c,e.token=c,e.renderer.add_token(e.renderer.data,c)}function re(e,c,n){for(;n<=e.len;){if(e.tokens[n]===c)return n;n+=1}return-1}function l(e,c){for(e.fence_start=0;e.len>c;)_(e)}function u(e,c){let n=0;for(let t=0;t<=e.len&&(c-=e.spaces[t],!(c<0));t+=1)switch(e.tokens[t]){case 9:case 10:case 20:case 25:n=t;break}for(;e.len>n;)_(e);return c}function A(e,c){let n=-1,t=-1;for(let s=e.blockquote_idx+1;s<=e.len;s+=1)if(e.tokens[s]===25){if(e.indent_len<e.spaces[s]){t=-1;break}t=s}else e.tokens[s]===c&&(n=s);return t===-1?n===-1?(l(e,e.blockquote_idx),i(e,c),!0):(l(e,n),!1):(l(e,t),i(e,c),!0)}function g(e,c){i(e,25),e.spaces[e.len]=e.indent_len+c,E(e),e.token=103}function E(e){e.indent="",e.indent_len=0,e.pending=""}function N(e){switch(e){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:return!0;default:return!1}}function ie(e){switch(e){case 32:case 58:case 59:case 41:case 44:case 33:case 46:case 63:case 93:case 10:return!0;default:return!1}}function se(e){return N(e)||ie(e)}function o(e,c){for(let n of c){if(e.token===101){switch(n){case" ":e.indent_len+=1;continue;case" ":e.indent_len+=4;continue}let s=u(e,e.indent_len);e.indent_len=0,e.token=e.tokens[e.len],s>0&&o(e," ".repeat(s))}let t=e.pending+n;switch(e.token){case 21:case 1:case 20:case 24:case 23:switch(e.pending[0]){case void 0:e.pending=n;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=1;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=4;continue;case`
|
||||
`:if(e.tokens[e.len]===25&&e.token===21){_(e),E(e),e.pending=n;continue}l(e,e.blockquote_idx),E(e),e.blockquote_idx=0,e.fence_start=0,e.pending=n;continue;case"#":switch(n){case"#":if(e.pending.length<6){e.pending=t;continue}break;case" ":u(e,e.indent_len),i(e,te(e.pending.length)),E(e);continue}break;case">":{let r=re(e,20,e.blockquote_idx+1);r===-1?(l(e,e.blockquote_idx),e.blockquote_idx+=1,e.fence_start=0,i(e,20)):e.blockquote_idx=r,E(e),e.pending=n;continue}case"-":case"*":case"_":if(e.hr_chars===0&&(e.hr_chars=1,e.hr_char=e.pending),e.hr_chars>0){switch(n){case e.hr_char:e.hr_chars+=1,e.pending=t;continue;case" ":e.pending=t;continue;case`
|
||||
`:if(e.hr_chars<3)break;u(e,e.indent_len),e.renderer.add_token(e.renderer.data,22),e.renderer.end_token(e.renderer.data),E(e),e.hr_chars=0;continue}e.hr_chars=0}if(e.pending[0]!=="_"&&e.pending[1]===" "){A(e,23),g(e,2),o(e,t.slice(2));continue}break;case"`":if(e.pending.length<3){if(n==="`"){e.pending=t,e.fence_start=t.length;continue}e.fence_start=0;break}switch(n){case"`":e.pending.length===e.fence_start?(e.pending=t,e.fence_start=t.length):(i(e,2),E(e),e.fence_start=0,o(e,t));continue;case`
|
||||
`:{u(e,e.indent_len),i(e,10),e.pending.length>e.fence_start&&e.renderer.set_attr(e.renderer.data,L,e.pending.slice(e.fence_start)),E(e),e.token=101;continue}default:e.pending=t;continue}case"+":if(n!==" ")break;A(e,23),g(e,2);continue;case"0":case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":if(e.pending[e.pending.length-1]==="."){if(n!==" ")break;A(e,24)&&e.pending!=="1."&&e.renderer.set_attr(e.renderer.data,f,e.pending.slice(0,-1)),g(e,e.pending.length+1);continue}else{let r=n.charCodeAt(0);if(r===46||N(r)){e.pending=t;continue}}break;case"|":l(e,e.blockquote_idx),i(e,27),i(e,28),e.pending="",o(e,n);continue}let s=t;if(e.token===21)e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data);else if(e.indent_len>=4){let r=0;for(;r<4;r+=1)if(e.indent[r]===" "){r=r+1;break}s=e.indent.slice(r)+t,i(e,9)}else i(e,2);E(e),o(e,s);continue;case 27:if(e.table_state===1)switch(n){case"-":case" ":case"|":case":":e.pending=t;continue;case`
|
||||
`:e.table_state=2,e.pending="";continue;default:_(e),e.table_state=0;break}else switch(e.pending){case"|":i(e,28),e.pending="",o(e,n);continue;case`
|
||||
`:_(e),e.pending="",e.table_state=0,o(e,n);continue}break;case 28:switch(e.pending){case"":break;case"|":i(e,29),_(e),e.pending="",o(e,n);continue;case`
|
||||
`:_(e),e.table_state=Math.min(e.table_state+1,2),e.pending="",o(e,n);continue;default:i(e,29),o(e,n);continue}break;case 29:if(e.pending==="|"){a(e),_(e),e.pending="",o(e,n);continue}break;case 9:switch(t){case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:e.text+=`
|
||||
`,e.pending="";continue;case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:case`
|
||||
`:e.pending=t;continue;default:e.pending.length!==0?(a(e),_(e),e.pending=n):e.text+=n;continue}case 10:switch(n){case"`":e.pending=t;continue;case`
|
||||
`:if(t.length===e.fence_start+e.fence_end+1){a(e),_(e),e.pending="",e.fence_start=0,e.fence_end=0,e.token=101;continue}e.token=101;break;case" ":if(e.pending[0]===`
|
||||
`){e.pending=t,e.fence_end+=1;continue}break}e.text+=e.pending,e.pending=n,e.fence_end=1;continue;case 11:switch(n){case"`":t.length===e.fence_start+ +(e.pending[0]===" ")?(a(e),_(e),e.pending="",e.fence_start=0):e.pending=t;continue;case`
|
||||
`:e.text+=e.pending,e.pending="",e.token=21,e.blockquote_idx=0,a(e);continue;case" ":e.text+=e.pending,e.pending=n;continue;default:e.text+=t,e.pending="";continue}case 103:switch(e.pending.length){case 0:if(n!=="[")break;e.pending=t;continue;case 1:if(n!==" "&&n!=="x")break;e.pending=t;continue;case 2:if(n!=="]")break;e.pending=t;continue;case 3:if(n!==" ")break;e.renderer.add_token(e.renderer.data,26),e.pending[1]==="x"&&e.renderer.set_attr(e.renderer.data,T,""),e.renderer.end_token(e.renderer.data),e.pending=" ";continue}e.token=e.tokens[e.len],e.pending="",o(e,t);continue;case 14:case 15:{let r="*",d=12;if(e.token===15&&(r="_",d=13),r===e.pending){if(a(e),r===n){_(e),e.pending="";continue}i(e,d),e.pending=n;continue}break}case 12:case 13:{let r="*",d=14;switch(e.token===13&&(r="_",d=15),e.pending){case r:r===n?e.tokens[e.len-1]===d?e.pending=t:(a(e),i(e,d),e.pending=""):(a(e),_(e),e.pending=n);continue;case r+r:let R=e.token;a(e),_(e),_(e),r!==n?(i(e,R),e.pending=n):e.pending="";continue}break}case 16:if(t==="~~"){a(e),_(e),e.pending="";continue}break;case 105:n===`
|
||||
`?(a(e),i(e,30),e.pending=""):(e.token=e.tokens[e.len],e.pending[0]==="\\"?e.text+="[":e.text+="$$",e.pending="",o(e,n));continue;case 30:if(t==="\\]"||t==="$$"){a(e),_(e),e.pending="";continue}break;case 31:if(t==="\\)"||e.pending[0]==="$"){a(e),_(e),n===")"?e.pending="":e.pending=n;continue}break;case 102:t==="http://"||t==="https://"?(a(e),i(e,18),e.pending=t,e.text=t):"http:/"[e.pending.length]===n||"https:/"[e.pending.length]===n?e.pending=t:(e.token=e.tokens[e.len],o(e,n));continue;case 17:case 19:if(e.pending==="]"){a(e),n==="("?e.pending=t:(_(e),e.pending=n);continue}if(e.pending[0]==="]"&&e.pending[1]==="("){if(n===")"){let r=e.token===17?I:k,d=e.pending.slice(2);e.renderer.set_attr(e.renderer.data,r,d),_(e),e.pending=""}else e.pending+=n;continue}break;case 18:n===" "||n===`
|
||||
`||n==="\\"?(e.renderer.set_attr(e.renderer.data,I,e.pending),a(e),_(e),e.pending=n):(e.text+=n,e.pending=t);continue;case 104:if(t.startsWith("<br")){if(t.length===3||n===" "||n==="/"&&(t.length===4||e.pending[e.pending.length-1]===" ")){e.pending=t;continue}if(n===">"){a(e),e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data),e.pending="";continue}}e.token=e.tokens[e.len],e.text+="<",e.pending=e.pending.slice(1),o(e,n);continue}switch(e.pending[0]){case"\\":if(e.token===19||e.token===30||e.token===31)break;switch(n){case"(":a(e),i(e,31),e.pending="";continue;case"[":e.token=105,e.pending=t;continue;case`
|
||||
`:e.pending=n;continue;default:let s=n.charCodeAt(0);e.pending="",e.text+=N(s)||s>=65&&s<=90||s>=97&&s<=122?t:n;continue}case`
|
||||
`:switch(e.token){case 19:case 30:case 31:break;case 3:case 4:case 5:case 6:case 7:case 8:a(e),l(e,e.blockquote_idx),e.blockquote_idx=0,e.pending=n;continue;default:a(e),e.pending=n,e.token=21,e.blockquote_idx=0;continue}break;case"<":if(e.token!==19&&e.token!==30&&e.token!==31){a(e),e.pending=t,e.token=104;continue}break;case"`":if(e.token===19)break;n==="`"?(e.fence_start+=1,e.pending=t):(e.fence_start+=1,a(e),i(e,11),e.text=n===" "||n===`
|
||||
`?"":n,e.pending="");continue;case"_":case"*":{if(e.token===19||e.token===30||e.token===31||e.token===14)break;let s=12,r=14,d=e.pending[0];if(d==="_"&&(s=13,r=15),e.pending.length===1){if(d===n){e.pending=t;continue}if(n!==" "&&n!==`
|
||||
`){a(e),i(e,s),e.pending=n;continue}}else{if(d===n){a(e),i(e,r),i(e,s),e.pending="";continue}if(n!==" "&&n!==`
|
||||
`){a(e),i(e,r),e.pending=n;continue}}break}case"~":if(e.token!==19&&e.token!==16){if(e.pending==="~"){if(n==="~"){e.pending=t;continue}}else if(n!==" "&&n!==`
|
||||
`){a(e),i(e,16),e.pending=n;continue}}break;case"$":if(e.token!==19&&e.token!==16&&e.pending==="$")if(n==="$"){e.token=105,e.pending=t;continue}else{if(se(n.charCodeAt(0)))break;a(e),i(e,31),e.pending=n;continue}break;case"[":if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n!=="]"){a(e),i(e,17),e.pending=n;continue}break;case"!":if(e.token!==19&&n==="["){a(e),i(e,19),e.pending="";continue}break;case" ":if(e.pending.length===1&&n===" ")continue;break}if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n==="h"&&(e.pending===" "||e.pending==="")){e.text+=e.pending,e.pending=n,e.token=102;continue}e.text+=e.pending,e.pending=n}a(e)}function _e(e){return{add_token:oe,end_token:de,add_text:Ee,set_attr:le,data:{nodes:[e,,,,,],index:0}}}function oe(e,c){let n=e.nodes[e.index],t;switch(c){case 1:return;case 20:t=document.createElement("blockquote");break;case 2:t=document.createElement("p");break;case 21:t=document.createElement("br");break;case 22:t=document.createElement("hr");break;case 3:t=document.createElement("h1");break;case 4:t=document.createElement("h2");break;case 5:t=document.createElement("h3");break;case 6:t=document.createElement("h4");break;case 7:t=document.createElement("h5");break;case 8:t=document.createElement("h6");break;case 12:case 13:t=document.createElement("em");break;case 14:case 15:t=document.createElement("strong");break;case 16:t=document.createElement("s");break;case 11:t=document.createElement("code");break;case 18:case 17:t=document.createElement("a");break;case 19:t=document.createElement("img");break;case 23:t=document.createElement("ul");break;case 24:t=document.createElement("ol");break;case 25:t=document.createElement("li");break;case 26:let s=t=document.createElement("input");s.type="checkbox",s.disabled=!0;break;case 9:case 10:n=n.appendChild(document.createElement("pre")),t=document.createElement("code");break;case 27:t=document.createElement("table");break;case 28:switch(n.children.length){case 0:n=n.appendChild(document.createElement("thead"));break;case 1:n=n.appendChild(document.createElement("tbody"));break;default:n=n.children[1]}t=document.createElement("tr");break;case 29:t=document.createElement(n.parentElement?.tagName==="THEAD"?"th":"td");break;case 30:t=document.createElement("equation-block");break;case 31:t=document.createElement("equation-inline");break}e.nodes[++e.index]=n.appendChild(t)}function de(e){e.index-=1}function Ee(e,c){e.nodes[e.index].appendChild(document.createTextNode(c))}function le(e,c,n){e.nodes[e.index].setAttribute(ee(c),n)}export{Y as BLOCKQUOTE,j as CHECKBOX,T as CHECKED,S as CODE_BLOCK,x as CODE_FENCE,m as CODE_INLINE,Z as EQUATION_BLOCK,p as EQUATION_INLINE,C as HEADING_1,h as HEADING_2,b as HEADING_3,B as HEADING_4,U as HEADING_5,G as HEADING_6,I as HREF,P as IMAGE,H as ITALIC_AST,K as ITALIC_UND,L as LANG,y as LINE_BREAK,q as LINK,X as LIST_ITEM,v as LIST_ORDERED,$ as LIST_UNORDERED,D as PARAGRAPH,W as RAW_URL,F as RULE,k as SRC,f as START,w as STRIKE,M as STRONG_AST,Q as STRONG_UND,z as TABLE,V as TABLE_CELL,J as TABLE_ROW,_e as default_renderer,ae as parser,ce as parser_end,o as parser_write};
|
||||
@@ -40,3 +40,7 @@ TEST_STATE_DIR = pathlib.Path(os.environ.get(
|
||||
'HERMES_WEBUI_TEST_STATE_DIR',
|
||||
str(_HERMES_HOME / _auto_state_dir_name(_REPO_ROOT))
|
||||
))
|
||||
|
||||
# Default model injected by conftest — tests that mutate the default model
|
||||
# must restore to this value so later tests see a consistent baseline.
|
||||
TEST_DEFAULT_MODEL = os.environ.get('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')
|
||||
|
||||
@@ -274,6 +274,14 @@ def test_server():
|
||||
# os.environ already set at module level above; no-op here.
|
||||
|
||||
env = os.environ.copy()
|
||||
# Strip real provider keys so test subprocess never inherits production credentials.
|
||||
# The test server uses a mock/isolated config — no real API calls are made.
|
||||
for _k in list(env):
|
||||
if any(_k.startswith(p) for p in (
|
||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
|
||||
'GOOGLE_API_KEY', 'DEEPSEEK_API_KEY',
|
||||
)):
|
||||
del env[_k]
|
||||
env.update({
|
||||
"HERMES_WEBUI_PORT": str(TEST_PORT),
|
||||
"HERMES_WEBUI_HOST": "127.0.0.1",
|
||||
@@ -281,6 +289,14 @@ def test_server():
|
||||
"HERMES_WEBUI_DEFAULT_WORKSPACE": str(TEST_WORKSPACE),
|
||||
"HERMES_WEBUI_DEFAULT_MODEL": "openai/gpt-5.4-mini",
|
||||
"HERMES_HOME": str(TEST_STATE_DIR),
|
||||
# Belt-and-suspenders: HERMES_BASE_HOME hard-locks _DEFAULT_HERMES_HOME
|
||||
# in api/profiles.py to the test state dir regardless of profile switching
|
||||
# or any os.environ mutation that happens inside the server process.
|
||||
# Without this, a profile switch or active_profile file in the real
|
||||
# ~/.hermes can redirect _get_active_hermes_home() out of the sandbox,
|
||||
# causing onboarding writes (config.yaml, .env) to land in the production
|
||||
# ~/.hermes/profiles/webui/ and overwrite real API keys.
|
||||
"HERMES_BASE_HOME": str(TEST_STATE_DIR),
|
||||
})
|
||||
|
||||
# Pass agent dir if discovered so server.py doesn't have to re-discover
|
||||
@@ -326,6 +342,33 @@ def base_url():
|
||||
return TEST_BASE
|
||||
|
||||
|
||||
# ── Per-test model cache invalidation ────────────────────────────────────────
|
||||
# The TTL cache for get_available_models() persists across tests within the
|
||||
# same process. Tests that modify cfg in-memory won't trigger the mtime path,
|
||||
# so the cache must be explicitly invalidated after each test that exercises
|
||||
# provider/model detection.
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _invalidate_models_cache_after_test():
|
||||
"""Force the TTL cache to be cleared before and after every test.
|
||||
|
||||
This prevents state bleed where a test that calls get_available_models()
|
||||
populates the cache with a particular config, and the next test sees stale
|
||||
results even though it has mutated _cfg_cache in-memory.
|
||||
"""
|
||||
try:
|
||||
from api.config import invalidate_models_cache
|
||||
invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
from api.config import invalidate_models_cache
|
||||
invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Per-test session cleanup ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
104
tests/test_745_code_block_newlines.py
Normal file
104
tests/test_745_code_block_newlines.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Tests for #745: code blocks losing newlines when not preceded by double blank line.
|
||||
|
||||
Root cause: the paragraph-splitter in renderMd() replaced \n with <br> inside
|
||||
<pre><code> blocks when they were not separated by a double newline from surrounding
|
||||
text. The fix stashes <pre> blocks (and pre-header divs, mermaid, katex) before
|
||||
the paragraph split and restores them afterwards.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
||||
|
||||
|
||||
def get_ui_js():
|
||||
return open(UI_JS, encoding='utf-8').read()
|
||||
|
||||
|
||||
class TestCodeBlockNewlinePreservation:
|
||||
|
||||
def test_pre_stash_present(self):
|
||||
"""The _pre_stash variable must exist in ui.js."""
|
||||
src = get_ui_js()
|
||||
assert '_pre_stash' in src, "_pre_stash not found in ui.js"
|
||||
|
||||
def test_pre_stash_token_E_used(self):
|
||||
"""Stash token \\x00E must be used for pre-block stashing."""
|
||||
src = get_ui_js()
|
||||
assert r'\x00E' in src, r"\x00E stash token not found in ui.js"
|
||||
|
||||
def test_stash_before_paragraph_split(self):
|
||||
"""_pre_stash must be populated BEFORE the parts=s.split line."""
|
||||
src = get_ui_js()
|
||||
pre_stash_pos = src.index('_pre_stash=[]')
|
||||
split_pos = src.index('const parts=s.split(/\\n{2,}/)')
|
||||
assert pre_stash_pos < split_pos, \
|
||||
"_pre_stash must be initialised before the paragraph split"
|
||||
|
||||
def test_restore_after_paragraph_split(self):
|
||||
"""_pre_stash restore must happen AFTER the paragraph map/join line."""
|
||||
src = get_ui_js()
|
||||
restore_pos = src.index('_pre_stash[+i]')
|
||||
split_pos = src.index("}).join('\\n');", src.index('const parts=s.split'))
|
||||
assert restore_pos > split_pos, \
|
||||
"_pre_stash must be restored after the paragraph split/join"
|
||||
|
||||
def test_paragraph_split_bypasses_stash_tokens(self):
|
||||
"""The paragraph map must bypass lines that start with \\x00E."""
|
||||
src = get_ui_js()
|
||||
# The map line must check for \x00E in its bypass condition
|
||||
map_line = next(
|
||||
l for l in src.splitlines()
|
||||
if 'parts.map' in l and '<br>' in l
|
||||
)
|
||||
assert r'\x00E' in map_line, \
|
||||
r"paragraph map must bypass \x00E stash tokens"
|
||||
|
||||
def test_pre_regex_covers_pre_header_div(self):
|
||||
"""The stash regex must match <div class=\"pre-header\"> before <pre>."""
|
||||
src = get_ui_js()
|
||||
# Find the replacement regex used to populate _pre_stash
|
||||
stash_block_idx = src.index('_pre_stash=[]')
|
||||
stash_block = src[stash_block_idx:stash_block_idx + 400]
|
||||
assert 'pre-header' in stash_block, \
|
||||
"pre-stash regex must match <div class=\"pre-header\"> wrappers"
|
||||
|
||||
def test_mermaid_covered_by_stash(self):
|
||||
"""The stash regex must also cover mermaid-block divs."""
|
||||
src = get_ui_js()
|
||||
stash_block_idx = src.index('_pre_stash=[]')
|
||||
stash_block = src[stash_block_idx:stash_block_idx + 400]
|
||||
assert 'mermaid-block' in stash_block, \
|
||||
"pre-stash regex must cover mermaid-block divs"
|
||||
|
||||
def test_katex_covered_by_stash(self):
|
||||
"""The stash regex must also cover katex-block divs."""
|
||||
src = get_ui_js()
|
||||
stash_block_idx = src.index('_pre_stash=[]')
|
||||
stash_block = src[stash_block_idx:stash_block_idx + 400]
|
||||
assert 'katex-block' in stash_block, \
|
||||
"pre-stash regex must cover katex-block divs"
|
||||
|
||||
def test_js_syntax_valid(self):
|
||||
"""ui.js must pass node --check after the fix."""
|
||||
result = subprocess.run(
|
||||
['node', '--check', UI_JS],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0, \
|
||||
f"node --check failed:\n{result.stderr}"
|
||||
|
||||
def test_stash_token_e_not_used_elsewhere(self):
|
||||
"""\\x00E must only appear in the pre-stash section (not reused)."""
|
||||
src = get_ui_js()
|
||||
occurrences = [
|
||||
i for i in range(len(src))
|
||||
if src[i:i+4] == r'\x00' and i + 4 < len(src) and src[i+4] == 'E'
|
||||
]
|
||||
# Allow 2 occurrences: the push token and the restore regex
|
||||
# (may be 3 if there's also a comment mentioning it)
|
||||
assert len(occurrences) >= 2, \
|
||||
r"Expected at least 2 uses of \x00E (push + restore)"
|
||||
92
tests/test_886_ordered_list_numbering.py
Normal file
92
tests/test_886_ordered_list_numbering.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Tests for #886: ordered list items always rendered as "1." regardless of position.
|
||||
|
||||
Root cause: when LLMs output numbered lists with blank lines between items,
|
||||
the paragraph-splitter in renderMd() splits the markdown into one chunk per item,
|
||||
so the ordered-list regex wraps each item in its own <ol>. Each <ol> restarts
|
||||
at 1, producing "1. 1. 1." instead of "1. 2. 3.".
|
||||
|
||||
Fix: emit value="N" on every <li> so the correct ordinal is preserved even when
|
||||
items end up in separate <ol> containers after the paragraph split.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
||||
|
||||
|
||||
def get_ui_js():
|
||||
return open(UI_JS, encoding='utf-8').read()
|
||||
|
||||
|
||||
class TestOrderedListNumbering:
|
||||
|
||||
def test_li_value_attr_present_in_ordered_list_block(self):
|
||||
"""The ordered-list renderer must emit value= on each <li>."""
|
||||
src = get_ui_js()
|
||||
# Locate the ordered-list replace block
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
# Extract a window large enough to cover the whole closure (~400 chars)
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
assert 'value=' in ol_block, (
|
||||
"Ordered-list block must emit value= attribute on <li> elements to "
|
||||
"preserve numbering when items are separated by blank lines (#886)"
|
||||
)
|
||||
|
||||
def test_li_value_uses_parsed_number(self):
|
||||
"""The value= must be derived from parseInt of the captured digit, not hardcoded."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
assert 'parseInt' in ol_block, (
|
||||
"Ordered-list block should use parseInt() to parse the list number (#886)"
|
||||
)
|
||||
|
||||
def test_numMatch_variable_present(self):
|
||||
"""The numMatch variable (or equivalent digit capture) must exist in the OL block."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
# Either numMatch or a similar digit-capture variable
|
||||
assert 'numMatch' in ol_block or re.search(r'match\(/.*\\d', ol_block), (
|
||||
"Ordered-list block should capture the list item number with a regex match (#886)"
|
||||
)
|
||||
|
||||
def test_valAttr_or_value_template_present(self):
|
||||
"""The <li> template must include the value attribute conditionally or unconditionally."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
ol_block = src[ol_idx:ol_idx + 500]
|
||||
# Either a valAttr variable or an inline value= in the template
|
||||
has_val_attr = 'valAttr' in ol_block
|
||||
has_inline_value = re.search(r'<li.*value=', ol_block)
|
||||
assert has_val_attr or has_inline_value, (
|
||||
"Ordered-list block must have value= on <li> (via valAttr var or inline) (#886)"
|
||||
)
|
||||
|
||||
def test_ordered_list_comment_references_issue(self):
|
||||
"""A comment near the OL fix should reference the issue (#886) or the symptom."""
|
||||
src = get_ui_js()
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
|
||||
# Look at the 300 chars BEFORE the replace line for an explanatory comment
|
||||
context = src[max(0, ol_idx - 300):ol_idx]
|
||||
has_comment = '#886' in context or '1. 1. 1.' in context or 'blank lines' in context.lower()
|
||||
assert has_comment, (
|
||||
"Expected a comment near the OL fix explaining the blank-line issue (#886)"
|
||||
)
|
||||
|
||||
def test_list_without_blank_lines_unaffected(self):
|
||||
"""A compact list (no blank lines) should still produce one <ol> with sequential items."""
|
||||
src = get_ui_js()
|
||||
# Structural check: the regex still captures multi-line blocks (\\n? allows groups)
|
||||
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
|
||||
assert ol_idx != -1, "Ordered-list replace block not found"
|
||||
# The \\n? quantifier that allows grouping must still be present
|
||||
assert '\\n?' in src[ol_idx:ol_idx + 80], (
|
||||
"The \\\\n? in the ordered-list regex was removed — compact lists may break"
|
||||
)
|
||||
94
tests/test_auth_session_persistence.py
Normal file
94
tests/test_auth_session_persistence.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Regression tests: auth sessions persist across process restarts.
|
||||
|
||||
_sessions is an in-memory dict. Without persistence, any restart (launchd,
|
||||
systemd, container) invalidates all active browser sessions and floods clients
|
||||
with 401s until they clear cookies. The HMAC signing key already persists to
|
||||
STATE_DIR; this PR persists the session table using the same pattern.
|
||||
"""
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Isolate state dir so tests never touch real sessions
|
||||
_TEST_STATE = Path(tempfile.mkdtemp())
|
||||
os.environ["HERMES_WEBUI_STATE_DIR"] = str(_TEST_STATE)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import api.auth as auth
|
||||
|
||||
|
||||
class TestSessionPersistence(unittest.TestCase):
|
||||
"""Sessions survive a simulated process restart (module reload)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
auth._sessions.clear()
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
if sessions_file.exists():
|
||||
sessions_file.unlink()
|
||||
|
||||
def _simulate_restart(self) -> None:
|
||||
"""Reload auth module to simulate a fresh process start."""
|
||||
importlib.reload(auth)
|
||||
|
||||
def test_session_survives_restart(self) -> None:
|
||||
"""A session created before restart should still verify after reload."""
|
||||
cookie = auth.create_session()
|
||||
self.assertTrue(auth.verify_session(cookie))
|
||||
self._simulate_restart()
|
||||
self.assertTrue(auth.verify_session(cookie),
|
||||
"Session must survive process restart via persisted .sessions.json")
|
||||
|
||||
def test_invalidated_session_does_not_survive_restart(self) -> None:
|
||||
"""Invalidating a session must be reflected after reload."""
|
||||
cookie = auth.create_session()
|
||||
auth.invalidate_session(cookie)
|
||||
self._simulate_restart()
|
||||
self.assertFalse(auth.verify_session(cookie),
|
||||
"Invalidated session must not be reinstated after restart")
|
||||
|
||||
def test_expired_sessions_pruned_on_load(self) -> None:
|
||||
"""Sessions that expire between restarts must not be loaded."""
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
# Write a sessions file with one expired and one valid entry
|
||||
now = time.time()
|
||||
sessions_file.write_text(json.dumps({
|
||||
"expired_token": now - 10,
|
||||
"valid_token": now + 3600,
|
||||
}))
|
||||
self._simulate_restart()
|
||||
self.assertNotIn("expired_token", auth._sessions)
|
||||
self.assertIn("valid_token", auth._sessions)
|
||||
|
||||
def test_sessions_file_permissions(self) -> None:
|
||||
"""Sessions file must be owner-read-only (0600)."""
|
||||
auth.create_session()
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
|
||||
mode = oct(sessions_file.stat().st_mode & 0o777)
|
||||
self.assertEqual(mode, oct(0o600),
|
||||
f".sessions.json permissions {mode} — expected 0o600")
|
||||
|
||||
def test_malformed_sessions_file_starts_fresh(self) -> None:
|
||||
"""A corrupt sessions file must not crash auth — start with empty dict."""
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
sessions_file.write_text("not valid json {{{{")
|
||||
self._simulate_restart()
|
||||
self.assertEqual(auth._sessions, {},
|
||||
"Corrupt sessions file must result in empty session dict")
|
||||
|
||||
def test_sessions_file_wrong_type_starts_fresh(self) -> None:
|
||||
"""A sessions file containing a non-dict must be ignored."""
|
||||
sessions_file = _TEST_STATE / '.sessions.json'
|
||||
sessions_file.write_text(json.dumps(["list", "not", "dict"]))
|
||||
self._simulate_restart()
|
||||
self.assertEqual(auth._sessions, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
148
tests/test_background_tasks.py
Normal file
148
tests/test_background_tasks.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Regression tests for the /background task tracker.
|
||||
|
||||
Covers two bugs caught in review of PR #932:
|
||||
|
||||
1. `get_results()` was calling `_BACKGROUND_TASKS.pop(parent_sid, [])`, which
|
||||
removed EVERY task (including still-running ones) on the first poll. Once
|
||||
popped, `complete_background()` could no longer find the task to mark done,
|
||||
so the final answer was silently lost.
|
||||
|
||||
2. The `_handle_background` worker thread called `_run_agent_streaming` but
|
||||
never invoked `complete_background()` after it returned. With no completion
|
||||
hook, every background task stayed in `status="running"` forever —
|
||||
`get_results()` filtered them out of its "done" list, and the user never
|
||||
saw the result.
|
||||
|
||||
These two bugs together made the `/background` command completely
|
||||
non-functional as originally shipped. The fix in api/background.py +
|
||||
api/routes.py wires the completion hook and keeps running tasks in the
|
||||
tracker until they resolve.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# Ensure the repo root is importable without relying on CWD.
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
class TestGetResultsKeepsRunningTasks(unittest.TestCase):
|
||||
"""get_results() MUST NOT drop still-running tasks from _BACKGROUND_TASKS."""
|
||||
|
||||
def setUp(self):
|
||||
import api.background as bg
|
||||
bg._BACKGROUND_TASKS.clear()
|
||||
self.bg = bg
|
||||
|
||||
def test_running_tasks_survive_get_results_call(self):
|
||||
"""A running task must remain in the tracker so complete_background()
|
||||
can still find it after the first poll returns."""
|
||||
parent = "parent-session-1"
|
||||
self.bg.track_background(
|
||||
parent_sid=parent, bg_sid="bg-a", stream_id="s-a",
|
||||
task_id="task-a", prompt="long task",
|
||||
)
|
||||
|
||||
# First poll: task is still running, no done results to return
|
||||
results = self.bg.get_results(parent)
|
||||
self.assertEqual(results, [], "no done tasks yet — nothing to return")
|
||||
|
||||
# The running task MUST still be tracked — otherwise the worker
|
||||
# thread's complete_background call cannot find it.
|
||||
remaining = self.bg.get_background_tasks(parent)
|
||||
self.assertEqual(len(remaining), 1, (
|
||||
"get_results dropped the still-running task — subsequent "
|
||||
"complete_background() calls will silently no-op and the "
|
||||
"result will be lost forever"
|
||||
))
|
||||
self.assertEqual(remaining[0]["status"], "running")
|
||||
self.assertEqual(remaining[0]["task_id"], "task-a")
|
||||
|
||||
def test_done_tasks_are_returned_and_removed(self):
|
||||
"""Done tasks are returned and popped; running tasks stay."""
|
||||
parent = "parent-session-2"
|
||||
self.bg.track_background(parent, "bg-done", "s-d", "task-done", "p1")
|
||||
self.bg.track_background(parent, "bg-run", "s-r", "task-run", "p2")
|
||||
self.bg.complete_background(parent, "task-done", "42")
|
||||
|
||||
results = self.bg.get_results(parent)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["task_id"], "task-done")
|
||||
self.assertEqual(results[0]["answer"], "42")
|
||||
|
||||
# Done one is gone; running one is still tracked
|
||||
remaining = self.bg.get_background_tasks(parent)
|
||||
self.assertEqual(len(remaining), 1)
|
||||
self.assertEqual(remaining[0]["task_id"], "task-run")
|
||||
self.assertEqual(remaining[0]["status"], "running")
|
||||
|
||||
def test_complete_after_poll_still_reaches_tracker(self):
|
||||
"""Regression for the original bug: poll → complete → poll must surface
|
||||
the result. Before the fix, the first poll popped the running task and
|
||||
complete_background()'s loop iterated over an empty list."""
|
||||
parent = "parent-session-3"
|
||||
self.bg.track_background(parent, "bg-x", "s-x", "task-x", "slow task")
|
||||
|
||||
# Frontend polls before the task finishes
|
||||
first = self.bg.get_results(parent)
|
||||
self.assertEqual(first, [])
|
||||
|
||||
# Worker thread finishes and calls complete_background
|
||||
self.bg.complete_background(parent, "task-x", "answer!")
|
||||
|
||||
# Next poll must surface the answer
|
||||
second = self.bg.get_results(parent)
|
||||
self.assertEqual(len(second), 1)
|
||||
self.assertEqual(second[0]["task_id"], "task-x")
|
||||
self.assertEqual(second[0]["answer"], "answer!")
|
||||
|
||||
def test_empty_parent_is_cleaned_up(self):
|
||||
"""When all tasks are done and returned, the parent key is removed from the dict."""
|
||||
parent = "parent-session-4"
|
||||
self.bg.track_background(parent, "bg-1", "s-1", "task-1", "p")
|
||||
self.bg.complete_background(parent, "task-1", "ok")
|
||||
self.bg.get_results(parent)
|
||||
self.assertNotIn(parent, self.bg._BACKGROUND_TASKS)
|
||||
|
||||
|
||||
class TestBackgroundCompletionHookWiring(unittest.TestCase):
|
||||
"""Static check: the _handle_background worker thread must call
|
||||
complete_background() after _run_agent_streaming returns. Without this,
|
||||
running tasks stay forever-running and the user never sees the result.
|
||||
"""
|
||||
|
||||
def test_run_bg_and_notify_calls_complete_background(self):
|
||||
"""_handle_background must wrap _run_agent_streaming in a function
|
||||
that subsequently invokes complete_background(parent_sid, task_id, answer)."""
|
||||
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
# Locate the _handle_background function
|
||||
idx = routes_src.find("def _handle_background(")
|
||||
self.assertGreater(idx, -1, "_handle_background() not found in routes.py")
|
||||
# Take a generous window around the function body
|
||||
end = routes_src.find("\ndef ", idx + 1)
|
||||
body = routes_src[idx:end if end > 0 else idx + 3000]
|
||||
|
||||
self.assertIn("complete_background", body, (
|
||||
"_handle_background worker must call complete_background() after "
|
||||
"_run_agent_streaming returns — otherwise the tracker never "
|
||||
"transitions the task to status='done' and /api/background/status "
|
||||
"returns nothing forever. See api/background.py:complete_background."
|
||||
))
|
||||
# Must extract the last assistant message content from the bg session
|
||||
self.assertIn("_run_agent_streaming", body)
|
||||
self.assertIn("Session.load", body, (
|
||||
"_run_bg_and_notify must reload the bg session to extract the "
|
||||
"final assistant reply so complete_background gets an actual answer"
|
||||
))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -77,32 +77,40 @@ class TestCronSkillCacheInvalidation:
|
||||
def test_cache_busted_on_form_open(self):
|
||||
src = self._panels_src()
|
||||
# toggleCronForm should set cache to null unconditionally
|
||||
# openCronCreate() opens the task create form (renamed from toggleCronForm
|
||||
# in the main-view refactor). It must null the skills cache before fetching.
|
||||
m = re.search(
|
||||
r'function toggleCronForm\(\)\{.*?_cronSkillsCache=null',
|
||||
r'function openCronCreate\(\)\{.*?_cronSkillsCache\s*=\s*null',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert m, (
|
||||
"toggleCronForm must unconditionally null _cronSkillsCache "
|
||||
"openCronCreate must unconditionally null _cronSkillsCache "
|
||||
"before fetching skills"
|
||||
)
|
||||
|
||||
def test_cache_not_guarded_by_if_on_open(self):
|
||||
src = self._panels_src()
|
||||
# The old guard should be gone
|
||||
assert "if(!_cronSkillsCache)" not in src, (
|
||||
"toggleCronForm should not use 'if(!_cronSkillsCache)' guard — "
|
||||
# openCronCreate must not gate the fetch behind an if(!_cronSkillsCache) guard.
|
||||
m = re.search(
|
||||
r'function openCronCreate\(\)\{.*?\}',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert m, "openCronCreate definition not found"
|
||||
assert "if(!_cronSkillsCache)" not in m.group(0), (
|
||||
"openCronCreate should not use 'if(!_cronSkillsCache)' guard — "
|
||||
"cache must always be busted on open"
|
||||
)
|
||||
|
||||
def test_cache_busted_on_skill_save(self):
|
||||
src = self._panels_src()
|
||||
# After submitSkillSave's api() call, _cronSkillsCache must be nulled
|
||||
# saveSkillForm() is the handler invoked on skill save (renamed from
|
||||
# submitSkillSave in the main-view refactor; the old name still aliases it).
|
||||
m = re.search(
|
||||
r'async function submitSkillSave\(\).*?_skillsData\s*=\s*null.*?_cronSkillsCache\s*=\s*null',
|
||||
r'async function saveSkillForm\(\).*?_skillsData\s*=\s*null.*?_cronSkillsCache\s*=\s*null',
|
||||
src, re.DOTALL
|
||||
)
|
||||
assert m, (
|
||||
"_cronSkillsCache must be set to null in submitSkillSave() "
|
||||
"_cronSkillsCache must be set to null in saveSkillForm() "
|
||||
"right after _skillsData = null"
|
||||
)
|
||||
|
||||
@@ -119,7 +127,7 @@ class TestSystemTheme:
|
||||
|
||||
def test_apply_theme_resolves_system(self):
|
||||
src = read("static/boot.js")
|
||||
assert "name==='system'" in src or "=== 'system'" in src, (
|
||||
assert "normalized.theme==='system'" in src or "=== 'system'" in src, (
|
||||
"_applyTheme must branch on 'system' to resolve via matchMedia"
|
||||
)
|
||||
|
||||
@@ -131,23 +139,23 @@ class TestSystemTheme:
|
||||
|
||||
def test_load_settings_calls_apply_theme(self):
|
||||
src = read("static/boot.js")
|
||||
assert "_applyTheme(_theme)" in src, (
|
||||
assert "_applyTheme(appearance.theme)" in src, (
|
||||
"loadSettings must call _applyTheme() instead of direct data-theme assignment"
|
||||
)
|
||||
|
||||
def test_system_option_in_theme_select(self):
|
||||
def test_system_option_in_theme_picker(self):
|
||||
html = read("static/index.html")
|
||||
assert 'value="system"' in html, (
|
||||
"Theme <select> must include <option value=\"system\">"
|
||||
assert "_pickTheme('system')" in html, (
|
||||
"Theme picker must include a system theme button"
|
||||
)
|
||||
assert "System (auto)" in html, (
|
||||
"Theme picker must show 'System (auto)' label"
|
||||
assert ">System<" in html, (
|
||||
"Theme picker must show 'System' label"
|
||||
)
|
||||
|
||||
def test_theme_select_uses_apply_theme_onchange(self):
|
||||
def test_theme_picker_uses_pick_theme(self):
|
||||
html = read("static/index.html")
|
||||
assert "_applyTheme(this.value)" in html, (
|
||||
"Theme <select> onchange must call _applyTheme(this.value)"
|
||||
assert "_pickTheme(" in html, (
|
||||
"Theme buttons must call _pickTheme()"
|
||||
)
|
||||
|
||||
def test_flicker_script_resolves_system(self):
|
||||
@@ -156,6 +164,9 @@ class TestSystemTheme:
|
||||
assert "==='system'" in html or "=== 'system'" in html, (
|
||||
"Flicker-prevention head script must resolve 'system' before setting data-theme"
|
||||
)
|
||||
assert "legacy={slate:['dark','slate']" in html, (
|
||||
"Flicker-prevention head script must normalize legacy theme names on first paint"
|
||||
)
|
||||
|
||||
def test_system_in_commands_themes_list(self):
|
||||
src = read("static/commands.js")
|
||||
@@ -165,8 +176,14 @@ class TestSystemTheme:
|
||||
|
||||
def test_commands_uses_apply_theme(self):
|
||||
src = read("static/commands.js")
|
||||
assert "_applyTheme(themeName)" in src, (
|
||||
"cmdTheme must call _applyTheme() to handle system resolution"
|
||||
assert "_applyTheme(appearance.theme)" in src, (
|
||||
"cmdTheme must call _applyTheme() with the normalized canonical theme"
|
||||
)
|
||||
|
||||
def test_commands_accept_legacy_theme_aliases(self):
|
||||
src = read("static/commands.js")
|
||||
assert "const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});" in src, (
|
||||
"cmdTheme must accept legacy theme aliases and map them onto canonical appearance values"
|
||||
)
|
||||
|
||||
def test_panels_reverts_via_apply_theme(self):
|
||||
@@ -197,3 +214,22 @@ class TestSystemTheme:
|
||||
f"cmd_theme description should mention 'system' in all 5 locales; "
|
||||
f"found {count}"
|
||||
)
|
||||
|
||||
def test_theme_listener_cleanup_uses_stable_handler(self):
|
||||
src = read("static/boot.js")
|
||||
assert "_systemThemeMq&&_onSystemThemeChange" in src, (
|
||||
"_applyTheme must track the active OS-theme listener so it can be removed cleanly"
|
||||
)
|
||||
assert "removeEventListener('change',_onSystemThemeChange)" in src, (
|
||||
"_applyTheme must remove the previous OS-theme listener before adding a new one"
|
||||
)
|
||||
|
||||
def test_panels_hydrates_appearance_before_models_fetch(self):
|
||||
src = read("static/panels.js")
|
||||
skin_idx = src.index("const skinVal=(settings.skin||'default').toLowerCase();")
|
||||
# models is now declared as let models=null before the try block
|
||||
models_idx = src.index("models=await api('/api/models');")
|
||||
assert skin_idx < models_idx, (
|
||||
"loadSettingsPanel must hydrate theme/skin before awaiting /api/models, "
|
||||
"otherwise a slow model fetch can clobber an in-progress skin selection"
|
||||
)
|
||||
|
||||
187
tests/test_bootstrap_dotenv.py
Normal file
187
tests/test_bootstrap_dotenv.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Tests for bootstrap.py .env loading fix (issue #730).
|
||||
|
||||
bootstrap.py is the primary documented entry point ("python3 bootstrap.py").
|
||||
Previously it did not load REPO_ROOT/.env, so HERMES_WEBUI_HOST, HERMES_WEBUI_PORT
|
||||
etc. were silently ignored when launching without start.sh.
|
||||
|
||||
Covers:
|
||||
1. _load_repo_dotenv() sets env vars from a repo .env file
|
||||
2. _load_repo_dotenv() ignores commented lines and blank lines
|
||||
3. _load_repo_dotenv() strips quotes from values
|
||||
4. _load_repo_dotenv() is a no-op when .env does not exist
|
||||
5. _load_repo_dotenv() prints a warning (not crash) on unreadable .env
|
||||
6. _load_repo_dotenv() overwrites existing env vars (shell source semantics)
|
||||
7. _load_repo_dotenv() handles 'export FOO=bar' prefix
|
||||
8. _load_repo_dotenv() preserves values containing '='
|
||||
9. Variables are set unconditionally (not setdefault)
|
||||
10. Structural: loader is called before DEFAULT_HOST/DEFAULT_PORT
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
class TestLoadRepoDotenv:
|
||||
|
||||
def setup_method(self):
|
||||
self._saved_env = os.environ.copy()
|
||||
|
||||
def teardown_method(self):
|
||||
os.environ.clear()
|
||||
os.environ.update(self._saved_env)
|
||||
|
||||
def _run(self, tmp_path, env_content: str):
|
||||
"""Write .env to tmp_path and run _load_repo_dotenv() with that root."""
|
||||
import bootstrap as bs
|
||||
(tmp_path / ".env").write_text(env_content, encoding="utf-8")
|
||||
orig_root = bs.REPO_ROOT
|
||||
try:
|
||||
bs.REPO_ROOT = tmp_path
|
||||
bs._load_repo_dotenv()
|
||||
finally:
|
||||
bs.REPO_ROOT = orig_root
|
||||
|
||||
def test_sets_env_var_from_dotenv(self, tmp_path):
|
||||
"""Basic key=value is loaded into os.environ."""
|
||||
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
|
||||
def test_sets_port_from_dotenv(self, tmp_path):
|
||||
"""HERMES_WEBUI_PORT is loaded as a string (caller does int() conversion)."""
|
||||
self._run(tmp_path, "HERMES_WEBUI_PORT=18787\n")
|
||||
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
|
||||
|
||||
def test_ignores_comment_lines(self, tmp_path):
|
||||
"""Lines starting with # are not loaded."""
|
||||
os.environ.pop("HERMES_WEBUI_HOST", None)
|
||||
self._run(tmp_path, "# HERMES_WEBUI_HOST=should-be-ignored\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") is None
|
||||
|
||||
def test_ignores_blank_lines(self, tmp_path):
|
||||
"""Blank lines are silently skipped without error."""
|
||||
self._run(tmp_path, "\n\nHERMES_WEBUI_PORT=9000\n\n")
|
||||
assert os.environ.get("HERMES_WEBUI_PORT") == "9000"
|
||||
|
||||
def test_strips_double_quoted_values(self, tmp_path):
|
||||
"""Values wrapped in double quotes are stripped."""
|
||||
self._run(tmp_path, 'HERMES_WEBUI_HOST="0.0.0.0"\n')
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
|
||||
def test_strips_single_quoted_values(self, tmp_path):
|
||||
"""Values wrapped in single quotes are stripped."""
|
||||
self._run(tmp_path, "HERMES_WEBUI_HOST='0.0.0.0'\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
|
||||
def test_noop_when_no_dotenv(self, tmp_path):
|
||||
"""No .env file — function returns silently without error."""
|
||||
import bootstrap as bs
|
||||
orig = bs.REPO_ROOT
|
||||
try:
|
||||
bs.REPO_ROOT = tmp_path # tmp_path has no .env
|
||||
bs._load_repo_dotenv() # must not raise
|
||||
finally:
|
||||
bs.REPO_ROOT = orig
|
||||
|
||||
def test_noop_when_dotenv_unreadable(self, tmp_path, capsys):
|
||||
"""Unreadable .env prints a warning to stderr — does not crash."""
|
||||
import bootstrap as bs
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("HERMES_WEBUI_PORT=9999\n")
|
||||
orig = bs.REPO_ROOT
|
||||
try:
|
||||
bs.REPO_ROOT = tmp_path
|
||||
with patch("pathlib.Path.read_text", side_effect=PermissionError("no access")):
|
||||
bs._load_repo_dotenv() # must not raise
|
||||
finally:
|
||||
bs.REPO_ROOT = orig
|
||||
captured = capsys.readouterr()
|
||||
assert "bootstrap" in captured.err.lower() or "warning" in captured.err.lower() or \
|
||||
"could not load" in captured.err.lower(), (
|
||||
"_load_repo_dotenv() should print a warning to stderr on read failure"
|
||||
)
|
||||
|
||||
def test_overwrites_existing_env_var(self, tmp_path):
|
||||
"""Unconditional overwrite matches shell source semantics."""
|
||||
os.environ["HERMES_WEBUI_HOST"] = "127.0.0.1"
|
||||
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
|
||||
def test_does_not_set_empty_values(self, tmp_path):
|
||||
"""A key whose value is empty after stripping is not set to a non-empty string."""
|
||||
os.environ.pop("HERMES_EMPTY_KEY", None)
|
||||
self._run(tmp_path, 'HERMES_EMPTY_KEY=""\n')
|
||||
# The current implementation sets key to "" (empty string) — verify it is
|
||||
# not set to a non-empty string, which would be clearly wrong.
|
||||
val = os.environ.get("HERMES_EMPTY_KEY")
|
||||
assert val != "something-wrong", "Empty-value key must not be set to a non-empty string"
|
||||
# Specifically: empty string or absent are both acceptable behaviours.
|
||||
assert val in (None, ""), f"Unexpected value for empty-quoted key: {val!r}"
|
||||
|
||||
def test_multiple_keys_all_loaded(self, tmp_path):
|
||||
"""Multiple key=value pairs in one file are all loaded."""
|
||||
content = "HERMES_WEBUI_HOST=0.0.0.0\nHERMES_WEBUI_PORT=18787\n"
|
||||
self._run(tmp_path, content)
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
|
||||
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
|
||||
|
||||
def test_value_with_equals_sign_preserved(self, tmp_path):
|
||||
"""Values containing '=' (e.g. base64) are preserved correctly."""
|
||||
self._run(tmp_path, "MY_KEY=abc=def==\n")
|
||||
assert os.environ.get("MY_KEY") == "abc=def=="
|
||||
|
||||
def test_export_prefix_stripped(self, tmp_path):
|
||||
"""'export FOO=bar' lines are parsed correctly — export prefix is stripped."""
|
||||
self._run(tmp_path, "export HERMES_WEBUI_HOST=0.0.0.0\n")
|
||||
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0", (
|
||||
"'export KEY=value' lines must set KEY, not 'export KEY'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structural tests — confirm the fix is in place
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBootstrapStructure:
|
||||
|
||||
def test_load_repo_dotenv_function_exists(self):
|
||||
"""bootstrap.py must export _load_repo_dotenv()."""
|
||||
import bootstrap as bs
|
||||
assert callable(getattr(bs, "_load_repo_dotenv", None)), (
|
||||
"bootstrap.py must define _load_repo_dotenv() so that "
|
||||
"python3 bootstrap.py loads REPO_ROOT/.env before reading env defaults"
|
||||
)
|
||||
|
||||
def test_dotenv_loaded_before_default_host_port(self):
|
||||
"""_load_repo_dotenv() call must appear before DEFAULT_HOST/DEFAULT_PORT in source."""
|
||||
src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
|
||||
load_pos = src.find("_load_repo_dotenv()")
|
||||
host_pos = src.find("DEFAULT_HOST")
|
||||
port_pos = src.find("DEFAULT_PORT")
|
||||
assert load_pos != -1, "_load_repo_dotenv() call not found in bootstrap.py"
|
||||
assert load_pos < host_pos, (
|
||||
"_load_repo_dotenv() must be called before DEFAULT_HOST assignment "
|
||||
"so that HERMES_WEBUI_HOST from .env is picked up"
|
||||
)
|
||||
assert load_pos < port_pos, (
|
||||
"_load_repo_dotenv() must be called before DEFAULT_PORT assignment "
|
||||
"so that HERMES_WEBUI_PORT from .env is picked up"
|
||||
)
|
||||
|
||||
def test_start_sh_and_bootstrap_equivalent_env_loading(self):
|
||||
"""start.sh sources .env before bootstrap.py; bootstrap.py must now do the same."""
|
||||
start_sh = (REPO_ROOT / "start.sh").read_text(encoding="utf-8")
|
||||
bootstrap_src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
|
||||
# start.sh sources .env
|
||||
assert "source" in start_sh and ".env" in start_sh, (
|
||||
"start.sh should still source .env (regression guard)"
|
||||
)
|
||||
# bootstrap.py now loads it too
|
||||
assert "_load_repo_dotenv" in bootstrap_src, (
|
||||
"bootstrap.py must load .env so direct invocation matches start.sh behaviour"
|
||||
)
|
||||
@@ -19,39 +19,83 @@ COMPOSE = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
|
||||
# ── #594: light theme dialog overrides ───────────────────────────────────────
|
||||
|
||||
def test_594_app_dialog_has_light_theme_override():
|
||||
"""style.css must have a light theme rule targeting .app-dialog background."""
|
||||
assert ':root[data-theme="light"] .app-dialog{' in STYLE_CSS or \
|
||||
":root[data-theme='light'] .app-dialog{" in STYLE_CSS, (
|
||||
"Missing light theme override for .app-dialog — dialogs appear dark on light theme"
|
||||
def test_594_app_dialog_has_light_mode_override():
|
||||
"""style.css must have a light mode rule targeting .app-dialog background."""
|
||||
assert ':root:not(.dark) .app-dialog{' in STYLE_CSS, (
|
||||
"Missing light mode override for .app-dialog — dialogs appear dark on light theme"
|
||||
)
|
||||
|
||||
|
||||
def test_594_app_dialog_input_has_light_theme_override():
|
||||
"""style.css must have a light theme rule for .app-dialog-input."""
|
||||
assert ":root[data-theme=\"light\"] .app-dialog-input{" in STYLE_CSS, (
|
||||
"Missing light theme override for .app-dialog-input"
|
||||
def test_594_app_dialog_input_has_light_mode_override():
|
||||
"""style.css must have a light mode rule for .app-dialog-input."""
|
||||
assert ":root:not(.dark) .app-dialog-input{" in STYLE_CSS, (
|
||||
"Missing light mode override for .app-dialog-input"
|
||||
)
|
||||
|
||||
|
||||
def test_594_app_dialog_btn_has_light_theme_override():
|
||||
"""style.css must have a light theme rule for .app-dialog-btn."""
|
||||
assert ":root[data-theme=\"light\"] .app-dialog-btn{" in STYLE_CSS, (
|
||||
"Missing light theme override for .app-dialog-btn"
|
||||
def test_594_app_dialog_btn_has_light_mode_override():
|
||||
"""style.css must have a light mode rule for .app-dialog-btn."""
|
||||
assert ":root:not(.dark) .app-dialog-btn{" in STYLE_CSS, (
|
||||
"Missing light mode override for .app-dialog-btn"
|
||||
)
|
||||
|
||||
|
||||
def test_594_app_dialog_close_has_light_theme_override():
|
||||
"""style.css must have a light theme rule for .app-dialog-close."""
|
||||
assert ":root[data-theme=\"light\"] .app-dialog-close{" in STYLE_CSS, (
|
||||
"Missing light theme override for .app-dialog-close"
|
||||
def test_594_app_dialog_close_has_light_mode_override():
|
||||
"""style.css must have a light mode rule for .app-dialog-close."""
|
||||
assert ":root:not(.dark) .app-dialog-close{" in STYLE_CSS, (
|
||||
"Missing light mode override for .app-dialog-close"
|
||||
)
|
||||
|
||||
|
||||
def test_594_file_rename_input_has_light_theme_override():
|
||||
"""style.css must have a light theme rule for .file-rename-input."""
|
||||
assert ":root[data-theme=\"light\"] .file-rename-input{" in STYLE_CSS, (
|
||||
"Missing light theme override for .file-rename-input"
|
||||
def test_594_file_rename_input_has_light_mode_override():
|
||||
"""style.css must have a light mode rule for .file-rename-input."""
|
||||
assert ":root:not(.dark) .file-rename-input{" in STYLE_CSS, (
|
||||
"Missing light mode override for .file-rename-input"
|
||||
)
|
||||
|
||||
|
||||
# ── dark-mode user bubble semantics ──────────────────────────────────────────
|
||||
|
||||
def test_dark_user_bubbles_use_dark_tinted_surface():
|
||||
"""Dark mode should keep user bubbles dark, with skin only tinting the bubble."""
|
||||
assert "--user-bubble-bg: var(--accent-bg-strong);" in STYLE_CSS, (
|
||||
"Dark mode user bubbles should use the dark accent tint, not the full bright accent fill"
|
||||
)
|
||||
assert "--user-bubble-border: var(--accent-bg-strong);" in STYLE_CSS, (
|
||||
"Dark mode user bubble borders should match the quieter thinking-card border intensity"
|
||||
)
|
||||
assert "--user-bubble-text: var(--text);" in STYLE_CSS, (
|
||||
"Dark mode user bubble text should inherit the theme text color"
|
||||
)
|
||||
|
||||
|
||||
def test_dark_user_bubbles_do_not_need_per_skin_text_hacks():
|
||||
"""Dark-mode user bubble contrast should not rely on per-skin text overrides."""
|
||||
assert re.search(r':root\.dark\[data-skin="[^"]+"\]\s*\{\s*--user-bubble-text:', STYLE_CSS) is None, (
|
||||
"Dark-mode user bubble contrast should come from shared theme tokens, not per-skin text hacks"
|
||||
)
|
||||
|
||||
|
||||
def test_user_bubbles_define_selection_tokens_for_both_modes():
|
||||
"""User bubbles need dedicated selection colors so selected text remains readable."""
|
||||
assert "--user-selection-bg: rgba(0,0,0,.22);" in STYLE_CSS, (
|
||||
"Light-mode user bubbles should define a darker selection fill for contrast"
|
||||
)
|
||||
assert "--user-selection-bg: rgba(255,255,255,.18);" in STYLE_CSS, (
|
||||
"Dark-mode user bubbles should define a lighter selection fill for contrast"
|
||||
)
|
||||
assert "--user-selection-text: #fff;" in STYLE_CSS, (
|
||||
"Light-mode user bubble selection should preserve readable text color"
|
||||
)
|
||||
|
||||
|
||||
def test_user_bubble_selection_is_scoped_to_user_message_body():
|
||||
"""Selection override must apply only to user bubbles, including nested markdown nodes."""
|
||||
assert '.msg-row[data-role="user"] .msg-body::selection,' in STYLE_CSS, (
|
||||
"Missing selection override on the user message bubble"
|
||||
)
|
||||
assert '.msg-row[data-role="user"] .msg-body *::selection {' in STYLE_CSS, (
|
||||
"Nested elements inside user messages must inherit the same selection colors"
|
||||
)
|
||||
|
||||
|
||||
|
||||
381
tests/test_byok_model_dropdown.py
Normal file
381
tests/test_byok_model_dropdown.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""Tests for #815 — BYOK/custom provider models missing from WebUI model dropdown.
|
||||
|
||||
Root causes fixed:
|
||||
1. active_provider alias not normalized in get_available_models()
|
||||
('z.ai' -> 'zai', 'x.ai' -> 'xai', 'google' -> 'gemini', etc.)
|
||||
causing the provider to fall to the 'else/unknown' branch with no models.
|
||||
|
||||
2. /api/models/live didn't normalize the provider query param, so
|
||||
provider_model_ids() received the un-aliased form and returned [].
|
||||
|
||||
3. /api/models/live returned empty for provider='custom' even when
|
||||
custom_providers entries exist in config.yaml — the live enrichment
|
||||
step never added those models.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import unittest.mock as mock
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(REPO))
|
||||
sys.path.insert(0, str(REPO.parent / ".hermes" / "hermes-agent"))
|
||||
|
||||
|
||||
def read(rel):
|
||||
return (REPO / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_models_cache():
|
||||
"""Invalidate the TTL model cache before AND after every test.
|
||||
|
||||
``get_available_models()`` caches its result keyed on config.yaml mtime.
|
||||
Tests in this file repoint ``_get_config_path`` to a tmp_path, populate
|
||||
the cache there, then let monkeypatch restore the original path. The
|
||||
cache, keyed on the tmp_path's mtime, then poisons downstream tests
|
||||
(e.g. test_model_resolver) which see stale data and never hit their
|
||||
mocks. Clearing the cache around each test breaks that linkage.
|
||||
"""
|
||||
import api.config as c
|
||||
try:
|
||||
c.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
c.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── api/config.py — active_provider normalization ─────────────────────────────
|
||||
|
||||
class TestActiveProviderNormalization:
|
||||
"""get_available_models() must normalize active_provider aliases before lookup."""
|
||||
|
||||
def _run(self, tmp_path, provider_str, monkeypatch):
|
||||
"""Return get_available_models() output for a given provider string."""
|
||||
import api.config as c
|
||||
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(
|
||||
f"model:\n provider: {provider_str}\n default: test-model\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
|
||||
c.reload_config()
|
||||
# Patch list_available_providers to avoid real network calls
|
||||
fake_prov = mock.MagicMock()
|
||||
fake_prov.return_value = []
|
||||
try:
|
||||
import hermes_cli.models as hm
|
||||
monkeypatch.setattr(hm, "list_available_providers", fake_prov)
|
||||
except Exception:
|
||||
pass
|
||||
result = c.get_available_models()
|
||||
c.reload_config()
|
||||
return result
|
||||
|
||||
def test_z_dot_ai_normalized_to_zai(self, tmp_path, monkeypatch):
|
||||
result = self._run(tmp_path, "z.ai", monkeypatch)
|
||||
# active_provider returned to browser must be canonical 'zai' or
|
||||
# at minimum must not be 'z.ai' (which would miss the _PROVIDER_MODELS lookup)
|
||||
ap = result.get("active_provider", "")
|
||||
assert ap in ("zai", ""), f"active_provider should be 'zai', got {ap!r}"
|
||||
|
||||
def test_x_dot_ai_normalized_to_xai(self, tmp_path, monkeypatch):
|
||||
result = self._run(tmp_path, "x.ai", monkeypatch)
|
||||
ap = result.get("active_provider", "")
|
||||
assert ap in ("xai", ""), f"active_provider should be 'xai', got {ap!r}"
|
||||
|
||||
def test_google_normalized_to_gemini(self, tmp_path, monkeypatch):
|
||||
result = self._run(tmp_path, "google", monkeypatch)
|
||||
ap = result.get("active_provider", "")
|
||||
assert ap in ("gemini", ""), f"active_provider should be 'gemini', got {ap!r}"
|
||||
|
||||
def test_normalization_code_present(self):
|
||||
"""Source-level check: config.py must call _PROVIDER_ALIASES for active_provider."""
|
||||
src = read("api/config.py")
|
||||
# Must alias-normalize active_provider before the group-builder runs
|
||||
assert "_PROVIDER_ALIASES" in src, (
|
||||
"api/config.py must import _PROVIDER_ALIASES to normalize active_provider"
|
||||
)
|
||||
# The normalization must happen before the group builder loop
|
||||
alias_pos = src.index("_PROVIDER_ALIASES")
|
||||
group_builder_pos = src.index("for pid in sorted(detected_providers)")
|
||||
assert alias_pos < group_builder_pos, (
|
||||
"active_provider normalization must occur before the group-builder loop"
|
||||
)
|
||||
|
||||
|
||||
# ── api/routes.py — /api/models/live provider normalization ───────────────────
|
||||
|
||||
class TestLiveModelsProviderNormalization:
|
||||
"""_handle_live_models must normalize the provider query param."""
|
||||
|
||||
def test_live_models_normalizes_provider_alias(self):
|
||||
src = read("api/routes.py")
|
||||
# Find _handle_live_models function
|
||||
m = re.search(
|
||||
r"def _handle_live_models\(.*?\ndef ",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "_handle_live_models not found"
|
||||
fn = m.group(0)
|
||||
assert "_resolve_provider_alias" in fn, (
|
||||
"_handle_live_models must normalize provider via "
|
||||
"api.config._resolve_provider_alias so 'z.ai' -> 'zai' "
|
||||
"before calling provider_model_ids()"
|
||||
)
|
||||
|
||||
def test_live_models_normalization_before_provider_model_ids(self):
|
||||
"""Normalization call must appear before the provider_model_ids call site."""
|
||||
src = read("api/routes.py")
|
||||
alias_match = re.search(
|
||||
r"provider\s*=\s*_resolve_provider_alias\(provider\)",
|
||||
src,
|
||||
)
|
||||
pmi_call_match = re.search(
|
||||
r"ids\s*=\s*_pmi\(provider\)",
|
||||
src,
|
||||
)
|
||||
assert alias_match, "_resolve_provider_alias call not found in routes.py"
|
||||
assert pmi_call_match, "ids = _pmi(provider) call not found"
|
||||
assert alias_match.start() < pmi_call_match.start(), (
|
||||
"alias normalization must occur before ids = _pmi(provider)"
|
||||
)
|
||||
|
||||
def test_alias_resolver_works_without_hermes_cli(self):
|
||||
"""Normalization must work even when hermes_cli is not importable —
|
||||
CI and installs without the agent cloned alongside the WebUI.
|
||||
The WebUI ships its own _PROVIDER_ALIASES table; the agent's table
|
||||
is merged only when available."""
|
||||
import api.config as c
|
||||
# Core CLI aliases from #815's bug report
|
||||
assert c._resolve_provider_alias('z.ai') == 'zai'
|
||||
assert c._resolve_provider_alias('x.ai') == 'xai'
|
||||
assert c._resolve_provider_alias('google') == 'gemini'
|
||||
assert c._resolve_provider_alias('grok') == 'xai'
|
||||
# Case / whitespace insensitive
|
||||
assert c._resolve_provider_alias(' Z.AI ') == 'zai'
|
||||
# Canonical names pass through unchanged
|
||||
assert c._resolve_provider_alias('openrouter') == 'openrouter'
|
||||
assert c._resolve_provider_alias('anthropic') == 'anthropic'
|
||||
assert c._resolve_provider_alias('custom') == 'custom'
|
||||
# Empty / None pass through
|
||||
assert c._resolve_provider_alias('') == ''
|
||||
assert c._resolve_provider_alias(None) is None
|
||||
|
||||
|
||||
# ── api/routes.py — /api/models/live custom_providers fallback ────────────────
|
||||
|
||||
class TestLiveModelsCustomProviderFallback:
|
||||
"""When provider='custom' and provider_model_ids() returns [],
|
||||
/api/models/live must fall back to custom_providers entries from config.yaml."""
|
||||
|
||||
def test_custom_fallback_code_present(self):
|
||||
src = read("api/routes.py")
|
||||
m = re.search(
|
||||
r"def _handle_live_models\(.*?\ndef ",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "_handle_live_models not found"
|
||||
fn = m.group(0)
|
||||
assert "custom_providers" in fn, (
|
||||
"_handle_live_models must read custom_providers from config "
|
||||
"as fallback when provider='custom' and provider_model_ids() returns []"
|
||||
)
|
||||
assert 'provider == "custom"' in fn or "provider=='custom'" in fn, (
|
||||
"_handle_live_models must check provider == 'custom' before fallback"
|
||||
)
|
||||
|
||||
def test_custom_fallback_returns_configured_models(self, tmp_path, monkeypatch):
|
||||
"""End-to-end: /api/models/live?provider=custom returns custom_providers models."""
|
||||
import api.config as c
|
||||
import api.routes as r
|
||||
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(
|
||||
"model:\n provider: custom\n default: my-byok-model\n"
|
||||
"custom_providers:\n"
|
||||
" - model: my-byok-model\n"
|
||||
" api_base: https://my-llm.example.com/v1\n"
|
||||
" api_key: sk-test\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
|
||||
c.reload_config()
|
||||
|
||||
# Mock handler and parsed URL
|
||||
handler = mock.MagicMock()
|
||||
responses = []
|
||||
def fake_j(h, data, **kw):
|
||||
responses.append(data)
|
||||
return True
|
||||
monkeypatch.setattr(r, "j", fake_j)
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed = mock.MagicMock()
|
||||
parsed.query = "provider=custom"
|
||||
|
||||
# Mock provider_model_ids to return [] (simulating no live endpoint)
|
||||
try:
|
||||
import hermes_cli.models as hm
|
||||
monkeypatch.setattr(hm, "provider_model_ids", lambda p: [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
r._handle_live_models(handler, parsed)
|
||||
|
||||
assert responses, "handler must produce a response"
|
||||
resp = responses[-1]
|
||||
assert "models" in resp
|
||||
model_ids = [m["id"] for m in resp.get("models", [])]
|
||||
assert "my-byok-model" in model_ids, (
|
||||
f"custom_providers model 'my-byok-model' must appear in live response; "
|
||||
f"got {model_ids}"
|
||||
)
|
||||
|
||||
|
||||
# ── Regression: known-good providers still work ───────────────────────────────
|
||||
|
||||
class TestKnownProvidersUnaffected:
|
||||
"""Normalization must not break providers whose names are already canonical."""
|
||||
|
||||
def test_openrouter_unaffected(self):
|
||||
src = read("api/config.py")
|
||||
# _PROVIDER_ALIASES lookup: 'openrouter' -> 'openrouter' (no change)
|
||||
assert "openrouter" in src, "openrouter must still exist in config"
|
||||
|
||||
def test_anthropic_unaffected(self):
|
||||
src = read("api/config.py")
|
||||
assert "anthropic" in src
|
||||
|
||||
def test_custom_unaffected(self):
|
||||
"""'custom' is not in _PROVIDER_ALIASES so normalization is a no-op."""
|
||||
try:
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert "custom" not in _PROVIDER_ALIASES, (
|
||||
"'custom' must not be aliased to anything — it's a special sentinel"
|
||||
)
|
||||
except ImportError:
|
||||
pass # hermes-agent not available in this env — skip
|
||||
|
||||
|
||||
# ── Source-level: active_provider returned to browser is canonical ─────────────
|
||||
|
||||
class TestProviderIdInGroupResponse:
|
||||
"""get_available_models() must include provider_id on every group so the JS
|
||||
_fetchLiveModels can match optgroups exactly rather than by substring."""
|
||||
|
||||
def test_groups_include_provider_id(self, tmp_path, monkeypatch):
|
||||
import api.config as c
|
||||
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(
|
||||
"model:\n provider: zai\n default: glm-5\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
|
||||
c.reload_config()
|
||||
try:
|
||||
import hermes_cli.models as hm
|
||||
monkeypatch.setattr(hm, "list_available_providers", lambda: [
|
||||
{"id": "zai", "authenticated": True}
|
||||
])
|
||||
import hermes_cli.auth as ha
|
||||
monkeypatch.setattr(ha, "get_auth_status", lambda p: {"key_source": "env"})
|
||||
except Exception:
|
||||
pass
|
||||
result = c.get_available_models()
|
||||
c.reload_config()
|
||||
for g in result.get("groups", []):
|
||||
assert "provider_id" in g, (
|
||||
f"group {g.get('provider')!r} missing provider_id — "
|
||||
"JS _fetchLiveModels needs it to match optgroups exactly"
|
||||
)
|
||||
|
||||
def test_provider_id_in_static_ui_js_optgroup(self):
|
||||
src = read("static/ui.js")
|
||||
assert "og.dataset.provider" in src, (
|
||||
"populateModelDropdown must set og.dataset.provider from g.provider_id "
|
||||
"so _fetchLiveModels can match by exact provider_id"
|
||||
)
|
||||
|
||||
def test_fetch_live_models_prefers_data_provider_match(self):
|
||||
src = read("static/ui.js")
|
||||
# Live model optgroup matching was extracted to _addLiveModelsToSelect (#872)
|
||||
m = re.search(r'function _addLiveModelsToSelect\b.*?\n\}', src, re.DOTALL)
|
||||
if not m:
|
||||
m = re.search(r'function _fetchLiveModels\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "_addLiveModelsToSelect or _fetchLiveModels not found"
|
||||
fn = m.group(0)
|
||||
assert 'og.dataset.provider' in fn, (
|
||||
"_addLiveModelsToSelect must check og.dataset.provider===provider before "
|
||||
"falling back to label substring match"
|
||||
)
|
||||
# The data-provider check must come before the label.includes check
|
||||
dp_pos = fn.index('og.dataset.provider')
|
||||
label_pos = fn.index('og.label')
|
||||
assert dp_pos < label_pos, (
|
||||
"data-provider exact match must be attempted before label substring match"
|
||||
)
|
||||
|
||||
|
||||
# ── Opus-identified edge case: 'ollama' normalizes to 'custom' ────────────────
|
||||
|
||||
class TestOllamaAliasEdgeCase:
|
||||
"""Opus review found: 'ollama' -> 'custom' via _PROVIDER_ALIASES.
|
||||
This is better behaviour (custom_providers fallback catches it) but worth
|
||||
documenting and not regressing."""
|
||||
|
||||
def test_ollama_not_in_provider_aliases_as_ollama(self):
|
||||
"""'ollama' maps to 'custom' in _PROVIDER_ALIASES — verify this is the
|
||||
intended behavior post-normalization (not a silent breakage)."""
|
||||
try:
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
# 'ollama' -> 'custom' means ollama users hit the custom_providers path
|
||||
# This is fine — ollama models appear via base_url auto-detection (step 3)
|
||||
# in get_available_models, not via _PROVIDER_MODELS lookup.
|
||||
ollama_target = _PROVIDER_ALIASES.get("ollama", "ollama")
|
||||
# Acceptable outcomes: either unchanged (not in aliases) or 'custom'/'ollama-cloud'
|
||||
assert ollama_target in ("ollama", "custom", "ollama-cloud"), (
|
||||
f"Unexpected ollama alias: {ollama_target}"
|
||||
)
|
||||
except ImportError:
|
||||
pass # hermes-agent not available
|
||||
|
||||
|
||||
class TestGetAvailableModelsReturnsCanonicalProvider:
|
||||
"""get_available_models() must return normalized active_provider in its response
|
||||
so the browser sends the right value to /api/models/live."""
|
||||
|
||||
def test_active_provider_in_response_is_normalized(self, tmp_path, monkeypatch):
|
||||
import api.config as c
|
||||
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(
|
||||
"model:\n provider: z.ai\n default: glm-5\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
|
||||
c.reload_config()
|
||||
try:
|
||||
import hermes_cli.models as hm
|
||||
monkeypatch.setattr(hm, "list_available_providers", lambda: [])
|
||||
except Exception:
|
||||
pass
|
||||
result = c.get_available_models()
|
||||
c.reload_config()
|
||||
ap = result.get("active_provider", "")
|
||||
# The browser will pass this value to /api/models/live?provider=<ap>
|
||||
# It must be 'zai' so optgroup matching works in _fetchLiveModels
|
||||
assert ap != "z.ai", (
|
||||
"active_provider 'z.ai' must be normalized to 'zai' before being "
|
||||
"returned to the browser (browser passes it back to /api/models/live)"
|
||||
)
|
||||
@@ -43,7 +43,10 @@ class TestCancelInterrupt:
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_agent.interrupt.assert_called_once_with("Cancelled by user")
|
||||
assert CANCEL_FLAGS[stream_id].is_set()
|
||||
# CANCEL_FLAGS is eagerly popped after cancel (#776 fix) so the flag
|
||||
# is no longer in the dict — verify the pop happened instead
|
||||
assert stream_id not in CANCEL_FLAGS, \
|
||||
"cancel_stream() should eagerly pop CANCEL_FLAGS after signalling"
|
||||
|
||||
def test_cancel_handles_interrupt_exception(self):
|
||||
"""Verify that cancel_stream() handles interrupt() exceptions gracefully"""
|
||||
@@ -61,7 +64,8 @@ class TestCancelInterrupt:
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_agent.interrupt.assert_called_once()
|
||||
assert CANCEL_FLAGS[stream_id].is_set()
|
||||
assert stream_id not in CANCEL_FLAGS, \
|
||||
"cancel_stream() should eagerly pop CANCEL_FLAGS even on interrupt exception"
|
||||
|
||||
def test_cancel_before_agent_ready(self):
|
||||
"""Test cancel when agent not yet stored in AGENT_INSTANCES (race condition)"""
|
||||
@@ -76,8 +80,11 @@ class TestCancelInterrupt:
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
assert CANCEL_FLAGS[stream_id].is_set()
|
||||
# Agent will check this flag when it starts
|
||||
# CANCEL_FLAGS is eagerly popped; the agent thread checks the event
|
||||
# object it already has a reference to — pop doesn't clear the event
|
||||
assert stream_id not in CANCEL_FLAGS, \
|
||||
"cancel_stream() should eagerly pop CANCEL_FLAGS even without an agent"
|
||||
# Agent will check this flag (it holds a reference to the event object)
|
||||
|
||||
def test_cancel_nonexistent_stream(self):
|
||||
"""Test cancel for a stream that doesn't exist"""
|
||||
|
||||
69
tests/test_cmd_dropdown_scroll_838.py
Normal file
69
tests/test_cmd_dropdown_scroll_838.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for #838 — slash command dropdown keyboard navigation keeps the
|
||||
selected item in view."""
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
|
||||
def _read(name):
|
||||
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
class TestNavigateCmdDropdownScroll:
|
||||
"""navigateCmdDropdown must scroll the newly selected item into view so
|
||||
keyboard navigation on a long list doesn't leave the highlight below the
|
||||
visible area of the dropdown."""
|
||||
|
||||
def test_navigate_calls_scroll_into_view(self):
|
||||
js = _read("static/commands.js")
|
||||
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
|
||||
assert m, "navigateCmdDropdown not found"
|
||||
fn = m.group(0)
|
||||
assert 'scrollIntoView' in fn, (
|
||||
"navigateCmdDropdown must call scrollIntoView on the newly "
|
||||
"selected item so ↓/↑ keeps the highlight visible (#838)"
|
||||
)
|
||||
|
||||
def test_scroll_uses_nearest_block_alignment(self):
|
||||
"""`{block:'nearest'}` is the correct option: scrolls only when
|
||||
needed, minimum distance — won't jump the list around on every
|
||||
arrow-key press when the item is already in view."""
|
||||
js = _read("static/commands.js")
|
||||
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
|
||||
assert m
|
||||
fn = m.group(0)
|
||||
assert "block:'nearest'" in fn or 'block: "nearest"' in fn, (
|
||||
"scrollIntoView should use {block:'nearest'} to scroll the "
|
||||
"minimum amount needed"
|
||||
)
|
||||
|
||||
def test_scroll_after_selected_class_update(self):
|
||||
"""The scroll call must come AFTER adding the .selected class so
|
||||
the correct item is targeted."""
|
||||
js = _read("static/commands.js")
|
||||
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
|
||||
assert m
|
||||
fn = m.group(0)
|
||||
selected_pos = fn.find("classList.add('selected')")
|
||||
scroll_pos = fn.find("scrollIntoView")
|
||||
assert selected_pos != -1 and scroll_pos != -1
|
||||
assert selected_pos < scroll_pos, (
|
||||
"scrollIntoView must run after classList.add('selected') so it "
|
||||
"scrolls the newly-highlighted item into view"
|
||||
)
|
||||
|
||||
def test_cmd_dropdown_is_scroll_container(self):
|
||||
"""Regression guard: the .cmd-dropdown must have overflow-y:auto
|
||||
(or similar) so scrollIntoView finds it as the scroll ancestor
|
||||
rather than bubbling up to the viewport."""
|
||||
css = _read("static/style.css")
|
||||
m = re.search(r'\.cmd-dropdown\s*\{[^}]+\}', css)
|
||||
assert m, ".cmd-dropdown rule not found"
|
||||
block = m.group(0)
|
||||
assert 'overflow-y:auto' in block or 'overflow-y: auto' in block or \
|
||||
'overflow:auto' in block or 'overflow: auto' in block, (
|
||||
".cmd-dropdown must have overflow-y:auto so scrollIntoView "
|
||||
"scrolls within the dropdown, not the whole page"
|
||||
)
|
||||
84
tests/test_commands_endpoint.py
Normal file
84
tests/test_commands_endpoint.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Tests for GET /api/commands -- exposes hermes-agent COMMAND_REGISTRY."""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import TEST_BASE, requires_agent_modules
|
||||
|
||||
|
||||
def _get(path):
|
||||
"""GET helper -- returns parsed JSON or raises HTTPError."""
|
||||
with urllib.request.urlopen(TEST_BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_returns_list():
|
||||
"""GET /api/commands returns a JSON object with a 'commands' list."""
|
||||
body = _get('/api/commands')
|
||||
assert 'commands' in body
|
||||
assert isinstance(body['commands'], list)
|
||||
assert len(body['commands']) > 0
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_includes_help():
|
||||
"""The 'help' command must always be present (it's not cli_only)."""
|
||||
body = _get('/api/commands')
|
||||
names = {c['name'] for c in body['commands']}
|
||||
assert 'help' in names
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_command_shape():
|
||||
"""Each command entry has the required fields."""
|
||||
body = _get('/api/commands')
|
||||
cmd = next(c for c in body['commands'] if c['name'] == 'help')
|
||||
required = {
|
||||
'name', 'description', 'category', 'aliases',
|
||||
'args_hint', 'subcommands', 'cli_only', 'gateway_only',
|
||||
}
|
||||
assert set(cmd.keys()) >= required
|
||||
assert isinstance(cmd['aliases'], list)
|
||||
assert isinstance(cmd['subcommands'], list)
|
||||
assert isinstance(cmd['cli_only'], bool)
|
||||
assert isinstance(cmd['gateway_only'], bool)
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_excludes_gateway_only_and_never_expose():
|
||||
"""gateway_only commands and the _NEVER_EXPOSE set are filtered out."""
|
||||
body = _get('/api/commands')
|
||||
names = {c['name'] for c in body['commands']}
|
||||
# /sethome, /restart, /update are gateway_only; /commands is in _NEVER_EXPOSE
|
||||
for name in ('sethome', 'restart', 'update', 'commands'):
|
||||
assert name not in names, f"{name} must be excluded from /api/commands"
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_commands_endpoint_keeps_new_with_reset_alias():
|
||||
"""The 'new' command stays exposed and carries its 'reset' alias."""
|
||||
body = _get('/api/commands')
|
||||
new_cmd = next(c for c in body['commands'] if c['name'] == 'new')
|
||||
assert 'reset' in new_cmd['aliases']
|
||||
|
||||
|
||||
def test_list_commands_returns_empty_for_empty_registry():
|
||||
"""list_commands(_registry=[]) returns [] -- the same path as when
|
||||
hermes_cli is missing (the empty-or-missing case)."""
|
||||
from api.commands import list_commands
|
||||
assert list_commands(_registry=[]) == []
|
||||
|
||||
|
||||
def test_list_commands_degrades_when_agent_missing(monkeypatch):
|
||||
"""If hermes_cli.commands is not importable, list_commands() returns []
|
||||
via the ImportError path. Verified by stubbing sys.modules; test cleanup
|
||||
is handled by monkeypatch + the fact that we don't reload api.commands."""
|
||||
import sys
|
||||
monkeypatch.setitem(sys.modules, 'hermes_cli.commands', None)
|
||||
# NOTE: we do NOT reload api.commands. The lazy import inside
|
||||
# list_commands() will re-attempt the import on each call and hit
|
||||
# the stubbed-None module, raising ImportError, taking the fallback path.
|
||||
from api.commands import list_commands
|
||||
assert list_commands() == []
|
||||
595
tests/test_credential_pool_providers.py
Normal file
595
tests/test_credential_pool_providers.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""Regression tests for credential_pool provider detection in /api/models."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import api.config as config
|
||||
import api.profiles as profiles
|
||||
|
||||
_AMBIENT_SOURCES = {"gh_cli", "gh auth token"}
|
||||
|
||||
|
||||
def _install_fake_hermes_cli(monkeypatch, *, with_load_pool: bool = False, pool_data: dict | None = None):
|
||||
"""Stub hermes_cli modules so tests are deterministic and offline.
|
||||
|
||||
When *with_load_pool* is True, also stubs hermes_cli.credential_pool with a
|
||||
suppression-aware load_pool() implementation that mirrors upstream behaviour:
|
||||
entries whose source/label/key_source signals ambient gh-cli auth are filtered out.
|
||||
"""
|
||||
fake_pkg = types.ModuleType("hermes_cli")
|
||||
fake_pkg.__path__ = []
|
||||
|
||||
fake_models = types.ModuleType("hermes_cli.models")
|
||||
fake_models.list_available_providers = lambda: []
|
||||
fake_models.provider_model_ids = lambda pid: (
|
||||
["gpt-oss:20b", "qwen3:30b-a3b"] if pid == "ollama-cloud" else []
|
||||
)
|
||||
|
||||
fake_auth = types.ModuleType("hermes_cli.auth")
|
||||
fake_auth.get_auth_status = lambda _pid: {}
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
|
||||
|
||||
# Always remove the real agent.credential_pool so get_available_models() takes
|
||||
# the ImportError fallback path and reads from the monkeypatched auth store,
|
||||
# not the live ~/.hermes/auth.json via the real venv module.
|
||||
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "agent", raising=False)
|
||||
|
||||
if with_load_pool:
|
||||
_pool_data = pool_data or {}
|
||||
|
||||
class _FakeEntry:
|
||||
"""Minimal PooledCredential stand-in with attribute access (matching the real class)."""
|
||||
def __init__(self, d):
|
||||
self.source = d.get("source", "manual")
|
||||
self.label = d.get("label", "")
|
||||
self.key_source = d.get("key_source", "")
|
||||
self.id = d.get("id", "")
|
||||
|
||||
class _FakePool:
|
||||
def __init__(self, entries_list):
|
||||
self._entries = entries_list
|
||||
|
||||
def entries(self):
|
||||
return self._entries
|
||||
|
||||
def _fake_load_pool(pid):
|
||||
# Return ALL entries without filtering — mirrors the real load_pool()
|
||||
# which does NOT suppress ambient gh-cli tokens on its own.
|
||||
# Ambient-source filtering is the webui's responsibility.
|
||||
raw = _pool_data.get(pid, [])
|
||||
return _FakePool([_FakeEntry(e) for e in raw])
|
||||
|
||||
fake_cp = types.ModuleType("agent.credential_pool")
|
||||
fake_cp.load_pool = _fake_load_pool
|
||||
monkeypatch.setitem(sys.modules, "agent.credential_pool", fake_cp)
|
||||
|
||||
|
||||
def _call_get_available_models(monkeypatch, tmp_path, auth_payload, *, with_load_pool: bool = False):
|
||||
"""Call get_available_models() with auth.json pinned to a temp Hermes home."""
|
||||
_install_fake_hermes_cli(
|
||||
monkeypatch,
|
||||
with_load_pool=with_load_pool,
|
||||
pool_data=auth_payload.get("credential_pool", {}),
|
||||
)
|
||||
|
||||
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
config.cfg["model"] = {}
|
||||
try:
|
||||
# Pin mtime to avoid reload_config() clobbering our in-memory cfg patch.
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
|
||||
config.invalidate_models_cache()
|
||||
try:
|
||||
return config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
config.invalidate_models_cache()
|
||||
|
||||
|
||||
def _group_by_provider(result):
|
||||
return {g["provider"]: g["models"] for g in result.get("groups", [])}
|
||||
|
||||
|
||||
def test_ollama_cloud_manual_credential_shows_group(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"ollama-cloud": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"label": "ollama-manual",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://ollama.com/v1",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "Ollama Cloud" in groups, f"Expected Ollama Cloud in {list(groups)}"
|
||||
model_ids = [m["id"] for m in groups["Ollama Cloud"]]
|
||||
assert model_ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], model_ids
|
||||
|
||||
|
||||
def test_copilot_gh_cli_only_credential_hidden(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "def456",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" not in groups, (
|
||||
"GitHub Copilot should be hidden when only ambient gh auth token is present; "
|
||||
f"got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
def test_copilot_mixed_credential_pool_remains_visible(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "def456",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
{
|
||||
"id": "ghi789",
|
||||
"label": "explicit-copilot",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
|
||||
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
|
||||
assert "@copilot:gpt-5.4" in model_ids, model_ids
|
||||
assert "@copilot:claude-opus-4.6" in model_ids, model_ids
|
||||
|
||||
|
||||
def test_copilot_empty_field_entries_are_treated_as_explicit(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "jkl012",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
|
||||
|
||||
|
||||
def test_copilot_oauth_credential_is_visible(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "mno345",
|
||||
"label": "github-oauth",
|
||||
"source": "oauth",
|
||||
"auth_type": "oauth",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
|
||||
|
||||
|
||||
# --- load_pool path (suppression-aware) ---
|
||||
|
||||
|
||||
def test_load_pool_copilot_ambient_only_remains_hidden(monkeypatch, tmp_path):
|
||||
"""load_pool path: copilot with only ambient gh-cli entries is suppressed."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp001",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" not in groups, (
|
||||
"GitHub Copilot must be hidden when load_pool returns no usable entries; "
|
||||
f"got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
def test_load_pool_copilot_ambient_key_source_only_remains_hidden(monkeypatch, tmp_path):
|
||||
"""load_pool path: key_source-only ambient markers must also be suppressed."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp001b",
|
||||
"label": "copilot-token",
|
||||
"source": "manual",
|
||||
"key_source": "gh auth token",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" not in groups, (
|
||||
"GitHub Copilot must stay hidden when load_pool entries only differ by key_source ambient markers; "
|
||||
f"got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
def test_load_pool_alias_provider_key_is_resolved(monkeypatch, tmp_path):
|
||||
"""load_pool path: aliased pool keys should resolve to canonical provider ids."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"google": [
|
||||
{
|
||||
"id": "gp001",
|
||||
"label": "explicit-gemini",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://generativelanguage.googleapis.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
|
||||
assert "Google" not in groups, f"Aliased provider key should not render under raw alias name: {list(groups)}"
|
||||
|
||||
|
||||
def test_load_pool_explicit_credential_shows_provider(monkeypatch, tmp_path):
|
||||
"""load_pool path: provider with at least one explicit entry is visible."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp002",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
{
|
||||
"id": "lp003",
|
||||
"label": "explicit-pat",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, (
|
||||
f"GitHub Copilot must appear when load_pool has at least one usable entry; got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
# --- _apply_provider_prefix helper ---
|
||||
|
||||
|
||||
def test_apply_provider_prefix_ollama_cloud_non_active():
|
||||
"""Bare ollama-cloud model ids get @ollama-cloud: prefix when not active."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [{"id": "gpt-oss:20b", "label": "gpt-oss:20b"}, {"id": "qwen3:30b-a3b", "label": "qwen3:30b-a3b"}]
|
||||
result = _apply_provider_prefix(raw, "ollama-cloud", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], ids
|
||||
|
||||
|
||||
def test_apply_provider_prefix_copilot_non_active():
|
||||
"""Bare copilot model ids get @copilot: prefix when not active."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}, {"id": "claude-opus-4.6", "label": "Claude Opus 4.6"}]
|
||||
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["@copilot:gpt-5.4", "@copilot:claude-opus-4.6"], ids
|
||||
|
||||
|
||||
def test_apply_provider_prefix_no_double_prefix():
|
||||
"""Already-prefixed or provider/model ids are not double-prefixed."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [
|
||||
{"id": "@copilot:gpt-5.4", "label": "already prefixed"},
|
||||
{"id": "openai/gpt-5.4", "label": "slash form"},
|
||||
{"id": "bare-model", "label": "bare"},
|
||||
]
|
||||
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["@copilot:gpt-5.4", "openai/gpt-5.4", "@copilot:bare-model"], ids
|
||||
|
||||
|
||||
def test_apply_provider_prefix_active_provider_no_prefix():
|
||||
"""No prefix is added when the provider is already the active one."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}]
|
||||
result = _apply_provider_prefix(raw, "openai-codex", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["gpt-5.4"], ids
|
||||
|
||||
|
||||
def test_copilot_mixed_pool_prefixed_models(monkeypatch, tmp_path):
|
||||
"""Copilot with mixed pool and non-active provider has @copilot: prefixed model ids."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp010",
|
||||
"label": "explicit-copilot",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups
|
||||
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
|
||||
assert all(mid.startswith("@copilot:") for mid in model_ids), model_ids
|
||||
|
||||
|
||||
def test_auth_store_active_provider_alias_is_resolved(monkeypatch, tmp_path):
|
||||
"""active_provider read from auth.json must be alias-normalized.
|
||||
|
||||
Regression: previously the alias table was applied only to config.yaml's
|
||||
active_provider, so an aliased name in auth.json (e.g. 'google') would
|
||||
not match the canonical pid ('gemini') and the prefixing logic would
|
||||
add an unwanted '@gemini:' prefix to the active provider's models.
|
||||
"""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
# Aliased name: 'google' → 'gemini' per _PROVIDER_ALIASES.
|
||||
"active_provider": "google",
|
||||
"credential_pool": {},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
# Gemini should appear under its canonical display name and its model
|
||||
# ids should NOT be prefixed (it's the active provider).
|
||||
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
|
||||
model_ids = [m["id"] for m in groups["Gemini"]]
|
||||
assert model_ids, "Gemini group should have models"
|
||||
assert not any(mid.startswith("@") for mid in model_ids), (
|
||||
f"Active provider models must not be prefixed; got {model_ids}"
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_cloud_empty_catalog_skips_group(monkeypatch, tmp_path):
|
||||
"""When hermes_cli returns no models for ollama-cloud, the group is omitted.
|
||||
|
||||
Matches the named-custom and unknown-provider branches: we don't invent a
|
||||
catalog we can't enumerate. The logger.warning in the except branch keeps
|
||||
diagnostics available for operators.
|
||||
"""
|
||||
_install_fake_hermes_cli(monkeypatch)
|
||||
|
||||
# Override the stub to return empty for ollama-cloud.
|
||||
import sys as _sys
|
||||
_sys.modules["hermes_cli.models"].provider_model_ids = lambda pid: []
|
||||
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"ollama-cloud": [
|
||||
{
|
||||
"id": "oc-empty",
|
||||
"label": "ollama-manual",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
config.cfg["model"] = {}
|
||||
try:
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
|
||||
try:
|
||||
result = config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
|
||||
groups = _group_by_provider(result)
|
||||
assert "Ollama Cloud" not in groups, (
|
||||
f"Ollama Cloud group should be skipped when catalog is empty; got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
# --- _format_ollama_label helper ---
|
||||
|
||||
|
||||
def test_format_ollama_label_simple():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("kimi-k2.5") == "Kimi K2.5"
|
||||
|
||||
|
||||
def test_format_ollama_label_with_variant():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("qwen3-vl:235b-instruct") == "Qwen3 VL (235B Instruct)"
|
||||
|
||||
|
||||
def test_format_ollama_label_short_acronym():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("glm-5.1") == "GLM 5.1"
|
||||
|
||||
|
||||
def test_format_ollama_label_gpt_oss_with_size():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("gpt-oss:20b") == "GPT OSS (20B)"
|
||||
|
||||
|
||||
def test_format_ollama_label_empty_string():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("") == ""
|
||||
|
||||
|
||||
def test_format_ollama_label_no_variant():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("nemotron-3-super") == "Nemotron 3 Super"
|
||||
|
||||
|
||||
# --- Fallback-path (ImportError branch) alias resolution ---
|
||||
|
||||
|
||||
def test_fallback_path_resolves_alias_when_load_pool_unavailable(monkeypatch, tmp_path):
|
||||
"""When agent.credential_pool can't be imported, the manual-inspection
|
||||
branch must still canonicalize pool keys so aliased names (e.g. 'google')
|
||||
end up under their canonical provider id ('gemini')."""
|
||||
_install_fake_hermes_cli(monkeypatch)
|
||||
# Ensure agent.credential_pool is not importable so the fallback branch runs.
|
||||
monkeypatch.setitem(sys.modules, "agent.credential_pool", None)
|
||||
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"google": [
|
||||
{
|
||||
"id": "gp-fallback",
|
||||
"label": "explicit-gemini",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
config.cfg["model"] = {}
|
||||
try:
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
|
||||
try:
|
||||
result = config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
|
||||
groups = _group_by_provider(result)
|
||||
assert "Gemini" in groups, (
|
||||
f"Fallback path must resolve 'google' -> 'gemini'; got {list(groups)}"
|
||||
)
|
||||
assert "Google" not in groups, (
|
||||
f"Raw alias name must not leak when fallback path runs; got {list(groups)}"
|
||||
)
|
||||
110
tests/test_cron_refresh_button_835.py
Normal file
110
tests/test_cron_refresh_button_835.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for #835 — refresh button in Tasks / Scheduled Jobs panel."""
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
|
||||
def _read(name):
|
||||
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
class TestCronRefreshButtonHtml:
|
||||
"""index.html must expose a refresh button in the Tasks panel header."""
|
||||
|
||||
def test_refresh_button_present(self):
|
||||
html = _read("static/index.html")
|
||||
assert 'id="cronRefreshBtn"' in html, (
|
||||
"Tasks panel must have a #cronRefreshBtn element"
|
||||
)
|
||||
|
||||
def test_refresh_button_has_accessibility_labels(self):
|
||||
"""Icon-only buttons need aria-label + title so screen readers and
|
||||
hover tooltips work."""
|
||||
html = _read("static/index.html")
|
||||
m = re.search(r'<button[^>]*id="cronRefreshBtn"[^>]*>', html)
|
||||
assert m, "cronRefreshBtn tag not found"
|
||||
tag = m.group(0)
|
||||
assert 'aria-label=' in tag, (
|
||||
"#cronRefreshBtn is icon-only and must have aria-label"
|
||||
)
|
||||
assert 'title=' in tag, (
|
||||
"#cronRefreshBtn should have a title tooltip"
|
||||
)
|
||||
|
||||
def test_refresh_button_calls_load_crons_with_animate(self):
|
||||
html = _read("static/index.html")
|
||||
m = re.search(r'<button[^>]*id="cronRefreshBtn"[^>]*>', html)
|
||||
assert m
|
||||
tag = m.group(0)
|
||||
assert 'loadCrons(true)' in tag, (
|
||||
"#cronRefreshBtn must call loadCrons(true) to enable the dim-while-fetching animation"
|
||||
)
|
||||
|
||||
def test_refresh_button_sits_next_to_new_job_button(self):
|
||||
"""Refresh button should appear in the same header row as the New Job
|
||||
button so the header layout stays tight."""
|
||||
html = _read("static/index.html")
|
||||
ref_pos = html.find('id="cronRefreshBtn"')
|
||||
newjob_pos = html.find('openCronCreate()')
|
||||
assert ref_pos != -1 and newjob_pos != -1
|
||||
# Must be close enough to be in the same header row (single SVG-inline
|
||||
# button can be around 500 chars by itself due to inline styles/attrs).
|
||||
assert abs(ref_pos - newjob_pos) < 1000, (
|
||||
"Refresh button and New Job button should be in the same header row"
|
||||
)
|
||||
|
||||
|
||||
class TestLoadCronsAnimateFlag:
|
||||
"""panels.js loadCrons() must accept an optional animate flag that dims
|
||||
the refresh button while fetching."""
|
||||
|
||||
def test_load_crons_accepts_animate_param(self):
|
||||
js = _read("static/panels.js")
|
||||
assert re.search(r'async function loadCrons\s*\(\s*animate\s*\)', js), (
|
||||
"loadCrons must accept an `animate` parameter"
|
||||
)
|
||||
|
||||
def test_load_crons_restores_button_in_finally(self):
|
||||
"""The opacity/disabled restore MUST be in a finally block so a
|
||||
throwing fetch doesn't leave the button stuck at 0.5 / disabled."""
|
||||
js = _read("static/panels.js")
|
||||
m = re.search(r'async function loadCrons\(.*?\n\}', js, re.DOTALL)
|
||||
assert m, "loadCrons body not found"
|
||||
fn = m.group(0)
|
||||
assert 'finally' in fn, (
|
||||
"loadCrons must restore the refresh button's opacity/disabled state "
|
||||
"in a finally block so errors during fetch don't leave the button stuck"
|
||||
)
|
||||
# The restore block sets opacity='' (not '1') so CSS cascade wins
|
||||
assert "opacity = ''" in fn or "opacity=''" in fn, (
|
||||
"restore must use opacity='' to clear the inline override"
|
||||
)
|
||||
|
||||
|
||||
class TestCronCreatedEventListener:
|
||||
"""A global `hermes:cron_created` listener must be registered so
|
||||
future chat paths can trigger the cron list refresh."""
|
||||
|
||||
def test_listener_registered_at_module_scope(self):
|
||||
js = _read("static/panels.js")
|
||||
assert re.search(
|
||||
r"addEventListener\(\s*['\"]hermes:cron_created['\"]",
|
||||
js,
|
||||
), (
|
||||
"panels.js must register a window-level 'hermes:cron_created' event listener"
|
||||
)
|
||||
|
||||
def test_listener_triggers_load_crons(self):
|
||||
js = _read("static/panels.js")
|
||||
m = re.search(
|
||||
r"addEventListener\(\s*['\"]hermes:cron_created['\"].*?\}\s*\)",
|
||||
js,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "hermes:cron_created listener body not found"
|
||||
body = m.group(0)
|
||||
assert 'loadCrons' in body, (
|
||||
"hermes:cron_created listener must call loadCrons() to refresh the list"
|
||||
)
|
||||
@@ -5,22 +5,52 @@ When a custom_providers entry carries a `name` field (e.g. "Agent37"), the
|
||||
web UI model picker should show that name as the group header rather than the
|
||||
generic "Custom" label.
|
||||
"""
|
||||
import pytest
|
||||
import api.config as config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_models_cache():
|
||||
"""Invalidate the models TTL cache before and after every test in this file."""
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _models_with_cfg(model_cfg=None, custom_providers=None, active_provider=None):
|
||||
"""Temporarily patch config.cfg, call get_available_models(), restore."""
|
||||
"""Temporarily patch config.cfg, call get_available_models(), restore.
|
||||
|
||||
Also pins _cfg_mtime to the current config.yaml mtime before calling
|
||||
get_available_models(). Without this, if a prior test wrote config.yaml
|
||||
(changing its mtime), the mtime-guard inside get_available_models() fires
|
||||
reload_config() which overwrites config.cfg with the real on-disk values,
|
||||
silently discarding the patch and causing ordering-dependent failures.
|
||||
This matches the pattern used in test_model_resolver.py.
|
||||
"""
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
if model_cfg:
|
||||
config.cfg["model"] = model_cfg
|
||||
if custom_providers is not None:
|
||||
config.cfg["custom_providers"] = custom_providers
|
||||
# Pin mtime so get_available_models() skips its reload_config() guard.
|
||||
try:
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0 # no config.yaml present; reload guard is a no-op
|
||||
try:
|
||||
return config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
|
||||
|
||||
# ── Named provider shows its name in the dropdown ─────────────────────────────
|
||||
|
||||
@@ -101,3 +101,48 @@ def test_ensure_workspace_dir_returns_false_for_unwritable_path(monkeypatch, tmp
|
||||
assert result is False
|
||||
finally:
|
||||
parent.chmod(stat.S_IRWXU)
|
||||
|
||||
|
||||
def test_env_var_wins_over_settings_json_on_startup(monkeypatch, tmp_path):
|
||||
"""HERMES_WEBUI_DEFAULT_WORKSPACE must not be overridden by settings.json at startup.
|
||||
|
||||
Regression for GitHub issue #609: Docker deployments set the env var to a
|
||||
volume mount, but settings.json from a previous container run used to
|
||||
silently win, reverting the files panel to the old path.
|
||||
"""
|
||||
import json as _json
|
||||
import os as _os
|
||||
|
||||
env_ws = tmp_path / "env_workspace"
|
||||
env_ws.mkdir()
|
||||
settings_ws = tmp_path / "settings_workspace"
|
||||
settings_ws.mkdir()
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
settings_file = state_dir / "settings.json"
|
||||
settings_file.write_text(
|
||||
_json.dumps({"default_workspace": str(settings_ws)}), encoding="utf-8"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(config, "HOME", tmp_path)
|
||||
monkeypatch.setattr(config, "STATE_DIR", state_dir)
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
|
||||
# Simulate DEFAULT_WORKSPACE already set correctly from env var at import time
|
||||
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", env_ws.resolve())
|
||||
monkeypatch.setenv("HERMES_WEBUI_DEFAULT_WORKSPACE", str(env_ws))
|
||||
|
||||
# Execute the patched startup block logic inline — env var present → skip override
|
||||
current_ws = config.DEFAULT_WORKSPACE
|
||||
startup_settings = config.load_settings()
|
||||
if not _os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE"):
|
||||
# This branch must be skipped because env var is set
|
||||
current_ws = config.resolve_default_workspace(
|
||||
startup_settings.get("default_workspace")
|
||||
)
|
||||
|
||||
# env var was set → the if block was skipped → env path wins over settings.json
|
||||
assert current_ws == env_ws.resolve(), (
|
||||
f"Expected {env_ws.resolve()}, got {current_ws}. "
|
||||
"settings.json must not override HERMES_WEBUI_DEFAULT_WORKSPACE."
|
||||
)
|
||||
|
||||
|
||||
228
tests/test_font_size_setting.py
Normal file
228
tests/test_font_size_setting.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Tests for font size setting (#833) — 3-toggle Small/Default/Large in Appearance."""
|
||||
import os
|
||||
import re
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
def _read(name):
|
||||
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
class TestFontSizeCssModifiers:
|
||||
"""CSS must define font-size overrides for small and large via data attribute."""
|
||||
|
||||
def test_small_font_size_rule_exists(self):
|
||||
css = _read("static/style.css")
|
||||
assert 'data-font-size="small"' in css, (
|
||||
"style.css must have :root[data-font-size=\"small\"] font-size rule"
|
||||
)
|
||||
|
||||
def test_large_font_size_rule_exists(self):
|
||||
css = _read("static/style.css")
|
||||
assert 'data-font-size="large"' in css, (
|
||||
"style.css must have :root[data-font-size=\"large\"] font-size rule"
|
||||
)
|
||||
|
||||
def test_small_is_smaller_than_default(self):
|
||||
css = _read("static/style.css")
|
||||
# Match both compact {font-size:12px} and spaced { font-size: 12px; } formats
|
||||
m_small = re.search(r':root\[data-font-size="small"\][^{]*\{[^}]*font-size:\s*(\d+)px', css)
|
||||
m_large = re.search(r':root\[data-font-size="large"\][^{]*\{[^}]*font-size:\s*(\d+)px', css)
|
||||
assert m_small and m_large, "Both small and large font-size rules must set px values"
|
||||
assert int(m_small.group(1)) < 14, "Small font size must be < 14px (default)"
|
||||
assert int(m_large.group(1)) > 14, "Large font size must be > 14px (default)"
|
||||
|
||||
|
||||
class TestFontSizeBootScript:
|
||||
"""The boot script must apply font size from localStorage before page renders."""
|
||||
|
||||
def test_boot_script_reads_hermes_font_size(self):
|
||||
html = _read("static/index.html")
|
||||
assert "hermes-font-size" in html, (
|
||||
"index.html boot script must read 'hermes-font-size' from localStorage"
|
||||
)
|
||||
assert "data-font-size" in html, (
|
||||
"boot script must set document.documentElement.dataset.fontSize"
|
||||
)
|
||||
|
||||
def test_font_size_picker_html_present(self):
|
||||
html = _read("static/index.html")
|
||||
assert "fontSizePickerGrid" in html, (
|
||||
"Appearance pane must contain a fontSizePickerGrid element"
|
||||
)
|
||||
assert "settingsFontSize" in html, (
|
||||
"Appearance pane must contain a hidden #settingsFontSize input"
|
||||
)
|
||||
assert "font-size-pick-btn" in html, (
|
||||
"Font size picker buttons must have font-size-pick-btn class"
|
||||
)
|
||||
|
||||
def test_three_font_size_values_present(self):
|
||||
html = _read("static/index.html")
|
||||
assert 'data-font-size-val="small"' in html, "Small button must exist"
|
||||
assert 'data-font-size-val="default"' in html, "Default button must exist"
|
||||
assert 'data-font-size-val="large"' in html, "Large button must exist"
|
||||
|
||||
def test_font_size_picker_not_duplicated(self):
|
||||
"""Regression guard: the font size picker grid must appear exactly once
|
||||
in index.html. Earlier versions of this PR accidentally injected the
|
||||
block into both settingsPaneAppearance (correct) and
|
||||
settingsPanePreferences (copy-paste duplicate), creating duplicate IDs
|
||||
that break _syncFontSizePicker visual sync on one of the grids."""
|
||||
html = _read("static/index.html")
|
||||
assert html.count('id="fontSizePickerGrid"') == 1, (
|
||||
"fontSizePickerGrid must appear exactly once — duplicate IDs "
|
||||
"violate HTML spec and break querySelectorAll-based sync."
|
||||
)
|
||||
assert html.count('id="settingsFontSize"') == 1, (
|
||||
"settingsFontSize hidden input must appear exactly once"
|
||||
)
|
||||
|
||||
def test_font_size_picker_lives_in_appearance_pane(self):
|
||||
"""The font size picker must be under settingsPaneAppearance,
|
||||
not Preferences/System/Conversation."""
|
||||
html = _read("static/index.html")
|
||||
appearance_start = html.find('id="settingsPaneAppearance"')
|
||||
next_pane_markers = [
|
||||
'id="settingsPanePreferences"',
|
||||
'id="settingsPaneSystem"',
|
||||
'id="settingsPaneConversation"',
|
||||
]
|
||||
next_pane_starts = [
|
||||
html.find(m, appearance_start + 1) for m in next_pane_markers
|
||||
]
|
||||
after_appearance = min(
|
||||
[p for p in next_pane_starts if p != -1] or [len(html)]
|
||||
)
|
||||
picker_pos = html.find('id="fontSizePickerGrid"')
|
||||
assert appearance_start != -1, "settingsPaneAppearance not found"
|
||||
assert picker_pos != -1, "fontSizePickerGrid not found"
|
||||
assert appearance_start < picker_pos < after_appearance, (
|
||||
"Font size picker must live inside settingsPaneAppearance "
|
||||
"(same section as Theme and Skin)"
|
||||
)
|
||||
|
||||
|
||||
class TestFontSizeJsFunctions:
|
||||
"""JS must expose _pickFontSize, _applyFontSize, and _syncFontSizePicker."""
|
||||
|
||||
def test_pick_font_size_function_exists(self):
|
||||
boot = _read("static/boot.js")
|
||||
assert "function _pickFontSize(" in boot, (
|
||||
"boot.js must define _pickFontSize()"
|
||||
)
|
||||
|
||||
def test_apply_font_size_function_exists(self):
|
||||
boot = _read("static/boot.js")
|
||||
assert "function _applyFontSize(" in boot, (
|
||||
"boot.js must define _applyFontSize()"
|
||||
)
|
||||
|
||||
def test_sync_font_size_picker_function_exists(self):
|
||||
boot = _read("static/boot.js")
|
||||
assert "function _syncFontSizePicker(" in boot, (
|
||||
"boot.js must define _syncFontSizePicker()"
|
||||
)
|
||||
|
||||
def test_pick_font_size_persists_to_localstorage(self):
|
||||
boot = _read("static/boot.js")
|
||||
idx = boot.find("function _pickFontSize(")
|
||||
block = boot[idx:idx+400]
|
||||
assert "localStorage.setItem('hermes-font-size'" in block, (
|
||||
"_pickFontSize must persist choice to localStorage"
|
||||
)
|
||||
|
||||
def test_apply_font_size_sets_data_attribute(self):
|
||||
boot = _read("static/boot.js")
|
||||
idx = boot.find("function _applyFontSize(")
|
||||
block = boot[idx:idx+300]
|
||||
assert "dataset.fontSize" in block, (
|
||||
"_applyFontSize must set document.documentElement.dataset.fontSize"
|
||||
)
|
||||
|
||||
|
||||
class TestFontSizeI18nCoverage:
|
||||
"""All locales must include the font size i18n keys."""
|
||||
|
||||
def _get_locale_keys(self, src, locale_marker_after, stop_marker):
|
||||
"""Extract keys from a locale block."""
|
||||
start = src.find(locale_marker_after)
|
||||
if start < 0:
|
||||
return set()
|
||||
end = src.find(stop_marker, start)
|
||||
block = src[start:end if end > 0 else start + 20000]
|
||||
return set(re.findall(r"(\w[\w_]+):", block))
|
||||
|
||||
REQUIRED_KEYS = {"settings_label_font_size", "font_size_small", "font_size_default", "font_size_large"}
|
||||
|
||||
def test_all_locales_have_font_size_keys(self):
|
||||
src = _read("static/i18n.js")
|
||||
count = src.count("settings_label_font_size")
|
||||
# 6 locales: en, ru, es, de, zh, zh-Hant
|
||||
assert count >= 6, (
|
||||
f"settings_label_font_size must appear in all 6 locales, found {count}"
|
||||
)
|
||||
|
||||
def test_font_size_small_key_in_all_locales(self):
|
||||
src = _read("static/i18n.js")
|
||||
count = src.count("font_size_small")
|
||||
assert count >= 6, f"font_size_small must appear in all 6 locales, found {count}"
|
||||
|
||||
def test_font_size_large_key_in_all_locales(self):
|
||||
src = _read("static/i18n.js")
|
||||
count = src.count("font_size_large")
|
||||
assert count >= 6, f"font_size_large must appear in all 6 locales, found {count}"
|
||||
|
||||
|
||||
class TestFontSizeCssTargetedOverrides:
|
||||
"""CSS must override px-unit text in key UI elements, not just :root font-size.
|
||||
|
||||
The original PR only set :root font-size, but the stylesheet uses hardcoded px
|
||||
values throughout — changing :root has no effect on those. This test class locks
|
||||
in the targeted overrides for the most visible UI surfaces.
|
||||
"""
|
||||
|
||||
def test_msg_body_overridden_for_small(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="small"] .msg-body' in css, \
|
||||
"Chat message text must be explicitly scaled for small"
|
||||
|
||||
def test_msg_body_overridden_for_large(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="large"] .msg-body' in css, \
|
||||
"Chat message text must be explicitly scaled for large"
|
||||
|
||||
def test_session_item_overridden_for_small(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="small"] .session-item' in css, \
|
||||
"Sidebar session list text must be explicitly scaled for small"
|
||||
|
||||
def test_session_item_overridden_for_large(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="large"] .session-item' in css, \
|
||||
"Sidebar session list text must be explicitly scaled for large"
|
||||
|
||||
def test_composer_overridden_for_small(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="small"] #msg' in css, \
|
||||
"Composer textarea must be explicitly scaled for small"
|
||||
|
||||
def test_composer_overridden_for_large(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="large"] #msg' in css, \
|
||||
"Composer textarea must be explicitly scaled for large"
|
||||
# Large composer must not equal the default 16px — that's a no-op
|
||||
import re
|
||||
m = re.search(r':root\[data-font-size="large"\] #msg \{ font-size: (\d+)px', css)
|
||||
assert m and int(m.group(1)) != 16, \
|
||||
"Large composer font-size must differ from default (16px) to have visible effect"
|
||||
|
||||
def test_file_item_overridden_for_small(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="small"] .file-item' in css, \
|
||||
"Workspace file tree text must be explicitly scaled for small"
|
||||
|
||||
def test_file_item_overridden_for_large(self):
|
||||
css = _read("static/style.css")
|
||||
assert ':root[data-font-size="large"] .file-item' in css, \
|
||||
"Workspace file tree text must be explicitly scaled for large"
|
||||
@@ -352,6 +352,23 @@ def test_gateway_sse_stream_endpoint_exists():
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sse_stream_probe_reports_status():
|
||||
"""Probe mode returns JSON watcher status instead of holding open an SSE stream."""
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
try:
|
||||
req = urllib.request.Request(BASE + '/api/sessions/gateway/stream?probe=1')
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
assert r.status == 200, f"Expected 200, got {r.status}"
|
||||
ctype = r.headers.get('Content-Type', '')
|
||||
assert 'application/json' in ctype, f"Expected application/json, got {ctype}"
|
||||
data = json.loads(r.read().decode('utf-8'))
|
||||
assert data['enabled'] is True
|
||||
assert 'watcher_running' in data
|
||||
assert data['fallback_poll_ms'] == 30000
|
||||
finally:
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_webui_sessions_not_duplicated():
|
||||
"""If a session_id exists both in WebUI store and state.db, it's not duplicated."""
|
||||
# Create a WebUI session with a known ID
|
||||
@@ -418,3 +435,124 @@ def test_cli_sessions_still_work():
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
# ── Unit tests for _gateway_sse_probe_payload ────────────────────────────────
|
||||
# These replace the deleted repo-root test_gateway_sse_probe_unit.py and account
|
||||
# for the watcher_alive check (thread existence + is_alive()).
|
||||
|
||||
import sys
|
||||
import threading
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
from api.routes import _gateway_sse_probe_payload
|
||||
|
||||
|
||||
def test_probe_payload_when_disabled():
|
||||
"""Probe returns 404 when show_cli_sessions is False."""
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': False}, watcher=None)
|
||||
assert status == 404
|
||||
assert body['ok'] is False
|
||||
assert body['enabled'] is False
|
||||
assert body['watcher_running'] is False
|
||||
assert body['error'] == 'agent sessions not enabled'
|
||||
assert body['fallback_poll_ms'] == 30000
|
||||
|
||||
|
||||
def test_probe_payload_when_watcher_missing():
|
||||
"""Probe returns 503 when enabled but no watcher instance."""
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=None)
|
||||
assert status == 503
|
||||
assert body['ok'] is False
|
||||
assert body['enabled'] is True
|
||||
assert body['watcher_running'] is False
|
||||
assert body['error'] == 'watcher not started'
|
||||
assert body['fallback_poll_ms'] == 30000
|
||||
|
||||
|
||||
def test_probe_payload_when_watcher_instance_no_thread():
|
||||
"""Probe returns 503 when watcher exists but _thread attribute is missing/None."""
|
||||
class _FakeWatcher:
|
||||
_thread = None
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=_FakeWatcher())
|
||||
assert status == 503
|
||||
assert body['watcher_running'] is False
|
||||
|
||||
|
||||
def test_probe_payload_when_watcher_thread_alive():
|
||||
"""Probe returns 200 when enabled and watcher thread is alive."""
|
||||
class _FakeWatcher:
|
||||
pass
|
||||
w = _FakeWatcher()
|
||||
t = threading.Thread(target=lambda: None)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
w._thread = t
|
||||
# Thread may finish fast — loop-start a live daemon thread for reliability
|
||||
import time as _time
|
||||
done = threading.Event()
|
||||
live = threading.Thread(target=done.wait, daemon=True)
|
||||
live.start()
|
||||
w._thread = live
|
||||
try:
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
|
||||
assert status == 200
|
||||
assert body['ok'] is True
|
||||
assert body['watcher_running'] is True
|
||||
assert body['fallback_poll_ms'] == 30000
|
||||
finally:
|
||||
done.set()
|
||||
live.join(timeout=1)
|
||||
|
||||
|
||||
def test_probe_payload_when_watcher_thread_dead():
|
||||
"""Probe returns 503 when watcher instance exists but thread has exited."""
|
||||
class _FakeWatcher:
|
||||
pass
|
||||
w = _FakeWatcher()
|
||||
t = threading.Thread(target=lambda: None)
|
||||
t.start()
|
||||
t.join() # wait for it to finish
|
||||
w._thread = t
|
||||
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
|
||||
assert status == 503
|
||||
assert body['watcher_running'] is False
|
||||
assert body['ok'] is False
|
||||
|
||||
|
||||
def test_gateway_watcher_is_alive_public_method():
|
||||
"""GatewayWatcher.is_alive() is the public API the probe uses. Cover all
|
||||
three states: before start(), while running, after stop()."""
|
||||
from api.gateway_watcher import GatewayWatcher
|
||||
w = GatewayWatcher()
|
||||
# Before start(): no thread
|
||||
assert w.is_alive() is False, "is_alive() must be False before start()"
|
||||
# After start(): thread running
|
||||
w.start()
|
||||
try:
|
||||
assert w.is_alive() is True, "is_alive() must be True while running"
|
||||
finally:
|
||||
w.stop()
|
||||
# After stop(): thread cleared
|
||||
assert w.is_alive() is False, "is_alive() must be False after stop()"
|
||||
|
||||
|
||||
def test_probe_payload_prefers_public_is_alive():
|
||||
"""Regression guard: _gateway_sse_probe_payload must call watcher.is_alive()
|
||||
rather than poking at _thread directly when the public method exists."""
|
||||
calls = []
|
||||
|
||||
class _WatcherWithPublicApi:
|
||||
def is_alive(self):
|
||||
calls.append('is_alive')
|
||||
return True
|
||||
# _thread is deliberately absent — must not be accessed.
|
||||
|
||||
body, status = _gateway_sse_probe_payload(
|
||||
{'show_cli_sessions': True},
|
||||
watcher=_WatcherWithPublicApi(),
|
||||
)
|
||||
assert status == 200
|
||||
assert body['watcher_running'] is True
|
||||
assert calls == ['is_alive'], (
|
||||
"probe must prefer the public is_alive() method over poking _thread"
|
||||
)
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
"""
|
||||
Tests for issue #336 — opt-in chat bubble layout (PR #398).
|
||||
|
||||
Covers:
|
||||
- api/config.py: bubble_layout present in _SETTINGS_DEFAULTS with default False
|
||||
- api/config.py: bubble_layout present in _SETTINGS_BOOL_KEYS
|
||||
- api/config.py: bubble_layout not in password-filtered keys (safe to expose)
|
||||
- static/boot.js: boot path applies bubble-layout class from settings
|
||||
- static/boot.js: catch path removes bubble-layout class on API failure
|
||||
- static/panels.js: loadSettingsPanel reads bubble_layout checkbox
|
||||
- static/panels.js: saveSettings writes bubble_layout and toggles body class
|
||||
- static/style.css: body.bubble-layout CSS selectors present
|
||||
- static/style.css: responsive max-width rule for bubble layout
|
||||
- static/index.html: settingsBubbleLayout checkbox element present
|
||||
- static/index.html: i18n keys wired on label and description
|
||||
- static/i18n.js: English label and description keys present
|
||||
- static/i18n.js: Spanish label and description keys present
|
||||
- Integration: bubble_layout default is False in GET /api/settings
|
||||
- Integration: bubble_layout persists via POST /api/settings
|
||||
- Integration: non-bool value is coerced to bool on POST
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text()
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text()
|
||||
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text()
|
||||
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
|
||||
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text()
|
||||
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text()
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
|
||||
def _get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def _post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
# ── config.py static checks ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBubbleLayoutConfig(unittest.TestCase):
|
||||
"""Verify bubble_layout is correctly registered in config.py."""
|
||||
|
||||
def test_bubble_layout_in_settings_defaults(self):
|
||||
"""bubble_layout must appear in _SETTINGS_DEFAULTS."""
|
||||
self.assertIn(
|
||||
'"bubble_layout"',
|
||||
CONFIG_PY,
|
||||
"bubble_layout key missing from _SETTINGS_DEFAULTS in api/config.py",
|
||||
)
|
||||
|
||||
def test_bubble_layout_default_is_false(self):
|
||||
"""bubble_layout default value must be False (opt-in, off by default)."""
|
||||
# Match "bubble_layout": False with optional spacing
|
||||
self.assertRegex(
|
||||
CONFIG_PY,
|
||||
r'"bubble_layout"\s*:\s*False',
|
||||
"bubble_layout default must be False in _SETTINGS_DEFAULTS",
|
||||
)
|
||||
|
||||
def test_bubble_layout_in_bool_keys(self):
|
||||
"""bubble_layout must be in _SETTINGS_BOOL_KEYS for coercion."""
|
||||
# Find the _SETTINGS_BOOL_KEYS block and verify membership
|
||||
bool_keys_match = re.search(
|
||||
r"_SETTINGS_BOOL_KEYS\s*=\s*\{([^}]+)\}", CONFIG_PY, re.DOTALL
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
bool_keys_match, "_SETTINGS_BOOL_KEYS block not found in config.py"
|
||||
)
|
||||
self.assertIn(
|
||||
'"bubble_layout"',
|
||||
bool_keys_match.group(1),
|
||||
"bubble_layout missing from _SETTINGS_BOOL_KEYS",
|
||||
)
|
||||
|
||||
|
||||
# ── boot.js static checks ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBubbleLayoutBootJS(unittest.TestCase):
|
||||
"""Verify bubble-layout class management in boot.js."""
|
||||
|
||||
def test_boot_applies_bubble_layout_class(self):
|
||||
"""boot.js success path must toggle body.bubble-layout from settings."""
|
||||
self.assertIn(
|
||||
"classList.toggle('bubble-layout',!!s.bubble_layout)",
|
||||
BOOT_JS,
|
||||
"boot.js must call classList.toggle('bubble-layout', ...) on settings load",
|
||||
)
|
||||
|
||||
def test_boot_catch_removes_bubble_layout_class(self):
|
||||
"""boot.js catch path must remove bubble-layout (default off on API failure)."""
|
||||
self.assertIn(
|
||||
"classList.remove('bubble-layout')",
|
||||
BOOT_JS,
|
||||
"boot.js catch block must call classList.remove('bubble-layout') on API failure",
|
||||
)
|
||||
|
||||
|
||||
# ── panels.js static checks ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBubbleLayoutPanelsJS(unittest.TestCase):
|
||||
"""Verify settings panel wires the bubble_layout checkbox."""
|
||||
|
||||
def test_load_settings_reads_bubble_layout_checkbox(self):
|
||||
"""loadSettingsPanel must read the settingsBubbleLayout checkbox state."""
|
||||
self.assertIn(
|
||||
"settingsBubbleLayout",
|
||||
PANELS_JS,
|
||||
"panels.js must reference settingsBubbleLayout checkbox",
|
||||
)
|
||||
|
||||
def test_save_settings_writes_bubble_layout(self):
|
||||
"""saveSettings must write body.bubble_layout from the checkbox."""
|
||||
self.assertIn(
|
||||
"body.bubble_layout",
|
||||
PANELS_JS,
|
||||
"saveSettings must set body.bubble_layout from checkbox",
|
||||
)
|
||||
|
||||
def test_save_settings_toggles_body_class(self):
|
||||
"""saveSettings must apply body class toggle for live preview."""
|
||||
self.assertIn(
|
||||
"classList.toggle('bubble-layout', body.bubble_layout)",
|
||||
PANELS_JS,
|
||||
"saveSettings must toggle 'bubble-layout' on document.body for live preview",
|
||||
)
|
||||
|
||||
|
||||
# ── style.css static checks ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBubbleLayoutCSS(unittest.TestCase):
|
||||
"""Verify CSS selectors for bubble layout are present and gated on body class."""
|
||||
|
||||
def test_user_row_right_align_selector_present(self):
|
||||
"""CSS must right-align user message rows when bubble-layout is active."""
|
||||
self.assertIn(
|
||||
"body.bubble-layout .msg-row:has(.msg-role.user)",
|
||||
STYLE_CSS,
|
||||
"CSS selector for user bubble alignment missing from style.css",
|
||||
)
|
||||
|
||||
def test_assistant_row_left_align_selector_present(self):
|
||||
"""CSS must left-align assistant message rows when bubble-layout is active."""
|
||||
self.assertIn(
|
||||
"body.bubble-layout .msg-row:has(.msg-role.assistant)",
|
||||
STYLE_CSS,
|
||||
"CSS selector for assistant bubble alignment missing from style.css",
|
||||
)
|
||||
|
||||
def test_bubble_layout_responsive_rule_present(self):
|
||||
"""A responsive max-width rule for narrow screens must be present."""
|
||||
# Both selectors must appear inside a @media block
|
||||
self.assertRegex(
|
||||
STYLE_CSS,
|
||||
r"@media\([^)]*700px[^)]*\)[^{]*\{[^}]*bubble-layout",
|
||||
"Responsive bubble-layout rule (700px breakpoint) missing from style.css",
|
||||
)
|
||||
|
||||
|
||||
# ── index.html static checks ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBubbleLayoutHTML(unittest.TestCase):
|
||||
"""Verify the settings checkbox is present and correctly wired in index.html."""
|
||||
|
||||
def test_settings_checkbox_present(self):
|
||||
"""The settingsBubbleLayout checkbox must exist in index.html."""
|
||||
self.assertIn(
|
||||
'id="settingsBubbleLayout"',
|
||||
INDEX_HTML,
|
||||
"settingsBubbleLayout checkbox missing from index.html",
|
||||
)
|
||||
|
||||
def test_settings_label_i18n_key_wired(self):
|
||||
"""Label span must carry the settings_label_bubble_layout i18n key."""
|
||||
self.assertIn(
|
||||
'data-i18n="settings_label_bubble_layout"',
|
||||
INDEX_HTML,
|
||||
"settings_label_bubble_layout i18n key not wired on label span",
|
||||
)
|
||||
|
||||
def test_settings_desc_i18n_key_wired(self):
|
||||
"""Description div must carry the settings_desc_bubble_layout i18n key."""
|
||||
self.assertIn(
|
||||
'data-i18n="settings_desc_bubble_layout"',
|
||||
INDEX_HTML,
|
||||
"settings_desc_bubble_layout i18n key not wired on description div",
|
||||
)
|
||||
|
||||
|
||||
# ── i18n.js static checks ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBubbleLayoutI18N(unittest.TestCase):
|
||||
"""Verify English and Spanish locale keys are present in i18n.js."""
|
||||
|
||||
def _extract_locale_block(self, lang_start_marker, lang_end_marker):
|
||||
"""Extract the content between two locale markers."""
|
||||
start = I18N_JS.find(lang_start_marker)
|
||||
end = I18N_JS.find(lang_end_marker, start)
|
||||
self.assertGreater(start, -1, f"Start marker '{lang_start_marker}' not found")
|
||||
self.assertGreater(end, start, f"End marker '{lang_end_marker}' not found after start")
|
||||
return I18N_JS[start:end]
|
||||
|
||||
def test_english_label_key_present(self):
|
||||
"""English locale must have settings_label_bubble_layout."""
|
||||
en_block = self._extract_locale_block("\n en: {", "\n es: {")
|
||||
self.assertIn(
|
||||
"settings_label_bubble_layout",
|
||||
en_block,
|
||||
"settings_label_bubble_layout missing from English locale",
|
||||
)
|
||||
|
||||
def test_english_desc_key_present(self):
|
||||
"""English locale must have settings_desc_bubble_layout."""
|
||||
en_block = self._extract_locale_block("\n en: {", "\n es: {")
|
||||
self.assertIn(
|
||||
"settings_desc_bubble_layout",
|
||||
en_block,
|
||||
"settings_desc_bubble_layout missing from English locale",
|
||||
)
|
||||
|
||||
def test_spanish_label_key_present(self):
|
||||
"""Spanish locale must have settings_label_bubble_layout."""
|
||||
es_block = self._extract_locale_block("\n es: {", "\n de: {")
|
||||
self.assertIn(
|
||||
"settings_label_bubble_layout",
|
||||
es_block,
|
||||
"settings_label_bubble_layout missing from Spanish locale",
|
||||
)
|
||||
|
||||
def test_spanish_desc_key_present(self):
|
||||
"""Spanish locale must have settings_desc_bubble_layout."""
|
||||
es_block = self._extract_locale_block("\n es: {", "\n de: {")
|
||||
self.assertIn(
|
||||
"settings_desc_bubble_layout",
|
||||
es_block,
|
||||
"settings_desc_bubble_layout missing from Spanish locale",
|
||||
)
|
||||
|
||||
|
||||
# ── Integration tests (require live server on test server port) ─────────────────
|
||||
|
||||
|
||||
class TestBubbleLayoutSettingsAPI(unittest.TestCase):
|
||||
"""Integration tests: bubble_layout via GET/POST /api/settings."""
|
||||
|
||||
def test_bubble_layout_default_is_false(self):
|
||||
"""GET /api/settings must return bubble_layout: false by default."""
|
||||
try:
|
||||
d, status = _get("/api/settings")
|
||||
except OSError:
|
||||
self.skipTest("Server not running on test server port")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(
|
||||
"bubble_layout",
|
||||
d,
|
||||
"bubble_layout missing from GET /api/settings response",
|
||||
)
|
||||
self.assertFalse(
|
||||
d["bubble_layout"],
|
||||
"bubble_layout default must be False (opt-in feature)",
|
||||
)
|
||||
|
||||
def test_bubble_layout_persists_true(self):
|
||||
"""POST /api/settings with bubble_layout:true must persist and round-trip."""
|
||||
try:
|
||||
_, status = _post("/api/settings", {"bubble_layout": True})
|
||||
except OSError:
|
||||
self.skipTest("Server not running on test server port")
|
||||
self.assertEqual(status, 200)
|
||||
d, _ = _get("/api/settings")
|
||||
self.assertTrue(d["bubble_layout"], "bubble_layout=True must persist after POST")
|
||||
# Restore
|
||||
_post("/api/settings", {"bubble_layout": False})
|
||||
|
||||
def test_bubble_layout_persists_false(self):
|
||||
"""POST /api/settings with bubble_layout:false must persist and round-trip."""
|
||||
try:
|
||||
_post("/api/settings", {"bubble_layout": True})
|
||||
_post("/api/settings", {"bubble_layout": False})
|
||||
except OSError:
|
||||
self.skipTest("Server not running on test server port")
|
||||
d, _ = _get("/api/settings")
|
||||
self.assertFalse(d["bubble_layout"], "bubble_layout=False must persist after POST")
|
||||
|
||||
def test_bubble_layout_truthy_string_coerced_to_bool(self):
|
||||
"""Non-bool truthy value must be coerced to bool by _SETTINGS_BOOL_KEYS logic."""
|
||||
try:
|
||||
_post("/api/settings", {"bubble_layout": "1"})
|
||||
except OSError:
|
||||
self.skipTest("Server not running on test server port")
|
||||
d, _ = _get("/api/settings")
|
||||
self.assertIsInstance(
|
||||
d["bubble_layout"],
|
||||
bool,
|
||||
"bubble_layout must be a bool in API response (bool coercion via _SETTINGS_BOOL_KEYS)",
|
||||
)
|
||||
# Restore
|
||||
_post("/api/settings", {"bubble_layout": False})
|
||||
@@ -28,7 +28,7 @@ def test_msg_body_table_tr_stripe_present():
|
||||
|
||||
def test_msg_body_light_theme_overrides():
|
||||
css = _read_css()
|
||||
assert ':root[data-theme="light"] .msg-body th' in css, \
|
||||
'Light-theme override for .msg-body th missing from style.css'
|
||||
assert ':root[data-theme="light"] .msg-body td' in css, \
|
||||
'Light-theme override for .msg-body td missing from style.css'
|
||||
assert ':root:not(.dark) .msg-body th' in css, \
|
||||
'Light-mode override for .msg-body th missing from style.css'
|
||||
assert ':root:not(.dark) .msg-body td' in css, \
|
||||
'Light-mode override for .msg-body td missing from style.css'
|
||||
|
||||
@@ -49,10 +49,13 @@ class TestDockerfileUvPreinstall:
|
||||
|
||||
def test_dockerfile_uv_installed_before_copy(self):
|
||||
"""uv installation must happen before COPY . /apptoo so it's in the image."""
|
||||
import re
|
||||
uv_pos = DOCKERFILE.find("uv/install.sh")
|
||||
copy_pos = DOCKERFILE.find("COPY . /apptoo")
|
||||
# Match COPY regardless of flags (e.g. --chown=...) — only the destination matters.
|
||||
m = re.search(r"^COPY\b.*\s/apptoo\b", DOCKERFILE, re.MULTILINE)
|
||||
assert uv_pos != -1, "uv install not found in Dockerfile"
|
||||
assert copy_pos != -1, "COPY . /apptoo not found in Dockerfile"
|
||||
assert m is not None, "COPY ... /apptoo not found in Dockerfile"
|
||||
copy_pos = m.start()
|
||||
assert uv_pos < copy_pos, "uv must be installed before COPY . /apptoo"
|
||||
|
||||
def test_dockerfile_uv_installed_as_root_or_before_user_switch(self):
|
||||
@@ -147,20 +150,21 @@ class TestWorkspacePermissions:
|
||||
)
|
||||
|
||||
def test_workspace_uses_sudo_chown(self):
|
||||
"""docker_init.bash must chown the workspace to hermeswebui after mkdir."""
|
||||
ws_section = INIT_SCRIPT[
|
||||
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE"):
|
||||
INIT_SCRIPT.find("HERMES_WEBUI_DEFAULT_WORKSPACE") + 800
|
||||
]
|
||||
assert "sudo chown" in ws_section and "hermeswebui" in ws_section, (
|
||||
"""docker_init.bash must chown the workspace to hermeswebui when writable.
|
||||
|
||||
The chown is now conditional on the workspace being writable, to allow
|
||||
read-only (:ro) workspace mounts without crashing (#670). The sudo chown
|
||||
must still be present in the script (just guarded by [ -w ]).
|
||||
"""
|
||||
assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"' in INIT_SCRIPT, (
|
||||
"docker_init.bash must 'sudo chown hermeswebui:hermeswebui' the workspace "
|
||||
"directory after creating it, so the app user can write to it (#357)"
|
||||
"when it is writable, so the app user can write to it (#357)"
|
||||
)
|
||||
|
||||
def test_workspace_mkdir_before_chown(self):
|
||||
"""sudo mkdir must come before sudo chown in docker_init.bash."""
|
||||
mkdir_pos = INIT_SCRIPT.find("sudo mkdir -p \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"")
|
||||
chown_pos = INIT_SCRIPT.find("sudo chown hermeswebui:hermeswebui \"$HERMES_WEBUI_DEFAULT_WORKSPACE\"")
|
||||
mkdir_pos = INIT_SCRIPT.find('sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE"')
|
||||
chown_pos = INIT_SCRIPT.find('sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE"')
|
||||
assert mkdir_pos != -1, "sudo mkdir for workspace not found"
|
||||
assert chown_pos != -1, "sudo chown for workspace not found"
|
||||
assert mkdir_pos < chown_pos, "sudo mkdir must come before sudo chown"
|
||||
@@ -171,10 +175,19 @@ class TestWorkspacePermissions:
|
||||
"sudo mkdir for workspace must call error_exit on failure"
|
||||
)
|
||||
|
||||
def test_workspace_error_exit_on_chown_failure(self):
|
||||
"""sudo chown must call error_exit on failure."""
|
||||
assert 'sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit' in INIT_SCRIPT, (
|
||||
"sudo chown for workspace must call error_exit on failure"
|
||||
def test_workspace_chown_is_conditional_on_writable(self):
|
||||
"""chown and write-test must be skipped for read-only workspace mounts (#670).
|
||||
|
||||
The script must check [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ] before
|
||||
attempting chown or a write test, so :ro bind-mounts don't crash startup.
|
||||
"""
|
||||
assert '[ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]' in INIT_SCRIPT, (
|
||||
"docker_init.bash must guard chown with [ -w ] to support read-only "
|
||||
"workspace mounts (:ro) without crashing (#670)"
|
||||
)
|
||||
# Read-only path must log a clear message rather than calling error_exit
|
||||
assert "read-only workspace is supported" in INIT_SCRIPT, (
|
||||
"docker_init.bash must print a clear message when workspace is read-only (#670)"
|
||||
)
|
||||
|
||||
def test_init_script_syntax_valid(self):
|
||||
|
||||
@@ -28,8 +28,10 @@ def test_loadsession_preserves_tool_rows():
|
||||
|
||||
def test_loadsession_uses_session_toolcalls_only_as_fallback():
|
||||
"""Session summaries are the fallback, not the primary reload source."""
|
||||
assert "if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length)" in SESSIONS_JS
|
||||
assert "S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));" in SESSIONS_JS
|
||||
assert ("if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length)" in SESSIONS_JS or
|
||||
"if (!hasMessageToolMetadata && data.session.tool_calls && data.session.tool_calls.length)" in SESSIONS_JS)
|
||||
assert ("S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));" in SESSIONS_JS or
|
||||
"S.toolCalls = data.session.tool_calls.map(tc => ({...tc, done: true}));" in SESSIONS_JS)
|
||||
assert "S.toolCalls=[];" in SESSIONS_JS
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ Tests for fixes:
|
||||
|
||||
- #569: docker_init.bash auto-detects WANTED_UID/WANTED_GID from mounted workspace
|
||||
so macOS users (UID 501) don't need to manually set the env var.
|
||||
- #579: Topbar message count already filters tool messages (role !== 'tool') —
|
||||
confirmed present. Closing as already fixed by #584 which removed the
|
||||
sidebar meta row (the only place raw message_count was ever displayed).
|
||||
- #579: Topbar message count already filters tool messages (role !== 'tool').
|
||||
The legacy raw sidebar count was removed by #584, and later reintroduced
|
||||
in a gated detailed-density mode by #673.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
@@ -47,9 +47,9 @@ def test_569_autodetect_before_usermod():
|
||||
|
||||
def test_569_skips_root_uid():
|
||||
"""Auto-detect must not use UID 0 (root-owned mount = untrustworthy)."""
|
||||
detect_block_start = INIT_SH.find("Auto-detect from mounted workspace")
|
||||
detect_block_start = INIT_SH.find("Auto-detect from mounted volumes")
|
||||
assert detect_block_start != -1, "auto-detect comment block not found"
|
||||
block = INIT_SH[detect_block_start:detect_block_start + 600]
|
||||
block = INIT_SH[detect_block_start:detect_block_start + 1200]
|
||||
assert '"0"' in block or "'0'" in block, (
|
||||
"Auto-detect block must skip UID 0 to avoid incorrectly using root ownership"
|
||||
)
|
||||
@@ -65,6 +65,58 @@ def test_569_fallback_preserved():
|
||||
)
|
||||
|
||||
|
||||
# ── #668: UID/GID auto-detect from hermes-home shared volume (two-container) ──
|
||||
|
||||
def test_668_uid_autodetect_checks_hermes_home():
|
||||
"""docker_init.bash must probe hermes-home dirs for UID in two-container setups.
|
||||
|
||||
When hermes-agent and hermes-webui run in separate containers sharing a
|
||||
named volume, /workspace may not exist but ~/.hermes will be owned by the
|
||||
agent's UID. The init script must probe it so the webui user is remapped
|
||||
to match (#668).
|
||||
"""
|
||||
assert "/home/hermeswebui/.hermes" in INIT_SH, (
|
||||
"docker_init.bash must probe /home/hermeswebui/.hermes for UID detection "
|
||||
"to support two-container setups where /workspace may not exist (#668)"
|
||||
)
|
||||
|
||||
|
||||
def test_668_gid_autodetect_checks_hermes_home():
|
||||
"""docker_init.bash must probe hermes-home dirs for GID in two-container setups (#668)."""
|
||||
# Both UID and GID detection share the same probe dirs — check GID block too
|
||||
gid_detect_start = INIT_SH.find("Auto-detect GID from mounted volumes")
|
||||
assert gid_detect_start != -1, (
|
||||
"GID auto-detect comment must be updated to mention shared volumes (#668)"
|
||||
)
|
||||
gid_block = INIT_SH[gid_detect_start:gid_detect_start + 600]
|
||||
assert "/home/hermeswebui/.hermes" in gid_block or "HERMES_HOME" in gid_block, (
|
||||
"GID auto-detect block must probe hermes-home dirs (#668)"
|
||||
)
|
||||
|
||||
|
||||
def test_668_uid_probe_loop_uses_break():
|
||||
"""UID probe loop must stop on first match (no double-detection)."""
|
||||
uid_detect_start = INIT_SH.find("Auto-detect from mounted volumes")
|
||||
assert uid_detect_start != -1, "UID auto-detect comment not found"
|
||||
uid_block = INIT_SH[uid_detect_start:uid_detect_start + 1200]
|
||||
assert "break" in uid_block, (
|
||||
"UID probe loop must break after first successful detection "
|
||||
"to avoid being overridden by a later probe dir (#668)"
|
||||
)
|
||||
|
||||
|
||||
def test_668_hermes_home_probe_before_workspace():
|
||||
"""Hermes-home probe must appear before /workspace probe in docker_init.bash (#668)."""
|
||||
hermes_home_pos = INIT_SH.find("/home/hermeswebui/.hermes")
|
||||
workspace_pos = INIT_SH.find('if [ -d "/workspace" ]')
|
||||
assert hermes_home_pos != -1, "/home/hermeswebui/.hermes probe not found"
|
||||
assert workspace_pos != -1, "/workspace probe not found"
|
||||
assert hermes_home_pos < workspace_pos, (
|
||||
"Hermes-home probe must come before /workspace probe — "
|
||||
"shared volume UID should take priority over workspace UID (#668)"
|
||||
)
|
||||
|
||||
|
||||
# ── #579: topbar message count already filters tool messages ──────────────────
|
||||
|
||||
def test_579_topbar_filters_tool_messages():
|
||||
@@ -91,15 +143,21 @@ def test_579_topbar_filters_tool_messages():
|
||||
)
|
||||
|
||||
|
||||
def test_579_sidebar_no_longer_shows_raw_count():
|
||||
"""sessions.js must not reference message_count in the render path (#579).
|
||||
def test_579_sidebar_count_is_gated_behind_detailed_density():
|
||||
"""sessions.js may only show sidebar count inside detailed density mode.
|
||||
|
||||
After PR #584, the sidebar no longer shows message_count at all,
|
||||
eliminating the inconsistency between sidebar (raw) and topbar (filtered).
|
||||
PR #584 removed the always-visible raw sidebar count to avoid mismatching the
|
||||
topbar's filtered count. PR #673 later reintroduced message_count as
|
||||
optional metadata, but only when the user explicitly opts into detailed
|
||||
sidebar density.
|
||||
"""
|
||||
sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
# message_count should not appear in the client-side session renderer
|
||||
assert "message_count" not in sessions_js, (
|
||||
"sessions.js must not reference message_count — "
|
||||
"the meta row that displayed it was removed in PR #584"
|
||||
assert "const density=(window._sidebarDensity==='detailed'?'detailed':'compact');" in sessions_js, (
|
||||
"sessions.js must normalize sidebar density before rendering metadata"
|
||||
)
|
||||
assert "if(density==='detailed'){" in sessions_js, (
|
||||
"sessions.js must gate sidebar metadata behind detailed density mode"
|
||||
)
|
||||
assert "typeof s.message_count==='number'?s.message_count:0" in sessions_js, (
|
||||
"message_count may be rendered only inside the detailed-density branch"
|
||||
)
|
||||
|
||||
56
tests/test_issue570_permission.py
Normal file
56
tests/test_issue570_permission.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Regression tests for GitHub issue #570 follow-up:
|
||||
PermissionError from SETTINGS_FILE.exists() in Docker UID-mismatch scenarios.
|
||||
|
||||
When ~/.hermes is owned by a different UID than the container user (common in
|
||||
Docker setups), Path.exists() raises PermissionError instead of returning False.
|
||||
load_settings() must treat that as "file not accessible = use defaults" rather
|
||||
than propagating the exception up to crash the request handler.
|
||||
"""
|
||||
import stat
|
||||
import pytest
|
||||
import api.config as config
|
||||
|
||||
|
||||
def test_load_settings_returns_defaults_when_settings_file_unreadable(monkeypatch, tmp_path):
|
||||
"""PermissionError from SETTINGS_FILE.exists() must not propagate — return defaults instead.
|
||||
|
||||
Regression for issue #570 comment: Docker UID mismatch caused every request
|
||||
to 500 because load_settings() called SETTINGS_FILE.exists() without catching OSError.
|
||||
"""
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
settings_file = state_dir / "settings.json"
|
||||
# Create the file then make the parent unreadable so .exists() raises PermissionError
|
||||
settings_file.write_text('{"send_key": "ctrl+enter"}', encoding="utf-8")
|
||||
state_dir.chmod(stat.S_IWUSR) # write-only: stat() on children will fail
|
||||
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
|
||||
|
||||
try:
|
||||
result = config.load_settings()
|
||||
# Must not raise; must return a dict with default values
|
||||
assert isinstance(result, dict)
|
||||
assert "send_key" in result
|
||||
# The corrupted/inaccessible value should NOT appear — defaults win
|
||||
assert result["send_key"] == config._SETTINGS_DEFAULTS["send_key"]
|
||||
finally:
|
||||
state_dir.chmod(stat.S_IRWXU) # restore for cleanup
|
||||
|
||||
|
||||
def test_load_settings_returns_defaults_when_exists_raises_permission_error(monkeypatch, tmp_path):
|
||||
"""Direct simulation: monkeypatch SETTINGS_FILE.exists to raise PermissionError."""
|
||||
from unittest import mock
|
||||
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
settings_file = state_dir / "settings.json"
|
||||
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
|
||||
|
||||
with mock.patch.object(type(settings_file), "exists",
|
||||
side_effect=PermissionError("Permission denied")):
|
||||
result = config.load_settings()
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["send_key"] == config._SETTINGS_DEFAULTS["send_key"]
|
||||
98
tests/test_issue607.py
Normal file
98
tests/test_issue607.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for PR #648 — Gemma 4 thinking token stripping (closes #607)."""
|
||||
import re
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_thinking_markup tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from api.streaming import _strip_thinking_markup, _looks_invalid_generated_title
|
||||
|
||||
|
||||
class TestGemma4ThinkingTokenStrip:
|
||||
"""Verify that <|turn|>thinking\n...\n<turn|> blocks are stripped."""
|
||||
|
||||
def test_strip_gemma4_basic(self):
|
||||
"""Basic Gemma 4 thinking block stripped, answer kept."""
|
||||
raw = "<|turn|>thinking\nSome internal reasoning\n<turn|>Final answer"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Final answer"
|
||||
|
||||
def test_strip_gemma4_multiline_reasoning(self):
|
||||
"""Multi-line reasoning block stripped cleanly."""
|
||||
raw = "<|turn|>thinking\nLine 1\nLine 2\nLine 3\n<turn|>Answer here"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Answer here"
|
||||
|
||||
def test_strip_gemma4_no_thinking_passthrough(self):
|
||||
"""Normal response without thinking tokens passes through unchanged."""
|
||||
raw = "Normal response without thinking tokens"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == raw
|
||||
|
||||
def test_strip_gemma4_with_leading_whitespace(self):
|
||||
"""Leading whitespace before the thinking block is handled."""
|
||||
raw = "\n\n<|turn|>thinking\nReasoning\n<turn|>Answer"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Answer"
|
||||
|
||||
def test_strip_gemma4_empty_reasoning(self):
|
||||
"""Empty reasoning block (just delimiters) is stripped."""
|
||||
raw = "<|turn|>thinking\n<turn|>Response"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Response"
|
||||
|
||||
def test_strip_gemma4_case_insensitive(self):
|
||||
"""Pattern is case-insensitive (though Gemma 4 uses fixed case)."""
|
||||
raw = "<|TURN|>THINKING\nreasoning\n<TURN|>answer"
|
||||
result = _strip_thinking_markup(raw)
|
||||
# The regex uses re.IGNORECASE — should strip uppercase variant too
|
||||
assert "THINKING" not in result
|
||||
assert "reasoning" not in result
|
||||
|
||||
def test_existing_think_tag_still_works(self):
|
||||
"""Ensure <think>...</think> still stripped (no regression)."""
|
||||
raw = "<think>inner reasoning</think>Final"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Final"
|
||||
|
||||
def test_existing_channel_tag_still_works(self):
|
||||
"""Ensure <|channel|>thought...</channel|> still stripped."""
|
||||
raw = "<|channel|>thoughtSome reasoning<channel|>Answer"
|
||||
result = _strip_thinking_markup(raw)
|
||||
assert result == "Answer"
|
||||
|
||||
|
||||
class TestGemma4TitleLeakDetection:
|
||||
"""Verify _looks_invalid_generated_title catches Gemma 4 leak."""
|
||||
|
||||
def test_detects_gemma4_leak_in_title(self):
|
||||
raw = "<|turn|>thinking\nUser asked about X\n<turn|>Session Title"
|
||||
assert _looks_invalid_generated_title(raw) is True
|
||||
|
||||
def test_clean_title_not_flagged(self):
|
||||
assert _looks_invalid_generated_title("Python debugging session") is False
|
||||
|
||||
|
||||
class TestGemma4MessagesJsThinkPairs:
|
||||
"""Verify static/messages.js contains the correct Gemma 4 pair."""
|
||||
|
||||
def test_messages_js_has_correct_gemma4_open(self):
|
||||
js = pathlib.Path("static/messages.js").read_text()
|
||||
# Must have double-pipe format: <|turn|>thinking
|
||||
assert "<|turn|>thinking" in js, (
|
||||
"messages.js is missing correct Gemma 4 open delimiter '<|turn|>thinking'"
|
||||
)
|
||||
|
||||
def test_messages_js_no_wrong_gemma4_open(self):
|
||||
js = pathlib.Path("static/messages.js").read_text()
|
||||
# Must NOT have single-pipe wrong format: <|turn>thinking
|
||||
assert "<|turn>thinking" not in js, (
|
||||
"messages.js still contains wrong Gemma 4 delimiter '<|turn>thinking' (missing |)"
|
||||
)
|
||||
|
||||
def test_messages_js_has_gemma4_close(self):
|
||||
js = pathlib.Path("static/messages.js").read_text()
|
||||
assert "<turn|>" in js, "messages.js missing Gemma 4 close delimiter '<turn|>'"
|
||||
107
tests/test_issue609.py
Normal file
107
tests/test_issue609.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Tests for GitHub issue #609 — Docker workspace path trust and env-var priority.
|
||||
|
||||
Two independent bugs were fixed:
|
||||
|
||||
1. HERMES_WEBUI_DEFAULT_WORKSPACE env var was silently overridden by
|
||||
settings.json at server startup. The env var must always win.
|
||||
|
||||
2. resolve_trusted_workspace() rejected paths that are children of
|
||||
DEFAULT_WORKSPACE (e.g. /data/workspace/project) when the default is a
|
||||
Docker volume mount outside the user's home directory. Any path under
|
||||
the boot-time default should be trusted automatically.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api.workspace import resolve_trusted_workspace
|
||||
|
||||
|
||||
# ── Fix 2: trust paths under DEFAULT_WORKSPACE ───────────────────────────────
|
||||
|
||||
def test_subdir_of_boot_default_is_trusted(monkeypatch, tmp_path):
|
||||
"""A subdirectory of BOOT_DEFAULT_WORKSPACE must be trusted without being in
|
||||
the saved workspace list and without being under the user's home directory.
|
||||
|
||||
This is the core Docker case: DEFAULT_WORKSPACE=/data/workspace, and the
|
||||
user tries to open /data/workspace/myproject — should NOT raise ValueError.
|
||||
"""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
sub = boot_default / "myproject"
|
||||
sub.mkdir()
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
# Should not raise — sub is under the boot default
|
||||
result = resolve_trusted_workspace(str(sub))
|
||||
assert result == sub.resolve()
|
||||
|
||||
|
||||
def test_boot_default_itself_is_trusted(monkeypatch, tmp_path):
|
||||
"""The DEFAULT_WORKSPACE path itself must also be trusted (not only subdirs)."""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
result = resolve_trusted_workspace(str(boot_default))
|
||||
assert result == boot_default.resolve()
|
||||
|
||||
|
||||
def test_path_outside_boot_default_and_home_is_rejected(monkeypatch, tmp_path):
|
||||
"""A path that is not under home, not in the saved list, and not under
|
||||
DEFAULT_WORKSPACE must still be rejected."""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
outside = tmp_path / "other_mount" / "secret"
|
||||
outside.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
with pytest.raises(ValueError, match="outside the user home"):
|
||||
resolve_trusted_workspace(str(outside))
|
||||
|
||||
|
||||
def test_none_path_returns_boot_default(monkeypatch, tmp_path):
|
||||
"""resolve_trusted_workspace(None) always returns the boot default unchanged."""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
result = resolve_trusted_workspace(None)
|
||||
assert result == boot_default.resolve()
|
||||
|
||||
|
||||
def test_path_traversal_via_dotdot_does_not_escape_boot_default(monkeypatch, tmp_path):
|
||||
"""A path that uses `..` to escape DEFAULT_WORKSPACE must not be trusted by (C).
|
||||
|
||||
`Path.resolve()` collapses `..` before the `relative_to(boot_default)` check
|
||||
runs, so `/data/workspace/../etc` resolves to `/etc` and is rejected (it's
|
||||
also caught earlier by the system-roots block, but this test pins the
|
||||
behavior in case the order of conditions ever changes).
|
||||
"""
|
||||
import api.workspace as ws_mod
|
||||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
sibling = tmp_path / "data" / "private"
|
||||
sibling.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
|
||||
# `boot_default/../private` resolves to `tmp_path/data/private`, which is
|
||||
# NOT a child of boot_default and not under home — must reject.
|
||||
escape = boot_default / ".." / "private"
|
||||
with pytest.raises(ValueError, match="outside the user home"):
|
||||
resolve_trusted_workspace(str(escape))
|
||||
17
tests/test_issue616.py
Normal file
17
tests/test_issue616.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import pathlib
|
||||
|
||||
|
||||
def test_workspace_suggest_endpoint_is_wired():
|
||||
src = pathlib.Path("api/routes.py").read_text(encoding="utf-8")
|
||||
assert '"/api/workspaces/suggest"' in src
|
||||
|
||||
|
||||
def test_spaces_panel_uses_workspace_suggest_autocomplete():
|
||||
src = pathlib.Path("static/panels.js").read_text(encoding="utf-8")
|
||||
assert "/api/workspaces/suggest" in src
|
||||
assert "workspaceFormPathSuggestions" in src
|
||||
assert "scheduleWorkspacePathSuggestions" in src
|
||||
assert "if(!prefix)" in src
|
||||
assert "dataset.path" in src
|
||||
assert "scrollIntoView" in src
|
||||
assert "_wsSuggestIndex=0" in src
|
||||
59
tests/test_issue632.py
Normal file
59
tests/test_issue632.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Issue #632: slash autocomplete should suggest second-level arguments.
|
||||
|
||||
Covers:
|
||||
- commands.js exposes a dedicated slash autocomplete parser/loader
|
||||
- /model sub-args hydrate from /api/models
|
||||
- /personality sub-args hydrate from /api/personalities
|
||||
- /reasoning provides static low/medium/high suggestions without becoming a
|
||||
locally executed built-in command
|
||||
- boot.js uses the async slash autocomplete helper while typing
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_subarg_registry_exists_and_reasoning_is_promoted_to_builtin():
|
||||
# SLASH_SUBARG_SOURCES still exists for model and personality
|
||||
assert "const SLASH_SUBARG_SOURCES=" in COMMANDS_JS
|
||||
# /reasoning is now a proper builtin command with a fn: handler (cmdReasoning)
|
||||
# so it is in the COMMANDS array, not SLASH_SUBARG_SOURCES
|
||||
assert "{name:'reasoning'" in COMMANDS_JS, \
|
||||
"/reasoning must be registered as a local built-in command with fn: handler"
|
||||
assert "fn:cmdReasoning" in COMMANDS_JS, \
|
||||
"/reasoning entry must reference cmdReasoning function"
|
||||
assert "function cmdReasoning" in COMMANDS_JS, \
|
||||
"cmdReasoning function must be defined"
|
||||
# source:'subarg-command' is still used for model/personality in SLASH_SUBARG_SOURCES
|
||||
assert "source:'subarg-command'" in COMMANDS_JS
|
||||
|
||||
|
||||
def test_model_and_personality_subargs_load_from_existing_apis():
|
||||
assert "_loadSlashModelSubArgs" in COMMANDS_JS
|
||||
assert "api('/api/models')" in COMMANDS_JS
|
||||
assert "_loadSlashPersonalitySubArgs" in COMMANDS_JS
|
||||
assert "api('/api/personalities')" in COMMANDS_JS
|
||||
|
||||
|
||||
def test_slash_autocomplete_parses_second_level_arguments():
|
||||
assert "function _parseSlashAutocomplete" in COMMANDS_JS
|
||||
assert "return {kind:'subargs'" in COMMANDS_JS
|
||||
assert "getSlashAutocompleteMatches" in COMMANDS_JS
|
||||
|
||||
|
||||
def test_boot_uses_async_slash_autocomplete_helper():
|
||||
assert "getSlashAutocompleteMatches(text).then(matches=>" in BOOT_JS
|
||||
|
||||
|
||||
def test_subarg_dropdown_has_distinct_parent_and_argument_styling():
|
||||
assert ".cmd-item-parent" in STYLE_CSS
|
||||
assert ".cmd-item-subarg" in STYLE_CSS
|
||||
assert ".cmd-item.selected{background:var(--accent-bg);" in STYLE_CSS
|
||||
assert "_cmdSelectedIdx=matches.length?0:-1;" in COMMANDS_JS
|
||||
assert "getSlashAutocompleteMatches(nextValue).then(matches=>" in COMMANDS_JS, \
|
||||
"selecting a first-level command with sub-args should immediately open second-level suggestions"
|
||||
75
tests/test_issue634.py
Normal file
75
tests/test_issue634.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Tests for #634: CLI sessions not visible when setting is enabled.
|
||||
|
||||
Root cause: get_cli_sessions() swallowed all errors silently (bare except → return []).
|
||||
Users with older hermes-agent versions (missing 'source' column in state.db) got
|
||||
an empty list with no log output, making diagnosis impossible.
|
||||
|
||||
Fixes:
|
||||
1. Schema introspection: check for 'source' column before querying, log a warning
|
||||
if missing and return early.
|
||||
2. Exception path: log a warning instead of silently returning [].
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
MODELS_PY = pathlib.Path(__file__).parent.parent / 'api' / 'models.py'
|
||||
src = MODELS_PY.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
class TestCliSessionsErrorSurface:
|
||||
"""get_cli_sessions() must log warnings instead of silently returning []."""
|
||||
|
||||
def test_schema_introspection_present(self):
|
||||
"""The function must check for the 'source' column before querying."""
|
||||
assert "PRAGMA table_info(sessions)" in src
|
||||
|
||||
def test_missing_source_column_logs_warning(self):
|
||||
"""If 'source' column is absent, a warning is logged."""
|
||||
# The warning message must mention the missing column and how to fix it
|
||||
assert "no 'source' column" in src or "has no 'source' column" in src
|
||||
|
||||
def test_missing_source_column_suggests_upgrade(self):
|
||||
"""Warning message must suggest upgrading hermes-agent."""
|
||||
assert "Upgrade hermes-agent" in src or "upgrade hermes-agent" in src.lower()
|
||||
|
||||
def test_exception_path_logs_warning(self):
|
||||
"""The except clause must call logger.warning, not silently pass."""
|
||||
# Find the exception handler in get_cli_sessions
|
||||
func_start = src.find("def get_cli_sessions()")
|
||||
func_end = src.find("\ndef ", func_start + 1)
|
||||
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
|
||||
assert "warning(" in func_body, \
|
||||
"get_cli_sessions() exception handler must call logging.warning()"
|
||||
|
||||
def test_exception_path_includes_db_path(self):
|
||||
"""The warning must include the db_path for diagnosability."""
|
||||
func_start = src.find("def get_cli_sessions()")
|
||||
func_end = src.find("\ndef ", func_start + 1)
|
||||
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
|
||||
# db_path should appear in the warning call
|
||||
warning_pos = func_body.find("warning(")
|
||||
warning_block = func_body[warning_pos:warning_pos + 300]
|
||||
assert "db_path" in warning_block, \
|
||||
"Warning must include db_path so admins can find the problematic database"
|
||||
|
||||
def test_still_returns_empty_on_error(self):
|
||||
"""Function must still return [] after logging (graceful degradation)."""
|
||||
# After the warning, it should return cli_sessions (the empty list) not raise
|
||||
func_start = src.find("def get_cli_sessions()")
|
||||
func_end = src.find("\ndef ", func_start + 1)
|
||||
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
|
||||
# Must have a 'return' after the warning call
|
||||
warning_pos = func_body.find("_cli_err:")
|
||||
after_warning = func_body[warning_pos:warning_pos + 400]
|
||||
assert "return" in after_warning, \
|
||||
"Function must return after the warning (not raise)"
|
||||
|
||||
def test_source_column_check_before_sql_query(self):
|
||||
"""Schema check must happen before the main SQL SELECT."""
|
||||
pragma_pos = src.find("PRAGMA table_info(sessions)")
|
||||
select_pos = src.find("SELECT s.id, s.title, s.model")
|
||||
assert pragma_pos != -1, "PRAGMA check not found"
|
||||
assert select_pos != -1, "SELECT query not found"
|
||||
assert pragma_pos < select_pos, \
|
||||
"Schema introspection must run before the main SQL query"
|
||||
139
tests/test_issue644.py
Normal file
139
tests/test_issue644.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Tests for PR #644 — load provider models from config.yaml in get_available_models()."""
|
||||
import pytest
|
||||
import api.config as _cfg
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_models_cache():
|
||||
"""Invalidate the models TTL cache before and after every test in this file."""
|
||||
try:
|
||||
_cfg.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
_cfg.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _available_models_with_cfg(cfg_override):
|
||||
"""Helper: temporarily patch config.cfg, call get_available_models(), restore."""
|
||||
old_cfg = dict(_cfg.cfg)
|
||||
_cfg.cfg.clear()
|
||||
_cfg.cfg.update(cfg_override)
|
||||
try:
|
||||
return _cfg.get_available_models()
|
||||
finally:
|
||||
_cfg.cfg.clear()
|
||||
_cfg.cfg.update(old_cfg)
|
||||
|
||||
|
||||
class TestConfigYamlModelsLoading:
|
||||
"""Verify that providers with explicit models in config.yaml use those models."""
|
||||
|
||||
def test_provider_in_config_but_not_provider_models_gets_cfg_models(self):
|
||||
"""A provider only in cfg.providers (not _PROVIDER_MODELS) should appear
|
||||
with its configured model list instead of being skipped entirely."""
|
||||
cfg = {
|
||||
"model": {"provider": "my-custom-llm"},
|
||||
"providers": {
|
||||
"my-custom-llm": {
|
||||
"base_url": "http://custom.local/v1",
|
||||
"models": ["custom-model-a", "custom-model-b"],
|
||||
}
|
||||
},
|
||||
}
|
||||
result = _available_models_with_cfg(cfg)
|
||||
groups = {g["provider"]: g["models"] for g in result["groups"]}
|
||||
# Provider should appear (previously it was silently skipped)
|
||||
provider_names = [g["provider"] for g in result["groups"]]
|
||||
found = any("my-custom-llm" in n.lower() or "My-Custom-Llm" in n for n in provider_names)
|
||||
# If it appears, its models must include our cfg models
|
||||
for g in result["groups"]:
|
||||
if "custom" in g["provider"].lower():
|
||||
model_ids = [m["id"] for m in g["models"]]
|
||||
assert any("custom-model-a" in mid for mid in model_ids), (
|
||||
f"custom-model-a not in group models: {model_ids}"
|
||||
)
|
||||
|
||||
def test_provider_models_dict_format_expanded(self):
|
||||
"""models: {model_id: {context_length: ...}} — keys become model IDs."""
|
||||
cfg = {
|
||||
"model": {"provider": "anthropic"},
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"models": {
|
||||
"claude-custom-1": {"context_length": 200000},
|
||||
"claude-custom-2": {"context_length": 100000},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
result = _available_models_with_cfg(cfg)
|
||||
# Find Anthropic group
|
||||
for g in result["groups"]:
|
||||
if g["provider"] == "Anthropic":
|
||||
model_ids = [m["id"] for m in g["models"]]
|
||||
assert "claude-custom-1" in model_ids, (
|
||||
f"claude-custom-1 not in Anthropic models: {model_ids}"
|
||||
)
|
||||
assert "claude-custom-2" in model_ids, (
|
||||
f"claude-custom-2 not in Anthropic models: {model_ids}"
|
||||
)
|
||||
break
|
||||
|
||||
def test_provider_models_list_format_expanded(self):
|
||||
"""models: [model_id, ...] — items become model IDs."""
|
||||
cfg = {
|
||||
"model": {"provider": "anthropic"},
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"models": ["claude-list-only-1", "claude-list-only-2"],
|
||||
}
|
||||
},
|
||||
}
|
||||
result = _available_models_with_cfg(cfg)
|
||||
for g in result["groups"]:
|
||||
if g["provider"] == "Anthropic":
|
||||
model_ids = [m["id"] for m in g["models"]]
|
||||
assert "claude-list-only-1" in model_ids, (
|
||||
f"claude-list-only-1 not in Anthropic models: {model_ids}"
|
||||
)
|
||||
break
|
||||
|
||||
def test_provider_in_provider_models_but_no_cfg_override_unchanged(self):
|
||||
"""When no models key in cfg.providers, hardcoded _PROVIDER_MODELS still used."""
|
||||
cfg = {
|
||||
"model": {"provider": "anthropic"},
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"api_key": "sk-test",
|
||||
# No 'models' key
|
||||
}
|
||||
},
|
||||
}
|
||||
result = _available_models_with_cfg(cfg)
|
||||
raw_ids = {m["id"] for m in _cfg._PROVIDER_MODELS.get("anthropic", [])}
|
||||
for g in result["groups"]:
|
||||
if g["provider"] == "Anthropic":
|
||||
returned_ids = {m["id"] for m in g["models"]}
|
||||
# Should still have the hardcoded models
|
||||
overlap = raw_ids & returned_ids
|
||||
assert overlap, (
|
||||
f"No _PROVIDER_MODELS models found in Anthropic group. "
|
||||
f"Expected subset of {raw_ids}, got {returned_ids}"
|
||||
)
|
||||
break
|
||||
|
||||
def test_non_dict_models_value_falls_through_gracefully(self):
|
||||
"""If models value is neither dict nor list (e.g. null), no crash."""
|
||||
cfg = {
|
||||
"model": {"provider": "anthropic"},
|
||||
"providers": {
|
||||
"anthropic": {"models": None}, # invalid — should not crash
|
||||
},
|
||||
}
|
||||
# Should not raise
|
||||
result = _available_models_with_cfg(cfg)
|
||||
assert "groups" in result
|
||||
54
tests/test_issue646.py
Normal file
54
tests/test_issue646.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Tests for PR #649 — empty DEFAULT_MODEL does not inject blank model entries."""
|
||||
import pytest
|
||||
from api import config as cfg
|
||||
|
||||
|
||||
class TestEmptyDefaultModel:
|
||||
"""Verify that DEFAULT_MODEL='' does not produce blank model entries."""
|
||||
|
||||
def test_no_empty_id_when_default_model_is_empty(self, monkeypatch):
|
||||
"""With empty DEFAULT_MODEL, no model entry should have id='' or label=''."""
|
||||
monkeypatch.setattr(cfg, "DEFAULT_MODEL", "")
|
||||
# Simulate the 'no providers' path by calling the model-list builder
|
||||
# We test the config module directly since it's a pure function path.
|
||||
# The key invariant: any model dict in the output must have non-empty id.
|
||||
# We check the branches that were patched in PR #649.
|
||||
|
||||
# Path 1: "no providers detected" branch
|
||||
# When default_model="", we should NOT append a Default group with empty model
|
||||
groups = []
|
||||
default_model = cfg.DEFAULT_MODEL
|
||||
if default_model:
|
||||
label = default_model.split("/")[-1] if "/" in default_model else default_model
|
||||
groups.append(
|
||||
{"provider": "Default", "models": [{"id": default_model, "label": label}]}
|
||||
)
|
||||
|
||||
# With empty default_model, groups should be empty (not appended)
|
||||
assert len(groups) == 0, "Empty default_model should not create any group"
|
||||
|
||||
def test_no_empty_id_when_default_model_is_set(self, monkeypatch):
|
||||
"""With a real DEFAULT_MODEL, the Default group should be created normally."""
|
||||
monkeypatch.setattr(cfg, "DEFAULT_MODEL", "openrouter/mistralai/mistral-7b-instruct")
|
||||
|
||||
groups = []
|
||||
default_model = cfg.DEFAULT_MODEL
|
||||
if default_model:
|
||||
label = default_model.split("/")[-1] if "/" in default_model else default_model
|
||||
groups.append(
|
||||
{"provider": "Default", "models": [{"id": default_model, "label": label}]}
|
||||
)
|
||||
|
||||
assert len(groups) == 1
|
||||
assert groups[0]["models"][0]["id"] == "openrouter/mistralai/mistral-7b-instruct"
|
||||
assert groups[0]["models"][0]["label"] == "mistral-7b-instruct"
|
||||
|
||||
def test_default_model_env_var_empty_string_accepted(self, monkeypatch):
|
||||
"""Empty string is a valid DEFAULT_MODEL value — no KeyError or crash."""
|
||||
import os
|
||||
monkeypatch.setenv("HERMES_WEBUI_DEFAULT_MODEL", "")
|
||||
# Verify the env var resolution pattern handles empty string gracefully
|
||||
val = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "")
|
||||
assert val == ""
|
||||
# And that the guard works
|
||||
assert not val # empty string is falsy — the guard `if default_model:` fires correctly
|
||||
76
tests/test_issue660.py
Normal file
76
tests/test_issue660.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Tests for #660: session queue persistence across page refresh.
|
||||
|
||||
The queue is stored to sessionStorage when entries are added/removed,
|
||||
and restored from sessionStorage on session load when the agent is idle.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
UI_JS = pathlib.Path(__file__).parent.parent / 'static' / 'ui.js'
|
||||
SESSIONS_JS = pathlib.Path(__file__).parent.parent / 'static' / 'sessions.js'
|
||||
|
||||
ui_src = UI_JS.read_text(encoding='utf-8')
|
||||
sess_src = SESSIONS_JS.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
class TestQueuePersistence:
|
||||
"""queueSessionMessage persists to sessionStorage."""
|
||||
|
||||
def test_queue_writes_to_session_storage(self):
|
||||
"""queueSessionMessage must write to sessionStorage after enqueueing."""
|
||||
assert "sessionStorage.setItem('hermes-queue-'+sid" in ui_src
|
||||
|
||||
def test_queue_stamps_queued_at_timestamp(self):
|
||||
"""Each queue entry must have a _queued_at timestamp for stale-entry detection."""
|
||||
assert '_queued_at' in ui_src
|
||||
|
||||
def test_shift_removes_from_session_storage(self):
|
||||
"""shiftQueuedSessionMessage must remove/update sessionStorage on dequeue."""
|
||||
assert "sessionStorage.removeItem('hermes-queue-'+sid)" in ui_src
|
||||
|
||||
def test_shift_updates_session_storage_when_items_remain(self):
|
||||
"""When queue still has items after shift, sessionStorage is updated (not removed)."""
|
||||
# After shift: if queue still has items, update storage with remaining
|
||||
assert "sessionStorage.setItem('hermes-queue-'+sid, JSON.stringify(q))" in ui_src
|
||||
# Counts: should appear in both add and update paths (2 occurrences minimum)
|
||||
count = ui_src.count("sessionStorage.setItem('hermes-queue-'+sid")
|
||||
assert count >= 2, f"Expected >=2 sessionStorage.setItem calls, found {count}"
|
||||
|
||||
|
||||
class TestQueueRestore:
|
||||
"""Queue is restored from sessionStorage on session load when agent is idle."""
|
||||
|
||||
def test_restore_reads_session_storage(self):
|
||||
"""sessions.js must read from sessionStorage in the idle-session load path."""
|
||||
assert "sessionStorage.getItem('hermes-queue-'+sid)" in sess_src
|
||||
|
||||
def test_restore_uses_timestamp_guard(self):
|
||||
"""Stale entries (created before last assistant response) must be dropped."""
|
||||
assert '_queued_at' in sess_src
|
||||
assert '_lastAsst' in sess_src
|
||||
|
||||
def test_restore_shows_toast(self):
|
||||
"""User must see a toast notification when a queue is restored."""
|
||||
assert 'queued message' in sess_src.lower() and 'restored' in sess_src.lower()
|
||||
|
||||
def test_restore_puts_text_in_composer(self):
|
||||
"""First queued message goes into the composer input, not auto-sent."""
|
||||
assert "_msg.value=_first.text" in sess_src
|
||||
|
||||
def test_restore_clears_stale_storage(self):
|
||||
"""On timestamp mismatch, stale sessionStorage entry is removed."""
|
||||
assert "sessionStorage.removeItem('hermes-queue-'+sid)" in sess_src
|
||||
|
||||
def test_restore_wrapped_in_try_catch(self):
|
||||
"""sessionStorage access must be wrapped in try/catch (private browsing may block it)."""
|
||||
# The restore block must have a catch that clears the bad key
|
||||
assert "catch(_){sessionStorage.removeItem" in sess_src
|
||||
|
||||
def test_active_session_not_restored_as_draft(self):
|
||||
"""When agent is active (INFLIGHT), queue restore must NOT run."""
|
||||
# The restore block must be inside the else branch (idle path), not the INFLIGHT branch
|
||||
inflight_pos = sess_src.find("if(INFLIGHT[sid]){")
|
||||
restore_pos = sess_src.find("sessionStorage.getItem('hermes-queue-'")
|
||||
else_pos = sess_src.find("}else{", inflight_pos)
|
||||
assert restore_pos > else_pos, \
|
||||
"Queue restore must be inside the else (idle) branch, not the INFLIGHT branch"
|
||||
181
tests/test_issue673.py
Normal file
181
tests/test_issue673.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Tests for issue #673 — sidebar density mode for the session list.
|
||||
|
||||
Covers:
|
||||
- api/config.py: sidebar_density registered in defaults + enum validation
|
||||
- static/index.html: settingsSidebarDensity field and i18n wiring present
|
||||
- static/boot.js: boot path applies window._sidebarDensity with compact default
|
||||
- static/panels.js: load/save settings wire sidebar_density correctly
|
||||
- static/sessions.js: detailed mode renders message count + model, and profile
|
||||
only when the "show all profiles" toggle is active
|
||||
- static/i18n.js: locale keys exist for all shipped locales
|
||||
- Integration: GET/POST /api/settings round-trip sidebar_density
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
|
||||
def _get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def _post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
class TestSidebarDensityConfig(unittest.TestCase):
|
||||
def test_sidebar_density_in_defaults(self):
|
||||
self.assertIn('"sidebar_density"', CONFIG_PY)
|
||||
|
||||
def test_sidebar_density_default_is_compact(self):
|
||||
self.assertRegex(CONFIG_PY, r'"sidebar_density"\s*:\s*"compact"')
|
||||
|
||||
def test_sidebar_density_in_enum_values(self):
|
||||
self.assertIn('"sidebar_density": {"compact", "detailed"}', CONFIG_PY)
|
||||
|
||||
|
||||
class TestSidebarDensityHTML(unittest.TestCase):
|
||||
def test_settings_select_present(self):
|
||||
self.assertIn('id="settingsSidebarDensity"', INDEX_HTML)
|
||||
|
||||
def test_i18n_wiring_present(self):
|
||||
for key in (
|
||||
'data-i18n="settings_label_sidebar_density"',
|
||||
'data-i18n="settings_desc_sidebar_density"',
|
||||
'data-i18n="settings_sidebar_density_compact"',
|
||||
'data-i18n="settings_sidebar_density_detailed"',
|
||||
):
|
||||
self.assertIn(key, INDEX_HTML)
|
||||
|
||||
|
||||
class TestSidebarDensityBootAndPanels(unittest.TestCase):
|
||||
def test_boot_applies_sidebar_density(self):
|
||||
self.assertIn(
|
||||
"window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');",
|
||||
BOOT_JS,
|
||||
)
|
||||
|
||||
def test_boot_fallback_is_compact(self):
|
||||
self.assertIn("window._sidebarDensity='compact';", BOOT_JS)
|
||||
|
||||
def test_settings_panel_reads_sidebar_density(self):
|
||||
self.assertIn("settingsSidebarDensity", PANELS_JS)
|
||||
self.assertIn(
|
||||
"settings.sidebar_density==='detailed'?'detailed':'compact'",
|
||||
PANELS_JS,
|
||||
)
|
||||
|
||||
def test_settings_save_writes_sidebar_density(self):
|
||||
self.assertIn("body.sidebar_density=sidebarDensity;", PANELS_JS)
|
||||
self.assertIn(
|
||||
"window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact';",
|
||||
PANELS_JS,
|
||||
)
|
||||
|
||||
|
||||
class TestSidebarDensitySessionRendering(unittest.TestCase):
|
||||
def test_detailed_mode_branch_present(self):
|
||||
self.assertIn(
|
||||
"const density=(window._sidebarDensity==='detailed'?'detailed':'compact');",
|
||||
SESSIONS_JS,
|
||||
)
|
||||
self.assertIn("if(density==='detailed')", SESSIONS_JS)
|
||||
|
||||
def test_detailed_mode_uses_message_count_and_model(self):
|
||||
self.assertIn("typeof s.message_count==='number'?s.message_count:0", SESSIONS_JS)
|
||||
self.assertIn("if(s.model) metaBits.push(s.model);", SESSIONS_JS)
|
||||
self.assertIn("t('session_meta_messages', msgCount)", SESSIONS_JS)
|
||||
|
||||
def test_profile_only_when_show_all_profiles(self):
|
||||
self.assertIn(
|
||||
"if(_showAllProfiles&&s.profile) metaBits.push(s.profile);", SESSIONS_JS
|
||||
)
|
||||
|
||||
def test_session_meta_css_hook_present(self):
|
||||
self.assertIn(".session-meta", STYLE_CSS)
|
||||
|
||||
|
||||
class TestSidebarDensityI18N(unittest.TestCase):
|
||||
def _extract_locale_block(self, start_marker, end_marker):
|
||||
start = I18N_JS.find(start_marker)
|
||||
end = I18N_JS.find(end_marker, start)
|
||||
self.assertGreater(start, -1)
|
||||
self.assertGreater(end, start)
|
||||
return I18N_JS[start:end]
|
||||
|
||||
def test_all_locale_blocks_have_sidebar_density_keys(self):
|
||||
locale_ranges = [
|
||||
("\n en: {", "\n ru: {"),
|
||||
("\n ru: {", "\n es: {"),
|
||||
("\n es: {", "\n de: {"),
|
||||
("\n de: {", "\n zh: {"),
|
||||
("\n zh: {", "\n // Traditional Chinese (zh-Hant)"),
|
||||
("\n // Traditional Chinese (zh-Hant)\n 'zh-Hant': {", "\n};"),
|
||||
]
|
||||
required = (
|
||||
"settings_label_sidebar_density",
|
||||
"settings_desc_sidebar_density",
|
||||
"settings_sidebar_density_compact",
|
||||
"settings_sidebar_density_detailed",
|
||||
"session_meta_messages",
|
||||
)
|
||||
for start, end in locale_ranges:
|
||||
block = self._extract_locale_block(start, end)
|
||||
for key in required:
|
||||
self.assertIn(key, block, f"{key} missing from locale block {start}")
|
||||
|
||||
|
||||
class TestSidebarDensitySettingsAPI(unittest.TestCase):
|
||||
def test_sidebar_density_default_is_compact(self):
|
||||
try:
|
||||
data, status = _get("/api/settings")
|
||||
except OSError:
|
||||
self.skipTest("Server not running on test server port")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data.get("sidebar_density"), "compact")
|
||||
|
||||
def test_sidebar_density_round_trips_detailed(self):
|
||||
try:
|
||||
_, status = _post("/api/settings", {"sidebar_density": "detailed"})
|
||||
except OSError:
|
||||
self.skipTest("Server not running on test server port")
|
||||
self.assertEqual(status, 200)
|
||||
data, _ = _get("/api/settings")
|
||||
self.assertEqual(data.get("sidebar_density"), "detailed")
|
||||
_post("/api/settings", {"sidebar_density": "compact"})
|
||||
|
||||
def test_invalid_sidebar_density_is_ignored(self):
|
||||
try:
|
||||
_post("/api/settings", {"sidebar_density": "compact"})
|
||||
data, status = _post("/api/settings", {"sidebar_density": "nope"})
|
||||
except OSError:
|
||||
self.skipTest("Server not running on test server port")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data.get("sidebar_density"), "compact")
|
||||
current, _ = _get("/api/settings")
|
||||
self.assertEqual(current.get("sidebar_density"), "compact")
|
||||
135
tests/test_issue677.py
Normal file
135
tests/test_issue677.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Tests for fix #677: auto-scroll override during streaming.
|
||||
|
||||
The scroll system has a _scrollPinned flag and scrollIfPinned() to respect
|
||||
user scroll position. The bug was that scrollToBottom() was called
|
||||
unconditionally inside renderMessages() and appendThinking(), even during
|
||||
an active stream — overriding any scroll position the user had set.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestScrollPinningFix:
|
||||
|
||||
def test_render_messages_respects_active_stream(self):
|
||||
"""renderMessages() must not call scrollToBottom() while streaming (#677).
|
||||
|
||||
During an active stream, scrollToBottom() unconditionally re-pins scroll
|
||||
and overrides the user's position. renderMessages() must use scrollIfPinned()
|
||||
instead when S.activeStreamId is set.
|
||||
"""
|
||||
# Find renderMessages function
|
||||
rm_start = UI_JS.find("function renderMessages()")
|
||||
assert rm_start != -1, "renderMessages() not found in ui.js"
|
||||
rm_end = UI_JS.find("\nfunction ", rm_start + 1)
|
||||
rm_body = UI_JS[rm_start:rm_end]
|
||||
|
||||
# Must check activeStreamId before deciding which scroll fn to call
|
||||
assert "activeStreamId" in rm_body, (
|
||||
"renderMessages() must check S.activeStreamId before scrolling — "
|
||||
"unconditional scrollToBottom() overrides user scroll position (#677)"
|
||||
)
|
||||
# scrollIfPinned must be called inside renderMessages (stream path)
|
||||
assert "scrollIfPinned()" in rm_body, (
|
||||
"renderMessages() must call scrollIfPinned() during streaming (#677)"
|
||||
)
|
||||
|
||||
def test_append_thinking_uses_scroll_if_pinned(self):
|
||||
"""appendThinking() must use scrollIfPinned() not scrollToBottom() (#677).
|
||||
|
||||
appendThinking() fires continuously during streaming — calling scrollToBottom()
|
||||
inside it re-pins on every token, preventing the user from scrolling up.
|
||||
"""
|
||||
at_start = UI_JS.find("function appendThinking(")
|
||||
assert at_start != -1, "appendThinking() not found in ui.js"
|
||||
at_end = UI_JS.find("\nfunction ", at_start + 1)
|
||||
at_body = UI_JS[at_start:at_end]
|
||||
|
||||
assert "scrollIfPinned()" in at_body, (
|
||||
"appendThinking() must call scrollIfPinned() not scrollToBottom() (#677)"
|
||||
)
|
||||
assert "scrollToBottom()" not in at_body, (
|
||||
"appendThinking() must not call scrollToBottom() — it fires mid-stream (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_threshold_increased(self):
|
||||
"""Scroll re-pin threshold must be at least 150px (#677).
|
||||
|
||||
80px was too small — a fast mouse scroll wheel can jump 100–120px in one
|
||||
tick, causing unintended re-pin. 150px gives a proper dead zone.
|
||||
"""
|
||||
# Find the nearBottom assignment in the scroll listener
|
||||
near_bottom_pos = UI_JS.find("nearBottom=")
|
||||
if near_bottom_pos == -1:
|
||||
near_bottom_pos = UI_JS.find("nearBottom =")
|
||||
assert near_bottom_pos != -1, "nearBottom scroll threshold assignment not found"
|
||||
threshold_line = UI_JS[near_bottom_pos:near_bottom_pos + 120]
|
||||
# Extract the numeric threshold
|
||||
match = re.search(r"<\s*(\d+)", threshold_line)
|
||||
assert match, f"Numeric threshold not found near nearBottom assignment: {threshold_line!r}"
|
||||
threshold = int(match.group(1))
|
||||
assert threshold >= 150, (
|
||||
f"Scroll re-pin threshold is {threshold}px — must be >= 150px to avoid "
|
||||
f"hair-trigger re-pinning on fast scroll wheels (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_to_bottom_button_exists_in_html(self):
|
||||
"""index.html must contain a scroll-to-bottom button (#677).
|
||||
|
||||
All major streaming chat UIs (Claude, ChatGPT) show a floating ↓ button
|
||||
when the user has scrolled up, giving a clear escape hatch to return to live output.
|
||||
"""
|
||||
assert "scrollToBottomBtn" in INDEX_HTML, (
|
||||
"index.html must contain a #scrollToBottomBtn element (#677)"
|
||||
)
|
||||
assert "scroll-to-bottom-btn" in INDEX_HTML, (
|
||||
"index.html must use class scroll-to-bottom-btn for the scroll button (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_to_bottom_button_hidden_by_default(self):
|
||||
"""Scroll-to-bottom button must be hidden by default (display:none) (#677)."""
|
||||
btn_pos = INDEX_HTML.find("scrollToBottomBtn")
|
||||
assert btn_pos != -1
|
||||
btn_context = INDEX_HTML[btn_pos:btn_pos + 200]
|
||||
assert "display:none" in btn_context or 'display="none"' in btn_context, (
|
||||
"scrollToBottomBtn must be hidden by default — only shown when user scrolls up (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_to_bottom_button_css_exists(self):
|
||||
"""style.css must have styling for .scroll-to-bottom-btn (#677)."""
|
||||
assert ".scroll-to-bottom-btn" in STYLE_CSS, (
|
||||
"style.css must define .scroll-to-bottom-btn styles (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_to_bottom_button_is_sticky(self):
|
||||
"""Scroll-to-bottom button must use position:sticky so it stays visible (#677)."""
|
||||
btn_css_pos = STYLE_CSS.find(".scroll-to-bottom-btn")
|
||||
assert btn_css_pos != -1
|
||||
btn_css = STYLE_CSS[btn_css_pos:btn_css_pos + 300]
|
||||
assert "sticky" in btn_css, (
|
||||
".scroll-to-bottom-btn must use position:sticky to stay at bottom of viewport (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_listener_hides_button_when_pinned(self):
|
||||
"""Scroll listener must hide the button when user is near the bottom (#677)."""
|
||||
scroll_listener_start = UI_JS.find("el.addEventListener('scroll'")
|
||||
assert scroll_listener_start != -1, "scroll event listener not found"
|
||||
listener_block = UI_JS[scroll_listener_start:scroll_listener_start + 300]
|
||||
assert "scrollToBottomBtn" in listener_block, (
|
||||
"Scroll listener must show/hide scrollToBottomBtn based on _scrollPinned (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_to_bottom_button_calls_scroll_to_bottom(self):
|
||||
"""scrollToBottomBtn onclick must call scrollToBottom() (#677)."""
|
||||
btn_pos = INDEX_HTML.find("scrollToBottomBtn")
|
||||
assert btn_pos != -1
|
||||
btn_context = INDEX_HTML[btn_pos:btn_pos + 200]
|
||||
assert "scrollToBottom()" in btn_context, (
|
||||
"scrollToBottomBtn onclick must call scrollToBottom() (#677)"
|
||||
)
|
||||
124
tests/test_issue739_652_653.py
Normal file
124
tests/test_issue739_652_653.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Tests for streaming error handling fixes:
|
||||
#739 — quota/credit exhaustion detected as distinct error type + persisted to session
|
||||
#652 — context compaction session_id rotation: stream_end uses original session_id
|
||||
#653 — bad tool call hang: same stream_end fix applies
|
||||
|
||||
All static tests (no live server required).
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
import pathlib
|
||||
|
||||
STREAMING = pathlib.Path(__file__).parent.parent / 'api' / 'streaming.py'
|
||||
MESSAGES_JS = pathlib.Path(__file__).parent.parent / 'static' / 'messages.js'
|
||||
|
||||
streaming_src = STREAMING.read_text(encoding='utf-8')
|
||||
messages_js_src = MESSAGES_JS.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
# ── #739: Quota exhaustion detection ─────────────────────────────────────────
|
||||
|
||||
class TestQuotaDetection:
|
||||
"""Quota-exhausted errors must be classified separately from rate limits."""
|
||||
|
||||
def test_quota_patterns_present_in_silent_failure_path(self):
|
||||
"""The silent-failure path checks for credit/quota strings."""
|
||||
block = streaming_src
|
||||
assert 'insufficient credit' in block
|
||||
assert 'credit balance' in block
|
||||
assert 'credits exhausted' in block
|
||||
assert 'quota_exceeded' in block
|
||||
assert 'exceeded your current quota' in block
|
||||
|
||||
def test_quota_type_emitted_as_quota_exhausted(self):
|
||||
"""The apperror type is 'quota_exhausted', not 'error' or 'rate_limit'."""
|
||||
assert "'quota_exhausted'" in streaming_src or '"quota_exhausted"' in streaming_src
|
||||
|
||||
def test_quota_checked_before_rate_limit(self):
|
||||
"""Quota check must appear before the rate-limit check in the exception path.
|
||||
OpenAI billing 429s overlap with rate-limit patterns."""
|
||||
quota_pos = streaming_src.find('_exc_is_quota')
|
||||
rate_pos = streaming_src.find('_exc_is_rate_limit')
|
||||
assert quota_pos != -1, '_exc_is_quota not found in exception path'
|
||||
assert rate_pos != -1, '_exc_is_rate_limit not found in exception path'
|
||||
assert quota_pos < rate_pos, 'Quota check must appear before rate-limit check'
|
||||
|
||||
def test_rate_limit_excludes_quota(self):
|
||||
"""Rate-limit detection must be guarded so quota errors don't also match."""
|
||||
# The pattern: _exc_is_rate_limit = (not _exc_is_quota) and (...)
|
||||
assert '(not _exc_is_quota)' in streaming_src
|
||||
|
||||
def test_js_quota_label_present(self):
|
||||
"""messages.js renders a 'quota_exhausted' apperror with a distinct label."""
|
||||
assert "quota_exhausted" in messages_js_src
|
||||
assert "Out of credits" in messages_js_src
|
||||
|
||||
|
||||
# ── #739: Error persistence across reload ─────────────────────────────────────
|
||||
|
||||
class TestErrorPersistence:
|
||||
"""Errors must be saved to the session so they survive page reload."""
|
||||
|
||||
def test_silent_failure_appends_error_message(self):
|
||||
"""Silent-failure path appends an _error-marked message before returning."""
|
||||
# Must append to s.messages with _error key
|
||||
assert "s.messages.append(" in streaming_src
|
||||
assert "'_error': True" in streaming_src
|
||||
|
||||
def test_silent_failure_calls_save_before_return(self):
|
||||
"""save() must be called after appending the error message."""
|
||||
# Find the silent failure block area and verify save precedes return
|
||||
pattern = re.compile(
|
||||
r"s\.messages\.append\(.*?'_error': True.*?\).*?s\.save\(\).*?return",
|
||||
re.DOTALL
|
||||
)
|
||||
assert pattern.search(streaming_src), \
|
||||
"save() must be called after appending the error message in the silent-failure path"
|
||||
|
||||
def test_exception_path_appends_error_message(self):
|
||||
"""Exception path also persists the error to the session."""
|
||||
# Both paths should have _error persistence
|
||||
count = streaming_src.count("'_error': True")
|
||||
assert count >= 2, f"Expected at least 2 _error persistence sites, found {count}"
|
||||
|
||||
def test_sanitize_skips_error_messages(self):
|
||||
"""_sanitize_messages_for_api must not send _error messages to the LLM."""
|
||||
assert "msg.get('_error')" in streaming_src or 'msg.get("_error")' in streaming_src
|
||||
# The skip must come before the role/tool filtering logic
|
||||
error_skip_pos = streaming_src.find("msg.get('_error')")
|
||||
tool_filter_pos = streaming_src.find("if role == 'tool':")
|
||||
assert error_skip_pos < tool_filter_pos, \
|
||||
"_error skip must appear before the tool-role filter in _sanitize_messages_for_api"
|
||||
|
||||
|
||||
# ── #652/#653: Context compaction stream_end fix ──────────────────────────────
|
||||
|
||||
class TestStreamEndSessionId:
|
||||
"""stream_end must use the original session_id param, not s.session_id."""
|
||||
|
||||
def test_non_bg_title_stream_end_uses_session_id_param(self):
|
||||
"""When no background title is spawned, stream_end should use original session_id."""
|
||||
# The fixed code: put('stream_end', {'session_id': session_id})
|
||||
# Not: put('stream_end', {'session_id': s.session_id})
|
||||
# Verify the pattern appears in the non-background-title branch
|
||||
assert "put('stream_end', {'session_id': session_id})" in streaming_src
|
||||
|
||||
def test_background_title_thread_stream_end_uses_session_id_param(self):
|
||||
"""Background title thread also emits stream_end with original session_id."""
|
||||
# In _run_background_title_update: put_event('stream_end', {'session_id': session_id})
|
||||
# The session_id param is passed from the caller with the original value
|
||||
assert "put_event('stream_end', {'session_id': session_id})" in streaming_src
|
||||
|
||||
def test_s_session_id_not_used_in_stream_end(self):
|
||||
"""s.session_id (which may be rotated after compaction) must not appear in stream_end."""
|
||||
# Find all stream_end emissions and verify none use s.session_id
|
||||
for match in re.finditer(r"put[_a-z]*\('stream_end',[^)]+\)", streaming_src):
|
||||
assert 's.session_id' not in match.group(), \
|
||||
f"stream_end uses s.session_id (may be rotated): {match.group()}"
|
||||
|
||||
def test_title_event_uses_original_session_id(self):
|
||||
"""title event in background title thread uses original session_id, not s.session_id."""
|
||||
# Client guard: if((d.session_id||activeSid)!==activeSid) return;
|
||||
# So title must be emitted with the original id
|
||||
assert "put_event('title', {'session_id': session_id," in streaming_src
|
||||
9
tests/test_issue744.py
Normal file
9
tests/test_issue744.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import pathlib
|
||||
|
||||
|
||||
def test_only_latest_user_message_gets_edit_button():
|
||||
src = pathlib.Path("static/ui.js").read_text(encoding="utf-8")
|
||||
assert "let lastUserRawIdx=-1;" in src
|
||||
assert "const isEditableUser=isUser&&rawIdx===lastUserRawIdx;" in src
|
||||
assert "const editBtn = isEditableUser ?" in src
|
||||
|
||||
839
tests/test_issue765_streaming_persistence.py
Normal file
839
tests/test_issue765_streaming_persistence.py
Normal file
@@ -0,0 +1,839 @@
|
||||
"""
|
||||
Tests for periodic session persistence during streaming (Issue #765).
|
||||
|
||||
Validates:
|
||||
- Session.save(skip_index=True) writes the JSON file but skips the index rebuild
|
||||
- The periodic checkpoint fires when _checkpoint_activity is incremented
|
||||
(as it would be by on_tool() during real agent execution)
|
||||
- Messages stored via pending_user_message survive a simulated server restart
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import api.models as models
|
||||
from api.models import Session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_session_dir(tmp_path, monkeypatch):
|
||||
"""Redirect SESSION_DIR and SESSION_INDEX_FILE to a temp directory."""
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
index_file = session_dir / "_index.json"
|
||||
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
|
||||
|
||||
models.SESSIONS.clear()
|
||||
yield session_dir, index_file
|
||||
models.SESSIONS.clear()
|
||||
|
||||
|
||||
def _make_session(session_id="abc123", messages=None):
|
||||
"""Helper to create a Session with a known ID."""
|
||||
return Session(
|
||||
session_id=session_id,
|
||||
title="Test Session",
|
||||
messages=messages or [{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
|
||||
class TestSaveSkipIndex:
|
||||
"""Tests for the skip_index parameter on Session.save()."""
|
||||
|
||||
def test_save_writes_json_file(self):
|
||||
"""save() always writes the session JSON file, regardless of skip_index."""
|
||||
s = _make_session("s1")
|
||||
s.save()
|
||||
assert s.path.exists()
|
||||
data = json.loads(s.path.read_text())
|
||||
assert data["session_id"] == "s1"
|
||||
assert len(data["messages"]) == 1
|
||||
|
||||
def test_save_with_skip_index_writes_json(self):
|
||||
"""save(skip_index=True) still writes the session JSON file."""
|
||||
s = _make_session("s2")
|
||||
s.save(skip_index=True)
|
||||
assert s.path.exists()
|
||||
data = json.loads(s.path.read_text())
|
||||
assert data["session_id"] == "s2"
|
||||
|
||||
def test_save_with_skip_index_skips_index_rebuild(self):
|
||||
"""save(skip_index=True) does NOT create or update the session index."""
|
||||
s = _make_session("s3")
|
||||
s.save(skip_index=True)
|
||||
index = models.SESSION_INDEX_FILE
|
||||
assert not index.exists(), "Index file should not be created with skip_index=True"
|
||||
|
||||
def test_save_without_skip_index_creates_index(self):
|
||||
"""save() (default) DOES create the session index."""
|
||||
s = _make_session("s4")
|
||||
s.save()
|
||||
index = models.SESSION_INDEX_FILE
|
||||
assert index.exists(), "Index file should be created by default save()"
|
||||
data = json.loads(index.read_text())
|
||||
sids = [e["session_id"] for e in data]
|
||||
assert "s4" in sids
|
||||
|
||||
def test_skip_index_then_full_save_updates_index(self):
|
||||
"""After skip_index saves, a full save() correctly builds the index."""
|
||||
s = _make_session("s5")
|
||||
s.messages.append({"role": "assistant", "content": "hi there"})
|
||||
s.save(skip_index=True)
|
||||
assert not models.SESSION_INDEX_FILE.exists()
|
||||
|
||||
s.messages.append({"role": "user", "content": "thanks"})
|
||||
s.save()
|
||||
assert models.SESSION_INDEX_FILE.exists()
|
||||
data = json.loads(s.path.read_text())
|
||||
assert len(data["messages"]) == 3
|
||||
|
||||
def test_skip_index_save_with_touch_updated_at_false(self):
|
||||
"""save(skip_index=True, touch_updated_at=False) preserves updated_at."""
|
||||
s = _make_session("touch1")
|
||||
original_updated_at = s.updated_at
|
||||
time.sleep(0.05)
|
||||
s.save(skip_index=True, touch_updated_at=False)
|
||||
data = json.loads(s.path.read_text())
|
||||
assert data["updated_at"] == original_updated_at
|
||||
assert not models.SESSION_INDEX_FILE.exists()
|
||||
|
||||
|
||||
class TestPeriodicCheckpoint:
|
||||
"""Tests for the periodic checkpoint mechanism during streaming.
|
||||
|
||||
The checkpoint is keyed off an activity counter (_checkpoint_activity[0]),
|
||||
incremented by on_tool() on each tool.completed event — NOT off s.messages
|
||||
which is never mutated during agent.run_conversation() (the agent copies it).
|
||||
"""
|
||||
|
||||
def test_checkpoint_fires_on_activity_counter_increment(self):
|
||||
"""Checkpoint saves when _checkpoint_activity counter grows."""
|
||||
s = _make_session("ckpt1")
|
||||
s.pending_user_message = "do a long task"
|
||||
s.save() # initial save (like routes.py does before streaming starts)
|
||||
|
||||
stop_event = threading.Event()
|
||||
_checkpoint_activity = [0]
|
||||
save_count = [0]
|
||||
|
||||
def periodic_checkpoint():
|
||||
last = 0
|
||||
while not stop_event.wait(0.1): # fast interval for test
|
||||
try:
|
||||
cur = _checkpoint_activity[0]
|
||||
if cur > last:
|
||||
s.save(skip_index=True)
|
||||
last = cur
|
||||
save_count[0] += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=periodic_checkpoint, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Simulate on_tool() completing twice (as would happen during a real agent run)
|
||||
time.sleep(0.15)
|
||||
_checkpoint_activity[0] += 1 # first tool completes
|
||||
time.sleep(0.25)
|
||||
_checkpoint_activity[0] += 1 # second tool completes
|
||||
time.sleep(0.25)
|
||||
|
||||
stop_event.set()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert save_count[0] >= 2, (
|
||||
"Expected at least 2 checkpoint saves (one per activity increment); "
|
||||
f"got {save_count[0]}"
|
||||
)
|
||||
# Verify the JSON is on disk and readable
|
||||
data = json.loads(s.path.read_text())
|
||||
assert data["pending_user_message"] == "do a long task"
|
||||
|
||||
def test_checkpoint_does_not_fire_without_activity(self):
|
||||
"""Checkpoint skips save when activity counter has not changed."""
|
||||
s = _make_session("ckpt2")
|
||||
s.save()
|
||||
|
||||
stop_event = threading.Event()
|
||||
_checkpoint_activity = [0]
|
||||
save_count = [0]
|
||||
|
||||
def periodic_checkpoint():
|
||||
last = 0
|
||||
while not stop_event.wait(0.05):
|
||||
cur = _checkpoint_activity[0]
|
||||
if cur > last:
|
||||
s.save(skip_index=True)
|
||||
last = cur
|
||||
save_count[0] += 1
|
||||
|
||||
t = threading.Thread(target=periodic_checkpoint, daemon=True)
|
||||
t.start()
|
||||
# No increments — checkpoint should stay quiet
|
||||
time.sleep(0.4)
|
||||
stop_event.set()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert save_count[0] == 0, (
|
||||
f"Expected 0 saves when activity is unchanged; got {save_count[0]}"
|
||||
)
|
||||
|
||||
def test_checkpoint_stops_on_signal(self):
|
||||
"""Checkpoint thread exits cleanly when stop event is set."""
|
||||
s = _make_session("ckpt3")
|
||||
stop_event = threading.Event()
|
||||
iterations = [0]
|
||||
|
||||
def periodic_checkpoint():
|
||||
while not stop_event.wait(0.02):
|
||||
iterations[0] += 1
|
||||
|
||||
t = threading.Thread(target=periodic_checkpoint, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.15)
|
||||
stop_event.set()
|
||||
t.join(timeout=1)
|
||||
assert not t.is_alive(), "Checkpoint thread should have stopped"
|
||||
|
||||
def test_pending_message_survives_simulated_restart(self):
|
||||
"""pending_user_message written before run_conversation survives a restart.
|
||||
|
||||
This is the minimal guarantee for Issue #765: even if the agent produces
|
||||
no tool calls before a crash, the user's message is not silently lost.
|
||||
"""
|
||||
s = _make_session("survive1", messages=[{"role": "user", "content": "first turn"}])
|
||||
s.save() # initial full save
|
||||
|
||||
# Simulate what routes.py does before _run_agent_streaming:
|
||||
s.pending_user_message = "do a long research task"
|
||||
s.pending_started_at = time.time()
|
||||
s.active_stream_id = "stream-abc123"
|
||||
s.save(skip_index=True) # checkpoint-style save
|
||||
|
||||
# Simulate restart: clear in-memory state, reload from disk
|
||||
del s
|
||||
models.SESSIONS.clear()
|
||||
|
||||
reloaded = Session.load("survive1")
|
||||
assert reloaded is not None
|
||||
assert reloaded.pending_user_message == "do a long research task"
|
||||
assert reloaded.active_stream_id == "stream-abc123"
|
||||
# Original messages still intact
|
||||
assert len(reloaded.messages) == 1
|
||||
|
||||
def test_activity_checkpoint_persists_updated_at(self):
|
||||
"""Each checkpoint save updates updated_at, keeping session fresh in sidebar."""
|
||||
s = _make_session("ts1")
|
||||
s.save()
|
||||
ts_before = s.updated_at
|
||||
|
||||
time.sleep(0.05)
|
||||
_checkpoint_activity = [1] # simulate one tool completion
|
||||
|
||||
stop_event = threading.Event()
|
||||
|
||||
def periodic_checkpoint():
|
||||
last = 0
|
||||
while not stop_event.wait(0.05):
|
||||
cur = _checkpoint_activity[0]
|
||||
if cur > last:
|
||||
s.save(skip_index=True)
|
||||
last = cur
|
||||
|
||||
t = threading.Thread(target=periodic_checkpoint, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.2)
|
||||
stop_event.set()
|
||||
t.join(timeout=1)
|
||||
|
||||
data = json.loads(s.path.read_text())
|
||||
assert data["updated_at"] > ts_before, "Checkpoint should update updated_at"
|
||||
|
||||
|
||||
class TestIssue765FollowupHardening:
|
||||
"""Regression tests for the follow-up hardening pass on Issue #765.
|
||||
|
||||
Includes the guard that the outer `finally` must not UnboundLocalError when
|
||||
an exception fires before the checkpoint thread is created.
|
||||
"""
|
||||
|
||||
def test_same_session_concurrent_saves_use_distinct_temp_files(self, monkeypatch):
|
||||
"""Two concurrent saves of the same session must not collide on one tmp path.
|
||||
|
||||
The key regression guard here is that each save call should reach os.replace()
|
||||
with a distinct source tmp path. With the old shared `<sid>.tmp` scheme, both
|
||||
threads would target the same path and the second replace would deterministically
|
||||
fail once the first consume/remove happened.
|
||||
"""
|
||||
s = _make_session("same_sid")
|
||||
s.save(skip_index=True) # seed the file on disk
|
||||
|
||||
original_replace = models.os.replace
|
||||
barrier = threading.Barrier(2)
|
||||
replace_sources = []
|
||||
errors = []
|
||||
|
||||
def _replace_with_barrier(src, dst):
|
||||
replace_sources.append(str(src))
|
||||
barrier.wait(timeout=5)
|
||||
return original_replace(src, dst)
|
||||
|
||||
monkeypatch.setattr(models.os, "replace", _replace_with_barrier)
|
||||
|
||||
def _save_worker():
|
||||
try:
|
||||
s.save(skip_index=True)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
t1 = threading.Thread(target=_save_worker)
|
||||
t2 = threading.Thread(target=_save_worker)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=5)
|
||||
t2.join(timeout=5)
|
||||
|
||||
assert not errors, f"Concurrent same-session saves should not fail: {errors}"
|
||||
assert len(replace_sources) == 2, f"Expected 2 replace calls, got {replace_sources}"
|
||||
assert len(set(replace_sources)) == 2, (
|
||||
"Concurrent same-session saves must use distinct temp files; "
|
||||
f"got {replace_sources}"
|
||||
)
|
||||
data = json.loads(s.path.read_text(encoding="utf-8"))
|
||||
assert data["session_id"] == "same_sid"
|
||||
|
||||
def test_success_path_joins_checkpoint_before_session_mutation(self):
|
||||
"""Static guard: success path must stop/join checkpoint thread before mutating.
|
||||
|
||||
This keeps the post-run_conversation session rewrite serialized relative to the
|
||||
periodic checkpoint worker.
|
||||
"""
|
||||
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
stop_idx = src.find("if _checkpoint_stop is not None:\n _checkpoint_stop.set()")
|
||||
join_idx = src.find("if _ckpt_thread is not None:\n _ckpt_thread.join(timeout=15)")
|
||||
lock_idx = src.find("with _agent_lock:\n s.messages = _restore_reasoning_metadata(")
|
||||
save_idx = src.find("s.messages = _restore_reasoning_metadata(")
|
||||
|
||||
assert stop_idx != -1, "Success path must stop the checkpoint thread"
|
||||
assert join_idx != -1, "Success path must join the checkpoint thread"
|
||||
assert lock_idx != -1, "Success path must serialize mutation with _agent_lock"
|
||||
assert save_idx != -1, "Success path restore/mutation block not found"
|
||||
assert stop_idx < join_idx < lock_idx <= save_idx, (
|
||||
"Checkpoint stop/join must happen before the success-path session mutation block"
|
||||
)
|
||||
|
||||
def test_silent_failure_path_does_not_reacquire_agent_lock(self):
|
||||
"""Silent-failure path must not nest `_agent_lock` inside the success lock.
|
||||
|
||||
Reacquiring the same per-session lock inside the post-run_conversation block
|
||||
deadlocks because `_get_session_agent_lock()` returns a non-reentrant Lock.
|
||||
"""
|
||||
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
outer_lock_idx = src.find("with _agent_lock:\n s.messages = _restore_reasoning_metadata(")
|
||||
silent_failure_idx = src.find("if not _assistant_added and not _token_sent:")
|
||||
inner_lock_idx = src.find("with _agent_lock:", outer_lock_idx + 1)
|
||||
compression_idx = src.find("# ── Handle context compression side effects ──")
|
||||
|
||||
assert outer_lock_idx != -1, "Outer success-path _agent_lock block not found"
|
||||
assert silent_failure_idx != -1, "Silent-failure branch not found"
|
||||
assert compression_idx != -1, "Compression marker not found"
|
||||
assert not (
|
||||
inner_lock_idx != -1 and silent_failure_idx < inner_lock_idx < compression_idx
|
||||
), "Silent-failure path must not reacquire _agent_lock inside the outer lock"
|
||||
|
||||
def test_checkpoint_stop_initialised_before_any_raiseable_code(self):
|
||||
"""Static check: `_checkpoint_stop = None` must appear before any code
|
||||
that could raise inside _run_agent_streaming's outer try."""
|
||||
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
lines = src.splitlines()
|
||||
try_line = next(
|
||||
i for i, ln in enumerate(lines, 1)
|
||||
if ln.rstrip().endswith("try:")
|
||||
and any(
|
||||
lines[j].strip().startswith("_checkpoint_stop = None")
|
||||
for j in range(max(0, i - 4), i - 1)
|
||||
)
|
||||
)
|
||||
# The assignment must precede the `try:` — not sit inside the nested
|
||||
# block where an earlier line could raise before it runs.
|
||||
init_line = next(
|
||||
i for i, ln in enumerate(lines, 1)
|
||||
if "_checkpoint_stop = None" in ln
|
||||
)
|
||||
assert init_line < try_line, (
|
||||
f"_checkpoint_stop = None (line {init_line}) must precede the outer "
|
||||
f"try block (line {try_line}) so the finally can safely check it."
|
||||
)
|
||||
|
||||
def test_finally_path_when_early_exception_does_not_unbound_error(self):
|
||||
"""Mirror the _run_agent_streaming try/finally structure — proves that
|
||||
pre-initialising _checkpoint_stop = None outside any raiseable code
|
||||
keeps the finally safe."""
|
||||
|
||||
def mimic_run_agent_streaming():
|
||||
_checkpoint_stop = None # pre-init (the fix)
|
||||
try:
|
||||
# Anything here could raise — simulate early failure
|
||||
raise ValueError("early failure, e.g. get_session KeyError")
|
||||
_checkpoint_stop = threading.Event() # never reached
|
||||
finally:
|
||||
# The guard the PR added — must not itself raise
|
||||
if _checkpoint_stop is not None:
|
||||
_checkpoint_stop.set()
|
||||
|
||||
with pytest.raises(ValueError, match="early failure"):
|
||||
mimic_run_agent_streaming()
|
||||
|
||||
def test_agent_lock_null_guard_in_except_block(self):
|
||||
"""The except block must not crash with AttributeError when _agent_lock
|
||||
is None (e.g. when get_session succeeds but _get_session_agent_lock
|
||||
hasn't been called yet, or _get_session_agent_lock itself raised).
|
||||
|
||||
The code must use a nullcontext fallback rather than unconditionally
|
||||
entering `with _agent_lock:`."""
|
||||
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# Verify contextlib.nullcontext is used as a fallback
|
||||
assert "contextlib.nullcontext()" in src, (
|
||||
"The except block must guard _agent_lock being None by falling "
|
||||
"back to contextlib.nullcontext() instead of unconditionally "
|
||||
"entering `with _agent_lock:`"
|
||||
)
|
||||
# Verify the except block uses _lock_ctx (the guarded variable)
|
||||
assert "_lock_ctx" in src, (
|
||||
"The except block must assign _agent_lock / nullcontext to a "
|
||||
"variable and use it, not enter `with _agent_lock:` directly"
|
||||
)
|
||||
|
||||
def test_periodic_checkpoint_uses_agent_lock(self):
|
||||
"""The periodic checkpoint thread must hold _agent_lock while saving
|
||||
to prevent concurrent mutation races with other endpoints."""
|
||||
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# Find the _periodic_checkpoint function
|
||||
ckpt_idx = src.find("def _periodic_checkpoint():")
|
||||
assert ckpt_idx != -1, "_periodic_checkpoint function not found"
|
||||
ckpt_block = src[ckpt_idx:ckpt_idx + 600]
|
||||
assert "with _agent_lock:" in ckpt_block, (
|
||||
"_periodic_checkpoint must hold _agent_lock while calling s.save() "
|
||||
"to prevent race conditions with other session-mutating endpoints"
|
||||
)
|
||||
|
||||
def test_background_title_update_rebinds_to_canonical_session_instance(self):
|
||||
"""Guard against stale Session object mutation after LLM round-trip.
|
||||
|
||||
_run_background_title_update must re-bind `s` to SESSIONS.get(session_id,
|
||||
s) under LOCK before deciding whether a manual rename should block the
|
||||
generated title write.
|
||||
"""
|
||||
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
fn_idx = src.find("def _run_background_title_update(")
|
||||
assert fn_idx != -1, "_run_background_title_update not found"
|
||||
fn_block = src[fn_idx:fn_idx + 3200]
|
||||
assert "with LOCK:" in fn_block, (
|
||||
"_run_background_title_update must acquire LOCK before rebinding "
|
||||
"to canonical cached session instance"
|
||||
)
|
||||
assert "s = SESSIONS.get(session_id, s)" in fn_block, (
|
||||
"_run_background_title_update must rebind to canonical cached "
|
||||
"session instance under LOCK"
|
||||
)
|
||||
|
||||
def test_cancel_stream_uses_agent_lock(self):
|
||||
"""cancel_stream must hold _agent_lock during session cleanup to
|
||||
prevent races with checkpoint saves and other writers."""
|
||||
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
cancel_idx = src.find("def cancel_stream(")
|
||||
assert cancel_idx != -1, "cancel_stream function not found"
|
||||
cancel_block = src[cancel_idx:]
|
||||
# Find the session cleanup section
|
||||
cleanup_idx = cancel_block.find("Session cleanup outside STREAMS_LOCK")
|
||||
assert cleanup_idx != -1, "Session cleanup comment not found in cancel_stream"
|
||||
cleanup_section = cancel_block[cleanup_idx:cleanup_idx + 800]
|
||||
assert "_get_session_agent_lock" in cleanup_section, (
|
||||
"cancel_stream must acquire _get_session_agent_lock during "
|
||||
"session cleanup to serialise with the checkpoint thread and "
|
||||
"other session-mutating endpoints"
|
||||
)
|
||||
|
||||
def test_session_ops_retry_undo_hold_agent_lock(self):
|
||||
"""retry_last and undo_last must hold _get_session_agent_lock for the
|
||||
entire read-modify-save cycle."""
|
||||
src = (Path(__file__).parent.parent / "api" / "session_ops.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "_get_session_agent_lock" in src, (
|
||||
"session_ops must import _get_session_agent_lock"
|
||||
)
|
||||
# Both functions must use with _get_session_agent_lock(session_id):
|
||||
for func_name in ("retry_last", "undo_last"):
|
||||
func_idx = src.find(f"def {func_name}(")
|
||||
assert func_idx != -1, f"{func_name} not found in session_ops.py"
|
||||
func_block = src[func_idx:func_idx + 1200]
|
||||
assert "with _get_session_agent_lock" in func_block, (
|
||||
f"{func_name} must wrap its read-modify-save cycle in "
|
||||
f"with _get_session_agent_lock(session_id)"
|
||||
)
|
||||
|
||||
def test_periodic_checkpoint_mutation_race_with_undo_last(self, tmp_path, monkeypatch):
|
||||
"""Run _periodic_checkpoint against a session whose messages list is
|
||||
concurrently truncated by undo_last; the on-disk JSON must remain
|
||||
parseable and internally consistent.
|
||||
|
||||
The simulated checkpoint mirrors production by acquiring
|
||||
_get_session_agent_lock around s.save(), and we assert that every
|
||||
on-disk snapshot's messages list is one of the allowed snapshots
|
||||
(never an interleaving of fields from two different saves).
|
||||
"""
|
||||
session_dir = tmp_path / "sessions_undo_race"
|
||||
session_dir.mkdir()
|
||||
index_file = session_dir / "_index.json"
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
|
||||
models.SESSIONS.clear()
|
||||
try:
|
||||
s = Session(
|
||||
session_id="race_test",
|
||||
title="Race Test",
|
||||
messages=[
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "reply 1"},
|
||||
{"role": "user", "content": "second"},
|
||||
{"role": "assistant", "content": "reply 2"},
|
||||
{"role": "user", "content": "third"},
|
||||
{"role": "assistant", "content": "reply 3"},
|
||||
],
|
||||
)
|
||||
s.save()
|
||||
models.SESSIONS[s.session_id] = s
|
||||
|
||||
_checkpoint_stop = threading.Event()
|
||||
_checkpoint_activity = [0]
|
||||
errors = []
|
||||
# Collect every on-disk messages snapshot observed by the
|
||||
# checkpoint thread so we can assert atomicity after the run.
|
||||
checkpoint_snapshots = []
|
||||
_lock = threading.Lock()
|
||||
|
||||
from api.config import _get_session_agent_lock
|
||||
_agent_lock = _get_session_agent_lock("race_test")
|
||||
|
||||
def _periodic_checkpoint():
|
||||
last = 0
|
||||
while not _checkpoint_stop.wait(0.01):
|
||||
try:
|
||||
cur = _checkpoint_activity[0]
|
||||
if cur > last:
|
||||
with _agent_lock:
|
||||
s.save(skip_index=True)
|
||||
# Read back the on-disk JSON to verify atomicity
|
||||
try:
|
||||
snap = json.loads(s.path.read_text())
|
||||
with _lock:
|
||||
checkpoint_snapshots.append(snap.get("messages"))
|
||||
except Exception:
|
||||
pass
|
||||
last = cur
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
t = threading.Thread(target=_periodic_checkpoint, daemon=True)
|
||||
t.start()
|
||||
|
||||
from api.session_ops import undo_last
|
||||
# Collect the allowed message snapshots (each state the session
|
||||
# is in at a point where a checkpoint might observe it).
|
||||
allowed_message_snapshots = []
|
||||
# The initial state (before any undo) is a valid checkpoint target.
|
||||
allowed_message_snapshots.append(
|
||||
[dict(m) if isinstance(m, dict) else m for m in s.messages]
|
||||
)
|
||||
for _ in range(5):
|
||||
_checkpoint_activity[0] += 1
|
||||
time.sleep(0.02)
|
||||
try:
|
||||
undo_last("race_test")
|
||||
except ValueError:
|
||||
pass
|
||||
# Record the post-undo state (before appending new messages)
|
||||
# as an allowed snapshot — the checkpoint may observe this.
|
||||
allowed_message_snapshots.append(
|
||||
[dict(m) if isinstance(m, dict) else m for m in s.messages]
|
||||
)
|
||||
# Wrap mutation + save in _agent_lock to mirror production
|
||||
# paths and prevent the checkpoint from observing an
|
||||
# intermediate +1-message snapshot.
|
||||
with _agent_lock:
|
||||
s.messages.append({"role": "user", "content": f"msg-{_}"})
|
||||
s.messages.append({"role": "assistant", "content": f"ans-{_}"})
|
||||
# Record the in-memory messages list *before* save so we
|
||||
# can verify that every checkpoint snapshot matches one
|
||||
# of these.
|
||||
allowed_message_snapshots.append(
|
||||
[dict(m) if isinstance(m, dict) else m for m in s.messages]
|
||||
)
|
||||
s.save()
|
||||
|
||||
_checkpoint_stop.set()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert not errors, f"Checkpoint thread encountered errors: {errors}"
|
||||
# Verify the on-disk JSON is parseable
|
||||
data = json.loads(s.path.read_text())
|
||||
assert data["session_id"] == "race_test"
|
||||
# Messages must be a list (not corrupted by concurrent mutation)
|
||||
assert isinstance(data["messages"], list)
|
||||
# Contract assertion: every checkpoint snapshot's messages must
|
||||
# equal one of the allowed in-memory snapshots, never an
|
||||
# interleaving of fields from two different saves. This assertion
|
||||
# has teeth: if the _agent_lock were removed from the checkpoint
|
||||
# or the undo path, concurrent mutations would produce snapshots
|
||||
# that match no allowed state (e.g. a list with some messages
|
||||
# from before undo and some from after).
|
||||
for snap_msgs in checkpoint_snapshots:
|
||||
if snap_msgs is None:
|
||||
continue
|
||||
# Normalize for comparison (strip display-only metadata)
|
||||
normalized = [
|
||||
{k: v for k, v in m.items() if k in ("role", "content")}
|
||||
if isinstance(m, dict) else m
|
||||
for m in snap_msgs
|
||||
]
|
||||
matched = False
|
||||
for allowed in allowed_message_snapshots:
|
||||
norm_allowed = [
|
||||
{k: v for k, v in m.items() if k in ("role", "content")}
|
||||
if isinstance(m, dict) else m
|
||||
for m in allowed
|
||||
]
|
||||
if normalized == norm_allowed:
|
||||
matched = True
|
||||
break
|
||||
assert matched, (
|
||||
f"Checkpoint snapshot {normalized!r} does not match any "
|
||||
f"allowed state — this indicates a serialization failure "
|
||||
f"(the _agent_lock is not preventing interleaved writes)."
|
||||
)
|
||||
finally:
|
||||
models.SESSIONS.clear()
|
||||
|
||||
def test_cancel_stream_concurrent_checkpoint_produces_valid_json(self, tmp_path, monkeypatch):
|
||||
"""Run cancel_stream while a _periodic_checkpoint thread is concurrently
|
||||
saving the same session; the resulting on-disk JSON must be parseable
|
||||
and active_stream_id must be None.
|
||||
|
||||
The simulated checkpoint mirrors production by acquiring
|
||||
_get_session_agent_lock around s.save(), and we assert that every
|
||||
on-disk snapshot is internally consistent (never an interleaving
|
||||
of fields from two different saves).
|
||||
"""
|
||||
session_dir = tmp_path / "sessions_cancel_race"
|
||||
session_dir.mkdir()
|
||||
index_file = session_dir / "_index.json"
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
|
||||
models.SESSIONS.clear()
|
||||
try:
|
||||
s = Session(
|
||||
session_id="cancel_race",
|
||||
title="Cancel Race Test",
|
||||
messages=[
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "world"},
|
||||
],
|
||||
active_stream_id="stream-abc",
|
||||
)
|
||||
s.save()
|
||||
models.SESSIONS[s.session_id] = s
|
||||
|
||||
_checkpoint_stop = threading.Event()
|
||||
_checkpoint_activity = [0]
|
||||
errors = []
|
||||
# Collect every on-disk snapshot observed by the checkpoint thread.
|
||||
checkpoint_snapshots = []
|
||||
_snap_lock = threading.Lock()
|
||||
|
||||
from api.config import _get_session_agent_lock
|
||||
_agent_lock = _get_session_agent_lock("cancel_race")
|
||||
|
||||
def _periodic_checkpoint():
|
||||
last = 0
|
||||
while not _checkpoint_stop.wait(0.01):
|
||||
try:
|
||||
cur = _checkpoint_activity[0]
|
||||
if cur > last:
|
||||
with _agent_lock:
|
||||
s.save(skip_index=True)
|
||||
# Read back the on-disk JSON to verify atomicity
|
||||
try:
|
||||
snap = json.loads(s.path.read_text())
|
||||
with _snap_lock:
|
||||
checkpoint_snapshots.append(snap)
|
||||
except Exception:
|
||||
pass
|
||||
last = cur
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
t = threading.Thread(target=_periodic_checkpoint, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Simulate cancel_stream session cleanup directly
|
||||
for i in range(10):
|
||||
_checkpoint_activity[0] += 1
|
||||
time.sleep(0.01)
|
||||
with _get_session_agent_lock("cancel_race"):
|
||||
s.active_stream_id = None
|
||||
s.pending_user_message = None
|
||||
s.pending_attachments = []
|
||||
s.pending_started_at = None
|
||||
s.save()
|
||||
|
||||
_checkpoint_stop.set()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert not errors, f"Checkpoint thread encountered errors: {errors}"
|
||||
data = json.loads(s.path.read_text())
|
||||
assert data["session_id"] == "cancel_race"
|
||||
assert data["active_stream_id"] is None, (
|
||||
"active_stream_id must be None after cancel cleanup"
|
||||
)
|
||||
assert isinstance(data["messages"], list)
|
||||
# Contract assertion: every checkpoint snapshot must be
|
||||
# internally consistent (no interleaving of fields from two
|
||||
# different saves). Because both the cancel cleanup and the
|
||||
# checkpoint hold the same _agent_lock, they are serialized —
|
||||
# but ordering is nondeterministic, so a snapshot taken
|
||||
# *before* cancel will see active_stream_id="stream-abc" and
|
||||
# one taken *after* will see None. The guarantee is that
|
||||
# each snapshot is self-consistent, never a partial mix.
|
||||
#
|
||||
# This assertion has teeth: if the _agent_lock were removed
|
||||
# from either the checkpoint or the cancel path, a snapshot
|
||||
# could see active_stream_id=None while pending_user_message
|
||||
# still holds the pre-cancel value — a partial state that
|
||||
# violates the atomicity contract.
|
||||
for snap in checkpoint_snapshots:
|
||||
assert isinstance(snap.get("messages"), list), (
|
||||
"Checkpoint snapshot messages must be a list"
|
||||
)
|
||||
assert snap.get("active_stream_id") in ("stream-abc", None), (
|
||||
"Checkpoint snapshot active_stream_id must be either "
|
||||
"the initial value or None (serialized, not interleaved), "
|
||||
f"got {snap.get('active_stream_id')!r}"
|
||||
)
|
||||
# When active_stream_id is None, the cancel cleanup must
|
||||
# have run — so all four cancel fields must be cleared
|
||||
# atomically. A partial state (e.g. active_stream_id=None
|
||||
# but pending_user_message still set) would indicate a
|
||||
# serialization failure.
|
||||
if snap.get("active_stream_id") is None:
|
||||
assert snap.get("pending_user_message") is None, (
|
||||
"Snapshot with active_stream_id=None must also have "
|
||||
"pending_user_message=None (atomic cancel cleanup "
|
||||
"under _agent_lock)"
|
||||
)
|
||||
assert snap.get("pending_attachments") == [] or snap.get("pending_attachments") is None, (
|
||||
"Snapshot with active_stream_id=None must also have "
|
||||
"empty pending_attachments (atomic cancel cleanup "
|
||||
"under _agent_lock)"
|
||||
)
|
||||
assert snap.get("pending_started_at") is None, (
|
||||
"Snapshot with active_stream_id=None must also have "
|
||||
"pending_started_at=None (atomic cancel cleanup "
|
||||
"under _agent_lock)"
|
||||
)
|
||||
finally:
|
||||
models.SESSIONS.clear()
|
||||
|
||||
def test_lock_identity_preserved_after_session_id_rotation(self):
|
||||
"""When compression rotates session_id, the per-session lock must be
|
||||
aliased so that _get_session_agent_lock(new_sid) returns the *same*
|
||||
Lock object as _get_session_agent_lock(old_sid).
|
||||
|
||||
This is a static guard: it directly simulates the migration that
|
||||
streaming.py performs inside the compression rotation block.
|
||||
"""
|
||||
from api.config import (
|
||||
_get_session_agent_lock,
|
||||
SESSION_AGENT_LOCKS,
|
||||
SESSION_AGENT_LOCKS_LOCK,
|
||||
)
|
||||
old_sid = "pre-rotation-id"
|
||||
new_sid = "post-rotation-id"
|
||||
|
||||
# Acquire the lock under the old ID
|
||||
old_lock = _get_session_agent_lock(old_sid)
|
||||
|
||||
# Simulate the migration that streaming.py does during compression:
|
||||
# alias new_sid → held _agent_lock reference, then pop old_sid.
|
||||
_agent_lock = old_lock
|
||||
with SESSION_AGENT_LOCKS_LOCK:
|
||||
SESSION_AGENT_LOCKS[new_sid] = _agent_lock
|
||||
SESSION_AGENT_LOCKS.pop(old_sid, None)
|
||||
|
||||
# Now looking up the new ID must return the exact same Lock object
|
||||
new_lock = _get_session_agent_lock(new_sid)
|
||||
assert new_lock is old_lock, (
|
||||
f"After rotation, _get_session_agent_lock({new_sid!r}) must "
|
||||
f"return the same Lock object as _get_session_agent_lock({old_sid!r}); "
|
||||
f"got {new_lock!r} vs {old_lock!r}"
|
||||
)
|
||||
|
||||
# The old ID entry must no longer exist (it was popped)
|
||||
with SESSION_AGENT_LOCKS_LOCK:
|
||||
assert old_sid not in SESSION_AGENT_LOCKS, (
|
||||
f"Old session ID {old_sid!r} must be removed from "
|
||||
f"SESSION_AGENT_LOCKS after rotation"
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
with SESSION_AGENT_LOCKS_LOCK:
|
||||
SESSION_AGENT_LOCKS.pop(new_sid, None)
|
||||
|
||||
def test_lock_rotation_migration_survives_old_id_already_pruned(self):
|
||||
"""Compression lock migration must not require old_sid to exist in dict.
|
||||
|
||||
A concurrent /api/session/delete can prune old_sid before rotation code
|
||||
runs. The migration must still succeed by assigning the held _agent_lock
|
||||
reference directly.
|
||||
"""
|
||||
from api.config import (
|
||||
_get_session_agent_lock,
|
||||
SESSION_AGENT_LOCKS,
|
||||
SESSION_AGENT_LOCKS_LOCK,
|
||||
)
|
||||
old_sid = "pre-rotation-pruned"
|
||||
new_sid = "post-rotation-pruned"
|
||||
|
||||
_agent_lock = _get_session_agent_lock(old_sid)
|
||||
with SESSION_AGENT_LOCKS_LOCK:
|
||||
SESSION_AGENT_LOCKS.pop(old_sid, None) # simulate concurrent prune
|
||||
|
||||
# Must not raise KeyError even though old_sid is absent.
|
||||
with SESSION_AGENT_LOCKS_LOCK:
|
||||
SESSION_AGENT_LOCKS[new_sid] = _agent_lock
|
||||
SESSION_AGENT_LOCKS.pop(old_sid, None)
|
||||
|
||||
new_lock = _get_session_agent_lock(new_sid)
|
||||
assert new_lock is _agent_lock
|
||||
|
||||
with SESSION_AGENT_LOCKS_LOCK:
|
||||
SESSION_AGENT_LOCKS.pop(new_sid, None)
|
||||
131
tests/test_issue781.py
Normal file
131
tests/test_issue781.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Tests for issue #781 — duplicate X close button in workspace preview header
|
||||
on window resize below 900px breakpoint.
|
||||
|
||||
Verifies that:
|
||||
- .close-preview is hidden (display:none) inside the @media (max-width:900px) block
|
||||
- .mobile-close-btn is shown (display:flex) inside the same @media block
|
||||
Both rules must appear inside the same @media(max-width:900px) block so that
|
||||
at mobile widths only the mobile-close-btn is visible.
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
|
||||
CSS_PATH = os.path.join(os.path.dirname(__file__), "..", "static", "style.css")
|
||||
|
||||
|
||||
def _load_css():
|
||||
with open(CSS_PATH, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _extract_media_block(css, media_query_pattern):
|
||||
"""Extract the content of a @media block by tracking brace depth.
|
||||
|
||||
Returns the inner text (between the outermost braces) of the first
|
||||
@media block matching media_query_pattern (a regex applied to the @media
|
||||
line itself).
|
||||
"""
|
||||
# Find the start of the @media declaration
|
||||
m = re.search(media_query_pattern, css)
|
||||
assert m, f"Media query matching {media_query_pattern!r} not found in style.css"
|
||||
|
||||
# Walk forward from the opening brace to find its matching close brace
|
||||
start = css.index("{", m.start())
|
||||
depth = 0
|
||||
for i in range(start, len(css)):
|
||||
if css[i] == "{":
|
||||
depth += 1
|
||||
elif css[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return css[start + 1 : i] # content between { and }
|
||||
raise AssertionError("Unmatched brace in CSS after @media block")
|
||||
|
||||
|
||||
def _strip_media_blocks(css):
|
||||
"""Remove all @media {...} blocks from CSS, returning base rules only."""
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(css):
|
||||
# Look for @media keyword
|
||||
m = re.search(r"@media\b", css[i:])
|
||||
if not m:
|
||||
result.append(css[i:])
|
||||
break
|
||||
# Append everything before this @media
|
||||
result.append(css[i : i + m.start()])
|
||||
# Find the opening brace of this @media block
|
||||
brace_start = css.index("{", i + m.start())
|
||||
depth = 0
|
||||
j = brace_start
|
||||
while j < len(css):
|
||||
if css[j] == "{":
|
||||
depth += 1
|
||||
elif css[j] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
i = j + 1
|
||||
break
|
||||
j += 1
|
||||
else:
|
||||
break
|
||||
return "".join(result)
|
||||
|
||||
|
||||
_MEDIA_900_PATTERN = r"@media\s*\(\s*max-width\s*:\s*900px\s*\)"
|
||||
|
||||
|
||||
def test_mobile_close_btn_displayed_in_900px_block():
|
||||
"""mobile-close-btn must be display:flex inside the 900px media query."""
|
||||
css = _load_css()
|
||||
block = _extract_media_block(css, _MEDIA_900_PATTERN)
|
||||
assert ".mobile-close-btn" in block, (
|
||||
".mobile-close-btn rule is missing from @media(max-width:900px) block"
|
||||
)
|
||||
rule_match = re.search(r"\.mobile-close-btn\s*\{([^}]*)\}", block)
|
||||
assert rule_match, ".mobile-close-btn rule body not found in 900px block"
|
||||
assert "display:flex" in rule_match.group(1).replace(" ", ""), (
|
||||
".mobile-close-btn should have display:flex in the 900px media query"
|
||||
)
|
||||
|
||||
|
||||
def test_close_preview_hidden_in_900px_block():
|
||||
""".close-preview must be display:none inside the 900px media query (fix for #781)."""
|
||||
css = _load_css()
|
||||
block = _extract_media_block(css, _MEDIA_900_PATTERN)
|
||||
assert ".close-preview" in block, (
|
||||
".close-preview rule is missing from @media(max-width:900px) block — "
|
||||
"the duplicate-button fix (#781) may have been reverted"
|
||||
)
|
||||
rule_match = re.search(r"\.close-preview\s*\{([^}]*)\}", block)
|
||||
assert rule_match, ".close-preview rule body not found in 900px block"
|
||||
assert "display:none" in rule_match.group(1).replace(" ", ""), (
|
||||
".close-preview should have display:none in the 900px media query to hide "
|
||||
"the duplicate desktop X button at mobile widths"
|
||||
)
|
||||
|
||||
|
||||
def test_both_rules_in_same_media_block():
|
||||
"""Both .close-preview and .mobile-close-btn must appear in the same 900px block."""
|
||||
css = _load_css()
|
||||
block = _extract_media_block(css, _MEDIA_900_PATTERN)
|
||||
assert ".mobile-close-btn" in block, (
|
||||
".mobile-close-btn missing from @media(max-width:900px) block"
|
||||
)
|
||||
assert ".close-preview" in block, (
|
||||
".close-preview missing from @media(max-width:900px) block"
|
||||
)
|
||||
|
||||
|
||||
def test_close_preview_visible_outside_media_query():
|
||||
"""Outside the media query, .close-preview must NOT be display:none
|
||||
(it should remain visible on desktop)."""
|
||||
css = _load_css()
|
||||
base_css = _strip_media_blocks(css)
|
||||
close_rules = re.findall(r"\.close-preview\s*\{([^}]*)\}", base_css)
|
||||
for rule_body in close_rules:
|
||||
assert "display:none" not in rule_body.replace(" ", ""), (
|
||||
".close-preview must not be hidden in base (desktop) CSS"
|
||||
)
|
||||
194
tests/test_issue789.py
Normal file
194
tests/test_issue789.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Regression tests for GitHub issue #789.
|
||||
|
||||
Bug: every brand-new session immediately disappeared from the sidebar because
|
||||
all_sessions() filtered out sessions where title == 'Untitled' AND
|
||||
message_count == 0. Since every new session starts with those values, it was
|
||||
filtered out of /api/sessions on the next refresh.
|
||||
|
||||
Fix: exempt sessions younger than 60 seconds from that filter. Sessions older
|
||||
than 60 seconds that are still Untitled with 0 messages are still suppressed
|
||||
(ghost sessions from test runs / accidental reloads).
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import api.models as models
|
||||
from api.models import Session, all_sessions
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate(tmp_path, monkeypatch):
|
||||
"""Redirect SESSION_DIR and SESSION_INDEX_FILE to a temp dir."""
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
index_file = session_dir / "_index.json"
|
||||
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
|
||||
|
||||
models.SESSIONS.clear()
|
||||
yield
|
||||
models.SESSIONS.clear()
|
||||
|
||||
|
||||
def _make_untitled_session(age_seconds, messages=None, session_id=None):
|
||||
"""Create a Session with title='Untitled', updated_at set to age_seconds ago."""
|
||||
now = time.time()
|
||||
s = Session(
|
||||
session_id=session_id or None,
|
||||
title="Untitled",
|
||||
messages=messages or [],
|
||||
updated_at=now - age_seconds,
|
||||
created_at=now - age_seconds,
|
||||
)
|
||||
# Persist to disk so the full-scan fallback can also find it
|
||||
s.path.write_text(
|
||||
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def _make_titled_session(age_seconds, session_id=None):
|
||||
"""Create a Session with a real title and one message."""
|
||||
now = time.time()
|
||||
s = Session(
|
||||
session_id=session_id or None,
|
||||
title="My conversation",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
updated_at=now - age_seconds,
|
||||
created_at=now - age_seconds,
|
||||
)
|
||||
s.path.write_text(
|
||||
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
# ── Test 1: brand-new Untitled 0-message session IS included ─────────────────
|
||||
|
||||
def test_new_untitled_session_is_visible_in_sidebar():
|
||||
"""A session created just now (0 seconds old) must appear in all_sessions()."""
|
||||
new_session = _make_untitled_session(age_seconds=0)
|
||||
|
||||
result = all_sessions()
|
||||
ids = {s["session_id"] for s in result}
|
||||
|
||||
assert new_session.session_id in ids, (
|
||||
"Brand-new Untitled 0-message session must be visible in the sidebar "
|
||||
"(fix for issue #789)"
|
||||
)
|
||||
|
||||
|
||||
def test_recent_untitled_session_under_60s_is_visible():
|
||||
"""A session 30 seconds old should still be visible."""
|
||||
recent_session = _make_untitled_session(age_seconds=30)
|
||||
|
||||
result = all_sessions()
|
||||
ids = {s["session_id"] for s in result}
|
||||
|
||||
assert recent_session.session_id in ids, (
|
||||
"Untitled 0-message session younger than 60 s must be visible (#789)"
|
||||
)
|
||||
|
||||
|
||||
# ── Test 2: old Untitled 0-message session IS still filtered ─────────────────
|
||||
|
||||
def test_old_untitled_session_over_60s_is_filtered():
|
||||
"""A ghost session (Untitled, 0 messages, >60 s old) must be hidden."""
|
||||
old_session = _make_untitled_session(age_seconds=120)
|
||||
|
||||
result = all_sessions()
|
||||
ids = {s["session_id"] for s in result}
|
||||
|
||||
assert old_session.session_id not in ids, (
|
||||
"Ghost Untitled 0-message session older than 60 s must be filtered out"
|
||||
)
|
||||
|
||||
|
||||
def test_session_exactly_at_boundary_is_filtered():
|
||||
"""A session just over 60 seconds old should be filtered."""
|
||||
boundary_session = _make_untitled_session(age_seconds=61)
|
||||
|
||||
result = all_sessions()
|
||||
ids = {s["session_id"] for s in result}
|
||||
|
||||
assert boundary_session.session_id not in ids, (
|
||||
"Untitled 0-message session older than 60 s must be filtered out"
|
||||
)
|
||||
|
||||
|
||||
# ── Test 3: session with messages is always visible regardless of age ─────────
|
||||
|
||||
def test_session_with_messages_always_visible_new():
|
||||
"""A session with messages (even Untitled) is always visible when new."""
|
||||
s = Session(
|
||||
title="Untitled",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
s.path.write_text(
|
||||
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
result = all_sessions()
|
||||
ids = {r["session_id"] for r in result}
|
||||
assert s.session_id in ids, "Session with messages must always appear in sidebar"
|
||||
|
||||
|
||||
def test_session_with_messages_always_visible_old():
|
||||
"""An old session with messages is always visible."""
|
||||
now = time.time()
|
||||
s = Session(
|
||||
title="Untitled",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
updated_at=now - 3600,
|
||||
created_at=now - 3600,
|
||||
)
|
||||
s.path.write_text(
|
||||
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
result = all_sessions()
|
||||
ids = {r["session_id"] for r in result}
|
||||
assert s.session_id in ids, (
|
||||
"Old session with messages must always appear in sidebar"
|
||||
)
|
||||
|
||||
|
||||
def test_titled_session_with_no_messages_old_is_visible():
|
||||
"""A titled session with 0 messages (old) should not be filtered — filter
|
||||
only targets Untitled sessions."""
|
||||
now = time.time()
|
||||
s = Session(
|
||||
title="Project Alpha",
|
||||
messages=[],
|
||||
updated_at=now - 3600,
|
||||
created_at=now - 3600,
|
||||
)
|
||||
s.path.write_text(
|
||||
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
result = all_sessions()
|
||||
ids = {r["session_id"] for r in result}
|
||||
assert s.session_id in ids, (
|
||||
"A titled session must always appear regardless of message count"
|
||||
)
|
||||
|
||||
|
||||
# ── Test 4: mixed bag — only old Untitled empty sessions are filtered ─────────
|
||||
|
||||
def test_mixed_sessions_correct_visibility():
|
||||
"""With a mix of sessions, only old+Untitled+empty ones are suppressed."""
|
||||
new_ghost = _make_untitled_session(age_seconds=5, session_id="new_ghost")
|
||||
old_ghost = _make_untitled_session(age_seconds=200, session_id="old_ghost")
|
||||
real_session = _make_titled_session(age_seconds=500, session_id="real_session")
|
||||
|
||||
result = all_sessions()
|
||||
ids = {s["session_id"] for s in result}
|
||||
|
||||
assert "new_ghost" in ids, "New Untitled session (5s old) must be visible"
|
||||
assert "old_ghost" not in ids, "Old Untitled session (200s old) must be hidden"
|
||||
assert "real_session" in ids, "Titled session with messages must be visible"
|
||||
186
tests/test_issue798.py
Normal file
186
tests/test_issue798.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Issue #798 — Profile isolation: switching profile in one browser client must not
|
||||
affect sessions created by other concurrent clients.
|
||||
|
||||
Root cause: _active_profile was a process-level global in api/profiles.py.
|
||||
Fix: new_session() now accepts an explicit `profile` param passed from the client
|
||||
request body (S.activeProfile), which bypasses the shared global entirely.
|
||||
get_hermes_home_for_profile() resolves a HERMES_HOME path from a name without
|
||||
touching os.environ or module-level state.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── R19: get_hermes_home_for_profile ─────────────────────────────────────────
|
||||
|
||||
def test_get_hermes_home_for_profile_returns_default_for_none():
|
||||
"""R19a: None / empty string / 'default' all return the base home."""
|
||||
import api.profiles as p
|
||||
base = p._DEFAULT_HERMES_HOME
|
||||
assert p.get_hermes_home_for_profile(None) == base
|
||||
assert p.get_hermes_home_for_profile('') == base
|
||||
assert p.get_hermes_home_for_profile('default') == base
|
||||
|
||||
|
||||
def test_get_hermes_home_for_profile_returns_profile_subdir(tmp_path, monkeypatch):
|
||||
"""R19b: Named profile that exists returns its subdirectory."""
|
||||
import api.profiles as p
|
||||
|
||||
profile_dir = tmp_path / 'profiles' / 'alice'
|
||||
profile_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
|
||||
result = p.get_hermes_home_for_profile('alice')
|
||||
assert result == profile_dir
|
||||
|
||||
|
||||
def test_get_hermes_home_for_profile_falls_back_for_missing_profile(tmp_path, monkeypatch):
|
||||
"""R19c: Named profile that does not exist falls back to base home."""
|
||||
import api.profiles as p
|
||||
|
||||
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
|
||||
result = p.get_hermes_home_for_profile('ghost')
|
||||
assert result == tmp_path
|
||||
|
||||
|
||||
def test_get_hermes_home_for_profile_does_not_mutate_globals():
|
||||
"""R19d: get_hermes_home_for_profile() must never change _active_profile or os.environ."""
|
||||
import api.profiles as p
|
||||
|
||||
before_active = p._active_profile
|
||||
before_hermes_home = os.environ.get('HERMES_HOME')
|
||||
|
||||
p.get_hermes_home_for_profile('some-other-profile')
|
||||
|
||||
assert p._active_profile == before_active, (
|
||||
"get_hermes_home_for_profile() must not mutate _active_profile"
|
||||
)
|
||||
assert os.environ.get('HERMES_HOME') == before_hermes_home, (
|
||||
"get_hermes_home_for_profile() must not mutate os.environ['HERMES_HOME']"
|
||||
)
|
||||
|
||||
|
||||
# ── R19e-h: new_session() profile isolation ───────────────────────────────────
|
||||
# These tests call new_session() directly in-process. Session.save() would write
|
||||
# to SESSION_DIR which is set from HERMES_WEBUI_STATE_DIR at import time and may
|
||||
# point to a test-scoped tmp dir that has already been torn down. We patch save()
|
||||
# to a no-op — the tests only care about s.profile, not persistence.
|
||||
|
||||
def test_new_session_uses_explicit_profile_not_global():
|
||||
"""R19e: new_session(profile='alice') stamps session.profile='alice' even when
|
||||
the process-level _active_profile is 'default'.
|
||||
Core fix for #798: client B's session is tagged to B's profile, not the global.
|
||||
"""
|
||||
import api.profiles as p
|
||||
import api.models as m
|
||||
|
||||
original = p._active_profile
|
||||
try:
|
||||
p._active_profile = 'default'
|
||||
with patch.object(m.Session, 'save', return_value=None):
|
||||
s = m.new_session(profile='alice')
|
||||
assert s.profile == 'alice', (
|
||||
f"Expected s.profile='alice', got {s.profile!r}. "
|
||||
"new_session() should use the explicit profile param, not the global."
|
||||
)
|
||||
finally:
|
||||
p._active_profile = original
|
||||
|
||||
|
||||
def test_new_session_falls_back_to_global_when_profile_not_supplied():
|
||||
"""R19f: new_session() without explicit profile still reads _active_profile (backward compat)."""
|
||||
import api.profiles as p
|
||||
import api.models as m
|
||||
|
||||
original = p._active_profile
|
||||
try:
|
||||
p._active_profile = 'default'
|
||||
with patch.object(m.Session, 'save', return_value=None):
|
||||
s = m.new_session()
|
||||
assert s.profile == 'default'
|
||||
finally:
|
||||
p._active_profile = original
|
||||
|
||||
|
||||
def test_new_session_none_profile_falls_back_to_global():
|
||||
"""R19g: profile=None explicitly also falls back to the global (same as omitting it)."""
|
||||
import api.profiles as p
|
||||
import api.models as m
|
||||
|
||||
original = p._active_profile
|
||||
try:
|
||||
p._active_profile = 'default'
|
||||
with patch.object(m.Session, 'save', return_value=None):
|
||||
s = m.new_session(profile=None)
|
||||
assert s.profile == 'default'
|
||||
finally:
|
||||
p._active_profile = original
|
||||
|
||||
|
||||
def test_concurrent_new_sessions_get_correct_profiles():
|
||||
"""R19h: Two threads call new_session() with different explicit profiles simultaneously.
|
||||
Each session must be stamped with its own profile, never the other's.
|
||||
Direct reproduction of the #798 race (minus the actual switch_profile() call).
|
||||
"""
|
||||
import api.models as m
|
||||
|
||||
results = {}
|
||||
errors = []
|
||||
|
||||
# Patch Session.save ONCE around both threads — not once per thread.
|
||||
# Per-thread `with patch.object(...)` nested across threads has a known
|
||||
# concurrency bug in unittest.mock where one thread's __exit__ can capture
|
||||
# the other thread's mock as the "original" and leave the class attribute
|
||||
# permanently pointing at a MagicMock, breaking every later test that
|
||||
# calls Session.save (any test writing a real session file).
|
||||
def make_session(profile_name, key):
|
||||
try:
|
||||
s = m.new_session(profile=profile_name)
|
||||
results[key] = s.profile
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
with patch.object(m.Session, 'save', return_value=None):
|
||||
t1 = threading.Thread(target=make_session, args=('alice', 'alice'))
|
||||
t2 = threading.Thread(target=make_session, args=('bob', 'bob'))
|
||||
t1.start(); t2.start()
|
||||
t1.join(timeout=5); t2.join(timeout=5)
|
||||
|
||||
assert not errors, f"Threads raised: {errors}"
|
||||
assert results.get('alice') == 'alice', f"alice session had profile {results.get('alice')!r}"
|
||||
assert results.get('bob') == 'bob', f"bob session had profile {results.get('bob')!r}"
|
||||
|
||||
|
||||
# ── R19i: sessions.js sends profile in the POST body ─────────────────────────
|
||||
|
||||
def test_sessions_js_sends_profile_in_new_session_post():
|
||||
"""R19i: sessions.js newSession() must include profile:S.activeProfile in the
|
||||
JSON body sent to /api/session/new — the client-side half of the #798 fix."""
|
||||
js = (Path(__file__).parent.parent / 'static' / 'sessions.js').read_text()
|
||||
assert 'profile:S.activeProfile' in js or 'profile: S.activeProfile' in js, (
|
||||
"sessions.js newSession() must send profile: S.activeProfile in the POST body "
|
||||
"so the server uses the tab's active profile, not the process global."
|
||||
)
|
||||
|
||||
|
||||
def test_get_hermes_home_for_profile_rejects_path_traversal():
|
||||
"""R19j: get_hermes_home_for_profile() must reject names that don't match
|
||||
_PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base home.
|
||||
The regex guard is defence-in-depth on top of the is_dir() fallback."""
|
||||
import api.profiles as p
|
||||
base = p._DEFAULT_HERMES_HOME
|
||||
assert p.get_hermes_home_for_profile('../../etc') == base
|
||||
assert p.get_hermes_home_for_profile('../escape') == base
|
||||
assert p.get_hermes_home_for_profile('/absolute/path') == base
|
||||
assert p.get_hermes_home_for_profile('has spaces') == base
|
||||
assert p.get_hermes_home_for_profile('UPPERCASE') == base
|
||||
# Valid names still work
|
||||
assert p.get_hermes_home_for_profile('alice') == base # nonexistent → fallback
|
||||
assert p.get_hermes_home_for_profile('my-profile') == base
|
||||
assert p.get_hermes_home_for_profile('profile_1') == base
|
||||
183
tests/test_issue803.py
Normal file
183
tests/test_issue803.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Issue #803 (completes #798) — per-client profile isolation via cookie + thread-local.
|
||||
|
||||
PR #800 fixed POST /api/session/new (client sends profile in body).
|
||||
PR #805 extends the fix to ALL endpoints: profile switches set a hermes_profile
|
||||
cookie, server.py reads it per-request into a thread-local, and the existing
|
||||
api/profiles.py helpers consult the thread-local before the process global.
|
||||
|
||||
Covers:
|
||||
1. build_profile_cookie() / get_profile_cookie() roundtrip + validation
|
||||
2. set_request_profile() / get_active_profile_name() / clear_request_profile()
|
||||
3. get_active_hermes_home() routes via thread-local
|
||||
4. switch_profile(process_wide=False) does NOT mutate process globals
|
||||
5. Concurrent requests on different threads see independent profiles
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── 1. Cookie build/parse roundtrip ──────────────────────────────────────────
|
||||
|
||||
class TestProfileCookieHelpers:
|
||||
|
||||
def test_build_profile_cookie_sets_value(self):
|
||||
from api.helpers import build_profile_cookie
|
||||
s = build_profile_cookie('alice')
|
||||
assert 'hermes_profile=alice' in s
|
||||
assert 'HttpOnly' in s
|
||||
assert 'SameSite=Lax' in s
|
||||
assert 'Path=/' in s
|
||||
|
||||
def test_build_profile_cookie_default_persists(self):
|
||||
from api.helpers import build_profile_cookie
|
||||
s = build_profile_cookie('default')
|
||||
assert 'hermes_profile=default' in s
|
||||
assert 'Max-Age=0' not in s
|
||||
|
||||
def test_get_profile_cookie_returns_none_when_absent(self):
|
||||
from api.helpers import get_profile_cookie
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': ''
|
||||
assert get_profile_cookie(handler) is None
|
||||
|
||||
def test_get_profile_cookie_extracts_valid_name(self):
|
||||
from api.helpers import get_profile_cookie
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': 'hermes_profile=alice' if k == 'Cookie' else d
|
||||
assert get_profile_cookie(handler) == 'alice'
|
||||
|
||||
def test_get_profile_cookie_accepts_default(self):
|
||||
from api.helpers import get_profile_cookie
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': 'hermes_profile=default' if k == 'Cookie' else d
|
||||
assert get_profile_cookie(handler) == 'default'
|
||||
|
||||
def test_get_profile_cookie_rejects_injection(self):
|
||||
"""Cookie value must pass _PROFILE_ID_RE fullmatch — rejects traversal/injection."""
|
||||
from api.helpers import get_profile_cookie
|
||||
for bad in ('../etc', 'a/b', 'name;DROP', 'WithCaps', 'has space', '.hidden'):
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='', v=bad: f'hermes_profile={v}' if k == 'Cookie' else d
|
||||
assert get_profile_cookie(handler) is None, f"{bad!r} should be rejected"
|
||||
|
||||
def test_get_profile_cookie_ignores_malformed_header(self):
|
||||
from api.helpers import get_profile_cookie
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': '\x00\x01not-a-cookie' if k == 'Cookie' else d
|
||||
# Must not raise; returns None
|
||||
result = get_profile_cookie(handler)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── 2. Thread-local request context ──────────────────────────────────────────
|
||||
|
||||
class TestThreadLocalProfileContext:
|
||||
|
||||
def test_tls_takes_priority_over_global(self):
|
||||
import api.profiles as p
|
||||
original = p._active_profile
|
||||
try:
|
||||
p._active_profile = 'global-default'
|
||||
p.set_request_profile('alice')
|
||||
assert p.get_active_profile_name() == 'alice'
|
||||
finally:
|
||||
p.clear_request_profile()
|
||||
p._active_profile = original
|
||||
|
||||
def test_global_used_when_tls_cleared(self):
|
||||
import api.profiles as p
|
||||
original = p._active_profile
|
||||
try:
|
||||
p._active_profile = 'global-default'
|
||||
p.set_request_profile('alice')
|
||||
p.clear_request_profile()
|
||||
assert p.get_active_profile_name() == 'global-default'
|
||||
finally:
|
||||
p._active_profile = original
|
||||
|
||||
def test_clear_is_idempotent(self):
|
||||
import api.profiles as p
|
||||
# Calling clear on a thread that never set anything must not raise
|
||||
p.clear_request_profile()
|
||||
p.clear_request_profile()
|
||||
|
||||
|
||||
# ── 3. get_active_hermes_home routes through TLS ─────────────────────────────
|
||||
|
||||
def test_get_active_hermes_home_respects_tls(tmp_path, monkeypatch):
|
||||
import api.profiles as p
|
||||
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
|
||||
profile_dir = tmp_path / 'profiles' / 'alice'
|
||||
profile_dir.mkdir(parents=True)
|
||||
try:
|
||||
p.set_request_profile('alice')
|
||||
assert p.get_active_hermes_home() == profile_dir
|
||||
p.set_request_profile('default')
|
||||
assert p.get_active_hermes_home() == tmp_path
|
||||
finally:
|
||||
p.clear_request_profile()
|
||||
|
||||
|
||||
# ── 4. switch_profile(process_wide=False) does not mutate globals ─────────────
|
||||
|
||||
def test_switch_profile_process_wide_false_does_not_mutate_global():
|
||||
"""Per-client switches from the WebUI must leave _active_profile untouched."""
|
||||
import api.profiles as p
|
||||
|
||||
# Monkey in a fake profile listing so switch_profile finds 'alice'
|
||||
original_global = p._active_profile
|
||||
original_env_home = os.environ.get('HERMES_HOME')
|
||||
|
||||
# We need a profile that exists to get past the validation path.
|
||||
# Use 'default' — switch_profile accepts it without requiring hermes_cli.
|
||||
try:
|
||||
result = p.switch_profile('default', process_wide=False)
|
||||
# Global must not change
|
||||
assert p._active_profile == original_global, (
|
||||
f"process_wide=False must not mutate _active_profile "
|
||||
f"(was {original_global!r}, now {p._active_profile!r})"
|
||||
)
|
||||
# HERMES_HOME env must not change
|
||||
assert os.environ.get('HERMES_HOME') == original_env_home, (
|
||||
"process_wide=False must not mutate os.environ['HERMES_HOME']"
|
||||
)
|
||||
# Response still shape-compatible
|
||||
assert isinstance(result, dict)
|
||||
finally:
|
||||
p._active_profile = original_global
|
||||
|
||||
|
||||
# ── 5. Concurrent threads see independent profile context ────────────────────
|
||||
|
||||
def test_concurrent_threads_see_independent_profiles():
|
||||
"""The whole point of thread-local isolation: two threads, two cookies,
|
||||
two different get_active_profile_name() results, simultaneously."""
|
||||
import api.profiles as p
|
||||
|
||||
results = {}
|
||||
errors = []
|
||||
barrier = threading.Barrier(2, timeout=5)
|
||||
|
||||
def worker(name, key):
|
||||
try:
|
||||
p.set_request_profile(name)
|
||||
barrier.wait() # both threads have set their TLS
|
||||
# Now each thread reads — must see its own value
|
||||
results[key] = p.get_active_profile_name()
|
||||
p.clear_request_profile()
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
t1 = threading.Thread(target=worker, args=('alice', 'alice'))
|
||||
t2 = threading.Thread(target=worker, args=('bob', 'bob'))
|
||||
t1.start(); t2.start()
|
||||
t1.join(timeout=10); t2.join(timeout=10)
|
||||
|
||||
assert not errors, f"Workers raised: {errors}"
|
||||
assert results.get('alice') == 'alice', f"alice thread saw {results.get('alice')!r}"
|
||||
assert results.get('bob') == 'bob', f"bob thread saw {results.get('bob')!r}"
|
||||
202
tests/test_issue840_slash_echo.py
Normal file
202
tests/test_issue840_slash_echo.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Tests for slash command echo (#840) — user message shown in chat after /skills, /help, etc."""
|
||||
import os
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
|
||||
def _read(name):
|
||||
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
class TestExecuteCommandReturnValue:
|
||||
"""executeCommand() now returns null or {noEcho:bool} instead of true/false."""
|
||||
|
||||
def test_execute_command_returns_null_on_no_match(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("function executeCommand(")
|
||||
block = src[idx:idx + 400]
|
||||
# Must return null (not false) when no command matched
|
||||
assert "return null;" in block, (
|
||||
"executeCommand must return null when no command found (not false)"
|
||||
)
|
||||
|
||||
def test_execute_command_returns_noecho_object(self):
|
||||
src = _read("static/commands.js")
|
||||
assert "return {noEcho:" in src, (
|
||||
"executeCommand must return {noEcho:...} when a command runs"
|
||||
)
|
||||
|
||||
def test_no_echo_flag_on_clear(self):
|
||||
src = _read("static/commands.js")
|
||||
# Find the clear command entry
|
||||
idx = src.find("name:'clear'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 100]
|
||||
assert "noEcho:true" in entry, "/clear must have noEcho:true"
|
||||
|
||||
def test_no_echo_flag_on_new(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'new'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 80]
|
||||
assert "noEcho:true" in entry, "/new must have noEcho:true"
|
||||
|
||||
def test_no_echo_flag_on_stop(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'stop'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 80]
|
||||
assert "noEcho:true" in entry, "/stop must have noEcho:true"
|
||||
|
||||
def test_no_echo_flag_on_retry(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'retry'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 80]
|
||||
assert "noEcho:true" in entry, "/retry must have noEcho:true"
|
||||
|
||||
def test_no_echo_flag_on_undo(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'undo'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 80]
|
||||
assert "noEcho:true" in entry, "/undo must have noEcho:true"
|
||||
|
||||
def test_no_echo_flag_on_voice(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'voice'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 80]
|
||||
assert "noEcho:true" in entry, "/voice must have noEcho:true"
|
||||
|
||||
def test_no_echo_flag_on_theme(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'theme'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 80]
|
||||
assert "noEcho:true" in entry, "/theme must have noEcho:true"
|
||||
|
||||
def test_no_echo_flag_on_model(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'model'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 130]
|
||||
assert "noEcho:true" in entry, "/model must have noEcho:true"
|
||||
|
||||
def test_skills_has_no_noecho(self):
|
||||
"""Commands that produce chat responses must NOT have noEcho."""
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'skills'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 100]
|
||||
assert "noEcho" not in entry, "/skills must echo — no noEcho flag"
|
||||
|
||||
def test_help_has_no_noecho(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'help'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 80]
|
||||
assert "noEcho" not in entry, "/help must echo — no noEcho flag"
|
||||
|
||||
def test_status_has_no_noecho(self):
|
||||
src = _read("static/commands.js")
|
||||
idx = src.find("name:'status'")
|
||||
assert idx >= 0
|
||||
entry = src[idx:idx + 80]
|
||||
assert "noEcho" not in entry, "/status must echo — no noEcho flag"
|
||||
|
||||
|
||||
class TestSendSlashIntercept:
|
||||
"""send() in messages.js must push user message for echo-worthy commands."""
|
||||
|
||||
def test_send_checks_noecho_flag(self):
|
||||
src = _read("static/messages.js")
|
||||
idx = src.find("Slash command intercept")
|
||||
block = src[idx:idx + 1400]
|
||||
assert "_cmd.noEcho" in block or "cmd.noEcho" in block, (
|
||||
"send() must check the command's noEcho flag before pushing user message (#840)"
|
||||
)
|
||||
|
||||
def test_send_pushes_user_message_for_echo_commands(self):
|
||||
src = _read("static/messages.js")
|
||||
idx = src.find("Slash command intercept")
|
||||
block = src[idx:idx + 1400]
|
||||
assert "role:'user'" in block and "content:text" in block, (
|
||||
"send() must push {role:'user', content:text} for echo-worthy slash commands (#840)"
|
||||
)
|
||||
|
||||
def test_send_pushes_user_message_before_running_handler(self):
|
||||
"""Ordering fix: cmdHelp-style handlers push their assistant response
|
||||
synchronously. The user message must be pushed BEFORE the handler
|
||||
runs so S.messages ends up [user, assistant] — not [assistant, user]
|
||||
which would display in reverse chronological order."""
|
||||
src = _read("static/messages.js")
|
||||
idx = src.find("Slash command intercept")
|
||||
block = src[idx:idx + 1400]
|
||||
user_push_pos = block.find("role:'user'")
|
||||
handler_call_pos = block.find("_cmd.fn(")
|
||||
if handler_call_pos == -1:
|
||||
handler_call_pos = block.find("cmd.fn(")
|
||||
assert user_push_pos != -1, "user message push not found in intercept block"
|
||||
assert handler_call_pos != -1, "handler invocation not found in intercept block"
|
||||
assert user_push_pos < handler_call_pos, (
|
||||
"User message must be pushed BEFORE the handler runs — otherwise "
|
||||
"sync handlers like cmdHelp push the assistant response first and "
|
||||
"the chat displays in reverse chronological order."
|
||||
)
|
||||
|
||||
def test_send_rolls_back_user_push_on_handler_optout(self):
|
||||
"""If a handler returns false (opt-out — e.g. /reasoning <level>),
|
||||
the pre-pushed user message must be popped so the normal send path
|
||||
can add it cleanly for forwarding to the agent."""
|
||||
src = _read("static/messages.js")
|
||||
idx = src.find("Slash command intercept")
|
||||
block = src[idx:idx + 1400]
|
||||
assert "S.messages.pop()" in block, (
|
||||
"send() must S.messages.pop() the user message on handler opt-out "
|
||||
"to avoid duplicating the user turn when falling through to "
|
||||
"the normal send path."
|
||||
)
|
||||
assert "===false" in block or "=== false" in block, (
|
||||
"opt-out must be detected by handler returning === false"
|
||||
)
|
||||
|
||||
|
||||
def test_compress_has_no_echo_flag():
|
||||
"""compress is action-only — it resets S.messages internally; echo would flicker."""
|
||||
src = _read("static/commands.js")
|
||||
import re
|
||||
m = re.search(r"\{name:'compress'[^}]+\}", src)
|
||||
assert m, "compress entry not found in COMMANDS"
|
||||
assert 'noEcho:true' in m.group(), f"compress missing noEcho:true: {m.group()}"
|
||||
|
||||
|
||||
def test_compact_has_no_echo_flag():
|
||||
"""compact is an alias for compress — same noEcho requirement."""
|
||||
src = _read("static/commands.js")
|
||||
import re
|
||||
m = re.search(r"\{name:'compact'[^}]+\}", src)
|
||||
assert m, "compact entry not found in COMMANDS"
|
||||
assert 'noEcho:true' in m.group(), f"compact missing noEcho:true: {m.group()}"
|
||||
|
||||
|
||||
def test_title_with_args_pushes_confirmation_message():
|
||||
"""When /title <name> succeeds, cmdTitle pushes an assistant confirmation bubble."""
|
||||
src = _read("static/commands.js")
|
||||
# After the rename API call succeeds, an assistant message is pushed
|
||||
idx = src.find("title_set")
|
||||
segment = src[idx: idx + 300]
|
||||
assert 'S.messages.push' in segment, "cmdTitle success path must push an assistant message"
|
||||
assert "role:'assistant'" in segment, "cmdTitle confirmation must have role:assistant"
|
||||
|
||||
|
||||
def test_personality_with_args_pushes_confirmation_message():
|
||||
"""When /personality <name> succeeds, cmdPersonality pushes an assistant confirmation bubble."""
|
||||
src = _read("static/commands.js")
|
||||
# Find the set-personality success path (after the clear/none/default branch)
|
||||
# S.messages.push comes BEFORE the personality_set toast
|
||||
idx = src.find("personality_set')+`**${name}**`")
|
||||
assert idx != -1, "cmdPersonality confirmation push not found in source"
|
||||
segment = src[max(0, idx-100): idx + 200]
|
||||
assert 'S.messages.push' in segment, "cmdPersonality success path must push an assistant message"
|
||||
assert "role:'assistant'" in segment, "cmdPersonality confirmation must have role:assistant"
|
||||
58
tests/test_issue852_thinking_card_mirror.py
Normal file
58
tests/test_issue852_thinking_card_mirror.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Regression tests for #852 — thinking card must not mirror the main response.
|
||||
|
||||
The `_streamDisplay()` function in messages.js had an early return
|
||||
`if(reasoningText) return raw` that bypassed think-block stripping when
|
||||
the reasoning SSE event had populated `reasoningText`. Providers that emit
|
||||
reasoning via BOTH `on_reasoning` AND `<think>` tags in the token stream
|
||||
then showed identical content in the thinking card and the main response.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
|
||||
def _read(name):
|
||||
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
class TestStreamDisplayStripsThinkBlocksAlways:
|
||||
|
||||
def test_early_return_on_reasoning_text_is_gone(self):
|
||||
"""Regression guard: the bypass that caused the thinking card to
|
||||
mirror the main response must stay removed."""
|
||||
js = _read("static/messages.js")
|
||||
m = re.search(r'function _streamDisplay\(\)\{.*?\n \}', js, re.DOTALL)
|
||||
assert m, "_streamDisplay not found"
|
||||
fn = m.group(0)
|
||||
assert "if(reasoningText) return raw" not in fn, (
|
||||
"The early-return `if(reasoningText) return raw;` must remain "
|
||||
"removed (#852) — it caused the thinking card to mirror the main "
|
||||
"response when providers emit <think> tags AND reasoning SSE events."
|
||||
)
|
||||
|
||||
def test_think_pair_stripping_still_runs(self):
|
||||
"""The `_thinkPairs` stripping loop must still be present so the
|
||||
fix actually strips think blocks."""
|
||||
js = _read("static/messages.js")
|
||||
m = re.search(r'function _streamDisplay\(\)\{.*?\n \}', js, re.DOTALL)
|
||||
assert m
|
||||
fn = m.group(0)
|
||||
assert "_thinkPairs" in fn, (
|
||||
"_streamDisplay must iterate _thinkPairs to strip think blocks"
|
||||
)
|
||||
assert "trimmed.startsWith(open)" in fn, (
|
||||
"the think-block stripping must check for the open tag"
|
||||
)
|
||||
|
||||
def test_still_handles_incomplete_think_tag_partial_prefix(self):
|
||||
"""Existing behaviour preserved: partial `<thi`, `<think` prefixes
|
||||
must still be suppressed so users don't see them mid-stream."""
|
||||
js = _read("static/messages.js")
|
||||
m = re.search(r'function _streamDisplay\(\)\{.*?\n \}', js, re.DOTALL)
|
||||
assert m
|
||||
fn = m.group(0)
|
||||
assert "open.startsWith(trimmed)" in fn, (
|
||||
"Partial-tag suppression must still be present"
|
||||
)
|
||||
100
tests/test_issue854_live_model_prefix.py
Normal file
100
tests/test_issue854_live_model_prefix.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Regression tests for #854 — live-fetched models must route through the
|
||||
configured portal provider, not OpenRouter."""
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
_SRC = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
|
||||
def _read(name):
|
||||
return open(os.path.join(_SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
class TestLiveModelPrefix:
|
||||
"""_fetchLiveModels() must apply @provider: prefix to live-fetched model
|
||||
IDs when the fetch is for the active portal provider (Nous, OpenCode,
|
||||
etc.) — including IDs that already contain a slash (the upstream vendor
|
||||
namespace), since those would otherwise be mis-routed via OpenRouter."""
|
||||
|
||||
def test_apply_prefix_to_any_non_at_id(self):
|
||||
"""The prefix check must not gate on `!mid.includes('/')`. The bug
|
||||
scenario in #854 is precisely about slash-prefixed IDs like
|
||||
`minimax/minimax-m2.7` from Nous's live catalog — excluding them
|
||||
leaves the bug unfixed."""
|
||||
js = _read("static/ui.js")
|
||||
# Live model prefix logic was extracted to _addLiveModelsToSelect (#872)
|
||||
m = re.search(r'function _addLiveModelsToSelect\(.*?\n\}', js, re.DOTALL)
|
||||
if not m:
|
||||
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL)
|
||||
assert m, "_addLiveModelsToSelect or _fetchLiveModels not found"
|
||||
fn = m.group(0)
|
||||
# The prefix application block must NOT have `!mid.includes('/')`
|
||||
# as a guard — slash-prefixed IDs from portal providers also need
|
||||
# the prefix.
|
||||
prefix_block = re.search(
|
||||
r"if\s*\(\s*[^)]*!mid\.startsWith\(['\"]@['\"]\)[^)]*\)\s*\{\s*mid\s*=\s*`@",
|
||||
fn,
|
||||
)
|
||||
assert prefix_block, "@provider: prefix application not found"
|
||||
# The block must prefix when portal-fetch is true and not already @-prefixed.
|
||||
# It must NOT check for slash presence — that's the bug.
|
||||
assert "!mid.includes('/')" not in prefix_block.group(0), (
|
||||
"The prefix application must NOT exclude slash-prefixed IDs — "
|
||||
"portal catalogs return `minimax/minimax-m2.7` and similar that "
|
||||
"need `@nous:` prefix to route through the configured portal (#854)"
|
||||
)
|
||||
|
||||
def test_portal_fetch_flag_semantics(self):
|
||||
"""The flag controlling prefix application should be named/structured
|
||||
so the prefix is ADDED when the flag is true (portal fetch), not when
|
||||
false. Earlier revision used `!_needsPrefix` (inverted)."""
|
||||
js = _read("static/ui.js")
|
||||
# Live model prefix logic was extracted to _addLiveModelsToSelect (#872)
|
||||
m = re.search(r'function _addLiveModelsToSelect\(.*?\n\}', js, re.DOTALL)
|
||||
if not m:
|
||||
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL)
|
||||
assert m
|
||||
fn = m.group(0)
|
||||
# New flag: _isPortalFetch (positive semantics)
|
||||
assert "_isPortalFetch" in fn, (
|
||||
"flag should be named _isPortalFetch to reflect positive semantics "
|
||||
"(prefix ADDED when true, not when false)"
|
||||
)
|
||||
# And the prefix application should be guarded BY the flag (not by its negation)
|
||||
gate = re.search(
|
||||
r"if\s*\(\s*_isPortalFetch\s*&&\s*!mid\.startsWith",
|
||||
fn,
|
||||
)
|
||||
assert gate, "prefix application must be guarded by _isPortalFetch (true ⇒ prefix)"
|
||||
|
||||
def test_portal_fetch_excludes_openrouter_and_custom(self):
|
||||
"""OpenRouter IDs are cross-namespace by design, and `custom` providers
|
||||
use user-defined bare names — neither should get a `@provider:` prefix."""
|
||||
js = _read("static/ui.js")
|
||||
# Live model prefix logic was extracted to _addLiveModelsToSelect (#872)
|
||||
m = re.search(r'function _addLiveModelsToSelect\(.*?\n\}', js, re.DOTALL)
|
||||
if not m:
|
||||
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL)
|
||||
assert m
|
||||
fn = m.group(0)
|
||||
assert "_ap!=='openrouter'" in fn or "_ap !== 'openrouter'" in fn, (
|
||||
"portal flag must exclude openrouter"
|
||||
)
|
||||
assert "_ap!=='custom'" in fn or "_ap !== 'custom'" in fn, (
|
||||
"portal flag must exclude custom"
|
||||
)
|
||||
|
||||
|
||||
class TestCheckProviderMismatchAtPrefix:
|
||||
"""_checkProviderMismatch() must not warn on `@provider:`-prefixed IDs —
|
||||
the prefix itself is an explicit provider hint, so there's no mismatch."""
|
||||
|
||||
def test_returns_null_for_at_prefix_ids(self):
|
||||
js = _read("static/ui.js")
|
||||
m = re.search(r'function _checkProviderMismatch\(.*?\n\}', js, re.DOTALL)
|
||||
assert m, "_checkProviderMismatch not found"
|
||||
fn = m.group(0)
|
||||
assert "modelId.startsWith('@')" in fn or 'modelId.startsWith("@")' in fn, (
|
||||
"_checkProviderMismatch must return null early for @provider: prefixed IDs"
|
||||
)
|
||||
49
tests/test_issue856_active_session_read_state.py
Normal file
49
tests/test_issue856_active_session_read_state.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Regression checks for #856 active-session unread state handling."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MESSAGES_JS = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text()
|
||||
|
||||
|
||||
def test_messages_js_defines_active_session_viewed_helper():
|
||||
assert "function _markSessionViewed(" in MESSAGES_JS, (
|
||||
"messages.js should define a helper that marks the active session as viewed"
|
||||
)
|
||||
assert "_setSessionViewedCount" in MESSAGES_JS, (
|
||||
"active-session viewed helper must delegate to the sidebar viewed-count store"
|
||||
)
|
||||
|
||||
|
||||
def test_done_path_marks_active_session_as_viewed():
|
||||
done_idx = MESSAGES_JS.find("source.addEventListener('done'")
|
||||
assert done_idx != -1, "done handler not found in messages.js"
|
||||
done_block = MESSAGES_JS[done_idx:MESSAGES_JS.find("source.addEventListener('stream_end'", done_idx)]
|
||||
assert "_markSessionViewed(activeSid" in done_block, (
|
||||
"done handler must mark the active session as viewed so unread dot does not linger"
|
||||
)
|
||||
|
||||
|
||||
def test_cancel_path_marks_active_session_as_viewed():
|
||||
cancel_idx = MESSAGES_JS.find("source.addEventListener('cancel'")
|
||||
assert cancel_idx != -1, "cancel handler not found in messages.js"
|
||||
cancel_block = MESSAGES_JS[cancel_idx:MESSAGES_JS.find("async function _restoreSettledSession()", cancel_idx)]
|
||||
assert "_markSessionViewed(activeSid" in cancel_block, (
|
||||
"cancel handler must mark the active session as viewed after settling messages"
|
||||
)
|
||||
|
||||
|
||||
def test_restore_and_error_paths_mark_active_session_as_viewed():
|
||||
restore_idx = MESSAGES_JS.find("async function _restoreSettledSession()")
|
||||
assert restore_idx != -1, "_restoreSettledSession() not found in messages.js"
|
||||
restore_block = MESSAGES_JS[restore_idx:MESSAGES_JS.find("function _handleStreamError()", restore_idx)]
|
||||
assert "_markSessionViewed(activeSid" in restore_block, (
|
||||
"_restoreSettledSession() must mark the active session as viewed"
|
||||
)
|
||||
|
||||
error_idx = MESSAGES_JS.find("function _handleStreamError()")
|
||||
assert error_idx != -1, "_handleStreamError() not found in messages.js"
|
||||
error_block = MESSAGES_JS[error_idx:]
|
||||
assert "_markSessionViewed(activeSid" in error_block, (
|
||||
"_handleStreamError() must mark the active session as viewed"
|
||||
)
|
||||
68
tests/test_issue856_pinned_indicator_layout.py
Normal file
68
tests/test_issue856_pinned_indicator_layout.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Regression checks for #856 pinned-star layout in the session list."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SESSIONS_JS = (Path(__file__).resolve().parent.parent / "static" / "sessions.js").read_text()
|
||||
STYLE_CSS = (Path(__file__).resolve().parent.parent / "static" / "style.css").read_text()
|
||||
|
||||
|
||||
def test_pinned_indicator_renders_inside_title_row():
|
||||
title_row_idx = SESSIONS_JS.find("titleRow.className='session-title-row';")
|
||||
assert title_row_idx != -1, "session title row construction not found"
|
||||
|
||||
pin_idx = SESSIONS_JS.find("pinInd.className='session-pin-indicator';", title_row_idx)
|
||||
assert pin_idx != -1, "pinned indicator creation not found after title row"
|
||||
|
||||
append_to_title_row_idx = SESSIONS_JS.find("titleRow.appendChild(pinInd);", pin_idx)
|
||||
assert append_to_title_row_idx != -1, "pinned indicator should be appended to titleRow"
|
||||
|
||||
append_to_el_idx = SESSIONS_JS.find("el.appendChild(pinInd);", pin_idx)
|
||||
assert append_to_el_idx == -1, (
|
||||
"pinned indicator should not be appended to the outer session row; "
|
||||
"it must align inside the title row with the spinner/unread indicator"
|
||||
)
|
||||
|
||||
|
||||
def test_pinned_indicator_uses_fixed_indicator_box():
|
||||
assert ".session-pin-indicator{" in STYLE_CSS, "session pin indicator CSS block missing"
|
||||
css_block = STYLE_CSS[STYLE_CSS.find(".session-pin-indicator{"):STYLE_CSS.find(".session-pin-indicator svg{")]
|
||||
assert "width:10px;" in css_block, "pin indicator should reserve a fixed 10px width"
|
||||
assert "height:10px;" in css_block, "pin indicator should reserve a fixed 10px height"
|
||||
assert "justify-content:center;" in css_block, "pin indicator should center the star inside its box"
|
||||
|
||||
|
||||
def test_state_indicator_always_appended_to_prevent_layout_shift():
|
||||
"""State span is always added to the DOM (visibility:hidden when inactive) to prevent
|
||||
titles shifting left/right when the spinner or unread dot appears/disappears."""
|
||||
title_row_idx = SESSIONS_JS.find("titleRow.className='session-title-row';")
|
||||
assert title_row_idx != -1, "title row construction not found"
|
||||
|
||||
# state span must be appended unconditionally (no surrounding if-check)
|
||||
append_idx = SESSIONS_JS.find("titleRow.appendChild(state);", title_row_idx)
|
||||
assert append_idx != -1, "state span must always be appended to titleRow"
|
||||
|
||||
# Verify CSS uses visibility:hidden to reserve the slot
|
||||
assert "session-state-indicator{" in STYLE_CSS, "session-state-indicator CSS rule missing"
|
||||
base_block_start = STYLE_CSS.find("session-state-indicator{")
|
||||
base_block_end = STYLE_CSS.find("}", base_block_start)
|
||||
base_block = STYLE_CSS[base_block_start:base_block_end]
|
||||
assert "visibility:hidden;" in base_block, (
|
||||
"session-state-indicator should default to visibility:hidden so it reserves slot "
|
||||
"without being visible — prevents title layout shift on state changes"
|
||||
)
|
||||
|
||||
|
||||
def test_apperror_path_calls_render_session_list():
|
||||
"""apperror handler must call renderSessionList() to clear the streaming indicator
|
||||
immediately rather than waiting for the 5s streaming poll interval."""
|
||||
messages_js = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text()
|
||||
apperror_idx = messages_js.find("source.addEventListener('apperror'")
|
||||
assert apperror_idx != -1, "apperror handler not found in messages.js"
|
||||
warning_idx = messages_js.find("source.addEventListener('warning'", apperror_idx)
|
||||
assert warning_idx != -1, "warning handler not found after apperror handler"
|
||||
apperror_block = messages_js[apperror_idx:warning_idx]
|
||||
assert "renderSessionList()" in apperror_block, (
|
||||
"apperror handler must call renderSessionList() so the streaming indicator "
|
||||
"clears immediately on server errors, not after a 5s poll delay"
|
||||
)
|
||||
93
tests/test_issue856_session_streaming_state.py
Normal file
93
tests/test_issue856_session_streaming_state.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Regression tests for session streaming indicator payloads used by the session list.
|
||||
|
||||
This ensures backend payloads report per-session streaming status from active stream
|
||||
tracking, not only for the foreground conversation.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
import api.models as models
|
||||
from api.models import Session, all_sessions
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_session_stream_state(tmp_path, monkeypatch):
|
||||
"""Keep session/index/stream state isolated from the host environment."""
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
index_file = session_dir / "_index.json"
|
||||
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
|
||||
models.SESSIONS.clear()
|
||||
|
||||
stream_map = {}
|
||||
stream_lock = threading.Lock()
|
||||
monkeypatch.setattr(models, "STREAMS", stream_map)
|
||||
monkeypatch.setattr(models, "STREAMS_LOCK", stream_lock)
|
||||
|
||||
yield
|
||||
|
||||
models.SESSIONS.clear()
|
||||
|
||||
|
||||
def _make_session(session_id, stream_id=None, message_count=1):
|
||||
s = Session(
|
||||
session_id=session_id,
|
||||
title=session_id,
|
||||
messages=[{"role": "user", "content": f"seed-{session_id}"}] * message_count,
|
||||
)
|
||||
s.active_stream_id = stream_id
|
||||
return s
|
||||
|
||||
|
||||
def test_all_sessions_marks_indexed_and_in_memory_streaming_sessions():
|
||||
"""Session records from both index and in-memory cache should expose is_streaming."""
|
||||
s_disk = _make_session("disk_session", stream_id="stream-1")
|
||||
s_disk.save()
|
||||
|
||||
s_memory = _make_session("memory_session", stream_id="stream-2")
|
||||
with models.LOCK:
|
||||
models.SESSIONS[s_memory.session_id] = s_memory
|
||||
|
||||
models.STREAMS["stream-1"] = object()
|
||||
models.STREAMS["stream-2"] = object()
|
||||
|
||||
listed = all_sessions()
|
||||
by_sid = {s["session_id"]: s for s in listed}
|
||||
|
||||
assert by_sid["disk_session"]["is_streaming"] is True
|
||||
assert by_sid["memory_session"]["is_streaming"] is True
|
||||
assert by_sid["memory_session"]["active_stream_id"] == "stream-2"
|
||||
|
||||
|
||||
def test_all_sessions_marks_streaming_false_when_stream_is_not_active():
|
||||
"""Stale active_stream_id should not imply streaming without active STREAMS entry."""
|
||||
s = _make_session("stalesession", stream_id="stale-stream")
|
||||
s.save()
|
||||
|
||||
assert all_sessions()[0]["is_streaming"] is False
|
||||
|
||||
models.STREAMS["stale-stream"] = object()
|
||||
assert all_sessions()[0]["is_streaming"] is True
|
||||
|
||||
models.STREAMS.pop("stale-stream", None)
|
||||
assert all_sessions()[0]["is_streaming"] is False
|
||||
|
||||
|
||||
def test_all_sessions_does_not_report_streaming_after_restart_without_active_registry():
|
||||
"""Server restarts should not resurrect sidebar streaming state from disk alone."""
|
||||
s = _make_session("restart_session", stream_id="old-stream")
|
||||
s.save()
|
||||
|
||||
models.SESSIONS.clear()
|
||||
reloaded = Session.load("restart_session")
|
||||
assert reloaded is not None
|
||||
assert reloaded.active_stream_id == "old-stream"
|
||||
|
||||
listed = all_sessions()
|
||||
assert listed[0]["active_stream_id"] == "old-stream"
|
||||
assert listed[0]["is_streaming"] is False
|
||||
296
tests/test_issue893_cancel_preserves_partial.py
Normal file
296
tests/test_issue893_cancel_preserves_partial.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Regression tests for #893 — cancel_stream() now preserves partial streamed
|
||||
assistant content rather than discarding it.
|
||||
|
||||
Before this fix, clicking Stop Generation threw away all streamed text. The
|
||||
session was saved with only '*Task cancelled.*' appended, so the user lost
|
||||
whatever the agent had produced up to that point.
|
||||
|
||||
After this fix:
|
||||
- Partial text is accumulated in STREAM_PARTIAL_TEXT[stream_id] via on_token()
|
||||
- cancel_stream() reads that buffer, strips thinking markup, and persists it
|
||||
as a '_partial: True' assistant message before the cancel marker
|
||||
- _sanitize_messages_for_api() does NOT strip _partial messages, so the model
|
||||
sees the partial content as prior context on the next turn
|
||||
- The cancel marker itself keeps _error=True so the model does not see it
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import api.config as config
|
||||
import api.streaming as streaming
|
||||
from api.config import STREAM_PARTIAL_TEXT, STREAMS_LOCK
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_stream_state():
|
||||
"""Isolate shared stream state between tests."""
|
||||
STREAM_PARTIAL_TEXT.clear()
|
||||
config.STREAMS.clear()
|
||||
config.CANCEL_FLAGS.clear()
|
||||
config.AGENT_INSTANCES.clear()
|
||||
yield
|
||||
STREAM_PARTIAL_TEXT.clear()
|
||||
config.STREAMS.clear()
|
||||
config.CANCEL_FLAGS.clear()
|
||||
config.AGENT_INSTANCES.clear()
|
||||
|
||||
|
||||
class TestStreamPartialTextAccumulation:
|
||||
|
||||
def test_stream_partial_text_initialized_on_stream_creation(self, tmp_path, monkeypatch):
|
||||
"""STREAM_PARTIAL_TEXT[stream_id] starts empty when a stream is registered."""
|
||||
import queue
|
||||
sid = 'test_init_stream'
|
||||
q = queue.Queue()
|
||||
cancel_event = threading.Event()
|
||||
with STREAMS_LOCK:
|
||||
config.STREAMS[sid] = q
|
||||
config.CANCEL_FLAGS[sid] = cancel_event
|
||||
STREAM_PARTIAL_TEXT[sid] = ''
|
||||
assert STREAM_PARTIAL_TEXT.get(sid) == ''
|
||||
|
||||
def test_stream_partial_text_cleaned_up_on_stream_end(self):
|
||||
"""STREAM_PARTIAL_TEXT[stream_id] is removed when the stream dict is cleaned up."""
|
||||
import queue
|
||||
sid = 'test_cleanup_stream'
|
||||
q = queue.Queue()
|
||||
with STREAMS_LOCK:
|
||||
config.STREAMS[sid] = q
|
||||
STREAM_PARTIAL_TEXT[sid] = 'some partial text'
|
||||
with STREAMS_LOCK:
|
||||
config.STREAMS.pop(sid, None)
|
||||
STREAM_PARTIAL_TEXT.pop(sid, None)
|
||||
assert sid not in STREAM_PARTIAL_TEXT
|
||||
|
||||
|
||||
class TestCancelStreamPreservesPartial:
|
||||
|
||||
def test_cancel_stream_saves_partial_text_to_session(self, tmp_path, monkeypatch):
|
||||
"""cancel_stream() persists accumulated partial text as an assistant message."""
|
||||
import queue
|
||||
from api.models import Session
|
||||
from api.streaming import cancel_stream
|
||||
|
||||
session_dir = tmp_path / 'sessions'
|
||||
session_dir.mkdir()
|
||||
import api.models as _models
|
||||
monkeypatch.setattr(config, 'SESSION_DIR', session_dir)
|
||||
monkeypatch.setattr(config, 'SESSION_INDEX_FILE', session_dir / '_index.json')
|
||||
monkeypatch.setattr(_models, 'SESSION_DIR', session_dir)
|
||||
monkeypatch.setattr(_models, 'SESSION_INDEX_FILE', session_dir / '_index.json')
|
||||
config.SESSIONS.clear()
|
||||
_models.SESSIONS.clear()
|
||||
|
||||
# Create a session and a fake running stream
|
||||
s = Session(session_id='sess_partial', title='Test')
|
||||
s.messages.append({'role': 'user', 'content': 'Tell me about Python'})
|
||||
s.active_stream_id = 'stream_partial'
|
||||
s.save()
|
||||
config.SESSIONS['sess_partial'] = s
|
||||
|
||||
q = queue.Queue()
|
||||
cancel_event = threading.Event()
|
||||
with STREAMS_LOCK:
|
||||
config.STREAMS['stream_partial'] = q
|
||||
config.CANCEL_FLAGS['stream_partial'] = cancel_event
|
||||
STREAM_PARTIAL_TEXT['stream_partial'] = 'Python is a high-level programming language'
|
||||
|
||||
# Fake agent with session_id attribute
|
||||
class FakeAgent:
|
||||
session_id = 'sess_partial'
|
||||
def interrupt(self, _): pass
|
||||
config.AGENT_INSTANCES['stream_partial'] = FakeAgent()
|
||||
|
||||
result = cancel_stream('stream_partial')
|
||||
|
||||
assert result is True
|
||||
|
||||
# Reload the session and check messages
|
||||
from api.models import Session
|
||||
saved = Session.load('sess_partial')
|
||||
assert saved is not None
|
||||
|
||||
msg_contents = [m.get('content', '') for m in saved.messages]
|
||||
# Should have: user message, partial assistant content, cancel marker
|
||||
assert any('Python is a high-level programming language' in c for c in msg_contents), (
|
||||
f"Partial text not found in session messages: {msg_contents}"
|
||||
)
|
||||
assert any('*Task cancelled.*' in c for c in msg_contents), (
|
||||
"Cancel marker missing from session messages"
|
||||
)
|
||||
# Partial message should NOT have _error=True (it's real content)
|
||||
partial_msg = next(m for m in saved.messages
|
||||
if 'Python is a high-level' in m.get('content', ''))
|
||||
assert partial_msg.get('_partial') is True
|
||||
assert not partial_msg.get('_error')
|
||||
# Cancel marker should have _error=True
|
||||
cancel_msg = next(m for m in saved.messages if '*Task cancelled.*' in m.get('content', ''))
|
||||
assert cancel_msg.get('_error') is True
|
||||
|
||||
def test_cancel_stream_with_no_partial_text_still_saves_cancel_marker(self, tmp_path, monkeypatch):
|
||||
"""If no tokens were streamed before cancel, only the cancel marker is saved."""
|
||||
import queue
|
||||
from api.models import Session
|
||||
from api.streaming import cancel_stream
|
||||
|
||||
session_dir = tmp_path / 'sessions'
|
||||
session_dir.mkdir()
|
||||
import api.models as _models
|
||||
monkeypatch.setattr(config, 'SESSION_DIR', session_dir)
|
||||
monkeypatch.setattr(config, 'SESSION_INDEX_FILE', session_dir / '_index.json')
|
||||
monkeypatch.setattr(_models, 'SESSION_DIR', session_dir)
|
||||
monkeypatch.setattr(_models, 'SESSION_INDEX_FILE', session_dir / '_index.json')
|
||||
config.SESSIONS.clear()
|
||||
_models.SESSIONS.clear()
|
||||
|
||||
s = Session(session_id='sess_nopartial', title='Test')
|
||||
s.messages.append({'role': 'user', 'content': 'Hello'})
|
||||
s.active_stream_id = 'stream_nopartial'
|
||||
s.save()
|
||||
config.SESSIONS['sess_nopartial'] = s
|
||||
|
||||
q = queue.Queue()
|
||||
cancel_event = threading.Event()
|
||||
with STREAMS_LOCK:
|
||||
config.STREAMS['stream_nopartial'] = q
|
||||
config.CANCEL_FLAGS['stream_nopartial'] = cancel_event
|
||||
STREAM_PARTIAL_TEXT['stream_nopartial'] = '' # empty — cancel before any tokens
|
||||
|
||||
class FakeAgent:
|
||||
session_id = 'sess_nopartial'
|
||||
def interrupt(self, _): pass
|
||||
config.AGENT_INSTANCES['stream_nopartial'] = FakeAgent()
|
||||
|
||||
cancel_stream('stream_nopartial')
|
||||
|
||||
saved = Session.load('sess_nopartial')
|
||||
msg_contents = [m.get('content', '') for m in saved.messages]
|
||||
assert any('*Task cancelled.*' in c for c in msg_contents)
|
||||
# No extra partial message when there was nothing streamed
|
||||
assert not any(m.get('_partial') for m in saved.messages), (
|
||||
"Should not add partial message when no tokens were streamed"
|
||||
)
|
||||
|
||||
def test_cancel_stream_strips_thinking_markup_from_partial(self, tmp_path, monkeypatch):
|
||||
"""Thinking blocks in partial text are stripped before saving."""
|
||||
import queue
|
||||
from api.models import Session
|
||||
from api.streaming import cancel_stream
|
||||
|
||||
session_dir = tmp_path / 'sessions'
|
||||
session_dir.mkdir()
|
||||
import api.models as _models
|
||||
monkeypatch.setattr(config, 'SESSION_DIR', session_dir)
|
||||
monkeypatch.setattr(config, 'SESSION_INDEX_FILE', session_dir / '_index.json')
|
||||
monkeypatch.setattr(_models, 'SESSION_DIR', session_dir)
|
||||
monkeypatch.setattr(_models, 'SESSION_INDEX_FILE', session_dir / '_index.json')
|
||||
config.SESSIONS.clear()
|
||||
_models.SESSIONS.clear()
|
||||
|
||||
s = Session(session_id='sess_thinking', title='Test')
|
||||
s.messages.append({'role': 'user', 'content': 'Think about this'})
|
||||
s.active_stream_id = 'stream_thinking'
|
||||
s.save()
|
||||
config.SESSIONS['sess_thinking'] = s
|
||||
|
||||
q = queue.Queue()
|
||||
cancel_event = threading.Event()
|
||||
with STREAMS_LOCK:
|
||||
config.STREAMS['stream_thinking'] = q
|
||||
config.CANCEL_FLAGS['stream_thinking'] = cancel_event
|
||||
STREAM_PARTIAL_TEXT['stream_thinking'] = (
|
||||
'<think>internal reasoning here</think>\nThe answer is 42'
|
||||
)
|
||||
|
||||
class FakeAgent:
|
||||
session_id = 'sess_thinking'
|
||||
def interrupt(self, _): pass
|
||||
config.AGENT_INSTANCES['stream_thinking'] = FakeAgent()
|
||||
|
||||
cancel_stream('stream_thinking')
|
||||
|
||||
saved = Session.load('sess_thinking')
|
||||
partial_msg = next(
|
||||
(m for m in saved.messages if m.get('_partial')), None
|
||||
)
|
||||
assert partial_msg is not None, "Partial message should be saved when content remains after stripping"
|
||||
assert '<think>' not in partial_msg['content'], "Closed thinking block should be stripped"
|
||||
assert 'The answer is 42' in partial_msg['content'], "Visible content should be preserved"
|
||||
|
||||
def test_cancel_stream_strips_unclosed_think_tag(self, tmp_path, monkeypatch):
|
||||
"""The common cancel-mid-reasoning case: <think> block without a closing tag."""
|
||||
import queue
|
||||
from api.models import Session
|
||||
from api.streaming import cancel_stream
|
||||
|
||||
session_dir = tmp_path / 'sessions'
|
||||
session_dir.mkdir()
|
||||
import api.models as _models
|
||||
monkeypatch.setattr(config, 'SESSION_DIR', session_dir)
|
||||
monkeypatch.setattr(config, 'SESSION_INDEX_FILE', session_dir / '_index.json')
|
||||
monkeypatch.setattr(_models, 'SESSION_DIR', session_dir)
|
||||
monkeypatch.setattr(_models, 'SESSION_INDEX_FILE', session_dir / '_index.json')
|
||||
config.SESSIONS.clear()
|
||||
_models.SESSIONS.clear()
|
||||
|
||||
s = Session(session_id='sess_unclosed', title='Test')
|
||||
s.messages.append({'role': 'user', 'content': 'Please reason step by step'})
|
||||
s.active_stream_id = 'stream_unclosed'
|
||||
s.save()
|
||||
config.SESSIONS['sess_unclosed'] = s
|
||||
|
||||
q = queue.Queue()
|
||||
cancel_event = threading.Event()
|
||||
with STREAMS_LOCK:
|
||||
config.STREAMS['stream_unclosed'] = q
|
||||
config.CANCEL_FLAGS['stream_unclosed'] = cancel_event
|
||||
# Simulates user hitting Stop mid-reasoning — <think> never closed
|
||||
STREAM_PARTIAL_TEXT['stream_unclosed'] = (
|
||||
'<think>\nStep 1: consider the problem...\nStep 2: the user wants'
|
||||
)
|
||||
|
||||
class FakeAgent:
|
||||
session_id = 'sess_unclosed'
|
||||
def interrupt(self, _): pass
|
||||
config.AGENT_INSTANCES['stream_unclosed'] = FakeAgent()
|
||||
|
||||
cancel_stream('stream_unclosed')
|
||||
|
||||
saved = Session.load('sess_unclosed')
|
||||
# The entire content was inside an unclosed <think> block — nothing visible
|
||||
# remains after stripping, so no _partial message should be saved
|
||||
partial_msg = next((m for m in saved.messages if m.get('_partial')), None)
|
||||
assert partial_msg is None, (
|
||||
"Unclosed think block with no visible content should not produce a partial message"
|
||||
)
|
||||
# Cancel marker should still be present
|
||||
assert any('Task cancelled' in m.get('content', '') for m in saved.messages)
|
||||
|
||||
|
||||
class TestPartialMessageInContext:
|
||||
|
||||
def test_partial_message_included_in_api_sanitization(self):
|
||||
"""Partial messages (_partial=True) are included in API history (model should see them)."""
|
||||
from api.streaming import _sanitize_messages_for_api
|
||||
|
||||
messages = [
|
||||
{'role': 'user', 'content': 'Tell me about Python'},
|
||||
{'role': 'assistant', 'content': 'Python is a high-level', '_partial': True},
|
||||
{'role': 'assistant', 'content': '*Task cancelled.*', '_error': True},
|
||||
]
|
||||
clean = _sanitize_messages_for_api(messages)
|
||||
roles = [m['role'] for m in clean]
|
||||
contents = [m.get('content', '') for m in clean]
|
||||
|
||||
# User message and partial assistant message should be included
|
||||
assert 'user' in roles
|
||||
assert any('Python is a high-level' in c for c in contents), (
|
||||
"Partial assistant content should be in API context so model can continue from it"
|
||||
)
|
||||
# Cancel marker (_error=True) should be excluded
|
||||
assert not any('Task cancelled' in c for c in contents), (
|
||||
"Cancel marker with _error=True must be stripped from API context"
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user