release: v0.50.241 (#1293)

release: v0.50.241

Batch release of 4 PRs:

- #1290 (@nickgiulioni1) — Inline audio/video media editor with playback
  speed controls and HTTP byte-range streaming. PDF/media previews in
  workspace file browser. Composer tray inline players for audio/video.
  (Rebased from #1232.)

- #1287 (@renatomott) — Configured model badges (Primary / Fallback N) in
  the model picker, carried through to the composer chip. Persists through
  on-disk model cache.

- #1289 (@franksong2702) — Appearance autosave for theme/skin/font-size in
  Settings; inline Saving / Saved / Failed status. Font size now persists
  to config.yaml. Refs #1003.

- #1294 (@franksong2702) — Normalize agent session source metadata
  (raw_source / session_source / source_label) through /api/sessions and
  gateway watcher SSE snapshots. Existing source_tag / is_cli_session
  fields preserved. Refs #1013.

Tests: 3254 passed, 2 skipped, 3 xpassed (was 3199 before this release).

Independently reviewed and approved by nesquena (commit d1738f6).
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-04-29 19:54:07 -07:00
1 parent 33a145a669
commit 0ad95cb16a
23 files changed
+1284 -164

No files matched your search

+10
View File
@@ -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
+53
View File
@@ -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:
+61
View File
@@ -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))]
+101 -1
View File
@@ -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"},
}
+3
View File
@@ -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:
+3
View File
@@ -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:
+96 -50
View File
@@ -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):
+10 -5
View File
@@ -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'))
+32
View File
@@ -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: '사이드바 밀도',
+6 -1
View File
@@ -401,6 +401,7 @@
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
<span class="composer-model-label" id="composerModelLabel"></span>
<span class="composer-model-badge" id="composerModelBadge" hidden></span>
<span class="composer-model-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<select id="modelSelect" class="composer-model-select" title="Conversation model" aria-hidden="true" tabindex="-1">
@@ -657,7 +658,7 @@
</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>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
<div id="settingsAppearanceAutosaveStatus" class="settings-autosave-status" aria-live="polite"></div>
</div>
<div class="settings-pane" id="settingsPanePreferences">
<div class="settings-section-head">
@@ -871,6 +872,10 @@
</div>
<pre class="preview-code" id="previewCode"></pre>
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
<div class="preview-media-wrap" id="previewMediaWrap" style="display:none"></div>
<div class="preview-pdf-wrap" id="previewPdfWrap" style="display:none">
<iframe class="preview-pdf-frame" id="previewPdfFrame" src="" title="PDF preview"></iframe>
</div>
<div class="preview-md" id="previewMd" style="display:none"></div>
<div class="preview-html-wrap" id="previewHtmlWrap" style="display:none;flex:1;border-radius:8px;overflow:hidden;border:1px solid var(--border2)">
<iframe id="previewHtmlIframe" style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-scripts" title="HTML preview"></iframe>
+80 -13
View File
@@ -119,6 +119,11 @@ function _beginSettingsPanelSession() {
_settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default';
_settingsFontSizeOnOpen = localStorage.getItem('hermes-font-size') || 'default';
_pendingSettingsTargetPanel = null;
if (_settingsAppearanceAutosaveTimer) {
clearTimeout(_settingsAppearanceAutosaveTimer);
_settingsAppearanceAutosaveTimer = null;
}
_settingsAppearanceAutosaveRetryPayload = null;
_resetSettingsPanelState();
}
@@ -2485,6 +2490,8 @@ let _settingsFontSizeOnOpen = null; // track font size at open time for discard
let _settingsHermesDefaultModelOnOpen = '';
let _settingsSection = 'conversation';
let _currentSettingsSection = 'conversation';
let _settingsAppearanceAutosaveTimer = null;
let _settingsAppearanceAutosaveRetryPayload = null;
function switchSettingsSection(name){
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation';
@@ -2541,6 +2548,7 @@ function toggleSettings(){
function _resetSettingsPanelState(){
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
_setAppearanceAutosaveStatus('');
}
function _hideSettingsPanel(){
@@ -2562,18 +2570,9 @@ function _closeSettingsPanel(){
// Revert live DOM/localStorage to what they were when the panel opened
function _revertSettingsPreview(){
if(_settingsThemeOnOpen){
localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
if(typeof _applyTheme==='function') _applyTheme(_settingsThemeOnOpen);
}
if(_settingsSkinOnOpen){
localStorage.setItem('hermes-skin', _settingsSkinOnOpen);
if(typeof _applySkin==='function') _applySkin(_settingsSkinOnOpen);
}
if(_settingsFontSizeOnOpen){
localStorage.setItem('hermes-font-size', _settingsFontSizeOnOpen);
if(typeof _applyFontSize==='function') _applyFontSize(_settingsFontSizeOnOpen);
}
// Appearance controls autosave immediately. Closing/discarding the settings
// panel must not roll back theme, skin, or font-size after the user sees the
// inline saved state.
}
// Show the "Unsaved changes" bar inside the settings panel
@@ -2604,6 +2603,71 @@ function _markSettingsDirty(){
_settingsDirty = true;
}
function _appearancePayloadFromUi(){
return {
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',
};
}
function _setAppearanceAutosaveStatus(state){
const el=$('settingsAppearanceAutosaveStatus');
if(!el) return;
el.className='settings-autosave-status';
if(!state){
el.textContent='';
return;
}
el.classList.add('is-'+state);
if(state==='saving'){
el.textContent=t('settings_autosave_saving');
}else if(state==='saved'){
el.textContent=t('settings_autosave_saved');
}else if(state==='failed'){
el.innerHTML=`<span>${esc(t('settings_autosave_failed'))}</span> <button type="button" onclick="_retryAppearanceAutosave()">${esc(t('settings_autosave_retry'))}</button>`;
}
}
function _rememberAppearanceSaved(payload){
if(!payload) return;
_settingsThemeOnOpen=payload.theme||localStorage.getItem('hermes-theme')||'dark';
_settingsSkinOnOpen=payload.skin||localStorage.getItem('hermes-skin')||'default';
_settingsFontSizeOnOpen=payload.font_size||localStorage.getItem('hermes-font-size')||'default';
}
function _scheduleAppearanceAutosave(){
const payload=_appearancePayloadFromUi();
// Keep discard/close behavior aligned with the new mental model: appearance
// changes are committed immediately instead of treated as preview-only edits.
_rememberAppearanceSaved(payload);
_settingsAppearanceAutosaveRetryPayload=payload;
_setAppearanceAutosaveStatus('saving');
if(_settingsAppearanceAutosaveTimer) clearTimeout(_settingsAppearanceAutosaveTimer);
_settingsAppearanceAutosaveTimer=setTimeout(()=>_autosaveAppearanceSettings(payload),350);
}
async function _autosaveAppearanceSettings(payload){
try{
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
_settingsAppearanceAutosaveRetryPayload=null;
_rememberAppearanceSaved(payload);
if(saved&&saved.font_size){
localStorage.setItem('hermes-font-size',saved.font_size);
}
_setAppearanceAutosaveStatus('saved');
}catch(e){
console.warn('[settings] appearance autosave failed', e);
_setAppearanceAutosaveStatus('failed');
}
}
function _retryAppearanceAutosave(){
const payload=_settingsAppearanceAutosaveRetryPayload||_appearancePayloadFromUi();
_setAppearanceAutosaveStatus('saving');
_autosaveAppearanceSettings(payload);
}
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
@@ -2621,7 +2685,9 @@ async function loadSettingsPanel(){
const skinSel=$('settingsSkin');
if(skinSel) skinSel.value=skinVal;
if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal);
const fontSizeVal=localStorage.getItem('hermes-font-size')||'default';
const fontSizeVal=settings.font_size||localStorage.getItem('hermes-font-size')||'default';
localStorage.setItem('hermes-font-size',fontSizeVal);
if(typeof _applyFontSize==='function') _applyFontSize(fontSizeVal);
const fontSizeSel=$('settingsFontSize');
if(fontSizeSel) fontSizeSel.value=fontSizeVal;
if(typeof _syncFontSizePicker==='function') _syncFontSizePicker(fontSizeVal);
@@ -3100,6 +3166,7 @@ async function saveSettings(andClose){
if(sendKey) body.send_key=sendKey;
body.theme=theme;
body.skin=skin;
body.font_size=fontSize;
body.language=language;
body.show_token_usage=showTokenUsage;
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
+59 -9
View File
@@ -739,13 +739,18 @@
.msg-media-svg{display:block;max-width:100%;height:auto;border-radius:6px;margin:6px 0;border:1px solid var(--border);background:var(--surface);}
.msg-media-label{display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin-bottom:4px;user-select:none;}
/* ── Inline audio player ── */
.msg-media-audio{margin:6px 0;padding:8px 10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;max-width:480px;}
.msg-media-audio audio{width:100%;height:36px;border-radius:4px;}
/* ── Inline video player ── */
.msg-media-video{margin:6px 0;background:var(--surface);border:1px solid var(--border);border-radius:8px;overflow:hidden;max-width:640px;}
.msg-media-video video{width:100%;display:block;border-radius:8px 8px 0 0;}
/* ── Inline audio/video media editor with speed controls ── */
.msg-media-editor{display:flex;flex-direction:column;gap:8px;width:min(520px,100%);max-width:100%;padding:10px;border:1px solid var(--border);border-radius:10px;background:var(--surface);margin:6px 0;}
.msg-media-editor--audio{min-width:min(360px,100%);}
.msg-media-player{width:100%;display:block;border-radius:8px;}
.msg-media-video.msg-media-player{max-height:320px;object-fit:contain;background:#000;border:1px solid var(--border2);}
.msg-media-audio.msg-media-player{height:36px;background:transparent;}
.msg-media-meta{display:flex;align-items:center;gap:8px;min-width:0;font-size:11px;color:var(--muted);}
.msg-media-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.media-speed-controls{display:flex;flex-wrap:wrap;gap:4px;align-items:center;}
.media-speed-btn{border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);border-radius:999px;padding:3px 8px;font-size:11px;font-weight:600;line-height:1.2;cursor:pointer;transition:background .15s,border-color .15s,color .15s;}
.media-speed-btn:hover{background:var(--hover-bg);color:var(--text);}
.media-speed-btn.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--accent-text);}
/* ── Composer attachment chips for media ── */
.attach-thumb--svg{background:var(--surface);padding:2px;border-radius:4px;}
@@ -794,6 +799,9 @@
.attach-chip button:hover{color:var(--accent);}
/* Image attachment chips show a thumbnail preview instead of a paperclip chip */
.attach-chip--image{background:transparent;border-color:var(--border);padding:3px;border-radius:6px;}
.attach-chip--audio,.attach-chip--video{max-width:260px;}
.attach-media-icon{display:inline-flex;align-items:center;color:var(--accent-text);}
.attach-chip-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.attach-thumb{width:56px;height:56px;object-fit:cover;border-radius:4px;display:block;cursor:default;}
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;}
textarea#msg::placeholder{color:var(--muted);}
@@ -838,10 +846,14 @@
.reasoning-option:hover{background:rgba(255,255,255,.07);}
.reasoning-option.selected{background:var(--accent-bg);}
.composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;}
.composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:220px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:280px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);}
.composer-model-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
.composer-model-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-model-badge{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;padding:2px 7px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;border:1px solid transparent;}
.composer-model-badge[hidden]{display:none;}
.composer-model-badge--primary{background:rgba(50,184,198,.16);border-color:rgba(50,184,198,.32);color:#8fe7ef;}
.composer-model-badge--fallback{background:rgba(255,184,77,.14);border-color:rgba(255,184,77,.28);color:#ffd18a;}
.composer-model-icon,.composer-model-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-model-select{position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;pointer-events:none!important;}
.composer-right{display:flex;gap:8px;align-items:center;flex-shrink:0;}
@@ -975,6 +987,11 @@
/* Image preview */
.preview-img-wrap{display:flex;align-items:center;justify-content:center;flex:1;padding:8px 0;min-height:0;}
.preview-img{max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.4);}
.preview-media-wrap{display:flex;align-items:center;justify-content:center;flex:1;min-height:0;padding:12px 0;}
.preview-media-wrap .msg-media-editor{width:100%;max-width:100%;background:var(--surface);}
.preview-media-wrap .msg-media-video{max-height:60vh;}
.preview-pdf-wrap{display:flex;flex:1;min-height:360px;border:1px solid var(--border2);border-radius:8px;overflow:hidden;background:#fff;}
.preview-pdf-frame{width:100%;height:100%;min-height:70vh;border:none;background:#fff;}
/* Markdown rendered preview */
.preview-md{font-size:13px;line-height:1.7;color:var(--text);flex:1;overflow-y:auto;min-height:0;}
.preview-md p{margin-bottom:10px;}.preview-md p:last-child{margin-bottom:0;}
@@ -1000,7 +1017,8 @@
.preview-md td code,.preview-md th code{font-size:0.85em;padding:1px 4px;vertical-align:baseline;}
/* File type badge in preview path bar */
.preview-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:8px;text-transform:uppercase;letter-spacing:.06em;}
.preview-badge.img{background:var(--accent-bg);color:var(--accent-text);}
.preview-badge.img,.preview-badge.image,.preview-badge.audio,.preview-badge.video{background:var(--accent-bg);color:var(--accent-text);}
.preview-badge.pdf{background:rgba(239,68,68,.12);color:#f87171;}
.preview-badge.md{background:var(--accent-bg-strong);color:var(--accent-text);}
.preview-badge.code{background:var(--hover-bg);color:var(--muted);}
::-webkit-scrollbar{width:4px;height:4px}
@@ -1144,7 +1162,11 @@
.model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;}
.model-opt:hover{background:rgba(255,255,255,.07);}
.model-opt.active{background:var(--accent-bg);}
.model-opt-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%;}
.model-opt-name{display:block;font-size:13px;color:var(--text);font-weight:500;line-height:1.25;}
.model-opt-badge{display:inline-flex;align-items:center;justify-content:center;padding:2px 7px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;border:1px solid transparent;}
.model-opt-badge--primary{background:rgba(50,184,198,.16);border-color:rgba(50,184,198,.32);color:#8fe7ef;}
.model-opt-badge--fallback{background:rgba(255,184,77,.14);border-color:rgba(255,184,77,.28);color:#ffd18a;}
.model-opt-id{display:block;font-size:10px;color:var(--muted);line-height:1.3;opacity:.72;word-break:break-word;}
.model-custom-sep{padding-top:4px;border-top:1px solid var(--border);margin-top:4px;}
.model-custom-row{display:flex;align-items:center;gap:6px;padding:6px 10px 8px;}
@@ -1901,6 +1923,34 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
#mainSettings .settings-btn:hover,
#mainSettings .sm-btn[onclick^="saveSettings"]:hover{background:var(--accent-hover);border-color:var(--accent-hover);}
/* Appearance autosave status line (in-place feedback while changing theme/skin/font size). */
#mainSettings .settings-autosave-status{
margin-top:10px;
min-height:18px;
display:flex;
align-items:center;
gap:6px;
font-size:12px;
color:var(--muted);
}
#mainSettings .settings-autosave-status.is-saving{color:var(--muted);}
#mainSettings .settings-autosave-status.is-saved{color:var(--success);}
#mainSettings .settings-autosave-status.is-failed{color:var(--error);}
#mainSettings .settings-autosave-status button{
border:none;
background:var(--accent);
color:#fff;
font-size:11px;
font-weight:600;
border-radius:6px;
padding:4px 10px;
cursor:pointer;
margin-left:4px;
}
#mainSettings .settings-autosave-status button:hover{
background:var(--accent-hover);
}
/* Secondary sm-btn fallback (Sign out / Disable auth) — neutral outline. */
#mainSettings .sm-btn{border-radius:8px;padding:9px 16px;font-weight:600;font-size:13px;background:var(--surface);border:1px solid var(--border);color:var(--text);cursor:pointer;transition:background .15s,border-color .15s;}
#mainSettings .sm-btn:hover{background:var(--accent-bg);border-color:var(--accent-bg-strong);}
+187 -65
View File
@@ -92,13 +92,102 @@ const _PDF_EXTS=/\.pdf$/i;
const _HTML_EXTS=/\.(html?|htm)$/i;
const _ARCHIVE_EXTS=/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
const _SVG_EXTS=/\.svg$/i;
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i;
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
const _CSV_EXTS=/\.csv$/i;
const _EXCALIDRAW_EXTS=/\.excalidraw$/i;
// ── Media playback speed controls ─────────────────────────────────────────
const MEDIA_PLAYBACK_RATES=[0.5,0.75,1,1.25,1.5,2];
const MEDIA_PLAYBACK_STORAGE_KEY='hermes-media-playback-rate';
function _getStoredMediaPlaybackRate(){
try{
const raw=localStorage.getItem(MEDIA_PLAYBACK_STORAGE_KEY);
const rate=Number(raw);
return MEDIA_PLAYBACK_RATES.includes(rate)?rate:1;
}catch(_){return 1;}
}
function _setStoredMediaPlaybackRate(rate){
if(!MEDIA_PLAYBACK_RATES.includes(rate)) return;
try{localStorage.setItem(MEDIA_PLAYBACK_STORAGE_KEY,String(rate));}catch(_){}
}
function _syncMediaSpeedButtons(editor, rate){
if(!editor) return;
editor.querySelectorAll('.media-speed-btn').forEach(b=>{
const active=Number(b.dataset.rate)===rate;
b.classList.toggle('active',active);
b.setAttribute('aria-pressed',active?'true':'false');
});
}
function _applyMediaPlaybackRate(media, rate=_getStoredMediaPlaybackRate()){
if(!media) return;
media.playbackRate=rate;
_syncMediaSpeedButtons(media.closest('.msg-media-editor,.preview-media-wrap'),rate);
}
function _mediaKindForName(name=''){
const clean=String(name||'').split('?')[0].toLowerCase();
if(_AUDIO_EXTS.test(clean)) return 'audio';
if(_VIDEO_EXTS.test(clean)) return 'video';
if(_IMAGE_EXTS.test(clean)) return 'image';
return '';
}
function _mediaSpeedControlsHtml(kind, label){
const safeLabel=esc(label||kind||'media');
const current=_getStoredMediaPlaybackRate();
return `<div class="media-speed-controls" role="group" aria-label="Playback speed for ${safeLabel}">${MEDIA_PLAYBACK_RATES.map(rate=>`<button type="button" class="media-speed-btn${rate===current?' active':''}" data-rate="${rate}" aria-pressed="${rate===current?'true':'false'}">${rate}×</button>`).join('')}</div>`;
}
function _mediaPlayerHtml(kind, src, name, extra=''){
const safeName=esc(name||'media');
const safeSrc=esc(src);
const tag=kind==='video'
? `<video class="msg-media-player msg-media-video" src="${safeSrc}" controls preload="metadata" playsinline title="${safeName}"></video>`
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span>${extra}</div>${_mediaSpeedControlsHtml(kind,safeName)}</div>`;
}
function _renderAttachmentHtml(fname, url){
const kind=_mediaKindForName(fname);
if(kind==='image') return `<img class="msg-media-img" src="${esc(url)}" alt="${esc(fname)}" loading="lazy">`;
if(kind==='audio'||kind==='video') return _mediaPlayerHtml(kind,url,fname);
return `<div class="msg-file-badge">${li('paperclip',12)} ${esc(fname)}</div>`;
}
document.addEventListener('click', e => {
const btn=e.target&&e.target.closest?e.target.closest('.media-speed-btn'):null;
if(!btn) return;
const editor=btn.closest('.msg-media-editor,.preview-media-wrap');
if(!editor) return;
const media=editor.querySelector('audio,video');
if(!media) return;
const rate=Number(btn.dataset.rate)||1;
_setStoredMediaPlaybackRate(rate);
_applyMediaPlaybackRate(media,rate);
});
document.addEventListener("loadedmetadata", e=>{
if(e.target&&e.target.matches&&e.target.matches('.msg-media-player,audio,video')){
_applyMediaPlaybackRate(e.target);
}
},true);
function _initMediaPlaybackObserver(){
if(!document.body||window._mediaPlaybackObserver) return;
window._mediaPlaybackObserver=new MutationObserver(records=>{
for(const rec of records){
for(const node of rec.addedNodes||[]){
if(!node||node.nodeType!==1) continue;
const media=[];
if(node.matches&&node.matches('audio,video')) media.push(node);
if(node.querySelectorAll) media.push(...node.querySelectorAll('audio,video'));
media.forEach(m=>_applyMediaPlaybackRate(m));
}
}
});
window._mediaPlaybackObserver.observe(document.body,{childList:true,subtree:true});
document.querySelectorAll('audio,video').forEach(m=>_applyMediaPlaybackRate(m));
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',_initMediaPlaybackObserver);
else _initMediaPlaybackObserver();
setTimeout(_initMediaPlaybackObserver,0);
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={};
window._configuredModelBadges=window._configuredModelBadges||{};
// ── Smart model resolver ────────────────────────────────────────────────────
// Finds the best matching option value in a <select> 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=`<span class="model-opt-name">${m.name}</span><span class="model-opt-id">${m.id}</span>`;
const badgeHtml=m.badge?`<span class="model-opt-badge model-opt-badge--${esc(m.badge.role||'configured')}">${esc(m.badge.label||'Configured')}</span>`:'';
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${m.name}</span>${badgeHtml}</div><span class="model-opt-id">${m.id}</span>`;
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'
? `<video class="msg-media-player msg-media-video" src="${safeSrc}" controls preload="metadata" playsinline title="${safeName}"></video>`
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span></div></div>`;
};
// 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 <img> 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 `<img class="msg-media-svg" src="${esc(src)}" alt="${t('media_svg_label')}" loading="lazy">`;
}
// Audio URLs → inline player
if(_AUDIO_EXTS.test(src.split('?')[0])){
return `<div class="msg-media-audio"><span class="msg-media-label">🎵 ${t('media_audio_label')}</span><audio controls preload="metadata" src="${esc(src)}"></audio></div>`;
}
// Video URLs → inline player
if(_VIDEO_EXTS.test(src.split('?')[0])){
return `<div class="msg-media-video"><span class="msg-media-label">🎬 ${t('media_video_label')}</span><video controls preload="metadata" src="${esc(src)}"></video></div>`;
}
// MEDIA: tokens are only emitted for tool-generated images (image_generate etc.).
// Render all https:// URLs as <img> — 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 <img> — extensionless CDN paths like fal.media still work (#853)
if(_IMAGE_EXTS.test(urlPath) || /^https?:\/\//i.test(src)){
return `<img class="msg-media-img" src="${esc(src)}" alt="image" loading="lazy">`;
}
return `<a href="${esc(src)}" target="_blank" rel="noopener">${esc(src)}</a>`;
}
// Local file path
const apiUrl='api/media?path='+encodeURIComponent(ref);
if(_IMAGE_EXTS.test(ref)){
const localKind=mediaKindForName(ref);
if(localKind==='image'){
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy">`;
}
// SVG → inline image (no download, render directly)
if(_SVG_EXTS.test(ref)){
return `<img class="msg-media-svg" src="${esc(apiUrl)}" alt="${t('media_svg_label')}" loading="lazy">`;
}
// Audio → inline player
if(_AUDIO_EXTS.test(ref)){
return `<div class="msg-media-audio"><span class="msg-media-label">🎵 ${t('media_audio_label')}</span><audio controls preload="metadata" src="${esc(apiUrl)}"></audio></div>`;
}
// Video → inline player
if(_VIDEO_EXTS.test(ref)){
return `<div class="msg-media-video"><span class="msg-media-label">🎬 ${t('media_video_label')}</span><video controls preload="metadata" src="${esc(apiUrl)}"></video></div>`;
// 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 `<a class="msg-media-link" href="${esc(apiUrl+'&download=1')}" download="${fname}">📎 ${fname}</a>`;
});
// ── 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=`<div class="msg-files">${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 `<img class="msg-media-img" src="${esc(imgUrl)}" alt="${esc(fname)}" loading="lazy">`;
}
if(_SVG_EXTS.test(fname)){
const svgUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
return `<img class="msg-media-svg" src="${esc(svgUrl)}" alt="${esc(fname)}" loading="lazy">`;
}
if(_AUDIO_EXTS.test(fname)){
const audioUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
return `<div class="msg-media-audio"><span class="msg-media-label">🎵 ${esc(fname)}</span><audio controls preload="metadata" src="${esc(audioUrl)}"></audio></div>`;
}
if(_VIDEO_EXTS.test(fname)){
const videoUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
return `<div class="msg-media-video"><span class="msg-media-label">🎬 ${esc(fname)}</span><video controls preload="metadata" src="${esc(videoUrl)}"></video></div>`;
}
return `<div class="msg-file-badge">${li('paperclip',12)} ${esc(fname)}</div>`;
// 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('')}</div>`;
}
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : 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=`<img class="attach-thumb" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
} 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=`<img class="attach-thumb attach-thumb--svg" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
} else if(_AUDIO_EXTS.test(f.name)){
const blobUrl=URL.createObjectURL(f);
chip.className='attach-chip attach-chip--audio';
chip.innerHTML=`<span class="attach-chip-media">🎵 ${esc(f.name)}</span><audio controls preload="metadata" src="${esc(blobUrl)}"></audio><button title="${t('remove_title')}">${li('x',12)}</button>`;
} else if(_VIDEO_EXTS.test(f.name)){
const blobUrl=URL.createObjectURL(f);
chip.className='attach-chip attach-chip--video';
chip.innerHTML=`<span class="attach-chip-media">🎬 ${esc(f.name)}</span><video controls preload="metadata" src="${esc(blobUrl)}"></video><button title="${t('remove_title')}">${li('x',12)}</button>`;
if(mediaKind==='image'){
chip.innerHTML=`<img class="attach-thumb" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
} else if(_SVG_EXTS.test(f.name)){
chip.innerHTML=`<img class="attach-thumb attach-thumb--svg" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
} else if(mediaKind==='audio'){
chip.innerHTML=`<span class="attach-chip-media">🎵 ${esc(f.name)}</span><audio controls preload="metadata" src="${esc(blobUrl)}"></audio><button title="${t('remove_title')}">${li('x',12)}</button>`;
} else if(mediaKind==='video'){
chip.innerHTML=`<span class="attach-chip-media">🎬 ${esc(f.name)}</span><video controls preload="metadata" src="${esc(blobUrl)}"></video><button title="${t('remove_title')}">${li('x',12)}</button>`;
}
} else {
chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} <button title="${t('remove_title')}">${li('x',12)}</button>`;
}
+31 -7
View File
@@ -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"></${mode}>`;
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{
+138
View File
@@ -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"],
})
+10 -4
View File
@@ -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):
+87
View File
@@ -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}"
)
+56 -1
View File
@@ -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
+2 -2
View File
@@ -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'<input|<select|<textarea', pane_html))
+126 -2
View File
@@ -23,6 +23,7 @@ from tests._pytest_port import BASE, TEST_STATE_DIR
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
WORKSPACE_JS = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
# ── Static analysis: renderMd MEDIA stash ────────────────────────────────────
@@ -50,6 +51,10 @@ class TestMediaRenderMdStash(unittest.TestCase):
self.assertIn("api/media?path=", UI_JS,
"renderMd must build api/media?path=... URL for local files")
def test_local_audio_video_media_tokens_request_inline_streaming(self):
self.assertIn("apiUrl+'&inline=1'", UI_JS,
"MEDIA: audio/video local paths must request inline streaming")
def test_media_stash_uses_null_byte_token(self):
self.assertIn("\\x00D", UI_JS,
"MEDIA stash must use null-byte token (\\x00D) to avoid conflicts")
@@ -108,6 +113,91 @@ class TestMediaCSS(unittest.TestCase):
"Download link style must be defined for non-image media")
class TestInlineAudioVideoEditor(unittest.TestCase):
"""Static checks for inline audio/video preview controls in chat and workspace."""
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
WORKSPACE_JS = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
def test_audio_and_video_extension_detection_exists(self):
self.assertIn("_AUDIO_EXTS", UI_JS)
self.assertIn("_VIDEO_EXTS", UI_JS)
for ext in ["mp3", "wav", "m4a", "mp4", "mov", "webm"]:
self.assertIn(ext, UI_JS)
def test_media_player_markup_has_native_controls(self):
self.assertIn("_mediaPlayerHtml", UI_JS)
self.assertIn("<audio", UI_JS)
self.assertIn("<video", UI_JS)
self.assertIn("controls", UI_JS)
self.assertIn("playsinline", UI_JS)
def test_variable_speed_buttons_and_playback_rate_handler_exist(self):
self.assertIn("MEDIA_PLAYBACK_RATES", UI_JS)
for rate in ["0.5", "0.75", "1.25", "1.5", "2"]:
self.assertIn(rate, UI_JS)
self.assertIn("playbackRate", UI_JS)
self.assertIn("media-speed-btn", UI_JS)
self.assertIn("aria-pressed", UI_JS)
def test_playback_speed_preference_persists_in_localstorage(self):
self.assertIn("MEDIA_PLAYBACK_STORAGE_KEY", UI_JS)
self.assertIn("localStorage.getItem(MEDIA_PLAYBACK_STORAGE_KEY)", UI_JS)
self.assertIn("localStorage.setItem(MEDIA_PLAYBACK_STORAGE_KEY", UI_JS)
self.assertIn("_applyMediaPlaybackRate", UI_JS)
self.assertIn('addEventListener("loadedmetadata"', UI_JS)
self.assertIn("MutationObserver", UI_JS)
self.assertIn("setTimeout(_initMediaPlaybackObserver,0)", UI_JS)
self.assertIn("_applyMediaPlaybackPreferences(inner)", UI_JS)
self.assertIn("_applyMediaPlaybackPreferences(wrap)", WORKSPACE_JS)
def test_message_attachments_render_audio_video_instead_of_badges(self):
self.assertIn("_renderAttachmentHtml", UI_JS)
self.assertIn("data-media-kind", UI_JS)
self.assertIn("api/file/raw?session_id=", UI_JS)
def test_composer_tray_recognizes_audio_video_files(self):
self.assertIn("attach-chip--media", UI_JS)
self.assertIn("attach-chip--'+mediaKind", UI_JS)
self.assertIn("URL.createObjectURL(f)", UI_JS)
def test_workspace_preview_routes_audio_video_inline(self):
self.assertIn("AUDIO_EXTS", self.WORKSPACE_JS)
self.assertIn("VIDEO_EXTS", self.WORKSPACE_JS)
self.assertIn("previewMediaWrap", self.WORKSPACE_JS)
self.assertIn("showPreview(mode)", self.WORKSPACE_JS)
self.assertIn("&inline=1", self.WORKSPACE_JS)
self.assertIn('id="previewMediaWrap"', self.INDEX_HTML)
def test_media_editor_css_defined(self):
for cls in [".msg-media-editor", ".msg-media-player", ".msg-media-video", ".media-speed-controls", ".media-speed-btn", ".preview-media-wrap"]:
self.assertIn(cls, self.CSS)
class TestWorkspacePdfViewer(unittest.TestCase):
"""Static checks for inline PDF preview support in the workspace panel."""
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
WORKSPACE_JS = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
def test_pdf_extension_routes_to_inline_viewer(self):
self.assertIn("PDF_EXTS", self.WORKSPACE_JS)
self.assertIn("PDF_EXTS.has(ext)", self.WORKSPACE_JS)
self.assertIn("showPreview('pdf')", self.WORKSPACE_JS)
self.assertIn("&inline=1", self.WORKSPACE_JS)
def test_pdf_viewer_markup_exists(self):
self.assertIn('id="previewPdfWrap"', self.INDEX_HTML)
self.assertIn('id="previewPdfFrame"', self.INDEX_HTML)
self.assertIn('title="PDF preview"', self.INDEX_HTML)
def test_pdf_preview_css_defined(self):
for cls in [".preview-pdf-wrap", ".preview-pdf-frame", ".preview-badge.pdf"]:
self.assertIn(cls, self.CSS)
# ── Backend: /api/media endpoint (unit-level, no server needed) ─────────────
class TestMediaEndpointUnit(unittest.TestCase):
@@ -145,6 +235,12 @@ class TestMediaEndpointUnit(unittest.TestCase):
self.assertIn("_INLINE_IMAGE_TYPES", routes_src,
"_INLINE_IMAGE_TYPES whitelist must exist in _handle_media")
def test_media_endpoints_advertise_byte_range_support(self):
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
self.assertIn("Accept-Ranges", routes_src)
self.assertIn("Content-Range", routes_src)
self.assertIn("206", routes_src)
# ── Integration tests: live server on TEST_PORT ───────────────────────────────
# No collection-time skip guard — conftest.py starts the server via its
@@ -161,9 +257,10 @@ class TestMediaEndpointIntegration(unittest.TestCase):
except Exception as exc:
self.fail(f"Test server at {BASE} is not reachable: {exc}")
def _get(self, path):
def _get(self, path, headers=None):
req = urllib.request.Request(BASE + path, headers=headers or {})
try:
with urllib.request.urlopen(BASE + path, timeout=10) as r:
with urllib.request.urlopen(req, timeout=10) as r:
return r.read(), r.status, r.headers
except urllib.error.HTTPError as e:
return e.read(), e.code, e.headers
@@ -205,6 +302,33 @@ class TestMediaEndpointIntegration(unittest.TestCase):
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
def test_audio_media_endpoint_inline_and_range(self):
"""MEDIA: audio paths stream inline and support byte ranges for playback."""
audio_bytes = b"RIFF" + (b"\x00" * 256)
with tempfile.NamedTemporaryFile(
suffix=".wav", prefix="hermes_test_", dir="/tmp", delete=False
) as f:
f.write(audio_bytes)
tmp_path = f.name
try:
encoded = urllib.request.quote(tmp_path)
body, status, headers = self._get(f"/api/media?path={encoded}&inline=1")
self.assertEqual(status, 200)
self.assertIn("audio/wav", headers.get("Content-Type", ""))
self.assertIn("inline", headers.get("Content-Disposition", ""))
self.assertEqual(headers.get("Accept-Ranges"), "bytes")
self.assertEqual(body, audio_bytes)
body, status, headers = self._get(
f"/api/media?path={encoded}&inline=1",
headers={"Range": "bytes=0-3"},
)
self.assertEqual(status, 206)
self.assertEqual(body, b"RIFF")
self.assertEqual(headers.get("Content-Range"), f"bytes 0-3/{len(audio_bytes)}")
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
def test_path_traversal_rejected(self):
_, status, _ = self._get(
"/api/media?path=" + urllib.request.quote("/tmp/../../etc/passwd")
+4
View File
@@ -21,6 +21,7 @@ def test_save_models_cache_to_disk_preserves_response_metadata(tmp_path, monkeyp
payload = {
"active_provider": "openai",
"default_model": "gpt-5.4-mini",
"configured_model_badges": {},
"groups": [
{
"provider": "OpenAI",
@@ -67,6 +68,7 @@ def test_load_models_cache_from_disk_rejects_partial_metadata_cache(
valid_payload = {
"active_provider": "openai",
"default_model": "gpt-5.4-mini",
"configured_model_badges": {},
"groups": [
{
"provider": "OpenAI",
@@ -80,9 +82,11 @@ def test_load_models_cache_from_disk_rejects_partial_metadata_cache(
{key: value for key, value in valid_payload.items() if key != "active_provider"},
{key: value for key, value in valid_payload.items() if key != "default_model"},
{key: value for key, value in valid_payload.items() if key != "groups"},
{key: value for key, value in valid_payload.items() if key != "configured_model_badges"},
{**valid_payload, "active_provider": 123},
{**valid_payload, "default_model": None},
{**valid_payload, "groups": {}},
{**valid_payload, "configured_model_badges": []},
]
for payload in invalid_payloads:
+123
View File
@@ -0,0 +1,123 @@
from pathlib import Path
from api import config
def _models_with_cfg(model_cfg=None, fallback_providers=None, custom_providers=None, active_provider=None):
old_cfg = config.cfg
old_mtime = config._cfg_mtime
old_cache = config._available_models_cache
old_cache_ts = config._available_models_cache_ts
try:
config._available_models_cache = None
config._available_models_cache_ts = 0.0
config._cfg_mtime = 0.0
config.cfg = {
"model": model_cfg or {"provider": "openai-codex", "default": "gpt-5.4"},
"fallback_providers": fallback_providers or [],
"providers": custom_providers or {},
}
if active_provider:
config.cfg["model"]["provider"] = active_provider
return config.get_available_models()
finally:
config.cfg = old_cfg
config._cfg_mtime = old_mtime
config._available_models_cache = old_cache
config._available_models_cache_ts = old_cache_ts
def test_available_models_exposes_primary_and_fallback_badges():
result = _models_with_cfg(
model_cfg={"provider": "openai-codex", "default": "gpt-5.4"},
fallback_providers=[
{"provider": "copilot", "model": "gpt-4.1"},
{"provider": "anthropic", "model": "claude-haiku-4.5"},
],
)
badges = result.get("configured_model_badges")
assert isinstance(badges, dict), (
"get_available_models() deve expor configured_model_badges para o frontend "
"marcar visualmente o dropdown com a cadeia primária + fallback configurada."
)
assert badges.get("@openai-codex:gpt-5.4", {}).get("role") == "primary"
assert badges.get("@openai-codex:gpt-5.4", {}).get("label") == "Primary"
assert badges.get("@copilot:gpt-4.1", {}).get("role") == "fallback"
assert badges.get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1"
assert badges.get("anthropic/claude-haiku-4.5", {}).get("role") == "fallback"
assert badges.get("anthropic/claude-haiku-4.5", {}).get("label") == "Fallback 2"
def test_get_available_models_cache_preserves_configured_model_badges(tmp_path, monkeypatch):
cache_path = tmp_path / "models_cache.json"
old_cfg = config.cfg
old_mtime = config._cfg_mtime
old_cache = config._available_models_cache
old_cache_ts = config._available_models_cache_ts
old_cache_path = config._models_cache_path
try:
monkeypatch.setattr(config, "_models_cache_path", cache_path)
config._available_models_cache = None
config._available_models_cache_ts = 0.0
config._cfg_mtime = 0.0
config.cfg = {
"model": {"provider": "openai-codex", "default": "gpt-5.4"},
"fallback_providers": [{"provider": "copilot", "model": "gpt-4.1"}],
"providers": {},
}
cold = config.get_available_models()
assert cold.get("configured_model_badges", {}).get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1"
config._available_models_cache = None
config._available_models_cache_ts = 0.0
warm = config.get_available_models()
assert "configured_model_badges" in warm, (
"O cache persistido de /api/models não pode descartar configured_model_badges, "
"senão o deploy/servidor reiniciado perde as TAGS do dropdown mesmo com o código novo."
)
assert warm["configured_model_badges"].get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1"
finally:
config.cfg = old_cfg
config._cfg_mtime = old_mtime
config._available_models_cache = old_cache
config._available_models_cache_ts = old_cache_ts
monkeypatch.setattr(config, "_models_cache_path", old_cache_path)
def test_ui_renders_model_badges_from_api_payload():
root = Path(__file__).resolve().parent.parent
js = (root / "static" / "ui.js").read_text(encoding="utf-8")
html = (root / "static" / "index.html").read_text(encoding="utf-8")
css = (root / "static" / "style.css").read_text(encoding="utf-8")
assert "window._configuredModelBadges=data.configured_model_badges||{};" in js, (
"populateModelDropdown() deve guardar configured_model_badges do /api/models "
"para que o dropdown reflita a cadeia configurada atual."
)
assert "model-opt-badge" in js, (
"renderModelDropdown() deve renderizar um badge visual por modelo quando houver "
"metadata de primário/fallback no payload."
)
assert "_getConfiguredModelBadge" in js, (
"A UI precisa de um helper de matching resiliente para religar badges mesmo quando "
"o update do catálogo mudar prefixos/formas do model ID."
)
assert 'id="composerModelBadge"' in html, (
"O chip principal do modelo precisa de um container dedicado para exibir o badge "
"do modelo selecionado fora do dropdown."
)
assert "composer-model-badge" in css, (
"O badge do chip principal precisa de estilo próprio para ficar visível ao lado "
"do nome do modelo selecionado."
)
assert "const badge=_getConfiguredModelBadge(sel.value||'',window._configuredModelBadges||{});" in js, (
"syncModelChip() deve buscar o badge configurado do modelo selecionado e projetá-lo "
"no chip principal da composer."
)
assert "badgeEl.textContent=badge&&badge.label?badge.label:'';" in js, (
"syncModelChip() deve preencher o texto do badge visível no chip principal quando houver metadata configurada."
)
+6 -4
View File
@@ -23,6 +23,7 @@ def test_svg_rendered_before_image_catch_all():
src = f.read()
# Find positions of SVG vs image catch-all in the URL section
svg_url_match = src.find("SVG URLs")
# Comment can say either variant of the catch-all description
image_catch_all = src.find("Render all https:// URLs as <img>")
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"