#4968: export chat to self-contained themed HTML, rebased on master
This commit is contained in:
1 parent
193723c7e0
commit
b31f3fab3b
6 files changed
+656
-3
No files matched your search
+27
-3
@@ -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")
|
||||
|
||||
@@ -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 —
|
||||
```` — 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"")
|
||||
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>"""
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"" 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 —  —
|
||||
# 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 ")
|
||||
# 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 ")
|
||||
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 "}]},
|
||||
theme="dark",
|
||||
)
|
||||
assert f'src="{remote}"' not in html
|
||||
assert remote in html # inert text placeholder
|
||||
Reference in new issue
Block a user