Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01404ac062 | ||
|
|
6c343aff84 | ||
|
|
7d1aa2e261 | ||
|
|
3ce7844a7a | ||
|
|
ad8e10304c | ||
|
|
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 | ||
|
|
9a3dc10d93 | ||
|
|
25d38a467a | ||
|
|
6c5911a79f |
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
|
||||
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -16,15 +16,26 @@ 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
|
||||
|
||||
# Local reference clones — never committed
|
||||
docs/
|
||||
# Local reference clones — never committed (except tracked design/UI-UX reference pages)
|
||||
docs/*
|
||||
!docs/ui-ux/
|
||||
!docs/ui-ux/**
|
||||
|
||||
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.
|
||||
@@ -7,10 +7,10 @@
|
||||
>
|
||||
> Keep this document updated as architecture changes are made.
|
||||
|
||||
> Current shipped build: `v0.50.36-local.1` (April 14, 2026).
|
||||
> Current shipped build: `v0.50.36-local.1` (April 16, 2026).
|
||||
> Baseline: upstream `nesquena/hermes-webui` `v0.50.36`.
|
||||
> Intentional local delta: first-time password enablement from Settings immediately issues a `hermes_session` cookie so the current browser remains signed in. The previous `Assistant Reply Language` customization has been removed, and legacy `assistant_language` settings are filtered out on load/save.
|
||||
> Automated coverage: 1059 passing tests.
|
||||
> Intentional local delta: first-time password enablement from Settings immediately issues a `hermes_session` cookie so the current browser remains signed in. The previous `Assistant Reply Language` customization has been removed, legacy `assistant_language` settings are filtered out on load/save, the workspace panel closed/open state is preloaded via a `documentElement` dataset marker before `style.css` paints to avoid a first-load desktop flash, transcript disclosure cards now animate caret rotation and body expansion with transitionable `max-height`/`opacity` states instead of `display:none/block`, and thinking cards now share the same rounded bordered card chrome as tool cards while keeping their gold palette.
|
||||
> Automated coverage: 1353 tests collected (`pytest tests/ --collect-only -q`).
|
||||
|
||||
---
|
||||
|
||||
@@ -23,6 +23,11 @@ and a demand-driven right panel used for workspace browsing and preview surfaces
|
||||
The right panel is closed by default on desktop and opens only when it is actively
|
||||
being used for browsing or previewing content.
|
||||
|
||||
To prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the
|
||||
saved workspace panel state into `document.documentElement.dataset.workspacePanel`
|
||||
before the main stylesheet loads. Desktop CSS honors that preload marker immediately,
|
||||
and `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.
|
||||
|
||||
The design philosophy is deliberately minimal. There is no build step, no bundler, no
|
||||
frontend framework. The Python server is split into a routing shell (server.py) and
|
||||
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
|
||||
@@ -1624,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)
|
||||
|
||||
1870
CHANGELOG.md
1870
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 14, 2026) — 1195 tests, 1195 passing
|
||||
> Local delta: enabling password from Settings keeps the current browser signed in; the former Assistant Reply Language enhancement has been removed.
|
||||
> Tests: 1059 total (1059 passing, 0 failures)
|
||||
> Last updated: v0.50.211 (April 25, 2026) — 2276 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.
|
||||
|
||||
48
TESTING.md
48
TESTING.md
@@ -8,8 +8,10 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 1195 total (1195 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard.
|
||||
> Automated coverage: 2276 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1686,6 +1688,13 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
- Click a directory toggle arrow (▸) → expands in-place showing children.
|
||||
- Click again (▾) → collapses. Double-click navigates into it (breadcrumb view).
|
||||
- If model returns thinking blocks (Claude extended thinking), verify collapsible gold card appears above response.
|
||||
- Verify the thinking card has a tinted background, visible border, and rounded corners like a tool card, but in the gold thinking palette.
|
||||
- Open and close a thinking card. Verify the caret rotation and the content reveal both animate smoothly instead of snapping open.
|
||||
|
||||
### UI Polish: Tool Card Disclosure Animation
|
||||
- Trigger a response with at least one completed tool call card.
|
||||
- Open and close the tool call card. Verify the caret rotates smoothly and the args/result section animates open and closed instead of appearing instantly.
|
||||
- If a turn has 2+ tool cards, use "Expand all / Collapse all" and verify the same smooth animation applies to every card in the group.
|
||||
|
||||
### Sprint 19: Auth + Security
|
||||
- No password set: everything works as normal. No login page.
|
||||
@@ -1740,8 +1749,41 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.50.44, April 14, 2026*
|
||||
*Total automated tests: 1195 (1195 passing, 0 failures)*
|
||||
## 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>/*
|
||||
|
||||
55
api/agent_sessions.py
Normal file
55
api/agent_sessions.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Shared helpers for reading Hermes Agent sessions from state.db."""
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def read_importable_agent_session_rows(db_path: Path, limit: int = 200, log=None) -> list[dict]:
|
||||
"""Return non-WebUI agent sessions that have readable message rows.
|
||||
|
||||
Hermes Agent can create rows in ``state.db.sessions`` before a session has
|
||||
any messages. WebUI cannot import those rows, so both the regular
|
||||
``/api/sessions`` path and the gateway SSE watcher must filter them the
|
||||
same way.
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
log = log or logger
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
# Older Hermes Agent versions may not have source tracking. Without a
|
||||
# source column we cannot safely distinguish WebUI rows from agent rows.
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
session_cols = {row[1] for row in cur.fetchall()}
|
||||
if 'source' not in session_cols:
|
||||
log.warning(
|
||||
"agent session listing skipped: state.db at %s has no 'source' column "
|
||||
"(older hermes-agent?). Agent sessions unavailable. "
|
||||
"Upgrade hermes-agent to fix this.",
|
||||
db_path,
|
||||
)
|
||||
return []
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
COUNT(m.id) AS actual_message_count,
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
WHERE s.source IS NOT NULL AND s.source != 'webui'
|
||||
GROUP BY s.id
|
||||
HAVING COUNT(m.id) > 0
|
||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
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
|
||||
879
api/config.py
879
api/config.py
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,12 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import HOME
|
||||
from api.agent_sessions import read_importable_agent_session_rows
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,33 +55,18 @@ def _get_agent_sessions_from_db() -> list:
|
||||
return []
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
WHERE s.source IS NOT NULL AND s.source != 'webui'
|
||||
GROUP BY s.id
|
||||
HAVING COUNT(m.id) > 0
|
||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
||||
LIMIT 200
|
||||
""")
|
||||
sessions = []
|
||||
for row in cur.fetchall():
|
||||
sessions.append({
|
||||
'session_id': row['id'],
|
||||
'title': row['title'] or 'Agent Session',
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': row['last_activity'] or row['started_at'],
|
||||
'source': row['source'] or 'cli',
|
||||
})
|
||||
return sessions
|
||||
sessions = []
|
||||
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
|
||||
sessions.append({
|
||||
'session_id': row['id'],
|
||||
'title': row['title'] or 'Agent Session',
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': row['last_activity'] or row['started_at'],
|
||||
'source': row['source'] or 'cli',
|
||||
})
|
||||
return sessions
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -119,6 +104,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()
|
||||
|
||||
@@ -42,10 +42,11 @@ def _security_headers(handler):
|
||||
handler.send_header('Referrer-Policy', 'same-origin')
|
||||
handler.send_header(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"default-src 'self' https://*.cloudflareaccess.com; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"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' https://*.cloudflareaccess.com; "
|
||||
"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()
|
||||
|
||||
187
api/metering.py
Normal file
187
api/metering.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Hermes Web UI -- Streaming performance metering.
|
||||
|
||||
Tracks Tokens Per Second (TPS) across all active WebUI sessions, and the
|
||||
HIGH/LOW TPS values observed over the past 60 minutes. Metering data is
|
||||
emitted via SSE events so the header label can update live during a stream.
|
||||
|
||||
Architecture
|
||||
────────────
|
||||
Each streaming session is tracked independently. TPS per session is:
|
||||
|
||||
session_tps = total_tokens / (last_token_ts - first_token_ts)
|
||||
|
||||
The global tps is the average of all currently active sessions' TPS values.
|
||||
This correctly represents the system's real-time capacity regardless of how
|
||||
many sessions are running or how long each has been streaming.
|
||||
|
||||
For HIGH/LOW tracking, every stats snapshot records the current global tps
|
||||
(only when > 0 — idle periods are skipped) into a rolling 60-minute history.
|
||||
The max/min of that history gives the peak throughput observed over the past hour.
|
||||
|
||||
The ticker in streaming.py calls get_interval() — it returns 1.0 when sessions
|
||||
are actively receiving tokens so the header updates at 1 Hz, and 10.0 when idle
|
||||
so the ticker exits and no idle readings are emitted.
|
||||
|
||||
Usage from api/streaming.py
|
||||
─────────────────────────────
|
||||
from api.metering import meter
|
||||
|
||||
meter().begin_session(stream_id) # stream starts
|
||||
meter().record_token(stream_id, running_output) # per output token
|
||||
meter().record_reasoning(stream_id, running_reasoning_len) # per reasoning token
|
||||
|
||||
The SSE `metering` event payload:
|
||||
{
|
||||
"tps": 47.3, # average TPS across active sessions (real-time)
|
||||
"high": 52.1, # highest average TPS observed in the past 60 minutes
|
||||
"low": 31.4, # lowest average TPS (excl. readings < 1 tps, to ignore idle)
|
||||
"active": 1, # sessions currently streaming
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
_HOUR_SECS = 3600.0 # rolling window for HIGH/LOW tracking
|
||||
_STALE_SECS = 60.0 # consider a session inactive after this
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SessionMeter:
|
||||
output_tokens: int = 0
|
||||
reasoning_tokens: int = 0
|
||||
first_token_ts: float = 0.0 # time.monotonic() of first token received
|
||||
last_token_ts: float = 0.0 # time.monotonic() of last token received
|
||||
|
||||
def total_tokens(self) -> int:
|
||||
return self.output_tokens + self.reasoning_tokens
|
||||
|
||||
def tps(self) -> float:
|
||||
if self.first_token_ts == 0.0 or self.last_token_ts <= self.first_token_ts:
|
||||
return 0.0
|
||||
return self.total_tokens() / (self.last_token_ts - self.first_token_ts)
|
||||
|
||||
|
||||
class GlobalMeter:
|
||||
"""Thread-safe global streaming meter.
|
||||
|
||||
Tracks per-session TPS, averages them for a global tps, and maintains a
|
||||
60-minute rolling history of global tps snapshots for HIGH/LOW reporting.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
'_lock',
|
||||
'_sessions', # stream_id -> _SessionMeter
|
||||
'_readings', # [(monotonic_ts, tps), ...] rolling 60-minute history
|
||||
'_window_start', # monotonic ts of current window
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._sessions: dict[str, _SessionMeter] = {}
|
||||
self._readings: list[tuple[float, float]] = []
|
||||
self._window_start: float = time.monotonic()
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def begin_session(self, stream_id: str) -> None:
|
||||
with self._lock:
|
||||
self._sessions[stream_id] = _SessionMeter()
|
||||
|
||||
def get_interval(self) -> float:
|
||||
"""Return 1.0 when sessions are actively receiving tokens, 10.0 when idle.
|
||||
|
||||
Used by the streaming ticker to run at 1 Hz during work and exit when
|
||||
there is nothing to measure.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
# Only count sessions that have received at least one token recently.
|
||||
active_sids = {
|
||||
sid for sid, s in self._sessions.items()
|
||||
if s.first_token_ts > 0 and (now - s.last_token_ts) <= _STALE_SECS
|
||||
}
|
||||
return 1.0 if active_sids else 10.0
|
||||
|
||||
def record_token(self, stream_id: str, running_output_tokens: int) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
s = self._sessions.get(stream_id)
|
||||
if s is None:
|
||||
return
|
||||
if s.first_token_ts == 0.0:
|
||||
s.first_token_ts = now
|
||||
s.last_token_ts = now
|
||||
s.output_tokens = running_output_tokens
|
||||
|
||||
def record_reasoning(self, stream_id: str, running_reasoning_tokens: int) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
s = self._sessions.get(stream_id)
|
||||
if s is None:
|
||||
return
|
||||
if s.first_token_ts == 0.0:
|
||||
s.first_token_ts = now
|
||||
s.last_token_ts = now
|
||||
s.reasoning_tokens = running_reasoning_tokens
|
||||
|
||||
def end_session(self, stream_id: str, final_output_tokens: int, input_tokens: int = 0) -> None:
|
||||
with self._lock:
|
||||
self._sessions.pop(stream_id, None)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
# Prune stale sessions
|
||||
stale = [
|
||||
sid for sid, s in self._sessions.items()
|
||||
if s.first_token_ts > 0 and (now - s.last_token_ts) > _STALE_SECS
|
||||
]
|
||||
for sid in stale:
|
||||
self._sessions.pop(sid, None)
|
||||
|
||||
# Reset window if everything went stale
|
||||
if not self._sessions:
|
||||
self._window_start = now
|
||||
|
||||
# Compute global tps: average of per-session TPS values
|
||||
active = [s for s in self._sessions.values() if s.first_token_ts > 0]
|
||||
if active:
|
||||
global_tps = sum(s.tps() for s in active) / len(active)
|
||||
else:
|
||||
global_tps = 0.0
|
||||
|
||||
# Prune readings older than 1 hour
|
||||
cutoff = now - _HOUR_SECS
|
||||
self._readings = [(ts, v) for ts, v in self._readings if ts > cutoff]
|
||||
|
||||
# Only record this snapshot for HIGH/LOW if there is active work.
|
||||
# This prevents idle periods from flooding the history and keeps
|
||||
# HIGH/LOW meaningful for the past hour of actual throughput.
|
||||
if global_tps > 0:
|
||||
self._readings.append((now, global_tps))
|
||||
|
||||
# HIGH/LOW from the past hour (skip near-zero idle readings)
|
||||
active_readings = [v for _, v in self._readings if v >= 1.0]
|
||||
high = max(active_readings) if active_readings else 0.0
|
||||
low = min(active_readings) if active_readings else 0.0
|
||||
|
||||
return {
|
||||
'tps': round(global_tps, 1),
|
||||
'high': round(high, 1),
|
||||
'low': round(low, 1),
|
||||
'active': len(self._sessions),
|
||||
}
|
||||
|
||||
|
||||
# ── Module-level singleton ─────────────────────────────────────────────────────
|
||||
|
||||
_meter = GlobalMeter()
|
||||
|
||||
|
||||
def meter() -> GlobalMeter:
|
||||
return _meter
|
||||
585
api/models.py
585
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,296 @@ 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
|
||||
from api.agent_sessions import read_importable_agent_session_rows
|
||||
|
||||
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)
|
||||
|
||||
def _session_sort_timestamp(session):
|
||||
if isinstance(session, dict):
|
||||
return session.get('last_message_at') or session.get('updated_at') or 0
|
||||
return _last_message_timestamp(getattr(session, 'messages', None)) or getattr(session, 'updated_at', 0) or 0
|
||||
|
||||
|
||||
def _message_timestamp(message):
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
raw = message.get('_ts') or message.get('timestamp')
|
||||
try:
|
||||
return float(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _last_message_timestamp(messages):
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get('role') == 'tool':
|
||||
continue
|
||||
ts = _message_timestamp(message)
|
||||
if ts:
|
||||
return ts
|
||||
return None
|
||||
|
||||
|
||||
def _find_top_level_json_key(text, key):
|
||||
"""Return the byte offset of a top-level JSON object key, if present."""
|
||||
depth = 0
|
||||
i = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if ch == '"':
|
||||
start = i
|
||||
i += 1
|
||||
escaped = False
|
||||
chars = []
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if escaped:
|
||||
chars.append(c)
|
||||
escaped = False
|
||||
elif c == '\\':
|
||||
escaped = True
|
||||
elif c == '"':
|
||||
break
|
||||
else:
|
||||
chars.append(c)
|
||||
i += 1
|
||||
if i >= n:
|
||||
return None
|
||||
if depth == 1 and ''.join(chars) == key:
|
||||
j = i + 1
|
||||
while j < n and text[j] in ' \t\r\n':
|
||||
j += 1
|
||||
if j < n and text[j] == ':':
|
||||
return start
|
||||
elif ch in '{[':
|
||||
depth += 1
|
||||
elif ch in '}]':
|
||||
depth -= 1
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def _read_metadata_json_prefix(path, max_prefix_bytes=65536):
|
||||
"""Read only the metadata portion before the top-level messages array."""
|
||||
buf = ''
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
while len(buf.encode('utf-8')) < max_prefix_bytes:
|
||||
chunk = f.read(4096)
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
messages_pos = _find_top_level_json_key(buf, 'messages')
|
||||
if messages_pos is None:
|
||||
continue
|
||||
prefix = buf[:messages_pos].rstrip()
|
||||
if prefix.endswith(','):
|
||||
prefix = prefix[:-1].rstrip()
|
||||
return f'{prefix}\n}}'
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_index_message_count(session_id):
|
||||
"""Return the indexed message count without loading the full session file."""
|
||||
try:
|
||||
entries = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(entries, list):
|
||||
return None
|
||||
for entry in entries:
|
||||
if entry.get('session_id') != session_id:
|
||||
continue
|
||||
count = entry.get('message_count')
|
||||
if isinstance(count, int) and count >= 0:
|
||||
return count
|
||||
try:
|
||||
count = int(count)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return count if count >= 0 else None
|
||||
return None
|
||||
|
||||
|
||||
class Session:
|
||||
@@ -48,6 +315,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 +338,51 @@ 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
|
||||
self._metadata_message_count = None
|
||||
|
||||
@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')
|
||||
and not k.startswith('_')}
|
||||
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,15 +394,52 @@ 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. Read only up to the
|
||||
top-level "messages" field and synthesize a small metadata-only object.
|
||||
Falls back to load() for legacy or unexpected file layouts.
|
||||
"""
|
||||
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:
|
||||
prefix = _read_metadata_json_prefix(p)
|
||||
if not prefix:
|
||||
return cls.load(sid)
|
||||
parsed = json.loads(prefix)
|
||||
needed = {'session_id', 'title', 'created_at', 'updated_at'}
|
||||
if not needed.issubset(parsed.keys()):
|
||||
return cls.load(sid)
|
||||
parsed['messages'] = []
|
||||
parsed['tool_calls'] = []
|
||||
session = cls(**parsed)
|
||||
session._metadata_message_count = _lookup_index_message_count(sid)
|
||||
return session
|
||||
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,
|
||||
'workspace': self.workspace,
|
||||
'model': self.model,
|
||||
'message_count': len(self.messages),
|
||||
'message_count': (
|
||||
self._metadata_message_count
|
||||
if self._metadata_message_count is not None
|
||||
else len(self.messages)
|
||||
),
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
'last_message_at': _last_message_timestamp(self.messages) or self.updated_at,
|
||||
'pinned': self.pinned,
|
||||
'archived': self.archived,
|
||||
'project_id': self.project_id,
|
||||
@@ -110,14 +448,32 @@ 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).
|
||||
|
||||
Metadata-only loads intentionally do not populate the full-session cache.
|
||||
Otherwise a later full load could return a compact object with an empty
|
||||
messages list. 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)
|
||||
if s:
|
||||
return s
|
||||
else:
|
||||
s = Session.load(sid)
|
||||
if s:
|
||||
with LOCK:
|
||||
SESSIONS[sid] = s
|
||||
@@ -127,14 +483,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 +514,49 @@ 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'))
|
||||
]
|
||||
backfilled = []
|
||||
for i, s in enumerate(index):
|
||||
if 'last_message_at' not in s:
|
||||
full = Session.load(s.get('session_id'))
|
||||
if full:
|
||||
index[i] = full.compact()
|
||||
backfilled.append(full)
|
||||
if backfilled:
|
||||
try:
|
||||
_write_session_index(updates=backfilled)
|
||||
except Exception:
|
||||
logger.debug("Failed to persist last_message_at backfill")
|
||||
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()
|
||||
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
|
||||
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), _session_sort_timestamp(s)), 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:
|
||||
@@ -175,8 +576,13 @@ def all_sessions():
|
||||
logger.debug("Failed to load session from %s", p)
|
||||
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)]
|
||||
out.sort(key=lambda s: (getattr(s, 'pinned', False), _session_sort_timestamp(s)), reverse=True)
|
||||
_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'
|
||||
@@ -244,16 +650,11 @@ def get_cli_sessions() -> list:
|
||||
"""Read CLI sessions from the agent's SQLite store and return them as
|
||||
dicts in a format the WebUI sidebar can render alongside local sessions.
|
||||
|
||||
Returns empty list if the SQLite DB is missing, the sqlite3 module is
|
||||
unavailable, or any error occurs -- the bridge is purely additive and never
|
||||
crashes the WebUI.
|
||||
Returns empty list if the SQLite DB is missing or any error occurs -- the
|
||||
bridge is purely additive and never crashes the WebUI.
|
||||
"""
|
||||
import os
|
||||
cli_sessions = []
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
return cli_sessions
|
||||
|
||||
# Use the active WebUI profile's HERMES_HOME to find state.db.
|
||||
# The active profile is determined by what the user has selected in the UI
|
||||
@@ -282,46 +683,56 @@ def get_cli_sessions() -> list:
|
||||
_cli_profile = None # older agent -- fall back to no profile
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
WHERE s.source IS NOT NULL AND s.source != 'webui'
|
||||
GROUP BY s.id
|
||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
||||
LIMIT 200
|
||||
""")
|
||||
for row in cur.fetchall():
|
||||
sid = row['id']
|
||||
raw_ts = row['last_activity'] or row['started_at']
|
||||
# Prefer the CLI session's own profile from the DB; fall back to
|
||||
# the active CLI profile so sidebar filtering works either way.
|
||||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
|
||||
sid = row['id']
|
||||
raw_ts = row['last_activity'] or row['started_at']
|
||||
# Prefer the CLI session's own profile from the DB; fall back to
|
||||
# the active CLI profile so sidebar filtering works either way.
|
||||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
|
||||
_source = row['source'] or 'cli'
|
||||
_display_title = row['title'] or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _display_title,
|
||||
'workspace': str(get_last_workspace()),
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': raw_ts,
|
||||
'pinned': False,
|
||||
'archived': False,
|
||||
'project_id': None,
|
||||
'profile': profile,
|
||||
'source_tag': _source,
|
||||
'is_cli_session': True,
|
||||
})
|
||||
except Exception:
|
||||
# DB schema changed, locked, or corrupted -- silently degrade
|
||||
_source = row['source'] or 'cli'
|
||||
_title = row['title']
|
||||
if not _title and _source == 'cron' and sid.startswith('cron_'):
|
||||
# Extract job_id from session ID (cron_{job_id}_{timestamp})
|
||||
# and look up the human-friendly job name from jobs.json
|
||||
parts = sid.split('_')
|
||||
if len(parts) >= 3:
|
||||
_job_id = parts[1]
|
||||
try:
|
||||
_jobs_path = hermes_home / 'cron' / 'jobs.json'
|
||||
if _jobs_path.exists():
|
||||
import json as _json
|
||||
_jobs_data = _json.loads(_jobs_path.read_text())
|
||||
for _j in _jobs_data.get('jobs', []):
|
||||
if _j.get('id') == _job_id:
|
||||
_title = _j.get('name') or _title
|
||||
break
|
||||
except Exception:
|
||||
pass # degrade gracefully
|
||||
_display_title = _title or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _display_title,
|
||||
'workspace': str(get_last_workspace()),
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': raw_ts,
|
||||
'pinned': False,
|
||||
'archived': False,
|
||||
'project_id': None,
|
||||
'profile': profile,
|
||||
'source_tag': _source,
|
||||
'is_cli_session': True,
|
||||
})
|
||||
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
|
||||
|
||||
@@ -29,6 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SUPPORTED_PROVIDER_SETUPS = {
|
||||
# ── Easy start ──────────────────────────────────────────────────────
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
"env_var": "OPENROUTER_API_KEY",
|
||||
@@ -37,6 +38,8 @@ _SUPPORTED_PROVIDER_SETUPS = {
|
||||
"models": [
|
||||
{"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS
|
||||
],
|
||||
"category": "easy_start",
|
||||
"quick": True,
|
||||
},
|
||||
"anthropic": {
|
||||
"label": "Anthropic",
|
||||
@@ -44,6 +47,7 @@ _SUPPORTED_PROVIDER_SETUPS = {
|
||||
"default_model": "claude-sonnet-4.6",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("anthropic", [])),
|
||||
"category": "easy_start",
|
||||
},
|
||||
"openai": {
|
||||
"label": "OpenAI",
|
||||
@@ -52,6 +56,26 @@ _SUPPORTED_PROVIDER_SETUPS = {
|
||||
"default_base_url": "https://api.openai.com/v1",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("openai", [])),
|
||||
"category": "easy_start",
|
||||
},
|
||||
# ── Open / self-hosted ─────────────────────────────────────────────
|
||||
"ollama": {
|
||||
"label": "Ollama",
|
||||
"env_var": "OLLAMA_API_KEY",
|
||||
"default_model": "qwen3:32b",
|
||||
"default_base_url": "http://localhost:11434/v1",
|
||||
"requires_base_url": True,
|
||||
"models": [],
|
||||
"category": "self_hosted",
|
||||
},
|
||||
"lmstudio": {
|
||||
"label": "LM Studio",
|
||||
"env_var": "LMSTUDIO_API_KEY",
|
||||
"default_model": "gpt-4o-mini",
|
||||
"default_base_url": "http://localhost:1234/v1",
|
||||
"requires_base_url": True,
|
||||
"models": [],
|
||||
"category": "self_hosted",
|
||||
},
|
||||
"custom": {
|
||||
"label": "Custom OpenAI-compatible",
|
||||
@@ -59,9 +83,59 @@ _SUPPORTED_PROVIDER_SETUPS = {
|
||||
"default_model": "gpt-4o-mini",
|
||||
"requires_base_url": True,
|
||||
"models": [],
|
||||
"category": "self_hosted",
|
||||
},
|
||||
# ── Specialized / extended ──────────────────────────────────────────
|
||||
"gemini": {
|
||||
"label": "Google Gemini",
|
||||
"env_var": "GOOGLE_API_KEY",
|
||||
"default_model": "gemini-3.1-pro-preview",
|
||||
"default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"requires_base_url": False,
|
||||
# _PROVIDER_MODELS in api/config.py is keyed under "google" even though
|
||||
# the agent's alias map normalizes "google" → "gemini". Use the catalog
|
||||
# key here so the wizard surfaces the actual model list.
|
||||
"models": list(_PROVIDER_MODELS.get("google", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
"deepseek": {
|
||||
"label": "DeepSeek",
|
||||
"env_var": "DEEPSEEK_API_KEY",
|
||||
"default_model": "deepseek-chat-v3-0324",
|
||||
"default_base_url": "https://api.deepseek.com/v1",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("deepseek", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
"mistralai": {
|
||||
"label": "Mistral",
|
||||
"env_var": "MISTRAL_API_KEY",
|
||||
"default_model": "mistral-large-latest",
|
||||
"default_base_url": "https://api.mistral.ai/v1",
|
||||
"requires_base_url": False,
|
||||
# No catalog entry for mistralai today — wizard shows a free-text input.
|
||||
"models": list(_PROVIDER_MODELS.get("mistralai", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
"x-ai": {
|
||||
"label": "xAI (Grok)",
|
||||
"env_var": "XAI_API_KEY",
|
||||
"default_model": "grok-4.20",
|
||||
"default_base_url": "https://api.x.ai/v1",
|
||||
"requires_base_url": False,
|
||||
# Agent normalizes "x-ai" → "xai"; _PROVIDER_MODELS is also keyed "xai"
|
||||
# when populated, so check both keys for forward-compatibility.
|
||||
"models": list(_PROVIDER_MODELS.get("xai", []) or _PROVIDER_MODELS.get("x-ai", [])),
|
||||
"category": "specialized",
|
||||
},
|
||||
}
|
||||
|
||||
_PROVIDER_CATEGORIES = [
|
||||
{"id": "easy_start", "label": "Easy start", "order": 0},
|
||||
{"id": "self_hosted", "label": "Open / self-hosted", "order": 1},
|
||||
{"id": "specialized", "label": "Specialized", "order": 2},
|
||||
]
|
||||
|
||||
_UNSUPPORTED_PROVIDER_NOTE = (
|
||||
"OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub "
|
||||
"Copilot are still terminal-first. Use `hermes model` for those flows."
|
||||
@@ -230,34 +304,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 +348,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
|
||||
|
||||
@@ -379,10 +458,24 @@ def _build_setup_catalog(cfg: dict) -> dict:
|
||||
"default_base_url": meta.get("default_base_url") or "",
|
||||
"requires_base_url": bool(meta.get("requires_base_url")),
|
||||
"models": list(meta.get("models", [])),
|
||||
"quick": provider_id == "openrouter",
|
||||
"category": meta.get("category", "easy_start"),
|
||||
"quick": meta.get("quick", False),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort providers by category order, then alphabetically within each category.
|
||||
cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES}
|
||||
providers.sort(key=lambda p: (cat_order.get(p["category"], 99), p["label"]))
|
||||
|
||||
# Group providers by category for the frontend.
|
||||
categories = []
|
||||
for cat in sorted(_PROVIDER_CATEGORIES, key=lambda c: c["order"]):
|
||||
categories.append({
|
||||
"id": cat["id"],
|
||||
"label": cat["label"],
|
||||
"providers": [p["id"] for p in providers if p["category"] == cat["id"]],
|
||||
})
|
||||
|
||||
# Flag whether the currently-configured provider is OAuth-based (not in the
|
||||
# API-key flow). The frontend uses this to show a confirmation card instead
|
||||
# of a key input when the user has already authenticated via 'hermes auth'.
|
||||
@@ -392,6 +485,7 @@ def _build_setup_catalog(cfg: dict) -> dict:
|
||||
|
||||
return {
|
||||
"providers": providers,
|
||||
"categories": categories,
|
||||
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
|
||||
"current_is_oauth": current_is_oauth,
|
||||
"current": {
|
||||
@@ -424,11 +518,52 @@ def get_onboarding_status() -> dict:
|
||||
auto_completed = skip_requested # unconditional: operator says skip, we skip
|
||||
|
||||
# Auto-complete for existing Hermes users: if config.yaml already exists
|
||||
# AND the system is chat_ready, treat onboarding as done. These users
|
||||
# configured Hermes via the CLI before the Web UI existed; they must never
|
||||
# be shown the first-run wizard — it would silently overwrite their config.
|
||||
# AND the provider is configured (or the system is chat_ready), treat onboarding
|
||||
# as done. These users configured Hermes via the CLI before the Web UI existed;
|
||||
# they must never be shown the first-run wizard — it would silently overwrite their
|
||||
# config. We use provider_configured (not chat_ready) so that users with
|
||||
# non-wizard providers (ollama-cloud, deepseek, xai, kimi, etc.) are not forced
|
||||
# through the wizard just because their provider doesn't have a detectable API key
|
||||
# — the wizard cannot represent their provider and would overwrite their config
|
||||
# with whichever wizard-supported provider they accidentally select.
|
||||
config_exists = Path(_get_config_path()).exists()
|
||||
config_auto_completed = config_exists and bool(runtime.get("chat_ready"))
|
||||
|
||||
# For providers not in the wizard's quick-setup list (e.g. ollama-cloud, deepseek,
|
||||
# xai, kimi-k2.6), the wizard can never help — it only knows how to configure
|
||||
# openrouter/anthropic/openai/google/custom. If such a user has a configured
|
||||
# provider + model in config.yaml, showing the wizard would only confuse them
|
||||
# (or worse, let them accidentally overwrite their config with gpt-5.4-mini).
|
||||
_current_provider = str(
|
||||
(cfg.get("model", {}) or {}).get("provider", "") if isinstance(cfg.get("model"), dict)
|
||||
else ""
|
||||
).strip().lower()
|
||||
_is_non_wizard_provider = bool(
|
||||
_current_provider and _current_provider not in _SUPPORTED_PROVIDER_SETUPS
|
||||
)
|
||||
|
||||
config_auto_completed = config_exists and (
|
||||
bool(runtime.get("chat_ready"))
|
||||
or (_is_non_wizard_provider and bool(runtime.get("provider_configured")))
|
||||
)
|
||||
|
||||
# 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,
|
||||
@@ -518,12 +653,10 @@ def apply_onboarding_setup(body: dict) -> dict:
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = _normalize_model_for_provider(provider, model)
|
||||
|
||||
if provider == "custom":
|
||||
if provider_meta.get("requires_base_url"):
|
||||
model_cfg["base_url"] = base_url
|
||||
elif provider == "openai":
|
||||
model_cfg["base_url"] = (
|
||||
provider_meta.get("default_base_url") or "https://api.openai.com/v1"
|
||||
)
|
||||
elif provider_meta.get("default_base_url"):
|
||||
model_cfg["base_url"] = provider_meta["default_base_url"]
|
||||
else:
|
||||
model_cfg.pop("base_url", None)
|
||||
|
||||
|
||||
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)
|
||||
1177
api/routes.py
1177
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():
|
||||
|
||||
1696
api/streaming.py
1696
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:
|
||||
|
||||
@@ -8,20 +8,27 @@ services:
|
||||
- "127.0.0.1:8787:8787"
|
||||
# - "8787:8787"
|
||||
volumes:
|
||||
# Within the containe the tool expects to find the .hermes location at /home/hermeswebui/.hermes, so we mount it there; this allows you to manage agent profiles and other features that rely on the .hermes directory from your host machine, make sure to adapt the path if your HERMES_HOME is different
|
||||
# Mount hermes home for agent features and profile management
|
||||
# Mount your Hermes home directory into the container.
|
||||
# The default (${HOME}/.hermes) works on both macOS (/Users/<you>/.hermes)
|
||||
# and Linux (/home/<you>/.hermes) — no change needed for standard installs.
|
||||
# Only set HERMES_HOME explicitly if your .hermes lives somewhere non-standard.
|
||||
# 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:
|
||||
# Modify the UID and GID to match your user; docker compose starts as root by default, but the container will drop privileges to the specified UID/GID
|
||||
# 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:
|
||||
# echo "UID=$(id -u)" >> .env
|
||||
# echo "GID=$(id -g)" >> .env
|
||||
# Without this, the container may not be able to read your mounted files.
|
||||
- WANTED_UID=${UID:-1000}
|
||||
- WANTED_GID=${GID:-1000}
|
||||
# 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,6 +59,34 @@ 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 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.
|
||||
# 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
|
||||
# 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
|
||||
echo "-- Auto-detected workspace UID: $_detected_uid (from /workspace)"
|
||||
WANTED_UID=$_detected_uid
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
WANTED_UID=${WANTED_UID:-1024}
|
||||
write_worldtmpfile $it "$WANTED_UID"
|
||||
echo "-- WANTED_UID: \"${WANTED_UID}\""
|
||||
@@ -67,6 +95,30 @@ 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 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
|
||||
echo "-- Auto-detected workspace GID: $_detected_gid (from /workspace)"
|
||||
WANTED_GID=$_detected_gid
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
WANTED_GID=${WANTED_GID:-1024}
|
||||
write_worldtmpfile $it "$WANTED_GID"
|
||||
echo "-- WANTED_GID: \"${WANTED_GID}\""
|
||||
@@ -187,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"
|
||||
@@ -235,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
|
||||
|
||||
838
docs/ui-ux/index.html
Normal file
838
docs/ui-ux/index.html
Normal file
@@ -0,0 +1,838 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="slate">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hermes WebUI — Messages UI Inventory</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<!-- Real app stylesheet -->
|
||||
<link rel="stylesheet" href="../../static/style.css">
|
||||
<!-- Prism (same theme the app pulls at runtime) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
|
||||
<!-- KaTeX -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||
<style>
|
||||
/* Showcase scaffold — styles only for the doc chrome. Everything inside
|
||||
.messages uses the real app CSS unchanged. */
|
||||
/* Real app CSS makes <body> a fixed-height flex shell. Undo that so this
|
||||
doc page can scroll normally with a stacked header + main. */
|
||||
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
|
||||
.doc-main{display:block;}
|
||||
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
|
||||
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
|
||||
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
|
||||
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
|
||||
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
|
||||
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
|
||||
.doc-main{max-width:1100px;margin:0 auto;padding:24px 24px 120px;}
|
||||
.doc-section{margin:40px 0 8px;padding-top:20px;border-top:1px dashed var(--border);}
|
||||
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
|
||||
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
|
||||
.doc-h{font-size:18px;font-weight:700;color:var(--text);margin:4px 0 4px;}
|
||||
.doc-note{font-size:12px;color:var(--muted);line-height:1.55;max-width:760px;margin-bottom:10px;}
|
||||
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:12px;padding:4px 6px;margin:12px 0;}
|
||||
.doc-label{position:absolute;top:-9px;left:12px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 8px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
|
||||
/* Force-show hover-only affordances inside explicitly flagged demos */
|
||||
.force-show .msg-actions,
|
||||
.force-show .msg-time,
|
||||
.force-show .msg-foot{opacity:1 !important;}
|
||||
/* Chat demo container mimics the app's .messages scroll wrapper but not fullscreen */
|
||||
.messages.doc-messages{overflow:visible;display:block;}
|
||||
.messages-inner.doc-inner{padding:14px 16px;}
|
||||
/* Make the in-page demos of approval/clarify cards visible without JS */
|
||||
.approval-card.doc-visible,
|
||||
.clarify-card.doc-visible{display:block;}
|
||||
.reconnect-banner.doc-visible{display:flex;align-items:center;justify-content:space-between;gap:12px;background:rgba(201,168,76,.12);border:1px solid rgba(201,168,76,.3);color:var(--gold);padding:8px 14px;border-radius:8px;font-size:12px;}
|
||||
.reconnect-banner.doc-visible .reconnect-btn{background:none;border:1px solid rgba(201,168,76,.35);color:var(--gold);padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;}
|
||||
.bg-error-banner.doc-visible{border-radius:8px;}
|
||||
/* Two-up grid for short comparisons */
|
||||
.doc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="doc-header">
|
||||
<div class="doc-title">Hermes WebUI — Messages UI Inventory<small>Every message-area element & combination, wired to the real <code>static/style.css</code>. · <a href="./two-stage-proposal.html" style="color:var(--blue);text-decoration:none;">Two-stage proposal (#536) →</a></small></div>
|
||||
<div class="doc-toggles">
|
||||
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
|
||||
<button data-theme-btn="default">Default</button>
|
||||
<button data-theme-btn="slate" class="on">Slate</button>
|
||||
<button data-theme-btn="light">Light</button>
|
||||
<button data-theme-btn="solarized">Solarized</button>
|
||||
<button data-theme-btn="monokai">Monokai</button>
|
||||
<button data-theme-btn="nord">Nord</button>
|
||||
<button data-theme-btn="oled">OLED</button>
|
||||
<span style="width:1px;height:18px;background:var(--border);margin:0 4px;align-self:center;"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="doc-main">
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">1 · Empty state</div>
|
||||
<h2 class="doc-h">First load / no messages</h2>
|
||||
<p class="doc-note">Renders inside <code>#messages</code> when <code>S.messages</code> is empty. Logo + title + subtitle + 3 suggestion buttons.</p>
|
||||
<div class="doc-card"><span class="doc-label">.empty-state</span>
|
||||
<div class="messages doc-messages">
|
||||
<div class="empty-state" style="min-height:340px;flex:0 0 auto;">
|
||||
<div class="empty-logo">H</div>
|
||||
<h2>What can I help with?</h2>
|
||||
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
|
||||
<div class="suggestion-grid">
|
||||
<button class="suggestion">📁 What files are in this workspace?</button>
|
||||
<button class="suggestion">📅 What's on my schedule today?</button>
|
||||
<button class="suggestion">🗺️ Help me plan a small project.</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">2 · User messages</div>
|
||||
<h2 class="doc-h">Right-aligned bubble, attachments, and edit mode</h2>
|
||||
<p class="doc-note">User rows have no avatar/label — the right-edge alignment and tinted bubble identify the sender. Timestamp + edit/copy live in a <code>.msg-foot</code> below the bubble, revealed on hover (forced visible here).</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-row[data-role="user"] — plain</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row force-show" data-role="user" data-raw-text="How do I run the dev server and point it at a specific workspace path?">
|
||||
<div class="msg-body"><p>How do I run the dev server and point it at a specific workspace path?</p></div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-time" title="Thu, Apr 16 2026, 10:42 AM">10:42</span>
|
||||
<span class="msg-actions">
|
||||
<button class="msg-action-btn" title="Edit"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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 class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-files — attachments above body (right-aligned)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user">
|
||||
<div class="msg-files">
|
||||
<span class="msg-file-badge">📎 architecture-notes.pdf</span>
|
||||
<span class="msg-file-badge">📎 Q1-forecast.xlsx</span>
|
||||
<span class="msg-file-badge">📎 meeting.docx</span>
|
||||
<span class="msg-file-badge">📎 screenshot.png</span>
|
||||
</div>
|
||||
<div class="msg-body"><p>Please review these docs and summarise the key decisions.</p></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-edit-area + .msg-edit-bar — edit mode</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user" data-editing="1">
|
||||
<textarea class="msg-edit-area">How do I run the dev server and point it at a specific workspace path — and can I do it without docker?</textarea>
|
||||
<div class="msg-edit-bar">
|
||||
<button class="msg-edit-send">Send edit</button>
|
||||
<button class="msg-edit-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">3 · Assistant — markdown basics</div>
|
||||
<h2 class="doc-h">Paragraphs, emphasis, lists, blockquote, hr, links</h2>
|
||||
<p class="doc-note">Assistant output is a single <code>.msg-row.assistant-turn</code> that holds one role header + an <code>.assistant-turn-blocks</code> column of one-or-more <code>.assistant-segment</code> children. Each segment may contain a <code>.thinking-card</code>, a <code>.msg-body</code>, and its own <code>.msg-foot</code> (copy / regen). This lets a turn stream reasoning → text → tool calls → more text without repeating the Hermes avatar each time.</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-body — rich prose</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn force-show" data-role="assistant">
|
||||
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
|
||||
<span class="role-icon assistant">H</span>
|
||||
<span>Hermes</span>
|
||||
</div>
|
||||
<div class="assistant-turn-blocks">
|
||||
<div class="assistant-segment" data-raw-text="Running the dev server...">
|
||||
<div class="msg-body">
|
||||
<h1>Running the dev server</h1>
|
||||
<p>You can start Hermes with the built-in launcher. The <strong>simplest path</strong> is <em>no docker, no proxy</em> — the CLI handles everything.</p>
|
||||
<h2>Prerequisites</h2>
|
||||
<ul>
|
||||
<li>Node <code>>= 18</code></li>
|
||||
<li>A workspace directory you own
|
||||
<ul>
|
||||
<li>Read/write permissions</li>
|
||||
<li>No existing <code>.hermes</code> folder</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>An API key set via <code>HERMES_API_KEY</code></li>
|
||||
</ul>
|
||||
<h2>Steps</h2>
|
||||
<ol>
|
||||
<li>Clone the repo</li>
|
||||
<li>Run <code>npm install</code></li>
|
||||
<li>Start with <code>npm run dev -- --workspace ~/code</code></li>
|
||||
</ol>
|
||||
<blockquote>Tip: the <code>--workspace</code> flag accepts absolute or <code>~</code>-prefixed paths. Relative paths are resolved against the CWD.</blockquote>
|
||||
<hr>
|
||||
<p>For full setup options see the <a href="#">configuration guide</a>.</p>
|
||||
</div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-actions">
|
||||
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-body table</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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>Model comparison:</p>
|
||||
<table>
|
||||
<thead><tr><th>Model</th><th>Context</th><th>Good for</th><th>Cost / 1M in</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Opus 4.6</td><td>1M</td><td>Deep reasoning, long code</td><td><code>$15.00</code></td></tr>
|
||||
<tr><td>Sonnet 4.6</td><td>1M</td><td>Daily driver, agents</td><td><code>$3.00</code></td></tr>
|
||||
<tr><td>Haiku 4.5</td><td>200k</td><td>Fast tasks, tool loops</td><td><code>$0.80</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">4 · Code blocks</div>
|
||||
<h2 class="doc-h">Plain, with header, with copy button, multi-language</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">pre + code (no header)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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">
|
||||
<pre><code class="language-bash">npm install
|
||||
npm run dev -- --workspace ~/code</code></pre>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.pre-header + pre + .code-copy-btn</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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">
|
||||
<div style="position:relative;">
|
||||
<div class="pre-header">typescript <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
|
||||
<pre><code class="language-typescript">export async function startServer(opts: ServerOptions) {
|
||||
const port = opts.port ?? 3000;
|
||||
const app = createApp();
|
||||
app.listen(port, () => {
|
||||
console.log(`Hermes listening on :${port}`);
|
||||
});
|
||||
return app;
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div style="position:relative;margin-top:14px;">
|
||||
<div class="pre-header">python <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
|
||||
<pre><code class="language-python">from hermes import Agent
|
||||
|
||||
def main() -> None:
|
||||
agent = Agent(model="claude-opus-4-6")
|
||||
reply = agent.run("Summarise today's commits")
|
||||
print(reply)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()</code></pre>
|
||||
</div>
|
||||
<div style="position:relative;margin-top:14px;">
|
||||
<div class="pre-header">json <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
|
||||
<pre><code class="language-json">{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"stream": true,
|
||||
"tools": ["bash", "edit_file", "search"]
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">5 · Inline media</div>
|
||||
<h2 class="doc-h">Images (default & zoomed) and downloadable links</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-media-img (default + .msg-media-img--full)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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>Here's the screenshot you asked for (click to zoom):</p>
|
||||
<img class="msg-media-img" alt="demo" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='360'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' x2='1'%3E%3Cstop offset='0' stop-color='%237cb9ff'/%3E%3Cstop offset='1' stop-color='%23c9a84c'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23g)' width='640' height='360'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='white' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img (480×400 cap)%3C/text%3E%3C/svg%3E">
|
||||
<p style="margin-top:10px;">And the full-width variant:</p>
|
||||
<img class="msg-media-img msg-media-img--full" alt="demo-full" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1280' height='320'%3E%3Crect fill='%231e2023' width='1280' height='320'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='%2382aaff' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img--full (unbounded)%3C/text%3E%3C/svg%3E">
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.msg-media-link — non-image downloads</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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>I saved the generated files:</p>
|
||||
<p><a class="msg-media-link" href="#">📎 report-2026-Q1.pdf</a> <a class="msg-media-link" href="#">📎 revenue.csv</a> <a class="msg-media-link" href="#">📎 diagram.svg</a></p>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">6 · Math & diagrams</div>
|
||||
<h2 class="doc-h">KaTeX inline / block & Mermaid block</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.katex-inline + .katex-block</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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>Inline math: <span class="katex-inline" data-math-inline>\(E = mc^2\)</span> and the quadratic formula below:</p>
|
||||
<div class="katex-block" data-math-block>$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$</div>
|
||||
<p>A tidier form: <span class="katex-inline" data-math-inline>\(\sum_{i=1}^{n} i = \frac{n(n+1)}{2}\)</span>.</p>
|
||||
<div class="katex-block" data-math-block>$$\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}$$</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.mermaid-block (pre-render placeholder)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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>The request flow:</p>
|
||||
<div class="mermaid-block"><pre style="margin:0;background:none;border:none;padding:0;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:12px;">graph LR
|
||||
U[User] --> C[Composer]
|
||||
C --> API[/api/chat/]
|
||||
API --> M((Model))
|
||||
M --> T{tool?}
|
||||
T -- yes --> X[Tool Runner]
|
||||
T -- no --> R[Reply]
|
||||
X --> R
|
||||
R --> U</pre></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">7 · Thinking / reasoning</div>
|
||||
<h2 class="doc-h">Bordered panel (collapsed / open, animated), live loader, streaming cursor</h2>
|
||||
<p class="doc-note">Thinking cards are rendered at the top of an <code>.assistant-segment</code>. They're now bordered gold-tinted panels (no more left-rule-only look) and expand/collapse with a <code>max-height</code> + opacity transition. Click the header in either example below to see the animation live.</p>
|
||||
|
||||
<div class="doc-grid">
|
||||
<div class="doc-card"><span class="doc-label">.thinking-card (collapsed, inside .assistant-segment)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
|
||||
<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="thinking-card">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 4.3s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>The user asked about the dev server...</pre></div>
|
||||
</div>
|
||||
<div class="msg-body"><p>Here's the shortest path…</p></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.thinking-card.open (animated — max-height + opacity)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
|
||||
<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="thinking-card open">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 4.3s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>The user is asking about launching the dev server.
|
||||
Options: npm script, docker, or the bundled CLI.
|
||||
The CLI is the simplest — no container runtime needed.
|
||||
I should show the exact commands and the --workspace flag,
|
||||
then mention the env var for the API key at the end.</pre></div>
|
||||
</div>
|
||||
<div class="msg-body"><p>Here's the shortest path…</p></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.thinking — live 3-dot loader (pre-reasoning)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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" data-live-assistant="1">
|
||||
<div class="thinking">Thinking <span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">[data-live-assistant="1"] — streaming cursor at end of last child</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn" data-role="assistant" id="liveAssistantTurn">
|
||||
<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" data-live-assistant="1">
|
||||
<div class="msg-body"><p>Sure — the simplest way is to run <code>npm run dev</code>. The CLI will pick up the default</p></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">8 · Tool cards</div>
|
||||
<h2 class="doc-h">Running, done, expanded, subagent, error, multi-card toggle</h2>
|
||||
<p class="doc-note">Tool cards sit in <code>.tool-card-row</code> wrappers (no longer nested under <code>.msg-row</code>). The details panel now animates open/closed via <code>max-height</code> + opacity — click any header below to see the transition.</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-running (collapsed, pulsing dot)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card tool-card-running">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-running-dot"></span>
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">npm run build</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card — done, collapsed</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">static/style.css · 1155 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card.open — args table + result snippet + Show more (animated detail)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card open">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">grep -rn "msg-role" static/ · exit 0 · 380ms</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="tool-card-detail">
|
||||
<div class="tool-card-args">
|
||||
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">grep -rn "msg-role" static/</span></div>
|
||||
<div><span class="tool-arg-key">cwd:</span> <span class="tool-arg-val">/Users/aron/hermes-webui</span></div>
|
||||
<div><span class="tool-arg-key">timeout:</span> <span class="tool-arg-val">30000</span></div>
|
||||
</div>
|
||||
<div class="tool-card-result">
|
||||
<pre>static/style.css:430: .msg-role{font-size:12px;font-weight:500...}
|
||||
static/style.css:431: .msg-role.user{color:rgba(124,185,255,0.65);}
|
||||
static/style.css:432: .msg-role.assistant{color:rgba(201,168,76,0.6);}
|
||||
static/ui.js:1141: const roleEl = el('div', 'msg-role ' + role);</pre>
|
||||
<button class="tool-card-more">Show more (+142 lines)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-subagent — delegated work</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card tool-card-subagent">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">🤖</span>
|
||||
<span class="tool-card-name">Subagent</span>
|
||||
<span class="tool-card-preview">Explore · Map chat messages UI elements</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card tool-card-subagent">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">🤖</span>
|
||||
<span class="tool-card-name">Delegate task</span>
|
||||
<span class="tool-card-preview">Plan · Propose redesign variants</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-card (error snippet)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card open">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="tool-card-detail">
|
||||
<div class="tool-card-args">
|
||||
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">npm run typecheck</span></div>
|
||||
</div>
|
||||
<div class="tool-card-result">
|
||||
<pre style="color:#fca5a5;">src/server.ts:42:7 - error TS2345: Argument of type 'string | undefined'
|
||||
is not assignable to parameter of type 'number'.
|
||||
|
||||
42 app.listen(opts.port, () => {
|
||||
~~~~~~~~~</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.tool-cards-toggle — Expand/Collapse All (≥2 cards)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="tool-cards-toggle">
|
||||
<button>Expand all (3)</button>
|
||||
<button>Collapse all</button>
|
||||
</div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">package.json</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">🔎</span><span class="tool-card-name">grep</span><span class="tool-card-preview">"listen" in src/</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">⚡</span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 0 · 4.1s</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">9 · Meta affordances</div>
|
||||
<h2 class="doc-h">Role timestamp tooltip, footer action toolbar, token-usage badge</h2>
|
||||
<p class="doc-note">Assistant timestamps live on the <code>.msg-role</code> <code>title</code> attribute (hover for full date). Copy/regen buttons sit in the per-segment <code>.msg-foot</code>, 45% opacity at rest, full on turn hover. The <code>.msg-usage</code> badge is always visible at the bottom of the turn.</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">Full hover state — .msg-foot actions + .msg-usage</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row assistant-turn force-show" data-role="assistant">
|
||||
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
|
||||
<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>Built and type-checked successfully — server is running on <code>:3000</code>.</p></div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-actions">
|
||||
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
|
||||
</span>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="msg-usage">3.2K in · 481 out · ~$0.012</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">10 · Full composition</div>
|
||||
<h2 class="doc-h">User turn → assistant turn (segment 1: thinking + body + tool cards) → usage</h2>
|
||||
<p class="doc-note">A realistic turn: one role header up top, then the segment hosting a thinking card plus the first body; tool cards follow as siblings of the turn inside <code>.messages-inner</code>; the usage badge closes the turn.</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">All-in-one turn</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row force-show" data-role="user">
|
||||
<div class="msg-files"><span class="msg-file-badge">📎 server.ts</span></div>
|
||||
<div class="msg-body"><p>The build fails — can you type-check and explain?</p></div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-time">10:40</span>
|
||||
<span class="msg-actions">
|
||||
<button class="msg-action-btn" title="Edit">✎</button>
|
||||
<button class="msg-copy-btn msg-action-btn" title="Copy">⎘</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg-row assistant-turn force-show" data-role="assistant">
|
||||
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
|
||||
<span class="role-icon assistant">H</span><span>Hermes</span>
|
||||
</div>
|
||||
<div class="assistant-turn-blocks">
|
||||
<div class="assistant-segment">
|
||||
<div class="thinking-card open">
|
||||
<div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.1s</span><span class="thinking-card-toggle">▶</span></div>
|
||||
<div class="thinking-card-body"><pre>Attached server.ts — probably typing issue.
|
||||
Run typecheck to confirm, then patch.</pre></div>
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<p>The build fails because <code>opts.port</code> can be <code>undefined</code>. Two fixes below — pick the one that matches your intent.</p>
|
||||
<h3>Option A — require the port</h3>
|
||||
<pre><code class="language-typescript">export function startServer(opts: { port: number }) {
|
||||
app.listen(opts.port);
|
||||
}</code></pre>
|
||||
<h3>Option B — default to 3000</h3>
|
||||
<pre><code class="language-typescript">export function startServer(opts: { port?: number } = {}) {
|
||||
const port = opts.port ?? 3000;
|
||||
app.listen(port);
|
||||
}</code></pre>
|
||||
<p>I ran the checks below to confirm.</p>
|
||||
</div>
|
||||
<div class="msg-foot">
|
||||
<span class="msg-actions">
|
||||
<button class="msg-copy-btn msg-action-btn" title="Copy">⎘</button>
|
||||
<button class="msg-action-btn" title="Regenerate">↻</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-usage">11.4K in · 612 out · ~$0.049</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-cards-toggle">
|
||||
<button>Expand all (3)</button><button>Collapse all</button>
|
||||
</div>
|
||||
<div class="tool-card-row"><div class="tool-card open">
|
||||
<div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">src/server.ts · 58 lines</span><span class="tool-card-toggle">▶</span></div>
|
||||
<div class="tool-card-detail">
|
||||
<div class="tool-card-args"><div><span class="tool-arg-key">path:</span> <span class="tool-arg-val">src/server.ts</span></div></div>
|
||||
<div class="tool-card-result"><pre>export function startServer(opts: ServerOptions) {
|
||||
app.listen(opts.port, () => { ... });
|
||||
}</pre></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="tool-card-row"><div class="tool-card">
|
||||
<div class="tool-card-header"><span class="tool-card-icon">⚡</span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span><span class="tool-card-toggle">▶</span></div>
|
||||
</div></div>
|
||||
<div class="tool-card-row"><div class="tool-card">
|
||||
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">src/server.ts +1 / -1</span><span class="tool-card-toggle">▶</span></div>
|
||||
</div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">12 · System / inline notes</div>
|
||||
<h2 class="doc-h">Compression, cancellation, errors — rendered as italicised assistant messages</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">Italic system notices (still italic — info, not errors)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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><em>[Context was auto-compressed to continue the conversation]</em></p></div></div>
|
||||
<div class="assistant-segment"><div class="msg-body"><p><em>Task cancelled.</em></p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.assistant-segment[data-error="1"] — real error card, red accent, no italic</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Connection lost. Your last message was saved — refresh to continue.</p></div></div>
|
||||
<div class="assistant-segment" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Upstream rate-limited (429). Retrying in 30s…</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">12b · Turn boundaries & date separators</div>
|
||||
<h2 class="doc-h">Right-alignment separates user turns · day-change separator</h2>
|
||||
<p class="doc-note">The dashed divider before each user turn was removed — the right-edge bubble alignment is its own visual break, so only a small vertical gap (10px top margin) remains between turns. Day changes still get a centred <code>.msg-date-sep</code>.</p>
|
||||
<div class="doc-card"><span class="doc-label">.msg-date-sep — Today / Yesterday / weekday / date</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-date-sep">Yesterday</div>
|
||||
<div class="msg-row" data-role="user"><div class="msg-body"><p>Can you summarise the PR I opened earlier?</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 — three files changed, net +42 / -18. Main change is the new rail variable…</p></div></div></div>
|
||||
</div>
|
||||
<div class="msg-date-sep">Today</div>
|
||||
<div class="msg-row" data-role="user"><div class="msg-body"><p>Did CI pass overnight?</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>All green — three jobs, 4m 12s total. Here's the breakdown:</p></div></div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">13 · Overlay cards (adjacent to transcript)</div>
|
||||
<h2 class="doc-h">Approval & Clarify cards + reconnect banner</h2>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.approval-card — 4 button variants (once / session / always / deny)</span>
|
||||
<div class="approval-card doc-visible">
|
||||
<div class="approval-inner">
|
||||
<div class="approval-header">⚠ Approval required</div>
|
||||
<div class="approval-desc" style="font-size:12px;color:var(--muted);margin-bottom:8px;">The agent wants to run a shell command in <code>/Users/aron/hermes-webui</code>.</div>
|
||||
<div class="approval-cmd">rm -rf node_modules && npm install</div>
|
||||
<div class="approval-btns">
|
||||
<button class="approval-btn once">✓ <span class="approval-btn-label">Allow once</span><kbd class="approval-kbd">↵</kbd></button>
|
||||
<button class="approval-btn session">🔒 <span class="approval-btn-label">Allow session</span></button>
|
||||
<button class="approval-btn always">★ <span class="approval-btn-label">Always allow</span></button>
|
||||
<button class="approval-btn deny">✕ <span class="approval-btn-label">Deny</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">.clarify-card — choice buttons + free-text fallback</span>
|
||||
<div class="clarify-card doc-visible">
|
||||
<div class="clarify-inner">
|
||||
<div class="clarify-header">? Clarification needed</div>
|
||||
<div class="clarify-question">Which environment should I deploy this to?</div>
|
||||
<div class="clarify-choices">
|
||||
<button class="clarify-choice"><span class="clarify-choice-badge">A</span><span class="clarify-choice-text">Staging — safe sandbox, auto-teardown nightly</span></button>
|
||||
<button class="clarify-choice"><span class="clarify-choice-badge">B</span><span class="clarify-choice-text">Production EU — customer-facing, requires change ticket</span></button>
|
||||
<button class="clarify-choice"><span class="clarify-choice-badge">C</span><span class="clarify-choice-text">Production US — same caveats as EU</span></button>
|
||||
<button class="clarify-choice other"><span class="clarify-choice-badge other">✎</span><span class="clarify-choice-text">Other — I'll type it below</span></button>
|
||||
</div>
|
||||
<div class="clarify-response">
|
||||
<input class="clarify-input" type="text" placeholder="Type your response…">
|
||||
<button class="clarify-submit">Send</button>
|
||||
</div>
|
||||
<div class="clarify-hint">Pick a choice, or type your own answer below.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-card"><span class="doc-label">Reconnect / mid-stream recovery banner</span>
|
||||
<div class="reconnect-banner doc-visible">
|
||||
<span>⚠ A response may have been in progress when you last left. Reload messages?</span>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="reconnect-btn">Dismiss</button>
|
||||
<button class="reconnect-btn">↻ Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-error-banner doc-visible" style="margin-top:8px;">
|
||||
<span>⚠ Agent run exited with non-zero status (code 1). Check the logs.</span>
|
||||
<button class="reconnect-btn">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">14 · Structure & data-attribute cheat sheet</div>
|
||||
<h2 class="doc-h">Wrappers and state markers produced by <code>renderMessages()</code></h2>
|
||||
|
||||
<div class="doc-card" style="padding:14px 18px;">
|
||||
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">Wrappers</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li><code>.msg-row[data-role="user"]</code> — one user turn (right-aligned bubble, 60% max-width)</li>
|
||||
<li><code>.msg-row.assistant-turn[data-role="assistant"]</code> — one assistant turn; contains <strong>one</strong> <code>.msg-role</code> and <strong>one</strong> <code>.assistant-turn-blocks</code></li>
|
||||
<li><code>.assistant-turn-blocks</code> — flex-column holder for segments</li>
|
||||
<li><code>.assistant-segment</code> — a single logical chunk inside a turn: optional <code>.thinking-card</code> + optional <code>.msg-body</code> + optional <code>.msg-foot</code></li>
|
||||
<li><code>.assistant-segment-anchor</code> — hidden segment kept as a DOM anchor for tool cards when the model emitted no text</li>
|
||||
<li><code>.tool-card-row</code> — per-tool-card wrapper, sibling of the turn inside <code>.messages-inner</code></li>
|
||||
<li><code>.msg-foot</code> — per-segment (or per-user-row) footer holding <code>.msg-time</code> + <code>.msg-actions</code></li>
|
||||
</ul>
|
||||
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Data attributes & IDs</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li><code>data-role="user|assistant"</code> — role marker on the row</li>
|
||||
<li><code>data-msgIdx="N"</code> — index into <code>S.messages</code>; on user rows <em>and</em> assistant segments</li>
|
||||
<li><code>data-raw-text="…"</code> — plain-text source for copy (now lives on <code>.assistant-segment</code> for assistant output)</li>
|
||||
<li><code>data-live-assistant="1"</code> — the segment that's currently streaming</li>
|
||||
<li><code>data-editing="1"</code> — row is in edit mode</li>
|
||||
<li><code>data-error="1"</code> — error state; applies to <code>.msg-row</code> (user) or <code>.assistant-segment</code></li>
|
||||
<li><code>id="liveAssistantTurn"</code> — on the turn that contains the streaming segment</li>
|
||||
<li><code>.tool-card-row[data-live-tid="…"]</code> — live tool-call card (removed when the turn settles)</li>
|
||||
<li><code>data-mermaid-id</code>, <code>data-katex</code>, <code>data-rendered</code> — block rendering state</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- Prism autoloader for real syntax highlighting -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
<!-- KaTeX auto-render -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false},{left:'$',right:'$',display:false}],throwOnError:false});"></script>
|
||||
|
||||
<script>
|
||||
// Theme picker
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const t = btn.dataset.themeBtn;
|
||||
if (t === 'default') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
});
|
||||
});
|
||||
// Bubble-layout toggle
|
||||
|
||||
// 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'));
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
742
docs/ui-ux/two-stage-proposal.html
Normal file
742
docs/ui-ux/two-stage-proposal.html
Normal file
@@ -0,0 +1,742 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="slate">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hermes WebUI — Two-Stage Chat Proposal (Issue #536)</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="../../static/style.css">
|
||||
<style>
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Doc-chrome scaffold (same pattern as index.html) — real app CSS
|
||||
is used unchanged inside .messages / .msg-row. New proposed
|
||||
elements are prefixed .p2s- so nothing collides with the app.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
|
||||
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
|
||||
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
|
||||
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
|
||||
.doc-title a{color:var(--blue);text-decoration:none;}
|
||||
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
|
||||
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
|
||||
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
|
||||
.doc-main{max-width:1180px;margin:0 auto;padding:24px 24px 120px;}
|
||||
.doc-section{margin:48px 0 8px;padding-top:22px;border-top:1px dashed var(--border);}
|
||||
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
|
||||
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
|
||||
.doc-h{font-size:20px;font-weight:700;color:var(--text);margin:4px 0 6px;letter-spacing:-.01em;}
|
||||
.doc-note{font-size:12.5px;color:var(--muted);line-height:1.6;max-width:780px;margin-bottom:14px;}
|
||||
.doc-note code{color:var(--text);background:rgba(255,255,255,.05);padding:1px 5px;border-radius:4px;font-size:11.5px;}
|
||||
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:14px;padding:6px 8px;margin:14px 0;}
|
||||
.doc-label{position:absolute;top:-9px;left:14px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 9px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
|
||||
.doc-label.current{color:var(--muted);}
|
||||
.doc-label.proposed{color:var(--gold);border-color:rgba(201,168,76,.35);background:var(--bg);}
|
||||
.force-show .msg-actions,.force-show .msg-time,.force-show .msg-foot{opacity:1 !important;}
|
||||
.messages.doc-messages{overflow:visible;display:block;}
|
||||
.messages-inner.doc-inner{padding:14px 16px;}
|
||||
.approval-card.doc-visible,.clarify-card.doc-visible{display:block;}
|
||||
.doc-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(440px,1fr));gap:14px;}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Proposed two-stage elements (prefix .p2s-)
|
||||
|
||||
The proposal introduces one container (.p2s-stage1) that wraps
|
||||
the execution history (thinking + tool cards) and one visual
|
||||
treatment (.p2s-answer) for the final-answer segment. The same
|
||||
DOM can be rendered in three modes:
|
||||
|
||||
.p2s-stage1.is-live → Working timer + expanded history
|
||||
.p2s-stage1.is-settled → Collapsed to one-line summary
|
||||
.p2s-stage1.is-settled.is-open → expanded on demand
|
||||
|
||||
Everything else (thinking-card, tool-card-row, msg-body) is the
|
||||
existing app CSS unchanged.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Worklog bar — the header of Stage 1.
|
||||
Aligns with every other rail child via --msg-rail / --msg-max. */
|
||||
.p2s-worklog{
|
||||
display:flex;align-items:center;gap:10px;
|
||||
margin:4px 0 6px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
padding:8px 12px;
|
||||
border:1px solid var(--border);
|
||||
border-radius:10px;
|
||||
background:rgba(255,255,255,.025);
|
||||
font-size:12px;color:var(--muted);
|
||||
cursor:pointer;user-select:none;
|
||||
transition:border-color .15s,background .15s;
|
||||
}
|
||||
.p2s-worklog:hover{border-color:var(--border2);background:rgba(255,255,255,.04);}
|
||||
.p2s-worklog-dot{
|
||||
width:8px;height:8px;border-radius:50%;background:var(--gold);flex-shrink:0;
|
||||
box-shadow:0 0 0 0 rgba(201,168,76,.4);
|
||||
}
|
||||
.p2s-stage1.is-live .p2s-worklog-dot{
|
||||
animation:p2sPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.p2s-stage1.is-settled .p2s-worklog-dot{
|
||||
background:var(--muted);opacity:.6;
|
||||
}
|
||||
@keyframes p2sPulse{
|
||||
0%,100%{box-shadow:0 0 0 0 rgba(201,168,76,.45);}
|
||||
50%{box-shadow:0 0 0 6px rgba(201,168,76,0);}
|
||||
}
|
||||
.p2s-worklog-label{color:var(--text);font-weight:500;}
|
||||
.p2s-worklog-stats{margin-left:auto;display:flex;gap:12px;color:var(--muted);font-size:11.5px;}
|
||||
.p2s-worklog-stats b{color:var(--text);font-weight:600;}
|
||||
.p2s-worklog-caret{
|
||||
display:inline-block;width:14px;height:14px;line-height:14px;text-align:center;
|
||||
color:var(--muted);font-size:10px;transition:transform .2s;
|
||||
margin-left:6px;
|
||||
}
|
||||
.p2s-stage1.is-live .p2s-worklog-caret{display:none;}
|
||||
.p2s-stage1.is-settled.is-open .p2s-worklog-caret{transform:rotate(90deg);}
|
||||
|
||||
/* Stage 1 body — holds thinking + tool cards + round separators. */
|
||||
.p2s-stage1-body{
|
||||
overflow:hidden;
|
||||
transition:max-height .35s ease,opacity .25s ease;
|
||||
}
|
||||
.p2s-stage1.is-live .p2s-stage1-body,
|
||||
.p2s-stage1.is-settled.is-open .p2s-stage1-body{
|
||||
max-height:2000px;opacity:1;
|
||||
}
|
||||
.p2s-stage1.is-settled:not(.is-open) .p2s-stage1-body{
|
||||
max-height:0;opacity:0;pointer-events:none;
|
||||
}
|
||||
|
||||
/* Round separator — shown inside Stage 1 between execution rounds. */
|
||||
.p2s-round-sep{
|
||||
display:flex;align-items:center;gap:10px;
|
||||
margin:10px 0 6px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
color:var(--muted);
|
||||
font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
|
||||
}
|
||||
.p2s-round-sep::before,.p2s-round-sep::after{
|
||||
content:"";flex:1;height:1px;background:var(--border);
|
||||
}
|
||||
|
||||
/* Stage 1 → Stage 2 transition divider. */
|
||||
.p2s-transition{
|
||||
margin:14px 0 10px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
height:1px;
|
||||
background:linear-gradient(
|
||||
to right,transparent,var(--border) 20%,var(--border) 80%,transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Stage 2 — the final answer wrapper.
|
||||
|
||||
Design intent: nothing loud. A small "Answer" kicker in gold,
|
||||
slightly taller line-height, the existing .msg-body styling,
|
||||
and a gentle top breathing-space. The user arrives at this
|
||||
block and it *feels* like a conclusion, not another tool row.
|
||||
*/
|
||||
.p2s-answer{margin-top:8px;}
|
||||
.p2s-answer-kicker{
|
||||
margin:0 0 4px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;
|
||||
color:var(--gold);opacity:.8;
|
||||
}
|
||||
.p2s-answer .msg-body{
|
||||
font-size:14.5px;line-height:1.78;
|
||||
}
|
||||
|
||||
/* Clarify slot — placed at the transition rather than inline. */
|
||||
.p2s-clarify-slot{
|
||||
margin:12px 0 4px var(--msg-rail);
|
||||
max-width:var(--msg-max);
|
||||
}
|
||||
.p2s-clarify-slot .clarify-card{margin:0;}
|
||||
|
||||
/* Comparison-grid accents. */
|
||||
.doc-compare-caption{
|
||||
font-size:11px;color:var(--muted);text-align:center;padding:6px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="doc-header">
|
||||
<div class="doc-title">
|
||||
Two-Stage Chat UX — Proposal for <a href="https://github.com/nesquena/hermes-webui/issues/536" target="_blank">issue #536</a>
|
||||
<small>Companion to <a href="./index.html">index.html</a> — shows <em>Working → Final answer</em> as a distinct two-phase interaction model.</small>
|
||||
</div>
|
||||
<div class="doc-toggles">
|
||||
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
|
||||
<button data-theme-btn="default">Default</button>
|
||||
<button data-theme-btn="slate" class="on">Slate</button>
|
||||
<button data-theme-btn="light">Light</button>
|
||||
<button data-theme-btn="solarized">Solarized</button>
|
||||
<button data-theme-btn="monokai">Monokai</button>
|
||||
<button data-theme-btn="nord">Nord</button>
|
||||
<button data-theme-btn="oled">OLED</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="doc-main">
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">0 · The model</div>
|
||||
<h2 class="doc-h">One turn, two stages</h2>
|
||||
<p class="doc-note">
|
||||
Today an assistant turn is a flat stream: thinking card → tool cards → answer, all stacked
|
||||
inline with equal visual weight. The proposal wraps the execution history in a
|
||||
<code>.p2s-stage1</code> container with a <em>worklog bar</em> as its header, and marks the
|
||||
final answer as <code>.p2s-answer</code>. The same DOM renders three ways:
|
||||
</p>
|
||||
<ul class="doc-note" style="padding-left:18px;list-style:disc;">
|
||||
<li><b>Live</b> — worklog shows <em>Working… 0:42 · 2 tools</em> with a pulsing dot; history is fully visible.</li>
|
||||
<li><b>Settled</b> — worklog collapses to a single line (<em>Worked 1:42 · 4 tools · 2 thinking</em>); final answer sits below as the calm conclusion.</li>
|
||||
<li><b>Settled + opened</b> — user clicks the worklog to re-expand the history for audit.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">1 · Current vs proposed — settled turn</div>
|
||||
<h2 class="doc-h">Side-by-side comparison</h2>
|
||||
<p class="doc-note">
|
||||
Same turn, same tool calls, same answer. Left is what #587 ships today. Right is the
|
||||
proposal: execution history collapses to a one-line summary; the final answer stands alone
|
||||
with a small <em>Answer</em> kicker.
|
||||
</p>
|
||||
|
||||
<div class="doc-grid-2">
|
||||
|
||||
<!-- CURRENT ──────────────────────────────────────────────── -->
|
||||
<div class="doc-card"><span class="doc-label current">Current (PR #587)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user">
|
||||
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</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="thinking-card open">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 3.1s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
|
||||
grep for HERMES_WORKSPACE and --workspace
|
||||
inspect argv vs env precedence.</pre></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card open">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="tool-card-detail">
|
||||
<div class="tool-card-result"><pre>cli/main.py:14:WORKSPACE_ENV = "HERMES_WORKSPACE"
|
||||
cli/main.py:92: ws = os.getenv(WORKSPACE_ENV) or args.workspace</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">cli/main.py · 148 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</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>Both work, but <strong>env wins</strong>. The CLI reads
|
||||
<code>HERMES_WORKSPACE</code> first and only falls back to the
|
||||
<code>--workspace</code> flag if the env var is unset.</p>
|
||||
<p>So in practice:</p>
|
||||
<ul>
|
||||
<li>CI / daemons → set the env var.</li>
|
||||
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="doc-compare-caption">Everything stacks equally — the answer is just the next block.</div>
|
||||
</div>
|
||||
|
||||
<!-- PROPOSED ─────────────────────────────────────────────── -->
|
||||
<div class="doc-card"><span class="doc-label proposed">Proposed — two-stage, settled</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user">
|
||||
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</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">
|
||||
|
||||
<!-- Stage 1 — settled, collapsed to summary (click to expand) -->
|
||||
<div class="p2s-stage1 is-settled" data-p2s-toggle>
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Worked for 0:08</span>
|
||||
<span class="p2s-worklog-stats">
|
||||
<span><b>2</b> tools</span>
|
||||
<span><b>1</b> thinking round</span>
|
||||
</span>
|
||||
<span class="p2s-worklog-caret">▶</span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
<div class="thinking-card open">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 3.1s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
|
||||
grep for HERMES_WORKSPACE and --workspace
|
||||
inspect argv vs env precedence.</pre></div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">cli/main.py · 148 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 2 — the final answer -->
|
||||
<div class="p2s-transition"></div>
|
||||
<div class="p2s-answer">
|
||||
<div class="p2s-answer-kicker">Answer</div>
|
||||
<div class="msg-body">
|
||||
<p>Both work, but <strong>env wins</strong>. The CLI reads
|
||||
<code>HERMES_WORKSPACE</code> first and only falls back to the
|
||||
<code>--workspace</code> flag if the env var is unset.</p>
|
||||
<p>So in practice:</p>
|
||||
<ul>
|
||||
<li>CI / daemons → set the env var.</li>
|
||||
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="doc-compare-caption">Click the worklog bar to expand the execution history.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">2 · Stage 1 · Live run</div>
|
||||
<h2 class="doc-h">Working timer + live execution history</h2>
|
||||
<p class="doc-note">
|
||||
The worklog bar at the top is the anchor for the whole active run: pulsing dot, elapsed
|
||||
timer that ticks every second, and live counts that increment as tool cards resolve.
|
||||
Thinking cards and tool cards render inside <code>.p2s-stage1-body</code> exactly as today.
|
||||
A <em>Round N</em> separator is inserted when the agent starts a new reasoning/tool cycle.
|
||||
</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label proposed">.p2s-stage1.is-live — Round 1 done, Round 2 running</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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" data-live-assistant="1">
|
||||
|
||||
<div class="p2s-stage1 is-live">
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Working… <span id="p2sTimer">0:42</span></span>
|
||||
<span class="p2s-worklog-stats">
|
||||
<span><b>3</b> tools</span>
|
||||
<span><b>2</b> thinking</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
|
||||
<div class="thinking-card open">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 2.4s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>Need to map the streaming code path first,
|
||||
then check the persistence layer.</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">api/streaming.py · 612 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">grep -rn "tool_call_id" api/ · exit 0 · 88ms</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p2s-round-sep">Round 2</div>
|
||||
|
||||
<div class="thinking-card">
|
||||
<div class="thinking-card-header">
|
||||
<span class="thinking-card-icon">💡</span>
|
||||
<span class="thinking-card-label">Thought for 1.8s</span>
|
||||
<span class="thinking-card-toggle">▶</span>
|
||||
</div>
|
||||
<div class="thinking-card-body"><pre>Streaming looks fine — drill into how
|
||||
tool_calls get attached before save.</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card tool-card-running">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-running-dot"></span>
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">pytest tests/test_tool_call_persistence.py -q</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">3 · Approve vs Clarify — placement</div>
|
||||
<h2 class="doc-h">Approvals stay in Stage 1; Clarify moves to the transition</h2>
|
||||
<p class="doc-note">
|
||||
Per the issue: <em>approvals are part of doing the work</em> (they gate a single tool),
|
||||
<em>clarifications stabilise the answer path</em> (they precede the conclusion). The
|
||||
proposal keeps <code>.approval-card</code> inline among tool cards, and places
|
||||
<code>.clarify-card</code> at the Stage 1 → Stage 2 seam, above the final answer.
|
||||
</p>
|
||||
|
||||
<div class="doc-grid-2">
|
||||
|
||||
<!-- Approve inline in Stage 1 -->
|
||||
<div class="doc-card"><span class="doc-label proposed">Approve card — inline in Stage 1</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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" data-live-assistant="1">
|
||||
|
||||
<div class="p2s-stage1 is-live">
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Working… 0:18</span>
|
||||
<span class="p2s-worklog-stats"><span><b>1</b> tool</span></span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">ls -la ~/.hermes/sessions · exit 0</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="approval-card doc-visible">
|
||||
<div class="approval-card-header">
|
||||
<span class="approval-card-icon">🔐</span>
|
||||
<span class="approval-card-title">Approve command</span>
|
||||
</div>
|
||||
<div class="approval-card-body">
|
||||
<p class="approval-card-desc">Hermes wants to run a potentially destructive command:</p>
|
||||
<pre class="approval-card-cmd">rm -rf ~/.hermes/sessions/*.json.bak</pre>
|
||||
</div>
|
||||
<div class="approval-card-actions">
|
||||
<button class="approval-btn approve">Approve</button>
|
||||
<button class="approval-btn deny">Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="doc-compare-caption">Permission gate sits next to the tools it gates.</div>
|
||||
</div>
|
||||
|
||||
<!-- Clarify at transition -->
|
||||
<div class="doc-card"><span class="doc-label proposed">Clarify card — Stage 1 → Stage 2 transition</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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="p2s-stage1 is-settled" data-p2s-toggle>
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Worked for 0:12</span>
|
||||
<span class="p2s-worklog-stats"><span><b>2</b> tools</span></span>
|
||||
<span class="p2s-worklog-caret">▶</span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">📄</span>
|
||||
<span class="tool-card-name">read_file</span>
|
||||
<span class="tool-card-preview">package.json · 48 lines</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-row">
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-card-icon">⚡</span>
|
||||
<span class="tool-card-name">bash</span>
|
||||
<span class="tool-card-preview">ls src/ · exit 0</span>
|
||||
<span class="tool-card-toggle">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p2s-transition"></div>
|
||||
<div class="p2s-clarify-slot">
|
||||
<div class="clarify-card doc-visible">
|
||||
<div class="clarify-card-header">
|
||||
<span class="clarify-card-icon">❓</span>
|
||||
<span class="clarify-card-title">One quick question before I answer</span>
|
||||
</div>
|
||||
<div class="clarify-card-body">
|
||||
<p>I can wire the dev server either as an <strong>npm script</strong> in the
|
||||
existing <code>package.json</code>, or as a standalone <strong>CLI
|
||||
entry-point</strong>. Which would you prefer?</p>
|
||||
</div>
|
||||
<div class="clarify-card-actions">
|
||||
<button class="clarify-opt">npm script</button>
|
||||
<button class="clarify-opt">CLI entry-point</button>
|
||||
<button class="clarify-opt">Let Hermes pick</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="doc-compare-caption">Stage 1 is already settled; the answer is paused on clarification.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">4 · Stage 2 · Calm conclusion</div>
|
||||
<h2 class="doc-h">What the "Answer" stage looks like on its own</h2>
|
||||
<p class="doc-note">
|
||||
Three small choices distinguish Stage 2 from a regular text block:
|
||||
(1) a thin horizontal divider above it, (2) a tiny gold <em>Answer</em> kicker aligned to
|
||||
the text rail, (3) a slightly taller line-height. No heavy borders, no boxed treatment —
|
||||
the emphasis comes from <em>what is missing around it</em>, not ornament.
|
||||
</p>
|
||||
|
||||
<div class="doc-card"><span class="doc-label proposed">.p2s-answer (Stage 1 collapsed above)</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<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="p2s-stage1 is-settled" data-p2s-toggle>
|
||||
<div class="p2s-worklog">
|
||||
<span class="p2s-worklog-dot"></span>
|
||||
<span class="p2s-worklog-label">Worked for 1:42</span>
|
||||
<span class="p2s-worklog-stats">
|
||||
<span><b>4</b> tools</span>
|
||||
<span><b>2</b> thinking</span>
|
||||
<span><b>1</b> approval</span>
|
||||
</span>
|
||||
<span class="p2s-worklog-caret">▶</span>
|
||||
</div>
|
||||
<div class="p2s-stage1-body">
|
||||
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.4s</span><span class="thinking-card-toggle">▶</span></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">api/streaming.py</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">⚡</span><span class="tool-card-name">bash</span><span class="tool-card-preview">grep -rn "tool_call_id" api/</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="p2s-round-sep">Round 2</div>
|
||||
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 1.8s</span><span class="thinking-card-toggle">▶</span></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">⚡</span><span class="tool-card-name">bash</span><span class="tool-card-preview">pytest -q · exit 0 · 2.4s</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">✍️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">api/streaming.py · +12 −3</span><span class="tool-card-toggle">▶</span></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p2s-transition"></div>
|
||||
<div class="p2s-answer">
|
||||
<div class="p2s-answer-kicker">Answer</div>
|
||||
<div class="msg-body">
|
||||
<p>Tool-call persistence was breaking because <code>session.tool_calls</code> was
|
||||
written <em>after</em> <code>s.save()</code> in <code>api/streaming.py</code>.
|
||||
I moved the attach step above the save, and added a fallback that reconstructs
|
||||
ordering from live tool-progress events when <code>tool_call_id</code> is absent
|
||||
on older sessions.</p>
|
||||
<p>Net result:</p>
|
||||
<ul>
|
||||
<li>Reloading mid-stream now preserves every tool card with args + output snippet.</li>
|
||||
<li>Last-turn reasoning survives reload.</li>
|
||||
<li>No schema migration needed — old sessions degrade gracefully.</li>
|
||||
</ul>
|
||||
<p>Covered by the new regression in <code>tests/test_tool_call_persistence.py</code>.</p>
|
||||
</div>
|
||||
<div class="msg-foot" style="opacity:1;padding-left:var(--msg-rail);">
|
||||
<span class="msg-time">11:42 AM · 2,481 tokens · 1.42s</span>
|
||||
<span class="msg-actions">
|
||||
<button class="msg-act" title="Copy">⧉</button>
|
||||
<button class="msg-act" title="Regenerate">↻</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">5 · Open-question answers (picked defaults)</div>
|
||||
<h2 class="doc-h">What this proposal commits to</h2>
|
||||
<div class="doc-card" style="padding:16px 20px;">
|
||||
<ul style="color:var(--muted);font-size:13px;line-height:1.85;list-style:disc;padding-left:22px;margin:0;">
|
||||
<li><b style="color:var(--text);">Stage 1 on settle →</b> <em>partial</em> collapse to a
|
||||
single worklog bar with counts. Click to re-expand. No "nuke to black box", no "keep
|
||||
everything open forever".</li>
|
||||
<li><b style="color:var(--text);">Final answer placement →</b> sits <em>beneath</em> Stage 1,
|
||||
not replacing it. Visual distinction comes from the divider + kicker + spacing, not from
|
||||
a two-panel layout.</li>
|
||||
<li><b style="color:var(--text);">Clarify placement →</b> at the Stage 1 → Stage 2 seam.
|
||||
Approvals stay inline with tools.</li>
|
||||
<li><b style="color:var(--text);">Timer →</b> lives on Stage 1 only. Stops when the agent
|
||||
emits the first Stage 2 token; final label becomes "Worked for N:NN".</li>
|
||||
<li><b style="color:var(--text);">Signal for "answer has started" →</b> first assistant
|
||||
text delta after all tool calls have resolved and no new <code>tool_use</code> is pending
|
||||
in the current round. Already present in the SSE stream per maintainer comment.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">6 · DOM cheat-sheet</div>
|
||||
<h2 class="doc-h">What changes vs index.html</h2>
|
||||
<div class="doc-card" style="padding:14px 18px;">
|
||||
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">New wrappers</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li><code>.p2s-stage1[is-live|is-settled][is-open]</code> — wraps the execution history inside an <code>.assistant-segment</code>.</li>
|
||||
<li><code>.p2s-worklog</code> — header of Stage 1. Pulsing dot + label + counts + caret. Clickable when settled.</li>
|
||||
<li><code>.p2s-stage1-body</code> — holds <code>.thinking-card</code> + <code>.tool-card-row</code> + <code>.p2s-round-sep</code>. Animated via <code>max-height</code>.</li>
|
||||
<li><code>.p2s-round-sep</code> — inline horizontal separator between tool/reasoning rounds.</li>
|
||||
<li><code>.p2s-transition</code> — thin gradient divider between Stage 1 and Stage 2.</li>
|
||||
<li><code>.p2s-answer</code> — wraps the final <code>.msg-body</code> + <code>.msg-foot</code>.</li>
|
||||
<li><code>.p2s-answer-kicker</code> — small gold <em>Answer</em> label.</li>
|
||||
<li><code>.p2s-clarify-slot</code> — placement slot for <code>.clarify-card</code> at the Stage 1/2 seam.</li>
|
||||
</ul>
|
||||
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Unchanged</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li><code>.thinking-card</code>, <code>.tool-card</code>, <code>.approval-card</code>, <code>.clarify-card</code>, <code>.msg-body</code>, <code>.msg-foot</code> — all existing app CSS and existing markup.</li>
|
||||
<li><code>.assistant-turn-blocks</code> and <code>.assistant-segment</code> remain the top-level wrappers.</li>
|
||||
<li>Tool cards still live as <code>.tool-card-row</code> siblings — now nested <em>inside</em> <code>.p2s-stage1-body</code> rather than as direct children of <code>.messages-inner</code>.</li>
|
||||
</ul>
|
||||
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Implementation notes</h3>
|
||||
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
|
||||
<li>Renderer in <code>static/messages.js</code> wraps an assistant turn's non-final blocks in <code>.p2s-stage1-body</code> and appends the <code>.p2s-worklog</code> header once; toggles <code>is-live</code>/<code>is-settled</code> based on <code>data-live-assistant</code>.</li>
|
||||
<li><code>static/boot.js</code> SSE handler ticks the timer while <code>is-live</code>, increments counts on each <code>tool_use</code>, and flips the class when the first Stage 2 delta arrives.</li>
|
||||
<li>Persistence: no schema change needed — the worklog summary can be derived on reload from the existing persisted tool-call list + thinking rounds.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Theme picker (matches index.html)
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const t = btn.dataset.themeBtn;
|
||||
if (t === 'default') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
});
|
||||
});
|
||||
|
||||
// Existing thinking/tool cards click-to-toggle.
|
||||
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
|
||||
h.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
h.parentElement.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
|
||||
// Click the worklog bar on a settled Stage 1 to expand/collapse the history.
|
||||
document.querySelectorAll('.p2s-stage1[data-p2s-toggle] .p2s-worklog').forEach(bar => {
|
||||
bar.addEventListener('click', () => {
|
||||
const stage = bar.closest('.p2s-stage1');
|
||||
if (!stage.classList.contains('is-settled')) return;
|
||||
stage.classList.toggle('is-open');
|
||||
});
|
||||
});
|
||||
|
||||
// Live timer demo in section 2 — ticks so the page feels alive.
|
||||
(function(){
|
||||
const el = document.getElementById('p2sTimer');
|
||||
if (!el) return;
|
||||
let [m, s] = el.textContent.split(':').map(Number);
|
||||
setInterval(() => {
|
||||
s = (s + 1) % 60;
|
||||
if (s === 0) m += 1;
|
||||
el.textContent = m + ':' + String(s).padStart(2,'0');
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
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:
|
||||
|
||||
324
static/boot.js
324
static/boot.js
@@ -40,7 +40,10 @@ function _setWorkspacePanelMode(mode){
|
||||
if(!layout||!panel)return;
|
||||
_workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
|
||||
const open=_workspacePanelMode!=='closed';
|
||||
document.documentElement.dataset.workspacePanel=open?'open':'closed';
|
||||
// Persist open/closed across refreshes (browse/preview → open; closed → closed)
|
||||
// Do NOT overwrite the user's "keep open" preference — only track runtime state
|
||||
// so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting.
|
||||
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
|
||||
layout.classList.toggle('workspace-panel-collapsed',!open);
|
||||
if(_isCompactWorkspaceViewport()){
|
||||
@@ -184,18 +187,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;
|
||||
@@ -260,7 +268,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';
|
||||
@@ -297,6 +305,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'),
|
||||
@@ -307,18 +322,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;
|
||||
}
|
||||
@@ -330,12 +353,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();
|
||||
@@ -347,6 +372,7 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
||||
mediaRecorder.start();
|
||||
_setRecording(true);
|
||||
}catch(err){
|
||||
_isRecording=false;
|
||||
window._micPendingSend=false;
|
||||
_stopTracks();
|
||||
showToast(t('mic_denied'));
|
||||
@@ -381,8 +407,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){
|
||||
@@ -430,9 +455,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();
|
||||
}
|
||||
@@ -492,9 +525,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
|
||||
@@ -578,29 +610,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=>{
|
||||
btn.classList.toggle('active',btn.dataset.themeVal===active);
|
||||
btn.style.borderColor='';
|
||||
btn.style.boxShadow='';
|
||||
});
|
||||
}
|
||||
|
||||
function _syncSkinPicker(active){
|
||||
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
|
||||
btn.classList.toggle('active',btn.dataset.skinVal===active);
|
||||
btn.style.borderColor='';
|
||||
btn.style.boxShadow='';
|
||||
});
|
||||
}
|
||||
|
||||
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=>{
|
||||
btn.classList.toggle('active',btn.dataset.fontSizeVal===(active||'default'));
|
||||
btn.style.borderColor='';
|
||||
btn.style.boxShadow='';
|
||||
});
|
||||
}
|
||||
|
||||
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(){
|
||||
@@ -627,11 +792,18 @@ 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);
|
||||
if(s.default_model) window._defaultModel=s.default_model;
|
||||
// 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'))
|
||||
@@ -646,9 +818,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'))
|
||||
@@ -670,29 +843,49 @@ function applyBotName(){
|
||||
// Update profile chip label immediately
|
||||
const profileLabel=$('profileChipLabel');
|
||||
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
|
||||
// Fetch available models from server and populate dropdown dynamically
|
||||
await populateModelDropdown();
|
||||
// Restore last-used model preference
|
||||
const savedModel=localStorage.getItem('hermes-webui-model');
|
||||
if(savedModel && $('modelSelect')){
|
||||
$('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');
|
||||
}
|
||||
// Fetch available models without blocking session restore. The static HTML
|
||||
// options are enough for first paint; the dynamic provider list can settle
|
||||
// after the saved session is visible.
|
||||
const _modelDropdownReady=populateModelDropdown().then(()=>{
|
||||
const savedModel=localStorage.getItem('hermes-webui-model');
|
||||
if(savedModel && $('modelSelect')){
|
||||
$('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();
|
||||
}
|
||||
if(S.session) syncTopbar();
|
||||
}).catch(()=>{});
|
||||
window._modelDropdownReady=_modelDropdownReady;
|
||||
// Pre-load workspace list so sidebar name is correct from first render
|
||||
await loadWorkspaceList();
|
||||
await loadOnboardingWizard();
|
||||
_initResizePanels();
|
||||
// Restore workspace panel open/closed state from last visit
|
||||
if(localStorage.getItem('hermes-webui-workspace-panel')==='open'){
|
||||
_workspacePanelMode='browse';
|
||||
}
|
||||
// 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{await loadSession(saved);syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
|
||||
try{
|
||||
await loadSession(saved);
|
||||
// Restore the panel from localStorage when the session has a workspace.
|
||||
// Preference key takes priority over runtime state so that closing
|
||||
// the panel via toolbar X doesn't suppress the "keep open" setting.
|
||||
const panelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|
||||
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
|
||||
if(S.session&&S.session.workspace&&panelPref){
|
||||
_workspacePanelMode='browse';
|
||||
}
|
||||
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='';
|
||||
@@ -700,3 +893,32 @@ 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-run the full layout
|
||||
// sync whenever the page is restored from cache (`event.persisted === true`).
|
||||
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
|
||||
// chrome aren't left in the stale bfcache snapshot.
|
||||
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 = '';
|
||||
// Close any dropdowns/popovers that were open when the user navigated away.
|
||||
// bfcache freezes DOM state, so a dropdown left open remains open on restore.
|
||||
if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {}
|
||||
if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
|
||||
if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
|
||||
if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
|
||||
// Re-synchronise layout chrome that the boot IIFE sets up but bfcache
|
||||
// doesn't re-run. Each call is guarded so missing helpers degrade silently.
|
||||
if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}
|
||||
if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {}
|
||||
if (typeof renderSessionListFromCache === 'function') {
|
||||
try { renderSessionListFromCache(); } catch (_) {}
|
||||
}
|
||||
// Restart the gateway SSE watcher — the persisted connection is dead after bfcache
|
||||
if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } 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 |
1758
static/i18n.js
1758
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,178 +4,204 @@
|
||||
<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 class="tps-chip" id="tpsStat" title="Tokens per second / minute">0.0 t/s · 0.0 high</div>
|
||||
</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>
|
||||
@@ -203,13 +229,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">
|
||||
@@ -219,54 +250,64 @@
|
||||
<button class="reconnect-btn" onclick="refreshSession()"><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" style="vertical-align:-1px"><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> Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
|
||||
<div class="approval-inner">
|
||||
<div class="approval-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
|
||||
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
|
||||
</div>
|
||||
<div class="approval-desc" id="approvalDesc"></div>
|
||||
<div class="approval-cmd" id="approvalCmd"></div>
|
||||
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
|
||||
<div class="approval-btns">
|
||||
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
|
||||
<kbd class="approval-kbd">↵</kbd>
|
||||
</button>
|
||||
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
|
||||
<span class="approval-btn-icon"><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="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
|
||||
</button>
|
||||
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
|
||||
<span class="approval-btn-icon"><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"><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"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
|
||||
</button>
|
||||
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
|
||||
<div class="clarify-inner">
|
||||
<div class="clarify-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
|
||||
</div>
|
||||
<div class="clarify-question" id="clarifyQuestion"></div>
|
||||
<div class="clarify-choices" id="clarifyChoices"></div>
|
||||
<div class="clarify-response">
|
||||
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
|
||||
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
|
||||
</div>
|
||||
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-wrap" id="composerWrap">
|
||||
<div class="cmd-dropdown" id="cmdDropdown"></div>
|
||||
<div class="composer-flyout">
|
||||
<!-- Queue flyout: slides up from behind composer, same pattern as approval-card -->
|
||||
<div id="queueCard" class="queue-card" role="region" aria-label="Queued messages" aria-live="polite">
|
||||
<div id="queueChips" class="queue-card-inner"></div>
|
||||
</div>
|
||||
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
|
||||
<div class="approval-inner">
|
||||
<div class="approval-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
|
||||
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
|
||||
</div>
|
||||
<div class="approval-desc" id="approvalDesc"></div>
|
||||
<div class="approval-cmd" id="approvalCmd"></div>
|
||||
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
|
||||
<div class="approval-btns">
|
||||
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
|
||||
<kbd class="approval-kbd">↵</kbd>
|
||||
</button>
|
||||
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
|
||||
<span class="approval-btn-icon"><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="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
|
||||
</button>
|
||||
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
|
||||
<span class="approval-btn-icon"><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"><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"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
|
||||
</button>
|
||||
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
|
||||
<div class="clarify-inner">
|
||||
<div class="clarify-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
|
||||
</div>
|
||||
<div class="clarify-question" id="clarifyQuestion"></div>
|
||||
<div class="clarify-choices" id="clarifyChoices"></div>
|
||||
<div class="clarify-response">
|
||||
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
|
||||
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
|
||||
</div>
|
||||
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Queue pill outer: same positioning wrapper as .queue-card (max-width + padding) -->
|
||||
<div class="queue-pill-outer">
|
||||
<button id="queuePill" class="queue-pill" aria-label="Show queued messages" type="button"></button>
|
||||
</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
|
||||
@@ -297,16 +338,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">
|
||||
@@ -322,12 +367,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>
|
||||
@@ -352,102 +405,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"/><line x1="22" y1="4" x2="22" y2="20"/></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">
|
||||
@@ -458,11 +531,81 @@
|
||||
</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>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsWorkspacePanelOpen" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_workspace_panel_open">Keep workspace panel open by default</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_workspace_panel_open">When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.</div>
|
||||
</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">
|
||||
@@ -476,19 +619,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>
|
||||
@@ -515,11 +645,22 @@
|
||||
<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 for="settingsAutoTitleRefresh" data-i18n="settings_label_auto_title_refresh">Adaptive title refresh</label>
|
||||
<select id="settingsAutoTitleRefresh" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="0" data-i18n="settings_auto_title_refresh_off">Off</option>
|
||||
<option value="5" data-i18n="settings_auto_title_refresh_5">Every 5 exchanges</option>
|
||||
<option value="10" data-i18n="settings_auto_title_refresh_10">Every 10 exchanges</option>
|
||||
<option value="20" data-i18n="settings_auto_title_refresh_20">Every 20 exchanges</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_auto_title_refresh">Automatically re-generates the session title based on the latest exchange, keeping it relevant as the conversation evolves. Requires an LLM title generation model to be configured.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
@@ -549,15 +690,33 @@
|
||||
</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.67</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>
|
||||
<div id="checkUpdatesBlock">
|
||||
<span class="settings-version-badge">—</span>
|
||||
<button class="btn-tiny" id="btnCheckUpdatesNow" onclick="checkUpdatesNow()" title="Check for updates now" data-i18n-title="settings_check_now"><svg id="checkUpdatesSpinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spinner-xs" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><polyline points="21 3 21 9 15 9"/></svg><span id="checkUpdatesLabel" data-i18n="settings_check_now">Check now</span></button>
|
||||
<span id="checkUpdatesStatus"></span>
|
||||
</div>
|
||||
</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">
|
||||
@@ -565,8 +724,62 @@
|
||||
<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="btnOpenInBrowser" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="openInBrowser()" title="Open in new browser tab"><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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> <span data-i18n="open_in_browser">Open in browser</span></button>
|
||||
<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>
|
||||
<div class="preview-html-wrap" id="previewHtmlWrap" style="display:none;flex:1;border-radius:8px;overflow:hidden;border:1px solid var(--border2)">
|
||||
<iframe id="previewHtmlIframe" style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-scripts" title="HTML preview"></iframe>
|
||||
</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>
|
||||
@@ -589,15 +802,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>
|
||||
|
||||
@@ -21,6 +21,20 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (err) { err.style.display = 'none'; }
|
||||
}
|
||||
|
||||
// Return the ?next= redirect path if present and safe, otherwise './'
|
||||
// Guards against open-redirect: rejects protocol-relative (//evil.com),
|
||||
// absolute URLs, backslash variants, and control characters.
|
||||
function _safeNextPath() {
|
||||
try {
|
||||
var raw = new URL(window.location.href).searchParams.get('next');
|
||||
if (!raw) return './';
|
||||
if (raw.charAt(0) !== '/') return './'; // must be path-absolute
|
||||
if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\
|
||||
if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace
|
||||
return raw;
|
||||
} catch (_) { return './'; }
|
||||
}
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
var pw = input.value;
|
||||
@@ -35,7 +49,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
var data = {};
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (res.ok && data.ok) {
|
||||
window.location.href = './';
|
||||
window.location.href = _safeNextPath();
|
||||
} else {
|
||||
showErr(data.error || invalidPw);
|
||||
}
|
||||
|
||||
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,17 +1,20 @@
|
||||
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]});
|
||||
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
|
||||
$('msg').value='';autoResize();
|
||||
S.pendingFiles=[];renderTray();
|
||||
updateQueueBadge(S.session.session_id);
|
||||
@@ -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,21 +60,24 @@ 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:[]});
|
||||
}
|
||||
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
|
||||
startApprovalPolling(activeSid);
|
||||
startClarifyPolling(activeSid);
|
||||
S.activeStreamId = null; // will be set after stream starts
|
||||
@@ -68,13 +103,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');
|
||||
@@ -88,7 +137,7 @@ async function send(){
|
||||
stopApprovalPolling();
|
||||
stopClarifyPolling();
|
||||
// Keep the user's attempted turn by queueing it for after the current run.
|
||||
queueSessionMessage(activeSid,{text:msgText,files:[]});
|
||||
queueSessionMessage(activeSid,{text:msgText,files:[],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
|
||||
updateQueueBadge(activeSid);
|
||||
showToast('Current session is still running. Reconnected and queued your message.',2600);
|
||||
try{
|
||||
@@ -107,12 +156,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);
|
||||
|
||||
}
|
||||
|
||||
@@ -138,12 +187,23 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
let assistantText='';
|
||||
let reasoningText='';
|
||||
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(){
|
||||
@@ -159,6 +219,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);
|
||||
}
|
||||
@@ -176,20 +248,36 @@ 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(){
|
||||
function ensureAssistantRow(force=false){
|
||||
if(!_isActiveSession()) return;
|
||||
if(assistantRow&&!assistantRow.isConnected){assistantRow=null;assistantBody=null;}
|
||||
if(!force&&!assistantRow){
|
||||
const parsed=_parseStreamState();
|
||||
if(!String((parsed&&parsed.displayText)||'').trim()) return;
|
||||
}
|
||||
let turn=$('liveAssistantTurn');
|
||||
if(!turn){
|
||||
appendThinking();
|
||||
turn=$('liveAssistantTurn');
|
||||
}
|
||||
const blocks=(typeof _assistantTurnBlocks==='function')?_assistantTurnBlocks(turn):null;
|
||||
if(!blocks) return;
|
||||
if(!assistantRow){
|
||||
const existing=$('msgInner').querySelector('.msg-row[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){
|
||||
@@ -197,31 +285,56 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return;
|
||||
}
|
||||
|
||||
removeThinking();
|
||||
const tr=$('toolRunningRow');if(tr)tr.remove();
|
||||
$('emptyState').style.display='none';
|
||||
assistantRow=document.createElement('div');assistantRow.className='msg-row';
|
||||
assistantRow=document.createElement('div');
|
||||
assistantRow.className='assistant-segment';
|
||||
assistantRow.setAttribute('data-live-assistant','1');
|
||||
assistantBody=document.createElement('div');assistantBody.className='msg-body';
|
||||
const role=document.createElement('div');role.className='msg-role assistant';
|
||||
const _bn=window._botName||'Hermes';
|
||||
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent=_bn.charAt(0).toUpperCase();
|
||||
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent=_bn;
|
||||
role.appendChild(icon);role.appendChild(lbl);
|
||||
assistantRow.appendChild(role);assistantRow.appendChild(assistantBody);
|
||||
$('msgInner').appendChild(assistantRow);
|
||||
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>.
|
||||
@@ -242,9 +355,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return raw;
|
||||
}
|
||||
function _parseStreamState(){
|
||||
const raw=assistantText;
|
||||
const raw=_stripXmlToolCalls(assistantText);
|
||||
if(reasoningText){
|
||||
return {thinkingText:reasoningText, displayText:_streamDisplay(), inThinking:false};
|
||||
return {thinkingText:liveReasoningText, displayText:_streamDisplay(), inThinking:false};
|
||||
}
|
||||
for(const {open,close} of _thinkPairs){
|
||||
const trimmed=raw.trimStart();
|
||||
@@ -270,45 +383,178 @@ 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);
|
||||
assistantText+=d.text;
|
||||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
|
||||
ensureAssistantRow();
|
||||
const parsed=_parseStreamState();
|
||||
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
|
||||
_scheduleRender();
|
||||
});
|
||||
|
||||
source.addEventListener('reasoning',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
reasoningText += d.text || '';
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -327,9 +573,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
persistInflightState();
|
||||
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
removeThinking();
|
||||
// NOTE: don't removeThinking() here — keep the thinking card visible
|
||||
// above the tool card so the turn reads top-to-bottom as:
|
||||
// user → thinking → tool cards → response. Removing it caused the card
|
||||
// to be re-created below everything when reasoning resumed post-tool.
|
||||
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -414,6 +673,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);
|
||||
@@ -447,9 +726,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');
|
||||
@@ -475,8 +755,27 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}catch(err){}
|
||||
});
|
||||
|
||||
source.addEventListener('metering',e=>{
|
||||
// TPS + HIGH/LOW stats for the header chip — emitted at 1 Hz during a stream,
|
||||
// silenced entirely when no sessions are active (ticker exits when idle).
|
||||
try{
|
||||
const d=JSON.parse(e.data||'{}');
|
||||
const el=$('tpsStat');
|
||||
if(!el) return;
|
||||
const tps=typeof d.tps==='number'?d.tps.toFixed(1):'0.0';
|
||||
const high=typeof d.high==='number' && d.high>=0?d.high.toFixed(1)+' high':'—';
|
||||
const low=typeof d.low==='number' && d.low>=0?d.low.toFixed(1)+' low':'';
|
||||
el.textContent=`${tps} t/s · ${high}${low?' · '+low:''}`;
|
||||
}catch(_){}
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -489,14 +788,17 @@ 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 isModelNotFound=d.type==='model_not_found';
|
||||
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'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):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;
|
||||
@@ -504,6 +806,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=>{
|
||||
@@ -520,7 +823,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('error',async e=>{
|
||||
source.close();
|
||||
if(_terminalStateReached){
|
||||
if(_terminalStateReached || _streamFinalized){
|
||||
_closeSource();
|
||||
return;
|
||||
}
|
||||
@@ -548,6 +851,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);
|
||||
@@ -555,10 +863,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('');}
|
||||
});
|
||||
@@ -577,10 +903,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.session=session;S.messages=session.messages||[];
|
||||
S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role);
|
||||
const hasMessageToolMetadata=S.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;
|
||||
});
|
||||
if(!hasMessageToolMetadata&&session.tool_calls&&session.tool_calls.length){
|
||||
S.toolCalls=(session.tool_calls||[]).map(tc=>({...tc,done:true}));
|
||||
}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;
|
||||
@@ -588,6 +926,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);
|
||||
@@ -596,6 +940,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;
|
||||
@@ -624,7 +969,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();
|
||||
@@ -736,12 +1081,9 @@ function showApprovalCard(pending, pendingCount) {
|
||||
const b = $(id); if (b) { b.disabled = false; b.classList.remove("loading"); }
|
||||
});
|
||||
card.classList.add("visible");
|
||||
if (!sameApproval) card.scrollIntoView({block:"nearest", behavior:"smooth"});
|
||||
// Apply current locale to data-i18n elements inside the card
|
||||
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
|
||||
// Focus Allow once button so Enter works immediately
|
||||
const onceBtn = $("approvalBtnOnce");
|
||||
if (onceBtn) setTimeout(() => onceBtn.focus(), 50);
|
||||
if (onceBtn) setTimeout(() => onceBtn.focus({preventScroll: true}), 50);
|
||||
}
|
||||
|
||||
async function respondApproval(choice) {
|
||||
@@ -970,14 +1312,9 @@ function showClarifyCard(pending) {
|
||||
lockComposerForClarify(question ? `Clarification needed: ${question}` : "Clarification needed");
|
||||
}
|
||||
_clarifySetControlsDisabled(false, false);
|
||||
const msgInner = $("msgInner");
|
||||
if (msgInner && card.parentElement !== msgInner) {
|
||||
msgInner.appendChild(card);
|
||||
}
|
||||
card.classList.add("visible");
|
||||
if (!sameClarify) card.scrollIntoView({block:"nearest", behavior:"smooth"});
|
||||
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
|
||||
if (input && !sameClarify) setTimeout(() => input.focus(), 50);
|
||||
if (input && !sameClarify) setTimeout(() => input.focus({preventScroll: true}), 50);
|
||||
}
|
||||
|
||||
async function respondClarify(response) {
|
||||
@@ -1062,4 +1399,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) ──
|
||||
|
||||
@@ -8,6 +8,30 @@ function _getOnboardingSetupProvider(id){
|
||||
return _getOnboardingSetupProviders().find(p=>p.id===id)||null;
|
||||
}
|
||||
|
||||
function _getOnboardingSetupCategories(){
|
||||
return (((ONBOARDING.status||{}).setup||{}).categories)||[];
|
||||
}
|
||||
|
||||
/** Render the provider <select> with <optgroup> per category. */
|
||||
function _renderProviderSelectOptions(selectedId){
|
||||
const providers=_getOnboardingSetupProviders();
|
||||
const categories=_getOnboardingSetupCategories();
|
||||
const provMap={};
|
||||
providers.forEach(p=>{provMap[p.id]=p;});
|
||||
if(!categories.length){
|
||||
// Fallback: flat list when no categories are available.
|
||||
return providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
|
||||
}
|
||||
return categories.map(cat=>{
|
||||
const opts=cat.providers.map(pid=>{
|
||||
const p=provMap[pid];
|
||||
if(!p)return '';
|
||||
return `<option value="${esc(p.id)}"${p.id===selectedId?' selected':''}>${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`;
|
||||
}).join('');
|
||||
return `<optgroup label="${esc(t('provider_category_'+cat.id)||cat.label)}">${opts}</optgroup>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _getOnboardingCurrentSetup(){
|
||||
return (((ONBOARDING.status||{}).setup||{}).current)||{};
|
||||
}
|
||||
@@ -107,9 +131,9 @@ function _renderOnboardingBody(){
|
||||
}
|
||||
|
||||
if(key==='setup'){
|
||||
const providers=_getOnboardingSetupProviders();
|
||||
const options=providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
|
||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
|
||||
const selectedId=ONBOARDING.form.provider;
|
||||
const groupedOptions=_renderProviderSelectOptions(selectedId);
|
||||
const provider=_getOnboardingSetupProvider(selectedId)||_getOnboardingSetupProviders()[0]||null;
|
||||
const showBaseUrl=provider&&provider.requires_base_url;
|
||||
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
|
||||
|
||||
@@ -132,7 +156,7 @@ function _renderOnboardingBody(){
|
||||
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
@@ -153,7 +177,7 @@ function _renderOnboardingBody(){
|
||||
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
@@ -162,8 +186,6 @@ function _renderOnboardingBody(){
|
||||
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||
}
|
||||
const providerSel=$('onboardingProviderSelect');
|
||||
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,7 +193,7 @@ function _renderOnboardingBody(){
|
||||
body.innerHTML=`
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
@@ -181,8 +203,6 @@ function _renderOnboardingBody(){
|
||||
<p class="onboarding-copy">${keyHelp}</p>
|
||||
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
|
||||
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
|
||||
const providerSel=$('onboardingProviderSelect');
|
||||
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,7 +286,7 @@ async function loadOnboardingWizard(){
|
||||
const current=((status.setup||{}).current)||{};
|
||||
ONBOARDING.form.provider=current.provider||'openrouter';
|
||||
ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||'';
|
||||
ONBOARDING.form.model=status.settings.default_model||current.model||'openai/gpt-5.4-mini';
|
||||
ONBOARDING.form.model=status.settings.default_model||current.model||'';
|
||||
ONBOARDING.form.password='';
|
||||
ONBOARDING.form.apiKey='';
|
||||
ONBOARDING.form.baseUrl=current.base_url||'';
|
||||
@@ -321,7 +341,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){
|
||||
|
||||
2193
static/panels.js
2193
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,52 +96,56 @@ 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&resolve_model=0`);
|
||||
} catch(e) {
|
||||
const _msgInner = $('msgInner');
|
||||
if(_msgInner){
|
||||
if(e.status===404){
|
||||
_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;">Session not available in web UI.</div>';
|
||||
} else {
|
||||
_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.session._modelResolutionDeferred=true;
|
||||
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);
|
||||
// B9: sanitize empty assistant messages (PR #402) — build index map to remap
|
||||
// session-level tool_calls.assistant_msg_idx to the new sanitized positions.
|
||||
const allMsgs = data.session.messages || [];
|
||||
const sanitized = [];
|
||||
const origIdxToSanitizedIdx = {};
|
||||
let lastKeptAsstIdx = -1;
|
||||
for (let i = 0; i < allMsgs.length; i++) {
|
||||
const m = allMsgs[i];
|
||||
if (!m || !m.role) continue;
|
||||
if (m.role === 'tool') continue;
|
||||
if (m.role === 'assistant') {
|
||||
let c = m.content || '';
|
||||
if (Array.isArray(c)) c = c.filter(p => p && p.type === 'text').map(p => p.text || '').join('');
|
||||
if (!String(c).trim().length) { continue; } // empty assistant — skip
|
||||
lastKeptAsstIdx = sanitized.length;
|
||||
}
|
||||
origIdxToSanitizedIdx[i] = sanitized.length;
|
||||
sanitized.push(m);
|
||||
}
|
||||
if (data.session.tool_calls && data.session.tool_calls.length) {
|
||||
for (const tc of data.session.tool_calls) {
|
||||
if (!tc || tc.assistant_msg_idx === undefined) continue;
|
||||
const origIdx = tc.assistant_msg_idx;
|
||||
tc.assistant_msg_idx = (origIdx in origIdxToSanitizedIdx)
|
||||
? origIdxToSanitizedIdx[origIdx]
|
||||
: (lastKeptAsstIdx >= 0 ? lastKeptAsstIdx : -1);
|
||||
}
|
||||
}
|
||||
data.session.messages = sanitized;
|
||||
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;
|
||||
@@ -102,19 +162,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{
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
S.messages=data.session.messages||[];
|
||||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
|
||||
|
||||
// Attach pending user message if one is queued.
|
||||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(S.session):null;
|
||||
if(pendingMsg) S.messages.push(pendingMsg);
|
||||
// Fix (PR #402): do NOT pre-fill S.toolCalls from session-level tool_calls —
|
||||
// those have stale assistant_msg_idx values after B9 sanitization. Instead,
|
||||
// set S.toolCalls=[] and let renderMessages() derive them from per-message
|
||||
// tool_calls (which already have correct sanitized-array indices).
|
||||
S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
|
||||
if(activeStreamId){
|
||||
S.busy=true;
|
||||
S.activeStreamId=activeStreamId;
|
||||
@@ -126,13 +231,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();
|
||||
@@ -143,6 +244,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'){
|
||||
@@ -157,6 +259,56 @@ async function loadSession(sid){
|
||||
threshold_tokens: _pick(u.threshold_tokens, _s.threshold_tokens),
|
||||
});
|
||||
}
|
||||
_resolveSessionModelForDisplaySoon(sid);
|
||||
}
|
||||
|
||||
function _resolveSessionModelForDisplaySoon(sid){
|
||||
if(!sid) return;
|
||||
setTimeout(async()=>{
|
||||
try{
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=1`);
|
||||
const model=data&&data.session&&data.session.model;
|
||||
if(!model||!S.session||S.session.session_id!==sid) return;
|
||||
S.session.model=model;
|
||||
S.session._modelResolutionDeferred=false;
|
||||
syncTopbar();
|
||||
}catch(_){
|
||||
// Keep session switching non-blocking; the next load can try again.
|
||||
}
|
||||
},0);
|
||||
}
|
||||
|
||||
// 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&resolve_model=0`);
|
||||
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;
|
||||
if(S.session&&S.session.session_id===sid){
|
||||
S.session.message_count=Number(data.session.message_count || msgs.length);
|
||||
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
|
||||
_setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));
|
||||
}
|
||||
}
|
||||
|
||||
let _allSessions = []; // cached for search filter
|
||||
@@ -230,8 +382,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();
|
||||
@@ -241,13 +393,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();
|
||||
@@ -255,8 +407,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();
|
||||
@@ -265,13 +417,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();
|
||||
@@ -281,14 +433,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();
|
||||
@@ -333,12 +485,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();
|
||||
@@ -349,14 +579,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(){
|
||||
@@ -364,6 +622,9 @@ function stopGatewaySSE(){
|
||||
_gatewaySSE.close();
|
||||
_gatewaySSE = null;
|
||||
}
|
||||
stopGatewayPollFallback();
|
||||
_gatewayProbeInFlight = false;
|
||||
_gatewaySSEWarningShown = false;
|
||||
}
|
||||
|
||||
let _searchDebounceTimer = null;
|
||||
@@ -387,7 +648,7 @@ function filterSessions(){
|
||||
}
|
||||
|
||||
function _sessionTimestampMs(session) {
|
||||
const raw = Number(session && (session.updated_at || session.created_at || 0));
|
||||
const raw = Number(session && (session.last_message_at || session.updated_at || session.created_at || 0));
|
||||
return Number.isFinite(raw) ? raw * 1000 : 0;
|
||||
}
|
||||
|
||||
@@ -428,7 +689,7 @@ function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
|
||||
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
|
||||
const dayDiff = Math.max(0, _localDayOrdinal(nowMs) - _localDayOrdinal(timestampMs));
|
||||
if (timestampMs >= startOfToday) {
|
||||
if (diffMs < minute) return t('session_time_just_now');
|
||||
if (diffMs < minute) return t('session_time_minutes_ago', 1);
|
||||
if (diffMs < hour) {
|
||||
const minutes = Math.floor(diffMs / minute);
|
||||
return t('session_time_minutes_ago', minutes);
|
||||
@@ -436,7 +697,7 @@ function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
|
||||
const hours = Math.floor(diffMs / hour);
|
||||
return t('session_time_hours_ago', hours);
|
||||
}
|
||||
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
|
||||
if (timestampMs >= startOfYesterday) return t('session_time_days_ago', 1);
|
||||
if (timestampMs >= startOfWeek) return t('session_time_days_ago', dayDiff);
|
||||
if (timestampMs >= startOfLastWeek) return t('session_time_last_week');
|
||||
return _formatSessionDate(timestampMs, nowMs);
|
||||
@@ -569,7 +830,7 @@ function renderSessionListFromCache(){
|
||||
hdr.className='session-date-header'+(g.isPinned?' pinned':'');
|
||||
const caret=document.createElement('span');
|
||||
caret.className='session-date-caret';
|
||||
caret.textContent='\u25B8'; // right-pointing triangle
|
||||
caret.textContent='\u25BE'; // down when expanded; rotated right when collapsed
|
||||
const label=document.createElement('span');
|
||||
label.textContent=g.label;
|
||||
hdr.appendChild(caret);hdr.appendChild(label);
|
||||
@@ -584,16 +845,25 @@ function renderSessionListFromCache(){
|
||||
_saveCollapsed();
|
||||
};
|
||||
wrapper.appendChild(hdr);
|
||||
for(const s of g.items){ body.appendChild(_renderOneSession(s)); }
|
||||
for(const s of g.items){ body.appendChild(_renderOneSession(s, Boolean(g.isPinned))); }
|
||||
wrapper.appendChild(body);
|
||||
list.appendChild(wrapper);
|
||||
}
|
||||
// ── Render session items (extracted for group body use) ──
|
||||
// Note: declared after the groups loop but available via function hoisting.
|
||||
function _renderOneSession(s){
|
||||
function _renderOneSession(s, isPinnedGroup=false){
|
||||
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 isLocalStreaming=Boolean(
|
||||
s.session_id
|
||||
&& (
|
||||
(isActive&&S.busy)
|
||||
|| (typeof INFLIGHT==='object'&&INFLIGHT&&INFLIGHT[s.session_id])
|
||||
)
|
||||
);
|
||||
const isStreaming=Boolean(s.is_streaming||isLocalStreaming);
|
||||
const hasUnread=_hasUnreadForSession(s)&&!isActive;
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'')+(hasUnread?' unread':'');
|
||||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||||
const rawTitle=s.title||'Untitled';
|
||||
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
|
||||
@@ -606,13 +876,39 @@ function renderSessionListFromCache(){
|
||||
sessionText.className='session-text';
|
||||
const titleRow=document.createElement('div');
|
||||
titleRow.className='session-title-row';
|
||||
if(s.pinned&&!isPinnedGroup){
|
||||
const pinInd=document.createElement('span');
|
||||
pinInd.className='session-pin-indicator';
|
||||
pinInd.innerHTML=ICONS.pin;
|
||||
titleRow.appendChild(pinInd);
|
||||
}
|
||||
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');
|
||||
const hasAttentionState=isStreaming||hasUnread;
|
||||
ts.className='session-time'+(hasAttentionState?' is-hidden':'');
|
||||
ts.textContent=hasAttentionState?'':_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');
|
||||
@@ -666,13 +962,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);
|
||||
@@ -685,6 +974,10 @@ function renderSessionListFromCache(){
|
||||
}
|
||||
}
|
||||
el.appendChild(sessionText);
|
||||
const state=document.createElement('span');
|
||||
state.className='session-attention-indicator session-state-indicator'+(isStreaming?' is-streaming':(hasUnread?' is-unread':''));
|
||||
state.setAttribute('aria-hidden','true');
|
||||
el.appendChild(state);
|
||||
// Single trigger button that opens a shared dropdown menu
|
||||
const actions=document.createElement('div');
|
||||
actions.className='session-actions';
|
||||
@@ -753,11 +1046,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');
|
||||
|
||||
1994
static/style.css
1994
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' } }
|
||||
));
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
1710
static/ui.js
1710
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};
|
||||
@@ -4,6 +4,10 @@ async function api(path,opts={}){
|
||||
const url=new URL(rel,location.href);
|
||||
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
|
||||
if(!res.ok){
|
||||
// 401 means the auth session expired. Redirect to /login so the user can
|
||||
// re-authenticate. This is especially important for iOS PWA (standalone mode)
|
||||
// where a server-side 302 → /login opens in Safari instead of within the PWA.
|
||||
if(res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;}
|
||||
const text=await res.text();
|
||||
// Parse JSON error body and surface the human-readable message,
|
||||
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
|
||||
@@ -97,6 +101,7 @@ function navigateUp(){
|
||||
// File extension sets for preview routing (must match server-side sets)
|
||||
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
|
||||
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
|
||||
const HTML_EXTS = new Set(['.html','.htm']);
|
||||
// Binary formats that should download rather than preview
|
||||
const DOWNLOAD_EXTS = new Set([
|
||||
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
|
||||
@@ -110,21 +115,25 @@ const DOWNLOAD_EXTS = new Set([
|
||||
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
|
||||
|
||||
let _previewCurrentPath = ''; // relative path of currently previewed file
|
||||
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
|
||||
let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html'
|
||||
let _previewDirty = false; // true when edits are unsaved
|
||||
|
||||
function showPreview(mode){
|
||||
// mode: 'code' | 'image' | 'md'
|
||||
// mode: 'code' | 'image' | 'md' | 'html'
|
||||
$('previewCode').style.display = mode==='code' ? '' : 'none';
|
||||
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
|
||||
$('previewMd').style.display = mode==='md' ? '' : 'none';
|
||||
$('previewHtmlWrap').style.display = mode==='html' ? '' : 'none';
|
||||
$('previewEditArea').style.display = 'none'; // start in read-only
|
||||
const badge=$('previewBadge');
|
||||
badge.className='preview-badge '+mode;
|
||||
badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text';
|
||||
badge.textContent = mode==='image'?'image':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text';
|
||||
_previewCurrentMode = mode;
|
||||
_previewDirty = false;
|
||||
updateEditBtn();
|
||||
// Show "Open in browser" button only for HTML mode
|
||||
const openBtn=$('btnOpenInBrowser');
|
||||
if(openBtn) openBtn.style.display = mode==='html'?'inline-flex':'none';
|
||||
}
|
||||
|
||||
function updateEditBtn(){
|
||||
@@ -219,6 +228,22 @@ async function openFile(path){
|
||||
$('previewMd').innerHTML=renderMd(data.content);
|
||||
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
|
||||
}catch(e){setStatus(t('file_open_failed'));}
|
||||
} else if(HTML_EXTS.has(ext)){
|
||||
// HTML: render in sandboxed iframe via raw endpoint.
|
||||
// SECURITY TRADEOFF: We use sandbox="allow-scripts" which lets inline JS run
|
||||
// but prevents access to the parent frame (origin isolation). This is a
|
||||
// deliberate choice — the user is previewing their own workspace files, so
|
||||
// blocking scripts entirely would break most HTML documents. The sandbox
|
||||
// still prevents the preview from navigating the parent, accessing cookies,
|
||||
// or reading other origin data. If a stricter mode is needed, remove
|
||||
// allow-scripts (or add sandbox="") to disable all JS execution.
|
||||
showPreview('html');
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`;
|
||||
const iframe=$('previewHtmlIframe');
|
||||
if(iframe){
|
||||
iframe.src=''; // clear first to avoid stale content
|
||||
iframe.src=url;
|
||||
}
|
||||
} else {
|
||||
// Plain code / text -- but fall back to download if server signals binary
|
||||
try{
|
||||
@@ -287,3 +312,9 @@ function renderFileBreadcrumb(filePath) {
|
||||
bar.appendChild(seg);
|
||||
}
|
||||
}
|
||||
|
||||
function openInBrowser(){
|
||||
if(!_previewCurrentPath||!S.session) return;
|
||||
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(_previewCurrentPath)}`;
|
||||
window.open(url,'_blank');
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
106
tests/test_1038_pwa_auth_redirect.py
Normal file
106
tests/test_1038_pwa_auth_redirect.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Tests for issue #1038 — iOS PWA auth-expiry redirect.
|
||||
|
||||
When a 401 is returned by any API endpoint, the client-side JS should redirect
|
||||
to /login rather than showing a raw error toast. On iOS PWA standalone mode a
|
||||
server-side 302→/login breaks out of the PWA shell into Safari, so the fix is
|
||||
client-side: workspace.js api() intercepts 401 before throwing and calls
|
||||
window.location.href = '/login'.
|
||||
|
||||
These are static regression tests that verify the JS source contains the
|
||||
correct guard patterns.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _workspace_js() -> str:
|
||||
return (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _ui_js() -> str:
|
||||
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestPWAAuthRedirect:
|
||||
def test_workspace_js_has_401_redirect(self):
|
||||
"""api() in workspace.js must redirect to /login on 401."""
|
||||
src = _workspace_js()
|
||||
# Guard must appear inside the !res.ok block, before throwing
|
||||
assert "res.status===401" in src, \
|
||||
"workspace.js api() must check res.status===401"
|
||||
assert "window.location.href='/login" in src or 'window.location.href="/login' in src, \
|
||||
"workspace.js api() must redirect to /login on 401"
|
||||
|
||||
def test_workspace_js_401_before_throw(self):
|
||||
"""The 401 redirect must come before the generic error throw."""
|
||||
src = _workspace_js()
|
||||
idx_401 = src.find("res.status===401")
|
||||
idx_throw = src.find("throw new Error")
|
||||
assert idx_401 != -1, "401 guard not found in workspace.js"
|
||||
assert idx_throw != -1, "throw not found in workspace.js"
|
||||
assert idx_401 < idx_throw, \
|
||||
"401 redirect must appear before the generic throw in workspace.js"
|
||||
|
||||
def test_ui_js_has_redirect_helper(self):
|
||||
"""ui.js must define _redirectIfUnauth helper."""
|
||||
src = _ui_js()
|
||||
assert "_redirectIfUnauth" in src, \
|
||||
"ui.js must define _redirectIfUnauth helper function"
|
||||
|
||||
def test_ui_js_models_fetch_uses_redirect(self):
|
||||
"""populateModelDropdown() must call _redirectIfUnauth on the api/models response."""
|
||||
src = _ui_js()
|
||||
# The helper must be called after the api/models fetch
|
||||
assert "_redirectIfUnauth(_modelsRes)" in src, \
|
||||
"populateModelDropdown() must check 401 on api/models fetch"
|
||||
|
||||
def test_ui_js_live_models_fetch_uses_redirect(self):
|
||||
"""loadLiveModels() must call _redirectIfUnauth on the api/models/live response."""
|
||||
src = _ui_js()
|
||||
assert "_redirectIfUnauth(_liveRes)" in src, \
|
||||
"loadLiveModels() must check 401 on api/models/live fetch"
|
||||
|
||||
def test_ui_js_upload_fetch_uses_redirect(self):
|
||||
"""File upload must call _redirectIfUnauth on the api/upload response."""
|
||||
src = _ui_js()
|
||||
assert "_redirectIfUnauth(res)" in src, \
|
||||
"upload fetch must call _redirectIfUnauth"
|
||||
|
||||
|
||||
class TestLoginJsSafeNextPath:
|
||||
"""login.js _safeNextPath() must honor ?next= but reject open-redirect payloads."""
|
||||
|
||||
@staticmethod
|
||||
def _login_js():
|
||||
return (Path(__file__).parent.parent / "static" / "login.js").read_text(encoding="utf-8")
|
||||
|
||||
def test_safe_next_path_function_exists(self):
|
||||
"""login.js must define _safeNextPath() to honor the ?next= redirect."""
|
||||
assert "_safeNextPath" in self._login_js(), (
|
||||
"login.js must define _safeNextPath() to use the ?next= redirect after login"
|
||||
)
|
||||
|
||||
def test_login_uses_safe_next_path(self):
|
||||
"""doLogin success handler must redirect to _safeNextPath(), not hardcoded './'."""
|
||||
src = self._login_js()
|
||||
assert "_safeNextPath()" in src, (
|
||||
"doLogin must call _safeNextPath() instead of hardcoding './'"
|
||||
)
|
||||
|
||||
def test_safe_next_path_rejects_protocol_relative(self):
|
||||
"""_safeNextPath guard must reject '//' prefix (protocol-relative open-redirect)."""
|
||||
src = self._login_js()
|
||||
assert "charAt(1) === '/'" in src or "startsWith('//')" in src, (
|
||||
"_safeNextPath must reject protocol-relative paths like //evil.com"
|
||||
)
|
||||
|
||||
def test_safe_next_path_rejects_non_path_absolute(self):
|
||||
"""_safeNextPath guard must require path starts with '/'."""
|
||||
src = self._login_js()
|
||||
assert "charAt(0) !== '/'" in src or "startsWith('/')" in src, (
|
||||
"_safeNextPath must reject non-path-absolute inputs (e.g. 'http://...')"
|
||||
)
|
||||
51
tests/test_1044_mermaid_csp_font.py
Normal file
51
tests/test_1044_mermaid_csp_font.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Tests for issue #1044 — Mermaid CSP font violation.
|
||||
|
||||
Mermaid's built-in themes inject an @import for Google Fonts (Manrope) at
|
||||
render time, which is blocked by the CSP's style-src directive. Fix: pass
|
||||
fontFamily:'inherit' in themeVariables so Mermaid never requests an external
|
||||
font URL.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _ui_js() -> str:
|
||||
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestMermaidCSPFont:
|
||||
def test_mermaid_init_has_font_family_inherit(self):
|
||||
"""themeVariables in mermaid.initialize() must set fontFamily to 'inherit'."""
|
||||
src = _ui_js()
|
||||
assert "fontFamily:'inherit'" in src, (
|
||||
"mermaid.initialize() themeVariables must set fontFamily:'inherit' "
|
||||
"to suppress the Google Fonts (Manrope) import that violates CSP"
|
||||
)
|
||||
|
||||
def test_mermaid_init_no_google_fonts_url(self):
|
||||
"""ui.js must not contain a hardcoded fonts.googleapis.com URL."""
|
||||
src = _ui_js()
|
||||
assert "fonts.googleapis.com" not in src, (
|
||||
"ui.js must not reference fonts.googleapis.com — use fontFamily:'inherit'"
|
||||
)
|
||||
|
||||
def test_mermaid_font_family_inside_theme_variables_block(self):
|
||||
"""fontFamily:'inherit' must be inside the themeVariables block of mermaid.initialize()."""
|
||||
src = _ui_js()
|
||||
init_idx = src.find("mermaid.initialize(")
|
||||
assert init_idx != -1, "mermaid.initialize() call not found in ui.js"
|
||||
# Find the themeVariables block after the initialize call
|
||||
tv_idx = src.find("themeVariables", init_idx)
|
||||
assert tv_idx != -1, "themeVariables not found inside mermaid.initialize()"
|
||||
font_idx = src.find("fontFamily:'inherit'", tv_idx)
|
||||
assert font_idx != -1, (
|
||||
"fontFamily:'inherit' must appear inside themeVariables in mermaid.initialize()"
|
||||
)
|
||||
# The closing brace of themeVariables should come after fontFamily
|
||||
close_brace = src.find("})", tv_idx)
|
||||
assert font_idx < close_brace, (
|
||||
"fontFamily:'inherit' must be inside the themeVariables block (before })"
|
||||
)
|
||||
116
tests/test_1045_bfcache_layout_restore.py
Normal file
116
tests/test_1045_bfcache_layout_restore.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Tests for issue #1045 — bfcache layout broken on tab restore.
|
||||
|
||||
When the browser restores a page from bfcache (event.persisted === true),
|
||||
the async boot IIFE does not re-run. The existing pageshow handler (added for
|
||||
#822) only cleared the session search field and re-rendered the session list.
|
||||
This left the rail, topbar, workspace panel, and resize handles in the stale
|
||||
bfcache DOM state, producing a broken layout.
|
||||
|
||||
Fix: extend the pageshow handler to also call syncTopbar, syncWorkspacePanelState,
|
||||
_initResizePanels, and startGatewaySSE — all guarded so missing helpers degrade.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _boot_js() -> str:
|
||||
return (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestBfcacheLayoutRestore:
|
||||
def test_pageshow_calls_sync_topbar(self):
|
||||
"""pageshow handler must call syncTopbar() on bfcache restore."""
|
||||
src = _boot_js()
|
||||
# Find the pageshow listener block
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
assert ps_idx != -1, "pageshow listener not found in boot.js"
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "syncTopbar" in handler_body, (
|
||||
"pageshow handler must call syncTopbar() to restore topbar state after bfcache"
|
||||
)
|
||||
|
||||
def test_pageshow_calls_sync_workspace_panel_state(self):
|
||||
"""pageshow handler must call syncWorkspacePanelState()."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "syncWorkspacePanelState" in handler_body, (
|
||||
"pageshow handler must call syncWorkspacePanelState() on bfcache restore"
|
||||
)
|
||||
|
||||
|
||||
def test_pageshow_calls_start_gateway_sse(self):
|
||||
"""pageshow handler must call startGatewaySSE() to reconnect the dead SSE connection."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "startGatewaySSE" in handler_body, (
|
||||
"pageshow handler must restart gateway SSE (bfcache-persisted connections are dead)"
|
||||
)
|
||||
|
||||
def test_pageshow_still_clears_session_search(self):
|
||||
"""pageshow handler must still clear #sessionSearch (original #822 fix preserved)."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "sessionSearch" in handler_body, (
|
||||
"pageshow handler must still clear #sessionSearch (regression: #822 fix must be preserved)"
|
||||
)
|
||||
|
||||
def test_pageshow_still_calls_render_session_list_from_cache(self):
|
||||
"""pageshow handler must still call renderSessionListFromCache()."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "renderSessionListFromCache" in handler_body, (
|
||||
"pageshow handler must still call renderSessionListFromCache() (regression: #822 fix)"
|
||||
)
|
||||
|
||||
def test_pageshow_does_not_call_init_resize_panels(self):
|
||||
"""pageshow handler must NOT call _initResizePanels() — bfcache
|
||||
preserves event listeners so re-attaching them stacks duplicates."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
assert "_initResizePanels" not in handler_body, (
|
||||
"pageshow handler must not call _initResizePanels() — it stacks "
|
||||
"duplicate mousedown listeners on every bfcache restore"
|
||||
)
|
||||
|
||||
def test_new_calls_are_guarded_with_typeof(self):
|
||||
"""New calls in the pageshow handler must be typeof-guarded for safe degradation."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
# Each of the new calls must be guarded
|
||||
for fn in ("syncTopbar", "syncWorkspacePanelState", "startGatewaySSE",
|
||||
"closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
|
||||
assert f"typeof {fn} === 'function'" in handler_body, (
|
||||
f"{fn}() call in pageshow handler must be guarded with typeof === 'function'"
|
||||
)
|
||||
|
||||
def test_pageshow_closes_all_dropdowns(self):
|
||||
"""pageshow handler must close all known dropdowns to reset frozen bfcache popover state."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
for fn in ("closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
|
||||
assert fn in handler_body, (
|
||||
f"pageshow handler must call {fn}() to dismiss any dropdown left open by bfcache"
|
||||
)
|
||||
|
||||
def test_dropdowns_closed_before_layout_sync(self):
|
||||
"""Dropdown closes must come before layout sync calls (clean state first)."""
|
||||
src = _boot_js()
|
||||
ps_idx = src.find("window.addEventListener('pageshow'")
|
||||
handler_body = src[ps_idx:ps_idx + 1600]
|
||||
close_idx = handler_body.find("closeModelDropdown")
|
||||
sync_idx = handler_body.find("syncTopbar")
|
||||
assert close_idx != -1 and sync_idx != -1, "Both close and sync calls must be present"
|
||||
assert close_idx < sync_idx, (
|
||||
"Dropdown close calls must appear before layout sync calls in the pageshow handler"
|
||||
)
|
||||
|
||||
382
tests/test_1058_adaptive_title_refresh.py
Normal file
382
tests/test_1058_adaptive_title_refresh.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""Tests for adaptive session title refresh helpers (PR #1058).
|
||||
|
||||
Covers all five new functions added to api/streaming.py:
|
||||
- _count_exchanges
|
||||
- _latest_exchange_snippets
|
||||
- _get_title_refresh_interval
|
||||
- _run_background_title_refresh
|
||||
- _maybe_schedule_title_refresh
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the project root is on sys.path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from api.streaming import (
|
||||
_count_exchanges,
|
||||
_latest_exchange_snippets,
|
||||
_get_title_refresh_interval,
|
||||
_run_background_title_refresh,
|
||||
_maybe_schedule_title_refresh,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_auth_sessions():
|
||||
"""Snapshot and restore api.auth._sessions around each test.
|
||||
|
||||
Importing api.streaming can trigger api.config.load_settings() which may
|
||||
call into api.auth and create a real session token. Without this fixture,
|
||||
that stale token leaks into test_auth_session_persistence.py tests (which
|
||||
assume _sessions starts empty) when our file runs first alphabetically.
|
||||
"""
|
||||
import api.auth as _auth
|
||||
snapshot = dict(_auth._sessions)
|
||||
yield
|
||||
_auth._sessions.clear()
|
||||
_auth._sessions.update(snapshot)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _user_msg(text):
|
||||
return {'role': 'user', 'content': text}
|
||||
|
||||
|
||||
def _asst_msg(text, tool_calls=None):
|
||||
msg = {'role': 'assistant', 'content': text}
|
||||
if tool_calls is not None:
|
||||
msg['tool_calls'] = tool_calls
|
||||
return msg
|
||||
|
||||
|
||||
def _tool_only_asst():
|
||||
"""Assistant message that only has tool_calls, no real text."""
|
||||
return {'role': 'assistant', 'content': '', 'tool_calls': [{'id': 't1', 'type': 'function'}]}
|
||||
|
||||
|
||||
def _make_session(title='My Title', llm_title_generated=True, messages=None, session_id='sid1'):
|
||||
s = MagicMock()
|
||||
s.title = title
|
||||
s.llm_title_generated = llm_title_generated
|
||||
s.messages = messages or []
|
||||
s.session_id = session_id
|
||||
s.save = MagicMock()
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _count_exchanges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCountExchanges:
|
||||
def test_empty_messages_returns_zero(self):
|
||||
assert _count_exchanges([]) == 0
|
||||
|
||||
def test_none_messages_returns_zero(self):
|
||||
assert _count_exchanges(None) == 0
|
||||
|
||||
def test_counts_only_user_messages(self):
|
||||
msgs = [_user_msg('hello'), _asst_msg('hi'), _user_msg('world')]
|
||||
assert _count_exchanges(msgs) == 2
|
||||
|
||||
def test_skips_empty_user_messages(self):
|
||||
msgs = [_user_msg(''), _user_msg(' '), _user_msg('real question')]
|
||||
assert _count_exchanges(msgs) == 1
|
||||
|
||||
def test_counts_list_content_user_messages(self):
|
||||
msgs = [
|
||||
{'role': 'user', 'content': [{'type': 'text', 'text': 'list question'}]},
|
||||
]
|
||||
assert _count_exchanges(msgs) == 1
|
||||
|
||||
def test_skips_empty_list_content(self):
|
||||
msgs = [
|
||||
{'role': 'user', 'content': [{'type': 'text', 'text': ' '}]},
|
||||
]
|
||||
assert _count_exchanges(msgs) == 0
|
||||
|
||||
def test_ignores_non_user_roles(self):
|
||||
msgs = [_asst_msg('response'), {'role': 'system', 'content': 'system prompt'}]
|
||||
assert _count_exchanges(msgs) == 0
|
||||
|
||||
def test_ignores_non_dict_entries(self):
|
||||
msgs = ['not a dict', _user_msg('real'), None]
|
||||
assert _count_exchanges(msgs) == 1
|
||||
|
||||
def test_five_exchanges(self):
|
||||
msgs = []
|
||||
for i in range(5):
|
||||
msgs.append(_user_msg(f'question {i}'))
|
||||
msgs.append(_asst_msg(f'answer {i}'))
|
||||
assert _count_exchanges(msgs) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _latest_exchange_snippets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLatestExchangeSnippets:
|
||||
def test_empty_returns_empty_strings(self):
|
||||
u, a = _latest_exchange_snippets([])
|
||||
assert u == '' and a == ''
|
||||
|
||||
def test_none_returns_empty_strings(self):
|
||||
u, a = _latest_exchange_snippets(None)
|
||||
assert u == '' and a == ''
|
||||
|
||||
def test_basic_pair(self):
|
||||
msgs = [_user_msg('first q'), _asst_msg('first a'),
|
||||
_user_msg('second q'), _asst_msg('second a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'second q'
|
||||
assert a == 'second a'
|
||||
|
||||
def test_returns_latest_not_first(self):
|
||||
msgs = [_user_msg('old q'), _asst_msg('old a'),
|
||||
_user_msg('new q'), _asst_msg('new a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'new q'
|
||||
|
||||
def test_skips_tool_call_only_assistant(self):
|
||||
"""An assistant msg with tool_calls and no real text should be skipped."""
|
||||
msgs = [_user_msg('q'), _asst_msg('real answer'),
|
||||
_user_msg('q2'), _tool_only_asst()]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
# _tool_only_asst should be skipped; fall back to previous real assistant
|
||||
assert a == 'real answer'
|
||||
assert u == 'q2'
|
||||
|
||||
def test_truncates_long_content(self):
|
||||
long_text = 'x' * 600
|
||||
msgs = [_user_msg(long_text), _asst_msg(long_text)]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert len(u) == 500
|
||||
assert len(a) == 500
|
||||
|
||||
def test_no_assistant_message(self):
|
||||
msgs = [_user_msg('q')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'q'
|
||||
assert a == ''
|
||||
|
||||
def test_no_user_message(self):
|
||||
msgs = [_asst_msg('a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == ''
|
||||
assert a == 'a'
|
||||
|
||||
def test_ignores_non_dict_entries(self):
|
||||
msgs = ['noise', _user_msg('q'), None, _asst_msg('a')]
|
||||
u, a = _latest_exchange_snippets(msgs)
|
||||
assert u == 'q'
|
||||
assert a == 'a'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_title_refresh_interval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetTitleRefreshInterval:
|
||||
def test_returns_int_for_valid_setting(self):
|
||||
# _get_title_refresh_interval does a local import: `from api.config import load_settings`
|
||||
# so patch the source module, not api.streaming
|
||||
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '5'}):
|
||||
assert _get_title_refresh_interval() == 5
|
||||
|
||||
def test_returns_zero_for_off_setting(self):
|
||||
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '0'}):
|
||||
assert _get_title_refresh_interval() == 0
|
||||
|
||||
def test_returns_zero_when_key_absent(self):
|
||||
with patch('api.config.load_settings', return_value={}):
|
||||
assert _get_title_refresh_interval() == 0
|
||||
|
||||
def test_returns_zero_on_exception(self):
|
||||
with patch('api.config.load_settings', side_effect=Exception('boom')):
|
||||
assert _get_title_refresh_interval() == 0
|
||||
|
||||
def test_valid_values_10_and_20(self):
|
||||
for val in ('10', '20'):
|
||||
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': val}):
|
||||
assert _get_title_refresh_interval() == int(val)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_background_title_refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunBackgroundTitleRefresh:
|
||||
def _make_put_event(self):
|
||||
events = []
|
||||
def put(name, data):
|
||||
events.append((name, data))
|
||||
return put, events
|
||||
|
||||
def _make_session_obj(self, title='Old Title'):
|
||||
s = MagicMock()
|
||||
s.title = title
|
||||
s.llm_title_generated = True
|
||||
s.save = MagicMock()
|
||||
return s
|
||||
|
||||
def test_skips_when_title_changed_before_call(self):
|
||||
"""If the title has changed (manual rename) since the refresh was scheduled, skip."""
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session') as mock_get, \
|
||||
patch('api.streaming.SESSIONS', {}), \
|
||||
patch('api.streaming.LOCK', threading.Lock()):
|
||||
s = self._make_session_obj(title='Different Title')
|
||||
mock_get.return_value = s
|
||||
_run_background_title_refresh(
|
||||
'sid', 'user', 'asst', 'Old Title', put, agent=None
|
||||
)
|
||||
# No 'title' event should have been emitted
|
||||
assert not any(name == 'title' for name, _ in events)
|
||||
|
||||
def test_skips_if_session_not_found(self):
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session', side_effect=KeyError('not found')):
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
|
||||
assert events == []
|
||||
|
||||
def test_skips_when_title_is_untitled(self):
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session') as mock_get:
|
||||
s = self._make_session_obj(title='Untitled')
|
||||
mock_get.return_value = s
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'Untitled', put)
|
||||
assert not any(name == 'title' for name, _ in events)
|
||||
|
||||
def test_skips_same_title(self):
|
||||
"""If the LLM generates a title identical to the current one, no event is emitted."""
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session') as mock_get, \
|
||||
patch('api.streaming._aux_title_configured', return_value=True), \
|
||||
patch('api.streaming._generate_llm_session_title_via_aux',
|
||||
return_value=('Old Title', 'llm_ok', 'raw')), \
|
||||
patch('api.streaming.SESSIONS', {}), \
|
||||
patch('api.streaming.LOCK', threading.Lock()):
|
||||
s = self._make_session_obj(title='Old Title')
|
||||
mock_get.return_value = s
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
|
||||
assert not any(name == 'title' for name, _ in events)
|
||||
|
||||
def test_emits_title_event_on_new_title(self):
|
||||
put, events = self._make_put_event()
|
||||
s = self._make_session_obj(title='Old Title')
|
||||
# Use a real dict for SESSIONS so .get() works, pre-populated with our session
|
||||
fake_sessions = {'sid': s}
|
||||
with patch('api.streaming.get_session', return_value=s), \
|
||||
patch('api.streaming._aux_title_configured', return_value=True), \
|
||||
patch('api.streaming._generate_llm_session_title_via_aux',
|
||||
return_value=('New Refreshed Title', 'llm_ok', 'raw')), \
|
||||
patch('api.streaming.SESSIONS', fake_sessions), \
|
||||
patch('api.streaming.LOCK', threading.Lock()):
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
|
||||
title_events = [(n, d) for n, d in events if n == 'title']
|
||||
assert len(title_events) == 1
|
||||
assert title_events[0][1]['title'] == 'New Refreshed Title'
|
||||
|
||||
def test_exceptions_are_silently_swallowed(self):
|
||||
"""Any unexpected error inside must not propagate — it's a background daemon."""
|
||||
put, events = self._make_put_event()
|
||||
with patch('api.streaming.get_session', side_effect=RuntimeError('oops')):
|
||||
# Should not raise
|
||||
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
|
||||
assert events == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _maybe_schedule_title_refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMaybeScheduleTitleRefresh:
|
||||
def _noop_put(self, name, data):
|
||||
pass
|
||||
|
||||
def test_does_nothing_when_disabled(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=0):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_when_title_is_empty(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(title='', messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_for_untitled(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(title='Untitled', messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_when_title_not_llm_generated(self):
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(llm_title_generated=False,
|
||||
messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_does_nothing_when_exchange_count_not_at_interval(self):
|
||||
"""Refresh only fires when exchange_count % interval == 0 (and > 0)."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
# 4 exchanges — not a multiple of 5
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 4)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
|
||||
def test_spawns_thread_at_exact_interval(self):
|
||||
"""Refresh fires when exchange_count == refresh_interval."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
spawned = []
|
||||
with patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
# 5 user messages = 5 exchanges
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert mock_thread_cls.called
|
||||
assert mock_thread.start.called
|
||||
|
||||
def test_spawns_thread_at_multiple_of_interval(self):
|
||||
"""Refresh fires at 10 exchanges when interval is 5."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
||||
with patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
# 10 exchanges
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 10)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert mock_thread_cls.called
|
||||
|
||||
def test_does_nothing_when_no_exchange_content(self):
|
||||
"""Even at interval, if both snippets are empty, don't spawn."""
|
||||
with patch('api.streaming._get_title_refresh_interval', return_value=5), \
|
||||
patch('api.streaming._latest_exchange_snippets', return_value=('', '')):
|
||||
spawned = []
|
||||
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
||||
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
||||
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
||||
assert spawned == []
|
||||
84
tests/test_1059_settings_picker_active_state.py
Normal file
84
tests/test_1059_settings_picker_active_state.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Regression tests for settings picker active-state highlighting.
|
||||
|
||||
The theme, skin, and font-size pickers in the Appearance settings tab must show
|
||||
the currently-selected option with a visible accent border. This was broken because
|
||||
the CSS rule used !important on border-color:var(--border) which overrode the inline
|
||||
style that _syncThemePicker/etc. set. Fixed by moving to .active CSS class + !important
|
||||
override on the active state.
|
||||
|
||||
Issue: #1059 (settings picker active state)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (Path(__file__).parent.parent / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestSettingsPickerActiveState:
|
||||
"""The selected picker card must be visually distinct via the .active class."""
|
||||
|
||||
def test_theme_picker_uses_active_class(self):
|
||||
"""_syncThemePicker must toggle .active class, not set inline borderColor."""
|
||||
idx = BOOT_JS.find("function _syncThemePicker(")
|
||||
assert idx >= 0, "_syncThemePicker function not found in boot.js"
|
||||
body = BOOT_JS[idx:idx + 300]
|
||||
assert "classList.toggle" in body, (
|
||||
"_syncThemePicker must use classList.toggle('active', ...) — "
|
||||
"inline style.borderColor is overridden by !important CSS rules"
|
||||
)
|
||||
# Confirm no accent/border2 color values set inline (clearing with '' is OK)
|
||||
assert "var(--accent)" not in body and "var(--border2)" not in body, (
|
||||
"_syncThemePicker must not set var(--accent) or var(--border2) inline — "
|
||||
"those are overridden by !important CSS rules"
|
||||
)
|
||||
|
||||
def test_font_size_picker_uses_active_class(self):
|
||||
"""_syncFontSizePicker must toggle .active class."""
|
||||
idx = BOOT_JS.find("function _syncFontSizePicker(")
|
||||
assert idx >= 0, "_syncFontSizePicker function not found in boot.js"
|
||||
body = BOOT_JS[idx:idx + 300]
|
||||
assert "classList.toggle" in body, (
|
||||
"_syncFontSizePicker must use classList.toggle('active', ...)"
|
||||
)
|
||||
assert "var(--accent)" not in body and "var(--border2)" not in body, (
|
||||
"_syncFontSizePicker must not set var(--accent) or var(--border2) inline"
|
||||
)
|
||||
|
||||
def test_skin_picker_uses_active_class(self):
|
||||
"""_syncSkinPicker must toggle .active class."""
|
||||
idx = BOOT_JS.find("function _syncSkinPicker(")
|
||||
assert idx >= 0, "_syncSkinPicker function not found in boot.js"
|
||||
body = BOOT_JS[idx:idx + 300]
|
||||
assert "classList.toggle" in body, (
|
||||
"_syncSkinPicker must use classList.toggle('active', ...)"
|
||||
)
|
||||
assert "var(--accent)" not in body and "var(--border2)" not in body, (
|
||||
"_syncSkinPicker must not set var(--accent) or var(--border2) inline"
|
||||
)
|
||||
|
||||
def test_css_active_rule_beats_base_rule(self):
|
||||
"""CSS must have a .active rule with !important that overrides the base border-color rule."""
|
||||
assert ".theme-pick-btn.active" in STYLE_CSS, (
|
||||
"style.css must have a .theme-pick-btn.active rule"
|
||||
)
|
||||
assert ".font-size-pick-btn.active" in STYLE_CSS, (
|
||||
"style.css must have a .font-size-pick-btn.active rule"
|
||||
)
|
||||
assert ".skin-pick-btn.active" in STYLE_CSS, (
|
||||
"style.css must have a .skin-pick-btn.active rule"
|
||||
)
|
||||
# The active rule must use !important to beat the base !important rule
|
||||
idx = STYLE_CSS.find(".theme-pick-btn.active")
|
||||
rule = STYLE_CSS[idx:idx + 200]
|
||||
assert "!important" in rule, (
|
||||
".theme-pick-btn.active must use !important to override "
|
||||
"the base border-color:var(--border)!important rule"
|
||||
)
|
||||
|
||||
def test_active_rule_uses_accent_color(self):
|
||||
"""The .active rule must apply the accent color to make selection visible."""
|
||||
idx = STYLE_CSS.find(".theme-pick-btn.active")
|
||||
rule = STYLE_CSS[idx:idx + 200]
|
||||
assert "var(--accent)" in rule, (
|
||||
".theme-pick-btn.active must set border-color to var(--accent)"
|
||||
)
|
||||
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)"
|
||||
94
tests/test_779_html_preview.py
Normal file
94
tests/test_779_html_preview.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Tests for inline HTML preview in workspace panel (issue #779)."""
|
||||
import pytest
|
||||
|
||||
|
||||
def _get_routes_content():
|
||||
return open("api/routes.py", encoding="utf-8").read()
|
||||
|
||||
|
||||
def _get_workspace_js():
|
||||
return open("static/workspace.js", encoding="utf-8").read()
|
||||
|
||||
|
||||
def _get_index_html():
|
||||
return open("static/index.html", encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_inline_preview_param_in_file_raw():
|
||||
"""?inline=1 must bypass Content-Disposition: attachment for text/html."""
|
||||
content = _get_routes_content()
|
||||
assert "inline_preview" in content, (
|
||||
"_handle_file_raw must read the inline query parameter"
|
||||
)
|
||||
assert "html_inline_ok" in content, (
|
||||
"_handle_file_raw must allow HTML inline when inline_preview=True"
|
||||
)
|
||||
|
||||
|
||||
def test_iframe_uses_inline_param():
|
||||
"""workspace.js must pass &inline=1 when setting the preview iframe src."""
|
||||
content = _get_workspace_js()
|
||||
assert "inline=1" in content, (
|
||||
"workspace.js must pass ?inline=1 to api/file/raw for the HTML preview iframe"
|
||||
)
|
||||
|
||||
|
||||
def test_html_preview_iframe_exists_in_html():
|
||||
"""The previewHtmlIframe element must be present in index.html."""
|
||||
content = _get_index_html()
|
||||
assert "previewHtmlIframe" in content, (
|
||||
"index.html must contain the previewHtmlIframe element"
|
||||
)
|
||||
|
||||
|
||||
def test_html_exts_defined_in_workspace_js():
|
||||
"""HTML_EXTS set must include .html and .htm."""
|
||||
content = _get_workspace_js()
|
||||
assert "HTML_EXTS" in content, "workspace.js must define HTML_EXTS"
|
||||
assert "'.html'" in content or '".html"' in content, "HTML_EXTS must include .html"
|
||||
assert "'.htm'" in content or '".htm"' in content, "HTML_EXTS must include .htm"
|
||||
|
||||
|
||||
def test_sandbox_allows_scripts_only():
|
||||
"""iframe sandbox must not include allow-same-origin (XSS risk)."""
|
||||
content = _get_index_html()
|
||||
# Find the sandbox attribute value
|
||||
import re
|
||||
sandboxes = re.findall(r'sandbox="([^"]*)"', content)
|
||||
preview_sandboxes = [s for s in sandboxes if "allow" in s]
|
||||
for sb in preview_sandboxes:
|
||||
assert "allow-same-origin" not in sb, (
|
||||
"HTML preview iframe must not have allow-same-origin (would expose parent cookies)"
|
||||
)
|
||||
|
||||
|
||||
def test_inline_html_response_sets_csp_sandbox():
|
||||
"""Defense-in-depth: ?inline=1 HTML responses must set Content-Security-Policy:
|
||||
sandbox so the same origin isolation applies even when the URL is opened
|
||||
directly in a top-level tab (not just inside the workspace panel iframe).
|
||||
|
||||
Without this, a user tricked into clicking a chat link like
|
||||
/api/file/raw?path=evil.html&inline=1 would render the HTML in the WebUI's
|
||||
origin without any sandbox, giving the page full access to cookies and
|
||||
localStorage. The CSP sandbox directive (no allow-same-origin) downgrades
|
||||
the document to a unique opaque origin server-side.
|
||||
"""
|
||||
content = _get_routes_content()
|
||||
# Find the html_inline_ok block in _handle_file_raw
|
||||
idx = content.find("html_inline_ok")
|
||||
assert idx != -1, "html_inline_ok block not found"
|
||||
block = content[idx:idx + 2500]
|
||||
assert "Content-Security-Policy" in block, (
|
||||
"_handle_file_raw must set Content-Security-Policy header on inline HTML responses"
|
||||
)
|
||||
assert "sandbox" in block, (
|
||||
"CSP must include the sandbox directive"
|
||||
)
|
||||
# Must NOT have allow-same-origin in the sandbox directive
|
||||
csp_sections = [line for line in block.splitlines() if "sandbox" in line and "Policy" in line]
|
||||
for line in csp_sections:
|
||||
# The line setting the CSP header — make sure it doesn't grant same-origin
|
||||
if "send_header" in line:
|
||||
assert "allow-same-origin" not in line, (
|
||||
"CSP sandbox must NOT include allow-same-origin — that would defeat the isolation"
|
||||
)
|
||||
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"
|
||||
)
|
||||
107
tests/test_auth_session_persistence.py
Normal file
107
tests/test_auth_session_persistence.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""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.
|
||||
|
||||
api.auth does `from api.config import STATE_DIR` at module level, so
|
||||
`_SESSIONS_FILE` is computed from api.config.STATE_DIR at reload time.
|
||||
We temporarily override api.config.STATE_DIR so the reload uses the
|
||||
test state dir without reloading api.config itself (which would
|
||||
invalidate imported references like STREAM_PARTIAL_TEXT in other tests).
|
||||
"""
|
||||
import api.config as _config
|
||||
_saved = _config.STATE_DIR
|
||||
_config.STATE_DIR = _TEST_STATE
|
||||
try:
|
||||
importlib.reload(auth)
|
||||
finally:
|
||||
_config.STATE_DIR = _saved
|
||||
|
||||
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"
|
||||
)
|
||||
189
tests/test_bugbatch_apr2026.py
Normal file
189
tests/test_bugbatch_apr2026.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Bug batch fixes — April 2026.
|
||||
|
||||
Covers:
|
||||
- #594: .app-dialog and .file-rename-input have light theme overrides in style.css
|
||||
- #576: workspace panel localStorage restore is gated on session.workspace presence (boot.js)
|
||||
- #585: get_available_models() calls reload_config() before reading config cache
|
||||
- #567: docker-compose.yml comment mentions macOS UID mismatch
|
||||
- #590: _transcribeBlob already calls setComposerStatus('Transcribing…') — confirmed present
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
COMPOSE = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── #594: light theme dialog overrides ───────────────────────────────────────
|
||||
|
||||
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_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_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_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_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"
|
||||
)
|
||||
|
||||
|
||||
# ── #576: workspace panel snap fix ───────────────────────────────────────────
|
||||
|
||||
def test_576_panel_restore_gated_on_workspace():
|
||||
"""boot.js: localStorage panel restore must be gated on session.workspace."""
|
||||
# The guard must appear: session.workspace check before _workspacePanelMode='browse'
|
||||
# Panel pref key takes priority over runtime key (toolbar close must not clear preference)
|
||||
assert "S.session&&S.session.workspace&&panelPref" in BOOT_JS, (
|
||||
"Workspace panel localStorage restore must be gated on S.session.workspace "
|
||||
"to prevent snap-open-then-closed on sessions without a workspace (#576)"
|
||||
)
|
||||
assert "'hermes-webui-workspace-panel-pref'" in BOOT_JS, (
|
||||
"Panel restore must check the preference key so toolbar close does not clear it"
|
||||
)
|
||||
|
||||
|
||||
def test_576_restore_happens_after_load_session():
|
||||
"""boot.js: loadSession() must come before the panel restore guard."""
|
||||
load_pos = BOOT_JS.find("await loadSession(saved)")
|
||||
restore_pos = BOOT_JS.find("panelPref")
|
||||
assert load_pos != -1, "loadSession call not found in boot.js"
|
||||
assert restore_pos != -1, "workspace panel restore guard not found"
|
||||
assert load_pos < restore_pos, (
|
||||
"loadSession() must run before the panel restore guard "
|
||||
"so S.session.workspace is known at restore time"
|
||||
)
|
||||
|
||||
|
||||
# ── #585: get_available_models reloads config ─────────────────────────────────
|
||||
|
||||
def test_585_get_available_models_calls_reload_config():
|
||||
"""api/config.py: get_available_models() must do a mtime-based reload check."""
|
||||
config_src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
fn_start = config_src.find("def get_available_models()")
|
||||
assert fn_start != -1, "get_available_models not found"
|
||||
fn_body_end = config_src.find('"""', config_src.find('"""', fn_start + 30) + 3) + 3
|
||||
# Must check mtime before reading config
|
||||
mtime_pos = config_src.find("_current_mtime", fn_body_end)
|
||||
active_prov_pos = config_src.find("active_provider = None", fn_body_end)
|
||||
assert mtime_pos != -1, (
|
||||
"get_available_models() must check config file mtime before reading cache (#585)"
|
||||
)
|
||||
assert mtime_pos < active_prov_pos, (
|
||||
"mtime check must come before active_provider = None in get_available_models()"
|
||||
)
|
||||
|
||||
|
||||
# ── #567: docker-compose UID note ─────────────────────────────────────────────
|
||||
|
||||
def test_567_compose_mentions_macos_uid():
|
||||
"""docker-compose.yml must mention macOS UID / id -u to help macOS users."""
|
||||
assert "macOS" in COMPOSE or "macos" in COMPOSE.lower(), (
|
||||
"docker-compose.yml should mention macOS UID issue (#567)"
|
||||
)
|
||||
assert "id -u" in COMPOSE, (
|
||||
"docker-compose.yml should tell users to run 'id -u' to find their UID (#567)"
|
||||
)
|
||||
|
||||
|
||||
# ── #590: transcription spinner already present ───────────────────────────────
|
||||
|
||||
def test_590_transcribing_status_shown_before_fetch():
|
||||
"""boot.js: setComposerStatus('Transcribing…') must fire before the fetch call."""
|
||||
transcribe_fn_start = BOOT_JS.find("async function _transcribeBlob(")
|
||||
assert transcribe_fn_start != -1, "_transcribeBlob not found in boot.js"
|
||||
fn_body = BOOT_JS[transcribe_fn_start:transcribe_fn_start + 600]
|
||||
status_pos = fn_body.find("setComposerStatus('Transcribing")
|
||||
fetch_pos = fn_body.find("await fetch(")
|
||||
assert status_pos != -1, (
|
||||
"setComposerStatus('Transcribing…') must be called before the fetch in _transcribeBlob"
|
||||
)
|
||||
assert fetch_pos != -1, "await fetch not found in _transcribeBlob"
|
||||
assert status_pos < fetch_pos, (
|
||||
"setComposerStatus('Transcribing…') must appear before 'await fetch' "
|
||||
"so the UI shows a spinner immediately on stop (#590)"
|
||||
)
|
||||
|
||||
|
||||
def test_590_recording_stops_before_transcribe():
|
||||
"""boot.js: _setRecording(false) must fire in onstop before _transcribeBlob."""
|
||||
onstop_start = BOOT_JS.find("mediaRecorder.onstop")
|
||||
assert onstop_start != -1, "mediaRecorder.onstop not found"
|
||||
onstop_body = BOOT_JS[onstop_start:onstop_start + 400]
|
||||
rec_pos = onstop_body.find("_setRecording(false)")
|
||||
blob_pos = onstop_body.find("_transcribeBlob(")
|
||||
assert rec_pos != -1 and blob_pos != -1
|
||||
assert rec_pos < blob_pos, (
|
||||
"_setRecording(false) must come before _transcribeBlob so mic icon clears immediately"
|
||||
)
|
||||
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"""
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_chinese_locale_includes_representative_translations():
|
||||
"approval_heading: '需要审批'",
|
||||
"tab_tasks: '任务'",
|
||||
"tab_profiles: '配置'",
|
||||
"session_time_just_now: '刚刚'",
|
||||
"session_time_bucket_today: '今天'",
|
||||
"onboarding_title: '欢迎使用 Hermes Web UI'",
|
||||
"onboarding_complete: '引导完成'",
|
||||
]
|
||||
|
||||
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"
|
||||
)
|
||||
148
tests/test_cron_session_title.py
Normal file
148
tests/test_cron_session_title.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Tests for cron session title fallback in get_cli_sessions().
|
||||
|
||||
When a CLI session originates from cron and has no title in state.db, the
|
||||
WebUI sidebar should display the human-friendly job name from cron/jobs.json
|
||||
instead of a generic "Cron Session" label.
|
||||
|
||||
Session ID format produced by hermes-agent: cron_<job_id>_<YYYYMMDD>_<HHMMSS>
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
import api.models as models
|
||||
|
||||
|
||||
def _make_state_db(path, sessions):
|
||||
"""Create a state.db with the schema get_cli_sessions() expects.
|
||||
|
||||
`sessions` is a list of (id, title, source) tuples.
|
||||
"""
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.execute("""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
message_count INTEGER,
|
||||
started_at REAL,
|
||||
source TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
timestamp REAL
|
||||
)
|
||||
""")
|
||||
for sid, title, source in sessions:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, title, model, message_count, started_at, source) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(sid, title, "gpt-x", 1, 1700000000.0, source),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, timestamp) VALUES (?, ?)",
|
||||
(sid, 1700000001.0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _write_jobs_json(hermes_home, jobs):
|
||||
"""Write cron/jobs.json with the given jobs list."""
|
||||
cron_dir = hermes_home / "cron"
|
||||
cron_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cron_dir / "jobs.json").write_text(
|
||||
json.dumps({"jobs": jobs}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_hermes_home(tmp_path, monkeypatch):
|
||||
"""Point get_cli_sessions() at a temporary HERMES_HOME and disable
|
||||
profile lookups so the test runs hermetically."""
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
|
||||
# Both profile helpers are imported lazily inside get_cli_sessions(),
|
||||
# so patching the api.profiles module reaches them.
|
||||
import api.profiles as profiles
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: home)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None)
|
||||
|
||||
return home
|
||||
|
||||
|
||||
def test_cron_session_uses_job_name_when_title_missing(fake_hermes_home):
|
||||
"""A cron session with no title should display the friendly job name."""
|
||||
_write_jobs_json(fake_hermes_home, [
|
||||
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
|
||||
])
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_cd65df6fc1a8_20260417_191049", None, "cron"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["title"] == "wiki-auto-ingest"
|
||||
|
||||
|
||||
def test_cron_session_falls_back_when_jobs_json_missing(fake_hermes_home):
|
||||
"""No jobs.json should not crash; title falls back to 'Cron Session'."""
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_abc123_20260417_191049", None, "cron"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert sessions[0]["title"] == "Cron Session"
|
||||
|
||||
|
||||
def test_cron_session_falls_back_when_job_id_not_in_jobs_json(fake_hermes_home):
|
||||
"""Stale session whose job has been deleted falls back gracefully."""
|
||||
_write_jobs_json(fake_hermes_home, [
|
||||
{"id": "different_job", "name": "Some Other Job"},
|
||||
])
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_orphan_20260417_191049", None, "cron"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert sessions[0]["title"] == "Cron Session"
|
||||
|
||||
|
||||
def test_explicit_title_is_preserved(fake_hermes_home):
|
||||
"""If state.db already has a title, the cron job lookup should not
|
||||
override it."""
|
||||
_write_jobs_json(fake_hermes_home, [
|
||||
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
|
||||
])
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_cd65df6fc1a8_20260417_191049", "User-edited title", "cron"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert sessions[0]["title"] == "User-edited title"
|
||||
|
||||
|
||||
def test_non_cron_sessions_unaffected(fake_hermes_home):
|
||||
"""The cron-name lookup must not run for cli-source sessions, so the
|
||||
generic 'Cli Session' fallback still applies when title is empty."""
|
||||
_write_jobs_json(fake_hermes_home, [
|
||||
{"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"},
|
||||
])
|
||||
# A 'cli' session whose ID coincidentally starts with 'cron_' must not
|
||||
# pick up the job name — the source check guards against this.
|
||||
_make_state_db(fake_hermes_home / "state.db", [
|
||||
("cron_cd65df6fc1a8_xx", None, "cli"),
|
||||
])
|
||||
|
||||
sessions = models.get_cli_sessions()
|
||||
|
||||
assert sessions[0]["title"] == "Cli Session"
|
||||
@@ -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"
|
||||
@@ -156,6 +156,79 @@ def test_gateway_sessions_appear_when_enabled():
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sessions_without_messages_are_hidden_from_sidebar():
|
||||
"""Regression: empty agent session rows must not appear as broken sidebar entries."""
|
||||
conn = _ensure_state_db()
|
||||
empty_sid = 'gw_empty_no_messages_001'
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(empty_sid, 'cron', 'Cron Session', 'openai/gpt-5', time.time(), 0),
|
||||
)
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
|
||||
conn.commit()
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
assert empty_sid not in {s.get('session_id') for s in sessions}, (
|
||||
"Agent sessions with no readable message rows should be filtered before "
|
||||
"they reach the sidebar; otherwise clicking them fails during import."
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, empty_sid)
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_watcher_hides_sessions_without_messages(monkeypatch):
|
||||
"""Regression: SSE watcher must use the same importable-agent filter."""
|
||||
conn = _ensure_state_db()
|
||||
empty_sid = 'gw_empty_watcher_001'
|
||||
live_sid = 'gw_live_watcher_001'
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(empty_sid, 'cron', 'Empty Cron Session', 'openai/gpt-5', time.time(), 0),
|
||||
)
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
|
||||
_insert_gateway_session(
|
||||
conn,
|
||||
session_id=live_sid,
|
||||
source='cron',
|
||||
title='Live Cron Session',
|
||||
message_count=0,
|
||||
)
|
||||
|
||||
import api.gateway_watcher as gateway_watcher
|
||||
|
||||
monkeypatch.setattr(gateway_watcher, '_get_state_db_path', _get_state_db_path)
|
||||
|
||||
sessions = gateway_watcher._get_agent_sessions_from_db()
|
||||
ids = {s.get('session_id') for s in sessions}
|
||||
live = next((s for s in sessions if s.get('session_id') == live_sid), None)
|
||||
|
||||
assert empty_sid not in ids
|
||||
assert live is not None
|
||||
assert live.get('message_count') == 2, (
|
||||
"Watcher should fall back to actual message rows when stored "
|
||||
"message_count is zero, matching the sidebar route."
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, empty_sid, live_sid)
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_gateway_sessions_excluded_when_disabled():
|
||||
"""Gateway sessions are NOT returned when show_cli_sessions is off."""
|
||||
conn = _ensure_state_db()
|
||||
@@ -352,6 +425,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 +508,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"
|
||||
)
|
||||
|
||||
198
tests/test_issue1014_model_not_found.py
Normal file
198
tests/test_issue1014_model_not_found.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Tests for issue #1014 — model-not-found error classification.
|
||||
|
||||
Covers:
|
||||
1. streaming.py: 404/model-not-found errors detected and classified as 'model_not_found'
|
||||
2. streaming.py: HTML tags stripped from provider error messages before classification
|
||||
3. static/messages.js: apperror handler has model_not_found branch
|
||||
4. static/i18n.js: model_not_found_label key present in all locales
|
||||
5. streaming.py: model_not_found checked after auth but before generic error
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
def _read(rel_path: str) -> str:
|
||||
return (REPO_ROOT / rel_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── 1. streaming.py: model-not-found error detection ─────────────────────────
|
||||
|
||||
class TestStreamingModelNotFoundDetection:
|
||||
"""streaming.py must classify 404/model-not-found errors as model_not_found."""
|
||||
|
||||
def test_model_not_found_type_defined_in_streaming(self):
|
||||
"""'model_not_found' type must be emitted for 404 errors."""
|
||||
src = _read("api/streaming.py")
|
||||
assert "model_not_found" in src, (
|
||||
"model_not_found type not found in streaming.py — "
|
||||
"404 errors will not be surfaced with a helpful message"
|
||||
)
|
||||
|
||||
def test_is_not_found_flag_defined(self):
|
||||
"""_exc_is_not_found variable must exist in the exception handler."""
|
||||
src = _read("api/streaming.py")
|
||||
assert "_exc_is_not_found" in src, (
|
||||
"_exc_is_not_found flag not found in streaming.py"
|
||||
)
|
||||
|
||||
def test_not_found_detects_404(self):
|
||||
"""'404' must be part of the model-not-found detection logic."""
|
||||
src = _read("api/streaming.py")
|
||||
idx = src.find("_exc_is_not_found")
|
||||
assert idx != -1, "_exc_is_not_found not found"
|
||||
block = src[idx:idx + 600]
|
||||
assert "'404'" in block or '"404"' in block, (
|
||||
"'404' not in model-not-found detection block"
|
||||
)
|
||||
|
||||
def test_not_found_detects_not_found_string(self):
|
||||
"""'not found' must be part of the detection logic."""
|
||||
src = _read("api/streaming.py")
|
||||
idx = src.find("_exc_is_not_found")
|
||||
block = src[idx:idx + 600]
|
||||
assert "not found" in block.lower(), (
|
||||
"'not found' not in model-not-found detection block"
|
||||
)
|
||||
|
||||
def test_not_found_detects_does_not_exist(self):
|
||||
"""'does not exist' must be part of the detection logic."""
|
||||
src = _read("api/streaming.py")
|
||||
idx = src.find("_exc_is_not_found")
|
||||
block = src[idx:idx + 600]
|
||||
assert "does not exist" in block.lower(), (
|
||||
"'does not exist' not in model-not-found detection block"
|
||||
)
|
||||
|
||||
def test_not_found_detects_invalid_model(self):
|
||||
"""'invalid model' must be part of the detection logic."""
|
||||
src = _read("api/streaming.py")
|
||||
idx = src.find("_exc_is_not_found")
|
||||
block = src[idx:idx + 600]
|
||||
assert "invalid model" in block.lower(), (
|
||||
"'invalid model' not in model-not-found detection block"
|
||||
)
|
||||
|
||||
def test_not_found_hint_mentions_settings(self):
|
||||
"""The model_not_found hint must mention Settings or hermes model."""
|
||||
src = _read("api/streaming.py")
|
||||
idx = src.find("model_not_found")
|
||||
block = src[idx:idx + 500]
|
||||
assert "Settings" in block or "hermes model" in block, (
|
||||
"model_not_found hint must mention Settings or hermes model command"
|
||||
)
|
||||
|
||||
def test_not_found_check_order_after_auth(self):
|
||||
"""model_not_found must be checked after auth_mismatch (auth first)."""
|
||||
src = _read("api/streaming.py")
|
||||
auth_idx = src.find("elif _exc_is_auth")
|
||||
nf_idx = src.find("elif _exc_is_not_found")
|
||||
assert auth_idx != -1, "_exc_is_auth not found"
|
||||
assert nf_idx != -1, "_exc_is_not_found not found"
|
||||
assert auth_idx < nf_idx, (
|
||||
"auth_mismatch should be checked before model_not_found — "
|
||||
"auth errors must not be mistaken for not-found errors"
|
||||
)
|
||||
|
||||
|
||||
# ── 2. streaming.py: HTML sanitization ───────────────────────────────────────
|
||||
|
||||
class TestStreamingHtmlSanitization:
|
||||
"""Provider error messages containing HTML must be stripped."""
|
||||
|
||||
def test_html_strip_before_classification(self):
|
||||
"""HTML tags must be stripped before error classification."""
|
||||
src = _read("api/streaming.py")
|
||||
# Find the HTML sanitization block in the exception handler
|
||||
# It should appear before _exc_lower = err_str.lower()
|
||||
sanitize_idx = src.find("re.sub(r'<[^>]+>'")
|
||||
exc_lower_idx = src.find("_exc_lower = err_str.lower()")
|
||||
assert sanitize_idx != -1, (
|
||||
"HTML tag stripping (re.sub) not found in streaming.py exception handler"
|
||||
)
|
||||
assert exc_lower_idx != -1, "_exc_lower not found"
|
||||
assert sanitize_idx < exc_lower_idx, (
|
||||
"HTML sanitization must happen before error classification"
|
||||
)
|
||||
|
||||
def test_whitespace_normalization(self):
|
||||
"""Stripped HTML must have whitespace collapsed."""
|
||||
src = _read("api/streaming.py")
|
||||
sanitize_idx = src.find("re.sub(r'<[^>]+>'")
|
||||
block = src[sanitize_idx:sanitize_idx + 300]
|
||||
assert r"\s+" in block, (
|
||||
"Whitespace normalization (\\s+) not found after HTML strip"
|
||||
)
|
||||
|
||||
|
||||
# ── 3. static/messages.js: apperror handler ──────────────────────────────────
|
||||
|
||||
class TestApperrorModelNotFound:
|
||||
"""messages.js apperror handler must handle model_not_found type."""
|
||||
|
||||
def test_model_not_found_type_handled(self):
|
||||
"""apperror handler must check for type='model_not_found'."""
|
||||
src = _read("static/messages.js")
|
||||
assert "model_not_found" in src, (
|
||||
"model_not_found type not handled in messages.js apperror handler"
|
||||
)
|
||||
|
||||
def test_model_not_found_label(self):
|
||||
"""'Model not found' label must appear in the error handling."""
|
||||
src = _read("static/messages.js")
|
||||
assert "Model not found" in src, (
|
||||
"'Model not found' label not found in messages.js"
|
||||
)
|
||||
|
||||
def test_is_model_not_found_variable(self):
|
||||
"""isModelNotFound variable must be defined."""
|
||||
src = _read("static/messages.js")
|
||||
assert "isModelNotFound" in src, (
|
||||
"isModelNotFound variable not found in messages.js apperror handler"
|
||||
)
|
||||
|
||||
|
||||
# ── 4. static/i18n.js: all locales ───────────────────────────────────────────
|
||||
|
||||
class TestI18nModelNotFound:
|
||||
"""All locales must have model_not_found_label."""
|
||||
|
||||
REQUIRED_KEY = "model_not_found_label"
|
||||
|
||||
def _locale_names(self, src: str) -> list:
|
||||
pattern = re.compile(
|
||||
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
|
||||
re.MULTILINE,
|
||||
)
|
||||
names = []
|
||||
for match in pattern.finditer(src):
|
||||
names.append(match.group("quoted") or match.group("plain"))
|
||||
return names
|
||||
|
||||
def _count_key(self, src: str, key: str) -> int:
|
||||
return len(re.findall(r'\b' + re.escape(key) + r'\b', src))
|
||||
|
||||
def test_all_locales_have_model_not_found_label(self):
|
||||
"""model_not_found_label must appear in all locales."""
|
||||
src = _read("static/i18n.js")
|
||||
locale_count = len(self._locale_names(src))
|
||||
count = self._count_key(src, self.REQUIRED_KEY)
|
||||
assert count >= locale_count, (
|
||||
f"model_not_found_label found {count} times, expected >= {locale_count} "
|
||||
f"(one per locale)"
|
||||
)
|
||||
|
||||
def test_english_label_is_plain_string(self):
|
||||
"""English model_not_found_label must be a plain string, not a function."""
|
||||
src = _read("static/i18n.js")
|
||||
en_start = src.find("\n en: {")
|
||||
es_start = src.find("\n es: {")
|
||||
en_block = src[en_start:es_start]
|
||||
assert self.REQUIRED_KEY in en_block, "Key not in en block"
|
||||
idx = en_block.find(self.REQUIRED_KEY)
|
||||
line = en_block[idx:idx + 200]
|
||||
assert "=>" not in line, (
|
||||
"model_not_found_label should be a plain string, not an arrow function"
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -1,216 +1,116 @@
|
||||
"""
|
||||
Regression tests for issue #401 / PR #402:
|
||||
Tool call cards show incorrect/duplicate entries on session load after context compaction.
|
||||
Regression tests for tool-card persistence on session reload.
|
||||
|
||||
Root cause: loadSession() applied its own B9 sanitization (producing a new message array
|
||||
with different indices) but did not remap the session-level tool_calls.assistant_msg_idx
|
||||
values to match. It then assigned the broken tool_calls directly to S.toolCalls, bypassing
|
||||
renderMessages()'s fallback that correctly derives tool calls from per-message tool_calls.
|
||||
The older loadSession() path rewrote message history on the client:
|
||||
- dropped role='tool' rows
|
||||
- dropped empty assistant rows even when they carried tool_calls
|
||||
- then ignored session.tool_calls on reload
|
||||
|
||||
Fix: build origIdxToSanitizedIdx during the B9 pass and remap each tc.assistant_msg_idx;
|
||||
set S.toolCalls=[] so renderMessages() uses the fallback derivation.
|
||||
|
||||
These tests verify the JS logic statically (no server needed).
|
||||
That broke both durable logging and page refresh for valid tool runs.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import textwrap
|
||||
import json
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# --- Static structural checks ---
|
||||
|
||||
def test_loadsession_sets_toolcalls_empty():
|
||||
"""loadSession must set S.toolCalls=[] instead of pre-filling from session-level tool_calls."""
|
||||
assert "S.toolCalls=[]" in SESSIONS_JS, (
|
||||
"loadSession() must set S.toolCalls=[] so renderMessages() uses its fallback "
|
||||
"derivation from per-message tool_calls with correct sanitized-array indices"
|
||||
def test_loadsession_preserves_tool_rows():
|
||||
"""Reload must keep tool rows in S.messages so snippets can be reconstructed."""
|
||||
assert "if (m.role === 'tool') continue;" not in SESSIONS_JS, (
|
||||
"loadSession() must not drop role='tool' messages; renderMessages() hides them "
|
||||
"visually, but it still needs them for snippet reconstruction"
|
||||
)
|
||||
|
||||
|
||||
def test_loadsession_does_not_assign_broken_tool_calls():
|
||||
"""loadSession must NOT assign session.tool_calls directly to S.toolCalls (causes index mismatch)."""
|
||||
# The old broken pattern: S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}))
|
||||
assert "S.toolCalls=(data.session.tool_calls" not in SESSIONS_JS, (
|
||||
"loadSession() must not assign session-level tool_calls directly to S.toolCalls — "
|
||||
"those indices are relative to the pre-sanitization array and will be wrong after B9 filtering"
|
||||
)
|
||||
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 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
|
||||
|
||||
|
||||
def test_loadsession_builds_idx_remap():
|
||||
"""loadSession must build an origIdxToSanitizedIdx map during B9 sanitization."""
|
||||
assert "origIdxToSanitizedIdx" in SESSIONS_JS, (
|
||||
"loadSession() must build origIdxToSanitizedIdx during B9 sanitization "
|
||||
"to remap session-level tool_calls.assistant_msg_idx"
|
||||
)
|
||||
def test_rendermessages_treats_openai_toolcall_assistants_as_visible():
|
||||
"""OpenAI assistant rows with empty content but tool_calls must stay anchorable."""
|
||||
assert "const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;" in UI_JS
|
||||
assert "if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;" in UI_JS
|
||||
|
||||
|
||||
def test_loadsession_remaps_assistant_msg_idx():
|
||||
"""loadSession must remap tc.assistant_msg_idx using the index map."""
|
||||
assert "tc.assistant_msg_idx" in SESSIONS_JS, (
|
||||
"loadSession() must update tc.assistant_msg_idx using the sanitized index map"
|
||||
)
|
||||
|
||||
|
||||
# --- Behavioural Node.js tests ---
|
||||
|
||||
def _run_js(script_body: str) -> dict:
|
||||
"""Run a JS snippet that exercises the B9 sanitization logic extracted from sessions.js."""
|
||||
# Extract just the B9 + index-remap block from loadSession
|
||||
# We'll re-implement it inline for testability
|
||||
script = textwrap.dedent(f"""
|
||||
// Simulate the B9 sanitization + index remap logic from loadSession()
|
||||
function sanitizeAndRemap(messages, tool_calls) {{
|
||||
const allMsgs = messages || [];
|
||||
const sanitized = [];
|
||||
const origIdxToSanitizedIdx = {{}};
|
||||
let lastKeptAsstIdx = -1;
|
||||
for (let i = 0; i < allMsgs.length; i++) {{
|
||||
const m = allMsgs[i];
|
||||
if (!m || !m.role) continue;
|
||||
if (m.role === 'tool') continue;
|
||||
if (m.role === 'assistant') {{
|
||||
let c = m.content || '';
|
||||
if (Array.isArray(c)) c = c.filter(p => p && p.type === 'text').map(p => p.text || '').join('');
|
||||
if (!String(c).trim().length) {{ continue; }}
|
||||
lastKeptAsstIdx = sanitized.length;
|
||||
}}
|
||||
origIdxToSanitizedIdx[i] = sanitized.length;
|
||||
sanitized.push(m);
|
||||
}}
|
||||
const remapped = (tool_calls || []).map(tc => {{
|
||||
if (!tc || tc.assistant_msg_idx === undefined) return tc;
|
||||
const origIdx = tc.assistant_msg_idx;
|
||||
const newIdx = (origIdx in origIdxToSanitizedIdx)
|
||||
? origIdxToSanitizedIdx[origIdx]
|
||||
: (lastKeptAsstIdx >= 0 ? lastKeptAsstIdx : -1);
|
||||
return {{ ...tc, assistant_msg_idx: newIdx }};
|
||||
function loadSessionShape(messages, sessionToolCalls) {{
|
||||
const filtered = (messages || []).filter(m => m && m.role);
|
||||
const hasMessageToolMetadata = filtered.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;
|
||||
}});
|
||||
return {{ sanitized, remapped }};
|
||||
const toolCalls = (!hasMessageToolMetadata && sessionToolCalls && sessionToolCalls.length)
|
||||
? sessionToolCalls.map(tc => ({{ ...tc, done: true }}))
|
||||
: [];
|
||||
return {{ filtered, hasMessageToolMetadata, toolCalls }};
|
||||
}}
|
||||
|
||||
{script_body}
|
||||
""")
|
||||
proc = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_b9_remaps_tool_call_idx_after_empty_assistant_filtered():
|
||||
"""Tool call pointing to index 1 (empty assistant at orig idx 1, kept at idx 0) remaps correctly."""
|
||||
def test_reload_keeps_empty_assistant_toolcall_anchor():
|
||||
"""OpenAI-style assistant {content:'', tool_calls:[...]} must survive reload."""
|
||||
result = _run_js("""
|
||||
const messages = [
|
||||
{ role: 'user', content: 'hello' }, // orig 0 -> sanitized 0
|
||||
{ role: 'assistant', content: '' }, // orig 1 -> FILTERED (empty)
|
||||
{ role: 'assistant', content: 'done.' }, // orig 2 -> sanitized 1
|
||||
{ role: 'user', content: 'list files' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{ id: 'call-1', function: { name: 'terminal', arguments: '{}' } }]
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call-1', content: '{"output":"ok"}' },
|
||||
{ role: 'assistant', content: 'Done.' }
|
||||
];
|
||||
const tool_calls = [
|
||||
{ name: 'terminal', assistant_msg_idx: 1 }, // pointed to filtered-out empty assistant
|
||||
{ name: 'read_file', assistant_msg_idx: 2 }, // pointed to kept assistant
|
||||
];
|
||||
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
|
||||
const loaded = loadSessionShape(messages, [{ name: 'terminal', assistant_msg_idx: 1 }]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
sanitized_length: sanitized.length,
|
||||
tc0_new_idx: remapped[0].assistant_msg_idx, // should attach to lastKeptAsstIdx = 1
|
||||
tc1_new_idx: remapped[1].assistant_msg_idx, // should remap 2 -> 1
|
||||
filtered_len: loaded.filtered.length,
|
||||
has_metadata: loaded.hasMessageToolMetadata,
|
||||
fallback_len: loaded.toolCalls.length,
|
||||
assistant_tool_idx: loaded.filtered.findIndex(m => m.role === 'assistant' && m.tool_calls),
|
||||
tool_idx: loaded.filtered.findIndex(m => m.role === 'tool')
|
||||
}));
|
||||
""")
|
||||
assert result["sanitized_length"] == 2, f"Expected 2 messages after B9, got {result['sanitized_length']}"
|
||||
assert result["tc0_new_idx"] == 1, (
|
||||
f"Tool call pointing to filtered empty assistant should attach to last kept assistant (idx 1), got {result['tc0_new_idx']}"
|
||||
)
|
||||
assert result["tc1_new_idx"] == 1, (
|
||||
f"Tool call pointing to orig idx 2 should remap to sanitized idx 1, got {result['tc1_new_idx']}"
|
||||
)
|
||||
assert result["filtered_len"] == 4
|
||||
assert result["has_metadata"] is True
|
||||
assert result["fallback_len"] == 0
|
||||
assert result["assistant_tool_idx"] == 1
|
||||
assert result["tool_idx"] == 2
|
||||
|
||||
|
||||
def test_b9_remaps_multiple_empty_assistants():
|
||||
"""Multiple consecutive empty assistants all remap to the last (nearest) kept assistant.
|
||||
|
||||
Note: the remapping pass runs after the full sanitization loop, so lastKeptAsstIdx
|
||||
already reflects the final kept-assistant position. This means even empty-assistant
|
||||
tool calls that came BEFORE the kept assistant get attached to it — which is correct
|
||||
behavior for context-compacted sessions where all tool calls belong to the one
|
||||
non-empty assistant response.
|
||||
"""
|
||||
def test_reload_uses_session_summary_when_messages_have_no_tool_metadata():
|
||||
"""Older sessions should still render from session.tool_calls on reload."""
|
||||
result = _run_js("""
|
||||
const messages = [
|
||||
{ role: 'user', content: 'go' }, // orig 0 -> sanitized 0
|
||||
{ role: 'assistant', content: '' }, // orig 1 -> FILTERED
|
||||
{ role: 'assistant', content: '' }, // orig 2 -> FILTERED
|
||||
{ role: 'assistant', content: '' }, // orig 3 -> FILTERED
|
||||
{ role: 'assistant', content: 'result' }, // orig 4 -> sanitized 1
|
||||
{ role: 'user', content: 'build site' },
|
||||
{ role: 'assistant', content: 'Starting.' },
|
||||
{ role: 'tool', content: '{"bytes_written": 4955}' },
|
||||
{ role: 'assistant', content: '' }
|
||||
];
|
||||
const tool_calls = [
|
||||
{ name: 'a', assistant_msg_idx: 1 },
|
||||
{ name: 'b', assistant_msg_idx: 2 },
|
||||
{ name: 'c', assistant_msg_idx: 3 },
|
||||
{ name: 'd', assistant_msg_idx: 4 },
|
||||
const sessionToolCalls = [
|
||||
{ name: 'write_file', assistant_msg_idx: 1, snippet: 'bytes_written', tid: '' }
|
||||
];
|
||||
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
|
||||
const loaded = loadSessionShape(messages, sessionToolCalls);
|
||||
process.stdout.write(JSON.stringify({
|
||||
sanitized_length: sanitized.length,
|
||||
tc0_idx: remapped[0].assistant_msg_idx,
|
||||
tc1_idx: remapped[1].assistant_msg_idx,
|
||||
tc2_idx: remapped[2].assistant_msg_idx,
|
||||
tc3_idx: remapped[3].assistant_msg_idx,
|
||||
has_metadata: loaded.hasMessageToolMetadata,
|
||||
fallback_len: loaded.toolCalls.length,
|
||||
done_flag: loaded.toolCalls[0] && loaded.toolCalls[0].done === true
|
||||
}));
|
||||
""")
|
||||
assert result["sanitized_length"] == 2
|
||||
# Tool calls from filtered empty assistants: after the full loop, lastKeptAsstIdx=1,
|
||||
# so all filtered-assistant tool calls correctly attach to the kept assistant at idx 1.
|
||||
assert result["tc0_idx"] == 1, f"Expected 1 (last kept asst), got {result['tc0_idx']}"
|
||||
assert result["tc1_idx"] == 1
|
||||
assert result["tc2_idx"] == 1
|
||||
# Tool call from the kept assistant at orig idx 4 -> sanitized idx 1
|
||||
assert result["tc3_idx"] == 1, f"Expected 1, got {result['tc3_idx']}"
|
||||
|
||||
|
||||
def test_b9_no_filtering_needed_indices_preserved():
|
||||
"""When no empty assistant messages exist, indices should pass through unchanged."""
|
||||
result = _run_js("""
|
||||
const messages = [
|
||||
{ role: 'user', content: 'hi' }, // orig 0 -> sanitized 0
|
||||
{ role: 'assistant', content: 'hello' }, // orig 1 -> sanitized 1
|
||||
{ role: 'user', content: 'more' }, // orig 2 -> sanitized 2
|
||||
{ role: 'assistant', content: 'yes' }, // orig 3 -> sanitized 3
|
||||
];
|
||||
const tool_calls = [
|
||||
{ name: 'x', assistant_msg_idx: 1 },
|
||||
{ name: 'y', assistant_msg_idx: 3 },
|
||||
];
|
||||
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
|
||||
process.stdout.write(JSON.stringify({
|
||||
sanitized_length: sanitized.length,
|
||||
tc0_idx: remapped[0].assistant_msg_idx,
|
||||
tc1_idx: remapped[1].assistant_msg_idx,
|
||||
}));
|
||||
""")
|
||||
assert result["sanitized_length"] == 4
|
||||
assert result["tc0_idx"] == 1, f"Expected 1, got {result['tc0_idx']}"
|
||||
assert result["tc1_idx"] == 3, f"Expected 3, got {result['tc1_idx']}"
|
||||
|
||||
|
||||
def test_b9_tool_role_messages_filtered():
|
||||
"""Messages with role='tool' must be filtered out and not affect index mapping."""
|
||||
result = _run_js("""
|
||||
const messages = [
|
||||
{ role: 'user', content: 'run' }, // orig 0 -> sanitized 0
|
||||
{ role: 'tool', content: 'output' }, // orig 1 -> FILTERED (tool role)
|
||||
{ role: 'assistant', content: 'done' }, // orig 2 -> sanitized 1
|
||||
];
|
||||
const tool_calls = [
|
||||
{ name: 'terminal', assistant_msg_idx: 2 },
|
||||
];
|
||||
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
|
||||
process.stdout.write(JSON.stringify({
|
||||
sanitized_length: sanitized.length,
|
||||
tc0_idx: remapped[0].assistant_msg_idx,
|
||||
}));
|
||||
""")
|
||||
assert result["sanitized_length"] == 2, f"tool-role message must be filtered, got {result['sanitized_length']}"
|
||||
assert result["tc0_idx"] == 1, f"Expected orig idx 2 -> sanitized idx 1, got {result['tc0_idx']}"
|
||||
assert result["has_metadata"] is False
|
||||
assert result["fallback_len"] == 1
|
||||
assert result["done_flag"] is True
|
||||
|
||||
163
tests/test_issue569_579.py
Normal file
163
tests/test_issue569_579.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
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').
|
||||
The legacy raw sidebar count was removed by #584, and later reintroduced
|
||||
in a gated detailed-density mode by #673.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
INIT_SH = (REPO_ROOT / "docker_init.bash").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── #569: docker UID/GID auto-detect ─────────────────────────────────────────
|
||||
|
||||
def test_569_uid_autodetect_present():
|
||||
"""docker_init.bash must have workspace-based UID auto-detection (#569)."""
|
||||
assert "stat -c '%u'" in INIT_SH or 'stat -c \'%u\'' in INIT_SH, (
|
||||
"docker_init.bash must use stat to read workspace UID (#569)"
|
||||
)
|
||||
|
||||
|
||||
def test_569_gid_autodetect_present():
|
||||
"""docker_init.bash must have workspace-based GID auto-detection (#569)."""
|
||||
assert "stat -c '%g'" in INIT_SH or 'stat -c \'%g\'' in INIT_SH, (
|
||||
"docker_init.bash must use stat to read workspace GID (#569)"
|
||||
)
|
||||
|
||||
|
||||
def test_569_autodetect_before_usermod():
|
||||
"""UID auto-detect must appear before usermod call in docker_init.bash."""
|
||||
detect_pos = INIT_SH.find("stat -c '%u'")
|
||||
if detect_pos == -1:
|
||||
detect_pos = INIT_SH.find("stat -c")
|
||||
usermod_pos = INIT_SH.find("sudo usermod")
|
||||
assert detect_pos != -1, "stat UID detection not found"
|
||||
assert usermod_pos != -1, "sudo usermod not found"
|
||||
assert detect_pos < usermod_pos, (
|
||||
"UID auto-detect must occur before 'sudo usermod' so the correct UID "
|
||||
"is used when remapping the hermeswebui user"
|
||||
)
|
||||
|
||||
|
||||
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 volumes")
|
||||
assert detect_block_start != -1, "auto-detect comment block not found"
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
def test_569_fallback_preserved():
|
||||
"""Hardcoded default 1024 fallback must still exist after auto-detect."""
|
||||
assert "WANTED_UID=${WANTED_UID:-1024}" in INIT_SH, (
|
||||
"WANTED_UID default fallback must remain so explicit env var still works"
|
||||
)
|
||||
assert "WANTED_GID=${WANTED_GID:-1024}" in INIT_SH, (
|
||||
"WANTED_GID default fallback must remain"
|
||||
)
|
||||
|
||||
|
||||
# ── #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():
|
||||
"""ui.js topbar count must filter out role='tool' messages (#579).
|
||||
|
||||
The sidebar previously showed raw message_count (which included tool
|
||||
messages), causing a mismatch with the topbar. PR #584 removed the
|
||||
sidebar count display entirely; the topbar was already correct.
|
||||
This test locks in the existing topbar filter so it can't regress.
|
||||
"""
|
||||
# Find the topbarMeta assignment
|
||||
meta_pos = UI_JS.find("topbarMeta")
|
||||
assert meta_pos != -1, "topbarMeta assignment not found in ui.js"
|
||||
|
||||
# Find the filter that precedes it — should exclude role==='tool'
|
||||
context = UI_JS[max(0, meta_pos - 400):meta_pos + 100]
|
||||
assert "role" in context and "tool" in context, (
|
||||
"topbarMeta count must filter by role — "
|
||||
"messages with role='tool' must be excluded from the displayed count"
|
||||
)
|
||||
# The filter must exclude tool messages (not include them)
|
||||
assert "!=='tool'" in context or "!= 'tool'" in context or "role!=='tool'" in context, (
|
||||
"topbar count filter must use !== 'tool' to exclude tool messages"
|
||||
)
|
||||
|
||||
|
||||
def test_579_sidebar_count_is_gated_behind_detailed_density():
|
||||
"""sessions.js may only show sidebar count inside detailed density mode.
|
||||
|
||||
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")
|
||||
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"]
|
||||
@@ -192,10 +192,22 @@ class TestOnboardingStatusUnsupportedProvider:
|
||||
"config.yaml + chat_ready=True must auto-complete onboarding regardless of provider."
|
||||
)
|
||||
|
||||
def test_minimax_cn_not_ready_shows_wizard(self):
|
||||
"""minimax-cn + chat_ready=False → wizard fires so user can fix it."""
|
||||
def test_minimax_cn_not_ready_skips_wizard(self):
|
||||
"""minimax-cn + chat_ready=False → wizard still skipped for non-wizard providers.
|
||||
|
||||
The onboarding wizard has no minimax-cn option — showing it would only confuse
|
||||
the user or let them accidentally overwrite their config with an OpenAI/Anthropic
|
||||
provider. For any provider not in _SUPPORTED_PROVIDER_SETUPS, onboarding is
|
||||
auto-completed as long as provider_configured is True, regardless of chat_ready.
|
||||
Users on non-wizard providers with no API key should fix credentials via
|
||||
Settings → Providers, not via the first-run wizard. (#1020)
|
||||
"""
|
||||
result = self._make_status(chat_ready=False)
|
||||
assert result["completed"] is False
|
||||
assert result["completed"] is True, (
|
||||
"Wizard fired for minimax-cn user with provider_configured=True! "
|
||||
"Non-wizard providers must auto-complete onboarding because the wizard "
|
||||
"cannot configure them and would silently overwrite their config."
|
||||
)
|
||||
|
||||
def test_current_is_oauth_set_for_unsupported_provider(self):
|
||||
"""setup.current_is_oauth must be True for minimax-cn (not in quick-setup list)."""
|
||||
|
||||
376
tests/test_issue603_provider_categories.py
Normal file
376
tests/test_issue603_provider_categories.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""Tests for #603 — categorize providers in setup wizard.
|
||||
|
||||
Validates:
|
||||
- New providers added to _SUPPORTED_PROVIDER_SETUPS with correct categories
|
||||
- _PROVIDER_CATEGORIES ordering and IDs
|
||||
- _build_setup_catalog returns grouped categories
|
||||
- apply_onboarding_setup writes base_url for requires_base_url providers
|
||||
- apply_onboarding_setup writes default_base_url for providers with one
|
||||
- Frontend helper _renderProviderSelectOptions produces <optgroup>
|
||||
- i18n keys exist for all category labels
|
||||
- Fallback when categories are empty
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys, os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from api.onboarding import (
|
||||
_SUPPORTED_PROVIDER_SETUPS,
|
||||
_PROVIDER_CATEGORIES,
|
||||
_build_setup_catalog,
|
||||
apply_onboarding_setup,
|
||||
)
|
||||
|
||||
|
||||
# ── Backend: provider catalog structure ──────────────────────────────────
|
||||
|
||||
class TestProviderCatalog:
|
||||
"""Verify the extended provider catalog has categories."""
|
||||
|
||||
def test_all_providers_have_category(self):
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
||||
assert "category" in meta, f"Provider {pid} missing 'category'"
|
||||
|
||||
def test_categories_are_valid(self):
|
||||
valid_ids = {c["id"] for c in _PROVIDER_CATEGORIES}
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
||||
cat = meta["category"]
|
||||
assert cat in valid_ids, f"Provider {pid} has invalid category '{cat}'"
|
||||
|
||||
def test_easy_start_has_core_providers(self):
|
||||
easy = {
|
||||
pid
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items()
|
||||
if meta["category"] == "easy_start"
|
||||
}
|
||||
assert "openrouter" in easy
|
||||
assert "anthropic" in easy
|
||||
assert "openai" in easy
|
||||
|
||||
def test_self_hosted_has_local_providers(self):
|
||||
local = {
|
||||
pid
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items()
|
||||
if meta["category"] == "self_hosted"
|
||||
}
|
||||
assert "ollama" in local
|
||||
assert "lmstudio" in local
|
||||
assert "custom" in local
|
||||
|
||||
def test_specialized_has_extended_providers(self):
|
||||
spec = {
|
||||
pid
|
||||
for pid, meta in _SUPPORTED_PROVIDER_SETUPS.items()
|
||||
if meta["category"] == "specialized"
|
||||
}
|
||||
assert "gemini" in spec
|
||||
assert "deepseek" in spec
|
||||
assert "mistralai" in spec
|
||||
assert "x-ai" in spec
|
||||
|
||||
def test_new_providers_exist(self):
|
||||
expected = {"ollama", "lmstudio", "gemini", "deepseek", "mistralai", "x-ai"}
|
||||
assert expected.issubset(_SUPPORTED_PROVIDER_SETUPS.keys())
|
||||
|
||||
def test_new_providers_have_env_vars(self):
|
||||
for pid in ["ollama", "lmstudio", "gemini", "deepseek", "mistralai", "x-ai"]:
|
||||
meta = _SUPPORTED_PROVIDER_SETUPS[pid]
|
||||
assert meta["env_var"], f"Provider {pid} missing env_var"
|
||||
assert meta["default_model"], f"Provider {pid} missing default_model"
|
||||
|
||||
def test_local_providers_require_base_url(self):
|
||||
for pid in ["ollama", "lmstudio", "custom"]:
|
||||
assert _SUPPORTED_PROVIDER_SETUPS[pid]["requires_base_url"]
|
||||
|
||||
def test_specialized_providers_have_base_url_defaults(self):
|
||||
for pid in ["gemini", "deepseek", "mistralai", "x-ai"]:
|
||||
meta = _SUPPORTED_PROVIDER_SETUPS[pid]
|
||||
assert meta["default_base_url"], f"Provider {pid} missing default_base_url"
|
||||
|
||||
def test_google_uses_gemini_key(self):
|
||||
"""Google Gemini must use 'gemini' as provider ID (matches Hermes CLI)."""
|
||||
assert "gemini" in _SUPPORTED_PROVIDER_SETUPS
|
||||
assert "google" not in _SUPPORTED_PROVIDER_SETUPS
|
||||
|
||||
def test_gemini_model_list_is_populated(self):
|
||||
"""The gemini provider's `models` list must be non-empty.
|
||||
|
||||
Regression: api/config.py:_PROVIDER_MODELS uses key "google" (not
|
||||
"gemini") for the model catalog. If the wizard does
|
||||
_PROVIDER_MODELS.get("gemini", []) it gets an empty list and the
|
||||
provider dropdown has no model options. The provider catalog must
|
||||
look up the right key.
|
||||
"""
|
||||
gemini = _SUPPORTED_PROVIDER_SETUPS["gemini"]
|
||||
assert len(gemini["models"]) > 0, (
|
||||
"gemini provider must surface a non-empty model list — check the "
|
||||
"_PROVIDER_MODELS lookup key (catalog uses 'google', not 'gemini')"
|
||||
)
|
||||
|
||||
def test_specialized_default_models_match_catalog(self):
|
||||
"""default_model values for specialized providers must reference real
|
||||
models in the agent's catalog (or be the latest known version).
|
||||
|
||||
Regression: previously had `gemini-2.5-pro-preview` (agent catalog has
|
||||
3.1) and `grok-3` (agent catalog has 4.20). Stale defaults landed users
|
||||
on non-existent models that produced 404s on first chat.
|
||||
"""
|
||||
gemini_default = _SUPPORTED_PROVIDER_SETUPS["gemini"]["default_model"]
|
||||
assert gemini_default.startswith("gemini-3."), (
|
||||
f"gemini default_model={gemini_default!r} is stale — agent catalog has 3.1 family"
|
||||
)
|
||||
xai_default = _SUPPORTED_PROVIDER_SETUPS["x-ai"]["default_model"]
|
||||
assert xai_default.startswith("grok-4"), (
|
||||
f"x-ai default_model={xai_default!r} is stale — agent catalog has 4.20 family"
|
||||
)
|
||||
deepseek_default = _SUPPORTED_PROVIDER_SETUPS["deepseek"]["default_model"]
|
||||
# deepseek-chat (rolling) or deepseek-chat-v3-0324 (pinned) both valid
|
||||
assert deepseek_default.startswith("deepseek-"), (
|
||||
f"deepseek default_model={deepseek_default!r} must start with 'deepseek-'"
|
||||
)
|
||||
|
||||
|
||||
class TestProviderCategoryOrder:
|
||||
"""Verify category ordering."""
|
||||
|
||||
def test_categories_sorted_by_order(self):
|
||||
orders = [c["order"] for c in _PROVIDER_CATEGORIES]
|
||||
assert orders == sorted(orders)
|
||||
|
||||
def test_category_ids(self):
|
||||
ids = {c["id"] for c in _PROVIDER_CATEGORIES}
|
||||
assert ids == {"easy_start", "self_hosted", "specialized"}
|
||||
|
||||
def test_three_categories(self):
|
||||
assert len(_PROVIDER_CATEGORIES) == 3
|
||||
|
||||
|
||||
# ── Backend: _build_setup_catalog ────────────────────────────────────────
|
||||
|
||||
class TestBuildSetupCatalog:
|
||||
def test_catalog_has_categories_key(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
assert "categories" in catalog
|
||||
assert isinstance(catalog["categories"], list)
|
||||
|
||||
def test_catalog_categories_have_providers_list(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
all_provider_ids = {p["id"] for p in catalog["providers"]}
|
||||
for cat in catalog["categories"]:
|
||||
assert "providers" in cat
|
||||
for pid in cat["providers"]:
|
||||
assert pid in all_provider_ids
|
||||
|
||||
def test_catalog_providers_have_category_field(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
for p in catalog["providers"]:
|
||||
assert "category" in p
|
||||
|
||||
def test_catalog_providers_sorted_by_category(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES}
|
||||
prev_order = -1
|
||||
for p in catalog["providers"]:
|
||||
order = cat_order.get(p["category"], 99)
|
||||
assert order >= prev_order, f"Provider {p['id']} out of order"
|
||||
prev_order = order
|
||||
|
||||
def test_catalog_quick_flag_on_openrouter(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
orow = next(p for p in catalog["providers"] if p["id"] == "openrouter")
|
||||
assert orow["quick"] is True
|
||||
|
||||
def test_catalog_no_quick_flag_on_others(self):
|
||||
cfg = {"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}}
|
||||
catalog = _build_setup_catalog(cfg)
|
||||
for p in catalog["providers"]:
|
||||
if p["id"] != "openrouter":
|
||||
assert p["quick"] is False
|
||||
|
||||
|
||||
# ── Backend: apply_onboarding_setup base_url handling ───────────────────
|
||||
|
||||
class TestApplyBaseURL:
|
||||
"""Verify the generic base_url save logic."""
|
||||
|
||||
def test_requires_base_url_writes_user_url(self, tmp_path, monkeypatch):
|
||||
"""Providers with requires_base_url=True should write user-provided base_url."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
env_path = str(tmp_path / ".env")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr(
|
||||
"api.onboarding._normalize_model_for_provider", lambda prov, m: m
|
||||
)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", lambda p, c: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
apply_onboarding_setup({
|
||||
"provider": "ollama",
|
||||
"model": "qwen3:32b",
|
||||
"api_key": "test-key",
|
||||
"base_url": "http://my-ollama:11434/v1",
|
||||
"confirm_overwrite": True,
|
||||
})
|
||||
|
||||
assert saved_cfg["model"]["base_url"] == "http://my-ollama:11434/v1"
|
||||
|
||||
def test_default_base_url_written_for_openai(self, tmp_path, monkeypatch):
|
||||
"""OpenAI should get its default_base_url written to config."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr(
|
||||
"api.onboarding._normalize_model_for_provider", lambda prov, m: m
|
||||
)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
apply_onboarding_setup({
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"api_key": "test-key",
|
||||
"confirm_overwrite": True,
|
||||
})
|
||||
|
||||
assert saved_cfg["model"]["base_url"] == "https://api.openai.com/v1"
|
||||
|
||||
def test_base_url_stripped_for_anthropic(self, tmp_path, monkeypatch):
|
||||
"""Anthropic should NOT have base_url in config (Hermes knows the URL)."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr(
|
||||
"api.onboarding._normalize_model_for_provider", lambda prov, m: m
|
||||
)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
apply_onboarding_setup({
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_key": "test-key",
|
||||
"confirm_overwrite": True,
|
||||
})
|
||||
|
||||
assert "base_url" not in saved_cfg["model"]
|
||||
|
||||
|
||||
# ── Frontend: i18n keys ─────────────────────────────────────────────────
|
||||
|
||||
class TestI18nCategoryKeys:
|
||||
def test_en_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
for key in ["provider_category_easy_start", "provider_category_self_hosted", "provider_category_specialized"]:
|
||||
assert f"{key}:" in content, f"Missing i18n key: {key}"
|
||||
|
||||
def test_ru_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
# Just verify count of category keys (should appear 6+ times: once per locale block)
|
||||
assert content.count("provider_category_easy_start:") >= 4
|
||||
|
||||
def test_es_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "Inicio rápido" in content # Spanish easy_start
|
||||
|
||||
def test_zh_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "快速开始" in content # Chinese easy_start
|
||||
|
||||
def test_zh_hant_has_all_category_keys(self):
|
||||
with open("static/i18n.js", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "\\u5feb\\u901f\\u958b\\u59cb" in content # zh-Hant easy_start
|
||||
|
||||
|
||||
class TestApplyBaseURLSpecialized:
|
||||
"""Verify apply_onboarding_setup sets base_url for specialized providers."""
|
||||
|
||||
_PROVIDER_DEFAULT_MODELS = {
|
||||
"gemini": "gemini-3.1-pro-preview",
|
||||
"deepseek": "deepseek-chat-v3-0324",
|
||||
"mistralai": "mistral-large-latest",
|
||||
"x-ai": "grok-4.20",
|
||||
}
|
||||
|
||||
def _run_setup(self, tmp_path, monkeypatch, provider):
|
||||
"""Run apply_onboarding_setup with the given provider and return saved_cfg."""
|
||||
config_path = str(tmp_path / "config.yaml")
|
||||
model = self._PROVIDER_DEFAULT_MODELS.get(provider, "test-model")
|
||||
|
||||
monkeypatch.setattr("api.onboarding._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.onboarding._get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("api.onboarding._load_yaml_config", lambda p: {})
|
||||
monkeypatch.setattr("api.onboarding._normalize_model_for_provider", lambda prov, m: m)
|
||||
monkeypatch.setattr("api.onboarding._write_env_file", lambda p, d: None)
|
||||
monkeypatch.setattr("api.onboarding._provider_api_key_present", lambda *a: True)
|
||||
monkeypatch.setattr("api.onboarding.reload_config", lambda: None)
|
||||
|
||||
saved_cfg = {}
|
||||
def mock_save(p, cfg):
|
||||
saved_cfg.update(cfg)
|
||||
monkeypatch.setattr("api.onboarding._save_yaml_config", mock_save)
|
||||
|
||||
from api.onboarding import apply_onboarding_setup
|
||||
apply_onboarding_setup({"provider": provider, "model": model, "api_key": "test-key", "confirm_overwrite": True})
|
||||
return saved_cfg
|
||||
|
||||
def test_gemini_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "gemini")
|
||||
assert "generativelanguage.googleapis.com" in saved.get("model", {}).get("base_url", ""), (
|
||||
"gemini setup must write the Gemini base_url to config"
|
||||
)
|
||||
|
||||
def test_deepseek_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "deepseek")
|
||||
assert "deepseek.com" in saved.get("model", {}).get("base_url", ""), (
|
||||
"deepseek setup must write the DeepSeek base_url to config"
|
||||
)
|
||||
|
||||
def test_mistral_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "mistralai")
|
||||
assert "mistral.ai" in saved.get("model", {}).get("base_url", ""), (
|
||||
"mistral setup must write the Mistral base_url to config"
|
||||
)
|
||||
|
||||
def test_x_ai_gets_default_base_url(self, tmp_path, monkeypatch):
|
||||
saved = self._run_setup(tmp_path, monkeypatch, "x-ai")
|
||||
assert "x.ai" in saved.get("model", {}).get("base_url", ""), (
|
||||
"x-ai setup must write the xAI base_url to config"
|
||||
)
|
||||
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"
|
||||
78
tests/test_issue634.py
Normal file
78
tests/test_issue634.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
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'
|
||||
AGENT_SESSIONS_PY = pathlib.Path(__file__).parent.parent / 'api' / 'agent_sessions.py'
|
||||
src = MODELS_PY.read_text(encoding='utf-8')
|
||||
agent_src = AGENT_SESSIONS_PY.read_text(encoding='utf-8')
|
||||
combined_src = src + "\n" + agent_src
|
||||
|
||||
|
||||
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 combined_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 combined_src or "has no 'source' column" in combined_src
|
||||
|
||||
def test_missing_source_column_suggests_upgrade(self):
|
||||
"""Warning message must suggest upgrading hermes-agent."""
|
||||
assert "Upgrade hermes-agent" in combined_src or "upgrade hermes-agent" in combined_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 = agent_src.find("PRAGMA table_info(sessions)")
|
||||
select_pos = agent_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"
|
||||
153
tests/test_issue644.py
Normal file
153
tests/test_issue644.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""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.
|
||||
|
||||
We also freeze _cfg_mtime to the *current* config file mtime so that
|
||||
get_available_models() does not call reload_config() from disk (which
|
||||
would overwrite the in-memory mock with the on-disk config.yaml).
|
||||
See #644 — this race exists in CI where config.yaml is present.
|
||||
"""
|
||||
old_cfg = dict(_cfg.cfg)
|
||||
_cfg.cfg.clear()
|
||||
_cfg.cfg.update(cfg_override)
|
||||
# Freeze mtime so reload_config() is not triggered inside get_available_models()
|
||||
old_mtime = _cfg._cfg_mtime
|
||||
try:
|
||||
from pathlib import Path
|
||||
_cfg._cfg_mtime = Path(_cfg._get_config_path()).stat().st_mtime
|
||||
except OSError:
|
||||
_cfg._cfg_mtime = 0.0
|
||||
try:
|
||||
return _cfg.get_available_models()
|
||||
finally:
|
||||
_cfg.cfg.clear()
|
||||
_cfg.cfg.update(old_cfg)
|
||||
_cfg._cfg_mtime = old_mtime
|
||||
|
||||
|
||||
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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user