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 — + ``![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)} +
+
Exported from Hermes WebUI on {exported}
+
+ +""" diff --git a/static/boot.js b/static/boot.js index c309f90d..154f97b9 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1734,6 +1734,36 @@ $('btnExportJSON').onclick=()=>{ const a=document.createElement('a');a.href=url; a.download=`hermes-${S.session.session_id}.json`;a.click(); }; +function exportSessionHTML(){ + if(!S.session)return; + const theme=document.documentElement.classList.contains('dark')?'dark':'light'; + // Capture the live WebUI palette so the export matches the user's active + // theme + skin exactly, not just the built-in dark/light fallback. Map each + // export-template variable to its WebUI source; getComputedStyle resolves to + // the currently rendered colour regardless of which skin is selected. + const cs=getComputedStyle(document.documentElement); + const read=(...names)=>{for(const n of names){const v=cs.getPropertyValue(n).trim();if(v)return v;}return '';}; + const palette={ + 'bg':read('--bg'), + 'panel':read('--surface','--bg'), + 'panel2':read('--code-bg','--surface'), + 'border':read('--border'), + 'text':read('--text'), + 'muted':read('--muted','--text'), + 'accent':read('--accent'), + 'code-bg':read('--code-bg'), + 'code-border':read('--border2','--border'), + 'code-text':read('--text'), + }; + // Drop empties so the inlined fallback keeps working for anything we couldn't read. + const clean={};for(const k in palette){if(palette[k])clean[k]=palette[k];} + const paletteB64=btoa(unescape(encodeURIComponent(JSON.stringify(clean)))); + const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}&format=html&theme=${theme}&palette=${encodeURIComponent(paletteB64)}`; + const a=document.createElement('a');a.href=url; + a.download=`hermes-${S.session.session_id}.html`;a.click(); +} +$('btnExportHTML').onclick=exportSessionHTML; +if($('btnExportHTMLComposer'))$('btnExportHTMLComposer').onclick=exportSessionHTML; $('btnImportJSON').onclick=()=>$('importFileInput').click(); $('importFileInput').onchange=async(e)=>{ const file=e.target.files[0]; diff --git a/static/i18n.js b/static/i18n.js index 4eeb86cf..c1caa3ad 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1112,6 +1112,8 @@ const LOCALES = { import: 'Import', export_session_json: 'JSON', export_session_json_tooltip: 'Export full session as JSON', + export_session_html: 'HTML', + export_session_html_tooltip: 'Export as a self-contained HTML page', import_session_json_tooltip: 'Import session from JSON', clear_conversation_btn_tooltip: 'Clear all messages in this conversation', // Settings detail @@ -2765,6 +2767,8 @@ const LOCALES = { import: 'Importa', export_session_json: 'JSON', export_session_json_tooltip: 'Esporta sessione completa come JSON', + export_session_html: 'HTML', + export_session_html_tooltip: 'Esporta come pagina HTML autonoma', import_session_json_tooltip: 'Importa sessione da JSON', clear_conversation_btn_tooltip: 'Cancella tutti i messaggi in questa conversazione', // Settings detail @@ -4426,6 +4430,8 @@ const LOCALES = { import: 'インポート', export_session_json: 'JSON', export_session_json_tooltip: 'セッション全体をJSONとしてエクスポート', + export_session_html: 'HTML', + export_session_html_tooltip: '自己完結型のHTMLページとしてエクスポート', import_session_json_tooltip: 'JSONからセッションをインポート', clear_conversation_btn_tooltip: 'この会話のすべてのメッセージをクリア', // Settings detail @@ -5740,6 +5746,8 @@ const LOCALES = { import: 'Импорт', export_session_json: 'JSON', export_session_json_tooltip: 'Экспортировать сессию как JSON', + export_session_html: 'HTML', + export_session_html_tooltip: 'Экспортировать как автономную HTML-страницу', import_session_json_tooltip: 'Импортировать сессию из JSON', clear_conversation_btn_tooltip: 'Очистить все сообщения в этой беседе', settings_label_rtl: 'Раскладка чата справа налево', @@ -7333,6 +7341,8 @@ const LOCALES = { import: 'Importar', export_session_json: 'JSON', export_session_json_tooltip: 'Exportar sesión completa como JSON', + export_session_html: 'HTML', + export_session_html_tooltip: 'Exportar como página HTML independiente', import_session_json_tooltip: 'Importar sesión desde JSON', clear_conversation_btn_tooltip: 'Borrar todos los mensajes de esta conversación', // Settings detail @@ -8894,6 +8904,8 @@ const LOCALES = { import: 'Importieren', export_session_json: 'JSON', export_session_json_tooltip: 'Gesamte Sitzung als JSON exportieren', + export_session_html: 'HTML', + export_session_html_tooltip: 'Als eigenständige HTML-Seite exportieren', import_session_json_tooltip: 'Sitzung aus JSON importieren', clear_conversation_btn_tooltip: 'Alle Nachrichten in dieser Konversation löschen', // Settings detail @@ -10532,6 +10544,8 @@ const LOCALES = { import: '导入', export_session_json: 'JSON', export_session_json_tooltip: '将会话完整导出为 JSON', + export_session_html: 'HTML', + export_session_html_tooltip: '导出为独立的 HTML 网页(双击即可在浏览器打开)', import_session_json_tooltip: '从 JSON 导入会话', clear_conversation_btn_tooltip: '清空此会话中的所有消息', editing: '编辑中', @@ -12460,6 +12474,8 @@ const LOCALES = { import: '匯入', export_session_json: 'JSON', export_session_json_tooltip: '將對話完整匯出為 JSON', + export_session_html: 'HTML', + export_session_html_tooltip: '匯出為獨立的 HTML 網頁(雙擊即可在瀏覽器開啟)', import_session_json_tooltip: '從 JSON 匯入對話', clear_conversation_btn_tooltip: '清空此對話中的所有訊息', // Settings detail @@ -13959,6 +13975,8 @@ const LOCALES = { import: 'Importar', export_session_json: 'JSON', export_session_json_tooltip: 'Exportar sessão completa como JSON', + export_session_html: 'HTML', + export_session_html_tooltip: 'Exportar como página HTML independente', import_session_json_tooltip: 'Importar sessão de JSON', clear_conversation_btn_tooltip: 'Limpar todas as mensagens nesta conversa', // Settings detail @@ -15513,6 +15531,8 @@ const LOCALES = { import: '가져오기', export_session_json: 'JSON', export_session_json_tooltip: '전체 세션을 JSON으로 내보내기', + export_session_html: 'HTML', + export_session_html_tooltip: '독립형 HTML 페이지로 내보내기', import_session_json_tooltip: 'JSON에서 세션 가져오기', clear_conversation_btn_tooltip: '이 대화의 모든 메시지 지우기', // Settings detail @@ -17230,6 +17250,8 @@ const LOCALES = { import: 'Importer', export_session_json: 'JSON', export_session_json_tooltip: 'Exporter la session complète en JSON', + export_session_html: 'HTML', + export_session_html_tooltip: 'Exporter en page HTML autonome', import_session_json_tooltip: 'Importer une session depuis JSON', clear_conversation_btn_tooltip: 'Effacer tous les messages de cette conversation', settings_label_rtl: 'Disposition du chat de droite à gauche', @@ -18804,6 +18826,8 @@ const LOCALES = { import: 'İçe aktarmak', export_session_json: 'JSON', export_session_json_tooltip: 'Tüm oturumu JSON olarak dışa aktar', + export_session_html: 'HTML', + export_session_html_tooltip: 'Bağımsız bir HTML sayfası olarak dışa aktar', import_session_json_tooltip: 'JSON\'dan oturumu içe aktar', clear_conversation_btn_tooltip: 'Bu görüşmedeki tüm mesajları temizle', // Settings detail @@ -20513,6 +20537,8 @@ const LOCALES = { import: 'Importuj', export_session_json: 'JSON', export_session_json_tooltip: 'Eksportuj pełną sesję jako JSON', + export_session_html: 'HTML', + export_session_html_tooltip: 'Eksportuj jako samodzielną stronę HTML', import_session_json_tooltip: 'Importuj sesję z pliku JSON', clear_conversation_btn_tooltip: 'Wyczyść wszystkie wiadomości w tej konwersacji', // Settings detail @@ -22017,6 +22043,8 @@ const LOCALES = { import: 'Import', export_session_json: 'JSON', export_session_json_tooltip: 'Xuất toàn bộ phiên dưới dạng JSON', + export_session_html: 'HTML', + export_session_html_tooltip: 'Xuất dưới dạng trang HTML độc lập', import_session_json_tooltip: 'Nhập phiên từ JSON', clear_conversation_btn_tooltip: 'Xóa toàn bộ tin nhắn trong cuộc trò chuyện này', // Settings detail diff --git a/static/index.html b/static/index.html index 1ec94c3f..c7e76806 100644 --- a/static/index.html +++ b/static/index.html @@ -584,6 +584,9 @@ + + diff --git a/tests/test_session_export_html_palette.py b/tests/test_session_export_html_palette.py new file mode 100644 index 00000000..3fdfbcc1 --- /dev/null +++ b/tests/test_session_export_html_palette.py @@ -0,0 +1,270 @@ +"""Tests for `api.session_export_html` palette injection. + +These guard the contract that: + 1. A palette captured from the live WebUI (getComputedStyle) flows through the + export and overrides the inlined fallback so the file matches the user's + active theme + skin. + 2. The palette is sanitised so a hostile client cannot break out of the + `