Merge pull request #121 from nesquena/feat/self-update-checker
feat: self-update checker with one-click update
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -5,6 +5,38 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [v0.36] Self-Update Checker with One-Click Update
|
||||||
|
*April 5, 2026 | 433 tests*
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Update checker.** Non-blocking background check on boot detects when the
|
||||||
|
WebUI or hermes-agent git repos are behind upstream. Blue banner shows
|
||||||
|
"WebUI: N updates, Agent: N updates available" with Update Now / Later.
|
||||||
|
- **One-click update.** "Update Now" runs `git stash && git pull --ff-only &&
|
||||||
|
git stash pop` on each behind repo, then reloads the page. Concurrent update
|
||||||
|
attempts blocked via lock. Dirty working trees safely stashed and restored.
|
||||||
|
- **Settings toggle.** "Check for updates" checkbox in Settings panel. Persisted
|
||||||
|
server-side. Disabled = no background fetch, no banner.
|
||||||
|
- **30-minute cache.** Git fetch runs at most twice per hour regardless of tab
|
||||||
|
count. Results cached server-side with TTL.
|
||||||
|
- **Session-scoped dismissal.** "Later" dismisses banner for the current tab
|
||||||
|
session (sessionStorage). New tabs get a fresh check.
|
||||||
|
- **Test mode.** `?test_updates=1` URL param shows the banner with fake data
|
||||||
|
(localhost only) for UI testing without needing to actually be behind.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- New `api/updates.py`: `check_for_updates()`, `apply_update()`. Thread-safe
|
||||||
|
caching with `_cache_lock`. Concurrent apply blocked with `_apply_lock`.
|
||||||
|
Default branch auto-detected (master/main).
|
||||||
|
- `api/routes.py`: `GET /api/updates/check`, `POST /api/updates/apply`.
|
||||||
|
Simulate endpoint gated to 127.0.0.1.
|
||||||
|
- `static/ui.js`: `_showUpdateBanner()`, `dismissUpdate()`, `applyUpdates()`.
|
||||||
|
- `static/boot.js`: fire-and-forget check on boot (does not block UI).
|
||||||
|
- `api/config.py`: `check_for_updates` in settings defaults + bool keys.
|
||||||
|
- Docker safe: all git ops gated by `.git` directory existence check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v0.35.1] Model dropdown fixes
|
## [v0.35.1] Model dropdown fixes
|
||||||
*April 5, 2026 | 433 tests*
|
*April 5, 2026 | 433 tests*
|
||||||
|
|
||||||
@@ -1236,4 +1268,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: v0.34, April 5, 2026 | Tests: 433*
|
*Last updated: v0.36, April 5, 2026 | Tests: 433*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hermes Web UI -- Forward Sprint Plan
|
# Hermes Web UI -- Forward Sprint Plan
|
||||||
|
|
||||||
> Current state: v0.35 | 433 tests | Daily driver ready
|
> Current state: v0.36 | 433 tests | Daily driver ready
|
||||||
> This document plans the path from here to two targets:
|
> This document plans the path from here to two targets:
|
||||||
>
|
>
|
||||||
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
||||||
@@ -1156,6 +1156,6 @@ New test cases in `tests/test_sprint26.py`:
|
|||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: April 5, 2026*
|
*Last updated: April 5, 2026*
|
||||||
*Current version: v0.35 | 433 tests*
|
*Current version: v0.36 | 433 tests*
|
||||||
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
||||||
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
|
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
|
||||||
|
|||||||
@@ -674,6 +674,7 @@ _SETTINGS_DEFAULTS = {
|
|||||||
'show_token_usage': False, # show input/output token badge below assistant messages
|
'show_token_usage': False, # show input/output token badge below assistant messages
|
||||||
'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar
|
'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar
|
||||||
'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights
|
'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights
|
||||||
|
'check_for_updates': True, # check if webui/agent repos are behind upstream
|
||||||
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
|
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
|
||||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||||
}
|
}
|
||||||
@@ -694,7 +695,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
|||||||
_SETTINGS_ENUM_VALUES = {
|
_SETTINGS_ENUM_VALUES = {
|
||||||
'send_key': {'enter', 'ctrl+enter'},
|
'send_key': {'enter', 'ctrl+enter'},
|
||||||
}
|
}
|
||||||
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights'}
|
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates'}
|
||||||
|
|
||||||
def save_settings(settings: dict) -> dict:
|
def save_settings(settings: dict) -> dict:
|
||||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||||
|
|||||||
@@ -227,6 +227,22 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
info = git_info_for_workspace(Path(s.workspace))
|
info = git_info_for_workspace(Path(s.workspace))
|
||||||
return j(handler, {'git': info})
|
return j(handler, {'git': info})
|
||||||
|
|
||||||
|
if parsed.path == '/api/updates/check':
|
||||||
|
settings = load_settings()
|
||||||
|
if not settings.get('check_for_updates', True):
|
||||||
|
return j(handler, {'disabled': True})
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
force = qs.get('force', ['0'])[0] == '1'
|
||||||
|
# ?simulate=1 returns fake behind counts for UI testing (localhost only)
|
||||||
|
if qs.get('simulate', ['0'])[0] == '1' and handler.client_address[0] == '127.0.0.1':
|
||||||
|
return j(handler, {
|
||||||
|
'webui': {'name': 'webui', 'behind': 3, 'current_sha': 'abc1234', 'latest_sha': 'def5678', 'branch': 'master'},
|
||||||
|
'agent': {'name': 'agent', 'behind': 1, 'current_sha': 'aaa0001', 'latest_sha': 'bbb0002', 'branch': 'master'},
|
||||||
|
'checked_at': 0,
|
||||||
|
})
|
||||||
|
from api.updates import check_for_updates
|
||||||
|
return j(handler, check_for_updates(force=force))
|
||||||
|
|
||||||
if parsed.path == '/api/chat/stream/status':
|
if parsed.path == '/api/chat/stream/status':
|
||||||
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
|
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
|
||||||
return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id})
|
return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id})
|
||||||
@@ -600,6 +616,14 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
if parsed.path == '/api/session/import':
|
if parsed.path == '/api/session/import':
|
||||||
return _handle_session_import(handler, body)
|
return _handle_session_import(handler, body)
|
||||||
|
|
||||||
|
# ── Self-update (POST) ──
|
||||||
|
if parsed.path == '/api/updates/apply':
|
||||||
|
target = body.get('target', '')
|
||||||
|
if target not in ('webui', 'agent'):
|
||||||
|
return bad(handler, 'target must be "webui" or "agent"')
|
||||||
|
from api.updates import apply_update
|
||||||
|
return j(handler, apply_update(target))
|
||||||
|
|
||||||
# ── CLI session import (POST) ──
|
# ── CLI session import (POST) ──
|
||||||
if parsed.path == '/api/session/import_cli':
|
if parsed.path == '/api/session/import_cli':
|
||||||
return _handle_session_import_cli(handler, body)
|
return _handle_session_import_cli(handler, body)
|
||||||
|
|||||||
164
api/updates.py
Normal file
164
api/updates.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
Hermes Web UI -- Self-update checker.
|
||||||
|
|
||||||
|
Checks if the webui and hermes-agent git repos are behind their upstream
|
||||||
|
branches. Results are cached server-side (30-min TTL) so git fetch runs
|
||||||
|
at most twice per hour regardless of client count.
|
||||||
|
|
||||||
|
Skips repos that are not git checkouts (e.g. Docker baked images where
|
||||||
|
.git does not exist).
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from api.config import REPO_ROOT
|
||||||
|
|
||||||
|
# Lazy -- may be None if agent not found
|
||||||
|
try:
|
||||||
|
from api.config import _AGENT_DIR
|
||||||
|
except ImportError:
|
||||||
|
_AGENT_DIR = None
|
||||||
|
|
||||||
|
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_check_in_progress = False
|
||||||
|
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
|
||||||
|
CACHE_TTL = 1800 # 30 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(args, cwd, timeout=10):
|
||||||
|
"""Run a git command and return (stdout, ok)."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
['git'] + args, cwd=str(cwd), capture_output=True,
|
||||||
|
text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
return r.stdout.strip(), r.returncode == 0
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return '', False
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_default_branch(path):
|
||||||
|
"""Detect the remote default branch (master or main)."""
|
||||||
|
out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path)
|
||||||
|
if ok and out:
|
||||||
|
# refs/remotes/origin/master -> master
|
||||||
|
return out.split('/')[-1]
|
||||||
|
# Fallback: try master, then main
|
||||||
|
for branch in ('master', 'main'):
|
||||||
|
_, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path)
|
||||||
|
if ok:
|
||||||
|
return branch
|
||||||
|
return 'master'
|
||||||
|
|
||||||
|
|
||||||
|
def _check_repo(path, name):
|
||||||
|
"""Check if a git repo is behind its upstream. Returns dict or None."""
|
||||||
|
if path is None or not (path / '.git').exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fetch latest from origin (network call, cached by TTL)
|
||||||
|
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||||
|
if not fetch_ok:
|
||||||
|
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
|
||||||
|
|
||||||
|
branch = _detect_default_branch(path)
|
||||||
|
|
||||||
|
# Count commits behind
|
||||||
|
out, ok = _run_git(['rev-list', '--count', f'HEAD..origin/{branch}'], path)
|
||||||
|
behind = int(out) if ok and out.isdigit() else 0
|
||||||
|
|
||||||
|
# Get short SHAs for display
|
||||||
|
current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path)
|
||||||
|
latest, _ = _run_git(['rev-parse', '--short', f'origin/{branch}'], path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'behind': behind,
|
||||||
|
'current_sha': current,
|
||||||
|
'latest_sha': latest,
|
||||||
|
'branch': branch,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_updates(force=False):
|
||||||
|
"""Return cached update status for webui and agent repos."""
|
||||||
|
global _check_in_progress
|
||||||
|
with _cache_lock:
|
||||||
|
if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL:
|
||||||
|
return dict(_update_cache)
|
||||||
|
if _check_in_progress:
|
||||||
|
return dict(_update_cache) # another thread is already checking
|
||||||
|
_check_in_progress = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run checks outside the lock (network I/O)
|
||||||
|
webui_info = _check_repo(REPO_ROOT, 'webui')
|
||||||
|
agent_info = _check_repo(_AGENT_DIR, 'agent')
|
||||||
|
|
||||||
|
with _cache_lock:
|
||||||
|
_update_cache['webui'] = webui_info
|
||||||
|
_update_cache['agent'] = agent_info
|
||||||
|
_update_cache['checked_at'] = time.time()
|
||||||
|
return dict(_update_cache)
|
||||||
|
finally:
|
||||||
|
_check_in_progress = False
|
||||||
|
|
||||||
|
|
||||||
|
def apply_update(target):
|
||||||
|
"""Stash, pull --ff-only, pop for the given target repo."""
|
||||||
|
if not _apply_lock.acquire(blocking=False):
|
||||||
|
return {'ok': False, 'message': 'Update already in progress'}
|
||||||
|
try:
|
||||||
|
return _apply_update_inner(target)
|
||||||
|
finally:
|
||||||
|
_apply_lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_update_inner(target):
|
||||||
|
"""Inner implementation of apply_update, called under _apply_lock."""
|
||||||
|
if target == 'webui':
|
||||||
|
path = REPO_ROOT
|
||||||
|
elif target == 'agent':
|
||||||
|
path = _AGENT_DIR
|
||||||
|
else:
|
||||||
|
return {'ok': False, 'message': f'Unknown target: {target}'}
|
||||||
|
|
||||||
|
if path is None or not (path / '.git').exists():
|
||||||
|
return {'ok': False, 'message': 'Not a git repository'}
|
||||||
|
|
||||||
|
branch = _detect_default_branch(path)
|
||||||
|
|
||||||
|
# Check for dirty working tree
|
||||||
|
status_out, _ = _run_git(['status', '--porcelain'], path)
|
||||||
|
stashed = False
|
||||||
|
if status_out:
|
||||||
|
_, ok = _run_git(['stash'], path)
|
||||||
|
if not ok:
|
||||||
|
return {'ok': False, 'message': 'Failed to stash local changes'}
|
||||||
|
stashed = True
|
||||||
|
|
||||||
|
# Pull with ff-only (no merge commits)
|
||||||
|
pull_out, pull_ok = _run_git(['pull', '--ff-only', 'origin', branch], path, timeout=30)
|
||||||
|
if not pull_ok:
|
||||||
|
if stashed:
|
||||||
|
_run_git(['stash', 'pop'], path)
|
||||||
|
return {'ok': False, 'message': f'Pull failed: {pull_out[:200]}'}
|
||||||
|
|
||||||
|
# Pop stash if we stashed
|
||||||
|
if stashed:
|
||||||
|
_, pop_ok = _run_git(['stash', 'pop'], path)
|
||||||
|
if not pop_ok:
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'message': 'Updated but stash pop failed -- manual merge needed',
|
||||||
|
'stash_conflict': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Invalidate cache
|
||||||
|
with _cache_lock:
|
||||||
|
_update_cache['checked_at'] = 0
|
||||||
|
|
||||||
|
return {'ok': True, 'message': f'{target} updated successfully', 'target': target}
|
||||||
@@ -308,7 +308,15 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
|||||||
|
|
||||||
(async()=>{
|
(async()=>{
|
||||||
// Load send key preference
|
// Load send key preference
|
||||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
let _bootSettings={};
|
||||||
|
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;_bootSettings={check_for_updates:false};}
|
||||||
|
// Non-blocking update check (fire-and-forget, once per tab session)
|
||||||
|
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
|
||||||
|
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
||||||
|
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
|
||||||
|
const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':'');
|
||||||
|
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
||||||
|
}
|
||||||
// Fetch active profile
|
// Fetch active profile
|
||||||
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
||||||
// Update profile chip label immediately
|
// Update profile chip label immediately
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<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.35.1</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.36</div></div></div>
|
||||||
<div class="sidebar-nav">
|
<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 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="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||||
@@ -203,6 +203,13 @@
|
|||||||
<div class="messages-inner" id="msgInner"></div>
|
<div class="messages-inner" id="msgInner"></div>
|
||||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="update-banner" id="updateBanner">
|
||||||
|
<span id="updateMsg"></span>
|
||||||
|
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||||
|
<button class="update-btn" onclick="dismissUpdate()">Later</button>
|
||||||
|
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="reconnect-banner" id="reconnectBanner">
|
<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">⚠ A response may have been in progress when you last left. Reload messages?</span>
|
||||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||||
@@ -319,10 +326,6 @@
|
|||||||
<label for="settingsModel">Default Model</label>
|
<label for="settingsModel">Default Model</label>
|
||||||
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field">
|
|
||||||
<label for="settingsWorkspace">Default Workspace</label>
|
|
||||||
<select id="settingsWorkspace" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
|
||||||
</div>
|
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label for="settingsSendKey">Send Key</label>
|
<label for="settingsSendKey">Send Key</label>
|
||||||
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||||
@@ -362,6 +365,13 @@
|
|||||||
</label>
|
</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">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
|
||||||
</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)">
|
||||||
|
Check for updates
|
||||||
|
</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>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword">Access Password</label>
|
<label for="settingsPassword">Access Password</label>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
<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>
|
||||||
|
|||||||
@@ -995,21 +995,6 @@ async function loadSettingsPanel(){
|
|||||||
modelSel.value=settings.default_model||'';
|
modelSel.value=settings.default_model||'';
|
||||||
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
|
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||||
}
|
}
|
||||||
// Populate workspace dropdown from /api/workspaces
|
|
||||||
const wsSel=$('settingsWorkspace');
|
|
||||||
if(wsSel){
|
|
||||||
wsSel.innerHTML='';
|
|
||||||
try{
|
|
||||||
const wsData=await api('/api/workspaces');
|
|
||||||
for(const w of (wsData.workspaces||[])){
|
|
||||||
const opt=document.createElement('option');
|
|
||||||
opt.value=w.path;opt.textContent=w.name||w.path;
|
|
||||||
wsSel.appendChild(opt);
|
|
||||||
}
|
|
||||||
}catch(e){}
|
|
||||||
wsSel.value=settings.default_workspace||'';
|
|
||||||
wsSel.addEventListener('change',_markSettingsDirty,{once:false});
|
|
||||||
}
|
|
||||||
// Send key preference
|
// Send key preference
|
||||||
const sendKeySel=$('settingsSendKey');
|
const sendKeySel=$('settingsSendKey');
|
||||||
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
|
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
@@ -1022,6 +1007,8 @@ async function loadSettingsPanel(){
|
|||||||
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
const syncCb=$('settingsSyncInsights');
|
const syncCb=$('settingsSyncInsights');
|
||||||
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
|
const updateCb=$('settingsCheckUpdates');
|
||||||
|
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
// Password field: always blank (we don't send hash back)
|
// Password field: always blank (we don't send hash back)
|
||||||
const pwField=$('settingsPassword');
|
const pwField=$('settingsPassword');
|
||||||
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
|
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||||
@@ -1041,7 +1028,6 @@ async function loadSettingsPanel(){
|
|||||||
|
|
||||||
async function saveSettings(andClose){
|
async function saveSettings(andClose){
|
||||||
const model=($('settingsModel')||{}).value;
|
const model=($('settingsModel')||{}).value;
|
||||||
const workspace=($('settingsWorkspace')||{}).value;
|
|
||||||
const sendKey=($('settingsSendKey')||{}).value;
|
const sendKey=($('settingsSendKey')||{}).value;
|
||||||
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
||||||
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
||||||
@@ -1049,12 +1035,13 @@ async function saveSettings(andClose){
|
|||||||
const theme=($('settingsTheme')||{}).value||'dark';
|
const theme=($('settingsTheme')||{}).value||'dark';
|
||||||
const body={};
|
const body={};
|
||||||
if(model) body.default_model=model;
|
if(model) body.default_model=model;
|
||||||
if(workspace) body.default_workspace=workspace;
|
|
||||||
if(sendKey) body.send_key=sendKey;
|
if(sendKey) body.send_key=sendKey;
|
||||||
body.theme=theme;
|
body.theme=theme;
|
||||||
body.show_token_usage=showTokenUsage;
|
body.show_token_usage=showTokenUsage;
|
||||||
body.show_cli_sessions=showCliSessions;
|
body.show_cli_sessions=showCliSessions;
|
||||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||||
|
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||||
// Password: only act if the field has content; blank = leave auth unchanged
|
// Password: only act if the field has content; blank = leave auth unchanged
|
||||||
if(pw && pw.trim()){
|
if(pw && pw.trim()){
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -145,6 +145,13 @@
|
|||||||
.reconnect-banner.visible{display:flex;}
|
.reconnect-banner.visible{display:flex;}
|
||||||
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
|
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
|
||||||
.reconnect-btn:hover{background:rgba(201,168,76,0.25);}
|
.reconnect-btn:hover{background:rgba(201,168,76,0.25);}
|
||||||
|
/* ── Update banner ── */
|
||||||
|
.update-banner{display:none;background:var(--surface);border:1px solid rgba(124,185,255,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--blue);align-items:center;justify-content:space-between;gap:12px;}
|
||||||
|
.update-banner.visible{display:flex;}
|
||||||
|
.update-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.3);color:var(--blue);cursor:pointer;transition:background .15s;}
|
||||||
|
.update-btn:hover{background:rgba(124,185,255,0.2);}
|
||||||
|
.update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);}
|
||||||
|
.update-btn:disabled{opacity:0.5;cursor:not-allowed;}
|
||||||
/* ── Approval card ── */
|
/* ── Approval card ── */
|
||||||
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
|
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
|
||||||
.approval-card.visible{display:block;}
|
.approval-card.visible{display:block;}
|
||||||
|
|||||||
41
static/ui.js
41
static/ui.js
@@ -334,6 +334,47 @@ async function refreshSession() {
|
|||||||
showToast('Conversation refreshed');
|
showToast('Conversation refreshed');
|
||||||
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
// ── Update banner ──
|
||||||
|
function _showUpdateBanner(data){
|
||||||
|
const parts=[];
|
||||||
|
if(data.webui&&data.webui.behind>0) parts.push(`WebUI: ${data.webui.behind} update${data.webui.behind>1?'s':''}`);
|
||||||
|
if(data.agent&&data.agent.behind>0) parts.push(`Agent: ${data.agent.behind} update${data.agent.behind>1?'s':''}`);
|
||||||
|
if(!parts.length)return;
|
||||||
|
const msg=$('updateMsg');
|
||||||
|
if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available';
|
||||||
|
const banner=$('updateBanner');
|
||||||
|
if(banner) banner.classList.add('visible');
|
||||||
|
window._updateData=data;
|
||||||
|
}
|
||||||
|
function dismissUpdate(){
|
||||||
|
const b=$('updateBanner');if(b)b.classList.remove('visible');
|
||||||
|
sessionStorage.setItem('hermes-update-dismissed','1');
|
||||||
|
}
|
||||||
|
async function applyUpdates(){
|
||||||
|
const btn=$('btnApplyUpdate');
|
||||||
|
if(btn){btn.disabled=true;btn.textContent='Updating\u2026';}
|
||||||
|
const targets=[];
|
||||||
|
if(window._updateData?.webui?.behind>0) targets.push('webui');
|
||||||
|
if(window._updateData?.agent?.behind>0) targets.push('agent');
|
||||||
|
try{
|
||||||
|
for(const target of targets){
|
||||||
|
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})});
|
||||||
|
if(!res.ok){
|
||||||
|
showToast('Update failed ('+target+'): '+(res.message||'unknown error'));
|
||||||
|
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast('Updated! Reloading\u2026');
|
||||||
|
sessionStorage.removeItem('hermes-update-checked');
|
||||||
|
sessionStorage.removeItem('hermes-update-dismissed');
|
||||||
|
setTimeout(()=>location.reload(),1500);
|
||||||
|
}catch(e){
|
||||||
|
showToast('Update failed: '+e.message);
|
||||||
|
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function checkInflightOnBoot(sid) {
|
async function checkInflightOnBoot(sid) {
|
||||||
const raw = localStorage.getItem(INFLIGHT_KEY);
|
const raw = localStorage.getItem(INFLIGHT_KEY);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user