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"
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",

View File

@@ -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"] }

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
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

View File

@@ -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};

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,
}
#[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)]
pub(crate) enum Message {
Noop,
@@ -354,7 +376,7 @@ pub(crate) enum Message {
OpenHelp,
HelpOpened(window::Id),
NewChat,
ExportChat,
ExportChat(ChatExportFormat),
ExportChatPicked(Option<PathBuf>, 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::<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(
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(
"<!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;
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"}}
```
<DSMLtool_calls>
<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.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</DSMLparameter></DSMLinvoke>
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</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",
);
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#"<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]
fn owned_runtime_paths_stay_in_application_support() {
let root = application_support_path();

View File

@@ -7,7 +7,8 @@ use crate::native_edit::EditCommand;
const PREFERENCES: &str = "preferences";
const MODEL_MANAGER: &str = "model-manager";
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 SHOW_CHAT: &str = "show-chat";
const SHOW_A2UI: &str = "show-a2ui";
@@ -25,7 +26,8 @@ const SELECT_ALL: &str = "select-all";
pub(crate) struct NativeMenu {
_menu: Menu,
new_chat: MenuItem,
export_chat: MenuItem,
export_markdown: MenuItem,
export_html: MenuItem,
sidebar: CheckMenuItem,
chat: CheckMenuItem,
a2ui: CheckMenuItem,
@@ -44,7 +46,8 @@ pub(crate) enum NativeMenuEvent {
Preferences,
ModelManager,
NewChat,
ExportChat,
ExportMarkdown,
ExportHtml,
ToggleSidebar,
ShowChat,
ShowA2ui,
@@ -84,8 +87,8 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
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<NativeMenu, String> {
Code::KeyS,
)),
);
let export_html = MenuItem::with_id(
EXPORT_HTML,
"Export Chat as HTML…",
false,
None::<Accelerator>,
);
let sidebar = CheckMenuItem::with_id(
TOGGLE_SIDEBAR,
"Show Sidebar",
@@ -135,7 +144,8 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
.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<NativeMenu, String> {
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<NativeMenuEvent> {
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,