Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af73a5d8fd | ||
|
|
a92c251ef8 | ||
|
|
574cd2cf70 | ||
|
|
d278563e00 | ||
|
|
8cd07d3774 | ||
|
|
959c386d8d | ||
|
|
690f04bff0 | ||
|
|
f5c9f218c4 | ||
|
|
dcb21dfd37 | ||
|
|
59a92e03d8 | ||
|
|
df3de7a543 | ||
|
|
46fdf3513f | ||
|
|
efb7293ae8 | ||
|
|
44aa538b7c | ||
|
|
1b1cd124f6 | ||
|
|
3f9d1da0e2 | ||
|
|
9363d967ed | ||
|
|
2dda99082f | ||
|
|
51bcf8fead | ||
|
|
56526ce502 | ||
|
|
e0a1ab8e03 | ||
|
|
d88419ccfb | ||
|
|
3c95502979 | ||
|
|
66bd84accb | ||
|
|
b8b62722ec |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
tests/
|
||||
.env*
|
||||
@@ -18,7 +18,7 @@ a central chat area, and a right panel for workspace file browsing.
|
||||
|
||||
The design philosophy is deliberately minimal. There is no build step, no bundler, no
|
||||
frontend framework. The Python server is split into a routing shell (server.py) and
|
||||
business logic modules (api/). The frontend is six vanilla JS modules loaded from static/.
|
||||
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
|
||||
This makes the code easy to modify from a terminal or by an agent.
|
||||
|
||||
---
|
||||
@@ -26,38 +26,40 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
## 2. File Inventory
|
||||
|
||||
<repo>/
|
||||
server.py Thin routing shell + HTTP Handler. ~76 lines. Pure Python.
|
||||
server.py Thin routing shell + HTTP Handler + auth middleware. ~79 lines.
|
||||
Delegates all route handling to api/routes.py.
|
||||
start.sh Discovery script: finds agent dir, Python, starts server.
|
||||
api/
|
||||
__init__.py Package marker
|
||||
routes.py All GET + POST route handlers (~1016 lines)
|
||||
config.py Shared configuration, constants, global state, model discovery (~640 lines)
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~57 lines)
|
||||
auth.py Optional password authentication, signed cookies (~149 lines)
|
||||
routes.py All GET + POST route handlers (~1109 lines)
|
||||
config.py Shared configuration, constants, global state, model discovery (~654 lines)
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers (~71 lines)
|
||||
models.py Session model + CRUD (~132 lines)
|
||||
workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines)
|
||||
upload.py Multipart parser, file upload handler (~77 lines)
|
||||
streaming.py SSE engine, run_agent integration, cancel support (~222 lines)
|
||||
static/
|
||||
index.html HTML template (served from disk)
|
||||
style.css All CSS
|
||||
ui.js DOM helpers, renderMd, tool cards, model dropdown (~846 lines)
|
||||
workspace.js File tree, preview, file ops (~169 lines)
|
||||
style.css All CSS (~590 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~957 lines)
|
||||
workspace.js File preview, file ops, loadDir, clearPreview (~185 lines)
|
||||
sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~532 lines)
|
||||
messages.js send(), SSE event handlers, approval, transcript (~293 lines)
|
||||
panels.js Cron, skills, memory, workspace, todo, switchPanel (~771 lines)
|
||||
boot.js Event wiring + boot IIFE (~175 lines)
|
||||
messages.js send(), SSE event handlers, approval, transcript (~297 lines)
|
||||
panels.js Cron, skills, memory, workspace, todo, switchPanel, settings (~813 lines)
|
||||
commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
|
||||
boot.js Event wiring, keydown handlers, boot IIFE (~208 lines)
|
||||
tests/
|
||||
conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines)
|
||||
test_sprint1-16.py Feature tests per sprint (14 files, Sprints 1-11 + 16)
|
||||
test_regressions.py Permanent regression gate
|
||||
test_sprint{1-19}.py Feature tests per sprint (17 files, 327 test functions)
|
||||
test_regressions.py Permanent regression gate (23 tests)
|
||||
AGENTS.md Instruction file for agents working in this directory.
|
||||
ROADMAP.md Feature and product roadmap document.
|
||||
SPRINTS.md Forward sprint plan with CLI + Claude parity targets.
|
||||
ARCHITECTURE.md THIS FILE.
|
||||
TESTING.md Manual browser test plan and automated coverage reference.
|
||||
CHANGELOG.md Release notes per sprint.
|
||||
PORTABILITY.md Portability design spec for download-and-run installs.
|
||||
BUGS.md Bug backlog and fixed items tracker.
|
||||
requirements.txt Python dependencies.
|
||||
.env.example Sample environment variable overrides.
|
||||
|
||||
@@ -67,7 +69,8 @@ State directory (runtime data, separate from source):
|
||||
sessions/ One JSON file per session: {session_id}.json
|
||||
workspaces.json Registered workspaces list
|
||||
last_workspace.txt Last-used workspace path
|
||||
settings.json (future) User settings
|
||||
settings.json User settings (default model, workspace, send key, password hash)
|
||||
projects.json Session project groups (name, color, id)
|
||||
|
||||
Log file:
|
||||
|
||||
@@ -91,6 +94,7 @@ Environment variables controlling behavior:
|
||||
HERMES_WEBUI_STATE_DIR Where sessions/ folder lives
|
||||
HERMES_CONFIG_PATH Path to ~/.hermes/config.yaml
|
||||
HERMES_WEBUI_DEFAULT_MODEL Default LLM model string
|
||||
HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default)
|
||||
|
||||
Test isolation environment variables (set by conftest.py):
|
||||
|
||||
|
||||
101
CHANGELOG.md
101
CHANGELOG.md
@@ -5,6 +5,105 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.23] Sprint 21 -- Mobile Responsive + Docker
|
||||
*April 3, 2026 | 415 tests*
|
||||
|
||||
### Features
|
||||
- **Mobile responsive layout (Issue #21).** Full mobile experience with
|
||||
hamburger sidebar (slide-in overlay), bottom navigation bar (5-tab iOS
|
||||
pattern), and files slide-over panel. Touch targets minimum 44px. Composer
|
||||
positioned above bottom nav. Session clicks auto-close sidebar. Desktop
|
||||
layout completely unchanged — all mobile elements hidden via `@media`.
|
||||
- **Docker support (Issue #7).** Dockerfile (`python:3.12-slim`), docker-compose.yml
|
||||
with named volume for state persistence, optional `~/.hermes` mount for
|
||||
agent features. Binds to `127.0.0.1` by default for security.
|
||||
|
||||
### Bug Fixes (from review)
|
||||
- **CSS cascade broke mobile slide-in.** `position:relative` rules after the
|
||||
media query overrode `position:fixed` on mobile. Wrapped in `@media(min-width:641px)`.
|
||||
- **mobileSwitchPanel() always reopened sidebar.** Chat tab now closes sidebar
|
||||
instead of reopening it over the main chat area.
|
||||
- **Dockerfile missing pip install.** Added `pip install -r requirements.txt`.
|
||||
- **No .dockerignore.** Added exclusions for `.git`, `tests/`, `.env*`.
|
||||
- **docker-compose tilde expansion.** Changed `~/.hermes` default to
|
||||
`${HOME}/.hermes` (Docker Compose doesn't shell-expand `~`).
|
||||
|
||||
### Architecture
|
||||
- Mobile navigation functions in `boot.js`: `toggleMobileSidebar()`,
|
||||
`closeMobileSidebar()`, `toggleMobileFiles()`, `mobileSwitchPanel()`.
|
||||
- `sessions.js`: `closeMobileSidebar()` called after session click.
|
||||
- 69 new CSS lines in `@media(max-width:640px)` block.
|
||||
- New files: `Dockerfile`, `docker-compose.yml`, `.dockerignore`.
|
||||
|
||||
---
|
||||
|
||||
## [v0.22] Sprint 20 -- Voice Input + Send Button Polish
|
||||
*April 3, 2026 | 415 tests*
|
||||
|
||||
### Features
|
||||
- **Voice input via Web Speech API.** Microphone button in the composer.
|
||||
Tap to start recording, tap again (or send) to stop. Live interim
|
||||
transcription appears in the textarea. Auto-stops after ~2s of silence.
|
||||
Final text stays editable before sending. Appends to existing textarea
|
||||
content rather than replacing it. Button hidden when browser doesn't
|
||||
support Web Speech API. No API keys, no external libraries, no server
|
||||
changes. Works in Chrome, Edge, Safari (partial). Firefox unsupported
|
||||
(button stays hidden).
|
||||
- **Send button polish.** Send button redesigned as a 34px icon-only circle
|
||||
with upward arrow SVG. Hidden by default — appears with pop-in spring
|
||||
animation when textarea has content or files are attached. Disappears
|
||||
on send or when content is cleared. Hidden while agent is responding.
|
||||
Blue fill (#7cb9ff) with glow, scale hover/active for tactile feedback.
|
||||
|
||||
### Architecture
|
||||
- Voice input IIFE in `boot.js`: SpeechRecognition lifecycle with
|
||||
`continuous=false`, `interimResults=true`, error handling via `showToast()`.
|
||||
- `_prefix` variable snapshots existing textarea content on recording start
|
||||
so dictation appends rather than overwrites.
|
||||
- `btnSend.onclick` stops active recognition before sending (send guard).
|
||||
- CSS: `.mic-btn`, `.mic-btn.recording` (red pulse), `.mic-status`,
|
||||
`.mic-dot`, `@keyframes mic-pulse`.
|
||||
- `updateSendBtn()` in `ui.js` tracks textarea content, pending files,
|
||||
and busy state. Hooked into `setBusy()`, `renderTray()`, `autoResize()`,
|
||||
and input event listener.
|
||||
- CSS: `.send-btn` redesigned (circle, glow), `.send-btn.visible` +
|
||||
`@keyframes send-pop-in` (spring animation).
|
||||
|
||||
### Tests
|
||||
- 52 new tests in `test_sprint20.py`: voice input HTML, CSS, JS, append
|
||||
behaviour, error handling, regressions.
|
||||
- 33 new tests in `test_sprint20b.py`: send button HTML, CSS, JS,
|
||||
animation, visibility logic, regressions. Total: **415 tests**.
|
||||
|
||||
---
|
||||
|
||||
## [v0.21] Sprint 19 -- Auth + Security Hardening
|
||||
*April 3, 2026 | 328 tests*
|
||||
|
||||
### Features
|
||||
- **Password authentication (Issue #23).** Optional password auth, off by default.
|
||||
Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel. Password-only
|
||||
(single-user app). Signed HMAC HTTP-only cookie with 24h TTL. Minimal dark-themed
|
||||
login page at `/login`. API calls without auth return 401; page loads redirect.
|
||||
New `api/auth.py` module with hashing, verification, session management.
|
||||
- **Security headers.** All responses now include `X-Content-Type-Options: nosniff`,
|
||||
`X-Frame-Options: DENY`, `Referrer-Policy: same-origin`.
|
||||
- **POST body size limit.** Non-upload POST bodies capped at 20MB via `read_body()`.
|
||||
- **Settings panel additions.** "Access Password" field and "Sign Out" button
|
||||
(only visible when auth is active).
|
||||
|
||||
### Architecture
|
||||
- New `api/auth.py`: password hashing (SHA-256 + STATE_DIR salt), signed cookies,
|
||||
auth middleware, public path allowlist.
|
||||
- Auth check in `server.py` do_GET/do_POST before routing.
|
||||
- `password_hash` added to `_SETTINGS_DEFAULTS`.
|
||||
|
||||
### Tests
|
||||
- 10 new tests in `test_sprint19.py`: auth status, login flow, security headers,
|
||||
cache-control, settings password field, request size limit. Total: **328 tests (328 passing)**.
|
||||
|
||||
---
|
||||
|
||||
## [v0.20] Sprint 18 -- File Preview Auto-Close + Thinking Display + Workspace Tree
|
||||
*April 3, 2026 | 318 tests*
|
||||
|
||||
@@ -649,4 +748,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.20, April 3, 2026 | Tests: 318*
|
||||
*Last updated: v0.23, April 3, 2026 | Tests: 415*
|
||||
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL maintainer="nesquena"
|
||||
LABEL description="Hermes Web UI — browser interface for Hermes Agent"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy source
|
||||
COPY . /app
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Default to binding all interfaces (required for container networking)
|
||||
ENV HERMES_WEBUI_HOST=0.0.0.0
|
||||
ENV HERMES_WEBUI_PORT=8787
|
||||
|
||||
# State directory (mount as volume for persistence)
|
||||
ENV HERMES_WEBUI_STATE_DIR=/data
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
31
README.md
31
README.md
@@ -35,6 +35,37 @@ That is it. The script will:
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
Run with Docker Compose (recommended):
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or build and run manually:
|
||||
|
||||
```bash
|
||||
docker build -t hermes-webui .
|
||||
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes:ro hermes-webui
|
||||
```
|
||||
|
||||
Open http://localhost:8787 in your browser.
|
||||
|
||||
To enable password protection:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret -v ~/.hermes:/root/.hermes:ro hermes-webui
|
||||
```
|
||||
|
||||
Session data persists in a named volume (`hermes-data`) across restarts.
|
||||
|
||||
> **Note:** By default, Docker Compose binds to `127.0.0.1` (localhost only).
|
||||
> To expose on a network, change the port to `"8787:8787"` in `docker-compose.yml`
|
||||
> and set `HERMES_WEBUI_PASSWORD` to enable authentication.
|
||||
|
||||
---
|
||||
|
||||
## What start.sh discovers automatically
|
||||
|
||||
| Thing | How it finds it |
|
||||
|
||||
52
ROADMAP.md
52
ROADMAP.md
@@ -3,8 +3,8 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: Sprint 17 / v0.19 (April 3, 2026)
|
||||
> Tests: 294 passing
|
||||
> Last updated: Sprint 19 / v0.21 (April 3, 2026)
|
||||
> Tests: 328 total (328 passing, 0 failures)
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -32,8 +32,10 @@
|
||||
| Sprint 13 | Alerts + polish | Cron completion alerts (polling + badge), background error banner, session duplicate, browser tab title | 221 |
|
||||
| Sprint 14 | Visual polish + workspace ops | Mermaid diagrams, message timestamps, file rename, folder create, session tags, session archive | 233 |
|
||||
| Sprint 15 | Session projects + code copy | Session projects/folders, code block copy button, tool card expand/collapse toggle | 237 |
|
||||
| Sprint 16 | Session sidebar visual polish | SVG action icons, overlay hover actions, pin indicator, project border, custom model discovery, GLM-5.1 | 237 |
|
||||
| Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 294 |
|
||||
| Sprint 16 | Session sidebar visual polish | SVG action icons, overlay hover actions, pin indicator, project border, safe HTML rendering | 289 |
|
||||
| Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 318 |
|
||||
| Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking/reasoning cards, expandable directory tree (#22) | 318 |
|
||||
| Sprint 19 | Auth + security hardening | Password auth (off by default), login page, security headers, 20MB body limit (#23) | 328 |
|
||||
|
||||
---
|
||||
|
||||
@@ -41,10 +43,10 @@
|
||||
|
||||
| Layer | Location | Status |
|
||||
|-------|----------|--------|
|
||||
| Python server | <repo>/server.py (~76 lines) + api/ modules (~2145 lines) | Thin shell + business logic in api/ |
|
||||
| Python server | <repo>/server.py (~79 lines) + api/ modules (~2491 lines) | Thin shell + auth middleware + business logic in api/ |
|
||||
| HTML template | <repo>/static/index.html | Served from disk |
|
||||
| CSS | <repo>/static/style.css (~560 lines) | Served from disk |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~2990 lines total |
|
||||
| CSS | <repo>/static/style.css (~590 lines) | Served from disk |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3148 lines total |
|
||||
| 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 |
|
||||
@@ -149,22 +151,42 @@
|
||||
|
||||
### Configuration
|
||||
- [x] Settings panel (default model, default workspace) (Sprint 12)
|
||||
- [x] Send key preference (Enter or Ctrl+Enter) (Sprint 17)
|
||||
- [x] Password authentication (Sprint 19)
|
||||
- [ ] Enable/disable toolsets per session (deferred)
|
||||
|
||||
### Notifications
|
||||
- [x] Cron job completion alerts (Sprint 13)
|
||||
- [x] Background agent error alerts (Sprint 13)
|
||||
|
||||
### Workspace
|
||||
- [x] Breadcrumb navigation in subdirectories (Sprint 17)
|
||||
- [x] Workspace tree view with expand/collapse (Sprint 18, Issue #22)
|
||||
- [x] File preview auto-close on directory navigation (Sprint 18)
|
||||
|
||||
### Slash Commands
|
||||
- [x] Command registry + autocomplete dropdown (Sprint 17)
|
||||
- [x] Built-in: /help, /clear, /model, /workspace, /new (Sprint 17)
|
||||
|
||||
### Security
|
||||
- [x] Password auth with signed cookies (Sprint 19, Issue #23)
|
||||
- [x] Security headers (X-Content-Type-Options, X-Frame-Options) (Sprint 19)
|
||||
- [x] POST body size limit (20MB) (Sprint 19)
|
||||
|
||||
### Thinking / Reasoning
|
||||
- [x] Collapsible thinking cards for extended-thinking models (Sprint 18)
|
||||
|
||||
### Advanced / Future
|
||||
- [ ] Voice input via Whisper (Wave 6)
|
||||
- [ ] TTS playback of responses (Wave 6)
|
||||
- [ ] Subagent delegation cards (Wave 6)
|
||||
- [ ] Voice input via Whisper (Sprint 20)
|
||||
- [ ] TTS playback of responses (Sprint 20)
|
||||
- [ ] Subagent delegation cards (deferred)
|
||||
- [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)
|
||||
- [ ] Code execution cell (deferred)
|
||||
- [ ] Mobile responsive layout (Sprint 21)
|
||||
- [ ] Multi-profile support (Sprint 22, Issue #28)
|
||||
- [ ] Desktop application (Sprint 23)
|
||||
- [ ] Extended slash command / skill integration (Sprint 24)
|
||||
- [ ] Virtual scroll for large lists (deferred)
|
||||
|
||||
---
|
||||
|
||||
|
||||
266
SPRINTS.md
266
SPRINTS.md
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.20 | 318 tests | Daily driver ready
|
||||
> Current state: v0.23 | 415 tests | Daily driver ready
|
||||
> This document plans the path from here to two targets:
|
||||
>
|
||||
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
||||
@@ -14,17 +14,19 @@
|
||||
|
||||
---
|
||||
|
||||
## Where we are now (v0.18)
|
||||
## Where we are now (v0.21)
|
||||
|
||||
**CLI parity: ~85% complete.** Core agent loop, all tools visible, workspace
|
||||
file ops, cron/skills/memory CRUD, session management, streaming, cancel,
|
||||
multi-provider models, custom endpoint discovery -- all solid. Gaps are
|
||||
subagent visibility, toolset control, and code execution.
|
||||
**CLI parity: ~90% complete.** Core agent loop, all tools visible, workspace
|
||||
file ops with tree view, cron/skills/memory CRUD, session management, streaming,
|
||||
cancel, multi-provider models, custom endpoint discovery, slash commands,
|
||||
thinking/reasoning display, password auth -- all solid. Gaps are subagent
|
||||
visibility, toolset control, and code execution.
|
||||
|
||||
**Claude parity: ~65% complete.** Chat, streaming, file browser, session
|
||||
**Claude parity: ~70% complete.** Chat, streaming, file browser, session
|
||||
management, tool cards, syntax highlighting, model switching, projects,
|
||||
settings, Mermaid diagrams, mobile layout -- all present. Gaps are
|
||||
artifacts, voice, reasoning display, sharing.
|
||||
settings, Mermaid diagrams, mobile layout, breadcrumb workspace nav, slash
|
||||
commands, thinking display, auth -- all present. Gaps are artifacts, voice,
|
||||
TTS, sharing, mobile-optimized layout.
|
||||
|
||||
---
|
||||
|
||||
@@ -323,122 +325,171 @@ handler for slash command autocomplete.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 18 -- Voice + Multimodal Input
|
||||
## Sprint 18 -- Thinking Display + Workspace Tree + Preview Fix (COMPLETED)
|
||||
|
||||
**Theme:** Input beyond the keyboard.
|
||||
**Theme:** Show the model's reasoning, improve workspace navigation, fix UX bug.
|
||||
|
||||
**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).
|
||||
**Why now:** Thinking/reasoning display was deferred twice (Sprint 16 → 17 → 18).
|
||||
Workspace tree view was the #1 community request (Issue #22). File preview
|
||||
staying open on directory navigation was a daily-driver annoyance.
|
||||
|
||||
### 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.
|
||||
- **File preview auto-close.** When viewing a file in the right panel and
|
||||
navigating directories (breadcrumbs, up button, folder clicks), the preview
|
||||
stayed visible with stale content. Fix: extracted `clearPreview()` as a named
|
||||
function in boot.js and call it from `loadDir()` in workspace.js.
|
||||
|
||||
### 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.
|
||||
- **Thinking/reasoning display.** Assistant messages with structured content
|
||||
arrays containing `type:'thinking'` or `type:'reasoning'` blocks now render
|
||||
as collapsible gold-themed cards above the response text. Collapsed by
|
||||
default, click header to expand. Works with Claude extended thinking and
|
||||
o3 reasoning tokens when preserved in the message array.
|
||||
- **Workspace tree view (Issue #22).** Directories expand/collapse in-place
|
||||
with toggle arrows. Single-click toggles, double-click navigates (breadcrumb
|
||||
view). Subdirectory contents fetched lazily and cached in `S._dirCache`.
|
||||
Nesting depth shown via indentation. Empty directories show "(empty)".
|
||||
|
||||
**Tests:** 0 new (pure CSS/DOM changes). Total: 318.
|
||||
**Hermes CLI parity impact:** Low
|
||||
**Claude parity impact:** High (reasoning display matches Claude's UI)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 19 -- Auth + Security Hardening (COMPLETED)
|
||||
|
||||
**Theme:** Make this safe to leave running beyond localhost.
|
||||
|
||||
**Why now:** Issue #23 requested authentication. Auth is the last production
|
||||
hardening feature before the app is safe to expose to a network.
|
||||
|
||||
### Track A: Bugs
|
||||
- **No request size limit.** POST bodies were unbounded (DoS risk). Added 20MB
|
||||
cap in `read_body()`.
|
||||
|
||||
### Track B: Features
|
||||
- **Password authentication (Issue #23).** Off by default — zero friction for
|
||||
localhost. Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel.
|
||||
Password-only (no username — single-user app). Signed HMAC HTTP-only cookie
|
||||
with 24h TTL. Minimal dark-themed login page at `/login`. API calls without
|
||||
auth return 401; page loads redirect to `/login`. Settings panel gains
|
||||
"Access Password" field and "Sign Out" button.
|
||||
- **Security headers.** All responses now include `X-Content-Type-Options: nosniff`,
|
||||
`X-Frame-Options: DENY`, `Referrer-Policy: same-origin`.
|
||||
|
||||
### 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.
|
||||
- New `api/auth.py` module: password hashing (SHA-256 + STATE_DIR salt), signed
|
||||
session cookies, auth middleware, public path allowlist.
|
||||
- Auth check in `server.py` do_GET/do_POST before routing.
|
||||
- `password_hash` added to `_SETTINGS_DEFAULTS` in config.py.
|
||||
- `_set_password` special field in save_settings for secure password updates.
|
||||
|
||||
**Tests:** ~12 new. Total: ~271.
|
||||
**Tests:** 10 new. Total: 328.
|
||||
**Hermes CLI parity impact:** Low (CLI has no auth concerns)
|
||||
**Claude parity impact:** High (Claude is authenticated)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 20 -- Voice Input + Send Button Polish (COMPLETED)
|
||||
|
||||
**Theme:** Input refinements — voice and visual polish.
|
||||
|
||||
**Why now:** Voice input was the next feature on the roadmap. The send button
|
||||
UX was a low-effort high-impact polish opportunity that pairs naturally.
|
||||
|
||||
### Track A: Bugs
|
||||
- **Send button always visible.** The old pill-shaped "Send" button was always
|
||||
visible even with an empty textarea, wasting space. Now hidden by default,
|
||||
appears only when there is content to send.
|
||||
|
||||
### Track B: Features
|
||||
- **Voice input (Web Speech API).** Microphone button in composer. Tap to
|
||||
record, tap again to stop. Live interim transcription in textarea. Auto-stops
|
||||
after ~2s of silence. Appends to existing text. Hidden when browser doesn't
|
||||
support Web Speech API. No API keys, no server changes.
|
||||
- **Send button polish.** Icon-only 34px circle with upward arrow SVG. Pop-in
|
||||
spring animation on appear. Scale hover/active for tactile feedback. Hidden
|
||||
while agent is responding.
|
||||
|
||||
### Track C: Architecture
|
||||
- Voice input IIFE in `boot.js` with SpeechRecognition lifecycle.
|
||||
- `updateSendBtn()` in `ui.js` hooked into setBusy, renderTray, autoResize.
|
||||
|
||||
**Tests:** 52 new (voice) + 33 new (send button). Total: 415.
|
||||
**Hermes CLI parity impact:** Medium (voice not in CLI, but adds capability)
|
||||
**Claude parity impact:** High (Claude has native voice mode)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 18 -- Subagent Visibility + Agentic Transparency
|
||||
## Sprint 21 -- Mobile Responsive + Docker (COMPLETED)
|
||||
|
||||
**Theme:** Watch Hermes think, not just respond.
|
||||
**Theme:** Mobile experience + containerized deployment.
|
||||
|
||||
**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.
|
||||
**Why now:** Issue #21 (mobile) was the most-requested UX gap. Issue #7 (Docker)
|
||||
enables deployment beyond localhost. Both were achievable without new dependencies.
|
||||
|
||||
### 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 A: Bugs (from review)
|
||||
- **CSS cascade broke mobile slide-in.** `position:relative` after the media query
|
||||
overrode `position:fixed`. Wrapped in `@media(min-width:641px)`.
|
||||
- **mobileSwitchPanel() always reopened sidebar.** Chat tab now closes it.
|
||||
- **Dockerfile missing pip install.** Container failed on startup.
|
||||
- **No .dockerignore.** `.git`, `tests/`, `.env*` leaked into images.
|
||||
- **docker-compose tilde expansion.** `~` doesn't expand in Compose defaults.
|
||||
|
||||
### 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.
|
||||
- **Hamburger sidebar.** Slide-in overlay on mobile, tap outside to close.
|
||||
- **Bottom navigation bar.** 5-tab iOS-style bar replaces sidebar tabs.
|
||||
- **Files slide-over.** Right panel opens as slide-over from right edge.
|
||||
- **Touch targets.** Minimum 44px on all interactive elements.
|
||||
- **Docker support.** Dockerfile, docker-compose.yml, .dockerignore.
|
||||
|
||||
### 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.
|
||||
- Mobile nav functions in `boot.js`. Session click auto-closes sidebar.
|
||||
- 69 new CSS lines scoped to `@media(max-width:640px)`.
|
||||
- Desktop layout untouched — all mobile elements `display:none` by default.
|
||||
|
||||
**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)
|
||||
**Tests:** 0 new (CSS/DOM changes). Total: 415.
|
||||
**Hermes CLI parity impact:** Low
|
||||
**Claude parity impact:** High (Claude has mobile layout)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 19 -- Auth, HTTPS, and Production Hardening
|
||||
## Sprint 22 -- Multi-Profile Support (PLANNED, Issue #28)
|
||||
|
||||
**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).
|
||||
**Theme:** Switch between Hermes agent profiles seamlessly.
|
||||
|
||||
### 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.
|
||||
- **Profile picker.** Sidebar or topbar dropdown to switch profiles.
|
||||
- **Per-profile config.** Each profile has its own skills, memory, config.yaml.
|
||||
- **Seamless switching.** No restart required.
|
||||
|
||||
### 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)
|
||||
## Sprint 23 -- Desktop Application (PLANNED)
|
||||
|
||||
**Theme:** Native desktop experience.
|
||||
|
||||
### Track B: Features
|
||||
- **Electron or Tauri wrapper.** Native window, menu bar, notifications.
|
||||
- **Auto-start option.** Launch on login.
|
||||
- **Packaged distribution.** .dmg (macOS), .exe (Windows).
|
||||
|
||||
---
|
||||
|
||||
## Sprint 24 -- Extended Command Support (PLANNED)
|
||||
|
||||
**Theme:** Deeper slash command and skill integration.
|
||||
|
||||
### Track B: Features
|
||||
- **Skill-aware autocomplete.** `/skill-name` triggers installed skills.
|
||||
- **Command chaining.** Compose multi-step commands.
|
||||
- **Agent tool exposure.** Surface agent capabilities as slash commands.
|
||||
|
||||
---
|
||||
|
||||
## Feature Parity Summary
|
||||
|
||||
### After Sprint 18 (Hermes CLI parity: complete)
|
||||
### Hermes CLI Parity (as of Sprint 19)
|
||||
|
||||
| CLI Feature | Status |
|
||||
|-------------|--------|
|
||||
@@ -454,15 +505,18 @@ address.
|
||||
| Workspace switching | Done (v0.7) |
|
||||
| Model selection | Done (v0.3) |
|
||||
| Multi-provider model support | Done (Sprint 11) |
|
||||
| Toolset control | Sprint 12 |
|
||||
| Settings persistence | Done (Sprint 12) |
|
||||
| Subagent visibility | Sprint 18 |
|
||||
| Background task monitor | Sprint 18 |
|
||||
| Code execution (Jupyter) | Sprint 17+ |
|
||||
| Cron completion alerts | Done (Sprint 13) |
|
||||
| Slash commands | Done (Sprint 17) |
|
||||
| Thinking/reasoning display | Done (Sprint 18) |
|
||||
| Auth / login | Done (Sprint 19) |
|
||||
| Voice input | Sprint 20 |
|
||||
| Subagent visibility | Deferred |
|
||||
| Code execution (Jupyter) | Deferred |
|
||||
| Toolset control | Deferred |
|
||||
| Virtual scroll (perf) | Deferred |
|
||||
|
||||
### After Sprint 19 (Claude parity: ~90% complete)
|
||||
### Claude Parity (as of Sprint 19)
|
||||
|
||||
| Claude Feature | Status |
|
||||
|----------------|--------|
|
||||
@@ -474,19 +528,21 @@ address.
|
||||
| Tool use visibility | Done (v0.11) |
|
||||
| Edit/regenerate messages | Done (v0.10) |
|
||||
| Session management | Done (v0.6) |
|
||||
| Artifacts (HTML/SVG preview) | Sprint 17+ |
|
||||
| Code execution inline | Sprint 17+ |
|
||||
| Mermaid diagrams | Done (Sprint 14) |
|
||||
| Projects / folders | Done (Sprint 15) |
|
||||
| Pinned/starred sessions | Done (Sprint 12) |
|
||||
| Reasoning display | Sprint 18 |
|
||||
| Voice input | Sprint 17 |
|
||||
| TTS playback | Sprint 17 |
|
||||
| Notifications | Done (Sprint 13) |
|
||||
| Settings panel | Done (Sprint 12) |
|
||||
| Auth / login | Sprint 19 |
|
||||
| HTTPS | Sprint 19 |
|
||||
| Mobile layout | Done (v0.16.1) |
|
||||
| Reasoning display | Done (Sprint 18) |
|
||||
| Auth / login | Done (Sprint 19) |
|
||||
| Mobile layout (basic) | Done (v0.16.1) |
|
||||
| Workspace tree view | Done (Sprint 18) |
|
||||
| Slash commands | Done (Sprint 17) |
|
||||
| Voice input | Sprint 20 |
|
||||
| TTS playback | Sprint 20 |
|
||||
| Artifacts (HTML/SVG preview) | Deferred |
|
||||
| Code execution inline | Deferred |
|
||||
| Mobile-optimized layout | Sprint 21 |
|
||||
| Sharing / public URLs | Not planned (requires server infra) |
|
||||
| Claude-specific features | Not replicable (Projects AI, artifacts sync) |
|
||||
|
||||
@@ -504,5 +560,5 @@ address.
|
||||
---
|
||||
|
||||
*Last updated: April 3, 2026*
|
||||
*Current version: v0.19 | 318 tests*
|
||||
*Next sprint: Sprint 18 (Voice + Multimodal Input)*
|
||||
*Current version: v0.23 | 415 tests*
|
||||
*Next sprint: Sprint 22 (Multi-Profile Support)*
|
||||
|
||||
86
TESTING.md
86
TESTING.md
@@ -1,12 +1,15 @@
|
||||
# Hermes Web UI: Browser Testing Plan
|
||||
|
||||
> This document is for manual browser testing by you or by a Claude browser agent.
|
||||
> It covers every user-facing feature of the UI through Sprint 2.
|
||||
> It covers user-facing features of the UI through Sprint 19 (v0.21).
|
||||
> Each section is written as a step-by-step test procedure with expected outcomes.
|
||||
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
|
||||
>
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 328 total (328 passing, 0 failures).
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
|
||||
@@ -1593,8 +1596,81 @@ FAIL: User message gone, blank chat, response lands in wrong session.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Post-Sprint 10 concurrency sweeps, March 31, 2026*
|
||||
*Total automated tests: 190/190*
|
||||
*Regression gate: tests/test_regressions.py (23 tests, one per introduced bug)*
|
||||
*Run: python -m pytest tests/ -v*
|
||||
---
|
||||
|
||||
## Sections Added Post-Sprint 10 (Sprints 11-19)
|
||||
|
||||
The following features were added in Sprints 11-19 and need manual browser testing.
|
||||
Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
|
||||
### Sprint 11: Multi-Provider Models
|
||||
- Open model dropdown. Verify models grouped by provider (OpenAI, Anthropic, Google, etc.)
|
||||
- If custom `base_url` configured in config.yaml, verify local models appear in dropdown.
|
||||
- Switch model. Send a message. Verify response uses selected model.
|
||||
|
||||
### Sprint 12: Settings + Pin + Import
|
||||
- Click gear icon. Settings overlay opens.
|
||||
- Change default model, save. Restart server. Verify setting persisted.
|
||||
- Pin a session (star icon in hover overlay). Verify it floats to top of list.
|
||||
- Export session as JSON. Import it back. Verify messages restored.
|
||||
|
||||
### Sprint 13: Alerts + Session QoL
|
||||
- Duplicate a session (copy icon in hover overlay). Verify "(copy)" title.
|
||||
- Browser tab title updates to active session name. Switch sessions — title changes.
|
||||
|
||||
### Sprint 14: Visual Polish + Workspace Ops
|
||||
- Create a mermaid code block in a response. Verify diagram renders inline.
|
||||
- Message timestamps visible next to role labels (hover for full date).
|
||||
- Double-click a file in workspace panel to rename. Enter saves, Escape cancels.
|
||||
- Create a folder via folder icon in workspace header.
|
||||
- Add `#tag` to session title. Verify tag chip appears in sidebar. Click to filter.
|
||||
- Archive a session. Verify it disappears. Toggle "Show archived" to see it.
|
||||
|
||||
### Sprint 15: Session Projects
|
||||
- Click "+" in project bar to create a project. Type name, Enter.
|
||||
- Click a project chip to filter sessions.
|
||||
- Hover a session → click folder icon → assign to project via picker.
|
||||
- Verify colored left border appears on assigned session.
|
||||
- Double-click project chip to rename. Right-click to delete.
|
||||
- Code blocks have a "Copy" button. Click → "Copied!" feedback.
|
||||
- Messages with 2+ tool cards show "Expand all / Collapse all" toggle.
|
||||
|
||||
### Sprint 16: Sidebar Visual Polish
|
||||
- Session titles use full sidebar width (no truncated space for hidden icons).
|
||||
- Hover a session → action buttons appear from right with gradient fade.
|
||||
- All icons are monochrome SVGs (not emoji). Consistent across platforms.
|
||||
- Pinned sessions show small gold star inline. Unpinned = no star, full title width.
|
||||
- Active session has gold highlight (not blue). Overlay gradient matches.
|
||||
- Double-click to rename → overlay hides during rename.
|
||||
|
||||
### Sprint 17: Workspace + Slash Commands + Send Key
|
||||
- Navigate into a subdirectory. Breadcrumb bar appears with clickable segments.
|
||||
- Up button in panel header navigates to parent. Hidden at root.
|
||||
- Type `/` in composer → autocomplete dropdown appears. Arrow keys navigate.
|
||||
- Type `/help` → lists all commands. `/clear` clears conversation. `/model` switches.
|
||||
- Settings panel: change send key to Ctrl+Enter. Verify Enter inserts newline.
|
||||
|
||||
### Sprint 18: Thinking + Tree View + Preview Fix
|
||||
- View a file in workspace. Click a breadcrumb or folder → preview closes automatically.
|
||||
- Click a directory toggle arrow (▸) → expands in-place showing children.
|
||||
- Click again (▾) → collapses. Double-click navigates into it (breadcrumb view).
|
||||
- If model returns thinking blocks (Claude extended thinking), verify collapsible gold card appears above response.
|
||||
|
||||
### Sprint 19: Auth + Security
|
||||
- No password set: everything works as normal. No login page.
|
||||
- Set `HERMES_WEBUI_PASSWORD=test` env var. Restart. All pages redirect to `/login`.
|
||||
- Login page: minimal card, password field, "Sign in" button.
|
||||
- Enter correct password → redirected to `/`. Cookie set (24h).
|
||||
- Enter wrong password → error message, stay on login page.
|
||||
- Settings panel: set password via "Access Password" field. Auth activates.
|
||||
- "Sign Out" button visible when auth active. Click → redirected to /login.
|
||||
- API calls without auth cookie → 401 JSON response.
|
||||
- Check response headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Sprint 19 / v0.21, April 3, 2026*
|
||||
*Total automated tests: 328 (328 passing, 0 failures)*
|
||||
*Regression gate: tests/test_regressions.py (23 tests)*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
149
api/auth.py
Normal file
149
api/auth.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Hermes Web UI -- Optional password authentication.
|
||||
Off by default. Enable by setting HERMES_WEBUI_PASSWORD env var
|
||||
or configuring a password in the Settings panel.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.cookies
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from api.config import STATE_DIR, load_settings
|
||||
|
||||
# ── Public paths (no auth required) ─────────────────────────────────────────
|
||||
PUBLIC_PATHS = frozenset({
|
||||
'/login', '/health', '/favicon.ico',
|
||||
'/api/auth/login', '/api/auth/status',
|
||||
})
|
||||
|
||||
COOKIE_NAME = 'hermes_session'
|
||||
SESSION_TTL = 86400 # 24 hours
|
||||
|
||||
# Active sessions: token -> expiry timestamp
|
||||
_sessions = {}
|
||||
|
||||
|
||||
def _signing_key():
|
||||
"""Derive a stable signing key from STATE_DIR."""
|
||||
return hashlib.sha256(str(STATE_DIR).encode()).digest()
|
||||
|
||||
|
||||
def _hash_password(password):
|
||||
"""SHA-256 hash with a salt derived from STATE_DIR."""
|
||||
salt = str(STATE_DIR).encode()
|
||||
return hashlib.sha256(salt + password.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_password_hash():
|
||||
"""Return the active password hash, or None if auth is disabled.
|
||||
Priority: env var > settings.json."""
|
||||
env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
|
||||
if env_pw:
|
||||
return _hash_password(env_pw)
|
||||
settings = load_settings()
|
||||
return settings.get('password_hash') or None
|
||||
|
||||
|
||||
def is_auth_enabled():
|
||||
"""True if a password is configured (env var or settings)."""
|
||||
return get_password_hash() is not None
|
||||
|
||||
|
||||
def verify_password(plain):
|
||||
"""Verify a plaintext password against the stored hash."""
|
||||
expected = get_password_hash()
|
||||
if not expected:
|
||||
return False
|
||||
return hmac.compare_digest(_hash_password(plain), expected)
|
||||
|
||||
|
||||
def create_session():
|
||||
"""Create a new auth session. Returns signed cookie value."""
|
||||
token = secrets.token_hex(32)
|
||||
_sessions[token] = time.time() + SESSION_TTL
|
||||
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
|
||||
return f"{token}.{sig}"
|
||||
|
||||
|
||||
def verify_session(cookie_value):
|
||||
"""Verify a signed session cookie. Returns True if valid and not expired."""
|
||||
if not cookie_value or '.' not in cookie_value:
|
||||
return False
|
||||
token, sig = cookie_value.rsplit('.', 1)
|
||||
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
|
||||
if not hmac.compare_digest(sig, expected_sig):
|
||||
return False
|
||||
expiry = _sessions.get(token)
|
||||
if not expiry or time.time() > expiry:
|
||||
_sessions.pop(token, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def invalidate_session(cookie_value):
|
||||
"""Remove a session token."""
|
||||
if cookie_value and '.' in cookie_value:
|
||||
token = cookie_value.rsplit('.', 1)[0]
|
||||
_sessions.pop(token, None)
|
||||
|
||||
|
||||
def parse_cookie(handler):
|
||||
"""Extract the auth cookie from the request headers."""
|
||||
cookie_header = handler.headers.get('Cookie', '')
|
||||
if not cookie_header:
|
||||
return None
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
try:
|
||||
cookie.load(cookie_header)
|
||||
except http.cookies.CookieError:
|
||||
return None
|
||||
morsel = cookie.get(COOKIE_NAME)
|
||||
return morsel.value if morsel else None
|
||||
|
||||
|
||||
def check_auth(handler, parsed):
|
||||
"""Check if request is authorized. Returns True if OK.
|
||||
If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""
|
||||
if not is_auth_enabled():
|
||||
return True
|
||||
# Public paths don't require auth
|
||||
if parsed.path in PUBLIC_PATHS or parsed.path.startswith('/static/'):
|
||||
return True
|
||||
# Check session cookie
|
||||
cookie_val = parse_cookie(handler)
|
||||
if cookie_val and verify_session(cookie_val):
|
||||
return True
|
||||
# Not authorized
|
||||
if parsed.path.startswith('/api/'):
|
||||
handler.send_response(401)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(b'{"error":"Authentication required"}')
|
||||
else:
|
||||
handler.send_response(302)
|
||||
handler.send_header('Location', '/login')
|
||||
handler.end_headers()
|
||||
return False
|
||||
|
||||
|
||||
def set_auth_cookie(handler, cookie_value):
|
||||
"""Set the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = cookie_value
|
||||
cookie[COOKIE_NAME]['httponly'] = True
|
||||
cookie[COOKIE_NAME]['samesite'] = 'Lax'
|
||||
cookie[COOKIE_NAME]['path'] = '/'
|
||||
cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL)
|
||||
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
|
||||
|
||||
|
||||
def clear_auth_cookie(handler):
|
||||
"""Clear the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
cookie[COOKIE_NAME] = ''
|
||||
cookie[COOKIE_NAME]['httponly'] = True
|
||||
cookie[COOKIE_NAME]['path'] = '/'
|
||||
cookie[COOKIE_NAME]['max-age'] = '0'
|
||||
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
|
||||
@@ -595,6 +595,7 @@ _SETTINGS_DEFAULTS = {
|
||||
'default_model': DEFAULT_MODEL,
|
||||
'default_workspace': str(DEFAULT_WORKSPACE),
|
||||
'send_key': 'enter', # 'enter' or 'ctrl+enter'
|
||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||
}
|
||||
|
||||
def load_settings() -> dict:
|
||||
@@ -609,14 +610,23 @@ def load_settings() -> dict:
|
||||
pass
|
||||
return settings
|
||||
|
||||
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys())
|
||||
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
||||
_SETTINGS_ENUM_VALUES = {
|
||||
'send_key': {'enter', 'ctrl+enter'},
|
||||
}
|
||||
|
||||
def save_settings(settings: dict) -> dict:
|
||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||
import hashlib as _hl
|
||||
current = load_settings()
|
||||
# Handle _set_password: hash and store as password_hash
|
||||
raw_pw = settings.pop('_set_password', None)
|
||||
if raw_pw and isinstance(raw_pw, str) and raw_pw.strip():
|
||||
salt = str(STATE_DIR).encode()
|
||||
current['password_hash'] = _hl.sha256(salt + raw_pw.strip().encode()).hexdigest()
|
||||
# Handle _clear_password: explicitly disable auth
|
||||
if settings.pop('_clear_password', False):
|
||||
current['password_hash'] = None
|
||||
for k, v in settings.items():
|
||||
if k in _SETTINGS_ALLOWED_KEYS:
|
||||
# Validate enum-constrained keys
|
||||
|
||||
@@ -25,6 +25,13 @@ def safe_resolve(root: Path, requested: str) -> Path:
|
||||
return resolved
|
||||
|
||||
|
||||
def _security_headers(handler):
|
||||
"""Add security headers to every response."""
|
||||
handler.send_header('X-Content-Type-Options', 'nosniff')
|
||||
handler.send_header('X-Frame-Options', 'DENY')
|
||||
handler.send_header('Referrer-Policy', 'same-origin')
|
||||
|
||||
|
||||
def j(handler, payload, status=200):
|
||||
"""Send a JSON response."""
|
||||
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
|
||||
@@ -32,6 +39,7 @@ def j(handler, payload, status=200):
|
||||
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')
|
||||
_security_headers(handler)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
@@ -43,13 +51,19 @@ def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
|
||||
handler.send_header('Content-Type', content_type)
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
|
||||
|
||||
|
||||
def read_body(handler):
|
||||
"""Read and JSON-parse a POST request body."""
|
||||
"""Read and JSON-parse a POST request body (capped at 20MB)."""
|
||||
length = int(handler.headers.get('Content-Length', 0))
|
||||
if length > MAX_BODY_BYTES:
|
||||
raise ValueError(f'Request body too large ({length} bytes, max {MAX_BODY_BYTES})')
|
||||
raw = handler.rfile.read(length) if length else b'{}'
|
||||
try:
|
||||
return _json.loads(raw)
|
||||
|
||||
106
api/routes.py
106
api/routes.py
@@ -19,7 +19,7 @@ from api.config import (
|
||||
IMAGE_EXTS, MD_EXTS, MIME_MAP, MAX_FILE_BYTES, MAX_UPLOAD_BYTES,
|
||||
CHAT_LOCK, load_settings, save_settings,
|
||||
)
|
||||
from api.helpers import require, bad, safe_resolve, j, t, read_body
|
||||
from api.helpers import require, bad, safe_resolve, j, t, read_body, _security_headers
|
||||
from api.models import (
|
||||
Session, get_session, new_session, all_sessions, title_from,
|
||||
_write_session_index, SESSION_INDEX_FILE,
|
||||
@@ -52,6 +52,58 @@ except ImportError:
|
||||
_permanent_approved = set()
|
||||
|
||||
|
||||
# ── Login page (self-contained, no external deps) ────────────────────────────
|
||||
_LOGIN_PAGE_HTML = '''<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hermes — Sign in</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#1a1a2e;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
|
||||
height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:#16213e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:36px 32px;
|
||||
width:320px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3)}
|
||||
.logo{width:48px;height:48px;border-radius:12px;background:linear-gradient(145deg,#e8a030,#e94560);
|
||||
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:20px;color:#fff;
|
||||
margin:0 auto 12px;box-shadow:0 2px 12px rgba(233,69,96,.3)}
|
||||
h1{font-size:18px;font-weight:600;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#8888aa;margin-bottom:24px}
|
||||
input{width:100%;padding:10px 14px;border-radius:10px;border:1px solid rgba(255,255,255,.1);
|
||||
background:rgba(255,255,255,.04);color:#e8e8f0;font-size:14px;outline:none;margin-bottom:14px;
|
||||
transition:border-color .15s}
|
||||
input:focus{border-color:rgba(124,185,255,.5);box-shadow:0 0 0 3px rgba(124,185,255,.1)}
|
||||
button{width:100%;padding:10px;border-radius:10px;border:none;background:rgba(124,185,255,.15);
|
||||
border:1px solid rgba(124,185,255,.3);color:#7cb9ff;font-size:14px;font-weight:600;cursor:pointer;
|
||||
transition:all .15s}
|
||||
button:hover{background:rgba(124,185,255,.25)}
|
||||
.err{color:#e94560;font-size:12px;margin-top:10px;display:none}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="logo">H</div>
|
||||
<h1>Hermes</h1>
|
||||
<p class="sub">Enter your password to continue</p>
|
||||
<form onsubmit="return doLogin(event)">
|
||||
<input type="password" id="pw" placeholder="Password" autofocus>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<div class="err" id="err"></div>
|
||||
</div>
|
||||
<script>
|
||||
async function doLogin(e){
|
||||
e.preventDefault();
|
||||
const pw=document.getElementById('pw').value;
|
||||
const err=document.getElementById('err');
|
||||
err.style.display='none';
|
||||
try{
|
||||
const res=await fetch('/api/auth/login',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({password:pw}),credentials:'include'});
|
||||
const data=await res.json();
|
||||
if(res.ok&&data.ok){window.location.href='/';}
|
||||
else{err.textContent=data.error||'Invalid password';err.style.display='block';}
|
||||
}catch(ex){err.textContent='Connection failed';err.style.display='block';}
|
||||
}
|
||||
</script></body></html>'''
|
||||
|
||||
# ── GET routes ────────────────────────────────────────────────────────────────
|
||||
|
||||
def handle_get(handler, parsed):
|
||||
@@ -61,6 +113,17 @@ def handle_get(handler, parsed):
|
||||
return t(handler, _INDEX_HTML_PATH.read_text(encoding='utf-8'),
|
||||
content_type='text/html; charset=utf-8')
|
||||
|
||||
if parsed.path == '/login':
|
||||
return t(handler, _LOGIN_PAGE_HTML, content_type='text/html; charset=utf-8')
|
||||
|
||||
if parsed.path == '/api/auth/status':
|
||||
from api.auth import is_auth_enabled, parse_cookie, verify_session
|
||||
logged_in = False
|
||||
if is_auth_enabled():
|
||||
cv = parse_cookie(handler)
|
||||
logged_in = bool(cv and verify_session(cv))
|
||||
return j(handler, {'auth_enabled': is_auth_enabled(), 'logged_in': logged_in})
|
||||
|
||||
if parsed.path == '/favicon.ico':
|
||||
handler.send_response(204); handler.end_headers(); return True
|
||||
|
||||
@@ -76,7 +139,10 @@ def handle_get(handler, parsed):
|
||||
return j(handler, get_available_models())
|
||||
|
||||
if parsed.path == '/api/settings':
|
||||
return j(handler, load_settings())
|
||||
settings = load_settings()
|
||||
# Never expose the stored password hash to clients
|
||||
settings.pop('password_hash', None)
|
||||
return j(handler, settings)
|
||||
|
||||
if parsed.path.startswith('/static/'):
|
||||
return _serve_static(handler, parsed)
|
||||
@@ -308,7 +374,9 @@ def handle_post(handler, parsed):
|
||||
|
||||
# ── Settings (POST) ──
|
||||
if parsed.path == '/api/settings':
|
||||
return j(handler, save_settings(body))
|
||||
saved = save_settings(body)
|
||||
saved.pop('password_hash', None) # never expose hash to client
|
||||
return j(handler, saved)
|
||||
|
||||
# ── Session pin (POST) ──
|
||||
if parsed.path == '/api/session/pin':
|
||||
@@ -400,6 +468,38 @@ def handle_post(handler, parsed):
|
||||
if parsed.path == '/api/session/import':
|
||||
return _handle_session_import(handler, body)
|
||||
|
||||
# ── Auth endpoints (POST) ──
|
||||
if parsed.path == '/api/auth/login':
|
||||
from api.auth import verify_password, create_session, set_auth_cookie, is_auth_enabled
|
||||
if not is_auth_enabled():
|
||||
return j(handler, {'ok': True, 'message': 'Auth not enabled'})
|
||||
password = body.get('password', '')
|
||||
if not verify_password(password):
|
||||
return bad(handler, 'Invalid password', 401)
|
||||
cookie_val = create_session()
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
set_auth_cookie(handler, cookie_val)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'ok': True}).encode())
|
||||
return True
|
||||
|
||||
if parsed.path == '/api/auth/logout':
|
||||
from api.auth import clear_auth_cookie, invalidate_session, parse_cookie
|
||||
cookie_val = parse_cookie(handler)
|
||||
if cookie_val:
|
||||
invalidate_session(cookie_val)
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
clear_auth_cookie(handler)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'ok': True}).encode())
|
||||
return True
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
|
||||
@@ -189,7 +189,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
|
||||
|
||||
except Exception as e:
|
||||
put('error', {'message': str(e), 'trace': traceback.format_exc()})
|
||||
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
|
||||
put('error', {'message': str(e)})
|
||||
finally:
|
||||
_clear_thread_env() # TD1: always clear thread-local context
|
||||
with STREAMS_LOCK:
|
||||
|
||||
@@ -74,4 +74,5 @@ def handle_upload(handler):
|
||||
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)
|
||||
print('[webui] upload error: ' + _tb.format_exc(), flush=True)
|
||||
return j(handler, {'error': 'Upload failed'}, status=500)
|
||||
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
hermes-webui:
|
||||
build: .
|
||||
ports:
|
||||
- "127.0.0.1:8787:8787"
|
||||
volumes:
|
||||
# Persist session data, settings, and projects across restarts
|
||||
- hermes-data:/data
|
||||
# Mount hermes-agent for full agent features (optional)
|
||||
- ${HERMES_HOME:-${HOME}/.hermes}:/root/.hermes:ro
|
||||
environment:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
- HERMES_WEBUI_STATE_DIR=/data
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hermes-data:
|
||||
@@ -8,6 +8,7 @@ import traceback
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from api.auth import check_auth
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import j
|
||||
from api.routes import handle_get, handle_post
|
||||
@@ -34,21 +35,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._req_t0 = time.time()
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
result = handle_get(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
except Exception as e:
|
||||
return j(self, {'error': str(e), 'trace': traceback.format_exc()}, status=500)
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
|
||||
def do_POST(self):
|
||||
self._req_t0 = time.time()
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
result = handle_post(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
except Exception as e:
|
||||
return j(self, {'error': str(e), 'trace': traceback.format_exc()}, status=500)
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
127
static/boot.js
127
static/boot.js
@@ -8,8 +8,132 @@ async function cancelStream(){
|
||||
}catch(e){setStatus('Cancel failed: '+e.message);}
|
||||
}
|
||||
|
||||
$('btnSend').onclick=send;
|
||||
// ── Mobile navigation ──────────────────────────────────────────────────────
|
||||
function toggleMobileSidebar(){
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(!sidebar)return;
|
||||
const isOpen=sidebar.classList.contains('mobile-open');
|
||||
if(isOpen){closeMobileSidebar();}
|
||||
else{sidebar.classList.add('mobile-open');if(overlay)overlay.classList.add('visible');}
|
||||
}
|
||||
function closeMobileSidebar(){
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(sidebar)sidebar.classList.remove('mobile-open');
|
||||
if(overlay)overlay.classList.remove('visible');
|
||||
}
|
||||
function toggleMobileFiles(){
|
||||
const panel=document.querySelector('.rightpanel');
|
||||
if(!panel)return;
|
||||
panel.classList.toggle('mobile-open');
|
||||
}
|
||||
function mobileSwitchPanel(name){
|
||||
// Switch the panel content view
|
||||
switchPanel(name);
|
||||
// For non-chat panels (tasks, skills, memory, spaces), open the sidebar
|
||||
// so the panel is visible. For 'chat', the content is in the main area —
|
||||
// just close the sidebar so the chat view is unobstructed.
|
||||
if(name==='chat'){
|
||||
closeMobileSidebar();
|
||||
} else {
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const overlay=$('mobileOverlay');
|
||||
if(sidebar){
|
||||
sidebar.classList.add('mobile-open');
|
||||
if(overlay)overlay.classList.add('visible');
|
||||
}
|
||||
}
|
||||
// Update bottom nav active state
|
||||
document.querySelectorAll('.mobile-nav-btn').forEach(btn=>{
|
||||
btn.classList.toggle('active',btn.dataset.panel===name);
|
||||
});
|
||||
}
|
||||
|
||||
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
|
||||
$('btnAttach').onclick=()=>$('fileInput').click();
|
||||
|
||||
// ── Voice input (Web Speech API) ─────────────────────────────────────────
|
||||
(function(){
|
||||
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
|
||||
if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden
|
||||
|
||||
const btn=$('btnMic');
|
||||
const status=$('micStatus');
|
||||
const ta=$('msg');
|
||||
btn.style.display=''; // Show button — browser supports speech
|
||||
|
||||
const recognition=new SpeechRecognition();
|
||||
recognition.continuous=false;
|
||||
recognition.interimResults=true;
|
||||
recognition.lang='en-US';
|
||||
|
||||
let _finalText='';
|
||||
let _prefix='';
|
||||
|
||||
function _setRecording(on){
|
||||
window._micActive=on;
|
||||
btn.classList.toggle('recording',on);
|
||||
status.style.display=on?'':'none';
|
||||
if(!on){ _finalText=''; _prefix=''; }
|
||||
}
|
||||
|
||||
recognition.onstart=()=>{ _finalText=''; };
|
||||
|
||||
recognition.onresult=(event)=>{
|
||||
let interim='';
|
||||
let final=_finalText;
|
||||
for(let i=event.resultIndex;i<event.results.length;i++){
|
||||
const t=event.results[i][0].transcript;
|
||||
if(event.results[i].isFinal){ final+=t; _finalText=final; }
|
||||
else{ interim+=t; }
|
||||
}
|
||||
// Append to whatever was already in the textarea before mic started
|
||||
ta.value=_prefix+(final||interim);
|
||||
autoResize();
|
||||
};
|
||||
|
||||
recognition.onend=()=>{
|
||||
// Commit: prefix + final transcription; trim trailing space if prefix was non-empty
|
||||
const committed=_finalText
|
||||
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
||||
? _prefix+' '+_finalText.trimStart()
|
||||
: _prefix+_finalText)
|
||||
: ta.value; // no speech detected — leave whatever is there
|
||||
_setRecording(false);
|
||||
ta.value=committed;
|
||||
autoResize();
|
||||
};
|
||||
|
||||
recognition.onerror=(event)=>{
|
||||
_setRecording(false);
|
||||
const msgs={
|
||||
'not-allowed':'Microphone access denied. Check browser permissions.',
|
||||
'no-speech':'No speech detected. Try again.',
|
||||
'network':'Speech recognition unavailable.',
|
||||
};
|
||||
showToast(msgs[event.error]||'Voice input error: '+event.error);
|
||||
};
|
||||
|
||||
function _stopMic(){
|
||||
if(window._micActive){ recognition.stop(); }
|
||||
}
|
||||
window._stopMic=_stopMic; // expose for send-guard above
|
||||
|
||||
btn.onclick=()=>{
|
||||
if(window._micActive){
|
||||
recognition.stop();
|
||||
// _setRecording(false) will be called by onend
|
||||
} else {
|
||||
_finalText='';
|
||||
// Snapshot existing textarea content so we append rather than replace
|
||||
_prefix=ta.value;
|
||||
recognition.start();
|
||||
_setRecording(true);
|
||||
}
|
||||
};
|
||||
})();
|
||||
window._micActive=window._micActive||false;
|
||||
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
||||
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
|
||||
$('btnDownload').onclick=()=>{
|
||||
@@ -63,6 +187,7 @@ $('modelSelect').onchange=async()=>{
|
||||
};
|
||||
$('msg').addEventListener('input',()=>{
|
||||
autoResize();
|
||||
updateSendBtn();
|
||||
const text=$('msg').value;
|
||||
if(text.startsWith('/')&&text.indexOf('\n')===-1){
|
||||
const prefix=text.slice(1);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.20</div></div></div>
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.23</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>
|
||||
@@ -143,6 +143,9 @@
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<button class="mobile-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" title="Menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
|
||||
<div class="topbar-chips">
|
||||
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
|
||||
@@ -152,6 +155,7 @@
|
||||
</div>
|
||||
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">🗑 Clear</button>
|
||||
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">⚙</button>
|
||||
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">📁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
@@ -213,6 +217,7 @@
|
||||
Drop files to upload to workspace
|
||||
</div>
|
||||
<div class="attach-tray" id="attachTray"></div>
|
||||
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-left">
|
||||
@@ -220,11 +225,18 @@
|
||||
<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>
|
||||
<button class="icon-btn mic-btn" id="btnMic" title="Voice input" style="display:none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="1" width="6" height="12" rx="3"/>
|
||||
<path d="M5 10a7 7 0 0 0 14 0"/>
|
||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</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 class="send-btn" id="btnSend" title="Send message" style="display:none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,10 +294,40 @@
|
||||
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600">Save Settings</button>
|
||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none">Disable Auth</button>
|
||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
|
||||
<nav class="mobile-bottom-nav" id="mobileBottomNav">
|
||||
<button class="mobile-nav-btn active" data-panel="chat" onclick="mobileSwitchPanel('chat')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>Chat</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-panel="tasks" onclick="mobileSwitchPanel('tasks')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>Tasks</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-panel="skills" onclick="mobileSwitchPanel('skills')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<span>Skills</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-panel="memory" onclick="mobileSwitchPanel('memory')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg>
|
||||
<span>Memory</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-panel="workspaces" onclick="mobileSwitchPanel('workspaces')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h8l2 2h10v14H2z"/></svg>
|
||||
<span>Spaces</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/static/ui.js"></script>
|
||||
<script src="/static/workspace.js"></script>
|
||||
|
||||
@@ -237,7 +237,7 @@ function transcript(){
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';}
|
||||
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';updateSendBtn();}
|
||||
|
||||
|
||||
// ── Approval polling ──
|
||||
|
||||
@@ -649,6 +649,18 @@ async function loadSettingsPanel(){
|
||||
// Send key preference
|
||||
const sendKeySel=$('settingsSendKey');
|
||||
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
|
||||
// Password field: always blank (we don't send hash back)
|
||||
const pwField=$('settingsPassword');
|
||||
if(pwField) pwField.value='';
|
||||
// Show auth buttons only when auth is active
|
||||
try{
|
||||
const authStatus=await api('/api/auth/status');
|
||||
const active=authStatus.auth_enabled;
|
||||
const signOutBtn=$('btnSignOut');
|
||||
if(signOutBtn) signOutBtn.style.display=active?'':'none';
|
||||
const disableBtn=$('btnDisableAuth');
|
||||
if(disableBtn) disableBtn.style.display=active?'':'none';
|
||||
}catch(e){}
|
||||
}catch(e){
|
||||
showToast('Failed to load settings: '+e.message);
|
||||
}
|
||||
@@ -658,10 +670,21 @@ async function saveSettings(){
|
||||
const model=($('settingsModel')||{}).value;
|
||||
const workspace=($('settingsWorkspace')||{}).value;
|
||||
const sendKey=($('settingsSendKey')||{}).value;
|
||||
const pw=($('settingsPassword')||{}).value;
|
||||
const body={};
|
||||
if(model) body.default_model=model;
|
||||
if(workspace) body.default_workspace=workspace;
|
||||
if(sendKey) body.send_key=sendKey;
|
||||
// Password: only act if the field has content; blank = leave auth unchanged
|
||||
if(pw && pw.trim()){
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
|
||||
window._sendKey=sendKey||'enter';
|
||||
showToast('Settings saved (password set — login now required)');
|
||||
toggleSettings();
|
||||
return;
|
||||
}catch(e){showToast('Save failed: '+e.message);return;}
|
||||
}
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||
window._sendKey=sendKey||'enter';
|
||||
@@ -672,6 +695,30 @@ async function saveSettings(){
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut(){
|
||||
try{
|
||||
await api('/api/auth/logout',{method:'POST',body:'{}'});
|
||||
window.location.href='/login';
|
||||
}catch(e){
|
||||
showToast('Sign out failed: '+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableAuth(){
|
||||
if(!confirm('Disable password protection? Anyone will be able to access this instance.')) return;
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
|
||||
showToast('Auth disabled — password protection removed');
|
||||
// Hide both auth buttons since auth is now off
|
||||
const disableBtn=$('btnDisableAuth');
|
||||
if(disableBtn) disableBtn.style.display='none';
|
||||
const signOutBtn=$('btnSignOut');
|
||||
if(signOutBtn) signOutBtn.style.display='none';
|
||||
}catch(e){
|
||||
showToast('Failed to disable auth: '+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Close settings on overlay click (not panel click)
|
||||
document.addEventListener('click',e=>{
|
||||
const overlay=$('settingsOverlay');
|
||||
|
||||
@@ -346,6 +346,7 @@ function renderSessionListFromCache(){
|
||||
_clickTimer=null;
|
||||
if(_renamingSid) return;
|
||||
await loadSession(s.session_id);renderSessionListFromCache();
|
||||
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
|
||||
}, 220);
|
||||
};
|
||||
el.ondblclick=async(e)=>{
|
||||
|
||||
101
static/style.css
101
static/style.css
@@ -187,11 +187,18 @@
|
||||
.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;}
|
||||
.mic-btn{transition:color .15s,background .15s;}
|
||||
.mic-btn.recording{color:#e94560;background:rgba(233,69,96,.12);animation:mic-pulse 1.2s ease-in-out infinite;}
|
||||
@keyframes mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(233,69,96,.3);}50%{box-shadow:0 0 0 6px rgba(233,69,96,0);}}
|
||||
.mic-status{font-size:11px;color:#e94560;padding:4px 12px;display:flex;align-items:center;gap:6px;}
|
||||
.mic-dot{width:6px;height:6px;border-radius:50%;background:#e94560;animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;}
|
||||
.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;}
|
||||
.send-btn{width:34px;height:34px;border-radius:50%;background:#7cb9ff;border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 8px rgba(124,185,255,.35);}
|
||||
.send-btn:hover{background:#a0d0ff;transform:scale(1.08);box-shadow:0 4px 14px rgba(124,185,255,.5);}
|
||||
.send-btn:active{transform:scale(0.95);box-shadow:0 1px 4px rgba(124,185,255,.25);}
|
||||
.send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;}
|
||||
.send-btn.visible{animation:send-pop-in .18s cubic-bezier(.34,1.56,.64,1) forwards;}
|
||||
@keyframes send-pop-in{from{opacity:0;transform:scale(.55);}to{opacity:1;transform:scale(1);}}
|
||||
.upload-bar-wrap{display:none;height:3px;background:rgba(255,255,255,.06);border-radius:0 0 16px 16px;overflow:hidden;}
|
||||
.upload-bar-wrap.active{display:block;}
|
||||
.upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;}
|
||||
@@ -253,38 +260,87 @@
|
||||
::-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}}
|
||||
/* ── Desktop: hide mobile-only elements ── */
|
||||
.mobile-hamburger{display:none;}
|
||||
.mobile-files-btn{display:none!important;}
|
||||
.mobile-overlay{display:none;}
|
||||
.mobile-bottom-nav{display:none;}
|
||||
|
||||
@media(max-width:900px){.rightpanel{display:none}.mobile-files-btn{display:inline-flex!important;}}
|
||||
|
||||
@media(max-width:640px){
|
||||
.sidebar{display:none}
|
||||
/* Topbar: stack title + chips vertically, allow wrapping */
|
||||
.topbar{padding:8px 12px;gap:6px;flex-wrap:wrap;}
|
||||
.topbar-left{min-width:0;flex:1 1 100%;}
|
||||
/* ── Sidebar: slide-in overlay instead of hidden ── */
|
||||
.sidebar{position:fixed;left:-300px;top:0;bottom:0;width:280px;z-index:200;
|
||||
transition:left .25s ease;box-shadow:4px 0 24px rgba(0,0,0,.4);}
|
||||
.sidebar.mobile-open{left:0;}
|
||||
.sidebar .resize-handle{display:none;}
|
||||
/* Hamburger button */
|
||||
.mobile-hamburger{display:flex;align-items:center;justify-content:center;
|
||||
background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;
|
||||
flex-shrink:0;-webkit-tap-highlight-color:transparent;}
|
||||
.mobile-hamburger:hover{color:var(--text);}
|
||||
/* Overlay backdrop */
|
||||
.mobile-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);
|
||||
z-index:199;-webkit-tap-highlight-color:transparent;}
|
||||
.mobile-overlay.visible{display:block;}
|
||||
/* Files button in topbar */
|
||||
.mobile-files-btn{display:inline-flex!important;}
|
||||
/* Right panel: slide-over from right */
|
||||
.rightpanel{display:flex!important;position:fixed;right:-320px;top:0;bottom:0;
|
||||
width:300px;z-index:200;transition:right .25s ease;
|
||||
box-shadow:-4px 0 24px rgba(0,0,0,.4);}
|
||||
.rightpanel.mobile-open{right:0;}
|
||||
.rightpanel .resize-handle{display:none;}
|
||||
/* Bottom navigation bar */
|
||||
.mobile-bottom-nav{display:flex;position:fixed;bottom:0;left:0;right:0;
|
||||
background:var(--sidebar);border-top:1px solid var(--border);
|
||||
z-index:150;padding:4px 0 env(safe-area-inset-bottom,0);
|
||||
justify-content:space-around;align-items:center;}
|
||||
.mobile-nav-btn{display:flex;flex-direction:column;align-items:center;gap:2px;
|
||||
background:none;border:none;color:var(--muted);font-size:9px;padding:6px 4px;
|
||||
cursor:pointer;min-width:44px;min-height:44px;justify-content:center;
|
||||
-webkit-tap-highlight-color:transparent;transition:color .15s;}
|
||||
.mobile-nav-btn.active{color:var(--blue);}
|
||||
.mobile-nav-btn:hover{color:var(--text);}
|
||||
.mobile-nav-btn svg{flex-shrink:0;}
|
||||
/* Hide sidebar nav tabs (replaced by bottom nav) */
|
||||
.sidebar-nav{display:none;}
|
||||
/* Hide sidebar bottom section on mobile (model select, workspace) */
|
||||
.sidebar-bottom{display:none;}
|
||||
/* Topbar adjustments */
|
||||
.topbar{padding:8px 12px;gap:8px;}
|
||||
.topbar-title{font-size:14px;}
|
||||
.topbar-meta{font-size:10px;}
|
||||
.topbar-chips{flex-wrap:wrap;gap:4px;}
|
||||
.topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:3px 8px!important;}
|
||||
/* Messages area */
|
||||
.topbar-meta{display:none;}
|
||||
.topbar-chips{flex-wrap:nowrap;gap:4px;overflow-x:auto;-webkit-overflow-scrolling:touch;}
|
||||
.topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:3px 8px!important;white-space:nowrap;}
|
||||
/* Messages area — account for bottom nav */
|
||||
.messages{padding-bottom:60px;}
|
||||
.messages-inner{padding:12px 10px 20px;}
|
||||
.msg-body{padding-left:0;max-width:100%;}
|
||||
.msg-role{font-size:12px;}
|
||||
/* Composer */
|
||||
.composer-wrap{padding:8px 10px 12px!important;}
|
||||
/* Composer — above bottom nav */
|
||||
.composer-wrap{padding:8px 10px 12px!important;margin-bottom:56px;}
|
||||
.composer-box{border-radius:12px;}
|
||||
.composer-box textarea{font-size:16px;min-height:40px;}
|
||||
.send-btn{padding:6px 14px;font-size:13px;}
|
||||
.send-btn{width:32px;height:32px;}
|
||||
/* Touch targets — minimum 44px */
|
||||
.icon-btn,.mic-btn{min-width:44px;min-height:44px;}
|
||||
.session-item{min-height:44px;padding:10px 12px;}
|
||||
/* Empty state */
|
||||
.empty-state h2{font-size:18px;}
|
||||
.empty-state p{font-size:13px;}
|
||||
.suggestion-grid{max-width:100%!important;}
|
||||
.suggestion-btn{font-size:12px;padding:8px 10px;}
|
||||
.suggestion{font-size:12px;padding:10px 12px;}
|
||||
/* Approval card */
|
||||
.approval-card{padding:0 10px 8px;}
|
||||
.approval-btns{gap:6px;}
|
||||
.approval-btn{padding:5px 10px;font-size:11px;}
|
||||
.approval-btn{padding:8px 12px;font-size:12px;min-height:44px;}
|
||||
/* Tool cards */
|
||||
.tool-card{margin-left:0!important;font-size:12px;}
|
||||
/* Settings modal */
|
||||
.settings-panel{width:95vw;max-width:95vw;}
|
||||
/* Login page responsive */
|
||||
.card{width:90vw;max-width:320px;padding:28px 24px;}
|
||||
}
|
||||
|
||||
/* ── Workspace dropdown (topbar) ── */
|
||||
@@ -469,9 +525,14 @@
|
||||
transition:background .15s;
|
||||
}
|
||||
.resize-handle:hover,.resize-handle.dragging{background:rgba(124,185,255,.35);}
|
||||
.sidebar{position:relative;}
|
||||
/* Desktop-only: position:relative for sidebar/rightpanel resize handles.
|
||||
Must be scoped to min-width:641px so it doesn't override the mobile
|
||||
position:fixed slide-in overlay set in the max-width:640px @media block above. */
|
||||
@media(min-width:641px){
|
||||
.sidebar{position:relative;}
|
||||
.rightpanel{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;}
|
||||
|
||||
19
static/ui.js
19
static/ui.js
@@ -187,9 +187,25 @@ function setStatus(t){
|
||||
if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none';
|
||||
}
|
||||
}
|
||||
function updateSendBtn(){
|
||||
const btn=$('btnSend');
|
||||
if(!btn) return;
|
||||
const hasContent=$('msg').value.trim().length>0||S.pendingFiles.length>0;
|
||||
const shouldShow=hasContent&&!S.busy;
|
||||
if(shouldShow&&btn.style.display==='none'){
|
||||
btn.style.display='';
|
||||
// Remove then re-add class to retrigger animation each time
|
||||
btn.classList.remove('visible');
|
||||
requestAnimationFrame(()=>btn.classList.add('visible'));
|
||||
} else if(!shouldShow&&btn.style.display!=='none'){
|
||||
btn.style.display='none';
|
||||
btn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
function setBusy(v){
|
||||
S.busy=v;
|
||||
$('btnSend').disabled=v;
|
||||
updateSendBtn();
|
||||
const dots=$('activityDots');
|
||||
if(dots) dots.style.display=v?'flex':'none';
|
||||
if(!v){
|
||||
@@ -920,8 +936,9 @@ async function promptNewFolder(){
|
||||
|
||||
function renderTray(){
|
||||
const tray=$('attachTray');tray.innerHTML='';
|
||||
if(!S.pendingFiles.length){tray.classList.remove('has-files');return;}
|
||||
if(!S.pendingFiles.length){tray.classList.remove('has-files');updateSendBtn();return;}
|
||||
tray.classList.add('has-files');
|
||||
updateSendBtn();
|
||||
S.pendingFiles.forEach((f,i)=>{
|
||||
const chip=document.createElement('div');chip.className='attach-chip';
|
||||
chip.innerHTML=`📎 ${esc(f.name)} <button title="Remove">✕</button>`;
|
||||
|
||||
@@ -438,3 +438,37 @@ def test_newSession_clears_live_tool_cards(cleanup_test_sessions):
|
||||
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"
|
||||
|
||||
|
||||
# ── R16: Stack traces must not leak to clients in 500 responses ────────────
|
||||
|
||||
def test_500_response_has_no_trace_field():
|
||||
"""R16: HTTP 500 responses must not include a 'trace' field.
|
||||
Leaking tracebacks exposes file paths, module names, and potentially
|
||||
secret values from local variables.
|
||||
"""
|
||||
# POST to /api/chat/start with missing required fields to trigger an error
|
||||
data, status = post("/api/chat/start", {})
|
||||
# Should be an error response (4xx or 5xx)
|
||||
assert "trace" not in data, \
|
||||
"Server must not leak stack traces to clients"
|
||||
|
||||
def test_upload_error_has_no_trace_field():
|
||||
"""R16b: Upload 500 responses must not include a 'trace' field."""
|
||||
# Send a POST to /api/upload with invalid content to trigger the error handler
|
||||
req = urllib.request.Request(
|
||||
BASE + "/api/upload",
|
||||
data=b"not-multipart-data",
|
||||
headers={"Content-Type": "text/plain", "Content-Length": "18"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
body = json.loads(r.read())
|
||||
code = r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
body = json.loads(e.read())
|
||||
code = e.code
|
||||
assert code >= 400, "Invalid upload should return an error status"
|
||||
assert "trace" not in body, \
|
||||
"Upload errors must not leak stack traces to clients"
|
||||
assert "error" in body, "Error responses must include an 'error' key"
|
||||
|
||||
118
tests/test_sprint19.py
Normal file
118
tests/test_sprint19.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Sprint 19 Tests: auth/login, security headers, request size limit.
|
||||
"""
|
||||
import json, urllib.error, urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get(path, headers=None):
|
||||
req = urllib.request.Request(BASE + path)
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status, dict(r.headers)
|
||||
|
||||
|
||||
def post(path, body=None, headers=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status, dict(r.headers)
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code, dict(e.headers)
|
||||
|
||||
|
||||
# ── Auth status (no password configured in test env) ──────────────────────
|
||||
|
||||
def test_auth_status_disabled():
|
||||
"""Auth should be disabled by default (no password set)."""
|
||||
d, status, _ = get("/api/auth/status")
|
||||
assert status == 200
|
||||
assert d["auth_enabled"] is False
|
||||
|
||||
|
||||
def test_login_when_auth_disabled():
|
||||
"""Login should succeed trivially when auth is not enabled."""
|
||||
d, status, _ = post("/api/auth/login", {"password": "anything"})
|
||||
assert status == 200
|
||||
assert d["ok"] is True
|
||||
|
||||
|
||||
def test_all_routes_accessible_without_auth():
|
||||
"""When auth is disabled, all routes should work without cookies."""
|
||||
d, status, _ = get("/api/sessions")
|
||||
assert status == 200
|
||||
assert "sessions" in d
|
||||
|
||||
|
||||
def test_login_page_served():
|
||||
"""GET /login should return the login page HTML."""
|
||||
req = urllib.request.Request(BASE + "/login")
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
html = r.read().decode()
|
||||
assert r.status == 200
|
||||
assert "Sign in" in html
|
||||
assert "Hermes" in html
|
||||
|
||||
|
||||
# ── Security headers ─────────────────────────────────────────────────────
|
||||
|
||||
def test_security_headers_on_json():
|
||||
"""JSON responses should include security headers."""
|
||||
d, status, headers = get("/api/auth/status")
|
||||
assert status == 200
|
||||
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert headers.get("X-Frame-Options") == "DENY"
|
||||
assert headers.get("Referrer-Policy") == "same-origin"
|
||||
|
||||
|
||||
def test_security_headers_on_health():
|
||||
"""Health endpoint should include security headers."""
|
||||
d, status, headers = get("/health")
|
||||
assert status == 200
|
||||
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||
|
||||
|
||||
def test_cache_control_no_store():
|
||||
"""API responses should have Cache-Control: no-store."""
|
||||
d, status, headers = get("/api/sessions")
|
||||
assert headers.get("Cache-Control") == "no-store"
|
||||
|
||||
|
||||
# ── Settings password field ──────────────────────────────────────────────
|
||||
|
||||
def test_settings_password_hash_not_exposed():
|
||||
"""GET /api/settings must never expose the stored password hash."""
|
||||
d, status, _ = get("/api/settings")
|
||||
assert status == 200
|
||||
assert "password_hash" not in d # security: never send hash to client
|
||||
|
||||
|
||||
def test_settings_save_preserves_other_fields():
|
||||
"""Saving settings should not break existing fields."""
|
||||
# Get current settings
|
||||
current, _, _ = get("/api/settings")
|
||||
# Save with just send_key
|
||||
d, status, _ = post("/api/settings", {"send_key": "enter"})
|
||||
assert status == 200
|
||||
# Verify other fields still present
|
||||
updated, _, _ = get("/api/settings")
|
||||
assert "default_model" in updated
|
||||
assert "default_workspace" in updated
|
||||
|
||||
|
||||
def test_settings_password_hash_not_directly_settable():
|
||||
"""POST /api/settings with password_hash must not overwrite the stored hash."""
|
||||
# Attempt to set a raw hash directly (attack vector)
|
||||
post("/api/settings", {"password_hash": "deadbeef" * 8})
|
||||
# Settings response must not expose it regardless
|
||||
updated, status, _ = get("/api/settings")
|
||||
assert status == 200
|
||||
assert "password_hash" not in updated
|
||||
422
tests/test_sprint20.py
Normal file
422
tests/test_sprint20.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Sprint 20 Tests: Voice input (mic button) via Web Speech API.
|
||||
|
||||
These tests verify the static assets contain the correct HTML structure,
|
||||
CSS rules, and JS logic for the mic feature — all of which runs purely in
|
||||
the browser with no server-side component.
|
||||
"""
|
||||
import re
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
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(), r.status
|
||||
|
||||
|
||||
# ── index.html ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mic_button_present_in_html():
|
||||
"""index.html must contain the mic button with id='btnMic'."""
|
||||
html, status = get_text("/")
|
||||
assert status == 200
|
||||
assert 'id="btnMic"' in html
|
||||
|
||||
|
||||
def test_mic_button_has_mic_btn_class():
|
||||
"""btnMic must carry the mic-btn CSS class for styling hooks."""
|
||||
html, _ = get_text("/")
|
||||
assert 'class="icon-btn mic-btn"' in html
|
||||
|
||||
|
||||
def test_mic_button_hidden_by_default():
|
||||
"""btnMic starts hidden (display:none) — JS shows it only if supported."""
|
||||
html, _ = get_text("/")
|
||||
# The button element should have display:none in its style attribute
|
||||
assert 'id="btnMic"' in html
|
||||
btn_match = re.search(r'id="btnMic"[^>]*>', html)
|
||||
assert btn_match, "btnMic element not found"
|
||||
assert 'display:none' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_mic_button_has_title():
|
||||
"""btnMic should have a descriptive title for accessibility."""
|
||||
html, _ = get_text("/")
|
||||
btn_match = re.search(r'id="btnMic"[^>]*>', html)
|
||||
assert btn_match
|
||||
assert 'title=' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_mic_status_div_present():
|
||||
"""index.html must contain the #micStatus listening indicator."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="micStatus"' in html
|
||||
|
||||
|
||||
def test_mic_status_hidden_by_default():
|
||||
"""#micStatus starts hidden — only shown during active recording."""
|
||||
html, _ = get_text("/")
|
||||
status_match = re.search(r'id="micStatus"[^>]*>', html)
|
||||
assert status_match, "#micStatus element not found"
|
||||
assert 'display:none' in status_match.group(0)
|
||||
|
||||
|
||||
def test_mic_status_has_mic_dot():
|
||||
"""#micStatus must contain a .mic-dot element for the pulse animation."""
|
||||
html, _ = get_text("/")
|
||||
# mic-dot should appear after micStatus
|
||||
idx_status = html.find('id="micStatus"')
|
||||
idx_dot = html.find('mic-dot', idx_status)
|
||||
assert idx_status != -1 and idx_dot != -1
|
||||
assert idx_dot > idx_status
|
||||
|
||||
|
||||
def test_mic_status_has_listening_text():
|
||||
"""#micStatus should display a 'Listening' label."""
|
||||
html, _ = get_text("/")
|
||||
assert 'Listening' in html
|
||||
|
||||
|
||||
def test_mic_button_svg_microphone_shape():
|
||||
"""btnMic SVG must include the rect (mic body) and path (mic arc)."""
|
||||
html, _ = get_text("/")
|
||||
# Find mic button section
|
||||
btn_start = html.find('id="btnMic"')
|
||||
btn_end = html.find('</button>', btn_start) + len('</button>')
|
||||
btn_html = html[btn_start:btn_end]
|
||||
assert '<rect' in btn_html, "mic SVG missing rect (mic body)"
|
||||
assert '<path' in btn_html, "mic SVG missing path (arc)"
|
||||
assert '<line' in btn_html, "mic SVG missing line (stand)"
|
||||
|
||||
|
||||
def test_mic_button_inside_composer_left():
|
||||
"""btnMic must be inside .composer-left, next to the attach button."""
|
||||
html, _ = get_text("/")
|
||||
composer_left_start = html.find('class="composer-left"')
|
||||
composer_left_end = html.find('</div>', composer_left_start)
|
||||
section = html[composer_left_start:composer_left_end]
|
||||
assert 'btnAttach' in section
|
||||
assert 'btnMic' in section
|
||||
|
||||
|
||||
# ── style.css ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mic_btn_css_rule_exists():
|
||||
"""style.css must define .mic-btn rule."""
|
||||
css, status = get_text("/static/style.css")
|
||||
assert status == 200
|
||||
assert '.mic-btn' in css
|
||||
|
||||
|
||||
def test_mic_btn_recording_state_css():
|
||||
""".mic-btn.recording must be defined for active recording visual state."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.mic-btn.recording' in css
|
||||
|
||||
|
||||
def test_mic_recording_color_red():
|
||||
""".mic-btn.recording must use the red accent color #e94560."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
recording_idx = css.find('.mic-btn.recording')
|
||||
# Find the rule block after the selector
|
||||
brace_open = css.find('{', recording_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert '#e94560' in rule or 'e94560' in rule
|
||||
|
||||
|
||||
def test_mic_recording_has_animation():
|
||||
""".mic-btn.recording must use an animation for the pulse effect."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
recording_idx = css.find('.mic-btn.recording')
|
||||
brace_open = css.find('{', recording_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'animation' in rule
|
||||
|
||||
|
||||
def test_mic_pulse_keyframes_defined():
|
||||
"""@keyframes mic-pulse must be defined for the pulsing animation."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert 'mic-pulse' in css
|
||||
assert '@keyframes' in css
|
||||
|
||||
|
||||
def test_mic_status_css_rule_exists():
|
||||
"""style.css must define .mic-status rule."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.mic-status' in css
|
||||
|
||||
|
||||
def test_mic_dot_css_rule_exists():
|
||||
"""style.css must define .mic-dot rule with animation."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.mic-dot' in css
|
||||
dot_idx = css.find('.mic-dot')
|
||||
brace_open = css.find('{', dot_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'animation' in rule
|
||||
|
||||
|
||||
def test_mic_btn_has_transition():
|
||||
""".mic-btn must define a transition for smooth state changes."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
mic_btn_idx = css.find('.mic-btn{')
|
||||
if mic_btn_idx == -1:
|
||||
mic_btn_idx = css.find('.mic-btn ')
|
||||
brace_open = css.find('{', mic_btn_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'transition' in rule
|
||||
|
||||
|
||||
# ── boot.js ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_boot_js_serves_ok():
|
||||
"""boot.js must be served successfully."""
|
||||
_, status = get_text("/static/boot.js")
|
||||
assert status == 200
|
||||
|
||||
|
||||
def test_boot_js_speech_recognition_check():
|
||||
"""boot.js must check for SpeechRecognition (with webkit fallback)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'SpeechRecognition' in js
|
||||
assert 'webkitSpeechRecognition' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_config():
|
||||
"""boot.js must configure recognition.continuous, interimResults, and lang."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.continuous' in js
|
||||
assert 'recognition.interimResults' in js
|
||||
assert 'recognition.lang' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_not_continuous():
|
||||
"""recognition.continuous must be false (auto-stop after silence)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.continuous=false' in js or 'recognition.continuous = false' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_interim_results():
|
||||
"""recognition.interimResults must be true (live transcription preview)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.interimResults=true' in js or 'recognition.interimResults = true' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_lang_en():
|
||||
"""recognition.lang must be set to en-US."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "recognition.lang='en-US'" in js or 'recognition.lang = "en-US"' in js or "recognition.lang='en-US'" in js
|
||||
|
||||
|
||||
def test_boot_js_onresult_handler():
|
||||
"""boot.js must define recognition.onresult to handle transcription."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.onresult' in js
|
||||
|
||||
|
||||
def test_boot_js_onend_handler():
|
||||
"""boot.js must define recognition.onend to reset state when recording stops."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.onend' in js
|
||||
|
||||
|
||||
def test_boot_js_onerror_handler():
|
||||
"""boot.js must define recognition.onerror for graceful error handling."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.onerror' in js
|
||||
|
||||
|
||||
def test_boot_js_not_allowed_error_message():
|
||||
"""onerror must handle 'not-allowed' with a user-friendly message."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'not-allowed' in js
|
||||
assert 'permission' in js.lower() or 'denied' in js.lower() or 'access' in js.lower()
|
||||
|
||||
|
||||
def test_boot_js_no_speech_error_message():
|
||||
"""onerror must handle 'no-speech' with a user-friendly message."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'no-speech' in js
|
||||
|
||||
|
||||
def test_boot_js_network_error_message():
|
||||
"""onerror must handle 'network' error."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "'network'" in js or '"network"' in js
|
||||
|
||||
|
||||
def test_boot_js_mic_active_flag():
|
||||
"""boot.js must track recording state via _micActive flag."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert '_micActive' in js
|
||||
|
||||
|
||||
def test_boot_js_mic_recording_class_toggle():
|
||||
"""boot.js must toggle 'recording' CSS class on the mic button."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "'recording'" in js or '"recording"' in js
|
||||
|
||||
|
||||
def test_boot_js_mic_status_toggle():
|
||||
"""boot.js must show/hide #micStatus during recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'micStatus' in js
|
||||
|
||||
|
||||
def test_boot_js_send_stops_mic():
|
||||
"""btnSend onclick must stop mic before sending (send guard)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# The send button onclick should check _micActive and stop recording
|
||||
send_onclick_idx = js.find("$('btnSend').onclick")
|
||||
assert send_onclick_idx != -1
|
||||
# Find the handler code — check that _micActive check appears near send assignment
|
||||
handler_end = js.find(';', send_onclick_idx)
|
||||
handler = js[send_onclick_idx:handler_end + 1]
|
||||
assert '_micActive' in handler or 'stopMic' in handler.lower()
|
||||
|
||||
|
||||
def test_boot_js_btn_mic_onclick():
|
||||
"""boot.js must attach an onclick handler to btnMic."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'btn.onclick' in js or "btnMic.onclick" in js or "$('btnMic').onclick" in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_start():
|
||||
"""boot.js must call recognition.start() to begin recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.start()' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_stop():
|
||||
"""boot.js must call recognition.stop() to end recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.stop()' in js
|
||||
|
||||
|
||||
def test_boot_js_iife_guard():
|
||||
"""Mic logic must be wrapped in an IIFE so it doesn't pollute global scope."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# IIFE pattern: (function(){...})() or (() => {...})()
|
||||
assert '(function(){' in js or '(function () {' in js
|
||||
|
||||
|
||||
def test_boot_js_browser_unsupported_return():
|
||||
"""boot.js must bail out (return) early when SpeechRecognition is unavailable."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# The IIFE should have an early return when SpeechRecognition is falsy
|
||||
assert 'if(!SpeechRecognition)' in js or 'if (!SpeechRecognition)' in js
|
||||
|
||||
|
||||
def test_boot_js_shows_mic_button_when_supported():
|
||||
"""boot.js must set display='' on btnMic when SpeechRecognition is available."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "btn.style.display=''" in js or 'btn.style.display = ""' in js
|
||||
|
||||
|
||||
def test_boot_js_show_toast_on_error():
|
||||
"""boot.js must call showToast() for mic errors."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'showToast' in js
|
||||
|
||||
|
||||
def test_boot_js_autoresize_called():
|
||||
"""boot.js must call autoResize() after updating textarea from transcript."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'autoResize()' in js
|
||||
|
||||
|
||||
# ── Append behaviour (fix: mic appends to existing text, not replace) ────
|
||||
|
||||
|
||||
def test_boot_js_prefix_variable_declared():
|
||||
"""boot.js must declare _prefix variable to snapshot pre-existing textarea content."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "_prefix" in js
|
||||
|
||||
|
||||
def test_boot_js_prefix_captured_on_start():
|
||||
"""_prefix must be set from ta.value when the user starts recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# _prefix assignment must happen in the btn.onclick else branch (before recognition.start)
|
||||
btn_onclick_idx = js.find("btn.onclick")
|
||||
btn_onclick_end = js.find("};", btn_onclick_idx)
|
||||
onclick_body = js[btn_onclick_idx:btn_onclick_end]
|
||||
assert "_prefix=ta.value" in onclick_body or "_prefix = ta.value" in onclick_body
|
||||
|
||||
|
||||
def test_boot_js_onresult_prepends_prefix():
|
||||
"""onresult must include _prefix when writing to textarea (append, not replace)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
onresult_idx = js.find("recognition.onresult")
|
||||
onresult_end = js.find("};", onresult_idx)
|
||||
onresult_body = js[onresult_idx:onresult_end]
|
||||
# ta.value must be set to _prefix + something, not just the transcript alone
|
||||
assert "_prefix" in onresult_body
|
||||
|
||||
|
||||
def test_boot_js_onend_commits_with_prefix():
|
||||
"""onend must commit _prefix + _finalText so appended text survives after recognition ends."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
onend_idx = js.find("recognition.onend")
|
||||
onend_end = js.find("};", onend_idx)
|
||||
onend_body = js[onend_idx:onend_end]
|
||||
assert "_prefix" in onend_body
|
||||
|
||||
|
||||
def test_boot_js_prefix_reset_on_stop():
|
||||
"""_prefix must be reset when recording stops so next session starts clean."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# _setRecording(false) clears both _finalText and _prefix
|
||||
set_rec_idx = js.find("function _setRecording")
|
||||
set_rec_end = js.find("}", set_rec_idx) + 1
|
||||
fn_body = js[set_rec_idx:set_rec_end]
|
||||
assert "_prefix" in fn_body
|
||||
|
||||
|
||||
def test_boot_js_auto_space_between_prefix_and_transcript():
|
||||
"""onend must insert a space between existing text and new transcript when needed."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
onend_idx = js.find("recognition.onend")
|
||||
onend_end = js.find("};", onend_idx)
|
||||
onend_body = js[onend_idx:onend_end]
|
||||
# Should handle spacing — look for trimStart or endsWith(' ') check
|
||||
has_spacing = ("trimStart" in onend_body or "endsWith(' ')" in onend_body
|
||||
or "endsWith(\" \")" in onend_body or "endsWith('\\n')" in onend_body)
|
||||
assert has_spacing, "onend should handle spacing between prefix and new transcript"
|
||||
|
||||
|
||||
# ── Regression: existing behaviour unchanged ──────────────────────────────
|
||||
|
||||
|
||||
def test_attach_button_still_wired():
|
||||
"""btnAttach onclick must still be wired up (no regression)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "$('btnAttach').onclick" in js
|
||||
|
||||
|
||||
def test_file_input_onchange_still_wired():
|
||||
"""fileInput onchange must still be wired up (no regression)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "$('fileInput').onchange" in js
|
||||
|
||||
|
||||
def test_index_html_still_has_send_button():
|
||||
"""btnSend must still be present in index.html (no regression)."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="btnSend"' in html
|
||||
|
||||
|
||||
def test_index_html_still_has_attach_button():
|
||||
"""btnAttach must still be present in index.html (no regression)."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="btnAttach"' in html
|
||||
343
tests/test_sprint20b.py
Normal file
343
tests/test_sprint20b.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Sprint 21 Tests: Send button polish — hidden until content, pop-in animation,
|
||||
icon-only circle design.
|
||||
"""
|
||||
import re
|
||||
import 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(), r.status
|
||||
|
||||
|
||||
# ── index.html ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_send_button_present():
|
||||
"""btnSend must still exist in the DOM."""
|
||||
html, status = get_text("/")
|
||||
assert status == 200
|
||||
assert 'id="btnSend"' in html
|
||||
|
||||
|
||||
def test_send_button_hidden_by_default():
|
||||
"""btnSend must start hidden (display:none) — only shown when there is content."""
|
||||
html, _ = get_text("/")
|
||||
btn_match = re.search(r'id="btnSend"[^>]*>', html)
|
||||
assert btn_match, "btnSend element not found"
|
||||
assert 'display:none' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_send_button_no_text_label():
|
||||
"""Send button must be icon-only — no visible 'Send' text label."""
|
||||
html, _ = get_text("/")
|
||||
# Find the full button element (from opening tag to closing tag)
|
||||
btn_open_end = html.find('>', html.find('id="btnSend"')) + 1
|
||||
btn_end = html.find('</button>', btn_open_end) + len('</button>')
|
||||
btn_inner = html[btn_open_end:btn_end]
|
||||
# Strip SVG content and any remaining tags; check visible text
|
||||
no_svg = re.sub(r'<svg[^>]*>.*?</svg>', '', btn_inner, flags=re.DOTALL)
|
||||
visible_text = re.sub(r'<[^>]+>', '', no_svg).strip()
|
||||
assert visible_text == '', f"Send button has visible text: {visible_text!r}"
|
||||
|
||||
|
||||
def test_send_button_has_svg_icon():
|
||||
"""Send button must have an SVG icon."""
|
||||
html, _ = get_text("/")
|
||||
btn_start = html.find('id="btnSend"')
|
||||
btn_end = html.find('</button>', btn_start) + len('</button>')
|
||||
btn_html = html[btn_start:btn_end]
|
||||
assert '<svg' in btn_html
|
||||
|
||||
|
||||
def test_send_button_has_title_attribute():
|
||||
"""btnSend must have a title attribute for accessibility (replaces text label)."""
|
||||
html, _ = get_text("/")
|
||||
btn_match = re.search(r'id="btnSend"[^>]*>', html)
|
||||
assert btn_match
|
||||
assert 'title=' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_send_button_svg_arrow_up():
|
||||
"""Send button SVG should use an upward arrow (line + polyline or path)."""
|
||||
html, _ = get_text("/")
|
||||
btn_start = html.find('id="btnSend"')
|
||||
btn_end = html.find('</button>', btn_start) + len('</button>')
|
||||
btn_html = html[btn_start:btn_end]
|
||||
# Must have some directional shape element
|
||||
has_shape = ('<line' in btn_html or '<polyline' in btn_html or
|
||||
'<polygon' in btn_html or '<path' in btn_html)
|
||||
assert has_shape, "Send button SVG missing directional shape"
|
||||
|
||||
|
||||
# ── style.css ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_send_btn_is_circle():
|
||||
"""send-btn must use border-radius:50% for the circle shape."""
|
||||
css, status = get_text("/static/style.css")
|
||||
assert status == 200
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'border-radius:50%' in rule or 'border-radius: 50%' in rule
|
||||
|
||||
|
||||
def test_send_btn_fixed_dimensions():
|
||||
"""send-btn must have explicit width and height (icon-circle, not text-padded)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'width:' in rule or 'width :' in rule
|
||||
assert 'height:' in rule or 'height :' in rule
|
||||
|
||||
|
||||
def test_send_btn_no_old_padding():
|
||||
"""send-btn must not use text padding layout (old pill style removed)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
# Old style used padding:7px 18px — should be gone
|
||||
assert 'padding:7px' not in rule and 'padding: 7px' not in rule
|
||||
|
||||
|
||||
def test_send_btn_blue_background():
|
||||
"""send-btn background must use the blue accent (#7cb9ff or similar)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert '7cb9ff' in rule or '5ba8f5' in rule or 'var(--blue)' in rule
|
||||
|
||||
|
||||
def test_send_btn_has_transition():
|
||||
"""send-btn must have transition for smooth hover/active states."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'transition' in rule
|
||||
|
||||
|
||||
def test_send_btn_has_box_shadow():
|
||||
"""send-btn must have a box-shadow glow effect."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
send_idx = css.find('.send-btn{')
|
||||
brace_open = css.find('{', send_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'box-shadow' in rule
|
||||
|
||||
|
||||
def test_send_btn_hover_has_scale():
|
||||
"""send-btn:hover must use transform:scale for a satisfying hover effect."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
hover_idx = css.find('.send-btn:hover{')
|
||||
brace_open = css.find('{', hover_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'scale' in rule
|
||||
|
||||
|
||||
def test_send_btn_active_shrinks():
|
||||
"""send-btn:active must scale down slightly for tactile press feedback."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
active_idx = css.find('.send-btn:active{')
|
||||
brace_open = css.find('{', active_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'scale' in rule
|
||||
|
||||
|
||||
def test_send_btn_disabled_rule_exists():
|
||||
"""send-btn:disabled must still be styled."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.send-btn:disabled' in css
|
||||
|
||||
|
||||
def test_send_btn_visible_class_defined():
|
||||
""".send-btn.visible class must be defined for the pop-in animation."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.send-btn.visible' in css
|
||||
|
||||
|
||||
def test_send_pop_in_keyframes_defined():
|
||||
"""@keyframes send-pop-in must be defined."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert 'send-pop-in' in css
|
||||
assert '@keyframes' in css
|
||||
|
||||
|
||||
def _extract_keyframe(css, name):
|
||||
"""Extract the full @keyframes block for the given animation name."""
|
||||
# Find '@keyframes <name>' directly (forward search) to avoid hitting
|
||||
# an earlier keyframe when multiple are defined on the same line.
|
||||
kf_start = css.find('@keyframes ' + name)
|
||||
assert kf_start != -1, f"@keyframes {name} not found in CSS"
|
||||
depth = 0
|
||||
kf_end = kf_start
|
||||
for i, ch in enumerate(css[kf_start:], kf_start):
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
kf_end = i
|
||||
break
|
||||
return css[kf_start:kf_end]
|
||||
|
||||
|
||||
def test_send_pop_in_uses_scale():
|
||||
"""send-pop-in keyframe must animate from a scaled-down state."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
kf_rule = _extract_keyframe(css, 'send-pop-in')
|
||||
assert 'scale' in kf_rule
|
||||
|
||||
|
||||
def test_send_pop_in_uses_opacity():
|
||||
"""send-pop-in keyframe must fade in (opacity transition)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
kf_rule = _extract_keyframe(css, 'send-pop-in')
|
||||
assert 'opacity' in kf_rule
|
||||
|
||||
|
||||
def test_send_btn_mobile_override_no_padding():
|
||||
"""Mobile override for send-btn must not add text padding (keeps circle shape)."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
# Find the @media block
|
||||
media_idx = css.find('@media')
|
||||
send_mobile_idx = css.find('.send-btn', media_idx)
|
||||
if send_mobile_idx == -1:
|
||||
return # No mobile override, fine
|
||||
brace_open = css.find('{', send_mobile_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'padding:' not in rule and 'font-size' not in rule
|
||||
|
||||
|
||||
# ── ui.js ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ui_js_update_send_btn_function():
|
||||
"""ui.js must define updateSendBtn() function."""
|
||||
js, status = get_text("/static/ui.js")
|
||||
assert status == 200
|
||||
assert 'function updateSendBtn' in js
|
||||
|
||||
|
||||
def test_update_send_btn_checks_content():
|
||||
"""updateSendBtn must check textarea value length."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
fn_idx = js.find('function updateSendBtn')
|
||||
fn_end = js.find('\n}', fn_idx) + 2
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert 'msg' in fn_body
|
||||
assert '.value' in fn_body
|
||||
assert '.length' in fn_body or '.trim()' in fn_body
|
||||
|
||||
|
||||
def test_update_send_btn_checks_pending_files():
|
||||
"""updateSendBtn must also show send button when files are attached."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
fn_idx = js.find('function updateSendBtn')
|
||||
fn_end = js.find('\n}', fn_idx) + 2
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert 'pendingFiles' in fn_body
|
||||
|
||||
|
||||
def test_update_send_btn_uses_visible_class():
|
||||
"""updateSendBtn must add .visible class to trigger the pop-in animation."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
fn_idx = js.find('function updateSendBtn')
|
||||
fn_end = js.find('\n}', fn_idx) + 2
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert 'visible' in fn_body
|
||||
|
||||
|
||||
def test_update_send_btn_uses_display_none():
|
||||
"""updateSendBtn must hide the button with display:none when no content."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
fn_idx = js.find('function updateSendBtn')
|
||||
fn_end = js.find('\n}', fn_idx) + 2
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert 'display' in fn_body
|
||||
assert 'none' in fn_body
|
||||
|
||||
|
||||
def test_set_busy_calls_update_send_btn():
|
||||
"""setBusy must call updateSendBtn() so button hides while agent is responding."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
busy_idx = js.find('function setBusy')
|
||||
busy_end = js.find('\n}', busy_idx) + 2
|
||||
busy_body = js[busy_idx:busy_end]
|
||||
assert 'updateSendBtn' in busy_body
|
||||
|
||||
|
||||
def test_render_tray_calls_update_send_btn():
|
||||
"""renderTray must call updateSendBtn() so button appears when files are attached."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
tray_idx = js.find('function renderTray')
|
||||
tray_end = js.find('\n}', tray_idx) + 2
|
||||
tray_body = js[tray_idx:tray_end]
|
||||
assert 'updateSendBtn' in tray_body
|
||||
|
||||
|
||||
# ── boot.js ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_boot_js_input_calls_update_send_btn():
|
||||
"""boot.js input event listener must call updateSendBtn()."""
|
||||
js, status = get_text("/static/boot.js")
|
||||
assert status == 200
|
||||
assert 'updateSendBtn' in js
|
||||
|
||||
|
||||
# ── messages.js ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_auto_resize_calls_update_send_btn():
|
||||
"""autoResize() must call updateSendBtn() so button hides after send clears textarea."""
|
||||
js, status = get_text("/static/messages.js")
|
||||
assert status == 200
|
||||
assert 'updateSendBtn' in js
|
||||
|
||||
|
||||
# ── Regression: existing behaviour unchanged ──────────────────────────────
|
||||
|
||||
|
||||
def test_send_button_still_has_send_btn_class():
|
||||
"""btnSend must still carry class='send-btn' for CSS targeting."""
|
||||
html, _ = get_text("/")
|
||||
assert 'class="send-btn"' in html
|
||||
|
||||
|
||||
def test_ui_js_set_busy_still_disables_btn():
|
||||
"""setBusy must still set btnSend.disabled (not just hide it)."""
|
||||
js, _ = get_text("/static/ui.js")
|
||||
busy_idx = js.find('function setBusy')
|
||||
busy_end = js.find('\n}', busy_idx) + 2
|
||||
busy_body = js[busy_idx:busy_end]
|
||||
assert "btnSend" in busy_body
|
||||
assert 'disabled' in busy_body
|
||||
|
||||
|
||||
def test_index_html_attach_button_unchanged():
|
||||
"""btnAttach must still be present (no regression)."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="btnAttach"' in html
|
||||
|
||||
|
||||
def test_send_function_still_exists():
|
||||
"""send() function must still be defined in messages.js."""
|
||||
js, _ = get_text("/static/messages.js")
|
||||
assert 'async function send()' in js
|
||||
Reference in New Issue
Block a user