Hermes WebUI v0.1.0 — initial public release

This commit is contained in:
Nathan Esquenazi
2026-03-30 20:40:19 -07:00
commit a4e2174c29
41 changed files with 11380 additions and 0 deletions

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
# Hermes Web UI -- local machine config template
# Copy this to .env and fill in your values.
# start.sh sources .env automatically if present.
# All values are optional -- auto-discovery will fill in anything left blank.
# Path to your hermes-agent checkout (the repo that contains run_agent.py)
# HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
# Python executable to use (defaults to the agent venv if found)
# HERMES_WEBUI_PYTHON=/path/to/python
# Bind address (default: 127.0.0.1 -- loopback only, safe default)
# HERMES_WEBUI_HOST=127.0.0.1
# Port to listen on (default: 8787)
# HERMES_WEBUI_PORT=8787
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)
# HERMES_WEBUI_STATE_DIR=~/.hermes/webui-mvp
# Default workspace directory shown on first launch
# HERMES_WEBUI_DEFAULT_WORKSPACE=~/workspace
# Base directory for all Hermes state (affects all paths above if set)
# HERMES_HOME=~/.hermes
# Path to your Hermes config.yaml (for toolsets and model config)
# HERMES_CONFIG_PATH=~/.hermes/config.yaml

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
# Backup and temporary files
*.bak
*.swp
*.swo
# Archive directory (pre-git backups, kept on disk but not tracked)
archive/
# Local environment and secrets (but keep the example template)
.env
.env.*
!.env.example
# Generated screenshots and transient artifacts
screenshot-*.png
full-UI.png
# OS files
.DS_Store
Thumbs.db

53
AGENTS.md Normal file
View File

@@ -0,0 +1,53 @@
# Web UI MVP Instructions
Canonical source: <repo>/
Symlink (for imports): <agent-dir>/webui-mvp -> <repo>
Runtime state: ~/.hermes/webui-mvp/sessions/
Purpose:
- Claude-style web UI for Hermes. Chat, workspace file browser, cron/skills/memory viewers.
Start server:
cd <agent-dir>
nohup venv/bin/python <repo>/server.py > /tmp/webui-mvp.log 2>&1 &
# OR: <repo>/start.sh
Run tests:
cd <agent-dir>
venv/bin/python -m pytest <repo>/tests/ -v
Health check: curl http://127.0.0.1:8787/health
Logs: tail -f /tmp/webui-mvp.log
SSH tunnel from Mac: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>
Living documents (always update after a sprint):
<repo>/ROADMAP.md
<repo>/ARCHITECTURE.md
<repo>/TESTING.md
Sprint process skill: webui-sprint-loop
# Workspace Convention (Web UI Sessions)
When running as an agent invoked from the web UI, each user message is prefixed with:
[Workspace: /absolute/path/to/workspace]
This tag is the single authoritative source of the active workspace. It reflects
whichever workspace the user has selected in the UI at the moment they sent that message.
It updates on every message, so if the user switches workspaces mid-session, the very
next message will carry the new path. Always use the value from the most recent tag.
This tag overrides any prior workspace mentioned in the system prompt, memory, or
conversation history. Never infer or fall back to a hardcoded path like
~/workspace when this tag is present.
Apply it as the default working directory for ALL file operations:
- write_file: resolve relative paths against this workspace
- read_file / search_files: resolve paths relative to this workspace
- terminal workdir: set to this path unless the user explicitly says otherwise
- patch: resolve file paths relative to this workspace
If no [Workspace: ...] tag is present (e.g., CLI sessions), fall back to
~/workspace as the default.

1588
ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load Diff

312
CHANGELOG.md Normal file
View File

@@ -0,0 +1,312 @@
# Hermes WebUI -- Changelog
> Living document. Updated at the end of every sprint.
> Source: <repo>/
> Repository: https://github.com/<your-username>/hermes-webui
---
## [v0.1.0] Concurrency + Correctness Sweeps
*March 31, 2026 | 190 tests*
Two systematic audits of all concurrent multi-session scenarios. Each finding
became a regression test so it cannot silently return.
### Sweep 1 (R10-R12)
- **R10: Approval response to wrong session.** `respondApproval()` used
`S.session.session_id` -- whoever you were viewing. If session A triggered
a dangerous command requiring approval and you switched to B then clicked
Allow, the approval went to B's session_id. Agent on A stayed stuck. Fixed:
approval events tag `_approvalSessionId`; `respondApproval()` uses that.
- **R11: Activity bar showed cross-session tool status.** Session A's tool
name appeared in session B's activity bar while you were viewing B. Fixed:
`setStatus()` in the tool SSE handler is now inside the `activeSid` guard.
- **R12: Live tool cards vanished on switch-away and back.** Switching back to
an in-flight session showed empty live cards even though tools had fired.
Fixed: `loadSession()` INFLIGHT branch now restores cards from `S.toolCalls`.
### Sweep 2 (R13-R15)
- **R13: Settled tool cards never rendered after response completes.**
`renderMessages()` has a `!S.busy` guard on tool card rendering. It was
called with `S.busy=true` in the done handler -- tool cards were skipped
every time. Fixed: `S.busy=false` set inline before `renderMessages()`.
- **R14: Wrong model sent for sessions with unlisted model.** `send()` used
`$('modelSelect').value` which could be stale if the session's model isn't
in the dropdown. Fixed: now uses `S.session.model || $('modelSelect').value`.
- **R15: Stale live tool cards in new sessions.** `newSession()` didn't call
`clearLiveToolCards()`. Fixed.
---
## [v0.0.9] Sprint 10 Post-Release Fixes
*March 31, 2026 | 177 tests*
Critical regressions introduced during the server.py split, caught by users and fixed immediately.
- **`uuid` not imported in server.py** -- `chat/start` returned 500 (NameError) on every new message
- **`AIAgent` not imported in api/streaming.py** -- agent thread crashed immediately, SSE returned 404
- **`has_pending` not imported in api/streaming.py** -- NameError during tool approval checks
- **`Session.__init__` missing `tool_calls` param** -- 500 on any session with tool history
- **SSE loop did not break on `cancel` event** -- connection hung after cancel
- **Regression test file added** (`tests/test_regressions.py`): 10 tests, one per introduced bug. These form a permanent regression gate so each class of error can never silently return.
---
## [v0.0.8] Sprint 10 -- Server Health + Operational Polish
*March 31, 2026 | 167 tests*
### Post-sprint Bug Fixes
- SSE loop now breaks on `cancel` event (was hanging after cancel)
- `setBusy(false)` now always hides the Cancel button
- `S.activeStreamId` properly initialized in the S global state object
- Tool card "Show more" button uses data attributes instead of inline JSON.stringify (XSS/parse safety)
- Version label updated to v0.0.8
- `Session.__init__` accepts `**kwargs` for forward-compatibility with future JSON fields
- Test cron jobs now isolated via `HERMES_HOME` env var in conftest (no more pollution of real jobs.json)
- `last_workspace` reset after each test in conftest (prevents workspace state bleed between tests)
- Tool cards now grouped per assistant turn instead of piled before last message
- Tool card insertion uses `data-msg-idx` attribute correctly (was `msgIdx`, matching HTML5 dataset API)
### Architecture
- **server.py split into api/ modules.** 1,150 lines -> 673 lines in server.py.
Extracted modules: `api/config.py` (101), `api/helpers.py` (57), `api/models.py` (114),
`api/workspace.py` (77), `api/upload.py` (77), `api/streaming.py` (187).
server.py is now the thin routing shell only. All business logic is independently importable.
### Features
- **Background task cancel.** Red "Cancel" button appears in the activity bar while a task
is running. Calls `GET /api/chat/cancel?stream_id=X`. The agent thread receives a cancel
event, emits a 'cancel' SSE event, and the UI shows "*Task cancelled.*" in the conversation.
Note: a tool call already in progress (e.g. a long terminal command) completes before
the cancel takes effect -- same behavior as CLI Ctrl+C.
- **Cron run history viewer.** Each job in the Tasks panel now has an "All runs" button.
Click to expand a list of up to 20 past runs with timestamps, each collapsible to show
the full output. Click again to hide.
- **Tool card UX polish.** Three improvements:
1. Pulsing blue dot on cards for in-progress tools (distinct from completed cards)
2. Smart snippet truncation at sentence boundaries instead of hard byte cutoff
3. "Show more / Show less" toggle on tool results longer than 220 chars
---
## [v0.0.7] Sprint 9 -- Codebase Health + Daily Driver Gaps
*March 31, 2026 | 149 tests*
The sprint that closed the last gaps for heavy agentic use.
### Architecture
- **app.js replaced by 6 modules.** `app.js` is deleted. The browser now loads 6 focused files:
`ui.js` (530), `workspace.js` (132), `sessions.js` (189), `messages.js` (221),
`panels.js` (555), `boot.js` (142). The modules are a superset of the original app.js
(two functions -- `loadTodos`, `toolIcon` -- were added directly to the modules after the split).
No single file exceeds 555 lines.
### Features
- **Tool call cards inline.** Every tool Hermes uses now appears as a collapsible card
in the conversation between the user message and the response. Live during streaming,
restored from session history on reload. Shows tool name, preview, args, result snippet.
- **Attachment metadata persists on reload.** File badges on user messages survive page
refresh. Server stores filenames on the user message in session JSON.
- **Todo list panel.** New checkmark tab in the sidebar. Shows current task list parsed
from the most recent todo tool result in message history. Status icons: pending (○),
in-progress (◉), completed (✓), cancelled (✗). Auto-refreshes when panel is active.
- **Model preference persists.** Last-used model saved to localStorage. Restored on page
load. New sessions inherit it automatically.
### Bug Fixes
- Tool card toggle arrow only shown when card has expandable content
- Attachment tagging matches by message content to avoid wrong-turn tagging
- SSE tool event was missing `args` field
- `/api/session` GET was not returning `tool_calls` (history lost on reload)
---
## [v0.0.6] Sprint 8 -- Daily Driver Finish Line
*March 31, 2026 | 139 tests*
### Features
- **Edit user message + regenerate.** Hover any user bubble, click the pencil icon.
Inline textarea, Enter submits, Escape cancels. Truncates session at that point and re-runs.
- **Regenerate last response.** Retry icon on the last assistant bubble only.
- **Clear conversation.** "Clear" button in topbar. Wipes messages, keeps session slot.
- **Syntax highlighting.** Prism.js via CDN (deferred). Python, JS, bash, JSON, SQL and more.
### Bug Fixes
- Reconnect banner false positive on normal loads (90-second window)
- Session list clipping on short screens
- Favicon 404 console noise (server now returns 204)
- Edit textarea auto-resize on open
- Send button guard while inline edit is active
- Escape closes dropdown, clears search, cancels active edit
- Approval polling not restarted on INFLIGHT session switch-back
- Version label updated to v0.0.6
### Hotfix: Message Queue + INFLIGHT
- **Message queue.** Sending while busy queues the message with toast + badge.
Drains automatically on completion. Cleared on session switch.
- **Message stays visible on switch-away/back.** loadSession checks INFLIGHT before
server fetch, so sent message and thinking dots persist correctly.
---
## [v0.9] Sprint 7 -- Wave 2 Core: CRUD + Search
*March 31, 2026 | 125 tests*
### Features
- **Cron edit + delete.** Inline edit form per job, save and delete with confirmation.
- **Skill create, edit, delete.** "+ New skill" form in Skills panel. Writes to `~/.hermes/skills/`.
- **Memory inline edit.** "Edit" button opens textarea for MEMORY.md. Saves via `/api/memory/write`.
- **Session content search.** Filter box searches message text (up to 5 messages per session)
in addition to titles. Debounced API call, results appended below title matches.
### Architecture
- `/health` now returns `active_streams` and `uptime_seconds`
- `git init` on `<repo>/`, pushed to GitHub
### Bug Fixes
- Activity bar overlap on short viewports
- Model chip stale after session switch
- Cron output overflow in tasks panel
---
## [v0.8] Sprint 6 -- Polish + Phase E Complete
*March 31, 2026 | 106 tests*
### Architecture
- **Phase E complete.** HTML extracted to `static/index.html`. server.py now pure Python.
Line count progression: 1778 (Sprint 1) → 1042 (Sprint 5) → 903 (Sprint 6).
- **Phase D complete.** All endpoints validated with proper 400/404 responses.
### Features
- **Resizable panels.** Sidebar and workspace panel drag-resizable. Widths persisted to localStorage.
- **Create cron job from UI.** "+ New job" form in Tasks panel with name, schedule, prompt, delivery.
- **Session JSON export.** Downloads full session as JSON via "JSON" button in sidebar footer.
- **Escape from file editor.** Cancels inline file edit without saving.
---
## [v0.7] Sprint 5 -- Phase A Complete + Workspace Management
*March 30, 2026 | 86 tests*
### Architecture
- **Phase A complete.** JS extracted to `static/app.js`. server.py: 1778 → 1042 lines.
- **LRU session cache.** `collections.OrderedDict` with cap of 100, oldest evicted automatically.
- **Session index.** `sessions/_index.json` for O(1) session list loads.
- **Isolated test server.** Port 8788 with own state dir, conftest autouse cleanup.
### Features
- **Workspace management panel.** Add/remove/rename workspaces. Persisted to `workspaces.json`.
- **Topbar workspace quick-switch.** Dropdown chip lists all workspaces, switches on click.
- **New sessions inherit last workspace.** `last_workspace.txt` tracks last used.
- **Copy message to clipboard.** Hover icon on each bubble with checkmark confirmation.
- **Inline file editor.** Preview any file, click Edit to modify, Save writes to disk.
---
## [v0.6] Sprint 4 -- Relocation + Session Power Features
*March 30, 2026 | 68 tests*
### Architecture
- **Source relocated** to `<repo>/` outside the hermes-agent git repo.
Safe from `git pull`, `git reset`, `git stash`. Symlink maintained at `hermes-agent/webui-mvp`.
- **CSS extracted (Phase A start).** All CSS moved to `static/style.css`.
- **Per-session agent lock (Phase B).** Prevents concurrent requests to same session from
corrupting environment variables.
### Features
- **Session rename.** Double-click any title in sidebar to edit inline. Enter saves, Escape cancels.
- **Session search/filter.** Live client-side filter box above session list.
- **File delete.** Hover trash icon on workspace files. Confirm dialog.
- **File create.** "+" button in workspace panel header.
---
## [v0.5] Sprint 3 -- Panel Navigation + Feature Viewers
*March 30, 2026 | 48 tests*
### Features
- **Sidebar panel navigation.** Four tabs: Chat, Tasks, Skills, Memory. Lazy-loads on first open.
- **Tasks panel.** Lists scheduled cron jobs with status badges. Run now, Pause, Resume.
Shows last run output automatically.
- **Skills panel.** All skills grouped by category. Search/filter. Click to preview SKILL.md.
- **Memory panel.** Renders MEMORY.md and USER.md as formatted markdown with timestamps.
### Bug Fixes
- B6: New session inherits current workspace
- B10: Tool events replace thinking dots (not stacked alongside)
- B14: Cmd/Ctrl+K creates new chat from anywhere
---
## [v0.4] Sprint 2 -- Rich File Preview
*March 30, 2026 | 27 tests*
### Features
- **Image preview.** PNG, JPG, GIF, SVG, WEBP displayed inline in workspace panel.
- **Rendered markdown.** `.md` files render as formatted HTML in the preview panel.
- **Table support.** Pipe-delimited markdown tables render as HTML tables.
- **Smart file icons.** Type-appropriate icons by extension in the file tree.
- **Preview path bar with type badge.** Colored badge shows file type.
---
## [v0.3] Sprint 1 -- Bug Fixes + Foundations
*March 30, 2026 | 19 tests*
The first sprint. Established the test suite, fixed critical bugs.
### Bug Fixes
- B1: Approval card now shows pattern keys
- B2: File input accepts valid types only
- B3: Model chip label correct for all 10 models (replaced substring check with dict)
- B4/B5: Reconnect banner on mid-stream reload (localStorage inflight tracking)
- B7: Session titles no longer overflow sidebar
- B9: Empty assistant messages no longer render as blank bubbles
- B11: `/api/session` GET returns 400 (not silent session creation) when ID missing
### Architecture
- Thread lock on SESSIONS dict
- Structured JSON request logging
- 10-model dropdown with 3 provider groups (OpenAI, Anthropic, Other)
- First test suite: 19 HTTP integration tests
---
## [v0.2] UI Polish Pass
*March 30, 2026*
Visual audit via screenshot analysis. No new features -- design refinement only.
- Nav tabs: icon-only with CSS tooltip (5 tabs, no overflow)
- Session list: grouped by Today / Yesterday / Earlier
- Active session: blue left border accent
- Role labels: Title Case, softened color, circular icons
- Code blocks: connected language header with separator
- Send button: gradient + hover lift
- Composer: blue glow ring on focus
- Toast: frosted glass with float animation
- Tool status moved from composer footer to activity bar above composer
- Empty session flood fixed (filter + cleanup endpoint + test autouse)
---
## [v0.1] Initial Build
*March 30, 2026*
Single-file web UI for Hermes. stdlib HTTP server, no external dependencies.
Three-panel layout: sessions sidebar, chat area, workspace panel.
**Core capabilities:**
- Send messages, receive SSE-streamed responses
- Session create/load/delete, auto-title from first message
- File upload with manual multipart parser
- Workspace file tree with directory navigation
- Tool approval card (4 choices: once, session, always, deny)
- INFLIGHT session-switch guard
- 10-model dropdown (OpenAI, Anthropic, Other)
- SSH tunnel access on port 8787
---
*Last updated: Sprint 9, March 31, 2026 | Tests: 149/149*

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Hermes Web UI Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

213
README.md Normal file
View File

@@ -0,0 +1,213 @@
# Hermes Web UI
A lightweight, dark-themed browser interface for Hermes.
Full parity with the CLI experience -- everything you can do from a terminal,
you can do from this UI. No build step, no framework, no bundler. Just Python
and vanilla JS.
Layout: three-panel Claude-style. Left sidebar for sessions and tools,
center for chat, right for workspace file browsing.
---
## Quick start
```bash
git clone <this-repo> hermes-webui
cd hermes-webui
./start.sh
```
That is it. The script will:
1. Locate your Hermes agent checkout automatically.
2. Find (or create) a Python environment with the required dependencies.
3. Start the server.
4. Print the URL (and SSH tunnel command if you are on a remote machine).
---
## What start.sh discovers automatically
| Thing | How it finds it |
|---|---|
| Hermes agent dir | `HERMES_WEBUI_AGENT_DIR` env, then `~/.hermes/hermes-agent`, then sibling `../hermes-agent` |
| Python executable | Agent venv first, then `.venv` in this repo, then system `python3` |
| State directory | `HERMES_WEBUI_STATE_DIR` env, then `~/.hermes/webui-mvp` |
| Default workspace | `HERMES_WEBUI_DEFAULT_WORKSPACE` env, then `~/workspace`, then state dir |
| Port | `HERMES_WEBUI_PORT` env or first argument, default `8787` |
If discovery finds everything, nothing else is required.
---
## Overrides (only needed if auto-detection misses)
```bash
export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
export HERMES_WEBUI_PYTHON=/path/to/python
export HERMES_WEBUI_PORT=9000
./start.sh
```
Or inline:
```bash
HERMES_WEBUI_AGENT_DIR=/custom/path ./start.sh 9000
```
Full list of environment variables:
| Variable | Default | Description |
|---|---|---|
| `HERMES_WEBUI_AGENT_DIR` | auto-discovered | Path to the hermes-agent checkout |
| `HERMES_WEBUI_PYTHON` | auto-discovered | Python executable |
| `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address |
| `HERMES_WEBUI_PORT` | `8787` | Port |
| `HERMES_WEBUI_STATE_DIR` | `~/.hermes/webui-mvp` | Where sessions and state are stored |
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
| `HERMES_WEBUI_DEFAULT_MODEL` | `openai/gpt-5.4-mini` | Default model |
| `HERMES_HOME` | `~/.hermes` | Base directory for Hermes state (affects all paths above) |
| `HERMES_CONFIG_PATH` | `~/.hermes/config.yaml` | Path to Hermes config file |
---
## Accessing from a remote machine
The server binds to `127.0.0.1` by default (loopback only). If you are running
Hermes on a VPS or remote server, use an SSH tunnel from your local machine:
```bash
ssh -N -L <local-port>:127.0.0.1:<remote-port> <user>@<server-host>
```
Example:
```bash
ssh -N -L 8787:127.0.0.1:8787 user@your.server.com
```
Then open `http://localhost:8787` in your local browser.
`start.sh` will print this command for you automatically when it detects you
are running over SSH.
---
## Manual launch (without start.sh)
If you prefer to launch the server directly:
```bash
cd /path/to/hermes-agent # or wherever sys.path can find Hermes modules
HERMES_WEBUI_PORT=8787 python /path/to/hermes-webui/server.py
```
Health check:
```bash
curl http://127.0.0.1:8787/health
```
---
## Running tests
Tests discover the repo and the Hermes agent dynamically -- no hardcoded paths.
```bash
cd hermes-webui
python -m pytest tests/ -v
```
Or using the agent venv explicitly:
```bash
/path/to/hermes-agent/venv/bin/python -m pytest tests/ -v
```
Tests run against an isolated server on port 8788 with a separate state directory.
Production data and real cron jobs are never touched.
---
## Features
### Chat and agent
- Streaming responses via SSE (tokens appear as they are generated)
- 10+ models across OpenAI, Anthropic, and other providers; last-used model persists
- Send a message while one is processing -- it queues automatically
- Edit any past user message inline and regenerate from that point
- Retry the last assistant response with one click
- Cancel a running task from the activity bar
- Tool call cards inline -- each shows the tool name, args, and result snippet
- Approval card for dangerous shell commands (allow once / session / always / deny)
- File attachments persist across page reloads
### Sessions
- Create, rename, delete, search by title and message content
- Grouped by Today / Yesterday / Earlier in the sidebar
- Download as Markdown transcript or full JSON export
- Sessions persist across page reloads and SSH tunnel reconnects
### Workspace file browser
- Browse directory tree with type icons
- Preview text, code, Markdown (rendered), and images inline
- Edit files in the browser
- Create and delete files
- Right panel is drag-resizable
### Panels
- **Chat** -- session list, search, new conversation
- **Tasks** -- view, create, edit, run, pause/resume, delete cron jobs
- **Skills** -- list all skills by category, search, preview, create/edit
- **Memory** -- view and edit MEMORY.md and USER.md inline
- **Todos** -- live task list from the current session
- **Spaces** -- add, rename, remove workspaces; quick-switch from topbar
---
## Architecture
```
server.py HTTP routing shell
api/
config.py Discovery + globals (HOST, PORT, SESSIONS, etc.)
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve()
models.py Session model + CRUD
workspace.py File ops: list_dir, read_file_content, workspace helpers
upload.py Multipart parser, file upload handler
streaming.py SSE engine, run_agent integration, cancel support
static/
index.html HTML template
style.css All CSS
ui.js DOM helpers, renderMd, tool cards
workspace.js File tree, preview, file ops
sessions.js Session CRUD, list rendering, search
messages.js send(), SSE event handlers, approval, transcript
panels.js Cron, skills, memory, workspace, todo, switchPanel
boot.js Event wiring + boot IIFE
tests/
conftest.py Isolated test server (port 8788, separate HERMES_HOME)
test_sprint1-10.py Feature tests per sprint
test_regressions.py Permanent regression gate
```
State lives outside the repo at `~/.hermes/webui-mvp/` by default
(sessions, workspaces, last_workspace). Override with `HERMES_WEBUI_STATE_DIR`.
---
## Docs
- `ROADMAP.md` -- feature roadmap and sprint history
- `ARCHITECTURE.md` -- system design, all API endpoints, implementation notes
- `TESTING.md` -- manual browser test plan and automated coverage reference
- `CHANGELOG.md` -- release notes
- `PORTABILITY.md` -- full portability design spec
## Repo
```
git@github.com:<your-username>/hermes-webui.git
```

309
ROADMAP.md Normal file
View File

@@ -0,0 +1,309 @@
# Hermes WebUI: Full Parity Roadmap
> 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: Post-Sprint 10 bug sweeps (March 31, 2026)
> Tests: 190/190 passing
> Source: <repo>/
---
## Sprint History (Completed)
| Sprint | Theme | Highlights | Tests |
|--------|-------|-----------|-------|
| Sprint 1 | Bug fixes + foundations | B1-B11 fixed, LOCK on SESSIONS, section headers, request logging | 19 |
| Sprint 2 | Rich file preview | Image preview, rendered markdown, table support, smart icons | 27 |
| Sprint 3 | Panel nav + viewers | Sidebar tabs, cron/skills/memory panels, B6/B10/B14, Phase D start | 48 |
| Sprint 4 | Relocation + power features | Source to <repo>/, CSS extracted, session rename/search, file ops | 68 |
| Sprint 5 | Phase A complete + workspace | JS extracted (server.py 1778->1042 lines), workspace management, copy message, file editor, session index | 86 |
| Test hardening | Isolated test environment | Port 8788 test server, conftest autouse, cleanup_zero_message, 5 test files rewritten | 90 |
| Sprint 6 | Polish + Phase E complete | HTML to static/, resizable panels, cron create, session JSON export, Escape from editor | 106 |
| Sprint 7 | Wave 2 Core: CRUD + Search | Cron edit/delete, skill create/edit/delete, memory write, session content search, health improvements, git init | 125 |
| Sprint 8 | Daily Driver Finish Line | Edit+regenerate user messages, regenerate last response, clear conversation, Prism.js syntax highlighting, reconnect banner fix, session list scroll fix | 139 |
| Sprint 8 hotfix | Message queue + INFLIGHT fix | Queue messages while busy (toast + badge + auto-drain), INFLIGHT-first loadSession (message stays on switch-away/back) | 139 |
| Sprint 9 | Codebase health + daily driver gaps | app.js deleted and replaced by 6 modules, tool call cards inline, attachment persistence on reload, todo list panel | 149 |
| Sprint 10 | Server health + operational polish | server.py split into api/ modules, background task cancel, cron run history viewer, tool card UX polish | 167 |
| Sprint 10 fixes | Import regressions + regression tests | uuid, AIAgent, has_pending, SSE cancel loop, Session.__init__ tool_calls; test_regressions.py | 177 |
| Concurrency sweeps | Multi-session correctness | Approval cross-session (R10), activity bar per-session (R11), live cards on switch-back (R12), tool cards after done (R13), session model authoritative (R14), newSession cards (R15) | 190 |
---
## Current Architecture Status
| Layer | Location | Status |
|-------|----------|--------|
| Python server | <repo>/server.py (~1100 lines) | Pure Python, no inline HTML/CSS/JS |
| HTML template | <repo>/static/index.html | Served from disk |
| CSS | <repo>/static/style.css | Served from disk |
| JavaScript | <repo>/static/app.js | Served from disk |
| Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files |
| Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run |
| Production server | Port 8787 | SSH tunnel from Mac |
---
## Feature Parity Checklist
### Chat and Agent
- [x] Send messages, get SSE-streaming responses
- [x] Switch models per session (10 models, grouped by provider)
- [x] Upload files to workspace (drag-drop, click, clipboard paste)
- [x] File tray with remove button
- [x] Tool progress shown in activity bar above composer
- [x] Approval card for dangerous commands (Allow once/session/always, Deny)
- [x] Approval polling + SSE-pushed approval events
- [x] INFLIGHT guard: switch sessions mid-request without losing response
- [x] Session restores from localStorage on page load
- [x] Reconnect banner if page reloaded mid-stream
- [x] Copy message to clipboard (hover icon on each bubble)
- [x] Edit last user message and regenerate
- [ ] Branch/fork conversation (Wave 3)
- [ ] Token/cost estimate per message (Wave 3)
### Tool Visibility
- [x] Tool progress in activity bar (moved out of composer footer)
- [x] Approval card with all 4 choices
- [x] Tool call cards inline (collapsed, show name/args/result)
### Workspace / Files
- [x] Browse workspace directory tree with type icons
- [x] Preview text/code files (read-only)
- [x] Preview markdown files (rendered, tables supported)
- [x] Preview image files (PNG, JPG, GIF, SVG, WEBP inline)
- [x] Edit files inline (Edit button, Enter to save, Escape to cancel)
- [x] Create new file (+ button in panel header)
- [x] Delete file (hover trash, confirm dialog)
- [x] File name truncation with tooltip for long names
- [x] Right panel resizable (drag inner edge)
- [x] Syntax highlighted code preview (Prism.js)
- [ ] Rename file (Wave 3)
- [ ] Create folder (Wave 3)
### Sessions
- [x] Create session (+ button or Cmd/Ctrl+K)
- [x] Load session (click in sidebar)
- [x] Delete session (hover trash, toast, correct fallback)
- [x] Auto-title from first user message
- [x] Rename session title (double-click in sidebar, Enter saves, Escape cancels)
- [x] Filter/search sessions by title (live filter box)
- [x] Date group headers (Today / Yesterday / Earlier)
- [x] Download session as Markdown transcript
- [x] Export session as JSON (full messages + metadata)
- [x] Session inherits last-used workspace on creation
- [x] Session content search (search message text across sessions)
- [ ] Session tags / labels (Wave 5)
- [ ] Archive sessions (Wave 5)
- [x] Clear conversation (wipe messages, keep session) (Wave 3)
- [ ] Import session from JSON (Wave 3)
### Workspace Management
- [x] Add workspace with path validation (must be existing directory)
- [x] Remove workspace
- [x] Rename workspace display name
- [x] Quick-switch workspace from topbar dropdown
- [x] Sidebar live workspace display (name + path, updates in real time)
- [x] New sessions inherit last used workspace
- [x] Workspace list persists to workspaces.json
- [ ] Workspace reorder (drag) (Wave 2)
### Scheduled Tasks (Cron)
- [x] View all cron jobs (Tasks sidebar tab)
- [x] View last run output per job (auto-loaded on expand)
- [x] Expand job to see prompt, schedule, last output
- [x] Run job manually (Run now button)
- [x] Pause / Resume job
- [x] Create cron job from UI (+ New job form with name, schedule, prompt, delivery)
- [x] Edit existing cron job
- [x] Delete cron job
- [x] View full cron run history (expandable per job)
- [ ] Skill picker in cron create form (Wave 3)
### Skills
- [x] List all skills grouped by category (Skills sidebar tab)
- [x] Search/filter skills by name, description, category
- [x] View full SKILL.md content in right preview panel
- [x] Create skill
- [x] Edit skill
- [x] Delete skill
- [ ] View skill linked files (Wave 3)
### Memory
- [x] View personal notes (MEMORY.md) rendered as markdown (Memory tab)
- [x] View user profile (USER.md) rendered as markdown (Memory tab)
- [x] Last-modified timestamp on each section
- [x] Add/edit memory entry inline
### Configuration
- [ ] Settings panel (default model, workspace, toolsets) (Wave 4)
- [ ] Enable/disable toolsets per session (Wave 4)
### Notifications
- [ ] Cron job completion alerts (Wave 4)
- [ ] Background agent error alerts (Wave 4)
### Advanced / Future
- [ ] Voice input via Whisper (Wave 6)
- [ ] TTS playback of responses (Wave 6)
- [ ] Subagent delegation cards (Wave 6)
- [x] Background task cancel (activity bar Cancel button)
- [ ] Code execution cell (Wave 6)
- [ ] Password authentication (Wave 7)
- [ ] HTTPS / reverse proxy (Wave 7)
- [ ] Mobile responsive layout (Wave 7)
- [ ] Virtual scroll for large lists (Wave 7)
---
## Sprint 7: Wave 2 Core -- Cron/Skill/Memory CRUD + Session Content Search (COMPLETED)
**Theme:** "Wave 2 Core -- Cron/Skill/Memory CRUD + Session Content Search"
### Track A: Bug Fixes
| Item | Description |
|------|-------------|
| Activity bar sizing | Activity bar sometimes overlaps first message on short viewports |
| Model dropdown sync | Model chip in topbar sometimes shows stale model after session switch |
| Cron output truncation | Long cron output in the tasks panel overflows its container |
### Track B: Features
| Feature | What | Value |
|---------|------|-------|
| Session content search | Search message text across all sessions, not just titles. GET /api/sessions/search already does title search; extend to message content with a configurable depth limit | High: the single most-requested nav feature after rename |
| Cron edit + delete | Edit an existing cron job (name, schedule, prompt, delivery) inline in the tasks panel. Delete with confirm. POST /api/crons/update and /api/crons/delete | High: closes the cron CRUD gap (create was Sprint 6) |
| Skill create + edit | A "New skill" form in the Skills panel. Name, category, SKILL.md content in a textarea editor. Save calls POST /api/skills/save (writes to ~/.hermes/skills/). Edit opens existing skill in the same editor | High: biggest remaining CLI gap after cron |
### Track C: Architecture
| Item | What |
|------|------|
| Phase E: app.js module split (start) | Split app.js (1332 lines) into logical modules: sessions.js, chat.js, workspace.js, panels.js, ui.js. Serve via ES module imports in index.html. This is Phase E completion. |
| Health endpoint improvement | Add active_streams, uptime_seconds to /health response (Phase G) |
| Git init | git init <repo>, first commit, push to private GitHub repo |
### Tests
- ~20 new pytest tests (cron update/delete, skill save, session content search)
- TESTING.md: Sections 29-31 (cron edit, skill edit, session search)
- Estimated total after Sprint 7: ~126
---
## Wave 2: Full CRUD and Interaction Parity
**Status:** In progress. Sprint 6 completed cron create and workspace management.
Remaining Wave 2 items targeted for Sprints 7-8.
### Sprint 2.0: Workspace Management (COMPLETE Sprint 5+6)
All workspace features delivered: add/validate/remove/rename workspaces, topbar quick-switch,
sidebar live display, new sessions inherit last workspace. See Sprint 5 completed section.
### Sprint 2.1: Cron Job Management (Partial -- Sprint 7 for remaining)
- [x] View all jobs (Sprint 3)
- [x] Run / pause / resume (Sprint 3)
- [x] Create job from UI (Sprint 6)
- [x] Edit job
- [x] Delete job
- [x] Full cron run history
### Sprint 2.2: Skill Management (Partial -- Sprint 7 for remaining)
- [x] List all skills with categories (Sprint 3)
- [x] View SKILL.md content (Sprint 3)
- [x] Create skill
- [x] Edit skill
- [x] Delete skill
### Sprint 2.3: Memory Write (Sprint 7)
- [x] View notes + profile (Sprint 3)
- [x] Edit notes inline
### Sprint 2.4: Todo Management (Wave 2)
- [x] View current todo list (sidebar Todo panel, parsed from session history)
### Sprint 2.5: Session Content Search (Sprint 7)
- [x] Session title search (Sprint 4)
- [x] Message content search across sessions
### Sprint 2.6: Session Rename (COMPLETE Sprint 4)
Double-click any session title in the left sidebar to edit inline.
Enter saves, Escape cancels. Topbar updates immediately.
---
## Wave 3: Power Features and Developer Experience
### Sprint 3.1: Tool Call Visibility Inline
Show tool calls as collapsible cards in the conversation.
Collapsed: tool name badge + one-line preview. Expanded: full args + result.
### Sprint 3.2: Multi-Model Expansion
Add more models. Group by provider. Model info tooltip on hover.
(Partially done: 10 models in dropdown from Sprint 1.)
### Sprint 3.2b: Resizable Panel Widths (COMPLETE Sprint 6)
Both sidebar and workspace panel are drag-resizable with localStorage persistence.
### Sprint 3.3: Workspace File Actions
- [ ] Rename file (inline, double-click) (Wave 3)
- [ ] Create folder (Wave 3)
- [x] Syntax highlighted code preview (Prism.js)
### Sprint 3.4: Conversation Controls
- [x] Copy message (Sprint 5)
- [x] Edit last user message + regenerate
- [x] Regenerate last assistant response
- [x] Clear conversation (wipe messages, keep session)
---
## Wave 4: Settings, Configuration, Notifications
### Sprint 4.1: Settings Panel
Full settings overlay: default model, default workspace, enabled toolsets, config viewer.
### Sprint 4.2: Notification Panel
Bell icon with unread count. SSE endpoint for cron completions and errors. Toast pop-ups.
### Sprint 4.3: Delivery Target Config
Configure and test-ping delivery targets (Discord, Telegram, Slack, email) for cron jobs.
---
## Wave 5: Honcho Integration and Long-term Memory
### Sprint 5.1: Honcho Memory Panel
User representation panel, cross-session context, Honcho search, memory write.
### Sprint 5.2: Session Continuity Features
"What were we working on?" button, session tags, session archive.
---
## Wave 6: Realtime and Agentic Features
### Sprint 6.1: Background Task Monitor
Live list of running agent threads. Cancel button. Queue visibility.
### Sprint 6.2: Subagent Delegation Cards
When delegate_task fires, show subagent progress inline in chat.
### Sprint 6.3: Code Execution Panel
Jupyter-style inline code cell. Stateful kernel per session.
### Sprint 6.4: Voice Mode
Push-to-talk mic button. Whisper transcription. Optional TTS playback.
---
## Wave 7: Production Hardening and Mobile
### Sprint 7.1: Authentication
HERMES_WEBUI_PASSWORD env var gate. Signed cookie. Login page.
### Sprint 7.2: HTTPS and Reverse Proxy
Nginx + Let's Encrypt. CORS headers for external domain.
### Sprint 7.3: Mobile Responsive Layout
Collapsible sidebar hamburger. Touch-friendly controls. Swipe gestures.
### Sprint 7.4: Performance and Scale
Virtual scroll for session/message lists. Incremental message loading.

