Add standalone session exports

This commit is contained in:
Georg Bauer
2026-08-31 14:07:29 +02:00
parent 5c74739f65
commit 85138c49d4
7 changed files with 1451 additions and 61 deletions

2
Cargo.lock generated
View File

@@ -810,6 +810,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
name = "ds4-server" name = "ds4-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"cc", "cc",
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
@@ -821,6 +822,7 @@ dependencies = [
"memmap2", "memmap2",
"muda", "muda",
"png 0.17.16", "png 0.17.16",
"pulldown-cmark 0.12.2",
"regex", "regex",
"rfd", "rfd",
"serde", "serde",

View File

@@ -12,6 +12,7 @@ build = "build.rs"
cc = "1.3.0" cc = "1.3.0"
[dependencies] [dependencies]
base64 = "0.22.1"
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] } diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] }
diesel_migrations = "2.3.2" diesel_migrations = "2.3.2"
git2 = { version = "0.21.0", features = ["https", "vendored-libgit2", "vendored-openssl"] } 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" libc = "0.2.186"
memmap2 = "0.9.11" memmap2 = "0.9.11"
png = "0.17.16" png = "0.17.16"
pulldown-cmark = "0.12.2"
regex = "1.13.1" regex = "1.13.1"
rfd = { version = "0.15.4", default-features = false } rfd = { version = "0.15.4", default-features = false }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }

View File

@@ -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 Chat controls can rename, pin, archive, restore, compact, rebuild, export, or
delete a conversation. Archived chats remain under the project's archive until delete a conversation. Archived chats remain under the project's archive until
restored or deleted. Deleting a chat permanently removes its transcript and KV restored or deleted. Deleting a chat permanently removes its transcript and KV
checkpoint. **File > Export Chat as Markdown** (`⇧⌘S`) saves the visible checkpoint. **File > Export Chat as Markdown** (`⇧⌘S`) or **Export Chat as
conversation, including reasoning and tool results but excluding internal HTML** saves the visible conversation, including clearly labeled user,
system messages. 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. 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 **Stop** cancels current model and agent work. You can work in several chats at

View File

@@ -1,6 +1,9 @@
mod evaluation; mod evaluation;
mod export;
mod validation; mod validation;
pub(crate) use export::surface_svg;
use evaluation::{evaluate, resolve, resolve_at}; use evaluation::{evaluate, resolve, resolve_at};
use validation::{validate_component, validate_function}; use validation::{validate_component, validate_function};

881
src/a2ui/export.rs Normal file
View File

