diff --git a/CHANGELOG.md b/CHANGELOG.md
index 996b212f..22a9a966 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
## [Unreleased]
+### Added
+
+- **Export a conversation to a self-contained, theme-matched HTML file.** From a session you can now download a single standalone `.html` of the transcript with the current theme's palette baked in — no external resource loads (fully offline/portable), messages rendered as escaped text (no script/style/raw-HTML injection from conversation content), and remote images neutralized. Thanks @brick-hard. (#4968)
+
### Fixed
- **Fixed a blank assistant turn (avatar with no text) that could appear after a dropped stream.** When a turn's live SSE dropped but its in-flight bookkeeping wasn't cleaned up, the empty live-turn shell could be re-attached over the settled transcript on the next self-heal re-render — pinning an avatar-only blank turn on top of the already-saved answer (the message was intact on disk; a reload restored it). The live turn is now preserved across the transcript re-render only when it's genuinely live (an active stream is running, or it already holds real rendered content), so a dead empty shell is dropped and the real answer renders. The mid-stream flicker fix (#3877) is unaffected. Thanks @allenliang2022. (#5390)
diff --git a/api/routes.py b/api/routes.py
index 289b9608..132ad5e9 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -15279,11 +15279,35 @@ def _handle_session_export(handler, parsed):
if not _profiles_match(getattr(s, "profile", None), active_profile):
return bad(handler, "Session not found", 404)
safe = redact_session_data(s.__dict__)
- payload = json.dumps(safe, ensure_ascii=False, indent=2)
+ qs = parse_qs(parsed.query)
+ fmt = qs.get("format", ["json"])[0].lower()
+ if fmt == "html":
+ from api.session_export_html import render_session_html
+ theme = qs.get("theme", ["dark"])[0].lower()
+ palette: dict | None = None
+ raw_palette = qs.get("palette", [""])[0]
+ if raw_palette:
+ try:
+ import base64 as _b64
+ decoded = _b64.b64decode(raw_palette, validate=False).decode("utf-8")
+ parsed_palette = json.loads(decoded)
+ if isinstance(parsed_palette, dict):
+ # Cap payload so a hostile client can't blow up the response.
+ if len(parsed_palette) <= 64:
+ palette = parsed_palette
+ except Exception:
+ palette = None
+ payload = render_session_html(safe, theme=theme, palette=palette)
+ content_type = "text/html; charset=utf-8"
+ ext = "html"
+ else:
+ payload = json.dumps(safe, ensure_ascii=False, indent=2)
+ content_type = "application/json; charset=utf-8"
+ ext = "json"
handler.send_response(200)
- handler.send_header("Content-Type", "application/json; charset=utf-8")
+ handler.send_header("Content-Type", content_type)
handler.send_header(
- "Content-Disposition", f'attachment; filename="hermes-{sid}.json"'
+ "Content-Disposition", f'attachment; filename="hermes-{sid}.{ext}"'
)
handler.send_header("Content-Length", str(len(payload.encode("utf-8"))))
handler.send_header("Cache-Control", "no-store")
diff --git a/api/session_export_html.py b/api/session_export_html.py
new file mode 100644
index 00000000..aa2f6045
--- /dev/null
+++ b/api/session_export_html.py
@@ -0,0 +1,297 @@
+"""Self-contained HTML export for a hermes-webui session transcript.
+
+Produces a single static HTML file (no external assets, no CDN) that renders
+the conversation the same way the WebUI does: Markdown, code blocks, tables,
+lists, blockquotes — all styled with an inlined dark theme matching the app.
+Double-click the file and it opens in any browser, offline.
+
+Public entry point: render_session_html(session_dict) -> str
+"""
+from __future__ import annotations
+
+import html
+import re
+import time
+from typing import Any
+
+try:
+ from markdown_it import MarkdownIt
+ _MD = (
+ MarkdownIt("commonmark", {"html": False, "linkify": True, "typographer": False})
+ .enable("table")
+ .enable("strikethrough")
+ )
+except Exception: # pragma: no cover - markdown_it always present in webui venv
+ _MD = None
+
+
+def _neutralize_remote_images(rendered_html: str) -> str:
+ """Replace any whose src isn't a data: URI with an inert placeholder.
+
+ This is the single chokepoint that keeps the export self-contained. The
+ multimodal flattening in _content_to_text() handles structured image_url
+ parts, but a *text* message body can carry Markdown image syntax —
+ ```` — which markdown_it renders
+ into an active ``
``. Opening the saved file would
+ then fire a network request and leak a signed/private URL. Filtering here,
+ after rendering, catches every path into the HTML (text Markdown images and
+ any future source) regardless of how the
was produced. data: URIs are
+ already embedded, render offline, and make no request, so they're kept.
+ """
+ if not rendered_html or "
str:
+ tag = match.group(0)
+ src_m = re.search(r'src\s*=\s*"([^"]*)"', tag) or re.search(
+ r"src\s*=\s*'([^']*)'", tag
+ )
+ src = src_m.group(1) if src_m else ""
+ if src.startswith("data:"):
+ return tag # already embedded, offline-safe
+ # Inert placeholder mirroring _content_to_text's remote-image handling.
+ label = html.escape(src) if src else "image"
+ return f"
[image: {label}]"
+
+ return re.sub(r"]*>", _repl, rendered_html, flags=re.IGNORECASE)
+
+
+def _render_markdown(text: str) -> str:
+ """Render Markdown to HTML. Falls back to escaped
if the lib is missing.""" + if not text: + return "" + if _MD is None: + return f"{html.escape(text)}" + try: + return _neutralize_remote_images(_MD.render(text)) + except Exception: + return f"{html.escape(text)}" + + +def _content_to_text(content: Any) -> str: + """Flatten message content (str or multimodal list) into Markdown text.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for c in content: + if isinstance(c, dict): + if c.get("type") == "text" or "text" in c: + parts.append(str(c.get("text", ""))) + elif c.get("type") in ("image_url", "image"): + url = "" + if isinstance(c.get("image_url"), dict): + url = c["image_url"].get("url", "") + url = url or c.get("url", "") + if url: + # Keep the export self-contained and avoid leaking + # private/signed URLs: only inline data: URIs (already + # embedded, render offline). Remote http(s) images are + # NOT rendered as(that would fire a network + # request on open) — show an inert placeholder + the + # URL as plain text instead. + if url.startswith("data:"): + parts.append(f"") + else: + parts.append(f"`[image: {url}]`") + else: + parts.append(f"`[{c.get('type', 'content')}]`") + else: + parts.append(str(c)) + return "\n\n".join(p for p in parts if p) + return str(content or "") + + +def _fmt_ts(t: Any) -> str: + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(float(t))) + except Exception: + return "" + + +_ROLE_LABELS = { + "user": ("You", "role-user"), + "assistant": ("Assistant", "role-assistant"), + "system": ("System", "role-system"), + "tool": ("Tool", "role-tool"), +} + +# Inlined theme matching the WebUI. Light is the default (:root); dark applies +# when . Palettes are copied from the WebUI's style.css gold +# theme so the export follows whatever appearance the user has active. +_CSS = """ +:root{--bg:#FEFCF7;--panel:#F3EEE3;--panel2:#FAF7F0;--border:#E0D8C8; +--text:#1A1610;--muted:#5C5344;--accent:#B8860B;--user:#0288A8;--assistant:#3D8B40; +--code-bg:#F5F0E5;--code-border:#E0D8C8;--code-text:#8b4513; +--badge-user-bg:rgba(2,136,168,.12);--badge-user-text:#0288A8; +--badge-assistant-bg:rgba(61,139,64,.12);--badge-assistant-text:#3D8B40; +--badge-system-bg:rgba(92,83,68,.14);--badge-system-text:#5C5344; +--badge-tool-bg:rgba(184,134,11,.14);--badge-tool-text:#8B6508; +--row-stripe:rgba(0,0,0,.02);--subtle:rgba(0,0,0,.02);} +:root.dark{--bg:#0D0D1A;--panel:#1A1A2E;--panel2:#141425;--border:#2A2A45; +--text:#FFF8DC;--muted:#C0C0C0;--accent:#FFD700;--user:#4DD0E1;--assistant:#4CAF50; +--code-bg:#1A1A2E;--code-border:#2A2A45;--code-text:#f0c27f; +--badge-user-bg:rgba(77,208,225,.16);--badge-user-text:#4DD0E1; +--badge-assistant-bg:rgba(76,175,80,.16);--badge-assistant-text:#56d364; +--badge-system-bg:rgba(192,192,192,.16);--badge-system-text:#C0C0C0; +--badge-tool-bg:rgba(255,191,0,.16);--badge-tool-text:#FFBF00; +--row-stripe:rgba(255,255,255,.025);--subtle:rgba(255,255,255,.02);} +*{box-sizing:border-box} +html{-webkit-text-size-adjust:100%} +body{margin:0;background:var(--bg);color:var(--text); +font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Roboto,Helvetica,Arial,sans-serif;} +.wrap{max-width:860px;margin:0 auto;padding:32px 20px 80px;} +header.doc-head{border-bottom:1px solid var(--border);padding-bottom:20px;margin-bottom:28px;} +header.doc-head h1{margin:0 0 10px;font-size:24px;font-weight:650;} +.meta{color:var(--muted);font-size:13px;line-height:1.9;} +.meta b{color:var(--text);font-weight:600;} +.msg{margin:0 0 22px;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--panel);} +.msg-head{display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--panel2); +border-bottom:1px solid var(--border);font-size:13px;} +.badge{font-weight:650;padding:2px 10px;border-radius:999px;font-size:12px;letter-spacing:.2px;} +.role-user .badge{background:var(--badge-user-bg);color:var(--badge-user-text);} +.role-assistant .badge{background:var(--badge-assistant-bg);color:var(--badge-assistant-text);} +.role-system .badge{background:var(--badge-system-bg);color:var(--badge-system-text);} +.role-tool .badge{background:var(--badge-tool-bg);color:var(--badge-tool-text);} +.ts{color:var(--muted);margin-left:auto;font-size:12px;} +.msg-body{padding:4px 18px 8px;} +.msg-body>:first-child{margin-top:8px}.msg-body>:last-child{margin-bottom:8px} +.msg-body p{margin:10px 0;} +.msg-body h1,.msg-body h2,.msg-body h3,.msg-body h4{margin:18px 0 10px;line-height:1.3;font-weight:640;} +.msg-body h1{font-size:21px}.msg-body h2{font-size:18px}.msg-body h3{font-size:16px} +.msg-body a{color:var(--accent);text-decoration:none}.msg-body a:hover{text-decoration:underline} +.msg-body ul,.msg-body ol{padding-left:24px;margin:10px 0;} +.msg-body li{margin:4px 0;} +.msg-body code{background:var(--code-bg);border:1px solid var(--code-border);border-radius:5px;color:var(--code-text); +padding:.15em .4em;font-size:.88em;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;} +.msg-body pre{background:var(--code-bg);border:1px solid var(--code-border);border-radius:10px; +padding:14px 16px;overflow:auto;margin:12px 0;} +.msg-body pre code{background:none;border:0;padding:0;color:var(--text);font-size:13px;line-height:1.55;} +.msg-body blockquote{border-left:3px solid var(--border);margin:12px 0;padding:2px 16px;color:var(--muted);} +.msg-body table{border-collapse:collapse;margin:14px 0;width:100%;font-size:14px;display:block;overflow-x:auto;} +.msg-body th,.msg-body td{border:1px solid var(--border);padding:8px 12px;text-align:left;} +.msg-body th{background:var(--panel2);font-weight:640;} +.msg-body tr:nth-child(even) td{background:var(--row-stripe);} +.msg-body img{max-width:100%;border-radius:8px;} +.msg-body hr{border:0;border-top:1px solid var(--border);margin:18px 0;} +details.reasoning{margin:6px 0 4px;border:1px dashed var(--border);border-radius:8px;background:var(--subtle);} +details.reasoning summary{cursor:pointer;padding:8px 14px;color:var(--muted);font-size:13px;user-select:none;} +details.reasoning[open] summary{border-bottom:1px solid var(--border);} +details.reasoning .reasoning-body{padding:4px 16px 10px;color:var(--muted);font-size:13.5px;} +footer.doc-foot{margin-top:36px;padding-top:18px;border-top:1px solid var(--border); +color:var(--muted);font-size:12px;text-align:center;} +""" + + +def _palette_to_css(palette: dict) -> str: + """Turn a {var-name: value} dict into a `:root{...}` override block. + + Var names may be given with or without the leading `--`. Values are sanitised + to a conservative charset (colors, numbers, a few CSS units/functions) so an + untrusted palette can't break out of the style block. + """ + if not isinstance(palette, dict) or not palette: + return "" + decls = [] + for raw_name, raw_val in palette.items(): + name = str(raw_name).strip().lstrip("-") + if not name or not re.fullmatch(r"[A-Za-z0-9-]+", name): + continue + val = str(raw_val).strip() + # Allow hex/rgb/hsla colors, numbers, %, px, var(), color-mix(), commas, spaces. + if not val or not re.fullmatch(r"[#A-Za-z0-9.,%()\-\s]+", val): + continue + if len(val) > 120: + continue + # Reject IE-only expression() — it evaluates JS in older IE and + # contradicts the "no active code in + + +
++ ++ +{html.escape(title)}
+ ++{''.join(blocks)} + + +