"""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 — ``![leak](https://host/private.png?sig=...)`` — 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"![image]({url})") 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)}

{meta_html}
{''.join(blocks)}
"""