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:
1 parent
33a145a669
commit
0ad95cb16a
23 files changed
+1284
-164
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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'))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
@@ -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):
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user