Add standalone session exports
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
881
src/a2ui/export.rs
Normal 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("&"),
|
||||||
|
'<' => output.push_str("<"),
|
||||||
|
'>' => output.push_str(">"),
|
||||||
|
'"' => output.push_str("""),
|
||||||
|
'\'' => output.push_str("'"),
|
||||||
|
_ => output.push(character),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon(name: &str) -> &'static str {
|
||||||
|
match name {
|
||||||
|
"accountCircle" | "person" => "●",
|
||||||
|
"add" => "+",
|
||||||
|
"arrowBack" => "←",
|
||||||
|
"arrowForward" => "→",
|
||||||
|
"attachFile" => "⌕",
|
||||||
|
"calendarToday" | "event" => "▣",
|
||||||
|
"call" | "phone" => "☎",
|
||||||
|
"camera" => "◉",
|
||||||
|
"check" => "✓",
|
||||||
|
"close" => "×",
|
||||||
|
"delete" => "⌫",
|
||||||
|
"download" => "⇩",
|
||||||
|
"edit" => "✎",
|
||||||
|
"error" => "⊗",
|
||||||
|
"fastForward" => "≫",
|
||||||
|
"favorite" => "♥",
|
||||||
|
"favoriteOff" => "♡",
|
||||||
|
"folder" => "▰",
|
||||||
|
"help" => "?",
|
||||||
|
"home" => "⌂",
|
||||||
|
"info" => "ⓘ",
|
||||||
|
"locationOn" => "⌖",
|
||||||
|
"lock" => "▣",
|
||||||
|
"lockOpen" => "□",
|
||||||
|
"mail" => "✉",
|
||||||
|
"menu" => "☰",
|
||||||
|
"moreVert" => "⋮",
|
||||||
|
"moreHoriz" => "…",
|
||||||
|
"notifications" => "◈",
|
||||||
|
"notificationsOff" => "◇",
|
||||||
|
"pause" => "Ⅱ",
|
||||||
|
"payment" => "¤",
|
||||||
|
"photo" => "▧",
|
||||||
|
"play" => "▶",
|
||||||
|
"print" => "▤",
|
||||||
|
"refresh" => "↻",
|
||||||
|
"rewind" => "≪",
|
||||||
|
"search" => "⌕",
|
||||||
|
"send" => "➤",
|
||||||
|
"settings" => "⚙",
|
||||||
|
"share" => "↗",
|
||||||
|
"shoppingCart" => "⌑",
|
||||||
|
"skipNext" => "▸|",
|
||||||
|
"skipPrevious" => "|◂",
|
||||||
|
"star" => "★",
|
||||||
|
"starHalf" | "starOff" => "☆",
|
||||||
|
"stop" => "■",
|
||||||
|
"upload" => "⇧",
|
||||||
|
"visibility" => "◉",
|
||||||
|
"visibilityOff" => "○",
|
||||||
|
"volumeDown" => "◖",
|
||||||
|
"volumeMute" => "◁",
|
||||||
|
"volumeOff" => "×",
|
||||||
|
"volumeUp" => "◀",
|
||||||
|
"warning" => "⚠",
|
||||||
|
_ => "•",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_svg_renderer_covers_the_complete_catalog() {
|
||||||
|
let entries = [
|
||||||
|
json!({"id":"text","component":"Text","text":"Hello"}),
|
||||||
|
json!({"id":"image","component":"Image","url":"https://example.com/a.png","description":"Preview"}),
|
||||||
|
json!({"id":"icon","component":"Icon","name":"settings"}),
|
||||||
|
json!({"id":"video","component":"Video","url":"https://example.com/a.mp4"}),
|
||||||
|
json!({"id":"audio","component":"AudioPlayer","url":"https://example.com/a.mp3"}),
|
||||||
|
json!({"id":"divider","component":"Divider"}),
|
||||||
|
json!({"id":"row","component":"Row","children":[]}),
|
||||||
|
json!({"id":"list","component":"List","children":[]}),
|
||||||
|
json!({"id":"card","component":"Card","child":"text"}),
|
||||||
|
json!({"id":"modal","component":"Modal","trigger":"text","content":"text"}),
|
||||||
|
json!({"id":"tabs","component":"Tabs","tabs":[{"title":"One","child":"text"}]}),
|
||||||
|
json!({"id":"button","component":"Button","child":"text"}),
|
||||||
|
json!({"id":"field","component":"TextField","label":"Name","value":"Ada"}),
|
||||||
|
json!({"id":"check","component":"CheckBox","label":"Ready","value":true}),
|
||||||
|
json!({"id":"slider","component":"Slider","label":"Amount","value":4,"max":10}),
|
||||||
|
json!({"id":"date","component":"DateTimeInput","label":"When","value":"2026-08-31"}),
|
||||||
|
json!({"id":"choice","component":"ChoicePicker","label":"Pick","options":[{"label":"One","value":"one"}],"value":["one"]}),
|
||||||
|
json!({"id":"chart","component":"Chart","title":"Chart","chartType":"heatmap","series":[{"label":"A","segments":[{"label":"B","value":2}]}]}),
|
||||||
|
json!({"id":"table","component":"Table","title":"Table","columns":["A"],"rows":[["B"]]}),
|
||||||
|
json!({"id":"metric","component":"Metric","label":"Count","value":"2"}),
|
||||||
|
json!({"id":"timeline","component":"Timeline","title":"Timeline","events":[{"time":"Now","title":"Done"}]}),
|
||||||
|
json!({"id":"map","component":"Map","title":"Map","locations":[{"label":"Here","latitude":1,"longitude":2}]}),
|
||||||
|
json!({"id":"mind","component":"MindMap","title":"Mind","nodes":[{"id":"n","label":"Root","children":[]}]}),
|
||||||
|
json!({"id":"form","component":"Form","title":"Form","children":[]}),
|
||||||
|
];
|
||||||
|
let ids = entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| entry.get("id").and_then(Value::as_str))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect::<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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
579
src/app.rs
579
src/app.rs
@@ -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,12 +2412,18 @@ 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) {
|
||||||
@@ -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("&"),
|
||||||
|
'<' => output.push_str("<"),
|
||||||
|
'>' => output.push_str(">"),
|
||||||
|
'"' => output.push_str("""),
|
||||||
|
'\'' => output.push_str("'"),
|
||||||
|
_ => output.push(character),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn markdown_to_safe_html(markdown: &str) -> String {
|
||||||
|
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
|
||||||
|
|
||||||
|
let options = Options::ENABLE_TABLES
|
||||||
|
| Options::ENABLE_FOOTNOTES
|
||||||
|
| Options::ENABLE_STRIKETHROUGH
|
||||||
|
| Options::ENABLE_TASKLISTS;
|
||||||
|
let parser = Parser::new_ext(markdown, options).scan(Vec::new(), |images, event| {
|
||||||
|
Some(match event {
|
||||||
|
Event::Html(html) | Event::InlineHtml(html) => Event::Text(html),
|
||||||
|
Event::Start(tag @ Tag::Image { .. }) => {
|
||||||
|
let embedded = matches!(
|
||||||
|
&tag,
|
||||||
|
Tag::Image { dest_url, .. } if dest_url.starts_with("data:image/")
|
||||||
|
);
|
||||||
|
images.push(embedded);
|
||||||
|
if embedded {
|
||||||
|
Event::Start(tag)
|
||||||
|
} else {
|
||||||
|
Event::Text("[Image: ".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::Image) => {
|
||||||
|
if images.pop().unwrap_or(false) {
|
||||||
|
Event::End(TagEnd::Image)
|
||||||
|
} else {
|
||||||
|
Event::Text("]".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event => event,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let mut output = String::new();
|
||||||
|
html::push_html(&mut output, parser);
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_chat(
|
||||||
|
format: ChatExportFormat,
|
||||||
|
title: &str,
|
||||||
|
model: ModelChoice,
|
||||||
|
conversation: &[ChatMessage],
|
||||||
|
surfaces: &[crate::a2ui::Surface],
|
||||||
|
images: &HashMap<String, 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"}}
|
||||||
|
```
|
||||||
<|DSML|tool_calls>
|
<|DSML|tool_calls>
|
||||||
<|DSML|invoke name="bash"><|DSML|parameter name="command" string="true">cat <<'EOF'
|
<|DSML|invoke name="bash"><|DSML|parameter name="command" string="true">cat <<'EOF'
|
||||||
```
|
```
|
||||||
@@ -2957,7 +3322,8 @@ EOF</|DSML|parameter></|DSML|invoke>
|
|||||||
);
|
);
|
||||||
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</|DSML|parameter></|DSML|invoke>
|
|||||||
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</|DSML|parameter></|DSML|invoke>
|
|||||||
"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\n\n```a2ui\n{a2ui}\n```<|DSML|tool_calls><|DSML|invoke name=\"present_svg\"><|DSML|parameter name=\"svg\" string=\"true\">{svg}</|DSML|parameter><|DSML|parameter name=\"alt\" string=\"true\">Diagram & chart</|DSML|parameter></|DSML|invoke><|DSML|invoke name=\"read\"><|DSML|parameter name=\"path\" string=\"true\">notes.md</|DSML|parameter></|DSML|invoke><|DSML|invoke name=\"present_svg\"><|DSML|parameter name=\"svg\" string=\"true\">not svg</|DSML|parameter><|DSML|parameter name=\"alt\" string=\"true\">Invalid</|DSML|parameter></|DSML|invoke></|DSML|tool_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 & 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(""path": "notes.md""));
|
||||||
|
assert!(
|
||||||
|
exported.contains("<script>alert('no')</script>"),
|
||||||
|
"{exported}"
|
||||||
|
);
|
||||||
|
assert!(!exported.contains("<script>alert"));
|
||||||
|
assert!(exported.contains("<figure class=\"embedded-svg\""));
|
||||||
|
assert_eq!(exported.matches(svg).count(), 1);
|
||||||
|
assert!(exported.contains("Handoff <unsafe>"));
|
||||||
|
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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user