From 85138c49d438d29a153e9eb11431ef38983ada53 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Mon, 31 Aug 2026 14:07:29 +0200 Subject: [PATCH] Add standalone session exports --- Cargo.lock | 2 + Cargo.toml | 2 + docs/USER_GUIDE.md | 10 +- src/a2ui.rs | 3 + src/a2ui/export.rs | 881 +++++++++++++++++++++++++++++++++++++++++++++ src/app.rs | 583 +++++++++++++++++++++++++++--- src/native_menu.rs | 31 +- 7 files changed, 1451 insertions(+), 61 deletions(-) create mode 100644 src/a2ui/export.rs diff --git a/Cargo.lock b/Cargo.lock index ba7a6a6..6603b23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,6 +810,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" name = "ds4-server" version = "0.1.0" dependencies = [ + "base64", "cc", "diesel", "diesel_migrations", @@ -821,6 +822,7 @@ dependencies = [ "memmap2", "muda", "png 0.17.16", + "pulldown-cmark 0.12.2", "regex", "rfd", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0a9ab6a..502e750 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ build = "build.rs" cc = "1.3.0" [dependencies] +base64 = "0.22.1" diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] } diesel_migrations = "2.3.2" git2 = { version = "0.21.0", features = ["https", "vendored-libgit2", "vendored-openssl"] } @@ -21,6 +22,7 @@ image = { version = "0.25.10", default-features = false, features = ["gif", "jpe libc = "0.2.186" memmap2 = "0.9.11" png = "0.17.16" +pulldown-cmark = "0.12.2" regex = "1.13.1" rfd = { version = "0.15.4", default-features = false } serde = { version = "1.0.228", features = ["derive"] } diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f481a3d..fee1409 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -32,9 +32,13 @@ to another project; a saved chat remains attached to its original project. Chat controls can rename, pin, archive, restore, compact, rebuild, export, or delete a conversation. Archived chats remain under the project's archive until restored or deleted. Deleting a chat permanently removes its transcript and KV -checkpoint. **File > Export Chat as Markdown** (`⇧⌘S`) saves the visible -conversation, including reasoning and tool results but excluding internal -system messages. +checkpoint. **File > Export Chat as Markdown** (`⇧⌘S`) or **Export Chat as +HTML** saves the visible conversation, including clearly labeled user, +assistant, thinking, embedded content, and tool sections while excluding +internal system messages. Markdown keeps embedded content as source code; +HTML renders validated SVG figures and A2UI surfaces inline in a self-contained +dark document. Tool-call JSON is collapsed by default and can be expanded from +its disclosure row. Send another prompt while a response is active to queue it for that chat. **Stop** cancels current model and agent work. You can work in several chats at diff --git a/src/a2ui.rs b/src/a2ui.rs index 53580cc..9c4532e 100644 --- a/src/a2ui.rs +++ b/src/a2ui.rs @@ -1,6 +1,9 @@ mod evaluation; +mod export; mod validation; +pub(crate) use export::surface_svg; + use evaluation::{evaluate, resolve, resolve_at}; use validation::{validate_component, validate_function}; diff --git a/src/a2ui/export.rs b/src/a2ui/export.rs new file mode 100644 index 0000000..745a494 --- /dev/null +++ b/src/a2ui/export.rs @@ -0,0 +1,881 @@ +use super::{Surface, bound_value_at, display_value, validate_surface_composition}; +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html}; +use serde_json::{Map, Value}; +use std::collections::{BTreeSet, HashMap}; +use std::fmt::Write; + +const WIDTH: usize = 900; +const HEIGHT: usize = 720; +const COLORS: [&str; 6] = [ + "#75beff", "#89d185", "#d18616", "#f14c4c", "#b180d7", "#e2c440", +]; + +pub(crate) fn surface_svg( + surface: &Surface, + images: &HashMap, +) -> Result { + validate_surface_composition(surface)?; + let renderer = Renderer { surface, images }; + let content = renderer.component("root", &surface.data, BTreeSet::new()); + let agent = surface + .surface_properties + .get("agentDisplayName") + .and_then(Value::as_str) + .unwrap_or("A2UI"); + let label = format!("{agent} surface {}", surface.id); + let mut svg = format!( + r#"
{}{}
"#, + escaped(&label), + STYLE, + escaped(agent), + escaped(&surface.id), + ); + svg.push_str(&content); + svg.push_str("
"); + Ok(svg) +} + +const STYLE: &str = r#" +*{box-sizing:border-box} .a2ui-document{height:100%;overflow:auto;background:#17181b;color:#e8e8ea;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;border:1px solid #34363d;border-radius:12px}.a2ui-document>header{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;padding:11px 14px;background:#202125;border-bottom:1px solid #34363d;color:#aeb0b6;font-size:12px}.a2ui-document>main{display:flex;flex-direction:column;gap:10px;padding:16px}.row,.column,.list{display:flex;gap:10px}.row,.list.horizontal{flex-direction:row;align-items:flex-start}.column,.list{flex-direction:column}.card,.map,.table{padding:13px;background:#202125;border:1px solid #34363d;border-radius:9px}.button,.chip{display:inline-block;padding:7px 11px;background:#30323a;border:1px solid #464951;border-radius:7px}.button.primary{background:#416a91}.button.borderless{background:transparent;border-color:transparent}.field{display:flex;flex-direction:column;gap:5px}.field-label,.muted,.chart-kind,.caption{color:#999ba3;font-size:12px}.input{min-height:34px;padding:7px 9px;background:#101114;border:1px solid #393b42;border-radius:6px;white-space:pre-wrap}.check{display:flex;gap:7px;align-items:center}.check-mark{width:18px;height:18px;text-align:center;border:1px solid #555862;border-radius:5px}.divider{height:1px;background:#3a3c43}.divider.vertical{width:1px;height:auto;min-height:32px}.metric-value{font-size:28px}.error{color:#ff8585}.bar{display:grid;grid-template-columns:minmax(80px,150px) 1fr auto;gap:8px;align-items:center}.bar-track{height:9px;overflow:hidden;background:#30323a;border-radius:5px}.bar-fill{height:100%;background:#75beff}.pie{width:150px;height:150px;border-radius:50%}.donut{position:relative}.donut:after{content:"";position:absolute;inset:34px;background:#17181b;border-radius:50%}.legend{display:flex;flex-wrap:wrap;gap:8px 14px}.swatch{display:inline-block;width:10px;height:10px;margin-right:5px;border-radius:2px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:7px 9px;border:1px solid #3a3c43;text-align:left}.timeline-item,.location,.mind-node{display:flex;gap:8px}.tabs>details{margin-top:7px;padding:7px 9px;border:1px solid #34363d;border-radius:7px}.tabs summary{cursor:pointer}.media{padding:10px;border:1px solid #34363d;border-radius:7px}.surface-image{display:block;max-width:100%;max-height:260px;margin:auto;border-radius:7px}.chart,.timeline,.mindmap{display:flex;flex-direction:column;gap:8px}h1,h2,h3,p{margin-top:0}pre{overflow:auto}a{color:#8bbceb} +"#; + +struct Renderer<'a> { + surface: &'a Surface, + images: &'a HashMap, +} + +impl Renderer<'_> { + fn component(&self, id: &str, context: &Value, mut ancestors: BTreeSet) -> String { + if !ancestors.insert(id.to_owned()) { + return format!( + "
Cyclic component {}
", + escaped(id) + ); + } + let Some(component) = self.surface.components.get(id).and_then(Value::as_object) else { + return format!( + "
Missing component {}
", + escaped(id) + ); + }; + let kind = component + .get("component") + .and_then(Value::as_str) + .unwrap_or("Unknown"); + match kind { + "Text" => { + let content = safe_markdown(&self.value(component, "text", context)); + if component.get("variant").and_then(Value::as_str) == Some("caption") { + format!("
{content}
") + } else { + content + } + } + "Image" => self.image(component, context), + "Video" | "AudioPlayer" => self.media(component, context, kind), + "Icon" => format!( + "{}", + escaped(&self.value(component, "name", context)), + icon(&self.value(component, "name", context)) + ), + "Divider" => format!( + "
", + if component.get("axis").and_then(Value::as_str) == Some("vertical") { + " vertical" + } else { + "" + } + ), + "Row" | "Column" | "List" => { + let horizontal = kind == "Row" + || (kind == "List" + && component.get("direction").and_then(Value::as_str) + == Some("horizontal")); + let class = if kind == "List" { + if horizontal { + "list horizontal" + } else { + "list" + } + } else if kind == "Row" { + "row" + } else { + "column" + }; + format!( + "
{}
", + layout_style(component), + self.children(component.get("children"), context, &ancestors) + ) + } + "Card" => format!( + "
{}
", + self.child(component, "child", context, &ancestors) + ), + "Tabs" => self.tabs(component, context, &ancestors), + "Modal" => format!( + "
{}
{}
", + self.child(component, "trigger", context, &ancestors), + self.child(component, "content", context, &ancestors) + ), + "Button" => self.with_error( + component, + format!( + "{}", + component + .get("variant") + .and_then(Value::as_str) + .unwrap_or("default"), + self.child(component, "child", context, &ancestors) + ), + ), + "TextField" => self.text_field(component, context), + "CheckBox" => self.checkbox(component, context), + "Slider" => self.slider(component, context), + "DateTimeInput" => self.with_error( + component, + self.field( + &self.value(component, "label", context), + &self.value(component, "value", context), + ), + ), + "ChoicePicker" => self.choices(component, context), + "Chart" => self.chart(component, context), + "Table" => self.table(component, context), + "Metric" => self.metric(component, context), + "Timeline" => self.timeline(component, context), + "Map" => self.map(component, context), + "MindMap" => self.mind_map(component, context), + "Form" => format!( + "
{}{}{}
", + escaped(&self.value(component, "title", context)), + self.children(component.get("children"), context, &ancestors), + escaped( + component + .get("submitLabel") + .and_then(Value::as_str) + .unwrap_or("Submit") + ) + ), + _ => format!( + "
Unsupported component {}
", + escaped(kind) + ), + } + } + + fn value(&self, component: &Map, key: &str, context: &Value) -> String { + display_value(&bound_value_at( + component.get(key), + &self.surface.data, + context, + )) + } + + fn with_error(&self, component: &Map, mut content: String) -> String { + if let Some(error) = super::first_failed_check(component, &self.surface.data) { + let _ = write!(content, "
{}
", escaped(&error)); + } + content + } + + fn child( + &self, + component: &Map, + key: &str, + context: &Value, + ancestors: &BTreeSet, + ) -> String { + component + .get(key) + .and_then(Value::as_str) + .map(|id| self.component(id, context, ancestors.clone())) + .unwrap_or_default() + } + + fn children( + &self, + children: Option<&Value>, + context: &Value, + ancestors: &BTreeSet, + ) -> String { + if let Some(children) = children.and_then(Value::as_array) { + return children + .iter() + .filter_map(Value::as_str) + .map(|id| self.weighted_component(id, context, ancestors)) + .collect(); + } + let Some(template) = children.and_then(Value::as_object) else { + return String::new(); + }; + let id = template + .get("componentId") + .and_then(Value::as_str) + .unwrap_or(""); + let path = template.get("path").and_then(Value::as_str).unwrap_or("/"); + self.surface + .data + .pointer(path) + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + .map(|(index, item)| { + super::with_template_index(index, || self.weighted_component(id, item, ancestors)) + }) + .collect() + } + + fn weighted_component( + &self, + id: &str, + context: &Value, + ancestors: &BTreeSet, + ) -> String { + let content = self.component(id, context, ancestors.clone()); + self.surface + .components + .get(id) + .and_then(|component| component.get("weight")) + .and_then(Value::as_f64) + .filter(|weight| weight.is_finite() && *weight > 0.0) + .map_or(content.clone(), |weight| { + format!("
{content}
") + }) + } + + fn tabs( + &self, + component: &Map, + context: &Value, + ancestors: &BTreeSet, + ) -> String { + let mut output = String::from("
"); + for (index, tab) in component + .get("tabs") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + { + let title = display_value(&bound_value_at( + tab.get("title"), + &self.surface.data, + context, + )); + let child = tab.get("child").and_then(Value::as_str).unwrap_or(""); + let _ = write!( + output, + "{}{}", + if index == 0 { " open" } else { "" }, + escaped(&title), + self.component(child, context, ancestors.clone()) + ); + } + output.push_str("
"); + output + } + + fn image(&self, component: &Map, context: &Value) -> String { + let url = self.value(component, "url", context); + let description = self.value(component, "description", context); + let source = self + .images + .get(&url) + .map(String::as_str) + .or_else(|| url.starts_with("data:image/").then_some(url.as_str())); + source.map_or_else( + || { + format!( + "
▧ {}
{}
", + escaped(&description), + escaped(&url) + ) + }, + |source| { + format!( + "\"{}\"/", + escaped(source), + escaped(&description) + ) + }, + ) + } + + fn media(&self, component: &Map, context: &Value, kind: &str) -> String { + format!( + "
{} {}
{}
", + if kind == "Video" { "▶" } else { "♫" }, + escaped(&self.value(component, "description", context)), + escaped(&self.value(component, "url", context)) + ) + } + + fn text_field(&self, component: &Map, context: &Value) -> String { + let mut value = self.value(component, "value", context); + if component.get("variant").and_then(Value::as_str) == Some("obscured") { + value = "••••••••".to_owned(); + } else if value.is_empty() { + value = self.value(component, "placeholder", context); + } + self.with_error( + component, + self.field(&self.value(component, "label", context), &value), + ) + } + + fn field(&self, label: &str, value: &str) -> String { + format!( + "
{}
{}
", + escaped(label), + escaped(value) + ) + } + + fn checkbox(&self, component: &Map, context: &Value) -> String { + let checked = bound_value_at(component.get("value"), &self.surface.data, context) + .as_bool() + .unwrap_or(false); + self.with_error( + component, + format!( + "
{}{}
", + if checked { "✓" } else { "" }, + escaped(&self.value(component, "label", context)) + ), + ) + } + + fn slider(&self, component: &Map, context: &Value) -> String { + let value = bound_value_at(component.get("value"), &self.surface.data, context) + .as_f64() + .unwrap_or(0.0); + let min = component.get("min").and_then(Value::as_f64).unwrap_or(0.0); + let max = component + .get("max") + .and_then(Value::as_f64) + .unwrap_or(100.0); + let percent = if max > min { + ((value - min) / (max - min) * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + self.with_error(component, format!( + "
{}
{}
", + escaped(&self.value(component, "label", context)), + escaped(&display_value(&Value::from(value))) + )) + } + + fn choices(&self, component: &Map, context: &Value) -> String { + let selected = bound_value_at(component.get("value"), &self.surface.data, context); + let multiple = + component.get("variant").and_then(Value::as_str) == Some("multipleSelection"); + let mut output = format!( + "
{}
", + escaped(&self.value(component, "label", context)) + ); + for option in component + .get("options") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let value = option.get("value").unwrap_or(&Value::Null); + let active = selected + .as_array() + .is_some_and(|values| values.contains(value)) + || (!multiple && &selected == value); + let _ = write!( + output, + "{} {}", + if active { "✓" } else { "○" }, + escaped(&option.get("label").map(display_value).unwrap_or_default()) + ); + } + output.push_str("
"); + self.with_error(component, output) + } + + fn chart(&self, component: &Map, context: &Value) -> String { + let series = bound_value_at(component.get("series"), &self.surface.data, context); + let series = series.as_array().map(Vec::as_slice).unwrap_or(&[]); + let chart_type = component + .get("chartType") + .and_then(Value::as_str) + .unwrap_or("bar"); + let mut output = format!( + "
{}{}", + escaped(&self.value(component, "title", context)), + escaped(&chart_type.to_uppercase()) + ); + if matches!(chart_type, "pie" | "donut") { + let total = series + .iter() + .filter_map(|point| point.get("value")?.as_f64()) + .filter(|value| *value > 0.0) + .sum::(); + let mut position = 0.0; + let mut stops = Vec::new(); + for (index, point) in series.iter().enumerate() { + let value = point + .get("value") + .and_then(Value::as_f64) + .unwrap_or(0.0) + .max(0.0); + let end = if total > 0.0 { + position + value / total * 100.0 + } else { + position + }; + stops.push(format!( + "{} {position:.3}% {end:.3}%", + COLORS[index % COLORS.len()] + )); + position = end; + } + let _ = write!( + output, + "
", + if chart_type == "donut" { "donut" } else { "" }, + stops.join(",") + ); + for (index, point) in series.iter().enumerate() { + let _ = write!( + output, + "{} {}", + COLORS[index % COLORS.len()], + escaped(&point.get("label").map(display_value).unwrap_or_default()), + escaped(&point.get("value").map(display_value).unwrap_or_default()) + ); + } + output.push_str("
"); + } else if chart_type == "heatmap" { + let columns = series + .iter() + .flat_map(|row| { + row.get("segments") + .and_then(Value::as_array) + .into_iter() + .flatten() + }) + .filter_map(|segment| segment.get("label").and_then(Value::as_str)) + .fold(Vec::::new(), |mut columns, label| { + if !columns.iter().any(|column| column == label) { + columns.push(label.to_owned()); + } + columns + }); + let max = series + .iter() + .flat_map(|row| { + row.get("segments") + .and_then(Value::as_array) + .into_iter() + .flatten() + }) + .filter_map(|segment| segment.get("value").and_then(Value::as_f64)) + .fold(0.0_f64, f64::max) + .max(1.0); + output.push_str(""); + for column in &columns { + let _ = write!(output, "", escaped(column)); + } + output.push_str(""); + for row in series { + let segments = row + .get("segments") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]); + let _ = write!( + output, + "", + escaped(&row.get("label").map(display_value).unwrap_or_default()) + ); + for column in &columns { + let value = segments + .iter() + .find(|segment| { + segment.get("label").and_then(Value::as_str) == Some(column) + }) + .and_then(|segment| segment.get("value").and_then(Value::as_f64)) + .unwrap_or(0.0); + let alpha = (value / max).clamp(0.0, 1.0); + let _ = write!( + output, + "", + escaped(&display_value(&Value::from(value))) + ); + } + output.push_str(""); + } + output.push_str("
{}
{}{}
"); + } else { + let max = series + .iter() + .filter_map(|point| point.get("value")?.as_f64()) + .fold(0.0_f64, f64::max) + .max(1.0); + for point in series { + let value = point.get("value").and_then(Value::as_f64).unwrap_or(0.0); + let percent = (value / max * 100.0).clamp(0.0, 100.0); + let _ = write!( + output, + "
{}
{}
", + escaped(&point.get("label").map(display_value).unwrap_or_default()), + escaped(&point.get("value").map(display_value).unwrap_or_default()) + ); + if let Some(segments) = point.get("segments").and_then(Value::as_array) { + let details = segments + .iter() + .map(|segment| { + format!( + "{} {}", + segment.get("label").map(display_value).unwrap_or_default(), + segment.get("value").map(display_value).unwrap_or_default() + ) + }) + .collect::>() + .join(" · "); + let _ = write!(output, "
{}
", escaped(&details)); + } + } + } + output.push_str("
"); + output + } + + fn table(&self, component: &Map, context: &Value) -> String { + let columns = bound_value_at(component.get("columns"), &self.surface.data, context); + let rows = bound_value_at(component.get("rows"), &self.surface.data, context); + let mut output = format!( + "
{}", + escaped(&self.value(component, "title", context)) + ); + for column in columns.as_array().into_iter().flatten() { + let _ = write!(output, "", escaped(&display_value(column))); + } + output.push_str(""); + for row in rows + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_array) + { + output.push_str(""); + for cell in row { + let _ = write!(output, "", escaped(&display_value(cell))); + } + output.push_str(""); + } + output.push_str("
{}
{}
"); + output + } + + fn metric(&self, component: &Map, context: &Value) -> String { + format!( + "
{}
{}
{} {}
", + escaped(&self.value(component, "label", context)), + escaped(&self.value(component, "value", context)), + escaped(&self.value(component, "detail", context)), + escaped(&self.value(component, "trend", context)) + ) + } + + fn timeline(&self, component: &Map, context: &Value) -> String { + let events = bound_value_at(component.get("events"), &self.surface.data, context); + let mut output = format!( + "
{}", + escaped(&self.value(component, "title", context)) + ); + for event in events.as_array().into_iter().flatten() { + let _ = write!( + output, + "
{}
{} · {} · {}
", + escaped(&event.get("title").map(display_value).unwrap_or_default()), + escaped(&event.get("time").map(display_value).unwrap_or_default()), + escaped( + &event + .get("description") + .map(display_value) + .unwrap_or_default() + ), + escaped(&event.get("status").map(display_value).unwrap_or_default()) + ); + } + output.push_str("
"); + output + } + + fn map(&self, component: &Map, context: &Value) -> String { + let locations = bound_value_at(component.get("locations"), &self.surface.data, context); + let mut output = format!( + "
{}", + escaped(&self.value(component, "title", context)) + ); + for location in locations.as_array().into_iter().flatten() { + let _ = write!( + output, + "
{}
{}, {} · {}
", + escaped(&location.get("label").map(display_value).unwrap_or_default()), + escaped( + &location + .get("latitude") + .map(display_value) + .unwrap_or_default() + ), + escaped( + &location + .get("longitude") + .map(display_value) + .unwrap_or_default() + ), + escaped( + &location + .get("detail") + .map(display_value) + .unwrap_or_default() + ) + ); + } + output.push_str("
"); + output + } + + fn mind_map(&self, component: &Map, context: &Value) -> String { + let nodes = bound_value_at(component.get("nodes"), &self.surface.data, context); + let nodes = nodes.as_array().map(Vec::as_slice).unwrap_or(&[]); + let mut output = format!( + "
{}", + escaped(&self.value(component, "title", context)) + ); + if let Some(root) = nodes.first() { + output.push_str(&mind_node(nodes, root, 0, BTreeSet::new())); + } + output.push_str("
"); + output + } +} + +fn mind_node(nodes: &[Value], node: &Value, depth: usize, mut seen: BTreeSet) -> String { + let id = node.get("id").map(display_value).unwrap_or_default(); + if !seen.insert(id) { + return "
Cycle
".to_owned(); + } + let mut output = format!( + "
{}{}
", + depth * 18, + if depth == 0 { "◆" } else { "↳" }, + escaped(&node.get("label").map(display_value).unwrap_or_default()) + ); + for child in node + .get("children") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(child) = nodes + .iter() + .find(|candidate| candidate.get("id") == Some(child)) + { + output.push_str(&mind_node(nodes, child, depth + 1, seen.clone())); + } + } + output +} + +fn layout_style(component: &Map) -> String { + let justify = match component.get("justify").and_then(Value::as_str) { + Some("center") => "center", + Some("end") => "flex-end", + Some("spaceBetween") => "space-between", + Some("spaceAround") => "space-around", + Some("spaceEvenly") => "space-evenly", + _ => "flex-start", + }; + let align = match component.get("align").and_then(Value::as_str) { + Some("center") => "center", + Some("end") => "flex-end", + Some("stretch") => "stretch", + _ => "flex-start", + }; + format!(" style=\"justify-content:{justify};align-items:{align}\"") +} + +fn safe_markdown(markdown: &str) -> String { + let options = Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS; + let parser = Parser::new_ext(markdown, options).scan(Vec::new(), |images, event| { + Some(match event { + Event::Html(html) | Event::InlineHtml(html) => Event::Text(html), + Event::Start(tag @ Tag::Image { .. }) => { + let embedded = matches!( + &tag, + Tag::Image { dest_url, .. } if dest_url.starts_with("data:image/") + ); + images.push(embedded); + if embedded { + Event::Start(tag) + } else { + Event::Text("[Image: ".into()) + } + } + Event::End(TagEnd::Image) => { + if images.pop().unwrap_or(false) { + Event::End(TagEnd::Image) + } else { + Event::Text("]".into()) + } + } + event => event, + }) + }); + let mut output = String::new(); + html::push_html(&mut output, parser); + output +} + +fn escaped(text: &str) -> String { + let mut output = String::with_capacity(text.len()); + for character in text.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '"' => output.push_str("""), + '\'' => output.push_str("'"), + _ => output.push(character), + } + } + output +} + +fn icon(name: &str) -> &'static str { + match name { + "accountCircle" | "person" => "●", + "add" => "+", + "arrowBack" => "←", + "arrowForward" => "→", + "attachFile" => "⌕", + "calendarToday" | "event" => "▣", + "call" | "phone" => "☎", + "camera" => "◉", + "check" => "✓", + "close" => "×", + "delete" => "⌫", + "download" => "⇩", + "edit" => "✎", + "error" => "⊗", + "fastForward" => "≫", + "favorite" => "♥", + "favoriteOff" => "♡", + "folder" => "▰", + "help" => "?", + "home" => "⌂", + "info" => "ⓘ", + "locationOn" => "⌖", + "lock" => "▣", + "lockOpen" => "□", + "mail" => "✉", + "menu" => "☰", + "moreVert" => "⋮", + "moreHoriz" => "…", + "notifications" => "◈", + "notificationsOff" => "◇", + "pause" => "Ⅱ", + "payment" => "¤", + "photo" => "▧", + "play" => "▶", + "print" => "▤", + "refresh" => "↻", + "rewind" => "≪", + "search" => "⌕", + "send" => "➤", + "settings" => "⚙", + "share" => "↗", + "shoppingCart" => "⌑", + "skipNext" => "▸|", + "skipPrevious" => "|◂", + "star" => "★", + "starHalf" | "starOff" => "☆", + "stop" => "■", + "upload" => "⇧", + "visibility" => "◉", + "visibilityOff" => "○", + "volumeDown" => "◖", + "volumeMute" => "◁", + "volumeOff" => "×", + "volumeUp" => "◀", + "warning" => "⚠", + _ => "•", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::BTreeMap; + + #[test] + fn static_svg_renderer_covers_the_complete_catalog() { + let entries = [ + json!({"id":"text","component":"Text","text":"Hello"}), + json!({"id":"image","component":"Image","url":"https://example.com/a.png","description":"Preview"}), + json!({"id":"icon","component":"Icon","name":"settings"}), + json!({"id":"video","component":"Video","url":"https://example.com/a.mp4"}), + json!({"id":"audio","component":"AudioPlayer","url":"https://example.com/a.mp3"}), + json!({"id":"divider","component":"Divider"}), + json!({"id":"row","component":"Row","children":[]}), + json!({"id":"list","component":"List","children":[]}), + json!({"id":"card","component":"Card","child":"text"}), + json!({"id":"modal","component":"Modal","trigger":"text","content":"text"}), + json!({"id":"tabs","component":"Tabs","tabs":[{"title":"One","child":"text"}]}), + json!({"id":"button","component":"Button","child":"text"}), + json!({"id":"field","component":"TextField","label":"Name","value":"Ada"}), + json!({"id":"check","component":"CheckBox","label":"Ready","value":true}), + json!({"id":"slider","component":"Slider","label":"Amount","value":4,"max":10}), + json!({"id":"date","component":"DateTimeInput","label":"When","value":"2026-08-31"}), + json!({"id":"choice","component":"ChoicePicker","label":"Pick","options":[{"label":"One","value":"one"}],"value":["one"]}), + json!({"id":"chart","component":"Chart","title":"Chart","chartType":"heatmap","series":[{"label":"A","segments":[{"label":"B","value":2}]}]}), + json!({"id":"table","component":"Table","title":"Table","columns":["A"],"rows":[["B"]]}), + json!({"id":"metric","component":"Metric","label":"Count","value":"2"}), + json!({"id":"timeline","component":"Timeline","title":"Timeline","events":[{"time":"Now","title":"Done"}]}), + json!({"id":"map","component":"Map","title":"Map","locations":[{"label":"Here","latitude":1,"longitude":2}]}), + json!({"id":"mind","component":"MindMap","title":"Mind","nodes":[{"id":"n","label":"Root","children":[]}]}), + json!({"id":"form","component":"Form","title":"Form","children":[]}), + ]; + let ids = entries + .iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_str)) + .map(str::to_owned) + .collect::>(); + let mut components = entries + .into_iter() + .map(|entry| (entry["id"].as_str().unwrap().to_owned(), entry)) + .collect::>(); + components.insert( + "root".into(), + json!({"id":"root","component":"Column","children":ids}), + ); + let surface = Surface { + id: "catalog".into(), + catalog_id: super::super::CATALOG_ID.into(), + surface_properties: json!({}), + send_data_model: false, + components, + data: json!({}), + owner_message_id: 1, + }; + + let svg = surface_svg(&surface, &HashMap::new()).unwrap(); + assert!(svg.starts_with(" &'static str { + match self { + Self::Markdown => "md", + Self::Html => "html", + } + } + + fn name(self) -> &'static str { + match self { + Self::Markdown => "Markdown", + Self::Html => "HTML", + } + } +} + #[derive(Debug, Clone)] pub(crate) enum Message { Noop, @@ -354,7 +376,7 @@ pub(crate) enum Message { OpenHelp, HelpOpened(window::Id), NewChat, - ExportChat, + ExportChat(ChatExportFormat), ExportChatPicked(Option, String), ModelManagerOpened(window::Id), WindowOpened(window::Id), @@ -1073,18 +1095,40 @@ impl App { return focus_composer(); } } - Message::ExportChat => { + Message::ExportChat(format) => { let Some(title) = self.active_chat_title().map(str::to_owned) else { return Task::none(); }; - let file_name = format!("{}.md", export_file_stem(&title)); - let content = export_markdown(&title, self.config.model, &self.conversation); + let dialog_title = format!("Export chat as {}", format.name()); + let file_name = format!("{}.{}", export_file_stem(&title), format.extension()); + let surfaces = self + .a2ui_history + .iter() + .chain(std::iter::once(&self.a2ui)) + .flat_map(crate::a2ui::Store::surfaces) + .cloned() + .collect::>(); + let images = self + .a2ui_images + .iter() + .filter_map(|(url, handle)| { + a2ui_image_data_url(handle).map(|data| (url.clone(), data)) + }) + .collect::>(); + let content = export_chat( + format, + &title, + self.config.model, + &self.conversation, + &surfaces, + &images, + ); return Task::perform( async move { AsyncFileDialog::new() - .set_title("Export chat as Markdown") + .set_title(&dialog_title) .set_file_name(&file_name) - .add_filter("Markdown", &["md"]) + .add_filter(format.name(), &[format.extension()]) .save_file() .await .map(|file| file.path().to_path_buf()) @@ -2027,7 +2071,12 @@ impl App { Message::OpenModelManager } Some(crate::native_menu::NativeMenuEvent::NewChat) => Message::NewChat, - Some(crate::native_menu::NativeMenuEvent::ExportChat) => Message::ExportChat, + Some(crate::native_menu::NativeMenuEvent::ExportMarkdown) => { + Message::ExportChat(ChatExportFormat::Markdown) + } + Some(crate::native_menu::NativeMenuEvent::ExportHtml) => { + Message::ExportChat(ChatExportFormat::Html) + } Some(crate::native_menu::NativeMenuEvent::ToggleSidebar) => Message::ToggleSidebar, Some(crate::native_menu::NativeMenuEvent::ShowChat) => Message::ShowChat, Some(crate::native_menu::NativeMenuEvent::ShowA2ui) => Message::ShowA2ui, @@ -2363,11 +2412,17 @@ fn export_file_stem(title: &str) -> String { } } -fn write_block_quote(output: &mut String, text: &str) { +fn write_labeled_block_quote(output: &mut String, label: &str, text: &str) { use std::fmt::Write; + let _ = writeln!(output, "> **{label}**"); + output.push_str(">\n"); for line in text.lines() { - let _ = writeln!(output, "> {line}"); + if line.is_empty() { + output.push_str(">\n"); + } else { + let _ = writeln!(output, "> {line}"); + } } } @@ -2390,10 +2445,298 @@ fn write_code_fence(output: &mut String, language: &str, text: &str) { let _ = write!(output, "\n{fence}{language}\n{text}\n{fence}\n"); } -fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage]) -> String { - use std::fmt::Write; +const EXPORT_HTML_STYLE: &str = r#" +:root { color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +body { margin: 0; background: #0d0e10; color: #e8e8ea; } +main { box-sizing: border-box; width: min(900px, 100%); margin: 0 auto; padding: 40px 20px 64px; } +.document-header { margin-bottom: 28px; } +h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.2; } +.metadata { margin: 0; color: #92949a; font-size: 13px; } +.turn { margin: 14px 0; padding: 18px 20px; background: #17181b; border: 1px solid #292b30; border-left-width: 3px; border-radius: 12px; } +.turn.user { border-left-color: #6e9bc5; } +.turn.assistant { border-left-color: #a491d3; } +.turn.tool { border-left-color: #c7975b; } +.role { margin-bottom: 12px; color: #aeb0b6; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.message-body > :first-child { margin-top: 0; } +.message-body > :last-child { margin-bottom: 0; } +.thinking, .compaction { margin: 12px 0; padding: 12px 14px; background: #111216; border: 1px solid #34363d; border-radius: 8px; color: #bbbcc2; } +.thinking-label { margin-bottom: 7px; color: #858790; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.thinking-body { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; line-height: 1.55; } +pre { overflow-x: auto; padding: 13px 15px; background: #0b0c0e; border: 1px solid #292b30; border-radius: 8px; } +code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; } +:not(pre) > code { padding: 2px 5px; background: #24262b; border-radius: 4px; } +.code-label { margin: 14px 0 6px; color: #858790; font-size: 11px; font-weight: 700; text-transform: uppercase; } +.embedded-svg { margin: 12px 0 0; padding: 16px; background: #f7f7f8; border-radius: 8px; color: #26272a; } +.embedded-svg svg { display: block; width: 100%; height: auto; max-height: 70vh; margin: 0 auto; } +.embedded-svg figcaption { margin-top: 12px; text-align: center; font-size: 13px; } +.a2ui-snapshot { display: block; width: 100%; height: auto; margin-top: 8px; aspect-ratio: 5 / 4; } +.tool-json { margin-top: 14px; } +.tool-json summary { cursor: pointer; color: #aeb0b6; font-size: 11px; font-weight: 700; text-transform: uppercase; } +a { color: #8bbceb; } +table { border-collapse: collapse; } +th, td { padding: 6px 9px; border: 1px solid #393b41; } +blockquote { margin-left: 0; padding-left: 14px; border-left: 3px solid #444750; color: #bbbcc2; } +"#; - let mut output = format!("# {title}\n"); +struct ChatExporter { + format: ChatExportFormat, + output: String, +} + +impl ChatExporter { + fn new(format: ChatExportFormat, title: &str, model: ModelChoice) -> Self { + use std::fmt::Write; + + let title = title.replace(['\r', '\n'], " "); + let output = match format { + ChatExportFormat::Markdown => format!("# {title}\n"), + ChatExportFormat::Html => { + let mut output = String::from( + "\n\n\n\n\n\n", + ); + write_html_escaped(&mut output, &title); + output.push_str("\n\n\n\n
\n

", + ); + write_html_escaped(&mut output, &title); + output.push_str("

Exported from DS4Server · "); + let _ = write!(output, "{model}"); + output.push_str("

\n"); + output + } + }; + Self { format, output } + } + + fn finish(mut self) -> String { + if self.format == ChatExportFormat::Html { + self.output.push_str("
\n\n\n"); + } + self.output + } + + fn start_turn(&mut self, role: &str, label: &str) { + use std::fmt::Write; + + match self.format { + ChatExportFormat::Markdown => { + let _ = write!(self.output, "\n## {label}\n"); + } + ChatExportFormat::Html => { + let _ = write!( + self.output, + "
\n
" + ); + write_html_escaped(&mut self.output, label); + self.output.push_str("
\n"); + } + } + } + + fn end_turn(&mut self) { + if self.format == ChatExportFormat::Html { + self.output.push_str("
\n"); + } + } + + fn markdown(&mut self, content: &str) { + use std::fmt::Write; + + match self.format { + ChatExportFormat::Markdown => { + let _ = write!(self.output, "\n{}\n", content.trim()); + } + ChatExportFormat::Html => { + self.output.push_str("
"); + self.output.push_str(&markdown_to_safe_html(content.trim())); + self.output.push_str("
\n"); + } + } + } + + fn callout(&mut self, label: &str, content: &str, class: &str) { + match self.format { + ChatExportFormat::Markdown => { + self.output.push('\n'); + write_labeled_block_quote(&mut self.output, label, content); + } + ChatExportFormat::Html => { + use std::fmt::Write; + + let _ = write!( + self.output, + "\n"); + } + } + } + + fn code_block(&mut self, label: &str, language: &str, content: &str) { + use std::fmt::Write; + + match self.format { + ChatExportFormat::Markdown => { + let _ = write!(self.output, "\n### {label}\n"); + write_code_fence(&mut self.output, language, content); + } + ChatExportFormat::Html => { + if language == "json" { + self.output + .push_str("
"); + write_html_escaped(&mut self.output, label); + self.output + .push_str("
");
+                    write_html_escaped(&mut self.output, content);
+                    self.output.push_str("
\n"); + return; + } + self.output.push_str("
"); + write_html_escaped(&mut self.output, label); + let _ = write!( + self.output, + "
"
+                );
+                write_html_escaped(&mut self.output, content);
+                self.output.push_str("
\n"); + } + } + } + + fn svg(&mut self, svg: &str, alt: &str) { + use std::fmt::Write; + + match self.format { + ChatExportFormat::Markdown => { + let _ = write!(self.output, "\n### Embedded SVG\n\n{alt}\n"); + write_code_fence(&mut self.output, "svg", svg); + } + ChatExportFormat::Html => { + self.output + .push_str("
\n"); + self.output.push_str(svg); + self.output.push_str("\n
"); + write_html_escaped(&mut self.output, alt); + self.output.push_str("
\n
\n"); + } + } + } + + fn a2ui_surface(&mut self, surface: &crate::a2ui::Surface, images: &HashMap) { + if self.format != ChatExportFormat::Html { + return; + } + self.output + .push_str("
Embedded A2UI surface
\n"); + match crate::a2ui::surface_svg(surface, images) { + Ok(svg) => self.output.push_str(&svg), + Err(error) => { + self.output.push_str(""); + } + } + self.output.push('\n'); + } +} + +fn a2ui_image_data_url(handle: &iced::widget::image::Handle) -> Option { + use base64::Engine; + use iced::widget::image::Handle; + + let bytes = match handle { + Handle::Path(_, path) => fs::read(path).ok()?, + Handle::Bytes(_, bytes) => bytes.to_vec(), + Handle::Rgba { + width, + height, + pixels, + .. + } => { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(&mut bytes, *width, *height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header().ok()?.write_image_data(pixels).ok()?; + bytes + } + }; + let mime = image::guess_format(&bytes).ok()?.to_mime_type(); + Some(format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +fn write_html_escaped(output: &mut String, text: &str) { + for character in text.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '"' => output.push_str("""), + '\'' => output.push_str("'"), + _ => output.push(character), + } + } +} + +fn markdown_to_safe_html(markdown: &str) -> String { + use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html}; + + let options = Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS; + let parser = Parser::new_ext(markdown, options).scan(Vec::new(), |images, event| { + Some(match event { + Event::Html(html) | Event::InlineHtml(html) => Event::Text(html), + Event::Start(tag @ Tag::Image { .. }) => { + let embedded = matches!( + &tag, + Tag::Image { dest_url, .. } if dest_url.starts_with("data:image/") + ); + images.push(embedded); + if embedded { + Event::Start(tag) + } else { + Event::Text("[Image: ".into()) + } + } + Event::End(TagEnd::Image) => { + if images.pop().unwrap_or(false) { + Event::End(TagEnd::Image) + } else { + Event::Text("]".into()) + } + } + event => event, + }) + }); + let mut output = String::new(); + html::push_html(&mut output, parser); + output +} + +fn export_chat( + format: ChatExportFormat, + title: &str, + model: ModelChoice, + conversation: &[ChatMessage], + surfaces: &[crate::a2ui::Surface], + images: &HashMap, +) -> String { + let mut export = ChatExporter::new(format, title, model); let mut index = 0; while let Some(message) = conversation.get(index) { index += 1; @@ -2401,30 +2744,36 @@ fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage] continue; } if message.compaction { - let _ = write!( - output, - "\n> Context compacted: {}\n", - message.content.trim() - ); + export.callout("Context compacted", message.content.trim(), "compaction"); continue; } - let label = if message.user { - "You" - } else if message.tool { - "Tool" + if message.tool { + export.start_turn("tool", "Tool"); + export.code_block("Output", "text", message.content.trim()); + export.end_turn(); + continue; + } + + let (role, label) = if message.user { + ("user", "User") } else { - "DS4" + ("assistant", "Assistant · DS4") }; - let _ = write!(output, "\n## {label}\n"); + export.start_turn(role, label); if let Some(reasoning) = message.reasoning.as_deref().filter(|text| !text.is_empty()) { - output.push('\n'); - write_block_quote(&mut output, reasoning); + export.callout("Thinking", reasoning, "thinking"); } let visible = crate::agent::visible_content(&message.content); - let visible = crate::a2ui::transcript_fallback(visible); - let _ = write!(output, "\n{}\n", visible.trim()); + export.markdown(visible); + for surface in surfaces + .iter() + .filter(|surface| surface.owner_message_id == message.id) + { + export.a2ui_surface(surface, images); + } + export.end_turn(); - if message.user || message.tool { + if message.user { continue; } let tool_result = conversation.get(index).filter(|message| message.tool); @@ -2438,10 +2787,18 @@ fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage] continue; } index += usize::from(tool_result.is_some()); - for (tool_index, card) in cards.into_iter().enumerate() { - if let Some(presentation) = crate::agent::svg_presentation(&card) { - let _ = write!(output, "\n## Figure\n\n{}\n", presentation.alt); - write_code_fence(&mut output, "svg", &presentation.svg); + for card in cards { + export.start_turn("tool", &format!("Tool · {}", card.call.name)); + let presentation = crate::agent::svg_presentation(&card); + if format == ChatExportFormat::Html + && (card.call.name != "present_svg" || presentation.is_some()) + { + let arguments = serde_json::to_string_pretty(&card.call.arguments) + .unwrap_or_else(|_| "{}".to_owned()); + export.code_block("Tool call", "json", &arguments); + } + if let Some(presentation) = presentation { + export.svg(&presentation.svg, &presentation.alt); } else if card.call.name == "bash" && let Some(command) = card .call @@ -2450,24 +2807,28 @@ fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage] .and_then(|value| value.as_str()) { if let Some(reason) = card.approval_reason { - output.push('\n'); - write_block_quote(&mut output, &reason); + export.callout("Approval", &reason, "thinking"); + } + if format == ChatExportFormat::Markdown { + export.code_block("Input", "bash", command.trim()); } - write_code_fence(&mut output, "bash", command.trim()); if let Some(result) = card.result { - write_code_fence(&mut output, "text", &result); + export.code_block("Output", "text", &result); + } + } else { + if format == ChatExportFormat::Markdown && card.call.name != "present_svg" { + let arguments = serde_json::to_string_pretty(&card.call.arguments) + .unwrap_or_else(|_| "{}".to_owned()); + export.code_block("Input", "json", &arguments); + } + if let Some(result) = card.result { + export.code_block("Output", "text", &result); } - } else if let Some(result) = card.result { - let _ = write!( - output, - "\n## Tool\n\nTool result {} ({}):\n{result}\n", - tool_index + 1, - card.call.name - ); } + export.end_turn(); } } - output + export.finish() } pub(crate) fn application_support_path() -> PathBuf { @@ -2949,6 +3310,10 @@ mod tests { false, false, r#"I will check. + +```a2ui +{"surfaceUpdate":{"surfaceId":"status"}} +``` <|DSML|tool_calls> <|DSML|invoke name="bash"><|DSML|parameter name="command" string="true">cat <<'EOF' ``` @@ -2957,7 +3322,8 @@ EOF ); assistant.reasoning = Some("First thought.\nSecond thought.".into()); assistant.tool_approval_reasons = vec![Some("This only prints text.".into())]; - let exported = export_markdown( + let exported = export_chat( + ChatExportFormat::Markdown, "A chat", ModelChoice::DeepSeekV4Flash0731, &[ @@ -2966,10 +3332,12 @@ EOF assistant, message(false, true, false, "Tool result 1 (bash):\n```\none\ntwo\n"), ], + &[], + &HashMap::new(), ); assert_eq!( exported, - "# A chat\n\n## You\n\nhello\n\n## DS4\n\n> First thought.\n> Second thought.\n\nI will check.\n\n> This only prints text.\n\n````bash\ncat <<'EOF'\n```\nEOF\n````\n\n````text\n```\none\ntwo\n````\n" + "# A chat\n\n## User\n\nhello\n\n## Assistant · DS4\n\n> **Thinking**\n>\n> First thought.\n> Second thought.\n\nI will check.\n\n```a2ui\n{\"surfaceUpdate\":{\"surfaceId\":\"status\"}}\n```\n\n## Tool · bash\n\n> **Approval**\n>\n> This only prints text.\n\n### Input\n\n````bash\ncat <<'EOF'\n```\nEOF\n````\n\n### Output\n\n````text\n```\none\ntwo\n````\n" ); assert!(!exported.contains("private")); } @@ -3010,18 +3378,135 @@ EOF "Tool result 1 (present_svg):\nSVG presented inline: A solid rectangle\nTool result 2 (present_svg):\nTool error: tool=present_svg code=execution_failed field=$ expected=successful tool execution received=SVG is malformed XML\n", ); - let exported = export_markdown( + let exported = export_chat( + ChatExportFormat::Markdown, "SVG chat", ModelChoice::DeepSeekV4Flash0731, &[assistant, result], + &[], + &HashMap::new(), ); - assert!(exported.contains("## Figure\n\nA solid rectangle\n\n```svg\n")); + assert!(exported.contains( + "## Tool · present_svg\n\n### Embedded SVG\n\nA solid rectangle\n\n```svg\n" + )); assert_eq!(exported.matches(svg).count(), 1); - assert!(exported.contains("Tool result 2 (present_svg):\nTool error:")); + assert!(exported.contains("## Tool · present_svg\n\n### Output\n\n```text\nTool error:")); assert!(!exported.contains("<|DSML|")); assert!(!exported.contains("not svg")); } + #[test] + fn chat_html_export_is_safe_standalone_and_renders_embedded_surfaces() { + let message = |id, user, tool, content: &str| ChatMessage { + id, + user, + tool, + system: false, + compaction: false, + compaction_tail_start: None, + generation_stats: None, + reasoning: None, + reasoning_complete: true, + reasoning_open: false, + content: content.into(), + model_content: None, + tool_approval_reasons: Vec::new(), + instruction_metadata: None, + markdown: markdown::Content::new(), + transcript: text_editor::Content::new(), + a2ui_lines_processed: 0, + a2ui_errors: Vec::new(), + a2ui_replies: Vec::new(), + a2ui_open_urls: Vec::new(), + }; + let svg = r#""#; + let a2ui = serde_json::json!({ + "version": crate::a2ui::VERSION, + "createSurface": { + "surfaceId": "handoff", + "catalogId": crate::a2ui::CATALOG_ID, + "surfaceProperties": {"agentDisplayName": "A2UI handoff"}, + "components": [ + {"id": "root", "component": "Card", "child": "body"}, + {"id": "body", "component": "Column", "children": ["title", "metric", "chart"]}, + {"id": "title", "component": "Text", "text": "**Live presentation**"}, + {"id": "metric", "component": "Metric", "label": "Requests", "value": "42", "detail": "complete"}, + {"id": "chart", "component": "Chart", "title": "Progress", "chartType": "bar", "series": [{"label": "Done", "value": 42}]} + ] + } + }) + .to_string(); + let mut assistant = message( + 2, + false, + false, + &format!( + "**Answer** \n\n![Remote](https://example.com/image.png)\n\n```a2ui\n{a2ui}\n```<|DSML|tool_calls><|DSML|invoke name=\"present_svg\"><|DSML|parameter name=\"svg\" string=\"true\">{svg}<|DSML|parameter name=\"alt\" string=\"true\">Diagram & chart<|DSML|invoke name=\"read\"><|DSML|parameter name=\"path\" string=\"true\">notes.md<|DSML|invoke name=\"present_svg\"><|DSML|parameter name=\"svg\" string=\"true\">not svg<|DSML|parameter name=\"alt\" string=\"true\">Invalid" + ), + ); + assistant.reasoning = Some("Private stays visible but inert.".into()); + let result = message( + 3, + false, + true, + "Tool result 1 (present_svg):\nSVG presented inline: Diagram & chart\nTool result 2 (read):\ncontents\nTool result 3 (present_svg):\nTool error: malformed SVG", + ); + let mut store = crate::a2ui::Store::default(); + store.apply_raw(&a2ui, assistant.id).unwrap(); + let surfaces = store.surfaces().cloned().collect::>(); + + let exported = export_chat( + ChatExportFormat::Html, + "Handoff ", + ModelChoice::DeepSeekV4Flash0731, + &[message(1, true, false, "Hello"), assistant, result], + &surfaces, + &HashMap::new(), + ); + assert!(exported.starts_with("")); + assert!(exported.contains("Content-Security-Policy")); + assert!(exported.contains("
")); + assert!(exported.contains("
")); + assert!(exported.contains("
")); + assert!(exported.contains("class=\"thinking\"")); + assert!(exported.contains("class=\"language-a2ui\"")); + assert!(exported.contains("class=\"a2ui-snapshot\"")); + assert!( + exported.contains(".embedded-svg svg { display: block; width: 100%; height: auto;") + ); + assert!(exported.contains("A2UI handoff")); + assert!(exported.contains("Live presentation")); + assert!(exported.contains("Requests")); + assert!(exported.contains("[Image: Remote]")); + assert!(!exported.contains("https://example.com/image.png")); + assert_eq!( + exported + .matches("
Tool call") + .count(), + 2 + ); + assert!(!exported.contains("
alert")); + assert!(exported.contains("
Result { false, Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyN)), ); - let export_chat = MenuItem::with_id( - EXPORT_CHAT, + let export_markdown = MenuItem::with_id( + EXPORT_MARKDOWN, "Export Chat as Markdown…", false, Some(Accelerator::new( @@ -93,6 +96,12 @@ pub(crate) fn install() -> Result { Code::KeyS, )), ); + let export_html = MenuItem::with_id( + EXPORT_HTML, + "Export Chat as HTML…", + false, + None::, + ); let sidebar = CheckMenuItem::with_id( TOGGLE_SIDEBAR, "Show Sidebar", @@ -135,7 +144,8 @@ pub(crate) fn install() -> Result { .map_err(|error| error.to_string())?; file.append_items(&[ &new_chat, - &export_chat, + &export_markdown, + &export_html, &PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None), ]) @@ -177,7 +187,8 @@ pub(crate) fn install() -> Result { Ok(NativeMenu { _menu: menu, new_chat, - export_chat, + export_markdown, + export_html, sidebar, chat, a2ui, @@ -202,7 +213,8 @@ impl NativeMenu { edit: crate::native_edit::EditAvailability, ) { self.new_chat.set_enabled(project_active); - self.export_chat.set_enabled(chat_active); + self.export_markdown.set_enabled(chat_active); + self.export_html.set_enabled(chat_active); self.sidebar.set_checked(sidebar_visible); self.chat.set_checked(detail_tab == DetailTab::Chat); self.a2ui.set_checked(detail_tab == DetailTab::A2ui); @@ -223,7 +235,8 @@ pub(crate) fn next_event() -> Option { PREFERENCES => NativeMenuEvent::Preferences, MODEL_MANAGER => NativeMenuEvent::ModelManager, NEW_CHAT => NativeMenuEvent::NewChat, - EXPORT_CHAT => NativeMenuEvent::ExportChat, + EXPORT_MARKDOWN => NativeMenuEvent::ExportMarkdown, + EXPORT_HTML => NativeMenuEvent::ExportHtml, TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar, SHOW_CHAT => NativeMenuEvent::ShowChat, SHOW_A2UI => NativeMenuEvent::ShowA2ui,