mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Merge remote-tracking branch 'origin/master' into fix/session-personality-default
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -7,6 +7,30 @@
|
||||
|
||||
- New WebUI sessions no longer persist `display.personality` into per-session `Session.personality`; only explicit personality changes remain durable, preventing stale global display defaults from overriding profile-scoped session behavior. Closes #2845.
|
||||
|
||||
## [v0.51.129] — 2026-05-24 — Release DA (stage-batch11 — 4-PR feature + perf batch)
|
||||
|
||||
### Performance
|
||||
|
||||
- **PR #2836** by @v2psv — HTTP/1.1 keep-alive for WebUI responses. Bumps `Handler.protocol_version` from the HTTP/1.0 default to `HTTP/1.1` so browsers can reuse TCP connections across normal API and static-file requests. Adds explicit `Content-Length` headers to hand-written responses that weren't already using shared `j()` / `t()` helpers. Adds `Content-Length: 0` to empty redirect / range-error responses. Switches SSE-style streaming endpoints from `Connection: keep-alive` to `Connection: close` (keep-alive is only safe when the response body is framed; SSE bodies have no fixed length). Significant first-paint / session-open improvements on high-RTT / VPN / proxied paths — author reports ~47% faster first paint and ~30-40% improvements on panel-load flows on a typical remote-host setup.
|
||||
|
||||
**Opus pre-release advisor caught one missing framing site** in the on-the-fly folder ZIP download path (`/api/folder/download`): the body has no known length, doesn't use chunked encoding, and was relying on HTTP/1.0 connection-close-equals-EOF. Under HTTP/1.1 this would have left clients hanging waiting for the next response after the central-directory bytes finished. Patched inline before tag: add `Connection: close` header to mirror the SSE-endpoint pattern. Opus verified this was the ONLY remaining streaming response in the codebase that needed the header — all 12 hand-written response paths + 8 SSE streams + j()/t() helpers + auth flow were already correctly framed by the PR.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2680** by @mccxj — Auxiliary Models settings card in Settings → Preferences. Lets users configure per-task model routing for 9 canonical side-task slots: vision, web extract, compression, session search, approval, MCP tool reasoning, title generation, skills hub, curator. Each slot exposes a provider dropdown + model dropdown plus an "auto (use main model)" / "auto (use provider default)" pair so users can keep aux routing implicit when they don't care. New endpoints: `GET /api/model/auxiliary` returns current assignments; `POST /api/model/set` writes assignments (`scope=auxiliary` for aux slots, `scope=main` for the default chat model) and supports `task="__reset__"` to reset all slots back to auto. 16 new i18n keys added across all 12 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr — Turkish translations added in-stage to cover the sibling-PR collision with v0.51.127's Turkish locale baseline). 24 source-level test assertions covering HTML structure, JS logic, i18n parity, and route registration.
|
||||
|
||||
- **PR #2842** by @AJV20 — PWA polish for installed launches. New `static/pwa-startup.js` is loaded synchronously in `<head>` before the main UI bundle, so the page knows whether it's running standalone / in-browser / on iOS / offline before first paint. Marks `pwa-standalone`, `pwa-browser`, `pwa-ios`, `pwa-offline`, and short-lived `pwa-resumed` classes on `<html>`. Exposes `window.HermesPWA.{isStandalone, syncMode, launchAction, promptInstall}` helpers and captures `beforeinstallprompt` / `appinstalled` early enough that any future install-prompt UI can chain off them. Manifest gains app identity / scope / `display_override` (`window-controls-overlay` → `standalone` → `minimal-ui`) and a "New conversation" PWA App Shortcut. Service worker pre-caches the startup helper, switches navigation and shell-asset fetches to `cache: 'no-store'` before falling back to CacheStorage. Boot path wires `?source=pwa&action=new-chat` to start a fresh chat instead of reopening the last saved session. The viewport meta now sets `maximum-scale=1, user-scalable=no` for native-feel — acknowledged trade-off against WCAG 2.1 1.4.4 (Resize text), intentionally kept for the PWA-installed feel of this user base.
|
||||
|
||||
- **PR #2794** by @Michaelyklam — Runtime adapter route selection harness. Routes explicit adapter-mode chat starts through `build_runtime_adapter(...)` and keeps `legacy-direct` as the default `/api/chat/start` path. Continues the #1925 RFC slice progression: this is slice 4e, the default-off chat-start route-selection seam. Returns a bounded `501 Not Configured` response when `runner-local` is explicitly selected before a supervised runner client exists, instead of silently starting a legacy WebUI-owned run. New `_chat_start_response_from_run_start(...)` helper whitelists legacy-compatible chat-start response fields and keeps adapter-internal `run_id`, `status`, and `active_controls` out of public responses. Updates `docs/rfcs/hermes-run-adapter-contract.md` to mark #2744 shipped and define slice 4e.
|
||||
|
||||
### Notes
|
||||
|
||||
- Full pytest: **6,467 passed / 6 skipped / 3 xpassed / 8 subtests passed**.
|
||||
- Opus pre-release advisor reviewed all 7 risk areas (HTTP framing surface completeness, PWA startup ordering, sibling-PR `api/routes.py` interaction, service worker cache invalidation, viewport-meta trade-off, runtime adapter response shape, locale-counter brittleness). Verdict: **1 MUST-FIX patched inline** (folder ZIP `Connection: close` header), **0 inline SHOULD-FIX**, 1 follow-up suggested (`set_auxiliary_model` could validate `task` against `AUX_TASK_SLOTS` whitelist — auth-gated, low severity, filing as follow-up).
|
||||
- Agent self-verified: protocol_version bumped, SSE Connection-close + Content-Length plumbing, Auxiliary Models API surface (config + endpoints + frontend), PWA helpers + manifest shortcuts + display_override, Runtime adapter wiring + whitelisting, i18n parity for all 12 locales on the 16 new aux keys.
|
||||
- Browser-verified at 1920×1080: Auxiliary Models card renders correctly under Settings → Preferences, 9 task slots with provider/model dropdowns, "Reset all to auto" button, layout consistent with surrounding Settings cards, no clutter or clipping. PWA classes populate on `<html>` and HermesPWA namespace populates with 4 helpers as expected.
|
||||
- In-stage commits added Turkish translations for #2680's 16 `settings_aux_*` / `settings_label_auxiliary_models` / `settings_desc_auxiliary_models` keys to close the sibling-collision gap with v0.51.127's Turkish locale (#2772). Bumped `test_auxiliary_models_settings.py::test_all_locales_have_auxiliary_keys` from `count == 11` to `count == 12` (the locale set grew when Turkish landed).
|
||||
|
||||
## [v0.51.128] — 2026-05-24 — Release CZ (stage-batch10 — 2-PR perf + correctness batch)
|
||||
|
||||
### Fixed
|
||||
|
||||
+4
-1
@@ -435,10 +435,12 @@ def check_auth(handler, parsed) -> bool:
|
||||
return True
|
||||
# Not authorized
|
||||
if parsed.path.startswith('/api/'):
|
||||
body = b'{"error":"Authentication required"}'
|
||||
handler.send_response(401)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(b'{"error":"Authentication required"}')
|
||||
handler.wfile.write(body)
|
||||
else:
|
||||
handler.send_response(302)
|
||||
# Pass the original path as ?next= so login.js redirects back after auth.
|
||||
@@ -468,6 +470,7 @@ def check_auth(handler, parsed) -> bool:
|
||||
# `?`, `&`, `=`) gets percent-encoded.
|
||||
_next = _urlparse.quote(_path_with_query, safe='/')
|
||||
handler.send_header('Location', 'login?next=' + _next)
|
||||
handler.send_header('Content-Length', '0')
|
||||
handler.end_headers()
|
||||
return False
|
||||
|
||||
|
||||
+106
@@ -2185,6 +2185,112 @@ def set_hermes_default_model(model_id: str) -> dict:
|
||||
return {"ok": True, "model": persisted_model}
|
||||
|
||||
|
||||
# ── Auxiliary model configuration ──────────────────────────────────────────
|
||||
|
||||
# Canonical auxiliary task slots. Keep in sync with hermes_cli/config.py
|
||||
# DEFAULT_CONFIG["auxiliary"] and hermes_cli/web_server.py _AUX_TASK_SLOTS.
|
||||
AUX_TASK_SLOTS: tuple[str, ...] = (
|
||||
"vision",
|
||||
"web_extract",
|
||||
"compression",
|
||||
"session_search",
|
||||
"skills_hub",
|
||||
"approval",
|
||||
"mcp",
|
||||
"title_generation",
|
||||
"curator",
|
||||
)
|
||||
|
||||
|
||||
def get_auxiliary_models() -> dict:
|
||||
"""Return current auxiliary task assignments from config.yaml.
|
||||
|
||||
Shape:
|
||||
{
|
||||
"tasks": [
|
||||
{"task": "vision", "provider": "auto", "model": "", "base_url": ""},
|
||||
...
|
||||
],
|
||||
"main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"},
|
||||
}
|
||||
"""
|
||||
reload_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
main_provider = str(model_cfg.get("provider") or "").strip()
|
||||
main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()
|
||||
|
||||
aux_cfg = cfg.get("auxiliary", {})
|
||||
if not isinstance(aux_cfg, dict):
|
||||
aux_cfg = {}
|
||||
|
||||
tasks = []
|
||||
for slot in AUX_TASK_SLOTS:
|
||||
entry = aux_cfg.get(slot, {})
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
tasks.append({
|
||||
"task": slot,
|
||||
"provider": str(entry.get("provider") or "auto").strip(),
|
||||
"model": str(entry.get("model") or "").strip(),
|
||||
"base_url": str(entry.get("base_url") or "").strip(),
|
||||
})
|
||||
|
||||
return {
|
||||
"tasks": tasks,
|
||||
"main": {"provider": main_provider, "model": main_model},
|
||||
}
|
||||
|
||||
|
||||
def set_auxiliary_model(task: str, provider: str, model: str) -> dict:
|
||||
"""Persist an auxiliary model assignment in config.yaml.
|
||||
|
||||
Special case: task='__reset__' clears all auxiliary slots.
|
||||
"""
|
||||
config_path = _get_config_path()
|
||||
with _cfg_lock:
|
||||
config_data = _load_yaml_config_file(config_path)
|
||||
|
||||
if task == "__reset__":
|
||||
# Per-slot reset: set each slot to auto, preserving extra fields
|
||||
# (timeout, extra_body, api_key, base_url, download_timeout, etc.)
|
||||
aux_cfg = config_data.get("auxiliary", {})
|
||||
if not isinstance(aux_cfg, dict):
|
||||
aux_cfg = {}
|
||||
for slot in AUX_TASK_SLOTS:
|
||||
slot_cfg = aux_cfg.get(slot, {})
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = "auto"
|
||||
slot_cfg["model"] = ""
|
||||
aux_cfg[slot] = slot_cfg
|
||||
config_data["auxiliary"] = aux_cfg
|
||||
else:
|
||||
aux_cfg = config_data.get("auxiliary", {})
|
||||
if not isinstance(aux_cfg, dict):
|
||||
aux_cfg = {}
|
||||
slot_cfg = aux_cfg.get(task, {})
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = provider or "auto"
|
||||
slot_cfg["model"] = model or ""
|
||||
if provider and (provider.startswith("custom:") or provider == "custom"):
|
||||
try:
|
||||
_, _, resolved_base_url = resolve_model_provider(model)
|
||||
if resolved_base_url:
|
||||
slot_cfg["base_url"] = str(resolved_base_url).strip().rstrip("/")
|
||||
except Exception:
|
||||
pass
|
||||
aux_cfg[task] = slot_cfg
|
||||
config_data["auxiliary"] = aux_cfg
|
||||
|
||||
_save_yaml_config_file(config_path, config_data)
|
||||
|
||||
reload_config()
|
||||
return {"ok": True, "task": task, "provider": provider, "model": model}
|
||||
|
||||
|
||||
# ── TTL cache for get_available_models() ─────────────────────────────────────
|
||||
_available_models_cache: dict | None = None
|
||||
_available_models_cache_ts: float = 0.0
|
||||
|
||||
@@ -1022,7 +1022,7 @@ def _handle_events_sse_stream(handler, parsed):
|
||||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
handler.send_header("X-Accel-Buffering", "no")
|
||||
handler.send_header("Connection", "keep-alive")
|
||||
handler.send_header("Connection", "close")
|
||||
handler.end_headers()
|
||||
|
||||
# Send an initial frame so the client knows the connection is open
|
||||
|
||||
+113
-27
@@ -3687,6 +3687,11 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/models/live":
|
||||
return _handle_live_models(handler, parsed)
|
||||
|
||||
# ── Auxiliary models (GET/POST) ──
|
||||
if parsed.path == "/api/model/auxiliary":
|
||||
from api.config import get_auxiliary_models
|
||||
return j(handler, get_auxiliary_models())
|
||||
|
||||
if parsed.path == "/api/dashboard/status":
|
||||
from api import dashboard_probe
|
||||
|
||||
@@ -4857,6 +4862,25 @@ def handle_post(handler, parsed) -> bool:
|
||||
except RuntimeError as e:
|
||||
return bad(handler, str(e), 500)
|
||||
|
||||
# ── Auxiliary model set (POST) ──
|
||||
if parsed.path == "/api/model/set":
|
||||
scope = str(body.get("scope") or "").strip()
|
||||
task = str(body.get("task") or "").strip()
|
||||
provider = str(body.get("provider") or "auto").strip()
|
||||
model = str(body.get("model") or "").strip()
|
||||
if scope == "auxiliary":
|
||||
from api.config import set_auxiliary_model
|
||||
try:
|
||||
return j(handler, set_auxiliary_model(task, provider, model))
|
||||
except Exception as exc:
|
||||
return bad(handler, str(exc), status=400)
|
||||
if scope == "main":
|
||||
try:
|
||||
return j(handler, set_hermes_default_model(model))
|
||||
except ValueError as exc:
|
||||
return bad(handler, str(exc), status=400)
|
||||
return bad(handler, f"unknown scope: {scope}", status=400)
|
||||
|
||||
# ── Providers (POST) ──
|
||||
if parsed.path == "/api/providers":
|
||||
provider_id = (body.get("provider") or "").strip().lower()
|
||||
@@ -6216,13 +6240,15 @@ def handle_post(handler, parsed) -> bool:
|
||||
_record_login_attempt(client_ip)
|
||||
return bad(handler, "Invalid password", 401)
|
||||
cookie_val = create_session()
|
||||
body = json.dumps({"ok": True}).encode()
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
_security_headers(handler)
|
||||
set_auth_cookie(handler, cookie_val)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({"ok": True}).encode())
|
||||
handler.wfile.write(body)
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/auth/logout":
|
||||
@@ -6231,13 +6257,15 @@ def handle_post(handler, parsed) -> bool:
|
||||
cookie_val = parse_cookie(handler)
|
||||
if cookie_val:
|
||||
invalidate_session(cookie_val)
|
||||
body = json.dumps({"ok": True}).encode()
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
_security_headers(handler)
|
||||
clear_auth_cookie(handler)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({"ok": True}).encode())
|
||||
handler.wfile.write(body)
|
||||
return True
|
||||
|
||||
# ── Checkpoints / Rollback (POST) ──
|
||||
@@ -6577,7 +6605,7 @@ def _handle_sse_stream(handler, parsed):
|
||||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
handler.send_header("X-Accel-Buffering", "no")
|
||||
handler.send_header("Connection", "keep-alive")
|
||||
handler.send_header("Connection", "close")
|
||||
handler.end_headers()
|
||||
try:
|
||||
_replay_run_journal(handler, stream_id, _parse_run_journal_after_seq(qs))
|
||||
@@ -6589,7 +6617,7 @@ def _handle_sse_stream(handler, parsed):
|
||||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
handler.send_header("X-Accel-Buffering", "no")
|
||||
handler.send_header("Connection", "keep-alive")
|
||||
handler.send_header("Connection", "close")
|
||||
handler.end_headers()
|
||||
try:
|
||||
while True:
|
||||
@@ -6720,7 +6748,7 @@ def _handle_terminal_output(handler, parsed):
|
||||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
handler.send_header("X-Accel-Buffering", "no")
|
||||
handler.send_header("Connection", "keep-alive")
|
||||
handler.send_header("Connection", "close")
|
||||
handler.end_headers()
|
||||
try:
|
||||
while True:
|
||||
@@ -6798,7 +6826,7 @@ def _handle_gateway_sse_stream(handler, parsed):
|
||||
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
handler.send_header('Cache-Control', 'no-cache')
|
||||
handler.send_header('X-Accel-Buffering', 'no')
|
||||
handler.send_header('Connection', 'keep-alive')
|
||||
handler.send_header('Connection', 'close')
|
||||
handler.end_headers()
|
||||
|
||||
q = watcher.subscribe()
|
||||
@@ -6831,7 +6859,7 @@ def _handle_session_events_stream(handler):
|
||||
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
handler.send_header('Cache-Control', 'no-cache')
|
||||
handler.send_header('X-Accel-Buffering', 'no')
|
||||
handler.send_header('Connection', 'keep-alive')
|
||||
handler.send_header('Connection', 'close')
|
||||
handler.end_headers()
|
||||
|
||||
q = subscribe_session_events()
|
||||
@@ -6917,6 +6945,7 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
|
||||
handler.send_response(416)
|
||||
handler.send_header("Content-Range", f"bytes */{file_size}")
|
||||
handler.send_header("Accept-Ranges", "bytes")
|
||||
handler.send_header("Content-Length", "0")
|
||||
_security_headers(handler)
|
||||
handler.end_headers()
|
||||
return True
|
||||
@@ -7027,10 +7056,12 @@ def _handle_media(handler, parsed):
|
||||
if is_auth_enabled():
|
||||
cv = parse_cookie(handler)
|
||||
if not (cv and verify_session(cv)):
|
||||
body = b'{"error":"Authentication required"}'
|
||||
handler.send_response(401)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(b'{"error":"Authentication required"}')
|
||||
handler.wfile.write(body)
|
||||
return
|
||||
|
||||
qs = parse_qs(parsed.query)
|
||||
@@ -7264,6 +7295,15 @@ def _handle_folder_download(handler, parsed):
|
||||
_content_disposition_value("attachment", zip_name),
|
||||
)
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
# Under HTTP/1.1 (Handler.protocol_version, see server.py post-#2836)
|
||||
# a response with no Content-Length and no Transfer-Encoding requires
|
||||
# Connection: close so the client knows the body ends at FIN. The ZIP
|
||||
# is built on-the-fly so we cannot send Content-Length up front; mirror
|
||||
# the SSE-endpoint pattern #2836 uses. Without this header the client
|
||||
# hangs waiting for the next pipelined response after the central
|
||||
# directory bytes finish. Caught by Opus pre-release advisor on
|
||||
# stage-batch11.
|
||||
handler.send_header("Connection", "close")
|
||||
handler.end_headers()
|
||||
|
||||
written = 0
|
||||
@@ -7389,7 +7429,7 @@ def _handle_approval_sse_stream(handler, parsed):
|
||||
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
handler.send_header('Cache-Control', 'no-cache')
|
||||
handler.send_header('X-Accel-Buffering', 'no')
|
||||
handler.send_header('Connection', 'keep-alive')
|
||||
handler.send_header('Connection', 'close')
|
||||
handler.end_headers()
|
||||
|
||||
from api.streaming import _sse
|
||||
@@ -7490,7 +7530,7 @@ def _handle_clarify_sse_stream(handler, parsed):
|
||||
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
handler.send_header('Cache-Control', 'no-cache')
|
||||
handler.send_header('X-Accel-Buffering', 'no')
|
||||
handler.send_header('Connection', 'keep-alive')
|
||||
handler.send_header('Connection', 'close')
|
||||
handler.end_headers()
|
||||
|
||||
from api.streaming import _sse
|
||||
@@ -8463,6 +8503,41 @@ def _start_chat_stream_for_session(
|
||||
return response
|
||||
|
||||
|
||||
def _runtime_runner_client_factory():
|
||||
"""Return the runner-local client when a supervised backend exists.
|
||||
|
||||
Slice 4d wires the `/api/chat/start` selection point without silently falling
|
||||
back to the legacy in-process runtime when `runner-local` is explicitly
|
||||
requested. The supervised runner backend itself is intentionally not created
|
||||
in this helper yet; a later slice can replace this factory with the concrete
|
||||
client while keeping the route contract stable.
|
||||
"""
|
||||
raise NotImplementedError("runner-local chat backend is not configured")
|
||||
|
||||
|
||||
def _chat_start_response_from_run_start(result):
|
||||
"""Expose only the legacy browser-facing chat-start response fields."""
|
||||
payload = dict(getattr(result, "payload", {}) or {})
|
||||
response = {}
|
||||
for key in (
|
||||
"stream_id",
|
||||
"session_id",
|
||||
"pending_started_at",
|
||||
"turn_id",
|
||||
"title",
|
||||
"effective_model",
|
||||
"effective_model_provider",
|
||||
"error",
|
||||
"active_stream_id",
|
||||
"_status",
|
||||
):
|
||||
if key in payload:
|
||||
response[key] = payload[key]
|
||||
response.setdefault("stream_id", result.stream_id)
|
||||
response.setdefault("session_id", result.session_id)
|
||||
return response
|
||||
|
||||
|
||||
def _runtime_adapter_goal_action(goal_args: str) -> str:
|
||||
"""Return the bounded RuntimeAdapter goal action for WebUI /goal args."""
|
||||
action = str(goal_args or "").strip().lower()
|
||||
@@ -8672,10 +8747,12 @@ def _handle_chat_start(handler, body, diag=None):
|
||||
from api.runtime_adapter import (
|
||||
LegacyJournalRuntimeAdapter,
|
||||
StartRunRequest,
|
||||
build_runtime_adapter,
|
||||
runtime_adapter_enabled,
|
||||
runtime_adapter_runner_enabled,
|
||||
)
|
||||
|
||||
if runtime_adapter_enabled():
|
||||
if runtime_adapter_enabled() or runtime_adapter_runner_enabled():
|
||||
def _legacy_start_run(request: StartRunRequest) -> dict:
|
||||
return _start_chat_stream_for_session(
|
||||
s,
|
||||
@@ -8688,23 +8765,32 @@ def _handle_chat_start(handler, body, diag=None):
|
||||
diag=diag,
|
||||
)
|
||||
|
||||
adapter = LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run)
|
||||
result = adapter.start_run(
|
||||
StartRunRequest(
|
||||
session_id=s.session_id,
|
||||
message=msg,
|
||||
attachments=attachments,
|
||||
workspace=workspace,
|
||||
profile=getattr(s, "profile", None),
|
||||
provider=model_provider,
|
||||
model=model,
|
||||
source="webui",
|
||||
metadata={"route": "/api/chat/start"},
|
||||
def _legacy_adapter_factory():
|
||||
return LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run)
|
||||
|
||||
try:
|
||||
adapter = build_runtime_adapter(
|
||||
legacy_adapter_factory=_legacy_adapter_factory,
|
||||
runner_client_factory=_runtime_runner_client_factory,
|
||||
)
|
||||
)
|
||||
response = dict(result.payload)
|
||||
response.setdefault("stream_id", result.stream_id)
|
||||
response.setdefault("session_id", result.session_id)
|
||||
if adapter is None:
|
||||
raise NotImplementedError("runtime adapter selection returned no adapter")
|
||||
result = adapter.start_run(
|
||||
StartRunRequest(
|
||||
session_id=s.session_id,
|
||||
message=msg,
|
||||
attachments=attachments,
|
||||
workspace=workspace,
|
||||
profile=getattr(s, "profile", None),
|
||||
provider=model_provider,
|
||||
model=model,
|
||||
source="webui",
|
||||
metadata={"route": "/api/chat/start"},
|
||||
)
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
return j(handler, {"error": str(exc)}, status=501)
|
||||
response = _chat_start_response_from_run_start(result)
|
||||
else:
|
||||
response = _start_chat_stream_for_session(
|
||||
s,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- **Author:** @Michaelyklam
|
||||
- **Updated by:** @franksong2702
|
||||
- **Created:** 2026-05-11
|
||||
- **Revised:** 2026-05-21
|
||||
- **Revised:** 2026-05-23
|
||||
- **Tracking issue:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925)
|
||||
|
||||
## Credit and Scope
|
||||
@@ -52,7 +52,7 @@ The immediate goal is not to build a sidecar. The immediate goal is to define th
|
||||
browser contract, classify current runtime state, and gate the first reversible
|
||||
journal slice.
|
||||
|
||||
## Current Gate State — 2026-05-21
|
||||
## Current Gate State — 2026-05-23
|
||||
|
||||
Slice 1 is now past the first active validation gate:
|
||||
|
||||
@@ -104,11 +104,14 @@ adapter-seam work:
|
||||
`runner-local` adapter selection point and `build_runtime_adapter(...)`
|
||||
factory wiring around an injected runner client. Live browser chat routes still
|
||||
stay on the legacy backend, and no supervised runner process exists yet.
|
||||
- The next implementation gate is a supervised/local runner backend proposal and
|
||||
route-selection harness. It must stay default-off, keep legacy fallback intact,
|
||||
pass explicit profile/workspace/model payloads instead of mutating WebUI
|
||||
process globals, and avoid recreating `STREAMS` / `CANCEL_FLAGS` / approval
|
||||
queues / clarify queues under new names.
|
||||
- #2744 shipped the Slice 4d supervised runner route gate in v0.51.108.
|
||||
- The next implementation slice is a default-off runner route-selection harness
|
||||
for `/api/chat/start`. It should only engage when `runner-local` is explicitly
|
||||
selected, return a bounded not-configured error until a supervised runner
|
||||
client exists, keep `legacy-direct` / `legacy-journal` fallback intact, pass
|
||||
explicit profile/workspace/model payloads instead of mutating WebUI process
|
||||
globals, and avoid recreating `STREAMS` / `CANCEL_FLAGS` / approval queues /
|
||||
clarify queues under new names.
|
||||
|
||||
The next gate is runner-backend plumbing, not queue implementation
|
||||
by default. Queue / continue routing should only move before Slice 4 if a future
|
||||
@@ -843,6 +846,10 @@ Non-goals for Slice 4c:
|
||||
|
||||
#### Slice 4d: Supervised runner backend route gate
|
||||
|
||||
Status as of 2026-05-23: shipped in v0.51.108 via #2744. The gate remains a
|
||||
docs/test contract: it defines the default-off route-selection requirements but
|
||||
does not itself route live chat to a runner backend.
|
||||
|
||||
After `runner-local` selection exists, the next reviewable gate should define the
|
||||
first supervised/local runner backend and the route-selection harness before live
|
||||
browser chat can use it. This is still a contract/test slice first: no default-on
|
||||
@@ -896,6 +903,51 @@ Non-goals for Slice 4d:
|
||||
- no broad UI/product surface migration; WebUI remains the rich workbench while
|
||||
only execution ownership moves.
|
||||
|
||||
#### Slice 4e: Default-off runner chat-start route-selection harness
|
||||
|
||||
The first implementation after the Slice 4d gate should wire the
|
||||
`/api/chat/start` selection point to the existing `RuntimeAdapter` factory
|
||||
without adding a supervised runner process yet. The harness must make the
|
||||
selection behavior explicit: `legacy-direct` stays default, `legacy-journal`
|
||||
continues to delegate to the legacy in-process stream path, and `runner-local`
|
||||
does not silently fall back to legacy when no runner client is configured.
|
||||
|
||||
Scope:
|
||||
|
||||
- route `/api/chat/start` through `build_runtime_adapter(...)` when an adapter
|
||||
mode is explicitly selected;
|
||||
- keep the successful browser response whitelisted to legacy-compatible fields
|
||||
such as `stream_id`, `session_id`, `pending_started_at`, `turn_id`, `title`,
|
||||
and effective model/provider metadata;
|
||||
- return a bounded not-configured error for `runner-local` until a supervised
|
||||
runner client/backend lands;
|
||||
- pass the existing explicit `StartRunRequest` payload fields across the seam.
|
||||
|
||||
Acceptance tests for Slice 4e:
|
||||
|
||||
1. **Default remains legacy-direct.** With no adapter env var, `/api/chat/start`
|
||||
keeps using `_start_chat_stream_for_session(...)` directly.
|
||||
2. **Legacy-journal remains behavior-preserving.** The flagged legacy adapter
|
||||
still delegates to the same stream-start helper and preserves the public
|
||||
response shape.
|
||||
3. **Runner-local does not fallback silently.** If `runner-local` is selected but
|
||||
no runner client exists, the route returns a bounded error instead of starting
|
||||
a WebUI-owned legacy run behind the operator's back.
|
||||
4. **No adapter-internal response drift.** `run_id`, `status`, and
|
||||
`active_controls` remain internal until a later contract explicitly exposes
|
||||
them.
|
||||
5. **No runtime-surrogate globals.** The harness does not add runner-owned stream,
|
||||
cancel, approval, clarify, cached-agent, goal, or queue maps to the main WebUI
|
||||
process.
|
||||
|
||||
Non-goals for Slice 4e:
|
||||
|
||||
- no supervised runner process yet;
|
||||
- no default-on runner mode;
|
||||
- no execution-survives-WebUI-restart claim for production chat turns;
|
||||
- no removal of `legacy-direct` or `legacy-journal`;
|
||||
- no server-side queue endpoint or queue scheduler just for adapter symmetry.
|
||||
|
||||
## First Meaningful Success Criteria
|
||||
|
||||
The first meaningful milestones are deliberately split.
|
||||
|
||||
@@ -170,6 +170,13 @@ class QuietHTTPServer(ThreadingHTTPServer):
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
# HTTP/1.1 enables keep-alive connection reuse — major latency win on
|
||||
# high-RTT links where every saved TCP handshake is 2×RTT. Each response
|
||||
# MUST declare framing (Content-Length, Transfer-Encoding: chunked, or
|
||||
# Connection: close) so the client knows where the message ends. Helpers
|
||||
# j()/t() emit Content-Length; SSE/streaming endpoints emit
|
||||
# Connection: close because the body has no terminator. See PR notes.
|
||||
protocol_version = "HTTP/1.1"
|
||||
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
|
||||
|
||||
def setup(self):
|
||||
|
||||
@@ -1662,6 +1662,17 @@ function applyBotName(){
|
||||
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
|
||||
if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
|
||||
const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
|
||||
const pwaLaunchAction=(window.HermesPWA&&typeof window.HermesPWA.launchAction==='function')
|
||||
? window.HermesPWA.launchAction()
|
||||
: null;
|
||||
if(pwaLaunchAction==='new-chat'){
|
||||
try{
|
||||
await newSession(true);
|
||||
if(S.session) await _startBootModelDropdown();
|
||||
S._bootReady=true;
|
||||
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();return;
|
||||
}catch(e){console.warn('[pwa] new-chat launch action failed', e);}
|
||||
}
|
||||
const savedLocal=localStorage.getItem('hermes-webui-session');
|
||||
const saved=urlSession||savedLocal;
|
||||
if(saved){
|
||||
|
||||
+193
-1
@@ -548,6 +548,22 @@ const LOCALES = {
|
||||
settings_save_btn: 'Save Settings',
|
||||
settings_label_model: 'Default Model',
|
||||
settings_desc_model: 'Used for new conversations. Existing conversations keep their selected model.',
|
||||
settings_label_auxiliary_models: 'Auxiliary Models',
|
||||
settings_desc_auxiliary_models: 'Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.',
|
||||
settings_btn_reset_aux_models: 'Reset all to auto',
|
||||
settings_btn_apply_aux_models: 'Apply changes',
|
||||
settings_aux_provider_auto: 'use main model',
|
||||
settings_aux_model_auto: 'auto (use provider default)',
|
||||
settings_aux_model_custom: 'Custom model…',
|
||||
settings_aux_model_custom_prompt: 'Enter model ID:',
|
||||
settings_aux_loading: 'Loading auxiliary models…',
|
||||
settings_aux_load_failed: 'Could not load auxiliary model settings. Make sure the agent API is available.',
|
||||
settings_aux_reset_confirm_title: 'Reset auxiliary models?',
|
||||
settings_aux_reset_confirm_msg: 'This will set all auxiliary tasks to auto (use main model).',
|
||||
settings_aux_reset_done: 'Auxiliary models reset to auto',
|
||||
settings_aux_save_failed: 'Failed to save auxiliary model',
|
||||
settings_aux_saved: 'Auxiliary models updated',
|
||||
settings_aux_no_changes: 'No changes to apply',
|
||||
settings_label_send_key: 'Send Key',
|
||||
settings_label_theme: 'Theme',
|
||||
settings_label_skin: 'Skin',
|
||||
@@ -1791,6 +1807,22 @@ const LOCALES = {
|
||||
settings_save_btn: 'Salva Impostazioni',
|
||||
settings_label_model: 'Modello Predefinito',
|
||||
settings_desc_model: 'Usato per le nuove conversazioni. Le conversazioni esistenti mantengono il modello selezionato.',
|
||||
settings_label_auxiliary_models: 'Modelli Ausiliari',
|
||||
settings_desc_auxiliary_models: 'Routing per attività secondarie come visione, compressione, generazione titoli, ecc. "Auto" utilizza il modello chat principale.',
|
||||
settings_btn_reset_aux_models: 'Ripristina tutto ad auto',
|
||||
settings_btn_apply_aux_models: 'Applica modifiche',
|
||||
settings_aux_provider_auto: 'usa modello principale',
|
||||
settings_aux_model_auto: 'auto (usa predefinito del provider)',
|
||||
settings_aux_model_custom: 'Modello personalizzato…',
|
||||
settings_aux_model_custom_prompt: 'Inserisci ID modello:',
|
||||
settings_aux_loading: 'Caricamento modelli ausiliari…',
|
||||
settings_aux_load_failed: 'Impossibile caricare le impostazioni dei modelli ausiliari. Assicurati che l’API dell’agent sia disponibile.',
|
||||
settings_aux_reset_confirm_title: 'Ripristinare modelli ausiliari?',
|
||||
settings_aux_reset_confirm_msg: 'Questo imposterà tutte le attività ausiliarie ad auto (usa modello principale).',
|
||||
settings_aux_reset_done: 'Modelli ausiliari ripristinati ad auto',
|
||||
settings_aux_save_failed: 'Salvataggio del modello ausiliario non riuscito',
|
||||
settings_aux_saved: 'Modelli ausiliari aggiornati',
|
||||
settings_aux_no_changes: 'Nessuna modifica da applicare',
|
||||
settings_label_send_key: 'Tasto Invio',
|
||||
settings_label_theme: 'Tema',
|
||||
settings_label_skin: 'Skin',
|
||||
@@ -3026,6 +3058,22 @@ const LOCALES = {
|
||||
settings_save_btn: '設定を保存',
|
||||
settings_label_model: 'デフォルトモデル',
|
||||
settings_desc_model: '新しい会話で使用されます。既存の会話は選択済みモデルを保持します。',
|
||||
settings_label_auxiliary_models: '補助モデル',
|
||||
settings_desc_auxiliary_models: 'ビジョン、圧縮、タイトル生成などの補助タスクのルーティング。「自動」はメインチャットモデルを使用します。',
|
||||
settings_btn_reset_aux_models: 'すべて自動にリセット',
|
||||
settings_btn_apply_aux_models: '変更を適用',
|
||||
settings_aux_provider_auto: 'メインモデルを使用',
|
||||
settings_aux_model_auto: '自動(プロバイダーのデフォルトを使用)',
|
||||
settings_aux_model_custom: 'カスタムモデル…',
|
||||
settings_aux_model_custom_prompt: 'モデルIDを入力:',
|
||||
settings_aux_loading: '補助モデルを読み込み中…',
|
||||
settings_aux_load_failed: '補助モデル設定を読み込めませんでした。エージェントAPIが利用可能であることを確認してください。',
|
||||
settings_aux_reset_confirm_title: '補助モデルをリセットしますか?',
|
||||
settings_aux_reset_confirm_msg: 'すべての補助タスクを自動(メインモデルを使用)に設定します。',
|
||||
settings_aux_reset_done: '補助モデルを自動にリセットしました',
|
||||
settings_aux_save_failed: '補助モデルの保存に失敗しました',
|
||||
settings_aux_saved: '補助モデルを更新しました',
|
||||
settings_aux_no_changes: '適用する変更はありません',
|
||||
settings_label_send_key: '送信キー',
|
||||
settings_label_theme: 'テーマ',
|
||||
settings_label_skin: 'スキン',
|
||||
@@ -4077,6 +4125,22 @@ const LOCALES = {
|
||||
settings_save_btn: 'Сохранить настройки',
|
||||
settings_label_model: 'Модель по умолчанию',
|
||||
settings_desc_model: 'Используется для новых бесед. Существующие беседы сохраняют выбранную модель.',
|
||||
settings_label_auxiliary_models: 'Вспомогательные модели',
|
||||
settings_desc_auxiliary_models: 'Маршрутизация побочных задач: зрение, сжатие, генерация заголовков и т.д. «Авто» использует основную модель чата.',
|
||||
settings_btn_reset_aux_models: 'Сбросить всё на авто',
|
||||
settings_btn_apply_aux_models: 'Применить изменения',
|
||||
settings_aux_provider_auto: 'использовать основную модель',
|
||||
settings_aux_model_auto: 'авто (по умолчанию провайдера)',
|
||||
settings_aux_model_custom: 'Пользовательская модель…',
|
||||
settings_aux_model_custom_prompt: 'Введите ID модели:',
|
||||
settings_aux_loading: 'Загрузка вспомогательных моделей…',
|
||||
settings_aux_load_failed: 'Не удалось загрузить настройки вспомогательных моделей. Убедитесь, что API агента доступен.',
|
||||
settings_aux_reset_confirm_title: 'Сбросить вспомогательные модели?',
|
||||
settings_aux_reset_confirm_msg: 'Все вспомогательные задачи будут установлены на «авто» (использовать основную модель).',
|
||||
settings_aux_reset_done: 'Вспомогательные модели сброшены на авто',
|
||||
settings_aux_save_failed: 'Не удалось сохранить вспомогательную модель',
|
||||
settings_aux_saved: 'Вспомогательные модели обновлены',
|
||||
settings_aux_no_changes: 'Нет изменений для применения',
|
||||
settings_label_send_key: 'Клавиша отправки',
|
||||
settings_label_theme: 'Тема',
|
||||
settings_label_language: 'Язык',
|
||||
@@ -5231,6 +5295,22 @@ const LOCALES = {
|
||||
settings_save_btn: 'Guardar configuración',
|
||||
settings_label_model: 'Modelo predeterminado',
|
||||
settings_desc_model: 'Se usa para conversaciones nuevas. Las conversaciones existentes conservan su modelo seleccionado.',
|
||||
settings_label_auxiliary_models: 'Modelos auxiliares',
|
||||
settings_desc_auxiliary_models: 'Enrutamiento para tareas secundarias como visión, compresión, generación de títulos, etc. «Auto» usa el modelo de chat principal.',
|
||||
settings_btn_reset_aux_models: 'Restablecer todo a auto',
|
||||
settings_btn_apply_aux_models: 'Aplicar cambios',
|
||||
settings_aux_provider_auto: 'usar modelo principal',
|
||||
settings_aux_model_auto: 'auto (usar predeterminado del proveedor)',
|
||||
settings_aux_model_custom: 'Modelo personalizado…',
|
||||
settings_aux_model_custom_prompt: 'Ingrese ID del modelo:',
|
||||
settings_aux_loading: 'Cargando modelos auxiliares…',
|
||||
settings_aux_load_failed: 'No se pudieron cargar las configuraciones de modelos auxiliares. Asegúrese de que la API del agente esté disponible.',
|
||||
settings_aux_reset_confirm_title: '¿Restablecer modelos auxiliares?',
|
||||
settings_aux_reset_confirm_msg: 'Esto establecerá todas las tareas auxiliares en auto (usar modelo principal).',
|
||||
settings_aux_reset_done: 'Modelos auxiliares restablecidos a auto',
|
||||
settings_aux_save_failed: 'Error al guardar el modelo auxiliar',
|
||||
settings_aux_saved: 'Modelos auxiliares actualizados',
|
||||
settings_aux_no_changes: 'Sin cambios para aplicar',
|
||||
settings_label_send_key: 'Tecla de envío',
|
||||
settings_label_theme: 'Tema',
|
||||
settings_label_skin: 'Piel',
|
||||
@@ -6388,6 +6468,22 @@ const LOCALES = {
|
||||
settings_save_btn: 'Einstellungen speichern',
|
||||
settings_label_model: 'Standard-Modell',
|
||||
settings_desc_model: 'Wird für neue Chats verwendet. Bestehende Chats behalten ihr ausgewähltes Modell.',
|
||||
settings_label_auxiliary_models: 'Hilfsmodelle',
|
||||
settings_desc_auxiliary_models: 'Routing für Nebenaufgaben wie Vision, Komprimierung, Titelgenerierung usw. „Auto" verwendet das Haupt-Chat-Modell.',
|
||||
settings_btn_reset_aux_models: 'Alle auf auto zurücksetzen',
|
||||
settings_btn_apply_aux_models: 'Änderungen anwenden',
|
||||
settings_aux_provider_auto: 'Hauptmodell verwenden',
|
||||
settings_aux_model_auto: 'auto (Provider-Standard verwenden)',
|
||||
settings_aux_model_custom: 'Benutzerdefiniertes Modell…',
|
||||
settings_aux_model_custom_prompt: 'Modell-ID eingeben:',
|
||||
settings_aux_loading: 'Hilfsmodelle werden geladen…',
|
||||
settings_aux_load_failed: 'Hilfsmodelle-Einstellungen konnten nicht geladen werden. Stellen Sie sicher, dass die Agent-API verfügbar ist.',
|
||||
settings_aux_reset_confirm_title: 'Hilfsmodelle zurücksetzen?',
|
||||
settings_aux_reset_confirm_msg: 'Dies setzt alle Hilfsaufgaben auf auto (Hauptmodell verwenden).',
|
||||
settings_aux_reset_done: 'Hilfsmodelle auf auto zurückgesetzt',
|
||||
settings_aux_save_failed: 'Hilfsmodell konnte nicht gespeichert werden',
|
||||
settings_aux_saved: 'Hilfsmodelle aktualisiert',
|
||||
settings_aux_no_changes: 'Keine Änderungen anzuwenden',
|
||||
settings_label_send_key: 'Sende-Taste',
|
||||
settings_label_theme: 'Theme',
|
||||
settings_label_skin: 'Skin',
|
||||
@@ -7597,6 +7693,22 @@ const LOCALES = {
|
||||
settings_save_btn: '保存设置',
|
||||
settings_label_model: '默认模型',
|
||||
settings_desc_model: '用于新对话。现有对话保持各自选定的模型。',
|
||||
settings_label_auxiliary_models: '辅助模型',
|
||||
settings_desc_auxiliary_models: '视觉分析、上下文压缩、标题生成等辅助任务的路由。"自动"表示使用主聊天模型。',
|
||||
settings_btn_reset_aux_models: '全部重置为自动',
|
||||
settings_btn_apply_aux_models: '应用更改',
|
||||
settings_aux_provider_auto: '使用主模型',
|
||||
settings_aux_model_auto: '自动(使用提供商默认)',
|
||||
settings_aux_model_custom: '自定义模型…',
|
||||
settings_aux_model_custom_prompt: '输入模型 ID:',
|
||||
settings_aux_loading: '正在加载辅助模型…',
|
||||
settings_aux_load_failed: '无法加载辅助模型设置,请确保 Agent API 可用。',
|
||||
settings_aux_reset_confirm_title: '重置辅助模型?',
|
||||
settings_aux_reset_confirm_msg: '这将把所有辅助任务设置为自动(使用主模型)。',
|
||||
settings_aux_reset_done: '辅助模型已重置为自动',
|
||||
settings_aux_save_failed: '辅助模型保存失败',
|
||||
settings_aux_saved: '辅助模型已更新',
|
||||
settings_aux_no_changes: '没有需要应用的更改',
|
||||
settings_label_send_key: '发送快捷键',
|
||||
settings_label_theme: '主题',
|
||||
settings_label_skin: '皮肤',
|
||||
@@ -8783,6 +8895,22 @@ const LOCALES = {
|
||||
settings_save_btn: '\u5132\u5b58\u8a2d\u5b9a',
|
||||
settings_label_model: '\u9ed8\u8a8d\u6a21\u578b',
|
||||
settings_desc_model: '\u7528\u65bc\u65b0\u6703\u8a71\u3002\u73fe\u6709\u6703\u8a71\u6703\u4fdd\u7559\u5404\u81ea\u9078\u5b9a\u7684\u6a21\u578b\u3002',
|
||||
settings_label_auxiliary_models: '\u8f14\u52a9\u6a21\u578b',
|
||||
settings_desc_auxiliary_models: '\u8996\u89ba\u5206\u6790\u3001\u4e0a\u4e0b\u6587\u58d3\u7e2e\u3001\u6a19\u984c\u7522\u751f\u7b49\u8f14\u52a9\u4efb\u52d9\u7684\u8def\u7531\u3002\u300c\u81ea\u52d5\u300d\u8868\u793a\u4f7f\u7528\u4e3b\u804a\u5929\u6a21\u578b\u3002',
|
||||
settings_btn_reset_aux_models: '\u5168\u90e8\u91cd\u7f6e\u70ba\u81ea\u52d5',
|
||||
settings_btn_apply_aux_models: '\u61c9\u7528\u8b8a\u66f4',
|
||||
settings_aux_provider_auto: '\u4f7f\u7528\u4e3b\u6a21\u578b',
|
||||
settings_aux_model_auto: '\u81ea\u52d5\uff08\u4f7f\u7528\u63d0\u4f9b\u5546\u9810\u8a2d\uff09',
|
||||
settings_aux_model_custom: '\u81ea\u8a02\u6a21\u578b\u2026',
|
||||
settings_aux_model_custom_prompt: '\u8f38\u5165\u6a21\u578b ID\uff1a',
|
||||
settings_aux_loading: '\u6b63\u5728\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u2026',
|
||||
settings_aux_load_failed: '\u7121\u6cd5\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u8a2d\u5b9a\uff0c\u8acb\u78ba\u4fdd Agent API \u53ef\u7528\u3002',
|
||||
settings_aux_reset_confirm_title: '\u91cd\u7f6e\u8f14\u52a9\u6a21\u578b\uff1f',
|
||||
settings_aux_reset_confirm_msg: '\u9019\u5c07\u628a\u6240\u6709\u8f14\u52a9\u4efb\u52d9\u8a2d\u5b9a\u70ba\u81ea\u52d5\uff08\u4f7f\u7528\u4e3b\u6a21\u578b\uff09\u3002',
|
||||
settings_aux_reset_done: '\u8f14\u52a9\u6a21\u578b\u5df2\u91cd\u7f6e\u70ba\u81ea\u52d5',
|
||||
settings_aux_save_failed: '\u8f14\u52a9\u6a21\u578b\u5132\u5b58\u5931\u6557',
|
||||
settings_aux_saved: '\u8f14\u52a9\u6a21\u578b\u5df2\u66f4\u65b0',
|
||||
settings_aux_no_changes: '\u6c92\u6709\u9700\u8981\u61c9\u7528\u7684\u8b8a\u66f4',
|
||||
settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375',
|
||||
settings_label_theme: '\u4e3b\u984c',
|
||||
settings_label_skin: '佈景',
|
||||
@@ -10096,6 +10224,22 @@ const LOCALES = {
|
||||
settings_save_btn: 'Salvar Configurações',
|
||||
settings_label_model: 'Modelo Padrão',
|
||||
settings_desc_model: 'Usado para novas conversas. Conversas existentes mantêm o modelo selecionado.',
|
||||
settings_label_auxiliary_models: 'Modelos auxiliares',
|
||||
settings_desc_auxiliary_models: 'Roteamento para tarefas secundárias como visão, compressão, geração de títulos, etc. "Auto" usa o modelo de chat principal.',
|
||||
settings_btn_reset_aux_models: 'Restaurar tudo para auto',
|
||||
settings_btn_apply_aux_models: 'Aplicar alterações',
|
||||
settings_aux_provider_auto: 'usar modelo principal',
|
||||
settings_aux_model_auto: 'auto (usar padrão do provedor)',
|
||||
settings_aux_model_custom: 'Modelo personalizado…',
|
||||
settings_aux_model_custom_prompt: 'Insira o ID do modelo:',
|
||||
settings_aux_loading: 'Carregando modelos auxiliares…',
|
||||
settings_aux_load_failed: 'Não foi possível carregar as configurações de modelos auxiliares. Certifique-se de que a API do agente esteja disponível.',
|
||||
settings_aux_reset_confirm_title: 'Restaurar modelos auxiliares?',
|
||||
settings_aux_reset_confirm_msg: 'Isso definirá todas as tarefas auxiliares para auto (usar modelo principal).',
|
||||
settings_aux_reset_done: 'Modelos auxiliares restaurados para auto',
|
||||
settings_aux_save_failed: 'Falha ao salvar o modelo auxiliar',
|
||||
settings_aux_saved: 'Modelos auxiliares atualizados',
|
||||
settings_aux_no_changes: 'Nenhuma alteração para aplicar',
|
||||
settings_label_send_key: 'Tecla de Envio',
|
||||
settings_label_theme: 'Tema',
|
||||
settings_label_skin: 'Skin',
|
||||
@@ -11234,6 +11378,22 @@ const LOCALES = {
|
||||
settings_save_btn: '설정 저장',
|
||||
settings_label_model: '기본 모델',
|
||||
settings_desc_model: '새 대화에 사용됩니다. 기존 대화는 선택된 모델을 유지합니다.',
|
||||
settings_label_auxiliary_models: '보조 모델',
|
||||
settings_desc_auxiliary_models: '비전, 압축, 제목 생성 등 보조 작업 라우팅. "자동"은 기본 채팅 모델을 사용합니다.',
|
||||
settings_btn_reset_aux_models: '모두 자동으로 재설정',
|
||||
settings_btn_apply_aux_models: '변경 사항 적용',
|
||||
settings_aux_provider_auto: '기본 모델 사용',
|
||||
settings_aux_model_auto: '자동 (제공자 기본값 사용)',
|
||||
settings_aux_model_custom: '사용자 정의 모델…',
|
||||
settings_aux_model_custom_prompt: '모델 ID 입력:',
|
||||
settings_aux_loading: '보조 모델 로딩 중…',
|
||||
settings_aux_load_failed: '보조 모델 설정을 로드할 수 없습니다. 에이전트 API가 사용 가능한지 확인하세요.',
|
||||
settings_aux_reset_confirm_title: '보조 모델을 재설정하시겠습니까?',
|
||||
settings_aux_reset_confirm_msg: '모든 보조 작업이 자동(기본 모델 사용)으로 설정됩니다.',
|
||||
settings_aux_reset_done: '보조 모델이 자동으로 재설정됨',
|
||||
settings_aux_save_failed: '보조 모델 저장 실패',
|
||||
settings_aux_saved: '보조 모델 업데이트됨',
|
||||
settings_aux_no_changes: '적용할 변경 사항 없음',
|
||||
settings_label_send_key: '전송 키',
|
||||
settings_label_theme: '테마',
|
||||
settings_label_skin: '스킨',
|
||||
@@ -12385,6 +12545,22 @@ const LOCALES = {
|
||||
settings_save_btn: 'Enregistrer les paramètres',
|
||||
settings_label_model: 'Modèle par défaut',
|
||||
settings_desc_model: 'Utilisé pour les nouvelles conversations. Les conversations existantes conservent leur modèle sélectionné.',
|
||||
settings_label_auxiliary_models: 'Modèles auxiliaires',
|
||||
settings_desc_auxiliary_models: 'Routage des tâches secondaires : vision, compression, génération de titres, etc. « Auto » utilise le modèle de chat principal.',
|
||||
settings_btn_reset_aux_models: 'Tout réinitialiser à auto',
|
||||
settings_btn_apply_aux_models: 'Appliquer les modifications',
|
||||
settings_aux_provider_auto: 'utiliser le modèle principal',
|
||||
settings_aux_model_auto: 'auto (utiliser la valeur par défaut du fournisseur)',
|
||||
settings_aux_model_custom: 'Modèle personnalisé…',
|
||||
settings_aux_model_custom_prompt: 'Entrez l\u2019ID du modèle :',
|
||||
settings_aux_loading: 'Chargement des modèles auxiliaires…',
|
||||
settings_aux_load_failed: 'Impossible de charger les paramètres des modèles auxiliaires. Vérifiez que l\u2019API de l\u2019agent est disponible.',
|
||||
settings_aux_reset_confirm_title: 'Réinitialiser les modèles auxiliaires ?',
|
||||
settings_aux_reset_confirm_msg: 'Cela définira toutes les tâches auxiliaires sur auto (utiliser le modèle principal).',
|
||||
settings_aux_reset_done: 'Modèles auxiliaires réinitialisés à auto',
|
||||
settings_aux_save_failed: 'Échec de la sauvegarde du modèle auxiliaire',
|
||||
settings_aux_saved: 'Modèles auxiliaires mis à jour',
|
||||
settings_aux_no_changes: 'Aucune modification à appliquer',
|
||||
settings_label_send_key: 'Envoyer la clé',
|
||||
settings_label_theme: 'Thème',
|
||||
settings_label_skin: 'Peau',
|
||||
@@ -13637,7 +13813,23 @@ const LOCALES = {
|
||||
settings_tab_system: 'Sistem',
|
||||
settings_title: 'Ayarlar',
|
||||
settings_save_btn: 'Ayarları Kaydet',
|
||||
settings_label_model: 'Varsayılan Model',
|
||||
settings_label_model: 'Varsayılan Model',
|
||||
settings_label_auxiliary_models: 'Yardımcı Modeller',
|
||||
settings_desc_auxiliary_models: 'Görü, sıkıştırma, başlık oluşturma vb. yan görevler için yönlendirme. "Otomatik" ana sohbet modelinizi kullanır.',
|
||||
settings_aux_loading: 'Yardımcı modeller yükleniyor…',
|
||||
settings_aux_load_failed: 'Yardımcı model ayarları yüklenemedi. Agent API\'sinin kullanılabilir olduğundan emin olun.',
|
||||
settings_aux_model_auto: 'otomatik (sağlayıcı varsayılanını kullan)',
|
||||
settings_aux_provider_auto: 'ana modeli kullan',
|
||||
settings_aux_model_custom: 'Özel model…',
|
||||
settings_aux_model_custom_prompt: 'Model kimliğini girin:',
|
||||
settings_aux_no_changes: 'Uygulanacak değişiklik yok',
|
||||
settings_aux_saved: 'Yardımcı modeller güncellendi',
|
||||
settings_aux_save_failed: 'Yardımcı model kaydedilemedi',
|
||||
settings_aux_reset_confirm_title: 'Yardımcı modeller sıfırlansın mı?',
|
||||
settings_aux_reset_confirm_msg: 'Bu, tüm yardımcı görevleri otomatik (ana modeli kullan) olarak ayarlayacaktır.',
|
||||
settings_aux_reset_done: 'Yardımcı modeller otomatiğe sıfırlandı',
|
||||
settings_btn_apply_aux_models: 'Değişiklikleri uygula',
|
||||
settings_btn_reset_aux_models: 'Tümünü otomatiğe sıfırla',
|
||||
settings_desc_model: 'Yeni konuşmalar için kullanılır. Mevcut konuşmalar seçilen modellerini korur.',
|
||||
settings_label_send_key: 'Anahtar Gönder',
|
||||
settings_label_theme: 'Tema',
|
||||
|
||||
+12
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>Hermes</title>
|
||||
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash).
|
||||
MUST appear before manifest/favicon links so browsers resolve relative URLs against the
|
||||
@@ -26,6 +26,8 @@
|
||||
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#141425':'#FAF7F0';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
|
||||
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
||||
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
|
||||
<link rel="preload" href="static/pwa-startup.js?v=__WEBUI_VERSION__" as="script">
|
||||
<script src="static/pwa-startup.js?v=__WEBUI_VERSION__"></script>
|
||||
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__,csrfToken:__CSRF_TOKEN_JSON__};</script>
|
||||
<script>(function(){
|
||||
var cfg=window.__HERMES_CONFIG__||{},token=cfg.csrfToken||'';
|
||||
@@ -997,6 +999,15 @@
|
||||
<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 style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_model">Used for new conversations. Existing conversations keep their selected model.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label data-i18n="settings_label_auxiliary_models">Auxiliary Models</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:10px" data-i18n="settings_desc_auxiliary_models">Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.</div>
|
||||
<div id="auxModelsContainer"></div>
|
||||
<div style="margin-top:8px;display:flex;gap:8px">
|
||||
<button type="button" id="btnResetAuxModels" class="settings-btn" style="font-size:12px;padding:4px 12px;border-radius:6px" data-i18n="settings_btn_reset_aux_models">Reset all to auto</button>
|
||||
<button type="button" id="btnApplyAuxModels" class="settings-btn" style="font-size:12px;padding:4px 12px;border-radius:6px;display:none" data-i18n="settings_btn_apply_aux_models">Apply changes</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsHideSuggestions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
|
||||
+20
-1
@@ -1,12 +1,31 @@
|
||||
{
|
||||
"id": "./",
|
||||
"name": "Hermes",
|
||||
"short_name": "Hermes",
|
||||
"description": "Hermes AI Agent Web UI",
|
||||
"start_url": "./",
|
||||
"start_url": "./?source=pwa",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||
"background_color": "#0D0D1A",
|
||||
"theme_color": "#0D0D1A",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "New conversation",
|
||||
"short_name": "New chat",
|
||||
"description": "Open Hermes ready for a new chat",
|
||||
"url": "./?source=pwa&action=new-chat",
|
||||
"icons": [
|
||||
{
|
||||
"src": "static/favicon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "static/favicon.svg",
|
||||
|
||||
@@ -5920,6 +5920,8 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||
}
|
||||
// Auxiliary models — load task assignments and provider/model options
|
||||
_loadAuxiliaryModels();
|
||||
// Send key preference
|
||||
const sendKeySel=$('settingsSendKey');
|
||||
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
@@ -6881,6 +6883,223 @@ async function checkUpdatesNow(){
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auxiliary Models ──────────────────────────────────────────────────────────
|
||||
|
||||
// Canonical auxiliary task slots with display names.
|
||||
// Keep in sync with hermes_cli/main.py _AUX_TASKS and hermes_cli/web_server.py _AUX_TASK_SLOTS.
|
||||
const _AUX_TASK_SLOTS=[
|
||||
{key:'vision',name:'Vision',desc:'image/screenshot analysis'},
|
||||
{key:'compression',name:'Compression',desc:'context summarization'},
|
||||
{key:'web_extract',name:'Web extract',desc:'web page summarization'},
|
||||
{key:'session_search',name:'Session search',desc:'past-conversation recall'},
|
||||
{key:'approval',name:'Approval',desc:'smart command approval'},
|
||||
{key:'mcp',name:'MCP',desc:'MCP tool reasoning'},
|
||||
{key:'title_generation',name:'Title generation',desc:'session titles'},
|
||||
{key:'skills_hub',name:'Skills hub',desc:'skills search/install'},
|
||||
{key:'curator',name:'Curator',desc:'skill-usage review pass'},
|
||||
];
|
||||
|
||||
let _auxProviders=[]; // cached provider list from /api/model/options
|
||||
let _auxOriginalConfig=null; // snapshot of initial config for dirty detection
|
||||
|
||||
function _auxSelectStyle(){
|
||||
return 'width:100%;padding:6px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px;box-sizing:border-box';
|
||||
}
|
||||
|
||||
function _buildAuxProviderOptions(sel,providers,currentProvider){
|
||||
sel.innerHTML='';
|
||||
// "auto" = use main model
|
||||
const autoOpt=document.createElement('option');
|
||||
autoOpt.value='auto';autoOpt.textContent='auto ('+t('settings_aux_provider_auto')+')';
|
||||
if(currentProvider==='auto'||!currentProvider) autoOpt.selected=true;
|
||||
sel.appendChild(autoOpt);
|
||||
for(const p of providers){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=p.slug;opt.textContent=p.name;
|
||||
if(p.slug===currentProvider) opt.selected=true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function _buildAuxModelOptions(sel,provider,providers,currentModel){
|
||||
sel.innerHTML='';
|
||||
const emptyOpt=document.createElement('option');
|
||||
emptyOpt.value='';emptyOpt.textContent=t('settings_aux_model_auto')||'auto (use provider default)';
|
||||
sel.appendChild(emptyOpt);
|
||||
if(!provider||provider==='auto'){
|
||||
sel.value=currentModel||'';
|
||||
return;
|
||||
}
|
||||
// Find matching provider in cached list
|
||||
const pData=providers.find(p=>p.slug===provider);
|
||||
if(pData&&pData.models){
|
||||
for(const mId of pData.models){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=mId;opt.textContent=mId;
|
||||
if(mId===currentModel) opt.selected=true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
// Always allow custom model — add a text input option hint
|
||||
const customOpt=document.createElement('option');
|
||||
customOpt.value='__custom__';customOpt.textContent=t('settings_aux_model_custom')||'Custom model…';
|
||||
sel.appendChild(customOpt);
|
||||
// If currentModel not in list and not empty, add it as a custom option
|
||||
if(currentModel&&!pData?.models?.includes(currentModel)){
|
||||
const existingOpt=document.createElement('option');
|
||||
existingOpt.value=currentModel;existingOpt.textContent=currentModel+' (configured)';
|
||||
existingOpt.selected=true;
|
||||
sel.insertBefore(existingOpt,customOpt);
|
||||
}
|
||||
}
|
||||
|
||||
function _onAuxProviderChange(taskKey,providers){
|
||||
const provSel=$('aux-prov-'+taskKey);
|
||||
const modelSel=$('aux-model-'+taskKey);
|
||||
if(!provSel||!modelSel) return;
|
||||
const provider=provSel.value;
|
||||
_buildAuxModelOptions(modelSel,provider,providers,'');
|
||||
_markAuxDirty();
|
||||
}
|
||||
|
||||
async function _onAuxModelChange(taskKey){
|
||||
const modelSel=$('aux-model-'+taskKey);
|
||||
if(!modelSel) return;
|
||||
if(modelSel.value==='__custom__'){
|
||||
const customModel=await showPromptDialog({title:t('settings_aux_model_custom')||'Custom model',message:t('settings_aux_model_custom_prompt')||'Enter model ID:',placeholder:'model/provider:model-id',confirmLabel:t('settings_btn_apply_aux_models')||'Apply'});
|
||||
if(customModel&&customModel.trim()){
|
||||
// Insert custom model option before the __custom__ option
|
||||
const opt=document.createElement('option');
|
||||
opt.value=customModel.trim();opt.textContent=customModel.trim();
|
||||
// Remove __custom__ selection
|
||||
const customIdx=[...modelSel.options].findIndex(o=>o.value==='__custom__');
|
||||
if(customIdx>=0) modelSel.insertBefore(opt,modelSel.options[customIdx]);
|
||||
modelSel.value=customModel.trim();
|
||||
}else{
|
||||
modelSel.value='';
|
||||
}
|
||||
}
|
||||
_markAuxDirty();
|
||||
}
|
||||
|
||||
function _markAuxDirty(){
|
||||
const applyBtn=$('btnApplyAuxModels');
|
||||
if(applyBtn) applyBtn.style.display='';
|
||||
_markSettingsDirty();
|
||||
}
|
||||
|
||||
async function _loadAuxiliaryModels(){
|
||||
const container=$('auxModelsContainer');
|
||||
if(!container) return;
|
||||
container.innerHTML='<div style="color:var(--muted);font-size:12px">'+(t('settings_aux_loading')||'Loading…')+'</div>';
|
||||
|
||||
try{
|
||||
// Fetch auxiliary config AND the WebUI's own /api/models for provider/model lists
|
||||
const [auxData,modelsData]=await Promise.all([
|
||||
api('/api/model/auxiliary').catch(()=>null),
|
||||
api('/api/models').catch(()=>null),
|
||||
]);
|
||||
// Build provider list from /api/models groups
|
||||
// /api/models returns: { groups: [{ provider: str, provider_id: str, models: [{id,label}] }] }
|
||||
const groups=(modelsData&&modelsData.groups)||[];
|
||||
_auxProviders=groups.filter(g=>g.provider&&g.models&&g.models.length>0).map(g=>({
|
||||
slug:g.provider_id||g.provider,
|
||||
name:g.provider,
|
||||
models:g.models.map(m=>m.id),
|
||||
}));
|
||||
const tasks=(auxData&&auxData.tasks)||[];
|
||||
// Build a quick lookup: taskKey → {provider, model}
|
||||
const taskMap={};
|
||||
for(const t of tasks) taskMap[t.task]=t;
|
||||
_auxOriginalConfig=JSON.parse(JSON.stringify(taskMap));
|
||||
|
||||
container.innerHTML='';
|
||||
for(const slot of _AUX_TASK_SLOTS){
|
||||
const cfg=taskMap[slot.key]||{provider:'auto',model:''};
|
||||
const row=document.createElement('div');
|
||||
row.style.cssText='display:grid;grid-template-columns:120px 1fr 1fr;gap:8px;align-items:center;margin-bottom:8px';
|
||||
|
||||
// Task name + description
|
||||
const label=document.createElement('div');
|
||||
label.style.cssText='font-size:12px;font-weight:500;color:var(--text);line-height:1.3';
|
||||
label.innerHTML=esc(slot.name)+'<div style="font-size:10px;color:var(--muted);font-weight:400">'+esc(slot.desc)+'</div>';
|
||||
row.appendChild(label);
|
||||
|
||||
// Provider select
|
||||
const provSel=document.createElement('select');
|
||||
provSel.id='aux-prov-'+slot.key;
|
||||
provSel.style.cssText=_auxSelectStyle();
|
||||
_buildAuxProviderOptions(provSel,_auxProviders,cfg.provider);
|
||||
provSel.addEventListener('change',()=>_onAuxProviderChange(slot.key,_auxProviders));
|
||||
row.appendChild(provSel);
|
||||
|
||||
// Model select
|
||||
const modelSel=document.createElement('select');
|
||||
modelSel.id='aux-model-'+slot.key;
|
||||
modelSel.style.cssText=_auxSelectStyle();
|
||||
_buildAuxModelOptions(modelSel,cfg.provider,_auxProviders,cfg.model);
|
||||
modelSel.addEventListener('change',()=>_onAuxModelChange(slot.key));
|
||||
row.appendChild(modelSel);
|
||||
|
||||
container.appendChild(row);
|
||||
}
|
||||
// Hide apply button (no changes yet)
|
||||
const applyBtn=$('btnApplyAuxModels');
|
||||
if(applyBtn) applyBtn.style.display='none';
|
||||
|
||||
// Reset button
|
||||
const resetBtn=$('btnResetAuxModels');
|
||||
if(resetBtn&&!resetBtn._bound){
|
||||
resetBtn._bound=true;
|
||||
resetBtn.addEventListener('click',async()=>{
|
||||
if(!(await showConfirmDialog({title:t('settings_aux_reset_confirm_title')||'Reset auxiliary models?',message:t('settings_aux_reset_confirm_msg')||'This will set all auxiliary tasks to auto (use main model).',confirmLabel:t('settings_btn_reset_aux_models')||'Reset',danger:true}))) return;
|
||||
try{
|
||||
await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:'__reset__',provider:'auto',model:''})});
|
||||
if(typeof showToast==='function') showToast(t('settings_aux_reset_done')||'Auxiliary models reset to auto');
|
||||
_loadAuxiliaryModels();
|
||||
}catch(e){
|
||||
if(typeof showToast==='function') showToast(t('settings_aux_save_failed')||'Failed to reset auxiliary models');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply button
|
||||
if(applyBtn&&!applyBtn._bound){
|
||||
applyBtn._bound=true;
|
||||
applyBtn.addEventListener('click',_applyAuxModels);
|
||||
}
|
||||
}catch(e){
|
||||
console.warn('[settings] auxiliary models load failed',e);
|
||||
container.innerHTML='<div style="color:var(--muted);font-size:12px">'+(t('settings_aux_load_failed')||'Could not load auxiliary model settings. Make sure the agent API is available.')+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function _applyAuxModels(){
|
||||
let saved=0;
|
||||
for(const slot of _AUX_TASK_SLOTS){
|
||||
const provSel=$('aux-prov-'+slot.key);
|
||||
const modelSel=$('aux-model-'+slot.key);
|
||||
if(!provSel) continue;
|
||||
const provider=provSel.value;
|
||||
const model=(modelSel&&modelSel.value!=='__custom__')?(modelSel.value||''):'';
|
||||
const orig=_auxOriginalConfig?.[slot.key]||{provider:'auto',model:''};
|
||||
// Only save if changed
|
||||
if(provider!==orig.provider||model!==orig.model){
|
||||
try{
|
||||
await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:slot.key,provider,model})});
|
||||
saved++;
|
||||
}catch(e){
|
||||
console.warn('[settings] failed to save aux task',slot.key,e);
|
||||
if(typeof showToast==='function') showToast(t('settings_aux_save_failed')||'Failed to save auxiliary model for '+slot.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(typeof showToast==='function') showToast(saved?(t('settings_aux_saved')||'Auxiliary models updated'):(t('settings_aux_no_changes')||'No changes to apply'));
|
||||
// Reload to refresh state
|
||||
_loadAuxiliaryModels();
|
||||
}
|
||||
|
||||
async function saveSettings(andClose){
|
||||
const model=($('settingsModel')||{}).value;
|
||||
const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||'');
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Early PWA startup helpers.
|
||||
// Runs before the main UI bundle so installed launches can paint with the
|
||||
// correct native-like classes and capture browser install events early.
|
||||
(function(){
|
||||
'use strict';
|
||||
var root=document.documentElement;
|
||||
|
||||
function mql(query){
|
||||
try{return window.matchMedia&&window.matchMedia(query).matches;}catch(_){return false;}
|
||||
}
|
||||
function isStandalone(){
|
||||
return window.navigator.standalone===true ||
|
||||
mql('(display-mode: standalone)') ||
|
||||
mql('(display-mode: fullscreen)') ||
|
||||
mql('(display-mode: window-controls-overlay)');
|
||||
}
|
||||
function isIOS(){
|
||||
return /iPad|iPhone|iPod/.test(window.navigator.userAgent||'') ||
|
||||
(window.navigator.platform==='MacIntel' && window.navigator.maxTouchPoints>1);
|
||||
}
|
||||
function syncMode(){
|
||||
var standalone=isStandalone();
|
||||
root.classList.toggle('pwa-standalone',standalone);
|
||||
root.classList.toggle('pwa-browser',!standalone);
|
||||
root.classList.toggle('pwa-ios',isIOS());
|
||||
root.classList.toggle('pwa-offline',window.navigator.onLine===false);
|
||||
root.dataset.pwaDisplayMode=standalone?'standalone':'browser';
|
||||
return standalone;
|
||||
}
|
||||
function dispatch(name,detail){
|
||||
try{window.dispatchEvent(new CustomEvent(name,{detail:detail||{}}));}catch(_){}
|
||||
}
|
||||
|
||||
syncMode();
|
||||
window.addEventListener('online',function(){syncMode();dispatch('hermes:pwa-connection-change',{online:true});});
|
||||
window.addEventListener('offline',function(){syncMode();dispatch('hermes:pwa-connection-change',{online:false});});
|
||||
if(window.matchMedia){
|
||||
['(display-mode: standalone)','(display-mode: fullscreen)','(display-mode: window-controls-overlay)'].forEach(function(query){
|
||||
try{
|
||||
var media=window.matchMedia(query);
|
||||
var handler=function(){syncMode();};
|
||||
if(media.addEventListener)media.addEventListener('change',handler);
|
||||
else if(media.addListener)media.addListener(handler);
|
||||
}catch(_){}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt',function(event){
|
||||
event.preventDefault();
|
||||
window.hermesDeferredInstallPrompt=event;
|
||||
root.classList.add('pwa-installable');
|
||||
dispatch('hermes:pwa-installable');
|
||||
});
|
||||
window.addEventListener('appinstalled',function(){
|
||||
window.hermesDeferredInstallPrompt=null;
|
||||
root.classList.remove('pwa-installable');
|
||||
root.classList.add('pwa-installed');
|
||||
dispatch('hermes:pwa-installed');
|
||||
});
|
||||
document.addEventListener('visibilitychange',function(){
|
||||
if(document.visibilityState==='visible'){
|
||||
syncMode();
|
||||
root.classList.add('pwa-resumed');
|
||||
window.setTimeout(function(){root.classList.remove('pwa-resumed');},1200);
|
||||
}
|
||||
});
|
||||
|
||||
window.HermesPWA={
|
||||
isStandalone:isStandalone,
|
||||
syncMode:syncMode,
|
||||
launchAction:function(){
|
||||
try{return new URLSearchParams(window.location.search||'').get('action')||null;}catch(_){return null;}
|
||||
},
|
||||
promptInstall:function(){
|
||||
var prompt=window.hermesDeferredInstallPrompt;
|
||||
if(!prompt||typeof prompt['prompt']!=='function')return Promise.resolve({outcome:'unavailable'});
|
||||
window.hermesDeferredInstallPrompt=null;
|
||||
root.classList.remove('pwa-installable');
|
||||
prompt['prompt']();
|
||||
return Promise.resolve(prompt.userChoice).catch(function(){return {outcome:'dismissed'};});
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -641,6 +641,13 @@
|
||||
/* ── Smooth dark mode transitions ── */
|
||||
body,header,footer,aside,nav,main,div,button,input,textarea,select{transition-property:background-color,border-color,color;transition-duration:.15s;transition-timing-function:ease;}
|
||||
:root{--app-titlebar-safe-top:0px;}
|
||||
.pwa-standalone{overscroll-behavior:none;}
|
||||
.pwa-standalone body{-webkit-tap-highlight-color:transparent;}
|
||||
.pwa-standalone .app-titlebar-reload{display:inline-flex;}
|
||||
.pwa-offline .app-titlebar::after{content:'';position:absolute;left:50%;bottom:5px;width:5px;height:5px;border-radius:999px;background:var(--warning);box-shadow:0 0 0 3px color-mix(in srgb,var(--warning) 22%,transparent);transform:translateX(-50%);}
|
||||
.pwa-resumed .app-titlebar-title{animation:pwa-title-resume .6s ease-out;}
|
||||
@keyframes pwa-title-resume{0%{opacity:.65;}100%{opacity:1;}}
|
||||
@media (prefers-reduced-motion: reduce){.pwa-resumed .app-titlebar-title{animation:none;}}
|
||||
@supports (padding-top: env(safe-area-inset-top)){
|
||||
@media (display-mode: standalone), (display-mode: fullscreen){
|
||||
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
|
||||
|
||||
+3
-2
@@ -23,6 +23,7 @@ const CACHE_NAME = 'hermes-shell-__WEBUI_VERSION__';
|
||||
const VQ = '?v=__WEBUI_VERSION__';
|
||||
const SHELL_ASSETS = [
|
||||
'./static/style.css' + VQ,
|
||||
'./static/pwa-startup.js' + VQ,
|
||||
'./static/boot.js' + VQ,
|
||||
'./static/ui.js' + VQ,
|
||||
'./static/messages.js' + VQ,
|
||||
@@ -115,7 +116,7 @@ self.addEventListener('fetch', (event) => {
|
||||
// freshly set login cookie until the user manually refreshes.
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request).then((response) => {
|
||||
fetch(new Request(event.request, { cache: 'no-store' })).then((response) => {
|
||||
if (
|
||||
event.request.method === 'GET' &&
|
||||
response.status === 200 &&
|
||||
@@ -152,7 +153,7 @@ self.addEventListener('fetch', (event) => {
|
||||
// but avoids executing stale JS/CSS after a local hotfix when WEBUI_VERSION
|
||||
// has not changed yet (e.g. before a guarded restart updates the ?v token).
|
||||
event.respondWith(
|
||||
fetch(event.request).then((response) => {
|
||||
fetch(new Request(event.request, { cache: 'no-store' })).then((response) => {
|
||||
if (
|
||||
event.request.method === 'GET' &&
|
||||
response.status === 200
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for auxiliary models settings UI — panels.js + index.html + i18n.js.
|
||||
|
||||
Verifies that the auxiliary models card is present in the settings HTML,
|
||||
that the JS loading/saving logic is wired up, and that all locales have the
|
||||
required i18n keys.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestAuxiliaryModelsHTML:
|
||||
"""The auxiliary models card must be present in the settings preferences pane."""
|
||||
|
||||
def test_aux_models_container_exists(self):
|
||||
"""The #auxModelsContainer div must exist in the preferences pane."""
|
||||
assert 'id="auxModelsContainer"' in INDEX_HTML, (
|
||||
"Missing #auxModelsContainer in index.html — auxiliary models card not rendered"
|
||||
)
|
||||
|
||||
def test_reset_button_exists(self):
|
||||
assert 'id="btnResetAuxModels"' in INDEX_HTML, (
|
||||
"Missing #btnResetAuxModels button in index.html"
|
||||
)
|
||||
|
||||
def test_apply_button_exists(self):
|
||||
assert 'id="btnApplyAuxModels"' in INDEX_HTML, (
|
||||
"Missing #btnApplyAuxModels button in index.html"
|
||||
)
|
||||
|
||||
def test_aux_card_after_default_model(self):
|
||||
"""Auxiliary Models card should come after the Default Model card in the DOM."""
|
||||
model_idx = INDEX_HTML.find('id="settingsModel"')
|
||||
aux_idx = INDEX_HTML.find('id="auxModelsContainer"')
|
||||
assert model_idx >= 0, "Default Model select not found in index.html"
|
||||
assert aux_idx >= 0, "Auxiliary Models container not found in index.html"
|
||||
assert aux_idx > model_idx, (
|
||||
"Auxiliary Models container must appear after Default Model in the DOM"
|
||||
)
|
||||
|
||||
def test_i18n_label_on_aux_card(self):
|
||||
"""The auxiliary models card label must use data-i18n attribute."""
|
||||
assert 'data-i18n="settings_label_auxiliary_models"' in INDEX_HTML, (
|
||||
"Missing data-i18n='settings_label_auxiliary_models' on auxiliary card label"
|
||||
)
|
||||
|
||||
|
||||
class TestAuxiliaryModelsJS:
|
||||
"""The JS logic for loading and saving auxiliary models must be in panels.js."""
|
||||
|
||||
def test_load_function_exists(self):
|
||||
assert "async function _loadAuxiliaryModels" in PANELS_JS, (
|
||||
"Missing _loadAuxiliaryModels() in panels.js"
|
||||
)
|
||||
|
||||
def test_apply_function_exists(self):
|
||||
assert "async function _applyAuxModels" in PANELS_JS, (
|
||||
"Missing _applyAuxModels() in panels.js"
|
||||
)
|
||||
|
||||
def test_aux_task_slots_defined(self):
|
||||
"""_AUX_TASK_SLOTS must list the 9 canonical task slots."""
|
||||
assert "_AUX_TASK_SLOTS" in PANELS_JS, (
|
||||
"Missing _AUX_TASK_SLOTS constant in panels.js"
|
||||
)
|
||||
# Verify all 9 tasks are present
|
||||
for key in ("vision", "compression", "web_extract", "session_search",
|
||||
"approval", "mcp", "title_generation", "skills_hub", "curator"):
|
||||
assert f"key:'{key}'" in PANELS_JS, (
|
||||
f"Missing auxiliary task slot '{key}' in _AUX_TASK_SLOTS"
|
||||
)
|
||||
|
||||
def test_calls_model_auxiliary_api(self):
|
||||
"""_loadAuxiliaryModels must call /api/model/auxiliary."""
|
||||
assert "/api/model/auxiliary" in PANELS_JS, (
|
||||
"panels.js must call /api/model/auxiliary to fetch current config"
|
||||
)
|
||||
|
||||
def test_calls_model_set_api(self):
|
||||
"""_applyAuxModels must call /api/model/set to save changes."""
|
||||
assert "/api/model/set" in PANELS_JS, (
|
||||
"panels.js must call /api/model/set to save auxiliary model changes"
|
||||
)
|
||||
|
||||
def test_provider_cascade(self):
|
||||
"""Changing provider must rebuild model dropdown."""
|
||||
assert "_onAuxProviderChange" in PANELS_JS, (
|
||||
"Missing _onAuxProviderChange() for provider→model cascade"
|
||||
)
|
||||
assert "_buildAuxModelOptions" in PANELS_JS, (
|
||||
"Missing _buildAuxModelOptions() for model dropdown rebuild"
|
||||
)
|
||||
|
||||
def test_custom_model_prompt(self):
|
||||
"""Selecting 'Custom model…' must prompt for model ID."""
|
||||
assert "__custom__" in PANELS_JS, (
|
||||
"Missing __custom__ sentinel option for custom model input"
|
||||
)
|
||||
|
||||
def test_reset_calls_api_with_reset_task(self):
|
||||
"""Reset button must call /api/model/set with task='__reset__'."""
|
||||
idx = PANELS_JS.find("btnResetAuxModels")
|
||||
assert idx >= 0, "btnResetAuxModels not found in panels.js"
|
||||
# Check that __reset__ is sent in the reset handler
|
||||
body_after = PANELS_JS[idx:idx + 2000]
|
||||
assert "__reset__" in body_after, (
|
||||
"Reset handler must send task='__reset__' to /api/model/set"
|
||||
)
|
||||
|
||||
def test_load_called_from_loadSettingsPanel(self):
|
||||
"""_loadAuxiliaryModels must be called from loadSettingsPanel."""
|
||||
assert "_loadAuxiliaryModels()" in PANELS_JS, (
|
||||
"_loadAuxiliaryModels() is not called from loadSettingsPanel"
|
||||
)
|
||||
|
||||
def test_dirty_flag_marking(self):
|
||||
"""Changing an auxiliary dropdown must mark settings dirty."""
|
||||
assert "_markAuxDirty" in PANELS_JS, (
|
||||
"Missing _markAuxDirty() for dirty detection"
|
||||
)
|
||||
# _markAuxDirty should call _markSettingsDirty
|
||||
idx = PANELS_JS.find("function _markAuxDirty")
|
||||
body = PANELS_JS[idx:idx + 200]
|
||||
assert "_markSettingsDirty" in body, (
|
||||
"_markAuxDirty must call _markSettingsDirty"
|
||||
)
|
||||
|
||||
|
||||
class TestAuxiliaryModelsI18n:
|
||||
"""All locales must have the auxiliary model i18n keys."""
|
||||
|
||||
REQUIRED_KEYS = [
|
||||
"settings_label_auxiliary_models",
|
||||
"settings_desc_auxiliary_models",
|
||||
"settings_btn_reset_aux_models",
|
||||
"settings_btn_apply_aux_models",
|
||||
"settings_aux_provider_auto",
|
||||
"settings_aux_model_auto",
|
||||
"settings_aux_model_custom",
|
||||
"settings_aux_model_custom_prompt",
|
||||
"settings_aux_loading",
|
||||
"settings_aux_load_failed",
|
||||
"settings_aux_reset_confirm_title",
|
||||
"settings_aux_reset_confirm_msg",
|
||||
"settings_aux_reset_done",
|
||||
"settings_aux_save_failed",
|
||||
"settings_aux_saved",
|
||||
"settings_aux_no_changes",
|
||||
]
|
||||
|
||||
def test_all_i18n_keys_present(self):
|
||||
"""Every required key must exist in i18n.js at least once."""
|
||||
for key in self.REQUIRED_KEYS:
|
||||
assert key in I18N_JS, (
|
||||
f"Missing i18n key '{key}' in i18n.js"
|
||||
)
|
||||
|
||||
def test_all_locales_have_auxiliary_keys(self):
|
||||
"""Count of each key should equal the number of locales (12 with Turkish)."""
|
||||
for key in self.REQUIRED_KEYS:
|
||||
count = I18N_JS.count(f"{key}:")
|
||||
assert count == 12, (
|
||||
f"i18n key '{key}' found {count} times — expected 12 (one per locale)"
|
||||
)
|
||||
|
||||
|
||||
class TestAuxiliaryModelsBackend:
|
||||
"""WebUI backend must expose /api/model/auxiliary and /api/model/set."""
|
||||
|
||||
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_model_auxiliary_route_exists(self):
|
||||
"""/api/model/auxiliary route must be registered in routes.py."""
|
||||
assert '"/api/model/auxiliary"' in self.ROUTES_PY, (
|
||||
"Missing /api/model/auxiliary route in routes.py"
|
||||
)
|
||||
|
||||
def test_model_set_route_exists(self):
|
||||
"""/api/model/set route must be registered in routes.py."""
|
||||
assert '"/api/model/set"' in self.ROUTES_PY, (
|
||||
"Missing /api/model/set route in routes.py"
|
||||
)
|
||||
|
||||
def test_get_auxiliary_models_function_exists(self):
|
||||
"""get_auxiliary_models() must exist in api/config.py."""
|
||||
assert "def get_auxiliary_models" in self.CONFIG_PY, (
|
||||
"Missing get_auxiliary_models() in api/config.py"
|
||||
)
|
||||
|
||||
def test_set_auxiliary_model_function_exists(self):
|
||||
"""set_auxiliary_model() must exist in api/config.py."""
|
||||
assert "def set_auxiliary_model" in self.CONFIG_PY, (
|
||||
"Missing set_auxiliary_model() in api/config.py"
|
||||
)
|
||||
|
||||
def test_aux_task_slots_constant_exists(self):
|
||||
"""AUX_TASK_SLOTS must be defined in api/config.py."""
|
||||
assert "AUX_TASK_SLOTS" in self.CONFIG_PY, (
|
||||
"Missing AUX_TASK_SLOTS constant in api/config.py"
|
||||
)
|
||||
|
||||
def test_js_uses_models_endpoint_not_options(self):
|
||||
"""Frontend must use /api/models (WebUI's own API) not /api/model/options (agent API)."""
|
||||
# _loadAuxiliaryModels should call /api/models, not /api/model/options
|
||||
idx = PANELS_JS.find("async function _loadAuxiliaryModels")
|
||||
assert idx >= 0, "_loadAuxiliaryModels not found"
|
||||
body = PANELS_JS[idx:idx + 800]
|
||||
assert "/api/models" in body, (
|
||||
"_loadAuxiliaryModels must call /api/models for provider/model lists"
|
||||
)
|
||||
assert "/api/model/options" not in body, (
|
||||
"_loadAuxiliaryModels must NOT call /api/model/options (agent-only endpoint)"
|
||||
)
|
||||
@@ -665,6 +665,11 @@ def test_100dvh_viewport_height():
|
||||
"style.css must use 100dvh for correct mobile viewport height (100vh hides content under address bar)"
|
||||
|
||||
|
||||
def test_viewport_disables_page_zoom_for_native_pwa_shell():
|
||||
"""Installed PWA launches should not rubber-band into browser-style page zoom."""
|
||||
assert 'name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"' in HTML
|
||||
|
||||
|
||||
def test_pwa_safe_area_top_stays_scoped_to_installed_modes():
|
||||
"""The PWA shell should not opt into cover-mode geometry for every browser surface."""
|
||||
assert 'viewport-fit=cover' not in HTML
|
||||
@@ -701,6 +706,20 @@ def test_safe_area_variables_available_for_pwa_shell():
|
||||
)
|
||||
|
||||
|
||||
def test_pwa_startup_classes_have_native_shell_affordances():
|
||||
"""The JS-startup fallback classes should mirror browser display-mode CSS.
|
||||
|
||||
iOS and embedded webviews do not always evaluate display-mode media queries
|
||||
the same way as Chromium. pwa-startup.js adds classes early, so CSS should
|
||||
provide the same native-feel affordances through those classes.
|
||||
"""
|
||||
assert ".pwa-standalone" in CSS
|
||||
assert ".pwa-standalone .app-titlebar-reload" in CSS
|
||||
assert "overscroll-behavior:none" in CSS
|
||||
assert ".pwa-offline .app-titlebar::after" in CSS
|
||||
assert "pwa-title-resume" in CSS
|
||||
|
||||
|
||||
def test_composer_touch_target_size():
|
||||
"""Send button and composer inputs must have minimum 44px touch targets on mobile.
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MANIFEST = ROOT / "static" / "manifest.json"
|
||||
SW = ROOT / "static" / "sw.js"
|
||||
PWA_STARTUP = ROOT / "static" / "pwa-startup.js"
|
||||
BOOT = ROOT / "static" / "boot.js"
|
||||
INDEX = ROOT / "static" / "index.html"
|
||||
ROUTES = ROOT / "api" / "routes.py"
|
||||
AUTH = ROOT / "api" / "auth.py"
|
||||
@@ -117,7 +119,7 @@ class TestServiceWorker:
|
||||
"""
|
||||
src = SW.read_text(encoding="utf-8")
|
||||
assert "Shell assets: network-first with cache fallback" in src
|
||||
assert "fetch(event.request).then((response)" in src
|
||||
assert "fetch(new Request(event.request, { cache: 'no-store' })).then((response)" in src
|
||||
assert "caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))" in src
|
||||
assert ".catch(() => caches.match(event.request)" in src
|
||||
assert "if (cached) return cached;" not in src, (
|
||||
@@ -281,10 +283,65 @@ class TestIndexHtmlIntegration:
|
||||
marker = "// Shell assets: network-first with cache fallback"
|
||||
assert marker in src
|
||||
block = src[src.find(marker):src.find(marker) + 900]
|
||||
assert "fetch(event.request).then" in block
|
||||
assert "fetch(new Request(event.request, { cache: 'no-store' })).then" in block
|
||||
assert "caches.match(event.request)" in block
|
||||
assert "caches.match(event.request).then((cached)" not in block[:250]
|
||||
|
||||
def test_index_loads_pwa_startup_helper_early(self):
|
||||
"""The installed-app shell should classify standalone/offline mode before
|
||||
the main UI bundle hydrates, so native chrome and safe-area affordances
|
||||
are present on first paint.
|
||||
"""
|
||||
src = INDEX.read_text(encoding="utf-8")
|
||||
preload_pos = src.find('href="static/pwa-startup.js?v=__WEBUI_VERSION__"')
|
||||
script_pos = src.find('src="static/pwa-startup.js?v=__WEBUI_VERSION__"')
|
||||
ui_pos = src.find('static/ui.js?v=__WEBUI_VERSION__')
|
||||
assert preload_pos != -1, "index.html must preload the PWA startup helper"
|
||||
assert script_pos != -1, "index.html must load the PWA startup helper"
|
||||
assert ui_pos != -1, "index.html must load the main UI bundle"
|
||||
assert preload_pos < ui_pos and script_pos < ui_pos, (
|
||||
"pwa-startup.js must run before ui.js so standalone/offline classes "
|
||||
"are available before the app shell paints"
|
||||
)
|
||||
|
||||
def test_sw_precaches_pwa_startup_helper(self):
|
||||
src = SW.read_text(encoding="utf-8")
|
||||
assert "pwa-startup.js' + VQ" in src or 'pwa-startup.js" + VQ' in src, (
|
||||
"sw.js SHELL_ASSETS must pre-cache pwa-startup.js with the same "
|
||||
"version query used by index.html"
|
||||
)
|
||||
|
||||
def test_manifest_has_native_launch_fields(self):
|
||||
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
assert data.get("id") == "./"
|
||||
assert data.get("scope") == "./"
|
||||
assert data.get("start_url") == "./?source=pwa"
|
||||
assert "standalone" in data.get("display_override", []), (
|
||||
"manifest.display_override should preserve standalone as a native "
|
||||
"launch fallback"
|
||||
)
|
||||
shortcuts = data.get("shortcuts") or []
|
||||
assert any(shortcut.get("url") == "./?source=pwa&action=new-chat" for shortcut in shortcuts), (
|
||||
"manifest should expose a native shortcut for starting a new chat"
|
||||
)
|
||||
|
||||
def test_pwa_startup_detects_standalone_and_install_events(self):
|
||||
src = PWA_STARTUP.read_text(encoding="utf-8")
|
||||
assert "pwa-standalone" in src
|
||||
assert "pwa-browser" in src
|
||||
assert "beforeinstallprompt" in src
|
||||
assert "appinstalled" in src
|
||||
assert "HermesPWA" in src
|
||||
assert "launchAction" in src
|
||||
assert "promptInstall" in src
|
||||
|
||||
def test_pwa_new_chat_shortcut_is_handled_at_boot(self):
|
||||
src = BOOT.read_text(encoding="utf-8")
|
||||
assert "pwaLaunchAction" in src
|
||||
assert "launchAction()" in src
|
||||
assert "pwaLaunchAction==='new-chat'" in src
|
||||
assert "await newSession(true)" in src
|
||||
|
||||
def test_index_route_url_encodes_asset_version(self):
|
||||
src = ROUTES.read_text(encoding="utf-8")
|
||||
idx = src.find('parsed.path in ("/", "/index.html")')
|
||||
|
||||
@@ -409,11 +409,31 @@ def test_chat_start_route_selects_adapter_only_when_flag_enabled():
|
||||
start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)]
|
||||
|
||||
assert "runtime_adapter_enabled()" in start_body
|
||||
assert "runtime_adapter_runner_enabled()" in start_body
|
||||
assert "build_runtime_adapter(" in start_body
|
||||
assert "legacy_adapter_factory=_legacy_adapter_factory" in start_body
|
||||
assert "runner_client_factory=_runtime_runner_client_factory" in start_body
|
||||
assert "LegacyJournalRuntimeAdapter" in start_body
|
||||
assert "_start_chat_stream_for_session(" in start_body
|
||||
assert "HERMES_WEBUI_RUNTIME_ADAPTER" not in start_body, "route should use runtime_adapter_enabled(), not inline env checks"
|
||||
|
||||
|
||||
def test_runner_local_chat_start_selection_does_not_fallback_to_legacy():
|
||||
routes = importlib.import_module("api.routes")
|
||||
src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
start_idx = src.index("def _handle_chat_start")
|
||||
start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)]
|
||||
|
||||
flag_branch = "if runtime_adapter_enabled() or runtime_adapter_runner_enabled():"
|
||||
assert flag_branch in start_body
|
||||
assert "except NotImplementedError as exc:" in start_body
|
||||
assert 'return j(handler, {"error": str(exc)}, status=501)' in start_body
|
||||
assert "runner-local chat backend is not configured" in src
|
||||
adapter_branch = start_body[start_body.index(flag_branch):start_body.index("else:", start_body.index(flag_branch))]
|
||||
assert "_start_chat_stream_for_session(" in adapter_branch, "legacy-journal delegate should still call the legacy path"
|
||||
assert "runtime_adapter_runner_enabled()" in adapter_branch
|
||||
|
||||
|
||||
def test_chat_start_adapter_path_preserves_legacy_response_shape():
|
||||
"""The RuntimeAdapter seam must be invisible to /api/chat/start callers.
|
||||
|
||||
@@ -422,17 +442,53 @@ def test_chat_start_adapter_path_preserves_legacy_response_shape():
|
||||
"""
|
||||
routes = importlib.import_module("api.routes")
|
||||
src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
start_idx = src.index("def _handle_chat_start")
|
||||
start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)]
|
||||
branch_start = start_body.index("if runtime_adapter_enabled():")
|
||||
branch_end = start_body.index("else:", branch_start)
|
||||
adapter_branch = start_body[branch_start:branch_end]
|
||||
helper_idx = src.index("def _chat_start_response_from_run_start")
|
||||
helper_body = src[helper_idx:src.index("def _runtime_adapter_goal_action", helper_idx)]
|
||||
|
||||
assert 'response.setdefault("stream_id", result.stream_id)' in adapter_branch
|
||||
assert 'response.setdefault("session_id", result.session_id)' in adapter_branch
|
||||
assert 'response.setdefault("run_id", result.run_id)' not in adapter_branch
|
||||
assert 'response.setdefault("status", result.status)' not in adapter_branch
|
||||
assert 'response.setdefault("active_controls", result.active_controls)' not in adapter_branch
|
||||
assert '"stream_id",' in helper_body
|
||||
assert '"session_id",' in helper_body
|
||||
assert 'response.setdefault("stream_id", result.stream_id)' in helper_body
|
||||
assert 'response.setdefault("session_id", result.session_id)' in helper_body
|
||||
assert '"run_id",' not in helper_body
|
||||
assert '"status",' not in helper_body
|
||||
assert '"active_controls",' not in helper_body
|
||||
|
||||
|
||||
def test_chat_start_response_from_run_start_filters_adapter_internal_fields():
|
||||
routes = importlib.import_module("api.routes")
|
||||
runtime = importlib.import_module("api.runtime_adapter")
|
||||
|
||||
response = routes._chat_start_response_from_run_start(
|
||||
runtime.RunStartResult(
|
||||
run_id="runner-internal-1",
|
||||
session_id="s1",
|
||||
stream_id="runner-stream-1",
|
||||
status="running",
|
||||
active_controls=["cancel"],
|
||||
payload={
|
||||
"stream_id": "runner-stream-1",
|
||||
"session_id": "s1",
|
||||
"pending_started_at": 123.0,
|
||||
"turn_id": "turn-1",
|
||||
"title": "Demo",
|
||||
"effective_model": "gpt-5.5",
|
||||
"effective_model_provider": "openai-codex",
|
||||
"run_id": "runner-internal-1",
|
||||
"status": "running",
|
||||
"active_controls": ["cancel"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert response == {
|
||||
"stream_id": "runner-stream-1",
|
||||
"session_id": "s1",
|
||||
"pending_started_at": 123.0,
|
||||
"turn_id": "turn-1",
|
||||
"title": "Demo",
|
||||
"effective_model": "gpt-5.5",
|
||||
"effective_model_provider": "openai-codex",
|
||||
}
|
||||
|
||||
|
||||
def test_rfc_distinguishes_goal_routing_from_queue_route_staging():
|
||||
@@ -485,6 +541,7 @@ def test_rfc_defines_slice4d_supervised_runner_route_gate():
|
||||
rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "#### Slice 4d: Supervised runner backend route gate" in rfc
|
||||
assert "Status as of 2026-05-23: shipped in v0.51.108 via #2744" in rfc
|
||||
assert "After `runner-local` selection exists" in rfc
|
||||
assert "route-selection harness before live\nbrowser chat can use it" in rfc
|
||||
assert "Route remains default-off" in rfc
|
||||
@@ -496,6 +553,19 @@ def test_rfc_defines_slice4d_supervised_runner_route_gate():
|
||||
assert "WebUI remains the rich workbench while\n only execution ownership moves" in rfc
|
||||
|
||||
|
||||
def test_rfc_defines_slice4e_runner_chat_start_route_selection_harness():
|
||||
routes = importlib.import_module("api.routes")
|
||||
rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "#### Slice 4e: Default-off runner chat-start route-selection harness" in rfc
|
||||
assert "route `/api/chat/start` through `build_runtime_adapter(...)`" in rfc
|
||||
assert "`legacy-direct` stays default" in rfc
|
||||
assert "`legacy-journal`\ncontinues to delegate to the legacy in-process stream path" in rfc
|
||||
assert "`runner-local`\ndoes not silently fall back to legacy" in rfc
|
||||
assert "return a bounded not-configured error for `runner-local`" in rfc
|
||||
assert "`run_id`, `status`, and\n `active_controls` remain internal" in rfc
|
||||
assert "no supervised runner process yet" in rfc
|
||||
|
||||
def test_runner_runtime_adapter_passes_explicit_start_payload_without_env_mutation(monkeypatch):
|
||||
runtime = importlib.import_module("api.runtime_adapter")
|
||||
captured = []
|
||||
|
||||
@@ -41,9 +41,9 @@ def test_service_worker_uses_network_first_for_page_navigation():
|
||||
"""Page navigations must hit the server before cache so expired auth redirects work."""
|
||||
navigate_idx = SW_SRC.find("event.request.mode === 'navigate'")
|
||||
assert navigate_idx != -1, "service worker must special-case page navigations"
|
||||
fetch_idx = SW_SRC.find("fetch(event.request)", navigate_idx)
|
||||
fetch_idx = SW_SRC.find("fetch(new Request(event.request, { cache: 'no-store' }))", navigate_idx)
|
||||
cache_idx = SW_SRC.find("caches.match", navigate_idx)
|
||||
assert fetch_idx != -1, "navigation branch must try the live server first"
|
||||
assert fetch_idx != -1, "navigation branch must try the live server first while bypassing HTTP cache"
|
||||
assert cache_idx != -1, "navigation branch may use cached shell only as offline fallback"
|
||||
assert fetch_idx < cache_idx, (
|
||||
"navigation requests must be network-first, not cache-first, so auth redirects "
|
||||
|
||||
Reference in New Issue
Block a user