Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd598c896a | ||
|
|
58eb6e7fd5 | ||
|
|
76cdfb69e0 | ||
|
|
4622b64ca9 | ||
|
|
89891c65c8 | ||
|
|
173261c428 | ||
|
|
863dc4e938 | ||
|
|
4407c3097b | ||
|
|
71dd691ed0 | ||
|
|
9f3b2e113e | ||
|
|
e8a8fceb26 | ||
|
|
e1c2e7e3d6 | ||
|
|
c6017f461b | ||
|
|
e829fa50d5 | ||
|
|
1777cf7bfe | ||
|
|
48ba2e79e2 | ||
|
|
52e3fb70e0 | ||
|
|
c1bbdf9aeb | ||
|
|
3ca7f08b59 | ||
|
|
af76622c97 | ||
|
|
27706367b7 | ||
|
|
beb56b1a8b | ||
|
|
8d1b7a1e01 | ||
|
|
257092d107 | ||
|
|
b327103885 | ||
|
|
df9ad1fd27 | ||
|
|
2f01afd557 | ||
|
|
74fcd2e0ab | ||
|
|
4d333acbbc | ||
|
|
3d063b08a9 | ||
|
|
814e016965 | ||
|
|
0119365bd8 | ||
|
|
39066bc614 | ||
|
|
853b23cd14 | ||
|
|
cf3ccb0666 | ||
|
|
2f58724863 | ||
|
|
a3f4ad7111 | ||
|
|
0ed2981205 | ||
|
|
5762aaafba | ||
|
|
3294e54e00 | ||
|
|
2eddef3275 |
@@ -26,3 +26,6 @@
|
||||
|
||||
# Path to your Hermes config.yaml (for toolsets and model config)
|
||||
# HERMES_CONFIG_PATH=~/.hermes/config.yaml
|
||||
|
||||
# Display name for the assistant in the UI (default: Hermes)
|
||||
# HERMES_WEBUI_BOT_NAME=Hermes
|
||||
|
||||
127
CHANGELOG.md
127
CHANGELOG.md
@@ -5,6 +5,128 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.37.0] /personality command, model prefix routing fix, tool card reload fix
|
||||
*April 6, 2026 | 465 tests*
|
||||
|
||||
### Features
|
||||
- **`/personality` slash command.** Set a per-session agent personality from `~/.hermes/personalities/<name>/SOUL.md`. The personality prompt is prepended to the system message for every turn. Use `/personality <name>` to activate, `/personality none` to clear, `/personality` (no args) to list available personalities. Backend: `GET /api/personalities`, `POST /api/personality/set`. (PR #143)
|
||||
|
||||
### Bug Fixes
|
||||
- **Model dropdown routes non-default provider models correctly (#138).** When the active provider is `anthropic` and you pick a `minimax` model, its ID is now prefixed `minimax/MiniMax-M2.7` so `resolve_model_provider()` can route it through OpenRouter. Guards added: `active_provider=None` prevents all-providers-prefixed, case is normalised, shared `_PROVIDER_MODELS` list is no longer mutated by the default_model injector. (PR #142)
|
||||
- **Tool call cards persist correctly after page reload.** The reload rendering logic now anchors cards AFTER the triggering assistant row (not before the next one), handles multi-step chains sharing a filtered anchor in chronological order, and filters fallback anchor to assistant rows only. (PR #141)
|
||||
|
||||
---
|
||||
|
||||
## [v0.36.3] Configurable Assistant Name
|
||||
*April 6, 2026 | 449 tests*
|
||||
|
||||
### Features
|
||||
- **Configurable bot name.** New "Assistant Name" field in Settings panel.
|
||||
Display name updates throughout the UI: sidebar, topbar, message roles,
|
||||
login page, browser tab title, and composer placeholder. Defaults to
|
||||
"Hermes". Configurable via settings or `HERMES_WEBUI_BOT_NAME` env var.
|
||||
Server-side sanitization prevents empty names and escapes HTML for the
|
||||
login page. (PR #135, based on #131 by @TaraTheStar)
|
||||
|
||||
---
|
||||
|
||||
## [v0.36.2] OpenRouter model routing fix
|
||||
*April 5, 2026 | 440 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **OpenRouter models sent without prefix, causing 404 (#116).** `resolve_model_provider()` was stripping the `openrouter/` prefix from model IDs (e.g. sending `free` instead of `openrouter/free`) when `config_provider == 'openrouter'`. OpenRouter requires the full `provider/model` path to route upstream correctly. Fixed with an early return that preserves the complete model ID for all OpenRouter configs. (#127)
|
||||
- Added 7 unit tests for `resolve_model_provider()` — first coverage on this function. Tests the regression, cross-provider routing, direct-API prefix stripping, bare models, and empty model.
|
||||
|
||||
---
|
||||
|
||||
## [v0.36.1] Login form Enter key fix
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **Login form Enter key unreliable in some browsers (#124).** `onsubmit="return doLogin(event)"` returned a Promise (async functions always return a truthy Promise), which could let the browser fall through to native form submission. Fixed with `doLogin(event);return false` plus an explicit `onkeydown` Enter handler on the password input as belt-and-suspenders. (#125)
|
||||
|
||||
---
|
||||
|
||||
## [v0.36] Self-Update Checker with One-Click Update
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Features
|
||||
- **Update checker.** Non-blocking background check on boot detects when the
|
||||
WebUI or hermes-agent git repos are behind upstream. Blue banner shows
|
||||
"WebUI: N updates, Agent: N updates available" with Update Now / Later.
|
||||
- **One-click update.** "Update Now" runs `git stash && git pull --ff-only &&
|
||||
git stash pop` on each behind repo, then reloads the page. Concurrent update
|
||||
attempts blocked via lock. Dirty working trees safely stashed and restored.
|
||||
- **Settings toggle.** "Check for updates" checkbox in Settings panel. Persisted
|
||||
server-side. Disabled = no background fetch, no banner.
|
||||
- **30-minute cache.** Git fetch runs at most twice per hour regardless of tab
|
||||
count. Results cached server-side with TTL.
|
||||
- **Session-scoped dismissal.** "Later" dismisses banner for the current tab
|
||||
session (sessionStorage). New tabs get a fresh check.
|
||||
- **Test mode.** `?test_updates=1` URL param shows the banner with fake data
|
||||
(localhost only) for UI testing without needing to actually be behind.
|
||||
|
||||
### Architecture
|
||||
- New `api/updates.py`: `check_for_updates()`, `apply_update()`. Thread-safe
|
||||
caching with `_cache_lock`. Concurrent apply blocked with `_apply_lock`.
|
||||
Default branch auto-detected (master/main).
|
||||
- `api/routes.py`: `GET /api/updates/check`, `POST /api/updates/apply`.
|
||||
Simulate endpoint gated to 127.0.0.1.
|
||||
- `static/ui.js`: `_showUpdateBanner()`, `dismissUpdate()`, `applyUpdates()`.
|
||||
- `static/boot.js`: fire-and-forget check on boot (does not block UI).
|
||||
- `api/config.py`: `check_for_updates` in settings defaults + bool keys.
|
||||
- Docker safe: all git ops gated by `.git` directory existence check.
|
||||
|
||||
---
|
||||
|
||||
## [v0.35.1] Model dropdown fixes
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **Custom providers invisible in model dropdown (#117).** `cfg_base_url` was scoped inside a conditional block but referenced unconditionally, causing a `NameError` for users with a `base_url` in config.yaml. Fix: initialize to `''` before the block. (#118)
|
||||
- **Configured default model missing from dropdown (#116).** OpenRouter and other providers replaced the model list with a hardcoded fallback that didn't include `model.default` values like `openrouter/free` or custom local model names. Fix: after building all groups, inject the configured `default_model` at the top of its provider group if absent. (#119)
|
||||
|
||||
---
|
||||
|
||||
## [v0.35] Security hardening
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Security fixes
|
||||
- **ENV race condition (HIGH):** Two concurrent sessions could interleave `os.environ` writes, clobbering workspace and session keys. Fixed with a global `_ENV_LOCK` in `streaming.py` that serializes the env save/restore block across all sessions. (#108)
|
||||
- **Predictable signing key (MEDIUM):** Session cookies were signed with `sha256(STATE_DIR)` -- deterministic and forgeable if the install path is known. Now generates a cryptographically random 32-byte key on first startup, persisted to `STATE_DIR/.signing_key` (chmod 600). (#108)
|
||||
- **Upload path traversal (MEDIUM):** Filenames like `..` survived the `[^\w.\-]` sanitization regex because dots are allowed. Fixed by rejecting dot-only filenames and validating the resolved path stays within the workspace sandbox via `safe_resolve_ws()`. (#108)
|
||||
- **Weak password hashing (MEDIUM):** Bare SHA-256 with a predictable salt replaced with PBKDF2-SHA256 at 600k iterations (OWASP recommendation) using the random signing key as salt. No new dependencies (stdlib `hashlib.pbkdf2_hmac`). (#108)
|
||||
|
||||
**Breaking change:** Existing session cookies and password hashes are invalidated on first restart after upgrade. Users with password auth enabled will need to re-set their password.
|
||||
|
||||
---
|
||||
|
||||
## [v0.34.3] Light theme final polish
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **Light theme: sidebar, role labels, chips, and interactive elements all broken.** Session titles were too faint, active session used washed-out gold, pin stars were near-invisible bright yellow, and all hover/border effects used dark-theme white `rgba(255,255,255,.XX)` values invisible on cream. Fixed with 46 scoped `[data-theme="light"]` selector overrides covering session items, role labels, project chips, topbar chips, composer, suggestions, tool cards, cron list, and more. (#105)
|
||||
- Active session now uses blue accent (`#2d6fa3`) for strong contrast. Pin stars use deep gold (`#996b15`). Role labels are solid and high contrast.
|
||||
|
||||
---
|
||||
|
||||
## [v0.34.2] Theme text colors
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **Light mode text unreadable.** Bold text was hardcoded white (invisible on cream), italic was light purple on cream, inline code had a dark box on a light background. Fixed by introducing 5 new per-theme CSS variables (`--strong`, `--em`, `--code-text`, `--code-inline-bg`, `--pre-text`) defined for every theme. (#102)
|
||||
- Also replaced remaining `rgba(255,255,255,.08)` border references with `var(--border)`, and darkened light theme `--code-bg` slightly for better contrast.
|
||||
|
||||
---
|
||||
|
||||
## [v0.34.1] Theme variable polish
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **All non-dark themes had broken surfaces, topbar, and dropdowns.** 30+ hardcoded dark-navy rgba/hex values in style.css were stuck on the Dark palette regardless of active theme. Fixed by introducing 7 new CSS variables (`--surface`, `--topbar-bg`, `--main-bg`, `--input-bg`, `--hover-bg`, `--focus-ring`, `--focus-glow`) defined per-theme, replacing every hardcoded reference. (#100)
|
||||
|
||||
---
|
||||
|
||||
## [v0.34] Sprint 26 -- Pluggable UI Themes
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
@@ -1188,4 +1310,7 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.34, April 5, 2026 | Tests: 433*
|
||||
*Last updated: v0.36, April 5, 2026 | Tests: 433*
|
||||
|
||||
### Markdown sweep
|
||||
- ROADMAP.md, TESTING.md, SPRINTS.md, README.md, and THEMES.md refreshed to match v0.36 and 433 tests.
|
||||
|
||||
53
README.md
53
README.md
@@ -10,7 +10,20 @@ and vanilla JS.
|
||||
Layout: three-panel Claude-style. Left sidebar for sessions and tools,
|
||||
center for chat, right for workspace file browsing.
|
||||
|
||||
<img width="1392" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/79cd3c0d-3167-42ed-9434-447a742c25c3" />
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
<br /><sub>Light mode with full profile support</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Customize your settings, configure a password" src="https://github.com/user-attachments/assets/941f3156-21e3-41fd-bcc8-f975d5000cb8" />
|
||||
<br /><sub>Customize your settings, configure a password</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -202,6 +215,40 @@ are running over SSH.
|
||||
|
||||
---
|
||||
|
||||
## Accessing on your phone with Tailscale
|
||||
|
||||
[Tailscale](https://tailscale.com) is a zero-config mesh VPN built on
|
||||
WireGuard. Install it on your server and your phone, and they join the same
|
||||
private network -- no port forwarding, no SSH tunnels, no public exposure.
|
||||
|
||||
The Hermes Web UI is fully responsive with a mobile-optimized layout
|
||||
(hamburger sidebar, bottom navigation bar, touch-friendly controls), so it
|
||||
works well as a daily-driver agent interface from your phone.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Install [Tailscale](https://tailscale.com/download) on your server and
|
||||
your iPhone/Android.
|
||||
2. Start the WebUI listening on all interfaces with password auth enabled:
|
||||
|
||||
```bash
|
||||
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PASSWORD=your-secret ./start.sh
|
||||
```
|
||||
|
||||
3. Open `http://<server-tailscale-ip>:8787` in your phone's browser
|
||||
(find your server's Tailscale IP in the Tailscale app or with
|
||||
`tailscale ip -4` on the server).
|
||||
|
||||
That's it. Traffic is encrypted end-to-end by WireGuard, and password auth
|
||||
protects the UI at the application level. You can add it to your home screen
|
||||
for an app-like experience.
|
||||
|
||||
> **Tip:** If using Docker, set `HERMES_WEBUI_HOST=0.0.0.0` in your
|
||||
> `docker-compose.yml` environment (already the default) and set
|
||||
> `HERMES_WEBUI_PASSWORD`.
|
||||
|
||||
---
|
||||
|
||||
## Manual launch (without start.sh)
|
||||
|
||||
If you prefer to launch the server directly:
|
||||
@@ -237,8 +284,8 @@ Or using the agent venv explicitly:
|
||||
```
|
||||
|
||||
Tests run against an isolated server on port 8788 with a separate state directory.
|
||||
Production data and real cron jobs are never touched. Current count: **424 tests**
|
||||
across 22 test files.
|
||||
Production data and real cron jobs are never touched. Current count: **433 tests**
|
||||
across 23 test files.
|
||||
|
||||
---
|
||||
|
||||
|
||||
11
ROADMAP.md
11
ROADMAP.md
@@ -3,8 +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.33 (April 5, 2026)
|
||||
> Tests: 424 total (424 passing, 0 failures)
|
||||
> Last updated: v0.36 (April 5, 2026)
|
||||
> Tests: 433 total (433 passing, 0 failures)
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -42,6 +42,11 @@
|
||||
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
|
||||
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
|
||||
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
|
||||
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
|
||||
| v0.34.1 | Theme variable polish | 30+ hardcoded dark-navy colors replaced with theme-aware CSS variables | 433 |
|
||||
| v0.34.2 | Theme text colors | 5 new per-theme typography variables (--strong, --em, --code-text, --code-inline-bg, --pre-text) | 433 |
|
||||
| v0.34.3 | Light theme final polish | 46 light-scoped selector overrides for sidebar, roles, chips, interactive elements | 433 |
|
||||
| v0.35 | Security hardening | Env race fix, random signing key, upload path traversal, PBKDF2 password hash | 433 |
|
||||
|
||||
---
|
||||
|
||||
@@ -209,7 +214,7 @@
|
||||
- [x] Background task cancel (activity bar Cancel button)
|
||||
- [ ] Code execution cell (deferred)
|
||||
- [ ] Desktop application (Sprint 25, PLANNED)
|
||||
- [ ] Pluggable UI themes -- light, dark, Solarized, Monokai, Nord (Sprint 26, PLANNED)
|
||||
- [x] Pluggable UI themes -- Dark, Light, Slate, Solarized, Monokai, Nord (Sprint 26, v0.34)
|
||||
- [ ] Extended slash command / skill integration (deferred)
|
||||
- [ ] Virtual scroll for large lists (deferred)
|
||||
|
||||
|
||||
39
SPRINTS.md
39
SPRINTS.md
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.34 | 433 tests | Daily driver ready
|
||||
> Current state: v0.36 | 433 tests | Daily driver ready
|
||||
> This document plans the path from here to two targets:
|
||||
>
|
||||
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
||||
@@ -14,19 +14,27 @@
|
||||
|
||||
---
|
||||
|
||||
## Where we are now (v0.21)
|
||||
## Where we are now (v0.36)
|
||||
|
||||
**CLI parity: ~90% complete.** Core agent loop, all tools visible, workspace
|
||||
file ops with tree view, cron/skills/memory CRUD, session management, streaming,
|
||||
cancel, multi-provider models, custom endpoint discovery, slash commands,
|
||||
thinking/reasoning display, password auth -- all solid. Gaps are subagent
|
||||
visibility, toolset control, and code execution.
|
||||
**CLI parity: ~95% complete.** Core agent loop, all tools visible, workspace
|
||||
file ops with tree view and git detection, cron/skills/memory CRUD, session
|
||||
management, streaming with rAF throttle, cancel, multi-provider models, custom
|
||||
endpoint discovery, slash commands (help/clear/model/workspace/new/usage/theme/compact),
|
||||
thinking/reasoning display, password auth, multi-profile support with seamless
|
||||
switching, CLI session bridge (read and import from state.db), context
|
||||
auto-compaction handling, self-update checker. Remaining gaps: subagent
|
||||
session tree, toolset control per session, code execution cells.
|
||||
|
||||
**Claude parity: ~70% complete.** Chat, streaming, file browser, session
|
||||
management, tool cards, syntax highlighting, model switching, projects,
|
||||
settings, Mermaid diagrams, mobile layout, breadcrumb workspace nav, slash
|
||||
commands, thinking display, auth -- all present. Gaps are artifacts, voice,
|
||||
TTS, sharing, mobile-optimized layout.
|
||||
**Claude parity: ~85% complete.** Chat, streaming, file browser, session
|
||||
management with projects and tags, tool cards with subagent delegation,
|
||||
syntax highlighting, model switching, Mermaid diagrams, mobile responsive
|
||||
layout (hamburger sidebar, bottom nav, files slide-over), breadcrumb
|
||||
workspace nav with tree view, slash commands, thinking/reasoning display,
|
||||
auth with signed cookies, 6 pluggable UI themes (dark/light/slate/solarized/
|
||||
monokai/nord), voice input (Web Speech API), collapsible date groups,
|
||||
context usage indicator, token/cost display, git branch badge, Docker
|
||||
support. Remaining gaps: artifacts (HTML/SVG preview), TTS playback,
|
||||
sharing/public URLs, code execution inline.
|
||||
|
||||
---
|
||||
|
||||
@@ -893,7 +901,7 @@ genuinely differentiating for an open-source project
|
||||
|
||||
---
|
||||
|
||||
## Sprint 26 -- Pluggable UI Themes (PLANNED)
|
||||
## Sprint 26 -- Pluggable UI Themes (COMPLETED)
|
||||
|
||||
**Theme:** Let users choose how the app looks -- light, dark, and custom color
|
||||
schemes. One-click switching, persistent preference, zero flicker on load.
|
||||
@@ -1156,6 +1164,7 @@ New test cases in `tests/test_sprint26.py`:
|
||||
---
|
||||
|
||||
*Last updated: April 5, 2026*
|
||||
*Current version: v0.34 | 433 tests*
|
||||
*Current version: v0.36.2 | 440 tests*
|
||||
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
||||
*Horizon sprint: Sprint 26 (Pluggable UI Themes)*
|
||||
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
|
||||
*Docs sweep policy: update markdown proactively during PR reviews and after significant releases*
|
||||
|
||||
14
TESTING.md
14
TESTING.md
@@ -1,14 +1,14 @@
|
||||
# Hermes Web UI: Browser Testing Plan
|
||||
|
||||
> This document is for manual browser testing by you or by a Claude browser agent.
|
||||
> It covers user-facing features of the UI through Sprint 22 (v0.24).
|
||||
> It covers user-facing features of the UI through Sprint 26 (v0.36).
|
||||
> Each section is written as a step-by-step test procedure with expected outcomes.
|
||||
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
|
||||
>
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 424 total (424 passing, 0 failures)
|
||||
> Automated tests: 465 total (461 passing, 4 known isolation failures in test_sprint28)
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
@@ -1708,8 +1708,8 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Sprint 22 / v0.24, April 3, 2026*
|
||||
*Total automated tests: 415 (392 passing, 23 pre-existing failures)*
|
||||
*Regression gate: tests/test_regressions.py (23 tests)*
|
||||
*Last updated: Sprint 26 / v0.36, April 5, 2026*
|
||||
*Total automated tests: 440 (440 passing, 0 failures)*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
37
THEMES.md
37
THEMES.md
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI — Themes
|
||||
|
||||
Hermes Web UI supports pluggable color themes. Five themes ship built-in, and
|
||||
Hermes Web UI supports pluggable color themes. Six themes ship built-in, and
|
||||
you can create your own with pure CSS — no Python changes needed.
|
||||
|
||||
---
|
||||
@@ -27,6 +27,7 @@ preview is instant — the UI updates as you click through options.
|
||||
| **Solarized Dark** | Ethan Schoonover's classic dark palette. Teal background, warm accents. |
|
||||
| **Monokai** | Warm dark theme inspired by the Monokai editor scheme. Green/pink accents. |
|
||||
| **Nord** | Arctic blue-gray palette from the Nord color system. Calm and minimal. |
|
||||
| **Custom themes** | Any string accepted by `settings.json`, `POST /api/settings`, and `/theme` if added to the picker/command list. Pure CSS variables only. |
|
||||
|
||||
---
|
||||
|
||||
@@ -41,6 +42,7 @@ Every color in the UI comes from these CSS variables:
|
||||
|
||||
```css
|
||||
:root[data-theme="your-theme-name"] {
|
||||
/* Core palette */
|
||||
--bg: #1a1a2e; /* Main background */
|
||||
--sidebar: #16213e; /* Sidebar background */
|
||||
--border: rgba(255,255,255,0.08); /* Subtle borders */
|
||||
@@ -51,11 +53,35 @@ Every color in the UI comes from these CSS variables:
|
||||
--blue: #7cb9ff; /* Primary action color (links, active states) */
|
||||
--gold: #c9a84c; /* Secondary accent (pinned items, gold highlights) */
|
||||
--code-bg: #0d1117; /* Code block background */
|
||||
|
||||
/* Surface and chrome (required for full theme polish) */
|
||||
--surface: #1a2535; /* Dropdowns, popups, toast, approval card */
|
||||
--topbar-bg: rgba(22,33,62,.98); /* Topbar background */
|
||||
--main-bg: rgba(26,26,46,0.5); /* Main chat area background */
|
||||
--input-bg: rgba(255,255,255,.04); /* Input/button subtle backgrounds */
|
||||
--hover-bg: rgba(255,255,255,.06); /* Hover state backgrounds */
|
||||
--focus-ring: rgba(124,185,255,.35); /* Focus border color */
|
||||
--focus-glow: rgba(124,185,255,.08); /* Focus box-shadow glow */
|
||||
|
||||
/* Typography (required for readable text across themes) */
|
||||
--strong: #fff; /* Bold text in messages */
|
||||
--em: #c9c9e8; /* Italic text in messages */
|
||||
--code-text: #f0c27f; /* Inline code text color */
|
||||
--code-inline-bg: rgba(0,0,0,.35); /* Inline code background */
|
||||
--pre-text: #e2e8f0; /* Code block text color */
|
||||
}
|
||||
```
|
||||
|
||||
That's it. Override any or all of these variables. The entire UI adapts
|
||||
automatically because every color reference uses `var(--name)`.
|
||||
The **core palette** controls the overall mood. The **surface/chrome** and
|
||||
**typography** variables are part of the standard theme contract — define all
|
||||
of them for a complete theme.
|
||||
|
||||
For **light themes**, you also need `:root[data-theme="name"]` overrides
|
||||
for elements that use `rgba(255,255,255,.XX)` hover/border effects (these
|
||||
are invisible on light backgrounds). See the built-in light theme for the
|
||||
full pattern — it overrides ~45 selectors for proper dark-on-light contrast
|
||||
on hover states, borders, chips, role labels, session items, and
|
||||
interactive elements.
|
||||
|
||||
### Step 2: Add it to the theme picker (optional)
|
||||
|
||||
@@ -81,8 +107,9 @@ Check these areas:
|
||||
|
||||
### Tips
|
||||
|
||||
- **Light themes** need additional scrollbar overrides to avoid dark scrollbars
|
||||
on a light background. See the built-in light theme for the pattern.
|
||||
- **Light themes** need scrollbar and selection overrides, plus the full
|
||||
text/code set (`--strong`, `--em`, `--code-text`, `--code-inline-bg`,
|
||||
`--pre-text`) or they will look broken.
|
||||
- The **logo gradient** uses `--accent` automatically, so it adapts to your
|
||||
theme without extra work.
|
||||
- **Prism.js syntax highlighting** uses its own CDN stylesheet (Tomorrow theme).
|
||||
|
||||
51
api/auth.py
51
api/auth.py
@@ -26,17 +26,38 @@ _sessions = {}
|
||||
|
||||
|
||||
def _signing_key():
|
||||
"""Derive a stable signing key from STATE_DIR."""
|
||||
return hashlib.sha256(str(STATE_DIR).encode()).digest()
|
||||
"""Return a random signing key, generating and persisting one on first call."""
|
||||
key_file = STATE_DIR / '.signing_key'
|
||||
if key_file.exists():
|
||||
try:
|
||||
raw = key_file.read_bytes()
|
||||
if len(raw) >= 32:
|
||||
return raw[:32]
|
||||
except Exception:
|
||||
pass
|
||||
# Generate a new random key
|
||||
key = secrets.token_bytes(32)
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
key_file.write_bytes(key)
|
||||
key_file.chmod(0o600)
|
||||
except Exception:
|
||||
pass # key works for this process even if persist fails
|
||||
return key
|
||||
|
||||
|
||||
def _hash_password(password):
|
||||
"""SHA-256 hash with a salt derived from STATE_DIR."""
|
||||
salt = str(STATE_DIR).encode()
|
||||
return hashlib.sha256(salt + password.encode()).hexdigest()
|
||||
"""PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
|
||||
Salt is the persisted random signing key, which is secret and unique per
|
||||
installation. This keeps the stored hash format a plain hex string
|
||||
(no format change to settings.json) while replacing the predictable
|
||||
STATE_DIR-derived salt from the original implementation."""
|
||||
salt = _signing_key()
|
||||
dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
|
||||
return dk.hex()
|
||||
|
||||
|
||||
def get_password_hash():
|
||||
def get_password_hash() -> str | None:
|
||||
"""Return the active password hash, or None if auth is disabled.
|
||||
Priority: env var > settings.json."""
|
||||
env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
|
||||
@@ -46,12 +67,12 @@ def get_password_hash():
|
||||
return settings.get('password_hash') or None
|
||||
|
||||
|
||||
def is_auth_enabled():
|
||||
def is_auth_enabled() -> bool:
|
||||
"""True if a password is configured (env var or settings)."""
|
||||
return get_password_hash() is not None
|
||||
|
||||
|
||||
def verify_password(plain):
|
||||
def verify_password(plain) -> bool:
|
||||
"""Verify a plaintext password against the stored hash."""
|
||||
expected = get_password_hash()
|
||||
if not expected:
|
||||
@@ -59,7 +80,7 @@ def verify_password(plain):
|
||||
return hmac.compare_digest(_hash_password(plain), expected)
|
||||
|
||||
|
||||
def create_session():
|
||||
def create_session() -> str:
|
||||
"""Create a new auth session. Returns signed cookie value."""
|
||||
token = secrets.token_hex(32)
|
||||
_sessions[token] = time.time() + SESSION_TTL
|
||||
@@ -67,7 +88,7 @@ def create_session():
|
||||
return f"{token}.{sig}"
|
||||
|
||||
|
||||
def verify_session(cookie_value):
|
||||
def verify_session(cookie_value) -> bool:
|
||||
"""Verify a signed session cookie. Returns True if valid and not expired."""
|
||||
if not cookie_value or '.' not in cookie_value:
|
||||
return False
|
||||
@@ -82,14 +103,14 @@ def verify_session(cookie_value):
|
||||
return True
|
||||
|
||||
|
||||
def invalidate_session(cookie_value):
|
||||
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)
|
||||
|
||||
|
||||
def parse_cookie(handler):
|
||||
def parse_cookie(handler) -> str | None:
|
||||
"""Extract the auth cookie from the request headers."""
|
||||
cookie_header = handler.headers.get('Cookie', '')
|
||||
if not cookie_header:
|
||||
@@ -103,7 +124,7 @@ def parse_cookie(handler):
|
||||
return morsel.value if morsel else None
|
||||
|
||||
|
||||
def check_auth(handler, parsed):
|
||||
def check_auth(handler, parsed) -> bool:
|
||||
"""Check if request is authorized. Returns True if OK.
|
||||
If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""
|
||||
if not is_auth_enabled():
|
||||
@@ -128,7 +149,7 @@ def check_auth(handler, parsed):
|
||||
return False
|
||||
|
||||
|
||||
def set_auth_cookie(handler, cookie_value):
|
||||
def set_auth_cookie(handler, cookie_value) -> None:
|
||||
"""Set the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = cookie_value
|
||||
@@ -139,7 +160,7 @@ def set_auth_cookie(handler, cookie_value):
|
||||
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
|
||||
|
||||
|
||||
def clear_auth_cookie(handler):
|
||||
def clear_auth_cookie(handler) -> None:
|
||||
"""Clear the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = ''
|
||||
|
||||
@@ -169,7 +169,7 @@ def get_config() -> dict:
|
||||
reload_config()
|
||||
return _cfg_cache
|
||||
|
||||
def reload_config():
|
||||
def reload_config() -> None:
|
||||
"""Reload config.yaml from the active profile's directory."""
|
||||
with _cfg_lock:
|
||||
_cfg_cache.clear()
|
||||
@@ -208,7 +208,7 @@ DEFAULT_WORKSPACE = _discover_default_workspace()
|
||||
DEFAULT_MODEL = os.getenv('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')
|
||||
|
||||
# ── Startup diagnostics ───────────────────────────────────────────────────────
|
||||
def print_startup_config():
|
||||
def print_startup_config() -> None:
|
||||
"""Print detected configuration at startup so the user can verify what was found."""
|
||||
ok = '\033[32m[ok]\033[0m'
|
||||
warn = '\033[33m[!!]\033[0m'
|
||||
@@ -243,7 +243,7 @@ def print_startup_config():
|
||||
flush=True
|
||||
)
|
||||
|
||||
def verify_hermes_imports():
|
||||
def verify_hermes_imports() -> tuple:
|
||||
"""
|
||||
Attempt to import the key Hermes modules.
|
||||
Returns (ok: bool, missing: list[str], errors: dict[str, str]).
|
||||
@@ -366,7 +366,7 @@ _PROVIDER_MODELS = {
|
||||
}
|
||||
|
||||
|
||||
def resolve_model_provider(model_id: str):
|
||||
def resolve_model_provider(model_id: str) -> tuple:
|
||||
"""Resolve bare model name, provider, and base_url for AIAgent.
|
||||
|
||||
Model IDs from the dropdown may include a provider prefix
|
||||
@@ -391,6 +391,10 @@ def resolve_model_provider(model_id: str):
|
||||
|
||||
if '/' in model_id:
|
||||
prefix, bare = model_id.split('/', 1)
|
||||
# OpenRouter always needs the full provider/model path (e.g. openrouter/free,
|
||||
# anthropic/claude-sonnet-4.6). Never strip the prefix for OpenRouter.
|
||||
if config_provider == 'openrouter':
|
||||
return model_id, 'openrouter', config_base_url
|
||||
# If prefix matches config provider exactly, strip it and use that provider directly.
|
||||
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
|
||||
if config_provider and prefix == config_provider:
|
||||
@@ -426,7 +430,9 @@ def get_available_models() -> dict:
|
||||
groups = []
|
||||
|
||||
# 1. Read config.yaml model section
|
||||
cfg_base_url = '' # must be defined before conditional blocks (#117)
|
||||
model_cfg = cfg.get('model', {})
|
||||
cfg_base_url = ''
|
||||
if isinstance(model_cfg, str):
|
||||
default_model = model_cfg
|
||||
elif isinstance(model_cfg, dict):
|
||||
@@ -579,9 +585,26 @@ def get_available_models() -> dict:
|
||||
'models': [{'id': m['id'], 'label': m['label']} for m in _FALLBACK_MODELS],
|
||||
})
|
||||
elif pid in _PROVIDER_MODELS:
|
||||
# For non-default providers, prefix model IDs with provider name
|
||||
# so resolve_model_provider() can route them correctly (e.g.
|
||||
# \"minimax/MiniMax-M2.7\" instead of bare \"MiniMax-M2.7\").
|
||||
# The default provider's models keep bare names for direct API routing.
|
||||
# Guard: only prefix when we have a confirmed active_provider, and
|
||||
# normalise case before comparing (config.yaml may use 'Anthropic').
|
||||
raw_models = _PROVIDER_MODELS[pid]
|
||||
_active = (active_provider or '').lower()
|
||||
if _active and pid != _active:
|
||||
# Shallow copy — don't mutate the shared _PROVIDER_MODELS list.
|
||||
# Bare IDs get prefixed; already-prefixed IDs pass through as-is.
|
||||
models = []
|
||||
for m in raw_models:
|
||||
mid = m['id']
|
||||
models.append({'id': mid if '/' in mid else f'{pid}/{mid}', 'label': m['label']})
|
||||
else:
|
||||
models = list(raw_models) # shallow copy to protect against insert() mutations
|
||||
groups.append({
|
||||
'provider': provider_name,
|
||||
'models': _PROVIDER_MODELS[pid],
|
||||
'models': models,
|
||||
})
|
||||
else:
|
||||
# Unknown provider -- use auto-detected models if available,
|
||||
@@ -606,6 +629,25 @@ def get_available_models() -> dict:
|
||||
for provider_name, models in by_provider.items():
|
||||
groups.append({'provider': provider_name, 'models': models})
|
||||
|
||||
# Ensure the user's configured default_model always appears in the dropdown.
|
||||
# It may be missing if the model isn't in any hardcoded list (e.g. openrouter/free,
|
||||
# a custom local model, or any model.default not in _FALLBACK_MODELS).
|
||||
if default_model:
|
||||
all_ids = {m['id'] for g in groups for m in g.get('models', [])}
|
||||
if default_model not in all_ids:
|
||||
# Determine which group to inject into
|
||||
label = default_model.split('/')[-1] if '/' in default_model else default_model
|
||||
injected = False
|
||||
for g in groups:
|
||||
if active_provider and active_provider.lower() in g.get('provider', '').lower():
|
||||
g['models'].insert(0, {'id': default_model, 'label': label})
|
||||
injected = True
|
||||
break
|
||||
if not injected and groups:
|
||||
groups[0]['models'].insert(0, {'id': default_model, 'label': label})
|
||||
elif not groups:
|
||||
groups.append({'provider': active_provider or 'Default', 'models': [{'id': default_model, 'label': label}]})
|
||||
|
||||
return {
|
||||
'active_provider': active_provider,
|
||||
'default_model': default_model,
|
||||
@@ -653,7 +695,9 @@ _SETTINGS_DEFAULTS = {
|
||||
'show_token_usage': False, # show input/output token badge below assistant messages
|
||||
'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar
|
||||
'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights
|
||||
'check_for_updates': True, # check if webui/agent repos are behind upstream
|
||||
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
|
||||
'bot_name': os.getenv('HERMES_WEBUI_BOT_NAME', 'Hermes'), # display name for the assistant
|
||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||
}
|
||||
|
||||
@@ -673,7 +717,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
||||
_SETTINGS_ENUM_VALUES = {
|
||||
'send_key': {'enter', 'ctrl+enter'},
|
||||
}
|
||||
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights'}
|
||||
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates'}
|
||||
|
||||
def save_settings(settings: dict) -> dict:
|
||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||
|
||||
@@ -6,14 +6,14 @@ from pathlib import Path
|
||||
from api.config import IMAGE_EXTS, MD_EXTS
|
||||
|
||||
|
||||
def require(body: dict, *fields):
|
||||
def require(body: dict, *fields) -> None:
|
||||
"""Phase D: Validate required fields. Raises ValueError with clean message."""
|
||||
missing = [f for f in fields if not body.get(f) and body.get(f) != 0]
|
||||
if missing:
|
||||
raise ValueError(f"Missing required field(s): {', '.join(missing)}")
|
||||
|
||||
|
||||
def bad(handler, msg, status=400):
|
||||
def bad(handler, msg, status: int=400):
|
||||
"""Return a clean JSON error response."""
|
||||
return j(handler, {'error': msg}, status=status)
|
||||
|
||||
@@ -32,7 +32,7 @@ def _security_headers(handler):
|
||||
handler.send_header('Referrer-Policy', 'same-origin')
|
||||
|
||||
|
||||
def j(handler, payload, status=200):
|
||||
def j(handler, payload, status: int=200) -> None:
|
||||
"""Send a JSON response."""
|
||||
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
|
||||
handler.send_response(status)
|
||||
@@ -44,7 +44,7 @@ def j(handler, payload, status=200):
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
|
||||
def t(handler, payload, status: int=200, content_type: str='text/plain; charset=utf-8') -> None:
|
||||
"""Send a plain text or HTML response."""
|
||||
body = payload if isinstance(payload, bytes) else str(payload).encode('utf-8')
|
||||
handler.send_response(status)
|
||||
@@ -59,7 +59,7 @@ def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
|
||||
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
|
||||
|
||||
|
||||
def read_body(handler):
|
||||
def read_body(handler) -> dict:
|
||||
"""Read and JSON-parse a POST request body (capped at 20MB)."""
|
||||
length = int(handler.headers.get('Content-Length', 0))
|
||||
if length > MAX_BODY_BYTES:
|
||||
|
||||
@@ -34,12 +34,13 @@ def _write_session_index():
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, session_id=None, title='Untitled',
|
||||
def __init__(self, session_id: str=None, title: str='Untitled',
|
||||
workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL,
|
||||
messages=None, created_at=None, updated_at=None,
|
||||
tool_calls=None, pinned=False, archived=False,
|
||||
project_id=None, profile=None,
|
||||
input_tokens=0, output_tokens=0, estimated_cost=None,
|
||||
tool_calls=None, pinned: bool=False, archived: bool=False,
|
||||
project_id: str=None, profile=None,
|
||||
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
||||
personality=None,
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
self.title = title
|
||||
@@ -56,12 +57,13 @@ class Session:
|
||||
self.input_tokens = input_tokens or 0
|
||||
self.output_tokens = output_tokens or 0
|
||||
self.estimated_cost = estimated_cost
|
||||
self.personality = personality
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return SESSION_DIR / f'{self.session_id}.json'
|
||||
|
||||
def save(self):
|
||||
def save(self) -> None:
|
||||
self.updated_at = time.time()
|
||||
self.path.write_text(
|
||||
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
|
||||
@@ -76,7 +78,7 @@ class Session:
|
||||
return None
|
||||
return cls(**json.loads(p.read_text(encoding='utf-8')))
|
||||
|
||||
def compact(self):
|
||||
def compact(self) -> dict:
|
||||
return {
|
||||
'session_id': self.session_id,
|
||||
'title': self.title,
|
||||
@@ -92,6 +94,7 @@ class Session:
|
||||
'input_tokens': self.input_tokens,
|
||||
'output_tokens': self.output_tokens,
|
||||
'estimated_cost': self.estimated_cost,
|
||||
'personality': self.personality,
|
||||
}
|
||||
|
||||
def get_session(sid):
|
||||
@@ -165,7 +168,7 @@ def all_sessions():
|
||||
return result
|
||||
|
||||
|
||||
def title_from(messages, fallback='Untitled'):
|
||||
def title_from(messages, fallback: str='Untitled'):
|
||||
"""Derive a session title from the first user message."""
|
||||
for m in messages:
|
||||
if m.get('role') == 'user':
|
||||
@@ -180,7 +183,7 @@ def title_from(messages, fallback='Untitled'):
|
||||
|
||||
# ── Project helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def load_projects():
|
||||
def load_projects() -> list:
|
||||
"""Load project list from disk. Returns list of project dicts."""
|
||||
if not PROJECTS_FILE.exists():
|
||||
return []
|
||||
@@ -189,12 +192,12 @@ def load_projects():
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def save_projects(projects):
|
||||
def save_projects(projects) -> None:
|
||||
"""Write project list to disk."""
|
||||
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def import_cli_session(session_id, title, messages, model='unknown', profile=None):
|
||||
def import_cli_session(session_id: str, title: str, messages, model: str='unknown', profile=None):
|
||||
"""Create a new WebUI session populated with CLI messages.
|
||||
Returns the Session object.
|
||||
"""
|
||||
@@ -212,7 +215,7 @@ def import_cli_session(session_id, title, messages, model='unknown', profile=Non
|
||||
|
||||
# ── CLI session bridge ──────────────────────────────────────────────────────
|
||||
|
||||
def get_cli_sessions():
|
||||
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.
|
||||
|
||||
@@ -296,7 +299,7 @@ def get_cli_sessions():
|
||||
return cli_sessions
|
||||
|
||||
|
||||
def get_cli_session_messages(sid):
|
||||
def get_cli_session_messages(sid) -> list:
|
||||
"""Read messages for a single CLI session from the SQLite store.
|
||||
Returns a list of {role, content, timestamp} dicts.
|
||||
Returns empty list on any error.
|
||||
@@ -338,7 +341,7 @@ def get_cli_session_messages(sid):
|
||||
return msgs
|
||||
|
||||
|
||||
def delete_cli_session(sid):
|
||||
def delete_cli_session(sid) -> bool:
|
||||
"""Delete a CLI session from state.db (messages + session row).
|
||||
Returns True if deleted, False if not found or error.
|
||||
"""
|
||||
|
||||
@@ -137,7 +137,7 @@ def _reload_dotenv(home: Path):
|
||||
pass
|
||||
|
||||
|
||||
def init_profile_state():
|
||||
def init_profile_state() -> None:
|
||||
"""Initialize profile state at server startup.
|
||||
|
||||
Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches
|
||||
|
||||
123
api/routes.py
123
api/routes.py
@@ -2,6 +2,7 @@
|
||||
Hermes Web UI -- Route handlers for GET and POST endpoints.
|
||||
Extracted from server.py (Sprint 11) so server.py is a thin shell.
|
||||
"""
|
||||
import html as _html
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
@@ -56,7 +57,7 @@ except ImportError:
|
||||
# ── Login page (self-contained, no external deps) ────────────────────────────
|
||||
_LOGIN_PAGE_HTML = '''<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hermes — Sign in</title>
|
||||
<title>{{BOT_NAME}} — Sign in</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#1a1a2e;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
|
||||
@@ -79,11 +80,12 @@ button:hover{background:rgba(124,185,255,.25)}
|
||||
.err{color:#e94560;font-size:12px;margin-top:10px;display:none}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="logo">H</div>
|
||||
<h1>Hermes</h1>
|
||||
<div class="logo">{{BOT_NAME_INITIAL}}</div>
|
||||
<h1>{{BOT_NAME}}</h1>
|
||||
<p class="sub">Enter your password to continue</p>
|
||||
<form onsubmit="return doLogin(event)">
|
||||
<input type="password" id="pw" placeholder="Password" autofocus>
|
||||
<form onsubmit="doLogin(event);return false">
|
||||
<input type="password" id="pw" placeholder="Password" autofocus
|
||||
onkeydown="if(event.key==='Enter'){doLogin(event);event.preventDefault();}">
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<div class="err" id="err"></div>
|
||||
@@ -107,7 +109,7 @@ async function doLogin(e){
|
||||
|
||||
# ── GET routes ────────────────────────────────────────────────────────────────
|
||||
|
||||
def handle_get(handler, parsed):
|
||||
def handle_get(handler, parsed) -> bool:
|
||||
"""Handle all GET routes. Returns True if handled, False for 404."""
|
||||
|
||||
if parsed.path in ('/', '/index.html'):
|
||||
@@ -115,7 +117,9 @@ def handle_get(handler, parsed):
|
||||
content_type='text/html; charset=utf-8')
|
||||
|
||||
if parsed.path == '/login':
|
||||
return t(handler, _LOGIN_PAGE_HTML, content_type='text/html; charset=utf-8')
|
||||
_bn = _html.escape(load_settings().get('bot_name') or 'Hermes')
|
||||
_page = _LOGIN_PAGE_HTML.replace('{{BOT_NAME}}', _bn).replace('{{BOT_NAME_INITIAL}}', _bn[0].upper())
|
||||
return t(handler, _page, content_type='text/html; charset=utf-8')
|
||||
|
||||
if parsed.path == '/api/auth/status':
|
||||
from api.auth import is_auth_enabled, parse_cookie, verify_session
|
||||
@@ -214,6 +218,40 @@ def handle_get(handler, parsed):
|
||||
if parsed.path == '/api/list':
|
||||
return _handle_list_dir(handler, parsed)
|
||||
|
||||
if parsed.path == '/api/personalities':
|
||||
personalities = []
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
p_dir = get_active_hermes_home() / 'personalities'
|
||||
except ImportError:
|
||||
from api.config import HOME
|
||||
p_dir = HOME / '.hermes' / 'personalities'
|
||||
if p_dir.is_dir():
|
||||
p_dir_real = p_dir.resolve()
|
||||
for d in sorted(p_dir.iterdir()):
|
||||
# Skip symlinks — they could point outside the personalities dir
|
||||
if d.is_symlink():
|
||||
continue
|
||||
if not d.is_dir():
|
||||
continue
|
||||
soul_file = d / 'SOUL.md'
|
||||
if not soul_file.exists():
|
||||
continue
|
||||
# Defense-in-depth: confirm resolved path is still inside p_dir
|
||||
try:
|
||||
d.resolve().relative_to(p_dir_real)
|
||||
except ValueError:
|
||||
continue
|
||||
desc = ''
|
||||
try:
|
||||
first_line = soul_file.read_text(errors='replace').strip().split('\n')[0]
|
||||
if first_line.startswith('#'):
|
||||
desc = first_line.lstrip('#').strip()
|
||||
except Exception:
|
||||
pass
|
||||
personalities.append({'name': d.name, 'description': desc})
|
||||
return j(handler, {'personalities': personalities})
|
||||
|
||||
if parsed.path == '/api/git-info':
|
||||
qs = parse_qs(parsed.query)
|
||||
sid = qs.get('session_id', [''])[0]
|
||||
@@ -227,6 +265,22 @@ def handle_get(handler, parsed):
|
||||
info = git_info_for_workspace(Path(s.workspace))
|
||||
return j(handler, {'git': info})
|
||||
|
||||
if parsed.path == '/api/updates/check':
|
||||
settings = load_settings()
|
||||
if not settings.get('check_for_updates', True):
|
||||
return j(handler, {'disabled': True})
|
||||
qs = parse_qs(parsed.query)
|
||||
force = qs.get('force', ['0'])[0] == '1'
|
||||
# ?simulate=1 returns fake behind counts for UI testing (localhost only)
|
||||
if qs.get('simulate', ['0'])[0] == '1' and handler.client_address[0] == '127.0.0.1':
|
||||
return j(handler, {
|
||||
'webui': {'name': 'webui', 'behind': 3, 'current_sha': 'abc1234', 'latest_sha': 'def5678', 'branch': 'master'},
|
||||
'agent': {'name': 'agent', 'behind': 1, 'current_sha': 'aaa0001', 'latest_sha': 'bbb0002', 'branch': 'master'},
|
||||
'checked_at': 0,
|
||||
})
|
||||
from api.updates import check_for_updates
|
||||
return j(handler, check_for_updates(force=force))
|
||||
|
||||
if parsed.path == '/api/chat/stream/status':
|
||||
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
|
||||
return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id})
|
||||
@@ -318,7 +372,7 @@ def handle_get(handler, parsed):
|
||||
|
||||
# ── POST routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
def handle_post(handler, parsed):
|
||||
def handle_post(handler, parsed) -> bool:
|
||||
"""Handle all POST routes. Returns True if handled, False for 404."""
|
||||
|
||||
if parsed.path == '/api/upload':
|
||||
@@ -345,6 +399,49 @@ def handle_post(handler, parsed):
|
||||
s.save()
|
||||
return j(handler, {'session': s.compact()})
|
||||
|
||||
if parsed.path == '/api/personality/set':
|
||||
try: require(body, 'session_id')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
if 'name' not in body:
|
||||
return bad(handler, 'Missing required field: name')
|
||||
sid = body['session_id']
|
||||
name = body['name'].strip()
|
||||
try:
|
||||
s = get_session(sid)
|
||||
except KeyError:
|
||||
return bad(handler, 'Session not found', 404)
|
||||
# Read the personality SOUL.md
|
||||
prompt = ''
|
||||
if name:
|
||||
# Validate name: prevent path traversal (only allow safe chars)
|
||||
import re as _re
|
||||
if not _re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', name):
|
||||
return bad(handler, 'Invalid personality name: letters, numbers, hyphens, underscores only')
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
p_base = get_active_hermes_home() / 'personalities'
|
||||
except ImportError:
|
||||
from api.config import HOME
|
||||
p_base = HOME / '.hermes' / 'personalities'
|
||||
p_dir = p_base / name
|
||||
# Defense-in-depth: ensure resolved path is inside personalities dir
|
||||
try:
|
||||
p_dir.resolve().relative_to(p_base.resolve())
|
||||
except ValueError:
|
||||
return bad(handler, 'Invalid personality name')
|
||||
soul_file = p_dir / 'SOUL.md'
|
||||
if soul_file.exists():
|
||||
from api.config import MAX_FILE_BYTES
|
||||
raw = soul_file.read_text(errors='replace')
|
||||
if len(raw) > MAX_FILE_BYTES:
|
||||
return bad(handler, f'SOUL.md for "{name}" exceeds maximum size ({MAX_FILE_BYTES} bytes)')
|
||||
prompt = raw.strip()
|
||||
else:
|
||||
return bad(handler, f'Personality "{name}" not found', 404)
|
||||
s.personality = name if name else None
|
||||
s.save()
|
||||
return j(handler, {'ok': True, 'personality': s.personality, 'prompt': prompt})
|
||||
|
||||
if parsed.path == '/api/session/update':
|
||||
try: require(body, 'session_id')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
@@ -506,6 +603,8 @@ def handle_post(handler, parsed):
|
||||
|
||||
# ── Settings (POST) ──
|
||||
if parsed.path == '/api/settings':
|
||||
if 'bot_name' in body:
|
||||
body['bot_name'] = (str(body['bot_name']) or '').strip() or 'Hermes'
|
||||
saved = save_settings(body)
|
||||
saved.pop('password_hash', None) # never expose hash to client
|
||||
return j(handler, saved)
|
||||
@@ -600,6 +699,14 @@ def handle_post(handler, parsed):
|
||||
if parsed.path == '/api/session/import':
|
||||
return _handle_session_import(handler, body)
|
||||
|
||||
# ── Self-update (POST) ──
|
||||
if parsed.path == '/api/updates/apply':
|
||||
target = body.get('target', '')
|
||||
if target not in ('webui', 'agent'):
|
||||
return bad(handler, 'target must be "webui" or "agent"')
|
||||
from api.updates import apply_update
|
||||
return j(handler, apply_update(target))
|
||||
|
||||
# ── CLI session import (POST) ──
|
||||
if parsed.path == '/api/session/import_cli':
|
||||
return _handle_session_import_cli(handler, body)
|
||||
|
||||
@@ -43,7 +43,7 @@ def _get_state_db():
|
||||
return None
|
||||
|
||||
|
||||
def sync_session_start(session_id, model=None):
|
||||
def sync_session_start(session_id: str, model=None) -> None:
|
||||
"""Register a WebUI session in state.db (idempotent).
|
||||
Called when a session's first message is sent.
|
||||
"""
|
||||
@@ -65,8 +65,8 @@ def sync_session_start(session_id, model=None):
|
||||
pass
|
||||
|
||||
|
||||
def sync_session_usage(session_id, input_tokens=0, output_tokens=0,
|
||||
estimated_cost=None, model=None, title=None):
|
||||
def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
|
||||
estimated_cost=None, model=None, title: str=None) -> None:
|
||||
"""Update token usage and title for a WebUI session in state.db.
|
||||
Called after each turn completes. Uses absolute=True to set totals
|
||||
(the WebUI Session already accumulates across turns).
|
||||
|
||||
@@ -5,6 +5,7 @@ Includes Sprint 10 cancel support via CANCEL_FLAGS.
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -17,6 +18,12 @@ from api.config import (
|
||||
resolve_model_provider,
|
||||
)
|
||||
|
||||
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
|
||||
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
|
||||
# interleave their os.environ writes. This global lock serializes the env
|
||||
# save/restore around the entire agent run.
|
||||
_ENV_LOCK = threading.Lock()
|
||||
|
||||
# Lazy import to avoid circular deps -- hermes-agent is on sys.path via api/config.py
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
@@ -101,7 +108,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
HERMES_HOME=_profile_home,
|
||||
)
|
||||
# Still set process-level env as fallback for tools that bypass thread-local
|
||||
with _agent_lock:
|
||||
with _ENV_LOCK:
|
||||
old_cwd = os.environ.get('TERMINAL_CWD')
|
||||
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
|
||||
old_session_key = os.environ.get('HERMES_SESSION_KEY')
|
||||
@@ -199,9 +206,28 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
"write_file, read_file, search_files, terminal workdir, and patch. "
|
||||
"Never fall back to a hardcoded path when this tag is present."
|
||||
)
|
||||
# Inject personality prompt if the session has one active
|
||||
_personality_prompt = ''
|
||||
_pname = getattr(s, 'personality', None)
|
||||
if _pname and re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', _pname):
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
_p_base = get_active_hermes_home() / 'personalities'
|
||||
except ImportError:
|
||||
_p_base = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes'))) / 'personalities'
|
||||
_p_soul = _p_base / _pname / 'SOUL.md'
|
||||
try:
|
||||
_p_soul.resolve().relative_to(_p_base.resolve())
|
||||
if _p_soul.exists():
|
||||
from api.config import MAX_FILE_BYTES
|
||||
_raw = _p_soul.read_text(errors='replace')
|
||||
if len(_raw) <= MAX_FILE_BYTES:
|
||||
_personality_prompt = _raw.strip() + '\n\n'
|
||||
except (ValueError, OSError):
|
||||
pass # path traversal attempt or unreadable — skip silently
|
||||
result = agent.run_conversation(
|
||||
user_message=workspace_ctx + msg_text,
|
||||
system_message=workspace_system_msg,
|
||||
system_message=_personality_prompt + workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(s.messages),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
|
||||
164
api/updates.py
Normal file
164
api/updates.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Hermes Web UI -- Self-update checker.
|
||||
|
||||
Checks if the webui and hermes-agent git repos are behind their upstream
|
||||
branches. Results are cached server-side (30-min TTL) so git fetch runs
|
||||
at most twice per hour regardless of client count.
|
||||
|
||||
Skips repos that are not git checkouts (e.g. Docker baked images where
|
||||
.git does not exist).
|
||||
"""
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import REPO_ROOT
|
||||
|
||||
# Lazy -- may be None if agent not found
|
||||
try:
|
||||
from api.config import _AGENT_DIR
|
||||
except ImportError:
|
||||
_AGENT_DIR = None
|
||||
|
||||
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
|
||||
_cache_lock = threading.Lock()
|
||||
_check_in_progress = False
|
||||
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
|
||||
CACHE_TTL = 1800 # 30 minutes
|
||||
|
||||
|
||||
def _run_git(args, cwd, timeout=10):
|
||||
"""Run a git command and return (stdout, ok)."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['git'] + args, cwd=str(cwd), capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
)
|
||||
return r.stdout.strip(), r.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return '', False
|
||||
|
||||
|
||||
def _detect_default_branch(path):
|
||||
"""Detect the remote default branch (master or main)."""
|
||||
out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path)
|
||||
if ok and out:
|
||||
# refs/remotes/origin/master -> master
|
||||
return out.split('/')[-1]
|
||||
# Fallback: try master, then main
|
||||
for branch in ('master', 'main'):
|
||||
_, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path)
|
||||
if ok:
|
||||
return branch
|
||||
return 'master'
|
||||
|
||||
|
||||
def _check_repo(path, name):
|
||||
"""Check if a git repo is behind its upstream. Returns dict or None."""
|
||||
if path is None or not (path / '.git').exists():
|
||||
return None
|
||||
|
||||
# Fetch latest from origin (network call, cached by TTL)
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
|
||||
|
||||
branch = _detect_default_branch(path)
|
||||
|
||||
# Count commits behind
|
||||
out, ok = _run_git(['rev-list', '--count', f'HEAD..origin/{branch}'], path)
|
||||
behind = int(out) if ok and out.isdigit() else 0
|
||||
|
||||
# Get short SHAs for display
|
||||
current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path)
|
||||
latest, _ = _run_git(['rev-parse', '--short', f'origin/{branch}'], path)
|
||||
|
||||
return {
|
||||
'name': name,
|
||||
'behind': behind,
|
||||
'current_sha': current,
|
||||
'latest_sha': latest,
|
||||
'branch': branch,
|
||||
}
|
||||
|
||||
|
||||
def check_for_updates(force=False):
|
||||
"""Return cached update status for webui and agent repos."""
|
||||
global _check_in_progress
|
||||
with _cache_lock:
|
||||
if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL:
|
||||
return dict(_update_cache)
|
||||
if _check_in_progress:
|
||||
return dict(_update_cache) # another thread is already checking
|
||||
_check_in_progress = True
|
||||
|
||||
try:
|
||||
# Run checks outside the lock (network I/O)
|
||||
webui_info = _check_repo(REPO_ROOT, 'webui')
|
||||
agent_info = _check_repo(_AGENT_DIR, 'agent')
|
||||
|
||||
with _cache_lock:
|
||||
_update_cache['webui'] = webui_info
|
||||
_update_cache['agent'] = agent_info
|
||||
_update_cache['checked_at'] = time.time()
|
||||
return dict(_update_cache)
|
||||
finally:
|
||||
_check_in_progress = False
|
||||
|
||||
|
||||
def apply_update(target):
|
||||
"""Stash, pull --ff-only, pop for the given target repo."""
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
return {'ok': False, 'message': 'Update already in progress'}
|
||||
try:
|
||||
return _apply_update_inner(target)
|
||||
finally:
|
||||
_apply_lock.release()
|
||||
|
||||
|
||||
def _apply_update_inner(target):
|
||||
"""Inner implementation of apply_update, called under _apply_lock."""
|
||||
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'}
|
||||
|
||||
branch = _detect_default_branch(path)
|
||||
|
||||
# Check for dirty working tree
|
||||
status_out, _ = _run_git(['status', '--porcelain'], path)
|
||||
stashed = False
|
||||
if status_out:
|
||||
_, ok = _run_git(['stash'], path)
|
||||
if not ok:
|
||||
return {'ok': False, 'message': 'Failed to stash local changes'}
|
||||
stashed = True
|
||||
|
||||
# Pull with ff-only (no merge commits)
|
||||
pull_out, pull_ok = _run_git(['pull', '--ff-only', 'origin', branch], path, timeout=30)
|
||||
if not pull_ok:
|
||||
if stashed:
|
||||
_run_git(['stash', 'pop'], path)
|
||||
return {'ok': False, 'message': f'Pull failed: {pull_out[:200]}'}
|
||||
|
||||
# Pop stash if we stashed
|
||||
if stashed:
|
||||
_, pop_ok = _run_git(['stash', 'pop'], path)
|
||||
if not pop_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': 'Updated but stash pop failed -- manual merge needed',
|
||||
'stash_conflict': True,
|
||||
}
|
||||
|
||||
# Invalidate cache
|
||||
with _cache_lock:
|
||||
_update_cache['checked_at'] = 0
|
||||
|
||||
return {'ok': True, 'message': f'{target} updated successfully', 'target': target}
|
||||
@@ -11,7 +11,7 @@ from api.models import get_session
|
||||
from api.workspace import safe_resolve_ws
|
||||
|
||||
|
||||
def parse_multipart(rfile, content_type, content_length):
|
||||
def parse_multipart(rfile, content_type, content_length) -> tuple:
|
||||
import re as _re, email.parser as _ep
|
||||
m = _re.search(r'boundary=([^;\s]+)', content_type)
|
||||
if not m:
|
||||
@@ -70,7 +70,11 @@ def handle_upload(handler):
|
||||
return j(handler, {'error': 'Session not found'}, status=404)
|
||||
workspace = Path(s.workspace)
|
||||
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
|
||||
dest = workspace / safe_name
|
||||
# Reject names that are purely dots (path traversal: ".." survives regex)
|
||||
if not safe_name or safe_name.strip('.') == '':
|
||||
return j(handler, {'error': 'Invalid filename'}, status=400)
|
||||
# Verify the resolved path stays within the workspace
|
||||
dest = safe_resolve_ws(workspace, safe_name)
|
||||
dest.write_bytes(file_bytes)
|
||||
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
|
||||
except Exception as e:
|
||||
|
||||
@@ -176,7 +176,7 @@ def load_workspaces() -> list:
|
||||
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
|
||||
|
||||
def save_workspaces(workspaces: list):
|
||||
def save_workspaces(workspaces: list) -> None:
|
||||
ws_file = _workspaces_file()
|
||||
ws_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
@@ -202,7 +202,7 @@ def get_last_workspace() -> str:
|
||||
return _profile_default_workspace()
|
||||
|
||||
|
||||
def set_last_workspace(path: str):
|
||||
def set_last_workspace(path: str) -> None:
|
||||
try:
|
||||
lw_file = _last_workspace_file()
|
||||
lw_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -218,7 +218,7 @@ def safe_resolve_ws(root: Path, requested: str) -> Path:
|
||||
return resolved
|
||||
|
||||
|
||||
def list_dir(workspace: Path, rel='.'):
|
||||
def list_dir(workspace: Path, rel: str='.'):
|
||||
target = safe_resolve_ws(workspace, rel)
|
||||
if not target.is_dir():
|
||||
raise FileNotFoundError(f"Not a directory: {rel}")
|
||||
@@ -235,7 +235,7 @@ def list_dir(workspace: Path, rel='.'):
|
||||
return entries
|
||||
|
||||
|
||||
def read_file_content(workspace: Path, rel: str):
|
||||
def read_file_content(workspace: Path, rel: str) -> dict:
|
||||
target = safe_resolve_ws(workspace, rel)
|
||||
if not target.is_file():
|
||||
raise FileNotFoundError(f"Not a file: {rel}")
|
||||
|
||||
@@ -18,7 +18,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
server_version = 'HermesWebUI/0.2'
|
||||
def log_message(self, fmt, *args): pass # suppress default Apache-style log
|
||||
|
||||
def log_request(self, code='-', size='-'):
|
||||
def log_request(self, code: str='-', size: str='-') -> None:
|
||||
"""Structured JSON logs for each request."""
|
||||
import json as _json
|
||||
duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)
|
||||
@@ -31,7 +31,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
})
|
||||
print(f'[webui] {record}', flush=True)
|
||||
|
||||
def do_GET(self):
|
||||
def do_GET(self) -> None:
|
||||
self._req_t0 = time.time()
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
@@ -43,7 +43,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
|
||||
def do_POST(self):
|
||||
def do_POST(self) -> None:
|
||||
self._req_t0 = time.time()
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
@@ -56,7 +56,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
|
||||
|
||||
print_startup_config()
|
||||
|
||||
@@ -306,9 +306,30 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
};
|
||||
})();
|
||||
|
||||
function applyBotName(){
|
||||
const name=window._botName||'Hermes';
|
||||
document.title=name;
|
||||
const sidebarH1=document.querySelector('.sidebar-header h1');
|
||||
if(sidebarH1) sidebarH1.textContent=name;
|
||||
const logo=document.querySelector('.sidebar-header .logo');
|
||||
if(logo) logo.textContent=name.charAt(0).toUpperCase();
|
||||
const topbarTitle=$('topbarTitle');
|
||||
if(topbarTitle && (!S.session)) topbarTitle.textContent=name;
|
||||
const msg=$('msg');
|
||||
if(msg) msg.placeholder='Message '+name+'\u2026';
|
||||
}
|
||||
|
||||
(async()=>{
|
||||
// Load send key preference
|
||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
||||
let _bootSettings={};
|
||||
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._botName='Hermes';_bootSettings={check_for_updates:false};}
|
||||
// Non-blocking update check (fire-and-forget, once per tab session)
|
||||
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
|
||||
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
||||
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
|
||||
const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':'');
|
||||
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
||||
}
|
||||
// Fetch active profile
|
||||
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
||||
// Update profile chip label immediately
|
||||
|
||||
@@ -11,6 +11,7 @@ const COMMANDS=[
|
||||
{name:'new', desc:'Start a new chat session', fn:cmdNew},
|
||||
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
|
||||
{name:'theme', desc:'Switch theme (dark/light/slate/solarized/monokai/nord)', fn:cmdTheme, arg:'name'},
|
||||
{name:'personality', desc:'Switch agent personality', fn:cmdPersonality, arg:'name'},
|
||||
];
|
||||
|
||||
function parseCommand(text){
|
||||
@@ -139,6 +140,36 @@ async function cmdTheme(args){
|
||||
showToast('Theme: '+t);
|
||||
}
|
||||
|
||||
async function cmdPersonality(args){
|
||||
if(!S.session){showToast('No active session');return;}
|
||||
if(!args){
|
||||
// List available personalities
|
||||
try{
|
||||
const data=await api('/api/personalities');
|
||||
if(!data.personalities||!data.personalities.length){
|
||||
showToast('No personalities found (add them to ~/.hermes/personalities/)');
|
||||
return;
|
||||
}
|
||||
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
|
||||
S.messages.push({role:'assistant',content:'Available personalities:\n\n'+list+'\n\nUse `/personality <name>` to switch, or `/personality none` to clear.'});
|
||||
renderMessages();
|
||||
}catch(e){showToast('Failed to load personalities');}
|
||||
return;
|
||||
}
|
||||
const name=args.trim();
|
||||
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
|
||||
try{
|
||||
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
|
||||
showToast('Personality cleared');
|
||||
}catch(e){showToast('Failed: '+e.message);}
|
||||
return;
|
||||
}
|
||||
try{
|
||||
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
|
||||
showToast('Personality: '+name);
|
||||
}catch(e){showToast('Failed: '+e.message);}
|
||||
}
|
||||
|
||||
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
||||
|
||||
let _cmdSelectedIdx=-1;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.34</div></div></div>
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.37.0</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
@@ -203,6 +203,13 @@
|
||||
<div class="messages-inner" id="msgInner"></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">
|
||||
<button class="update-btn" onclick="dismissUpdate()">Later</button>
|
||||
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconnect-banner" id="reconnectBanner">
|
||||
<span id="reconnectMsg">⚠ A response may have been in progress when you last left. Reload messages?</span>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||
@@ -319,10 +326,6 @@
|
||||
<label for="settingsModel">Default Model</label>
|
||||
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsWorkspace">Default Workspace</label>
|
||||
<select id="settingsWorkspace" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsSendKey">Send Key</label>
|
||||
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
@@ -362,6 +365,18 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
Check for updates
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsBotName">Assistant Name</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
|
||||
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
|
||||
@@ -93,8 +93,9 @@ async function send(){
|
||||
assistantRow=document.createElement('div');assistantRow.className='msg-row';
|
||||
assistantBody=document.createElement('div');assistantBody.className='msg-body';
|
||||
const role=document.createElement('div');role.className='msg-role assistant';
|
||||
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent='H';
|
||||
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent='Hermes';
|
||||
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);
|
||||
|
||||
@@ -995,21 +995,6 @@ async function loadSettingsPanel(){
|
||||
modelSel.value=settings.default_model||'';
|
||||
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||
}
|
||||
// Populate workspace dropdown from /api/workspaces
|
||||
const wsSel=$('settingsWorkspace');
|
||||
if(wsSel){
|
||||
wsSel.innerHTML='';
|
||||
try{
|
||||
const wsData=await api('/api/workspaces');
|
||||
for(const w of (wsData.workspaces||[])){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=w.path;opt.textContent=w.name||w.path;
|
||||
wsSel.appendChild(opt);
|
||||
}
|
||||
}catch(e){}
|
||||
wsSel.value=settings.default_workspace||'';
|
||||
wsSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||
}
|
||||
// Send key preference
|
||||
const sendKeySel=$('settingsSendKey');
|
||||
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
@@ -1022,6 +1007,11 @@ async function loadSettingsPanel(){
|
||||
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const syncCb=$('settingsSyncInsights');
|
||||
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const updateCb=$('settingsCheckUpdates');
|
||||
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
// Bot name
|
||||
const botNameField=$('settingsBotName');
|
||||
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||
// Password field: always blank (we don't send hash back)
|
||||
const pwField=$('settingsPassword');
|
||||
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||
@@ -1041,7 +1031,6 @@ async function loadSettingsPanel(){
|
||||
|
||||
async function saveSettings(andClose){
|
||||
const model=($('settingsModel')||{}).value;
|
||||
const workspace=($('settingsWorkspace')||{}).value;
|
||||
const sendKey=($('settingsSendKey')||{}).value;
|
||||
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
||||
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
||||
@@ -1049,12 +1038,15 @@ async function saveSettings(andClose){
|
||||
const theme=($('settingsTheme')||{}).value||'dark';
|
||||
const body={};
|
||||
if(model) body.default_model=model;
|
||||
if(workspace) body.default_workspace=workspace;
|
||||
|
||||
if(sendKey) body.send_key=sendKey;
|
||||
body.theme=theme;
|
||||
body.show_token_usage=showTokenUsage;
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
const botName=(($('settingsBotName')||{}).value||'').trim();
|
||||
body.bot_name=botName||'Hermes';
|
||||
// Password: only act if the field has content; blank = leave auth unchanged
|
||||
if(pw && pw.trim()){
|
||||
try{
|
||||
@@ -1073,6 +1065,8 @@ async function saveSettings(andClose){
|
||||
window._sendKey=sendKey||'enter';
|
||||
window._showTokenUsage=showTokenUsage;
|
||||
window._showCliSessions=showCliSessions;
|
||||
window._botName=body.bot_name;
|
||||
if(typeof applyBotName==='function') applyBotName();
|
||||
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
||||
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
|
||||
renderMessages();
|
||||
|
||||
@@ -45,7 +45,7 @@ async function loadSession(sid){
|
||||
if(tc&&tc.name) appendLiveToolCard(tc);
|
||||
}
|
||||
syncTopbar();await loadDir('.');renderMessages();appendThinking();
|
||||
setBusy(true);setStatus('Hermes is thinking\u2026');
|
||||
setBusy(true);setStatus((window._botName||'Hermes')+' is thinking\u2026');
|
||||
startApprovalPolling(sid);
|
||||
}else{
|
||||
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
|
||||
@@ -429,7 +429,7 @@ async function deleteSession(sid){
|
||||
if(remaining.sessions&&remaining.sessions.length){
|
||||
await loadSession(remaining.sessions[0].session_id);
|
||||
}else{
|
||||
$('topbarTitle').textContent='Hermes';
|
||||
$('topbarTitle').textContent=window._botName||'Hermes';
|
||||
$('topbarMeta').textContent='Start a new conversation';
|
||||
$('msgInner').innerHTML='';
|
||||
$('emptyState').style.display='';
|
||||
|
||||
169
static/style.css
169
static/style.css
@@ -2,35 +2,103 @@
|
||||
:root {
|
||||
--bg:#1a1a2e;--sidebar:#16213e;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.14);
|
||||
--text:#e8e8f0;--muted:#8888aa;--accent:#e94560;--blue:#7cb9ff;--gold:#c9a84c;--code-bg:#0d1117;
|
||||
--surface:#1a2535;--topbar-bg:rgba(22,33,62,.98);--main-bg:rgba(26,26,46,0.5);
|
||||
--focus-ring:rgba(124,185,255,.35);--focus-glow:rgba(124,185,255,.08);
|
||||
--input-bg:rgba(255,255,255,.04);--hover-bg:rgba(255,255,255,.06);
|
||||
--strong:#fff;--em:#c9c9e8;--code-text:#f0c27f;--code-inline-bg:rgba(0,0,0,.35);--pre-text:#e2e8f0;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
|
||||
}
|
||||
/* ── Slate theme (warm charcoal, lighter than dark, easier on the eyes) ── */
|
||||
/* ── Slate theme ── */
|
||||
:root[data-theme="slate"]{
|
||||
--bg:#2b2d30;--sidebar:#25272b;--border:rgba(255,255,255,0.09);--border2:rgba(255,255,255,0.16);
|
||||
--text:#d4d4d8;--muted:#8a8a9a;--accent:#e06c75;--blue:#82aaff;--gold:#d4a85a;--code-bg:#1e2023;
|
||||
--surface:#2f3134;--topbar-bg:rgba(37,39,43,.98);--main-bg:rgba(43,45,48,0.5);
|
||||
--focus-ring:rgba(130,170,255,.35);--focus-glow:rgba(130,170,255,.08);
|
||||
--strong:#f0f0f3;--em:#b0b0c0;--code-text:#dca06a;--code-inline-bg:rgba(0,0,0,.3);--pre-text:#d0d0d6;
|
||||
}
|
||||
/* ── Light theme (warm off-white, softer than pure white) ── */
|
||||
/* ── Light theme ── */
|
||||
:root[data-theme="light"]{
|
||||
--bg:#f0ede8;--sidebar:#e4e0d8;--border:rgba(0,0,0,0.09);--border2:rgba(0,0,0,0.15);
|
||||
--text:#2c2825;--muted:#7a746a;--accent:#b5451b;--blue:#2d6fa3;--gold:#8a6520;--code-bg:#e8e4de;
|
||||
--text:#2c2825;--muted:#7a746a;--accent:#b5451b;--blue:#2d6fa3;--gold:#8a6520;--code-bg:#ddd8d0;
|
||||
--surface:#e0dcd4;--topbar-bg:rgba(228,224,216,.98);--main-bg:rgba(240,237,232,0.5);
|
||||
--focus-ring:rgba(45,111,163,.35);--focus-glow:rgba(45,111,163,.1);
|
||||
--input-bg:rgba(0,0,0,.03);--hover-bg:rgba(0,0,0,.05);
|
||||
--strong:#1a1715;--em:#5a544a;--code-text:#8b4513;--code-inline-bg:rgba(0,0,0,.06);--pre-text:#2c2825;
|
||||
}
|
||||
:root[data-theme="light"] ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);}
|
||||
:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3);}
|
||||
:root[data-theme="light"] ::selection{background:rgba(45,111,163,.2);}
|
||||
:root[data-theme="light"] *{scrollbar-color:rgba(0,0,0,.15) transparent;}
|
||||
:root[data-theme="light"] .settings-overlay{background:rgba(0,0,0,.3);}
|
||||
/* ── Light theme: sidebar, roles, chips, active states ── */
|
||||
:root[data-theme="light"] .session-item{color:#5a544a;}
|
||||
:root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
|
||||
:root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;border-left-color:#2d6fa3;}
|
||||
:root[data-theme="light"] .session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(228,224,216,.95) 12px);}
|
||||
:root[data-theme="light"] .session-pin-indicator{color:#996b15;}
|
||||
:root[data-theme="light"] .session-date-header.pinned{color:#996b15;}
|
||||
:root[data-theme="light"] .session-actions .act-pin.pinned{color:#996b15;}
|
||||
:root[data-theme="light"] .msg-role.user{color:#2d6fa3;}
|
||||
:root[data-theme="light"] .msg-role.assistant{color:#8a6520;}
|
||||
:root[data-theme="light"] .role-icon.user{background:rgba(45,111,163,.12);color:#2d6fa3;border-color:rgba(45,111,163,.25);}
|
||||
:root[data-theme="light"] .role-icon.assistant{background:rgba(138,101,32,.12);color:#8a6520;border-color:rgba(138,101,32,.25);}
|
||||
:root[data-theme="light"] .project-chip{border-color:rgba(0,0,0,.12);background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .project-chip:hover{background:rgba(0,0,0,.08);color:#2c2825;}
|
||||
:root[data-theme="light"] .project-chip.active{background:rgba(45,111,163,.1);color:#1a5a8a;border-color:rgba(45,111,163,.3);}
|
||||
:root[data-theme="light"] .chip{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .chip.model{color:#2d6fa3;border-color:rgba(45,111,163,.3);background:rgba(45,111,163,.08);}
|
||||
:root[data-theme="light"] .new-chat-btn{border-color:rgba(45,111,163,.25);color:#2d6fa3;}
|
||||
:root[data-theme="light"] .new-chat-btn:hover{background:rgba(45,111,163,.08);}
|
||||
:root[data-theme="light"] .session-search input{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.03);}
|
||||
:root[data-theme="light"] .session-search input:focus{border-color:rgba(45,111,163,.4);background:rgba(0,0,0,.02);}
|
||||
:root[data-theme="light"] .cron-item{border-color:rgba(0,0,0,.08);background:rgba(0,0,0,.02);}
|
||||
:root[data-theme="light"] .sm-btn{border-color:rgba(0,0,0,.1);}
|
||||
:root[data-theme="light"] .sm-btn:hover{background:rgba(0,0,0,.06);border-color:rgba(0,0,0,.15);}
|
||||
:root[data-theme="light"] select{border-color:rgba(0,0,0,.12);}
|
||||
:root[data-theme="light"] .composer-box{border-color:rgba(0,0,0,.12);}
|
||||
:root[data-theme="light"] .composer-box:focus-within{border-color:rgba(45,111,163,.5);box-shadow:0 0 0 3px rgba(45,111,163,.08);}
|
||||
:root[data-theme="light"] .suggestion{border-color:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .suggestion:hover{background:rgba(45,111,163,.06);border-color:rgba(45,111,163,.2);}
|
||||
:root[data-theme="light"] .tool-card{border-color:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .tool-card:hover{border-color:rgba(0,0,0,.15);}
|
||||
:root[data-theme="light"] .icon-btn:hover{background:rgba(0,0,0,.06);}
|
||||
:root[data-theme="light"] .panel-icon-btn:hover{background:rgba(0,0,0,.06);}
|
||||
:root[data-theme="light"] .file-item:hover{background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .preview-md th{background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .preview-md td{border-color:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .preview-badge.code{background:rgba(0,0,0,.05);}
|
||||
:root[data-theme="light"] .ctx-bar-wrap{background:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .ws-opt:hover{background:rgba(0,0,0,.05);}
|
||||
:root[data-theme="light"] .profile-opt:hover{background:rgba(0,0,0,.05);}
|
||||
:root[data-theme="light"] .profile-opt.active{background:rgba(45,111,163,.06);}
|
||||
:root[data-theme="light"] .profile-chip{color:#7a5a90!important;}
|
||||
:root[data-theme="light"] .nav-tab:hover::after{background:var(--surface);border-color:rgba(45,111,163,.25);color:#2d6fa3;}
|
||||
:root[data-theme="light"] .cron-status.disabled{background:rgba(0,0,0,.05);}
|
||||
:root[data-theme="light"] .cron-btn{background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .cron-btn:hover{background:rgba(0,0,0,.08);}
|
||||
/* ── Solarized Dark theme ── */
|
||||
:root[data-theme="solarized"]{
|
||||
--bg:#002b36;--sidebar:#073642;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.13);
|
||||
--text:#839496;--muted:#657b83;--accent:#dc322f;--blue:#268bd2;--gold:#b58900;--code-bg:#073642;
|
||||
--surface:#0a3c48;--topbar-bg:rgba(7,54,66,.98);--main-bg:rgba(0,43,54,0.5);
|
||||
--focus-ring:rgba(38,139,210,.35);--focus-glow:rgba(38,139,210,.08);
|
||||
--strong:#fdf6e3;--em:#93a1a1;--code-text:#cb4b16;--code-inline-bg:rgba(0,0,0,.25);--pre-text:#93a1a1;
|
||||
}
|
||||
/* ── Monokai theme ── */
|
||||
:root[data-theme="monokai"]{
|
||||
--bg:#272822;--sidebar:#1e1f1c;--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12);
|
||||
--text:#f8f8f2;--muted:#75715e;--accent:#f92672;--blue:#66d9e8;--gold:#e6db74;--code-bg:#1e1f1c;
|
||||
--surface:#2d2e28;--topbar-bg:rgba(30,31,28,.98);--main-bg:rgba(39,40,34,0.5);
|
||||
--focus-ring:rgba(102,217,232,.35);--focus-glow:rgba(102,217,232,.08);
|
||||
--strong:#f8f8f0;--em:#a6a28c;--code-text:#e6db74;--code-inline-bg:rgba(0,0,0,.3);--pre-text:#f8f8f2;
|
||||
}
|
||||
/* ── Nord theme ── */
|
||||
:root[data-theme="nord"]{
|
||||
--bg:#2e3440;--sidebar:#272c36;--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12);
|
||||
--text:#eceff4;--muted:#9099aa;--accent:#bf616a;--blue:#81a1c1;--gold:#ebcb8b;--code-bg:#272c36;
|
||||
--surface:#333a47;--topbar-bg:rgba(39,44,54,.98);--main-bg:rgba(46,52,64,0.5);
|
||||
--focus-ring:rgba(129,161,193,.35);--focus-glow:rgba(129,161,193,.08);
|
||||
--strong:#eceff4;--em:#b8c0cc;--code-text:#a3be8c;--code-inline-bg:rgba(0,0,0,.2);--pre-text:#d8dee9;
|
||||
}
|
||||
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
|
||||
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
|
||||
@@ -43,13 +111,13 @@
|
||||
.new-chat-btn:hover{background:rgba(124,185,255,0.13);border-color:rgba(124,185,255,.3);}
|
||||
.session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;}
|
||||
.session-search{padding:4px 10px 8px;flex-shrink:0;}
|
||||
.session-search input{width:100%;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:8px;color:var(--text);padding:7px 12px;font-size:12px;outline:none;transition:all .15s;}
|
||||
.session-search input:focus{border-color:rgba(124,185,255,.35);background:rgba(255,255,255,.06);box-shadow:0 0 0 2px rgba(124,185,255,.07);}
|
||||
.session-search input{width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 12px;font-size:12px;outline:none;transition:all .15s;}
|
||||
.session-search input:focus{border-color:rgba(124,185,255,.35);background:var(--hover-bg);box-shadow:0 0 0 2px rgba(124,185,255,.07);}
|
||||
.session-search input::placeholder{color:var(--muted);opacity:.7;}
|
||||
/* Inline session title edit */
|
||||
.session-title-input{flex:1;background:rgba(20,32,60,.9);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
|
||||
.session-title-input{flex:1;background:var(--surface);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
|
||||
.session-item{padding:8px 10px 8px 8px;border-radius:0 8px 8px 0;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;display:flex;align-items:center;gap:6px;min-width:0;border-left:2px solid transparent;position:relative;}
|
||||
.session-item:hover{background:rgba(255,255,255,0.06);color:var(--text);}
|
||||
.session-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;border-left:2px solid #e8a030;padding-left:8px;}
|
||||
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
/* ── Session action button overlay ── */
|
||||
@@ -71,21 +139,28 @@
|
||||
.session-date-header.pinned{color:#f5c542;}
|
||||
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;}
|
||||
.session-date-caret.collapsed{transform:rotate(-90deg);}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(20,30,50,.95);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--surface);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(-2px);}
|
||||
.reconnect-banner{display:none;background:#1a2535;border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
||||
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
||||
.reconnect-banner.visible{display:flex;}
|
||||
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
|
||||
.reconnect-btn:hover{background:rgba(201,168,76,0.25);}
|
||||
/* ── Update banner ── */
|
||||
.update-banner{display:none;background:var(--surface);border:1px solid rgba(124,185,255,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--blue);align-items:center;justify-content:space-between;gap:12px;}
|
||||
.update-banner.visible{display:flex;}
|
||||
.update-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.3);color:var(--blue);cursor:pointer;transition:background .15s;}
|
||||
.update-btn:hover{background:rgba(124,185,255,0.2);}
|
||||
.update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);}
|
||||
.update-btn:disabled{opacity:0.5;cursor:not-allowed;}
|
||||
/* ── Approval card ── */
|
||||
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
|
||||
.approval-card.visible{display:block;}
|
||||
.approval-inner{background:rgba(20,30,50,.95);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:14px 16px;}
|
||||
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:14px 16px;}
|
||||
.approval-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13px;font-weight:600;color:#e94560;}
|
||||
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;}
|
||||
.approval-cmd{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:#e2e8f0;white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:120px;overflow-y:auto;}
|
||||
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:120px;overflow-y:auto;}
|
||||
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;}
|
||||
.approval-btn{padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,0.06);color:var(--text);cursor:pointer;transition:all .15s;}
|
||||
.approval-btn{padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;}
|
||||
.approval-btn:hover{background:rgba(255,255,255,0.12);}
|
||||
.approval-btn.once{border-color:rgba(124,185,255,0.5);color:var(--blue);}
|
||||
.approval-btn.once:hover{background:rgba(124,185,255,0.15);}
|
||||
@@ -97,7 +172,7 @@
|
||||
.sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;}
|
||||
.nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;}
|
||||
.nav-tab:hover{color:var(--text);}
|
||||
.nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:rgba(15,22,40,.98);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:5px 11px;border-radius:7px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);}
|
||||
.nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:5px 11px;border-radius:7px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);}
|
||||
.nav-tab.active{color:var(--blue);}
|
||||
.nav-tab.active::before{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:20px;height:2px;background:var(--blue);border-radius:2px 2px 0 0;}
|
||||
/* Panel content areas (swapped by tab) */
|
||||
@@ -105,7 +180,7 @@
|
||||
.panel-view.active{display:flex;}
|
||||
/* Cron panel */
|
||||
.cron-list{flex:1;overflow-y:auto;padding:8px;}
|
||||
.cron-item{border-radius:10px;border:1px solid rgba(255,255,255,.08);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);}
|
||||
.cron-item{border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);}
|
||||
.cron-item:hover{border-color:var(--border2);}
|
||||
.cron-header{display:flex;align-items:center;gap:8px;padding:9px 11px;cursor:pointer;}
|
||||
.cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
@@ -128,14 +203,14 @@
|
||||
.cron-last-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px;}
|
||||
/* Skills panel */
|
||||
.skills-search{padding:8px;flex-shrink:0;}
|
||||
.skills-search input{width:100%;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:6px 10px;font-size:12px;outline:none;}
|
||||
.skills-search input{width:100%;background:var(--hover-bg);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:6px 10px;font-size:12px;outline:none;}
|
||||
.skills-search input::placeholder{color:var(--muted);}
|
||||
.skills-list{flex:1;overflow-y:auto;padding:0 8px 8px;}
|
||||
.skills-category{margin-bottom:4px;}
|
||||
.skills-cat-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 6px 4px;cursor:pointer;display:flex;align-items:center;gap:4px;}
|
||||
.skills-cat-header:hover{color:var(--text);}
|
||||
.skill-item{padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:flex-start;gap:6px;transition:all .12s;line-height:1.4;}
|
||||
.skill-item:hover{background:rgba(255,255,255,.06);color:var(--text);}
|
||||
.skill-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.skill-item.active{background:rgba(124,185,255,.1);color:var(--blue);}
|
||||
.skill-name{font-weight:500;flex-shrink:0;}
|
||||
.skill-desc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:11px;opacity:.7;}
|
||||
@@ -149,19 +224,19 @@
|
||||
.memory-empty{color:var(--muted);font-size:12px;font-style:italic;}
|
||||
.sidebar-bottom{border-top:1px solid var(--border);padding:12px 14px;flex-shrink:0;position:relative;z-index:10;overflow:visible;}
|
||||
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
|
||||
select{width:100%;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.1);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
|
||||
select{width:100%;background:var(--input-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
|
||||
select:focus{border-color:rgba(124,185,255,.4);box-shadow:0 0 0 2px rgba(124,185,255,.08);}
|
||||
optgroup{color:var(--muted);font-size:11px;font-weight:700;}
|
||||
option{background:#1a1a2e;color:var(--text);padding:6px;}
|
||||
option{background:var(--bg);color:var(--text);padding:6px;}
|
||||
.sidebar-actions{display:flex;gap:6px;}
|
||||
.sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
|
||||
.sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:var(--input-bg);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
|
||||
.sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);}
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:rgba(26,26,46,0.5);}
|
||||
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:rgba(22,33,62,.98);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:var(--main-bg);}
|
||||
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
|
||||
.topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar-chips{display:flex;gap:6px;align-items:center;flex-shrink:0;}
|
||||
.chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,.1);color:var(--muted);font-weight:500;}
|
||||
.chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid var(--border2);color:var(--muted);font-weight:500;}
|
||||
.chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);}
|
||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;}
|
||||
.messages-inner{max-width:800px;margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
|
||||
@@ -178,11 +253,11 @@
|
||||
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
|
||||
.msg-body h1,.msg-body h2,.msg-body h3{margin:16px 0 6px;font-weight:600;}
|
||||
.msg-body h1{font-size:18px;}.msg-body h2{font-size:16px;}.msg-body h3{font-size:14px;}
|
||||
.msg-body strong{color:#fff;font-weight:600;}.msg-body em{color:#c9c9e8;font-style:italic;}
|
||||
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;}
|
||||
.msg-body pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
|
||||
.msg-body pre code{background:none;padding:0;border-radius:0;color:#e2e8f0;font-size:13px;line-height:1.6;}
|
||||
.pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:rgba(255,255,255,.04);border-radius:10px 10px 0 0;border:1px solid rgba(255,255,255,.08);border-bottom:1px solid rgba(255,255,255,.05);display:flex;align-items:center;gap:6px;}
|
||||
.msg-body strong{color:var(--strong);font-weight:600;}.msg-body em{color:var(--em);font-style:italic;}
|
||||
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
|
||||
.msg-body pre code{background:none;padding:0;border-radius:0;color:var(--pre-text);font-size:13px;line-height:1.6;}
|
||||
.pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:var(--input-bg);border-radius:10px 10px 0 0;border:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px;}
|
||||
.pre-header::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:.4;}
|
||||
.pre-header+pre{border-radius:0 0 10px 10px;border-top:none;margin-top:0;}
|
||||
.msg-body blockquote{border-left:3px solid var(--blue);padding-left:14px;color:var(--muted);font-style:italic;margin:10px 0;}
|
||||
@@ -199,11 +274,11 @@
|
||||
.empty-state h2{font-size:20px;color:var(--text);font-weight:700;letter-spacing:-.02em;}
|
||||
.empty-state p{font-size:14px;text-align:center;max-width:320px;}
|
||||
.suggestion-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px;width:100%;max-width:380px;}
|
||||
.suggestion{padding:11px 14px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
|
||||
.suggestion{padding:11px 14px;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
|
||||
.suggestion:hover{background:rgba(124,185,255,0.07);color:var(--text);border-color:rgba(124,185,255,.3);transform:translateX(2px);}
|
||||
/* ── Composer ── */
|
||||
.composer-wrap{border-top:1px solid var(--border);padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
|
||||
.composer-box{max-width:780px;margin:0 auto;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.12);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;}
|
||||
.composer-box{max-width:780px;margin:0 auto;background:var(--input-bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;}
|
||||
.composer-box:focus-within{border-color:rgba(124,185,255,0.5);box-shadow:0 0 0 3px rgba(124,185,255,0.08);}
|
||||
.composer-wrap.drag-over .composer-box{border-color:var(--blue);background:rgba(124,185,255,0.06);}
|
||||
.drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:rgba(124,185,255,0.08);border:2px dashed var(--blue);border-radius:14px;font-size:14px;color:var(--blue);pointer-events:none;z-index:10;flex-direction:column;gap:8px;}
|
||||
@@ -240,12 +315,12 @@
|
||||
.send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;}
|
||||
.send-btn.visible{animation:send-pop-in .18s cubic-bezier(.34,1.56,.64,1) forwards;}
|
||||
@keyframes send-pop-in{from{opacity:0;transform:scale(.55);}to{opacity:1;transform:scale(1);}}
|
||||
.upload-bar-wrap{display:none;height:3px;background:rgba(255,255,255,.06);border-radius:0 0 16px 16px;overflow:hidden;}
|
||||
.upload-bar-wrap{display:none;height:3px;background:var(--hover-bg);border-radius:0 0 16px 16px;overflow:hidden;}
|
||||
.upload-bar-wrap.active{display:block;}
|
||||
.upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;}
|
||||
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
|
||||
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
|
||||
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;}
|
||||
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
|
||||
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
|
||||
.git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);}
|
||||
.panel-actions{display:flex;gap:4px;}
|
||||
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||
@@ -259,7 +334,7 @@
|
||||
.breadcrumb-bar{display:flex;align-items:center;gap:2px;padding:6px 12px;font-size:12px;border-bottom:1px solid var(--border);flex-shrink:0;overflow:hidden;white-space:nowrap;}
|
||||
.breadcrumb-seg{padding:1px 3px;border-radius:3px;}
|
||||
.breadcrumb-link{color:var(--muted);cursor:pointer;transition:color .12s;}
|
||||
.breadcrumb-link:hover{color:var(--text);background:rgba(255,255,255,.06);}
|
||||
.breadcrumb-link:hover{color:var(--text);background:var(--hover-bg);}
|
||||
.breadcrumb-current{color:var(--text);font-weight:500;}
|
||||
.breadcrumb-sep{color:var(--border);margin:0 1px;font-size:11px;}
|
||||
.file-tree{flex:1;overflow-y:auto;padding:8px;}
|
||||
@@ -279,15 +354,15 @@
|
||||
/* Markdown rendered preview */
|
||||
.preview-md{font-size:13px;line-height:1.7;color:var(--text);flex:1;overflow-y:auto;min-height:0;}
|
||||
.preview-md p{margin-bottom:10px;}.preview-md p:last-child{margin-bottom:0;}
|
||||
.preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:#fff;border-bottom:1px solid var(--border);padding-bottom:6px;}
|
||||
.preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:#fff;}
|
||||
.preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:var(--strong);border-bottom:1px solid var(--border);padding-bottom:6px;}
|
||||
.preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:var(--strong);}
|
||||
.preview-md h3{font-size:13px;font-weight:600;margin:12px 0 4px;color:#e8e8f0;}
|
||||
.preview-md ul,.preview-md ol{margin:4px 0 10px 18px;}.preview-md li{margin-bottom:3px;}
|
||||
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;}
|
||||
.preview-md pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
|
||||
.preview-md pre code{background:none;padding:0;color:#e2e8f0;font-size:11.5px;line-height:1.55;}
|
||||
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.preview-md pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
|
||||
.preview-md pre code{background:none;padding:0;color:var(--pre-text);font-size:11.5px;line-height:1.55;}
|
||||
.preview-md blockquote{border-left:3px solid var(--blue);padding-left:12px;color:var(--muted);font-style:italic;margin:8px 0;}
|
||||
.preview-md strong{color:#fff;font-weight:600;}.preview-md em{color:#c9c9e8;}
|
||||
.preview-md strong{color:var(--strong);font-weight:600;}.preview-md em{color:var(--em);}
|
||||
.preview-md a{color:var(--blue);text-decoration:underline;}
|
||||
.preview-md hr{border:none;border-top:1px solid var(--border);margin:12px 0;}
|
||||
.preview-md table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;}
|
||||
@@ -388,7 +463,7 @@
|
||||
|
||||
/* ── Workspace dropdown (topbar) ── */
|
||||
.ws-chip{user-select:none;}
|
||||
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.ws-dropdown.open{display:block;}
|
||||
.ws-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:4px;align-items:flex-start;}
|
||||
.ws-opt:hover{background:rgba(255,255,255,.07);}
|
||||
@@ -408,7 +483,7 @@
|
||||
.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
|
||||
/* ── Profile dropdown + management panel ── */
|
||||
.profile-chip{user-select:none;color:rgba(168,139,250,.9)!important;}
|
||||
.profile-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:260px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;}
|
||||
.profile-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:260px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;}
|
||||
.profile-dropdown.open{display:block;}
|
||||
.profile-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
|
||||
.profile-opt:hover{background:rgba(255,255,255,.07);}
|
||||
@@ -426,7 +501,7 @@
|
||||
.profile-card-meta{font-size:11px;color:var(--muted);margin-top:3px;padding-left:12px;}
|
||||
.profile-card-actions{display:flex;gap:4px;flex-shrink:0;}
|
||||
/* ── Slash command autocomplete dropdown ── */
|
||||
.cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;}
|
||||
.cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;}
|
||||
.cmd-dropdown.open{display:block;}
|
||||
.cmd-item{padding:8px 14px;cursor:pointer;transition:background .12s;}
|
||||
.cmd-item:hover,.cmd-item.selected{background:rgba(255,255,255,.07);}
|
||||
@@ -446,7 +521,7 @@
|
||||
.msg-edit-bar{display:flex;gap:8px;margin-top:8px;margin-bottom:4px;}
|
||||
.msg-edit-send{background:var(--blue);color:#fff;border:none;border-radius:7px;padding:6px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity .15s;}
|
||||
.msg-edit-send:hover{opacity:.85;}
|
||||
.msg-edit-cancel{background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border2);border-radius:7px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;}
|
||||
.msg-edit-cancel{background:var(--hover-bg);color:var(--muted);border:1px solid var(--border2);border-radius:7px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;}
|
||||
.msg-edit-cancel:hover{background:rgba(255,255,255,.1);}
|
||||
|
||||
/* ── Clear conversation chip ── */
|
||||
@@ -624,7 +699,7 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.skill-linked-section{margin-bottom:8px;}
|
||||
.skill-linked-section h4{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:4px;}
|
||||
.skill-linked-file{display:block;font-size:12px;padding:3px 6px;border-radius:4px;cursor:pointer;color:var(--blue);text-decoration:none;}
|
||||
.skill-linked-file:hover{background:rgba(255,255,255,.06);}
|
||||
.skill-linked-file:hover{background:var(--hover-bg);}
|
||||
.tool-card-row{margin:0;padding:1px 0;}
|
||||
.tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;}
|
||||
.tool-card:hover{border-color:rgba(255,255,255,.12);}
|
||||
@@ -694,13 +769,13 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
|
||||
/* ── Session projects ── */
|
||||
.project-bar{display:flex;gap:4px;padding:4px 10px 8px;flex-wrap:wrap;align-items:center;flex-shrink:0;}
|
||||
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:rgba(255,255,255,.04);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;}
|
||||
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;}
|
||||
.project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
.project-chip.active{background:rgba(124,185,255,.12);color:var(--blue);border-color:rgba(124,185,255,.4);}
|
||||
.project-chip .color-dot{width:6px;height:6px;border-radius:50%;display:inline-block;flex-shrink:0;}
|
||||
.project-create-btn{font-size:10px;padding:3px 6px;border-radius:12px;cursor:pointer;border:1px dashed var(--border2);background:none;color:var(--muted);opacity:.6;transition:all .15s;}
|
||||
.project-create-btn:hover{opacity:1;border-color:var(--blue);color:var(--blue);}
|
||||
.project-create-input{font-size:10px;padding:3px 8px;border-radius:12px;border:1px solid rgba(124,185,255,.6);background:rgba(20,32,60,.9);color:var(--text);outline:none;width:100px;font-family:inherit;box-shadow:0 0 0 2px rgba(124,185,255,.15);}
|
||||
.project-create-input{font-size:10px;padding:3px 8px;border-radius:12px;border:1px solid rgba(124,185,255,.6);background:var(--surface);color:var(--text);outline:none;width:100px;font-family:inherit;box-shadow:0 0 0 2px rgba(124,185,255,.15);}
|
||||
.project-picker{position:absolute;right:0;top:100%;background:var(--sidebar);border:1px solid var(--border2);border-radius:8px;padding:4px;z-index:30;min-width:160px;max-width:220px;width:max-content;box-shadow:0 4px 16px rgba(0,0,0,.3);}
|
||||
.project-picker-item{padding:5px 10px;font-size:11px;border-radius:6px;cursor:pointer;color:var(--muted);transition:all .1s;display:flex;align-items:center;gap:6px;}
|
||||
.project-picker-item:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
@@ -710,7 +785,7 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.session-project-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;margin-left:4px;vertical-align:middle;}
|
||||
|
||||
/* ── Code copy button ── */
|
||||
.code-copy-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
|
||||
.code-copy-btn{background:var(--hover-bg);border:1px solid var(--border2);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
|
||||
.code-copy-btn:hover{background:rgba(255,255,255,.12);color:var(--text);}
|
||||
|
||||
/* ── Tool card expand/collapse toggle ── */
|
||||
|
||||
92
static/ui.js
92
static/ui.js
@@ -237,7 +237,7 @@ function setStatus(t){
|
||||
txt.textContent=t;
|
||||
bar.style.display='';
|
||||
// Show dismiss X only for static/error messages, not transient busy ones
|
||||
const transient = t.endsWith('…') || t === 'Hermes is thinking…';
|
||||
const transient = t.endsWith('…') || t === (window._botName||'Hermes')+' is thinking\u2026';
|
||||
if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none';
|
||||
}
|
||||
}
|
||||
@@ -334,6 +334,47 @@ async function refreshSession() {
|
||||
showToast('Conversation refreshed');
|
||||
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
||||
}
|
||||
// ── Update banner ──
|
||||
function _showUpdateBanner(data){
|
||||
const parts=[];
|
||||
if(data.webui&&data.webui.behind>0) parts.push(`WebUI: ${data.webui.behind} update${data.webui.behind>1?'s':''}`);
|
||||
if(data.agent&&data.agent.behind>0) parts.push(`Agent: ${data.agent.behind} update${data.agent.behind>1?'s':''}`);
|
||||
if(!parts.length)return;
|
||||
const msg=$('updateMsg');
|
||||
if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available';
|
||||
const banner=$('updateBanner');
|
||||
if(banner) banner.classList.add('visible');
|
||||
window._updateData=data;
|
||||
}
|
||||
function dismissUpdate(){
|
||||
const b=$('updateBanner');if(b)b.classList.remove('visible');
|
||||
sessionStorage.setItem('hermes-update-dismissed','1');
|
||||
}
|
||||
async function applyUpdates(){
|
||||
const btn=$('btnApplyUpdate');
|
||||
if(btn){btn.disabled=true;btn.textContent='Updating\u2026';}
|
||||
const targets=[];
|
||||
if(window._updateData?.webui?.behind>0) targets.push('webui');
|
||||
if(window._updateData?.agent?.behind>0) targets.push('agent');
|
||||
try{
|
||||
for(const target of targets){
|
||||
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})});
|
||||
if(!res.ok){
|
||||
showToast('Update failed ('+target+'): '+(res.message||'unknown error'));
|
||||
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||
return;
|
||||
}
|
||||
}
|
||||
showToast('Updated! Reloading\u2026');
|
||||
sessionStorage.removeItem('hermes-update-checked');
|
||||
sessionStorage.removeItem('hermes-update-dismissed');
|
||||
setTimeout(()=>location.reload(),1500);
|
||||
}catch(e){
|
||||
showToast('Update failed: '+e.message);
|
||||
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkInflightOnBoot(sid) {
|
||||
const raw = localStorage.getItem(INFLIGHT_KEY);
|
||||
if (!raw) return;
|
||||
@@ -361,7 +402,7 @@ async function checkInflightOnBoot(sid) {
|
||||
|
||||
function syncTopbar(){
|
||||
if(!S.session){
|
||||
document.title='Hermes';
|
||||
document.title=window._botName||'Hermes';
|
||||
// Show default workspace name even without a session
|
||||
const sidebarName=$('sidebarWsName');
|
||||
if(sidebarName && sidebarName.textContent==='Workspace'){
|
||||
@@ -371,7 +412,7 @@ function syncTopbar(){
|
||||
}
|
||||
const sessionTitle=S.session.title||'Untitled';
|
||||
$('topbarTitle').textContent=sessionTitle;
|
||||
document.title=sessionTitle+' \u2014 Hermes';
|
||||
document.title=sessionTitle+' \u2014 '+(window._botName||'Hermes');
|
||||
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
|
||||
$('topbarMeta').textContent=`${vis.length} messages`;
|
||||
// If a profile switch just happened, apply its model rather than the session's stale value.
|
||||
@@ -464,7 +505,8 @@ function renderMessages(){
|
||||
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="Regenerate response" onclick="regenerateResponse(this)">↻</button>` : '';
|
||||
const tsVal=m._ts||m.timestamp;
|
||||
const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():'';
|
||||
row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':'H'}</div><span style="font-size:12px">${isUser?'You':'Hermes'}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="Copy" onclick="copyMsg(this)">📋</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
|
||||
const _bn=window._botName||'Hermes';
|
||||
row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${isUser?'You':esc(_bn)}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="Copy" onclick="copyMsg(this)">📋</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
|
||||
row.dataset.rawText = String(content).trim();
|
||||
inner.appendChild(row);
|
||||
}
|
||||
@@ -480,18 +522,35 @@ function renderMessages(){
|
||||
byAssistant[key].push(tc);
|
||||
}
|
||||
const allRows = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]'));
|
||||
// Track the last inserted node per anchor so back-to-back groups for the
|
||||
// same (filtered) anchor row are inserted in chronological order.
|
||||
const anchorInsertAfter = new Map();
|
||||
for(const [key, cards] of Object.entries(byAssistant)){
|
||||
const aIdx = parseInt(key);
|
||||
let insertBefore = null;
|
||||
if(aIdx === -1){
|
||||
for(let i=allRows.length-1;i>=0;i--){
|
||||
const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10);
|
||||
if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=allRows[i];break;}
|
||||
}
|
||||
} else {
|
||||
// Find the right insertion point: cards go AFTER the assistant message
|
||||
// that triggered them. We look for the row at aIdx, or the nearest
|
||||
// visible ASSISTANT row at or before aIdx (the assistant message may be
|
||||
// filtered out if it contained only tool_use blocks with no text response).
|
||||
let anchorRow = null;
|
||||
if(aIdx >= 0){
|
||||
// First: exact match for the assistant row
|
||||
for(const r of allRows){
|
||||
const ri=parseInt(r.dataset.msgIdx||'-1');
|
||||
if(ri>aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=r;break;}
|
||||
if(ri===aIdx){anchorRow=r;break;}
|
||||
}
|
||||
// Fallback: nearest visible ASSISTANT row at or before aIdx
|
||||
if(!anchorRow){
|
||||
for(let i=allRows.length-1;i>=0;i--){
|
||||
const ri=parseInt(allRows[i].dataset.msgIdx||'-1');
|
||||
if(ri<=aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
|
||||
}
|
||||
}
|
||||
}
|
||||
// aIdx === -1 or no assistant anchor found: attach after the last assistant row
|
||||
if(!anchorRow){
|
||||
for(let i=allRows.length-1;i>=0;i--){
|
||||
const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10);
|
||||
if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
|
||||
}
|
||||
}
|
||||
const frag=document.createDocumentFragment();
|
||||
@@ -512,8 +571,15 @@ function renderMessages(){
|
||||
toggle.appendChild(collapseBtn);
|
||||
frag.insertBefore(toggle,frag.firstChild);
|
||||
}
|
||||
if(insertBefore) inner.insertBefore(frag,insertBefore);
|
||||
// Insert after the anchor row (or after any previously inserted group for
|
||||
// the same anchor), preserving chronological order for multi-step chains.
|
||||
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
|
||||
const refNode = insertAfterNode ? insertAfterNode.nextSibling : null;
|
||||
if(refNode) inner.insertBefore(frag,refNode);
|
||||
else inner.appendChild(frag);
|
||||
// Record the last child we inserted so the next group for this anchor
|
||||
// goes after it rather than back at anchorRow.nextSibling.
|
||||
anchorInsertAfter.set(anchorRow, inner.lastChild);
|
||||
}
|
||||
}
|
||||
// Render usage badge on the last assistant message row (if enabled and usage data exists)
|
||||
|
||||
180
tests/test_model_resolver.py
Normal file
180
tests/test_model_resolver.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Tests for resolve_model_provider() model routing logic.
|
||||
Verifies that model IDs are correctly resolved to (model, provider, base_url)
|
||||
tuples for different provider configurations.
|
||||
"""
|
||||
import api.config as config
|
||||
|
||||
|
||||
def _resolve_with_config(model_id, provider=None, base_url=None, default=None):
|
||||
"""Helper: temporarily set config.cfg model section, call resolve, restore."""
|
||||
old_cfg = dict(config.cfg)
|
||||
model_cfg = {}
|
||||
if provider:
|
||||
model_cfg['provider'] = provider
|
||||
if base_url:
|
||||
model_cfg['base_url'] = base_url
|
||||
if default:
|
||||
model_cfg['default'] = default
|
||||
config.cfg['model'] = model_cfg if model_cfg else {}
|
||||
try:
|
||||
return config.resolve_model_provider(model_id)
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
|
||||
|
||||
# ── OpenRouter prefix handling ────────────────────────────────────────────
|
||||
|
||||
def test_openrouter_free_keeps_full_path():
|
||||
"""openrouter/free must NOT be stripped to 'free' when provider is openrouter."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'openrouter/free', provider='openrouter',
|
||||
base_url='https://openrouter.ai/api/v1',
|
||||
)
|
||||
assert model == 'openrouter/free', f"Expected 'openrouter/free', got '{model}'"
|
||||
assert provider == 'openrouter'
|
||||
|
||||
|
||||
def test_openrouter_model_with_provider_prefix():
|
||||
"""anthropic/claude-sonnet-4.6 via openrouter keeps full path."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'anthropic/claude-sonnet-4.6', provider='openrouter',
|
||||
base_url='https://openrouter.ai/api/v1',
|
||||
)
|
||||
assert model == 'anthropic/claude-sonnet-4.6'
|
||||
assert provider == 'openrouter'
|
||||
|
||||
|
||||
# ── Direct provider prefix stripping ─────────────────────────────────────
|
||||
|
||||
def test_anthropic_prefix_stripped_for_direct_api():
|
||||
"""anthropic/claude-sonnet-4.6 strips prefix when provider is anthropic."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'anthropic/claude-sonnet-4.6', provider='anthropic',
|
||||
)
|
||||
assert model == 'claude-sonnet-4.6'
|
||||
assert provider == 'anthropic'
|
||||
|
||||
|
||||
def test_openai_prefix_stripped_for_direct_api():
|
||||
"""openai/gpt-5.4-mini strips prefix when provider is openai."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'openai/gpt-5.4-mini', provider='openai',
|
||||
)
|
||||
assert model == 'gpt-5.4-mini'
|
||||
assert provider == 'openai'
|
||||
|
||||
|
||||
# ── Cross-provider routing ───────────────────────────────────────────────
|
||||
|
||||
def test_cross_provider_routes_through_openrouter():
|
||||
"""Picking openai model when config is anthropic routes via openrouter."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'openai/gpt-5.4-mini', provider='anthropic',
|
||||
)
|
||||
assert model == 'openai/gpt-5.4-mini'
|
||||
assert provider == 'openrouter'
|
||||
assert base_url is None # openrouter uses its own endpoint
|
||||
|
||||
|
||||
# ── Bare model names ─────────────────────────────────────────────────────
|
||||
|
||||
def test_bare_model_uses_config_provider():
|
||||
"""A model name without / uses the config provider and base_url."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'gemma-4-26B', provider='custom',
|
||||
base_url='http://192.168.1.160:4000',
|
||||
)
|
||||
assert model == 'gemma-4-26B'
|
||||
assert provider == 'custom'
|
||||
assert base_url == 'http://192.168.1.160:4000'
|
||||
|
||||
|
||||
def test_empty_model_returns_config_defaults():
|
||||
"""Empty model string returns config provider and base_url."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'', provider='anthropic',
|
||||
)
|
||||
assert model == ''
|
||||
assert provider == 'anthropic'
|
||||
|
||||
|
||||
# ── Non-default provider prefix routing (Issue #138) ────────────────────
|
||||
|
||||
def test_prefixed_non_default_provider_routes_through_openrouter():
|
||||
"""minimax/MiniMax-M2.7 with anthropic as default should route via openrouter."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'minimax/MiniMax-M2.7', provider='anthropic',
|
||||
)
|
||||
assert model == 'minimax/MiniMax-M2.7'
|
||||
assert provider == 'openrouter'
|
||||
|
||||
|
||||
def test_prefixed_non_default_provider_zai():
|
||||
"""zai/GLM-5 with openai as default should route via openrouter."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'zai/GLM-5', provider='openai',
|
||||
)
|
||||
assert model == 'zai/GLM-5'
|
||||
assert provider == 'openrouter'
|
||||
|
||||
|
||||
# ── get_available_models() prefix behaviour ───────────────────────────────
|
||||
|
||||
def _available_models_with_provider(provider):
|
||||
"""Helper: temporarily set active_provider in auth store simulation via config.cfg."""
|
||||
old_cfg = dict(config.cfg)
|
||||
config.cfg['model'] = {'provider': provider}
|
||||
try:
|
||||
return config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
|
||||
|
||||
def test_non_default_provider_models_are_prefixed():
|
||||
"""With anthropic as default, minimax model IDs should be prefixed 'minimax/...'."""
|
||||
result = _available_models_with_provider('anthropic')
|
||||
groups = {g['provider']: g['models'] for g in result['groups']}
|
||||
if 'MiniMax' in groups:
|
||||
for m in groups['MiniMax']:
|
||||
assert m['id'].startswith('minimax/'), (
|
||||
f"Expected minimax/ prefix, got: {m['id']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_default_provider_models_not_prefixed():
|
||||
"""The active provider's _PROVIDER_MODELS entries remain bare (no prefix added)."""
|
||||
import api.config as _cfg
|
||||
# The bare IDs as stored in _PROVIDER_MODELS (e.g. 'claude-sonnet-4.6')
|
||||
raw_anthropic_ids = {m['id'] for m in _cfg._PROVIDER_MODELS.get('anthropic', [])}
|
||||
result = _available_models_with_provider('anthropic')
|
||||
groups = {g['provider']: g['models'] for g in result['groups']}
|
||||
if 'Anthropic' in groups:
|
||||
returned_ids = {m['id'] for m in groups['Anthropic']}
|
||||
# Every bare _PROVIDER_MODELS ID must still appear bare (not turned into 'anthropic/...')
|
||||
for bare_id in raw_anthropic_ids:
|
||||
assert bare_id in returned_ids, (
|
||||
f"_PROVIDER_MODELS entry '{bare_id}' is missing from the Anthropic group "
|
||||
f"(returned: {sorted(returned_ids)})"
|
||||
)
|
||||
|
||||
|
||||
def test_no_active_provider_models_not_prefixed():
|
||||
"""With no confirmed active_provider, models should not be prefixed."""
|
||||
old_cfg = dict(config.cfg)
|
||||
config.cfg['model'] = {} # no provider set
|
||||
try:
|
||||
result = config.get_available_models()
|
||||
for g in result['groups']:
|
||||
for m in g['models']:
|
||||
# No model should have a double-prefix like 'minimax/minimax/...'
|
||||
parts = m['id'].split('/')
|
||||
if len(parts) >= 2:
|
||||
assert parts[0] != parts[1], (
|
||||
f"Double-prefix detected: {m['id']!r}"
|
||||
)
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
136
tests/test_sprint27.py
Normal file
136
tests/test_sprint27.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Sprint 27 Tests: configurable assistant display name (bot_name).
|
||||
Tests cover settings API round-trip, empty/missing input defaults,
|
||||
login page rendering, and server-side sanitization.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def get_raw(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return r.read().decode(), 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
|
||||
|
||||
|
||||
# ── Default value ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_settings_default_bot_name():
|
||||
"""GET /api/settings should return bot_name defaulting to 'Hermes'."""
|
||||
d, status = get("/api/settings")
|
||||
assert status == 200
|
||||
assert "bot_name" in d
|
||||
assert d["bot_name"] == "Hermes"
|
||||
|
||||
|
||||
# ── Round-trip ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_settings_set_bot_name():
|
||||
"""POST /api/settings with bot_name should persist and round-trip."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"bot_name": "TestBot"})
|
||||
assert status == 200
|
||||
assert d.get("bot_name") == "TestBot"
|
||||
d2, _ = get("/api/settings")
|
||||
assert d2.get("bot_name") == "TestBot"
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
def test_settings_bot_name_special_chars():
|
||||
"""bot_name with safe special characters should persist correctly."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"bot_name": "My Assistant 2.0"})
|
||||
assert status == 200
|
||||
d2, _ = get("/api/settings")
|
||||
assert d2.get("bot_name") == "My Assistant 2.0"
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
# ── Server-side sanitization ──────────────────────────────────────────────
|
||||
|
||||
def test_settings_empty_bot_name_defaults_to_hermes():
|
||||
"""Posting an empty bot_name should default to 'Hermes' server-side."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"bot_name": ""})
|
||||
assert status == 200
|
||||
assert d.get("bot_name") == "Hermes"
|
||||
d2, _ = get("/api/settings")
|
||||
assert d2.get("bot_name") == "Hermes"
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
def test_settings_whitespace_bot_name_defaults_to_hermes():
|
||||
"""Posting a whitespace-only bot_name should default to 'Hermes'."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"bot_name": " "})
|
||||
assert status == 200
|
||||
assert d.get("bot_name") == "Hermes"
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
# ── Login page rendering ──────────────────────────────────────────────────
|
||||
|
||||
def test_login_page_shows_default_bot_name():
|
||||
"""GET /login should contain 'Hermes' in title and h1 when default."""
|
||||
html, status = get_raw("/login")
|
||||
assert status == 200
|
||||
assert "<title>Hermes" in html
|
||||
assert "<h1>Hermes</h1>" in html
|
||||
|
||||
|
||||
def test_login_page_shows_custom_bot_name():
|
||||
"""GET /login should reflect the configured bot_name."""
|
||||
try:
|
||||
post("/api/settings", {"bot_name": "Aria"})
|
||||
html, status = get_raw("/login")
|
||||
assert status == 200
|
||||
assert "<title>Aria" in html
|
||||
assert "<h1>Aria</h1>" in html
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
def test_login_page_empty_name_does_not_crash():
|
||||
"""Login page must not 500 even if somehow bot_name is empty in settings."""
|
||||
# Force an empty value by patching settings file directly — skipped here
|
||||
# because the server-side guard in POST /api/settings prevents storing empty.
|
||||
# Instead, verify that /login returns 200 reliably.
|
||||
html, status = get_raw("/login")
|
||||
assert status == 200
|
||||
assert "Sign in" in html
|
||||
|
||||
|
||||
def test_login_page_xss_escaped():
|
||||
"""bot_name with HTML special chars should be escaped in the login page."""
|
||||
try:
|
||||
post("/api/settings", {"bot_name": "<script>alert(1)</script>"})
|
||||
html, status = get_raw("/login")
|
||||
assert status == 200
|
||||
# Raw tag must not appear unescaped
|
||||
assert "<script>alert(1)</script>" not in html
|
||||
# Escaped form should appear
|
||||
assert "<script>" in html
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
227
tests/test_sprint28.py
Normal file
227
tests/test_sprint28.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Sprint 28 Tests: /personality slash command — backend API coverage.
|
||||
Tests: GET /api/personalities, POST /api/personality/set, Session.compact(),
|
||||
path traversal defence, size cap, clear personality.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# Import test constants from conftest (same process — these are module-level values)
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
||||
from conftest import TEST_STATE_DIR
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
def _personalities_dir():
|
||||
"""Return the personalities directory the test server will look in.
|
||||
|
||||
conftest sets HERMES_HOME=TEST_STATE_DIR in the server's environment.
|
||||
The server's api/profiles._DEFAULT_HERMES_HOME resolves to TEST_STATE_DIR,
|
||||
so get_active_hermes_home() returns TEST_STATE_DIR, and personalities
|
||||
live at TEST_STATE_DIR/personalities.
|
||||
"""
|
||||
p = TEST_STATE_DIR / 'personalities'
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _make_personality(name, content="# Test Bot\nA test personality."):
|
||||
"""Create a personality directory with a SOUL.md."""
|
||||
d = _personalities_dir() / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SOUL.md").write_text(content)
|
||||
return d
|
||||
|
||||
|
||||
def _make_session():
|
||||
"""Create a new session and return its session_id."""
|
||||
d, status = post("/api/session/new", {})
|
||||
assert status == 200, f"Failed to create session: {d}"
|
||||
return d["session"]["session_id"]
|
||||
|
||||
|
||||
def _cleanup_session(sid):
|
||||
try:
|
||||
post("/api/session/delete", {"session_id": sid})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── GET /api/personalities ────────────────────────────────────────────────────
|
||||
|
||||
def test_personalities_empty_when_none_exist():
|
||||
"""GET /api/personalities returns empty list when no personalities exist."""
|
||||
p_dir = _personalities_dir()
|
||||
for child in list(p_dir.iterdir()):
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
shutil.rmtree(child)
|
||||
d, status = get("/api/personalities")
|
||||
assert status == 200
|
||||
assert d.get("personalities") == []
|
||||
|
||||
|
||||
def test_personalities_lists_valid_personalities():
|
||||
"""GET /api/personalities returns personalities that have SOUL.md."""
|
||||
_make_personality("testbot", "# TestBot\nA helpful assistant.")
|
||||
try:
|
||||
d, status = get("/api/personalities")
|
||||
assert status == 200
|
||||
names = [p["name"] for p in d["personalities"]]
|
||||
assert "testbot" in names
|
||||
testbot = next(p for p in d["personalities"] if p["name"] == "testbot")
|
||||
assert testbot["description"] == "TestBot"
|
||||
finally:
|
||||
shutil.rmtree(_personalities_dir() / "testbot", ignore_errors=True)
|
||||
|
||||
|
||||
def test_personalities_skips_dirs_without_soul_md():
|
||||
"""Directories without SOUL.md are not listed."""
|
||||
empty_dir = _personalities_dir() / "nodoc"
|
||||
empty_dir.mkdir(exist_ok=True)
|
||||
try:
|
||||
d, status = get("/api/personalities")
|
||||
assert status == 200
|
||||
names = [p["name"] for p in d["personalities"]]
|
||||
assert "nodoc" not in names
|
||||
finally:
|
||||
shutil.rmtree(empty_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_personalities_skips_symlinks():
|
||||
"""Symlinks inside personalities dir are skipped (security guard)."""
|
||||
p_dir = _personalities_dir()
|
||||
real_dir = p_dir.parent / "real_personality_target"
|
||||
real_dir.mkdir(exist_ok=True)
|
||||
(real_dir / "SOUL.md").write_text("# Leaked\nContent")
|
||||
link = p_dir / "symlinked"
|
||||
try:
|
||||
link.symlink_to(real_dir)
|
||||
d, status = get("/api/personalities")
|
||||
assert status == 200
|
||||
names = [p["name"] for p in d["personalities"]]
|
||||
assert "symlinked" not in names
|
||||
finally:
|
||||
link.unlink(missing_ok=True)
|
||||
shutil.rmtree(real_dir, ignore_errors=True)
|
||||
|
||||
|
||||
# ── POST /api/personality/set ─────────────────────────────────────────────────
|
||||
|
||||
def test_set_personality_valid():
|
||||
"""Setting a valid personality stores name and returns prompt."""
|
||||
_make_personality("assistant", "# Assistant\nBe helpful.")
|
||||
sid = _make_session()
|
||||
try:
|
||||
d, status = post("/api/personality/set", {"session_id": sid, "name": "assistant"})
|
||||
assert status == 200
|
||||
assert d.get("ok") is True
|
||||
assert d.get("personality") == "assistant"
|
||||
assert "Assistant" in d.get("prompt", "")
|
||||
finally:
|
||||
_cleanup_session(sid)
|
||||
shutil.rmtree(_personalities_dir() / "assistant", ignore_errors=True)
|
||||
|
||||
|
||||
def test_set_personality_persists_in_compact():
|
||||
"""After setting personality, GET /api/session returns personality in compact."""
|
||||
_make_personality("coder", "# Coder\nWrite clean code.")
|
||||
sid = _make_session()
|
||||
try:
|
||||
post("/api/personality/set", {"session_id": sid, "name": "coder"})
|
||||
d, status = get(f"/api/session?session_id={sid}")
|
||||
assert status == 200
|
||||
session = d.get("session", {})
|
||||
assert session.get("personality") == "coder"
|
||||
finally:
|
||||
_cleanup_session(sid)
|
||||
shutil.rmtree(_personalities_dir() / "coder", ignore_errors=True)
|
||||
|
||||
|
||||
def test_clear_personality_sets_null():
|
||||
"""Clearing personality with name='' sets it to None (null in JSON)."""
|
||||
_make_personality("pirate", "# Pirate\nArrr.")
|
||||
sid = _make_session()
|
||||
try:
|
||||
post("/api/personality/set", {"session_id": sid, "name": "pirate"})
|
||||
d, status = post("/api/personality/set", {"session_id": sid, "name": ""})
|
||||
assert status == 200
|
||||
assert d.get("personality") is None
|
||||
# Verify persisted via direct session fetch
|
||||
d2, s2 = get(f"/api/session?session_id={sid}")
|
||||
assert s2 == 200
|
||||
assert d2.get("session", {}).get("personality") is None
|
||||
finally:
|
||||
_cleanup_session(sid)
|
||||
shutil.rmtree(_personalities_dir() / "pirate", ignore_errors=True)
|
||||
|
||||
|
||||
def test_set_personality_not_found_returns_404():
|
||||
"""Setting a non-existent personality returns 404."""
|
||||
sid = _make_session()
|
||||
try:
|
||||
d, status = post("/api/personality/set",
|
||||
{"session_id": sid, "name": "doesnotexist"})
|
||||
assert status == 404
|
||||
finally:
|
||||
_cleanup_session(sid)
|
||||
|
||||
|
||||
def test_set_personality_path_traversal_rejected():
|
||||
"""Personality names with path traversal chars are rejected (400)."""
|
||||
sid = _make_session()
|
||||
try:
|
||||
for bad_name in ["../etc", "a/b", ".hidden", "has space"]:
|
||||
d, status = post("/api/personality/set",
|
||||
{"session_id": sid, "name": bad_name})
|
||||
assert status == 400, (
|
||||
f"Expected 400 for name={bad_name!r}, got {status}: {d}"
|
||||
)
|
||||
finally:
|
||||
_cleanup_session(sid)
|
||||
|
||||
|
||||
def test_set_personality_missing_session_returns_404():
|
||||
"""Setting personality on non-existent session returns 404."""
|
||||
_make_personality("x", "# X\nTest.")
|
||||
try:
|
||||
d, status = post("/api/personality/set",
|
||||
{"session_id": "nonexistent000", "name": "x"})
|
||||
assert status == 404
|
||||
finally:
|
||||
shutil.rmtree(_personalities_dir() / "x", ignore_errors=True)
|
||||
|
||||
|
||||
def test_set_personality_size_cap():
|
||||
"""SOUL.md files larger than MAX_FILE_BYTES are rejected."""
|
||||
from api.config import MAX_FILE_BYTES
|
||||
big_content = "A" * (MAX_FILE_BYTES + 1)
|
||||
_make_personality("toobig", big_content)
|
||||
sid = _make_session()
|
||||
try:
|
||||
d, status = post("/api/personality/set", {"session_id": sid, "name": "toobig"})
|
||||
assert status == 400
|
||||
assert "exceeds" in d.get("error", "").lower()
|
||||
finally:
|
||||
_cleanup_session(sid)
|
||||
shutil.rmtree(_personalities_dir() / "toobig", ignore_errors=True)
|
||||
Reference in New Issue
Block a user