Hermes WebUI v0.1.0 — initial public release
This commit is contained in:
28
.env.example
Normal file
28
.env.example
Normal 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
26
.gitignore
vendored
Normal 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
53
AGENTS.md
Normal 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
1588
ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
312
CHANGELOG.md
Normal file
312
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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
213
README.md
Normal 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
309
ROADMAP.md
Normal 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
407
SPRINTS.md
Normal 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
1600
TESTING.md
Normal file
File diff suppressed because it is too large
Load Diff
1
api/__init__.py
Normal file
1
api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Hermes WebUI -- API modules."""
|
||||||
273
api/config.py
Normal file
273
api/config.py
Normal 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
57
api/helpers.py
Normal 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
114
api/models.py
Normal 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
218
api/streaming.py
Normal 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
77
api/upload.py
Normal 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
77
api/workspace.py
Normal 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
4
requirements.txt
Normal 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
704
server.py
Normal 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
260
start.sh
Executable 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
152
static/boot.js
Normal 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
264
static/index.html
Normal 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">💬</button>
|
||||||
|
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||||
|
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills">🧩</button>
|
||||||
|
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory">🧠</button>
|
||||||
|
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces">📁</button>
|
||||||
|
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list">✅</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">⌘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()">✎ 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">📁</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">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-actions">
|
||||||
|
<button class="sm-btn" id="btnDownload" title="Download as Markdown">↓ Transcript</button>
|
||||||
|
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON">❬/❭ 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">📁 test-workspace ▾</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">🗑 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">⚠ 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()">↻ 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')">✓ Allow once</button>
|
||||||
|
<button class="approval-btn session" onclick="respondApproval('session')">🔒 Allow this session</button>
|
||||||
|
<button class="approval-btn always" onclick="respondApproval('always')">☆ Always allow</button>
|
||||||
|
<button class="approval-btn deny" onclick="respondApproval('deny')">✕ 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">⚙</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">■ 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">✕</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()">+</button>
|
||||||
|
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir('.')">↻</button>
|
||||||
|
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview">✕</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">⇩ Download</button>
|
||||||
|
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none" onclick="toggleEditMode()">✎ 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
310
static/messages.js
Normal 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
600
static/panels.js
Normal 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">🕑 ${esc(job.schedule_display || job.schedule?.expression || '')} | Next: ${esc(nextRun)} | 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}')">▶ Run now</button>
|
||||||
|
${statusLabel==='paused'
|
||||||
|
? `<button class="cron-btn" onclick="cronResume('${job.id}')">▶│ Resume</button>`
|
||||||
|
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">▮▮ Pause</button>`}
|
||||||
|
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'"')})">✎ Edit</button>
|
||||||
|
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">🗑 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">📁 ${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='⚙ 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)}')">→ Use</button>
|
||||||
|
<button class="ws-action-btn danger" title="Remove" onclick="removeWorkspace('${esc(w.path)}')">✕</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()">+ 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">
|
||||||
|
🧠 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">
|
||||||
|
👤 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
206
static/sessions.js
Normal 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='🗑';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
450
static/style.css
Normal 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
589
static/ui.js
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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='✓';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">📎 ${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)">✎</button>` : '';
|
||||||
|
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="Regenerate response" onclick="regenerateResponse(this)">↻</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)">📋</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,'"')}" data-short="${esc(displaySnippet||'').replace(/"/g,'"')}" 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=`📎 ${esc(f.name)} <button title="Remove">✕</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
168
static/workspace.js
Normal 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 ? '💾 Save' : '✎ Edit';
|
||||||
|
btn.title = editing ? 'Save changes' : 'Edit this file';
|
||||||
|
btn.style.color = editing ? 'var(--blue)' : '';
|
||||||
|
if(_previewDirty) btn.innerHTML = '💾 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
0
tests/__init__.py
Normal file
240
tests/conftest.py
Normal file
240
tests/conftest.py
Normal 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
416
tests/test_regressions.py
Normal 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
437
tests/test_sprint1.py
Normal 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
139
tests/test_sprint10.py
Normal 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
106
tests/test_sprint2.py
Normal 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
144
tests/test_sprint3.py
Normal 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
156
tests/test_sprint4.py
Normal 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
140
tests/test_sprint5.py
Normal 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
151
tests/test_sprint6.py
Normal 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
130
tests/test_sprint7.py
Normal 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
125
tests/test_sprint8.py
Normal 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
115
tests/test_sprint9.py
Normal 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"
|
||||||
Reference in New Issue
Block a user