Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03ddf78c9 | ||
|
|
5c9edfc7bf | ||
|
|
733957cea1 | ||
|
|
e61382ef71 | ||
|
|
da43a6a09a | ||
|
|
4eae6c98f9 | ||
|
|
c71439d8ab | ||
|
|
ad755e49e5 | ||
|
|
f75e17c912 | ||
|
|
3d8cf85ef2 | ||
|
|
d4ab01c152 | ||
|
|
c778c1eb0c | ||
|
|
ca01845643 | ||
|
|
30529e0002 | ||
|
|
7ef203cd41 | ||
|
|
3520fa5643 | ||
|
|
0480bbf34c | ||
|
|
28ac04da7d | ||
|
|
f21b088a14 | ||
|
|
4bec7c082e | ||
|
|
571a5a40f1 | ||
|
|
d2b27f6f1e | ||
|
|
af73a5d8fd | ||
|
|
a92c251ef8 | ||
|
|
574cd2cf70 | ||
|
|
d278563e00 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
tests/
|
||||
.env*
|
||||
@@ -26,32 +26,36 @@ 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 + auth middleware. ~79 lines.
|
||||
server.py Thin routing shell + HTTP Handler + auth middleware. ~81 lines.
|
||||
Delegates all route handling to api/routes.py.
|
||||
start.sh Discovery script: finds agent dir, Python, starts server.
|
||||
Dockerfile python:3.12-slim container image (~23 lines)
|
||||
docker-compose.yml Compose config with named volume and optional auth (~22 lines)
|
||||
.dockerignore Excludes .git, tests/, .env* from Docker builds
|
||||
api/
|
||||
__init__.py Package marker
|
||||
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)
|
||||
config.py Discovery, globals, model detection, reloadable config (~701 lines)
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers (~71 lines)
|
||||
models.py Session model + CRUD (~132 lines)
|
||||
models.py Session model + CRUD, per-session profile tracking (~137 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~246 lines)
|
||||
routes.py All GET + POST route handlers (~1180 lines)
|
||||
streaming.py SSE engine, run_agent, cancel, HERMES_HOME save/restore (~236 lines)
|
||||
upload.py Multipart parser, file upload handler (~78 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 (~590 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~957 lines)
|
||||
index.html HTML template (~364 lines)
|
||||
style.css All CSS incl. mobile responsive (~670 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~977 lines)
|
||||
workspace.js File preview, file ops, loadDir, clearPreview (~185 lines)
|
||||
sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~532 lines)
|
||||
sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~533 lines)
|
||||
messages.js send(), SSE event handlers, approval, transcript (~297 lines)
|
||||
panels.js Cron, skills, memory, workspace, todo, switchPanel, settings (~813 lines)
|
||||
panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines)
|
||||
commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
|
||||
boot.js Event wiring, keydown handlers, boot IIFE (~208 lines)
|
||||
boot.js Event wiring, mobile nav, voice input, boot IIFE (~338 lines)
|
||||
tests/
|
||||
conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines)
|
||||
test_sprint{1-19}.py Feature tests per sprint (17 files, 327 test functions)
|
||||
test_sprint{1-20b}.py Feature tests per sprint (21 files, 415 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.
|
||||
@@ -95,6 +99,7 @@ Environment variables controlling behavior:
|
||||
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)
|
||||
HERMES_HOME Base directory for Hermes state (~/.hermes by default)
|
||||
|
||||
Test isolation environment variables (set by conftest.py):
|
||||
|
||||
@@ -113,6 +118,8 @@ Per-request environment variables (set by chat handler, restored after):
|
||||
HERMES_EXEC_ASK Set to "1" to enable approval gate for dangerous commands.
|
||||
HERMES_SESSION_KEY Set to session_id. The approval tool keys pending entries
|
||||
by this value, enabling per-session approval state.
|
||||
HERMES_HOME Set to the active profile's directory before running agent.
|
||||
Saved and restored around each agent run.
|
||||
|
||||
WARNING: These env vars are process-global. Two concurrent chat requests will clobber
|
||||
each other. This is safe only for single-user, single-concurrent-request use.
|
||||
|
||||
163
CHANGELOG.md
163
CHANGELOG.md
@@ -5,6 +5,167 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.26] Profile System Polish -- 10 Post-Sprint-23 Fixes
|
||||
*April 3, 2026 | 426 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **Profile switch base dir bug.** When `HERMES_HOME` was mutated to a
|
||||
`profiles/` subdir at startup, `switch_profile()` doubled the path
|
||||
(e.g. `~/.hermes/profiles/X/profiles/X`). New `_resolve_base_hermes_home()`
|
||||
detects profile subdirs and walks up to the actual base.
|
||||
- **Cross-provider model routing.** Picking a model from a different provider
|
||||
than the config's default now routes through OpenRouter instead of trying
|
||||
a direct API call to a provider whose key may not exist.
|
||||
- **Legacy sessions missing profile tag.** `all_sessions()` now backfills
|
||||
`profile='default'` for pre-Sprint-22 sessions so the profile filter works.
|
||||
- **Workspace list cleanup.** Stale paths, test artifacts, and cross-profile
|
||||
entries are now cleaned on load. Legacy global workspace file migrated
|
||||
once for the default profile.
|
||||
- **API error messages.** `api()` helper now parses JSON error bodies and
|
||||
surfaces the human-readable message instead of raw JSON.
|
||||
- **Workspace dropdown moved to sidebar.** The workspace picker now opens
|
||||
upward from the sidebar bottom instead of clipping behind the topbar.
|
||||
|
||||
### Features
|
||||
- **Rate limit error display.** Rate limit errors (429) now show a distinct
|
||||
card with a rate limit icon and hint, instead of the generic error message.
|
||||
- **SSE `apperror`/`warning` events.** Server can send typed error events
|
||||
that the frontend handles with appropriate UX (rate limit card, fallback
|
||||
notice, etc.).
|
||||
- **Smart model resolver.** `_findModelInDropdown()` handles name mismatches
|
||||
between config model IDs and dropdown values (e.g. `claude-sonnet-4-6` vs
|
||||
`anthropic/claude-sonnet-4.6`).
|
||||
- **Profile switch starts new session.** When the current session has messages,
|
||||
switching profiles automatically starts a fresh session to prevent
|
||||
cross-profile tagging.
|
||||
- **Per-profile toolsets.** Agent now reads `platform_toolsets.cli` from the
|
||||
active profile's config at call time, not the boot-time snapshot.
|
||||
- **Per-profile fallback model.** `fallback_model` config is read from the
|
||||
active profile and passed to AIAgent.
|
||||
|
||||
### Architecture
|
||||
- `api/profiles.py`: `_resolve_base_hermes_home()` replaces naive env var read.
|
||||
- `api/workspace.py`: `_clean_workspace_list()`, `_migrate_global_workspaces()`.
|
||||
- `api/streaming.py`: Per-profile toolsets and fallback model at call time.
|
||||
- `api/models.py`: `all_sessions()` backfills `profile='default'`.
|
||||
- `static/ui.js`: `_findModelInDropdown()`, `_applyModelToDropdown()`.
|
||||
- `static/messages.js`: `apperror` and `warning` SSE event handlers.
|
||||
|
||||
---
|
||||
|
||||
## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence
|
||||
*April 3, 2026 | 423 tests*
|
||||
|
||||
### Features
|
||||
- **Profile-local workspace storage.** Each named profile now stores its own
|
||||
`workspaces.json` and `last_workspace.txt` under `{profile_home}/webui_state/`.
|
||||
Default profile continues using the global STATE_DIR for backward compat.
|
||||
- **Profile switch returns defaults.** `POST /api/profile/switch` response now
|
||||
includes `default_model` and `default_workspace` from the new profile's
|
||||
config.yaml, enabling one-round-trip state sync.
|
||||
- **Session profile filter.** Session sidebar filters to the active profile by
|
||||
default. "Show N from other profiles" toggle reveals sessions from all
|
||||
profiles, modeled on the existing archived toggle. Resets on profile switch.
|
||||
|
||||
### Bug Fixes
|
||||
- **Model picker ignores profile on switch.** `switchToProfile()` now clears
|
||||
the `hermes-webui-model` localStorage key so the profile's default model
|
||||
applies instead of a stale preference from another profile.
|
||||
- **Workspace list was global.** Switching profiles no longer shows the wrong
|
||||
profile's workspaces.
|
||||
- **`DEFAULT_WORKSPACE` was a boot-time singleton.** Now resolved dynamically
|
||||
through `_profile_default_workspace()`.
|
||||
- **Session list showed all profiles.** Now filtered to active profile.
|
||||
- **`switchToProfile()` didn't refresh workspaces or sessions.** Now refreshes
|
||||
workspace list, session list, and resets profile filter on switch.
|
||||
|
||||
### Architecture
|
||||
- `api/workspace.py` rewritten with profile-aware path resolution.
|
||||
- `api/profiles.py`: `switch_profile()` returns `default_model` and
|
||||
`default_workspace`.
|
||||
- `static/sessions.js`: Profile filter with toggle UI.
|
||||
- `static/panels.js`: Full cascade refresh on profile switch.
|
||||
- 8 new tests in `test_sprint23.py`.
|
||||
|
||||
---
|
||||
|
||||
## [v0.24] Sprint 22 -- Multi-Profile Support (Issue #28)
|
||||
*April 3, 2026 | 415 tests*
|
||||
|
||||
### Features
|
||||
- **Profile picker (topbar).** Purple-accented chip with SVG user icon. Click
|
||||
to open dropdown listing all profiles with gateway status dots (green =
|
||||
running), model info, and skill count. Click any profile to switch; "Manage
|
||||
profiles" link opens the sidebar panel.
|
||||
- **Profiles management panel.** New sidebar tab with full CRUD UI. Profile
|
||||
cards show name, model/provider, skill count, API key status, and gateway
|
||||
status badge. "Use" button switches profile, delete button removes non-default
|
||||
profiles (with confirmation).
|
||||
- **Profile creation.** "+ New profile" form with name validation (`[a-z0-9_-]`),
|
||||
optional "clone config from active" checkbox. Wraps the CLI's
|
||||
`hermes_cli.profiles.create_profile()`.
|
||||
- **Profile deletion.** Confirm dialog. Auto-switches to default if deleting
|
||||
the active profile. Blocked while agent is running.
|
||||
- **Seamless profile switching.** No server restart. Profile switch updates
|
||||
`HERMES_HOME`, patches module-level caches in hermes-agent's `skills_tool`
|
||||
and `cron/jobs`, reloads `.env` API keys and `config.yaml`, refreshes the
|
||||
model dropdown, skills, memory, and cron panels.
|
||||
- **Per-session profile tracking.** `profile` field on Session records which
|
||||
profile was active at creation. Backward-compatible (`null` for old sessions).
|
||||
|
||||
### Bug Fixes
|
||||
- **Hardcoded `~/.hermes` paths.** Memory read/write and model discovery used
|
||||
hardcoded paths. Now resolved through `get_active_hermes_home()`.
|
||||
- **Module-level path caching.** hermes-agent modules snapshot `HERMES_HOME`
|
||||
at import time. Profile switch now monkey-patches `SKILLS_DIR`, `CRON_DIR`,
|
||||
`JOBS_FILE`, `OUTPUT_DIR` so they track the active profile.
|
||||
|
||||
### Architecture
|
||||
- New `api/profiles.py`: profile state management wrapping `hermes_cli.profiles`.
|
||||
Thread-safe (`_profile_lock`). Lazy imports avoid circular deps.
|
||||
- `api/config.py`: module-level `cfg` replaced with reloadable `get_config()`
|
||||
/ `reload_config()`. Dynamic `_get_config_path()` resolves through profile.
|
||||
- `api/streaming.py`: `HERMES_HOME` added to env save/restore block.
|
||||
- Profile switch blocked while agent streams are active.
|
||||
- 5 new API endpoints: `GET /api/profiles`, `GET /api/profile/active`,
|
||||
`POST /api/profile/switch`, `POST /api/profile/create`,
|
||||
`POST /api/profile/delete`.
|
||||
- Zero modifications to hermes-agent code.
|
||||
|
||||
---
|
||||
|
||||
## [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*
|
||||
|
||||
@@ -716,4 +877,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.22, April 3, 2026 | Tests: 415*
|
||||
*Last updated: v0.26, April 3, 2026 | Tests: 426*
|
||||
|
||||
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"]
|
||||
152
README.md
152
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 |
|
||||
@@ -75,7 +106,8 @@ Full list of environment variables:
|
||||
| `HERMES_WEBUI_STATE_DIR` | `~/.hermes/webui-mvp` | Where sessions and state are stored |
|
||||
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
|
||||
| `HERMES_WEBUI_DEFAULT_MODEL` | `openai/gpt-5.4-mini` | Default model |
|
||||
| `HERMES_HOME` | `~/.hermes` | Base directory for Hermes state (affects all paths above) |
|
||||
| `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication |
|
||||
| `HERMES_HOME` | `~/.hermes` | Base directory for Hermes state (affects all paths) |
|
||||
| `HERMES_CONFIG_PATH` | `~/.hermes/config.yaml` | Path to Hermes config file |
|
||||
|
||||
---
|
||||
@@ -127,17 +159,18 @@ Tests discover the repo and the Hermes agent dynamically -- no hardcoded paths.
|
||||
|
||||
```bash
|
||||
cd hermes-webui
|
||||
python -m pytest tests/ -v
|
||||
pytest tests/ -v --timeout=60
|
||||
```
|
||||
|
||||
Or using the agent venv explicitly:
|
||||
|
||||
```bash
|
||||
/path/to/hermes-agent/venv/bin/python -m pytest tests/ -v # or any Python with deps installed
|
||||
/path/to/hermes-agent/venv/bin/python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Tests run against an isolated server on port 8788 with a separate state directory.
|
||||
Production data and real cron jobs are never touched.
|
||||
Production data and real cron jobs are never touched. Current count: **415 tests**
|
||||
across 21 test files.
|
||||
|
||||
---
|
||||
|
||||
@@ -145,22 +178,27 @@ Production data and real cron jobs are never touched.
|
||||
|
||||
### Chat and agent
|
||||
- Streaming responses via SSE (tokens appear as they are generated)
|
||||
- Multi-provider model support -- any Hermes API provider (OpenAI, Anthropic, Google, DeepSeek, Nous Portal, OpenRouter); dynamic model dropdown populated from configured keys
|
||||
- Multi-provider model support -- any Hermes API provider (OpenAI, Anthropic, Google, DeepSeek, Nous Portal, OpenRouter, MiniMax, Z.AI); dynamic model dropdown populated from configured keys
|
||||
- Send a message while one is processing -- it queues automatically
|
||||
- Edit any past user message inline and regenerate from that point
|
||||
- Retry the last assistant response with one click
|
||||
- Cancel a running task from the activity bar
|
||||
- Tool call cards inline -- each shows the tool name, args, and result snippet
|
||||
- Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns
|
||||
- Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts)
|
||||
- Thinking/reasoning display -- collapsible gold-themed cards for Claude extended thinking and o3 reasoning blocks
|
||||
- Approval card for dangerous shell commands (allow once / session / always / deny)
|
||||
- SSE auto-reconnect on network blips (SSH tunnel resilience)
|
||||
- File attachments persist across page reloads
|
||||
- Message timestamps (HH:MM next to each message, full date on hover)
|
||||
- Code block copy button with "Copied!" feedback
|
||||
- Syntax highlighting via Prism.js (Python, JS, bash, JSON, SQL, and more)
|
||||
- Safe HTML rendering in AI responses (bold, italic, code converted to markdown)
|
||||
|
||||
### Sessions
|
||||
- Create, rename, duplicate, delete, search by title and message content
|
||||
- Pin/star sessions to the top of the sidebar
|
||||
- Pin/star sessions to the top of the sidebar (gold indicator)
|
||||
- Archive sessions (hide without deleting, toggle to show)
|
||||
- Session projects -- named groups with colors for organizing sessions
|
||||
- Session tags -- add #tag to titles for colored chips and click-to-filter
|
||||
- Grouped by Today / Yesterday / Earlier in the sidebar
|
||||
- Download as Markdown transcript, full JSON export, or import from JSON
|
||||
@@ -168,56 +206,105 @@ Production data and real cron jobs are never touched.
|
||||
- Browser tab title reflects the active session name
|
||||
|
||||
### Workspace file browser
|
||||
- Browse directory tree with type icons
|
||||
- Directory tree with expand/collapse (single-click toggles, double-click navigates)
|
||||
- Breadcrumb navigation with clickable path segments
|
||||
- Preview text, code, Markdown (rendered), and images inline
|
||||
- Edit, create, delete, and rename files; create folders
|
||||
- Binary file download (auto-detected from server)
|
||||
- File preview auto-closes on directory navigation (with unsaved-edit guard)
|
||||
- Right panel is drag-resizable
|
||||
- Syntax highlighted code preview (Prism.js)
|
||||
|
||||
### Voice input
|
||||
- Microphone button in the composer (Web Speech API)
|
||||
- Tap to record, tap again or send to stop
|
||||
- Live interim transcription appears in the textarea
|
||||
- Auto-stops after ~2s of silence
|
||||
- Appends to existing textarea content (doesn't replace)
|
||||
- Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari)
|
||||
|
||||
### Profiles
|
||||
- Profile picker in the topbar -- purple chip with dropdown showing all profiles
|
||||
- Gateway status dots (green = running), model info, skill count per profile
|
||||
- Profiles management panel -- create, switch, and delete profiles from the sidebar
|
||||
- Clone config from active profile on create
|
||||
- Seamless switching -- no server restart; reloads config, skills, memory, cron, models
|
||||
- Per-session profile tracking (records which profile was active at creation)
|
||||
|
||||
### Authentication and security
|
||||
- Optional password auth -- off by default, zero friction for localhost
|
||||
- Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel
|
||||
- Signed HMAC HTTP-only cookie with 24h TTL
|
||||
- Minimal dark-themed login page at `/login`
|
||||
- Security headers on all responses (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
|
||||
- 20MB POST body size limit
|
||||
- CDN resources pinned with SRI integrity hashes
|
||||
|
||||
### Settings and configuration
|
||||
- Settings panel (gear icon in topbar) -- persist default model and default workspace server-side
|
||||
- Settings panel (gear icon) -- default model, default workspace, send key preference
|
||||
- Send key: Enter (default) or Ctrl/Cmd+Enter
|
||||
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
|
||||
- Background agent error alerts -- banner when a non-active session encounters an error
|
||||
|
||||
### Slash commands
|
||||
- Type `/` in the composer for autocomplete dropdown
|
||||
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`
|
||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||
- Unrecognized commands pass through to the agent
|
||||
|
||||
### Panels
|
||||
- **Chat** -- session list, search, pin, archive, new conversation
|
||||
- **Tasks** -- view, create, edit, run, pause/resume, delete cron jobs; completion alerts
|
||||
- **Chat** -- session list, search, pin, archive, projects, new conversation
|
||||
- **Tasks** -- view, create, edit, run, pause/resume, delete cron jobs; run history; completion alerts
|
||||
- **Skills** -- list all skills by category, search, preview, create/edit/delete
|
||||
- **Memory** -- view and edit MEMORY.md and USER.md inline
|
||||
- **Profiles** -- create, switch, delete agent profiles; clone config
|
||||
- **Todos** -- live task list from the current session
|
||||
- **Spaces** -- add, rename, remove workspaces; quick-switch from topbar
|
||||
|
||||
### Mobile responsive
|
||||
- Hamburger sidebar -- slide-in overlay on mobile (<640px)
|
||||
- Bottom navigation bar -- 5-tab iOS-style fixed bar
|
||||
- Files slide-over panel from right edge
|
||||
- Touch targets minimum 44px on all interactive elements
|
||||
- Composer positioned above bottom nav
|
||||
- Desktop layout completely unchanged
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
server.py HTTP routing shell (~76 lines)
|
||||
server.py HTTP routing shell + auth middleware (~81 lines)
|
||||
api/
|
||||
routes.py All GET + POST route handlers
|
||||
config.py Discovery + globals + model provider detection
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve()
|
||||
models.py Session model + CRUD
|
||||
workspace.py File ops: list_dir, read_file_content, workspace helpers
|
||||
upload.py Multipart parser, file upload handler
|
||||
streaming.py SSE engine, run_agent integration, cancel support
|
||||
auth.py Optional password authentication, signed cookies (~149 lines)
|
||||
config.py Discovery, globals, model detection, reloadable config (~701 lines)
|
||||
helpers.py HTTP helpers, security headers (~71 lines)
|
||||
models.py Session model + CRUD (~137 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~246 lines)
|
||||
routes.py All GET + POST route handlers (~1180 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~236 lines)
|
||||
upload.py Multipart parser, file upload handler (~78 lines)
|
||||
workspace.py File ops, workspace helpers (~77 lines)
|
||||
static/
|
||||
index.html HTML template
|
||||
style.css All CSS
|
||||
ui.js DOM helpers, renderMd, Mermaid, tool cards, file tree
|
||||
workspace.js File tree, preview, file ops
|
||||
sessions.js Session CRUD, list rendering, search, tags, archive
|
||||
messages.js send(), SSE event handlers, approval, transcript
|
||||
panels.js Cron, skills, memory, workspace, todo, switchPanel, alerts
|
||||
boot.js Event wiring + boot IIFE
|
||||
index.html HTML template (~364 lines)
|
||||
style.css All CSS incl. mobile responsive (~670 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, file tree (~977 lines)
|
||||
workspace.js File preview, file ops (~185 lines)
|
||||
sessions.js Session CRUD, list rendering, search (~533 lines)
|
||||
messages.js send(), SSE handlers, approval, transcript (~297 lines)
|
||||
panels.js Cron, skills, memory, profiles, settings (~974 lines)
|
||||
commands.js Slash command autocomplete (~156 lines)
|
||||
boot.js Mobile nav, voice input, boot IIFE (~338 lines)
|
||||
tests/
|
||||
conftest.py Isolated test server (port 8788, separate HERMES_HOME)
|
||||
test_sprint1-14.py Feature tests per sprint
|
||||
test_regressions.py Permanent regression gate
|
||||
conftest.py Isolated test server (port 8788)
|
||||
test_sprint{1-20b}.py 21 test files, 415 test functions
|
||||
test_regressions.py Permanent regression gate (23 tests)
|
||||
Dockerfile python:3.12-slim container image
|
||||
docker-compose.yml Compose with named volume and optional auth
|
||||
```
|
||||
|
||||
State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
||||
(sessions, workspaces, settings, last_workspace). Override with `HERMES_WEBUI_STATE_DIR`.
|
||||
(sessions, workspaces, settings, projects, last_workspace). Override with `HERMES_WEBUI_STATE_DIR`.
|
||||
|
||||
---
|
||||
|
||||
@@ -226,7 +313,8 @@ State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
||||
- `ROADMAP.md` -- feature roadmap and sprint history
|
||||
- `ARCHITECTURE.md` -- system design, all API endpoints, implementation notes
|
||||
- `TESTING.md` -- manual browser test plan and automated coverage reference
|
||||
- `CHANGELOG.md` -- release notes
|
||||
- `CHANGELOG.md` -- release notes per sprint
|
||||
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
|
||||
|
||||
## Repo
|
||||
|
||||
|
||||
34
ROADMAP.md
34
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 19 / v0.21 (April 3, 2026)
|
||||
> Tests: 328 total (328 passing, 0 failures)
|
||||
> Last updated: Sprint 22 / v0.24 (April 3, 2026)
|
||||
> Tests: 415 total (392 passing, 23 pre-existing failures)
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -36,6 +36,9 @@
|
||||
| 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 |
|
||||
| Sprint 20 | Voice input + send button | Voice input (Web Speech API), send button icon-circle with pop-in animation | 415 |
|
||||
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, bottom nav, files slide-over, Docker support (#21, #7) | 415 |
|
||||
| Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) | 415 |
|
||||
|
||||
---
|
||||
|
||||
@@ -43,10 +46,11 @@
|
||||
|
||||
| Layer | Location | Status |
|
||||
|-------|----------|--------|
|
||||
| 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 (~590 lines) | Served from disk |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3148 lines total |
|
||||
| Python server | <repo>/server.py (~81 lines) + api/ modules (~2876 lines) | Thin shell + auth middleware + business logic in api/ |
|
||||
| HTML template | <repo>/static/index.html (~364 lines) | Served from disk |
|
||||
| CSS | <repo>/static/style.css (~670 lines) | Served from disk, incl. mobile responsive |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3460 lines total |
|
||||
| Docker | Dockerfile, docker-compose.yml, .dockerignore | python:3.12-slim, named volume |
|
||||
| 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 |
|
||||
@@ -176,16 +180,22 @@
|
||||
### Thinking / Reasoning
|
||||
- [x] Collapsible thinking cards for extended-thinking models (Sprint 18)
|
||||
|
||||
### Voice
|
||||
- [x] Voice input via Web Speech API (Sprint 20)
|
||||
|
||||
### Mobile
|
||||
- [x] Mobile responsive layout — hamburger sidebar, bottom nav, files slide-over (Sprint 21)
|
||||
|
||||
### Profiles
|
||||
- [x] Multi-profile support — create, switch, delete profiles (Sprint 22, Issue #28)
|
||||
|
||||
### Advanced / Future
|
||||
- [ ] Voice input via Whisper (Sprint 20)
|
||||
- [ ] TTS playback of responses (Sprint 20)
|
||||
- [ ] TTS playback of responses (deferred)
|
||||
- [ ] Subagent delegation cards (deferred)
|
||||
- [x] Background task cancel (activity bar Cancel button)
|
||||
- [ ] 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)
|
||||
- [ ] Desktop application (deferred)
|
||||
- [ ] Extended slash command / skill integration (deferred)
|
||||
- [ ] Virtual scroll for large lists (deferred)
|
||||
|
||||
---
|
||||
|
||||
163
SPRINTS.md
163
SPRINTS.md
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.22 | 415 tests | Daily driver ready
|
||||
> Current state: v0.26 | 426 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
|
||||
@@ -421,31 +421,153 @@ UX was a low-effort high-impact polish opportunity that pairs naturally.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 21 -- Mobile Responsive (PLANNED)
|
||||
## Sprint 21 -- Mobile Responsive + Docker (COMPLETED)
|
||||
|
||||
**Theme:** A genuinely good mobile experience, not just responsive CSS.
|
||||
**Theme:** Mobile experience + containerized deployment.
|
||||
|
||||
**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 (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
|
||||
- **Collapsible sidebar.** Hamburger menu replaces the always-visible sidebar.
|
||||
- **Touch-friendly session list.** Tap to navigate, swipe gestures.
|
||||
- **Right panel as tab.** Files panel hidden by default, accessible via tab.
|
||||
- **Composer focus behavior.** Expands on focus, keyboard-aware.
|
||||
- Consider a separate mobile-optimized layout rather than just media queries.
|
||||
- **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
|
||||
- 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:** 0 new (CSS/DOM changes). Total: 415.
|
||||
**Hermes CLI parity impact:** Low
|
||||
**Claude parity impact:** High (Claude has mobile layout)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 22 -- Multi-Profile Support (PLANNED, Issue #28)
|
||||
## Sprint 22 -- Multi-Profile Support (COMPLETED, Issue #28)
|
||||
|
||||
**Theme:** Switch between Hermes agent profiles seamlessly.
|
||||
**Theme:** Switch between Hermes agent profiles seamlessly from the web UI.
|
||||
|
||||
**Why now:** Issue #28 requested full profile management in the UI. The CLI has
|
||||
had comprehensive profile support since v0.6.0 — isolated instances with their
|
||||
own config, skills, memory, cron, and API keys. The web UI was locked to a
|
||||
single default profile, blocking multi-persona workflows.
|
||||
|
||||
### Track A: Bugs
|
||||
- **Hardcoded `~/.hermes` paths.** Memory read/write in routes.py and model
|
||||
discovery in config.py used hardcoded paths instead of the active profile's
|
||||
directory. Fixed to resolve through `get_active_hermes_home()`.
|
||||
- **Module-level cached paths.** hermes-agent's `skills_tool.py` and `cron/jobs.py`
|
||||
snapshot `HERMES_HOME` at import time. Profile switch now monkey-patches these
|
||||
cached variables (`SKILLS_DIR`, `CRON_DIR`, `JOBS_FILE`, `OUTPUT_DIR`).
|
||||
|
||||
### Track B: Features
|
||||
- **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.
|
||||
- **Profile picker (topbar).** Purple-accented chip with SVG user icon in the
|
||||
topbar. Click opens a dropdown listing all profiles with gateway status dots,
|
||||
model info, and skill count. Click to switch; "Manage profiles" link opens
|
||||
the management panel.
|
||||
- **Profiles sidebar panel.** New nav tab with full management UI. Cards show
|
||||
each profile with model, provider, skill count, API key status, and gateway
|
||||
badge. "Use" button to switch, delete button for non-default profiles.
|
||||
- **Profile creation.** "+ New profile" form with name validation (lowercase
|
||||
alphanumeric + hyphens), optional "clone config from active" checkbox. Wraps
|
||||
`hermes_cli.profiles.create_profile()`.
|
||||
- **Profile deletion.** Confirm dialog, auto-switches to default if deleting
|
||||
the active profile. Blocked while agent is running.
|
||||
- **Seamless switching.** No server restart required. Profile switch updates
|
||||
`HERMES_HOME` env var, patches module-level caches, reloads `.env` API keys,
|
||||
reloads `config.yaml`, and refreshes the model dropdown, skills, memory, and
|
||||
cron panels.
|
||||
- **Per-session profile tracking.** New `profile` field on Session records which
|
||||
profile was active when the session was created. Backward-compatible (defaults
|
||||
to `null` for old sessions).
|
||||
|
||||
### Track C: Architecture
|
||||
- New `api/profiles.py` module (~200 lines): profile state management wrapping
|
||||
`hermes_cli.profiles`. Thread-safe with `_profile_lock`. Lazy imports to
|
||||
avoid circular dependencies.
|
||||
- `api/config.py`: Replaced module-level `cfg` dict with reloadable
|
||||
`get_config()`/`reload_config()`. Dynamic `_get_config_path()` resolves
|
||||
through active profile.
|
||||
- `api/streaming.py`: `HERMES_HOME` added to env save/restore block around
|
||||
agent runs (alongside `TERMINAL_CWD`, `HERMES_EXEC_ASK`).
|
||||
- Profile switch blocked while any agent stream is active (process-global
|
||||
`HERMES_HOME` cannot be changed mid-run).
|
||||
- Zero modifications to hermes-agent code required.
|
||||
|
||||
**Tests:** 0 new (profile management requires hermes-agent integration). Total: 415.
|
||||
**Hermes CLI parity impact:** Very High (profile support is a major CLI feature)
|
||||
**Claude parity impact:** Low (Claude has no profile concept)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 23 -- Desktop Application (PLANNED)
|
||||
## Sprint 23 -- Profile/Workspace/Model Coherence (COMPLETED)
|
||||
|
||||
**Theme:** Make profiles, workspaces, models, and sessions coherent across
|
||||
profile switches.
|
||||
|
||||
**Why now:** Sprint 22 added profile switching but five coherence bugs remained:
|
||||
the model picker ignored the profile's default, workspaces were a global file,
|
||||
DEFAULT_WORKSPACE was a startup singleton, the session list showed all profiles,
|
||||
and switchToProfile() didn't refresh workspaces or sessions.
|
||||
|
||||
### Track A: Bugs
|
||||
- **Model picker ignores profile on switch.** `populateModelDropdown()` skipped
|
||||
the profile's default model if `localStorage` had a saved preference. Fixed:
|
||||
`switchToProfile()` now clears `hermes-webui-model` from localStorage and
|
||||
applies the profile's default model from the switch response.
|
||||
- **Workspace list is a global file.** `workspaces.json` was process-global.
|
||||
Fixed: workspace storage is now profile-local at `{profile_home}/webui_state/`.
|
||||
Default profile uses global STATE_DIR for backward compatibility.
|
||||
- **`DEFAULT_WORKSPACE` is a startup singleton.** Frozen at boot. Fixed:
|
||||
`get_last_workspace()` and `_profile_default_workspace()` now resolve
|
||||
dynamically through the active profile's config.
|
||||
- **Session list shows all profiles.** Fixed: `renderSessionListFromCache()`
|
||||
filters to `S.activeProfile` by default, with "Show N from other profiles"
|
||||
toggle (modeled on the archived toggle).
|
||||
- **`switchToProfile()` doesn't refresh workspace list or sessions.** Fixed:
|
||||
now calls `loadWorkspaceList()`, `renderSessionList()`, resets profile filter.
|
||||
|
||||
### Track B: Features
|
||||
- **Profile-local workspace storage.** Each named profile stores its own
|
||||
`workspaces.json` and `last_workspace.txt` under `{profile_home}/webui_state/`.
|
||||
Falls back to global STATE_DIR for the default profile (preserves test
|
||||
isolation and backward compat).
|
||||
- **Profile switch returns defaults.** `POST /api/profile/switch` response now
|
||||
includes `default_model` and `default_workspace` so the frontend can apply
|
||||
both in one round-trip.
|
||||
- **Session profile filter.** Session sidebar filters to active profile by
|
||||
default. "Show N from other profiles" toggle reveals sessions from all
|
||||
profiles. Resets on profile switch.
|
||||
|
||||
### Track C: Architecture
|
||||
- `api/workspace.py`: Rewritten with `_profile_state_dir()`, `_workspaces_file()`,
|
||||
`_last_workspace_file()`, `_profile_default_workspace()`. All lazy imports to
|
||||
avoid circular deps.
|
||||
- `api/profiles.py`: `switch_profile()` returns `default_model` and
|
||||
`default_workspace` from the new profile's config.yaml.
|
||||
- `static/panels.js`: `switchToProfile()` clears localStorage model key,
|
||||
refreshes workspace list and session list, resets profile filter.
|
||||
- `static/sessions.js`: `_showAllProfiles` state variable, profile filter in
|
||||
`renderSessionListFromCache()`, toggle UI.
|
||||
|
||||
**Tests:** 8 new (test_sprint23.py). Total: 423.
|
||||
**Hermes CLI parity impact:** High (coherent profile behavior)
|
||||
**Claude parity impact:** Low
|
||||
|
||||
---
|
||||
|
||||
## Sprint 24 -- Desktop Application (PLANNED)
|
||||
|
||||
**Theme:** Native desktop experience.
|
||||
|
||||
@@ -490,7 +612,8 @@ UX was a low-effort high-impact polish opportunity that pairs naturally.
|
||||
| Slash commands | Done (Sprint 17) |
|
||||
| Thinking/reasoning display | Done (Sprint 18) |
|
||||
| Auth / login | Done (Sprint 19) |
|
||||
| Voice input | Sprint 20 |
|
||||
| Voice input | Done (Sprint 20) |
|
||||
| Multi-profile support | Done (Sprint 22) |
|
||||
| Subagent visibility | Deferred |
|
||||
| Code execution (Jupyter) | Deferred |
|
||||
| Toolset control | Deferred |
|
||||
@@ -518,11 +641,11 @@ UX was a low-effort high-impact polish opportunity that pairs naturally.
|
||||
| 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 |
|
||||
| Voice input | Done (Sprint 20) |
|
||||
| TTS playback | Deferred |
|
||||
| Artifacts (HTML/SVG preview) | Deferred |
|
||||
| Code execution inline | Deferred |
|
||||
| Mobile-optimized layout | Sprint 21 |
|
||||
| Mobile-optimized layout | Done (Sprint 21) |
|
||||
| Sharing / public URLs | Not planned (requires server infra) |
|
||||
| Claude-specific features | Not replicable (Projects AI, artifacts sync) |
|
||||
|
||||
@@ -540,5 +663,5 @@ UX was a low-effort high-impact polish opportunity that pairs naturally.
|
||||
---
|
||||
|
||||
*Last updated: April 3, 2026*
|
||||
*Current version: v0.22 | 415 tests*
|
||||
*Next sprint: Sprint 21 (Mobile Responsive)*
|
||||
*Current version: v0.26 | 426 tests*
|
||||
*Next sprint: Sprint 24 (Desktop Application)*
|
||||
|
||||
47
TESTING.md
47
TESTING.md
@@ -1,14 +1,14 @@
|
||||
# Hermes Web UI: Browser Testing Plan
|
||||
|
||||
> This document is for manual browser testing by you or by a Claude browser agent.
|
||||
> It covers user-facing features of the UI through Sprint 19 (v0.21).
|
||||
> It covers user-facing features of the UI through Sprint 22 (v0.24).
|
||||
> 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).
|
||||
> Automated tests: 415 total (392 passing, 23 pre-existing failures).
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
@@ -1667,10 +1667,49 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
- API calls without auth cookie → 401 JSON response.
|
||||
- Check response headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`.
|
||||
|
||||
### Sprint 20: Voice Input + Send Button
|
||||
- Mic button visible in composer (Chrome/Edge). Hidden in Firefox.
|
||||
- Tap mic → button turns red with pulse, "Listening..." indicator appears.
|
||||
- Speak → live transcription appears in textarea.
|
||||
- Stop speaking → auto-stops after ~2s silence. Text stays editable.
|
||||
- Tap mic again or Send → stops recording, sends text.
|
||||
- Type text, then tap mic → spoken text appends to existing text (doesn't replace).
|
||||
- Send button hidden when textarea is empty. Appears with pop-in animation when typing.
|
||||
- Send button is icon-only circle (no "Send" text label). Blue with glow.
|
||||
- Attach a file with no text → send button appears.
|
||||
- Send message → button disappears after textarea clears.
|
||||
- While agent is responding → send button hidden.
|
||||
|
||||
### Sprint 21: Mobile Responsive + Docker
|
||||
- Open on mobile viewport (<640px): hamburger icon visible in topbar.
|
||||
- Tap hamburger → sidebar slides in from left with backdrop overlay.
|
||||
- Tap outside sidebar → closes. Tap a session → closes and loads session.
|
||||
- Bottom navigation bar: 5 tabs (Chat, Tasks, Skills, Memory, Spaces).
|
||||
- Tap "Tasks" in bottom nav → sidebar opens showing Tasks panel.
|
||||
- Tap "Chat" in bottom nav → sidebar closes (chat is in main area).
|
||||
- Files button in topbar → right panel slides in from right.
|
||||
- All touch targets are at least 44px (session items, buttons, icons).
|
||||
- Desktop viewport (>640px): no hamburger, no bottom nav, no mobile elements.
|
||||
- Docker: `docker compose up -d` starts server on port 8787.
|
||||
- Docker: session data persists across container restarts (named volume).
|
||||
|
||||
### Sprint 22: Multi-Profile Support
|
||||
- Profile chip in topbar (purple accent). Click → dropdown with all profiles.
|
||||
- Dropdown shows gateway status dots, model info, skill count per profile.
|
||||
- Click a profile → switches; model dropdown, skills, memory, cron refresh.
|
||||
- "Manage profiles" link opens Profiles sidebar panel.
|
||||
- Profiles panel: cards with name, model, provider, skill count, API key status.
|
||||
- "Use" button switches profile. Delete button removes non-default profiles.
|
||||
- "+ New profile" form: name validation (lowercase + hyphens), clone config checkbox.
|
||||
- Create profile → appears in list and dropdown.
|
||||
- Delete profile → confirm dialog. Auto-switches to default if deleting active.
|
||||
- Attempt switch while agent busy → blocked with toast message.
|
||||
- With hermes-agent not installed → only default profile shown, graceful fallback.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Sprint 19 / v0.21, April 3, 2026*
|
||||
*Total automated tests: 328 (328 passing, 0 failures)*
|
||||
*Last updated: Sprint 22 / v0.24, April 3, 2026*
|
||||
*Total automated tests: 415 (392 passing, 23 pre-existing failures)*
|
||||
*Regression gate: tests/test_regressions.py (23 tests)*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
@@ -134,17 +134,44 @@ if _AGENT_DIR is not None:
|
||||
else:
|
||||
_HERMES_FOUND = False
|
||||
|
||||
# ── Config file (optional YAML) ──────────────────────────────────────────────
|
||||
CONFIG_PATH = Path(os.getenv(
|
||||
'HERMES_CONFIG_PATH',
|
||||
str(HOME / '.hermes' / 'config.yaml')
|
||||
)).expanduser()
|
||||
# ── Config file (reloadable -- supports profile switching) ──────────────────
|
||||
_cfg_cache = {}
|
||||
_cfg_lock = threading.Lock()
|
||||
|
||||
try:
|
||||
import yaml as _yaml
|
||||
cfg = _yaml.safe_load(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
def _get_config_path() -> Path:
|
||||
"""Return config.yaml path for the active profile."""
|
||||
env_override = os.getenv('HERMES_CONFIG_PATH')
|
||||
if env_override:
|
||||
return Path(env_override).expanduser()
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
return get_active_hermes_home() / 'config.yaml'
|
||||
except ImportError:
|
||||
return HOME / '.hermes' / 'config.yaml'
|
||||
|
||||
def get_config() -> dict:
|
||||
"""Return the cached config dict, loading from disk if needed."""
|
||||
if not _cfg_cache:
|
||||
reload_config()
|
||||
return _cfg_cache
|
||||
|
||||
def reload_config():
|
||||
"""Reload config.yaml from the active profile's directory."""
|
||||
with _cfg_lock:
|
||||
_cfg_cache.clear()
|
||||
config_path = _get_config_path()
|
||||
try:
|
||||
import yaml as _yaml
|
||||
if config_path.exists():
|
||||
loaded = _yaml.safe_load(config_path.read_text())
|
||||
if isinstance(loaded, dict):
|
||||
_cfg_cache.update(loaded)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Initial load
|
||||
reload_config()
|
||||
cfg = _cfg_cache # alias for backward compat with existing references
|
||||
|
||||
# ── Default workspace discovery ───────────────────────────────────────────────
|
||||
def _discover_default_workspace() -> Path:
|
||||
@@ -183,7 +210,7 @@ def print_startup_config():
|
||||
f' state dir : {STATE_DIR}',
|
||||
f' workspace : {DEFAULT_WORKSPACE}',
|
||||
f' host:port : {HOST}:{PORT}',
|
||||
f' config file : {CONFIG_PATH} {"(found)" if CONFIG_PATH.exists() else "(not found, using defaults)"}',
|
||||
f' config file : {_get_config_path()} {"(found)" if _get_config_path().exists() else "(not found, using defaults)"}',
|
||||
'',
|
||||
]
|
||||
print('\n'.join(lines), flush=True)
|
||||
@@ -234,11 +261,12 @@ MIME_MAP = {
|
||||
}
|
||||
|
||||
# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
|
||||
CLI_TOOLSETS = cfg.get('platform_toolsets', {}).get('cli', [
|
||||
_DEFAULT_TOOLSETS = [
|
||||
'browser', 'clarify', 'code_execution', 'cronjob', 'delegation', 'file',
|
||||
'image_gen', 'memory', 'session_search', 'skills', 'terminal', 'todo',
|
||||
'web', 'webhook',
|
||||
])
|
||||
]
|
||||
CLI_TOOLSETS = get_config().get('platform_toolsets', {}).get('cli', _DEFAULT_TOOLSETS)
|
||||
|
||||
# ── Model / provider discovery ───────────────────────────────────────────────
|
||||
|
||||
@@ -345,15 +373,16 @@ def resolve_model_provider(model_id: str):
|
||||
|
||||
if '/' in model_id:
|
||||
prefix, bare = model_id.split('/', 1)
|
||||
# If prefix matches config provider, strip it and use that provider directly
|
||||
# If prefix matches config provider exactly, strip it and use that provider directly.
|
||||
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
|
||||
if config_provider and prefix == config_provider:
|
||||
return bare, config_provider, config_base_url
|
||||
# If the config provider is openrouter (or unset/None), pass the full
|
||||
# provider/model string through -- OpenRouter uses this as its model ID.
|
||||
# Only strip the prefix and switch to a direct-API provider when the
|
||||
# config is explicitly set to that direct provider.
|
||||
if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
|
||||
return bare, prefix, None
|
||||
# If prefix does NOT match config provider, the user picked a cross-provider model
|
||||
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
|
||||
# In this case always route through openrouter with the full provider/model string.
|
||||
# Never strip the prefix and try a direct-API call to a provider whose key may not exist.
|
||||
if prefix in _PROVIDER_MODELS and prefix != config_provider:
|
||||
return model_id, 'openrouter', None
|
||||
|
||||
return model_id, config_provider, config_base_url
|
||||
|
||||
@@ -396,7 +425,11 @@ def get_available_models() -> dict:
|
||||
|
||||
# 3. Try to read auth store for active provider (if hermes is installed)
|
||||
if not active_provider:
|
||||
auth_store_path = HOME / '.hermes' / 'auth.json'
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home as _gah
|
||||
auth_store_path = _gah() / 'auth.json'
|
||||
except ImportError:
|
||||
auth_store_path = HOME / '.hermes' / 'auth.json'
|
||||
if auth_store_path.exists():
|
||||
try:
|
||||
import json as _j
|
||||
@@ -406,7 +439,11 @@ def get_available_models() -> dict:
|
||||
pass
|
||||
|
||||
# 4. Check for API keys that imply available providers
|
||||
hermes_env_path = HOME / '.hermes' / '.env'
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home as _gah2
|
||||
hermes_env_path = _gah2() / '.env'
|
||||
except ImportError:
|
||||
hermes_env_path = HOME / '.hermes' / '.env'
|
||||
env_keys = {}
|
||||
if hermes_env_path.exists():
|
||||
try:
|
||||
@@ -655,3 +692,11 @@ if SETTINGS_FILE.exists():
|
||||
|
||||
# ── SESSIONS in-memory cache (LRU OrderedDict) ───────────────────────────────
|
||||
SESSIONS: collections.OrderedDict = collections.OrderedDict()
|
||||
|
||||
# ── Profile state initialisation ────────────────────────────────────────────
|
||||
# Must run after all imports are resolved to correctly patch module-level caches
|
||||
try:
|
||||
from api.profiles import init_profile_state
|
||||
init_profile_state()
|
||||
except ImportError:
|
||||
pass # hermes_cli not available -- default profile only
|
||||
|
||||
@@ -34,8 +34,8 @@ def _write_session_index():
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, **kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None
|
||||
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, profile=None, **kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None; self.profile = profile
|
||||
@property
|
||||
def path(self): return SESSION_DIR / f'{self.session_id}.json'
|
||||
def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index()
|
||||
@@ -44,7 +44,7 @@ class Session:
|
||||
p = SESSION_DIR / f'{sid}.json'
|
||||
if not p.exists(): return None
|
||||
return cls(**json.loads(p.read_text(encoding='utf-8')))
|
||||
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id}
|
||||
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id, 'profile': self.profile}
|
||||
|
||||
def get_session(sid):
|
||||
with LOCK:
|
||||
@@ -63,7 +63,12 @@ def get_session(sid):
|
||||
|
||||
def new_session(workspace=None, model=None):
|
||||
# Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect
|
||||
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL)
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
_profile = get_active_profile_name()
|
||||
except ImportError:
|
||||
_profile = None
|
||||
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL, profile=_profile)
|
||||
with LOCK:
|
||||
SESSIONS[s.session_id] = s
|
||||
SESSIONS.move_to_end(s.session_id)
|
||||
@@ -85,6 +90,11 @@ def all_sessions():
|
||||
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
|
||||
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
|
||||
result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)]
|
||||
# Backfill: sessions created before Sprint 22 have no profile tag.
|
||||
# Attribute them to 'default' so the client profile filter works correctly.
|
||||
for s in result:
|
||||
if not s.get('profile'):
|
||||
s['profile'] = 'default'
|
||||
return result
|
||||
except Exception:
|
||||
pass # fall through to full scan
|
||||
@@ -100,7 +110,11 @@ def all_sessions():
|
||||
for s in SESSIONS.values():
|
||||
if all(s.session_id != x.session_id for x in out): out.append(s)
|
||||
out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True)
|
||||
return [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
|
||||
result = [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
|
||||
for s in result:
|
||||
if not s.get('profile'):
|
||||
s['profile'] = 'default'
|
||||
return result
|
||||
|
||||
|
||||
def title_from(messages, fallback='Untitled'):
|
||||
|
||||
299
api/profiles.py
Normal file
299
api/profiles.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Hermes Web UI -- Profile state management.
|
||||
Wraps hermes_cli.profiles to provide profile switching for the web UI.
|
||||
|
||||
The web UI maintains a process-level "active profile" that determines which
|
||||
HERMES_HOME directory is used for config, skills, memory, cron, and API keys.
|
||||
Profile switches update os.environ['HERMES_HOME'] and monkey-patch module-level
|
||||
cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot
|
||||
HERMES_HOME at import time.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
# ── Module state ────────────────────────────────────────────────────────────
|
||||
_active_profile = 'default'
|
||||
_profile_lock = threading.Lock()
|
||||
|
||||
def _resolve_base_hermes_home() -> Path:
|
||||
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
|
||||
|
||||
This is intentionally distinct from HERMES_HOME, which tracks the *active
|
||||
profile's* home and changes on every profile switch. The base dir must
|
||||
always point to the top-level .hermes regardless of which profile is active.
|
||||
|
||||
Resolution order:
|
||||
1. HERMES_BASE_HOME env var (set explicitly, highest priority)
|
||||
2. HERMES_HOME env var — but only if it does NOT look like a profile subdir
|
||||
(i.e. its parent is not named 'profiles'). This handles test isolation
|
||||
where HERMES_HOME is set to an isolated test state dir.
|
||||
3. ~/.hermes (always-correct default)
|
||||
|
||||
The bug this prevents: if HERMES_HOME has already been mutated to
|
||||
/home/user/.hermes/profiles/webui (by init_profile_state at startup),
|
||||
reading it here would make _DEFAULT_HERMES_HOME point to that subdir,
|
||||
causing switch_profile('webui') to look for
|
||||
/home/user/.hermes/profiles/webui/profiles/webui — which doesn't exist.
|
||||
"""
|
||||
# Explicit override for tests or unusual setups
|
||||
base_override = os.getenv('HERMES_BASE_HOME', '').strip()
|
||||
if base_override:
|
||||
return Path(base_override).expanduser()
|
||||
|
||||
hermes_home = os.getenv('HERMES_HOME', '').strip()
|
||||
if hermes_home:
|
||||
p = Path(hermes_home).expanduser()
|
||||
# If HERMES_HOME points to a profiles/ subdir, walk up two levels to the base
|
||||
if p.parent.name == 'profiles':
|
||||
return p.parent.parent
|
||||
# Otherwise trust it (e.g. test isolation sets HERMES_HOME to TEST_STATE_DIR)
|
||||
return p
|
||||
|
||||
return Path.home() / '.hermes'
|
||||
|
||||
_DEFAULT_HERMES_HOME = _resolve_base_hermes_home()
|
||||
|
||||
|
||||
def _read_active_profile_file() -> str:
|
||||
"""Read the sticky active profile from ~/.hermes/active_profile."""
|
||||
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
|
||||
if ap_file.exists():
|
||||
try:
|
||||
name = ap_file.read_text().strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
return 'default'
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_active_profile_name() -> str:
|
||||
"""Return the currently active profile name."""
|
||||
return _active_profile
|
||||
|
||||
|
||||
def get_active_hermes_home() -> Path:
|
||||
"""Return the HERMES_HOME path for the currently active profile."""
|
||||
if _active_profile == 'default':
|
||||
return _DEFAULT_HERMES_HOME
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile
|
||||
if profile_dir.is_dir():
|
||||
return profile_dir
|
||||
return _DEFAULT_HERMES_HOME
|
||||
|
||||
|
||||
def _set_hermes_home(home: Path):
|
||||
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
|
||||
os.environ['HERMES_HOME'] = str(home)
|
||||
|
||||
# Patch skills_tool module-level cache (snapshots HERMES_HOME at import)
|
||||
try:
|
||||
import tools.skills_tool as _sk
|
||||
_sk.HERMES_HOME = home
|
||||
_sk.SKILLS_DIR = home / 'skills'
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
# Patch cron/jobs module-level cache
|
||||
try:
|
||||
import cron.jobs as _cj
|
||||
_cj.HERMES_DIR = home
|
||||
_cj.CRON_DIR = home / 'cron'
|
||||
_cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
|
||||
_cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def _reload_dotenv(home: Path):
|
||||
"""Load .env from the profile dir into os.environ (additive)."""
|
||||
env_path = home / '.env'
|
||||
if not env_path.exists():
|
||||
return
|
||||
try:
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
k, v = line.split('=', 1)
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k and v:
|
||||
os.environ[k] = v
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def init_profile_state():
|
||||
"""Initialize profile state at server startup.
|
||||
|
||||
Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches
|
||||
module-level cached paths. Called once from config.py after imports.
|
||||
"""
|
||||
global _active_profile
|
||||
_active_profile = _read_active_profile_file()
|
||||
home = get_active_hermes_home()
|
||||
_set_hermes_home(home)
|
||||
_reload_dotenv(home)
|
||||
|
||||
|
||||
def switch_profile(name: str) -> dict:
|
||||
"""Switch the active profile.
|
||||
|
||||
Validates the profile exists, updates process state, patches module caches,
|
||||
reloads .env, and reloads config.yaml.
|
||||
|
||||
Returns: {'profiles': [...], 'active': name}
|
||||
Raises ValueError if profile doesn't exist or agent is busy.
|
||||
"""
|
||||
global _active_profile
|
||||
|
||||
# Import here to avoid circular import at module load
|
||||
from api.config import STREAMS, STREAMS_LOCK, reload_config
|
||||
|
||||
# Block if agent is running
|
||||
with STREAMS_LOCK:
|
||||
if len(STREAMS) > 0:
|
||||
raise RuntimeError(
|
||||
'Cannot switch profiles while an agent is running. '
|
||||
'Cancel or wait for it to finish.'
|
||||
)
|
||||
|
||||
# Resolve profile directory
|
||||
if name == 'default':
|
||||
home = _DEFAULT_HERMES_HOME
|
||||
else:
|
||||
home = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
if not home.is_dir():
|
||||
raise ValueError(f"Profile '{name}' does not exist.")
|
||||
|
||||
with _profile_lock:
|
||||
_active_profile = name
|
||||
_set_hermes_home(home)
|
||||
_reload_dotenv(home)
|
||||
|
||||
# Write sticky default for CLI consistency
|
||||
try:
|
||||
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
|
||||
ap_file.write_text(name if name != 'default' else '')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reload config.yaml from the new profile
|
||||
reload_config()
|
||||
|
||||
# Return profile-specific defaults so frontend can apply them
|
||||
from api.workspace import get_last_workspace
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
model_cfg = cfg.get('model', {})
|
||||
default_model = None
|
||||
if isinstance(model_cfg, str):
|
||||
default_model = model_cfg
|
||||
elif isinstance(model_cfg, dict):
|
||||
default_model = model_cfg.get('default')
|
||||
|
||||
return {
|
||||
'profiles': list_profiles_api(),
|
||||
'active': name,
|
||||
'default_model': default_model,
|
||||
'default_workspace': get_last_workspace(),
|
||||
}
|
||||
|
||||
|
||||
def list_profiles_api() -> list:
|
||||
"""List all profiles with metadata, serialized for JSON response."""
|
||||
try:
|
||||
from hermes_cli.profiles import list_profiles
|
||||
infos = list_profiles()
|
||||
except ImportError:
|
||||
# hermes_cli not available -- return just the default
|
||||
return [_default_profile_dict()]
|
||||
|
||||
active = _active_profile
|
||||
result = []
|
||||
for p in infos:
|
||||
result.append({
|
||||
'name': p.name,
|
||||
'path': str(p.path),
|
||||
'is_default': p.is_default,
|
||||
'is_active': p.name == active,
|
||||
'gateway_running': p.gateway_running,
|
||||
'model': p.model,
|
||||
'provider': p.provider,
|
||||
'has_env': p.has_env,
|
||||
'skill_count': p.skill_count,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _default_profile_dict() -> dict:
|
||||
"""Fallback profile dict when hermes_cli is not importable."""
|
||||
return {
|
||||
'name': 'default',
|
||||
'path': str(_DEFAULT_HERMES_HOME),
|
||||
'is_default': True,
|
||||
'is_active': True,
|
||||
'gateway_running': False,
|
||||
'model': None,
|
||||
'provider': None,
|
||||
'has_env': (_DEFAULT_HERMES_HOME / '.env').exists(),
|
||||
'skill_count': 0,
|
||||
}
|
||||
|
||||
|
||||
def create_profile_api(name: str, clone_from: str = None,
|
||||
clone_config: bool = False) -> dict:
|
||||
"""Create a new profile. Returns the new profile info dict."""
|
||||
try:
|
||||
from hermes_cli.profiles import create_profile, validate_profile_name
|
||||
except ImportError:
|
||||
raise RuntimeError('Profile management requires hermes-agent to be installed.')
|
||||
|
||||
validate_profile_name(name)
|
||||
create_profile(
|
||||
name,
|
||||
clone_from=clone_from,
|
||||
clone_config=clone_config,
|
||||
clone_all=False,
|
||||
no_alias=True,
|
||||
)
|
||||
|
||||
# Find and return the newly created profile info
|
||||
for p in list_profiles_api():
|
||||
if p['name'] == name:
|
||||
return p
|
||||
return {'name': name, 'path': str(_DEFAULT_HERMES_HOME / 'profiles' / name)}
|
||||
|
||||
|
||||
def delete_profile_api(name: str) -> dict:
|
||||
"""Delete a profile. Switches to default first if it's the active one."""
|
||||
if name == 'default':
|
||||
raise ValueError("Cannot delete the default profile.")
|
||||
|
||||
# If deleting the active profile, switch to default first
|
||||
if _active_profile == name:
|
||||
try:
|
||||
switch_profile('default')
|
||||
except RuntimeError:
|
||||
raise RuntimeError(
|
||||
f"Cannot delete active profile '{name}' while an agent is running. "
|
||||
"Cancel or wait for it to finish."
|
||||
)
|
||||
|
||||
try:
|
||||
from hermes_cli.profiles import delete_profile
|
||||
delete_profile(name, yes=True)
|
||||
except ImportError:
|
||||
# Manual fallback: just remove the directory
|
||||
import shutil
|
||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||
if profile_dir.is_dir():
|
||||
shutil.rmtree(str(profile_dir))
|
||||
else:
|
||||
raise ValueError(f"Profile '{name}' does not exist.")
|
||||
|
||||
return {'ok': True, 'name': name}
|
||||
@@ -234,6 +234,15 @@ def handle_get(handler, parsed):
|
||||
if parsed.path == '/api/memory':
|
||||
return _handle_memory_read(handler)
|
||||
|
||||
# ── Profile API (GET) ──
|
||||
if parsed.path == '/api/profiles':
|
||||
from api.profiles import list_profiles_api, get_active_profile_name
|
||||
return j(handler, {'profiles': list_profiles_api(), 'active': get_active_profile_name()})
|
||||
|
||||
if parsed.path == '/api/profile/active':
|
||||
from api.profiles import get_active_profile_name, get_active_hermes_home
|
||||
return j(handler, {'name': get_active_profile_name(), 'path': str(get_active_hermes_home())})
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
@@ -372,6 +381,53 @@ def handle_post(handler, parsed):
|
||||
if parsed.path == '/api/memory/write':
|
||||
return _handle_memory_write(handler, body)
|
||||
|
||||
# ── Profile API (POST) ──
|
||||
if parsed.path == '/api/profile/switch':
|
||||
name = body.get('name', '').strip()
|
||||
if not name: return bad(handler, 'name is required')
|
||||
try:
|
||||
from api.profiles import switch_profile
|
||||
result = switch_profile(name)
|
||||
return j(handler, result)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
return bad(handler, str(e), 404)
|
||||
except RuntimeError as e:
|
||||
return bad(handler, str(e), 409)
|
||||
|
||||
if parsed.path == '/api/profile/create':
|
||||
name = body.get('name', '').strip()
|
||||
if not name: return bad(handler, 'name is required')
|
||||
import re as _re
|
||||
if not _re.match(r'^[a-z0-9][a-z0-9_-]{0,63}$', name):
|
||||
return bad(handler, 'Invalid profile name: lowercase letters, numbers, hyphens, underscores only')
|
||||
clone_from = body.get('clone_from')
|
||||
if clone_from is not None:
|
||||
clone_from = str(clone_from).strip()
|
||||
if not _re.match(r'^[a-z0-9][a-z0-9_-]{0,63}$', clone_from):
|
||||
return bad(handler, 'Invalid clone_from name')
|
||||
try:
|
||||
from api.profiles import create_profile_api
|
||||
result = create_profile_api(
|
||||
name,
|
||||
clone_from=clone_from,
|
||||
clone_config=bool(body.get('clone_config', False)),
|
||||
)
|
||||
return j(handler, {'ok': True, 'profile': result})
|
||||
except (ValueError, FileExistsError, RuntimeError) as e:
|
||||
return bad(handler, str(e))
|
||||
|
||||
if parsed.path == '/api/profile/delete':
|
||||
name = body.get('name', '').strip()
|
||||
if not name: return bad(handler, 'name is required')
|
||||
try:
|
||||
from api.profiles import delete_profile_api
|
||||
result = delete_profile_api(name)
|
||||
return j(handler, result)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
return bad(handler, str(e))
|
||||
except RuntimeError as e:
|
||||
return bad(handler, str(e), 409)
|
||||
|
||||
# ── Settings (POST) ──
|
||||
if parsed.path == '/api/settings':
|
||||
saved = save_settings(body)
|
||||
@@ -731,7 +787,11 @@ def _handle_cron_recent(handler, parsed):
|
||||
|
||||
|
||||
def _handle_memory_read(handler):
|
||||
mem_dir = Path.home() / '.hermes' / 'memories'
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
mem_dir = get_active_hermes_home() / 'memories'
|
||||
except ImportError:
|
||||
mem_dir = Path.home() / '.hermes' / 'memories'
|
||||
mem_file = mem_dir / 'MEMORY.md'
|
||||
user_file = mem_dir / 'USER.md'
|
||||
memory = mem_file.read_text(encoding='utf-8', errors='replace') if mem_file.exists() else ''
|
||||
@@ -1078,7 +1138,11 @@ def _handle_skill_delete(handler, body):
|
||||
def _handle_memory_write(handler, body):
|
||||
try: require(body, 'section', 'content')
|
||||
except ValueError as e: return bad(handler, str(e))
|
||||
mem_dir = Path.home() / '.hermes' / 'memories'
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
mem_dir = get_active_hermes_home() / 'memories'
|
||||
except ImportError:
|
||||
mem_dir = Path.home() / '.hermes' / 'memories'
|
||||
mem_dir.mkdir(parents=True, exist_ok=True)
|
||||
section = body['section']
|
||||
if section == 'memory':
|
||||
|
||||
@@ -64,19 +64,30 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
put('cancel', {'message': 'Cancelled before start'})
|
||||
return
|
||||
|
||||
# Resolve profile home for this agent run (snapshot at start)
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
_profile_home = str(get_active_hermes_home())
|
||||
except ImportError:
|
||||
_profile_home = os.environ.get('HERMES_HOME', '')
|
||||
|
||||
_set_thread_env(
|
||||
TERMINAL_CWD=str(s.workspace),
|
||||
HERMES_EXEC_ASK='1',
|
||||
HERMES_SESSION_KEY=session_id,
|
||||
HERMES_HOME=_profile_home,
|
||||
)
|
||||
# Still set process-level env as fallback for tools that bypass thread-local
|
||||
with _agent_lock:
|
||||
old_cwd = os.environ.get('TERMINAL_CWD')
|
||||
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
|
||||
old_session_key = os.environ.get('HERMES_SESSION_KEY')
|
||||
old_hermes_home = os.environ.get('HERMES_HOME')
|
||||
os.environ['TERMINAL_CWD'] = str(s.workspace)
|
||||
os.environ['HERMES_EXEC_ASK'] = '1'
|
||||
os.environ['HERMES_SESSION_KEY'] = session_id
|
||||
if _profile_home:
|
||||
os.environ['HERMES_HOME'] = _profile_home
|
||||
|
||||
try:
|
||||
def on_token(text):
|
||||
@@ -101,13 +112,38 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
if AIAgent is None:
|
||||
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
|
||||
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model)
|
||||
|
||||
# Read per-profile config at call time (not module-level snapshot)
|
||||
from api.config import get_config as _get_config
|
||||
_cfg = _get_config()
|
||||
|
||||
# Per-profile toolsets (fall back to module-level CLI_TOOLSETS)
|
||||
_pt = _cfg.get('platform_toolsets', {})
|
||||
_toolsets = _pt.get('cli', CLI_TOOLSETS) if isinstance(_pt, dict) else CLI_TOOLSETS
|
||||
|
||||
# Fallback model from profile config (e.g. for rate-limit recovery)
|
||||
_fallback = _cfg.get('fallback_model') or None
|
||||
if _fallback:
|
||||
# Resolve the fallback through our provider logic too
|
||||
fb_model = _fallback.get('model', '')
|
||||
fb_provider = _fallback.get('provider', '')
|
||||
fb_base_url = _fallback.get('base_url')
|
||||
_fallback_resolved = {
|
||||
'model': fb_model,
|
||||
'provider': fb_provider,
|
||||
'base_url': fb_base_url,
|
||||
}
|
||||
else:
|
||||
_fallback_resolved = None
|
||||
|
||||
agent = AIAgent(
|
||||
model=resolved_model,
|
||||
provider=resolved_provider,
|
||||
base_url=resolved_base_url,
|
||||
platform='cli',
|
||||
quiet_mode=True,
|
||||
enabled_toolsets=CLI_TOOLSETS,
|
||||
enabled_toolsets=_toolsets,
|
||||
fallback_model=_fallback_resolved,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=on_token,
|
||||
tool_progress_callback=on_tool,
|
||||
@@ -187,10 +223,23 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
|
||||
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
|
||||
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
|
||||
if old_hermes_home is None: os.environ.pop('HERMES_HOME', None)
|
||||
else: os.environ['HERMES_HOME'] = old_hermes_home
|
||||
|
||||
except Exception as e:
|
||||
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
|
||||
put('error', {'message': str(e)})
|
||||
err_str = str(e)
|
||||
# Detect rate limit errors specifically so the client can show a helpful card
|
||||
# rather than the generic "Connection lost" message
|
||||
is_rate_limit = 'rate limit' in err_str.lower() or '429' in err_str or 'RateLimitError' in type(e).__name__
|
||||
if is_rate_limit:
|
||||
put('apperror', {
|
||||
'message': err_str,
|
||||
'type': 'rate_limit',
|
||||
'hint': 'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.',
|
||||
})
|
||||
else:
|
||||
put('apperror', {'message': err_str, 'type': 'error'})
|
||||
finally:
|
||||
_clear_thread_env() # TD1: always clear thread-local context
|
||||
with STREAMS_LOCK:
|
||||
|
||||
188
api/workspace.py
188
api/workspace.py
@@ -1,43 +1,211 @@
|
||||
"""
|
||||
Hermes Web UI -- Workspace and file system helpers.
|
||||
|
||||
Workspace lists and last-used workspace are stored per-profile so each
|
||||
profile has its own workspace configuration. State files live at
|
||||
``{profile_home}/webui_state/workspaces.json`` and
|
||||
``{profile_home}/webui_state/last_workspace.txt``. The global STATE_DIR
|
||||
paths are used as fallback when no profile module is available.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import (
|
||||
WORKSPACES_FILE, LAST_WORKSPACE_FILE, DEFAULT_WORKSPACE,
|
||||
WORKSPACES_FILE as _GLOBAL_WS_FILE,
|
||||
LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
|
||||
DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
|
||||
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
|
||||
)
|
||||
|
||||
|
||||
def load_workspaces() -> list:
|
||||
if WORKSPACES_FILE.exists():
|
||||
# ── Profile-aware path resolution ───────────────────────────────────────────
|
||||
|
||||
def _profile_state_dir() -> Path:
|
||||
"""Return the webui_state directory for the active profile.
|
||||
|
||||
For the default profile, returns the global STATE_DIR (respects
|
||||
HERMES_WEBUI_STATE_DIR env var for test isolation).
|
||||
For named profiles, returns {profile_home}/webui_state/.
|
||||
"""
|
||||
try:
|
||||
from api.profiles import get_active_profile_name, get_active_hermes_home
|
||||
name = get_active_profile_name()
|
||||
if name and name != 'default':
|
||||
d = get_active_hermes_home() / 'webui_state'
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
except ImportError:
|
||||
pass
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
|
||||
|
||||
def _workspaces_file() -> Path:
|
||||
"""Return the workspaces.json path for the active profile."""
|
||||
return _profile_state_dir() / 'workspaces.json'
|
||||
|
||||
|
||||
def _last_workspace_file() -> Path:
|
||||
"""Return the last_workspace.txt path for the active profile."""
|
||||
return _profile_state_dir() / 'last_workspace.txt'
|
||||
|
||||
|
||||
def _profile_default_workspace() -> str:
|
||||
"""Read the profile's default workspace from its config.yaml.
|
||||
|
||||
Checks keys in priority order:
|
||||
1. 'workspace' — explicit webui workspace key
|
||||
2. 'default_workspace' — alternate explicit key
|
||||
3. 'terminal.cwd' — hermes-agent terminal working dir (most common)
|
||||
|
||||
Falls back to the boot-time DEFAULT_WORKSPACE constant.
|
||||
"""
|
||||
try:
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
# Explicit webui workspace keys first
|
||||
for key in ('workspace', 'default_workspace'):
|
||||
ws = cfg.get(key)
|
||||
if ws:
|
||||
p = Path(str(ws)).expanduser().resolve()
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
# Fall through to terminal.cwd — the agent's configured working directory
|
||||
terminal_cfg = cfg.get('terminal', {})
|
||||
if isinstance(terminal_cfg, dict):
|
||||
cwd = terminal_cfg.get('cwd', '')
|
||||
if cwd and str(cwd) not in ('.', ''):
|
||||
p = Path(str(cwd)).expanduser().resolve()
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
return str(_BOOT_DEFAULT_WORKSPACE)
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _clean_workspace_list(workspaces: list) -> list:
|
||||
"""Sanitize a workspace list:
|
||||
- Remove entries whose paths no longer exist on disk.
|
||||
- Remove entries that look like test artifacts (webui-mvp-test, test-workspace).
|
||||
- Remove entries whose paths live inside another profile's directory
|
||||
(e.g. ~/.hermes/profiles/X/... should not appear on a different profile).
|
||||
- Rename any entry whose name is literally 'default' to 'Home' (avoids
|
||||
confusion with the 'default' profile name).
|
||||
Returns the cleaned list (may be empty).
|
||||
"""
|
||||
hermes_profiles = (Path.home() / '.hermes' / 'profiles').resolve()
|
||||
result = []
|
||||
for w in workspaces:
|
||||
path = w.get('path', '')
|
||||
name = w.get('name', '')
|
||||
p = Path(path).resolve() if path else Path('/')
|
||||
# Skip test artifacts
|
||||
if 'test-workspace' in path or 'webui-mvp-test' in path:
|
||||
continue
|
||||
# Skip paths that no longer exist
|
||||
if not p.is_dir():
|
||||
continue
|
||||
# Skip paths inside a named profile's directory (cross-profile leak)
|
||||
try:
|
||||
return json.loads(WORKSPACES_FILE.read_text(encoding='utf-8'))
|
||||
p.relative_to(hermes_profiles)
|
||||
continue # it IS under profiles/ — remove it
|
||||
except ValueError:
|
||||
pass
|
||||
# Rename confusing 'default' label to 'Home'
|
||||
if name.lower() == 'default':
|
||||
name = 'Home'
|
||||
result.append({'path': str(p), 'name': name})
|
||||
return result
|
||||
|
||||
|
||||
def _migrate_global_workspaces() -> list:
|
||||
"""Read the legacy global workspaces.json, clean it, and return the result.
|
||||
|
||||
This is the migration path for users upgrading from a pre-profile version:
|
||||
their global file may contain cross-profile entries, test artifacts, and
|
||||
stale paths accumulated over time. We clean it in-place and rewrite it.
|
||||
"""
|
||||
if not _GLOBAL_WS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
|
||||
cleaned = _clean_workspace_list(raw)
|
||||
if len(cleaned) != len(raw):
|
||||
# Rewrite the cleaned version so future reads are already clean
|
||||
_GLOBAL_WS_FILE.write_text(
|
||||
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
return cleaned
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def load_workspaces() -> list:
|
||||
ws_file = _workspaces_file()
|
||||
if ws_file.exists():
|
||||
try:
|
||||
raw = json.loads(ws_file.read_text(encoding='utf-8'))
|
||||
cleaned = _clean_workspace_list(raw)
|
||||
if len(cleaned) != len(raw):
|
||||
# Persist the cleaned version so stale entries don't keep reappearing
|
||||
try:
|
||||
ws_file.write_text(
|
||||
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
except Exception:
|
||||
pass
|
||||
return [{'path': str(DEFAULT_WORKSPACE), 'name': 'default'}]
|
||||
# No profile-local file yet.
|
||||
# For the DEFAULT profile: migrate from the legacy global file (one-time cleanup).
|
||||
# For NAMED profiles: always start clean with just their own workspace.
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
is_default = get_active_profile_name() in ('default', None)
|
||||
except ImportError:
|
||||
is_default = True
|
||||
if is_default:
|
||||
migrated = _migrate_global_workspaces()
|
||||
if migrated:
|
||||
return migrated
|
||||
# Fresh start: single entry from the profile's configured workspace, labeled "Home"
|
||||
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
|
||||
|
||||
def save_workspaces(workspaces: list):
|
||||
WORKSPACES_FILE.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
ws_file = _workspaces_file()
|
||||
ws_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def get_last_workspace() -> str:
|
||||
if LAST_WORKSPACE_FILE.exists():
|
||||
lw_file = _last_workspace_file()
|
||||
if lw_file.exists():
|
||||
try:
|
||||
p = LAST_WORKSPACE_FILE.read_text(encoding='utf-8').strip()
|
||||
p = lw_file.read_text(encoding='utf-8').strip()
|
||||
if p and Path(p).is_dir():
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
return str(DEFAULT_WORKSPACE)
|
||||
# Fallback: try global file
|
||||
if _GLOBAL_LW_FILE.exists():
|
||||
try:
|
||||
p = _GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()
|
||||
if p and Path(p).is_dir():
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
return _profile_default_workspace()
|
||||
|
||||
|
||||
def set_last_workspace(path: str):
|
||||
try:
|
||||
LAST_WORKSPACE_FILE.write_text(str(path), encoding='utf-8')
|
||||
lw_file = _last_workspace_file()
|
||||
lw_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
lw_file.write_text(str(path), encoding='utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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,48 @@ async function cancelStream(){
|
||||
}catch(e){setStatus('Cancel failed: '+e.message);}
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
|
||||
@@ -267,6 +309,11 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
(async()=>{
|
||||
// Load send key preference
|
||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';}
|
||||
// Fetch active profile
|
||||
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
||||
// Update profile chip label immediately
|
||||
const profileLabel=$('profileChipLabel');
|
||||
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
|
||||
// Fetch available models from server and populate dropdown dynamically
|
||||
await populateModelDropdown();
|
||||
// Restore last-used model preference
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
<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.22</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.26</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills">🧩</button>
|
||||
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory">🧠</button>
|
||||
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces">📁</button>
|
||||
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
|
||||
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list">✅</button>
|
||||
</div>
|
||||
<!-- Chat panel -->
|
||||
@@ -104,6 +105,26 @@
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)">Add and switch workspaces for your sessions.</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Profiles panel -->
|
||||
<div class="panel-view" id="panelProfiles">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="font-size:11px;color:var(--muted)">Agent profiles</div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ New profile</button>
|
||||
</div>
|
||||
<!-- Profile create form (hidden by default) -->
|
||||
<div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
<input id="profileFormName" placeholder="Profile name (lowercase, a-z 0-9 hyphens)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);margin-bottom:8px;cursor:pointer">
|
||||
<input type="checkbox" id="profileFormClone" style="accent-color:var(--accent)"> Clone config from active profile
|
||||
</label>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitProfileCreate()">Create</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleProfileForm()">Cancel</button>
|
||||
</div>
|
||||
<div id="profileFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="profilesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
</div>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="field-label" style="font-size:10px;letter-spacing:.07em;margin-bottom:4px">MODEL</div>
|
||||
<select id="modelSelect">
|
||||
@@ -124,13 +145,16 @@
|
||||
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
|
||||
<span style="font-size:14px;opacity:.7">📁</span>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
|
||||
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
|
||||
<div style="position:relative">
|
||||
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
|
||||
<span style="font-size:14px;opacity:.7">📁</span>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
|
||||
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
|
||||
</div>
|
||||
<span style="font-size:10px;color:var(--muted);flex-shrink:0">▾</span>
|
||||
</div>
|
||||
<span style="font-size:10px;color:var(--muted);flex-shrink:0">▾</span>
|
||||
<div class="ws-dropdown" id="wsDropdown"></div>
|
||||
</div>
|
||||
<div class="sidebar-actions">
|
||||
<button class="sm-btn" id="btnDownload" title="Download as Markdown">↓ Transcript</button>
|
||||
@@ -143,15 +167,20 @@
|
||||
</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>
|
||||
<div id="wsChipWrap" style="position:relative">
|
||||
<div class="chip ws-chip" id="wsChip" onclick="toggleWsDropdown()" title="Switch workspace" style="cursor:pointer">📁 test-workspace ▾</div>
|
||||
<div class="ws-dropdown" id="wsDropdown"></div>
|
||||
<div id="profileChipWrap" style="position:relative">
|
||||
<div class="chip profile-chip" id="profileChip" onclick="toggleProfileDropdown()" title="Switch profile" style="cursor:pointer"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span id="profileChipLabel">default</span> ▾</div>
|
||||
<div class="profile-dropdown" id="profileDropdown"></div>
|
||||
</div>
|
||||
<div class="chip model" id="modelChip">GPT-5.4 Mini</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">
|
||||
@@ -301,6 +330,29 @@
|
||||
</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>
|
||||
|
||||
@@ -162,6 +162,46 @@ async function send(){
|
||||
renderSessionList();setBusy(false);setStatus('');
|
||||
});
|
||||
|
||||
source.addEventListener('apperror',e=>{
|
||||
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
|
||||
// This is distinct from the SSE network 'error' event below.
|
||||
source.close();
|
||||
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
const isRateLimit=d.type==='rate_limit';
|
||||
const icon=isRateLimit?'⏱️':'⚠️';
|
||||
const label=isRateLimit?'Rate limit reached':'Error';
|
||||
const hint=d.hint?`\n\n*${d.hint}*`:'';
|
||||
S.messages.push({role:'assistant',content:`**${icon} ${label}:** ${d.message}${hint}`});
|
||||
}catch(_){
|
||||
S.messages.push({role:'assistant',content:'**⚠️ Error:** An error occurred. Check server logs.'});
|
||||
}
|
||||
renderMessages();
|
||||
}else if(typeof trackBackgroundError==='function'){
|
||||
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
|
||||
try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');}
|
||||
catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
|
||||
}
|
||||
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('');}
|
||||
});
|
||||
|
||||
source.addEventListener('warning',e=>{
|
||||
// Non-fatal warning from server (e.g. fallback activated, retrying)
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
// Show as a small inline notice, not a full error
|
||||
setStatus(`⚠️ ${d.message||'Warning'}`);
|
||||
// If it's a fallback notice, show it briefly then clear
|
||||
if(d.type==='fallback') setTimeout(()=>setStatus(''),4000);
|
||||
}catch(_){}
|
||||
});
|
||||
|
||||
source.addEventListener('error',e=>{
|
||||
source.close();
|
||||
// Attempt one reconnect if the stream is still active server-side
|
||||
|
||||
208
static/panels.js
208
static/panels.js
@@ -14,6 +14,7 @@ async function switchPanel(name) {
|
||||
if (name === 'skills') await loadSkills();
|
||||
if (name === 'memory') await loadMemory();
|
||||
if (name === 'workspaces') await loadWorkspacesPanel();
|
||||
if (name === 'profiles') await loadProfilesPanel();
|
||||
if (name === 'todos') loadTodos();
|
||||
}
|
||||
|
||||
@@ -476,6 +477,7 @@ function toggleWsDropdown(){
|
||||
const open=dd.classList.contains('open');
|
||||
if(open){closeWsDropdown();}
|
||||
else{
|
||||
closeProfileDropdown(); // close profile dropdown if open
|
||||
loadWorkspaceList().then(data=>{
|
||||
renderWorkspaceDropdown(data.workspaces, S.session?S.session.workspace:'');
|
||||
dd.classList.add('open');
|
||||
@@ -488,7 +490,7 @@ function closeWsDropdown(){
|
||||
if(dd)dd.classList.remove('open');
|
||||
}
|
||||
document.addEventListener('click',e=>{
|
||||
if(!e.target.closest('#wsChipWrap'))closeWsDropdown();
|
||||
if(!e.target.closest('#sidebarWsDisplay') && !e.target.closest('#wsDropdown'))closeWsDropdown();
|
||||
});
|
||||
|
||||
async function loadWorkspacesPanel(){
|
||||
@@ -561,6 +563,210 @@ async function switchToWorkspace(path,name){
|
||||
}catch(e){setStatus('Switch failed: '+e.message);}
|
||||
}
|
||||
|
||||
// ── Profile panel + dropdown ──
|
||||
let _profilesCache = null;
|
||||
|
||||
async function loadProfilesPanel() {
|
||||
const panel = $('profilesPanel');
|
||||
if (!panel) return;
|
||||
try {
|
||||
const data = await api('/api/profiles');
|
||||
_profilesCache = data;
|
||||
panel.innerHTML = '';
|
||||
if (!data.profiles || !data.profiles.length) {
|
||||
panel.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No profiles found.</div>';
|
||||
return;
|
||||
}
|
||||
for (const p of data.profiles) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'profile-card';
|
||||
const meta = [];
|
||||
if (p.model) meta.push(p.model.split('/').pop());
|
||||
if (p.provider) meta.push(p.provider);
|
||||
if (p.skill_count) meta.push(p.skill_count + ' skill' + (p.skill_count !== 1 ? 's' : ''));
|
||||
if (p.has_env) meta.push('API keys configured');
|
||||
const gwDot = p.gateway_running
|
||||
? '<span class="profile-opt-badge running" title="Gateway running"></span>'
|
||||
: '<span class="profile-opt-badge stopped" title="Gateway stopped"></span>';
|
||||
const isActive = p.name === data.active;
|
||||
const activeBadge = isActive ? '<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">ACTIVE</span>' : '';
|
||||
card.innerHTML = `
|
||||
<div class="profile-card-header">
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5">(default)</span>' : ''}${activeBadge}</div>
|
||||
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : '<div class="profile-card-meta">No configuration</div>'}
|
||||
</div>
|
||||
<div class="profile-card-actions">
|
||||
${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="Switch to this profile">Use</button>` : ''}
|
||||
${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">✕</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
panel.appendChild(card);
|
||||
}
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">Error: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfileDropdown(data) {
|
||||
const dd = $('profileDropdown');
|
||||
if (!dd) return;
|
||||
dd.innerHTML = '';
|
||||
const profiles = data.profiles || [];
|
||||
const active = data.active || 'default';
|
||||
for (const p of profiles) {
|
||||
const opt = document.createElement('div');
|
||||
opt.className = 'profile-opt' + (p.name === active ? ' active' : '');
|
||||
const meta = [];
|
||||
if (p.model) meta.push(p.model.split('/').pop());
|
||||
if (p.skill_count) meta.push(p.skill_count + ' skills');
|
||||
const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
|
||||
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
|
||||
opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5;font-weight:400">(default)</span>' : ''}${checkmark}</div>` +
|
||||
(meta.length ? `<div class="profile-opt-meta">${esc(meta.join(' \u00b7 '))}</div>` : '');
|
||||
opt.onclick = async () => {
|
||||
closeProfileDropdown();
|
||||
if (p.name === active) return;
|
||||
await switchToProfile(p.name);
|
||||
};
|
||||
dd.appendChild(opt);
|
||||
}
|
||||
// Divider + Manage link
|
||||
const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div);
|
||||
const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage';
|
||||
mgmt.innerHTML = '⚙ Manage profiles';
|
||||
mgmt.onclick = () => { closeProfileDropdown(); switchPanel('profiles'); };
|
||||
dd.appendChild(mgmt);
|
||||
}
|
||||
|
||||
function toggleProfileDropdown() {
|
||||
const dd = $('profileDropdown');
|
||||
if (!dd) return;
|
||||
if (dd.classList.contains('open')) { closeProfileDropdown(); return; }
|
||||
closeWsDropdown(); // close workspace dropdown if open
|
||||
api('/api/profiles').then(data => {
|
||||
renderProfileDropdown(data);
|
||||
dd.classList.add('open');
|
||||
}).catch(e => { showToast('Failed to load profiles'); });
|
||||
}
|
||||
|
||||
function closeProfileDropdown() {
|
||||
const dd = $('profileDropdown');
|
||||
if (dd) dd.classList.remove('open');
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('#profileChipWrap')) closeProfileDropdown();
|
||||
});
|
||||
|
||||
async function switchToProfile(name) {
|
||||
if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; }
|
||||
|
||||
// Determine whether the current session has any messages.
|
||||
// A session with messages is "in progress" and belongs to the current profile —
|
||||
// we must not retag it. We'll start a fresh session for the new profile instead.
|
||||
const sessionInProgress = S.session && S.messages && S.messages.length > 0;
|
||||
|
||||
try {
|
||||
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
|
||||
S.activeProfile = data.active || name;
|
||||
|
||||
// ── Model ──────────────────────────────────────────────────────────────
|
||||
localStorage.removeItem('hermes-webui-model');
|
||||
_skillsData = null;
|
||||
await populateModelDropdown();
|
||||
if (data.default_model) {
|
||||
const sel = $('modelSelect');
|
||||
const resolved = _applyModelToDropdown(data.default_model, sel);
|
||||
const modelToUse = resolved || data.default_model;
|
||||
S._pendingProfileModel = modelToUse;
|
||||
// Only patch the in-memory session model if we're NOT about to replace the session
|
||||
if (S.session && !sessionInProgress) {
|
||||
S.session.model = modelToUse;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace ──────────────────────────────────────────────────────────
|
||||
_workspaceList = null;
|
||||
await loadWorkspaceList();
|
||||
if (data.default_workspace) {
|
||||
// Always store the profile default for new sessions
|
||||
S._profileDefaultWorkspace = data.default_workspace;
|
||||
|
||||
if (S.session && !sessionInProgress) {
|
||||
// Empty session (no messages yet) — safe to update it in place
|
||||
try {
|
||||
await api('/api/session/update', { method: 'POST', body: JSON.stringify({
|
||||
session_id: S.session.session_id,
|
||||
workspace: data.default_workspace,
|
||||
model: S.session.model,
|
||||
})});
|
||||
S.session.workspace = data.default_workspace;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session ────────────────────────────────────────────────────────────
|
||||
_showAllProfiles = false;
|
||||
|
||||
if (sessionInProgress) {
|
||||
// The current session has messages and belongs to the previous profile.
|
||||
// Start a new session for the new profile so nothing gets cross-tagged.
|
||||
await newSession(false);
|
||||
await renderSessionList();
|
||||
showToast('Switched to profile: ' + name + ' — new conversation started');
|
||||
} else {
|
||||
// No messages yet — just refresh the list and topbar in place
|
||||
await renderSessionList();
|
||||
syncTopbar();
|
||||
showToast('Switched to profile: ' + name);
|
||||
}
|
||||
|
||||
// ── Sidebar panels ─────────────────────────────────────────────────────
|
||||
if (_currentPanel === 'skills') await loadSkills();
|
||||
if (_currentPanel === 'memory') await loadMemory();
|
||||
if (_currentPanel === 'tasks') await loadCrons();
|
||||
if (_currentPanel === 'profiles') await loadProfilesPanel();
|
||||
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
|
||||
|
||||
} catch (e) { showToast('Switch failed: ' + e.message); }
|
||||
}
|
||||
|
||||
function toggleProfileForm() {
|
||||
const form = $('profileCreateForm');
|
||||
if (!form) return;
|
||||
form.style.display = form.style.display === 'none' ? '' : 'none';
|
||||
if (form.style.display !== 'none') {
|
||||
$('profileFormName').value = '';
|
||||
$('profileFormClone').checked = false;
|
||||
const errEl = $('profileFormError');
|
||||
if (errEl) errEl.style.display = 'none';
|
||||
$('profileFormName').focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProfileCreate() {
|
||||
const name = ($('profileFormName').value || '').trim().toLowerCase();
|
||||
const cloneConfig = $('profileFormClone').checked;
|
||||
const errEl = $('profileFormError');
|
||||
if (!name) { errEl.textContent = 'Name is required'; errEl.style.display = ''; return; }
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = 'Lowercase letters, numbers, hyphens, underscores only'; errEl.style.display = ''; return; }
|
||||
try {
|
||||
await api('/api/profile/create', { method: 'POST', body: JSON.stringify({ name, clone_config: cloneConfig }) });
|
||||
toggleProfileForm();
|
||||
await loadProfilesPanel();
|
||||
showToast('Profile created: ' + name);
|
||||
} catch (e) { errEl.textContent = e.message || 'Create failed'; errEl.style.display = ''; }
|
||||
}
|
||||
|
||||
async function deleteProfile(name) {
|
||||
if (!confirm(`Delete profile "${name}"? This removes all config, skills, memory, and sessions for this profile.`)) return;
|
||||
try {
|
||||
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
|
||||
await loadProfilesPanel();
|
||||
showToast('Profile deleted: ' + name);
|
||||
} catch (e) { showToast('Delete failed: ' + e.message); }
|
||||
}
|
||||
|
||||
// ── Memory panel ──
|
||||
async function loadMemory(force) {
|
||||
const panel = $('memoryPanel');
|
||||
|
||||
@@ -13,7 +13,10 @@ async function newSession(flash){
|
||||
MSG_QUEUE.length=0;updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
const inheritWs=S.session?S.session.workspace:null;
|
||||
// Use profile default workspace for new sessions after a profile switch (one-shot),
|
||||
// otherwise inherit from the current session (or let server pick the default)
|
||||
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
|
||||
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
|
||||
S.session=data.session;S.messages=data.session.messages||[];
|
||||
if(flash)S.session._flash=true;
|
||||
@@ -69,6 +72,7 @@ let _renamingSid = null; // session_id currently being renamed (blocks list re-
|
||||
let _showArchived = false; // toggle to show archived sessions
|
||||
let _allProjects = []; // cached project list
|
||||
let _activeProject = null; // project_id filter (null = show all)
|
||||
let _showAllProfiles = false; // false = filter to active profile only
|
||||
|
||||
async function renderSessionList(){
|
||||
try{
|
||||
@@ -111,8 +115,12 @@ function renderSessionListFromCache(){
|
||||
// Merge content matches (deduped): content matches appended after title matches
|
||||
const titleIds=new Set(titleMatches.map(s=>s.session_id));
|
||||
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
|
||||
// Filter by active profile (unless "All profiles" is toggled on)
|
||||
// Server backfills profile='default' for legacy sessions, so every session has a profile.
|
||||
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
|
||||
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.profile===S.activeProfile);
|
||||
// Filter by active project
|
||||
const projectFiltered=_activeProject?allMatched.filter(s=>s.project_id===_activeProject):allMatched;
|
||||
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
|
||||
// Filter archived unless toggle is on
|
||||
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
||||
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
||||
@@ -154,6 +162,21 @@ function renderSessionListFromCache(){
|
||||
bar.appendChild(addBtn);
|
||||
list.appendChild(bar);
|
||||
}
|
||||
// Profile filter toggle (show sessions from other profiles)
|
||||
const otherProfileCount=allMatched.filter(s=>s.profile&&s.profile!==S.activeProfile).length;
|
||||
if(otherProfileCount>0&&!_showAllProfiles){
|
||||
const pfToggle=document.createElement('div');
|
||||
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
|
||||
pfToggle.textContent='Show '+otherProfileCount+' from other profiles';
|
||||
pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionListFromCache();};
|
||||
list.appendChild(pfToggle);
|
||||
} else if(_showAllProfiles&&otherProfileCount>0){
|
||||
const pfToggle=document.createElement('div');
|
||||
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
|
||||
pfToggle.textContent='Show active profile only';
|
||||
pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionListFromCache();};
|
||||
list.appendChild(pfToggle);
|
||||
}
|
||||
// Show/hide archived toggle if there are archived sessions
|
||||
if(archivedCount>0){
|
||||
const toggle=document.createElement('div');
|
||||
@@ -346,6 +369,7 @@ function renderSessionListFromCache(){
|
||||
_clickTimer=null;
|
||||
if(_renamingSid) return;
|
||||
await loadSession(s.session_id);renderSessionListFromCache();
|
||||
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
|
||||
}, 220);
|
||||
};
|
||||
el.ondblclick=async(e)=>{
|
||||
|
||||
105
static/style.css
105
static/style.css
@@ -260,43 +260,92 @@
|
||||
::-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{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) ── */
|
||||
.ws-chip{user-select:none;}
|
||||
.ws-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:240px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.ws-dropdown.open{display:block;}
|
||||
.ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
|
||||
.ws-opt:hover{background:rgba(255,255,255,.07);}
|
||||
@@ -314,6 +363,25 @@
|
||||
.ws-row-actions{display:flex;gap:4px;flex-shrink:0;}
|
||||
.ws-action-btn{padding:4px 9px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;}
|
||||
.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
|
||||
/* ── Profile dropdown + management panel ── */
|
||||
.profile-chip{user-select:none;color:rgba(168,139,250,.9)!important;}
|
||||
.profile-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:260px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;}
|
||||
.profile-dropdown.open{display:block;}
|
||||
.profile-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
|
||||
.profile-opt:hover{background:rgba(255,255,255,.07);}
|
||||
.profile-opt.active{background:rgba(168,139,250,.08);}
|
||||
.profile-opt-name{font-size:13px;color:var(--text);font-weight:500;}
|
||||
.profile-opt-meta{font-size:11px;color:var(--muted);margin-top:2px;}
|
||||
.profile-opt-badge{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px;vertical-align:middle;}
|
||||
.profile-opt-badge.running{background:#4caf50;box-shadow:0 0 4px rgba(76,175,80,.5);}
|
||||
.profile-opt-badge.stopped{background:rgba(255,255,255,.2);}
|
||||
.profile-card{padding:10px 0;border-bottom:1px solid var(--border);}
|
||||
.profile-card:last-of-type{border-bottom:none;}
|
||||
.profile-card-header{display:flex;align-items:center;justify-content:space-between;gap:8px;}
|
||||
.profile-card-name{font-size:13px;font-weight:600;color:var(--text);}
|
||||
.profile-card-name.is-active{color:rgba(168,139,250,.9);}
|
||||
.profile-card-meta{font-size:11px;color:var(--muted);margin-top:3px;padding-left:12px;}
|
||||
.profile-card-actions{display:flex;gap:4px;flex-shrink:0;}
|
||||
/* ── Slash command autocomplete dropdown ── */
|
||||
.cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;}
|
||||
.cmd-dropdown.open{display:block;}
|
||||
@@ -476,9 +544,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;}
|
||||
|
||||
80
static/ui.js
80
static/ui.js
@@ -1,4 +1,4 @@
|
||||
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.'};
|
||||
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'};
|
||||
const INFLIGHT={}; // keyed by session_id while request in-flight
|
||||
const MSG_QUEUE=[]; // messages queued while a request is in-flight
|
||||
const $=id=>document.getElementById(id);
|
||||
@@ -7,6 +7,38 @@ const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'&
|
||||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||||
let _dynamicModelLabels={};
|
||||
|
||||
// ── Smart model resolver ────────────────────────────────────────────────────
|
||||
// Finds the best matching option value in a <select> for a given model ID.
|
||||
// Handles mismatches like 'claude-sonnet-4-6' vs 'anthropic/claude-sonnet-4.6'.
|
||||
// Returns the matched option's value (already in the list), or null if no match.
|
||||
function _findModelInDropdown(modelId, sel){
|
||||
if(!modelId||!sel) return null;
|
||||
const opts=Array.from(sel.options).map(o=>o.value);
|
||||
// 1. Exact match
|
||||
if(opts.includes(modelId)) return modelId;
|
||||
// 2. Normalize: lowercase, strip namespace prefix, replace hyphens→dots
|
||||
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/-/g,'.');
|
||||
const target=norm(modelId);
|
||||
const exact=opts.find(o=>norm(o)===target);
|
||||
if(exact) return exact;
|
||||
// 3. Prefix/substring: target starts with or contains a significant chunk
|
||||
const base=target.replace(/\.\d+$/,''); // strip trailing version number
|
||||
const partial=opts.find(o=>norm(o).startsWith(base)||norm(o).includes(base));
|
||||
return partial||null;
|
||||
}
|
||||
|
||||
// Set the model picker to the best match for modelId.
|
||||
// Returns the resolved value that was actually set, or null if nothing matched.
|
||||
function _applyModelToDropdown(modelId, sel){
|
||||
if(!modelId||!sel) return null;
|
||||
const resolved=_findModelInDropdown(modelId,sel);
|
||||
if(resolved){
|
||||
sel.value=resolved;
|
||||
return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function populateModelDropdown(){
|
||||
const sel=$('modelSelect');
|
||||
if(!sel) return;
|
||||
@@ -30,15 +62,7 @@ async function populateModelDropdown(){
|
||||
}
|
||||
// Set default model from server if no localStorage preference
|
||||
if(data.default_model && !localStorage.getItem('hermes-webui-model')){
|
||||
sel.value=data.default_model;
|
||||
// If the default isn't in the list, add it
|
||||
if(sel.value!==data.default_model){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=data.default_model;
|
||||
opt.textContent=data.default_model.split('/').pop();
|
||||
sel.insertBefore(opt,sel.firstChild);
|
||||
sel.value=data.default_model;
|
||||
}
|
||||
_applyModelToDropdown(data.default_model, sel);
|
||||
}
|
||||
}catch(e){
|
||||
// API unavailable -- keep the hardcoded HTML options as fallback
|
||||
@@ -320,15 +344,23 @@ function syncTopbar(){
|
||||
document.title=sessionTitle+' \u2014 Hermes';
|
||||
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
|
||||
$('topbarMeta').textContent=`${vis.length} messages`;
|
||||
const m=S.session.model||'';
|
||||
$('modelSelect').value=m; // set dropdown first so chip reads consistent value
|
||||
// If session model isn't in the dropdown, add it dynamically
|
||||
if(m && $('modelSelect').value!==m){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=m;
|
||||
opt.textContent=getModelLabel(m);
|
||||
$('modelSelect').appendChild(opt);
|
||||
$('modelSelect').value=m;
|
||||
// If a profile switch just happened, apply its model rather than the session's stale value.
|
||||
// S._pendingProfileModel is set by switchToProfile() and cleared here after one application.
|
||||
const modelOverride=S._pendingProfileModel;
|
||||
if(modelOverride){
|
||||
S._pendingProfileModel=null;
|
||||
_applyModelToDropdown(modelOverride,$('modelSelect'));
|
||||
} else {
|
||||
const m=S.session.model||'';
|
||||
const applied=_applyModelToDropdown(m,$('modelSelect'));
|
||||
// If the model isn't in the list at all, add it so the session value is preserved
|
||||
if(!applied && m){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=m;
|
||||
opt.textContent=getModelLabel(m);
|
||||
$('modelSelect').appendChild(opt);
|
||||
$('modelSelect').value=m;
|
||||
}
|
||||
}
|
||||
// Show Clear button only when session has messages
|
||||
const clearBtn=$('btnClearConv');
|
||||
@@ -336,13 +368,6 @@ function syncTopbar(){
|
||||
const displayModel=$('modelSelect').value||m;
|
||||
$('modelChip').textContent=getModelLabel(displayModel);
|
||||
const ws=S.session.workspace||'';
|
||||
$('wsChip').textContent=ws.split('/').slice(-2).join('/')||ws;
|
||||
// Update workspace chip in topbar with friendly name from workspace list
|
||||
const wsChipEl=$('wsChip');
|
||||
if(wsChipEl){
|
||||
const wsFriendly=getWorkspaceFriendlyName(ws);
|
||||
wsChipEl.textContent='\u{1F4C1} '+wsFriendly+' \u25BE';
|
||||
}
|
||||
// Update sidebar workspace display
|
||||
const sidebarName=$('sidebarWsName');
|
||||
const sidebarPath=$('sidebarWsPath');
|
||||
@@ -353,6 +378,9 @@ function syncTopbar(){
|
||||
sidebarPath.textContent=ws;
|
||||
}
|
||||
// modelSelect already set above
|
||||
// Update profile chip label
|
||||
const profileLabel=$('profileChipLabel');
|
||||
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
|
||||
}
|
||||
|
||||
function msgContent(m){
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
async function api(path,opts={}){
|
||||
const url=new URL(path,location.origin);
|
||||
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
|
||||
if(!res.ok)throw new Error(await res.text());
|
||||
if(!res.ok){
|
||||
const text=await res.text();
|
||||
// Parse JSON error body and surface the human-readable message,
|
||||
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
|
||||
try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);}
|
||||
catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;}
|
||||
}
|
||||
const ct=res.headers.get('content-type')||'';
|
||||
return ct.includes('application/json')?res.json():res.text();
|
||||
}
|
||||
|
||||
193
tests/test_sprint23.py
Normal file
193
tests/test_sprint23.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Sprint 23 tests: profile/workspace/model coherence."""
|
||||
import json, pathlib, re, urllib.request, urllib.error
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
# ── Workspace profile-locality ──────────────────────────────────────────────
|
||||
|
||||
def test_workspace_list_returns_data():
|
||||
"""Workspace list endpoint works after profile-local refactor."""
|
||||
data, status = get("/api/workspaces")
|
||||
assert status == 200
|
||||
assert "workspaces" in data
|
||||
assert isinstance(data["workspaces"], list)
|
||||
assert "last" in data
|
||||
|
||||
|
||||
def test_workspace_add_remove_roundtrip():
|
||||
"""Workspace add/remove still works with profile-local storage."""
|
||||
import os
|
||||
# Use a path that won't resolve differently (macOS /tmp -> /private/tmp)
|
||||
resolved_tmp = str(pathlib.Path("/tmp").resolve())
|
||||
# Clean slate
|
||||
post("/api/workspaces/remove", {"path": resolved_tmp})
|
||||
# Add
|
||||
data, status = post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
|
||||
assert status == 200
|
||||
assert any(w["path"] == resolved_tmp for w in data.get("workspaces", []))
|
||||
# Remove
|
||||
data, status = post("/api/workspaces/remove", {"path": resolved_tmp})
|
||||
assert status == 200
|
||||
assert not any(w["path"] == resolved_tmp for w in data.get("workspaces", []))
|
||||
|
||||
|
||||
# ── Profile switch response fields ─────────────────────────────────────────
|
||||
|
||||
def test_profile_switch_returns_default_model_and_workspace():
|
||||
"""switch_profile() response includes default_model and default_workspace."""
|
||||
# Prior tests (test_chat_stream_opens_successfully) may leave a live LLM stream in
|
||||
# STREAMS. The server-side thread keeps running until the LLM response completes.
|
||||
# Wait up to 30 seconds for it to drain before attempting the profile switch.
|
||||
import time
|
||||
for _ in range(60):
|
||||
health, _ = get("/health")
|
||||
if health.get("active_streams", 0) == 0:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
data, status = post("/api/profile/switch", {"name": "default"})
|
||||
assert status == 200, f"Profile switch returned {status}: {data}"
|
||||
assert "active" in data
|
||||
assert data["active"] == "default"
|
||||
# default_workspace should always be present (may be null for model)
|
||||
assert "default_workspace" in data
|
||||
assert isinstance(data["default_workspace"], str)
|
||||
assert "default_model" in data # can be None
|
||||
|
||||
|
||||
def test_profile_active_endpoint():
|
||||
"""GET /api/profile/active returns name and path."""
|
||||
data, status = get("/api/profile/active")
|
||||
assert status == 200
|
||||
assert "name" in data, "Response missing 'name' field"
|
||||
assert isinstance(data["name"], str) and data["name"], "Profile name should be a non-empty string"
|
||||
assert "path" in data
|
||||
|
||||
|
||||
# ── Session profile tagging ────────────────────────────────────────────────
|
||||
|
||||
def test_new_session_has_profile_field():
|
||||
"""Sessions created after Sprint 22 should have a profile field."""
|
||||
data, status = post("/api/session/new", {})
|
||||
assert status == 200
|
||||
session = data["session"]
|
||||
assert "profile" in session
|
||||
# Clean up
|
||||
post("/api/session/delete", {"session_id": session["session_id"]})
|
||||
|
||||
|
||||
def test_sessions_list_includes_profile():
|
||||
"""Sessions created after Sprint 22 expose a profile field."""
|
||||
# Create a session and check via the direct session endpoint
|
||||
# (/api/sessions filters out empty Untitled sessions; use /api/session instead)
|
||||
create_data, _ = post("/api/session/new", {})
|
||||
sid = create_data["session"]["session_id"]
|
||||
try:
|
||||
data, status = get(f"/api/session?session_id={sid}")
|
||||
assert status == 200
|
||||
session = data.get("session", data)
|
||||
assert "profile" in session, f"'profile' field missing from session: {list(session.keys())}"
|
||||
finally:
|
||||
post("/api/session/delete", {"session_id": sid})
|
||||
|
||||
|
||||
# ── Static JS analysis ─────────────────────────────────────────────────────
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
|
||||
def test_sessions_js_has_profile_filter():
|
||||
"""sessions.js should filter sessions by active profile."""
|
||||
content = (REPO_ROOT / "static" / "sessions.js").read_text()
|
||||
assert "_showAllProfiles" in content
|
||||
assert "profileFiltered" in content
|
||||
assert "S.activeProfile" in content
|
||||
|
||||
|
||||
def test_panels_js_clears_model_on_switch():
|
||||
"""switchToProfile() must clear localStorage model key."""
|
||||
content = (REPO_ROOT / "static" / "panels.js").read_text()
|
||||
assert "localStorage.removeItem('hermes-webui-model')" in content
|
||||
assert "loadWorkspaceList" in content
|
||||
assert "renderSessionList" in content
|
||||
|
||||
|
||||
# ── Regression: profile switch base dir bug (PR #44) ──────────────────────
|
||||
|
||||
def test_profile_switch_base_home_not_subdir():
|
||||
"""_DEFAULT_HERMES_HOME must always be the base ~/.hermes root, not a
|
||||
profile subdir. Regression: if HERMES_HOME was mutated to a profiles/
|
||||
subdir at server startup, switch_profile() looked for
|
||||
~/.hermes/profiles/X/profiles/X which never exists — returning 404.
|
||||
|
||||
We verify the fix is present via static analysis of profiles.py.
|
||||
The live-switch variant is in test_profile_switch_returns_default_model_and_workspace.
|
||||
"""
|
||||
content = (REPO_ROOT / "api" / "profiles.py").read_text()
|
||||
|
||||
# The fix must define a resolver function that handles the profiles/ subdir case
|
||||
assert "_resolve_base_hermes_home" in content, (
|
||||
"profiles.py must define _resolve_base_hermes_home() to safely resolve "
|
||||
"the base HERMES_HOME regardless of HERMES_HOME env var mutation"
|
||||
)
|
||||
assert "p.parent.name == 'profiles'" in content, (
|
||||
"_resolve_base_hermes_home must detect when HERMES_HOME points to a "
|
||||
"profiles/ subdir (e.g. ~/.hermes/profiles/webui) and walk up to base"
|
||||
)
|
||||
assert "p.parent.parent" in content, (
|
||||
"_resolve_base_hermes_home must return p.parent.parent when HERMES_HOME "
|
||||
"is a profiles/ subdir, giving back the actual ~/.hermes base"
|
||||
)
|
||||
# _DEFAULT_HERMES_HOME must be set from the resolver, not directly from env
|
||||
assert "_DEFAULT_HERMES_HOME = _resolve_base_hermes_home()" in content, (
|
||||
"_DEFAULT_HERMES_HOME must be assigned from _resolve_base_hermes_home(), "
|
||||
"not directly from os.getenv('HERMES_HOME')"
|
||||
)
|
||||
|
||||
|
||||
def test_api_helper_returns_clean_error_message():
|
||||
"""workspace.js api() helper must parse JSON error bodies and surface
|
||||
the human-readable 'error' field, not raw JSON like
|
||||
{'error': 'Profile X does not exist.'}.
|
||||
|
||||
Regression: api() did `throw new Error(await res.text())` which made
|
||||
showToast display 'Switch failed: {"error":"Profile X does not exist."}' --
|
||||
JSON noise the user shouldn't see.
|
||||
"""
|
||||
content = (REPO_ROOT / "static" / "workspace.js").read_text()
|
||||
# Must parse the JSON error body
|
||||
assert "JSON.parse(text)" in content, (
|
||||
"api() must parse JSON error bodies -- raw res.text() leaks JSON to the UI"
|
||||
)
|
||||
# Must extract the .error field
|
||||
assert "j.error" in content, (
|
||||
"api() must extract j.error from parsed JSON error response"
|
||||
)
|
||||
|
||||
|
||||
def test_profile_switch_resolve_base_home_logic():
|
||||
"""Static analysis: _resolve_base_hermes_home() must handle the case
|
||||
where HERMES_HOME points to a profiles/ subdir by walking up to the base.
|
||||
"""
|
||||
content = (REPO_ROOT / "api" / "profiles.py").read_text()
|
||||
assert "_resolve_base_hermes_home" in content, (
|
||||
"profiles.py must define _resolve_base_hermes_home()"
|
||||
)
|
||||
assert "p.parent.name == 'profiles'" in content, (
|
||||
"_resolve_base_hermes_home must detect and unwrap profiles/ subdir paths"
|
||||
)
|
||||
assert "p.parent.parent" in content, (
|
||||
"_resolve_base_hermes_home must walk up two levels from a profiles/ subdir"
|
||||
)
|
||||
Reference in New Issue
Block a user