#4968: export chat to self-contained themed HTML, rebased on master

This commit is contained in:
nesquena-hermes committed 2026-07-02 02:36:09 +00:00
1 parent 193723c7e0
commit b31f3fab3b
6 files changed
+656 -3

No files matched your search

+27 -3
View File
@@ -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")
+297
View File
@@ -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 <img> 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 ``<img src="https://...">``. 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 <img> was produced. data: URIs are
already embedded, render offline, and make no request, so they're kept.
"""
if not rendered_html or "<img" not in rendered_html:
return rendered_html
def _repl(match: "re.Match[str]") -> 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"<code>[image: {label}]</code>"
return re.sub(r"<img\b[^>]*>", _repl, rendered_html, flags=re.IGNORECASE)
def _render_markdown(text: str) -> str:
"""Render Markdown to HTML. Falls back to escaped <pre> if the lib is missing."""
if not text:
return ""
if _MD is None:
return f"<pre>{html.escape(text)}</pre>"
try:
return _neutralize_remote_images(_MD.render(text))
except Exception:
return f"<pre>{html.escape(text)}</pre>"
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 <img> (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 <html class="dark">. 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 <style>" guarantee.
if re.search(r"expression\s*\(", val, re.IGNORECASE):
continue
decls.append(f"--{name}:{val};")
if not decls:
return ""
# Use both :root and :root.dark so the captured palette wins regardless of
# theme mode. _CSS defines :root.dark{…} at specificity (0,0,2,0) which
# beats a plain :root{…} (0,0,1,0). By emitting both selectors the override
# matches dark-mode specificity and source-order ensures it wins in light too.
return ":root,:root.dark{" + "".join(decls) + "}"
def render_session_html(session: dict, theme: str = "dark", palette: dict | None = None) -> str:
"""Build a complete, self-contained HTML document for the session.
theme: "dark" or "light" — selects which inlined fallback palette is active.
palette: optional {css-var: value} dict captured from the live WebUI
(getComputedStyle of :root). When provided it is injected last so the
export matches the user's exact active theme AND skin, not just the
built-in dark/light fallback.
"""
html_class = ' class="dark"' if str(theme).lower() != "light" else ""
palette_css = _palette_to_css(palette or {})
title = (session.get("title") or "Hermes Conversation").strip()
sid = session.get("session_id", "")
model = session.get("model", "")
provider = session.get("model_provider", "")
created = _fmt_ts(session.get("created_at"))
updated = _fmt_ts(session.get("updated_at"))
messages = session.get("messages") or []
# Skip system messages by default (usually long boilerplate); keep user/assistant/tool.
visible = [m for m in messages if isinstance(m, dict) and m.get("role") != "system"]
blocks = []
for m in visible:
role = m.get("role", "")
label, cls = _ROLE_LABELS.get(role, (role or "?", "role-system"))
ts = _fmt_ts(m.get("timestamp"))
ts_html = f'<span class="ts">{html.escape(ts)}</span>' if ts else ""
body_md = _content_to_text(m.get("content"))
body_html = _render_markdown(body_md)
reasoning = m.get("reasoning")
reasoning_html = ""
if reasoning and isinstance(reasoning, str) and reasoning.strip():
reasoning_html = (
'<details class="reasoning"><summary>💭 Reasoning</summary>'
f'<div class="reasoning-body">{_render_markdown(reasoning)}</div></details>'
)
blocks.append(
f'<section class="msg {cls}">'
f'<div class="msg-head"><span class="badge">{html.escape(label)}</span>{ts_html}</div>'
f'<div class="msg-body">{reasoning_html}{body_html}</div>'
f'</section>'
)
meta_lines = []
if sid:
meta_lines.append(f"<div>Session: <b>{html.escape(sid)}</b></div>")
if model:
prov = f" · {html.escape(provider)}" if provider else ""
meta_lines.append(f"<div>Model: <b>{html.escape(str(model))}</b>{prov}</div>")
if created or updated:
meta_lines.append(
f"<div>Created: <b>{html.escape(created)}</b> · Updated: <b>{html.escape(updated)}</b></div>"
)
meta_lines.append(f"<div>Messages: <b>{len(visible)}</b></div>")
meta_html = "".join(meta_lines)
exported = time.strftime("%Y-%m-%d %H:%M:%S")
return f"""<!DOCTYPE html>
<html lang="en"{html_class}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>{_CSS}{palette_css}</style>
</head>
<body>
<div class="wrap">
<header class="doc-head">
<h1>{html.escape(title)}</h1>
<div class="meta">{meta_html}</div>
</header>
<main>
{''.join(blocks)}
</main>
<footer class="doc-foot">Exported from Hermes WebUI on {exported}</footer>
</div>
</body>
</html>"""
+30
View File
@@ -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];
+28
View File
@@ -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
+4
View File
@@ -584,6 +584,9 @@
<button type="button" class="icon-btn has-tooltip" id="btnSavedPrompts" data-tooltip="Saved prompts" onclick="toggleSavedPromptsPopup()" aria-haspopup="menu" aria-expanded="false" aria-controls="savedPromptsPopup">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
</button>
<button type="button" class="icon-btn has-tooltip" id="btnExportHTMLComposer" data-tooltip="Export chat as HTML" data-i18n-title="export_session_html_tooltip">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l-2 2 2 2"/><path d="M15 13l2 2-2 2"/></svg>
</button>
<button class="icon-btn mic-btn has-tooltip" id="btnMic" data-tooltip="Dictate" data-i18n-title="voice_dictate" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="1" width="6" height="12" rx="3"/>
@@ -922,6 +925,7 @@
<div class="hermes-action-grid">
<button class="settings-action-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <span data-i18n="transcript">Transcript</span></button>
<button class="settings-action-btn" id="btnExportJSON" title="Export full session as JSON" data-i18n-title="export_session_json_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> <span data-i18n="export_session_json">JSON</span></button>
<button class="settings-action-btn" id="btnExportHTML" title="Export as a self-contained HTML page" data-i18n-title="export_session_html_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l-2 2 2 2"/><path d="M15 13l2 2-2 2"/></svg> <span data-i18n="export_session_html">HTML</span></button>
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON" data-i18n-title="import_session_json_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation" data-i18n-title="clear_conversation_btn_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> <span data-i18n="clear">Clear</span></button>
</div>
+270
View File
@@ -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
`<style>` block via CSS injection.
3. When no palette is supplied (CLI / direct API consumers), the existing
dark/light fallback still renders unchanged.
"""
from __future__ import annotations
from api.session_export_html import (
_content_to_text,
_neutralize_remote_images,
_palette_to_css,
_render_markdown,
render_session_html,
)
def _fake_session() -> dict:
return {
"session_id": "abc123",
"title": "Palette test",
"model": "gpt-test",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello back"},
],
}
# ---------------------------------------------------------------- sanitiser ---
def test_palette_to_css_accepts_hex_rgb_and_color_mix() -> None:
out = _palette_to_css({
"bg": "#FAF7F0",
"accent": "rgb(184, 134, 11)",
"border": "color-mix(in srgb, #000 60%, transparent)",
})
assert out.startswith(":root,:root.dark{")
assert "--bg:#FAF7F0;" in out
assert "--accent:rgb(184, 134, 11);" in out
assert "color-mix" in out
def test_palette_to_css_strips_invalid_names_and_values() -> None:
# Bad name (contains ;{}) is dropped; bad value (style-break attempt) is dropped.
out = _palette_to_css({
"; }body{display:none": "#fff", # malicious name
"bg": "red; }body{display:none", # malicious value
"border": "#abc", # legit, must survive
})
assert "display:none" not in out
assert ";}body{" not in out
# The legit entry still makes it through, even when paired with hostile siblings.
assert "--border:#abc;" in out
def test_palette_to_css_empty_input_returns_empty_string() -> None:
assert _palette_to_css({}) == ""
assert _palette_to_css(None) == "" # type: ignore[arg-type]
def test_palette_to_css_caps_value_length() -> None:
# A pathologically long value should be rejected rather than embedded.
out = _palette_to_css({"bg": "#" + ("a" * 200)})
assert out == ""
def test_palette_to_css_drops_fetchable_url_values() -> None:
# A value carrying a fetchable url() must be dropped: the charset has no
# `/`, `:`, `'` or `"`, so a real `url(http://...)` / `url(/path)` can't
# survive — pinning that so the no-outbound-request guarantee can't
# silently regress if the charset is ever widened.
out = _palette_to_css({
"bg": "url(http://evil.example/x.png)", # protocol-bearing
"fg": "url(//evil.example/x.png)", # scheme-relative
"accent": "url(/leak/signed-url)", # path-bearing
"border": "url('http://evil.example/y')", # quoted
"ok": "#abc", # legit sibling survives
})
assert "evil.example" not in out
assert "url(http" not in out
assert "url(//" not in out
assert "url(/" not in out
# The one safe entry still makes it through.
assert "--ok:#abc;" in out
# ---------------------------------------------------------------- end-to-end ---
def test_render_without_palette_uses_builtin_fallback() -> None:
html = render_session_html(_fake_session(), theme="dark")
# Built-in dark palette is present; no extra `:root{...}` override was appended.
assert "--bg:#0D0D1A" in html # dark fallback
# The inlined CSS contains exactly one bare `:root{` (the light defaults) and
# one `:root.dark{` (the dark overrides). No `palette_css` override block.
assert html.count(":root{") == 1
assert html.count(":root.dark{") == 1
assert '<html lang="en" class="dark">' in html
def test_render_with_palette_appends_override_after_builtin() -> None:
palette = {"bg": "#FAF7F0", "text": "#1A1610", "accent": "#B8860B"}
html = render_session_html(_fake_session(), theme="light", palette=palette)
builtin_pos = html.find(":root{--bg:#FEFCF7") # light fallback marker
override_pos = html.rfind(":root,:root.dark{--bg:#FAF7F0") # our override
assert builtin_pos > 0, "light fallback should still be inlined"
assert override_pos > builtin_pos, (
"palette override must come AFTER the built-in CSS so it actually wins; "
f"builtin@{builtin_pos} override@{override_pos}"
)
assert "--accent:#B8860B;" in html
# No <html class="dark"> when theme=light.
assert 'class="dark"' not in html
def test_render_with_hostile_palette_drops_bad_entries_but_keeps_safe_ones() -> None:
html = render_session_html(
_fake_session(),
theme="light",
palette={"bg": "red;}body{display:none", "border": "#abc"},
)
assert "display:none" not in html
assert "--border:#abc;" in html
def test_render_dark_mode_palette_overrides_builtin_dark() -> None:
"""Regression: captured palette must win in dark mode (the default theme).
_CSS defines :root.dark{…} at specificity (0,0,2,0). The palette override
must match or beat that specificity so it actually applies when the export is
rendered with <html class="dark">. Without the fix the palette was silently
ignored and the export fell back to the generic dark theme.
"""
palette = {"bg": "#112233", "text": "#EEDDCC", "accent": "#FF5500"}
html = render_session_html(_fake_session(), theme="dark", palette=palette)
# The exported HTML uses dark mode.
assert '<html lang="en" class="dark">' in html
# The palette override block is present with both selectors.
assert ":root,:root.dark{" in html
# The captured values appear in the override block (not just the fallback).
assert "--bg:#112233;" in html
assert "--text:#EEDDCC;" in html
assert "--accent:#FF5500;" in html
# The override comes AFTER the built-in :root.dark block so source-order wins.
builtin_dark_pos = html.find(":root.dark{--bg:#0D0D1A")
override_pos = html.find(":root,:root.dark{--bg:#112233")
assert builtin_dark_pos > 0
assert override_pos > builtin_dark_pos, (
"palette override must come after built-in :root.dark so it wins; "
f"builtin_dark@{builtin_dark_pos} override@{override_pos}"
)
def test_palette_to_css_drops_expression_values() -> None:
"""expression() is IE-only CSS-eval; must be rejected as defense-in-depth."""
out = _palette_to_css({
"bg": "expression(alert(1))",
"fg": "Expression( document.cookie )",
"accent": "EXPRESSION(0)",
"ok": "#abc", # legit sibling survives
})
assert "expression" not in out.lower()
assert "--ok:#abc;" in out
# ----------------------------------------------------- self-contained images ---
def _image_content(url: str) -> list:
return [
{"type": "text", "text": "look at this"},
{"type": "image_url", "image_url": {"url": url}},
]
def test_remote_image_url_is_flattened_to_inert_text() -> None:
# A remote http(s) image must NOT become a Markdown image (which would
# render as <img src="http..."> and fire a network request on open, leaking
# a signed/private URL). It is flattened to an inline-code placeholder that
# still records the URL so the transcript isn't lossy.
remote = "https://example.com/private/signed.png?sig=secret"
flat = _content_to_text(_image_content(remote))
assert "![image]" not in flat # not an active image
assert f"`[image: {remote}]`" in flat # inert, URL preserved
def test_data_uri_image_is_kept_as_inline_image() -> None:
# data: URIs are already self-contained (no network), so they stay as a real
# Markdown image and render offline.
data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
flat = _content_to_text(_image_content(data_uri))
assert f"![image]({data_uri})" in flat
def test_remote_image_never_appears_as_active_img_in_full_html() -> None:
# End-to-end guard regardless of whether markdown_it is installed: the
# rendered document must not contain an <img> pointing at the remote URL.
remote = "https://example.com/leak.png?token=abc"
html = render_session_html(
{"session_id": "img1", "title": "t",
"messages": [{"role": "user", "content": _image_content(remote)}]},
theme="dark",
)
assert f'src="{remote}"' not in html
assert remote in html # still present as inert text
# ------------------------------------------- markdown-image-in-text (P1 #4968) ---
#
# The multimodal-part flattening above only covers structured image_url content.
# A *text* message body carrying Markdown image syntax — ![alt](https://...) —
# is rendered by markdown_it into an active <img src="https://...">, which fires
# a network request on open and leaks signed/private URLs. _neutralize_remote_images
# closes that path post-render. These tests pin it.
def test_text_markdown_remote_image_is_neutralized() -> None:
remote = "https://example.com/private.png?sig=secret"
out = _render_markdown(f"here is a leak ![x]({remote})")
# Core security invariant holds in BOTH environments:
# - markdown_it present -> <img> rendered then neutralized to placeholder
# - markdown_it absent -> fallback escapes into <pre>, never an <img>
assert "<img" not in out # no active image element
assert f'src="{remote}"' not in out # remote src never emitted
assert remote in out # URL kept as inert text
from api.session_export_html import _MD
if _MD is not None:
assert "[image:" in out # shown via placeholder
def test_text_markdown_data_uri_image_is_kept() -> None:
# data: images are already embedded and offline-safe. With markdown_it they
# render as an active <img>; without it the fallback keeps the data: URI as
# escaped text. Either way the URI is preserved and no remote fetch occurs.
data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
out = _render_markdown(f"embedded ![x]({data_uri})")
assert data_uri in out
from api.session_export_html import _MD
if _MD is not None:
assert "<img" in out
def test_neutralize_handles_single_quoted_and_uppercase_src() -> None:
# Defensive: the regex must catch single-quoted src and tag-case variants,
# not just the canonical markdown_it output.
assert "<img" not in _neutralize_remote_images("<img src='http://h/x.png'>")
assert "<img" not in _neutralize_remote_images('<IMG SRC="http://h/x.png">')
# data: survives regardless of quoting/case.
kept = _neutralize_remote_images("<IMG src='data:image/png;base64,AAAA'>")
assert "<IMG" in kept or "<img" in kept
def test_text_markdown_remote_image_absent_from_full_html() -> None:
# End-to-end: a remote markdown image in a plain-text turn must not survive
# as an <img> in the exported document.
remote = "https://example.com/leak2.png?token=xyz"
html = render_session_html(
{"session_id": "md-img", "title": "t",
"messages": [{"role": "assistant", "content": f"see ![pic]({remote})"}]},
theme="dark",
)
assert f'src="{remote}"' not in html
assert remote in html # inert text placeholder