mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Release v0.51.30 — Release G (offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll)
Merge stage-325 to master.
This commit is contained in:
+34
-1
@@ -1,6 +1,39 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.51.29] — 2026-05-08 — 6-PR contributor batch (Release F: Docker hardening + login persistence + scroll/lineage fixes + i18n cleanup)
|
||||
## [v0.51.30] — 2026-05-08 — 3-PR contributor batch (Release G: offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll)
|
||||
|
||||
### Added (3 PRs, all from @ai-ag2026)
|
||||
|
||||
- **PR #1891** — Browser offline recovery and PWA cache hardening. Adds an offline/recovery banner that probes `/health` and auto-refreshes when Hermes is reachable again. Defers stream error handling while the browser is offline so reconnecting does not immediately surface a terminal chat error. Makes service-worker shell assets network-first with cache fallback (so local hotfixes are not hidden behind stale cached JS/CSS), while preserving offline-launch capability via `install` pre-caching of SHELL_ASSETS. Keeps PWA/native chrome colors aligned with the dark Hermes background. Stream-error deferral only triggers when the banner is visible OR `navigator.onLine===false` — so Hermes-up + browser-online flows errors through normally; no swallowed auth errors. Supersedes the recovery/PWA portion of #1888.
|
||||
|
||||
- **PR #1928** — Opt-in session Start/End jump buttons (`session_jump_buttons` setting, default OFF). Adds an Appearance setting that surfaces a sticky `Start` pill (loads full history and jumps to beginning) and expands the existing scroll-to-bottom button into an `End` pill. Localized text, tooltip, and aria labels for the jump controls. The opt-in default keeps the existing UI unchanged for users who don't want the floating pills.
|
||||
|
||||
- **PR #1929** — Opt-in session endless-scroll (`session_endless_scroll` setting, default OFF). Adds automatic prefetching of older transcript pages while scrolling upward (1.5x viewport prefetch window). Builds on #1927's viewport-preservation fix (shipped in v0.51.29) so prepended pages have scroll runway and don't jump. Replaces the previous auto-trigger-at-scrollTop<80 behavior — when the setting is OFF, users get the manual "Load earlier" button path (`_wireMessageWindowLoadEarlierButton`).
|
||||
|
||||
### Conflict resolution applied during stage merge
|
||||
|
||||
#1928 and #1929 both touch `static/ui.js`, `static/i18n.js`, `static/index.html`, `static/panels.js`, `api/config.py`. Mechanical conflicts (both add new settings keys / locale entries / HTML toggles / accessor branches) were resolved by keeping both — the features are independent opt-in toggles. The `static/ui.js` scroll-listener conflict required an intent-based resolution: #1929 INTENTIONALLY replaces the `el.scrollTop<80` auto-trigger block with the gated prefetch block, so the old block was removed. Test `tests/test_session_endless_scroll.py::test_scroll_listener_prefetches_older_messages_only_when_enabled` enforces this. CHANGELOG conflicts auto-resolved during rebase (took ours strategy).
|
||||
|
||||
### Tests
|
||||
|
||||
4960 → **4977 collected, 4977 passing, 0 regressions** (+17 net new). Full suite ~140s on Python 3.11 (HERMES_HOME isolated). JS syntax check (`node -c`) clean on all 6 modified `static/*.js` files. Browser API sanity harness (port 8789): all 11 endpoints + 20 QA tests PASS. **Manual browser verification on stage-325 server** (port 8789): both new settings toggles render in the Settings panel; `window._isSessionEndlessScrollEnabled()` correctly reflects toggle state; `_updateSessionStartJumpButton` function is exposed; offline-banner template + "Check now" button present in HTML. Opus advisor: SHIP-WITH-FIXES (one tracked race fast-follow + one i18n polish fast-follow, both non-blockers per Opus's own recommendation "Ship the batch").
|
||||
|
||||
### Pre-release verification
|
||||
|
||||
- Full pytest under `HERMES_HOME` isolation: **4977 passed, 8 skipped, 1 xfailed, 2 xpassed, 8 subtests passed** in 140.56s.
|
||||
- Browser API harness against stage-325 on port 8789: all 11 endpoints + 20 QA tests PASS (111.35s for QA phase).
|
||||
- Manual browser verification: stage-325 server up on 8789, navigated to /, verified new toggles render in Settings panel, verified helper functions exposed correctly, verified offline-banner template loaded.
|
||||
- `node -c` on all 6 modified `static/*.js` files: clean.
|
||||
- Stage diff: 16 files, +649/-30.
|
||||
- Opus advisor pass on stage-325 brief: VERDICT=SHIP-WITH-FIXES with explicit "Ship the batch" recommendation. Two fast-follows filed for tracking, neither is blocking.
|
||||
- v0.51.29 carry-overs verified preserved (no in-batch changes to `_strip_workspace_prefix`, `evaluate_goal_after_turn`, `_profiles_match`, `mcp_server.py`).
|
||||
- Pre-stamp re-fetch of all 3 PR heads: no contributor force-push during Opus window.
|
||||
|
||||
### Follow-up items filed (non-blocking)
|
||||
|
||||
- **Race between endless-scroll prefetch and Start-jump's `_ensureAllMessagesLoaded`** — with both opt-ins ON, an in-flight prefetch (started by 1.5x-viewport trigger) racing with `jumpToSessionStart` → `_ensureAllMessagesLoaded` could produce duplicate messages if the prefetch resolves last. Narrow window, but the fix is to gate `_ensureAllMessagesLoaded` on the existing `_loadingOlder` flag.
|
||||
- **#1928 locale parity** — `session_jump_*` and `settings_*_session_jump_buttons` keys are English literals in ja/ru/es/de/zh/zh-Hant/pt/ko. Default-OFF + English fallback works, but breaks the locale-parity standard set by #1929 and #1891 in the same release.
|
||||
|
||||
|
||||
### Added (1 PR)
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
|
||||
>
|
||||
> Last updated: v0.51.29 (May 8, 2026) — 4960 tests collected — 6-PR Release F batch (Docker hardening + login persistence + scroll/lineage fixes + i18n cleanup)
|
||||
> Last updated: v0.51.30 (May 8, 2026) — 4977 tests collected — 3-PR Release G batch (offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll)
|
||||
> Test source: `pytest tests/ --collect-only -q`
|
||||
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
|
||||
+2
-2
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.51.29, May 8, 2026*
|
||||
*Total automated tests collected: 4960*
|
||||
*Last updated: v0.51.30, May 8, 2026*
|
||||
*Total automated tests collected: 4977*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Public paths (no auth required) ─────────────────────────────────────────
|
||||
PUBLIC_PATHS = frozenset({
|
||||
'/login', '/health', '/favicon.ico',
|
||||
'/login', '/health', '/favicon.ico', '/sw.js',
|
||||
'/api/auth/login', '/api/auth/status',
|
||||
'/manifest.json', '/manifest.webmanifest',
|
||||
})
|
||||
|
||||
@@ -3682,6 +3682,8 @@ _SETTINGS_DEFAULTS = {
|
||||
"theme": "dark", # light | dark | system
|
||||
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard
|
||||
"font_size": "default", # small | default | large
|
||||
"session_jump_buttons": False, # show Start/End transcript jump pills
|
||||
"session_endless_scroll": False, # auto-load older transcript pages while scrolling upward
|
||||
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
|
||||
"bot_name": os.getenv(
|
||||
"HERMES_WEBUI_BOT_NAME", "Hermes"
|
||||
@@ -3810,6 +3812,8 @@ _SETTINGS_BOOL_KEYS = {
|
||||
"show_thinking",
|
||||
"simplified_tool_calling",
|
||||
"api_redact_enabled",
|
||||
"session_jump_buttons",
|
||||
"session_endless_scroll",
|
||||
}
|
||||
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
|
||||
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
|
||||
|
||||
+14
-3
@@ -1130,10 +1130,17 @@ function _normalizeAppearance(theme,skin){
|
||||
// the meta tag.
|
||||
function _syncThemeColorMeta(){
|
||||
try{
|
||||
const meta=document.getElementById('hermes-theme-color');
|
||||
if(!meta) return;
|
||||
const bg=getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
|
||||
if(bg) meta.setAttribute('content',bg);
|
||||
if(!bg) return;
|
||||
const known=document.getElementById('hermes-theme-color');
|
||||
if(known){
|
||||
known.setAttribute('content',bg);
|
||||
known.removeAttribute('media');
|
||||
}
|
||||
document.querySelectorAll('meta[name="theme-color"]').forEach(meta=>{
|
||||
meta.setAttribute('content',bg);
|
||||
meta.removeAttribute('media');
|
||||
});
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
@@ -1303,11 +1310,13 @@ function applyBotName(){
|
||||
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
|
||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||
window._busyInputMode=(s.busy_input_mode||'queue');
|
||||
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
if(s.default_model) window._defaultModel=s.default_model;
|
||||
// Persist default workspace so the blank new-chat page can show it
|
||||
// and workspace actions (New file/folder) work before the first session (#804).
|
||||
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
|
||||
window._sessionJumpButtonsEnabled=!!s.session_jump_buttons;
|
||||
const appearance=_normalizeAppearance(s.theme,s.skin);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
_applyTheme(appearance.theme);
|
||||
@@ -1335,8 +1344,10 @@ function applyBotName(){
|
||||
window._notificationsEnabled=false;
|
||||
window._showThinking=true;
|
||||
window._simplifiedToolCalling=true;
|
||||
window._sessionJumpButtonsEnabled=false;
|
||||
window._sidebarDensity='compact';
|
||||
window._busyInputMode='queue';
|
||||
window._sessionEndlessScrollEnabled=false;
|
||||
window._botName='Hermes';
|
||||
_bootSettings={check_for_updates:false};
|
||||
if(typeof setLocale==='function'){
|
||||
|
||||
+155
@@ -5,6 +5,13 @@
|
||||
|
||||
const LOCALES = {
|
||||
en: {
|
||||
offline_title: 'Connection lost',
|
||||
offline_browser_detail: 'Your browser reports that this device is offline.',
|
||||
offline_network_detail: 'Hermes is unreachable from this browser right now.',
|
||||
offline_autorefresh: 'I will refresh this page automatically when Hermes is reachable again.',
|
||||
offline_check_now: 'Check now',
|
||||
offline_checking: 'Checking…',
|
||||
offline_stream_waiting: 'Connection lost. Waiting to refresh…',
|
||||
_lang: 'en',
|
||||
_label: 'English',
|
||||
_speech: 'en-US',
|
||||
@@ -125,6 +132,10 @@ const LOCALES = {
|
||||
untitled: 'Untitled',
|
||||
n_messages: (n) => `${n} messages`,
|
||||
load_older_messages: '↑ Scroll up or click to load older messages',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
queued_label: 'Sends after response',
|
||||
queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`,
|
||||
queued_cancel: 'Cancel queued message',
|
||||
@@ -421,6 +432,12 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Update check failed',
|
||||
settings_label_workspace_panel_open: 'Keep workspace panel open by default',
|
||||
settings_desc_workspace_panel_open: 'When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
|
||||
settings_label_session_endless_scroll: 'Load older messages while scrolling up',
|
||||
|
||||
settings_desc_session_endless_scroll: 'When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.',
|
||||
open_in_browser: 'Open in browser',
|
||||
settings_dropdown_conversation: 'Conversation',
|
||||
settings_dropdown_appearance: 'Appearance',
|
||||
@@ -1026,6 +1043,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
ja: {
|
||||
offline_title: '接続が切断されました',
|
||||
offline_browser_detail: 'ブラウザはこのデバイスがオフラインだと報告しています。',
|
||||
offline_network_detail: '現在、このブラウザからHermesに到達できません。',
|
||||
offline_autorefresh: 'Hermesに再接続できたら、このページを自動的に更新します。',
|
||||
offline_check_now: '今すぐ確認',
|
||||
offline_checking: '確認中…',
|
||||
offline_stream_waiting: '接続が切断されました。更新を待っています…',
|
||||
_lang: 'ja',
|
||||
_label: '日本語',
|
||||
_speech: 'ja-JP',
|
||||
@@ -1146,6 +1170,10 @@ const LOCALES = {
|
||||
untitled: '無題',
|
||||
n_messages: (n) => `${n} 件のメッセージ`,
|
||||
load_older_messages: '↑ 上にスクロール、またはクリックして過去のメッセージを読み込む',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
queued_label: '応答後に送信',
|
||||
queued_count: (n) => `${n} 件キュー中`,
|
||||
queued_cancel: 'キューに入れたメッセージをキャンセル',
|
||||
@@ -1442,6 +1470,12 @@ const LOCALES = {
|
||||
settings_updates_disabled: 'アップデート確認は無効です',
|
||||
settings_label_workspace_panel_open: 'ワークスペースパネルをデフォルトで開いておく',
|
||||
settings_desc_workspace_panel_open: '有効にすると、新しいセッションごとにワークスペース/ファイルブラウザパネルが自動で開きます。手動でいつでも閉じられます。',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
|
||||
settings_label_session_endless_scroll: '上スクロールで古いメッセージを読み込む',
|
||||
|
||||
settings_desc_session_endless_scroll: '有効にすると、上にスクロールしたとき古いメッセージを自動で読み込みます。無効の場合は古いメッセージボタンを使います。',
|
||||
open_in_browser: 'ブラウザで開く',
|
||||
settings_dropdown_conversation: '会話',
|
||||
settings_dropdown_appearance: '外観',
|
||||
@@ -2047,6 +2081,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
ru: {
|
||||
offline_title: 'Соединение потеряно',
|
||||
offline_browser_detail: 'Браузер сообщает, что это устройство офлайн.',
|
||||
offline_network_detail: 'Hermes сейчас недоступен из этого браузера.',
|
||||
offline_autorefresh: 'Я автоматически обновлю страницу, когда Hermes снова станет доступен.',
|
||||
offline_check_now: 'Проверить сейчас',
|
||||
offline_checking: 'Проверка…',
|
||||
offline_stream_waiting: 'Соединение потеряно. Ожидаю обновления…',
|
||||
_lang: 'ru',
|
||||
_label: 'Русский',
|
||||
_speech: 'ru-RU',
|
||||
@@ -2145,6 +2186,10 @@ const LOCALES = {
|
||||
untitled: 'Без названия',
|
||||
n_messages: (n) => `${n} сообщений`,
|
||||
load_older_messages: '↑ Прокрутите вверх или нажмите, чтобы загрузить ранние сообщения',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
queued_label: 'Отправить после ответа',
|
||||
queued_count: (n) => n === 1 ? '1 в очереди' : `${n} в очереди`,
|
||||
queued_cancel: 'Отменить сообщение',
|
||||
@@ -2880,6 +2925,12 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Ошибка проверки обновлений',
|
||||
settings_label_workspace_panel_open: 'Открывать панель рабочей области по умолчанию',
|
||||
settings_desc_workspace_panel_open: 'При включении панель файлов будет открываться автоматически в каждой новой сессии.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
|
||||
settings_label_session_endless_scroll: 'Загружать старые сообщения при прокрутке вверх',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Если включено, старые сообщения загружаются автоматически при прокрутке вверх. Если выключено, используйте кнопку загрузки старых сообщений.',
|
||||
open_in_browser: 'Открыть в браузере',
|
||||
settings_section_system_title: 'System',
|
||||
settings_tab_appearance: 'Appearance',
|
||||
@@ -3006,6 +3057,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
es: {
|
||||
offline_title: 'Conexión perdida',
|
||||
offline_browser_detail: 'Tu navegador indica que este dispositivo está sin conexión.',
|
||||
offline_network_detail: 'Hermes no está disponible desde este navegador ahora mismo.',
|
||||
offline_autorefresh: 'Actualizaré esta página automáticamente cuando Hermes vuelva a estar disponible.',
|
||||
offline_check_now: 'Comprobar ahora',
|
||||
offline_checking: 'Comprobando…',
|
||||
offline_stream_waiting: 'Conexión perdida. Esperando para actualizar…',
|
||||
_lang: 'es',
|
||||
_label: 'Español',
|
||||
_speech: 'es-ES',
|
||||
@@ -3102,6 +3160,10 @@ const LOCALES = {
|
||||
untitled: 'Sin título',
|
||||
n_messages: (n) => `${n} mensajes`,
|
||||
load_older_messages: '↑ Desplázate hacia arriba o haz clic para cargar mensajes anteriores',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
queued_label: 'Enviar después de la respuesta',
|
||||
queued_count: (n) => n === 1 ? '1 en cola' : `${n} en cola`,
|
||||
queued_cancel: 'Cancelar mensaje en cola',
|
||||
@@ -3823,6 +3885,12 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Error al comprobar actualizaciones',
|
||||
settings_label_workspace_panel_open: 'Mantener panel de espacio abierto',
|
||||
settings_desc_workspace_panel_open: 'Al activar, el panel de archivos se abre automáticamente en cada nueva sesión. Aún puedes cerrarlo manualmente.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
|
||||
settings_label_session_endless_scroll: 'Cargar mensajes antiguos al desplazarse hacia arriba',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Si está activado, los mensajes antiguos se cargan automáticamente al desplazarte hacia arriba. Si está desactivado, usa el botón de mensajes antiguos.',
|
||||
open_in_browser: 'Abrir en el navegador',
|
||||
settings_section_system_title: 'System',
|
||||
settings_tab_appearance: 'Appearance',
|
||||
@@ -3959,6 +4027,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
de: {
|
||||
offline_title: 'Verbindung verloren',
|
||||
offline_browser_detail: 'Dein Browser meldet, dass dieses Gerät offline ist.',
|
||||
offline_network_detail: 'Hermes ist von diesem Browser aus gerade nicht erreichbar.',
|
||||
offline_autorefresh: 'Ich aktualisiere diese Seite automatisch, sobald Hermes wieder erreichbar ist.',
|
||||
offline_check_now: 'Jetzt prüfen',
|
||||
offline_checking: 'Prüfe…',
|
||||
offline_stream_waiting: 'Verbindung verloren. Warte auf Aktualisierung…',
|
||||
_lang: 'de',
|
||||
_label: 'Deutsch',
|
||||
_speech: 'de-DE',
|
||||
@@ -4055,6 +4130,10 @@ const LOCALES = {
|
||||
untitled: 'Unbenannt',
|
||||
n_messages: (n) => `${n} Nachrichten`,
|
||||
load_older_messages: '↑ Nach oben scrollen oder klicken, um ältere Nachrichten zu laden',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
queued_label: 'Wird nach Antwort gesendet',
|
||||
queued_count: (n) => n === 1 ? '1 in Warteschlange' : `${n} in Warteschlange`,
|
||||
queued_cancel: 'Nachricht abbrechen',
|
||||
@@ -4511,6 +4590,12 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Update-Prüfung fehlgeschlagen',
|
||||
settings_label_workspace_panel_open: 'Arbeitsbereich-Panel standardmäßig öffnen',
|
||||
settings_desc_workspace_panel_open: 'Wenn aktiviert, wird der Datei-Browser bei jeder neuen Sitzung automatisch geöffnet. Er kann jederzeit manuell geschlossen werden.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
|
||||
settings_label_session_endless_scroll: 'Ältere Nachrichten beim Hochscrollen laden',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Wenn aktiviert, werden ältere Nachrichten beim Hochscrollen automatisch geladen. Wenn deaktiviert, nutzt du den Button für ältere Nachrichten.',
|
||||
|
||||
workspace_drag_hint: 'Ziehen zum Neuordnen',
|
||||
workspace_reorder_failed: 'Neuordnen fehlgeschlagen',
|
||||
@@ -4916,6 +5001,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
zh: {
|
||||
offline_title: '连接已断开',
|
||||
offline_browser_detail: '浏览器报告此设备当前离线。',
|
||||
offline_network_detail: '此浏览器当前无法连接到 Hermes。',
|
||||
offline_autorefresh: '当 Hermes 可访问时,我会自动刷新此页面。',
|
||||
offline_check_now: '立即检查',
|
||||
offline_checking: '正在检查…',
|
||||
offline_stream_waiting: '连接已断开。正在等待刷新…',
|
||||
_lang: 'zh',
|
||||
_label: '\u7b80\u4f53\u4e2d\u6587',
|
||||
_speech: 'zh-CN',
|
||||
@@ -5012,6 +5104,10 @@ const LOCALES = {
|
||||
untitled: '\u672a\u547d\u540d',
|
||||
n_messages: (n) => `${n} \u6761\u6d88\u606f`,
|
||||
load_older_messages: '↑ 向上滚动或点击加载更早的消息',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
queued_label: '响应后发送',
|
||||
queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`,
|
||||
queued_cancel: '取消排队消息',
|
||||
@@ -5730,6 +5826,12 @@ const LOCALES = {
|
||||
settings_update_check_failed: '更新检查失败',
|
||||
settings_label_workspace_panel_open: '默认保持工作区面板打开',
|
||||
settings_desc_workspace_panel_open: '启用后,工作区/文件浏览器面板会在每次新会话时自动打开。您仍可随时手动关闭。',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
|
||||
settings_label_session_endless_scroll: '向上滚动时加载更早的消息',
|
||||
|
||||
settings_desc_session_endless_scroll: '启用后,向上滚动时会自动加载更早的消息。禁用时请使用加载更早消息按钮。',
|
||||
open_in_browser: '在浏览器中打开',
|
||||
settings_section_system_title: 'System',
|
||||
settings_tab_appearance: 'Appearance',
|
||||
@@ -5964,6 +6066,10 @@ const LOCALES = {
|
||||
untitled: '\u672a\u547d\u540d',
|
||||
n_messages: (n) => `${n} \u689d\u8a0a\u606f`,
|
||||
load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d',
|
||||
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`,
|
||||
@@ -6136,6 +6242,12 @@ const LOCALES = {
|
||||
settings_update_check_failed: '更新檢查失敗',
|
||||
settings_label_workspace_panel_open: '預設保持工作區面板開啓',
|
||||
settings_desc_workspace_panel_open: '啟用後,工作區/檔案瀏覽器面板會在每次新會話時自動開啓。您仍可隨時手動關閉。',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
|
||||
settings_label_session_endless_scroll: '向上捲動時載入較早訊息',
|
||||
|
||||
settings_desc_session_endless_scroll: '啟用後,向上捲動時會自動載入較早訊息。停用時請使用載入較早訊息按鈕。',
|
||||
open_in_browser: '在瀏覽器中開啓',
|
||||
settings_dropdown_conversation: '對話',
|
||||
settings_dropdown_appearance: '外觀',
|
||||
@@ -6288,6 +6400,10 @@ const LOCALES = {
|
||||
downloading: (filename) => `正在下載 ${filename}…`,
|
||||
n_messages: (n) => `${n} 則訊息`,
|
||||
load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
onboarding_api_key_help_prefix: '\u900f\u904e\u4ee5\u4e0b\u65b9\u5f0f\u5132\u5b58\u70ba Hermes .env \u6a94\u6848\u4e2d\u7684\u6a5f\u5bc6',
|
||||
onboarding_api_key_label: 'API \u91d1\u9470',
|
||||
onboarding_api_key_placeholder: '\u7559\u7a7a\u4ee5\u4fdd\u7559\u5df2\u5132\u5b58\u7684\u91d1\u9470',
|
||||
@@ -6857,6 +6973,13 @@ const LOCALES = {
|
||||
},
|
||||
|
||||
pt: {
|
||||
offline_title: 'Conexão perdida',
|
||||
offline_browser_detail: 'O navegador informa que este dispositivo está offline.',
|
||||
offline_network_detail: 'O Hermes está inacessível neste navegador agora.',
|
||||
offline_autorefresh: 'Vou atualizar esta página automaticamente quando o Hermes voltar a responder.',
|
||||
offline_check_now: 'Verificar agora',
|
||||
offline_checking: 'Verificando…',
|
||||
offline_stream_waiting: 'Conexão perdida. Aguardando para atualizar…',
|
||||
_lang: 'pt',
|
||||
_label: 'Português',
|
||||
_speech: 'pt-BR',
|
||||
@@ -6906,6 +7029,10 @@ const LOCALES = {
|
||||
untitled: 'Sem título',
|
||||
n_messages: (n) => `${n} mensagens`,
|
||||
load_older_messages: '↑ Role para cima ou clique para carregar mensagens mais antigas',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
queued_label: 'Envia após a resposta',
|
||||
queued_count: (n) => n === 1 ? '1 na fila' : `${n} na fila`,
|
||||
queued_cancel: 'Cancelar mensagem na fila',
|
||||
@@ -7170,6 +7297,12 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Falha ao verificar updates',
|
||||
settings_label_workspace_panel_open: 'Manter painel workspace aberto por padrão',
|
||||
settings_desc_workspace_panel_open: 'Quando ativo, o painel workspace abre automaticamente com cada nova sessão.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
|
||||
settings_label_session_endless_scroll: 'Carregar mensagens antigas ao rolar para cima',
|
||||
|
||||
settings_desc_session_endless_scroll: 'Quando ativado, mensagens antigas carregam automaticamente ao rolar para cima. Quando desativado, use o botão de mensagens antigas.',
|
||||
open_in_browser: 'Abrir no navegador',
|
||||
settings_dropdown_conversation: 'Conversa',
|
||||
settings_dropdown_appearance: 'Aparência',
|
||||
@@ -7706,6 +7839,13 @@ const LOCALES = {
|
||||
disable_auth_confirm_title: 'Desativar proteção por senha',
|
||||
},
|
||||
ko: {
|
||||
offline_title: '연결이 끊겼습니다',
|
||||
offline_browser_detail: '브라우저가 이 장치가 오프라인이라고 보고합니다.',
|
||||
offline_network_detail: '현재 이 브라우저에서 Hermes에 연결할 수 없습니다.',
|
||||
offline_autorefresh: 'Hermes에 다시 연결되면 이 페이지를 자동으로 새로고침합니다.',
|
||||
offline_check_now: '지금 확인',
|
||||
offline_checking: '확인 중…',
|
||||
offline_stream_waiting: '연결이 끊겼습니다. 새로고침을 기다리는 중…',
|
||||
_lang: 'ko',
|
||||
_label: '한국어',
|
||||
_speech: 'ko-KR',
|
||||
@@ -7802,6 +7942,10 @@ const LOCALES = {
|
||||
untitled: '제목 없음',
|
||||
n_messages: (n) => `${n}개 메시지`,
|
||||
load_older_messages: '↑ 위로 스크롤하거나 클릭하여 이전 메시지 불러오기',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
queued_label: 'Sends after response',
|
||||
queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`,
|
||||
queued_cancel: 'Cancel queued message',
|
||||
@@ -8089,6 +8233,12 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Update check failed',
|
||||
settings_label_workspace_panel_open: '기본으로 워크스페이스 패널 열기',
|
||||
settings_desc_workspace_panel_open: '활성화하면 새 세션마다 워크스페이스/파일 브라우저 패널이 자동으로 열립니다. 언제든지 수동으로 닫을 수 있습니다.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
|
||||
settings_label_session_endless_scroll: '위로 스크롤할 때 이전 메시지 불러오기',
|
||||
|
||||
settings_desc_session_endless_scroll: '활성화하면 위로 스크롤할 때 이전 메시지를 자동으로 불러옵니다. 비활성화하면 이전 메시지 버튼을 사용합니다.',
|
||||
open_in_browser: '브라우저에서 열기',
|
||||
settings_dropdown_conversation: '대화',
|
||||
settings_dropdown_appearance: '외형',
|
||||
@@ -8832,6 +8982,11 @@ function applyLocaleToDOM() {
|
||||
const val = t(key);
|
||||
if (val && val !== key) el.placeholder = val;
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-aria-label');
|
||||
const val = t(key);
|
||||
if (val && val !== key) el.setAttribute('aria-label', val);
|
||||
});
|
||||
if (typeof syncAppTitlebar === 'function') syncAppTitlebar();
|
||||
}
|
||||
|
||||
|
||||
+22
-2
@@ -21,7 +21,7 @@
|
||||
<meta name="theme-color" content="#FEFCF7" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0D0D1A" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" id="hermes-theme-color" content="#0D0D1A">
|
||||
<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'?'#0D0D1A':'#FEFCF7';var m=document.getElementById('hermes-theme-color');if(m)m.setAttribute('content',c);}catch(e){}})()</script>
|
||||
<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'?'#0D0D1A':'#FEFCF7';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>
|
||||
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
|
||||
@@ -297,7 +297,8 @@
|
||||
<main class="main">
|
||||
<div id="mainChat" class="main-view">
|
||||
<div class="messages" id="messages">
|
||||
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" aria-label="Scroll to bottom" onclick="scrollToBottom()" style="display:none">↓</button>
|
||||
<button id="jumpToSessionStartBtn" class="session-jump-btn session-jump-btn--start" aria-label="Jump to beginning of session" data-i18n-aria-label="session_jump_start_label" data-i18n-title="session_jump_start_label" onclick="jumpToSessionStart()" style="display:none"><span aria-hidden="true">↑</span><span data-i18n="session_jump_start">Start</span></button>
|
||||
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" style="display:none" onclick="scrollToBottom()" aria-label="Scroll to bottom" data-i18n-aria-label="session_jump_end_label" data-i18n-title="session_jump_end_label"><span aria-hidden="true">↓</span><span class="session-jump-btn__text" data-i18n="session_jump_end">End</span></button>
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
|
||||
<defs>
|
||||
@@ -347,6 +348,14 @@
|
||||
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="offline-banner" id="offlineBanner" role="status" aria-live="assertive" hidden>
|
||||
<div class="offline-copy">
|
||||
<strong id="offlineTitle" data-i18n="offline_title">Connection lost</strong>
|
||||
<span id="offlineDetails" data-i18n="offline_browser_detail">Your browser reports that this device is offline.</span>
|
||||
<span id="offlineAutorefresh" data-i18n="offline_autorefresh">I will refresh this page automatically when Hermes is reachable again.</span>
|
||||
</div>
|
||||
<button class="offline-action" id="offlineCheckNow" type="button" onclick="checkOfflineRecoveryNow()" data-i18n="offline_check_now">Check now</button>
|
||||
</div>
|
||||
<div class="agent-health-banner" id="agentHealthBanner" role="alert" aria-live="assertive" hidden>
|
||||
<div class="agent-health-copy">
|
||||
<strong id="agentHealthTitle">Hermes agent is not responding</strong>
|
||||
@@ -863,6 +872,17 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_workspace_panel_open">When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSessionJumpButtons" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_session_jump_buttons">Show session jump buttons</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_session_jump_buttons">Show floating Start and End buttons while reading long session histories.</div>
|
||||
<input type="checkbox" id="settingsSessionEndlessScroll" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_session_endless_scroll">Load older messages while scrolling up</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_session_endless_scroll">When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.</div>
|
||||
</div>
|
||||
<div id="settingsAppearanceAutosaveStatus" class="settings-autosave-status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPanePreferences">
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"description": "Hermes AI Agent Web UI",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#1a1a1a",
|
||||
"background_color": "#0D0D1A",
|
||||
"theme_color": "#0D0D1A",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
|
||||
+19
-1
@@ -32,6 +32,19 @@ function _markActiveSessionViewedOnReturn() {
|
||||
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
|
||||
}
|
||||
|
||||
function _deferStreamErrorIfOffline(){
|
||||
if(typeof isOfflineBannerVisible==='function' && isOfflineBannerVisible()){
|
||||
setComposerStatus(t('offline_stream_waiting'));
|
||||
return true;
|
||||
}
|
||||
if(typeof showOfflineBanner==='function' && navigator.onLine===false){
|
||||
showOfflineBanner('browser');
|
||||
setComposerStatus(t('offline_stream_waiting'));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', _markActiveSessionViewedOnReturn);
|
||||
window.addEventListener('focus', _markActiveSessionViewedOnReturn);
|
||||
// TTS: pause speech synthesis when user focuses the composer (#499)
|
||||
@@ -1207,6 +1220,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('error',async e=>{
|
||||
source.close();
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
if(_terminalStateReached || _streamFinalized){
|
||||
_closeSource();
|
||||
return;
|
||||
@@ -1223,13 +1237,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true}));
|
||||
return;
|
||||
}
|
||||
}catch(_){}
|
||||
}catch(_){
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
}
|
||||
if(await _restoreSettledSession()) return;
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
_handleStreamError();
|
||||
},1500);
|
||||
return;
|
||||
}
|
||||
if(await _restoreSettledSession()) return;
|
||||
if(_deferStreamErrorIfOffline()) return;
|
||||
_handleStreamError();
|
||||
});
|
||||
|
||||
|
||||
@@ -4251,6 +4251,8 @@ function _appearancePayloadFromUi(){
|
||||
theme: ($('settingsTheme')||{}).value || localStorage.getItem('hermes-theme') || 'dark',
|
||||
skin: ($('settingsSkin')||{}).value || localStorage.getItem('hermes-skin') || 'default',
|
||||
font_size: ($('settingsFontSize')||{}).value || localStorage.getItem('hermes-font-size') || 'default',
|
||||
session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked,
|
||||
session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4298,6 +4300,11 @@ async function _autosaveAppearanceSettings(payload){
|
||||
if(saved&&saved.font_size){
|
||||
localStorage.setItem('hermes-font-size',saved.font_size);
|
||||
}
|
||||
if(saved){
|
||||
window._sessionJumpButtonsEnabled=!!saved.session_jump_buttons;
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
}
|
||||
window._sessionEndlessScrollEnabled=!!(saved&&saved.session_endless_scroll);
|
||||
_setAppearanceAutosaveStatus('saved');
|
||||
}catch(e){
|
||||
console.warn('[settings] appearance autosave failed', e);
|
||||
@@ -4454,6 +4461,17 @@ async function loadSettingsPanel(){
|
||||
const fontSizeSel=$('settingsFontSize');
|
||||
if(fontSizeSel) fontSizeSel.value=fontSizeVal;
|
||||
if(typeof _syncFontSizePicker==='function') _syncFontSizePicker(fontSizeVal);
|
||||
const jumpButtonsCb=$('settingsSessionJumpButtons');
|
||||
if(jumpButtonsCb){
|
||||
jumpButtonsCb.checked=!!settings.session_jump_buttons;
|
||||
window._sessionJumpButtonsEnabled=jumpButtonsCb.checked;
|
||||
jumpButtonsCb.onchange=function(){
|
||||
window._sessionJumpButtonsEnabled=this.checked;
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
_scheduleAppearanceAutosave();
|
||||
};
|
||||
}
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
// Workspace panel default-open toggle (localStorage-backed)
|
||||
// Uses a separate key (hermes-webui-workspace-panel-pref) so that
|
||||
// closing the panel via toolbar X does not clear the user's preference.
|
||||
@@ -4470,6 +4488,15 @@ async function loadSettingsPanel(){
|
||||
else if(!open&&_workspacePanelMode!=='closed') toggleWorkspacePanel(false);
|
||||
};
|
||||
}
|
||||
const endlessScrollCb=$('settingsSessionEndlessScroll');
|
||||
if(endlessScrollCb){
|
||||
endlessScrollCb.checked=!!settings.session_endless_scroll;
|
||||
window._sessionEndlessScrollEnabled=endlessScrollCb.checked;
|
||||
endlessScrollCb.onchange=function(){
|
||||
window._sessionEndlessScrollEnabled=this.checked;
|
||||
_scheduleAppearanceAutosave();
|
||||
};
|
||||
}
|
||||
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
|
||||
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
|
||||
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
|
||||
@@ -5124,8 +5151,11 @@ function _applySavedSettingsUi(saved, body, opts){
|
||||
window._notificationsEnabled=body.notifications_enabled;
|
||||
window._showThinking=body.show_thinking!==false;
|
||||
window._simplifiedToolCalling=body.simplified_tool_calling!==false;
|
||||
window._sessionJumpButtonsEnabled=!!body.session_jump_buttons;
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact';
|
||||
window._busyInputMode=body.busy_input_mode||'queue';
|
||||
window._sessionEndlessScrollEnabled=!!body.session_endless_scroll;
|
||||
window._botName=body.bot_name||'Hermes';
|
||||
if(typeof applyBotName==='function') applyBotName();
|
||||
if(typeof setLocale==='function') setLocale(language);
|
||||
@@ -5221,6 +5251,8 @@ async function saveSettings(andClose){
|
||||
body.theme=theme;
|
||||
body.skin=skin;
|
||||
body.font_size=fontSize;
|
||||
body.session_jump_buttons=!!($('settingsSessionJumpButtons')||{}).checked;
|
||||
body.session_endless_scroll=!!($('settingsSessionEndlessScroll')||{}).checked;
|
||||
body.language=language;
|
||||
body.show_token_usage=showTokenUsage;
|
||||
body.show_tps=showTps;
|
||||
|
||||
+17
-2
@@ -536,6 +536,14 @@
|
||||
.reconnect-banner.visible{display:flex;}
|
||||
.reconnect-btn{padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:var(--accent-bg-strong);border:1px solid var(--accent-bg-strong);color:var(--accent-text);cursor:pointer;}
|
||||
.reconnect-btn:hover{background:var(--accent-bg-strong);}
|
||||
.offline-banner{position:fixed;left:0;right:0;top:0;z-index:1200;display:none;align-items:center;justify-content:space-between;gap:14px;padding:12px 18px;border-bottom:1px solid color-mix(in srgb,var(--warning,#f6c343) 55%,var(--surface));background:color-mix(in srgb,var(--bg-1,#0d0d1a) 88%,var(--warning,#f6c343));color:var(--text);box-shadow:0 12px 40px rgba(0,0,0,.22);backdrop-filter:blur(10px);}
|
||||
.offline-banner.visible{display:flex;}
|
||||
.offline-copy{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:13px;line-height:1.35;}
|
||||
.offline-copy strong{color:var(--warning,#f6c343);font-size:13px;letter-spacing:.08em;text-transform:uppercase;}
|
||||
.offline-copy span{color:var(--muted);}
|
||||
.offline-action{flex-shrink:0;padding:7px 13px;border-radius:9px;border:1px solid color-mix(in srgb,var(--warning,#f6c343) 48%,var(--surface));background:color-mix(in srgb,var(--warning,#f6c343) 12%,var(--surface));color:var(--warning,#f6c343);font-size:12px;font-weight:700;cursor:pointer;}
|
||||
.offline-action:hover{background:color-mix(in srgb,var(--warning,#f6c343) 20%,var(--surface));}
|
||||
.offline-action[disabled]{cursor:wait;opacity:.65;}
|
||||
.agent-health-banner{position:sticky;bottom:0;z-index:4;display:none;align-items:center;justify-content:space-between;gap:12px;margin:10px auto 0;max-width:var(--msg-max);width:calc(100% - 40px);padding:12px 16px;border:1px solid color-mix(in srgb,var(--error) 55%,var(--surface));border-radius:12px;background:color-mix(in srgb,var(--error) 14%,var(--surface));color:var(--text);box-shadow:0 10px 32px rgba(0,0,0,.16);}
|
||||
.agent-health-banner.visible{display:flex;}
|
||||
.agent-health-copy{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:13px;line-height:1.35;}
|
||||
@@ -772,9 +780,16 @@
|
||||
.workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;}
|
||||
.chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);}
|
||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;}
|
||||
/* sticky-first-child: button is first child of .messages so its natural position is above viewport; sticky+bottom:16px pins it there when visible */
|
||||
.scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s;}
|
||||
/* sticky-first-child: button is early in .messages so its natural position is above viewport; sticky+bottom pins it there when visible */
|
||||
.scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;}
|
||||
.scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);}
|
||||
.session-jump-btn__text{display:none;}
|
||||
.session-jump-btn{position:sticky;align-self:flex-end;flex:0 0 32px;min-height:32px;margin-right:20px;height:32px;border-radius:999px;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 11px;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:11;transition:color .12s,border-color .12s,background .12s,opacity .12s,transform .12s;}
|
||||
.session-jump-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);transform:translateY(-1px);}
|
||||
.session-jump-btn--start{top:16px;margin-bottom:-36px;}
|
||||
.messages.session-nav-enabled .scroll-to-bottom-btn{width:auto;min-width:32px;border-radius:999px;font-size:12px;font-weight:600;gap:5px;padding:0 11px;}
|
||||
.messages.session-nav-enabled .scroll-to-bottom-btn:hover{transform:translateY(-1px);}
|
||||
.messages.session-nav-enabled .session-jump-btn__text{display:inline;}
|
||||
.messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
|
||||
@media(min-width:1400px){.messages-inner{max-width:1100px;}}
|
||||
@media(min-width:1800px){.messages-inner{max-width:1200px;}}
|
||||
|
||||
+18
-17
@@ -68,7 +68,7 @@ self.addEventListener('activate', (event) => {
|
||||
// - API calls (/api/*, /stream) → always network (never cache)
|
||||
// - Login assets → always network (never cache stale auth code)
|
||||
// - Page navigations → network-first so auth redirects/cookies are honored
|
||||
// - Shell assets → cache-first with network fallback
|
||||
// - Shell assets → network-first with cache fallback
|
||||
// - Everything else → network-only
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
@@ -131,7 +131,7 @@ self.addEventListener('fetch', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only explicit shell assets use cache-first. Everything else should hit the
|
||||
// Only explicit shell assets are cached. Everything else should hit the
|
||||
// network so stale one-off files (especially auth/login scripts) do not get
|
||||
// trapped in CacheStorage until a manual cache clear.
|
||||
const scopePath = new URL(self.registration.scope).pathname;
|
||||
@@ -141,21 +141,22 @@ self.addEventListener('fetch', (event) => {
|
||||
const shellPath = './' + relPath.replace(/^\/+/, '') + url.search;
|
||||
if (!SHELL_ASSETS.includes(shellPath)) return;
|
||||
|
||||
// Shell assets: cache-first
|
||||
// Shell assets: network-first with cache fallback. This keeps offline support
|
||||
// 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(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then((response) => {
|
||||
// Cache successful GET responses for shell assets
|
||||
if (
|
||||
event.request.method === 'GET' &&
|
||||
response.status === 200
|
||||
) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
fetch(event.request).then((response) => {
|
||||
if (
|
||||
event.request.method === 'GET' &&
|
||||
response.status === 200
|
||||
) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => caches.match(event.request).then((cached) => cached || new Response('Offline', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
})))
|
||||
);
|
||||
});
|
||||
|
||||
+153
-2
@@ -11,6 +11,105 @@ const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024);
|
||||
// single-threaded so only one done event fires at a time in practice.
|
||||
let _queueDrainSid=null;
|
||||
const $=id=>document.getElementById(id);
|
||||
const OFFLINE_RECHECK_MS=2500;
|
||||
let _offlineVisible=false;
|
||||
let _offlineReason='browser';
|
||||
let _offlineProbeTimer=null;
|
||||
let _offlineChecking=false;
|
||||
let _offlineProbePromise=null;
|
||||
let _offlineHealthProbePromise=null;
|
||||
let _offlineRawFetch=null;
|
||||
let _offlineFetchPatched=false;
|
||||
function _browserReportsOnline(){return !('onLine' in navigator)||navigator.onLine!==false;}
|
||||
function _offlineHealthUrl(){const url=new URL('health',document.baseURI||location.href);url.searchParams.set('offline_probe',String(Date.now()));return url.href;}
|
||||
function _setOfflineChecking(checking){
|
||||
_offlineChecking=!!checking;
|
||||
const btn=$('offlineCheckNow');
|
||||
if(btn){btn.disabled=_offlineChecking;btn.textContent=_offlineChecking?t('offline_checking'):t('offline_check_now');}
|
||||
}
|
||||
function _renderOfflineBanner(){
|
||||
const banner=$('offlineBanner');
|
||||
if(!banner)return;
|
||||
const detail=$('offlineDetails');
|
||||
if(detail)detail.textContent=t(_offlineReason==='browser'?'offline_browser_detail':'offline_network_detail');
|
||||
const title=$('offlineTitle');
|
||||
if(title)title.textContent=t('offline_title');
|
||||
const auto=$('offlineAutorefresh');
|
||||
if(auto)auto.textContent=t('offline_autorefresh');
|
||||
_setOfflineChecking(_offlineChecking);
|
||||
banner.hidden=false;
|
||||
banner.classList.add('visible');
|
||||
}
|
||||
function _startOfflineProbeTimer(){
|
||||
if(_offlineProbeTimer)return;
|
||||
_offlineProbeTimer=setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS);
|
||||
}
|
||||
function _stopOfflineProbeTimer(){
|
||||
if(_offlineProbeTimer){clearInterval(_offlineProbeTimer);_offlineProbeTimer=null;}
|
||||
}
|
||||
function showOfflineBanner(reason){
|
||||
_offlineVisible=true;
|
||||
_offlineReason=reason||(_browserReportsOnline()?'network':'browser');
|
||||
_renderOfflineBanner();
|
||||
_startOfflineProbeTimer();
|
||||
}
|
||||
function isOfflineBannerVisible(){return _offlineVisible;}
|
||||
function _hideOfflineBanner(){
|
||||
_offlineVisible=false;
|
||||
_stopOfflineProbeTimer();
|
||||
_setOfflineChecking(false);
|
||||
const banner=$('offlineBanner');
|
||||
if(banner){banner.classList.remove('visible');banner.hidden=true;}
|
||||
}
|
||||
async function _probeOfflineRecovery(){
|
||||
if(_offlineHealthProbePromise)return _offlineHealthProbePromise;
|
||||
_offlineHealthProbePromise=(async()=>{
|
||||
const fetcher=_offlineRawFetch||window.fetch.bind(window);
|
||||
try{
|
||||
const res=await fetcher(_offlineHealthUrl(),{cache:'no-store',credentials:'include'});
|
||||
return !!(res&&res.ok);
|
||||
}catch(_){return false;}
|
||||
})();
|
||||
try{return await _offlineHealthProbePromise;}
|
||||
finally{_offlineHealthProbePromise=null;}
|
||||
}
|
||||
async function checkOfflineRecoveryNow(){
|
||||
if(_offlineProbePromise)return _offlineProbePromise;
|
||||
_offlineProbePromise=(async()=>{
|
||||
if(!_offlineVisible)return false;
|
||||
if(!_browserReportsOnline()){showOfflineBanner('browser');return false;}
|
||||
_setOfflineChecking(true);
|
||||
const ok=await _probeOfflineRecovery();
|
||||
_setOfflineChecking(false);
|
||||
if(ok){_stopOfflineProbeTimer();window.location.reload();return true;}
|
||||
showOfflineBanner('network');
|
||||
return false;
|
||||
})();
|
||||
try{return await _offlineProbePromise;}
|
||||
finally{_offlineProbePromise=null;}
|
||||
}
|
||||
function _isAbortError(e){return !!(e&&(e.name==='AbortError'||e.code===20));}
|
||||
function _patchOfflineFetch(){
|
||||
if(_offlineFetchPatched||typeof window.fetch!=='function')return;
|
||||
_offlineFetchPatched=true;
|
||||
_offlineRawFetch=window.fetch.bind(window);
|
||||
window.fetch=async function(...args){
|
||||
try{return await _offlineRawFetch(...args);}
|
||||
catch(e){
|
||||
if(!_browserReportsOnline())showOfflineBanner('browser');
|
||||
else if(e instanceof TypeError&&!_isAbortError(e))void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');});
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
function initOfflineMonitor(){
|
||||
_patchOfflineFetch();
|
||||
window.addEventListener('offline',()=>showOfflineBanner('browser'));
|
||||
window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();});
|
||||
if(!_browserReportsOnline())showOfflineBanner('browser');
|
||||
}
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true});
|
||||
else initOfflineMonitor();
|
||||
// Redirect to login when the server responds with 401 (auth session expired).
|
||||
// Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from
|
||||
// escaping to the personal site root /login.
|
||||
@@ -185,6 +284,9 @@ function _messageRenderableMessageCount(){
|
||||
function _messageHiddenBeforeCount(){
|
||||
return Math.max(0,_messageRenderableMessageCount()-_currentMessageRenderWindowSize());
|
||||
}
|
||||
function _isSessionEndlessScrollEnabled(){
|
||||
return window._sessionEndlessScrollEnabled===true;
|
||||
}
|
||||
function _wireMessageWindowLoadEarlierButton(){
|
||||
const indicator=$('loadOlderIndicator');
|
||||
if(!indicator) return;
|
||||
@@ -205,6 +307,48 @@ function _showEarlierRenderedMessages(){
|
||||
}
|
||||
_scrollPinned=false;
|
||||
}
|
||||
function _isSessionJumpButtonsEnabled(){
|
||||
return window._sessionJumpButtonsEnabled===true;
|
||||
}
|
||||
function _applySessionNavigationPrefs(){
|
||||
const container=$('messages');
|
||||
if(container) container.classList.toggle('session-nav-enabled',_isSessionJumpButtonsEnabled());
|
||||
_updateSessionStartJumpButton();
|
||||
}
|
||||
function _updateSessionStartJumpButton(){
|
||||
const btn=$('jumpToSessionStartBtn');
|
||||
const container=$('messages');
|
||||
if(!btn||!container) return;
|
||||
if(!_isSessionJumpButtonsEnabled()){
|
||||
btn.style.display='none';
|
||||
return;
|
||||
}
|
||||
const hasSession=!!(S&&S.session&&S.messages&&S.messages.length);
|
||||
const awayFromStart=container.scrollTop>Math.max(240,container.clientHeight*0.35);
|
||||
const hasScrollableHistory=container.scrollHeight>container.clientHeight+Math.max(240,container.clientHeight*0.35);
|
||||
const canRevealStart=hasScrollableHistory||_messageHiddenBeforeCount()>0||!!(typeof _messagesTruncated!=='undefined'&&_messagesTruncated);
|
||||
btn.style.display=(hasSession&&canRevealStart&&awayFromStart)?'flex':'none';
|
||||
}
|
||||
async function jumpToSessionStart(){
|
||||
const container=$('messages');
|
||||
if(!container||!S.session) return;
|
||||
_scrollPinned=false;
|
||||
_messageUserUnpinned=true;
|
||||
_programmaticScroll=true;
|
||||
try{
|
||||
if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded();
|
||||
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount());
|
||||
renderMessages({ preserveScroll:true });
|
||||
requestAnimationFrame(()=>{
|
||||
container.scrollTop=0;
|
||||
_updateSessionStartJumpButton();
|
||||
requestAnimationFrame(()=>{ _programmaticScroll=false; });
|
||||
});
|
||||
}catch(e){
|
||||
console.warn('jumpToSessionStart failed:',e);
|
||||
_programmaticScroll=false;
|
||||
}
|
||||
}
|
||||
|
||||
const DASHBOARD_STATUS_TTL_MS=60000;
|
||||
let _dashboardStatusCache=null;
|
||||
@@ -1574,8 +1718,12 @@ if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetS
|
||||
} // #1360
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(btn) btn.style.display=_scrollPinned?'none':'flex';
|
||||
// Load older messages when scrolled near the top
|
||||
if(el.scrollTop<80 && typeof _messagesTruncated!=='undefined' && _messagesTruncated && typeof _loadOlderMessages==='function'){
|
||||
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
|
||||
// Prefetch older messages before the reader hits the hard top. Prepending
|
||||
// then preserving scrollTop is seamless only if there is runway left for
|
||||
// the user's continued upward wheel/touch movement.
|
||||
const olderPrefetchPx=Math.max(600,el.clientHeight*1.5);
|
||||
if(_isSessionEndlessScrollEnabled()&&el.scrollTop<olderPrefetchPx && typeof _messagesTruncated!=='undefined' && _messagesTruncated && typeof _loadOlderMessages==='function'){
|
||||
_loadOlderMessages();
|
||||
}
|
||||
});
|
||||
@@ -1913,6 +2061,7 @@ function scrollToBottom(){
|
||||
_settleMessageScrollToBottom(true);
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(btn) btn.style.display='none';
|
||||
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
|
||||
}
|
||||
|
||||
function _fmtOllamaLabel(mid){
|
||||
@@ -4566,6 +4715,7 @@ function renderMessages(options){
|
||||
inner.innerHTML=cached.html;
|
||||
_sessionHtmlCacheSid=sid;
|
||||
_wireMessageWindowLoadEarlierButton();
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
_scrollAfterMessageRender(preserveScroll);
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
@@ -4626,6 +4776,7 @@ function renderMessages(options){
|
||||
const renderVisWithIdx=visWithIdx.slice(windowStart);
|
||||
const firstRenderedRawIdx=renderVisWithIdx.length?renderVisWithIdx[0].rawIdx:Infinity;
|
||||
const hasServerOlder=!!(typeof _messagesTruncated!=='undefined' && _messagesTruncated && S.messages.length>0);
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
if(hiddenBeforeCount>0 || hasServerOlder){
|
||||
const indicator=document.createElement('button');
|
||||
indicator.type='button';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Regression coverage for the browser-offline banner and auto-refresh loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_offline_banner_markup_styles_and_copy_exist():
|
||||
assert 'id="offlineBanner"' in INDEX_HTML
|
||||
assert 'role="status"' in INDEX_HTML
|
||||
assert 'aria-live="assertive"' in INDEX_HTML
|
||||
assert 'onclick="checkOfflineRecoveryNow()"' in INDEX_HTML
|
||||
assert ".offline-banner" in STYLE_CSS
|
||||
assert ".offline-banner.visible" in STYLE_CSS
|
||||
assert ".offline-action[disabled]" in STYLE_CSS
|
||||
for key in (
|
||||
"offline_title",
|
||||
"offline_browser_detail",
|
||||
"offline_network_detail",
|
||||
"offline_autorefresh",
|
||||
"offline_check_now",
|
||||
"offline_checking",
|
||||
"offline_stream_waiting",
|
||||
):
|
||||
assert key in I18N_JS
|
||||
|
||||
|
||||
def test_offline_monitor_patches_fetch_and_auto_reloads_after_health_probe():
|
||||
assert "const OFFLINE_RECHECK_MS=2500" in UI_JS
|
||||
assert "window.fetch=async function(...args)" in UI_JS
|
||||
assert "window.addEventListener('offline',()=>showOfflineBanner('browser'))" in UI_JS
|
||||
assert "window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();})" in UI_JS
|
||||
assert "setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS)" in UI_JS
|
||||
assert "new URL('health',document.baseURI||location.href)" in UI_JS
|
||||
assert "window.location.reload()" in UI_JS
|
||||
|
||||
|
||||
def test_offline_recovery_probe_is_serialized_and_stops_timer_before_reload():
|
||||
assert "let _offlineProbePromise=null" in UI_JS
|
||||
assert "let _offlineHealthProbePromise=null" in UI_JS
|
||||
assert "if(!_offlineVisible)return false;" in UI_JS
|
||||
assert "if(!_offlineVisible&&!_offlineFetchPatched)return false;" not in UI_JS
|
||||
assert "finally{_offlineProbePromise=null;}" in UI_JS
|
||||
assert "finally{_offlineHealthProbePromise=null;}" in UI_JS
|
||||
reload_idx = UI_JS.find("window.location.reload()")
|
||||
assert reload_idx != -1
|
||||
assert UI_JS.rfind("_stopOfflineProbeTimer();", 0, reload_idx) != -1
|
||||
|
||||
|
||||
def test_fetch_typeerror_is_gated_by_health_probe_not_blind_banner():
|
||||
fetch_patch = UI_JS.split("window.fetch=async function(...args){", 1)[1].split("function initOfflineMonitor", 1)[0]
|
||||
assert "function _isAbortError(e)" in UI_JS
|
||||
assert "e instanceof TypeError&&!_isAbortError(e)" in fetch_patch
|
||||
assert "void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');})" in fetch_patch
|
||||
assert "if(!_browserReportsOnline())showOfflineBanner('browser');" in fetch_patch
|
||||
assert "e instanceof TypeError||!_browserReportsOnline()" not in fetch_patch
|
||||
|
||||
|
||||
def test_sse_network_error_defers_to_offline_banner_instead_of_inline_error():
|
||||
assert "function _deferStreamErrorIfOffline()" in MESSAGES_JS
|
||||
assert "t('offline_stream_waiting')" in MESSAGES_JS
|
||||
assert "if(_deferStreamErrorIfOffline()) return;" in MESSAGES_JS
|
||||
error_handler = MESSAGES_JS.split("source.addEventListener('error',async e=>{", 1)[1].split("source.addEventListener('cancel'", 1)[0]
|
||||
assert error_handler.find("_deferStreamErrorIfOffline()") < error_handler.rfind("_handleStreamError()")
|
||||
@@ -427,10 +427,11 @@ class TestMessagePaginationFrontend:
|
||||
assert "async function _ensureAllMessagesLoaded" in SESSIONS_JS
|
||||
|
||||
def test_scroll_to_top_triggers_loading(self):
|
||||
"""Scroll event handler must trigger _loadOlderMessages near top."""
|
||||
"""Scroll event handler must trigger _loadOlderMessages near top when opt-in is enabled."""
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "el.scrollTop<80" in UI_JS
|
||||
assert "const olderPrefetchPx=Math.max(600,el.clientHeight*1.5)" in UI_JS
|
||||
assert "_isSessionEndlessScrollEnabled()&&el.scrollTop<olderPrefetchPx" in UI_JS
|
||||
assert "_loadOlderMessages" in UI_JS
|
||||
|
||||
def test_load_older_indicator_in_render(self):
|
||||
|
||||
@@ -18,6 +18,7 @@ MANIFEST = ROOT / "static" / "manifest.json"
|
||||
SW = ROOT / "static" / "sw.js"
|
||||
INDEX = ROOT / "static" / "index.html"
|
||||
ROUTES = ROOT / "api" / "routes.py"
|
||||
AUTH = ROOT / "api" / "auth.py"
|
||||
|
||||
|
||||
class TestManifest:
|
||||
@@ -107,6 +108,22 @@ class TestServiceWorker:
|
||||
"sw.js must await/then the caches.match() result before applying the fallback"
|
||||
)
|
||||
|
||||
def test_sw_shell_assets_are_network_first_with_cache_fallback(self):
|
||||
"""Local hotfixes can change JS/CSS while WEBUI_VERSION stays unchanged.
|
||||
|
||||
If shell assets are cache-first, the browser can keep executing stale
|
||||
sessions.js even though the server/curl already returns patched source.
|
||||
Network-first preserves offline fallback without hiding local fixes.
|
||||
"""
|
||||
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 "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, (
|
||||
"shell assets must not be cache-first; stale JS can survive hard refresh"
|
||||
)
|
||||
|
||||
def test_sw_never_caches_api_responses(self):
|
||||
"""Defensive: the SW must not cache responses from /api/* paths.
|
||||
Currently enforced by early-return before the shell-asset cache block."""
|
||||
@@ -162,6 +179,15 @@ class TestPWARoutes:
|
||||
"the expected scope"
|
||||
)
|
||||
|
||||
def test_sw_is_public_auth_path(self):
|
||||
src = AUTH.read_text(encoding="utf-8")
|
||||
public_idx = src.find("PUBLIC_PATHS")
|
||||
assert public_idx != -1, "auth.py must define PUBLIC_PATHS"
|
||||
block = src[public_idx:public_idx + 400]
|
||||
assert "'/sw.js'" in block, (
|
||||
"/sw.js must be public so service-worker updates never return login HTML"
|
||||
)
|
||||
|
||||
|
||||
class TestIndexHtmlIntegration:
|
||||
def test_index_links_manifest(self):
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_endless_scroll_is_opt_in_setting():
|
||||
assert '"session_endless_scroll": False' in CONFIG_PY
|
||||
assert '"session_endless_scroll"' in CONFIG_PY
|
||||
assert 'id="settingsSessionEndlessScroll"' in INDEX_HTML
|
||||
assert 'data-i18n="settings_label_session_endless_scroll"' in INDEX_HTML
|
||||
assert 'data-i18n="settings_desc_session_endless_scroll"' in INDEX_HTML
|
||||
assert "session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked" in PANELS_JS
|
||||
assert "window._sessionEndlessScrollEnabled=!!s.session_endless_scroll" in BOOT_JS
|
||||
assert "window._sessionEndlessScrollEnabled=false" in BOOT_JS
|
||||
|
||||
|
||||
def test_scroll_listener_prefetches_older_messages_only_when_enabled():
|
||||
assert "function _isSessionEndlessScrollEnabled" in UI_JS
|
||||
assert "const olderPrefetchPx=Math.max(600,el.clientHeight*1.5)" in UI_JS
|
||||
assert "_isSessionEndlessScrollEnabled()&&el.scrollTop<olderPrefetchPx" in UI_JS
|
||||
assert "el.scrollTop<80 && typeof _messagesTruncated" not in UI_JS
|
||||
|
||||
|
||||
def test_endless_scroll_i18n_keys_exist_for_each_locale():
|
||||
assert I18N_JS.count("settings_label_session_endless_scroll") == I18N_JS.count("settings_label_workspace_panel_open")
|
||||
assert I18N_JS.count("settings_desc_session_endless_scroll") == I18N_JS.count("settings_desc_workspace_panel_open")
|
||||
@@ -0,0 +1,77 @@
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, signature: str) -> str:
|
||||
start = src.index(signature)
|
||||
brace = src.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(src)):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start : i + 1]
|
||||
raise AssertionError(f"function body not found: {signature}")
|
||||
|
||||
|
||||
def test_session_jump_buttons_are_opt_in_and_keep_existing_bottom_button():
|
||||
assert '"session_jump_buttons": False' in CONFIG_PY
|
||||
assert '"session_jump_buttons"' in CONFIG_PY
|
||||
assert "window._sessionJumpButtonsEnabled=!!s.session_jump_buttons" in BOOT_JS
|
||||
assert "window._sessionJumpButtonsEnabled=false" in BOOT_JS
|
||||
assert "session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked" in PANELS_JS
|
||||
|
||||
scroll_listener = UI_JS[UI_JS.index("el.addEventListener('scroll'") : UI_JS.index("})();", UI_JS.index("el.addEventListener('scroll'"))]
|
||||
assert "if(btn) btn.style.display=_scrollPinned?'none':'flex'" in scroll_listener
|
||||
assert "!_isSessionJumpButtonsEnabled()||_scrollPinned" not in UI_JS
|
||||
|
||||
|
||||
def test_jump_to_session_start_button_loads_full_history_and_scrolls_top():
|
||||
jump = _function_body(UI_JS, "async function jumpToSessionStart")
|
||||
update = _function_body(UI_JS, "function _updateSessionStartJumpButton")
|
||||
|
||||
assert 'id="jumpToSessionStartBtn"' in INDEX_HTML
|
||||
assert 'class="session-jump-btn session-jump-btn--start"' in INDEX_HTML
|
||||
assert "data-i18n=\"session_jump_start\"" in INDEX_HTML
|
||||
assert "data-i18n=\"session_jump_end\"" in INDEX_HTML
|
||||
assert "data-i18n-aria-label=\"session_jump_start_label\"" in INDEX_HTML
|
||||
assert "data-i18n-aria-label=\"session_jump_end_label\"" in INDEX_HTML
|
||||
|
||||
assert "_ensureAllMessagesLoaded" in jump
|
||||
assert "_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())" in jump
|
||||
assert "renderMessages({ preserveScroll:true })" in jump
|
||||
assert "container.scrollTop=0" in jump
|
||||
assert "btn.style.display=(hasSession&&canRevealStart&&awayFromStart)?'flex':'none'" in update
|
||||
|
||||
|
||||
def test_session_jump_buttons_match_pill_layout_without_regressing_default_arrow():
|
||||
assert ".session-jump-btn" in STYLE_CSS
|
||||
assert ".session-jump-btn--start{top:16px" in STYLE_CSS
|
||||
assert ".session-jump-btn__text{display:none" in STYLE_CSS
|
||||
assert ".messages.session-nav-enabled .scroll-to-bottom-btn" in STYLE_CSS
|
||||
assert ".messages.session-nav-enabled .session-jump-btn__text{display:inline" in STYLE_CSS
|
||||
assert "classList.toggle('session-nav-enabled',_isSessionJumpButtonsEnabled())" in UI_JS
|
||||
|
||||
|
||||
def test_session_jump_buttons_are_i18n_localized_in_text_tooltip_and_aria():
|
||||
for key in [
|
||||
"session_jump_start",
|
||||
"session_jump_start_label",
|
||||
"session_jump_end",
|
||||
"session_jump_end_label",
|
||||
"settings_label_session_jump_buttons",
|
||||
"settings_desc_session_jump_buttons",
|
||||
]:
|
||||
assert I18N_JS.count(f"{key}:") >= 8, f"missing locale entries for {key}"
|
||||
assert "document.querySelectorAll('[data-i18n-aria-label]')" in I18N_JS
|
||||
assert "el.setAttribute('aria-label', val)" in I18N_JS
|
||||
Reference in New Issue
Block a user