@@ -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<String, String>,
) -> Result<String, String> {
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#"<svg xmlns="http://www.w3.org/2000/svg" class="a2ui-snapshot" viewBox="0 0 {WIDTH} {HEIGHT}" role="img" aria-label="{}"><foreignObject x="0" y="0" width="{WIDTH}" height="{HEIGHT}"><div xmlns="http://www.w3.org/1999/xhtml" class="a2ui-document"><style>{}</style><header><strong>{}</strong><span>{}</span></header><main>"#,
escaped(&label),
STYLE,
escaped(agent),
escaped(&surface.id),
);
svg.push_str(&content);
svg.push_str("</main></div></foreignObject></svg>");
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<String, String>,
}
impl Renderer<'_> {
fn component(&self, id: &str, context: &Value, mut ancestors: BTreeSet<String>) -> String {
if !ancestors.insert(id.to_owned()) {
return format!(
"<div class=\"error\">Cyclic component {}</div>",
escaped(id)
);
}
let Some(component) = self.surface.components.get(id).and_then(Value::as_object) else {
return format!(
"<div class=\"error\">Missing component {}</div>",
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!("<div class=\"caption\">{content}</div>")
} else {
content
}
}
"Image" => self.image(component, context),
"Video" | "AudioPlayer" => self.media(component, context, kind),
"Icon" => format!(
"<span aria-label=\"{}\">{}</span>",
escaped(&self.value(component, "name", context)),
icon(&self.value(component, "name", context))
),
"Divider" => format!(
"<div class=\"divider{}\"></div>",
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!(
"<div class=\"{class}\"{}>{}</div>",
layout_style(component),
self.children(component.get("children"), context, &ancestors)
)
}
"Card" => format!(
"<div class=\"card\">{}</div>",
self.child(component, "child", context, &ancestors)
),
"Tabs" => self.tabs(component, context, &ancestors),
"Modal" => format!(
"<details><summary>{}</summary><div class=\"card\">{}</div></details>",
self.child(component, "trigger", context, &ancestors),
self.child(component, "content", context, &ancestors)
),
"Button" => self.with_error(
component,
format!(
"<span class=\"button {}\">{}</span>",
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!(
"<section class=\"card column\"><strong>{}</strong>{}<span class=\"button\">{}</span></section>",
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!(
"<div class=\"error\">Unsupported component {}</div>",
escaped(kind)
),
}
}
fn value(&self, component: &Map<String, Value>, key: &str, context: &Value) -> String {
display_value(&bound_value_at(
component.get(key),
&self.surface.data,
context,
))
}
fn with_error(&self, component: &Map<String, Value>, mut content: String) -> String {
if let Some(error) = super::first_failed_check(component, &self.surface.data) {
let _ = write!(content, "<div class=\"error\">{}</div>", escaped(&error));
}
content
}
fn child(
&self,
component: &Map<String, Value>,
key: &str,
context: &Value,
ancestors: &BTreeSet<String>,
) -> 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>,
) -> 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>,
) -> 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!("<div style=\"flex:{weight} 1 0\">{content}</div>")
})
}
fn tabs(
&self,
component: &Map<String, Value>,
context: &Value,
ancestors: &BTreeSet<String>,
) -> String {
let mut output = String::from("<div class=\"tabs\">");
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,
"<details{}><summary>{}</summary>{}</details>",
if index == 0 { " open" } else { "" },
escaped(&title),
self.component(child, context, ancestors.clone())
);
}
output.push_str("</div>");
output
}
fn image(&self, component: &Map<String, Value>, 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!(
"<div class=\"media\">▧ {}<div class=\"muted\">{}</div></div>",
escaped(&description),
escaped(&url)
)
},
|source| {
format!(
"<img class=\"surface-image\" src=\"{}\" alt=\"{}\"/>",
escaped(source),
escaped(&description)
)
},
)
}
fn media(&self, component: &Map<String, Value>, context: &Value, kind: &str) -> String {
format!(
"<div class=\"media\">{} {}<div class=\"muted\">{}</div></div>",
if kind == "Video" { "" } else { "" },
escaped(&self.value(component, "description", context)),
escaped(&self.value(component, "url", context))
)
}
fn text_field(&self, component: &Map<String, Value>, 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!(
"<div class=\"field\"><span class=\"field-label\">{}</span><div class=\"input\">{}</div></div>",
escaped(label),
escaped(value)
)
}
fn checkbox(&self, component: &Map<String, Value>, 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!(
"<div class=\"check\"><span class=\"check-mark\">{}</span>{}</div>",
if checked { "" } else { "" },
escaped(&self.value(component, "label", context))
),
)
}
fn slider(&self, component: &Map<String, Value>, 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!(
"<div class=\"field\"><span class=\"field-label\">{}</span><div class=\"bar-track\"><div class=\"bar-fill\" style=\"width:{percent:.2}%\"></div></div><span>{}</span></div>",
escaped(&self.value(component, "label", context)),
escaped(&display_value(&Value::from(value)))
))
}
fn choices(&self, component: &Map<String, Value>, 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!(
"<div class=\"field\"><span class=\"field-label\">{}</span><div class=\"row\">",
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,
"<span class=\"chip\">{} {}</span>",
if active { "" } else { "" },
escaped(&option.get("label").map(display_value).unwrap_or_default())
);
}
output.push_str("</div></div>");
self.with_error(component, output)
}
fn chart(&self, component: &Map<String, Value>, 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!(
"<section class=\"chart\"><strong>{}</strong><span class=\"chart-kind\">{}</span>",
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::<f64>();
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,
"<div class=\"row\"><div class=\"pie {}\" style=\"background:conic-gradient({})\"></div><div class=\"legend\">",
if chart_type == "donut" { "donut" } else { "" },
stops.join(",")
);
for (index, point) in series.iter().enumerate() {
let _ = write!(
output,
"<span><i class=\"swatch\" style=\"background:{}\"></i>{} {}</span>",
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("</div></div>");
} 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::<String>::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("<table class=\"table\"><thead><tr><th></th>");
for column in &columns {
let _ = write!(output, "<th>{}</th>", escaped(column));
}
output.push_str("</tr></thead><tbody>");
for row in series {
let segments = row
.get("segments")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
let _ = write!(
output,
"<tr><th>{}</th>",
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,
"<td style=\"background:rgba(183,72,72,{alpha:.3})\">{}</td>",
escaped(&display_value(&Value::from(value)))
);
}
output.push_str("</tr>");
}
output.push_str("</tbody></table>");
} 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,
"<div class=\"bar\"><span>{}</span><div class=\"bar-track\"><div class=\"bar-fill\" style=\"width:{percent:.2}%\"></div></div><span>{}</span></div>",
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::<Vec<_>>()
.join(" · ");
let _ = write!(output, "<div class=\"muted\">{}</div>", escaped(&details));
}
}
}
output.push_str("</section>");
output
}
fn table(&self, component: &Map<String, Value>, 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!(
"<section><strong>{}</strong><table class=\"table\"><thead><tr>",
escaped(&self.value(component, "title", context))
);
for column in columns.as_array().into_iter().flatten() {
let _ = write!(output, "<th>{}</th>", escaped(&display_value(column)));
}
output.push_str("</tr></thead><tbody>");
for row in rows
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_array)
{
output.push_str("<tr>");
for cell in row {
let _ = write!(output, "<td>{}</td>", escaped(&display_value(cell)));
}
output.push_str("</tr>");
}
output.push_str("</tbody></table></section>");
output
}
fn metric(&self, component: &Map<String, Value>, context: &Value) -> String {
format!(
"<section><div class=\"muted\">{}</div><div class=\"metric-value\">{}</div><div class=\"muted\">{} {}</div></section>",
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<String, Value>, context: &Value) -> String {
let events = bound_value_at(component.get("events"), &self.surface.data, context);
let mut output = format!(
"<section class=\"timeline\"><strong>{}</strong>",
escaped(&self.value(component, "title", context))
);
for event in events.as_array().into_iter().flatten() {
let _ = write!(
output,
"<div class=\"timeline-item\"><span>●</span><div><strong>{}</strong><div class=\"muted\">{} · {} · {}</div></div></div>",
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("</section>");
output
}
fn map(&self, component: &Map<String, Value>, context: &Value) -> String {
let locations = bound_value_at(component.get("locations"), &self.surface.data, context);
let mut output = format!(
"<section class=\"map\"><strong>{}</strong>",
escaped(&self.value(component, "title", context))
);
for location in locations.as_array().into_iter().flatten() {
let _ = write!(
output,
"<div class=\"location\"><span>⌖</span><div><strong>{}</strong><div class=\"muted\">{}, {} · {}</div></div></div>",
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("</section>");
output
}
fn mind_map(&self, component: &Map<String, Value>, 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!(
"<section class=\"mindmap\"><strong>{}</strong>",
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("</section>");
output
}
}
fn mind_node(nodes: &[Value], node: &Value, depth: usize, mut seen: BTreeSet<String>) -> String {
let id = node.get("id").map(display_value).unwrap_or_default();
if !seen.insert(id) {
return "<div class=\"muted\">Cycle</div>".to_owned();
}
let mut output = format!(
"<div class=\"mind-node\" style=\"padding-left:{}px\"><span>{}</span><span>{}</span></div>",
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, Value>) -> 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("&amp;"),
'<' => output.push_str("&lt;"),
'>' => output.push_str("&gt;"),
'"' => output.push_str("&quot;"),
'\'' => output.push_str("&#39;"),
_ => 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::<Vec<_>>();
let mut components = entries
.into_iter()
.map(|entry| (entry["id"].as_str().unwrap().to_owned(), entry))
.collect::<BTreeMap<_, _>>();
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("<svg"));
assert!(!svg.contains("Unsupported component"));
assert!(svg.contains("HEATMAP"));
assert!(svg.contains("Submit"));
}
}

View File

@@ -341,6 +341,28 @@ pub(super) struct MetricsPoint {
pub(super) ssd_wait_ms_per_second: f32, pub(super) ssd_wait_ms_per_second: f32,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ChatExportFormat {
Markdown,
Html,
}
impl ChatExportFormat {
fn extension(self) -> &'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)] #[derive(Debug, Clone)]
pub(crate) enum Message { pub(crate) enum Message {
Noop, Noop,
@@ -354,7 +376,7 @@ pub(crate) enum Message {
OpenHelp, OpenHelp,
HelpOpened(window::Id), HelpOpened(window::Id),
NewChat, NewChat,
ExportChat, ExportChat(ChatExportFormat),
ExportChatPicked(Option<PathBuf>, String), ExportChatPicked(Option<PathBuf>, String),
ModelManagerOpened(window::Id), ModelManagerOpened(window::Id),
WindowOpened(window::Id), WindowOpened(window::Id),
@@ -1073,18 +1095,40 @@ impl App {
return focus_composer(); return focus_composer();
} }
} }
Message::ExportChat => { Message::ExportChat(format) => {
let Some(title) = self.active_chat_title().map(str::to_owned) else { let Some(title) = self.active_chat_title().map(str::to_owned) else {
return Task::none(); return Task::none();
}; };
let file_name = format!("{}.md", export_file_stem(&title)); let dialog_title = format!("Export chat as {}", format.name());
let content = export_markdown(&title, self.config.model, &self.conversation); 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::<Vec<_>>();
let images = self
.a2ui_images
.iter()
.filter_map(|(url, handle)| {
a2ui_image_data_url(handle).map(|data| (url.clone(), data))
})
.collect::<HashMap<_, _>>();
let content = export_chat(
format,
&title,
self.config.model,
&self.conversation,
&surfaces,
&images,
);
return Task::perform( return Task::perform(
async move { async move {
AsyncFileDialog::new() AsyncFileDialog::new()
.set_title("Export chat as Markdown") .set_title(&dialog_title)
.set_file_name(&file_name) .set_file_name(&file_name)
.add_filter("Markdown", &["md"]) .add_filter(format.name(), &[format.extension()])
.save_file() .save_file()
.await .await
.map(|file| file.path().to_path_buf()) .map(|file| file.path().to_path_buf())
@@ -2027,7 +2071,12 @@ impl App {
Message::OpenModelManager Message::OpenModelManager
} }
Some(crate::native_menu::NativeMenuEvent::NewChat) => Message::NewChat, 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::ToggleSidebar) => Message::ToggleSidebar,
Some(crate::native_menu::NativeMenuEvent::ShowChat) => Message::ShowChat, Some(crate::native_menu::NativeMenuEvent::ShowChat) => Message::ShowChat,
Some(crate::native_menu::NativeMenuEvent::ShowA2ui) => Message::ShowA2ui, Some(crate::native_menu::NativeMenuEvent::ShowA2ui) => Message::ShowA2ui,
@@ -2363,13 +2412,19 @@ 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; use std::fmt::Write;
let _ = writeln!(output, "> **{label}**");
output.push_str(">\n");
for line in text.lines() { for line in text.lines() {
if line.is_empty() {
output.push_str(">\n");
} else {
let _ = writeln!(output, "> {line}"); let _ = writeln!(output, "> {line}");
} }
} }
}
fn write_code_fence(output: &mut String, language: &str, text: &str) { fn write_code_fence(output: &mut String, language: &str, text: &str) {
use std::fmt::Write; use std::fmt::Write;
@@ -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"); let _ = write!(output, "\n{fence}{language}\n{text}\n{fence}\n");
} }
fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage]) -> String { 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; }
"#;
struct ChatExporter {
format: ChatExportFormat,
output: String,
}
impl ChatExporter {
fn new(format: ChatExportFormat, title: &str, model: ModelChoice) -> Self {
use std::fmt::Write; use std::fmt::Write;
let mut output = format!("# {title}\n"); let title = title.replace(['\r', '\n'], " ");
let output = match format {
ChatExportFormat::Markdown => format!("# {title}\n"),
ChatExportFormat::Html => {
let mut output = String::from(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'\">\n<title>",
);
write_html_escaped(&mut output, &title);
output.push_str("</title>\n<style>");
output.push_str(EXPORT_HTML_STYLE);
output.push_str(
"</style>\n</head>\n<body>\n<main>\n<header class=\"document-header\"><h1>",
);
write_html_escaped(&mut output, &title);
output.push_str("</h1><p class=\"metadata\">Exported from DS4Server · ");
let _ = write!(output, "{model}");
output.push_str("</p></header>\n");
output
}
};
Self { format, output }
}
fn finish(mut self) -> String {
if self.format == ChatExportFormat::Html {
self.output.push_str("</main>\n</body>\n</html>\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,
"<section class=\"turn {role}\">\n<div class=\"role\">"
);
write_html_escaped(&mut self.output, label);
self.output.push_str("</div>\n");
}
}
}
fn end_turn(&mut self) {
if self.format == ChatExportFormat::Html {
self.output.push_str("</section>\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("<div class=\"message-body\">");
self.output.push_str(&markdown_to_safe_html(content.trim()));
self.output.push_str("</div>\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,
"<aside class=\"{class}\"><div class=\"thinking-label\">"
);
write_html_escaped(&mut self.output, label);
self.output.push_str("</div><div class=\"thinking-body\">");
write_html_escaped(&mut self.output, content);
self.output.push_str("</div></aside>\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("<details class=\"tool-json\"><summary>");
write_html_escaped(&mut self.output, label);
self.output
.push_str("</summary><pre><code class=\"language-json\">");
write_html_escaped(&mut self.output, content);
self.output.push_str("</code></pre></details>\n");
return;
}
self.output.push_str("<div class=\"code-label\">");
write_html_escaped(&mut self.output, label);
let _ = write!(
self.output,
"</div><pre><code class=\"language-{language}\">"
);
write_html_escaped(&mut self.output, content);
self.output.push_str("</code></pre>\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("<figure class=\"embedded-svg\" role=\"img\" aria-label=\"");
write_html_escaped(&mut self.output, alt);
self.output.push_str("\">\n");
self.output.push_str(svg);
self.output.push_str("\n<figcaption>");
write_html_escaped(&mut self.output, alt);
self.output.push_str("</figcaption>\n</figure>\n");
}
}
}
fn a2ui_surface(&mut self, surface: &crate::a2ui::Surface, images: &HashMap<String, String>) {
if self.format != ChatExportFormat::Html {
return;
}
self.output
.push_str("<div class=\"code-label\">Embedded A2UI surface</div>\n");
match crate::a2ui::surface_svg(surface, images) {
Ok(svg) => self.output.push_str(&svg),
Err(error) => {
self.output.push_str("<aside class=\"compaction\">");
write_html_escaped(
&mut self.output,
&format!("A2UI surface unavailable: {error}"),
);
self.output.push_str("</aside>");
}
}
self.output.push('\n');
}
}
fn a2ui_image_data_url(handle: &iced::widget::image::Handle) -> Option<String> {
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("&amp;"),
'<' => output.push_str("&lt;"),
'>' => output.push_str("&gt;"),
'"' => output.push_str("&quot;"),
'\'' => output.push_str("&#39;"),
_ => 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, String>,
) -> String {
let mut export = ChatExporter::new(format, title, model);
let mut index = 0; let mut index = 0;
while let Some(message) = conversation.get(index) { while let Some(message) = conversation.get(index) {
index += 1; index += 1;
@@ -2401,30 +2744,36 @@ fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage]
continue; continue;
} }
if message.compaction { if message.compaction {
let _ = write!( export.callout("Context compacted", message.content.trim(), "compaction");
output,
"\n> Context compacted: {}\n",
message.content.trim()
);
continue; continue;
} }
let label = if message.user { if message.tool {
"You" export.start_turn("tool", "Tool");
} else if message.tool { export.code_block("Output", "text", message.content.trim());
"Tool" export.end_turn();
continue;
}
let (role, label) = if message.user {
("user", "User")
} else { } 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()) { if let Some(reasoning) = message.reasoning.as_deref().filter(|text| !text.is_empty()) {
output.push('\n'); export.callout("Thinking", reasoning, "thinking");
write_block_quote(&mut output, reasoning);
} }
let visible = crate::agent::visible_content(&message.content); let visible = crate::agent::visible_content(&message.content);
let visible = crate::a2ui::transcript_fallback(visible); export.markdown(visible);
let _ = write!(output, "\n{}\n", visible.trim()); 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; continue;
} }
let tool_result = conversation.get(index).filter(|message| message.tool); let tool_result = conversation.get(index).filter(|message| message.tool);
@@ -2438,10 +2787,18 @@ fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage]
continue; continue;
} }
index += usize::from(tool_result.is_some()); index += usize::from(tool_result.is_some());
for (tool_index, card) in cards.into_iter().enumerate() { for card in cards {
if let Some(presentation) = crate::agent::svg_presentation(&card) { export.start_turn("tool", &format!("Tool · {}", card.call.name));
let _ = write!(output, "\n## Figure\n\n{}\n", presentation.alt); let presentation = crate::agent::svg_presentation(&card);
write_code_fence(&mut output, "svg", &presentation.svg); 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" } else if card.call.name == "bash"
&& let Some(command) = card && let Some(command) = card
.call .call
@@ -2450,24 +2807,28 @@ fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage]
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
{ {
if let Some(reason) = card.approval_reason { if let Some(reason) = card.approval_reason {
output.push('\n'); export.callout("Approval", &reason, "thinking");
write_block_quote(&mut output, &reason); }
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 { if let Some(result) = card.result {
write_code_fence(&mut output, "text", &result); export.code_block("Output", "text", &result);
} }
} else if let Some(result) = card.result { } else {
let _ = write!( if format == ChatExportFormat::Markdown && card.call.name != "present_svg" {
output, let arguments = serde_json::to_string_pretty(&card.call.arguments)
"\n## Tool\n\nTool result {} ({}):\n{result}\n", .unwrap_or_else(|_| "{}".to_owned());
tool_index + 1, export.code_block("Input", "json", &arguments);
card.call.name }
); if let Some(result) = card.result {
export.code_block("Output", "text", &result);
} }
} }
export.end_turn();
} }
output }
export.finish()
} }
pub(crate) fn application_support_path() -> PathBuf { pub(crate) fn application_support_path() -> PathBuf {
@@ -2949,6 +3310,10 @@ mod tests {
false, false,
false, false,
r#"I will check. r#"I will check.
```a2ui
{"surfaceUpdate":{"surfaceId":"status"}}
```
<DSMLtool_calls> <DSMLtool_calls>
<DSMLinvoke name="bash"><DSMLparameter name="command" string="true">cat <<'EOF' <DSMLinvoke name="bash"><DSMLparameter name="command" string="true">cat <<'EOF'
``` ```
@@ -2957,7 +3322,8 @@ EOF</DSMLparameter></DSMLinvoke>
); );
assistant.reasoning = Some("First thought.\nSecond thought.".into()); assistant.reasoning = Some("First thought.\nSecond thought.".into());
assistant.tool_approval_reasons = vec![Some("This only prints text.".into())]; assistant.tool_approval_reasons = vec![Some("This only prints text.".into())];
let exported = export_markdown( let exported = export_chat(
ChatExportFormat::Markdown,
"A chat", "A chat",
ModelChoice::DeepSeekV4Flash0731, ModelChoice::DeepSeekV4Flash0731,
&[ &[
@@ -2966,10 +3332,12 @@ EOF</DSMLparameter></DSMLinvoke>
assistant, assistant,
message(false, true, false, "Tool result 1 (bash):\n```\none\ntwo\n"), message(false, true, false, "Tool result 1 (bash):\n```\none\ntwo\n"),
], ],
&[],
&HashMap::new(),
); );
assert_eq!( assert_eq!(
exported, 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")); assert!(!exported.contains("private"));
} }
@@ -3010,18 +3378,135 @@ EOF</DSMLparameter></DSMLinvoke>
"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", "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", "SVG chat",
ModelChoice::DeepSeekV4Flash0731, ModelChoice::DeepSeekV4Flash0731,
&[assistant, result], &[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_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("<DSML"));
assert!(!exported.contains("not svg")); 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#"<svg width="24" height="12"><path d="M0 0h24v12H0z"/></svg>"#;
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** <script>alert('no')</script>\n\n![Remote](https://example.com/image.png)\n\n```a2ui\n{a2ui}\n```<DSMLtool_calls><DSMLinvoke name=\"present_svg\"><DSMLparameter name=\"svg\" string=\"true\">{svg}</DSMLparameter><DSMLparameter name=\"alt\" string=\"true\">Diagram &amp; chart</DSMLparameter></DSMLinvoke><DSMLinvoke name=\"read\"><DSMLparameter name=\"path\" string=\"true\">notes.md</DSMLparameter></DSMLinvoke><DSMLinvoke name=\"present_svg\"><DSMLparameter name=\"svg\" string=\"true\">not svg</DSMLparameter><DSMLparameter name=\"alt\" string=\"true\">Invalid</DSMLparameter></DSMLinvoke></DSMLtool_calls>"
),
);
assistant.reasoning = Some("Private <reasoning> stays visible but inert.".into());
let result = message(
3,
false,
true,
"Tool result 1 (present_svg):\nSVG presented inline: Diagram &amp; 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::<Vec<_>>();
let exported = export_chat(
ChatExportFormat::Html,
"Handoff <unsafe>",
ModelChoice::DeepSeekV4Flash0731,
&[message(1, true, false, "Hello"), assistant, result],
&surfaces,
&HashMap::new(),
);
assert!(exported.starts_with("<!doctype html>"));
assert!(exported.contains("Content-Security-Policy"));
assert!(exported.contains("<section class=\"turn user\">"));
assert!(exported.contains("<section class=\"turn assistant\">"));
assert!(exported.contains("<section class=\"turn tool\">"));
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("<details class=\"tool-json\"><summary>Tool call</summary>")
.count(),
2
);
assert!(!exported.contains("<details class=\"tool-json\" open"));
assert!(exported.contains("&quot;path&quot;: &quot;notes.md&quot;"));
assert!(
exported.contains("&lt;script&gt;alert('no')&lt;/script&gt;"),
"{exported}"
);
assert!(!exported.contains("<script>alert"));
assert!(exported.contains("<figure class=\"embedded-svg\""));
assert_eq!(exported.matches(svg).count(), 1);
assert!(exported.contains("Handoff &lt;unsafe&gt;"));
assert!(!exported.contains("<DSML"));
assert!(!exported.contains("not svg"));
}
#[test]
fn chat_html_export_embeds_loaded_a2ui_images_as_data_urls() {
let handle = iced::widget::image::Handle::from_rgba(1, 1, vec![255, 0, 0, 255]);
let data = a2ui_image_data_url(&handle).unwrap();
assert!(data.starts_with("data:image/png;base64,"));
assert!(!data.contains(['\n', '\r']));
}
#[test] #[test]
fn owned_runtime_paths_stay_in_application_support() { fn owned_runtime_paths_stay_in_application_support() {
let root = application_support_path(); let root = application_support_path();

View File

@@ -7,7 +7,8 @@ use crate::native_edit::EditCommand;
const PREFERENCES: &str = "preferences"; const PREFERENCES: &str = "preferences";
const MODEL_MANAGER: &str = "model-manager"; const MODEL_MANAGER: &str = "model-manager";
const NEW_CHAT: &str = "new-chat"; const NEW_CHAT: &str = "new-chat";
const EXPORT_CHAT: &str = "export-chat"; const EXPORT_MARKDOWN: &str = "export-markdown";
const EXPORT_HTML: &str = "export-html";
const TOGGLE_SIDEBAR: &str = "toggle-sidebar"; const TOGGLE_SIDEBAR: &str = "toggle-sidebar";
const SHOW_CHAT: &str = "show-chat"; const SHOW_CHAT: &str = "show-chat";
const SHOW_A2UI: &str = "show-a2ui"; const SHOW_A2UI: &str = "show-a2ui";
@@ -25,7 +26,8 @@ const SELECT_ALL: &str = "select-all";
pub(crate) struct NativeMenu { pub(crate) struct NativeMenu {
_menu: Menu, _menu: Menu,
new_chat: MenuItem, new_chat: MenuItem,
export_chat: MenuItem, export_markdown: MenuItem,
export_html: MenuItem,
sidebar: CheckMenuItem, sidebar: CheckMenuItem,
chat: CheckMenuItem, chat: CheckMenuItem,
a2ui: CheckMenuItem, a2ui: CheckMenuItem,
@@ -44,7 +46,8 @@ pub(crate) enum NativeMenuEvent {
Preferences, Preferences,
ModelManager, ModelManager,
NewChat, NewChat,
ExportChat, ExportMarkdown,
ExportHtml,
ToggleSidebar, ToggleSidebar,
ShowChat, ShowChat,
ShowA2ui, ShowA2ui,
@@ -84,8 +87,8 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
false, false,
Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyN)), Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyN)),
); );
let export_chat = MenuItem::with_id( let export_markdown = MenuItem::with_id(
EXPORT_CHAT, EXPORT_MARKDOWN,
"Export Chat as Markdown…", "Export Chat as Markdown…",
false, false,
Some(Accelerator::new( Some(Accelerator::new(
@@ -93,6 +96,12 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
Code::KeyS, Code::KeyS,
)), )),
); );
let export_html = MenuItem::with_id(
EXPORT_HTML,
"Export Chat as HTML…",
false,
None::<Accelerator>,
);
let sidebar = CheckMenuItem::with_id( let sidebar = CheckMenuItem::with_id(
TOGGLE_SIDEBAR, TOGGLE_SIDEBAR,
"Show Sidebar", "Show Sidebar",
@@ -135,7 +144,8 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
file.append_items(&[ file.append_items(&[
&new_chat, &new_chat,
&export_chat, &export_markdown,
&export_html,
&PredefinedMenuItem::separator(), &PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(None), &PredefinedMenuItem::close_window(None),
]) ])
@@ -177,7 +187,8 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
Ok(NativeMenu { Ok(NativeMenu {
_menu: menu, _menu: menu,
new_chat, new_chat,
export_chat, export_markdown,
export_html,
sidebar, sidebar,
chat, chat,
a2ui, a2ui,
@@ -202,7 +213,8 @@ impl NativeMenu {
edit: crate::native_edit::EditAvailability, edit: crate::native_edit::EditAvailability,
) { ) {
self.new_chat.set_enabled(project_active); 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.sidebar.set_checked(sidebar_visible);
self.chat.set_checked(detail_tab == DetailTab::Chat); self.chat.set_checked(detail_tab == DetailTab::Chat);
self.a2ui.set_checked(detail_tab == DetailTab::A2ui); self.a2ui.set_checked(detail_tab == DetailTab::A2ui);
@@ -223,7 +235,8 @@ pub(crate) fn next_event() -> Option<NativeMenuEvent> {
PREFERENCES => NativeMenuEvent::Preferences, PREFERENCES => NativeMenuEvent::Preferences,
MODEL_MANAGER => NativeMenuEvent::ModelManager, MODEL_MANAGER => NativeMenuEvent::ModelManager,
NEW_CHAT => NativeMenuEvent::NewChat, NEW_CHAT => NativeMenuEvent::NewChat,
EXPORT_CHAT => NativeMenuEvent::ExportChat, EXPORT_MARKDOWN => NativeMenuEvent::ExportMarkdown,
EXPORT_HTML => NativeMenuEvent::ExportHtml,
TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar, TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar,
SHOW_CHAT => NativeMenuEvent::ShowChat, SHOW_CHAT => NativeMenuEvent::ShowChat,
SHOW_A2UI => NativeMenuEvent::ShowA2ui, SHOW_A2UI => NativeMenuEvent::ShowA2ui,