407
SPRINTS.md Normal file
View File

@@ -0,0 +1,407 @@
# Hermes WebUI -- Forward Sprint Plan
> Current state: v0.1.0 | 190 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
> terminal, you can do from the browser)
>
> Target B: 1:1 parity with Claude's reproducible features (the full Claude
> browser UI experience, minus things only Anthropic can build)
>
> Sprints are ordered by impact. Each builds on the one before.
> Past sprint history lives in CHANGELOG.md.
---
## Where we are now (v0.1.0)
**CLI parity: ~80% complete.** Core agent loop, all tools visible, workspace
file ops, cron/skills/memory CRUD, session management, streaming, cancel --
all solid. Gaps are configuration, subagent visibility, and runtime controls.
**Claude parity: ~55% complete.** Chat, streaming, file browser,
session management, tool cards, syntax highlighting, model switching -- all
present. Gaps are project organization, artifacts, voice, sharing, mobile.
---
## Sprint 11 -- Streaming Smoothness + Tool Card Incremental Render
**Theme:** Make heavy agentic work feel fast and fluid.
**Why now:** The biggest remaining daily friction point. During a 20-step task,
every tool event triggers a full renderMessages() re-render of the entire
message list. On fast tasks you can see flicker. This is the last thing that
makes the UI feel noticeably worse than watching the CLI.
### Track A: Bugs
- Tool card DOM thrash: renderMessages() rebuilds all cards on each tool event.
Switch to incremental append (append new card to existing group, no full rebuild).
- Scroll position lost on re-render during streaming (messages jump).
### Track B: Features
- **Incremental tool card streaming:** Instead of renderMessages() on each
tool event, maintain a live card group element per turn and append/update
cards in place. The assistant text row below the cards also updates
incrementally (already does via assistantBody.innerHTML).
- **Tool card collapse-all / expand-all:** A small toggle in the topbar or
per-message to collapse all tool cards in a response. Useful when a response
has 10+ tool calls.
- **Smooth scroll:** Pin scroll to bottom during streaming unless user has
manually scrolled up (read-back mode). Resume pinning when user scrolls
back to bottom.
### Track C: Architecture
- `api/routes.py`: extract the 49 if/elif route handlers from server.py's
Handler class into a dedicated routes module. server.py becomes a true
~50-line shell: imports, Handler stub that delegates to routes, main().
Completes the server split started in Sprint 10.
**Tests:** ~12 new. Total: ~196.
**Hermes CLI parity impact:** Low (smoothness, not features)
**Claude parity impact:** Low
---
## Sprint 12 -- Settings Panel + Toolset Control
**Theme:** Configuration you can actually reach from the UI.
**Why now:** Last remaining thing that forces a trip to the CLI or config files
for basic setup. The model dropdown works but defaults aren't persisted
server-side. Toolsets can't be toggled per session.
### Track A: Bugs
- Model dropdown doesn't sync when a session was created with a model not in
the current dropdown list (edge case from model additions).
- Workspace validation on add doesn't check symlinks (shows as invalid when
it's actually a valid symlink to a directory).
### Track B: Features
- **Settings panel:** A gear icon in the topbar opens a slide-in settings panel.
Sections: Default Model (writes HERMES_WEBUI_DEFAULT_MODEL to a settings file),
Default Workspace (writes HERMES_WEBUI_DEFAULT_WORKSPACE), UI preferences
(font size, message density). Persisted server-side in `~/.hermes/webui-mvp/settings.json`.
- **Toolset control per session:** A "Tools" chip in the session topbar opens
a popover listing all available toolsets (terminal, web, file, memory, etc.)
with toggles. Selected toolsets stored on the session and passed to AIAgent.
Matches the `--tools` flag behavior in the CLI.
- **Rename file / Create folder:** Two small file tree ops that close the last
workspace management gap. Inline rename on double-click (same pattern as
session rename). Create folder via + menu next to the existing + file button.
### Track C: Architecture
- Settings schema: `settings.json` with typed fields, validated on load, with
sane defaults. Served via `GET /api/settings`, written via `POST /api/settings`.
**Tests:** ~15 new. Total: ~211.
**Hermes CLI parity impact:** High (toolset control is the last major CLI feature)
**Claude parity impact:** Medium (settings exist in Claude as a panel)
---
## Sprint 13 -- Notification System + Background Visibility
**Theme:** Know what Hermes is doing even when you're not watching.
**Why now:** Cron jobs run silently. Background errors surface nowhere. You have
no way to know a long-running task finished (or failed) while you were on another
tab. This is a meaningful daily driver gap for anyone using cron heavily.
### Track A: Bugs
- Cron "Run now" button shows no feedback if the job errors immediately.
- Sessions with very long message histories (100+ messages) cause noticeable
render lag on load (no virtual scroll yet).
### Track B: Features
- **Cron completion alerts:** When a cron job finishes (success or error), push
a toast notification to the UI. Use a polling endpoint (`GET /api/crons/status`)
that the UI checks every 30s while the window is focused. Badge count on the
Tasks tab icon when there are unread completions.
- **Background agent error alerts:** When a streaming session errors out (network
drop, model error, tool failure), and the user is not currently viewing that
session, show a persistent banner: "Session X encountered an error." Clicking
it navigates to that session.
- **Virtual scroll for session list:** Session list currently renders all sessions
in the DOM. Above ~100 sessions, the sidebar gets slow. Implement simple virtual
scroll: render only ~20 visible rows, reuse DOM nodes on scroll.
### Track C: Architecture
- SSE reconnect: if the SSE connection drops mid-stream, auto-reconnect once
(with the same stream_id). Currently a network blip ends the response silently.
**Tests:** ~14 new. Total: ~225.
**Hermes CLI parity impact:** High (cron visibility, error surfacing)
**Claude parity impact:** Medium (Claude has notification panel)
---
## Sprint 14 -- Project Organization + Session Management
**Theme:** Organize work the way you think, not just chronologically.
**Why now:** After 100+ sessions the sidebar is a flat chronological list.
Finding sessions from 2 weeks ago, or keeping a "MyProject" workspace separate
from personal work, requires the search box. This is the biggest remaining
daily organizational gap vs. Claude's project folders.
### Track A: Bugs
- Session search content scan (depth=5) is slow on large session histories.
Add server-side caching of search index.
- Date group headers ("Today / Yesterday / Earlier") use updated_at which can
be misleading for sessions touched by automated title-setting. Use created_at
for initial grouping, updated_at for sort order.
### Track B: Features
- **Session folders / projects:** A "Projects" section above the session list.
Each project is a named group. Sessions can be dragged into projects or
assigned via right-click. Stored in `projects.json`. Projects collapse/expand.
This is the single biggest Claude parity feature missing.
- **Pin sessions:** Star icon on any session to pin it to the top of the list
above date groups. Persisted on the session JSON as `pinned: true`.
- **Session tags:** Inline `#tag` syntax in session titles gets extracted and
shown as colored chips. Clicking a tag filters the list. No backend change
needed -- parsed client-side from title text.
- **Archive sessions:** A "More" overflow menu on each session (right-click or
long-press) with: Archive (hides from main list, accessible via filter),
Duplicate (new session with same workspace/model), Export JSON.
- **Import session from JSON:** Drag a `.json` export file into the sidebar to
restore it as a new session. Mirrors the existing JSON export.
### Track C: Architecture
- Session index v2: extend `_index.json` to include `tags`, `pinned`, and
`project_id` fields. Rebuild on session save. Enables fast client-side
filtering without disk reads.
**Tests:** ~16 new. Total: ~241.
**Hermes CLI parity impact:** Low (CLI has no session organization)
**Claude parity impact:** Very High (projects are a core Claude concept)
---
## Sprint 15 -- Artifacts + Code Execution
**Theme:** See outputs, not just text.
**Why now:** Claude's most distinctive feature is the artifact panel --
code runs inline, HTML renders in a sandboxed iframe, SVGs show as images.
This is the largest single capability gap between what we have and what Claude
feels like. It also directly enables the Hermes "code execution cell" feature
(Jupyter-style in-browser execution).
### Track A: Bugs
- Prism.js autoloader makes one CDN request per language encountered. On a
code-heavy session this causes noticeable latency. Bundle the top 10 languages
(Python, JS, bash, JSON, SQL, YAML, TypeScript, CSS, HTML, Rust) locally.
- Code blocks in long responses sometimes re-highlight on every renderMessages()
call. Debounce highlightCode() with requestAnimationFrame.
### Track B: Features
- **Artifact panel:** When Hermes produces a code block tagged as `html`, `svg`,
or `react`, a "Preview" button appears on that code block. Clicking it opens
a sandboxed `<iframe>` in the right panel showing the rendered output. The
preview updates live if Hermes edits the artifact in a follow-up.
- **Code execution cell:** A "Run" button on Python code blocks. Sends the code
to a new server endpoint (`POST /api/execute`) which runs it in a subprocess
with a 30-second timeout and streams stdout/stderr back as SSE. Output appears
below the code block inline. This is the Jupyter cell experience without
needing a kernel.
- **Mermaid diagram rendering:** Mermaid.js CDN (deferred). Code blocks tagged
as `mermaid` render as flow/sequence/gantt diagrams inline.
### Track C: Architecture
- Sandbox safety: `/api/execute` runs in a restricted subprocess (no network,
limited filesystem via a temp directory). Returns exit code, stdout, stderr,
and execution time.
- Artifact state: artifacts are tracked in `S.artifacts = {}` (code block hash
-> rendered content). Persisted in session JSON as `artifacts` array.
**Tests:** ~18 new. Total: ~259.
**Hermes CLI parity impact:** High (code execution closes the Jupyter gap)
**Claude parity impact:** Very High (artifacts are Claude's signature feature)
---
## Sprint 16 -- Voice + Multimodal Input
**Theme:** Input beyond the keyboard.
**Why now:** Voice is a meaningful quality-of-life feature for longer sessions
and is achievable with Whisper. Image input closes the last modality gap with
Claude (Claude accepts image paste natively -- we do too, but only as
file uploads, not clipboard screenshots into the conversation directly).
### Track A: Bugs
- Image paste currently requires a click-to-attach flow. Direct paste into the
message textarea should embed the image inline (as a preview chip) and queue
it for upload on Send. (Partially works -- clean up edge cases.)
- Large image uploads (>5MB) time out the upload step silently.
### Track B: Features
- **Voice input (Whisper):** A microphone icon in the composer. Hold to record,
release to transcribe via `POST /api/transcribe` (calls local Whisper or
OpenAI Whisper API). Transcribed text appears in the message input, editable
before send. Supports the full "voice -> text -> Hermes response" loop.
- **TTS playback:** A speaker icon on assistant messages. Calls a TTS endpoint
(ElevenLabs or OpenAI TTS) and plays the audio. Toggle per-message. Optional
auto-play mode in settings.
- **Vision input improvements:** Paste a screenshot directly from clipboard into
the conversation (not just the tray). Shows as an inline preview chip with
the image thumbnail. On Send, uploads and includes in the message.
### Track C: Architecture
- Audio pipeline: `POST /api/transcribe` streams audio bytes, returns transcript.
`GET /api/tts?text=...` returns audio/mpeg. Both use lazy import of Whisper
and TTS libraries to keep cold start fast.
**Tests:** ~12 new. Total: ~271.
**Hermes CLI parity impact:** Medium (voice not in CLI, but adds capability)
**Claude parity impact:** High (Claude has native voice mode)
---
## Sprint 17 -- Subagent Visibility + Agentic Transparency
**Theme:** Watch Hermes think, not just respond.
**Why now:** When Hermes delegates to subagents (delegate_task, spawns parallel
workstreams), the UI shows nothing. On long multi-agent tasks you have no idea
what's happening. This is the last major "CLI feels better" gap for power users.
### Track A: Bugs
- Tool cards for delegate_task show no information about what the subagent was
asked to do or what it returned.
- The activity bar text truncates at 55 chars -- tool previews for long terminal
commands show nothing useful.
### Track B: Features
- **Subagent delegation cards:** When `delegate_task` fires, show an expandable
card with the subagent's goal, status (pending/running/done), and result
summary. Multiple subagents from one call appear as a card group. Uses the
existing tool card infrastructure.
- **Background task monitor:** A "Tasks" indicator in the topbar (separate from
the cron Tasks panel). Shows count of active agent threads. Click opens a
popover listing all in-flight streams with session names and elapsed times.
Cancel any individual thread. This is the full job queue visibility the CLI
implicitly has via `ps aux`.
- **Thinking/reasoning display:** When the model emits reasoning tokens (o3,
Claude extended thinking), show them in a collapsible "Reasoning" card above
the response. Collapsed by default. This matches Claude's reasoning display.
### Track C: Architecture
- Task registry: extend STREAMS to include session name, start time, and task
description. New `GET /api/tasks/active` endpoint returns all running streams
with metadata.
**Tests:** ~14 new. Total: ~285.
**Hermes CLI parity impact:** Very High (subagent and task visibility is the
last major CLI gap)
**Claude parity impact:** High (Claude shows reasoning, tool use visibly)
---
## Sprint 18 -- Auth, HTTPS, and Production Hardening
**Theme:** Make this safe to leave running.
**Why now:** Everything else is done. This is the sprint you run when you want
to expose the UI beyond localhost -- to a team, a mobile device, or a public
address.
### Track A: Bugs
- Server has no request size limit on non-upload endpoints (potential DoS).
- Session JSON files have no size cap (a runaway agent could write GBs).
### Track B: Features
- **Password authentication:** A login page with a configurable password
(HERMES_WEBUI_PASSWORD env var). Signed cookie session (24h expiry).
Single-user model -- no accounts, no registration.
- **HTTPS / reverse proxy guide:** A one-page `DEPLOY.md` with instructions
for running behind nginx + Let's Encrypt on a VPS. Configuration snippets
for systemd service, nginx config, certbot.
- **Mobile responsive layout:** Collapsible sidebar (hamburger). Touch-friendly
session list (swipe to delete, tap to navigate). Composer expands on focus.
Right panel hidden by default on mobile, accessible via a Files tab.
- **Rate limiting:** Simple per-IP token bucket on the chat/start endpoint
(configurable, default 10 req/min) to prevent accidental hammering.
### Track C: Architecture
- Helmet headers: X-Content-Type-Options, X-Frame-Options, HSTS (when served
over HTTPS). Simple middleware in the Handler.
**Tests:** ~12 new. Total: ~297.
**Hermes CLI parity impact:** Low (CLI has no auth/HTTPS concerns)
**Claude parity impact:** Very High (Claude is authenticated, HTTPS only)
---
## Feature Parity Summary
### After Sprint 17 (Hermes CLI parity: complete)
| CLI Feature | Status |
|-------------|--------|
| Chat / agent loop | Done (v0.3) |
| Streaming responses | Done (v0.5) |
| Tool call visibility | Done (v0.0.7) |
| File ops (read/write/search/patch) | Done (v0.6) |
| Terminal commands | Done via workspace |
| Cron job management | Done (v0.9) |
| Skills management | Done (v0.9) |
| Memory read/write | Done (v0.9) |
| Session history | Done (v0.3) |
| Workspace switching | Done (v0.7) |
| Model selection | Done (v0.3) |
| Toolset control | Sprint 12 |
| Settings persistence | Sprint 12 |
| Subagent visibility | Sprint 17 |
| Background task monitor | Sprint 17 |
| Code execution (Jupyter) | Sprint 15 |
| Cron completion alerts | Sprint 13 |
| Virtual scroll (perf) | Sprint 13 |
### After Sprint 18 (Claude parity: ~90% complete)
| Claude Feature | Status |
|----------------|--------|
| Dark theme, 3-panel layout | Done (v0.1) |
| Streaming chat | Done (v0.5) |
| Model switching | Done (v0.3) |
| File attachments | Done (v0.6) |
| Syntax highlighting | Done (v0.0.6) |
| Tool use visibility | Done (v0.0.7) |
| Edit/regenerate messages | Done (v0.0.6) |
| Session management | Done (v0.6) |
| Artifacts (HTML/SVG preview) | Sprint 15 |
| Code execution inline | Sprint 15 |
| Mermaid diagrams | Sprint 15 |
| Projects / folders | Sprint 14 |
| Pinned/starred sessions | Sprint 14 |
| Reasoning display | Sprint 17 |
| Voice input | Sprint 16 |
| TTS playback | Sprint 16 |
| Notifications | Sprint 13 |
| Settings panel | Sprint 12 |
| Auth / login | Sprint 18 |
| HTTPS | Sprint 18 |
| Mobile layout | Sprint 18 |
| Sharing / public URLs | Not planned (requires server infra) |
| Claude-specific features | Not replicable (Projects AI, artifacts sync) |
### What is intentionally not planned
- **Sharing / public conversation URLs:** Requires a hosted backend with access
control and CDN. Out of scope for a personal VPS deployment.
- **Claude-specific model features:** Claude-native Projects memory, extended
artifacts sync, Anthropic's proprietary reasoning UI. These are Anthropic
infrastructure, not reproducible.
- **Real-time collaboration:** Multiple users in the same session simultaneously.
Single-user assumption throughout.
- **Plugin marketplace:** Hermes skills cover this use case already.
---
*Last updated: March 31, 2026*
*Current version: v0.1.0 | 190 tests*
*Next sprint: Sprint 11 (streaming smoothness + api/routes.py split)*

1600
TESTING.md Normal file

File diff suppressed because it is too large Load Diff

1
api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Hermes WebUI -- API modules."""

273
api/config.py Normal file
View File

@@ -0,0 +1,273 @@
"""
Hermes WebUI -- Shared configuration, constants, and global state.
Imported by all other api/* modules and by server.py.
Discovery order for all paths:
1. Explicit environment variable
2. Filesystem heuristics (sibling checkout, parent dir, common install locations)
3. Hardened defaults relative to $HOME
4. Fail loudly with a human-readable fix-it message if required modules are missing
"""
import collections
import json
import os
import sys
import threading
import time
import traceback
import uuid
from pathlib import Path
from urllib.parse import parse_qs, urlparse
# ── Basic layout ──────────────────────────────────────────────────────────────
HOME = Path.home()
# REPO_ROOT is the directory that contains this file's parent (api/ -> repo root)
REPO_ROOT = Path(__file__).parent.parent.resolve()
# ── Network config (env-overridable) ─────────────────────────────────────────
HOST = os.getenv('HERMES_WEBUI_HOST', '127.0.0.1')
PORT = int(os.getenv('HERMES_WEBUI_PORT', '8787'))
# ── State directory (env-overridable, never inside repo) ──────────────────────
STATE_DIR = Path(os.getenv(
'HERMES_WEBUI_STATE_DIR',
str(HOME / '.hermes' / 'webui-mvp')
)).expanduser().resolve()
SESSION_DIR = STATE_DIR / 'sessions'
WORKSPACES_FILE = STATE_DIR / 'workspaces.json'
SESSION_INDEX_FILE = SESSION_DIR / '_index.json'
LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt'
# ── Hermes agent directory discovery ─────────────────────────────────────────
def _discover_agent_dir() -> Path:
"""
Locate the hermes-agent checkout using a multi-strategy search.
Priority:
1. HERMES_WEBUI_AGENT_DIR env var -- explicit override always wins
2. HERMES_HOME / hermes-agent -- e.g. ~/.hermes/hermes-agent
3. Sibling of this repo -- ../hermes-agent
4. Parent of this repo -- ../../hermes-agent (nested layout)
5. Common install paths -- ~/.hermes/hermes-agent (again as fallback)
6. HOME / hermes-agent -- ~/hermes-agent (simple flat layout)
"""
candidates = []
# 1. Explicit env var
if os.getenv('HERMES_WEBUI_AGENT_DIR'):
candidates.append(Path(os.getenv('HERMES_WEBUI_AGENT_DIR')).expanduser().resolve())
# 2. HERMES_HOME / hermes-agent
hermes_home = os.getenv('HERMES_HOME', str(HOME / '.hermes'))
candidates.append(Path(hermes_home).expanduser() / 'hermes-agent')
# 3. Sibling: <repo-root>/../hermes-agent
candidates.append(REPO_ROOT.parent / 'hermes-agent')
# 4. Parent is the agent repo itself (repo cloned inside hermes-agent/)
if (REPO_ROOT.parent / 'run_agent.py').exists():
candidates.append(REPO_ROOT.parent)
# 5. ~/.hermes/hermes-agent (explicit common path)
candidates.append(HOME / '.hermes' / 'hermes-agent')
# 6. ~/hermes-agent
candidates.append(HOME / 'hermes-agent')
for path in candidates:
if path.exists() and (path / 'run_agent.py').exists():
return path.resolve()
return None
def _discover_python(agent_dir: Path) -> str:
"""
Locate a Python executable that has the Hermes agent dependencies installed.
Priority:
1. HERMES_WEBUI_PYTHON env var
2. Agent venv at <agent_dir>/venv/bin/python
3. Local .venv inside this repo
4. System python3
"""
if os.getenv('HERMES_WEBUI_PYTHON'):
return os.getenv('HERMES_WEBUI_PYTHON')
if agent_dir:
venv_py = agent_dir / 'venv' / 'bin' / 'python'
if venv_py.exists():
return str(venv_py)
# Windows layout
venv_py_win = agent_dir / 'venv' / 'Scripts' / 'python.exe'
if venv_py_win.exists():
return str(venv_py_win)
# Local .venv inside this repo
local_venv = REPO_ROOT / '.venv' / 'bin' / 'python'
if local_venv.exists():
return str(local_venv)
# Fall back to system python3
import shutil
for name in ('python3', 'python'):
found = shutil.which(name)
if found:
return found
return 'python3'
# Run discovery
_AGENT_DIR = _discover_agent_dir()
PYTHON_EXE = _discover_python(_AGENT_DIR)
# ── Inject agent dir into sys.path so Hermes modules are importable ───────────
if _AGENT_DIR is not None:
if str(_AGENT_DIR) not in sys.path:
sys.path.insert(0, str(_AGENT_DIR))
_HERMES_FOUND = True
else:
_HERMES_FOUND = False
# ── Config file (optional YAML) ──────────────────────────────────────────────
CONFIG_PATH = Path(os.getenv(
'HERMES_CONFIG_PATH',
str(HOME / '.hermes' / 'config.yaml')
)).expanduser()
try:
import yaml as _yaml
cfg = _yaml.safe_load(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
except Exception:
cfg = {}
# ── Default workspace discovery ───────────────────────────────────────────────
def _discover_default_workspace() -> Path:
"""
Resolve the default workspace in order:
1. HERMES_WEBUI_DEFAULT_WORKSPACE env var
2. ~/workspace (common Hermes convention)
3. STATE_DIR / workspace (isolated fallback)
"""
if os.getenv('HERMES_WEBUI_DEFAULT_WORKSPACE'):
return Path(os.getenv('HERMES_WEBUI_DEFAULT_WORKSPACE')).expanduser().resolve()
common = HOME / 'workspace'
if common.exists():
return common.resolve()
return (STATE_DIR / 'workspace').resolve()
DEFAULT_WORKSPACE = _discover_default_workspace()
DEFAULT_MODEL = os.getenv('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')
# ── Startup diagnostics ───────────────────────────────────────────────────────
def print_startup_config():
"""Print detected configuration at startup so the user can verify what was found."""
ok = '\033[32m[ok]\033[0m'
warn = '\033[33m[!!]\033[0m'
err = '\033[31m[XX]\033[0m'
lines = [
'',
' Hermes Web UI -- startup config',
' --------------------------------',
f' repo root : {REPO_ROOT}',
f' agent dir : {_AGENT_DIR if _AGENT_DIR else "NOT FOUND"} {ok if _AGENT_DIR else err}',
f' python : {PYTHON_EXE}',
f' state dir : {STATE_DIR}',
f' workspace : {DEFAULT_WORKSPACE}',
f' host:port : {HOST}:{PORT}',
f' config file : {CONFIG_PATH} {"(found)" if CONFIG_PATH.exists() else "(not found, using defaults)"}',
'',
]
print('\n'.join(lines), flush=True)
if not _HERMES_FOUND:
print(
f'{err} Could not find the Hermes agent directory.\n'
' The server will start but agent features will not work.\n'
'\n'
' To fix, set one of:\n'
' export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent\n'
' export HERMES_HOME=/path/to/.hermes\n'
'\n'
' Or clone hermes-agent as a sibling of this repo:\n'
' git clone <hermes-agent-repo> ../hermes-agent\n',
flush=True
)
def verify_hermes_imports():
"""
Attempt to import the key Hermes modules.
Returns (ok: bool, missing: list[str]).
"""
required = ['run_agent']
missing = []
for mod in required:
try:
__import__(mod)
except ImportError:
missing.append(mod)
return (len(missing) == 0), missing
# ── Limits ───────────────────────────────────────────────────────────────────
MAX_FILE_BYTES = 200_000
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
# ── File type maps ───────────────────────────────────────────────────────────
IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp'}
MD_EXTS = {'.md', '.markdown', '.mdown'}
CODE_EXTS = {'.py', '.js', '.ts', '.jsx', '.tsx', '.css', '.html', '.json',
'.yaml', '.yml', '.toml', '.sh', '.bash', '.txt', '.log', '.env',
'.csv', '.xml', '.sql', '.rs', '.go', '.java', '.c', '.cpp', '.h'}
MIME_MAP = {
'.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg',
'.gif':'image/gif', '.svg':'image/svg+xml', '.webp':'image/webp',
'.ico':'image/x-icon', '.bmp':'image/bmp',
'.pdf':'application/pdf', '.json':'application/json',
}
# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
CLI_TOOLSETS = cfg.get('platform_toolsets', {}).get('cli', [
'browser', 'clarify', 'code_execution', 'cronjob', 'delegation', 'file',
'image_gen', 'memory', 'session_search', 'skills', 'terminal', 'todo',
'web', 'webhook',
])
# ── Static file path ─────────────────────────────────────────────────────────
_INDEX_HTML_PATH = REPO_ROOT / 'static' / 'index.html'
# ── Thread synchronisation ───────────────────────────────────────────────────
LOCK = threading.Lock()
SESSIONS_MAX = 100
CHAT_LOCK = threading.Lock()
STREAMS: dict = {}
STREAMS_LOCK = threading.Lock()
CANCEL_FLAGS: dict = {}
SERVER_START_TIME = time.time()
# ── Thread-local env context ─────────────────────────────────────────────────
_thread_ctx = threading.local()
def _set_thread_env(**kwargs):
_thread_ctx.env = kwargs
def _clear_thread_env():
_thread_ctx.env = {}
# ── Per-session agent locks ───────────────────────────────────────────────────
SESSION_AGENT_LOCKS: dict = {}
SESSION_AGENT_LOCKS_LOCK = threading.Lock()
def _get_session_agent_lock(session_id: str) -> threading.Lock:
with SESSION_AGENT_LOCKS_LOCK:
if session_id not in SESSION_AGENT_LOCKS:
SESSION_AGENT_LOCKS[session_id] = threading.Lock()
return SESSION_AGENT_LOCKS[session_id]
# ── SESSIONS in-memory cache (LRU OrderedDict) ───────────────────────────────
SESSIONS: collections.OrderedDict = collections.OrderedDict()

57
api/helpers.py Normal file
View File

@@ -0,0 +1,57 @@
"""
Hermes WebUI -- HTTP helper functions.
"""
import json as _json
from pathlib import Path
from api.config import IMAGE_EXTS, MD_EXTS
def require(body: dict, *fields):
"""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):
"""Return a clean JSON error response."""
return j(handler, {'error': msg}, status=status)
def safe_resolve(root: Path, requested: str) -> Path:
"""Resolve a relative path inside root, raising ValueError on traversal."""
resolved = (root / requested).resolve()
resolved.relative_to(root.resolve()) # raises ValueError if outside root
return resolved
def j(handler, payload, status=200):
"""Send a JSON response."""
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
handler.send_response(status)
handler.send_header('Content-Type', 'application/json; charset=utf-8')
handler.send_header('Content-Length', str(len(body)))
handler.send_header('Cache-Control', 'no-store')
handler.end_headers()
handler.wfile.write(body)
def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
"""Send a plain text or HTML response."""
body = payload if isinstance(payload, bytes) else str(payload).encode('utf-8')
handler.send_response(status)
handler.send_header('Content-Type', content_type)
handler.send_header('Content-Length', str(len(body)))
handler.send_header('Cache-Control', 'no-store')
handler.end_headers()
handler.wfile.write(body)
def read_body(handler):
"""Read and JSON-parse a POST request body."""
length = int(handler.headers.get('Content-Length', 0))
raw = handler.rfile.read(length) if length else b'{}'
try:
return _json.loads(raw)
except Exception:
return {}

114
api/models.py Normal file
View File

@@ -0,0 +1,114 @@
"""
Hermes WebUI -- Session model and in-memory session store.
"""
import collections
import json
import time
import uuid
from pathlib import Path
from api.config import (
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL
)
from api.workspace import get_last_workspace
def _write_session_index():
"""Rebuild the session index file for O(1) future reads."""
entries = []
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'): continue
try:
s = Session.load(p.stem)
if s: entries.append(s.compact())
except Exception:
pass
with LOCK:
for s in SESSIONS.values():
if not any(e['session_id'] == s.session_id for e in entries):
entries.append(s.compact())
entries.sort(key=lambda s: s['updated_at'], reverse=True)
SESSION_INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8')
class Session:
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, **kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time()
@property
def path(self): return SESSION_DIR / f'{self.session_id}.json'
def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index()
@classmethod
def load(cls, sid):
p = SESSION_DIR / f'{sid}.json'
if not p.exists(): return None
return cls(**json.loads(p.read_text(encoding='utf-8')))
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at}
def get_session(sid):
with LOCK:
if sid in SESSIONS:
SESSIONS.move_to_end(sid) # LRU: mark as recently used
return SESSIONS[sid]
s = Session.load(sid)
if s:
with LOCK:
SESSIONS[sid] = s
SESSIONS.move_to_end(sid)
while len(SESSIONS) > SESSIONS_MAX:
SESSIONS.popitem(last=False) # evict least recently used
return s
raise KeyError(sid)
def new_session(workspace=None, model=None):
s = Session(workspace=workspace or get_last_workspace(), model=model or DEFAULT_MODEL)
with LOCK:
SESSIONS[s.session_id] = s
SESSIONS.move_to_end(s.session_id)
while len(SESSIONS) > SESSIONS_MAX:
SESSIONS.popitem(last=False)
s.save()
return s
def all_sessions():
# Phase C: try index first for O(1) read; fall back to full scan
if SESSION_INDEX_FILE.exists():
try:
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
# Overlay any in-memory sessions that may be newer than the index
index_map = {s['session_id']: s for s in index}
with LOCK:
for s in SESSIONS.values():
index_map[s.session_id] = s.compact()
result = sorted(index_map.values(), key=lambda s: s['updated_at'], reverse=True)
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)]
return result
except Exception:
pass # fall through to full scan
# Full scan fallback
out = []
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'): continue
try:
s = Session.load(p.stem)
if s: out.append(s)
except Exception:
pass
for s in SESSIONS.values():
if all(s.session_id != x.session_id for x in out): out.append(s)
out.sort(key=lambda s: s.updated_at, reverse=True)
return [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
def title_from(messages, fallback='Untitled'):
"""Derive a session title from the first user message."""
for m in messages:
if m.get('role') == 'user':
c = m.get('content', '')
if isinstance(c, list):
c = ' '.join(p.get('text', '') for p in c if isinstance(p, dict) and p.get('type') == 'text')
text = str(c).strip()
if text:
return text[:64]
return fallback

218
api/streaming.py Normal file
View File

@@ -0,0 +1,218 @@
"""
Hermes WebUI -- SSE streaming engine and agent thread runner.
Includes Sprint 10 cancel support via CANCEL_FLAGS.
"""
import json
import os
import queue
import threading
import time
import traceback
from pathlib import Path
from api.config import (
STREAMS, STREAMS_LOCK, CANCEL_FLAGS, CLI_TOOLSETS,
_get_session_agent_lock, _set_thread_env, _clear_thread_env,
)
# Lazy import to avoid circular deps -- hermes-agent is on sys.path via api/config.py
try:
from run_agent import AIAgent
except ImportError:
AIAgent = None
from api.models import get_session, title_from
from api.workspace import set_last_workspace
def _sse(handler, event, data):
"""Write one SSE event to the response stream."""
payload = f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
handler.wfile.write(payload.encode('utf-8'))
handler.wfile.flush()
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None):
"""Run agent in background thread, writing SSE events to STREAMS[stream_id]."""
q = STREAMS.get(stream_id)
if q is None:
return
# Sprint 10: create a cancel event for this stream
cancel_event = threading.Event()
with STREAMS_LOCK:
CANCEL_FLAGS[stream_id] = cancel_event
def put(event, data):
# If cancelled, drop all further events except the cancel event itself
if cancel_event.is_set() and event not in ('cancel', 'error'):
return
try:
q.put_nowait((event, data))
except Exception:
pass
try:
s = get_session(session_id)
s.workspace = str(Path(workspace).expanduser().resolve())
s.model = model
_agent_lock = _get_session_agent_lock(session_id)
# TD1: set thread-local env context so concurrent sessions don't clobber globals
# Check for pre-flight cancel (user cancelled before agent even started)
if cancel_event.is_set():
put('cancel', {'message': 'Cancelled before start'})
return
_set_thread_env(
TERMINAL_CWD=str(s.workspace),
HERMES_EXEC_ASK='1',
HERMES_SESSION_KEY=session_id,
)
# Still set process-level env as fallback for tools that bypass thread-local
with _agent_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')
os.environ['TERMINAL_CWD'] = str(s.workspace)
os.environ['HERMES_EXEC_ASK'] = '1'
os.environ['HERMES_SESSION_KEY'] = session_id
try:
def on_token(text):
if text is None:
return # end-of-stream sentinel
put('token', {'text': text})
def on_tool(name, preview, args):
args_snap = {}
if isinstance(args, dict):
for k, v in list(args.items())[:4]:
s2 = str(v); args_snap[k] = s2[:120]+('...' if len(s2)>120 else '')
put('tool', {'name': name, 'preview': preview, 'args': args_snap})
# also check for pending approval and surface it immediately
from tools.approval import has_pending as _has_pending, _pending, _lock
if _has_pending(session_id):
with _lock:
p = dict(_pending.get(session_id, {}))
if p:
put('approval', p)
if AIAgent is None:
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
agent = AIAgent(
model=model,
platform='cli',
quiet_mode=True,
enabled_toolsets=CLI_TOOLSETS,
session_id=session_id,
stream_delta_callback=on_token,
tool_progress_callback=on_tool,
)
# Prepend workspace context so the agent always knows which directory
# to use for file operations, regardless of session age or AGENTS.md defaults.
workspace_ctx = f"[Workspace: {s.workspace}]\n"
workspace_system_msg = (
f"Active workspace at session start: {s.workspace}\n"
"Every user message is prefixed with [Workspace: /absolute/path] indicating the "
"workspace the user has selected in the web UI at the time they sent that message. "
"This tag is the single authoritative source of the active workspace and updates "
"with every message. It overrides any prior workspace mentioned in this system "
"prompt, memory, or conversation history. Always use the value from the most recent "
"[Workspace: ...] tag as your default working directory for ALL file operations: "
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
result = agent.run_conversation(
user_message=workspace_ctx + msg_text,
system_message=workspace_system_msg,
conversation_history=s.messages,
task_id=session_id,
persist_user_message=msg_text,
)
s.messages = result.get('messages') or s.messages
s.title = title_from(s.messages, s.title)
# Extract tool call metadata grouped by assistant message index
# Each tool call gets assistant_msg_idx so the client can render
# cards inline with the assistant bubble that triggered them.
tool_calls = []
pending_names = {} # tool_call_id -> name
pending_asst_idx = {} # tool_call_id -> index in s.messages
for msg_idx, m in enumerate(s.messages):
if m.get('role') == 'assistant':
c = m.get('content', '')
if isinstance(c, list):
for p in c:
if isinstance(p, dict) and p.get('type') == 'tool_use':
tid = p.get('id', '')
pending_names[tid] = p.get('name', 'tool')
pending_asst_idx[tid] = msg_idx
elif m.get('role') == 'tool':
tid = m.get('tool_call_id') or m.get('tool_use_id', '')
name = pending_names.get(tid, 'tool')
asst_idx = pending_asst_idx.get(tid, -1)
raw = str(m.get('content', ''))
try:
import json as _j2
rd = _j2.loads(raw)
snippet = str(rd.get('output') or rd.get('result') or rd.get('error') or raw)[:200]
except Exception:
snippet = raw[:200]
tool_calls.append({
'name': name, 'snippet': snippet, 'tid': tid,
'assistant_msg_idx': asst_idx,
})
s.tool_calls = tool_calls
# Tag the matching user message with attachment filenames for display on reload
# Only tag a user message whose content relates to this turn's text
# (msg_text is the full message including the [Attached files: ...] suffix)
if attachments:
for m in reversed(s.messages):
if m.get('role') == 'user':
content = str(m.get('content', ''))
# Match if content is part of the sent message or vice-versa
base_text = msg_text.split('\n\n[Attached files:')[0].strip()
if base_text[:60] in content or content[:60] in msg_text:
m['attachments'] = attachments
break
s.save()
put('done', {'session': s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}})
finally:
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
else: os.environ['TERMINAL_CWD'] = old_cwd
if old_exec_ask is None: os.environ.pop('HERMES_EXEC_ASK', None)
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
except Exception as e:
put('error', {'message': str(e), 'trace': traceback.format_exc()})
finally:
_clear_thread_env() # TD1: always clear thread-local context
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
CANCEL_FLAGS.pop(stream_id, None)
# ============================================================
# SECTION: HTTP Request Handler
# do_GET: read-only API endpoints + SSE stream + static HTML
# do_POST: mutating endpoints (session CRUD, chat, upload, approval)
# Routing is a flat if/elif chain. See ARCHITECTURE.md section 4.1.
# ============================================================
def cancel_stream(stream_id: str) -> bool:
"""Signal an in-flight stream to cancel. Returns True if the stream existed."""
with STREAMS_LOCK:
if stream_id not in STREAMS:
return False
flag = CANCEL_FLAGS.get(stream_id)
if flag:
flag.set()
# Put a cancel sentinel into the queue so the SSE handler wakes up
q = STREAMS.get(stream_id)
if q:
try:
q.put_nowait(('cancel', {'message': 'Cancelled by user'}))
except Exception:
pass
return True

77
api/upload.py Normal file
View File

@@ -0,0 +1,77 @@
"""
Hermes WebUI -- File upload: multipart parser and upload handler.
"""
import re as _re
import email.parser
from pathlib import Path
from api.config import MAX_UPLOAD_BYTES
from api.helpers import j, bad
from api.models import get_session
from api.workspace import safe_resolve_ws
def parse_multipart(rfile, content_type, content_length):
import re as _re, email.parser as _ep
m = _re.search(r'boundary=([^;\s]+)', content_type)
if not m:
raise ValueError('No boundary in Content-Type')
boundary = m.group(1).strip('"').encode()
raw = rfile.read(content_length)
fields = {}
files = {}
delimiter = b'--' + boundary
end_marker = b'--' + boundary + b'--'
parts = raw.split(delimiter)
for part in parts[1:]:
stripped = part.lstrip(b'\r\n')
if stripped.startswith(b'--'):
break
sep = b'\r\n\r\n' if b'\r\n\r\n' in part else b'\n\n'
if sep not in part:
continue
header_raw, body = part.split(sep, 1)
if body.endswith(b'\r\n'):
body = body[:-2]
elif body.endswith(b'\n'):
body = body[:-1]
header_text = header_raw.lstrip(b'\r\n').decode('utf-8', errors='replace')
msg = _ep.HeaderParser().parsestr(header_text)
disp = msg.get('Content-Disposition', '')
name_m = _re.search(r'name="([^"]*)"', disp)
file_m = _re.search(r'filename="([^"]*)"', disp)
if not name_m:
continue
name = name_m.group(1)
if file_m:
files[name] = (file_m.group(1), body)
else:
fields[name] = body.decode('utf-8', errors='replace')
return fields, files
def handle_upload(handler):
import re as _re, traceback as _tb
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
if content_length > MAX_UPLOAD_BYTES:
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
fields, files = parse_multipart(handler.rfile, content_type, content_length)
session_id = fields.get('session_id', '')
if 'file' not in files:
return j(handler, {'error': 'No file field in request'}, status=400)
filename, file_bytes = files['file']
if not filename:
return j(handler, {'error': 'No filename in upload'}, status=400)
try:
s = get_session(session_id)
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
dest = 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:
return j(handler, {'error': str(e), 'trace': _tb.format_exc()}, status=500)

77
api/workspace.py Normal file
View File

@@ -0,0 +1,77 @@
"""
Hermes WebUI -- Workspace and file system helpers.
"""
import json
import os
from pathlib import Path
from api.config import (
WORKSPACES_FILE, LAST_WORKSPACE_FILE, DEFAULT_WORKSPACE,
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
)
def load_workspaces() -> list:
if WORKSPACES_FILE.exists():
try:
return json.loads(WORKSPACES_FILE.read_text(encoding='utf-8'))
except Exception:
pass
return [{'path': str(DEFAULT_WORKSPACE), 'name': 'default'}]
def save_workspaces(workspaces: list):
WORKSPACES_FILE.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
def get_last_workspace() -> str:
if LAST_WORKSPACE_FILE.exists():
try:
p = LAST_WORKSPACE_FILE.read_text(encoding='utf-8').strip()
if p and Path(p).is_dir():
return p
except Exception:
pass
return str(DEFAULT_WORKSPACE)
def set_last_workspace(path: str):
try:
LAST_WORKSPACE_FILE.write_text(str(path), encoding='utf-8')
except Exception:
pass
def safe_resolve_ws(root: Path, requested: str) -> Path:
"""Resolve a relative path inside a workspace root, raising ValueError on traversal."""
resolved = (root / requested).resolve()
resolved.relative_to(root.resolve())
return resolved
def list_dir(workspace: Path, rel='.'):
target = safe_resolve_ws(workspace, rel)
if not target.is_dir():
raise FileNotFoundError(f"Not a directory: {rel}")
entries = []
for item in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
entries.append({
'name': item.name,
'path': str(item.relative_to(workspace)),
'type': 'dir' if item.is_dir() else 'file',
'size': item.stat().st_size if item.is_file() else None,
})
if len(entries) >= 200:
break
return entries
def read_file_content(workspace: Path, rel: str):
target = safe_resolve_ws(workspace, rel)
if not target.is_file():
raise FileNotFoundError(f"Not a file: {rel}")
size = target.stat().st_size
if size > MAX_FILE_BYTES:
raise ValueError(f"File too large ({size} bytes, max {MAX_FILE_BYTES})")
content = target.read_text(encoding='utf-8', errors='replace')
return {'path': rel, 'content': content, 'size': size, 'lines': content.count('\n') + 1}

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
# Hermes Web UI -- minimal Python dependencies
# The server uses only stdlib + pyyaml.
# All heavy ML/agent deps live in the Hermes agent venv.
pyyaml>=6.0

704
server.py Normal file
View File

@@ -0,0 +1,704 @@
"""
Hermes WebUI -- Main server entry point.
HTTP Handler (routing) + startup. All business logic lives in api/*.
"""
import json
import os
import queue
import sys
import threading
import time
import traceback
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
# ── API modules ───────────────────────────────────────────────────────────────
from api.config import (
HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE, DEFAULT_MODEL,
SESSIONS, SESSIONS_MAX, LOCK, STREAMS, STREAMS_LOCK, CANCEL_FLAGS,
SERVER_START_TIME, CLI_TOOLSETS, _INDEX_HTML_PATH,
IMAGE_EXTS, MD_EXTS, MIME_MAP, MAX_FILE_BYTES, MAX_UPLOAD_BYTES,
_get_session_agent_lock, SESSION_AGENT_LOCKS, SESSION_AGENT_LOCKS_LOCK,
)
from api.helpers import require, bad, safe_resolve, j, t, read_body
from api.models import (
Session, get_session, new_session, all_sessions, title_from,
_write_session_index, SESSION_INDEX_FILE,
)
from api.workspace import (
load_workspaces, save_workspaces, get_last_workspace, set_last_workspace,
list_dir, read_file_content, safe_resolve_ws,
)
from api.upload import parse_multipart, handle_upload
from api.streaming import _sse, _run_agent_streaming, cancel_stream
# Approval system
try:
from tools.approval import (
has_pending, pop_pending, submit_pending,
approve_session, approve_permanent, save_permanent_allowlist,
is_approved,
)
except ImportError:
def has_pending(*a, **k): return False
def pop_pending(*a, **k): return None
def submit_pending(*a, **k): pass
def approve_session(*a, **k): pass
def approve_permanent(*a, **k): pass
def save_permanent_allowlist(*a, **k): pass
def is_approved(*a, **k): return True
class Handler(BaseHTTPRequestHandler):
server_version = 'HermesWebUI/0.1.0'
def log_message(self, fmt, *args): pass # suppress default Apache-style log
def log_request(self, code='-', size='-'):
"""Override BaseHTTPRequestHandler.log_request to emit structured JSON logs."""
import json as _json
duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)
record = _json.dumps({
'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'method': self.command or '-',
'path': self.path or '-',
'status': int(code) if str(code).isdigit() else code,
'ms': duration_ms,
})
print(f'[webui] {record}', flush=True)
def _log_request(self, method, path, status, duration_ms):
pass # kept for backward compat with error path calls; log_request handles it now
def do_GET(self):
_t0 = time.time()
self._req_t0 = _t0
try:
parsed = urlparse(self.path)
if parsed.path in ('/', '/index.html'): return t(self, _INDEX_HTML_PATH.read_text(encoding='utf-8'), content_type='text/html; charset=utf-8')
if parsed.path == '/favicon.ico':
self.send_response(204); self.end_headers(); return
if parsed.path == '/health':
with STREAMS_LOCK: n_streams = len(STREAMS)
return j(self, {'status':'ok','sessions':len(SESSIONS),'active_streams':n_streams,'uptime_seconds':round(time.time()-SERVER_START_TIME,1)})
if parsed.path.startswith('/static/'):
# Phase A: serve static assets from disk
static_file = Path(__file__).parent / parsed.path.lstrip('/')
if not static_file.exists() or not static_file.is_file():
return j(self, {'error': 'not found'}, status=404)
ext = static_file.suffix.lower()
ct = {'css': 'text/css', 'js': 'application/javascript', 'html': 'text/html'}.get(ext.lstrip('.'), 'text/plain')
self.send_response(200)
self.send_header('Content-Type', f'{ct}; charset=utf-8')
self.send_header('Cache-Control', 'no-store')
raw = static_file.read_bytes()
self.send_header('Content-Length', str(len(raw)))
self.end_headers()
self.wfile.write(raw)
return
if parsed.path == '/api/session':
sid = parse_qs(parsed.query).get('session_id', [''])[0]
if not sid:
return j(self, {'error': 'session_id is required'}, status=400)
s = get_session(sid); return j(self, {'session': s.compact() | {'messages': s.messages, 'tool_calls': getattr(s, 'tool_calls', [])}})
if parsed.path == '/api/sessions': return j(self, {'sessions': all_sessions()})
if parsed.path == '/api/session/export':
sid = parse_qs(parsed.query).get('session_id', [''])[0]
if not sid: return bad(self, 'session_id is required')
try: s = get_session(sid)
except KeyError: return bad(self, 'Session not found', 404)
import json as _json_exp
payload = _json_exp.dumps(s.__dict__, ensure_ascii=False, indent=2)
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Disposition', f'attachment; filename="hermes-{sid}.json"')
self.send_header('Content-Length', str(len(payload.encode('utf-8'))))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(payload.encode('utf-8'))
return
if parsed.path == '/api/workspaces':
return j(self, {'workspaces': load_workspaces(), 'last': get_last_workspace()})
if parsed.path == '/api/sessions/search':
qs2 = parse_qs(parsed.query)
q = qs2.get('q', [''])[0].lower().strip()
content_search = qs2.get('content', ['1'])[0] == '1' # default: search message content too
depth = int(qs2.get('depth', ['5'])[0]) # max messages per session to scan
if not q: return j(self, {'sessions': all_sessions()})
results = []
for s in all_sessions():
title_match = q in (s.get('title') or '').lower()
if title_match:
results.append(dict(s, match_type='title'))
continue
if content_search:
# Load full session to search message content
try:
sess = get_session(s['session_id'])
msgs = sess.messages[:depth] if depth else sess.messages
for m in msgs:
c = m.get('content') or ''
if isinstance(c, list):
c = ' '.join(p.get('text','') for p in c if isinstance(p,dict) and p.get('type')=='text')
if q in str(c).lower():
results.append(dict(s, match_type='content'))
break
except (KeyError, Exception):
pass
return j(self, {'sessions': results, 'query': q, 'count': len(results)})
if parsed.path == '/api/list':
qs2 = parse_qs(parsed.query)
sid2 = qs2.get('session_id', [''])[0]
if not sid2: return bad(self, 'session_id is required')
try: s = get_session(sid2)
except KeyError: return bad(self, 'Session not found', 404)
try: return j(self, {'entries': list_dir(Path(s.workspace), qs2.get('path', ['.'])[0]), 'path': qs2.get('path', ['.'])[0]})
except (FileNotFoundError, ValueError) as e: return bad(self, str(e), 404)
if parsed.path == '/api/chat/stream/status':
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
active = stream_id in STREAMS
return j(self, {'active': active, 'stream_id': stream_id})
if parsed.path == '/api/chat/cancel':
# Sprint 10: cancel an in-flight stream
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
if not stream_id:
return bad(self, 'stream_id required')
cancelled = cancel_stream(stream_id)
return j(self, {'ok': True, 'cancelled': cancelled, 'stream_id': stream_id})
if parsed.path == '/api/chat/stream':
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
q = STREAMS.get(stream_id)
if q is None: return j(self, {'error': 'stream not found'}, status=404)
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream; charset=utf-8')
self.send_header('Cache-Control', 'no-cache')
self.send_header('X-Accel-Buffering', 'no')
self.send_header('Connection', 'keep-alive')
self.end_headers()
try:
while True:
try:
event, data = q.get(timeout=30)
except queue.Empty:
self.wfile.write(b': heartbeat\n\n'); self.wfile.flush(); continue
_sse(self, event, data)
if event in ('done', 'error', 'cancel'): break
except (BrokenPipeError, ConnectionResetError): pass
return
if parsed.path == '/api/file/raw':
# Serve raw file bytes (for images and downloads).
# Pass ?download=1 to force Content-Disposition: attachment (save to disk).
qs = parse_qs(parsed.query)
_raw_sid = qs.get('session_id', [''])[0]
if not _raw_sid: return bad(self, 'session_id is required')
try: s = get_session(_raw_sid)
except KeyError: return bad(self, 'Session not found', 404)
rel = qs.get('path', [''])[0]
force_download = qs.get('download', [''])[0] == '1'
target = safe_resolve(Path(s.workspace), rel)
if not target.exists() or not target.is_file():
return j(self, {'error': 'not found'}, status=404)
ext = target.suffix.lower()
mime = MIME_MAP.get(ext, 'application/octet-stream')
raw_bytes = target.read_bytes()
import urllib.parse as _up
safe_name = _up.quote(target.name, safe='')
self.send_response(200)
self.send_header('Content-Type', mime)
self.send_header('Content-Length', str(len(raw_bytes)))
self.send_header('Cache-Control', 'no-store')
if force_download:
self.send_header('Content-Disposition', f'attachment; filename="{target.name}"; filename*=UTF-8\'\'{safe_name}')
self.end_headers()
self.wfile.write(raw_bytes)
return
if parsed.path == '/api/file':
qs3 = parse_qs(parsed.query)
sid3 = qs3.get('session_id', [''])[0]
if not sid3: return bad(self, 'session_id is required')
try: s = get_session(sid3)
except KeyError: return bad(self, 'Session not found', 404)
rel3 = qs3.get('path', [''])[0]
if not rel3: return bad(self, 'path is required')
try: return j(self, read_file_content(Path(s.workspace), rel3))
except (FileNotFoundError, ValueError) as e: return bad(self, str(e), 404)
if parsed.path == '/api/approval/pending':
sid = parse_qs(parsed.query).get('session_id', [''])[0]
if has_pending(sid):
# peek without removing
import threading as _t
from tools.approval import _pending, _lock
with _lock:
p = dict(_pending.get(sid, {}))
return j(self, {'pending': p})
return j(self, {'pending': None})
# Test-only: inject a pending approval entry directly (no agent needed)
if parsed.path == '/api/approval/inject_test':
qs2 = parse_qs(parsed.query)
sid = qs2.get('session_id', [''])[0]
key = qs2.get('pattern_key', ['test_pattern'])[0]
cmd = qs2.get('command', ['rm -rf /tmp/test'])[0]
if sid:
submit_pending(sid, {
'command': cmd, 'pattern_key': key,
'pattern_keys': [key], 'description': 'test pattern',
})
return j(self, {'ok': True, 'session_id': sid})
return j(self, {'error': 'session_id required'}, status=400)
self._log_request(self.command, self.path, 404, (time.time()-_t0)*1000)
# ── Cron API ──
if parsed.path == '/api/crons':
sys.path.insert(0, str(Path(__file__).parent.parent))
from cron.jobs import list_jobs, OUTPUT_DIR as CRON_OUT
jobs = list_jobs(include_disabled=True)
return j(self, {'jobs': jobs})
if parsed.path == '/api/crons/output':
from cron.jobs import OUTPUT_DIR as CRON_OUT
job_id = parse_qs(parsed.query).get('job_id', [''])[0]
limit = int(parse_qs(parsed.query).get('limit', ['5'])[0])
if not job_id: return j(self, {'error': 'job_id required'}, status=400)
out_dir = CRON_OUT / job_id
outputs = []
if out_dir.exists():
files = sorted(out_dir.glob('*.md'), reverse=True)[:limit]
for f in files:
try:
txt_content = f.read_text(encoding='utf-8', errors='replace')
outputs.append({'filename': f.name, 'content': txt_content[:8000]})
except Exception: pass
return j(self, {'job_id': job_id, 'outputs': outputs})
# ── Skills API ──
if parsed.path == '/api/skills':
from tools.skills_tool import skills_list as _skills_list
import json as _j
raw = _skills_list()
data = _j.loads(raw) if isinstance(raw, str) else raw
return j(self, {'skills': data.get('skills', [])})
if parsed.path == '/api/skills/content':
from tools.skills_tool import skill_view as _skill_view
import json as _j
name = parse_qs(parsed.query).get('name', [''])[0]
if not name: return j(self, {'error': 'name required'}, status=400)
raw = _skill_view(name)
data = _j.loads(raw) if isinstance(raw, str) else raw
return j(self, data)
# ── Memory API ──
if parsed.path == '/api/memory':
mem_dir = Path.home() / '.hermes' / 'memories'
mem_file = mem_dir / 'MEMORY.md'
user_file = mem_dir / 'USER.md'
memory = mem_file.read_text(encoding='utf-8', errors='replace') if mem_file.exists() else ''
user = user_file.read_text(encoding='utf-8', errors='replace') if user_file.exists() else ''
return j(self, {
'memory': memory, 'user': user,
'memory_path': str(mem_file), 'user_path': str(user_file),
'memory_mtime': mem_file.stat().st_mtime if mem_file.exists() else None,
'user_mtime': user_file.stat().st_mtime if user_file.exists() else None,
})
if parsed.path == '/api/crons/run':
job_id = body.get('job_id', '')
if not job_id: return bad(self, 'job_id required')
from cron.jobs import get_job
from cron.scheduler import run_job
import threading as _threading
job = get_job(job_id)
if not job: return bad(self, 'Job not found', 404)
# Run in a background thread so the request returns immediately
_threading.Thread(target=run_job, args=(job,), daemon=True).start()
return j(self, {'ok': True, 'job_id': job_id, 'status': 'triggered'})
if parsed.path == '/api/crons/pause':
job_id = body.get('job_id', '')
if not job_id: return bad(self, 'job_id required')
from cron.jobs import pause_job
result = pause_job(job_id, reason=body.get('reason'))
if result: return j(self, {'ok': True, 'job': result})
return bad(self, 'Job not found', 404)
if parsed.path == '/api/crons/resume':
job_id = body.get('job_id', '')
if not job_id: return bad(self, 'job_id required')
from cron.jobs import resume_job
result = resume_job(job_id)
if result: return j(self, {'ok': True, 'job': result})
return bad(self, 'Job not found', 404)
self._log_request(self.command, self.path, 404, (time.time()-_t0)*1000)
if parsed.path == '/api/skills/save':
# Create or update a skill's SKILL.md content
try: require(body, 'name', 'content')
except ValueError as e: return bad(self, str(e))
skill_name = body['name'].strip().lower().replace(' ', '-')
if not skill_name or '/' in skill_name or '..' in skill_name:
return bad(self, 'Invalid skill name')
category = body.get('category', '').strip()
from tools.skills_tool import SKILLS_DIR
if category:
skill_dir = SKILLS_DIR / category / skill_name
else:
skill_dir = SKILLS_DIR / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
skill_file = skill_dir / 'SKILL.md'
skill_file.write_text(body['content'], encoding='utf-8')
return j(self, {'ok': True, 'name': skill_name, 'path': str(skill_file)})
if parsed.path == '/api/skills/delete':
try: require(body, 'name')
except ValueError as e: return bad(self, str(e))
from tools.skills_tool import SKILLS_DIR
import shutil as _shutil
# Search for the skill directory by name
matches = list(SKILLS_DIR.rglob(f'{body["name"]}/SKILL.md'))
if not matches: return bad(self, 'Skill not found', 404)
skill_dir = matches[0].parent
_shutil.rmtree(str(skill_dir))
return j(self, {'ok': True, 'name': body['name']})
if parsed.path == '/api/memory/write':
# Write to MEMORY.md or USER.md
try: require(body, 'section', 'content')
except ValueError as e: return bad(self, str(e))
mem_dir = Path.home() / '.hermes' / 'memories'
mem_dir.mkdir(parents=True, exist_ok=True)
section = body['section']
if section == 'memory':
target = mem_dir / 'MEMORY.md'
elif section == 'user':
target = mem_dir / 'USER.md'
else:
return bad(self, 'section must be "memory" or "user"')
target.write_text(body['content'], encoding='utf-8')
return j(self, {'ok': True, 'section': section, 'path': str(target)})
return j(self, {'error':'not found'}, status=404)
except Exception as e:
self._log_request(self.command, self.path, 500, (time.time()-_t0)*1000)
return j(self, {'error': str(e), 'trace': traceback.format_exc()}, status=500)
def do_POST(self):
_t0 = time.time()
self._req_t0 = _t0
try:
parsed = urlparse(self.path)
if parsed.path == '/api/upload':
return handle_upload(self)
body = read_body(self)
if parsed.path == '/api/session/new':
s = new_session(workspace=body.get('workspace'), model=body.get('model')); return j(self, {'session': s.compact() | {'messages': s.messages}})
if parsed.path == '/api/sessions/cleanup':
# Delete all sessions with no messages and title == Untitled (legacy)
cleaned = 0
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'): continue
try:
s = Session.load(p.stem)
if s and s.title == 'Untitled' and len(s.messages) == 0:
with LOCK: SESSIONS.pop(p.stem, None)
p.unlink(missing_ok=True)
cleaned += 1
except Exception: pass
if SESSION_INDEX_FILE.exists():
SESSION_INDEX_FILE.unlink(missing_ok=True)
return j(self, {'ok': True, 'cleaned': cleaned})
if parsed.path == '/api/sessions/cleanup_zero_message':
# Delete ALL sessions with 0 messages (used by test teardown)
cleaned = 0
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'): continue
try:
s = Session.load(p.stem)
if s and len(s.messages) == 0:
with LOCK: SESSIONS.pop(p.stem, None)
p.unlink(missing_ok=True)
cleaned += 1
except Exception: pass
if SESSION_INDEX_FILE.exists():
SESSION_INDEX_FILE.unlink(missing_ok=True)
return j(self, {'ok': True, 'cleaned': cleaned})
if parsed.path == '/api/session/rename':
try: require(body, 'session_id', 'title')
except ValueError as e: return bad(self, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(self, 'Session not found', 404)
s.title = str(body['title']).strip()[:80] or 'Untitled'
s.save()
return j(self, {'session': s.compact()})
if parsed.path == '/api/session/update':
try: require(body, 'session_id')
except ValueError as e: return bad(self, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(self, 'Session not found', 404)
new_ws = str(Path(body.get('workspace', s.workspace)).expanduser().resolve())
s.workspace = new_ws; s.model = body.get('model', s.model); s.save()
set_last_workspace(new_ws) # persist for new session inheritance
return j(self, {'session': s.compact() | {'messages': s.messages}})
if parsed.path == '/api/session/delete':
sid = body.get('session_id','')
if not sid: return bad(self, 'session_id is required')
with LOCK: SESSIONS.pop(sid, None)
p = SESSION_DIR / f'{sid}.json'
try: p.unlink(missing_ok=True)
except Exception: pass
# Invalidate index so the deleted session stops appearing in lists
try: SESSION_INDEX_FILE.unlink(missing_ok=True)
except Exception: pass
return j(self, {'ok': True})
if parsed.path == '/api/session/clear':
# Wipe all messages from a session, keep session metadata
try: require(body, 'session_id')
except ValueError as e: return bad(self, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(self, 'Session not found', 404)
s.messages = []
s.tool_calls = []
s.title = 'Untitled'
s.save()
return j(self, {'ok': True, 'session': s.compact()})
if parsed.path == '/api/session/truncate':
# Truncate messages at a given index (keep messages[:index])
# Used by edit+regenerate: trim everything from the edited message onward
try: require(body, 'session_id')
except ValueError as e: return bad(self, str(e))
if body.get('keep_count') is None: return bad(self, 'Missing required field(s): keep_count')
try: s = get_session(body['session_id'])
except KeyError: return bad(self, 'Session not found', 404)
keep = int(body['keep_count'])
s.messages = s.messages[:keep]
s.save()
return j(self, {'ok': True, 'session': s.compact() | {'messages': s.messages}})
if parsed.path == '/api/chat/start':
try: require(body, 'session_id')
except ValueError as e: return bad(self, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(self, 'Session not found', 404)
msg = str(body.get('message', '')).strip()
if not msg: return bad(self, 'message is required')
attachments = [str(a) for a in (body.get('attachments') or [])][:20]
workspace = str(Path(body.get('workspace') or s.workspace).expanduser().resolve())
model = body.get('model') or s.model
s.workspace = workspace; s.model = model; s.save()
set_last_workspace(workspace) # persist for new session inheritance
stream_id = uuid.uuid4().hex
q = queue.Queue()
with STREAMS_LOCK: STREAMS[stream_id] = q
t = threading.Thread(target=_run_agent_streaming,
args=(s.session_id, msg, model, workspace, stream_id, attachments), daemon=True)
t.start()
return j(self, {'stream_id': stream_id, 'session_id': s.session_id})
if parsed.path == '/api/chat':
s = get_session(body['session_id']); msg = str(body.get('message', '')).strip()
if not msg: return j(self, {'error':'empty message'}, status=400)
workspace = Path(body.get('workspace') or s.workspace).expanduser().resolve(); s.workspace = str(workspace); s.model = body.get('model') or s.model
old_cwd = os.environ.get('TERMINAL_CWD'); os.environ['TERMINAL_CWD'] = str(workspace)
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
old_session_key = os.environ.get('HERMES_SESSION_KEY')
os.environ['HERMES_EXEC_ASK'] = '1'
os.environ['HERMES_SESSION_KEY'] = s.session_id
try:
with CHAT_LOCK:
agent = AIAgent(model=s.model, platform='cli', quiet_mode=True, enabled_toolsets=CLI_TOOLSETS, session_id=s.session_id)
workspace_ctx = f"[Workspace: {s.workspace}]\n"
workspace_system_msg = (
f"Active workspace at session start: {s.workspace}\n"
"Every user message is prefixed with [Workspace: /absolute/path] indicating the "
"workspace the user has selected in the web UI at the time they sent that message. "
"This tag is the single authoritative source of the active workspace and updates "
"with every message. It overrides any prior workspace mentioned in this system "
"prompt, memory, or conversation history. Always use the value from the most recent "
"[Workspace: ...] tag as your default working directory for ALL file operations: "
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
result = agent.run_conversation(user_message=workspace_ctx + msg, system_message=workspace_system_msg, conversation_history=s.messages, task_id=s.session_id, persist_user_message=msg)
finally:
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
else: os.environ['TERMINAL_CWD'] = old_cwd
if old_exec_ask is None: os.environ.pop('HERMES_EXEC_ASK', None)
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
s.messages = result.get('messages') or s.messages; s.title = title_from(s.messages, s.title); s.save()
return j(self, {'answer': result.get('final_response') or '', 'status': 'done' if result.get('completed', True) else 'partial', 'session': s.compact() | {'messages': s.messages}, 'result': {k:v for k,v in result.items() if k != 'messages'}})
if parsed.path == '/api/crons/create':
try: require(body, 'prompt', 'schedule')
except ValueError as e: return bad(self, str(e))
try:
from cron.jobs import create_job
job = create_job(
prompt=body['prompt'],
schedule=body['schedule'],
name=body.get('name') or None,
deliver=body.get('deliver') or 'local',
skills=body.get('skills') or [],
model=body.get('model') or None,
)
return j(self, {'ok': True, 'job': job})
except Exception as e:
return j(self, {'error': str(e)}, status=400)
if parsed.path == '/api/crons/update':
try: require(body, 'job_id')
except ValueError as e: return bad(self, str(e))
from cron.jobs import update_job
updates = {k: v for k, v in body.items() if k != 'job_id' and v is not None}
job = update_job(body['job_id'], updates)
if not job: return bad(self, 'Job not found', 404)
return j(self, {'ok': True, 'job': job})
if parsed.path == '/api/crons/delete':
try: require(body, 'job_id')
except ValueError as e: return bad(self, str(e))
from cron.jobs import remove_job
ok = remove_job(body['job_id'])
if not ok: return bad(self, 'Job not found', 404)
return j(self, {'ok': True, 'job_id': body['job_id']})
if parsed.path == '/api/file/delete':
try: require(body, 'session_id', 'path')
except ValueError as e: return bad(self, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(self, 'Session not found', 404)
try:
target = safe_resolve(Path(s.workspace), body['path'])
if not target.exists(): return bad(self, 'File not found', 404)
if target.is_dir(): return bad(self, 'Cannot delete directories via this endpoint')
target.unlink()
return j(self, {'ok': True, 'path': body['path']})
except (ValueError, PermissionError) as e: return bad(self, str(e))
if parsed.path == '/api/file/save':
try: require(body, 'session_id', 'path')
except ValueError as e: return bad(self, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(self, 'Session not found', 404)
try:
target = safe_resolve(Path(s.workspace), body['path'])
if not target.exists(): return bad(self, 'File not found', 404)
if target.is_dir(): return bad(self, 'Cannot save: path is a directory')
target.write_text(body.get('content', ''), encoding='utf-8')
return j(self, {'ok': True, 'path': body['path'], 'size': target.stat().st_size})
except (ValueError, PermissionError) as e: return bad(self, str(e))
if parsed.path == '/api/file/create':
try: require(body, 'session_id', 'path')
except ValueError as e: return bad(self, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(self, 'Session not found', 404)
try:
target = safe_resolve(Path(s.workspace), body['path'])
if target.exists(): return bad(self, 'File already exists')
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body.get('content', ''), encoding='utf-8')
return j(self, {'ok': True, 'path': str(target.relative_to(Path(s.workspace)))})
except (ValueError, PermissionError) as e: return bad(self, str(e))
if parsed.path == '/api/workspaces/add':
path_str = body.get('path', '').strip()
name = body.get('name', '').strip()
if not path_str: return bad(self, 'path is required')
p = Path(path_str).expanduser().resolve()
if not p.exists(): return bad(self, f'Path does not exist: {p}')
if not p.is_dir(): return bad(self, f'Path is not a directory: {p}')
wss = load_workspaces()
if any(w['path'] == str(p) for w in wss):
return bad(self, 'Workspace already in list')
wss.append({'path': str(p), 'name': name or p.name})
save_workspaces(wss)
return j(self, {'ok': True, 'workspaces': wss})
if parsed.path == '/api/workspaces/remove':
path_str = body.get('path', '').strip()
if not path_str: return bad(self, 'path is required')
wss = load_workspaces()
wss = [w for w in wss if w['path'] != path_str]
save_workspaces(wss)
return j(self, {'ok': True, 'workspaces': wss})
if parsed.path == '/api/workspaces/rename':
path_str = body.get('path', '').strip()
name = body.get('name', '').strip()
if not path_str or not name: return bad(self, 'path and name are required')
wss = load_workspaces()
for w in wss:
if w['path'] == path_str:
w['name'] = name; break
else:
return bad(self, 'Workspace not found', 404)
save_workspaces(wss)
return j(self, {'ok': True, 'workspaces': wss})
if parsed.path == '/api/approval/respond':
sid = body.get('session_id', '')
if not sid: return bad(self, 'session_id is required')
choice = body.get('choice', 'deny')
if choice not in ('once','session','always','deny'):
return bad(self, f'Invalid choice: {choice}')
from tools.approval import _pending, _lock, _permanent_approved
with _lock:
pending = _pending.pop(sid, None)
if pending:
keys = pending.get('pattern_keys') or [pending.get('pattern_key', '')]
if choice in ('once', 'session'):
for k in keys: approve_session(sid, k)
elif choice == 'always':
for k in keys:
approve_session(sid, k); approve_permanent(k)
save_permanent_allowlist(_permanent_approved)
return j(self, {'ok': True, 'choice': choice})
if parsed.path == '/api/skills/save':
# Create or update a skill's SKILL.md content
try: require(body, 'name', 'content')
except ValueError as e: return bad(self, str(e))
skill_name = body['name'].strip().lower().replace(' ', '-')
if not skill_name or '/' in skill_name or '..' in skill_name:
return bad(self, 'Invalid skill name')
category = body.get('category', '').strip()
from tools.skills_tool import SKILLS_DIR
if category:
skill_dir = SKILLS_DIR / category / skill_name
else:
skill_dir = SKILLS_DIR / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
skill_file = skill_dir / 'SKILL.md'
skill_file.write_text(body['content'], encoding='utf-8')
return j(self, {'ok': True, 'name': skill_name, 'path': str(skill_file)})
if parsed.path == '/api/skills/delete':
try: require(body, 'name')
except ValueError as e: return bad(self, str(e))
from tools.skills_tool import SKILLS_DIR
import shutil as _shutil
matches = list(SKILLS_DIR.rglob(f'{body["name"]}/SKILL.md'))
if not matches: return bad(self, 'Skill not found', 404)
skill_dir = matches[0].parent
_shutil.rmtree(str(skill_dir))
return j(self, {'ok': True, 'name': body['name']})
if parsed.path == '/api/memory/write':
# Write to MEMORY.md or USER.md
try: require(body, 'section', 'content')
except ValueError as e: return bad(self, str(e))
mem_dir = Path.home() / '.hermes' / 'memories'
mem_dir.mkdir(parents=True, exist_ok=True)
section = body['section']
if section == 'memory':
target = mem_dir / 'MEMORY.md'
elif section == 'user':
target = mem_dir / 'USER.md'
else:
return bad(self, 'section must be "memory" or "user"')
target.write_text(body['content'], encoding='utf-8')
return j(self, {'ok': True, 'section': section, 'path': str(target)})
return j(self, {'error':'not found'}, status=404)
except Exception as e:
self._log_request(self.command, self.path, 500, (time.time()-_t0)*1000)
return j(self, {'error': str(e), 'trace': traceback.format_exc()}, status=500)
def main():
from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
print_startup_config()
if not _HERMES_FOUND:
ok, missing = verify_hermes_imports()
else:
ok, missing = verify_hermes_imports()
if not ok:
print(f'[!!] Warning: Hermes agent found but missing modules: {missing}', flush=True)
print(' Agent features may not work correctly.', flush=True)
STATE_DIR.mkdir(parents=True, exist_ok=True)
SESSION_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_WORKSPACE.mkdir(parents=True, exist_ok=True)
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
print(f' Hermes WebUI listening on http://{HOST}:{PORT}', flush=True)
if HOST == '127.0.0.1':
print(f' Remote access: ssh -N -L {PORT}:127.0.0.1:{PORT} <user>@<your-server>', flush=True)
print(f' Then open: http://localhost:{PORT}', flush=True)
print('', flush=True)
httpd.serve_forever()
if __name__ == '__main__':
main()

260
start.sh Executable file
View File

@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# ============================================================
# Hermes Web UI -- portable bootstrap
# Usage: ./start.sh [port]
#
# One-command startup. Discovers your Hermes install, sets up
# a local virtualenv if needed, installs dependencies, then
# launches the server and prints everything you need to know.
#
# Override any step with environment variables:
# HERMES_WEBUI_AGENT_DIR path to hermes-agent checkout
# HERMES_WEBUI_PYTHON python executable to use
# HERMES_WEBUI_PORT port to listen on (default: 8787)
# HERMES_WEBUI_HOST bind address (default: 127.0.0.1)
# HERMES_HOME override ~/.hermes base
# HERMES_WEBUI_STATE_DIR override state directory
# ============================================================
set -euo pipefail
# ── Load .env if present (machine-local overrides, not committed) ─────────────
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "${_SCRIPT_DIR}/.env" ]]; then
set -a
# shellcheck source=/dev/null
source "${_SCRIPT_DIR}/.env"
set +a
fi
# ── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
ok() { echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[!!]${RESET} $*"; }
die() { echo -e "${RED}[XX]${RESET} $*" >&2; exit 1; }
info() { echo -e "${CYAN}[--]${RESET} $*"; }
hdr() { echo -e "\n${BOLD}$*${RESET}"; }
# ── Resolve repo root (the directory this script lives in) ───────────────────
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
info "Repo root: ${REPO_ROOT}"
# ── Port ─────────────────────────────────────────────────────────────────────
PORT="${1:-${HERMES_WEBUI_PORT:-8787}}"
export HERMES_WEBUI_PORT="${PORT}"
# ── Python discovery ─────────────────────────────────────────────────────────
hdr "Discovering Python..."
_find_python() {
# 1. Explicit env var
if [[ -n "${HERMES_WEBUI_PYTHON:-}" ]]; then
echo "${HERMES_WEBUI_PYTHON}"; return
fi
# 2. Agent venv (discovered below -- call again after agent dir found)
# (handled after agent dir discovery)
# 3. Local .venv in repo
if [[ -x "${REPO_ROOT}/.venv/bin/python" ]]; then
echo "${REPO_ROOT}/.venv/bin/python"; return
fi
# 4. System python3
if command -v python3 &>/dev/null; then
echo "$(command -v python3)"; return
fi
echo ""
}
PYTHON="$(_find_python)"
# ── Hermes agent discovery ────────────────────────────────────────────────────
hdr "Discovering Hermes agent..."
HERMES_HOME="${HERMES_HOME:-${HOME}/.hermes}"
AGENT_DIR=""
_find_agent() {
local candidates=(
"${HERMES_WEBUI_AGENT_DIR:-}"
"${HERMES_HOME}/hermes-agent"
"${REPO_ROOT}/../hermes-agent"
"${HOME}/.hermes/hermes-agent"
"${HOME}/hermes-agent"
)
for d in "${candidates[@]}"; do
[[ -z "$d" ]] && continue
d="$(cd "${d}" 2>/dev/null && pwd || true)"
if [[ -n "$d" && -f "${d}/run_agent.py" ]]; then
echo "$d"; return
fi
done
echo ""
}
AGENT_DIR="$(_find_agent)"
if [[ -n "${AGENT_DIR}" ]]; then
ok "Hermes agent: ${AGENT_DIR}"
export HERMES_WEBUI_AGENT_DIR="${AGENT_DIR}"
# Now that we have agent dir, prefer its venv if we don't already have a python
if [[ -z "${HERMES_WEBUI_PYTHON:-}" && -x "${AGENT_DIR}/venv/bin/python" ]]; then
PYTHON="${AGENT_DIR}/venv/bin/python"
fi
else
warn "Hermes agent not found. Agent features will not work."
warn "Fix with: export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent"
fi
if [[ -n "${PYTHON}" ]]; then
ok "Python: ${PYTHON} ($(${PYTHON} --version 2>&1))"
else
warn "No Python found. Attempting to install..."
if command -v apt-get &>/dev/null; then
sudo apt-get install -y python3 python3-venv python3-pip
elif command -v brew &>/dev/null; then
brew install python3
else
die "Could not find or install Python. Please install Python 3.8+ and re-run."
fi
PYTHON="$(command -v python3)"
ok "Python installed: ${PYTHON}"
fi
# ── Minimum Python version check ─────────────────────────────────────────────
PY_VER="$(${PYTHON} -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
PY_MAJOR="$(echo "${PY_VER}" | cut -d. -f1)"
PY_MINOR="$(echo "${PY_VER}" | cut -d. -f2)"
if [[ "${PY_MAJOR}" -lt 3 || ( "${PY_MAJOR}" -eq 3 && "${PY_MINOR}" -lt 8 ) ]]; then
die "Python 3.8+ required. Found: ${PY_VER}"
fi
# ── Dependency check / local venv setup ──────────────────────────────────────
hdr "Checking dependencies..."
VENV_NEEDED=false
VENV_PATH="${REPO_ROOT}/.venv"
# If the chosen python is already the agent venv, its deps are already installed.
# If it is a system python, check if we can import the webui deps, create a local
# .venv if not.
_check_deps() {
"${PYTHON}" -c "import yaml" 2>/dev/null
}
if ! _check_deps; then
info "PyYAML not found in ${PYTHON}. Creating local .venv..."
if [[ ! -d "${VENV_PATH}" ]]; then
"${PYTHON}" -m venv "${VENV_PATH}" || die "Failed to create virtualenv at ${VENV_PATH}"
fi
VENV_PY="${VENV_PATH}/bin/python"
"${VENV_PY}" -m pip install --quiet --upgrade pip
if [[ -f "${REPO_ROOT}/requirements.txt" ]]; then
info "Installing from requirements.txt..."
"${VENV_PY}" -m pip install --quiet -r "${REPO_ROOT}/requirements.txt"
else
info "Installing minimal deps (pyyaml)..."
"${VENV_PY}" -m pip install --quiet pyyaml
fi
PYTHON="${VENV_PY}"
ok "Local venv ready: ${VENV_PATH}"
else
ok "Dependencies satisfied."
fi
# ── Kill any stale instance on the same port ─────────────────────────────────
hdr "Checking for existing instances..."
EXISTING=$(lsof -ti tcp:"${PORT}" 2>/dev/null || true)
if [[ -n "${EXISTING}" ]]; then
warn "Killing existing process on port ${PORT} (PID ${EXISTING})"
kill "${EXISTING}" 2>/dev/null || true
sleep 0.5
fi
# Also kill any server.py process from this repo
pkill -f "${REPO_ROOT}/server.py" 2>/dev/null || true
# ── Set up working directory for Hermes imports ───────────────────────────────
# server.py / api/config.py inject agent dir into sys.path at import time,
# but we also cd into the agent dir so relative imports in run_agent work.
if [[ -n "${AGENT_DIR}" ]]; then
WORKDIR="${AGENT_DIR}"
else
WORKDIR="${REPO_ROOT}"
fi
# ── Launch ───────────────────────────────────────────────────────────────────
hdr "Starting Hermes Web UI..."
LOG="/tmp/hermes-webui-${PORT}.log"
export HERMES_WEBUI_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
export HERMES_WEBUI_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${HERMES_HOME}/webui-mvp}"
nohup "${PYTHON}" "${REPO_ROOT}/server.py" \
> "${LOG}" 2>&1 &
PID=$!
echo -e "\n${CYAN} PID ${PID} starting...${RESET}"
sleep 1.5
# ── Health check ─────────────────────────────────────────────────────────────
HEALTH_URL="http://${HERMES_WEBUI_HOST:-127.0.0.1}:${PORT}/health"
MAX_WAIT=15
ELAPSED=0
while [[ $ELAPSED -lt $MAX_WAIT ]]; do
if curl -sf "${HEALTH_URL}" | grep -q '"status"' 2>/dev/null; then
break
fi
sleep 0.5
ELAPSED=$((ELAPSED + 1))
done
if ! curl -sf "${HEALTH_URL}" | grep -q '"status"' 2>/dev/null; then
warn "Health check did not pass within ${MAX_WAIT}s. Check log:"
tail -20 "${LOG}"
echo ""
warn "Server may still be starting. Try: curl ${HEALTH_URL}"
else
ok "Server is healthy."
fi
# ── Print access instructions ─────────────────────────────────────────────────
BIND_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
echo ""
echo -e "${BOLD}========================================${RESET}"
echo -e "${GREEN} Hermes Web UI is running${RESET}"
echo -e "${BOLD}========================================${RESET}"
echo ""
if [[ "${BIND_HOST}" == "127.0.0.1" || "${BIND_HOST}" == "localhost" ]]; then
# Server is bound to loopback -- detect if we are on a remote machine
# by checking if $SSH_CLIENT or $SSH_TTY is set
if [[ -n "${SSH_CLIENT:-}" || -n "${SSH_TTY:-}" ]]; then
SERVER_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo "<your-server-ip>")"
echo -e " You are on a remote machine. To access from your local browser:"
echo ""
echo -e " ${CYAN}ssh -N -L ${PORT}:127.0.0.1:${PORT} \$(whoami)@${SERVER_IP}${RESET}"
echo ""
echo -e " Then open: ${BOLD}http://localhost:${PORT}${RESET}"
else
echo -e " Open: ${BOLD}http://localhost:${PORT}${RESET}"
fi
else
echo -e " Open: ${BOLD}http://${BIND_HOST}:${PORT}${RESET}"
fi
echo ""
echo -e " Log: ${LOG}"
echo -e " PID: ${PID}"
echo ""

152
static/boot.js Normal file
View File

@@ -0,0 +1,152 @@
async function cancelStream(){
const streamId = S.activeStreamId;
if(!streamId) return;
try{
await fetch(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`);
const btn=$('btnCancel');if(btn)btn.style.display='none';
setStatus('Cancelling…');
}catch(e){setStatus('Cancel failed: '+e.message);}
}
$('btnSend').onclick=send;
$('btnAttach').onclick=()=>$('fileInput').click();
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
$('btnDownload').onclick=()=>{
if(!S.session)return;
const blob=new Blob([transcript()],{type:'text/markdown'});
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href);
};
$('btnExportJSON').onclick=()=>{
if(!S.session)return;
const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}`;
const a=document.createElement('a');a.href=url;
a.download=`hermes-${S.session.session_id}.json`;a.click();
};
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
$('btnClearPreview').onclick=()=>{
$('previewArea').classList.remove('visible');
$('previewImg').src='';
$('previewMd').innerHTML='';
$('previewCode').textContent='';
$('previewPathText').textContent='';
$('fileTree').style.display='';
};
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
$('modelSelect').onchange=async()=>{
if(!S.session)return;
const selectedModel=$('modelSelect').value;
localStorage.setItem('hermes-webui-model', selectedModel);
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
S.session.model=selectedModel;syncTopbar();
};
$('msg').addEventListener('input',autoResize);
$('msg').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}});
// B14: Cmd/Ctrl+K creates a new chat from anywhere
document.addEventListener('keydown',async e=>{
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
e.preventDefault();
if(!S.busy){await newSession();await renderSessionList();$('msg').focus();}
}
if(e.key==='Escape'){
// Close workspace dropdown
closeWsDropdown();
// Clear session search
const ss=$('sessionSearch');
if(ss&&ss.value){ss.value='';filterSessions();}
// Cancel any active message edit
const editArea=document.querySelector('.msg-edit-area');
if(editArea){
const bar=editArea.closest('.msg-row')&&editArea.closest('.msg-row').querySelector('.msg-edit-bar');
if(bar){const cancel=bar.querySelector('.msg-edit-cancel');if(cancel)cancel.click();}
}
}
});
$('msg').addEventListener('paste',e=>{
const items=Array.from(e.clipboardData?.items||[]);
const imageItems=items.filter(i=>i.type.startsWith('image/'));
if(!imageItems.length)return;
e.preventDefault();
const files=imageItems.map(i=>{
const blob=i.getAsFile();
const ext=i.type.split('/')[1]||'png';
return new File([blob],`screenshot-${Date.now()}.${ext}`,{type:i.type});
});
addFiles(files);
setStatus(`Image pasted: ${files.map(f=>f.name).join(', ')}`);
});
document.querySelectorAll('.suggestion').forEach(btn=>{
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
});
// Boot: restore last session or start fresh
// ── Resizable panels ──────────────────────────────────────────────────────
(function(){
const SIDEBAR_MIN=180, SIDEBAR_MAX=420;
const PANEL_MIN=180, PANEL_MAX=500;
function initResize(handleId, targetEl, edge, minW, maxW, storageKey){
const handle = $(handleId);
if(!handle || !targetEl) return;
// Restore saved width
const saved = localStorage.getItem(storageKey);
if(saved) targetEl.style.width = saved + 'px';
let startX=0, startW=0;
handle.addEventListener('mousedown', e=>{
e.preventDefault();
startX = e.clientX;
startW = targetEl.getBoundingClientRect().width;
handle.classList.add('dragging');
document.body.classList.add('resizing');
const onMove = ev=>{
const delta = edge==='right' ? ev.clientX - startX : startX - ev.clientX;
const newW = Math.min(maxW, Math.max(minW, startW + delta));
targetEl.style.width = newW + 'px';
};
const onUp = ()=>{
handle.classList.remove('dragging');
document.body.classList.remove('resizing');
localStorage.setItem(storageKey, parseInt(targetEl.style.width));
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// Run after DOM ready (called from boot)
window._initResizePanels = function(){
const sidebar = document.querySelector('.sidebar');
const rightpanel = document.querySelector('.rightpanel');
initResize('sidebarResize', sidebar, 'right', SIDEBAR_MIN, SIDEBAR_MAX, 'hermes-sidebar-w');
initResize('rightpanelResize', rightpanel, 'left', PANEL_MIN, PANEL_MAX, 'hermes-panel-w');
};
})();
(async()=>{
// Restore last-used model preference
const savedModel=localStorage.getItem('hermes-webui-model');
if(savedModel && $('modelSelect')){
$('modelSelect').value=savedModel;
// If the value didn't take (model not in list), clear the bad pref
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
}
// Pre-load workspace list so sidebar name is correct from first render
await loadWorkspaceList();
_initResizePanels();
const saved=localStorage.getItem('hermes-webui-session');
if(saved){
try{await loadSession(saved);await renderSessionList();await checkInflightOnBoot(saved);return;}
catch(e){localStorage.removeItem('hermes-webui-session');}
}
// no saved session - show empty state, wait for user to hit +
$('emptyState').style.display='';
await renderSessionList();
})();

264
static/index.html Normal file
View File

@@ -0,0 +1,264 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes</title>
<link rel="stylesheet" href="/static/style.css">
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css">
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" defer></script>
</head>
<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.1.0 · WebUI</div></div></div>
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills">&#129513;</button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory">&#129504;</button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces">&#128193;</button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list">&#9989;</button>
</div>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
<div class="sidebar-section">
<button class="new-chat-btn" id="btnNewChat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New conversation <span style="font-size:10px;opacity:.5;margin-left:4px">&#8984;K</span>
</button>
</div>
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." oninput="filterSessions()"></div>
<div class="session-list" id="sessionList"></div>
</div>
<!-- Tasks (cron) panel -->
<div class="panel-view" id="panelTasks">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)">Scheduled jobs</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ New job</button>
</div>
<!-- Create job form (hidden by default) -->
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
<input id="cronFormSchedule" placeholder="Schedule: '0 9 * * *' or 'every 1h'" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
<textarea id="cronFormPrompt" rows="3" placeholder="Prompt (must be self-contained)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:6px"></textarea>
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:8px">
<option value="local">Local (save output only)</option>
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
</select>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()">Create job</button>
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()">Cancel</button>
</div>
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
</div>
<!-- Skills panel -->
<div class="panel-view" id="panelSkills">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." oninput="filterSkills()"></div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ New skill</button>
</div>
<!-- Skill create/edit form (hidden by default) -->
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="skillFormName" placeholder="Skill name (e.g. my-skill)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()">Save skill</button>
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()">Cancel</button>
</div>
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
</div>
<!-- Memory panel -->
<div class="panel-view" id="panelMemory">
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<span style="font-size:11px;color:var(--muted)">Personal memory</span>
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()">&#9998; Edit</button>
</div>
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
<!-- Memory edit form (hidden by default) -->
<div id="memoryEditForm" style="display:none;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0">
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Editing: <span id="memEditSection">memory</span></div>
<textarea id="memEditContent" rows="10" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:11px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;box-sizing:border-box;margin-bottom:6px;line-height:1.5"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()">Save</button>
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()">Cancel</button>
</div>
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
</div>
<!-- Todo panel -->
<div class="panel-view" id="panelTodos">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0">Current task list</div>
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
</div>
<!-- Workspaces panel -->
<div class="panel-view" id="panelWorkspaces">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
</div>
<div class="sidebar-bottom">
<div class="field-label" style="font-size:10px;letter-spacing:.07em;margin-bottom:4px">MODEL</div>
<select id="modelSelect">
<optgroup label="OpenAI">
<option value="openai/gpt-5.4-mini">GPT-5.4 Mini</option>
<option value="openai/gpt-4o">GPT-4o</option>
<option value="openai/o3">o3</option>
<option value="openai/o4-mini">o4-mini</option>
</optgroup>
<optgroup label="Anthropic">
<option value="anthropic/claude-sonnet-4.6">Claude Sonnet 4.6</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option>
</optgroup>
<optgroup label="Other">
<option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option>
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option>
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
</optgroup>
</select>
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
<span style="font-size:14px;opacity:.7">&#128193;</span>
<div style="min-width:0;flex:1">
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
</div>
<span style="font-size:10px;color:var(--muted);flex-shrink:0">&#9662;</span>
</div>
<div class="sidebar-actions">
<button class="sm-btn" id="btnDownload" title="Download as Markdown">&#8595; Transcript</button>
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON">&#10092;/&#10093; JSON</button>
</div>
</div>
<div class="resize-handle" id="sidebarResize"></div>
</aside>
<main class="main">
<div class="topbar">
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
<div class="topbar-chips">
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
<div id="wsChipWrap" style="position:relative">
<div class="chip ws-chip" id="wsChip" onclick="toggleWsDropdown()" title="Switch workspace" style="cursor:pointer">&#128193; test-workspace &#9662;</div>
<div class="ws-dropdown" id="wsDropdown"></div>
</div>
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">&#128465; Clear</button>
</div>
</div>
<div class="messages" id="messages">
<div class="empty-state" id="emptyState">
<div class="empty-logo">🦉</div>
<h2>What can I help with?</h2>
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
<div class="suggestion-grid">
<button class="suggestion" data-msg="What files are in this workspace?">📁 What files are in this workspace?</button>
<button class="suggestion" data-msg="What's on my schedule today?">📋 What's on my schedule today?</button>
<button class="suggestion" data-msg="Help me plan a small project.">🗺 Help me plan a small project.</button>
</div>
</div>
<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="reconnect-banner" id="reconnectBanner">
<span id="reconnectMsg">&#9888; A response may have been in progress when you last left. Reload messages?</span>
<div style="display:flex;gap:8px;flex-shrink:0">
<button class="reconnect-btn" onclick="dismissReconnect()">Dismiss</button>
<button class="reconnect-btn" onclick="refreshSession()">&#8635; Reload</button>
</div>
</div>
<div class="approval-card" id="approvalCard">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Dangerous command — approval required
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-btns">
<button class="approval-btn once" onclick="respondApproval('once')">&#10003; Allow once</button>
<button class="approval-btn session" onclick="respondApproval('session')">&#128274; Allow this session</button>
<button class="approval-btn always" onclick="respondApproval('always')">&#9734; Always allow</button>
<button class="approval-btn deny" onclick="respondApproval('deny')">&#10005; Deny</button>
</div>
</div>
</div>
<!-- Activity bar: shows tool progress / status above composer (not inside input) -->
<div id="activityBar" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;">
<div id="activityBarInner" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);font-size:12px;color:var(--muted);animation:fadeIn .15s ease;">
<span id="activityIcon" style="font-size:13px;opacity:.6">&#9881;</span>
<span id="activityText" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<button id="btnCancel" onclick="cancelStream()" style="display:none;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.35);color:#e94560;font-size:11px;font-weight:600;padding:3px 10px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:background .15s" title="Cancel this task">&#9632; Cancel</button>
<button id="btnDismissStatus" onclick="setStatus('')" style="display:none;background:none;border:none;color:var(--muted);font-size:14px;line-height:1;cursor:pointer;padding:0 2px;opacity:.5;flex-shrink:0" title="Dismiss">&#x2715;</button>
<span id="activityDots" style="display:flex;gap:3px;align-items:center">
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite"></span>
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .22s infinite"></span>
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .44s infinite"></span>
</span>
</div>
</div>
<div class="composer-wrap" id="composerWrap">
<div class="composer-box" id="composerBox">
<div class="drop-hint" id="dropHint">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Drop files to upload to workspace
</div>
<div class="attach-tray" id="attachTray"></div>
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer">
<div class="composer-left">
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env" style="display:none">
<button class="icon-btn" id="btnAttach" title="Attach files">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
</div>
<div class="composer-right">
<button class="send-btn" id="btnSend">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
Send
</button>
</div>
</div>
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
</div>
</div>
</main>
<aside class="rightpanel">
<div class="resize-handle" id="rightpanelResize"></div>
<div class="panel-header">
<span>Workspace</span>
<div class="panel-actions">
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">&#43;</button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir('.')">&#8635;</button>
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview">&#10005;</button>
</div>
</div>
<div class="file-tree" id="fileTree"></div>
<div class="preview-area" id="previewArea">
<div class="preview-path" id="previewPath">
<span id="previewPathText"></span>
<span class="preview-badge" id="previewBadge"></span>
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer">&#8681; Download</button>
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none" onclick="toggleEditMode()">&#9998; Edit</button>
</div>
<pre class="preview-code" id="previewCode"></pre>
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
<div class="preview-md" id="previewMd" style="display:none"></div>
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:#e2e8f0;border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
</div>
</aside>
</div>
<div class="toast" id="toast"></div>
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>
<script src="/static/sessions.js"></script>
<script src="/static/messages.js"></script>
<script src="/static/panels.js"></script>
<script src="/static/boot.js"></script>
</body>
</html>

310
static/messages.js Normal file
View File

@@ -0,0 +1,310 @@
async function send(){
const text=$('msg').value.trim();
if(!text&&!S.pendingFiles.length)return;
// Don't send while an inline message edit is active
if(document.querySelector('.msg-edit-area'))return;
// If busy, queue the message instead of dropping it
if(S.busy){
if(text){
MSG_QUEUE.push(text);
$('msg').value='';autoResize();
updateQueueBadge();
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'\u2026':''}"`,2000);
}
return;
}
if(!S.session){await newSession();await renderSessionList();}
const activeSid=S.session.session_id;
setStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':'Sending…');
let uploaded=[];
try{uploaded=await uploadPendingFiles();}
catch(e){if(!text){setStatus(`${e.message}`);return;}}
let msgText=text;
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploaded.join(', ')}`;
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploaded.join(', ')}]`;
if(!msgText){setStatus('Nothing to send');return;}
$('msg').value='';autoResize();
const displayText=text||(uploaded.length?`Uploaded: ${uploaded.join(', ')}`:'(file upload)');
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploaded:undefined};
S.toolCalls=[]; // clear tool calls from previous turn
clearLiveToolCards(); // clear any leftover live cards from last turn
S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true); // activity bar shown via setBusy
INFLIGHT[activeSid]={messages:[...S.messages],uploaded};
startApprovalPolling(activeSid);
S.activeStreamId = null; // will be set after stream starts
// Set provisional title from user message immediately so session appears
// in the sidebar right away with a meaningful name (server may refine later)
if(S.session&&(S.session.title==='Untitled'||!S.session.title)){
const provisionalTitle=displayText.slice(0,64);
S.session.title=provisionalTitle;
syncTopbar();
// Persist it and refresh the sidebar now -- don't wait for done
api('/api/session/rename',{method:'POST',body:JSON.stringify({
session_id:activeSid, title:provisionalTitle
})}).catch(()=>{}); // fire-and-forget, server refines on done
renderSessionList(); // session appears in sidebar immediately
} else {
renderSessionList(); // ensure it's visible even if already titled
}
// Start the agent via POST, get a stream_id back
let streamId;
try{
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
session_id:activeSid,message:msgText,
model:S.session.model||$('modelSelect').value,workspace:S.session.workspace,
attachments:uploaded.length?uploaded:undefined
})});
streamId=startData.stream_id;
S.activeStreamId = streamId;
markInflight(activeSid, streamId);
// Show Cancel button
const cancelBtn=$('btnCancel');
if(cancelBtn) cancelBtn.style.display='';
}catch(e){
delete INFLIGHT[activeSid];
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();removeThinking();
S.messages.push({role:'assistant',content:`**Error:** ${e.message}`});
renderMessages();setBusy(false);setStatus('Error: '+e.message);
return;
}
// Open SSE stream and render tokens live
let assistantText='';
let assistantRow=null;
let assistantBody=null;
function ensureAssistantRow(){
if(assistantRow)return;
removeThinking();
const tr=$('toolRunningRow');if(tr)tr.remove();
$('emptyState').style.display='none';
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';
role.appendChild(icon);role.appendChild(lbl);
assistantRow.appendChild(role);assistantRow.appendChild(assistantBody);
$('msgInner').appendChild(assistantRow);
}
const es=new EventSource(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`);
es.addEventListener('token',e=>{
// Guard: if the user switched sessions, don't write tokens to the wrong DOM
if(!S.session||S.session.session_id!==activeSid) return;
const d=JSON.parse(e.data);
assistantText+=d.text;
ensureAssistantRow();
assistantBody.innerHTML=renderMd(assistantText);
$('messages').scrollTop=$('messages').scrollHeight;
});
es.addEventListener('tool',e=>{
const d=JSON.parse(e.data);
// Only update activity bar if viewing this session
if(S.session&&S.session.session_id===activeSid){
setStatus(`${d.name}${d.preview?' · '+d.preview.slice(0,55):''}`);
}
if(!S.session||S.session.session_id!==activeSid) return;
removeThinking();
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
// Append card to the stable live container -- no renderMessages() call
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false};
S.toolCalls.push(tc);
appendLiveToolCard(tc);
$('messages').scrollTop=$('messages').scrollHeight;
});
es.addEventListener('approval',e=>{
const d=JSON.parse(e.data);
// Tag the approval with the session that owns it so respondApproval uses correct sid
d._session_id=activeSid;
showApprovalCard(d);
});
es.addEventListener('done',e=>{
es.close();
const d=JSON.parse(e.data);
delete INFLIGHT[activeSid];
clearInflight();
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
// Only clear active stream state if this is the currently viewed session
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
}
if(S.session&&S.session.session_id===activeSid){
S.session=d.session;S.messages=d.session.messages||[];
// Populate tool calls from server-extracted metadata (has snippets)
if(d.session.tool_calls&&d.session.tool_calls.length){
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
} else {
S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true}));
}
if(uploaded.length){
const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
if(lastUser)lastUser.attachments=uploaded;
}
clearLiveToolCards();
// Set S.busy=false BEFORE renderMessages so the settled tool card
// block (!S.busy guard) can render the final grouped cards.
S.busy=false;
syncTopbar();renderMessages();loadDir('.');
}
renderSessionList();setBusy(false);setStatus('');
});
es.addEventListener('error',e=>{
es.close();
delete INFLIGHT[activeSid];
clearInflight();
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
}
let msg='Connection error';
try{const d=JSON.parse(e.data);msg=d.message||msg;}catch(_){}
if(S.session&&S.session.session_id===activeSid){
clearLiveToolCards();
if(!assistantText){removeThinking();}
S.messages.push({role:'assistant',content:`**Error:** ${msg}`});
renderMessages();
}
if(!S.session || !INFLIGHT[S.session.session_id]){
setBusy(false);setStatus('Error: '+msg);
}
});
es.addEventListener('cancel',e=>{
es.close();
delete INFLIGHT[activeSid];
clearInflight();
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
}
if(S.session&&S.session.session_id===activeSid){
clearLiveToolCards();
if(!assistantText){removeThinking();}
S.messages.push({role:'assistant',content:'*Task cancelled.*'});
renderMessages();
}
renderSessionList();
if(!S.session || !INFLIGHT[S.session.session_id]){
setBusy(false);setStatus('');
}
});
// Handle SSE connection errors (network drop etc)
es.onerror=()=>{
if(es.readyState===EventSource.CLOSED){
delete INFLIGHT[activeSid];
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
const _cbo=$('btnCancel');if(_cbo)_cbo.style.display='none';
}
if(!assistantText&&S.session&&S.session.session_id===activeSid){
removeThinking();
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});
renderMessages();
}
if(!S.session || !INFLIGHT[S.session.session_id]){
setBusy(false);setStatus('');
}
}
};
}
function transcript(){
const lines=[`# Hermes session ${S.session?.session_id||''}`,``,
`Workspace: ${S.session?.workspace||''}`,`Model: ${S.session?.model||''}`,``];
for(const m of S.messages){
if(!m||m.role==='tool')continue;
let c=m.content||'';
if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('\n');
const ct=String(c).trim();
if(!ct&&!m.attachments?.length)continue;
const attach=m.attachments?.length?`\n\n_Files: ${m.attachments.join(', ')}_`:'';
lines.push(`## ${m.role}`,'',ct+attach,'');
}
return lines.join('\n');
}
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';}
// ── Approval polling ──
let _approvalPollTimer = null;
// showApprovalCard moved above respondApproval
function hideApprovalCard() {
$("approvalCard").classList.remove("visible");
$("approvalCmd").textContent = "";
$("approvalDesc").textContent = "";
}
// Track session_id of the active approval so respond goes to the right session
let _approvalSessionId = null;
function showApprovalCard(pending) {
$("approvalDesc").textContent = pending.description || "";
$("approvalCmd").textContent = pending.command || "";
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
$("approvalDesc").textContent = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
_approvalSessionId = pending._session_id || (S.session && S.session.session_id) || null;
$("approvalCard").classList.add("visible");
}
async function respondApproval(choice) {
const sid = _approvalSessionId || (S.session && S.session.session_id);
if (!sid) return;
hideApprovalCard();
_approvalSessionId = null;
try {
await api("/api/approval/respond", {
method: "POST",
body: JSON.stringify({ session_id: sid, choice })
});
} catch(e) { setStatus("Approval error: " + e.message); }
}
function startApprovalPolling(sid) {
stopApprovalPolling();
_approvalPollTimer = setInterval(async () => {
if (!S.busy || !S.session || S.session.session_id !== sid) {
stopApprovalPolling(); hideApprovalCard(); return;
}
try {
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid));
if (data.pending) { data.pending._session_id=sid; showApprovalCard(data.pending); }
else { hideApprovalCard(); }
} catch(e) { /* ignore poll errors */ }
}, 1500);
}
function stopApprovalPolling() {
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
}
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──

