Add durable inline SVG presentation
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -832,6 +832,7 @@ dependencies = [
|
|||||||
"turbovault-parser",
|
"turbovault-parser",
|
||||||
"ureq",
|
"ureq",
|
||||||
"url",
|
"url",
|
||||||
|
"usvg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ time = { version = "0.3.54", features = ["formatting", "parsing"] }
|
|||||||
turbovault-parser = "1.6.0"
|
turbovault-parser = "1.6.0"
|
||||||
ureq = { version = "3.3.0", default-features = false, features = ["rustls"] }
|
ureq = { version = "3.3.0", default-features = false, features = ["rustls"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
usvg = "0.45.1"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
muda = { version = "0.19.3", default-features = false }
|
muda = { version = "0.19.3", default-features = false }
|
||||||
|
|||||||
406
src/agent.rs
406
src/agent.rs
@@ -28,6 +28,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||||
const MAX_RALPH_REPORT_BYTES: usize = 16 * 1024;
|
const MAX_RALPH_REPORT_BYTES: usize = 16 * 1024;
|
||||||
|
const MAX_SVG_SOURCE_BYTES: usize = 64 * 1024;
|
||||||
|
const MAX_SVG_ALT_BYTES: usize = 512;
|
||||||
|
const MAX_SVG_NODES: usize = 1_024;
|
||||||
|
const MIN_SVG_DIMENSION: f32 = 1.0;
|
||||||
|
const MAX_SVG_DIMENSION: f32 = 4_096.0;
|
||||||
const DEFAULT_RALPH_ROUNDS: usize = 8;
|
const DEFAULT_RALPH_ROUNDS: usize = 8;
|
||||||
const MAX_RALPH_STEPS_PER_ROUND: usize = 64;
|
const MAX_RALPH_STEPS_PER_ROUND: usize = 64;
|
||||||
const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5);
|
const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
@@ -322,6 +327,7 @@ const RISK_CLASSIFIER_SYSTEM_PROMPT: &str = "You are a shell-command risk classi
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum ToolHandler {
|
enum ToolHandler {
|
||||||
|
PresentSvg,
|
||||||
GoogleSearch,
|
GoogleSearch,
|
||||||
VisitPage,
|
VisitPage,
|
||||||
Bash,
|
Bash,
|
||||||
@@ -383,6 +389,25 @@ const POSITIVE: ParameterKind = ParameterKind::Integer {
|
|||||||
max: usize::MAX as u64,
|
max: usize::MAX as u64,
|
||||||
};
|
};
|
||||||
const TOOLS: &[ToolSpec] = &[
|
const TOOLS: &[ToolSpec] = &[
|
||||||
|
ToolSpec {
|
||||||
|
name: "present_svg",
|
||||||
|
description: "Present a self-contained static SVG inline in the chat when a diagram or illustration materially improves the answer. alt must concisely describe the information conveyed. This is presentation only: use A2UI instead for interactive controls or stateful UI. Do not include scripts, animation, links, HTML, raster images, external resources, file paths, or remote URLs.",
|
||||||
|
parameters: &[
|
||||||
|
ToolParameter {
|
||||||
|
name: "svg",
|
||||||
|
kind: NON_EMPTY,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
ToolParameter {
|
||||||
|
name: "alt",
|
||||||
|
kind: NON_EMPTY,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rule: ToolRule::None,
|
||||||
|
handler: ToolHandler::PresentSvg,
|
||||||
|
dev_brain: false,
|
||||||
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "google_search",
|
name: "google_search",
|
||||||
description: "Search Google in a browser and return compact Markdown links. If browser startup is denied, do not repeat the unchanged call; explain why web access is needed or continue with local evidence.",
|
description: "Search Google in a browser and return compact Markdown links. If browser startup is denied, do not repeat the unchanged call; explain why web access is needed or continue with local evidence.",
|
||||||
@@ -1260,6 +1285,198 @@ pub(crate) struct ToolCall {
|
|||||||
pub(crate) arguments: Map<String, Value>,
|
pub(crate) arguments: Map<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) struct SvgPresentation {
|
||||||
|
pub(crate) svg: String,
|
||||||
|
pub(crate) alt: String,
|
||||||
|
pub(crate) height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn present_svg(call: &ToolCall) -> Result<String, String> {
|
||||||
|
let presentation =
|
||||||
|
validate_svg_presentation(required_string(call, "svg")?, required_string(call, "alt")?)?;
|
||||||
|
Ok(format!("SVG presented inline: {}", presentation.alt))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_svg_presentation(svg: &str, alt: &str) -> Result<SvgPresentation, String> {
|
||||||
|
if svg.trim().is_empty() {
|
||||||
|
return Err("SVG source is empty".into());
|
||||||
|
}
|
||||||
|
if svg.len() > MAX_SVG_SOURCE_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"SVG source is {} bytes; maximum is {MAX_SVG_SOURCE_BYTES} bytes",
|
||||||
|
svg.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let alt = alt.trim();
|
||||||
|
if alt.is_empty() {
|
||||||
|
return Err("SVG alt text is empty".into());
|
||||||
|
}
|
||||||
|
if alt.len() > MAX_SVG_ALT_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"SVG alt text is {} bytes; maximum is {MAX_SVG_ALT_BYTES} bytes",
|
||||||
|
alt.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let document = usvg::roxmltree::Document::parse(svg)
|
||||||
|
.map_err(|error| format!("SVG is malformed XML: {error}"))?;
|
||||||
|
if document
|
||||||
|
.descendants()
|
||||||
|
.any(|node| node.node_type() == usvg::roxmltree::NodeType::PI)
|
||||||
|
{
|
||||||
|
return Err("SVG processing instructions are not allowed".into());
|
||||||
|
}
|
||||||
|
let root = document.root_element();
|
||||||
|
if root.tag_name().name() != "svg"
|
||||||
|
|| !matches!(
|
||||||
|
root.tag_name().namespace(),
|
||||||
|
None | Some("http://www.w3.org/2000/svg")
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err("SVG source must have an svg root element".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut source_nodes = 0;
|
||||||
|
for node in document
|
||||||
|
.descendants()
|
||||||
|
.filter(usvg::roxmltree::Node::is_element)
|
||||||
|
{
|
||||||
|
source_nodes += 1;
|
||||||
|
if source_nodes > MAX_SVG_NODES {
|
||||||
|
return Err(format!(
|
||||||
|
"SVG has more than {MAX_SVG_NODES} elements and is too complex"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !matches!(
|
||||||
|
node.tag_name().namespace(),
|
||||||
|
None | Some("http://www.w3.org/2000/svg")
|
||||||
|
) {
|
||||||
|
return Err("SVG contains foreign or HTML content".into());
|
||||||
|
}
|
||||||
|
let element = node.tag_name().name().to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
|
element.as_str(),
|
||||||
|
"script"
|
||||||
|
| "animate"
|
||||||
|
| "animatecolor"
|
||||||
|
| "animatemotion"
|
||||||
|
| "animatetransform"
|
||||||
|
| "set"
|
||||||
|
| "discard"
|
||||||
|
| "handler"
|
||||||
|
| "listener"
|
||||||
|
| "a"
|
||||||
|
| "foreignobject"
|
||||||
|
| "iframe"
|
||||||
|
| "object"
|
||||||
|
| "embed"
|
||||||
|
| "audio"
|
||||||
|
| "video"
|
||||||
|
| "canvas"
|
||||||
|
| "image"
|
||||||
|
| "feimage"
|
||||||
|
| "style"
|
||||||
|
) {
|
||||||
|
return Err(format!("SVG element <{element}> is not allowed"));
|
||||||
|
}
|
||||||
|
for attribute in node.attributes() {
|
||||||
|
let name = attribute.name().to_ascii_lowercase();
|
||||||
|
let value = attribute.value().trim();
|
||||||
|
if name.starts_with("on") {
|
||||||
|
return Err(format!("SVG event attribute {name} is not allowed"));
|
||||||
|
}
|
||||||
|
if name == "href" && !value.starts_with('#') {
|
||||||
|
return Err("SVG external references and links are not allowed".into());
|
||||||
|
}
|
||||||
|
if has_external_svg_reference(value) {
|
||||||
|
return Err("SVG external resources and URLs are not allowed".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = usvg::Options {
|
||||||
|
resources_dir: None,
|
||||||
|
image_href_resolver: usvg::ImageHrefResolver {
|
||||||
|
resolve_data: Box::new(|_, _, _| None),
|
||||||
|
resolve_string: Box::new(|_, _| None),
|
||||||
|
},
|
||||||
|
..usvg::Options::default()
|
||||||
|
};
|
||||||
|
let tree = usvg::Tree::from_str(svg, &options)
|
||||||
|
.map_err(|error| format!("SVG cannot be rendered: {error}"))?;
|
||||||
|
let size = tree.size();
|
||||||
|
let (width, height) = (size.width(), size.height());
|
||||||
|
if !width.is_finite()
|
||||||
|
|| !height.is_finite()
|
||||||
|
|| width < MIN_SVG_DIMENSION
|
||||||
|
|| height < MIN_SVG_DIMENSION
|
||||||
|
|| width > MAX_SVG_DIMENSION
|
||||||
|
|| height > MAX_SVG_DIMENSION
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"SVG dimensions must be finite and between {MIN_SVG_DIMENSION} and {MAX_SVG_DIMENSION}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut rendered_nodes = 0;
|
||||||
|
let mut visible_nodes = 0;
|
||||||
|
count_svg_nodes(tree.root(), &mut rendered_nodes, &mut visible_nodes);
|
||||||
|
if visible_nodes == 0 {
|
||||||
|
return Err("SVG has no renderable vector or text content".into());
|
||||||
|
}
|
||||||
|
if rendered_nodes > MAX_SVG_NODES {
|
||||||
|
return Err(format!(
|
||||||
|
"rendered SVG has more than {MAX_SVG_NODES} nodes and is too complex"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(SvgPresentation {
|
||||||
|
svg: svg.to_owned(),
|
||||||
|
alt: alt.to_owned(),
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_external_svg_reference(value: &str) -> bool {
|
||||||
|
let value = value.to_ascii_lowercase();
|
||||||
|
if value.contains("@import")
|
||||||
|
|| value.contains("javascript:")
|
||||||
|
|| value.contains("data:")
|
||||||
|
|| value.contains("file:")
|
||||||
|
|| value.contains("http:")
|
||||||
|
|| value.contains("https:")
|
||||||
|
|| value.contains("expression(")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut rest = value.as_str();
|
||||||
|
while let Some(start) = rest.find("url(") {
|
||||||
|
let body = &rest[start + 4..];
|
||||||
|
let Some(end) = body.find(')') else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if !body[..end]
|
||||||
|
.trim()
|
||||||
|
.trim_matches(['\'', '"'])
|
||||||
|
.starts_with('#')
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
rest = &body[end + 1..];
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_svg_nodes(group: &usvg::Group, nodes: &mut usize, visible: &mut usize) {
|
||||||
|
for node in group.children() {
|
||||||
|
*nodes += 1;
|
||||||
|
match node {
|
||||||
|
usvg::Node::Group(group) => count_svg_nodes(group, nodes, visible),
|
||||||
|
usvg::Node::Path(_) | usvg::Node::Text(_) => *visible += 1,
|
||||||
|
usvg::Node::Image(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct ActiveTools {
|
pub(crate) struct ActiveTools {
|
||||||
pub(crate) results: Receiver<ToolRunResult>,
|
pub(crate) results: Receiver<ToolRunResult>,
|
||||||
pub(crate) events: Receiver<ToolEvent>,
|
pub(crate) events: Receiver<ToolEvent>,
|
||||||
@@ -1334,6 +1551,13 @@ impl ToolCard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn svg_presentation(card: &ToolCard) -> Option<SvgPresentation> {
|
||||||
|
if card.call.name != "present_svg" || card.state != ToolLifecycle::Completed {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
validate_svg_presentation(string(&card.call, "svg")?, string(&card.call, "alt")?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct ApprovalPrompt {
|
pub(crate) struct ApprovalPrompt {
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
@@ -1691,6 +1915,7 @@ impl Tools {
|
|||||||
cancel: &AtomicBool,
|
cancel: &AtomicBool,
|
||||||
) -> String {
|
) -> String {
|
||||||
let result = match tool.handler {
|
let result = match tool.handler {
|
||||||
|
ToolHandler::PresentSvg => present_svg(call),
|
||||||
ToolHandler::Read => self.read(call),
|
ToolHandler::Read => self.read(call),
|
||||||
ToolHandler::More => self.more(call),
|
ToolHandler::More => self.more(call),
|
||||||
ToolHandler::Write => self.write(call),
|
ToolHandler::Write => self.write(call),
|
||||||
@@ -3863,6 +4088,176 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_svg_parsers_recover_exact_source_for_both_model_transports() {
|
||||||
|
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><path d="M0 0h120v80H0z" fill="#123456"/></svg>"##;
|
||||||
|
let dsml = format!(
|
||||||
|
"<|DSML|tool_calls><|DSML|invoke name=\"present_svg\"><|DSML|parameter name=\"svg\" string=\"true\">{svg}</|DSML|parameter><|DSML|parameter name=\"alt\" string=\"true\">A dark rectangle</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"
|
||||||
|
);
|
||||||
|
let (_, calls) = parse_tool_calls(ModelChoice::DeepSeekV4Flash0731, &dsml).unwrap();
|
||||||
|
assert_eq!(string(&calls[0], "svg"), Some(svg));
|
||||||
|
assert_eq!(string(&calls[0], "alt"), Some("A dark rectangle"));
|
||||||
|
|
||||||
|
let glm = format!(
|
||||||
|
"<tool_call>present_svg<arg_key>svg</arg_key><arg_value>{svg}</arg_value><arg_key>alt</arg_key><arg_value>A dark rectangle</arg_value></tool_call>"
|
||||||
|
);
|
||||||
|
let (_, calls) = parse_tool_calls(ModelChoice::Glm52, &glm).unwrap();
|
||||||
|
assert_eq!(string(&calls[0], "svg"), Some(svg));
|
||||||
|
assert_eq!(string(&calls[0], "alt"), Some("A dark rectangle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_svg_validation_enforces_the_static_vector_boundary() {
|
||||||
|
let valid = r##"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><defs><linearGradient id="g"><stop offset="0" stop-color="#111"/><stop offset="1" stop-color="#555"/></linearGradient></defs><path d="M0 0h120v80H0z" fill="url(#g)"/></svg>"##;
|
||||||
|
let presentation = validate_svg_presentation(valid, "A gradient rectangle").unwrap();
|
||||||
|
assert_eq!(presentation.svg, valid);
|
||||||
|
assert_eq!(presentation.alt, "A gradient rectangle");
|
||||||
|
assert_eq!(presentation.height, 80.0);
|
||||||
|
|
||||||
|
let excessive = format!(
|
||||||
|
"<svg width=\"10\" height=\"10\">{}</svg>",
|
||||||
|
"<circle cx=\"1\" cy=\"1\" r=\"1\"/>".repeat(MAX_SVG_NODES)
|
||||||
|
);
|
||||||
|
let cases = [
|
||||||
|
("", "empty"),
|
||||||
|
(&"x".repeat(MAX_SVG_SOURCE_BYTES + 1), "maximum"),
|
||||||
|
("<svg", "malformed"),
|
||||||
|
("<html></html>", "svg root"),
|
||||||
|
("<svg width=\"10\" height=\"10\"></svg>", "no renderable"),
|
||||||
|
(
|
||||||
|
"<svg width=\"4097\" height=\"10\"><path d=\"M0 0h1v1z\"/></svg>",
|
||||||
|
"dimensions",
|
||||||
|
),
|
||||||
|
(&excessive, "too complex"),
|
||||||
|
(
|
||||||
|
"<svg width=\"10\" height=\"10\"><use href=\"other.svg#shape\"/></svg>",
|
||||||
|
"external references",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<svg width=\"10\" height=\"10\"><path marker-start=\"url(other.svg#marker)\" d=\"M0 0h1v1z\"/></svg>",
|
||||||
|
"external resources",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<svg width=\"10\" height=\"10\"><image href=\"data:image/png;base64,AA==\"/></svg>",
|
||||||
|
"<image>",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<svg width=\"10\" height=\"10\"><script>alert(1)</script></svg>",
|
||||||
|
"<script>",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<svg width=\"10\" height=\"10\"><path onclick=\"alert(1)\" d=\"M0 0h1v1z\"/></svg>",
|
||||||
|
"event attribute",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<svg width=\"10\" height=\"10\"><a href=\"#x\"><path d=\"M0 0h1v1z\"/></a></svg>",
|
||||||
|
"<a>",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<svg width=\"10\" height=\"10\"><animate attributeName=\"x\"/></svg>",
|
||||||
|
"<animate>",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<svg xmlns:h=\"http://www.w3.org/1999/xhtml\" width=\"10\" height=\"10\"><foreignObject><h:div>HTML</h:div></foreignObject></svg>",
|
||||||
|
"<foreignobject>",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<svg width=\"10\" height=\"10\"><style>@import url(https://example.com/x.css)</style><path d=\"M0 0h1v1z\"/></svg>",
|
||||||
|
"<style>",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<?xml-stylesheet href=\"https://example.com/x.css\"?><svg width=\"10\" height=\"10\"><path d=\"M0 0h1v1z\"/></svg>",
|
||||||
|
"processing instructions",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (svg, expected) in cases {
|
||||||
|
let error = validate_svg_presentation(svg, "description").unwrap_err();
|
||||||
|
assert!(error.contains(expected), "{error}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
validate_svg_presentation(valid, &"x".repeat(MAX_SVG_ALT_BYTES + 1))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("alt text")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_svg_executes_without_side_effects_and_maps_only_completed_cards() {
|
||||||
|
let directory = std::env::temp_dir().join(format!(
|
||||||
|
"ds4-present-svg-{}",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&directory).unwrap();
|
||||||
|
let svg = r#"<svg width="24" height="12"><path d="M0 0h24v12H0z"/></svg>"#;
|
||||||
|
let present_call = call("present_svg", [("svg", svg), ("alt", "A solid rectangle")]);
|
||||||
|
let invalid_call = call(
|
||||||
|
"present_svg",
|
||||||
|
[("svg", "not svg"), ("alt", "Invalid figure")],
|
||||||
|
);
|
||||||
|
let tools = Arc::new(Mutex::new(Tools::new(&directory, 4_096).unwrap()));
|
||||||
|
let active = execute_async(
|
||||||
|
tools,
|
||||||
|
vec![
|
||||||
|
call("list", [("path", ".")]),
|
||||||
|
present_call.clone(),
|
||||||
|
invalid_call,
|
||||||
|
],
|
||||||
|
ShellApprovalMode::Heuristic,
|
||||||
|
);
|
||||||
|
let result = active.results.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.content
|
||||||
|
.contains("SVG presented inline: A solid rectangle")
|
||||||
|
);
|
||||||
|
assert!(!result.content.contains(svg));
|
||||||
|
assert!(result.touched_paths.is_empty());
|
||||||
|
assert!(fs::read_dir(&directory).unwrap().next().is_none());
|
||||||
|
|
||||||
|
let assistant = format!(
|
||||||
|
"<|DSML|tool_calls><|DSML|invoke name=\"list\"><|DSML|parameter name=\"path\" string=\"true\">.</|DSML|parameter></|DSML|invoke><|DSML|invoke name=\"present_svg\"><|DSML|parameter name=\"svg\" string=\"true\">{svg}</|DSML|parameter><|DSML|parameter name=\"alt\" string=\"true\">A solid rectangle</|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 figure</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"
|
||||||
|
);
|
||||||
|
let cards = stored_tool_cards(
|
||||||
|
ModelChoice::DeepSeekV4Flash0731,
|
||||||
|
&assistant,
|
||||||
|
Some(&result.content),
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cards
|
||||||
|
.iter()
|
||||||
|
.map(|card| card.call.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["list", "present_svg", "present_svg"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cards.iter().map(|card| card.state).collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
ToolLifecycle::Completed,
|
||||||
|
ToolLifecycle::Completed,
|
||||||
|
ToolLifecycle::Failed,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cards
|
||||||
|
.iter()
|
||||||
|
.map(|card| svg_presentation(card).is_some())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[false, true, false]
|
||||||
|
);
|
||||||
|
let running = ToolCard {
|
||||||
|
call: present_call,
|
||||||
|
state: ToolLifecycle::Running,
|
||||||
|
result: None,
|
||||||
|
approval_reason: None,
|
||||||
|
};
|
||||||
|
assert!(svg_presentation(&running).is_none());
|
||||||
|
fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_schemas_match_the_executable_tool_contracts() {
|
fn generated_schemas_match_the_executable_tool_contracts() {
|
||||||
let schemas = tool_schemas(true, false)
|
let schemas = tool_schemas(true, false)
|
||||||
@@ -3910,6 +4305,17 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("non-zero exit")
|
.contains("non-zero exit")
|
||||||
);
|
);
|
||||||
|
let present_svg = schemas
|
||||||
|
.iter()
|
||||||
|
.find(|schema| schema["function"]["name"] == "present_svg")
|
||||||
|
.unwrap();
|
||||||
|
let description = present_svg["function"]["description"].as_str().unwrap();
|
||||||
|
assert!(description.contains("static SVG inline"));
|
||||||
|
assert!(description.contains("A2UI"));
|
||||||
|
assert_eq!(
|
||||||
|
present_svg["function"]["parameters"]["required"],
|
||||||
|
serde_json::json!(["svg", "alt"])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
53
src/app.rs
53
src/app.rs
@@ -2439,7 +2439,10 @@ fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage]
|
|||||||
}
|
}
|
||||||
index += usize::from(tool_result.is_some());
|
index += usize::from(tool_result.is_some());
|
||||||
for (tool_index, card) in cards.into_iter().enumerate() {
|
for (tool_index, card) in cards.into_iter().enumerate() {
|
||||||
if card.call.name == "bash"
|
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);
|
||||||
|
} else if card.call.name == "bash"
|
||||||
&& let Some(command) = card
|
&& let Some(command) = card
|
||||||
.call
|
.call
|
||||||
.arguments
|
.arguments
|
||||||
@@ -2971,6 +2974,54 @@ EOF</|DSML|parameter></|DSML|invoke>
|
|||||||
assert!(!exported.contains("private"));
|
assert!(!exported.contains("private"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_export_preserves_completed_svg_and_keeps_failures_generic() {
|
||||||
|
let message = |tool, content: &str| ChatMessage {
|
||||||
|
id: 1,
|
||||||
|
user: false,
|
||||||
|
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 assistant = message(
|
||||||
|
false,
|
||||||
|
&format!(
|
||||||
|
"Two diagrams.<|DSML|tool_calls><|DSML|invoke name=\"present_svg\"><|DSML|parameter name=\"svg\" string=\"true\">{svg}</|DSML|parameter><|DSML|parameter name=\"alt\" string=\"true\">A solid rectangle</|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 figure</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let result = message(
|
||||||
|
true,
|
||||||
|
"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(
|
||||||
|
"SVG chat",
|
||||||
|
ModelChoice::DeepSeekV4Flash0731,
|
||||||
|
&[assistant, result],
|
||||||
|
);
|
||||||
|
assert!(exported.contains("## Figure\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("<|DSML|"));
|
||||||
|
assert!(!exported.contains("not svg"));
|
||||||
|
}
|
||||||
|
|
||||||
#[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();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use iced::widget::column;
|
use iced::widget::column;
|
||||||
|
|
||||||
|
const MAX_INLINE_SVG_HEIGHT: f32 = 480.0;
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub(super) fn chat_detail(&self) -> Element<'_, Message> {
|
pub(super) fn chat_detail(&self) -> Element<'_, Message> {
|
||||||
let Some(item) = self.selected_project() else {
|
let Some(item) = self.selected_project() else {
|
||||||
@@ -494,6 +496,19 @@ fn tool_cards(cards: Vec<crate::agent::ToolCard>) -> Element<'static, Message> {
|
|||||||
if index > 0 {
|
if index > 0 {
|
||||||
rows = rows.push(rule::horizontal(1));
|
rows = rows.push(rule::horizontal(1));
|
||||||
}
|
}
|
||||||
|
if let Some(presentation) = crate::agent::svg_presentation(&card) {
|
||||||
|
let height = presentation.height.min(MAX_INLINE_SVG_HEIGHT);
|
||||||
|
let figure = column![
|
||||||
|
svg(svg::Handle::from_memory(presentation.svg.into_bytes()))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fixed(height))
|
||||||
|
.content_fit(iced::ContentFit::Contain),
|
||||||
|
text(presentation.alt).size(12).color(muted_text()),
|
||||||
|
]
|
||||||
|
.spacing(8);
|
||||||
|
rows = rows.push(container(figure).padding(10).width(Length::Fill));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let parameters = crate::agent::tool_parameters(&card.call);
|
let parameters = crate::agent::tool_parameters(&card.call);
|
||||||
let call = crate::agent::tool_call_text(&card.call);
|
let call = crate::agent::tool_call_text(&card.call);
|
||||||
let copy_call = tooltip(
|
let copy_call = tooltip(
|
||||||
|
|||||||
@@ -1254,4 +1254,71 @@ mod tests {
|
|||||||
drop(reopened);
|
drop(reopened);
|
||||||
fs::remove_file(path).unwrap();
|
fs::remove_file(path).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_svg_tool_call_survives_session_switch_compaction_and_reopen() {
|
||||||
|
let id = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let path = std::env::temp_dir().join(format!("ds4-svg-chat-{id}.sqlite3"));
|
||||||
|
let mut database = Database::open(&path).unwrap();
|
||||||
|
let project = database.create_project("DS4", "/tmp/ds4-svg-chat").unwrap();
|
||||||
|
let session = database
|
||||||
|
.create_session(project.id, "SVG", PermissionMode::Heuristic)
|
||||||
|
.unwrap();
|
||||||
|
let other = database
|
||||||
|
.create_session(project.id, "Other", PermissionMode::Heuristic)
|
||||||
|
.unwrap();
|
||||||
|
let assistant = database
|
||||||
|
.start_chat_turn(session.id, "Draw it", None, &[], false)
|
||||||
|
.unwrap()
|
||||||
|
.pop()
|
||||||
|
.unwrap();
|
||||||
|
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><path d="M0 0h120v80H0z" fill="#345"/></svg>"##;
|
||||||
|
let content = format!(
|
||||||
|
"Here is the diagram.<|DSML|tool_calls><|DSML|invoke name=\"present_svg\"><|DSML|parameter name=\"svg\" string=\"true\">{svg}</|DSML|parameter><|DSML|parameter name=\"alt\" string=\"true\">A dark rectangle</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.update_message(assistant.id, None, true, &content)
|
||||||
|
.unwrap();
|
||||||
|
database
|
||||||
|
.record_tool_result(
|
||||||
|
session.id,
|
||||||
|
"Tool result 1 (present_svg):\nSVG presented inline: A dark rectangle\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
database
|
||||||
|
.record_compaction(session.id, "Later context summary.", None, None, 100, 1_000)
|
||||||
|
.unwrap();
|
||||||
|
drop(database);
|
||||||
|
|
||||||
|
let mut reopened = Database::open(&path).unwrap();
|
||||||
|
assert!(reopened.load_messages(other.id).unwrap().is_empty());
|
||||||
|
let messages = reopened.load_messages(session.id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.filter(|message| message.content.contains(svg))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let assistant_index = messages
|
||||||
|
.iter()
|
||||||
|
.position(|message| message.id == assistant.id)
|
||||||
|
.unwrap();
|
||||||
|
let cards = crate::agent::stored_tool_cards(
|
||||||
|
crate::model::ModelChoice::DeepSeekV4Flash0731,
|
||||||
|
&messages[assistant_index].content,
|
||||||
|
Some(&messages[assistant_index + 1].content),
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert_eq!(cards.len(), 1);
|
||||||
|
let presentation = crate::agent::svg_presentation(&cards[0]).unwrap();
|
||||||
|
assert_eq!(presentation.svg, svg);
|
||||||
|
assert_eq!(presentation.alt, "A dark rectangle");
|
||||||
|
assert!(messages.iter().any(|message| message.compaction));
|
||||||
|
drop(reopened);
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user