1//! A call to Jev as a window a visitor can read: the request and the response, printed from the 2//! raw bodies, indented and coloured, and a probability drawn as lmjtfy draws it. This is lmjtfy's 3//! tool-call panel, lifted out so another site shows its own calls the same way. 4//! 5//! The rule it keeps (lmjtfy's, 2026-10-02): **printing may add whitespace and nothing else.** No 6//! reordered keys, no dropped or summarised fields. A site's tests parse what is printed back and 7//! compare it to the body it holds (`printed_json` here does the parsing). 8 9use maud::{Markup, html}; 10use serde_json::Value; 11 12use crate::components::{Icon, pixel_icon}; 13 14/// A JSON body, indented and coloured for reading. Only whitespace is added: keys keep their order 15/// and every value is printed as it was sent. Text that is not JSON is shown as it is. 16pub fn json(text: &str) -> Markup { 17 match serde_json::from_str::<Value>(text) { 18 Ok(value) => html! { pre .json { (node(&value, 0)) } }, 19 Err(_) => html! { pre { (text) } }, 20 } 21} 22 23fn node(value: &Value, depth: usize) -> Markup { 24 let indent = " ".repeat(depth + 1); 25 let close = " ".repeat(depth); 26 match value { 27 Value::Object(map) if !map.is_empty() => html! { 28 "{\n" 29 @for (i, (key, child)) in map.iter().enumerate() { 30 (indent) span .k { (quoted(key)) } ": " (node(child, depth + 1)) 31 @if i + 1 < map.len() { "," } "\n" 32 } 33 (close) "}" 34 }, 35 Value::Array(items) if !items.is_empty() => html! { 36 "[\n" 37 @for (i, child) in items.iter().enumerate() { 38 (indent) (node(child, depth + 1)) 39 @if i + 1 < items.len() { "," } "\n" 40 } 41 (close) "]" 42 }, 43 Value::String(text) => html! { span .s { (quoted(text)) } }, 44 // Numbers, booleans, null, and the empty object and array. 45 other => html! { span .n { (other.to_string()) } }, 46 } 47} 48 49/// A string as JSON writes it, quotes and escapes included. 50fn quoted(text: &str) -> String { 51 Value::String(text.to_owned()).to_string() 52} 53 54/// One labelled block of a call: an optional plain head (the request line and headers) over a body. 55pub struct Panel<'a> { 56 /// `request`, `response`. 57 pub label: &'a str, 58 /// Lines shown as they are above the body, as `POST /v1/systemone`. 59 pub head: Option<&'a str>, 60 pub body: &'a str, 61 /// A line after the body, marked with a `!`: what the reader should know about this panel 62 /// (a call that was not sent, and why). 63 pub note: Option<&'a str>, 64} 65 66/// A call as a window that folds (`open` or shut): a title bar (`title`, and `meta` at the right) over its 67/// panels. 68pub fn exchange(icon: &Icon, title: &str, meta: &str, open: bool, panels: &[Panel<'_>]) -> Markup { 69 exchange_with(icon, html! { (title) }, (!meta.is_empty()).then(|| html! { (meta) }), open, html! {}, panels) 70} 71 72/// `exchange` with a title and a `meta` that are markup (a moment the page rewrites as "3 minutes 73/// ago", a name in a link), and `lead`, whatever the site puts first in the body, above the 74/// panels (a choice between earlier answers to the same request). 75pub fn exchange_with(icon: &Icon, title: Markup, meta: Option<Markup>, open: bool, lead: Markup, panels: &[Panel<'_>]) -> Markup { 76 html! { 77 details .win open[open] { 78 summary .bar-title { 79 span .fold {} (pixel_icon(icon, "")) (title) 80 @if let Some(meta) = meta { span .meta { (meta) } } 81 } 82 div .body { 83 (lead) 84 @for panel in panels { 85 div .wire { 86 span .label-s { (panel.label) } 87 @if let Some(head) = panel.head { pre { (head) } } 88 (json(panel.body)) 89 @if let Some(note) = panel.note { p .note { (note) } } 90 } 91 } 92 } 93 } 94 } 95} 96 97/// One option of a choice (or the one side of a yes-or-no) and its probability. 98pub struct Bar<'a> { 99 pub label: &'a str, 100 pub p: f64, 101 /// The winner is drawn inverted, with a filled square. 102 pub won: bool, 103} 104 105fn percent(p: f64) -> String { 106 format!("{:.0}%", p * 100.0) 107} 108 109/// Where the square sits on its rule, from a probability. 110fn at(p: f64) -> String { 111 format!("left: {:.1}%", (p * 100.0).clamp(0.0, 100.0)) 112} 113 114/// Probabilities as lmjtfy draws them: an inverted label, the figure at the right, and a rule with 115/// a square at the value. `threshold` draws a tick on every rule where a decision is made on it, 116/// named `threshold_label`. 117pub fn bars(bars: &[Bar<'_>], threshold: Option<(f64, &str)>) -> Markup { 118 html! { 119 div .bars { 120 @for bar in bars { 121 div .bar.won[bar.won] { 122 span .label { (bar.label) } 123 span .pct { (percent(bar.p)) } 124 span .track { 125 @if let Some((t, name)) = threshold { span .cut style=(at(t)) title=(name) {} } 126 span .thumb style=(at(bar.p)) {} 127 } 128 } 129 } 130 } 131 } 132} 133 134/// The JSON a panel printed, read back out of its markup: the text with tags gone and entities 135/// undone. A site's test compares it to the body it passed in. 136pub fn printed_json(markup: &str) -> Option<Value> { 137 let mut text = String::new(); 138 let mut in_tag = false; 139 for ch in markup.chars() { 140 match ch { 141 '<' => in_tag = true, 142 '>' => in_tag = false, 143 c if !in_tag => text.push(c), 144 _ => {} 145 } 146 } 147 let text = text.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"); 148 serde_json::from_str(&text).ok() 149} 150 151#[cfg(test)] 152mod tests { 153 use super::*; 154 use crate::components::icons; 155 156 const RAW: &str = r#"{"model":"m","questions":{"b":{"type":"noul","instructions":"Is <it> \"ok\" & fine?"}},"state":{"z":1,"a":[true,null,"x"],"e":{},"l":[]}}"#; 157 158 #[test] 159 fn what_is_printed_is_the_body_with_whitespace_added_and_nothing_else() { 160 let html = json(RAW).into_string(); 161 let back = printed_json(&html).expect("the printed text is JSON"); 162 let sent: Value = serde_json::from_str(RAW).unwrap(); 163 assert_eq!(back, sent); 164 // Keys are in the order they were sent, not sorted. 165 assert_eq!(serde_json::to_string(&back).unwrap(), RAW); 166 assert!(html.contains("<span class=\"k\">")); 167 } 168 169 #[test] 170 fn text_that_is_not_json_is_shown_as_it_is() { 171 assert_eq!(json("upstream <down>").into_string(), "<pre>upstream <down></pre>"); 172 } 173 174 #[test] 175 fn a_call_is_a_window_with_a_labelled_panel_for_each_body() { 176 let html = exchange( 177 &icons::JEV, 178 "jev · 2 questions", 179 "200", 180 true, 181 &[Panel { label: "request", head: Some("POST /v1/systemone"), body: RAW, note: None }, Panel { label: "response", head: None, body: "{\"a\":1}", note: Some("kept") }], 182 ) 183 .into_string(); 184 assert!(html.starts_with("<details class=\"win\" open>")); 185 assert!(html.contains("<span class=\"label-s\">request</span>") && html.contains("<span class=\"label-s\">response</span>")); 186 assert!(html.contains("<pre>POST /v1/systemone</pre>")); 187 assert!(html.find("request").unwrap() < html.find("response").unwrap()); 188 assert!(html.contains("<p class=\"note\">kept</p>") && html.matches("class=\"note\"").count() == 1); 189 } 190 191 #[test] 192 fn a_call_may_lead_with_markup_and_carry_markup_in_its_title_bar() { 193 let html = exchange_with(&icons::TOOL, html! { "tool call · " b { "x" } }, Some(html! { i { "now" } }), false, html! { div #lead {} }, &[]).into_string(); 194 assert!(html.starts_with("<details class=\"win\"><summary class=\"bar-title\">"), "{html}"); 195 assert!(html.contains("tool call · <b>x</b>") && html.contains("<span class=\"meta\"><i>now</i></span>")); 196 assert!(html.find("id=\"lead\"").unwrap() > html.find("class=\"body\"").unwrap()); 197 } 198 199 #[test] 200 fn a_probability_is_a_square_on_a_rule_and_a_threshold_is_a_tick_on_it() { 201 let html = bars(&[Bar { label: "yes", p: 0.9, won: true }, Bar { label: "no", p: 0.1, won: false }], Some((0.85, "allowed from here"))).into_string(); 202 assert!(html.contains("<span class=\"pct\">90%</span>") && html.contains("left: 90.0%")); 203 assert_eq!(html.matches("class=\"cut\"").count(), 2); 204 assert!(html.contains("left: 85.0%") && html.contains("title=\"allowed from here\"")); 205 assert!(html.contains("class=\"bar won\"")); 206 assert!(!bars(&[Bar { label: "x", p: 2.0, won: false }], None).into_string().contains("left: 200")); 207 } 208}