600
static/panels.js Normal file
View File

@@ -0,0 +1,600 @@
let _currentPanel = 'chat';
let _skillsData = null; // cached skills list
async function switchPanel(name) {
_currentPanel = name;
// Update nav tabs
document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.panel === name));
// Update panel views
document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active'));
const panelEl = $('panel' + name.charAt(0).toUpperCase() + name.slice(1));
if (panelEl) panelEl.classList.add('active');
// Lazy-load panel data
if (name === 'tasks') await loadCrons();
if (name === 'skills') await loadSkills();
if (name === 'memory') await loadMemory();
if (name === 'workspaces') await loadWorkspacesPanel();
if (name === 'todos') loadTodos();
}
// ── Cron panel ──
async function loadCrons() {
const box = $('cronList');
try {
const data = await api('/api/crons');
if (!data.jobs || !data.jobs.length) {
box.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No scheduled jobs found.</div>';
return;
}
box.innerHTML = '';
for (const job of data.jobs) {
const item = document.createElement('div');
item.className = 'cron-item';
item.id = 'cron-' + job.id;
const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
const statusLabel = job.enabled === false ? 'off' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A';
const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'never';
item.innerHTML = `
<div class="cron-header" onclick="toggleCron('${job.id}')">
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
<span class="cron-status ${statusClass}">${statusLabel}</span>
</div>
<div class="cron-body" id="cron-body-${job.id}">
<div class="cron-schedule">&#128337; ${esc(job.schedule_display || job.schedule?.expression || '')} &nbsp;|&nbsp; Next: ${esc(nextRun)} &nbsp;|&nbsp; Last: ${esc(lastRun)}</div>
<div class="cron-prompt">${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}</div>
<div class="cron-actions">
<button class="cron-btn run" onclick="cronRun('${job.id}')">&#9654; Run now</button>
${statusLabel==='paused'
? `<button class="cron-btn" onclick="cronResume('${job.id}')">&#9654;&#9474; Resume</button>`
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">&#9646;&#9646; Pause</button>`}
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">&#9998; Edit</button>
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">&#128465; Delete</button>
</div>
<!-- Inline edit form, hidden by default -->
<div id="cron-edit-${job.id}" style="display:none;margin-top:8px;border-top:1px solid var(--border);padding-top:8px">
<input id="cron-edit-name-${job.id}" placeholder="Job name" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:5px;box-sizing:border-box">
<input id="cron-edit-schedule-${job.id}" placeholder="Schedule" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:5px;box-sizing:border-box">
<textarea id="cron-edit-prompt-${job.id}" rows="3" placeholder="Prompt" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:5px;box-sizing:border-box"></textarea>
<div id="cron-edit-err-${job.id}" style="font-size:11px;color:var(--accent);display:none;margin-bottom:5px"></div>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="cronEditSave('${job.id}')">Save</button>
<button class="cron-btn" style="flex:1" onclick="cronEditClose('${job.id}')">Cancel</button>
</div>
</div>
<div id="cron-output-${job.id}">
<div class="cron-last-header" style="display:flex;align-items:center;justify-content:space-between">
<span>Last output</span>
<button class="cron-btn" style="padding:1px 8px;font-size:10px" onclick="loadCronHistory('${job.id}',this)">All runs</button>
</div>
<div class="cron-last" id="cron-out-text-${job.id}" style="color:var(--muted);font-size:11px">Loading…</div>
<div id="cron-history-${job.id}" style="display:none"></div>
</div>
</div>`;
box.appendChild(item);
// Eagerly load last output for visible items
loadCronOutput(job.id);
}
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
}
function toggleCronForm(){
const form=$('cronCreateForm');
if(!form)return;
const open=form.style.display!=='none';
form.style.display=open?'none':'';
if(!open){
$('cronFormName').value='';
$('cronFormSchedule').value='';
$('cronFormPrompt').value='';
$('cronFormDeliver').value='local';
$('cronFormError').style.display='none';
$('cronFormName').focus();
}
}
async function submitCronCreate(){
const name=$('cronFormName').value.trim();
const schedule=$('cronFormSchedule').value.trim();
const prompt=$('cronFormPrompt').value.trim();
const deliver=$('cronFormDeliver').value;
const errEl=$('cronFormError');
errEl.style.display='none';
if(!schedule){errEl.textContent='Schedule is required (e.g. "0 9 * * *" or "every 1h")';errEl.style.display='';return;}
if(!prompt){errEl.textContent='Prompt is required';errEl.style.display='';return;}
try{
await api('/api/crons/create',{method:'POST',body:JSON.stringify({name:name||undefined,schedule,prompt,deliver})});
toggleCronForm();
showToast('Job created ✓');
await loadCrons();
}catch(e){
errEl.textContent='Error: '+e.message;errEl.style.display='';
}
}
function _cronOutputSnippet(content) {
// Extract the response body from a cron output .md file
const lines = content.split('\n');
const responseIdx = lines.findIndex(l => l.startsWith('## Response') || l.startsWith('# Response'));
const body = (responseIdx >= 0 ? lines.slice(responseIdx + 1) : lines).join('\n').trim();
return body.slice(0, 600) || '(empty)';
}
async function loadCronOutput(jobId) {
try {
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=1`);
const el = $('cron-out-text-' + jobId);
if (!el) return;
if (!data.outputs || !data.outputs.length) { el.textContent = '(no runs yet)'; return; }
const out = data.outputs[0];
const ts = out.filename.replace('.md','').replace(/_/g,' ');
el.textContent = ts + '\n\n' + _cronOutputSnippet(out.content);
} catch(e) { /* ignore */ }
}
async function loadCronHistory(jobId, btn) {
const histEl = $('cron-history-' + jobId);
if (!histEl) return;
// Toggle: if already open, close it
if (histEl.style.display !== 'none') {
histEl.style.display = 'none';
if (btn) btn.textContent = 'All runs';
return;
}
if (btn) btn.textContent = 'Loading…';
try {
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`);
if (!data.outputs || !data.outputs.length) {
histEl.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 0">(no runs yet)</div>';
} else {
histEl.innerHTML = data.outputs.map((out, i) => {
const ts = out.filename.replace('.md','').replace(/_/g,' ');
const snippet = _cronOutputSnippet(out.content);
const id = `cron-hist-run-${jobId}-${i}`;
return `<div style="border-top:1px solid var(--border);padding:6px 0">
<div style="display:flex;align-items:center;justify-content:space-between;cursor:pointer" onclick="document.getElementById('${id}').style.display=document.getElementById('${id}').style.display==='none'?'':'none'">
<span style="font-size:11px;font-weight:600;color:var(--muted)">${esc(ts)}</span>
<span style="font-size:10px;color:var(--muted);opacity:.6">▸</span>
</div>
<div id="${id}" style="display:none;font-size:11px;color:var(--muted);white-space:pre-wrap;line-height:1.5;margin-top:4px;max-height:200px;overflow-y:auto">${esc(snippet)}</div>
</div>`;
}).join('');
}
histEl.style.display = '';
if (btn) btn.textContent = 'Hide runs';
} catch(e) {
if (btn) btn.textContent = 'All runs';
}
}
function toggleCron(id) {
const body = $('cron-body-' + id);
if (body) body.classList.toggle('open');
}
async function cronRun(id) {
try {
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job triggered ✓');
setTimeout(() => loadCronOutput(id), 5000);
} catch(e) { showToast('Run failed: ' + e.message, 4000); }
}
async function cronPause(id) {
try {
await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job paused');
await loadCrons();
} catch(e) { showToast('Pause failed: ' + e.message, 4000); }
}
async function cronResume(id) {
try {
await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job resumed ✓');
await loadCrons();
} catch(e) { showToast('Resume failed: ' + e.message, 4000); }
}
function cronEditOpen(id, job) {
const form = $('cron-edit-' + id);
if (!form) return;
$('cron-edit-name-' + id).value = job.name || '';
$('cron-edit-schedule-' + id).value = job.schedule_display || (job.schedule && job.schedule.expression) || job.schedule || '';
$('cron-edit-prompt-' + id).value = job.prompt || '';
const errEl = $('cron-edit-err-' + id);
if (errEl) errEl.style.display = 'none';
form.style.display = '';
}
function cronEditClose(id) {
const form = $('cron-edit-' + id);
if (form) form.style.display = 'none';
}
async function cronEditSave(id) {
const name = $('cron-edit-name-' + id).value.trim();
const schedule = $('cron-edit-schedule-' + id).value.trim();
const prompt = $('cron-edit-prompt-' + id).value.trim();
const errEl = $('cron-edit-err-' + id);
if (!schedule) { errEl.textContent = 'Schedule is required'; errEl.style.display = ''; return; }
if (!prompt) { errEl.textContent = 'Prompt is required'; errEl.style.display = ''; return; }
try {
const updates = {job_id: id, schedule, prompt};
if (name) updates.name = name;
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
showToast('Job updated ✓');
await loadCrons();
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
}
async function cronDelete(id) {
if (!confirm('Delete this cron job? This cannot be undone.')) return;
try {
await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job deleted');
await loadCrons();
} catch(e) { showToast('Delete failed: ' + e.message, 4000); }
}
function loadTodos() {
const panel = $('todoPanel');
if (!panel) return;
// Parse the most recent todo state from message history
let todos = [];
for (let i = S.messages.length - 1; i >= 0; i--) {
const m = S.messages[i];
if (m && m.role === 'tool') {
try {
const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
if (d && Array.isArray(d.todos) && d.todos.length) {
todos = d.todos;
break;
}
} catch(e) {}
}
}
if (!todos.length) {
panel.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:4px 0">No active task list in this session.</div>';
return;
}
const statusIcon = {pending:'○', in_progress:'◉', completed:'✓', cancelled:'✗'};
const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'};
panel.innerHTML = todos.map(t => `
<div style="display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);">
<span style="font-size:14px;flex-shrink:0;margin-top:1px;color:${statusColor[t.status]||'var(--muted)'}">${statusIcon[t.status]||'○'}</span>
<div style="flex:1;min-width:0">
<div style="font-size:13px;color:${t.status==='completed'?'var(--muted)':t.status==='in_progress'?'var(--text)':'var(--text)'};${t.status==='completed'?'text-decoration:line-through;opacity:.5':''};line-height:1.4">${esc(t.content)}</div>
<div style="font-size:10px;color:var(--muted);margin-top:2px;opacity:.6">${esc(t.id)} · ${esc(t.status)}</div>
</div>
</div>`).join('');
}
async function clearConversation() {
if(!S.session) return;
if(!confirm('Clear all messages in this conversation? This cannot be undone.')) return;
try {
const data = await api('/api/session/clear', {method:'POST',
body: JSON.stringify({session_id: S.session.session_id})});
S.session = data.session;
S.messages = [];
S.toolCalls = [];
syncTopbar();
renderMessages();
showToast('Conversation cleared');
} catch(e) { setStatus('Clear failed: ' + e.message); }
}
// ── Skills panel ──
async function loadSkills() {
if (_skillsData) { renderSkills(_skillsData); return; }
const box = $('skillsList');
try {
const data = await api('/api/skills');
_skillsData = data.skills || [];
renderSkills(_skillsData);
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
}
function renderSkills(skills) {
const query = ($('skillsSearch').value || '').toLowerCase();
const filtered = query ? skills.filter(s =>
(s.name||'').toLowerCase().includes(query) ||
(s.description||'').toLowerCase().includes(query) ||
(s.category||'').toLowerCase().includes(query)
) : skills;
// Group by category
const cats = {};
for (const s of filtered) {
const cat = s.category || '(general)';
if (!cats[cat]) cats[cat] = [];
cats[cat].push(s);
}
const box = $('skillsList');
box.innerHTML = '';
if (!filtered.length) { box.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:12px">No skills match.</div>'; return; }
for (const [cat, items] of Object.entries(cats).sort()) {
const sec = document.createElement('div');
sec.className = 'skills-category';
sec.innerHTML = `<div class="skills-cat-header">&#128193; ${esc(cat)} <span style="opacity:.5">(${items.length})</span></div>`;
for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) {
const el = document.createElement('div');
el.className = 'skill-item';
el.innerHTML = `<span class="skill-name">${esc(skill.name)}</span><span class="skill-desc">${esc(skill.description||'')}</span>`;
el.onclick = () => openSkill(skill.name, el);
sec.appendChild(el);
}
box.appendChild(sec);
}
}
function filterSkills() {
if (_skillsData) renderSkills(_skillsData);
}
async function openSkill(name, el) {
// Highlight active skill
document.querySelectorAll('.skill-item').forEach(e => e.classList.remove('active'));
if (el) el.classList.add('active');
try {
const data = await api(`/api/skills/content?name=${encodeURIComponent(name)}`);
// Show skill content in right panel preview
$('previewPathText').textContent = name + '.md';
$('previewBadge').textContent = 'skill';
$('previewBadge').className = 'preview-badge md';
showPreview('md');
$('previewMd').innerHTML = renderMd(data.content || '(no content)');
$('previewArea').classList.add('visible');
$('fileTree').style.display = 'none';
} catch(e) { setStatus('Could not load skill: ' + e.message); }
}
// ── Skill create/edit form ──
let _editingSkillName = null;
function toggleSkillForm(prefillName, prefillCategory, prefillContent) {
const form = $('skillCreateForm');
if (!form) return;
const open = form.style.display !== 'none';
if (open) { form.style.display = 'none'; _editingSkillName = null; return; }
$('skillFormName').value = prefillName || '';
$('skillFormCategory').value = prefillCategory || '';
$('skillFormContent').value = prefillContent || '';
$('skillFormError').style.display = 'none';
_editingSkillName = prefillName || null;
form.style.display = '';
$('skillFormName').focus();
}
async function submitSkillSave() {
const name = ($('skillFormName').value||'').trim().toLowerCase().replace(/\s+/g, '-');
const category = ($('skillFormCategory').value||'').trim();
const content = $('skillFormContent').value;
const errEl = $('skillFormError');
errEl.style.display = 'none';
if (!name) { errEl.textContent = 'Skill name is required'; errEl.style.display = ''; return; }
if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; }
try {
await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})});
showToast(_editingSkillName ? 'Skill updated ✓' : 'Skill created ✓');
_skillsData = null;
toggleSkillForm();
await loadSkills();
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
}
// ── Memory inline edit ──
let _memoryData = null;
function toggleMemoryEdit() {
const form = $('memoryEditForm');
if (!form) return;
const open = form.style.display !== 'none';
if (open) { form.style.display = 'none'; return; }
$('memEditSection').textContent = 'memory (notes)';
$('memEditContent').value = _memoryData ? (_memoryData.memory || '') : '';
$('memEditError').style.display = 'none';
form.style.display = '';
}
function closeMemoryEdit() {
const form = $('memoryEditForm');
if (form) form.style.display = 'none';
}
async function submitMemorySave() {
const content = $('memEditContent').value;
const errEl = $('memEditError');
errEl.style.display = 'none';
try {
await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})});
showToast('Memory saved ✓');
closeMemoryEdit();
await loadMemory(true);
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
}
// ── Workspace management ──
let _workspaceList = []; // cached from /api/workspaces
function getWorkspaceFriendlyName(path){
// Look up the friendly name from the workspace list cache, fallback to last path segment
if(_workspaceList && _workspaceList.length){
const match=_workspaceList.find(w=>w.path===path);
if(match && match.name) return match.name;
}
return path.split('/').filter(Boolean).pop()||path;
}
async function loadWorkspaceList(){
try{
const data = await api('/api/workspaces');
_workspaceList = data.workspaces || [];
// Refresh sidebar display if we have a current session
if(S.session && S.session.workspace) {
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');
if(sidebarName) sidebarName.textContent=getWorkspaceFriendlyName(S.session.workspace);
if(sidebarPath) sidebarPath.textContent=S.session.workspace;
}
return data;
}catch(e){ return {workspaces:[], last:''}; }
}
function renderWorkspaceDropdown(workspaces, currentWs){
const dd = $('wsDropdown');
if(!dd)return;
dd.innerHTML='';
for(const w of workspaces){
const opt=document.createElement('div');
opt.className='ws-opt'+(w.path===currentWs?' active':'');
opt.innerHTML=`<span class="ws-opt-name">${esc(w.name)}</span><span class="ws-opt-path">${esc(w.path)}</span>`;
opt.onclick=async()=>{
closeWsDropdown();
if(!S.session||w.path===S.session.workspace)return;
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, workspace:w.path, model:S.session.model
})});
S.session.workspace=w.path;
syncTopbar();
await loadDir('.');
showToast(`Switched to ${w.name}`);
};
dd.appendChild(opt);
}
// Divider + Manage link
const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div);
const mgmt=document.createElement('div');mgmt.className='ws-opt ws-manage';
mgmt.innerHTML='&#9881; Manage workspaces';
mgmt.onclick=()=>{closeWsDropdown();switchPanel('workspaces');};
dd.appendChild(mgmt);
}
function toggleWsDropdown(){
const dd=$('wsDropdown');
if(!dd)return;
const open=dd.classList.contains('open');
if(open){closeWsDropdown();}
else{
loadWorkspaceList().then(data=>{
renderWorkspaceDropdown(data.workspaces, S.session?S.session.workspace:'');
dd.classList.add('open');
});
}
}
function closeWsDropdown(){
const dd=$('wsDropdown');
if(dd)dd.classList.remove('open');
}
document.addEventListener('click',e=>{
if(!e.target.closest('#wsChipWrap'))closeWsDropdown();
});
async function loadWorkspacesPanel(){
const panel=$('workspacesPanel');
if(!panel)return;
const data=await loadWorkspaceList();
renderWorkspacesPanel(data.workspaces);
}
function renderWorkspacesPanel(workspaces){
const panel=$('workspacesPanel');
panel.innerHTML='';
for(const w of workspaces){
const row=document.createElement('div');row.className='ws-row';
row.innerHTML=`
<div class="ws-row-info">
<div class="ws-row-name">${esc(w.name)}</div>
<div class="ws-row-path">${esc(w.path)}</div>
</div>
<div class="ws-row-actions">
<button class="ws-action-btn" title="Use in current session" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">&#8594; Use</button>
<button class="ws-action-btn danger" title="Remove" onclick="removeWorkspace('${esc(w.path)}')">&#10005;</button>
</div>`;
panel.appendChild(row);
}
const addRow=document.createElement('div');addRow.className='ws-add-row';
addRow.innerHTML=`
<input id="wsAddInput" placeholder="Add workspace path (e.g. /home/user/my-project)" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
<button class="ws-action-btn" onclick="addWorkspace()">&#43; Add</button>`;
panel.appendChild(addRow);
const hint=document.createElement('div');
hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px';
hint.textContent='Paths are validated as existing directories before saving.';
panel.appendChild(hint);
}
async function addWorkspace(){
const input=$('wsAddInput');
const path=(input?input.value:'').trim();
if(!path)return;
try{
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})});
_workspaceList=data.workspaces;
renderWorkspacesPanel(data.workspaces);
if(input)input.value='';
showToast('Workspace added');
}catch(e){setStatus('Add failed: '+e.message);}
}
async function removeWorkspace(path){
if(!confirm(`Remove workspace "${path}"?`))return;
try{
const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})});
_workspaceList=data.workspaces;
renderWorkspacesPanel(data.workspaces);
showToast('Workspace removed');
}catch(e){setStatus('Remove failed: '+e.message);}
}
async function switchToWorkspace(path,name){
if(!S.session)return;
try{
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, workspace:path, model:S.session.model
})});
S.session.workspace=path;
syncTopbar();
await loadDir('.');
showToast(`Switched to ${name}`);
}catch(e){setStatus('Switch failed: '+e.message);}
}
// ── Memory panel ──
async function loadMemory(force) {
const panel = $('memoryPanel');
try {
const data = await api('/api/memory');
_memoryData = data; // cache for edit form
const fmtTime = ts => ts ? new Date(ts*1000).toLocaleString() : '';
panel.innerHTML = `
<div class="memory-section">
<div class="memory-section-title">
&#129504; My Notes
<span class="memory-mtime">${fmtTime(data.memory_mtime)}</span>
</div>
${data.memory
? `<div class="memory-content preview-md">${renderMd(data.memory)}</div>`
: '<div class="memory-empty">No notes yet.</div>'}
</div>
<div class="memory-section">
<div class="memory-section-title">
&#128100; User Profile
<span class="memory-mtime">${fmtTime(data.user_mtime)}</span>
</div>
${data.user
? `<div class="memory-content preview-md">${renderMd(data.user)}</div>`
: '<div class="memory-empty">No profile yet.</div>'}
</div>`;
} catch(e) { panel.innerHTML = `<div style="color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
}
// Drag and drop
const wrap=$('composerWrap');let dragCounter=0;
document.addEventListener('dragover',e=>e.preventDefault());
document.addEventListener('dragenter',e=>{e.preventDefault();if(e.dataTransfer.types.includes('Files')){dragCounter++;wrap.classList.add('drag-over');}});
document.addEventListener('dragleave',e=>{dragCounter--;if(dragCounter<=0){dragCounter=0;wrap.classList.remove('drag-over');}});
document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.classList.remove('drag-over');const files=Array.from(e.dataTransfer.files);if(files.length){addFiles(files);$('msg').focus();}});
// Event wiring

206
static/sessions.js Normal file
View File

@@ -0,0 +1,206 @@
async function newSession(flash){
MSG_QUEUE.length=0;updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
const inheritWs=S.session?S.session.workspace:null;
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
S.session=data.session;S.messages=data.session.messages||[];
if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id);
syncTopbar();await loadDir('.');renderMessages();
// don't call renderSessionList here - callers do it when needed
}
async function loadSession(sid){
stopApprovalPolling();hideApprovalCard();
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
S.session=data.session;
localStorage.setItem('hermes-webui-session',S.session.session_id);
// B9: sanitize empty assistant messages that can appear when agent only ran tool calls
data.session.messages=(data.session.messages||[]).filter(m=>{
if(!m||!m.role)return false;
if(m.role==='tool')return false;
if(m.role==='assistant'){let c=m.content||'';if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('');return String(c).trim().length>0;}
return true;
});
if(INFLIGHT[sid]){
S.messages=INFLIGHT[sid].messages;
// Restore live tool cards for this in-flight session
clearLiveToolCards();
for(const tc of (S.toolCalls||[])){
if(tc&&tc.name) appendLiveToolCard(tc);
}
syncTopbar();await loadDir('.');renderMessages();appendThinking();
setBusy(true);setStatus('Hermes is thinking\u2026');
startApprovalPolling(sid);
}else{
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
S.messages=data.session.messages||[];
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
// Reset per-session visual state: the viewed session is idle even if another
// session's stream is still running in the background.
// We directly update the DOM instead of calling setBusy(false), because
// setBusy(false) drains MSG_QUEUE which we don't want here.
S.busy=false;
S.activeStreamId=null;
$('btnSend').disabled=false;
$('btnSend').style.opacity='1';
const _dots=$('activityDots');if(_dots)_dots.style.display='none';
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
clearLiveToolCards();
syncTopbar();await loadDir('.');renderMessages();highlightCode();
}
}
let _allSessions = []; // cached for search filter
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
async function renderSessionList(){
try{
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const data=await api('/api/sessions');
_allSessions = data.sessions||[];
renderSessionListFromCache(); // no-ops if rename is in progress
}catch(e){console.warn('renderSessionList',e);}
}
let _searchDebounceTimer = null;
let _contentSearchResults = []; // results from /api/sessions/search content scan
function filterSessions(){
// Immediate client-side title filter (no flicker)
renderSessionListFromCache();
// Debounced content search via API for message text
const q = ($('sessionSearch').value || '').trim();
clearTimeout(_searchDebounceTimer);
if (!q) { _contentSearchResults = []; return; }
_searchDebounceTimer = setTimeout(async () => {
try {
const data = await api(`/api/sessions/search?q=${encodeURIComponent(q)}&content=1&depth=5`);
const titleIds = new Set(_allSessions.filter(s => (s.title||'Untitled').toLowerCase().includes(q.toLowerCase())).map(s=>s.session_id));
_contentSearchResults = (data.sessions||[]).filter(s => s.match_type === 'content' && !titleIds.has(s.session_id));
renderSessionListFromCache();
} catch(e) { /* ignore */ }
}, 350);
}
function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return;
const q=($('sessionSearch').value||'').toLowerCase();
const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions;
// Merge content matches (deduped): content matches appended after title matches
const titleIds=new Set(titleMatches.map(s=>s.session_id));
const sessions=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
const list=$('sessionList');list.innerHTML='';
// Date grouping: Today / Yesterday / Earlier
const now=Date.now();
const ONE_DAY=86400000;
let lastGroup='';
for(const s of sessions.slice(0,50)){
const ts=(s.updated_at||0)*1000;
const group=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier';
if(group!==lastGroup){
lastGroup=group;
const hdr=document.createElement('div');
hdr.style.cssText='font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:10px 10px 4px;opacity:.8;';
hdr.textContent=group;
list.appendChild(hdr);
}
const el=document.createElement('div');
const isActive=S.session&&s.session_id===S.session.session_id;
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'');
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
const title=document.createElement('span');
title.className='session-title';title.textContent=s.title||'Untitled';
title.title='Double-click to rename';
// Rename: called directly when we confirm it's a double-click
const startRename=()=>{
_renamingSid = s.session_id;
const inp=document.createElement('input');
inp.className='session-title-input';
inp.value=s.title||'Untitled';
['click','mousedown','dblclick','pointerdown'].forEach(ev=>
inp.addEventListener(ev, e2=>e2.stopPropagation())
);
const finish=async(save)=>{
_renamingSid = null;
if(save){
const newTitle=inp.value.trim()||'Untitled';
title.textContent=newTitle;
s.title=newTitle;
if(S.session&&S.session.session_id===s.session_id){S.session.title=newTitle;syncTopbar();}
try{await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:s.session_id,title:newTitle})});}
catch(err){setStatus('Rename failed: '+err.message);}
}
inp.replaceWith(title);
// Allow list re-renders again after a short delay
setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50);
};
inp.onkeydown=e2=>{
if(e2.key==='Enter'){e2.preventDefault();e2.stopPropagation();finish(true);}
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
};
// onblur: cancel only -- no accidental saves
inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(false); };
title.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
};
const trash=document.createElement('button');
trash.className='session-trash';trash.innerHTML='&#128465;';trash.title='Delete';
trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);};
el.appendChild(title);el.appendChild(trash);
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
// This prevents loadSession from firing on the first click of a double-click,
// which would re-render the list and destroy the dblclick target before it fires.
let _clickTimer=null;
el.onclick=async(e)=>{
if(_renamingSid) return; // ignore while any rename is active
if(e.target===trash||trash.contains(e.target)) return; // trash handles itself
clearTimeout(_clickTimer);
_clickTimer=setTimeout(async()=>{
_clickTimer=null;
if(_renamingSid) return;
await loadSession(s.session_id);renderSessionListFromCache();
}, 220);
};
el.ondblclick=async(e)=>{
e.stopPropagation();
e.preventDefault();
clearTimeout(_clickTimer); // cancel the pending single-click navigation
_clickTimer=null;
startRename();
};
list.appendChild(el);
}
}
async function deleteSession(sid){
if(!confirm('Delete this conversation?'))return;
try{
await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
}catch(e){setStatus(`Delete failed: ${e.message}`);return;}
if(S.session&&S.session.session_id===sid){
S.session=null;S.messages=[];S.entries=[];
localStorage.removeItem('hermes-webui-session');
// load the most recent remaining session, or show blank if none left
const remaining=await api('/api/sessions');
if(remaining.sessions&&remaining.sessions.length){
await loadSession(remaining.sessions[0].session_id);
}else{
$('topbarTitle').textContent='Hermes';
$('topbarMeta').textContent='Start a new conversation';
$('msgInner').innerHTML='';
$('emptyState').style.display='';
$('fileTree').innerHTML='';
}
}
showToast('Conversation deleted');
await renderSessionList();
}

450
static/style.css Normal file
View File

@@ -0,0 +1,450 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
: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;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
}
body{background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;}
.layout{display:flex;width:100%;height:100vh;}
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
.sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,#e8a030,var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px rgba(233,69,96,.3);}
.sidebar-header h1{font-size:15px;font-weight:600;}
.sidebar-section{padding:14px 14px 8px;}
.new-chat-btn{width:100%;padding:9px 12px;border-radius:9px;background:rgba(124,185,255,0.07);border:1px solid rgba(124,185,255,0.18);color:var(--blue);font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s;margin-bottom:8px;font-weight:500;}
.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::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-item{padding:8px 10px 8px 8px;border-radius:8px;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;}
.session-item:hover{background:rgba(255,255,255,0.06);color:var(--text);}
.session-item.active{background:rgba(124,185,255,0.1);color:var(--blue);border-left:2px solid var(--blue);padding-left:8px;}
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.session-trash{flex-shrink:0;opacity:0;font-size:13px;color:var(--muted);background:none;border:none;cursor:pointer;padding:0 2px;line-height:1;transition:opacity .15s,color .15s;}
.session-item:hover .session-trash{opacity:1;}
.session-trash:hover{color:var(--accent)!important;}
@keyframes newflash{0%{background:rgba(124,185,255,0.22);color:var(--blue);}100%{background:transparent;color:var(--muted);}}
.session-item.new-flash{animation:newflash 1.4s ease-out forwards;}
.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.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.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);}
/* ── 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-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-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: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);}
.approval-btn.session{border-color:rgba(124,185,255,0.3);color:var(--blue);}
.approval-btn.always{border-color:rgba(201,168,76,0.5);color:var(--gold);}
.approval-btn.deny{border-color:rgba(233,69,96,0.5);color:var(--accent);}
.approval-btn.deny:hover{background:rgba(233,69,96,0.12);}
/* Sidebar navigation tabs */
.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.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) */
.panel-view{display:none;flex:1;overflow:hidden;flex-direction:column;}
.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: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;}
.cron-status{font-size:10px;font-weight:700;padding:2px 7px;border-radius:99px;flex-shrink:0;}
.cron-status.active{background:rgba(34,197,94,.15);color:#4ade80;}
.cron-status.paused{background:rgba(201,168,76,.15);color:var(--gold);}
.cron-status.disabled{background:rgba(255,255,255,.07);color:var(--muted);}
.cron-status.error{background:rgba(233,69,96,.15);color:var(--accent);}
.cron-body{display:none;padding:0 11px 10px;border-top:1px solid var(--border);overflow:hidden;}
.cron-body.open{display:block;}
.cron-schedule{font-size:11px;color:var(--muted);margin:8px 0 6px;}
.cron-prompt{font-size:11px;color:var(--muted);line-height:1.55;max-height:80px;overflow-y:auto;background:rgba(0,0,0,.2);padding:6px 8px;border-radius:6px;white-space:pre-wrap;margin-bottom:8px;box-sizing:border-box;}
.cron-actions{display:flex;gap:6px;margin-bottom:8px;}
.cron-btn{padding:4px 10px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;}
.cron-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
.cron-btn.run{border-color:rgba(124,185,255,.4);color:var(--blue);}
.cron-btn.run:hover{background:rgba(124,185,255,.12);}
.cron-btn.pause{border-color:rgba(201,168,76,.4);color:var(--gold);}
.cron-last{font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:8px;max-height:220px;overflow-y:auto;white-space:pre-wrap;line-height:1.5;word-break:break-word;}
.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::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.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;}
/* Memory panel */
.memory-panel{flex:1;overflow-y:auto;padding:12px;}
.memory-section{margin-bottom:16px;}
.memory-section-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;}
.memory-mtime{font-size:10px;font-weight:400;text-transform:none;opacity:.6;}
.memory-content{font-size:12px;line-height:1.7;color:var(--text);}
.memory-content p{margin-bottom:6px;}
.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;}
.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: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;}
.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: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;}
.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.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;}
.messages-inner{max-width:800px;margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
.msg-row{padding:10px 0;}
.msg-row+.msg-row{border-top:none;}
.msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;}
.msg-role.user{color:rgba(124,185,255,0.65);}
.msg-role.assistant{color:rgba(201,168,76,0.6);}
.role-icon{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;}
.role-icon.user{background:rgba(124,185,255,0.15);color:var(--blue);border:1px solid rgba(124,185,255,0.2);}
.role-icon.assistant{background:rgba(201,168,76,0.15);color:var(--gold);border:1px solid rgba(201,168,76,0.2);}
.msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;}
.msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;}
.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;}
.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;}
.msg-body a{color:var(--blue);text-decoration:underline;}
.msg-body hr{border:none;border-top:1px solid var(--border);margin:14px 0;}
.msg-files{display:flex;flex-wrap:wrap;gap:6px;padding-left:30px;margin-bottom:10px;}
.msg-file-badge{display:flex;align-items:center;gap:5px;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.25);border-radius:6px;padding:4px 9px;font-size:12px;color:var(--blue);}
.thinking{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;padding-left:30px;}
.dot{width:6px;height:6px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite;}
.dot:nth-child(2){animation-delay:.22s;}.dot:nth-child(3){animation-delay:.44s;}
@keyframes pulse{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:.8;transform:scale(1)}}
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:40px;color:var(--muted);}
.empty-logo{width:64px;height:64px;border-radius:20px;background:linear-gradient(145deg,rgba(124,185,255,.15),rgba(201,168,76,.1));border:1px solid rgba(124,185,255,.2);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:var(--blue);margin-bottom:4px;box-shadow:0 4px 20px rgba(124,185,255,.1);}
.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: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: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;}
.composer-wrap.drag-over .drop-hint{display:flex;}
.attach-tray{display:none;flex-wrap:wrap;gap:6px;padding:10px 14px 0;}
.attach-tray.has-files{display:flex;}
.attach-chip{display:flex;align-items:center;gap:5px;background:rgba(124,185,255,0.08);border:1px solid rgba(124,185,255,0.22);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:500;color:var(--blue);}
.attach-chip button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;line-height:1;padding:0 0 0 3px;}
.attach-chip button:hover{color:var(--accent);}
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:14px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;}
textarea#msg::placeholder{color:var(--muted);}
.composer-footer{display:flex;align-items:center;justify-content:space-between;padding:6px 10px 10px;}
.composer-left{display:flex;gap:2px;align-items:center;}
.composer-right{display:flex;gap:6px;align-items:center;}
.icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;}
.icon-btn{opacity:.75;}
.icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;}
.status-text{font-size:11px;color:var(--muted);padding-left:4px;}
.send-btn{padding:7px 18px;border-radius:10px;font-size:13px;font-weight:600;background:linear-gradient(135deg,#5ba8f5,#7cb9ff);border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all .15s;flex-shrink:0;letter-spacing:.01em;}
.send-btn:hover{background:linear-gradient(135deg,#7cb9ff,#a0d0ff);transform:translateY(-1px);}
.send-btn:active{transform:translateY(0);}
.send-btn:disabled{opacity:.4;cursor:not-allowed;}
.upload-bar-wrap{display:none;height:3px;background:rgba(255,255,255,.06);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;}
.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;}
.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;}
.panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);}
/* File row actions (shown on hover) */
/* file-item-actions removed: delete button is now a flex child */
.file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;}
.file-action-btn:hover{color:var(--accent);}
.close-preview{cursor:pointer;opacity:.6;}.close-preview:hover{opacity:1;}
.file-tree{flex:1;overflow-y:auto;padding:8px;}
.file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;}
.file-item:hover{background:rgba(255,255,255,.07);color:var(--text);}
.file-item.active{background:rgba(124,185,255,.12);color:var(--blue);}
.preview-area{flex:1;overflow:auto;padding:14px;flex-direction:column;gap:8px;display:none;opacity:0;transition:opacity .15s;}
.preview-area.visible{display:flex;opacity:1;}
.preview-path{font-size:11px;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:#cdd6e0;}
/* Image preview */
.preview-img-wrap{display:flex;align-items:center;justify-content:center;flex:1;padding:8px 0;min-height:0;}
.preview-img{max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.4);}
/* 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 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 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 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;}
.preview-md th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
.preview-md td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
.preview-md tr:nth-child(even){background:rgba(255,255,255,.03);}
/* File type badge in preview path bar */
.preview-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:8px;text-transform:uppercase;letter-spacing:.06em;}
.preview-badge.img{background:rgba(124,185,255,.15);color:var(--blue);}
.preview-badge.md{background:rgba(201,168,76,.15);color:var(--gold);}
.preview-badge.code{background:rgba(255,255,255,.07);color:var(--muted);}
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:99px;transition:background .2s}
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22)}
@media(max-width:900px){.rightpanel{display:none}}@media(max-width:640px){.sidebar{display:none}}
/* ── Workspace dropdown (topbar) ── */
.ws-chip{user-select:none;}
.ws-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:240px;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:320px;overflow-y:auto;}
.ws-dropdown.open{display:block;}
.ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
.ws-opt:hover{background:rgba(255,255,255,.07);}
.ws-opt.active{background:rgba(124,185,255,.1);}
.ws-opt-name{font-size:13px;color:var(--text);font-weight:500;}
.ws-opt-path{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ws-divider{height:1px;background:var(--border);margin:4px 0;}
.ws-manage{color:var(--muted);font-size:12px;}
/* ── Workspace management panel ── */
.ws-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border);}
.ws-row:last-of-type{border-bottom:none;}
.ws-row-info{flex:1;min-width:0;}
.ws-row-name{font-size:13px;font-weight:500;color:var(--text);}
.ws-row-path{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ws-row-actions{display:flex;gap:4px;flex-shrink:0;}
.ws-action-btn{padding:4px 9px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;}
.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
.ws-action-btn.danger:hover{background:rgba(233,69,96,.12);color:var(--accent);border-color:rgba(233,69,96,.3);}
.ws-add-row{display:flex;gap:8px;align-items:center;padding:10px 0 4px;}
/* ── Message action buttons (copy, edit, retry) ── */
.msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;}
.msg-row:hover .msg-actions{opacity:1;}
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
.msg-action-btn:hover{color:var(--blue);background:rgba(124,185,255,.1);}
/* ── Edit message inline ── */
.msg-edit-area{width:100%;background:rgba(255,255,255,.05);border:1px solid rgba(124,185,255,.35);border-radius:8px;color:var(--text);padding:10px 12px;font-size:14px;font-family:inherit;line-height:1.6;resize:none;outline:none;min-height:60px;box-sizing:border-box;box-shadow:0 0 0 3px rgba(124,185,255,.07);margin-top:4px;}
.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:hover{background:rgba(255,255,255,.1);}
/* ── Clear conversation chip ── */
.clear-btn{background:rgba(201,168,76,.06);border:1px solid rgba(201,168,76,.18);color:var(--gold);font-size:11px;padding:4px 10px;cursor:pointer;transition:background .15s;}
.clear-btn:hover{background:rgba(201,168,76,.12);}
/* ── Copy button on messages ── */
/* msg-copy-btn styles moved to msg-action-btn */
/* ── Nav tab nowrap ── */
/* nav-tab-nowrap-handled-above */
/* ── Final polish additions ── */
/* Smooth hover on file items */
/* Sidebar section padding: give the session-section breathing room */
.sidebar-section{padding:10px 12px 6px;}
/* New chat btn icon - align nicely */
.new-chat-btn svg{flex-shrink:0;opacity:.8;}
/* Session list: group header spacing */
.session-list > div[style]{padding-left:12px;}
/* Preview path bar: flex row with nice gap */
.preview-path{display:flex;align-items:center;gap:6px;flex-wrap:nowrap;overflow:hidden;min-width:0;}
.preview-path #previewPathText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.preview-path #previewBadge{flex-shrink:0;white-space:nowrap;}
.preview-path #btnDownloadFile,.preview-path #btnEditFile{flex-shrink:0;white-space:nowrap;}
/* Preview badge typography */
.preview-badge{font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;}
/* Approval buttons: tab stops */
.approval-btn:focus{outline:2px solid var(--blue);outline-offset:2px;}
/* Message role: breathing room between icon and name */
.msg-role > span{line-height:1;}
/* Composer wrap: slightly less padding on smaller heights */
.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;}
/* Cron status badges: pill shape refinement */
.cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;}
/* Right panel icons: tighter */
.panel-actions{gap:2px;}
/* Workspace hint text: no intrusion */
.sidebar-bottom > div[style*="topbar"]{pointer-events:none;}
/* Topbar: border should match the subtle sidebar border */
.topbar{border-bottom:1px solid rgba(255,255,255,.07);}
/* Suggestion grid: consistent width */
.suggestion-grid{width:100%;max-width:400px;}
/* Empty state: add subtle gradient behind logo */
.empty-state{background:radial-gradient(ellipse at 50% 20%,rgba(124,185,255,.04) 0%,transparent 60%);}
/* ── Activity bar (tool status above composer) ── */
@keyframes fadeIn{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
#activityBar{padding-bottom:8px;flex-shrink:0;}
#activityBarInner{transition:opacity .2s;}
/* Remove old status-text from composer (kept for error messages only) */
.status-text{font-size:11px;color:var(--muted);padding-left:2px;display:none;}
/* Sidebar workspace display */
#sidebarWsDisplay:hover{background:rgba(255,255,255,.05);}
#sidebarWsDisplay:hover #sidebarWsName{color:var(--blue);}
/* Date group headers in session list */
.session-list > div[style*="uppercase"] {
padding: 8px 10px 3px !important;
font-size: 10px !important;
}
/* Sidebar bottom: tighten model field */
.sidebar-bottom { padding: 10px 14px 12px; }
/* Right panel file tree: more padding for breathing room */
/* Composer footer: even spacing */
.composer-footer { padding: 4px 10px 8px; }
/* ── File tree: clean delete button via CSS hover ── */
.file-del-btn{
flex-shrink:0;
width:0;height:16px;
overflow:hidden;
background:none;border:none;
color:var(--muted);cursor:pointer;
font-size:13px;font-weight:300;
opacity:0;
transition:width .12s,opacity .12s,color .12s;
padding:0;border-radius:3px;
display:flex;align-items:center;justify-content:center;
line-height:1;
}
.file-item:hover .file-del-btn{ width:16px;opacity:1;margin-left:2px; }
.file-del-btn:hover{ color:var(--accent); }
/* file-name must be a flex child that can shrink to zero */
.file-name{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
flex:1 1 0;
min-width:0;
}
/* file-size: never wraps, shrinks away gracefully */
.file-size{
flex-shrink:0;
font-size:10px;
color:var(--muted);
white-space:nowrap;
margin-left:4px;
font-variant-numeric:tabular-nums;
}
/* file-icon: never shrinks */
.file-icon{
flex-shrink:0;
font-size:13px;
opacity:.7;
line-height:1;
}
/* ── Resizable panels ── */
.resize-handle{
position:absolute;
top:0;bottom:0;
width:5px;
cursor:col-resize;
z-index:10;
transition:background .15s;
}
.resize-handle:hover,.resize-handle.dragging{background:rgba(124,185,255,.35);}
.sidebar{position:relative;}
.sidebar .resize-handle{right:-2px;}
.rightpanel{position:relative;}
.rightpanel .resize-handle{left:-2px;}
/* Prevent text selection during drag */
body.resizing{user-select:none;cursor:col-resize;}
/* ── Tool call cards ── */
/* Running indicator dot (pulsing) */
.tool-card-running-dot{width:7px;height:7px;border-radius:50%;background:var(--blue);opacity:.8;flex-shrink:0;animation:pulse 1.2s ease-in-out infinite;}
/* Show more button inside tool card result */
.tool-card-more{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;padding:3px 0 0;opacity:.7;display:block;}
.tool-card-more:hover{opacity:1;}
.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);}
.tool-card-running{border-color:rgba(124,185,255,.25);background:rgba(124,185,255,.04);}
.tool-card-header{display:flex;align-items:center;gap:7px;padding:4px 10px;cursor:pointer;user-select:none;}
.tool-card-icon{font-size:13px;flex-shrink:0;opacity:.8;}
.tool-card-name{font-size:12px;font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;}
.tool-card-preview{font-size:11px;color:var(--muted);opacity:.6;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;transition:transform .15s;}
.tool-card.open .tool-card-toggle{transform:rotate(90deg);}
.tool-card-detail{display:none;border-top:1px solid rgba(255,255,255,.06);padding:8px 12px;}
.tool-card.open .tool-card-detail{display:block;}
.tool-card-args{margin-bottom:6px;}
.tool-card-args div{font-size:11px;line-height:1.6;}
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;}
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;word-break:break-all;}
.tool-card-result pre{font-size:11px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:180px;overflow-y:auto;margin:0;line-height:1.55;}
/* ── Scrollbar polish ── */
::-webkit-scrollbar{width:5px;height:5px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:3px;}
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22);}
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.12) transparent; }

589
static/ui.js Normal file
View File

@@ -0,0 +1,589 @@
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null};
const INFLIGHT={}; // keyed by session_id while request in-flight
const MSG_QUEUE=[]; // messages queued while a request is in-flight
const $=id=>document.getElementById(id);
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function renderMd(raw){
let s=raw||'';
s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';return `${h}<pre><code>${esc(code.replace(/\n$/,''))}</code></pre>`;});
s=s.replace(/`([^`\n]+)`/g,(_,c)=>`<code>${esc(c)}</code>`);
s=s.replace(/\*\*\*(.+?)\*\*\*/g,'<strong><em>$1</em></strong>');
s=s.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
s=s.replace(/\*([^*\n]+)\*/g,'<em>$1</em>');
s=s.replace(/^### (.+)$/gm,'<h3>$1</h3>').replace(/^## (.+)$/gm,'<h2>$1</h2>').replace(/^# (.+)$/gm,'<h1>$1</h1>');
s=s.replace(/^---+$/gm,'<hr>');
s=s.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>');
// B8: improved list handling supporting up to 2 levels of indentation
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
const lines=block.trimEnd().split('\n');
let html='<ul>';
for(const l of lines){
const indent=/^ {2,}/.test(l);
const text=l.replace(/^ {0,4}[-*+] /,'');
if(indent) html+=`<li style="margin-left:16px">${text}</li>`;
else html+=`<li>${text}</li>`;
}
return html+'</ul>';
});
s=s.replace(/((?:^(?: )?\d+\. .+\n?)+)/gm,block=>{
const lines=block.trimEnd().split('\n');
let html='<ol>';
for(const l of lines){
const text=l.replace(/^ {0,4}\d+\. /,'');
html+=`<li>${text}</li>`;
}
return html+'</ol>';
});
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{
const rows=block.trim().split('\n').filter(r=>r.trim());
if(rows.length<2)return block;
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
if(!isSep(rows[1]))return block;
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${c.trim()}</td>`).join('');
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${c.trim()}</th>`).join('');
const header=`<tr>${parseHeader(rows[0])}</tr>`;
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
});
const parts=s.split(/\n{2,}/);
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
return s;
}
function setStatus(t){
const bar=$('activityBar');
const txt=$('activityText');
const dismiss=$('btnDismissStatus');
if(!bar||!txt)return;
if(!t){
bar.style.display='none';
txt.textContent='';
if(dismiss)dismiss.style.display='none';
} else {
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…';
if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none';
}
}
function setBusy(v){
S.busy=v;
$('btnSend').disabled=v;
const dots=$('activityDots');
if(dots) dots.style.display=v?'flex':'none';
if(!v){
setStatus('');
// Always hide Cancel button when not busy
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
updateQueueBadge();
// Drain one queued message after UI settles
if(MSG_QUEUE.length>0){
const next=MSG_QUEUE.shift();
updateQueueBadge();
setTimeout(()=>{ $('msg').value=next; send(); }, 120);
}
}
}
function updateQueueBadge(){
let badge=$('queueBadge');
if(MSG_QUEUE.length>0){
if(!badge){
badge=document.createElement('div');
badge.id='queueBadge';
badge.style.cssText='position:fixed;bottom:80px;right:24px;background:rgba(124,185,255,.18);border:1px solid rgba(124,185,255,.4);color:var(--blue);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;z-index:50;pointer-events:none;backdrop-filter:blur(8px);';
document.body.appendChild(badge);
}
badge.textContent=MSG_QUEUE.length===1?'1 message queued':`${MSG_QUEUE.length} messages queued`;
} else {
if(badge) badge.remove();
}
}
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
function copyMsg(btn){
const row=btn.closest('.msg-row');
const text=row?row.dataset.rawText:'';
if(!text)return;
navigator.clipboard.writeText(text).then(()=>{
const orig=btn.innerHTML;btn.innerHTML='&#10003;';btn.style.color='var(--blue)';
setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500);
}).catch(()=>showToast('Copy failed'));
}
// ── Reconnect banner (B4/B5: reload resilience) ──
const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking
function markInflight(sid, streamId) {
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()}));
}
function clearInflight() {
localStorage.removeItem(INFLIGHT_KEY);
}
function showReconnectBanner(msg) {
$('reconnectMsg').textContent = msg || 'A response may have been in progress when you last left.';
$('reconnectBanner').classList.add('visible');
}
function dismissReconnect() {
$('reconnectBanner').classList.remove('visible');
clearInflight();
}
async function refreshSession() {
dismissReconnect();
if (!S.session) return;
try {
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
S.session = data.session;
S.messages = (data.session.messages || []).filter(m => {
if (!m || !m.role || m.role === 'tool') return false;
if (m.role === 'assistant') { let c = m.content || ''; if (Array.isArray(c)) c = c.map(p => p.text||'').join(''); return String(c).trim().length > 0; }
return true;
});
syncTopbar(); renderMessages();
showToast('Conversation refreshed');
} catch(e) { setStatus('Refresh failed: ' + e.message); }
}
async function checkInflightOnBoot(sid) {
const raw = localStorage.getItem(INFLIGHT_KEY);
if (!raw) return;
try {
const {sid: inflightSid, streamId, ts} = JSON.parse(raw);
if (inflightSid !== sid) { clearInflight(); return; }
// Only show banner if the in-flight entry is less than 10 minutes old
if (Date.now() - ts > 10 * 60 * 1000) { clearInflight(); return; }
// Check if stream is still active
const status = await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId || '')}`);
if (status.active) {
// Stream is genuinely still running -- show the banner
showReconnectBanner('A response is still being generated. Reload when ready?');
} else {
// Stream finished. Only show banner if reload happened within 90 seconds
// (longer gap = normal completed session, not a mid-stream reload)
if (Date.now() - ts < 90 * 1000) {
showReconnectBanner('A response was in progress when you last left. Messages may have updated.');
} else {
clearInflight(); // completed normally, no banner needed
}
}
} catch(e) { clearInflight(); }
}
function syncTopbar(){
if(!S.session){
// Show default workspace name even without a session
const sidebarName=$('sidebarWsName');
if(sidebarName && sidebarName.textContent==='Workspace'){
sidebarName.textContent='No workspace';
}
return;
}
$('topbarTitle').textContent=S.session.title||'Untitled';
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
$('topbarMeta').textContent=`${vis.length} messages`;
const m=S.session.model||'';
const MODEL_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-2.5-pro':'Gemini 2.5 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3','meta-llama/llama-4-scout':'Llama 4 Scout'};
$('modelSelect').value=m; // set dropdown first so chip reads consistent value
// Show Clear button only when session has messages
const clearBtn=$('btnClearConv');
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(m=>m.role!=='tool').length>0)?'':'none';
const displayModel=$('modelSelect').value||m;
$('modelChip').textContent=MODEL_LABELS[displayModel]||(displayModel.split('/').pop()||'Unknown');
const ws=S.session.workspace||'';
$('wsChip').textContent=ws.split('/').slice(-2).join('/')||ws;
// Update workspace chip in topbar with friendly name from workspace list
const wsChipEl=$('wsChip');
if(wsChipEl){
const wsFriendly=getWorkspaceFriendlyName(ws);
wsChipEl.textContent='\u{1F4C1} '+wsFriendly+' \u25BE';
}
// Update sidebar workspace display
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');
if(sidebarName){
sidebarName.textContent=getWorkspaceFriendlyName(ws);
}
if(sidebarPath){
sidebarPath.textContent=ws;
}
// modelSelect already set above
}
function msgContent(m){
// Extract plain text content from a message for filtering
let c=m.content||'';
if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('').trim();
return String(c).trim();
}
function renderMessages(){
const inner=$('msgInner');
const vis=S.messages.filter(m=>{
if(!m||!m.role||m.role==='tool')return false;
return msgContent(m)||m.attachments?.length;
});
$('emptyState').style.display=vis.length?'none':'';
inner.innerHTML='';
// Track original indices (in S.messages) so truncate knows the cut point
const visWithIdx=[];
let rawIdx=0;
for(const m of S.messages){
if(!m||!m.role||m.role==='tool'){rawIdx++;continue;}
if(msgContent(m)||m.attachments?.length) visWithIdx.push({m,rawIdx});
rawIdx++;
}
for(let vi=0;vi<visWithIdx.length;vi++){
const {m,rawIdx}=visWithIdx[vi];
let content=m.content||'';
if(Array.isArray(content))content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
const isUser=m.role==='user';
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
const row=document.createElement('div');row.className='msg-row';
row.dataset.msgIdx=rawIdx;
let filesHtml='';
if(m.attachments&&m.attachments.length)
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">&#128206; ${esc(f)}</div>`).join('')}</div>`;
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(String(content));
// Action buttons for this bubble
const editBtn = isUser ? `<button class="msg-action-btn" title="Edit message" onclick="editMessage(this)">&#9998;</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="Regenerate response" onclick="regenerateResponse(this)">&#8635;</button>` : '';
row.innerHTML=`<div class="msg-role ${m.role}"><div class="role-icon ${m.role}">${isUser?'Y':'H'}</div><span style="font-size:12px">${isUser?'You':'Hermes'}</span><span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="Copy" onclick="copyMsg(this)">&#128203;</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
row.dataset.rawText = String(content).trim();
inner.appendChild(row);
}
// Insert settled tool call cards (history view only).
// During live streaming, tool cards are rendered in #liveToolCards by the
// tool SSE handler and never mixed into the message list until done fires.
if(!S.busy && S.toolCalls && S.toolCalls.length){
inner.querySelectorAll('.tool-card-row').forEach(el=>el.remove());
const byAssistant = {};
for(const tc of S.toolCalls){
const key = tc.assistant_msg_idx !== undefined ? tc.assistant_msg_idx : -1;
if(!byAssistant[key]) byAssistant[key] = [];
byAssistant[key].push(tc);
}
const allRows = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]'));
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 {
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;}
}
}
const frag=document.createDocumentFragment();
for(const tc of cards){frag.appendChild(buildToolCard(tc));}
if(insertBefore) inner.insertBefore(frag,insertBefore);
else inner.appendChild(frag);
}
}
$('messages').scrollTop=$('messages').scrollHeight;
// Apply syntax highlighting after DOM is built
requestAnimationFrame(()=>highlightCode());
// Refresh todo panel if it's currently open
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
loadTodos();
}
}
function toolIcon(name){
const icons={terminal:'⬛',read_file:'📄',write_file:'✏️',search_files:'🔍',
web_search:'🌐',web_extract:'🌐',execute_code:'⚙️',patch:'🔧',
memory:'🧠',skill_manage:'📚',todo:'✅',cronjob:'⏱️',delegate_task:'🤖',
send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️'};
return icons[name]||'🔧';
}
function buildToolCard(tc){
const row=document.createElement('div');
row.className='msg-row tool-card-row';
const icon=toolIcon(tc.name);
const hasDetail=tc.snippet||(tc.args&&Object.keys(tc.args).length>0);
let displaySnippet='';
if(tc.snippet){
const s=tc.snippet;
if(s.length<=220){displaySnippet=s;}
else{
const cutoff=s.slice(0,220);
const lastBreak=Math.max(cutoff.lastIndexOf('. '),cutoff.lastIndexOf('\n'),cutoff.lastIndexOf('; '));
displaySnippet=lastBreak>80?s.slice(0,lastBreak+1):cutoff;
}
}
const hasMore=tc.snippet&&tc.snippet.length>displaySnippet.length;
const runIndicator=tc.done===false?'<span class="tool-card-running-dot"></span>':'';
row.innerHTML=`
<div class="tool-card${tc.done===false?' tool-card-running':''}">
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
${runIndicator}
<span class="tool-card-icon">${icon}</span>
<span class="tool-card-name">${esc(tc.name)}</span>
<span class="tool-card-preview">${esc(tc.preview||displaySnippet||'')}</span>
${hasDetail?'<span class="tool-card-toggle">▸</span>':''}
</div>
${hasDetail?`<div class="tool-card-detail">
${tc.args&&Object.keys(tc.args).length?`<div class="tool-card-args">${
Object.entries(tc.args).map(([k,v])=>`<div><span class="tool-arg-key">${esc(k)}</span> <span class="tool-arg-val">${esc(String(v))}</span></div>`).join('')
}</div>`:''}
${displaySnippet?`<div class="tool-card-result">
<pre>${esc(displaySnippet)}</pre>
${hasMore?`<button class="tool-card-more" data-full="${esc(tc.snippet||'').replace(/"/g,'&quot;')}" data-short="${esc(displaySnippet||'').replace(/"/g,'&quot;')}" onclick="event.stopPropagation();const p=this.previousElementSibling;const full=this.dataset.full;const short=this.dataset.short;p.textContent=p.textContent===short?full:short;this.textContent=p.textContent===short?'Show more':'Show less'">Show more</button>`:''}
</div>`:''}
</div>`:''}
</div>`;
return row;
}
// ── Live tool card helpers (called during SSE streaming) ──
function appendLiveToolCard(tc){
const container=$('liveToolCards');
if(!container)return;
container.style.display='';
// Update existing card if same tool call id (e.g. snippet arrives after done)
const existing=container.querySelector(`[data-tid="${CSS.escape(tc.tid||'')}"]`);
if(existing){existing.replaceWith(buildToolCard(tc));return;}
const card=buildToolCard(tc);
if(tc.tid)card.dataset.tid=tc.tid;
container.appendChild(card);
}
function clearLiveToolCards(){
const container=$('liveToolCards');
if(!container)return;
container.innerHTML='';
container.style.display='none';
}
// ── Edit + Regenerate ──
function editMessage(btn) {
if(S.busy) return;
const row = btn.closest('.msg-row');
if(!row) return;
const msgIdx = parseInt(row.dataset.msgIdx, 10);
const originalText = row.dataset.rawText || '';
const body = row.querySelector('.msg-body');
if(!body || row.dataset.editing) return;
row.dataset.editing = '1';
// Replace msg-body with an editable textarea
const ta = document.createElement('textarea');
ta.className = 'msg-edit-area';
ta.value = originalText;
body.replaceWith(ta);
// Resize after DOM insertion so scrollHeight is correct
requestAnimationFrame(() => { autoResizeTextarea(ta); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); });
ta.addEventListener('input', () => autoResizeTextarea(ta));
// Action bar below the textarea
const bar = document.createElement('div');
bar.className = 'msg-edit-bar';
bar.innerHTML = `<button class="msg-edit-send">Send edit</button><button class="msg-edit-cancel">Cancel</button>`;
ta.after(bar);
bar.querySelector('.msg-edit-send').onclick = async () => {
const newText = ta.value.trim();
if(!newText) return;
await submitEdit(msgIdx, newText);
};
bar.querySelector('.msg-edit-cancel').onclick = () => cancelEdit(row, originalText, body);
ta.addEventListener('keydown', e => {
if(e.key==='Enter' && !e.shiftKey) { e.preventDefault(); bar.querySelector('.msg-edit-send').click(); }
if(e.key==='Escape') { e.preventDefault(); cancelEdit(row, originalText, body); }
});
}
function cancelEdit(row, originalText, originalBody) {
delete row.dataset.editing;
const ta = row.querySelector('.msg-edit-area');
const bar = row.querySelector('.msg-edit-bar');
if(ta) ta.replaceWith(originalBody);
if(bar) bar.remove();
}
function autoResizeTextarea(ta) {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 300) + 'px';
}
async function submitEdit(msgIdx, newText) {
if(!S.session || S.busy) return;
// Truncate session at msgIdx (keep messages before the edited one)
// then re-send the edited text
try {
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({
session_id: S.session.session_id,
keep_count: msgIdx // keep messages[0..msgIdx-1], discard from msgIdx onward
})});
S.messages = S.messages.slice(0, msgIdx);
renderMessages();
// Now send the edited message as a new chat
$('msg').value = newText;
await send();
} catch(e) { setStatus('Edit failed: ' + e.message); }
}
async function regenerateResponse(btn) {
if(!S.session || S.busy) return;
// Find the last user message and re-run it
// Remove the last assistant message first (truncate to before it)
const row = btn.closest('.msg-row');
if(!row) return;
const assistantIdx = parseInt(row.dataset.msgIdx, 10);
// Find the last user message text (one before this assistant message)
let lastUserText = '';
for(let i = assistantIdx - 1; i >= 0; i--) {
const m = S.messages[i];
if(m && m.role === 'user') { lastUserText = msgContent(m); break; }
}
if(!lastUserText) return;
try {
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({
session_id: S.session.session_id,
keep_count: assistantIdx // remove the assistant message
})});
S.messages = S.messages.slice(0, assistantIdx);
renderMessages();
$('msg').value = lastUserText;
await send();
} catch(e) { setStatus('Regenerate failed: ' + e.message); }
}
function highlightCode(container) {
// Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area)
if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return;
const el = container || $('msgInner');
if(!el) return;
// Prism autoloader handles language detection via class="language-xxx"
Prism.highlightAllUnder(el);
}
function appendThinking(){
$('emptyState').style.display='none';
const row=document.createElement('div');row.className='msg-row';row.id='thinkingRow';
row.innerHTML=`<div class="msg-role assistant"><div class="role-icon assistant">H</div>Hermes</div><div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
$('msgInner').appendChild(row);$('messages').scrollTop=$('messages').scrollHeight;
}
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
function fileIcon(name, type){
if(type==='dir') return '📁';
const e=fileExt(name);
if(IMAGE_EXTS.has(e)) return '📷';
if(MD_EXTS.has(e)) return '📝';
if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return '⬇️';
if(e==='.py') return '🐍';
if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return '⚡';
if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return '⚙';
if(e==='.sh'||e==='.bash') return '💻';
if(e==='.pdf') return '⬇️';
return '📄';
}
function renderFileTree(){
const box=$('fileTree');box.innerHTML='';
for(const item of S.entries){
const el=document.createElement('div');el.className='file-item';
// Icon
const iconEl=document.createElement('span');
iconEl.className='file-icon';iconEl.textContent=fileIcon(item.name,item.type);
el.appendChild(iconEl);
// Name -- takes all remaining space, truncates with ellipsis
const nameEl=document.createElement('span');
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=item.name;
el.appendChild(nameEl);
// Size -- only for files, right-aligned, shrinks but never wraps
if(item.type==='file'&&item.size){
const sizeEl=document.createElement('span');
sizeEl.className='file-size';
sizeEl.textContent=`${(item.size/1024).toFixed(1)}k`;
el.appendChild(sizeEl);
}
// Delete button -- only for files, shown as a CSS class toggle on hover
if(item.type==='file'){
const del=document.createElement('button');
del.className='file-del-btn';del.title='Delete';del.textContent='×';
del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);};
el.appendChild(del);
}
el.onclick=async()=>item.type==='dir'?loadDir(item.path):openFile(item.path);
box.appendChild(el);
}
}
async function deleteWorkspaceFile(relPath, name){
if(!S.session)return;
if(!confirm(`Delete ${name}?`))return;
try{
await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
showToast(`Deleted ${name}`);
// Close preview if we just deleted the viewed file
if($('previewPathText').textContent===relPath)$('btnClearPreview').onclick();
await loadDir('.');
}catch(e){setStatus('Delete failed: '+e.message);}
}
async function promptNewFile(){
if(!S.session)return;
const name=prompt('New file name (e.g. notes.md):','');
if(!name||!name.trim())return;
try{
await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim(),content:''})});
showToast(`Created ${name.trim()}`);
await loadDir('.');
// Open the new file immediately
openFile(name.trim());
}catch(e){setStatus('Create failed: '+e.message);}
}
function renderTray(){
const tray=$('attachTray');tray.innerHTML='';
if(!S.pendingFiles.length){tray.classList.remove('has-files');return;}
tray.classList.add('has-files');
S.pendingFiles.forEach((f,i)=>{
const chip=document.createElement('div');chip.className='attach-chip';
chip.innerHTML=`&#128206; ${esc(f.name)} <button title="Remove">&#10005;</button>`;
chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();};
tray.appendChild(chip);
});
}
function addFiles(files){for(const f of files){if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);}renderTray();}
async function uploadPendingFiles(){
if(!S.pendingFiles.length||!S.session)return[];
const names=[];let failures=0;
const bar=$('uploadBar');const barWrap=$('uploadBarWrap');
barWrap.classList.add('active');bar.style.width='0%';
const total=S.pendingFiles.length;
for(let i=0;i<total;i++){
const f=S.pendingFiles[i];const fd=new FormData();
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
try{
const res=await fetch('/api/upload',{method:'POST',body:fd});
if(!res.ok){const err=await res.text();throw new Error(err);}
const data=await res.json();
if(data.error)throw new Error(data.error);
names.push(data.filename);
}catch(e){failures++;setStatus(`\u274c Upload failed: ${f.name} \u2014 ${e.message}`);}
bar.style.width=`${Math.round((i+1)/total*100)}%`;
}
barWrap.classList.remove('active');bar.style.width='0%';
S.pendingFiles=[];renderTray();
if(failures===total&&total>0)throw new Error(`All ${total} upload(s) failed`);
return names;
}

