Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4947a6b0c3 | ||
|
|
0df9d4830f | ||
|
|
e0a95193d8 | ||
|
|
e3c85624d9 | ||
|
|
ed9023a431 | ||
|
|
e59fedd351 | ||
|
|
9a5435176d | ||
|
|
31281a6025 | ||
|
|
cc8cbc4d3f | ||
|
|
0e5e465ea0 | ||
|
|
a92e21553d | ||
|
|
06f46439c0 | ||
|
|
e68c1b92a4 | ||
|
|
fb19c7ea1f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,3 +25,6 @@ full-UI.png
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local reference clones — never committed
|
||||
docs/
|
||||
|
||||
@@ -40,6 +40,7 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
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)
|
||||
startup.py Startup helpers: auto_install_agent_deps() (~50 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)
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -6,6 +6,54 @@
|
||||
---
|
||||
|
||||
|
||||
## [v0.44.0] — 2026-04-10
|
||||
|
||||
### Features
|
||||
- **Lucide SVG icons** (PR #221): Replaces all emoji icons in the sidebar, workspace, and tool cards with self-hosted Lucide SVG paths via `static/icons.js`. No CDN dependency — icons are bundled directly. The `li(name)` renderer uses a hardcoded whitelist, so server-supplied tool names never inject arbitrary SVG. All 35 `onclick=` functions verified to exist in JS; all 21 icon references verified in `icons.js`.
|
||||
|
||||
### Bug Fixes
|
||||
- **Approval card hides immediately on respond/stream-end** (PR #225): `respondApproval()` and all stream-end SSE handlers (done, cancel, apperror, error, start-error) now call `hideApprovalCard(true)`. Previously the 30s minimum-visibility guard deferred the hide, leaving the card visible with disabled buttons for up to 30s after the user clicked Approve/Deny or the session completed. The poll-loop tick correctly keeps no-force so the guard still protects against transient polling gaps. Adds 11 structural tests for the timer logic.
|
||||
- **Login page CSP fix** (PR #226): Moves `doLogin()` and Enter key listener from inline `<script>`/`onsubmit`/`onkeydown` attributes into `static/login.js`. Inline handlers are blocked by strict `script-src` CSP, causing silent login failure. i18n error strings now passed via `data-*` attributes instead of injected JS literals. Also guards `res.json()` parse with try/catch so non-JSON server errors fall back to the password-error message. Fixes #222.
|
||||
- **Update error messages** (PR #227): `_apply_update_inner()` now fetches before pulling and surfaces three distinct failure modes with actionable recovery commands: network unreachable, diverged history (`git reset --hard`), and missing upstream tracking branch (`git branch --set-upstream-to`). Generic fallback truncates to 300 chars with a sentinel for empty output. Adds 13 tests covering all new diagnostic code paths. Fixes #223.
|
||||
- **Approval pending check** (PR #228): `GET /api/approval/pending` always returned `{pending: null}` after the agent module renamed `has_pending` to `has_blocking_approval`. The route now checks `_pending` directly under `_lock`, matching how `submit_pending` writes to it. Fixes `test_approval_submit_and_respond`.
|
||||
|
||||
### Tests
|
||||
- 579 passing, 16 skipped (up from 555 on v0.43.1 — +24 new tests across PRs #225, #227, #228)
|
||||
|
||||
## [v0.43.1] — 2026-04-10
|
||||
|
||||
- **CSRF fix for reverse proxies** (PR #219): The CSRF check now accepts `X-Forwarded-Host` and `X-Real-Host` headers in addition to `Host`, so deployments behind Caddy, nginx, and Traefik no longer reject POST requests with "Cross-origin request rejected". Security is preserved — requests with no matching proxy header are still rejected. Fixes #218.
|
||||
|
||||
## [v0.43.0] — 2026-04-10
|
||||
|
||||
### Features
|
||||
- **Auto-install agent dependencies on startup** (PRs #215 + #216): When `hermes-agent` is found on disk but its Python dependencies are missing (common in Docker deployments where the agent is volume-mounted post-build), `server.py` now calls `api/startup.auto_install_agent_deps()` to install from `requirements.txt` or `pyproject.toml`. Falls back gracefully — failures are logged and never fatal.
|
||||
|
||||
### Bug Fixes
|
||||
- **Session ID validator broadened** (PR #212): `Session.load()` rejected any session ID containing non-hex characters, breaking sessions created by the new hermes-agent format (`YYYYMMDD_HHMMSS_xxxxxx`). Validator now accepts `[0-9a-z_]` while rejecting path traversal patterns (null bytes, slashes, backslashes, dot-extensions).
|
||||
- **Test suite isolation** (PR #216): `conftest.py` now kills any stale process on the test port (8788) before starting the fixture server. Stale QA harness servers (8792/8793) could occupy 8788 and cause non-deterministic test failures across the full suite.
|
||||
|
||||
## [v0.42.2] — 2026-04-10
|
||||
|
||||
### Bug Fixes
|
||||
- **CSP blocking inline event handlers** (PR #209): `script-src 'self'` blocked all 55+ inline `onclick=` handlers in `index.html`, making the settings panel, sidebar navigation, and most interactive controls non-functional. Added `'unsafe-inline'` to `script-src`. Also restores `https://cdn.jsdelivr.net` to `script-src` and `style-src` for Mermaid.js and Prism.js (dropped in v0.42.1).
|
||||
|
||||
## [v0.42.1] — 2026-04-11
|
||||
|
||||
### Bug Fixes
|
||||
- **i18n button text stripping** (post-review): Three sidebar buttons (`+ New job`, `+ New skill`, `+ New profile`) and three suggestion buttons had `data-i18n` on the outer element, which caused `applyLocaleToDOM` to replace the entire `textContent` — stripping the `+` prefix and emoji characters on locale switch. Fixed by wrapping only the translatable label text in a `<span data-i18n="...">`.
|
||||
- **German translation corrections** (post-review): Fixed `cancelling` (imperative → progressive `"Wird abgebrochen…"`), `editing` (first-person verb → noun `"Bearbeitung"`), and completed truncated descriptions for `empty_subtitle`, `settings_desc_check_updates`, and `settings_desc_cli_sessions`.
|
||||
|
||||
## [v0.42.0] — 2026-04-10
|
||||
|
||||
### Features
|
||||
- **German translation** (PR #190 by @DavidSchuchert): Complete `de` locale covering all UI strings — settings, commands, sidebar, approval cards. Also extends the i18n system with `data-i18n-title` and `data-i18n-placeholder` attribute support so tooltip text and input placeholders are now translatable. German speech recognition uses `de-DE`.
|
||||
|
||||
### Bug Fixes
|
||||
- **Custom slash-model routing** (PR #189 by @smurmann): Model IDs like `google/gemma-4-26b-a4b` from custom providers (LM Studio, Ollama) were silently misrouted to OpenRouter because of the slash-heuristic. Custom providers now win: entries in `config.yaml → custom_providers` are checked first, so their model IDs route to the correct local endpoint regardless of format.
|
||||
- **Phantom Custom group in model picker** (PR #191 by @mbac): When `model.provider` was a named provider (e.g. `openai-codex`) and `model.base_url` was set, `hermes_cli` reported `'custom'` as authenticated, producing a duplicate "Custom" group in the dropdown. The real provider's group was missing the configured default model. Fixed by discarding the phantom `custom` entry when a real named provider is active.
|
||||
- **Hyphen/space model group injection** (PR #191): The "ensure default_model appears" post-pass used `active_provider.lower() in group_name.lower()`, which fails for `openai-codex` vs display name `OpenAI Codex` (hyphen vs space). Now uses `_PROVIDER_DISPLAY` for exact display-name matching.
|
||||
|
||||
## [v0.41.0] — 2026-04-10
|
||||
|
||||
### Features
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.39.0 (April 8, 2026)
|
||||
> Last updated: v0.44.0 (April 10, 2026) — 595 tests, 579 passing
|
||||
> Tests: 499 total (499 passing, 0 failures)
|
||||
> Source: <repo>/
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
| 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 |
|
||||
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
|
||||
| v0.44.0 patch | Fix batch: approval card, login CSP, update diagnostics, Lucide icons | PRs #221 #225 #226 #227 #228 | 579 |
|
||||
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
|
||||
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
|
||||
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 547 total (547 passing, 0 known isolation failures)
|
||||
> Automated tests: 595 total (579 passing, 16 skipped, 0 known failures)
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
|
||||
@@ -389,14 +389,21 @@ def resolve_model_provider(model_id: str) -> tuple:
|
||||
"""Resolve model name, provider, and base_url for AIAgent.
|
||||
|
||||
Model IDs from the dropdown can be in several formats:
|
||||
- 'claude-sonnet-4.6' (bare name, uses config default provider)
|
||||
- 'anthropic/claude-sonnet-4.6' (OpenRouter format, provider/model)
|
||||
- '@minimax:MiniMax-M2.7' (explicit provider hint from dropdown)
|
||||
- 'claude-sonnet-4.6' (bare name, uses config default provider)
|
||||
- 'anthropic/claude-sonnet-4.6' (OpenRouter-style provider/model)
|
||||
- '@minimax:MiniMax-M2.7' (explicit provider hint from dropdown)
|
||||
|
||||
The @provider:model format is used for models from non-default provider
|
||||
groups in the dropdown, so we can route them through the correct provider
|
||||
via resolve_runtime_provider(requested=provider) instead of the default.
|
||||
|
||||
Custom OpenAI-compatible endpoints are special: their model IDs often look
|
||||
like provider/model (for example ``google/gemma-4-26b-a4b``), which would be
|
||||
mistaken for an OpenRouter model if we only looked at the slash. To avoid
|
||||
that, first check whether the selected model matches an entry in
|
||||
config.yaml -> custom_providers and route it through that named custom
|
||||
provider.
|
||||
|
||||
Returns (model, provider, base_url) where provider and base_url may be None.
|
||||
"""
|
||||
config_provider = None
|
||||
@@ -410,6 +417,20 @@ def resolve_model_provider(model_id: str) -> tuple:
|
||||
if not model_id:
|
||||
return model_id, config_provider, config_base_url
|
||||
|
||||
# Custom providers declared in config.yaml should win over slash-based
|
||||
# OpenRouter heuristics. Their model IDs commonly contain '/' too.
|
||||
custom_providers = cfg.get('custom_providers', [])
|
||||
if isinstance(custom_providers, list):
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_model = (entry.get('model') or '').strip()
|
||||
entry_name = (entry.get('name') or '').strip()
|
||||
entry_base_url = (entry.get('base_url') or '').strip()
|
||||
if entry_model and entry_name and model_id == entry_model:
|
||||
provider_hint = 'custom:' + entry_name.lower().replace(' ', '-')
|
||||
return model_id, provider_hint, entry_base_url or None
|
||||
|
||||
# @provider:model format — explicit provider hint from the dropdown.
|
||||
# Route through that provider directly (resolve_runtime_provider will
|
||||
# resolve credentials in streaming.py).
|
||||
@@ -659,6 +680,14 @@ def get_available_models() -> dict:
|
||||
_seen_custom_ids.add(_cp_model)
|
||||
detected_providers.add('custom')
|
||||
|
||||
# If the user configured a real model.provider, the base_url belongs to
|
||||
# THAT provider, not to a separate "Custom" group. hermes_cli reports
|
||||
# 'custom' as authenticated whenever base_url is set, which would otherwise
|
||||
# build a phantom "Custom" bucket next to the real provider's group. Drop
|
||||
# it unless the user explicitly chose 'custom' as their active provider.
|
||||
if active_provider and active_provider != 'custom':
|
||||
detected_providers.discard('custom')
|
||||
|
||||
# 5. Build model groups
|
||||
if detected_providers:
|
||||
for pid in sorted(detected_providers):
|
||||
@@ -720,11 +749,21 @@ def get_available_models() -> dict:
|
||||
_norm = lambda mid: mid.split('/', 1)[-1] if '/' in mid else mid
|
||||
all_ids_norm = {_norm(m['id']) for g in groups for m in g.get('models', [])}
|
||||
if _norm(default_model) not in all_ids_norm:
|
||||
# Determine which group to inject into
|
||||
# Determine which group to inject into. Compare against the
|
||||
# provider's display name from _PROVIDER_DISPLAY rather than
|
||||
# doing a substring match on active_provider — substring
|
||||
# matching breaks on hyphenated provider IDs like 'openai-codex'
|
||||
# vs display name 'OpenAI Codex' (hyphen vs. space), which
|
||||
# silently falls through to groups[0] and lands the model in
|
||||
# the wrong group.
|
||||
label = default_model.split('/')[-1] if '/' in default_model else default_model
|
||||
target_display = (
|
||||
_PROVIDER_DISPLAY.get(active_provider, active_provider or '').lower()
|
||||
if active_provider else ''
|
||||
)
|
||||
injected = False
|
||||
for g in groups:
|
||||
if active_provider and active_provider.lower() in g.get('provider', '').lower():
|
||||
if target_display and g.get('provider', '').lower() == target_display:
|
||||
g['models'].insert(0, {'id': default_model, 'label': label})
|
||||
injected = True
|
||||
break
|
||||
|
||||
@@ -41,7 +41,9 @@ def _security_headers(handler):
|
||||
handler.send_header('Referrer-Policy', 'same-origin')
|
||||
handler.send_header(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"img-src 'self' data:; font-src 'self' data:; connect-src 'self'; "
|
||||
"base-uri 'self'; form-action 'self'"
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ class Session:
|
||||
@classmethod
|
||||
def load(cls, sid):
|
||||
# Validate session ID format to prevent path traversal
|
||||
if not sid or not all(c in '0123456789abcdef' for c in sid):
|
||||
if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
|
||||
return None
|
||||
p = SESSION_DIR / f'{sid}.json'
|
||||
if not p.exists():
|
||||
|
||||
@@ -32,12 +32,21 @@ def _check_csrf(handler) -> bool:
|
||||
if not origin and not referer:
|
||||
return True # non-browser clients (curl, agent) have no Origin
|
||||
target = origin or referer
|
||||
# Allow same-origin: Origin must match Host
|
||||
if host and target:
|
||||
# Extract host:port from origin/referer
|
||||
m = _re.match(r'^https?://([^/]+)', target)
|
||||
if m and m.group(1) == host:
|
||||
return True
|
||||
# Extract host:port from origin/referer
|
||||
m = _re.match(r'^https?://([^/]+)', target)
|
||||
if not m:
|
||||
return False
|
||||
origin_host = m.group(1)
|
||||
# Allow same-origin: check Host, X-Forwarded-Host (reverse proxy), and
|
||||
# X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set
|
||||
# X-Forwarded-Host to the client's original Host header.
|
||||
allowed_hosts = {h.strip() for h in [
|
||||
host,
|
||||
handler.headers.get('X-Forwarded-Host', ''),
|
||||
handler.headers.get('X-Real-Host', ''),
|
||||
] if h.strip()}
|
||||
if origin_host in allowed_hosts:
|
||||
return True
|
||||
return False
|
||||
from api.models import (
|
||||
Session, get_session, new_session, all_sessions, title_from,
|
||||
@@ -55,14 +64,13 @@ from api.streaming import _sse, _run_agent_streaming, cancel_stream
|
||||
# Approval system (optional -- graceful fallback if agent not available)
|
||||
try:
|
||||
from tools.approval import (
|
||||
has_pending, pop_pending, submit_pending,
|
||||
submit_pending,
|
||||
approve_session, approve_permanent, save_permanent_allowlist,
|
||||
is_approved, _pending, _lock, _permanent_approved,
|
||||
resolve_gateway_approval,
|
||||
)
|
||||
except ImportError:
|
||||
has_pending = lambda *a, **k: False
|
||||
pop_pending = lambda *a, **k: None
|
||||
|
||||
submit_pending = lambda *a, **k: None
|
||||
approve_session = lambda *a, **k: None
|
||||
approve_permanent = lambda *a, **k: None
|
||||
@@ -121,29 +129,14 @@ button:hover{background:rgba(124,185,255,.25)}
|
||||
<div class="logo">{{BOT_NAME_INITIAL}}</div>
|
||||
<h1>{{BOT_NAME}}</h1>
|
||||
<p class="sub">{{LOGIN_SUBTITLE}}</p>
|
||||
<form onsubmit="doLogin(event);return false">
|
||||
<input type="password" id="pw" placeholder="{{LOGIN_PLACEHOLDER}}" autofocus
|
||||
onkeydown="if(event.key==='Enter'){doLogin(event);event.preventDefault();}">
|
||||
<form id="login-form" data-invalid-pw="{{LOGIN_INVALID_PW}}" data-conn-failed="{{LOGIN_CONN_FAILED}}">
|
||||
<input type="password" id="pw" placeholder="{{LOGIN_PLACEHOLDER}}" autofocus>
|
||||
<button type="submit">{{LOGIN_BTN}}</button>
|
||||
</form>
|
||||
<div class="err" id="err"></div>
|
||||
</div>
|
||||
<script>
|
||||
async function doLogin(e){
|
||||
e.preventDefault();
|
||||
const pw=document.getElementById('pw').value;
|
||||
const err=document.getElementById('err');
|
||||
err.style.display='none';
|
||||
try{
|
||||
const res=await fetch('/api/auth/login',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({password:pw}),credentials:'include'});
|
||||
const data=await res.json();
|
||||
if(res.ok&&data.ok){window.location.href='/';}
|
||||
else{err.textContent=data.error||'{{LOGIN_INVALID_PW}}';err.style.display='block';}
|
||||
}catch(ex){err.textContent='{{LOGIN_CONN_FAILED}}';err.style.display='block';}
|
||||
}
|
||||
</script></body></html>'''
|
||||
<script src="/static/login.js"></script>
|
||||
</body></html>'''
|
||||
|
||||
# ── GET routes ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -168,8 +161,8 @@ def handle_get(handler, parsed) -> bool:
|
||||
.replace('{{LOGIN_SUBTITLE}}', _html.escape(_login_strings['subtitle']))
|
||||
.replace('{{LOGIN_PLACEHOLDER}}', _html.escape(_login_strings['placeholder']))
|
||||
.replace('{{LOGIN_BTN}}', _html.escape(_login_strings['btn']))
|
||||
.replace('{{LOGIN_INVALID_PW}}', _login_strings['invalid_pw'].replace('\\','\\\\').replace("'","\\'"))
|
||||
.replace('{{LOGIN_CONN_FAILED}}', _login_strings['conn_failed'].replace('\\','\\\\').replace("'","\\'"))
|
||||
.replace('{{LOGIN_INVALID_PW}}', _html.escape(_login_strings['invalid_pw']))
|
||||
.replace('{{LOGIN_CONN_FAILED}}', _html.escape(_login_strings['conn_failed']))
|
||||
)
|
||||
return t(handler, _page, content_type='text/html; charset=utf-8')
|
||||
|
||||
@@ -960,10 +953,10 @@ def _handle_file_read(handler, parsed):
|
||||
|
||||
def _handle_approval_pending(handler, parsed):
|
||||
sid = parse_qs(parsed.query).get('session_id', [''])[0]
|
||||
if has_pending(sid):
|
||||
with _lock:
|
||||
p = dict(_pending.get(sid, {}))
|
||||
return j(handler, {'pending': p})
|
||||
with _lock:
|
||||
p = _pending.get(sid)
|
||||
if p:
|
||||
return j(handler, {'pending': dict(p)})
|
||||
return j(handler, {'pending': None})
|
||||
|
||||
|
||||
|
||||
46
api/startup.py
Normal file
46
api/startup.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Hermes Web UI -- startup helpers."""
|
||||
from __future__ import annotations
|
||||
import os, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
def _agent_dir() -> Path | None:
|
||||
hermes_home = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes')))
|
||||
for raw in [os.environ.get('HERMES_WEBUI_AGENT_DIR', '').strip(), str(hermes_home / 'hermes-agent')]:
|
||||
if not raw:
|
||||
continue
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
return p.resolve()
|
||||
return None
|
||||
|
||||
def auto_install_agent_deps() -> bool:
|
||||
agent_dir = _agent_dir()
|
||||
if agent_dir is None:
|
||||
print('[!!] Auto-install skipped: agent directory not found.', flush=True)
|
||||
return False
|
||||
req_file = agent_dir / 'requirements.txt'
|
||||
pyproject = agent_dir / 'pyproject.toml'
|
||||
if req_file.exists():
|
||||
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', '-r', str(req_file)]
|
||||
print(f' Installing from {req_file} ...', flush=True)
|
||||
elif pyproject.exists():
|
||||
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', str(agent_dir)]
|
||||
print(f' Installing from {agent_dir} (pyproject.toml) ...', flush=True)
|
||||
else:
|
||||
print('[!!] Auto-install skipped: no requirements.txt or pyproject.toml in agent dir.', flush=True)
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(install_args, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
print(f'[!!] pip install failed (exit {result.returncode}):', flush=True)
|
||||
for line in (result.stderr or '').splitlines()[-10:]:
|
||||
print(f' {line}', flush=True)
|
||||
return False
|
||||
print('[ok] pip install completed.', flush=True)
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
print('[!!] Auto-install timed out after 120s.', flush=True)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f'[!!] Auto-install error: {e}', flush=True)
|
||||
return False
|
||||
@@ -148,6 +148,17 @@ def _apply_update_inner(target):
|
||||
branch = _detect_default_branch(path)
|
||||
compare_ref = f'origin/{branch}'
|
||||
|
||||
# Fetch before attempting pull, so the remote ref is current.
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
'Could not reach the remote repository. '
|
||||
'Check your internet connection and try again.'
|
||||
),
|
||||
}
|
||||
|
||||
# Check for dirty working tree
|
||||
status_out, _ = _run_git(['status', '--porcelain'], path)
|
||||
stashed = False
|
||||
@@ -162,7 +173,31 @@ def _apply_update_inner(target):
|
||||
if not pull_ok:
|
||||
if stashed:
|
||||
_run_git(['stash', 'pop'], path)
|
||||
return {'ok': False, 'message': f'Pull failed: {pull_out[:200]}'}
|
||||
|
||||
# Diagnose the most common failure modes and surface actionable messages.
|
||||
pull_lower = pull_out.lower()
|
||||
if 'not possible to fast-forward' in pull_lower or 'diverged' in pull_lower:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'The local {target} repo has commits that are not on the remote '
|
||||
'branch, so a fast-forward update is not possible. '
|
||||
'Run: git -C ' + str(path) + ' fetch origin && '
|
||||
'git -C ' + str(path) + ' reset --hard ' + compare_ref
|
||||
),
|
||||
'diverged': True,
|
||||
}
|
||||
if 'does not track' in pull_lower or 'no tracking information' in pull_lower:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'The local {target} branch has no upstream tracking branch configured. '
|
||||
'Run: git -C ' + str(path) + ' branch --set-upstream-to=' + compare_ref
|
||||
),
|
||||
}
|
||||
# Generic fallback — include the raw git output for debugging.
|
||||
detail = pull_out.strip()[:300] if pull_out.strip() else '(no output from git)'
|
||||
return {'ok': False, 'message': f'Pull failed: {detail}'}
|
||||
|
||||
# Pop stash if we stashed
|
||||
if stashed:
|
||||
|
||||
12
server.py
12
server.py
@@ -12,6 +12,7 @@ from api.auth import check_auth
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import j
|
||||
from api.routes import handle_get, handle_post
|
||||
from api.startup import auto_install_agent_deps
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -75,7 +76,16 @@ def main() -> None:
|
||||
print(f'[!!] Warning: Hermes agent found but missing modules: {missing}', flush=True)
|
||||
for mod, err in errors.items():
|
||||
print(f' {mod}: {err}', flush=True)
|
||||
print(' Agent features may not work correctly.', flush=True)
|
||||
print(' Attempting to install missing dependencies from agent requirements.txt...', flush=True)
|
||||
auto_install_agent_deps()
|
||||
ok, missing, errors = verify_hermes_imports()
|
||||
if not ok:
|
||||
print(f'[!!] Still missing after install attempt: {missing}', flush=True)
|
||||
for mod, err in errors.items():
|
||||
print(f' {mod}: {err}', flush=True)
|
||||
print(' Agent features may not work correctly.', flush=True)
|
||||
else:
|
||||
print('[ok] Agent dependencies installed successfully.', flush=True)
|
||||
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
236
static/i18n.js
236
static/i18n.js
@@ -136,6 +136,232 @@ const LOCALES = {
|
||||
login_btn: 'Sign in',
|
||||
login_invalid_pw: 'Invalid password',
|
||||
login_conn_failed: 'Connection failed',
|
||||
// Sidebar & Tabs
|
||||
tab_chat: 'Chat',
|
||||
tab_tasks: 'Tasks',
|
||||
tab_skills: 'Skills',
|
||||
tab_memory: 'Memory',
|
||||
tab_workspaces: 'Spaces',
|
||||
tab_profiles: 'Profiles',
|
||||
tab_todos: 'Todos',
|
||||
new_conversation: 'New conversation',
|
||||
filter_conversations: 'Filter conversations...',
|
||||
scheduled_jobs: 'Scheduled jobs',
|
||||
new_job: 'New job',
|
||||
loading: 'Loading...',
|
||||
search_skills: 'Search skills...',
|
||||
new_skill: 'New skill',
|
||||
personal_memory: 'Personal memory',
|
||||
current_task_list: 'Current task list',
|
||||
workspace_desc: 'Add and switch workspaces for your sessions.',
|
||||
new_profile: 'New profile',
|
||||
transcript: 'Transcript',
|
||||
download_transcript: 'Download as Markdown',
|
||||
import: 'Import',
|
||||
// Settings detail
|
||||
settings_label_sound: 'Notification sound',
|
||||
settings_desc_sound: 'Play a sound when the assistant finishes a response.',
|
||||
settings_label_notifications: 'Browser notifications',
|
||||
settings_desc_notifications: 'Show a system notification when a response completes while the tab is in the background.',
|
||||
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
|
||||
settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.',
|
||||
settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.',
|
||||
settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.',
|
||||
settings_desc_bot_name: 'Display name for the assistant throughout the UI. Defaults to Hermes.',
|
||||
settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.',
|
||||
password_placeholder: 'Enter new password…',
|
||||
disable_auth: 'Disable Auth',
|
||||
sign_out: 'Sign Out',
|
||||
cancel: 'Cancel',
|
||||
create_job: 'Create job',
|
||||
save_skill: 'Save skill',
|
||||
editing: 'Editing',
|
||||
// Empty state
|
||||
empty_title: 'What can I help with?',
|
||||
empty_subtitle: 'Ask anything, run commands, explore files, or manage your scheduled tasks.',
|
||||
suggest_files: 'What files are in this workspace?',
|
||||
suggest_schedule: "What's on my schedule today?",
|
||||
suggest_plan: 'Help me plan a small project.',
|
||||
},
|
||||
|
||||
de: {
|
||||
_lang: 'de',
|
||||
_label: 'Deutsch',
|
||||
_speech: 'de-DE',
|
||||
// boot.js
|
||||
cancelling: 'Wird abgebrochen\u2026',
|
||||
cancel_failed: 'Abbrechen fehlgeschlagen: ',
|
||||
mic_denied: 'Mikrofonzugriff verweigert. Überprüfen Sie die Browserberechtigungen.',
|
||||
mic_no_speech: 'Keine Sprache erkannt. Versuchen Sie es erneut.',
|
||||
mic_network: 'Spracherkennung nicht verfügbar.',
|
||||
mic_error: 'Spracheingabefehler: ',
|
||||
session_imported: 'Sitzung importiert',
|
||||
import_failed: 'Import fehlgeschlagen: ',
|
||||
import_invalid_json: 'Ungültiges JSON',
|
||||
image_pasted: 'Bild eingefügt: ',
|
||||
// messages.js
|
||||
edit_message: 'Nachricht bearbeiten',
|
||||
regenerate: 'Antwort regenerieren',
|
||||
copy: 'Kopieren',
|
||||
copied: 'Kopiert!',
|
||||
you: 'Du',
|
||||
thinking: 'Nachdenken',
|
||||
expand_all: 'Alle ausklappen',
|
||||
collapse_all: 'Alle einklappen',
|
||||
edit_failed: 'Bearbeiten fehlgeschlagen: ',
|
||||
regen_failed: 'Regeneration fehlgeschlagen: ',
|
||||
reconnect_active: 'Eine Antwort wird noch generiert. Neu laden, wenn bereit?',
|
||||
reconnect_finished: 'Eine Antwort war in Arbeit, als Sie zuletzt gegangen sind. Nachrichten könnten aktualisiert worden sein.',
|
||||
// approval card
|
||||
approval_heading: 'Genehmigung erforderlich',
|
||||
approval_desc_prefix: 'Gefährlicher Befehl erkannt',
|
||||
approval_btn_once: 'Einmal zulassen',
|
||||
approval_btn_once_title: 'Diesen einen Befehl zulassen (Enter)',
|
||||
approval_btn_session: 'Sitzung zulassen',
|
||||
approval_btn_session_title: 'Für diese Konversationssitzung zulassen',
|
||||
approval_btn_always: 'Immer zulassen',
|
||||
approval_btn_always_title: 'Dieses Befehlsmuster immer zulassen',
|
||||
approval_btn_deny: 'Ablehnen',
|
||||
approval_btn_deny_title: 'Ablehnen \u2014 diesen Befehl nicht ausführen',
|
||||
approval_responding: 'Antwortet\u2026',
|
||||
untitled: 'Unbenannt',
|
||||
n_messages: (n) => `${n} Nachrichten`,
|
||||
model_unavailable: ' (nicht verfügbar)',
|
||||
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
|
||||
// commands.js
|
||||
cmd_help: 'Verfügbare Befehle auflisten',
|
||||
cmd_clear: 'Konversationsverlauf löschen',
|
||||
cmd_compact: 'Kontext komprimieren',
|
||||
cmd_model: 'Modell wechseln (z.B. /model gpt-4o)',
|
||||
cmd_workspace: 'Workspace nach Namen wechseln',
|
||||
cmd_new: 'Neue Chat-Sitzung starten',
|
||||
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
|
||||
cmd_theme: 'Theme wechseln (dark/light/slate/solarized/monokai/nord/oled)',
|
||||
cmd_personality: 'Agenten-Persönlichkeit wechseln',
|
||||
available_commands: 'Verfügbare Befehle:',
|
||||
type_slash: 'Tippe / für Befehle',
|
||||
conversation_cleared: 'Konversation gelöscht',
|
||||
model_usage: 'Nutzung: /model <name>',
|
||||
no_model_match: 'Kein Modell gefunden für "',
|
||||
switched_to: 'Gewechselt zu ',
|
||||
workspace_usage: 'Nutzung: /workspace <name>',
|
||||
no_workspace_match: 'Kein Workspace gefunden für "',
|
||||
switched_workspace: 'Gewechselt zu Workspace: ',
|
||||
workspace_switch_failed: 'Workspace-Wechsel fehlgeschlagen: ',
|
||||
new_session: 'Neue Sitzung erstellt',
|
||||
compressing: 'Kontext-Komprimierung wird angefordert...',
|
||||
token_usage_on: 'Token-Verbrauch an',
|
||||
token_usage_off: 'Token-Verbrauch aus',
|
||||
theme_usage: 'Nutzung: /theme ',
|
||||
theme_set: 'Theme: ',
|
||||
no_active_session: 'Keine aktive Sitzung',
|
||||
no_personalities: 'Keine Persönlichkeiten gefunden (füge sie in ~/.hermes/personalities/ hinzu)',
|
||||
available_personalities: 'Verfügbare Persönlichkeiten:',
|
||||
personality_switch_hint: '\n\nNutze `/personality <name>` zum Wechseln, oder `/personality none` zum Löschen.',
|
||||
personalities_load_failed: 'Fehler beim Laden der Persönlichkeiten',
|
||||
personality_cleared: 'Persönlichkeit gelöscht',
|
||||
personality_set: 'Persönlichkeit: ',
|
||||
failed_colon: 'Fehlgeschlagen: ',
|
||||
// ui.js
|
||||
no_workspace: 'Kein Workspace',
|
||||
// workspace.js
|
||||
unsaved_confirm: 'Sie haben ungespeicherte Änderungen in der Vorschau. Verwerfen und fortfahren?',
|
||||
save: 'Speichern',
|
||||
edit: 'Bearbeiten',
|
||||
save_title: 'Änderungen speichern',
|
||||
edit_title: 'Diese Datei bearbeiten',
|
||||
saved: 'Gespeichert',
|
||||
save_failed: 'Speichern fehlgeschlagen: ',
|
||||
image_load_failed: 'Bild konnte nicht geladen werden',
|
||||
file_open_failed: 'Datei konnte nicht geöffnet werden',
|
||||
downloading: (name) => `Lade ${name} herunter\u2026`,
|
||||
double_click_rename: 'Doppelklick zum Umbenennen',
|
||||
renamed_to: 'Umbenannt in ',
|
||||
rename_failed: 'Umbenennen fehlgeschlagen: ',
|
||||
delete_title: 'Löschen',
|
||||
delete_confirm: (name) => `${name} löschen?`,
|
||||
deleted: 'Gelöscht ',
|
||||
delete_failed: 'Löschen fehlgeschlagen: ',
|
||||
new_file_prompt: 'Neuer Dateiname (z.B. notes.md):',
|
||||
created: 'Erstellt ',
|
||||
create_failed: 'Erstellen fehlgeschlagen: ',
|
||||
new_folder_prompt: 'Neuer Ordnername:',
|
||||
folder_created: 'Ordner erstellt ',
|
||||
folder_create_failed: 'Ordner erstellen fehlgeschlagen: ',
|
||||
remove_title: 'Entfernen',
|
||||
empty_dir: '(leer)',
|
||||
upload_failed: 'Upload fehlgeschlagen: ',
|
||||
all_uploads_failed: (n) => `Alle ${n} Upload(s) fehlgeschlagen`,
|
||||
// settings panel
|
||||
settings_title: 'Einstellungen',
|
||||
settings_save_btn: 'Einstellungen speichern',
|
||||
settings_label_model: 'Standard-Modell',
|
||||
settings_label_send_key: 'Sende-Taste',
|
||||
settings_label_theme: 'Theme',
|
||||
settings_label_language: 'Sprache',
|
||||
settings_label_token_usage: 'Token-Verbrauch anzeigen',
|
||||
settings_label_cli_sessions: 'CLI-Sitzungen anzeigen',
|
||||
settings_label_sync_insights: 'Mit Insights synchronisieren',
|
||||
settings_label_check_updates: 'Nach Updates suchen',
|
||||
settings_label_bot_name: 'Assistenten-Name',
|
||||
settings_label_password: 'Zugangspasswort',
|
||||
settings_saved: 'Einstellungen gespeichert',
|
||||
settings_save_failed: 'Speichern fehlgeschlagen: ',
|
||||
settings_load_failed: 'Laden der Einstellungen fehlgeschlagen: ',
|
||||
settings_saved_pw: 'Einstellungen gespeichert (Passwort gesetzt \u2014 Login jetzt erforderlich)',
|
||||
// login page
|
||||
login_title: 'Anmelden',
|
||||
login_subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren',
|
||||
login_placeholder: 'Passwort',
|
||||
login_btn: 'Anmelden',
|
||||
login_invalid_pw: 'Ungültiges Passwort',
|
||||
login_conn_failed: 'Verbindung fehlgeschlagen',
|
||||
// Sidebar & Tabs
|
||||
tab_chat: 'Chat',
|
||||
tab_tasks: 'Aufgaben',
|
||||
tab_skills: 'Skills',
|
||||
tab_memory: 'Gedächtnis',
|
||||
tab_workspaces: 'Spaces',
|
||||
tab_profiles: 'Profile',
|
||||
tab_todos: 'Todos',
|
||||
new_conversation: 'Neuer Chat',
|
||||
filter_conversations: 'Chats filtern...',
|
||||
scheduled_jobs: 'Geplante Aufgaben',
|
||||
new_job: 'Neuer Job',
|
||||
loading: 'Lädt...',
|
||||
search_skills: 'Skills suchen...',
|
||||
new_skill: 'Neuer Skill',
|
||||
personal_memory: 'Persönliches Gedächtnis',
|
||||
current_task_list: 'Aktuelle Aufgabenliste',
|
||||
workspace_desc: 'Workspaces hinzufügen und wechseln.',
|
||||
new_profile: 'Neues Profil',
|
||||
transcript: 'Protokoll',
|
||||
download_transcript: 'Als Markdown herunterladen',
|
||||
import: 'Importieren',
|
||||
// Settings detail
|
||||
settings_label_sound: 'Benachrichtigungston',
|
||||
settings_desc_sound: 'Spielt einen Ton ab, wenn der Assistent eine Antwort beendet.',
|
||||
settings_label_notifications: 'Browser-Benachrichtigungen',
|
||||
settings_desc_notifications: 'Zeigt eine Systembenachrichtigung an, wenn eine Antwort fertiggestellt wird, während der Tab im Hintergrund ist.',
|
||||
settings_desc_token_usage: 'Zeigt die Anzahl der Input/Output-Token unter jeder Antwort des Assistenten an. Auch umschaltbar mit /usage.',
|
||||
settings_desc_cli_sessions: 'Fügt Sitzungen aus der Hermes CLI (state.db) in die Sitzungsliste ein. Klicken Sie auf eine CLI-Sitzung, um sie zu importieren und das Gespräch fortzusetzen.',
|
||||
settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.',
|
||||
settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.',
|
||||
settings_desc_bot_name: 'Anzeigename für den Assistenten in der UI. Standardmäßig Hermes.',
|
||||
settings_desc_password: 'Geben Sie ein neues Passwort ein, um es zu setzen oder zu ändern. Leer lassen, um die aktuelle Einstellung beizubehalten.',
|
||||
password_placeholder: 'Neues Passwort eingeben…',
|
||||
disable_auth: 'Authentifizierung deaktivieren',
|
||||
sign_out: 'Abmelden',
|
||||
cancel: 'Abbrechen',
|
||||
create_job: 'Job erstellen',
|
||||
save_skill: 'Skill speichern',
|
||||
editing: 'Bearbeitung',
|
||||
// Empty state
|
||||
empty_title: 'Wie kann ich helfen?',
|
||||
empty_subtitle: 'Frage mich alles, führe Befehle aus, erkunde Dateien oder verwalte deine Aufgaben.',
|
||||
suggest_files: 'Welche Dateien sind in diesem Workspace?',
|
||||
suggest_schedule: 'Was steht heute auf meinem Plan?',
|
||||
suggest_plan: 'Hilf mir, ein kleines Projekt zu planen.',
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -321,6 +547,16 @@ function applyLocaleToDOM() {
|
||||
const val = t(key);
|
||||
if (val && val !== key) el.textContent = val;
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
const val = t(key);
|
||||
if (val && val !== key) el.title = val;
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
const val = t(key);
|
||||
if (val && val !== key) el.placeholder = val;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply saved locale immediately so there's no flash of English on reload.
|
||||
|
||||
69
static/icons.js
Normal file
69
static/icons.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// ── Lucide icon library (self-hosted SVG paths, no CDN dependency) ──────────
|
||||
// All icons are 24×24 viewBox, stroke-based, currentColor.
|
||||
// Usage: li('folder') → returns a ready-to-embed SVG string
|
||||
// The returned SVG uses display:inline-block + vertical-align so it sits
|
||||
// neatly beside text in both HTML templates and innerHTML assignments.
|
||||
|
||||
const LI_PATHS = {
|
||||
// Navigation tabs
|
||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
'calendar': '<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"/>',
|
||||
'layers': '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
|
||||
'lightbulb': '<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"/>',
|
||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
||||
// Editing / actions
|
||||
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
|
||||
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
'check': '<polyline points="20 6 9 17 4 12"/>',
|
||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
'square': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
|
||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
|
||||
'loader': '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
|
||||
// Tool icons
|
||||
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'file-pen': '<path d="M12 22h6a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v10"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10.4 19.4 14 16l-4-1 .4 4.4z"/><path d="m14 16 1.5-1.5a2.12 2.12 0 0 1 3 3L17 19"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
|
||||
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
||||
'brain': '<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/>',
|
||||
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'bot': '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>',
|
||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'shuffle': '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>',
|
||||
// File-type icons
|
||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||
'file-code': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
// Suggestion buttons
|
||||
'clipboard-list': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/>',
|
||||
'map': '<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a Lucide SVG string for the given icon name.
|
||||
* @param {string} name – key in LI_PATHS (e.g. 'folder', 'trash-2')
|
||||
* @param {number} size – width/height in px (default 16)
|
||||
* @returns {string} SVG element string ready for innerHTML
|
||||
*/
|
||||
function li(name, size = 16) {
|
||||
const p = LI_PATHS[name];
|
||||
if (!p) { console.warn('li(): unknown icon', name); return ''; }
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" `
|
||||
+ `stroke="currentColor" stroke-width="2" stroke-linecap="round" `
|
||||
+ `stroke-linejoin="round" aria-hidden="true" `
|
||||
+ `style="display:inline-block;vertical-align:-0.15em;flex-shrink:0">${p}</svg>`;
|
||||
}
|
||||
@@ -14,32 +14,32 @@
|
||||
<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.41.0</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.44.0</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
<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>
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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></button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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></button>
|
||||
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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></button>
|
||||
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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></button>
|
||||
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_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" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
</div>
|
||||
<!-- Chat panel -->
|
||||
<div class="panel-view active" id="panelChat">
|
||||
<div class="sidebar-section">
|
||||
<button class="new-chat-btn" id="btnNewChat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New conversation <span style="font-size:10px;opacity:.5;margin-left:4px">⌘K</span>
|
||||
<span data-i18n="new_conversation">New conversation</span> <span style="font-size:10px;opacity:.5;margin-left:4px">⌘K</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." oninput="filterSessions()"></div>
|
||||
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()"></div>
|
||||
<div class="session-list" id="sessionList"></div>
|
||||
</div>
|
||||
<!-- Tasks (cron) panel -->
|
||||
<div class="panel-view" id="panelTasks">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="font-size:11px;color:var(--muted)">Scheduled jobs</div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ New job</button>
|
||||
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
|
||||
</div>
|
||||
<!-- Create job form (hidden by default) -->
|
||||
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
@@ -57,18 +57,18 @@
|
||||
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()">Create job</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()">Cancel</button>
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()" data-i18n="create_job">Create job</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Skills panel -->
|
||||
<div class="panel-view" id="panelSkills">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." oninput="filterSkills()"></div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ New skill</button>
|
||||
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ <span data-i18n="new_skill">New skill</span></button>
|
||||
</div>
|
||||
<!-- Skill create/edit form (hidden by default) -->
|
||||
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
@@ -76,46 +76,46 @@
|
||||
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()">Save skill</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()">Cancel</button>
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()" data-i18n="save_skill">Save skill</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Memory panel -->
|
||||
<div class="panel-view" id="panelMemory">
|
||||
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
|
||||
<span style="font-size:11px;color:var(--muted)">Personal memory</span>
|
||||
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()">✎ Edit</button>
|
||||
<span style="font-size:11px;color:var(--muted)" data-i18n="personal_memory">Personal memory</span>
|
||||
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> <span data-i18n="edit">Edit</span></button>
|
||||
</div>
|
||||
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
<!-- Memory edit form (hidden by default) -->
|
||||
<div id="memoryEditForm" style="display:none;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Editing: <span id="memEditSection">memory</span></div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px"><span data-i18n="editing">Editing</span>: <span id="memEditSection">memory</span></div>
|
||||
<textarea id="memEditContent" rows="10" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:11px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;box-sizing:border-box;margin-bottom:6px;line-height:1.5"></textarea>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()">Save</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()">Cancel</button>
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()" data-i18n="save">Save</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Todo panel -->
|
||||
<div class="panel-view" id="panelTodos">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0">Current task list</div>
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0" data-i18n="current_task_list">Current task list</div>
|
||||
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
|
||||
</div>
|
||||
<!-- Workspaces panel -->
|
||||
<div class="panel-view" id="panelWorkspaces">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)">Add and switch workspaces for your sessions.</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)" data-i18n="workspace_desc">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" data-i18n="loading">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 style="font-size:11px;color:var(--muted)" data-i18n="tab_profiles">Agent profiles</div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ <span data-i18n="new_profile">New profile</span></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">
|
||||
@@ -153,19 +153,19 @@
|
||||
</select>
|
||||
<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>
|
||||
<span style="opacity:.7;line-height:1"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></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>
|
||||
<span style="color:var(--muted);flex-shrink:0;line-height:1"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</div>
|
||||
<div class="ws-dropdown" id="wsDropdown"></div>
|
||||
</div>
|
||||
<div class="sidebar-actions">
|
||||
<button class="sm-btn" id="btnDownload" title="Download as Markdown">↓ Transcript</button>
|
||||
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON">❬/❭ JSON</button>
|
||||
<button class="sm-btn" id="btnImportJSON" title="Import session from JSON">↑ Import</button>
|
||||
<button class="sm-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <span data-i18n="transcript">Transcript</span></button>
|
||||
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> JSON</button>
|
||||
<button class="sm-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
|
||||
<input type="file" id="importFileInput" accept=".json" style="display:none">
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,28 +176,44 @@
|
||||
<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 style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
|
||||
<div class="topbar-chips">
|
||||
<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="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> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></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>
|
||||
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg> <span data-i18n="copy">Clear</span></button>
|
||||
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings" data-i18n-title="settings_title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-logo">🦉</div>
|
||||
<h2>What can I help with?</h2>
|
||||
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
|
||||
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
|
||||
<defs>
|
||||
<linearGradient id="hermes-gold" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#hermes-gold)"/>
|
||||
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
|
||||
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
|
||||
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
|
||||
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
|
||||
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42" fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42" fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
|
||||
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
|
||||
</svg></div>
|
||||
<h2 data-i18n="empty_title">What can I help with?</h2>
|
||||
<p data-i18n="empty_subtitle">Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
|
||||
<div class="suggestion-grid">
|
||||
<button class="suggestion" data-msg="What files are in this workspace?">📁 What files are in this workspace?</button>
|
||||
<button class="suggestion" data-msg="What's on my schedule today?">📋 What's on my schedule today?</button>
|
||||
<button class="suggestion" data-msg="Help me plan a small project.">🗺 Help me plan a small project.</button>
|
||||
<button class="suggestion" data-msg="What files are in this workspace?"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <span data-i18n="suggest_files">What files are in this workspace?</span></button>
|
||||
<button class="suggestion" data-msg="What's on my schedule today?"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/></svg> <span data-i18n="suggest_schedule">What's on my schedule today?</span></button>
|
||||
<button class="suggestion" data-msg="Help me plan a small project."><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> <span data-i18n="suggest_plan">Help me plan a small project.</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-inner" id="msgInner"></div>
|
||||
@@ -211,10 +227,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconnect-banner" id="reconnectBanner">
|
||||
<span id="reconnectMsg">⚠ A response may have been in progress when you last left. Reload messages?</span>
|
||||
<span id="reconnectMsg"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> A response may have been in progress when you last left. Reload messages?</span>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||
<button class="reconnect-btn" onclick="dismissReconnect()">Dismiss</button>
|
||||
<button class="reconnect-btn" onclick="refreshSession()">↻ Reload</button>
|
||||
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
|
||||
@@ -227,20 +243,20 @@
|
||||
<div class="approval-cmd" id="approvalCmd"></div>
|
||||
<div class="approval-btns">
|
||||
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
|
||||
<span class="approval-btn-icon">✓</span>
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
|
||||
<kbd class="approval-kbd">↵</kbd>
|
||||
</button>
|
||||
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
|
||||
<span class="approval-btn-icon">🔒</span>
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
|
||||
</button>
|
||||
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
|
||||
<span class="approval-btn-icon">☆</span>
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
|
||||
</button>
|
||||
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
|
||||
<span class="approval-btn-icon">✕</span>
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
|
||||
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -249,10 +265,10 @@
|
||||
<!-- Activity bar: shows tool progress / status above composer (not inside input) -->
|
||||
<div id="activityBar" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;">
|
||||
<div id="activityBarInner" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);font-size:12px;color:var(--muted);animation:fadeIn .15s ease;">
|
||||
<span id="activityIcon" style="font-size:13px;opacity:.6">⚙</span>
|
||||
<span id="activityIcon" style="opacity:.6;line-height:1"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg></span>
|
||||
<span id="activityText" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button id="btnCancel" onclick="cancelStream()" style="display:none;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.35);color:#e94560;font-size:11px;font-weight:600;padding:3px 10px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:background .15s" title="Cancel this task">■ Cancel</button>
|
||||
<button id="btnDismissStatus" onclick="setStatus('')" style="display:none;background:none;border:none;color:var(--muted);font-size:14px;line-height:1;cursor:pointer;padding:0 2px;opacity:.5;flex-shrink:0" title="Dismiss">✕</button>
|
||||
<button id="btnCancel" onclick="cancelStream()" style="display:none;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.35);color:#e94560;font-size:11px;font-weight:600;padding:3px 10px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:background .15s;align-items:center;gap:4px" title="Cancel this task"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg> Cancel</button>
|
||||
<button id="btnDismissStatus" onclick="setStatus('')" style="display:none;background:none;border:none;color:var(--muted);line-height:1;cursor:pointer;padding:0 2px;opacity:.5;flex-shrink:0" title="Dismiss"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<span id="activityDots" style="display:flex;gap:3px;align-items:center">
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite"></span>
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .22s infinite"></span>
|
||||
@@ -305,11 +321,11 @@
|
||||
<span>Workspace</span>
|
||||
<span class="git-badge" id="gitBadge" style="display:none"></span>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none">↑</button>
|
||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">+</button>
|
||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()">📁</button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)">↻</button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview">✕</button>
|
||||
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
||||
@@ -318,8 +334,8 @@
|
||||
<div class="preview-path" id="previewPath">
|
||||
<span id="previewPathText"></span>
|
||||
<span class="preview-badge" id="previewBadge"></span>
|
||||
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer">⇩ Download</button>
|
||||
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none" onclick="toggleEditMode()">✎ Edit</button>
|
||||
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:inline-flex;align-items:center;gap:4px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download</button>
|
||||
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
|
||||
</div>
|
||||
<pre class="preview-code" id="previewCode"></pre>
|
||||
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
|
||||
@@ -332,7 +348,7 @@
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header">
|
||||
<h3 style="margin:0;font-size:16px" data-i18n="settings_title">Settings</h3>
|
||||
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close">✕</button>
|
||||
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="settings-field">
|
||||
@@ -365,58 +381,58 @@
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSoundEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
Notification sound
|
||||
<span data-i18n="settings_label_sound">Notification sound</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Play a sound when the assistant finishes a response.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sound">Play a sound when the assistant finishes a response.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsNotificationsEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
Browser notifications
|
||||
<span data-i18n="settings_label_notifications">Browser notifications</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a system notification when a response completes while the tab is in the background.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_notifications">Show a system notification when a response completes while the tab is in the background.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_token_usage">Show token usage after responses</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_cli_sessions">Show CLI sessions in sidebar</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cli_sessions">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_sync_insights">Sync usage to /insights</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sync_insights">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_check_updates">Check for updates</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
|
||||
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none">Disable Auth</button>
|
||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none">Sign Out</button>
|
||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
|
||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -424,27 +440,28 @@
|
||||
<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>
|
||||
<span data-i18n="tab_chat">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>
|
||||
<span data-i18n="tab_tasks">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>
|
||||
<span data-i18n="tab_skills">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>
|
||||
<span data-i18n="tab_memory">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>
|
||||
<span data-i18n="tab_workspaces">Spaces</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/static/i18n.js"></script>
|
||||
<script src="/static/icons.js"></script>
|
||||
<script src="/static/ui.js"></script>
|
||||
<script src="/static/workspace.js"></script>
|
||||
<script src="/static/sessions.js"></script>
|
||||
@@ -453,4 +470,4 @@
|
||||
<script src="/static/panels.js"></script>
|
||||
<script src="/static/boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
55
static/login.js
Normal file
55
static/login.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Login page — external script, no inline handlers.
|
||||
* Loaded by the /login route. Reads data attributes from the form for
|
||||
* i18n strings so the server does not need to inject JS literals.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var form = document.getElementById('login-form');
|
||||
var input = document.getElementById('pw');
|
||||
|
||||
if (!form || !input) return;
|
||||
|
||||
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
|
||||
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
|
||||
|
||||
function showErr(msg) {
|
||||
var err = document.getElementById('err');
|
||||
if (err) { err.textContent = msg; err.style.display = 'block'; }
|
||||
}
|
||||
|
||||
function hideErr() {
|
||||
var err = document.getElementById('err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
}
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
var pw = input.value;
|
||||
hideErr();
|
||||
try {
|
||||
var res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw }),
|
||||
credentials: 'include',
|
||||
});
|
||||
var data = {};
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (res.ok && data.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
showErr(data.error || invalidPw);
|
||||
}
|
||||
} catch (ex) {
|
||||
showErr(connFailed);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', doLogin);
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
doLogin(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ async function send(){
|
||||
setStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':'Sending…');
|
||||
let uploaded=[];
|
||||
try{uploaded=await uploadPendingFiles();}
|
||||
catch(e){if(!text){setStatus(`❌ ${e.message}`);return;}}
|
||||
catch(e){if(!text){setStatus(`Upload error: ${e.message}`);return;}}
|
||||
|
||||
let msgText=text;
|
||||
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploaded.join(', ')}`;
|
||||
@@ -69,12 +69,12 @@ async function send(){
|
||||
markInflight(activeSid, streamId);
|
||||
// Show Cancel button
|
||||
const cancelBtn=$('btnCancel');
|
||||
if(cancelBtn) cancelBtn.style.display='';
|
||||
if(cancelBtn) cancelBtn.style.display='inline-flex';
|
||||
}catch(e){
|
||||
delete INFLIGHT[activeSid];
|
||||
stopApprovalPolling();
|
||||
// Only hide approval card if it belongs to the session that just finished
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();removeThinking();
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking();
|
||||
S.messages.push({role:'assistant',content:`**Error:** ${e.message}`});
|
||||
renderMessages();setBusy(false);setStatus('Error: '+e.message);
|
||||
return;
|
||||
@@ -182,7 +182,7 @@ async function send(){
|
||||
delete INFLIGHT[activeSid];
|
||||
clearInflight();
|
||||
stopApprovalPolling();
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;
|
||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||
@@ -227,19 +227,18 @@ async function send(){
|
||||
// This is distinct from the SSE network 'error' event below.
|
||||
source.close();
|
||||
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
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}`});
|
||||
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`});
|
||||
}catch(_){
|
||||
S.messages.push({role:'assistant',content:'**⚠️ Error:** An error occurred. Check server logs.'});
|
||||
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
|
||||
}
|
||||
renderMessages();
|
||||
}else if(typeof trackBackgroundError==='function'){
|
||||
@@ -256,7 +255,7 @@ async function send(){
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
// Show as a small inline notice, not a full error
|
||||
setStatus(`⚠️ ${d.message||'Warning'}`);
|
||||
setStatus(`${d.message||'Warning'}`);
|
||||
// If it's a fallback notice, show it briefly then clear
|
||||
if(d.type==='fallback') setTimeout(()=>setStatus(''),4000);
|
||||
}catch(_){}
|
||||
@@ -287,7 +286,7 @@ async function send(){
|
||||
source.addEventListener('cancel',e=>{
|
||||
source.close();
|
||||
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
|
||||
}
|
||||
@@ -302,7 +301,7 @@ async function send(){
|
||||
|
||||
function _handleStreamError(){
|
||||
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
@@ -342,11 +341,45 @@ function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=M
|
||||
|
||||
// ── Approval polling ──
|
||||
let _approvalPollTimer = null;
|
||||
let _approvalHideTimer = null;
|
||||
let _approvalVisibleSince = 0;
|
||||
let _approvalSignature = '';
|
||||
const APPROVAL_MIN_VISIBLE_MS = 30000;
|
||||
|
||||
// showApprovalCard moved above respondApproval
|
||||
|
||||
function hideApprovalCard() {
|
||||
$("approvalCard").classList.remove("visible");
|
||||
function _clearApprovalHideTimer() {
|
||||
if (_approvalHideTimer) {
|
||||
clearTimeout(_approvalHideTimer);
|
||||
_approvalHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _resetApprovalCardState() {
|
||||
_clearApprovalHideTimer();
|
||||
_approvalVisibleSince = 0;
|
||||
_approvalSignature = '';
|
||||
}
|
||||
|
||||
function hideApprovalCard(force=false) {
|
||||
const card = $("approvalCard");
|
||||
if (!card) return;
|
||||
if (!force && _approvalVisibleSince) {
|
||||
const remaining = APPROVAL_MIN_VISIBLE_MS - (Date.now() - _approvalVisibleSince);
|
||||
if (remaining > 0) {
|
||||
const scheduledSignature = _approvalSignature;
|
||||
_clearApprovalHideTimer();
|
||||
_approvalHideTimer = setTimeout(() => {
|
||||
_approvalHideTimer = null;
|
||||
if (_approvalSignature !== scheduledSignature) return;
|
||||
hideApprovalCard(true);
|
||||
}, remaining);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_approvalSessionId = null;
|
||||
_resetApprovalCardState();
|
||||
card.classList.remove("visible");
|
||||
$("approvalCmd").textContent = "";
|
||||
$("approvalDesc").textContent = "";
|
||||
}
|
||||
@@ -357,15 +390,24 @@ let _approvalSessionId = null;
|
||||
function showApprovalCard(pending) {
|
||||
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
|
||||
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
|
||||
const cmd = pending.command || "";
|
||||
const sig = JSON.stringify({desc, cmd, sid: pending._session_id || (S.session && S.session.session_id) || null});
|
||||
const card = $("approvalCard");
|
||||
const sameApproval = card.classList.contains("visible") && _approvalSignature === sig;
|
||||
$("approvalDesc").textContent = desc;
|
||||
$("approvalCmd").textContent = pending.command || "";
|
||||
$("approvalCmd").textContent = cmd;
|
||||
_approvalSessionId = pending._session_id || (S.session && S.session.session_id) || null;
|
||||
_approvalSignature = sig;
|
||||
if (!sameApproval) {
|
||||
_approvalVisibleSince = Date.now();
|
||||
_clearApprovalHideTimer();
|
||||
}
|
||||
// Re-enable buttons in case a previous approval disabled them
|
||||
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
|
||||
const b = $(id); if (b) { b.disabled = false; b.classList.remove("loading"); }
|
||||
});
|
||||
const card = $("approvalCard");
|
||||
card.classList.add("visible");
|
||||
if (!sameApproval) card.scrollIntoView({block:"nearest", behavior:"smooth"});
|
||||
// Apply current locale to data-i18n elements inside the card
|
||||
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
|
||||
// Focus Allow once button so Enter works immediately
|
||||
@@ -382,7 +424,7 @@ async function respondApproval(choice) {
|
||||
if (b) { b.disabled = true; if (b.id === "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1)) b.classList.add("loading"); }
|
||||
});
|
||||
_approvalSessionId = null;
|
||||
hideApprovalCard();
|
||||
hideApprovalCard(true);
|
||||
try {
|
||||
await api("/api/approval/respond", {
|
||||
method: "POST",
|
||||
@@ -395,7 +437,7 @@ function startApprovalPolling(sid) {
|
||||
stopApprovalPolling();
|
||||
_approvalPollTimer = setInterval(async () => {
|
||||
if (!S.busy || !S.session || S.session.session_id !== sid) {
|
||||
stopApprovalPolling(); hideApprovalCard(); return;
|
||||
stopApprovalPolling(); hideApprovalCard(true); return;
|
||||
}
|
||||
try {
|
||||
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid));
|
||||
@@ -441,4 +483,3 @@ function sendBrowserNotification(title,body){
|
||||
}
|
||||
|
||||
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──
|
||||
|
||||
|
||||
48
static/ui.js
48
static/ui.js
@@ -664,12 +664,26 @@ function renderMessages(){
|
||||
}
|
||||
|
||||
function toolIcon(name){
|
||||
const icons={terminal:'⬛',read_file:'📄',write_file:'✏️',search_files:'🔍',
|
||||
web_search:'🌐',web_extract:'🌐',execute_code:'⚙️',patch:'🔧',
|
||||
memory:'🧠',skill_manage:'📚',todo:'✅',cronjob:'⏱️',delegate_task:'🤖',
|
||||
send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️',
|
||||
subagent_progress:'🔀'};
|
||||
return icons[name]||'🔧';
|
||||
const icons={
|
||||
terminal: li('terminal'),
|
||||
read_file: li('file-text'),
|
||||
write_file: li('file-pen'),
|
||||
search_files: li('search'),
|
||||
web_search: li('globe'),
|
||||
web_extract: li('globe'),
|
||||
execute_code: li('play'),
|
||||
patch: li('wrench'),
|
||||
memory: li('brain'),
|
||||
skill_manage: li('book-open'),
|
||||
todo: li('list-todo'),
|
||||
cronjob: li('clock'),
|
||||
delegate_task: li('bot'),
|
||||
send_message: li('message-square'),
|
||||
browser_navigate:li('globe'),
|
||||
vision_analyze: li('eye'),
|
||||
subagent_progress:li('shuffle'),
|
||||
};
|
||||
return icons[name]||li('wrench');
|
||||
}
|
||||
|
||||
function buildToolCard(tc){
|
||||
@@ -925,17 +939,17 @@ function appendThinking(){
|
||||
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
|
||||
|
||||
function fileIcon(name, type){
|
||||
if(type==='dir') return '📁';
|
||||
if(type==='dir') return li('folder',14);
|
||||
const e=fileExt(name);
|
||||
if(IMAGE_EXTS.has(e)) return '📷';
|
||||
if(MD_EXTS.has(e)) return '📝';
|
||||
if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return '⬇️';
|
||||
if(e==='.py') return '🐍';
|
||||
if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return '⚡';
|
||||
if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return '⚙';
|
||||
if(e==='.sh'||e==='.bash') return '💻';
|
||||
if(e==='.pdf') return '⬇️';
|
||||
return '📄';
|
||||
if(IMAGE_EXTS.has(e)) return li('image',14);
|
||||
if(MD_EXTS.has(e)) return li('file-text',14);
|
||||
if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return li('download',14);
|
||||
if(e==='.py') return li('file-code',14);
|
||||
if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return li('zap',14);
|
||||
if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return li('settings',14);
|
||||
if(e==='.sh'||e==='.bash') return li('terminal',14);
|
||||
if(e==='.pdf') return li('download',14);
|
||||
return li('file-text',14);
|
||||
}
|
||||
|
||||
function renderBreadcrumb(){
|
||||
@@ -1005,7 +1019,7 @@ function _renderTreeItems(container, entries, depth){
|
||||
|
||||
// Icon
|
||||
const iconEl=document.createElement('span');
|
||||
iconEl.className='file-icon';iconEl.textContent=fileIcon(item.name,item.type);
|
||||
iconEl.className='file-icon';iconEl.innerHTML=fileIcon(item.name,item.type);
|
||||
el.appendChild(iconEl);
|
||||
|
||||
// Name
|
||||
|
||||
@@ -210,6 +210,18 @@ def test_server():
|
||||
Start an isolated test server on TEST_PORT with a clean state directory.
|
||||
Paths are discovered dynamically -- no hardcoded absolute path assumptions.
|
||||
"""
|
||||
# Kill any leftover process on the test port before starting.
|
||||
# Stale servers from QA harness runs or prior test sessions cause
|
||||
# conftest to think the server is already up, producing false failures.
|
||||
try:
|
||||
import subprocess as _sp
|
||||
_sp.run(['fuser', '-k', f'{TEST_PORT}/tcp'],
|
||||
capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
import time as _time
|
||||
_time.sleep(0.5) # brief pause to let the port release
|
||||
|
||||
# Clean slate
|
||||
if TEST_STATE_DIR.exists():
|
||||
shutil.rmtree(TEST_STATE_DIR)
|
||||
|
||||
@@ -6,8 +6,8 @@ tuples for different provider configurations.
|
||||
import api.config as config
|
||||
|
||||
|
||||
def _resolve_with_config(model_id, provider=None, base_url=None, default=None):
|
||||
"""Helper: temporarily set config.cfg model section, call resolve, restore."""
|
||||
def _resolve_with_config(model_id, provider=None, base_url=None, default=None, custom_providers=None):
|
||||
"""Helper: temporarily set config.cfg model/custom provider sections, call resolve, restore."""
|
||||
old_cfg = dict(config.cfg)
|
||||
model_cfg = {}
|
||||
if provider:
|
||||
@@ -17,6 +17,8 @@ def _resolve_with_config(model_id, provider=None, base_url=None, default=None):
|
||||
if default:
|
||||
model_cfg['default'] = default
|
||||
config.cfg['model'] = model_cfg if model_cfg else {}
|
||||
if custom_providers is not None:
|
||||
config.cfg['custom_providers'] = custom_providers
|
||||
try:
|
||||
return config.resolve_model_provider(model_id)
|
||||
finally:
|
||||
@@ -139,6 +141,23 @@ def test_slash_prefix_non_default_still_routes_openrouter():
|
||||
assert provider == 'openrouter'
|
||||
|
||||
|
||||
def test_custom_provider_model_with_slash_routes_to_named_custom_provider():
|
||||
"""Slash-containing custom endpoint model IDs must not be mistaken for OpenRouter models."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'google/gemma-4-26b-a4b',
|
||||
provider='openrouter',
|
||||
base_url='https://openrouter.ai/api/v1',
|
||||
custom_providers=[{
|
||||
'name': 'Local LM Studio',
|
||||
'base_url': 'http://lmstudio.local:1234/v1',
|
||||
'model': 'google/gemma-4-26b-a4b',
|
||||
}],
|
||||
)
|
||||
assert model == 'google/gemma-4-26b-a4b'
|
||||
assert provider == 'custom:local-lm-studio'
|
||||
assert base_url == 'http://lmstudio.local:1234/v1'
|
||||
|
||||
|
||||
# ── get_available_models() @provider: hint behaviour ──────────────────────
|
||||
|
||||
def _available_models_with_provider(provider):
|
||||
@@ -202,3 +221,108 @@ def test_default_provider_models_not_prefixed():
|
||||
assert bare_id in returned_ids, (
|
||||
f"_PROVIDER_MODELS entry '{bare_id}' is missing from the Anthropic group"
|
||||
)
|
||||
|
||||
|
||||
# ── get_available_models(): phantom "Custom" group regression ─────────────
|
||||
#
|
||||
# When the user has model.provider set to a real provider (e.g. openai-codex)
|
||||
# AND a model.base_url set, hermes_cli reports the 'custom' pseudo-provider as
|
||||
# authenticated. The WebUI picker must NOT build a separate "Custom" group in
|
||||
# that case — the base_url belongs to the active provider.
|
||||
|
||||
def _available_models_with_full_cfg(provider, default, base_url):
|
||||
"""Helper: set model.provider, model.default, model.base_url at once.
|
||||
|
||||
Clears model-override env vars (HERMES_MODEL, OPENAI_MODEL, LLM_MODEL)
|
||||
during the call so the real hermes profile environment doesn't leak into
|
||||
the test and override the fixture's default model.
|
||||
"""
|
||||
import os
|
||||
import api.config as _cfg
|
||||
old_cfg = dict(_cfg.cfg)
|
||||
_cfg.cfg['model'] = {
|
||||
'provider': provider,
|
||||
'default': default,
|
||||
'base_url': base_url,
|
||||
}
|
||||
# Clear model-override env vars to prevent the real profile from leaking in
|
||||
_model_env_keys = ('HERMES_MODEL', 'OPENAI_MODEL', 'LLM_MODEL')
|
||||
_saved_env = {k: os.environ.pop(k, None) for k in _model_env_keys}
|
||||
try:
|
||||
return _cfg.get_available_models()
|
||||
finally:
|
||||
_cfg.cfg.clear()
|
||||
_cfg.cfg.update(old_cfg)
|
||||
for k, v in _saved_env.items():
|
||||
if v is not None:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def test_no_phantom_custom_group_when_active_provider_is_set(monkeypatch):
|
||||
"""Issue: with provider=openai-codex + base_url set, gpt-5.4 was landing
|
||||
under a phantom "Custom" group instead of the "OpenAI Codex" group."""
|
||||
import sys, types
|
||||
|
||||
# Force hermes_cli to report both the real provider and the phantom
|
||||
# 'custom' as authenticated, simulating what list_available_providers()
|
||||
# returns when base_url is configured.
|
||||
fake_mod = types.ModuleType('hermes_cli.models')
|
||||
fake_mod.list_available_providers = lambda: [
|
||||
{'id': 'openai-codex', 'authenticated': True},
|
||||
{'id': 'custom', 'authenticated': True},
|
||||
]
|
||||
fake_auth = types.ModuleType('hermes_cli.auth')
|
||||
fake_auth.get_auth_status = lambda pid: {'key_source': 'env'}
|
||||
monkeypatch.setitem(sys.modules, 'hermes_cli.models', fake_mod)
|
||||
monkeypatch.setitem(sys.modules, 'hermes_cli.auth', fake_auth)
|
||||
|
||||
result = _available_models_with_full_cfg(
|
||||
provider='openai-codex',
|
||||
default='gpt-5.4',
|
||||
base_url='https://chatgpt.com/backend-api/codex',
|
||||
)
|
||||
group_names = [g['provider'] for g in result['groups']]
|
||||
assert 'Custom' not in group_names, (
|
||||
f"Phantom 'Custom' group present; full groups: {group_names}"
|
||||
)
|
||||
|
||||
|
||||
def test_default_model_lands_under_active_provider_group(monkeypatch):
|
||||
"""The configured default_model must appear under the active provider's
|
||||
display group, even when the model isn't in _PROVIDER_MODELS[provider]
|
||||
AND the active provider isn't the alphabetical first detected provider.
|
||||
|
||||
Regression guard for a hyphen-vs-space bug in the "ensure default_model
|
||||
appears" post-pass: the substring check `active_provider.lower() in
|
||||
g.get('provider', '').lower()` was failing for 'openai-codex' vs
|
||||
display name 'OpenAI Codex' (hyphen vs. space), silently falling back
|
||||
to groups[0] — which, when another provider sorted earlier
|
||||
alphabetically (e.g. 'anthropic'), placed gpt-5.4 in the WRONG group.
|
||||
"""
|
||||
import sys, types
|
||||
fake_mod = types.ModuleType('hermes_cli.models')
|
||||
fake_mod.list_available_providers = lambda: [
|
||||
{'id': 'anthropic', 'authenticated': True}, # sorts before openai-codex
|
||||
{'id': 'openai-codex', 'authenticated': True},
|
||||
{'id': 'custom', 'authenticated': True},
|
||||
]
|
||||
fake_auth = types.ModuleType('hermes_cli.auth')
|
||||
fake_auth.get_auth_status = lambda pid: {'key_source': 'env'}
|
||||
monkeypatch.setitem(sys.modules, 'hermes_cli.models', fake_mod)
|
||||
monkeypatch.setitem(sys.modules, 'hermes_cli.auth', fake_auth)
|
||||
|
||||
result = _available_models_with_full_cfg(
|
||||
provider='openai-codex',
|
||||
default='gpt-5.4',
|
||||
base_url='https://chatgpt.com/backend-api/codex',
|
||||
)
|
||||
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
|
||||
assert 'OpenAI Codex' in groups, f"OpenAI Codex group missing: {list(groups)}"
|
||||
assert 'gpt-5.4' in groups['OpenAI Codex'], (
|
||||
f"gpt-5.4 not in OpenAI Codex group; contents: {groups['OpenAI Codex']}"
|
||||
)
|
||||
# And crucially, it must NOT have landed in the alphabetically-first
|
||||
# group (Anthropic) via the fallback path.
|
||||
assert 'gpt-5.4' not in groups.get('Anthropic', []), (
|
||||
f"gpt-5.4 leaked into Anthropic group via fallback: {groups.get('Anthropic')}"
|
||||
)
|
||||
|
||||
@@ -154,8 +154,15 @@ class TestSessionIDValidation:
|
||||
result = Session.load(valid_hex)
|
||||
assert result is None # No file, but no error
|
||||
|
||||
def test_new_format_session_id_passes_validation(self):
|
||||
"""New hermes-agent session IDs (YYYYMMDD_HHMMSS_xxxxxx) must pass validation."""
|
||||
from api.models import Session
|
||||
# Should pass the validator (returns None only because the file doesn't exist)
|
||||
result = Session.load("20260406_164014_74b2d1")
|
||||
assert result is None # file doesn't exist, but validator passed
|
||||
|
||||
def test_non_hex_session_id_rejected(self):
|
||||
"""A session ID with non-hex chars must be rejected."""
|
||||
"""A session ID with dangerous chars must be rejected."""
|
||||
from api.models import Session
|
||||
evil_ids = [
|
||||
"../../../etc/passwd",
|
||||
@@ -163,11 +170,15 @@ class TestSessionIDValidation:
|
||||
"session; rm -rf /",
|
||||
"hello world",
|
||||
"ZZZZZZZZZZZZZZZZ",
|
||||
"session\x00evil",
|
||||
"..\\..\\windows\\system32",
|
||||
"session/../../etc/passwd",
|
||||
"valid_looking.json",
|
||||
]
|
||||
for sid in evil_ids:
|
||||
result = Session.load(sid)
|
||||
assert result is None, \
|
||||
f"Session.load should reject non-hex ID '{sid}', got {result}"
|
||||
f"Session.load should reject dangerous ID '{sid}', got {result}"
|
||||
|
||||
def test_empty_session_id_rejected(self):
|
||||
"""An empty session ID must be rejected."""
|
||||
|
||||
@@ -280,3 +280,107 @@ class TestApprovalRespondHTTP:
|
||||
assert status == 200
|
||||
assert "choice" in result
|
||||
assert result["choice"] == "always"
|
||||
|
||||
|
||||
class TestApprovalCardTimerLogic:
|
||||
"""Tests for the 30s minimum visibility guard introduced in PR #225."""
|
||||
|
||||
def _get_js(self):
|
||||
return pathlib.Path(__file__).parent.parent / 'static' / 'messages.js'
|
||||
|
||||
def test_approval_min_visible_ms_constant_present(self):
|
||||
"""APPROVAL_MIN_VISIBLE_MS constant exists and is 30000."""
|
||||
src = self._get_js().read_text()
|
||||
assert 'APPROVAL_MIN_VISIBLE_MS' in src
|
||||
import re
|
||||
m = re.search(r'APPROVAL_MIN_VISIBLE_MS\s*=\s*(\d+)', src)
|
||||
assert m is not None, 'APPROVAL_MIN_VISIBLE_MS not assigned'
|
||||
assert int(m.group(1)) == 30000, f'Expected 30000, got {m.group(1)}'
|
||||
|
||||
def test_hide_approval_card_has_force_parameter(self):
|
||||
"""hideApprovalCard() accepts a force parameter."""
|
||||
src = self._get_js().read_text()
|
||||
assert 'hideApprovalCard(force=false)' in src or \
|
||||
'hideApprovalCard(force = false)' in src, \
|
||||
'hideApprovalCard must have force=false default parameter'
|
||||
|
||||
def test_hide_approval_card_checks_force_flag(self):
|
||||
"""hideApprovalCard body has a conditional on force."""
|
||||
src = self._get_js().read_text()
|
||||
# The guard: if (!force && _approvalVisibleSince)
|
||||
assert '!force' in src, 'hideApprovalCard must check !force before deferred hide'
|
||||
|
||||
def test_approval_hide_timer_variable_present(self):
|
||||
"""Module-level _approvalHideTimer variable is declared."""
|
||||
src = self._get_js().read_text()
|
||||
assert '_approvalHideTimer' in src
|
||||
|
||||
def test_approval_visible_since_variable_present(self):
|
||||
"""Module-level _approvalVisibleSince variable is declared."""
|
||||
src = self._get_js().read_text()
|
||||
assert '_approvalVisibleSince' in src
|
||||
|
||||
def test_approval_signature_variable_present(self):
|
||||
"""Module-level _approvalSignature variable is declared."""
|
||||
src = self._get_js().read_text()
|
||||
assert '_approvalSignature' in src
|
||||
|
||||
def test_respond_approval_calls_hide_with_force(self):
|
||||
"""respondApproval must call hideApprovalCard(true) — not no-arg."""
|
||||
src = self._get_js().read_text()
|
||||
# Extract respondApproval function body
|
||||
import re
|
||||
m = re.search(r'async function respondApproval.*?(?=\nasync function|\nfunction |\Z)',
|
||||
src, re.DOTALL)
|
||||
assert m, 'respondApproval function not found'
|
||||
body = m.group(0)
|
||||
# Must call hideApprovalCard(true), not the bare hideApprovalCard()
|
||||
assert 'hideApprovalCard(true)' in body, \
|
||||
'respondApproval must call hideApprovalCard(true) so card hides immediately after user clicks'
|
||||
# Must NOT have bare hideApprovalCard() without force
|
||||
bare_calls = re.findall(r'hideApprovalCard\((?!true)', body)
|
||||
assert not bare_calls, \
|
||||
f'respondApproval has bare hideApprovalCard() calls (no force=true): {bare_calls}'
|
||||
|
||||
def test_stream_done_calls_hide_with_force(self):
|
||||
"""Done SSE event handler must call hideApprovalCard(true)."""
|
||||
src = self._get_js().read_text()
|
||||
# Find the done event handler section (stopApprovalPolling followed by hideApprovalCard)
|
||||
import re
|
||||
# Look for pattern: stopApprovalPolling();\n + hideApprovalCard
|
||||
matches = re.findall(
|
||||
r'stopApprovalPolling\(\);\s*\n\s*if\(!_approvalSessionId[^)]*\)\s*hideApprovalCard\((\w*)\)',
|
||||
src
|
||||
)
|
||||
# All stopApprovalPolling paths that call hideApprovalCard should use force=true
|
||||
for match in matches:
|
||||
assert match == 'true', \
|
||||
f'After stopApprovalPolling(), hideApprovalCard called without force=true (got: {match!r})'
|
||||
|
||||
def test_poll_loop_still_uses_no_force(self):
|
||||
"""Poll loop hideApprovalCard() (when pending gone) keeps no-force — correct behavior."""
|
||||
src = self._get_js().read_text()
|
||||
# Line 446: else { hideApprovalCard(); } — this is the poll-loop path
|
||||
# The 30s guard should protect this call (don't force from poll ticks)
|
||||
assert 'else { hideApprovalCard(); }' in src or \
|
||||
'else {hideApprovalCard();}' in src or \
|
||||
'else { hideApprovalCard() }' in src, \
|
||||
'Poll loop should still call hideApprovalCard() without force=true'
|
||||
|
||||
def test_show_approval_card_signature_dedup(self):
|
||||
"""showApprovalCard uses a signature to avoid resetting timer on repeat polls."""
|
||||
src = self._get_js().read_text()
|
||||
# The sig computation must use JSON.stringify on card content
|
||||
import re
|
||||
m = re.search(r'function showApprovalCard.*?(?=\nfunction |\nasync function |\Z)',
|
||||
src, re.DOTALL)
|
||||
assert m, 'showApprovalCard function not found'
|
||||
body = m.group(0)
|
||||
assert 'JSON.stringify' in body, 'showApprovalCard must compute a signature via JSON.stringify'
|
||||
assert '_approvalSignature' in body, 'showApprovalCard must check/set _approvalSignature'
|
||||
|
||||
def test_clear_approval_hide_timer_helper_present(self):
|
||||
"""_clearApprovalHideTimer helper exists to cancel deferred hides."""
|
||||
src = self._get_js().read_text()
|
||||
assert '_clearApprovalHideTimer' in src, \
|
||||
'_clearApprovalHideTimer helper must exist to cancel deferred setTimeout'
|
||||
|
||||
71
tests/test_sprint32.py
Normal file
71
tests/test_sprint32.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
import subprocess
|
||||
from api.startup import auto_install_agent_deps
|
||||
|
||||
class TestAutoInstallAgentDeps:
|
||||
def test_installs_from_requirements_txt(self, tmp_path):
|
||||
agent_dir = tmp_path / 'hermes-agent'
|
||||
agent_dir.mkdir()
|
||||
req = agent_dir / 'requirements.txt'
|
||||
req.write_text('pyyaml\n')
|
||||
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||
assert auto_install_agent_deps() is True
|
||||
args = mock_run.call_args[0][0]
|
||||
assert '-r' in args and str(req) in args
|
||||
|
||||
def test_falls_back_to_pyproject(self, tmp_path):
|
||||
agent_dir = tmp_path / 'hermes-agent'
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / 'pyproject.toml').write_text('[project]\nname="hermes-agent"\n')
|
||||
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||
assert auto_install_agent_deps() is True
|
||||
args = mock_run.call_args[0][0]
|
||||
assert str(agent_dir) in args and '-r' not in args
|
||||
|
||||
def test_skips_when_agent_dir_missing(self, tmp_path, capsys):
|
||||
missing = tmp_path / 'nonexistent-agent'
|
||||
# Patch both HERMES_WEBUI_AGENT_DIR and HERMES_HOME so the fallback
|
||||
# path (HERMES_HOME/hermes-agent) also resolves to a nonexistent dir,
|
||||
# preventing the real agent dir from being found in the test environment.
|
||||
env_overrides = {
|
||||
'HERMES_WEBUI_AGENT_DIR': str(missing),
|
||||
'HERMES_HOME': str(tmp_path / 'no-hermes-home'),
|
||||
}
|
||||
with patch.dict('os.environ', env_overrides, clear=False):
|
||||
with patch('subprocess.run') as mock_run:
|
||||
assert auto_install_agent_deps() is False
|
||||
assert not mock_run.called
|
||||
assert 'skipped' in capsys.readouterr().out.lower()
|
||||
|
||||
def test_skips_when_no_install_file(self, tmp_path, capsys):
|
||||
agent_dir = tmp_path / 'hermes-agent'
|
||||
agent_dir.mkdir()
|
||||
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
|
||||
with patch('subprocess.run') as mock_run:
|
||||
assert auto_install_agent_deps() is False
|
||||
assert not mock_run.called
|
||||
assert 'skipped' in capsys.readouterr().out.lower()
|
||||
|
||||
def test_tolerates_pip_failure(self, tmp_path, capsys):
|
||||
agent_dir = tmp_path / 'hermes-agent'
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / 'requirements.txt').write_text('somepkg\n')
|
||||
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr='ERROR: could not find package')
|
||||
assert auto_install_agent_deps() is False
|
||||
assert 'failed' in capsys.readouterr().out.lower() or 'pip' in capsys.readouterr().out.lower()
|
||||
|
||||
def test_tolerates_timeout(self, tmp_path, capsys):
|
||||
agent_dir = tmp_path / 'hermes-agent'
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / 'requirements.txt').write_text('somepkg\n')
|
||||
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
|
||||
with patch('subprocess.run', side_effect=subprocess.TimeoutExpired('pip', 120)):
|
||||
assert auto_install_agent_deps() is False
|
||||
assert 'timed out' in capsys.readouterr().out.lower()
|
||||
317
tests/test_update_checker.py
Normal file
317
tests/test_update_checker.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Tests for api/updates.py -- specifically the diagnostic code paths added
|
||||
in fix/223-update-pull-failed-diagnostics (PR #227).
|
||||
|
||||
Tests cover the four new branches in _apply_update_inner():
|
||||
1. fetch fails → network error message
|
||||
2. pull fails + diverged history → recovery command with git reset --hard
|
||||
3. pull fails + no upstream tracking → recovery command with set-upstream-to
|
||||
4. pull fails + generic fallback → raw git output truncated at 300 chars
|
||||
"""
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, call
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_run_git_side_effect(*sequence):
|
||||
"""Return a side_effect function that yields successive (stdout, ok) tuples."""
|
||||
it = iter(sequence)
|
||||
def _side_effect(args, cwd, timeout=10):
|
||||
return next(it)
|
||||
return _side_effect
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path used for patching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MODULE = 'api.updates'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for _apply_update_inner() diagnostic paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApplyUpdateDiagnostics:
|
||||
"""New code paths introduced in PR #227."""
|
||||
|
||||
def _apply(self, target, run_git_side_effect):
|
||||
"""Call _apply_update_inner with _apply_lock bypassed and _run_git mocked."""
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}._run_git', side_effect=run_git_side_effect), \
|
||||
patch.object(updates, '_apply_lock') as mock_lock:
|
||||
mock_lock.acquire.return_value = True
|
||||
mock_lock.release.return_value = None
|
||||
return updates._apply_update_inner(target)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path 1: fetch step fails → network error message
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_fetch_failure_returns_network_error_message(self, tmp_path):
|
||||
"""When git fetch fails, return a human-readable connection error."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
# Call sequence: upstream query, fetch
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True), # rev-parse @{upstream}
|
||||
('', False), # fetch fails
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
msg = result['message'].lower()
|
||||
assert 'could not reach' in msg or 'internet connection' in msg or 'remote repository' in msg
|
||||
|
||||
def test_fetch_failure_does_not_attempt_pull(self, tmp_path):
|
||||
"""When fetch fails, pull is never called."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True), # upstream query
|
||||
('', False), # fetch fails
|
||||
]
|
||||
updates._apply_update_inner('webui')
|
||||
# Only 2 calls: upstream query + fetch. No pull call.
|
||||
assert mock_run_git.call_count == 2
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path 2: pull fails + diverged history
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_diverged_history_returns_reset_hard_command(self, tmp_path):
|
||||
"""Diverged history produces a message with 'reset --hard'."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True), # upstream query
|
||||
('', True), # fetch succeeds
|
||||
('', True), # status --porcelain (clean)
|
||||
('Not possible to fast-forward, aborting.', False), # pull fails
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert result.get('diverged') is True
|
||||
msg = result['message']
|
||||
assert 'reset --hard' in msg
|
||||
|
||||
def test_diverged_history_message_contains_compare_ref(self, tmp_path):
|
||||
"""Diverged history message includes the upstream ref."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/feat/my-feature', True), # upstream query
|
||||
('', True), # fetch
|
||||
('', True), # status (clean)
|
||||
('Your branch and origin have diverged.', False), # pull
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'origin/feat/my-feature' in result['message']
|
||||
|
||||
def test_diverged_matching_is_case_insensitive(self, tmp_path):
|
||||
"""'DIVERGED' in uppercase is still detected."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True),
|
||||
('', True),
|
||||
('', True),
|
||||
('DIVERGED from upstream', False),
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert result.get('diverged') is True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path 3: pull fails + no upstream tracking configured
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_no_tracking_returns_set_upstream_command(self, tmp_path):
|
||||
"""Missing upstream tracking branch produces set-upstream-to message."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True), # upstream query
|
||||
('', True), # fetch
|
||||
('', True), # status (clean)
|
||||
('There is no tracking information for the current branch.', False), # pull
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'set-upstream-to' in result['message']
|
||||
assert result.get('diverged') is None
|
||||
|
||||
def test_no_tracking_alternate_phrasing(self, tmp_path):
|
||||
"""'does not track' alternate git message is also detected."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True),
|
||||
('', True),
|
||||
('', True),
|
||||
('fatal: The current branch local does not track a remote branch.', False),
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'set-upstream-to' in result['message']
|
||||
|
||||
def test_no_tracking_message_contains_compare_ref(self, tmp_path):
|
||||
"""set-upstream-to message includes the upstream ref to configure."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/main', True),
|
||||
('', True),
|
||||
('', True),
|
||||
('no tracking information', False),
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'origin/main' in result['message']
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path 4: pull fails + generic fallback (truncated raw output)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_generic_failure_includes_truncated_git_output(self, tmp_path):
|
||||
"""Generic pull failure includes up to 300 chars of git output."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
long_error = 'X' * 500 # 500-char error from git
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True),
|
||||
('', True),
|
||||
('', True),
|
||||
(long_error, False),
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
msg = result['message']
|
||||
# The raw output in the message must be truncated at 300 chars
|
||||
assert 'X' * 300 in msg
|
||||
assert 'X' * 301 not in msg
|
||||
|
||||
def test_generic_failure_empty_output_shows_sentinel(self, tmp_path):
|
||||
"""When git produces no output, message contains a fallback sentinel."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True),
|
||||
('', True),
|
||||
('', True),
|
||||
('', False), # pull fails with empty output
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'no output' in result['message'].lower() or result['message']
|
||||
|
||||
def test_generic_failure_does_not_set_diverged(self, tmp_path):
|
||||
"""A generic pull failure must not set diverged=True."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True),
|
||||
('', True),
|
||||
('', True),
|
||||
('Some unrecognized git error', False),
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert not result.get('diverged')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Regression: existing success path still works after fetch addition
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_successful_update_still_returns_ok(self, tmp_path):
|
||||
"""Fetch + status + pull success path returns ok=True (regression guard)."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
# Patch the cache's 'checked_at' key directly to avoid the lock
|
||||
# invalidation block raising. We use a fresh dict swap.
|
||||
fake_cache = {'webui': None, 'agent': None, 'checked_at': 1}
|
||||
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git, \
|
||||
patch(f'{_MODULE}._update_cache', fake_cache), \
|
||||
patch(f'{_MODULE}._cache_lock'):
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True), # upstream query
|
||||
('', True), # fetch succeeds
|
||||
('', True), # status (clean working tree)
|
||||
('Already up to date.', True), # pull succeeds
|
||||
]
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Agent target works the same as webui target
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_fetch_failure_for_agent_target(self, tmp_path):
|
||||
"""Fetch failure path also works when target='agent'."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
from api import updates
|
||||
with patch(f'{_MODULE}._AGENT_DIR', tmp_path), \
|
||||
patch(f'{_MODULE}._run_git') as mock_run_git:
|
||||
mock_run_git.side_effect = [
|
||||
('origin/master', True),
|
||||
('', False), # fetch fails
|
||||
]
|
||||
result = updates._apply_update_inner('agent')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'could not reach' in result['message'].lower() or \
|
||||
'internet' in result['message'].lower() or \
|
||||
'remote' in result['message'].lower()
|
||||
Reference in New Issue
Block a user