diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1d4d78..40b96f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ### Fixed +## [v0.50.241] — 2026-04-30 + +### Added +- **Inline audio/video media editor with playback speed controls** — MEDIA: tokens and file attachments for audio/video now render as a full media editor card with 0.5×–2× speed buttons, rate stored in `localStorage`, and a `MutationObserver` that auto-applies the saved rate to any newly rendered player. Composer tray shows compact inline players for attached audio/video files. (`static/ui.js`, `static/boot.js`, `static/style.css`, `static/workspace.js`) @nickgiulioni1 — PR #1290 (rebased #1232) +- **HTTP byte-range streaming for audio/video** — `/api/media?inline=1` now handles `Range:` request headers and returns HTTP 206 Partial Content, enabling seekable playback of large audio and video files. Path access is guarded by the existing `within_allowed` check before `_serve_file_bytes` is called. (`api/routes.py`) @nickgiulioni1 — PR #1290 +- **PDF and media previews in workspace file browser** — PDF, audio, and video files in the workspace panel now render inline instead of forcing download. (`static/workspace.js`) @nickgiulioni1 — PR #1290 +- **Configured model badges** — models that appear in `config.yaml` as primary or fallback are now labeled with `Primary` / `Fallback N` badges in the model picker, and the badge is carried through to the selected-model chip in the composer header. Badge data persists through the on-disk model cache so it survives server restarts. (`api/config.py`, `static/ui.js`, `static/index.html`, `static/style.css`) @renatomott — PR #1287 +- **Appearance autosave** — Theme, skin, and font-size pickers in Settings › Appearance now save immediately with inline `Saving…` / `Saved` / `Failed — Retry` status. These controls no longer set the global unsaved-changes dirty state, so closing Settings after tweaking appearance never prompts to discard. Font size is also now persisted to `config.yaml` and restored on page load. (`static/boot.js`, `static/panels.js`, `api/config.py`, `static/i18n.js`) @franksong2702 — PR #1289, refs #1003 +- **Agent session source normalization** — Imported Hermes Agent sessions now expose `raw_source`, `session_source`, and `source_label` metadata through both `/api/sessions` and gateway watcher SSE snapshots. Existing `source_tag` / `is_cli_session` compatibility fields remain unchanged so sidebar display is preserved; this lays the groundwork for source-aware sidebar policies. (`api/agent_sessions.py`, `api/gateway_watcher.py`, `api/models.py`) @franksong2702 — PR #1294, refs #1013 + ## [v0.50.240] — 2026-04-30 ### Added diff --git a/TESTING.md b/TESTING.md index 86eabd5d..ac0c97d7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -240,6 +240,32 @@ EXPECT: - If it was the only file, tray collapses FAIL: File not removed, error. +### T4.6: Inline Audio Attachment Editor with Variable Speed +SETUP: Active session, an audio file ready locally (`.mp3`, `.wav`, `.m4a`, `.ogg`, or `.flac`). +STEPS: + 1. Attach the audio file with the paperclip or drag/drop + 2. Confirm the tray shows an audio media chip, then send the message + 3. In the sent user message, press Play on the inline audio player + 4. Click 0.5×, 1.25×, 1.5×, and 2× speed buttons +EXPECT: + - The audio renders inline in the chat instead of only as a download/file badge + - Native audio controls are visible and usable + - The clicked speed button becomes active and playback speed changes immediately + - Download/open behavior for non-media files is unchanged +FAIL: Audio only downloads, no speed buttons appear, or speed buttons do not affect playback. + +### T4.7: Inline Video Attachment Editor with Variable Speed +SETUP: Active session, a video file ready locally (`.mp4`, `.mov`, `.webm`, or `.m4v`). +STEPS: + 1. Attach and send the video file + 2. In the sent user message, play the inline video + 3. Switch among 0.75×, 1×, 1.5×, and 2× speed controls +EXPECT: + - The video renders inline, contained within the message width + - Native video controls are visible and usable + - Speed selection updates the video `playbackRate` without reloading the media +FAIL: Video only shows a generic badge, overflows the chat column, or speed controls fail. + --- ## Section 5: Workspace File Browser @@ -306,6 +332,33 @@ EXPECT: - Image maintains aspect ratio FAIL: Raw binary text displayed, broken image icon, error message, or nothing happens. +### T5.5b: Preview Audio/Video Files Inline +SETUP: Workspace contains at least one audio file (`.mp3`, `.wav`, `.m4a`) and one video file (`.mp4`, `.mov`, `.webm`). +STEPS: + 1. Click the audio file in the workspace file tree + 2. Play it and select 1.5× or 2× speed + 3. Close preview, then click the video file + 4. Play it and select 0.75× or 1.25× speed +EXPECT: + - Audio/video open in the workspace preview panel instead of downloading immediately + - Path badge shows `audio` or `video` + - Native media controls and the variable-speed buttons are visible + - Video scales to the preview panel without overflowing +FAIL: Browser downloads the media immediately, raw binary appears, or speed controls are missing/broken. + +### T5.5c: Preview PDF Files Inline +SETUP: Workspace contains at least one `.pdf` file. +STEPS: + 1. Click the PDF file in the workspace file tree + 2. Use the browser/PDF viewer scroll and zoom controls if available + 3. Click "Open in browser" as a fallback +EXPECT: + - PDF opens in the workspace preview panel instead of downloading immediately + - Path badge shows `pdf` + - PDF iframe fills the preview area + - "Open in browser" opens the same raw file endpoint in a new tab +FAIL: Browser downloads the PDF immediately, raw binary appears, or the preview panel is blank without an open fallback. + ### T5.6: Preview a Markdown File (Sprint 2) SETUP: Workspace has a .md file (or create one: upload a file named README.md with some markdown content). STEPS: diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 43999a7b..ba9ae718 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -6,6 +6,66 @@ from pathlib import Path logger = logging.getLogger(__name__) +MESSAGING_SOURCES = { + 'discord', + 'slack', + 'telegram', + 'weixin', +} + +SOURCE_LABELS = { + 'api_server': 'API', + 'cli': 'CLI', + 'cron': 'Cron', + 'discord': 'Discord', + 'slack': 'Slack', + 'telegram': 'Telegram', + 'tool': 'Tool', + 'webui': 'WebUI', + 'weixin': 'Weixin', +} + + +def normalize_agent_session_source(raw_source: str | None) -> dict: + """Return stable source metadata for Hermes Agent session rows. + + ``sessions.source`` is an Agent-level raw value. WebUI needs a smaller, + durable contract so routes, SSE snapshots, and future sidebar policies do + not each reimplement raw-source checks. + """ + raw = str(raw_source or '').strip().lower() or 'unknown' + + if raw == 'webui': + session_source = 'webui' + elif raw == 'cli': + session_source = 'cli' + elif raw in MESSAGING_SOURCES: + session_source = 'messaging' + elif raw == 'cron': + session_source = 'cron' + elif raw == 'tool': + session_source = 'tool' + elif raw == 'api_server': + session_source = 'api' + else: + session_source = 'other' + + label = SOURCE_LABELS.get(raw) + if not label: + label = raw.replace('_', ' ').title() if raw != 'unknown' else 'Agent' + + return { + 'raw_source': None if raw == 'unknown' else raw, + 'session_source': session_source, + 'source_label': label, + } + + +def _with_normalized_source(row: dict) -> dict: + normalized = normalize_agent_session_source(row.get('source')) + return {**row, **normalized} + + def _optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str: return f"s.{name}" if name in columns else f"{fallback} AS {name}" @@ -191,6 +251,7 @@ def read_importable_agent_session_rows( params, ) projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()]) + projected = [_with_normalized_source(row) for row in projected] if limit is None: return projected return projected[:max(0, int(limit))] diff --git a/api/config.py b/api/config.py index d337abb6..eb6a850e 100644 --- a/api/config.py +++ b/api/config.py @@ -448,6 +448,19 @@ MIME_MAP = { ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".m4a": "audio/mp4", + ".aac": "audio/aac", + ".ogg": "audio/ogg", + ".oga": "audio/ogg", + ".opus": "audio/opus", + ".flac": "audio/flac", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".m4v": "video/mp4", + ".webm": "video/webm", + ".ogv": "video/ogg", } # ── Toolsets (from config.yaml or hardcoded default) ───────────────────────── @@ -1238,12 +1251,13 @@ def _is_valid_models_cache(cache: object) -> bool: """Return True when a disk cache payload has the full /api/models shape.""" if not isinstance(cache, dict): return False - if not {"active_provider", "default_model", "groups"}.issubset(cache): + if not {"active_provider", "default_model", "configured_model_badges", "groups"}.issubset(cache): return False active_provider = cache.get("active_provider") return ( (active_provider is None or isinstance(active_provider, str)) and isinstance(cache.get("default_model"), str) + and isinstance(cache.get("configured_model_badges"), dict) and isinstance(cache.get("groups"), list) ) @@ -1273,6 +1287,7 @@ def _save_models_cache_to_disk(cache: dict) -> None: { "active_provider": cache["active_provider"], "default_model": cache["default_model"], + "configured_model_badges": cache["configured_model_badges"], "groups": cache["groups"], }, f, @@ -1418,6 +1433,88 @@ def get_available_models() -> dict: default_model = get_effective_default_model(cfg) groups = [] + def _norm_model_id(model_id: str) -> str: + s = str(model_id or "").strip().lower() + if s.startswith("@") and ":" in s: + s = s.split(":", 1)[1] + if "/" in s: + s = s.split("/", 1)[1] + return s.replace("-", ".") + + def _build_configured_model_badges() -> dict[str, dict[str, str]]: + configured_entries: list[dict[str, str]] = [] + if active_provider and default_model: + configured_entries.append( + { + "provider": active_provider, + "model": default_model, + "role": "primary", + "label": "Primary", + } + ) + fallback_cfg = cfg.get("fallback_providers", []) + if isinstance(fallback_cfg, list): + for idx, entry in enumerate(fallback_cfg, start=1): + if not isinstance(entry, dict): + continue + provider = _resolve_provider_alias(entry.get("provider")) + model = str(entry.get("model") or "").strip() + if not provider or not model: + continue + configured_entries.append( + { + "provider": provider, + "model": model, + "role": "fallback", + "label": f"Fallback {idx}", + } + ) + + option_ids = [m.get("id", "") for g in groups for m in g.get("models", []) if m.get("id")] + option_lookup = {str(opt_id): str(opt_id) for opt_id in option_ids} + norm_lookup: dict[str, list[str]] = {} + for opt_id in option_ids: + norm_lookup.setdefault(_norm_model_id(opt_id), []).append(opt_id) + + badges: dict[str, dict[str, str]] = {} + for entry in configured_entries: + provider = entry["provider"] + model = entry["model"] + raw_candidates = [] + for candidate in ( + model, + f"{provider}/{model}", + f"@{provider}:{model}", + ): + if candidate and candidate not in raw_candidates: + raw_candidates.append(candidate) + + match_id = None + for candidate in raw_candidates: + if candidate in option_lookup: + match_id = option_lookup[candidate] + break + if match_id is None: + for candidate in raw_candidates: + normalized = _norm_model_id(candidate) + matches = norm_lookup.get(normalized, []) + if not matches: + continue + provider_match = next( + (m for m in matches if m.startswith(f"@{provider}:") or m.startswith(f"{provider}/")), + None, + ) + match_id = provider_match or matches[0] + if match_id: + break + + badge_payload = {"role": entry["role"], "label": entry["label"], "provider": provider} + for candidate in raw_candidates: + badges[candidate] = badge_payload + if match_id: + badges[match_id] = badge_payload + return badges + # 1. Read config.yaml model section cfg_base_url = "" # must be defined before conditional blocks (#117) model_cfg = cfg.get("model", {}) @@ -1921,6 +2018,7 @@ def get_available_models() -> dict: return { "active_provider": active_provider, "default_model": default_model, + "configured_model_badges": _build_configured_model_badges(), "groups": groups, } @@ -2083,6 +2181,7 @@ _SETTINGS_DEFAULTS = { "check_for_updates": True, # check if webui/agent repos are behind upstream "theme": "dark", # light | dark | system | calm "skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard + "font_size": "default", # small | default | large "language": "en", # UI locale code; must match a key in static/i18n.js LOCALES "bot_name": os.getenv( "HERMES_WEBUI_BOT_NAME", "Hermes" @@ -2194,6 +2293,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - { _SETTINGS_ENUM_VALUES = { "send_key": {"enter", "ctrl+enter"}, "sidebar_density": {"compact", "detailed"}, + "font_size": {"small", "default", "large"}, "auto_title_refresh_every": {"0", "5", "10", "20"}, "busy_input_mode": {"queue", "interrupt", "steer"}, } diff --git a/api/gateway_watcher.py b/api/gateway_watcher.py index b41ebc83..c8bf8644 100644 --- a/api/gateway_watcher.py +++ b/api/gateway_watcher.py @@ -65,6 +65,9 @@ def _get_agent_sessions_from_db() -> list: 'created_at': row['started_at'], 'updated_at': row['last_activity'] or row['started_at'], 'source': row['source'] or 'cli', + 'raw_source': row.get('raw_source'), + 'session_source': row.get('session_source'), + 'source_label': row.get('source_label'), }) return sessions except Exception: diff --git a/api/models.py b/api/models.py index 2464d70e..091895af 100644 --- a/api/models.py +++ b/api/models.py @@ -942,6 +942,9 @@ def get_cli_sessions() -> list: 'project_id': None, 'profile': profile, 'source_tag': _source, + 'raw_source': row.get('raw_source'), + 'session_source': row.get('session_source'), + 'source_label': row.get('source_label'), 'is_cli_session': True, }) except Exception as _cli_err: diff --git a/api/routes.py b/api/routes.py index 4f02c029..7f4ad069 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2433,6 +2433,84 @@ def _content_disposition_value(disposition: str, filename: str) -> str: ) +def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int] | None: + """Parse a single HTTP bytes range into inclusive start/end offsets.""" + if not range_header or not range_header.startswith("bytes=") or file_size < 1: + return None + spec = range_header.split("=", 1)[1].strip() + if "," in spec or "-" not in spec: + return None + start_s, end_s = spec.split("-", 1) + try: + if start_s == "": + # suffix range: bytes=-500 + suffix_len = int(end_s) + if suffix_len <= 0: + return None + start = max(0, file_size - suffix_len) + end = file_size - 1 + else: + start = int(start_s) + end = int(end_s) if end_s else file_size - 1 + if start < 0: + return None + end = min(end, file_size - 1) + if start > end or start >= file_size: + return None + return start, end + except ValueError: + return None + + +def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_control: str, *, csp: str | None = None): + """Serve a file with correct MIME/disposition and optional byte-range support.""" + try: + file_size = target.stat().st_size + except PermissionError: + return bad(handler, "Permission denied", 403) + except Exception: + return bad(handler, "Could not stat file", 500) + + byte_range = _parse_range_header(handler.headers.get("Range", ""), file_size) + if handler.headers.get("Range") and byte_range is None: + handler.send_response(416) + handler.send_header("Content-Range", f"bytes */{file_size}") + handler.send_header("Accept-Ranges", "bytes") + _security_headers(handler) + handler.end_headers() + return True + + start, end = byte_range if byte_range else (0, max(0, file_size - 1)) + content_length = end - start + 1 if file_size else 0 + handler.send_response(206 if byte_range else 200) + handler.send_header("Content-Type", mime) + handler.send_header("Content-Length", str(content_length)) + handler.send_header("Accept-Ranges", "bytes") + if byte_range: + handler.send_header("Content-Range", f"bytes {start}-{end}/{file_size}") + handler.send_header("Cache-Control", cache_control) + handler.send_header("Content-Disposition", _content_disposition_value(disposition, target.name)) + if csp: + handler.send_header("Content-Security-Policy", csp) + _security_headers(handler) + handler.end_headers() + + if content_length: + try: + with target.open("rb") as f: + f.seek(start) + remaining = content_length + while remaining: + chunk = f.read(min(1024 * 1024, remaining)) + if not chunk: + break + handler.wfile.write(chunk) + remaining -= len(chunk) + except PermissionError: + return True + return True + + def _handle_media(handler, parsed): """Serve a local file by absolute path for inline display in the chat. @@ -2500,39 +2578,26 @@ def _handle_media(handler, parsed): ext = target.suffix.lower() mime = MIME_MAP.get(ext, "application/octet-stream") - # Only serve image types inline; everything else is a download + # Only serve safe media/PDF types inline when explicitly requested. Everything + # else remains a download. SVG is always a download (XSS risk). _INLINE_IMAGE_TYPES = { "image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/bmp", } + _INLINE_PREVIEW_TYPES = _INLINE_IMAGE_TYPES | { + "audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/aac", + "audio/ogg", "audio/opus", "audio/flac", + "video/mp4", "video/quicktime", "video/webm", "video/ogg", + "application/pdf", + } _DOWNLOAD_TYPES = {"image/svg+xml"} # SVG: XSS risk, force download - - try: - raw_bytes = target.read_bytes() - except PermissionError: - return bad(handler, "Permission denied", 403) - except Exception: - return bad(handler, "Could not read file", 500) - - handler.send_response(200) - handler.send_header("Content-Type", mime) - handler.send_header("Content-Length", str(len(raw_bytes))) - handler.send_header("Cache-Control", "private, max-age=3600") - _security_headers(handler) - - if mime in _DOWNLOAD_TYPES or mime not in _INLINE_IMAGE_TYPES: - handler.send_header( - "Content-Disposition", - _content_disposition_value("attachment", target.name), + inline_preview = qs.get("inline", [""])[0] == "1" + disposition = "inline" if ( + mime not in _DOWNLOAD_TYPES and ( + mime in _INLINE_IMAGE_TYPES or (inline_preview and mime in _INLINE_PREVIEW_TYPES) ) - else: - handler.send_header( - "Content-Disposition", - _content_disposition_value("inline", target.name), - ) - - handler.end_headers() - handler.wfile.write(raw_bytes) + ) else "attachment" + return _serve_file_bytes(handler, target, mime, disposition, "private, max-age=3600") def _handle_file_raw(handler, parsed): @@ -2551,11 +2616,6 @@ def _handle_file_raw(handler, parsed): return j(handler, {"error": "not found"}, status=404) ext = target.suffix.lower() mime = MIME_MAP.get(ext, "application/octet-stream") - raw_bytes = target.read_bytes() - handler.send_response(200) - handler.send_header("Content-Type", mime) - handler.send_header("Content-Length", str(len(raw_bytes))) - handler.send_header("Cache-Control", "no-store") # Security: force download for dangerous MIME types to prevent XSS. # Exception: ?inline=1 permits text/html to be served inline for the # sandboxed workspace HTML preview iframe (sandbox="allow-scripts" with no @@ -2563,16 +2623,7 @@ def _handle_file_raw(handler, parsed): inline_preview = qs.get("inline", [""])[0] == "1" dangerous_types = {"text/html", "application/xhtml+xml", "image/svg+xml"} html_inline_ok = inline_preview and mime == "text/html" - if force_download or (mime in dangerous_types and not html_inline_ok): - handler.send_header( - "Content-Disposition", - _content_disposition_value("attachment", target.name), - ) - else: - handler.send_header( - "Content-Disposition", - _content_disposition_value("inline", target.name), - ) + disposition = "attachment" if force_download or (mime in dangerous_types and not html_inline_ok) else "inline" # Defense-in-depth for ?inline=1 HTML: even though the workspace.js iframe # sets sandbox="allow-scripts", a user could be tricked into opening the # ?inline=1 URL directly in a top-level tab (e.g. via a chat link), which @@ -2580,14 +2631,9 @@ def _handle_file_raw(handler, parsed): # CSP sandbox directive applies the same isolation server-side: without # allow-same-origin, the document is treated as a unique opaque origin and # cannot read WebUI cookies, localStorage, or postMessage to the parent. - if html_inline_ok: - # Match the iframe sandbox="allow-scripts" exactly: scripts allowed, - # but no allow-same-origin → unique opaque origin (no cookie/storage - # access even when accessed via direct URL outside the iframe). - handler.send_header("Content-Security-Policy", "sandbox allow-scripts") - handler.end_headers() - handler.wfile.write(raw_bytes) - return True + csp = "sandbox allow-scripts" if html_inline_ok else None + # _serve_file_bytes sends Content-Security-Policy when csp is set. + return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp) def _handle_file_read(handler, parsed): diff --git a/static/boot.js b/static/boot.js index 4a1a6642..e5402c71 100644 --- a/static/boot.js +++ b/static/boot.js @@ -426,16 +426,18 @@ $('importFileInput').onchange=async(e)=>{ }; // btnRefreshFiles is now panel-icon-btn in header (see HTML) function clearPreview(){ + // Restore directory breadcrumb after closing file preview + if(typeof renderBreadcrumb==='function') renderBreadcrumb(); const closePanelAfter=_workspacePanelMode==='preview'; const pa=$('previewArea');if(pa)pa.classList.remove('visible'); const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';} + const pdf=$('previewPdfFrame');if(pdf)pdf.src=''; + const html=$('previewHtmlIframe');if(html)html.src=''; const pm=$('previewMd');if(pm)pm.innerHTML=''; const pc=$('previewCode');if(pc)pc.textContent=''; const pp=$('previewPathText');if(pp)pp.textContent=''; const ft=$('fileTree');if(ft)ft.style.display=''; _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false; - // Restore directory breadcrumb after closing file preview - if(typeof renderBreadcrumb==='function') renderBreadcrumb(); if(closePanelAfter)closeWorkspacePanel(); else syncWorkspacePanelUI(); } @@ -710,11 +712,11 @@ function _pickTheme(name){ _applySkin(appearance.skin); _syncThemePicker(appearance.theme); _syncSkinPicker(appearance.skin); - if(typeof _markSettingsDirty==='function') _markSettingsDirty(); const hidden=$('settingsTheme'); if(hidden) hidden.value=appearance.theme; const skinHidden=$('settingsSkin'); if(skinHidden) skinHidden.value=appearance.skin; + if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave(); } function _pickSkin(name){ @@ -725,11 +727,11 @@ function _pickSkin(name){ _applySkin(appearance.skin); _syncThemePicker(appearance.theme); _syncSkinPicker(appearance.skin); - if(typeof _markSettingsDirty==='function') _markSettingsDirty(); const hidden=$('settingsSkin'); if(hidden) hidden.value=appearance.skin; const themeHidden=$('settingsTheme'); if(themeHidden) themeHidden.value=appearance.theme; + if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave(); } function _syncThemePicker(active){ @@ -760,9 +762,9 @@ function _pickFontSize(size){ localStorage.setItem('hermes-font-size',size); _applyFontSize(size); _syncFontSizePicker(size); - if(typeof _markSettingsDirty==='function') _markSettingsDirty(); const hidden=$('settingsFontSize'); if(hidden) hidden.value=size; + if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave(); } function _syncFontSizePicker(active){ @@ -838,6 +840,9 @@ function applyBotName(){ _applyTheme(appearance.theme); localStorage.setItem('hermes-skin',appearance.skin); _applySkin(appearance.skin); + const fontSize=(s.font_size||localStorage.getItem('hermes-font-size')||'default'); + localStorage.setItem('hermes-font-size',fontSize); + _applyFontSize(fontSize); if(typeof setLocale==='function'){ const _lang=typeof resolvePreferredLocale==='function' ? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang')) diff --git a/static/i18n.js b/static/i18n.js index e0677e35..628ee285 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -371,6 +371,10 @@ const LOCALES = { font_size_small: 'Small', font_size_default: 'Default', font_size_large: 'Large', + settings_autosave_saving: 'Saving…', + settings_autosave_saved: 'Saved', + settings_autosave_failed: 'Save failed', + settings_autosave_retry: 'Retry', settings_label_language: 'Language', settings_label_token_usage: 'Show token usage', settings_label_sidebar_density: 'Sidebar density', @@ -941,6 +945,10 @@ const LOCALES = { font_size_small: 'Маленький', font_size_default: 'Стандарт', font_size_large: 'Большой', + settings_autosave_saving: 'Сохранение…', + settings_autosave_saved: 'Сохранено', + settings_autosave_failed: 'Не удалось сохранить', + settings_autosave_retry: 'Повторить', workspace_empty_dir: 'Это рабочее пространство пусто.', workspace_empty_no_path: 'Рабочее пространство не выбрано. Настройте его в Настройки → Рабочее пространство.', available_personalities: 'Доступные личности:', @@ -1732,6 +1740,10 @@ const LOCALES = { font_size_small: 'Pequeño', font_size_default: 'Por defecto', font_size_large: 'Grande', + settings_autosave_saving: 'Guardando…', + settings_autosave_saved: 'Guardado', + settings_autosave_failed: 'Error al guardar', + settings_autosave_retry: 'Reintentar', settings_label_language: 'Idioma', settings_label_token_usage: 'Mostrar uso de tokens', settings_label_sidebar_density: 'Densidad de la barra lateral', @@ -2451,6 +2463,10 @@ const LOCALES = { font_size_small: 'Small', font_size_default: 'Default', font_size_large: 'Large', + settings_autosave_saving: 'Wird gespeichert…', + settings_autosave_saved: 'Gespeichert', + settings_autosave_failed: 'Speichern fehlgeschlagen', + settings_autosave_retry: 'Wiederholen', settings_label_language: 'Sprache', settings_label_token_usage: 'Token-Verbrauch anzeigen', settings_label_sidebar_density: 'Seitenleistendichte', @@ -3175,6 +3191,10 @@ const LOCALES = { font_size_small: '\u5c0f', font_size_default: '\u9ed8\u8ba4', font_size_large: '\u5927', + settings_autosave_saving: '\u4fdd\u5b58\u4e2d…', + settings_autosave_saved: '\u5df2\u4fdd\u5b58', + settings_autosave_failed: '\u4fdd\u5b58\u5931\u8d25', + settings_autosave_retry: '\u91cd\u8bd5', settings_label_language: '\u8bed\u8a00', settings_label_token_usage: '\u663e\u793a token \u7528\u91cf', settings_label_sidebar_density: '侧边栏密度', @@ -3913,6 +3933,10 @@ const LOCALES = { font_size_small: '\u5c0f', font_size_default: '\u9810\u8a2d', font_size_large: '\u5927', + settings_autosave_saving: '\u5132\u5b58\u4e2d…', + settings_autosave_saved: '\u5df2\u5132\u5b58', + settings_autosave_failed: '\u5132\u5b58\u5931\u6557', + settings_autosave_retry: '\u91cd\u8a66', settings_label_language: '\u8a9e\u8a00', settings_label_token_usage: '\u986f\u793a token \u7528\u91cf', settings_label_sidebar_density: '側邊欄密度', @@ -4769,6 +4793,10 @@ const LOCALES = { font_size_small: 'Pequeno', font_size_default: 'Padrão', font_size_large: 'Grande', + settings_autosave_saving: 'Salvando…', + settings_autosave_saved: 'Salvo', + settings_autosave_failed: 'Falha ao salvar', + settings_autosave_retry: 'Tentar novamente', settings_label_language: 'Idioma', settings_label_token_usage: 'Mostrar uso de tokens', settings_label_sidebar_density: 'Densidade da sidebar', @@ -5466,6 +5494,10 @@ const LOCALES = { font_size_small: '작게', font_size_default: '기본', font_size_large: '크게', + settings_autosave_saving: '저장 중…', + settings_autosave_saved: '저장됨', + settings_autosave_failed: '저장 실패', + settings_autosave_retry: '다시 시도', settings_label_language: '언어', settings_label_token_usage: '토큰 사용량 표시', settings_label_sidebar_density: '사이드바 밀도', diff --git a/static/index.html b/static/index.html index 91a79fd6..3a1c3ef5 100644 --- a/static/index.html +++ b/static/index.html @@ -401,6 +401,7 @@ for a given model ID. @@ -152,6 +241,7 @@ async function populateModelDropdown(){ // Store default model so newSession() can apply it (#872). // Per-page-load — not synced across browser tabs. window._defaultModel=data.default_model||null; + window._configuredModelBadges=data.configured_model_badges||{}; // Clear existing options sel.innerHTML=''; _dynamicModelLabels={}; @@ -313,16 +403,50 @@ function _selectedModelOption(){ return sel.options[sel.selectedIndex]||null; } +function _normalizeConfiguredModelKey(modelId){ + let s=String(modelId||'').trim().toLowerCase(); + if(s.startsWith('@')&&s.includes(':')) s=s.substring(s.indexOf(':')+1); + if(s.includes('/')) s=s.split('/').pop(); + return s.replace(/-/g,'.'); +} + +function _getConfiguredModelBadge(modelId,badgeMap){ + const map=badgeMap||window._configuredModelBadges||{}; + if(!modelId||!map) return null; + if(map[modelId]) return map[modelId]; + const targetNorm=_normalizeConfiguredModelKey(modelId); + for(const [candidate,badge] of Object.entries(map)){ + if(_normalizeConfiguredModelKey(candidate)===targetNorm) return badge; + } + return null; +} + function syncModelChip(){ const sel=$('modelSelect'); const chip=$('composerModelChip'); const label=$('composerModelLabel'); + const badgeEl=$('composerModelBadge'); const dd=$('composerModelDropdown'); if(!sel||!chip||!label) return; // Don't show a model label until boot has finished loading to prevent flash of wrong default - if(!S._bootReady){ label.textContent=''; chip.title='Conversation model'; return; } + if(!S._bootReady){ + label.textContent=''; + chip.title='Conversation model'; + if(badgeEl){ + badgeEl.textContent=''; + badgeEl.hidden=true; + badgeEl.className='composer-model-badge'; + } + return; + } const opt=_selectedModelOption(); label.textContent=opt?opt.textContent:getModelLabel(sel.value||''); + const badge=_getConfiguredModelBadge(sel.value||'',window._configuredModelBadges||{}); + if(badgeEl){ + badgeEl.textContent=badge&&badge.label?badge.label:''; + badgeEl.hidden=!badgeEl.textContent; + badgeEl.className='composer-model-badge'+(badge&&badge.role?` composer-model-badge--${badge.role}`:''); + } chip.title=sel.value||'Conversation model'; chip.classList.toggle('active',!!(dd&&dd.classList.contains('open'))); } @@ -346,14 +470,15 @@ function renderModelDropdown(){ if(!dd||!sel) return; // Store model data for filtering const _modelData=[]; + const _badgeMap=window._configuredModelBadges||{}; for(const child of Array.from(sel.children)){ if(child.tagName==='OPTGROUP'){ for(const opt of Array.from(child.children)){ - _modelData.push({value:opt.value,name:esc(opt.textContent||getModelLabel(opt.value)),id:esc(opt.value),group:child.label||''}); + _modelData.push({value:opt.value,name:esc(opt.textContent||getModelLabel(opt.value)),id:esc(opt.value),group:child.label||'',badge:_getConfiguredModelBadge(opt.value,_badgeMap)}); } } if(child.tagName==='OPTION'){ - _modelData.push({value:child.value,name:esc(child.textContent||getModelLabel(child.value)),id:esc(child.value),group:''}); + _modelData.push({value:child.value,name:esc(child.textContent||getModelLabel(child.value)),id:esc(child.value),group:'',badge:_getConfiguredModelBadge(child.value,_badgeMap)}); } } // Create search input FIRST before filterModels definition @@ -405,7 +530,8 @@ function renderModelDropdown(){ } const row=document.createElement('div'); row.className='model-opt'+(m.value===sel.value?' active':''); - row.innerHTML=`${m.name}${m.id}`; + const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; + row.innerHTML=`
${m.name}${badgeHtml}
${m.id}`; row.onclick=()=>selectModelFromDropdown(m.value); dd.appendChild(row); } @@ -1192,6 +1318,24 @@ function renderMd(raw){ // ── Restore MEDIA stash → inline images or download links ───────────────── s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{ const ref=media_stash[+i]; + // Keep this logic self-contained: some tests extract renderMd() alone and + // execute it in node, without the top-level helper functions from ui.js. + const mediaKindForName=(name='')=>{ + const clean=String(name||'').split('?')[0].toLowerCase(); + if(/\.(mp3|wav|m4a|aac|ogg|oga|opus|flac)$/i.test(clean)) return 'audio'; + if(/\.(mp4|mov|m4v|webm|ogv|avi|mkv)$/i.test(clean)) return 'video'; + if(_IMAGE_EXTS.test(clean)) return 'image'; + return ''; + }; + const mediaPlayerHtml=(kind,src,name)=>{ + if(typeof _mediaPlayerHtml==='function') return _mediaPlayerHtml(kind,src,name); + const safeName=esc(name||kind||'media'); + const safeSrc=esc(src); + const tag=kind==='video' + ? `` + : ``; + return `
${tag}
${safeName}
`; + }; // HTTP(S) URL if(/^https?:\/\//i.test(ref)){ // Rewrite localhost/127.0.0.1 to the actual server base URL so remote @@ -1200,45 +1344,39 @@ function renderMd(raw){ // joins cleanly — this preserves any subpath mount (e.g. /hermes/). let src=ref; if(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(src)){ - const base=document.baseURI.replace(/\/$/,''); + const base=(document.baseURI||'').replace(/\/$/,''); src=src.replace(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i,base); } - // SVG URLs → render inline as image (before image catch-all) - if(_SVG_EXTS.test(src.split('?')[0])){ + // MEDIA: tokens are usually tool-generated images. Render all https:// + // URLs as so extensionless CDN paths still work (#853), while + // preserving explicit audio/video/SVG URLs with their proper handlers. + const urlPath=src.split('?')[0]; + const mediaKind=mediaKindForName(urlPath); + // SVG URLs → render inline as image + if(_SVG_EXTS.test(urlPath)){ return `${t('media_svg_label')}`; } - // Audio URLs → inline player - if(_AUDIO_EXTS.test(src.split('?')[0])){ - return `
🎵 ${t('media_audio_label')}
`; - } - // Video URLs → inline player - if(_VIDEO_EXTS.test(src.split('?')[0])){ - return `
🎬 ${t('media_video_label')}
`; - } - // MEDIA: tokens are only emitted for tool-generated images (image_generate etc.). - // Render all https:// URLs as — extension check would miss extensionless - // CDN paths like fal.media content-addressed URLs (closes #853). - if(_IMAGE_EXTS.test(src.split('?')[0]) || /^https?:\/\//i.test(src)){ + if(mediaKind==='audio'||mediaKind==='video') return mediaPlayerHtml(mediaKind,src,urlPath.split('/').pop()||mediaKind); + // Render all https:// URLs as — extensionless CDN paths like fal.media still work (#853) + if(_IMAGE_EXTS.test(urlPath) || /^https?:\/\//i.test(src)){ return `image`; } return `${esc(src)}`; } // Local file path const apiUrl='api/media?path='+encodeURIComponent(ref); - if(_IMAGE_EXTS.test(ref)){ + const localKind=mediaKindForName(ref); + if(localKind==='image'){ return `${esc(ref.split('/').pop())}`; } // SVG → inline image (no download, render directly) if(_SVG_EXTS.test(ref)){ return `${t('media_svg_label')}`; } - // Audio → inline player - if(_AUDIO_EXTS.test(ref)){ - return `
🎵 ${t('media_audio_label')}
`; - } - // Video → inline player - if(_VIDEO_EXTS.test(ref)){ - return `
🎬 ${t('media_video_label')}
`; + // Audio/video → inline player with speed controls; use &inline=1 for byte-range seeking + if(_AUDIO_EXTS.test(ref)||_VIDEO_EXTS.test(ref)){ + const kind=_AUDIO_EXTS.test(ref)?'audio':'video'; + return _mediaPlayerHtml(kind,apiUrl+'&inline=1',ref.split('/').pop()||ref); } // PDF files → render first page preview with lazy-load if(_PDF_EXTS.test(ref)){ @@ -1264,6 +1402,7 @@ function renderMd(raw){ } return `📎 ${fname}`; }); + // ── End MEDIA restore ────────────────────────────────────────────────────── // Restore blockquote stash. Done last so the inner HTML (already produced // by the recursive renderMd in the pre-pass) is dropped into the final @@ -2599,6 +2738,7 @@ function renderMessages(){ if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();} requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); + if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver(); if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();} return; } @@ -2720,29 +2860,14 @@ function renderMessages(){ const isLastAssistant=!isUser&&vi===visWithIdx.length-1; let filesHtml=''; if(m.attachments&&m.attachments.length){ + // Static regression tests intentionally look for msg-media-img/msg-file-badge near this branch. const _attachSid=(S.session&&S.session.session_id)||''; filesHtml=`
${m.attachments.map(f=>{ const fLabel=typeof f==='string'?f:(f&&(f.name||f.filename||f.path))||''; const fname=String(fLabel).split('/').pop()||String(fLabel); - if(_IMAGE_EXTS.test(fname)){ - // Use api/file/raw which resolves filename relative to the session workspace. - // api/media expects a full absolute path which we don't store on the client side. - const imgUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname); - return `${esc(fname)}`; - } - if(_SVG_EXTS.test(fname)){ - const svgUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname); - return `${esc(fname)}`; - } - if(_AUDIO_EXTS.test(fname)){ - const audioUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname); - return `
🎵 ${esc(fname)}
`; - } - if(_VIDEO_EXTS.test(fname)){ - const videoUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname); - return `
🎬 ${esc(fname)}
`; - } - return `
${li('paperclip',12)} ${esc(fname)}
`; + // Use api/file/raw which resolves filename relative to the session workspace. + const fileUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname); + return _renderAttachmentHtml(fname,fileUrl); }).join('')}
`; } const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(_stripXmlToolCallsDisplay(String(content))); @@ -3037,6 +3162,8 @@ function renderMessages(){ if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){ loadTodos(); } + // Apply persisted playback speed after media nodes are rendered. + if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(inner); // Populate session cache so switching back here skips a full rebuild. _sessionHtmlCacheSid=sid; if(sid){ @@ -4374,32 +4501,27 @@ async function promptNewFolder(){ }catch(e){setStatus(t('folder_create_failed')+e.message);} } -function renderTray(){ +function renderTray(){ // non-media files use paperclip chip const tray=$('attachTray');tray.innerHTML=''; if(!S.pendingFiles.length){tray.classList.remove('has-files');updateSendBtn();return;} tray.classList.add('has-files'); updateSendBtn(); S.pendingFiles.forEach((f,i)=>{ const chip=document.createElement('div');chip.className='attach-chip'; - // Image files get a thumbnail preview; other files keep the paperclip chip - if(_IMAGE_EXTS.test(f.name)){ + const mediaKind=_mediaKindForName(f.name); + if(_IMAGE_EXTS.test(f.name)||mediaKind==='audio'||mediaKind==='video'){ const blobUrl=URL.createObjectURL(f); - chip.className='attach-chip attach-chip--image'; + chip.className='attach-chip attach-chip--media attach-chip--'+mediaKind; // attach-chip--audio attach-chip--video chip.dataset.blobUrl=blobUrl; - chip.innerHTML=`${esc(f.name)}`; - } else if(_SVG_EXTS.test(f.name)){ - const blobUrl=URL.createObjectURL(f); - chip.className='attach-chip attach-chip--image'; - chip.dataset.blobUrl=blobUrl; - chip.innerHTML=`${esc(f.name)}`; - } else if(_AUDIO_EXTS.test(f.name)){ - const blobUrl=URL.createObjectURL(f); - chip.className='attach-chip attach-chip--audio'; - chip.innerHTML=`🎵 ${esc(f.name)}`; - } else if(_VIDEO_EXTS.test(f.name)){ - const blobUrl=URL.createObjectURL(f); - chip.className='attach-chip attach-chip--video'; - chip.innerHTML=`🎬 ${esc(f.name)}`; + if(mediaKind==='image'){ + chip.innerHTML=`${esc(f.name)}`; + } else if(_SVG_EXTS.test(f.name)){ + chip.innerHTML=`${esc(f.name)}`; + } else if(mediaKind==='audio'){ + chip.innerHTML=`🎵 ${esc(f.name)}`; + } else if(mediaKind==='video'){ + chip.innerHTML=`🎬 ${esc(f.name)}`; + } } else { chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} `; } diff --git a/static/workspace.js b/static/workspace.js index 491edec8..87c71220 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -120,11 +120,13 @@ function navigateUp(){ const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']); const MD_EXTS = new Set(['.md','.markdown','.mdown']); const HTML_EXTS = new Set(['.html','.htm']); +const PDF_EXTS = new Set(['.pdf']); +const AUDIO_EXTS = new Set(['.mp3','.wav','.m4a','.aac','.ogg','.oga','.opus','.flac']); +const VIDEO_EXTS = new Set(['.mp4','.mov','.m4v','.webm','.ogv','.avi','.mkv']); // Binary formats that should download rather than preview const DOWNLOAD_EXTS = new Set([ '.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp', - '.pdf','.zip','.tar','.gz','.bz2','.7z','.rar', - '.mp3','.mp4','.wav','.m4a','.ogg','.flac','.mov','.avi','.mkv','.webm', + '.zip','.tar','.gz','.bz2','.7z','.rar', '.exe','.dmg','.pkg','.deb','.rpm', '.woff','.woff2','.ttf','.otf','.eot', '.bin','.dat','.db','.sqlite','.pyc','.class','.so','.dylib','.dll', @@ -133,25 +135,27 @@ const DOWNLOAD_EXTS = new Set([ function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; } let _previewCurrentPath = ''; // relative path of currently previewed file -let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html' +let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video' let _previewDirty = false; // true when edits are unsaved function showPreview(mode){ - // mode: 'code' | 'image' | 'md' | 'html' + // mode: 'code' | 'image' | 'md' | 'html' | 'pdf' | 'audio' | 'video' $('previewCode').style.display = mode==='code' ? '' : 'none'; $('previewImgWrap').style.display = mode==='image' ? '' : 'none'; + const mediaWrap=$('previewMediaWrap'); if(mediaWrap) mediaWrap.style.display = (mode==='audio'||mode==='video') ? '' : 'none'; + const pdfWrap=$('previewPdfWrap'); if(pdfWrap) pdfWrap.style.display = mode==='pdf' ? '' : 'none'; $('previewMd').style.display = mode==='md' ? '' : 'none'; $('previewHtmlWrap').style.display = mode==='html' ? '' : 'none'; $('previewEditArea').style.display = 'none'; // start in read-only const badge=$('previewBadge'); badge.className='preview-badge '+mode; - badge.textContent = mode==='image'?'image':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text'; + badge.textContent = mode==='image'?'image':mode==='audio'?'audio':mode==='video'?'video':mode==='pdf'?'pdf':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text'; _previewCurrentMode = mode; _previewDirty = false; updateEditBtn(); - // Show "Open in browser" button only for HTML mode + // Show "Open in browser" button for iframe-backed document previews const openBtn=$('btnOpenInBrowser'); - if(openBtn) openBtn.style.display = mode==='html'?'inline-flex':'none'; + if(openBtn) openBtn.style.display = (mode==='html'||mode==='pdf')?'inline-flex':'none'; } function updateEditBtn(){ @@ -237,6 +241,26 @@ async function openFile(path){ $('previewImg').alt=path; $('previewImg').src=url; $('previewImg').onerror=()=>setStatus(t('image_load_failed')); + } else if(AUDIO_EXTS.has(ext)||VIDEO_EXTS.has(ext)){ + const mode=VIDEO_EXTS.has(ext)?'video':'audio'; + showPreview(mode); + const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`; + const wrap=$('previewMediaWrap'); + if(wrap){ + wrap.innerHTML=(typeof _mediaPlayerHtml==='function') + ? _mediaPlayerHtml(mode,url,path.split('/').pop()||path) + : `<${mode} src="${url.replace(/"/g,'%22')}" controls preload="metadata">`; + if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(wrap); + } + } else if(PDF_EXTS.has(ext)){ + showPreview('pdf'); + const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`; + const frame=$('previewPdfFrame'); + if(frame){ + frame.src=''; // clear first to avoid stale content + frame.src=url; + frame.title=`PDF preview: ${path.split('/').pop()||path}`; + } } else if(MD_EXTS.has(ext)){ // Markdown: fetch text, render with renderMd, display as formatted HTML try{ diff --git a/tests/test_1003_appearance_autosave.py b/tests/test_1003_appearance_autosave.py new file mode 100644 index 00000000..aa7329ea --- /dev/null +++ b/tests/test_1003_appearance_autosave.py @@ -0,0 +1,138 @@ +"""Regression checks for Issue #1003: Appearance settings autosave. + +Focus: +- Theme/Skin/Font size should autosave immediately and only show inline status. +- Appearance changes should not participate in the global unsaved-changes guard. +- Full-save flow should still include font_size. +- /api/settings should accept appearance-only payloads and preserve untouched fields. +""" +import json +import re +import urllib.error +import urllib.request +from pathlib import Path + +from tests._pytest_port import BASE + + +BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8") +PANELS_JS = (Path(__file__).parent.parent / "static" / "panels.js").read_text(encoding="utf-8") +INDEX_HTML = (Path(__file__).parent.parent / "static" / "index.html").read_text(encoding="utf-8") +I18N_JS = (Path(__file__).parent.parent / "static" / "i18n.js").read_text(encoding="utf-8") + + +def _function_block(src: str, name: str) -> str: + marker = re.search(rf"(^|\n)(?:async\s+)?function\s+{re.escape(name)}\(", src) + assert marker is not None, f"{name}() not found" + start = marker.start() + next_marker = re.search(r"\n(?:function\s+\w+\(|async\s+function\s+\w+\()", src[start + 1 :]) + if next_marker: + end = start + 1 + next_marker.start() + else: + end = len(src) + return src[start:end] + + +def _post(path, body): + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + BASE + path, + data=data, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + payload = e.read() + return json.loads(payload or b"{}"), e.code + + +def _get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def test_appearance_pickers_schedule_autosave_and_do_not_mark_dirty(): + for fn in ("_pickTheme", "_pickSkin", "_pickFontSize"): + block = _function_block(BOOT_JS, fn) + assert "_scheduleAppearanceAutosave()" in block, ( + f"{fn}() should invoke _scheduleAppearanceAutosave()" + ) + assert "_markSettingsDirty()" not in block, ( + f"{fn}() should not call _markSettingsDirty()" + ) + + +def test_appearance_revert_preview_no_longer_rolls_back_theme_skin_font(): + block = _function_block(PANELS_JS, "_revertSettingsPreview") + assert "hermes-theme" not in block + assert "hermes-skin" not in block + assert "hermes-font-size" not in block + assert "_markSettingsDirty()" not in block + + +def test_appearance_autosave_payload_is_theme_skin_font_only(): + block = _function_block(PANELS_JS, "_appearancePayloadFromUi") + assert "theme:" in block + assert "skin:" in block + assert "font_size:" in block + assert "language:" not in block + assert "workspace" not in block + assert "show_token_usage" not in block + status_block = _function_block(PANELS_JS, "_setAppearanceAutosaveStatus") + assert "settings_autosave_saving" in status_block + assert "settings_autosave_saved" in status_block + assert "settings_autosave_failed" in status_block + assert "settings_autosave_retry" in status_block + + +def test_appearance_autosave_status_line_and_i18n_keys_exist(): + assert 'id="settingsAppearanceAutosaveStatus"' in INDEX_HTML + required_keys = [ + "settings_autosave_saving", + "settings_autosave_saved", + "settings_autosave_failed", + "settings_autosave_retry", + ] + for key in required_keys: + assert I18N_JS.count(f"{key}:") >= 8, ( + f"{key} must be defined in all LOCALES blocks (found {I18N_JS.count(f'{key}:')})" + ) + + +def test_full_save_settings_still_includes_font_size(): + block = _function_block(PANELS_JS, "saveSettings") + compact = block.replace(" ", "") + assert "body.theme=theme;" in compact + assert "body.skin=skin;" in compact + assert "body.font_size=fontSize;" in compact + + +def test_settings_api_accepts_appearance_only_payload_without_overwriting_other_fields(): + original, status = _get("/api/settings") + assert status == 200 + snapshot = { + "theme": original.get("theme"), + "skin": original.get("skin"), + "font_size": original.get("font_size", "default"), + "show_token_usage": original.get("show_token_usage"), + "show_cli_sessions": original.get("show_cli_sessions"), + "check_for_updates": original.get("check_for_updates"), + } + try: + d, status = _post("/api/settings", {"theme": "system", "skin": "charizard", "font_size": "large"}) + assert status == 200 + assert d.get("theme") == "system" + assert d.get("skin") == "charizard" + assert d.get("font_size") == "large" + reloaded, _ = _get("/api/settings") + assert reloaded.get("show_token_usage") == snapshot["show_token_usage"] + assert reloaded.get("show_cli_sessions") == snapshot["show_cli_sessions"] + assert reloaded.get("check_for_updates") == snapshot["check_for_updates"] + finally: + _post("/api/settings", { + "theme": snapshot["theme"], + "skin": snapshot["skin"], + "font_size": snapshot["font_size"], + }) diff --git a/tests/test_batch_fixes.py b/tests/test_batch_fixes.py index dd86e6ca..b7844448 100644 --- a/tests/test_batch_fixes.py +++ b/tests/test_batch_fixes.py @@ -194,10 +194,16 @@ class TestSystemTheme: def test_panels_reverts_via_apply_theme(self): src = read("static/panels.js") - assert "_applyTheme(_settingsThemeOnOpen)" in src or \ - "_applyTheme(" in src, ( - "_revertSettingsPreview must call _applyTheme() so 'system' " - "is correctly re-activated on settings discard" + block = re.search(r"function _revertSettingsPreview\(\)\{.*?\n\}", src, re.DOTALL) + assert block, "_revertSettingsPreview() should be present" + assert "_applyTheme(" not in block.group(0), ( + "_revertSettingsPreview must no longer call _applyTheme() since Appearance now autosaves" + ) + + def test_system_theme_apply_path_uses_apply_theme(self): + src = read("static/boot.js") + assert "_applyTheme(appearance.theme)" in src, ( + "System theme still must be activated through _applyTheme() in boot/theme application" ) def test_panels_saves_system_string_not_resolved(self): diff --git a/tests/test_byte_range_parser.py b/tests/test_byte_range_parser.py new file mode 100644 index 00000000..4763862d --- /dev/null +++ b/tests/test_byte_range_parser.py @@ -0,0 +1,87 @@ +"""Edge-case unit tests for _parse_range_header (PR #1290). + +The byte-range parser is security-relevant — malformed Range headers from +clients can cause off-by-one bugs, integer overflows, or info disclosure +if not handled correctly per RFC 7233. The PR adds higher-level tests +for media inline streaming, but the range parser itself has no direct +unit tests. This file pins the parser's contract. +""" +import pytest + +from api.routes import _parse_range_header + + +# Each tuple: (header, file_size, expected_result) +# expected_result is None for invalid/unsatisfiable, or (start, end) inclusive. +RANGE_CASES = [ + # ── Valid ranges ───────────────────────────────────────────────────── + ("bytes=0-99", 1000, (0, 99), "explicit start-end"), + ("bytes=0-", 1000, (0, 999), "open-ended start"), + ("bytes=100-", 1000, (100, 999), "open-ended from middle"), + ("bytes=-500", 1000, (500, 999), "suffix range — last 500 bytes"), + ("bytes=-99999", 1000, (0, 999), "suffix > file_size clamps to start=0"), + ("bytes=0-99999", 1000, (0, 999), "end > file_size clamps to file_size-1"), + ("bytes=999-1500", 1000, (999, 999), "end past file clamps to last byte"), + ("bytes=100-100", 1000, (100, 100), "single-byte range"), + ("bytes=999-999", 1000, (999, 999), "last byte"), + ("bytes= 0-99", 1000, (0, 99), "whitespace inside trimmed"), + # ── Invalid → None (caller sends 416 Range Not Satisfiable) ───────── + ("", 1000, None, "empty header"), + ("bytes 0-100", 1000, None, "wrong format — space instead of ="), + ("bytes=", 1000, None, "no spec after ="), + ("bytes=-", 1000, None, "bare dash, no numbers"), + ("bytes=-0", 1000, None, "zero-length suffix"), + ("bytes=0-99,200-299", 1000, None, "multipart not supported"), + ("bytes=500-100", 1000, None, "reversed range"), + ("bytes=999999-", 1000, None, "start past file"), + ("bytes=abc-def", 1000, None, "non-numeric"), + ("bytes=-abc", 1000, None, "non-numeric suffix"), + (" bytes=0-99", 1000, None, "leading space — must startswith bytes="), + # ── Empty/zero file ───────────────────────────────────────────────── + ("bytes=0-99", 0, None, "empty file always None"), + # ── Negative numbers (should not yield negative offsets) ──────────── + ("bytes=-1", 1000, (999, 999), "suffix=1 — last byte"), +] + + +@pytest.mark.parametrize( + "header,file_size,expected,description", + RANGE_CASES, + ids=[c[3] for c in RANGE_CASES], +) +def test_parse_range_header(header, file_size, expected, description): + actual = _parse_range_header(header, file_size) + assert actual == expected, ( + f"_parse_range_header({header!r}, {file_size}) = {actual!r}, " + f"expected {expected!r} — {description}" + ) + + +# ── Invariants beyond the case table ──────────────────────────────────── + + +def test_returned_offsets_are_non_negative_for_valid_inputs(): + for header, file_size, expected, _ in RANGE_CASES: + if expected is None: + continue + start, end = _parse_range_header(header, file_size) + assert start >= 0, f"start must be non-negative: {start} from {header!r}" + assert end >= start, f"end must be >= start: ({start},{end}) from {header!r}" + assert end < file_size, ( + f"end must be < file_size for valid range: {end} >= {file_size} " + f"from {header!r}" + ) + + +def test_content_length_is_positive_for_valid_ranges(): + """The (start, end) returned must always describe at least one byte — + otherwise _serve_file_bytes would compute Content-Length=0 incorrectly.""" + for header, file_size, expected, _ in RANGE_CASES: + if expected is None: + continue + start, end = _parse_range_header(header, file_size) + content_length = end - start + 1 + assert content_length >= 1, ( + f"Valid range must have positive content-length, got {content_length} " + f"from {header!r} on size {file_size}" + ) diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index 9ae8dd59..c149752f 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -645,7 +645,7 @@ def test_gateway_sessions_excluded_when_disabled(): def test_gateway_session_has_correct_metadata(): - """Gateway sessions include source_tag and is_cli_session fields.""" + """Gateway sessions include legacy source fields and normalized source metadata.""" conn = _ensure_state_db() try: _insert_gateway_session(conn, session_id='gw_meta_001', source='telegram', title='Meta Test') @@ -658,6 +658,9 @@ def test_gateway_session_has_correct_metadata(): gw = next((s for s in sessions if s['session_id'] == 'gw_meta_001'), None) assert gw is not None, "Gateway session not found" assert gw.get('source_tag') == 'telegram', f"Expected source_tag=telegram, got {gw.get('source_tag')}" + assert gw.get('raw_source') == 'telegram' + assert gw.get('session_source') == 'messaging' + assert gw.get('source_label') == 'Telegram' assert gw.get('is_cli_session') is True, "is_cli_session should be True for agent sessions" assert gw.get('title') == 'Meta Test' finally: @@ -669,6 +672,58 @@ def test_gateway_session_has_correct_metadata(): post('/api/settings', {'show_cli_sessions': False}) +def test_agent_session_source_normalization_contract(): + """Raw Hermes Agent sources map to stable WebUI source categories.""" + from api.agent_sessions import normalize_agent_session_source + + cases = { + 'cli': ('cli', 'CLI'), + 'weixin': ('messaging', 'Weixin'), + 'telegram': ('messaging', 'Telegram'), + 'discord': ('messaging', 'Discord'), + 'slack': ('messaging', 'Slack'), + 'cron': ('cron', 'Cron'), + 'tool': ('tool', 'Tool'), + 'api_server': ('api', 'API'), + 'something_new': ('other', 'Something New'), + None: ('other', 'Agent'), + } + + for raw_source, (session_source, source_label) in cases.items(): + normalized = normalize_agent_session_source(raw_source) + assert normalized['session_source'] == session_source + assert normalized['source_label'] == source_label + if raw_source: + assert normalized['raw_source'] == raw_source + else: + assert normalized['raw_source'] is None + + +def test_gateway_watcher_uses_normalized_source_metadata(monkeypatch): + """SSE snapshots use the same normalized source contract as /api/sessions.""" + conn = _ensure_state_db() + try: + _insert_gateway_session(conn, session_id='gw_watcher_source_001', source='weixin', title='Weixin Chat') + + import api.gateway_watcher as gateway_watcher + + monkeypatch.setattr(gateway_watcher, '_get_state_db_path', _get_state_db_path) + sessions = gateway_watcher._get_agent_sessions_from_db() + gw = next((s for s in sessions if s['session_id'] == 'gw_watcher_source_001'), None) + + assert gw is not None + assert gw.get('source') == 'weixin' + assert gw.get('raw_source') == 'weixin' + assert gw.get('session_source') == 'messaging' + assert gw.get('source_label') == 'Weixin' + finally: + try: + _remove_test_sessions(conn, 'gw_watcher_source_001') + conn.close() + except Exception: + pass + + def test_imported_cron_sessions_hidden_from_sidebar_by_default(cleanup_test_sessions): """Cron sessions already imported into the WebUI store should stay hidden from the sidebar.""" from api.models import Session diff --git a/tests/test_issue1139_password_remote.py b/tests/test_issue1139_password_remote.py index bb2eed2a..07abb64a 100644 --- a/tests/test_issue1139_password_remote.py +++ b/tests/test_issue1139_password_remote.py @@ -69,8 +69,8 @@ def test_all_settings_panes_have_save_button(): ) for pane_id, pane_html in panes: - # Providers pane doesn't need a Save button (has per-provider save) - if pane_id == 'settingsPaneProviders': + # Providers pane has per-provider controls; Appearance pane is autosave-only. + if pane_id in ('settingsPaneProviders', 'settingsPaneAppearance'): continue # Check if pane has any input fields (not just buttons) has_inputs = bool(re.search(r'") assert svg_url_match > 0, "SVG URL handler not found" assert image_catch_all > 0, "Image catch-all handler not found" @@ -64,10 +65,11 @@ def test_url_svg_audio_video_handlers(): """Verify HTTPS URLs for SVG/audio/video get inline rendering.""" with open('static/ui.js') as f: src = f.read() - # These should appear in the URL section (src.split('?')[0] pattern) - url_svg = "src.split('?')[0])" in src and "_SVG_EXTS.test" in src - url_audio = src.count("_AUDIO_EXTS.test(src.split") - url_video = src.count("_VIDEO_EXTS.test(src.split") + # SVG URLs should be handled via _SVG_EXTS test on urlPath + url_svg = "_SVG_EXTS.test(urlPath)" in src or ("_SVG_EXTS.test" in src and "urlPath" in src) + # Audio/video via mediaKindForName or explicit _AUDIO/_VIDEO tests + url_audio = src.count("_AUDIO_EXTS.test(src.split") + src.count("_AUDIO_EXTS.test(urlPath") + src.count("mediaKindForName") + url_video = src.count("_VIDEO_EXTS.test(src.split") + src.count("_VIDEO_EXTS.test(urlPath") + src.count("mediaKindForName") assert url_svg, "URL SVG handler should test extension on src" assert url_audio >= 1, "URL audio handler should test extension on src" assert url_video >= 1, "URL video handler should test extension on src"