168
static/workspace.js Normal file
View File

@@ -0,0 +1,168 @@
async function api(path,opts={}){
const res=await fetch(path,{headers:{'Content-Type':'application/json'},...opts});
if(!res.ok)throw new Error(await res.text());
const ct=res.headers.get('content-type')||'';
return ct.includes('application/json')?res.json():res.text();
}
async function loadDir(path){
if(!S.session)return;
try{
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
S.entries=data.entries||[];renderFileTree();
}catch(e){console.warn('loadDir',e);}
}
// File extension sets for preview routing (must match server-side sets)
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
// Binary formats that should download rather than preview
const DOWNLOAD_EXTS = new Set([
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
'.pdf','.zip','.tar','.gz','.bz2','.7z','.rar',
'.mp3','.mp4','.wav','.m4a','.ogg','.flac','.mov','.avi','.mkv','.webm',
'.exe','.dmg','.pkg','.deb','.rpm',
'.woff','.woff2','.ttf','.otf','.eot',
'.bin','.dat','.db','.sqlite','.pyc','.class','.so','.dylib','.dll',
]);
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
let _previewCurrentPath = ''; // relative path of currently previewed file
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
let _previewDirty = false; // true when edits are unsaved
function showPreview(mode){
// mode: 'code' | 'image' | 'md'
$('previewCode').style.display = mode==='code' ? '' : 'none';
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
$('previewMd').style.display = mode==='md' ? '' : 'none';
$('previewEditArea').style.display = 'none'; // start in read-only
const badge=$('previewBadge');
badge.className='preview-badge '+mode;
badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text';
_previewCurrentMode = mode;
_previewDirty = false;
updateEditBtn();
}
function updateEditBtn(){
const btn=$('btnEditFile');
if(!btn)return;
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md';
btn.style.display = editable?'':'none';
const editing = $('previewEditArea').style.display!=='none';
btn.innerHTML = editing ? '&#128190; Save' : '&#9998; Edit';
btn.title = editing ? 'Save changes' : 'Edit this file';
btn.style.color = editing ? 'var(--blue)' : '';
if(_previewDirty) btn.innerHTML = '&#128190; Save*';
}
async function toggleEditMode(){
const editing = $('previewEditArea').style.display!=='none';
if(editing){
// Save
if(!S.session||!_previewCurrentPath)return;
const content=$('previewEditArea').value;
try{
await api('/api/file/save',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, path:_previewCurrentPath, content
})});
_previewDirty=false;
// Update read-only views
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
else $('previewMd').innerHTML=renderMd(content);
$('previewEditArea').style.display='none';
if(_previewCurrentMode==='code') $('previewCode').style.display='';
else $('previewMd').style.display='';
showToast('Saved');
}catch(e){setStatus('Save failed: '+e.message);}
}else{
// Enter edit mode: populate textarea with current content
const currentText = _previewCurrentMode==='code'
? $('previewCode').textContent
: _previewRawContent||'';
$('previewEditArea').value=currentText;
$('previewEditArea').style.display='';
if(_previewCurrentMode==='code') $('previewCode').style.display='none';
else $('previewMd').style.display='none';
// Escape cancels the edit without saving
$('previewEditArea').onkeydown=e=>{
if(e.key==='Escape'){e.preventDefault();cancelEditMode();}
};
}
updateEditBtn();
}
let _previewRawContent = ''; // raw text for md files (to populate editor)
function cancelEditMode(){
// Discard changes and return to read-only view
$('previewEditArea').style.display='none';
$('previewEditArea').onkeydown=null;
if(_previewCurrentMode==='code') $('previewCode').style.display='';
else $('previewMd').style.display='';
_previewDirty=false;
updateEditBtn();
}
async function openFile(path){
if(!S.session)return;
const ext=fileExt(path);
// Binary/download-only formats: trigger browser download, don't preview
if(DOWNLOAD_EXTS.has(ext)){
downloadFile(path);
return;
}
$('previewPathText').textContent=path;
$('previewArea').classList.add('visible');
$('fileTree').style.display='none';
_previewCurrentPath = path;
if(IMAGE_EXTS.has(ext)){
// Image: load via raw endpoint, show as <img>
showPreview('image');
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
$('previewImg').alt=path;
$('previewImg').src=url;
$('previewImg').onerror=()=>setStatus('Could not load image');
} else if(MD_EXTS.has(ext)){
// Markdown: fetch text, render with renderMd, display as formatted HTML
try{
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
showPreview('md');
_previewRawContent = data.content;
$('previewMd').innerHTML=renderMd(data.content);
}catch(e){setStatus('Could not open file');}
} else {
// Plain code / text -- but fall back to download if server signals binary
try{
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
if(data.binary){
// Server flagged this as binary content
downloadFile(path);
return;
}
showPreview('code');
$('previewCode').textContent=data.content;
}catch(e){
// If it's a 400/too-large error, offer download instead
downloadFile(path);
}
}
}
function downloadFile(path){
if(!S.session)return;
// Trigger browser download via the raw file endpoint with content-disposition attachment
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
const filename=path.split('/').pop();
const a=document.createElement('a');
a.href=url;a.download=filename;
document.body.appendChild(a);a.click();
setTimeout(()=>document.body.removeChild(a),100);
showToast(`Downloading ${filename}\u2026`,2000);
}

0
tests/__init__.py Normal file
View File

240
tests/conftest.py Normal file
View File

@@ -0,0 +1,240 @@
"""
Shared pytest fixtures for webui-mvp tests.
TEST ISOLATION:
Tests run against a SEPARATE server instance on port 8788 with a
completely separate state directory. Production data is never touched.
The test state dir is wiped before each full test run and again on teardown.
PATH DISCOVERY:
No hardcoded paths. Discovery order:
1. Environment variables (HERMES_WEBUI_AGENT_DIR, HERMES_WEBUI_PYTHON, etc.)
2. Sibling checkout heuristics relative to this repo
3. Common install paths (~/.hermes/hermes-agent)
4. System python3 as a last resort
"""
import json
import os
import pathlib
import shutil
import subprocess
import time
import urllib.request
import urllib.error
import pytest
# ── Repo root discovery ────────────────────────────────────────────────────
# conftest.py lives at <repo>/tests/conftest.py
TESTS_DIR = pathlib.Path(__file__).parent.resolve()
REPO_ROOT = TESTS_DIR.parent.resolve()
HOME = pathlib.Path.home()
HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME', str(HOME / '.hermes')))
# ── Test server config ────────────────────────────────────────────────────
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT', '8788'))
TEST_BASE = f"http://127.0.0.1:{TEST_PORT}"
TEST_STATE_DIR = pathlib.Path(os.getenv(
'HERMES_WEBUI_TEST_STATE_DIR',
str(HERMES_HOME / 'webui-mvp-test')
))
TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace'
# ── Server script: always relative to repo root ───────────────────────────
SERVER_SCRIPT = REPO_ROOT / 'server.py'
if not SERVER_SCRIPT.exists():
raise RuntimeError(
f"server.py not found at {SERVER_SCRIPT}. "
"Is conftest.py in the tests/ subdirectory of the repo?"
)
# ── Hermes agent discovery (mirrors api/config._discover_agent_dir) ───────
def _discover_agent_dir() -> pathlib.Path:
candidates = [
os.getenv('HERMES_WEBUI_AGENT_DIR', ''),
str(HERMES_HOME / 'hermes-agent'),
str(REPO_ROOT.parent / 'hermes-agent'),
str(HOME / '.hermes' / 'hermes-agent'),
str(HOME / 'hermes-agent'),
]
for c in candidates:
if not c:
continue
p = pathlib.Path(c).expanduser()
if p.exists() and (p / 'run_agent.py').exists():
return p.resolve()
return None
# ── Python discovery (mirrors api/config._discover_python) ────────────────
def _discover_python(agent_dir) -> str:
if os.getenv('HERMES_WEBUI_PYTHON'):
return os.getenv('HERMES_WEBUI_PYTHON')
if agent_dir:
venv_py = agent_dir / 'venv' / 'bin' / 'python'
if venv_py.exists():
return str(venv_py)
local_venv = REPO_ROOT / '.venv' / 'bin' / 'python'
if local_venv.exists():
return str(local_venv)
return shutil.which('python3') or shutil.which('python') or 'python3'
HERMES_AGENT = _discover_agent_dir()
VENV_PYTHON = _discover_python(HERMES_AGENT)
# Work dir: agent dir if found, else repo root
WORKDIR = str(HERMES_AGENT) if HERMES_AGENT else str(REPO_ROOT)
# ── Helpers ──────────────────────────────────────────────────────────────────
def _post(base, 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())
except urllib.error.HTTPError as e:
try:
return json.loads(e.read())
except Exception:
return {}
def _wait_for_server(base, timeout=20):
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(base + "/health", timeout=2) as r:
if json.loads(r.read()).get("status") == "ok":
return True
except Exception:
time.sleep(0.3)
return False
# ── Session-scoped test server ────────────────────────────────────────────────
@pytest.fixture(scope="session", autouse=True)
def test_server():
"""
Start an isolated test server on TEST_PORT with a clean state directory.
Paths are discovered dynamically -- no hardcoded absolute path assumptions.
"""
# Clean slate
if TEST_STATE_DIR.exists():
shutil.rmtree(TEST_STATE_DIR)
TEST_STATE_DIR.mkdir(parents=True)
TEST_WORKSPACE.mkdir(parents=True)
# Symlink real skills into test home so skill-related tests work,
# but all write-heavy state stays isolated.
real_skills = HERMES_HOME / 'skills'
test_skills = TEST_STATE_DIR / 'skills'
if real_skills.exists() and not test_skills.exists():
test_skills.symlink_to(real_skills)
# Isolated cron state
(TEST_STATE_DIR / 'cron').mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
env.update({
"HERMES_WEBUI_PORT": str(TEST_PORT),
"HERMES_WEBUI_HOST": "127.0.0.1",
"HERMES_WEBUI_STATE_DIR": str(TEST_STATE_DIR),
"HERMES_WEBUI_DEFAULT_WORKSPACE": str(TEST_WORKSPACE),
"HERMES_WEBUI_DEFAULT_MODEL": "openai/gpt-5.4-mini",
"HERMES_HOME": str(TEST_STATE_DIR),
})
# Pass agent dir if discovered so server.py doesn't have to re-discover
if HERMES_AGENT:
env["HERMES_WEBUI_AGENT_DIR"] = str(HERMES_AGENT)
proc = subprocess.Popen(
[VENV_PYTHON, str(SERVER_SCRIPT)],
cwd=WORKDIR,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if not _wait_for_server(TEST_BASE, timeout=20):
proc.kill()
pytest.fail(
f"Test server on port {TEST_PORT} did not start within 20s.\n"
f" server.py : {SERVER_SCRIPT}\n"
f" python : {VENV_PYTHON}\n"
f" agent dir : {HERMES_AGENT}\n"
f" workdir : {WORKDIR}\n"
)
yield proc
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
try:
shutil.rmtree(TEST_STATE_DIR)
except Exception:
pass
# ── Test base URL ─────────────────────────────────────────────────────────────
@pytest.fixture(scope="session")
def base_url():
return TEST_BASE
# ── Per-test session cleanup ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def cleanup_test_sessions():
"""
Yields a list for tests to register created session IDs.
Deletes all registered sessions after each test.
Resets last_workspace to the test workspace to prevent state bleed.
"""
created: list[str] = []
yield created
for sid in created:
try:
_post(TEST_BASE, "/api/session/delete", {"session_id": sid})
except Exception:
pass
try:
_post(TEST_BASE, "/api/sessions/cleanup_zero_message")
except Exception:
pass
try:
last_ws_file = TEST_STATE_DIR / "last_workspace.txt"
last_ws_file.write_text(str(TEST_WORKSPACE), encoding='utf-8')
except Exception:
pass
# ── Convenience helpers ────────────────────────────────────────────────────────
def make_session_tracked(created_list, ws=None):
"""
Create a session on the test server and register it for cleanup.
Usage:
def test_something(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
"""
body = {}
if ws:
body["workspace"] = str(ws)
d = _post(TEST_BASE, "/api/session/new", body)
sid = d["session"]["session_id"]
ws_path = pathlib.Path(d["session"]["workspace"])
created_list.append(sid)
return sid, ws_path

416
tests/test_regressions.py Normal file
View File

@@ -0,0 +1,416 @@
"""
Regression tests -- one test per bug that was introduced and fixed.
These tests exist specifically to prevent those bugs from silently returning.
Each test is tagged with the sprint/commit where the bug was found and fixed.
"""
import json
import pathlib
import time
import urllib.error
import urllib.request
import urllib.parse
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
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(), r.headers.get("Content-Type",""), 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 make_session(created_list):
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
created_list.append(sid)
return sid
# ── R1: uuid not imported in server.py (Sprint 10 split regression) ──────────
def test_chat_start_returns_stream_id(cleanup_test_sessions):
"""R1: chat/start must return stream_id -- catches missing uuid import.
When uuid was missing, this returned 500 (NameError).
"""
sid = make_session(cleanup_test_sessions)
data, status = post("/api/chat/start", {
"session_id": sid,
"message": "ping",
"model": "openai/gpt-5.4-mini",
})
# Must return 200 with a stream_id -- not 500
assert status == 200, f"chat/start failed with {status}: {data}"
assert "stream_id" in data, "stream_id missing from chat/start response"
assert len(data["stream_id"]) > 8, "stream_id looks invalid"
post("/api/session/delete", {"session_id": sid})
cleanup_test_sessions.clear()
# ── R2: AIAgent not imported in api/streaming.py (Sprint 10 split regression) ─
def test_chat_stream_opens_successfully(cleanup_test_sessions):
"""R2: After chat/start, GET /api/chat/stream must return 200 (SSE opens).
When AIAgent was missing, the thread crashed immediately, popped STREAMS,
and the SSE GET returned 404.
"""
sid = make_session(cleanup_test_sessions)
data, status = post("/api/chat/start", {
"session_id": sid,
"message": "say: hello",
"model": "openai/gpt-5.4-mini",
})
assert status == 200, f"chat/start failed: {data}"
stream_id = data["stream_id"]
# Open the SSE stream -- must return 200, not 404
# We only check headers (don't read the full stream body)
req = urllib.request.Request(BASE + f"/api/chat/stream?stream_id={stream_id}")
try:
r = urllib.request.urlopen(req, timeout=3)
assert r.status == 200, f"SSE stream returned {r.status} (expected 200)"
ct = r.headers.get("Content-Type", "")
assert "text/event-stream" in ct, f"Wrong Content-Type: {ct}"
r.close()
except urllib.error.HTTPError as e:
assert False, f"SSE stream returned {e.code} -- AIAgent may not be imported"
except Exception:
pass # timeout or connection close after brief read is fine
post("/api/session/delete", {"session_id": sid})
cleanup_test_sessions.clear()
# ── R3: Session.__init__ missing tool_calls param (Sprint 10 split regression) ─
def test_session_with_tool_calls_in_json_loads_ok(cleanup_test_sessions):
"""R3: Sessions that have tool_calls in their JSON must load without 500.
When tool_calls=None was missing from Session.__init__, loading such sessions
threw TypeError: unexpected keyword argument.
"""
sid = make_session(cleanup_test_sessions)
# Manually inject tool_calls into the session's JSON file
sessions_dir = pathlib.Path.home() / ".hermes" / "webui-mvp-test" / "sessions"
session_file = sessions_dir / f"{sid}.json"
if session_file.exists():
d = json.loads(session_file.read_text())
d["tool_calls"] = [
{"name": "terminal", "snippet": "test output", "tid": "test_tid_001", "assistant_msg_idx": 1}
]
session_file.write_text(json.dumps(d))
# Loading the session must return 200, not 500
data, status = get(f"/api/session?session_id={urllib.parse.quote(sid)}")
assert status == 200, f"Session with tool_calls returned {status}: {data}"
assert data["session"]["session_id"] == sid
post("/api/session/delete", {"session_id": sid})
cleanup_test_sessions.clear()
# ── R4: has_pending not imported in streaming.py (Sprint 10 split regression) ─
def test_streaming_py_imports_has_pending(cleanup_test_sessions):
"""R4: api/streaming.py must import or define has_pending.
When missing, the approval check mid-stream caused NameError.
"""
src = (REPO_ROOT / "api/streaming.py").read_text()
assert "has_pending" in src, "has_pending not found in api/streaming.py"
# Verify it's imported (not just used)
assert "import" in src and "has_pending" in src, \
"has_pending must be imported in api/streaming.py"
def test_aiagent_imported_in_streaming(cleanup_test_sessions):
"""R2b: api/streaming.py must import AIAgent.
When missing, the streaming thread crashed immediately after being spawned.
"""
src = (REPO_ROOT / "api/streaming.py").read_text()
assert "AIAgent" in src, "AIAgent not referenced in api/streaming.py"
assert "from run_agent import AIAgent" in src or "import AIAgent" in src, \
"AIAgent must be imported in api/streaming.py"
# ── R5: SSE loop did not break on cancel event (Sprint 10 bug) ───────────────
def test_cancel_nonexistent_stream_returns_not_cancelled(cleanup_test_sessions):
"""R5a: Cancel endpoint works and returns cancelled:false for unknown stream."""
data, status = get("/api/chat/cancel?stream_id=nonexistent_test_xyz")
assert status == 200
assert data["ok"] is True
assert data["cancelled"] is False
def test_server_py_sse_loop_breaks_on_cancel(cleanup_test_sessions):
"""R5b: server.py SSE loop must include 'cancel' in the break condition.
When missing, the connection hung after the cancel event was processed.
"""
src = (REPO_ROOT / "server.py").read_text()
# Find the SSE break condition
import re
m = re.search(r"if event in \([^)]+\):\s*break", src)
assert m, "SSE break condition not found in server.py"
assert "cancel" in m.group(), \
f"'cancel' missing from SSE break condition: {m.group()}"
# ── R6: Test cron isolation (Sprint 10) ──────────────────────────────────────
def test_real_jobs_json_not_polluted_by_tests(cleanup_test_sessions):
"""R6: Test runs must not write to the real ~/.hermes/cron/jobs.json.
When HERMES_HOME isolation was missing, every test run added test-job-* entries.
"""
real_jobs_path = pathlib.Path.home() / ".hermes" / "cron" / "jobs.json"
if not real_jobs_path.exists():
return # no jobs file at all -- fine
jobs = json.loads(real_jobs_path.read_text())
if isinstance(jobs, dict):
jobs = jobs.get("jobs", [])
test_jobs = [j for j in jobs if j.get("name", "").startswith("test-job-")]
assert len(test_jobs) == 0, \
f"Real jobs.json contains {len(test_jobs)} test-job-* entries: " \
f"{[j['name'] for j in test_jobs]}"
# ── General: api modules all importable ──────────────────────────────────────
def test_all_api_modules_importable(cleanup_test_sessions):
"""All api/ modules must be importable without NameError or ImportError.
Catches missing imports introduced during future module splits.
"""
import ast, pathlib
api_dir = REPO_ROOT / "api"
for module_file in api_dir.glob("*.py"):
src = module_file.read_text()
try:
ast.parse(src)
except SyntaxError as e:
assert False, f"{module_file.name} has syntax error: {e}"
def test_server_py_importable(cleanup_test_sessions):
"""server.py must parse without syntax errors after any split."""
import ast, pathlib
src = (REPO_ROOT / "server.py").read_text()
try:
ast.parse(src)
except SyntaxError as e:
assert False, f"server.py has syntax error: {e}"
# ── R7: Cross-session busy state bleed ───────────────────────────────────────
def test_loadSession_resets_busy_state_for_idle_session(cleanup_test_sessions):
"""R7: sessions.js loadSession for a non-inflight session must reset S.busy to false.
When missing, switching from a busy session to an idle one left the Send button
disabled, showed the wrong activity bar, and pointed Cancel at the wrong stream.
"""
src = (REPO_ROOT / "static/sessions.js").read_text()
# The fix adds explicit S.busy=false in the non-inflight else branch
assert "S.busy=false;" in src, "sessions.js loadSession must set S.busy=false when loading a non-inflight session"
# btnSend must be explicitly re-enabled
assert "$('btnSend').disabled=false;" in src, "sessions.js loadSession must enable btnSend for non-inflight sessions"
def test_done_handler_guards_setbusy_with_inflight_check(cleanup_test_sessions):
"""R7b: messages.js done/error handlers must not call setBusy(false) if the
currently viewed session is itself still in-flight.
When missing, finishing session A while viewing in-flight session B would
disable B's Send button.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
# The fix wraps setBusy(false) in a guard
assert "INFLIGHT[S.session.session_id]" in src, "messages.js must guard setBusy(false) with INFLIGHT check for current session"
def test_cancel_button_not_cleared_across_sessions(cleanup_test_sessions):
"""R7c: The Cancel button and activeStreamId must only be cleared when the
done/error event belongs to the currently viewed session.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
# Both clear operations must be inside the activeSid === S.session guard
# We check for the pattern added by the fix
assert "S.session.session_id===activeSid" in src, "messages.js must guard activeStreamId/Cancel clearing with session identity check"
# ── R8: Session delete does not invalidate index (ghost sessions) ─────────────
def test_deleted_session_does_not_appear_in_list(cleanup_test_sessions):
"""R8: After deleting a session, it must not appear in /api/sessions.
When _index.json was not invalidated on delete, the session reappeared
in the list even after the JSON file was removed.
"""
# Create a session with a title so it shows in the list
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
post("/api/session/rename", {"session_id": sid, "title": "regression-test-delete-R8"})
# Verify it appears
sessions, _ = get("/api/sessions")
ids_before = [s["session_id"] for s in sessions["sessions"]]
assert sid in ids_before, "Session must appear in list before delete"
# Delete it
result, status = post("/api/session/delete", {"session_id": sid})
assert status == 200 and result.get("ok") is True
# Verify it no longer appears -- even after a second fetch (index rebuild)
sessions2, _ = get("/api/sessions")
ids_after = [s["session_id"] for s in sessions2["sessions"]]
assert sid not in ids_after, f"Deleted session {sid} still appears in list -- index not invalidated on delete"
def test_server_delete_invalidates_index(cleanup_test_sessions):
"""R8b: server.py session/delete handler must unlink _index.json.
Static check that the fix is in place.
"""
src = (REPO_ROOT / "server.py").read_text()
# Find the delete handler and verify it unlinks the index
delete_idx = src.find("if parsed.path == '/api/session/delete':")
assert delete_idx >= 0, "session/delete handler not found"
delete_block = src[delete_idx:delete_idx+600]
assert "SESSION_INDEX_FILE" in delete_block, "server.py session/delete must invalidate SESSION_INDEX_FILE"
# ── R9: Token/tool SSE events write to wrong session after switch ─────────────
def test_token_handler_guards_session_id(cleanup_test_sessions):
"""R9a: The SSE token event handler must check activeSid before writing to DOM.
When missing, tokens from session A would render into session B's message area
if the user switched sessions mid-stream.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
# Find the token event handler
token_idx = src.find("es.addEventListener('token'")
assert token_idx >= 0, "token event handler not found"
token_block = src[token_idx:token_idx+300]
assert "activeSid" in token_block, "token handler must check activeSid before writing to DOM"
assert "S.session.session_id!==activeSid" in token_block or "S.session.session_id===activeSid" in token_block, "token handler must compare current session to activeSid"
def test_tool_handler_guards_session_id(cleanup_test_sessions):
"""R9b: The SSE tool event handler must check activeSid before writing to DOM.
When missing, tool cards from session A would render into session B's message area.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
tool_idx = src.find("es.addEventListener('tool'")
assert tool_idx >= 0, "tool event handler not found"
tool_block = src[tool_idx:tool_idx+400]
assert "activeSid" in tool_block, "tool handler must check activeSid before writing to DOM"
# ── R10: respondApproval uses wrong session_id after switch (multi-session) ─
def test_respond_approval_uses_approval_session_id(cleanup_test_sessions):
"""R10: respondApproval must use the session_id of the session that triggered
the approval, not S.session.session_id (which may be a different session
if the user switched while approval was pending).
"""
src = (REPO_ROOT / "static/messages.js").read_text()
# The fix introduces _approvalSessionId to track the correct session
assert "_approvalSessionId" in src, "messages.js must use _approvalSessionId in respondApproval"
# respondApproval must use _approvalSessionId, not S.session.session_id directly
idx = src.find("async function respondApproval(")
assert idx >= 0, "respondApproval not found"
fn_body = src[idx:idx+300]
assert "_approvalSessionId" in fn_body, "respondApproval must read _approvalSessionId, not S.session.session_id"
# ── R11: Activity bar shows cross-session tool status ─────────────────────
def test_tool_status_only_shown_for_current_session(cleanup_test_sessions):
"""R11: The activity bar setStatus() call in the tool SSE handler must only
fire when the user is viewing the session that triggered the tool.
When missing, session A's tool names would appear in session B's activity bar.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
# Find the tool event handler
tool_idx = src.find("es.addEventListener('tool'")
assert tool_idx >= 0
tool_block = src[tool_idx:tool_idx+400]
# setStatus must be inside the activeSid guard, not before it
status_pos = tool_block.find("setStatus(")
guard_pos = tool_block.find("S.session.session_id===activeSid")
assert guard_pos >= 0, "tool handler must guard with activeSid check"
# The guard must appear BEFORE or AROUND the setStatus call
# (status only fires for the current session)
assert status_pos > tool_block.find("activeSid"), "setStatus in tool handler must be inside the activeSid guard"
# ── R12: Live tool cards lost on switch-away and switch-back ──────────────
def test_loadSession_inflight_restores_live_tool_cards(cleanup_test_sessions):
"""R12: When switching back to an in-flight session, live tool cards in
#liveToolCards must be restored from S.toolCalls.
When missing, tool cards disappeared on switch-away even though the session
was still processing.
"""
src = (REPO_ROOT / "static/sessions.js").read_text()
# INFLIGHT branch must call appendLiveToolCard
inflight_idx = src.find("if(INFLIGHT[sid]){")
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
inflight_block = src[inflight_idx:inflight_idx+500]
assert "appendLiveToolCard" in inflight_block, "loadSession INFLIGHT branch must restore live tool cards via appendLiveToolCard"
assert "clearLiveToolCards" in inflight_block, "loadSession INFLIGHT branch must clear old live cards before restoring"
# ── R13: renderMessages() called before S.busy=false in done handler ────────
def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_sessions):
"""R13: In the done handler, S.busy must be set to false BEFORE renderMessages()
is called for the active session. The !S.busy guard in renderMessages() controls
whether settled tool cards are rendered. When S.busy=true during renderMessages(),
tool cards are skipped entirely after a response completes.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
done_idx = src.find("es.addEventListener('done'")
assert done_idx >= 0
done_block = src[done_idx:done_idx+1500]
# S.busy=false must appear before renderMessages() within the done handler
busy_pos = done_block.find("S.busy=false;")
render_pos = done_block.find("renderMessages()")
assert busy_pos >= 0, "done handler must set S.busy=false before renderMessages()"
assert busy_pos < render_pos, f"S.busy=false (pos {busy_pos}) must come before renderMessages() (pos {render_pos})"
# ── R14: send() uses stale modelSelect.value instead of session model ────────
def test_send_uses_session_model_as_authoritative_source(cleanup_test_sessions):
"""R14: send() must use S.session.model as the authoritative model, not just
$('modelSelect').value. When a session was created with a model not in the
current dropdown list, the select value would be stale after switching sessions,
causing the wrong model to be sent.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
# The model field in the chat/start payload must prefer S.session.model
chat_start_idx = src.find("/api/chat/start")
assert chat_start_idx >= 0
payload_block = src[chat_start_idx:chat_start_idx+300]
assert "S.session.model" in payload_block, "send() must use S.session.model in the chat/start payload"
# ── R15: newSession does not clear live tool cards ────────────────────────────
def test_newSession_clears_live_tool_cards(cleanup_test_sessions):
"""R15: newSession() must call clearLiveToolCards() so live cards from a
previous in-flight session don't persist when starting a fresh conversation.
"""
src = (REPO_ROOT / "static/sessions.js").read_text()
new_sess_idx = src.find("async function newSession(")
assert new_sess_idx >= 0
# Find end of newSession (next async function)
next_fn = src.find("async function ", new_sess_idx + 10)
new_sess_body = src[new_sess_idx:next_fn]
assert "clearLiveToolCards" in new_sess_body, "newSession() must call clearLiveToolCards() to clear stale live cards"

437
tests/test_sprint1.py Normal file
View File

@@ -0,0 +1,437 @@
"""
Sprint 1 test suite for the Hermes WebUI.
Tests use the ISOLATED test server running on http://127.0.0.1:8788.
Production server (port 8787) and your real conversations are never touched.
Start the server before running:
<repo>/start.sh
# wait 2 seconds
pytest webui-mvp/tests/test_sprint1.py -v
All tests are HTTP-level: they call real API endpoints and verify responses.
No mocking required for session CRUD, upload parser, or approval API.
"""
import io
import json
import os
import sys
import time
import uuid
import urllib.request
import urllib.parse
import urllib.error
import tempfile
import pathlib
# Allow importing server modules directly for unit tests
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent))
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
# ──────────────────────────────────────────────
# HTTP helpers
# ──────────────────────────────────────────────
def get(path):
url = BASE + path
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
def post(path, body=None):
url = BASE + path
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, 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 post_multipart(path, fields, files):
"""Post a multipart/form-data request. files: {name: (filename, bytes)}"""
boundary = uuid.uuid4().hex.encode()
body = b""
for name, value in fields.items():
body += b"--" + boundary + b"\r\n"
body += f"Content-Disposition: form-data; name=\"{name}\"\r\n\r\n".encode()
body += value.encode() + b"\r\n"
for name, (filename, data) in files.items():
body += b"--" + boundary + b"\r\n"
body += f"Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n".encode()
body += b"Content-Type: application/octet-stream\r\n\r\n"
body += data + b"\r\n"
body += b"--" + boundary + b"--\r\n"
req = urllib.request.Request(BASE + path, data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary.decode()}"})
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 make_session_tracked(created_list, ws=None):
"""Create a session and register it with the cleanup fixture."""
body = {}
if ws: body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, pathlib.Path(d["session"]["workspace"])
# ──────────────────────────────────────────────
# Health check (prerequisite for all tests)
# ──────────────────────────────────────────────
def test_health():
"""Server must be running and healthy."""
data = get("/health")
assert data["status"] == "ok", f"health not ok: {data}"
# ──────────────────────────────────────────────
# B11: /api/session GET footgun fix
# ──────────────────────────────────────────────
def test_session_get_no_id_returns_400():
"""B11: GET /api/session with no session_id must return 400, not silently create."""
try:
data = get("/api/session")
# If we get here, the server returned 200 (old broken behavior)
assert False, f"Expected 400 but got 200: {data}"
except urllib.error.HTTPError as e:
assert e.code == 400, f"Expected 400, got {e.code}"
body = json.loads(e.read())
assert "error" in body
# ──────────────────────────────────────────────
# Session CRUD
# ──────────────────────────────────────────────
def test_session_create_and_load():
"""Create a session, verify it appears in /api/sessions, load it."""
data, status = post("/api/session/new", {"model": "openai/gpt-5.4-mini"})
assert status == 200, f"Expected 200, got {status}: {data}"
assert "session" in data
sid = data["session"]["session_id"]
assert len(sid) == 12 # uuid4().hex[:12]
# Give it a title so it's visible in the session list (empty Untitled sessions are filtered)
post("/api/session/rename", {"session_id": sid, "title": "test-create-verify"})
# Verify it appears in /api/sessions list
sessions = get("/api/sessions")
sids = [s["session_id"] for s in sessions["sessions"]]
assert sid in sids, f"New session {sid} not in sessions list"
# Load it directly
loaded = get(f"/api/session?session_id={sid}")
assert loaded["session"]["session_id"] == sid
assert loaded["session"]["messages"] == []
# Cleanup
post("/api/session/delete", {"session_id": sid})
def test_session_update():
"""Create session, update workspace and model, verify persisted."""
data, _ = post("/api/session/new", {})
sid = data["session"]["session_id"]
updated, status = post("/api/session/update", {
"session_id": sid,
"workspace": "/tmp",
"model": "anthropic/claude-sonnet-4.6"
})
assert status == 200
assert updated["session"]["model"] == "anthropic/claude-sonnet-4.6"
# Reload and verify persistence
reloaded = get(f"/api/session?session_id={sid}")
assert reloaded["session"]["model"] == "anthropic/claude-sonnet-4.6"
def test_session_delete():
"""Create session, delete it, verify it no longer loads."""
data, _ = post("/api/session/new", {})
sid = data["session"]["session_id"]
result, status = post("/api/session/delete", {"session_id": sid})
assert status == 200
assert result.get("ok") is True
# Trying to load it should now 404/500 (KeyError -> 500 in current handler)
try:
get(f"/api/session?session_id={sid}")
assert False, "Expected error loading deleted session"
except urllib.error.HTTPError as e:
assert e.code in (404, 500), f"Expected 404 or 500, got {e.code}"
def test_session_delete_nonexistent():
"""Deleting a nonexistent session should return ok:True (idempotent)."""
result, status = post("/api/session/delete", {"session_id": "doesnotexist"})
assert status == 200
assert result.get("ok") is True
def test_sessions_list_sorted():
"""Sessions list should be sorted most-recently-updated first."""
# Create two sessions with a title so they're visible (empty Untitled sessions are filtered)
a, _ = post("/api/session/new", {})
time.sleep(0.05)
b, _ = post("/api/session/new", {})
sid_a = a["session"]["session_id"]
sid_b = b["session"]["session_id"]
post("/api/session/rename", {"session_id": sid_a, "title": "test-sort-a"})
time.sleep(0.05)
post("/api/session/rename", {"session_id": sid_b, "title": "test-sort-b"})
sessions = get("/api/sessions")
sids = [s["session_id"] for s in sessions["sessions"]]
# b was updated more recently, should appear before a
assert sids.index(sid_b) < sids.index(sid_a), \
"Sessions not sorted by updated_at desc"
# Cleanup
post("/api/session/delete", {"session_id": sid_a})
post("/api/session/delete", {"session_id": sid_b})
# ──────────────────────────────────────────────
# Upload parser unit tests (pure function, no HTTP)
# ──────────────────────────────────────────────
def test_parse_multipart_text_file():
"""parse_multipart correctly parses a text file field."""
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent))
# Import the function directly from the server module
import importlib.util
spec = importlib.util.spec_from_file_location(
"server",
str(pathlib.Path(__file__).parent.parent / "server.py")
)
# We only need parse_multipart; import it without running the server
# Parse manually by reading the source and exec only the function
src = pathlib.Path(__file__).parent.parent.joinpath("api/upload.py").read_text()
# Extract and exec parse_multipart
import re
# Find the function
m = re.search(r"(def parse_multipart\(.*?)(?=\ndef )", src, re.DOTALL)
assert m, "Could not find parse_multipart in server.py"
ns = {}
exec("import re as _re, email.parser as _ep\n" + m.group(1), ns)
parse_multipart = ns["parse_multipart"]
# Build a minimal multipart body
boundary = b"testboundary"
body = (
b"--testboundary\r\n"
b"Content-Disposition: form-data; name=\"session_id\"\r\n\r\n"
b"abc123\r\n"
b"--testboundary\r\n"
b"Content-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\n"
b"Content-Type: text/plain\r\n\r\n"
b"hello world\r\n"
b"--testboundary--\r\n"
)
fields, files = parse_multipart(
io.BytesIO(body),
"multipart/form-data; boundary=testboundary",
len(body)
)
assert fields.get("session_id") == "abc123", f"fields: {fields}"
assert "file" in files, f"files: {files}"
filename, content = files["file"]
assert filename == "hello.txt"
assert content == b"hello world"
def test_parse_multipart_binary_file():
"""parse_multipart handles binary (PNG header bytes) without corruption."""
src = pathlib.Path(__file__).parent.parent.joinpath("api/upload.py").read_text()
import re
m = re.search(r"(def parse_multipart\(.*?)(?=\ndef )", src, re.DOTALL)
ns = {}
exec("import re as _re, email.parser as _ep\n" + m.group(1), ns)
parse_multipart = ns["parse_multipart"]
# Fake PNG: first 8 bytes of PNG magic
png_magic = b"\x89PNG\r\n\x1a\n"
boundary = b"binboundary"
body = (
b"--binboundary\r\n"
b"Content-Disposition: form-data; name=\"session_id\"\r\n\r\n"
b"sess1\r\n"
b"--binboundary\r\n"
b"Content-Disposition: form-data; name=\"file\"; filename=\"test.png\"\r\n"
b"Content-Type: image/png\r\n\r\n" + png_magic + b"\r\n"
b"--binboundary--\r\n"
)
fields, files = parse_multipart(
io.BytesIO(body),
"multipart/form-data; boundary=binboundary",
len(body)
)
assert "file" in files
filename, content = files["file"]
assert filename == "test.png"
assert content == png_magic, f"Binary content corrupted: {content!r}"
# ──────────────────────────────────────────────
# File upload via HTTP
# ──────────────────────────────────────────────
def test_upload_text_file(cleanup_test_sessions):
"""Upload a text file to a session workspace, verify it appears in /api/list."""
sid, ws = make_session_tracked(cleanup_test_sessions)
result, status = post_multipart("/api/upload", {"session_id": sid}, {
"file": ("test_upload.txt", b"sprint1 test content")
})
assert status == 200, f"Upload failed {status}: {result}"
assert "filename" in result
assert result["size"] == len(b"sprint1 test content")
# Verify file appears in listing
listing = get(f"/api/list?session_id={sid}&path=.")
names = [e["name"] for e in listing["entries"]]
assert result["filename"] in names, f"{result['filename']} not in {names}"
# Cleanup the uploaded file
post("/api/file/delete", {"session_id": sid, "path": result["filename"]})
def test_upload_too_large(cleanup_test_sessions):
"""Uploading a file over MAX_UPLOAD_BYTES is rejected (413 or connection closed)."""
sid, _ = make_session_tracked(cleanup_test_sessions)
# 21MB > 20MB limit
big = b"x" * (21 * 1024 * 1024)
try:
result, status = post_multipart("/api/upload", {"session_id": sid}, {
"file": ("big.bin", big)
})
# If we get a response it should be 413
assert status == 413, f"Expected 413, got {status}: {result}"
except (urllib.error.URLError, ConnectionResetError, BrokenPipeError):
# Server closed connection after reading Content-Length > limit before body
# This is also valid rejection behavior
pass
def test_upload_no_file_field(cleanup_test_sessions):
"""Upload with no file field returns 400."""
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post_multipart("/api/upload", {"session_id": sid}, {})
assert status == 400, f"Expected 400, got {status}: {result}"
def test_upload_bad_session():
"""Upload to nonexistent session returns 404."""
result, status = post_multipart("/api/upload", {"session_id": "nosuchsession"}, {
"file": ("x.txt", b"data")
})
assert status == 404, f"Expected 404, got {status}: {result}"
# ──────────────────────────────────────────────
# Approval API
# ──────────────────────────────────────────────
def test_approval_pending_none():
"""GET /api/approval/pending for a session with no pending entry returns null."""
data = get("/api/approval/pending?session_id=no_such_session")
assert data["pending"] is None
def test_approval_submit_and_respond():
"""Inject a pending approval via server endpoint, retrieve it, respond with deny."""
test_sid = f"test-approval-{uuid.uuid4().hex[:6]}"
cmd = "rm -rf /tmp/testdir"
key = "recursive_delete"
# Inject into server process via test endpoint (shared module state)
inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(test_sid)}&pattern_key={key}&command={urllib.parse.quote(cmd)}")
assert inject["ok"] is True
# Poll should now show the pending entry
data = get(f"/api/approval/pending?session_id={urllib.parse.quote(test_sid)}")
assert data["pending"] is not None, "Pending entry not visible after inject"
assert data["pending"]["command"] == cmd
# Respond with deny
result, status = post("/api/approval/respond", {
"session_id": test_sid,
"choice": "deny"
})
assert status == 200
assert result["ok"] is True
assert result["choice"] == "deny"
# Pending should be gone
data2 = get(f"/api/approval/pending?session_id={urllib.parse.quote(test_sid)}")
assert data2["pending"] is None, "Pending entry should be cleared after respond"
def test_approval_respond_allow_session():
"""Inject pending entry, respond with session choice, verify cleared (approved)."""
test_sid = f"test-approval-sess-{uuid.uuid4().hex[:6]}"
inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(test_sid)}&pattern_key=force_kill&command=pkill+-9+someproc")
assert inject["ok"] is True
result, status = post("/api/approval/respond", {
"session_id": test_sid,
"choice": "session"
})
assert status == 200
assert result["ok"] is True
assert result["choice"] == "session"
# After session approval, pending should be cleared
data = get(f"/api/approval/pending?session_id={urllib.parse.quote(test_sid)}")
assert data["pending"] is None, "Pending entry should be cleared after session approval"
# ──────────────────────────────────────────────
# Stream status endpoint (B4/B5)
# ──────────────────────────────────────────────
def test_stream_status_unknown_id():
"""GET /api/chat/stream/status for unknown stream_id returns active:false."""
data = get("/api/chat/stream/status?stream_id=doesnotexist")
assert data["active"] is False
# ──────────────────────────────────────────────
# File browser
# ──────────────────────────────────────────────
def test_list_dir(cleanup_test_sessions):
"""List workspace directory for a session."""
sid, _ = make_session_tracked(cleanup_test_sessions)
listing = get(f"/api/list?session_id={sid}&path=.")
assert "entries" in listing
assert isinstance(listing["entries"], list)
def test_list_dir_path_traversal(cleanup_test_sessions):
"""Path traversal via ../.. should be blocked (500 or 400)."""
sid, _ = make_session_tracked(cleanup_test_sessions)
try:
listing = get(f"/api/list?session_id={sid}&path=../../etc")
# If server returns entries outside workspace root, that is a bug
# (safe_resolve should raise ValueError)
assert False, f"Expected error for path traversal, got: {listing}"
except urllib.error.HTTPError as e:
assert e.code in (400, 404, 500), f"Expected 400/404/500 for traversal, got {e.code}"

139
tests/test_sprint10.py Normal file
View File

@@ -0,0 +1,139 @@
"""
Sprint 10 Tests: server.py split, cancel endpoint, cron history, tool card polish.
"""
import json, pathlib, urllib.error, urllib.request, urllib.parse
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
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_text(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
def make_session(created_list):
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
created_list.append(sid)
return sid
# ── server.py split: api/ modules served / importable ─────────────────────
def test_health_still_works(cleanup_test_sessions):
data, status = get("/health")
assert status == 200
assert data["status"] == "ok"
assert "uptime_seconds" in data
assert "active_streams" in data
def test_api_modules_exist(cleanup_test_sessions):
"""All api/ module files must exist on disk."""
base = REPO_ROOT / "api"
for mod in ["__init__.py", "config.py", "helpers.py", "models.py",
"workspace.py", "upload.py", "streaming.py"]:
assert (base / mod).exists(), f"Missing api/{mod}"
def test_server_py_under_750_lines(cleanup_test_sessions):
"""server.py should be under 750 lines after the split."""
lines = len((REPO_ROOT / "server.py").read_text().splitlines())
assert lines < 750, f"server.py is {lines} lines -- split may not have landed"
def test_api_config_has_cancel_flags(cleanup_test_sessions):
src = (REPO_ROOT / "api/config.py").read_text()
assert "CANCEL_FLAGS" in src
assert "STREAMS" in src
def test_session_crud_still_works(cleanup_test_sessions):
"""Full session lifecycle works after split."""
created = []
sid = make_session(created)
data, status = get(f"/api/session?session_id={urllib.parse.quote(sid)}")
assert status == 200
assert data["session"]["session_id"] == sid
post("/api/session/delete", {"session_id": sid})
def test_static_files_still_served(cleanup_test_sessions):
for f in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]:
src, status = get_text(f"/static/{f}")
assert status == 200, f"/static/{f} returned {status}"
assert len(src) > 100
# ── Cancel endpoint ────────────────────────────────────────────────────────
def test_cancel_requires_stream_id(cleanup_test_sessions):
try:
data, status = get("/api/chat/cancel")
assert status == 400
except urllib.error.HTTPError as e:
assert e.code == 400
def test_cancel_nonexistent_stream(cleanup_test_sessions):
data, status = get("/api/chat/cancel?stream_id=nonexistent_xyz")
assert status == 200
assert data["ok"] is True
assert data["cancelled"] is False
def test_cancel_button_in_html(cleanup_test_sessions):
src, _ = get_text("/")
assert "btnCancel" in src
assert "cancelStream" in src
def test_cancel_function_in_boot_js(cleanup_test_sessions):
src, _ = get_text("/static/boot.js")
assert "async function cancelStream(" in src
assert "/api/chat/cancel" in src
# ── Cron history ───────────────────────────────────────────────────────────
def test_crons_output_limit_param(cleanup_test_sessions):
"""Server accepts limit parameter > 1."""
data, status = get("/api/crons/output?job_id=nonexistent&limit=20")
# 404 or 200 with empty -- both acceptable for nonexistent job
assert status in (200, 404)
def test_cron_history_button_in_panels_js(cleanup_test_sessions):
src, _ = get_text("/static/panels.js")
assert "loadCronHistory" in src
assert "All runs" in src
def test_cron_output_snippet_helper(cleanup_test_sessions):
src, _ = get_text("/static/panels.js")
assert "_cronOutputSnippet" in src
# ── Tool card polish ───────────────────────────────────────────────────────
def test_tool_card_running_dot_in_css(cleanup_test_sessions):
src, _ = get_text("/static/style.css")
assert "tool-card-running-dot" in src
def test_tool_card_show_more_in_ui_js(cleanup_test_sessions):
src, _ = get_text("/static/ui.js")
assert "Show more" in src
assert "tool-card-more" in src
def test_tool_card_smart_truncation_in_ui_js(cleanup_test_sessions):
src, _ = get_text("/static/ui.js")
assert "displaySnippet" in src
assert "lastBreak" in src
def test_cancel_sse_event_handler_in_messages_js(cleanup_test_sessions):
src, _ = get_text("/static/messages.js")
assert "addEventListener('cancel'" in src
assert "Task cancelled" in src
def test_active_stream_id_tracked(cleanup_test_sessions):
src, _ = get_text("/static/messages.js")
assert "S.activeStreamId" in src

106
tests/test_sprint2.py Normal file
View File

@@ -0,0 +1,106 @@
"""Sprint 2 tests: image preview, file types, markdown. Uses cleanup_test_sessions fixture."""
import io, json, uuid, urllib.request, urllib.error, pathlib
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
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(), r.headers.get('Content-Type', ''), 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 make_session_tracked(created_list, ws=None):
"""Create a session and register it with the cleanup fixture."""
import pathlib as _pathlib
body = {}
if ws: body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, _pathlib.Path(d["session"]["workspace"])
def test_raw_endpoint_serves_png(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
png = (b"\x89PNG\r\n\x1a\n" b"\x00\x00\x00\rIHDR\x00\x00\x00\x01"
b"\x00\x00\x00\x01\x08\x02\x00\x00\x00"
b"\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc"
b"\xf8\x0f\x00\x00\x01\x01\x00\x05\x18"
b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82")
(ws / "test.png").write_bytes(png)
raw, ct, status = get_raw(f"/api/file/raw?session_id={sid}&path=test.png")
assert status == 200
assert "image/png" in ct
assert raw == png
def test_raw_endpoint_serves_jpeg(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
(ws / "photo.jpg").write_bytes(jpeg)
raw, ct, status = get_raw(f"/api/file/raw?session_id={sid}&path=photo.jpg")
assert status == 200
assert "image/jpeg" in ct
def test_raw_endpoint_serves_svg(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><circle/></svg>"
(ws / "icon.svg").write_bytes(svg)
raw, ct, status = get_raw(f"/api/file/raw?session_id={sid}&path=icon.svg")
assert status == 200
assert "image/svg" in ct
def test_raw_endpoint_path_traversal_blocked(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
try:
get_raw(f"/api/file/raw?session_id={sid}&path=../../etc/passwd")
assert False
except urllib.error.HTTPError as e:
assert e.code in (400, 500)
def test_raw_endpoint_missing_file_returns_404(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
try:
get_raw(f"/api/file/raw?session_id={sid}&path=no_such_file.png")
assert False
except urllib.error.HTTPError as e:
assert e.code in (404, 500)
def test_md_file_returns_text_via_api_file(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
md = "# Hello\n\nThis is **bold**.\n"
(ws / "README.md").write_text(md)
data, status = get(f"/api/file?session_id={sid}&path=README.md")
assert status == 200
assert data["content"] == md
def test_md_file_with_table(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
md = "| Name | Value |\n|------|-------|\n| foo | bar |\n"
(ws / "table.md").write_text(md)
data, status = get(f"/api/file?session_id={sid}&path=table.md")
assert status == 200
assert "| Name | Value |" in data["content"]
def test_file_listing_includes_images(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
(ws / "photo.png").write_bytes(b"fake png")
(ws / "notes.md").write_text("# Notes")
(ws / "script.py").write_text("print('hello')")
data, status = get(f"/api/list?session_id={sid}&path=.")
assert status == 200
names = {e["name"]: e for e in data["entries"]}
assert "photo.png" in names
assert "notes.md" in names
assert "script.py" in names

144
tests/test_sprint3.py Normal file
View File

@@ -0,0 +1,144 @@
"""Sprint 3 tests: cron API, skills API, memory API, input validation."""
import json, uuid, urllib.request, urllib.error
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
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 make_session_tracked(created_list, ws=None):
"""Create a session and register it with the cleanup fixture."""
import pathlib as _pathlib
body = {}
if ws: body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, _pathlib.Path(d["session"]["workspace"])
def test_crons_list():
data, status = get("/api/crons")
assert status == 200
assert "jobs" in data
def test_crons_list_has_required_fields():
data, _ = get("/api/crons")
if not data["jobs"]: return
job = data["jobs"][0]
for field in ("id", "name", "prompt", "enabled", "schedule_display"):
assert field in job
def test_crons_output_requires_job_id():
try:
get("/api/crons/output")
assert False
except urllib.error.HTTPError as e:
assert e.code == 400
def test_crons_output_real_job():
data, _ = get("/api/crons")
if not data["jobs"]: return
job_id = data["jobs"][0]["id"]
out, status = get(f"/api/crons/output?job_id={job_id}&limit=3")
assert status == 200
assert "outputs" in out
def test_crons_pause_requires_job_id():
result, status = post("/api/crons/pause", {})
assert status in (400, 404)
def test_crons_resume_requires_job_id():
result, status = post("/api/crons/resume", {})
assert status in (400, 404)
def test_crons_run_nonexistent():
result, status = post("/api/crons/run", {"job_id": "doesnotexist999"})
assert status == 404
def test_skills_list():
data, status = get("/api/skills")
assert status == 200
assert len(data["skills"]) > 0
def test_skills_list_has_required_fields():
data, _ = get("/api/skills")
skill = data["skills"][0]
assert "name" in skill and "description" in skill
def test_skills_content_known():
data, status = get("/api/skills/content?name=dogfood")
assert status == 200
assert len(data["content"]) > 0
def test_skills_content_requires_name():
try:
get("/api/skills/content")
assert False
except urllib.error.HTTPError as e:
assert e.code == 400
def test_skills_search_returns_subset():
data, _ = get("/api/skills")
assert len(data["skills"]) > 5
def test_memory_returns_both_files():
data, status = get("/api/memory")
assert status == 200
assert "memory" in data and "user" in data
def test_memory_content_is_string():
data, _ = get("/api/memory")
assert isinstance(data["memory"], str)
assert isinstance(data["user"], str)
def test_memory_has_mtime():
data, _ = get("/api/memory")
assert "memory_mtime" in data and "user_mtime" in data
def test_session_update_requires_session_id():
result, status = post("/api/session/update", {"model": "openai/gpt-5.4-mini"})
assert status == 400
def test_session_delete_requires_session_id():
result, status = post("/api/session/delete", {})
assert status == 400
def test_chat_start_requires_session_id():
result, status = post("/api/chat/start", {"message": "hello"})
assert status == 400
def test_chat_start_requires_message(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/chat/start", {"session_id": sid, "message": ""})
assert status == 400
def test_session_update_unknown_id_returns_404():
result, status = post("/api/session/update", {"session_id": "nosuchsession", "model": "openai/gpt-5.4-mini"})
assert status == 404
def test_session_search_returns_matches(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
post("/api/session/rename", {"session_id": sid, "title": f"unique-s3-{sid}"})
data, status = get(f"/api/sessions/search?q=unique-s3-{sid}")
assert status == 200
sids = [s["session_id"] for s in data["sessions"]]
assert sid in sids
def test_session_search_empty_query_returns_all():
data, status = get("/api/sessions/search?q=")
assert status == 200 and "sessions" in data
def test_session_search_no_results():
data, status = get("/api/sessions/search?q=zzznomatchzzz9999")
assert status == 200 and data["sessions"] == []

156
tests/test_sprint4.py Normal file
View File

@@ -0,0 +1,156 @@
"""Sprint 4 tests: relocation, session rename, search, file ops, validation."""
import json, pathlib, uuid, urllib.request, urllib.error
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
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(), r.headers.get("Content-Type",""), 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 make_session_tracked(created_list, ws=None):
"""Create a session and register it with the cleanup fixture."""
import pathlib as _pathlib
body = {}
if ws: body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, _pathlib.Path(d["session"]["workspace"])
def test_server_running_from_new_location():
data, status = get("/health")
assert status == 200 and data["status"] == "ok"
def test_static_css_served():
raw, ct, status = get_raw("/static/style.css")
assert status == 200 and "text/css" in ct and b"--bg" in raw
def test_static_unknown_file_404():
try:
get_raw("/static/doesnotexist.xyz")
assert False
except urllib.error.HTTPError as e:
assert e.code == 404
def test_session_rename(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/session/rename", {"session_id": sid, "title": "Renamed Session"})
assert status == 200 and result["session"]["title"] == "Renamed Session"
def test_session_rename_persists(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
post("/api/session/rename", {"session_id": sid, "title": "Persisted"})
loaded, _ = get(f"/api/session?session_id={sid}")
assert loaded["session"]["title"] == "Persisted"
def test_session_rename_truncates(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/session/rename", {"session_id": sid, "title": "A" * 200})
assert status == 200 and len(result["session"]["title"]) <= 80
def test_session_rename_requires_fields():
result, status = post("/api/session/rename", {"session_id": "x"})
assert status == 400
result2, status2 = post("/api/session/rename", {"title": "hi"})
assert status2 == 400
def test_session_rename_unknown_id():
result, status = post("/api/session/rename", {"session_id": "nosuchid", "title": "hi"})
assert status == 404
def test_session_search_returns_matches(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
uid = uuid.uuid4().hex[:8]
post("/api/session/rename", {"session_id": sid, "title": f"s4-search-{uid}"})
data, status = get(f"/api/sessions/search?q=s4-search-{uid}")
assert status == 200
sids = [s["session_id"] for s in data["sessions"]]
assert sid in sids
def test_session_search_empty_query_returns_all():
data, status = get("/api/sessions/search?q=")
assert status == 200 and "sessions" in data
def test_session_search_no_results():
data, status = get("/api/sessions/search?q=zzznomatchzzz9999")
assert status == 200 and data["sessions"] == []
def test_file_create(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
fname = f"test_{uuid.uuid4().hex[:6]}.txt"
result, status = post("/api/file/create", {"session_id": sid, "path": fname, "content": "hello sprint4"})
assert status == 200 and result["ok"] is True
assert (ws / fname).read_text() == "hello sprint4"
def test_file_create_requires_fields(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/file/create", {"session_id": sid})
assert status == 400
result2, status2 = post("/api/file/create", {"path": "x.txt"})
assert status2 == 400
def test_file_create_duplicate_rejected(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
fname = f"dup_{uuid.uuid4().hex[:6]}.txt"
post("/api/file/create", {"session_id": sid, "path": fname, "content": ""})
result, status = post("/api/file/create", {"session_id": sid, "path": fname, "content": ""})
assert status == 400
def test_file_delete(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
(ws / "to_delete.txt").write_text("bye")
result, status = post("/api/file/delete", {"session_id": sid, "path": "to_delete.txt"})
assert status == 200 and not (ws / "to_delete.txt").exists()
def test_file_delete_missing_returns_404(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/file/delete", {"session_id": sid, "path": "nosuchfile.txt"})
assert status == 404
def test_file_delete_path_traversal_blocked(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/file/delete", {"session_id": sid, "path": "../../etc/passwd"})
assert status in (400, 500)
def test_list_requires_session_id():
try:
get("/api/list?path=.")
assert False
except urllib.error.HTTPError as e:
assert e.code == 400
def test_file_requires_session_id():
try:
get("/api/file?path=readme.txt")
assert False
except urllib.error.HTTPError as e:
assert e.code == 400
def test_file_requires_path(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
try:
get(f"/api/file?session_id={sid}")
assert False
except urllib.error.HTTPError as e:
assert e.code == 400
def test_new_session_inherits_workspace(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
post("/api/session/update", {"session_id": sid, "workspace": "/tmp", "model": "openai/gpt-5.4-mini"})
sid2, _ = make_session_tracked(cleanup_test_sessions)
data, _ = get(f"/api/session?session_id={sid2}")
assert data["session"]["workspace"] == "/tmp"

140
tests/test_sprint5.py Normal file
View File

@@ -0,0 +1,140 @@
"""Sprint 5 tests: workspace CRUD, file save, session index, JS serving."""
import json, pathlib, uuid, urllib.request, urllib.error
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
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(), r.headers.get("Content-Type",""), 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 make_session_tracked(created_list, ws=None):
"""Create a session and register it with the cleanup fixture."""
import pathlib as _pathlib
body = {}
if ws: body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, _pathlib.Path(d["session"]["workspace"])
def test_server_running_from_new_location():
data, status = get("/health")
assert status == 200 and data["status"] == "ok"
def test_app_js_served():
"""Sprint 9: app.js replaced by modules. Verify ui.js (contains renderMd) is served."""
raw, ct, status = get_raw("/static/ui.js")
assert status == 200 and "javascript" in ct and b"renderMd" in raw
def test_workspaces_list():
data, status = get("/api/workspaces")
assert status == 200 and "workspaces" in data and "last" in data
def test_workspace_add_valid():
post("/api/workspaces/remove", {"path": "/tmp"})
result, status = post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
assert status == 200 and any(w["path"]=="/tmp" for w in result["workspaces"])
post("/api/workspaces/remove", {"path": "/tmp"})
def test_workspace_add_validates_existence():
result, status = post("/api/workspaces/add", {"path": "/tmp/does_not_exist_xyz_999"})
assert status == 400
def test_workspace_add_validates_is_dir():
result, status = post("/api/workspaces/add", {"path": "/etc/hostname"})
assert status == 400
def test_workspace_add_no_duplicate():
post("/api/workspaces/remove", {"path": "/tmp"})
post("/api/workspaces/add", {"path": "/tmp"})
result, status = post("/api/workspaces/add", {"path": "/tmp"})
assert status == 400 and "already" in result.get("error","").lower()
post("/api/workspaces/remove", {"path": "/tmp"})
def test_workspace_add_requires_path():
result, status = post("/api/workspaces/add", {})
assert status == 400
def test_workspace_remove():
post("/api/workspaces/remove", {"path": "/tmp"})
post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
result, status = post("/api/workspaces/remove", {"path": "/tmp"})
assert status == 200 and "/tmp" not in [w["path"] for w in result["workspaces"]]
def test_workspace_rename():
post("/api/workspaces/remove", {"path": "/tmp"})
post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
result, status = post("/api/workspaces/rename", {"path": "/tmp", "name": "My Temp"})
assert status == 200
assert {w["path"]: w["name"] for w in result["workspaces"]}.get("/tmp") == "My Temp"
post("/api/workspaces/remove", {"path": "/tmp"})
def test_workspace_rename_unknown():
result, status = post("/api/workspaces/rename", {"path": "/no/such/path", "name": "X"})
assert status == 404
def test_last_workspace_updates_on_session_update(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
post("/api/session/update", {"session_id": sid, "workspace": "/tmp", "model": "openai/gpt-5.4-mini"})
data, _ = get("/api/workspaces")
assert data["last"] == "/tmp"
def test_file_save(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
fname = f"save_{uuid.uuid4().hex[:6]}.txt"
(ws / fname).write_text("original content")
result, status = post("/api/file/save", {"session_id": sid, "path": fname, "content": "updated"})
assert status == 200 and (ws / fname).read_text() == "updated"
def test_file_save_requires_fields(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/file/save", {"session_id": sid})
assert status == 400
def test_file_save_nonexistent_returns_404(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/file/save", {"session_id": sid, "path": "no_such.txt", "content": ""})
assert status == 404
def test_file_save_path_traversal_blocked(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/file/save", {"session_id": sid, "path": "../../etc/passwd", "content": ""})
assert status in (400, 500)
def test_session_index_created_after_save(cleanup_test_sessions):
# Index is created in the TEST state dir, not the production dir
test_state_dir = pathlib.Path.home() / ".hermes" / "webui-mvp-test"
index_path = test_state_dir / "sessions" / "_index.json"
make_session_tracked(cleanup_test_sessions)
# Index may not exist yet if cleanup already wiped it -- just check the endpoint works
data, status = get("/api/sessions")
assert status == 200
assert isinstance(data["sessions"], list)
def test_sessions_endpoint_returns_sorted():
data, status = get("/api/sessions")
assert status == 200
sessions = data["sessions"]
if len(sessions) >= 2:
assert sessions[0]["updated_at"] >= sessions[1]["updated_at"]
def test_new_session_inherits_last_workspace(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
post("/api/session/update", {"session_id": sid, "workspace": "/tmp", "model": "openai/gpt-5.4-mini"})
sid2, _ = make_session_tracked(cleanup_test_sessions)
d, _ = get(f"/api/session?session_id={sid2}")
assert d["session"]["workspace"] == "/tmp"

151
tests/test_sprint6.py Normal file
View File

@@ -0,0 +1,151 @@
"""Sprint 6 tests: Escape from editor, Phase D validation, HTML extraction, cron create, session export."""
import json, uuid, pathlib, urllib.request, urllib.error
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788" # isolated test server
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(), r.headers, 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 make_session_tracked(created_list, ws=None):
body = {}
if ws: body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, pathlib.Path(d["session"]["workspace"])
# ── Phase E: HTML served from static/index.html ──
def test_index_html_served():
raw, headers, status = get_raw("/")
assert status == 200
assert b"sidebarResize" in raw, "Resize handle not found in HTML"
assert b"cronCreateForm" in raw, "Cron create form not found in HTML"
assert b"btnExportJSON" in raw, "Export JSON button not found in HTML"
def test_index_html_file_exists():
p = REPO_ROOT / "static/index.html"
assert p.exists(), "static/index.html does not exist"
assert p.stat().st_size > 5000, "index.html seems too small"
def test_server_py_has_no_html_string():
txt = (REPO_ROOT / "server.py").read_text()
assert 'HTML = r"""' not in txt, "server.py still contains inline HTML string"
assert "doctype html" not in txt.lower(), "server.py still contains raw HTML"
# ── Phase D: remaining endpoint validation ──
def test_approval_respond_requires_session_id():
result, status = post("/api/approval/respond", {"choice": "deny"})
assert status == 400
def test_approval_respond_rejects_invalid_choice(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/approval/respond", {"session_id": sid, "choice": "INVALID"})
assert status == 400
def test_file_raw_requires_session_id():
try:
get_raw("/api/file/raw?path=test.png")
assert False, "Expected 400"
except urllib.error.HTTPError as e:
assert e.code == 400
def test_file_raw_unknown_session():
try:
get_raw("/api/file/raw?session_id=nosuchsession&path=test.png")
assert False, "Expected 404"
except urllib.error.HTTPError as e:
assert e.code == 404
# ── Cron create ──
def test_cron_create_requires_prompt():
result, status = post("/api/crons/create", {"schedule": "0 9 * * *"})
assert status == 400
assert "prompt" in result.get("error", "").lower()
def test_cron_create_requires_schedule():
result, status = post("/api/crons/create", {"prompt": "Say hello"})
assert status == 400
assert "schedule" in result.get("error", "").lower()
def test_cron_create_invalid_schedule():
result, status = post("/api/crons/create", {
"prompt": "Say hello", "schedule": "not_a_valid_schedule_xyz"
})
assert status == 400
def test_cron_create_success():
uid = uuid.uuid4().hex[:6]
result, status = post("/api/crons/create", {
"name": f"test-job-{uid}",
"prompt": "Just say 'hello' and nothing else.",
"schedule": "every 999h", # far future -- won't actually run during test
"deliver": "local",
})
assert status == 200, f"Expected 200 got {status}: {result}"
assert result["ok"] is True
assert "job" in result
job_id = result["job"]["id"]
# Verify it appears in the cron list
jobs, _ = get("/api/crons")
ids = [j["id"] for j in jobs["jobs"]]
assert job_id in ids, f"Created job {job_id} not in list"
# ── Session export ──
def test_session_export_requires_session_id():
try:
get_raw("/api/session/export")
assert False
except urllib.error.HTTPError as e:
assert e.code == 400
def test_session_export_unknown_session():
try:
get_raw("/api/session/export?session_id=nosuchsession")
assert False
except urllib.error.HTTPError as e:
assert e.code == 404
def test_session_export_returns_json(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
raw, headers, status = get_raw(f"/api/session/export?session_id={sid}")
assert status == 200
assert "application/json" in headers.get("Content-Type", "")
data = json.loads(raw)
assert data["session_id"] == sid
assert "messages" in data
assert "title" in data
# ── Resizable panels: static files present ──
def test_static_index_has_resize_handles():
raw, _, status = get_raw("/")
assert status == 200
assert b"sidebarResize" in raw
assert b"rightpanelResize" in raw
def test_app_js_has_resize_logic():
"""Sprint 9: app.js replaced by modules. Resize logic lives in boot.js."""
raw, _, status = get_raw("/static/boot.js")
assert status == 200
assert b"_initResizePanels" in raw
assert b"hermes-sidebar-w" in raw
assert b"hermes-panel-w" in raw

130
tests/test_sprint7.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Sprint 7 Tests: Cron CRUD, Skill CRUD, Memory Write, Session Content Search, Health
"""
import json, pathlib, urllib.error, urllib.parse, 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())
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 make_session_tracked(created_list, ws=None):
body = {}
if ws: body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, pathlib.Path(d["session"]["workspace"])
# ── Health (Phase G) ──────────────────────────────────────────────
def test_health_has_active_streams():
data = get("/health")
assert "active_streams" in data
assert isinstance(data["active_streams"], int) and data["active_streams"] >= 0
def test_health_has_uptime_seconds():
data = get("/health")
assert "uptime_seconds" in data
assert isinstance(data["uptime_seconds"], (int, float)) and data["uptime_seconds"] >= 0
# ── Session content search ────────────────────────────────────────
def test_session_search_empty_returns_all(cleanup_test_sessions):
data = get("/api/sessions/search?q=")
assert "sessions" in data
def test_session_search_content_params_accepted(cleanup_test_sessions):
data = get("/api/sessions/search?q=hello&content=1&depth=3")
assert "sessions" in data and "query" in data and data["query"] == "hello"
def test_session_search_returns_count(cleanup_test_sessions):
data = get("/api/sessions/search?q=nonexistent_xyz_9999&content=1")
assert "count" in data and data["count"] == 0
# ── Cron update ───────────────────────────────────────────────────
def test_cron_update_requires_job_id(cleanup_test_sessions):
data, status = post("/api/crons/update", {"name": "test"})
assert status == 400
def test_cron_update_unknown_job_404(cleanup_test_sessions):
data, status = post("/api/crons/update", {"job_id": "nonexistent_abc123"})
assert status == 404
# ── Cron delete ───────────────────────────────────────────────────
def test_cron_delete_requires_job_id(cleanup_test_sessions):
data, status = post("/api/crons/delete", {})
assert status == 400
def test_cron_delete_unknown_404(cleanup_test_sessions):
data, status = post("/api/crons/delete", {"job_id": "nonexistent_xyz999"})
assert status == 404
# ── Skill save ────────────────────────────────────────────────────
def test_skill_save_requires_name(cleanup_test_sessions):
data, status = post("/api/skills/save", {"content": "# test"})
assert status == 400
def test_skill_save_requires_content(cleanup_test_sessions):
data, status = post("/api/skills/save", {"name": "test-no-content"})
assert status == 400
def test_skill_save_invalid_name_rejected(cleanup_test_sessions):
data, status = post("/api/skills/save", {"name": "../../../etc/passwd", "content": "bad"})
assert status == 400
def test_skill_save_delete_roundtrip(cleanup_test_sessions):
skill_name = "test-sprint7-skill"
content = "---\nname: test-sprint7-skill\ndescription: Sprint 7 test.\ntags: [test]\n---\n\n# Test\n\nSprint 7 test skill."
data, status = post("/api/skills/save", {"name": skill_name, "content": content})
assert status == 200 and data.get("ok") is True
skill_path = pathlib.Path(data["path"])
assert skill_path.exists() and skill_path.read_text() == content
del_data, del_status = post("/api/skills/delete", {"name": skill_name})
assert del_status == 200 and del_data.get("ok") is True
assert not skill_path.exists()
def test_skill_delete_requires_name(cleanup_test_sessions):
data, status = post("/api/skills/delete", {})
assert status == 400
def test_skill_delete_unknown_404(cleanup_test_sessions):
data, status = post("/api/skills/delete", {"name": "nonexistent-skill-xyz-9999"})
assert status == 404
# ── Memory write ──────────────────────────────────────────────────
def test_memory_write_requires_section(cleanup_test_sessions):
data, status = post("/api/memory/write", {"content": "test"})
assert status == 400
def test_memory_write_requires_content(cleanup_test_sessions):
data, status = post("/api/memory/write", {"section": "memory"})
assert status == 400
def test_memory_write_invalid_section(cleanup_test_sessions):
data, status = post("/api/memory/write", {"section": "invalid", "content": "test"})
assert status == 400
def test_memory_write_read_roundtrip(cleanup_test_sessions):
original = get("/api/memory").get("memory", "")
test_content = "# Sprint 7 Test\nWritten by test_memory_write_read_roundtrip."
data, status = post("/api/memory/write", {"section": "memory", "content": test_content})
assert status == 200 and data.get("ok") is True
read_back = get("/api/memory").get("memory")
assert read_back == test_content
# Restore
post("/api/memory/write", {"section": "memory", "content": original})

125
tests/test_sprint8.py Normal file
View File

@@ -0,0 +1,125 @@
"""
Sprint 8 Tests: Edit/regenerate, clear conversation, truncate, reconnect banner fix, syntax highlight.
"""
import json, pathlib, urllib.error, urllib.parse, 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())
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 make_session_tracked(created_list):
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
created_list.append(sid)
return sid
# ── /api/session/clear ─────────────────────────────────────────────
def test_session_clear_requires_session_id(cleanup_test_sessions):
data, status = post("/api/session/clear", {})
assert status == 400
def test_session_clear_unknown_session_404(cleanup_test_sessions):
data, status = post("/api/session/clear", {"session_id": "nonexistent_xyz"})
assert status == 404
def test_session_clear_wipes_messages(cleanup_test_sessions):
created = []
sid = make_session_tracked(created)
# Inject a fake message directly into the session via rename (to give it a title first)
post("/api/session/rename", {"session_id": sid, "title": "clear-test"})
# Manually load and verify session exists
sess = get(f"/api/session?session_id={urllib.parse.quote(sid)}")
assert sess["session"]["session_id"] == sid
# Clear it
data, status = post("/api/session/clear", {"session_id": sid})
assert status == 200, f"Expected 200, got {status}: {data}"
assert data.get("ok") is True
assert data.get("session") is not None
# Load again and verify messages empty
sess2 = get(f"/api/session?session_id={urllib.parse.quote(sid)}")
assert sess2["session"]["messages"] == []
assert sess2["session"]["title"] == "Untitled"
# Cleanup
post("/api/session/delete", {"session_id": sid})
def test_session_clear_returns_session_compact(cleanup_test_sessions):
created = []
sid = make_session_tracked(created)
data, status = post("/api/session/clear", {"session_id": sid})
assert status == 200
assert "session" in data
assert data["session"]["session_id"] == sid
post("/api/session/delete", {"session_id": sid})
# ── /api/session/truncate ──────────────────────────────────────────
def test_session_truncate_requires_session_id(cleanup_test_sessions):
data, status = post("/api/session/truncate", {"keep_count": 2})
assert status == 400
def test_session_truncate_requires_keep_count(cleanup_test_sessions):
data, status = post("/api/session/truncate", {"session_id": "xyz"})
assert status == 400
def test_session_truncate_unknown_session_404(cleanup_test_sessions):
data, status = post("/api/session/truncate", {"session_id": "nonexistent_xyz", "keep_count": 0})
assert status == 404
def test_session_truncate_returns_messages(cleanup_test_sessions):
created = []
sid = make_session_tracked(created)
data, status = post("/api/session/truncate", {"session_id": sid, "keep_count": 0})
assert status == 200
assert data.get("ok") is True
assert "messages" in data["session"]
assert data["session"]["messages"] == []
post("/api/session/delete", {"session_id": sid})
# ── Static files contain new features ─────────────────────────────
def test_app_js_contains_edit_message(cleanup_test_sessions):
"""Verify editMessage function is present in ui.js (Sprint 9: module split)."""
with urllib.request.urlopen(BASE + "/static/ui.js", timeout=10) as r:
src = r.read().decode()
assert "editMessage" in src
assert "msg-edit-area" in src
def test_app_js_contains_regenerate(cleanup_test_sessions):
with urllib.request.urlopen(BASE + "/static/ui.js", timeout=10) as r:
src = r.read().decode()
assert "regenerateResponse" in src
def test_app_js_contains_clear_conversation(cleanup_test_sessions):
with urllib.request.urlopen(BASE + "/static/panels.js", timeout=10) as r:
src = r.read().decode()
assert "clearConversation" in src
assert "api/session/clear" in src
def test_app_js_contains_highlight_code(cleanup_test_sessions):
with urllib.request.urlopen(BASE + "/static/ui.js", timeout=10) as r:
src = r.read().decode()
assert "highlightCode" in src
assert "Prism" in src
def test_index_html_contains_prism(cleanup_test_sessions):
with urllib.request.urlopen(BASE + "/", timeout=10) as r:
src = r.read().decode()
assert "prismjs" in src.lower()
def test_index_html_contains_clear_button(cleanup_test_sessions):
with urllib.request.urlopen(BASE + "/", timeout=10) as r:
src = r.read().decode()
assert "btnClearConv" in src
assert "clearConversation" in src

115
tests/test_sprint9.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Sprint 9 Tests: app.js module split verification, tool cards, todo panel.
Run: python -m pytest tests/test_sprint9.py -v
"""
import json, pathlib, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
def get_text(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return r.read().decode()
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read())
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
# ── Module split: all 6 files served ──────────────────────────────────────
def test_ui_js_served(cleanup_test_sessions):
src = get_text("/static/ui.js")
assert len(src) > 1000
assert "function setBusy" in src
assert "function syncTopbar" in src
assert "const S=" in src or "const S =" in src
def test_workspace_js_served(cleanup_test_sessions):
src = get_text("/static/workspace.js")
assert "async function api(" in src
assert "async function loadDir(" in src
assert "async function openFile(" in src # renderFileTree is in ui.js
def test_sessions_js_served(cleanup_test_sessions):
src = get_text("/static/sessions.js")
assert "async function newSession(" in src
assert "async function loadSession(" in src
assert "async function renderSessionList(" in src
def test_messages_js_served(cleanup_test_sessions):
src = get_text("/static/messages.js")
assert "async function send(" in src
assert "function transcript(" in src
def test_panels_js_served(cleanup_test_sessions):
src = get_text("/static/panels.js")
assert "async function switchPanel(" in src
assert "async function loadCrons(" in src
assert "async function loadSkills(" in src
assert "async function loadMemory(" in src
def test_boot_js_served(cleanup_test_sessions):
src = get_text("/static/boot.js")
assert "btnSend" in src
assert "btnNewChat" in src
# boot IIFE
assert "(async()=>{" in src or "(async () => {" in src
def test_app_js_no_longer_referenced_in_html(cleanup_test_sessions):
"""index.html must not reference the old monolithic app.js."""
html = get_text("/")
assert 'src="/static/app.js"' not in html
# All 6 modules must be present
for module in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]:
assert f'src="/static/{module}"' in html, f"Missing {module} in index.html"
def test_module_load_order_correct(cleanup_test_sessions):
"""ui.js must appear before sessions.js which must appear before boot.js."""
html = get_text("/")
ui_pos = html.find('src="/static/ui.js"')
ws_pos = html.find('src="/static/workspace.js"')
sess_pos = html.find('src="/static/sessions.js"')
msg_pos = html.find('src="/static/messages.js"')
panels_pos = html.find('src="/static/panels.js"')
boot_pos = html.find('src="/static/boot.js"')
assert ui_pos < ws_pos < sess_pos < msg_pos < panels_pos < boot_pos
def test_no_duplicate_function_definitions(cleanup_test_sessions):
"""No function name should appear in more than one module."""
import re
modules = ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]
seen = {}
for m in modules:
src = get_text(f"/static/{m}")
fns = re.findall(r'(?:async )?function ([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(', src)
for fn in fns:
if fn in seen:
assert False, f"Duplicate function {fn} in both {seen[fn]} and {m}"
seen[fn] = m
assert len(seen) > 50, f"Expected 50+ functions, got {len(seen)}"
def test_all_functions_present_across_modules(cleanup_test_sessions):
"""Key functions must be present somewhere in the split modules."""
import re
modules = ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]
all_src = ""
for m in modules:
all_src += get_text(f"/static/{m}")
required = [
"setBusy", "syncTopbar", "renderMessages", "send", "loadSession",
"newSession", "renderSessionList", "loadDir", "switchPanel",
"loadCrons", "loadSkills", "loadMemory", "editMessage",
"regenerateResponse", "clearConversation", "highlightCode",
"toggleSkillForm", "submitSkillSave", "toggleMemoryEdit",
]
for fn in required:
assert fn in all_src, f"Function {fn} missing from all modules"