1//! The page and the transcript, as HTML. Pure: a `View` in, markup out. 2 3use crate::diagram; 4use crate::host::Host; 5use crate::meatproxy; 6 7pub mod code; 8pub mod releases; 9pub mod repos; 10pub mod toast; 11 12use std::time::Duration; 13 14use archive::{Answer, Detail, Entry, Home, Stats}; 15use ask::{Judged, Outcome, Prepared, Sent}; 16use jev_protocol::{ChoiceAnswer, ScoreAnswer}; 17use llm::{Draft, ToolCall}; 18use maud::{DOCTYPE, Markup, PreEscaped, html}; 19use rules::{Fact, Kind, Known, Network}; 20use serde_json::Value; 21 22const CSS: &str = include_str!("page.css"); 23const JS: &str = include_str!("page.js"); 24// The nearest open faces to typesafe.ai's own: Inter Tight for its grotesk, 25// VT323 for its pixel terminal face, and JetBrains Mono, which it also uses. 26const FONTS: &str = "https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500&family=JetBrains+Mono:wght@400;500&family=VT323&display=swap"; 27 28/// Pixel icons for the window title bars, 12 by 12, `#` for ink. 29mod icons { 30 pub const ASK: [&str; 12] = [ 31 "............", "..#####.....", ".#.....#....", ".#.....#....", ".#.....#....", ".#.....#....", 32 "..#####.....", ".......##...", "........##..", ".........##.", "..........#.", "............", 33 ]; 34 pub const JEV: [&str; 12] = [ 35 ".....##.....", "....#.##....", "...#.#.##...", "..#.#.#.##..", ".#.#.#.#.##.", "#.#.#.#.####", 36 "####.#.#.#.#", ".##.#.#.#.#.", "..##.#.#.#..", "...##.#.#...", "....##.#....", ".....##.....", 37 ]; 38 pub const LLM: [&str; 12] = [ 39 "............", ".#########..", ".#.......#..", ".#.##.##.#..", ".#.......#..", ".#.#####.#..", 40 ".#.......#..", ".#########..", "...##.......", "..##........", ".#..........", "............", 41 ]; 42 pub const TOOL: [&str; 12] = [ 43 "............", ".......###..", "......##....", "......##..#.", "......#####.", ".....####...", 44 "....###.....", "...###......", "..###.......", ".###........", ".##.........", "............", 45 ]; 46} 47 48/// A pixel icon as inline SVG: one square per `#`. 49fn icon(rows: &[&str; 12], class: &str) -> Markup { 50 html! { 51 svg class={ "icon " (class) } viewBox="0 0 12 12" aria-hidden="true" { 52 @for (y, row) in rows.iter().enumerate() { 53 @for (x, cell) in row.bytes().enumerate() { 54 @if cell == b'#' { rect x=(x) y=(y) width="1" height="1" fill="currentColor" {} } 55 } 56 } 57 } 58 } 59} 60 61/// One request to Jev, before and after its response. It may ask several 62/// questions. 63pub struct JevCall { 64 /// What the request is for: `facts` or `answers`. 65 pub id: &'static str, 66 /// Each question's id and Jev type, in the order asked. 67 pub parts: Vec<(String, &'static str)>, 68 /// The request body as sent. 69 pub request: String, 70 /// `None` while Jev is still answering. 71 pub outcome: Option<Outcome>, 72 /// Which of the request's kept responses this is, once one is. 73 pub kept: Option<Versions>, 74} 75 76/// A call's place among the responses its request got: version `version` of 77/// `versions`, and its name for the page's ↻ and ◀ ▶ (`archive::call_id`). 78#[derive(Clone, Debug, PartialEq)] 79pub struct Versions { 80 pub id: String, 81 pub version: u32, 82 pub versions: u32, 83} 84 85impl JevCall { 86 pub fn pending(prepared: &Prepared) -> Self { 87 JevCall { 88 id: prepared.id, 89 parts: prepared.parts.iter().map(|part| (part.id.clone(), part.kind)).collect(), 90 request: prepared.request.clone(), 91 outcome: None, 92 kept: None, 93 } 94 } 95 96 /// The answer to the question called `id`, if Jev gave one. 97 pub fn judged(&self, id: &str) -> Option<&Judged> { 98 let index = self.parts.iter().position(|(part, _)| part == id)?; 99 match &self.outcome { 100 Some(Outcome::Answered { judged, .. }) => judged.get(index), 101 _ => None, 102 } 103 } 104} 105 106/// The call to the LLM that writes Jev's questions. 107pub struct LlmCall { 108 pub model: &'static str, 109 /// The request body as sent. 110 pub request: String, 111 /// `None` while the LLM is still writing. 112 pub outcome: Option<LlmOutcome>, 113 /// Which of the request's kept responses this is, once one is. 114 pub kept: Option<Versions>, 115} 116 117pub struct LlmOutcome { 118 /// The reply as it came, or the error in its place. 119 pub body: String, 120 /// What the reply cost when it was sent. 121 pub neurons: f64, 122 pub took: Duration, 123 /// Tool calls the LLM made past the cap, which were not run. 124 pub dropped: usize, 125 /// Whether this ask sent the request, or was given the reply kept from 126 /// the first time. 127 pub sent: Sent, 128} 129 130/// One tool call the LLM made. 131pub struct Tool { 132 /// Its question's id in the request to Jev: `q1`, `q2`, ... 133 pub id: String, 134 pub call: ToolCall, 135 /// Why Jev's own protocol refused a call whose shape was right. 136 pub refused: Option<String>, 137} 138 139impl Tool { 140 /// The question to send to Jev, if the call is one. 141 pub fn draft(&self) -> Option<&Draft> { 142 self.call.draft.as_ref().ok().filter(|_| self.refused.is_none()) 143 } 144 145 /// Why this call was not sent to Jev, if it was not. 146 fn problem(&self) -> Option<&str> { 147 match &self.call.draft { 148 Err(error) => Some(error), 149 Ok(_) => self.refused.as_deref(), 150 } 151 } 152} 153 154/// How the exchange ended. 155pub enum Ending { 156 /// Nothing was typed; nothing was sent. 157 Empty, 158 /// The gate said this is not something Jev can judge. 159 NotAQuestion { p_yes: f64 }, 160 /// Jev answered: the input itself, read one way or two, or what the LLM 161 /// wrote. Which, is in the rules' notes. 162 Answered, 163 /// This visitor has asked too much in the last minute. Nothing was sent. 164 Slow, 165 /// The LLM replied, and nothing in the reply was a question Jev answered. 166 NoQuestion, 167 /// A call failed, or could not be built. 168 Failed { error: String }, 169 /// The Worker has no API key. 170 Offline, 171 /// Today's shared budget for `what` is used up, until `resets_ms`. 172 Spent { what: &'static str, resets_ms: f64 }, 173 /// The page is looking at an old version of a call, and the call after 174 /// it was never sent: nothing is sent while looking, so it waits for ↻. 175 NotKept { id: String }, 176} 177 178/// One answer in the transcript. 179struct Shown<'a> { 180 /// The question it answers: the input, or what the LLM wrote. 181 question: &'a str, 182 judged: &'a Judged, 183 /// How the input was read, when it was read more than one way. 184 read_as: Option<&'static str>, 185 /// A how-likely: the answer is the probability itself, not a yes or no. 186 chance: bool, 187} 188 189impl Shown<'_> { 190 /// The answer as it is kept for the feed and for link previews. 191 fn told(&self) -> Answer { 192 let detail = match self.judged { 193 Judged::Noul(p) if self.chance => Detail::Chance { p: *p }, 194 Judged::Noul(p_yes) => Detail::Noul { p_yes: *p_yes }, 195 Judged::Choice(answer) => { 196 let mut options = answer.probabilities.clone(); 197 options.sort_by(|a, b| b.1.total_cmp(&a.1)); 198 Detail::Choice { confidence: answer.confidence, options } 199 } 200 Judged::Score(answer) => Detail::Score { 201 score: answer.score, 202 confidence: answer.confidence, 203 levels: answer.legend.iter().cloned().zip(answer.probabilities.iter().copied()).collect(), 204 }, 205 }; 206 Answer { headline: self.headline(), detail } 207 } 208 209 /// The answer in a few words: what the transcript prints large. 210 fn headline(&self) -> String { 211 match self.judged { 212 Judged::Noul(p) if self.chance => format!("{}.", percent(*p)), 213 judged => headline(judged), 214 } 215 } 216} 217 218/// Everything the transcript shows. 219pub struct View { 220 pub input: String, 221 /// What is known about the input so far: what the rules run on. 222 pub known: Known, 223 /// The rules that decided a step, in order (indices into the network's 224 /// terminals). 225 pub fired: Vec<usize>, 226 /// The request for the facts the rules wanted. 227 pub facts: Option<JevCall>, 228 pub llm: Option<LlmCall>, 229 pub tools: Vec<Tool>, 230 /// The request that asks Jev every question the LLM wrote. 231 pub judging: Option<JevCall>, 232 /// `None` while calls are still being made. 233 pub ending: Option<Ending>, 234 /// The card of the LLM suggestion's site, read before the ending is 235 /// shown (`meatproxy::head`); nothing if it could not be. 236 pub preview: Option<meatproxy::Head>, 237 /// Which version of each call the page asked for (`$pins`). 238 pub pins: archive::Pins, 239} 240 241impl View { 242 pub fn new(input: String) -> Self { 243 View { 244 input, 245 known: Known::default(), 246 fired: Vec::new(), 247 facts: None, 248 llm: None, 249 tools: Vec::new(), 250 judging: None, 251 ending: None, 252 preview: None, 253 pins: archive::Pins::default(), 254 } 255 } 256 257 /// `$pins` once this view is shown: every call shown at a version other 258 /// than its newest, so the page's next ↻ or ◀ ▶ keeps them as they are. 259 pub fn pinned(&self) -> String { 260 let kept = [self.facts.as_ref().and_then(|call| call.kept.as_ref()), self.llm.as_ref().and_then(|call| call.kept.as_ref()), self.judging.as_ref().and_then(|call| call.kept.as_ref())]; 261 let pins: Vec<String> = kept.into_iter().flatten().filter(|kept| kept.version < kept.versions).map(|kept| format!("{}:{}", kept.id, kept.version)).collect(); 262 pins.join(";") 263 } 264 265 /// How many of this view's calls were sent for it, and how many were 266 /// answered from what was kept. 267 pub fn calls(&self) -> (u32, u32) { 268 let jev = [&self.facts, &self.judging].into_iter().flatten().filter_map(|call| match &call.outcome { 269 Some(Outcome::Answered { sent, .. }) => Some(*sent), 270 _ => None, 271 }); 272 let llm = self.llm.as_ref().and_then(|call| call.outcome.as_ref()).map(|outcome| outcome.sent); 273 let (mut sent, mut kept) = (0, 0); 274 for call in jev.chain(llm) { 275 match call { 276 Sent::Now => sent += 1, 277 Sent::Before { .. } => kept += 1, 278 } 279 } 280 (sent, kept) 281 } 282 283 fn fact(&self, fact: Fact) -> Option<&Judged> { 284 self.facts.as_ref()?.judged(ask::question_id(fact)) 285 } 286 287 fn judged(&self, tool: &Tool) -> Option<&Judged> { 288 self.judging.as_ref()?.judged(&tool.id) 289 } 290 291 /// The kind Jev thinks the input most likely is. 292 pub fn reading(&self) -> Option<Kind> { 293 match self.fact(Fact::Reads(Kind::Noul))? { 294 Judged::Choice(answer) => ask::readings(answer).first().copied(), 295 _ => None, 296 } 297 } 298 299 /// What to print for a fact on the diagram: Jev's own number for a fact 300 /// it answered, a count for the others. 301 fn shown(&self, fact: Fact) -> Option<String> { 302 match fact { 303 Fact::Drafted => Some(self.tools.iter().filter(|tool| tool.draft().is_some()).count().to_string()), 304 Fact::Judged => Some(self.tools.iter().filter(|tool| self.judged(tool).is_some()).count().to_string()), 305 Fact::Reads(kind) => match self.fact(fact)? { 306 Judged::Choice(answer) => ask::kind_probability(answer, kind).map(probability), 307 _ => None, 308 }, 309 _ => match self.fact(fact)? { 310 Judged::Noul(p_yes) => Some(probability(*p_yes)), 311 Judged::Score(answer) => Some(format!("{:.1}", answer.score)), 312 Judged::Choice(_) => None, 313 }, 314 } 315 } 316 317 /// Whether Jev answered any question the LLM wrote. 318 pub fn any_judged(&self) -> bool { 319 self.tools.iter().any(|tool| self.judged(tool).is_some()) 320 } 321 322 /// The answers the rules say to show, in rule order: the input itself as 323 /// Jev answered it with the facts (read as a yes-or-no, as a how-much, or 324 /// both when Jev was split), then what the LLM wrote. 325 fn results(&self) -> Vec<Shown<'_>> { 326 let mut results = Vec::new(); 327 for fact in Network::lmjtfy().shows(&self.known) { 328 if fact == Fact::Judged { 329 results.extend(self.tools.iter().filter_map(|tool| { 330 let question = tool.call.draft.as_ref().ok()?.instructions(); 331 Some(Shown { question, judged: self.judged(tool)?, read_as: None, chance: false }) 332 })); 333 } else if let Some(judged) = self.fact(fact) { 334 let read_as = match fact { 335 Fact::Yes => "read as a yes or no", 336 Fact::Chance => "read as a how likely", 337 _ => "read as a how much", 338 }; 339 results.push(Shown { question: &self.input, judged, read_as: Some(read_as), chance: fact == Fact::Chance }); 340 } 341 } 342 // The reading is worth saying only when there is another beside it. 343 if results.len() == 1 { 344 results[0].read_as = None; 345 } 346 results 347 } 348 349 /// Whether there is any answer to show. 350 pub fn answered(&self) -> bool { 351 !self.results().is_empty() 352 } 353 354 /// What Jev said to each question it answered, as it is kept for the 355 /// feed and for link previews. 356 pub fn said(&self) -> Vec<Answer> { 357 self.results().iter().map(Shown::told).collect() 358 } 359} 360 361/// What is used of the budgets every visitor shares, and their limits. 362pub struct Shared { 363 pub status: budget::Status, 364 pub neurons_per_day: f64, 365 pub jev_dollars_per_day: f64, 366} 367 368/// A whole number with thousands separators. 369fn grouped(n: f64) -> String { 370 let digits = format!("{:.0}", n.max(0.0)); 371 let mut out = String::new(); 372 for (i, digit) in digits.chars().enumerate() { 373 if i > 0 && (digits.len() - i) % 3 == 0 { 374 out.push(','); 375 } 376 out.push(digit); 377 } 378 out 379} 380 381/// A moment, for a person. The server knows neither the reader's clock nor 382/// their timezone, so it writes the moment in UTC and `aldebaran_time::SCRIPT` rewrites it: 383/// the text as how long ago or how soon ("3 days ago", "in 5 hours"), and 384/// the tooltip as the date and time where the reader is. 385pub fn moment(at_ms: f64) -> Markup { 386 aldebaran_time::moment(at_ms as i64) 387} 388 389/// The shared budgets, as a window everyone sees: what is left today, whose 390/// count it is, and that it is one pot. With no figures to show, it says so. 391pub fn budget(shared: Option<Shared>) -> Markup { 392 html! { 393 div #budget .win { 394 div .bar-title { (icon(&icons::JEV, "")) "Shared budget" span .meta { @if let Some(shared) = &shared { "resets " (moment(budget::resets_ms(shared.status.day))) } } } 395 div .body { 396 @if let Some(shared) = shared { 397 @let left = (shared.neurons_per_day - shared.status.neurons).max(0.0); 398 @let jev_left = (shared.jev_dollars_per_day - shared.status.jev_dollars).max(0.0); 399 div .bars { 400 div .bar.won { 401 span .label { "LLM · free neurons left today" } 402 span .pct { (grouped(left)) " of " (grouped(shared.neurons_per_day)) } 403 span .track { span .thumb style=(at(left / shared.neurons_per_day)) {} } 404 } 405 div .bar.won { 406 span .label { "Jev · dollars left today" } 407 span .pct { (format!("${jev_left:.4} of ${:.2}", shared.jev_dollars_per_day)) } 408 span .track { span .thumb style=(at(jev_left / shared.jev_dollars_per_day)) {} } 409 } 410 } 411 p .fine { 412 "Everyone who opens this page spends from the same two pots. When one is empty, " 413 "the site stops answering until it resets. " 414 @match shared.status.counted { 415 budget::Counted::Account => "The neuron figure is Cloudflare's own count for the whole account.", 416 budget::Counted::Own => "The neuron figure is this site's own count; Cloudflare's could not be read.", 417 } 418 } 419 } @else { 420 p .fine { "The shared budget could not be read just now." } 421 } 422 } 423 } 424 } 425} 426 427/// A line of a feed. A line of "asked lately" has an id from its question, 428/// so the line a question already has is the one a live update moves to the 429/// top; "most asked" repeats some of those questions, and has none. 430fn entry(entry: &Entry, id: Option<String>) -> Markup { 431 html! { 432 li .entry id=[id] { 433 a href=(address(&entry.input, &entry.slug)) title=(aldebaran_time::utc_text(entry.asked_ms as i64)) data-when=(aldebaran_time::iso_utc(entry.asked_ms as i64)) { (entry.input) } 434 span .said-s { 435 (entry.headlines()) 436 @if entry.times > 1 { span .times { " ×" (entry.times) } } 437 } 438 } 439 } 440} 441 442fn lately_entry(asked: &Entry) -> Markup { 443 entry(asked, Some(entry_id(&asked.input))) 444} 445 446/// The id of a question's line in the feed: FNV-1a of the question, so it 447/// is the same on every page and for every render. 448fn entry_id(input: &str) -> String { 449 let hash = input.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)); 450 format!("q-{hash:016x}") 451} 452 453/// The end of "asked lately". A full page may have more after it, so when it 454/// scrolls into view it asks `/feed` for the page after the last line shown, 455/// once: the answer replaces it, with a new end that asks again. A short 456/// page is the end of the feed, and asks nothing. 457fn more(shown: &[Entry]) -> Markup { 458 let after = (shown.len() == archive::FEED as usize).then(|| shown.last()).flatten(); 459 html! { 460 @if let Some(last) = after { 461 li #more "data-on-intersect__once"={ 462 "@get('/feed?ms=" (last.asked_ms) "&q=" (url_encode(&last.input)) "')" 463 } { span .meta { "more…" } } 464 } @else { 465 li #more hidden {} 466 } 467 } 468} 469 470/// The page of the feed after a scroll: its lines, which go before the end, 471/// and the new end. 472pub fn older(entries: &[Entry]) -> (Markup, Markup) { 473 (html! { @for asked in entries { (lately_entry(asked)) } }, more(entries)) 474} 475 476/// A question just asked, for the top of every open page's feed. 477pub fn lately_top(entry: &Entry) -> Markup { 478 html! { ol hidden "data-prepend"="lately" { (lately_entry(entry)) } } 479} 480 481/// The most asked questions, replaced whole when they change. 482pub fn most(most: &[Entry]) -> Markup { 483 html! { 484 div #most { 485 @if !most.is_empty() { 486 span .label-s { "most asked" } 487 ol .feed { @for asked in most { (entry(asked, None)) } } 488 } 489 } 490 } 491} 492 493/// What people asked and what Jev said: lately, and most often. Each is a 494/// link that asks it again, which sends nothing: every call it needs is 495/// kept. Nothing about who asked is shown, and a question Jev judged unfit to show is not listed. "Asked 496/// lately" scrolls on for as long as there is feed (`more`). The window is 497/// there even when the feed is empty, and hidden by the style sheet until a 498/// line arrives, so a live update has somewhere to put the first one. 499pub fn feed(home: Option<&Home>) -> Markup { 500 let lately = home.map(|home| home.lately.as_slice()).unwrap_or_default(); 501 let most_asked = home.map(|home| home.most.as_slice()).unwrap_or_default(); 502 html! { 503 div #feed .win { 504 div .bar-title { (icon(&icons::ASK, "")) "Asked lately" span .meta { "by nobody in particular" } } 505 div .body { 506 ol #lately .feed { @for asked in lately { (lately_entry(asked)) } (more(lately)) } 507 (most(most_asked)) 508 } 509 } 510 } 511} 512 513fn plural(n: u32, one: &str, many: &str) -> String { 514 format!("{} {}", grouped(f64::from(n)), if n == 1 { one } else { many }) 515} 516 517/// The archive in numbers: how much Jev settled alone, and how many 518/// requests were never sent because their response was kept. 519pub fn tally(stats: Option<&Stats>) -> Markup { 520 let stats = stats.filter(|stats| stats.questions > 0); 521 html! { 522 div #tally .win[stats.is_some()] { 523 @if let Some(stats) = stats { 524 div .bar-title { (icon(&icons::JEV, "")) "So far" } 525 div .body { 526 ul .tally { 527 li { 528 b { (plural(stats.questions, "question", "questions")) } 529 " answered, asked " (plural(stats.asks, "time", "times")) "." 530 } 531 @if let Some(percent) = stats.no_llm_percent() { 532 li { b { (percent) "%" } " of them Jev answered alone, with no LLM." } 533 } 534 li { 535 b { (plural(stats.sent, "request", "requests")) } 536 " sent to Jev and the LLM. " 537 b { (grouped(f64::from(stats.kept))) } 538 " more were not sent, because the same request had been answered before." 539 } 540 @if stats.clones + stats.pulls > 0 { 541 li { 542 "The code was " a href=(CODE) { "cloned" } " " 543 b { (plural(stats.clones, "time", "times")) } " and pulled " 544 b { (plural(stats.pulls, "time", "times")) } "." 545 } 546 } 547 } 548 } 549 } 550 } 551 } 552} 553 554/// A short mark of an answer, for the address of its picture: whoever shows 555/// previews keeps a picture by its address, so the address has to change 556/// when the answer does (a question asked for the first time, most of all). 557fn mark(text: &str) -> String { 558 // FNV-1a. 559 let hash = text.bytes().fold(0x811c_9dc5_u32, |hash, byte| (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193)); 560 format!("{hash:08x}") 561} 562 563/// What a link to the page unfurls as, in Discord and wherever else reads 564/// Open Graph tags. The home page says what the site is. A `?q=` link shows 565/// the question, and Jev's answer when it has been asked before: a preview 566/// is built from what is kept and never asks anything. `origin` is where 567/// the site is, for the picture's address. 568fn preview(typed: &str, said: Option<&Entry>, declined: bool, origin: &str) -> Markup { 569 // Each answer with its numbers: the probability, the confidence, the 570 // likeliest options. That is half of what Jev says. 571 let answer = said.filter(|entry| !entry.answers.is_empty()).map(|entry| { 572 let lines: Vec<String> = entry.answers.iter().map(Answer::line).collect(); 573 lines.join(" / ") 574 }); 575 let (title, description, picture) = if typed.is_empty() { 576 ( 577 "Let Me Jev That For You".to_owned(), 578 "Ask Jev anything. It answers yes, no, which one or how much, with a probability, and shows its work." 579 .to_owned(), 580 format!("{origin}/card.png"), 581 ) 582 } else { 583 // A question Jev could not take says so, and nothing of Jev's: it 584 // points at the LLMs the page does (`meatproxy`), and its picture 585 // is the card for that (`card::Card::Declined`). 586 let declined = declined && answer.is_none(); 587 let description = match &answer { 588 Some(answer) => format!("Jev: {answer}"), 589 None if declined => "Please choose an LLM instead: Claude, ChatGPT or Gemini.".to_owned(), 590 None => "Let me Jev that for you.".to_owned(), 591 }; 592 let picture = format!( 593 "{origin}/card.png?q={}&a={}", 594 url_encode(typed), 595 mark(if declined { "declined" } else { answer.as_deref().unwrap_or_default() }) 596 ); 597 (format!("Q: {typed}"), description, picture) 598 }; 599 // A question with a slug is one page at one address, whichever link 600 // led here. Only what the feed may show is for a search engine: any 601 // text at all makes a `?q=` page, and a question Jev judged unfit is 602 // for whoever holds the link. 603 let canonical = said.filter(|entry| !entry.slug.is_empty()).map(|entry| format!("{origin}{}", address(&entry.input, &entry.slug))); 604 let unlisted = !typed.is_empty() && !said.is_some_and(|entry| entry.shown); 605 html! { 606 title { (title) } 607 @if let Some(canonical) = &canonical { link rel="canonical" href=(canonical); } 608 @if unlisted { meta name="robots" content="noindex"; } 609 meta name="description" content=(description); 610 meta property="og:type" content="website"; 611 meta property="og:site_name" content="LMJTFY · Let Me Jev That For You"; 612 meta property="og:title" content=(title); 613 meta property="og:description" content=(description); 614 meta property="og:image" content=(picture); 615 meta property="og:image:type" content="image/png"; 616 meta property="og:image:width" content=(card::WIDTH); 617 meta property="og:image:height" content=(card::HEIGHT); 618 // The picture is shown large, under the text. 619 meta name="twitter:card" content="summary_large_image"; 620 // The bar down the side of the embed: the page's pink. 621 meta name="theme-color" content="#f386a1"; 622 } 623} 624 625/// The start of every page: what it unfurls as, the type and the look. 626fn head(preview: Markup) -> Markup { 627 html! { 628 head { 629 meta charset="utf-8"; 630 meta name="viewport" content="width=device-width, initial-scale=1"; 631 // The build this page came from, for `/live` to compare (page.js). 632 meta name="lmjtfy-build" content=(crate::BUILD); 633 (preview) 634 link rel="preconnect" href="https://fonts.googleapis.com"; 635 link rel="preconnect" href="https://fonts.gstatic.com" crossorigin; 636 link rel="stylesheet" href=(FONTS); 637 style { (PreEscaped(CSS)) } 638 } 639 } 640} 641 642/// The bar at the top of every page. On the code host (`host`) the title 643/// leads home to the other address, and there is no online count or toasts: 644/// that host has no `/live`, and page.js, finding no `#online`, opens no 645/// socket and sends no `/seen`. 646fn nav(host: Host) -> Markup { 647 html! { 648 nav { 649 a href=(host.home_link()) { "LMJTFY" } 650 @if host.is_live() { 651 // Filled in over `/live` (page.js): pages open now, and a toast 652 // for each question asked and each clone while this one is open. 653 span .online-wrap tabindex="0" { 654 span #online .online hidden {} 655 (places(&[])) 656 } 657 div #toasts aria-live="polite" {} 658 } 659 a .dark href=(CODE) { "Code" } 660 a .dark href="https://docs.typesafe.ai" { "What is Jev?" } 661 } 662 } 663} 664 665/// Whether Jev was right, as the people who asked think: a vote each, one per 666/// browser, taken back by pressing it again. `rating` is `None` when there 667/// is nothing to vote on yet; `can` is false for a browser with no id. 668pub fn rating(rating: Option<&archive::Rating>, can: bool) -> Markup { 669 // The thumbs mean otherwise on a question Jev declined: up is "right to 670 // pass", down is "could have answered". 671 let declined = rating.is_some_and(|rating| rating.declined); 672 let button = |vote: archive::Vote, glyph: &str, n: u32, label: &str| { 673 let on = rating.and_then(|rating| rating.mine) == Some(vote); 674 let name = match vote { 675 archive::Vote::Up => "up", 676 archive::Vote::Down => "down", 677 }; 678 html! { 679 button .vote .on[on] type="button" disabled[!can] aria-pressed=(on) title=(label) 680 "data-on:click"={ "$vote = '" (name) "'; @post('/rate')" } { 681 span .glyph aria-hidden="true" { (glyph) } " " (n) 682 } 683 } 684 }; 685 html! { 686 div #rating .rating { 687 @if let Some(shown) = rating { 688 @if declined { 689 span .label-s { "Was Jev right to pass?" } 690 (button(archive::Vote::Up, "👍", shown.up, "Jev was right to pass")) 691 (button(archive::Vote::Down, "👎", shown.down, "Jev could have answered")) 692 } @else { 693 span .label-s { "Was Jev right?" } 694 (button(archive::Vote::Up, "👍", shown.up, "Jev was right")) 695 (button(archive::Vote::Down, "👎", shown.down, "Jev was wrong")) 696 } 697 @if !can { span .meta { "Voting needs this site's cookie: reload the page." } } 698 } 699 } 700 } 701} 702 703/// The box that opens under the vote (`/comment`). It is on the page from the 704/// first answer and opens by the `open` signal, which `/rate` sets while the 705/// browser has a vote, so the transition is the page's own CSS and a pressed 706/// vote costs no markup. It is never patched but for its two notes: the text 707/// lives in the `comment` signal, so a patch cannot take it away, and a 708/// failed save leaves it where it was. Closed, it is `inert`, so it is not 709/// a tab stop either. 710/// 711/// Under a question Jev declined (`declined`) it asks why Jev could have 712/// answered, and how the site can be better. 713pub fn comment_box(declined: bool) -> Markup { 714 let placeholder = if declined { "Why could Jev have answered? Or how can we improve?" } else { "Let us know what we can improve" }; 715 html! { 716 div #comment .comment data-class:open="$open" data-attr:inert="!$open" { 717 div .comment-inner { 718 label .label-s for="comment-text" { "Comments?" } 719 textarea #comment-text .comment-text name="comment" rows="3" maxlength=(archive::MOST_COMMENT) 720 placeholder=(placeholder) data-bind:comment "data-on:input"="$saved = false" {} 721 div .comment-actions { 722 button .vote type="button" data-attr:disabled="$saved || $comment.trim() === ''" "data-on:click"="@post('/comment')" { "Save" } 723 span #comment-thanks .thanks data-class:shown="$saved" role="status" { "Thanks! Your comment was saved." } 724 (comment_error(None)) 725 } 726 } 727 } 728 } 729} 730 731/// Why a comment was not saved, said where the Save button is. Fixed words: 732/// nothing the visitor typed is ever echoed into it. 733pub fn comment_error(unsaid: Option<archive::Unsaid>) -> Markup { 734 use archive::Unsaid; 735 html! { 736 span #comment-error .meta role="alert" { 737 @match unsaid { 738 None => {} 739 Some(Unsaid::Empty) => { "Write something first." } 740 Some(Unsaid::TooLong) => { "That is over " (archive::MOST_COMMENT) " characters: shorten it and Save again." } 741 Some(Unsaid::NoVote) => { "Vote first, then comment." } 742 Some(Unsaid::Limit) => { "Slow down a little: try again in a minute." } 743 Some(Unsaid::Failed) => { "That could not be saved. Your text is still here: try again." } 744 } 745 } 746 } 747} 748 749/// Where the open pages are, for the drop-down under "N online": a flag and 750/// a place a line, most first. Replaced whole over `/live` when a page comes 751/// or goes. 752pub fn places(open: &[(archive::Place, u32)]) -> Markup { 753 html! { 754 ul #places .places { 755 @for (place, n) in open { 756 li { 757 span .flag aria-hidden="true" { (place.flag().unwrap_or_else(|| "·".to_owned())) } 758 span .where { (place.label()) } 759 @if *n > 1 { span .times { "×" (n) } } 760 } 761 } 762 } 763 } 764} 765 766/// Where the code is cloned from, and its landing page: the same address. 767const CODE: &str = "/lmjtfy.git"; 768 769/// The foot of the home page and the code page: what the site is, and on 770/// the home page, how to get its code (the code page has it at the top). 771fn footer(clone: Option<&str>) -> Markup { 772 html! { 773 footer { 774 div .wrap { 775 div .about { 776 (icon(&icons::JEV, "big")) 777 span { 778 "Jev answers typed questions with calibrated probabilities, not text. " 779 "Rules decide what to ask it, and an LLM writes the options when a question needs them. " 780 a href="/rules" { "See the rules." } " " 781 // In every page as it leaves the server, so a search 782 // engine finds the link; the three that carry a 783 // visitor's words are only in an answer (meatproxy.rs). 784 "For a question Jev can't take, there is " 785 a href=(meatproxy::BASE) { "Let Me MeatProxy That For You" } ". " 786 "Not affiliated with TypeSafe AI." 787 } 788 } 789 @if let Some(origin) = clone { 790 (clone_line(&crate::clone::command(origin), html! { a href={ (origin) (CODE) } { "Get the code:" } })) 791 } 792 } 793 } 794 } 795} 796 797/// A clone command with a copy button. 798fn clone_line(command: &str, label: Markup) -> Markup { 799 clone_line_as("clone", command, label) 800} 801 802/// The same, for a page with several: `id` is what the copy button copies 803/// (page.js's `data-copy`), so it is one per command on the page. 804fn clone_line_as(id: &str, command: &str, label: Markup) -> Markup { 805 html! { 806 div .share.clone { 807 span { (label) } 808 code id=(id) { (command) } 809 button .ghost type="button" data-copy=(id) { "Copy" } 810 } 811 } 812} 813 814/// The rules with facts set by hand (`playground.rs`): every test is a link. 815pub fn playground( 816 network: &Network, 817 known: &Known, 818 fired: &[usize], 819 says: &str, 820 link: impl Fn(diagram::Clicked) -> String, 821) -> Markup { 822 let title = "The rules · LMJTFY"; 823 let about = "The rules that decide what happens to a question, as a Rete network you can set the facts of."; 824 html! { 825 (DOCTYPE) 826 html lang="en" { 827 (head(html! { 828 title { (title) } 829 meta name="description" content=(about); 830 meta property="og:title" content=(title); 831 meta property="og:description" content=(about); 832 meta name="theme-color" content="#f386a1"; 833 })) 834 body { 835 div .sheet { 836 div .wrap { 837 (nav(Host::Home)) 838 div .stack { 839 div .win { 840 div .bar-title { 841 (icon(&icons::TOOL, "")) "rules · rete" 842 span .meta { 843 @if fired.is_empty() { "nothing decided yet" } @else { (diagram::sequence(network, fired)) } 844 } 845 } 846 div .body { 847 (diagram::rete(network, known, fired, |_| None, |clicked| Some(link(clicked)))) 848 p .says { (says) } 849 p .fine { 850 "Click a test to say its fact is so, or a fact to step through what it can be. " 851 "Nothing is asked of Jev or the LLM here: the facts are whatever you set. " 852 a href="/rules" { "Start over." } 853 } 854 p .fine { 855 "On a real question, Jev gives every fact in the first frame in one " 856 "request, and the rules take it from there. Filled is what a rule that " 857 "holds stands on, outlined is known and not needed, dashed failed." 858 } 859 } 860 } 861 } 862 } 863 } 864 // The online count and the toasts, as on every page. 865 script { (PreEscaped(JS)) } 866 script { (PreEscaped(aldebaran_time::SCRIPT)) } 867 } 868 } 869 } 870} 871 872/// `said` is the kept answer to `typed`, if there is one, for the preview. 873/// 874/// `origin` is where this page is, for the preview's picture; `code` is where 875/// the code is cloned from (`host::Host::code_origin`), for the command at the foot. 876pub fn page(typed: &str, said: Option<&Entry>, declined: bool, origin: &str, code: &str, shared: Option<Shared>, home: Option<&Home>) -> Markup { 877 html! { 878 (DOCTYPE) 879 html lang="en" { 880 (head(html! { 881 (preview(typed, said, declined, origin)) 882 script type="module" src="/datastar.js" {} 883 })) 884 body data-signals="{q: '', pins: '', comment: '', open: false, saved: false}" { 885 div .sheet { 886 div .wrap { 887 (nav(Host::Home)) 888 div .headline { 889 p .eyebrow { "Let me ........................ Jev that for you" } 890 h1 { "Let Me Jev That For You" } 891 } 892 div .stack { 893 div .win { 894 div .bar-title { (icon(&icons::ASK, "")) "Ask" } 895 div .body { 896 form #ask data-type=(typed) data-on:submit__prevent="@post('/ask')" data-indicator:asking { 897 // Jev answers in about a fifth of a second, so the 898 // gate runs on every pause in the typing. 899 // A textarea so a long question wraps and the box grows 900 // with it (page.css, page.js); Enter still asks, and a 901 // question is one line, so no newline gets in (page.js). 902 textarea #q name="q" rows="1" data-bind:q "data-on:input__debounce.300ms"="@post('/gate')" 903 autocomplete="off" required maxlength=(ask::MAX_INPUT_CHARS) 904 placeholder="Ask Jev anything" aria-label="Your question" {} 905 button type="submit" data-attr:disabled="$asking" { "Ask Jev" } 906 } 907 (suggested(&[])) 908 (live(None)) 909 } 910 } 911 (budget(shared)) 912 (feed(home)) 913 (tally(home.map(|home| &home.stats))) 914 } 915 } 916 } 917 (resting(said)) 918 (footer(Some(code))) 919 script { (PreEscaped(JS)) } 920 script { (PreEscaped(aldebaran_time::SCRIPT)) } 921 } 922 } 923 } 924} 925 926/// What the facts say about the text so far, for the line under the box. 927pub struct Glance { 928 /// Jev's probability that it can judge the text. 929 pub answerable: f64, 930 pub kind: Option<Kind>, 931 /// The rules' note that the text stops part way (`Note::Unfinished`). 932 pub unfinished: bool, 933} 934 935fn kind_words(kind: Kind) -> &'static str { 936 match kind { 937 Kind::Noul => "a yes or no", 938 Kind::Choice => "a pick", 939 Kind::Score => "a how much", 940 Kind::Chance => "a how likely", 941 } 942} 943 944/// The line under the box: what Jev makes of the text so far. 945pub fn live(glance: Option<Glance>) -> Markup { 946 html! { 947 p #live .live aria-live="polite" { 948 @if let Some(glance) = glance { 949 @if glance.unfinished { 950 // Half a question is not judged yet: what Jev would say 951 // of it is not what it will say of the whole. 952 span .dot {} "Jev reads this as unfinished · keep typing" 953 } @else if ask::is_yes(glance.answerable) { 954 span .dot.yes {} "Jev can judge this" 955 @if let Some(kind) = glance.kind { " · " (kind_words(kind)) } 956 } @else { 957 span .dot {} "Jev can't judge this yet" 958 } 959 @if !glance.unfinished { span .meta { " · " (probability(glance.answerable)) } } 960 // The way forward, so a low score mid-sentence does not end the 961 // typing: asking shows where an LLM can answer (see meatproxy.rs). 962 // Short enough for one line beside the pixel font at desktop width. 963 @if !glance.unfinished && !ask::is_yes(glance.answerable) { span .meta { " · press Ask for an LLM that can" } } 964 } 965 } 966 } 967} 968 969/// Under the box as the visitor types: questions already on the feed that 970/// what is typed could be the start of, each with Jev's answer. Following 971/// one asks it again. Empty when there are none. 972pub fn suggested(entries: &[Entry]) -> Markup { 973 html! { 974 ul #suggested .feed.suggested aria-label="Already asked" { 975 @for asked in entries { (entry(asked, None)) } 976 } 977 } 978} 979 980/// The transcript before anything is asked. On the page of a question 981/// already answered it holds the question and what Jev said, read from the 982/// archive: the page then asks, which costs nothing for a kept answer, and 983/// the transcript proper takes its place. A reader that runs no script (a 984/// search engine, a preview) has the answer all the same. 985fn resting(said: Option<&Entry>) -> Markup { 986 html! { 987 div #transcript { 988 @if let Some(entry) = said.filter(|entry| !entry.answers.is_empty()) { 989 div .sheet { 990 div .wrap { 991 div .win.white { 992 div .bar-title { (icon(&icons::JEV, "")) "Jev" } 993 div .body { 994 div .row { span .who { "Q:" } span .q { (entry.input) } } 995 div .row { span .who { "Jev:" } div .said { 996 @for answer in &entry.answers { 997 p .answer { (answer.headline) } 998 p .why { (answer.line()) } 999 } 1000 } } 1001 } 1002 } 1003 } 1004 } 1005 } 1006 } 1007 } 1008} 1009 1010/// The transcript as it stands. Sent whole each time; Datastar morphs it in by id. 1011pub fn transcript(view: &View) -> Markup { 1012 let results = view.results(); 1013 let said = view.ending.is_some() || !results.is_empty(); 1014 html! { 1015 div #transcript { 1016 div .sheet { 1017 div .wrap { 1018 p .step { (step(view)) } 1019 div .win.white { 1020 div .bar-title { (icon(&icons::JEV, "")) "Jev" } 1021 div .body { 1022 div .row { span .who { "Q:" } span .q { (view.input) } } 1023 @if said { 1024 div .row { span .who { "Jev:" } div .said { 1025 @for shown in &results { (result(shown)) } 1026 @if let Some(ending) = &view.ending { (ended(ending, &view.input, view.preview.as_ref())) } 1027 } } 1028 } 1029 // Filled in once the answer, or the declining, is kept 1030 // (`/rate`); the thumbs mean something else on the latter. 1031 @if matches!(view.ending, Some(Ending::Answered | Ending::NotAQuestion { .. } | Ending::NoQuestion)) { 1032 div #rating .rating {} 1033 (comment_box(!matches!(view.ending, Some(Ending::Answered)))) 1034 } 1035 @if matches!(view.ending, Some(Ending::Answered | Ending::NotAQuestion { .. })) { 1036 (share(&view.input, "")) 1037 } 1038 } 1039 } 1040 } 1041 } 1042 @if view.facts.is_some() { 1043 div .band { 1044 div .wrap.stack { 1045 (rules_card(view)) 1046 @if let Some(facts) = &view.facts { (jev_card(facts)) } 1047 @if let Some(llm) = &view.llm { (llm_card(llm)) } 1048 @for tool in &view.tools { (tool_card(tool, view.judging.is_some())) } 1049 @if let Some(judging) = &view.judging { (jev_card(judging)) } 1050 } 1051 } 1052 } 1053 } 1054 } 1055} 1056 1057fn step(view: &View) -> &'static str { 1058 match &view.ending { 1059 Some(Ending::Empty) => "Type a question first", 1060 Some(Ending::Answered | Ending::NotAQuestion { .. }) => "Was that so hard?", 1061 Some(_) => "Nothing came back", 1062 None if view.judging.is_some() => "Step 3: asking Jev", 1063 None if view.llm.is_some() => "Step 2: an LLM is writing Jev's question", 1064 None if view.facts.is_some() => "Step 1: asking Jev what kind of question that is", 1065 None => "Getting Jev", 1066 } 1067} 1068 1069/// The rules as a Rete network, with what is known about this query marked 1070/// on it (`diagram.rs` says what each mark means). 1071fn rules_card(view: &View) -> Markup { 1072 let network = Network::lmjtfy(); 1073 html! { 1074 details .win open { 1075 summary .bar-title { 1076 span .fold {} (icon(&icons::TOOL, "")) "rules · rete" 1077 span .meta { 1078 // No rule decides a step when Jev's first answers settle 1079 // it: then the rules that hold are the story. 1080 @let holding = network.holding(&view.known); 1081 @if !view.fired.is_empty() { 1082 (diagram::sequence(&network, &view.fired)) 1083 } @else if holding.is_empty() { 1084 "waiting on facts" 1085 } @else { 1086 (holding.join(" · ")) 1087 } 1088 } 1089 } 1090 div .body { 1091 (diagram::rete(&network, &view.known, &view.fired, |fact| view.shown(fact), |_| None)) 1092 p .fine { 1093 "Facts on the left, with what Jev said; rules on the right, numbered in the order they " 1094 "decided; the tests that join them between. Filled is what a rule that fired stood on, " 1095 "outlined was learned and not needed, dashed failed. A line from a rule back to a fact " 1096 "is a call that teaches it. " 1097 a href="/rules" { "Try the rules yourself." } 1098 } 1099 } 1100 } 1101 } 1102} 1103 1104/// What Jev said to one question. 1105fn result(shown: &Shown<'_>) -> Markup { 1106 html! { 1107 div .result { 1108 p .asked { 1109 (shown.question) 1110 @if let Some(read_as) = shown.read_as { span .read-as { " · " (read_as) } } 1111 } 1112 @match shown.judged { 1113 // The probability is the answer; the note says it is a Noul, 1114 // and how to read the number. 1115 Judged::Noul(_) if shown.chance => { 1116 p .answer { (shown.headline()) } 1117 p .verdict { (shown.told().note().unwrap_or_default()) } 1118 }, 1119 Judged::Noul(p_yes) => { 1120 p .answer { (headline(shown.judged)) } 1121 p .verdict { "Noul · p(yes) = " (probability(*p_yes)) } 1122 }, 1123 Judged::Choice(answer) => (choice(answer)), 1124 Judged::Score(answer) => (score(answer)), 1125 } 1126 } 1127 } 1128} 1129 1130/// A `Noul` as a word: flat when Jev is sure, a question when it is not. 1131fn yes_or_no(p_yes: f64) -> &'static str { 1132 match (ask::is_yes(p_yes), ask::is_sure(p_yes)) { 1133 (true, true) => "Yes.", 1134 (true, false) => "Yes?", 1135 (false, true) => "No.", 1136 (false, false) => "No?", 1137 } 1138} 1139 1140/// The level a `Score` fell nearest, and the highest level there is. The 1141/// score is Jev's answer: the probability-weighted level, which "can land 1142/// between levels" (TypeSafe's docs), so this is the level it rounds to. 1143fn nearest(answer: &ScoreAnswer) -> (usize, usize) { 1144 let top = answer.probabilities.len().saturating_sub(1); 1145 ((answer.score.round().max(0.0) as usize).min(top), top) 1146} 1147 1148/// The level with the most probability (the lowest of equals). It can differ 1149/// from the nearest: a spread-out answer has a mean on a level that is not 1150/// the likeliest, which is what a low confidence says. 1151fn likeliest(answer: &ScoreAnswer) -> usize { 1152 answer.probabilities.iter().enumerate().fold((0, f64::NEG_INFINITY), |best, (level, p)| if *p > best.1 { (level, *p) } else { best }).0 1153} 1154 1155/// An answer in a few words: what the transcript prints large, and what the 1156/// feed prints beside the question. 1157fn headline(judged: &Judged) -> String { 1158 match judged { 1159 Judged::Noul(p_yes) => yes_or_no(*p_yes).to_owned(), 1160 Judged::Choice(answer) => format!("{}.", answer.choice), 1161 Judged::Score(answer) => { 1162 let level = answer.legend.get(nearest(answer).0).map(String::as_str).unwrap_or_default(); 1163 format!("{level}{}", if ask::is_confident(answer.confidence) { "." } else { "?" }) 1164 } 1165 } 1166} 1167 1168/// Where the square sits on its rule, from a probability. 1169fn at(p: f64) -> String { 1170 format!("left: {:.1}%", (p * 100.0).clamp(0.0, 100.0)) 1171} 1172 1173fn percent(p: f64) -> String { 1174 format!("{:.0}%", p * 100.0) 1175} 1176 1177/// Every option with its probability, most likely first. 1178fn choice(answer: &ChoiceAnswer) -> Markup { 1179 let mut ranked: Vec<&(String, f64)> = answer.probabilities.iter().collect(); 1180 ranked.sort_by(|a, b| b.1.total_cmp(&a.1)); 1181 html! { 1182 p .answer.pick { (headline(&Judged::Choice(answer.clone()))) } 1183 div .bars { 1184 @for (label, p) in ranked { 1185 div .bar.won[*label == answer.choice] { 1186 span .label { (label) } 1187 span .pct { (percent(*p)) } 1188 span .track { span .thumb style=(at(*p)) {} } 1189 } 1190 } 1191 } 1192 p .verdict { "Choice · confidence " (probability(answer.confidence)) } 1193 } 1194} 1195 1196/// Every level with its probability, lowest first. The headline's level (the 1197/// one nearest the score) is inked; the likeliest is tagged, and is the same 1198/// level when Jev is sure. Under the bars: the score, which is the mean, and 1199/// how sure Jev is. 1200fn score(answer: &ScoreAnswer) -> Markup { 1201 let (nearest, top) = nearest(answer); 1202 let likeliest = likeliest(answer); 1203 let sure = ask::is_confident(answer.confidence); 1204 html! { 1205 p .answer.pick { 1206 (headline(&Judged::Score(answer.clone()))) 1207 span .of { " mean " (format!("{:.1}", answer.score)) " / " (top) } 1208 } 1209 div .bars { 1210 @for (level, p) in answer.probabilities.iter().enumerate() { 1211 div .bar.won[level == nearest] { 1212 span .label { 1213 (level) " · " (answer.legend.get(level).map(String::as_str).unwrap_or_default()) 1214 @if level == likeliest { " " span .tag { "likeliest" } } 1215 } 1216 span .pct { (percent(*p)) } 1217 span .track { span .thumb style=(at(*p)) {} } 1218 } 1219 } 1220 } 1221 p .verdict { 1222 "Score · 0 to " (top) " · confidence " (probability(answer.confidence)) 1223 @if !sure { " · unsure: the levels are ambiguous" } 1224 } 1225 } 1226} 1227 1228fn ended(ending: &Ending, input: &str, preview: Option<&meatproxy::Head>) -> Markup { 1229 match ending { 1230 Ending::Answered => html! {}, 1231 Ending::Slow => html! { 1232 p .answer.quiet { "Slow down." } 1233 p .why { "That is a lot of questions in one minute. Nothing was sent. Try again in a minute." } 1234 }, 1235 Ending::NotAQuestion { p_yes } => html! { 1236 p .answer { (yes_or_no(*p_yes)) } 1237 p .why { "That's not a question I can answer. Ask me yes or no, which one, or how much." } 1238 (meatproxy::suggestion(input, preview)) 1239 p .verdict { "p(I can answer this) = " (probability(*p_yes)) } 1240 }, 1241 Ending::NoQuestion => html! { 1242 p .answer.quiet { "Jev wasn't asked anything." } 1243 p .why { "The LLM didn't write a question Jev can take. Its reply is below." } 1244 (meatproxy::suggestion(input, preview)) 1245 }, 1246 Ending::Failed { error } => html! { 1247 p .answer.quiet { "Jev didn't answer." } 1248 p .why { (error) } 1249 }, 1250 Ending::Offline => html! { 1251 p .answer.quiet { "Jev is offline." } 1252 p .why { "This server has no TypeSafe API key, so nothing was sent." } 1253 }, 1254 Ending::NotKept { id } => html! { 1255 p .answer.quiet { "Not asked yet." } 1256 p .why { 1257 "This version leads to a question Jev was never asked. Looking back costs nothing, so it was not sent. " 1258 (again(id, "↻ Ask it")) 1259 } 1260 }, 1261 Ending::Spent { what, resets_ms } => html! { 1262 p .answer.quiet { "Out of budget for today." } 1263 p .why { "Everyone shares one daily budget for " (what) ", and it is used up. It resets " (moment(*resets_ms)) "." } 1264 }, 1265 Ending::Empty => html! { p .answer.quiet { "Ask me something." } }, 1266 } 1267} 1268 1269/// The LLM's call: what it was sent and what it wrote. 1270fn llm_card(made: &LlmCall) -> Markup { 1271 html! { 1272 details .win { 1273 summary .bar-title { 1274 span .fold {} (icon(&icons::LLM, "")) "llm · " (made.model) 1275 span .meta { 1276 @match &made.outcome { 1277 None => "writing Jev's question", 1278 Some(LlmOutcome { sent: Sent::Before { at_ms }, .. }) => (kept(*at_ms)), 1279 Some(outcome) => (format!("{} ms · {:.1} neurons", outcome.took.as_millis(), outcome.neurons)), 1280 } 1281 } 1282 } 1283 div .body { 1284 (versions(made.kept.as_ref())) 1285 div .wire { 1286 span .label-s { "request" } 1287 (json(&made.request)) 1288 } 1289 @if let Some(outcome) = &made.outcome { 1290 div .wire { 1291 span .label-s { "response" } 1292 (json(&outcome.body)) 1293 @if outcome.dropped > 0 { 1294 p .note { (outcome.dropped) " more tool calls were over the limit and were not run." } 1295 } 1296 } 1297 } 1298 } 1299 } 1300 } 1301} 1302 1303/// One tool call the LLM made: what it wrote, and whether it went to Jev. 1304/// `sent` is whether a request to Jev was made at all. 1305fn tool_card(tool: &Tool, sent: bool) -> Markup { 1306 html! { 1307 details .win open[tool.problem().is_some()] { 1308 summary .bar-title { 1309 span .fold {} (icon(&icons::TOOL, "")) "tool call · " (tool.call.name) 1310 span .meta { 1311 @if tool.problem().is_some() { "not sent" } @else if sent { "sent to Jev as " (tool.id) } @else { (tool.id) } 1312 } 1313 } 1314 div .body { 1315 div .wire { 1316 span .label-s { "the LLM's tool call" } 1317 pre { (tool.call.name) } 1318 (json(&tool.call.arguments)) 1319 @if let Some(problem) = tool.problem() { 1320 p .note { "Not sent to Jev: " (problem) } 1321 } 1322 } 1323 } 1324 } 1325 } 1326} 1327 1328/// A request to Jev: the body as sent and the response as it arrived. 1329/// ↻ for the call `id`: send its request again, and work the rest out from 1330/// the answer. 1331fn again(id: &str, label: &str) -> Markup { 1332 html! { 1333 button .again type="button" title="Send this request again and keep the answer as its newest" 1334 "data-on:click"={ "$pins = ($pins || '') + ';" (id) ":fresh'; @post('/ask')" } { (label) } 1335 } 1336} 1337 1338/// ◀ version n of m ▶ and ↻, for a call that has an answer. 1339fn versions(kept: Option<&Versions>) -> Markup { 1340 let Some(kept) = kept else { return html! {} }; 1341 let step = |to: u32, glyph: &str, label: &str| { 1342 html! { 1343 button .step type="button" title=(label) 1344 "data-on:click"={ "$pins = ($pins || '') + ';" (kept.id) ":" (to) "'; @post('/ask')" } { (glyph) } 1345 } 1346 }; 1347 html! { 1348 div .versions { 1349 @if kept.version > 1 { (step(kept.version - 1, "◀", "The answer before")) } 1350 span .label-s { "answer " (kept.version) " of " (kept.versions) } 1351 @if kept.version < kept.versions { (step(kept.version + 1, "▶", "The answer after")) } 1352 (again(&kept.id, "↻ Ask again")) 1353 } 1354 } 1355} 1356 1357fn jev_card(made: &JevCall) -> Markup { 1358 let questions = if made.parts.len() == 1 { "1 question".to_owned() } else { format!("{} questions", made.parts.len()) }; 1359 html! { 1360 details .win open { 1361 summary .bar-title { 1362 span .fold {} (icon(&icons::TOOL, "")) "jev · " (made.id) " · " (questions) 1363 span .meta { (meta(made.outcome.as_ref())) } 1364 } 1365 div .body { 1366 (versions(made.kept.as_ref())) 1367 div .wire { 1368 span .label-s { "request" } 1369 pre { 1370 "POST " (jev_protocol::ENDPOINT) "\n" 1371 "authorization: Bearer [the server's key]\n" 1372 "content-type: application/json" 1373 } 1374 (json(&made.request)) 1375 } 1376 @match &made.outcome { 1377 Some(Outcome::Answered { body, .. }) => div .wire { 1378 span .label-s { "response" } 1379 (json(body)) 1380 }, 1381 Some(Outcome::Failed { error, .. }) => div .wire { 1382 span .label-s { "no response" } 1383 pre { (error) } 1384 }, 1385 None => {}, 1386 } 1387 } 1388 } 1389 } 1390} 1391 1392/// A JSON body, indented and coloured for reading. Only whitespace is added: 1393/// keys keep their order and every value is printed as it was sent. Text that 1394/// is not JSON is shown as it is. 1395fn json(text: &str) -> Markup { 1396 match serde_json::from_str::<Value>(text) { 1397 Ok(value) => html! { pre .json { (node(&value, 0)) } }, 1398 Err(_) => html! { pre { (text) } }, 1399 } 1400} 1401 1402fn node(value: &Value, depth: usize) -> Markup { 1403 let indent = " ".repeat(depth + 1); 1404 let close = " ".repeat(depth); 1405 match value { 1406 Value::Object(map) if !map.is_empty() => html! { 1407 "{\n" 1408 @for (i, (key, child)) in map.iter().enumerate() { 1409 (indent) span .k { (quoted(key)) } ": " (node(child, depth + 1)) 1410 @if i + 1 < map.len() { "," } "\n" 1411 } 1412 (close) "}" 1413 }, 1414 Value::Array(items) if !items.is_empty() => html! { 1415 "[\n" 1416 @for (i, child) in items.iter().enumerate() { 1417 (indent) (node(child, depth + 1)) 1418 @if i + 1 < items.len() { "," } "\n" 1419 } 1420 (close) "]" 1421 }, 1422 Value::String(text) => html! { span .s { (quoted(text)) } }, 1423 // Numbers, booleans, null, and the empty object and array. 1424 other => html! { span .n { (other.to_string()) } }, 1425 } 1426} 1427 1428/// A string as JSON writes it, quotes and escapes included. 1429fn quoted(text: &str) -> String { 1430 Value::String(text.to_owned()).to_string() 1431} 1432 1433/// A call's title bar when nothing was sent: the response is the one kept 1434/// from the first time this exact request was made. 1435fn kept(at_ms: f64) -> Markup { 1436 html! { "not sent · answered " (moment(at_ms)) } 1437} 1438 1439fn meta(outcome: Option<&Outcome>) -> Markup { 1440 let text = match outcome { 1441 None => "waiting for Jev".to_owned(), 1442 Some(Outcome::Answered { sent: Sent::Before { at_ms }, request_id, .. }) => { 1443 return html! { (kept(*at_ms)) @if let Some(id) = request_id { " · " (id) } }; 1444 } 1445 Some(Outcome::Answered { request_id, attempts, usage, took, .. }) => { 1446 let mut parts = vec![ 1447 format!("{} ms", took.as_millis()), 1448 if *attempts == 1 { "1 attempt".to_owned() } else { format!("{attempts} attempts") }, 1449 format!("{} tokens in", usage.input_tokens), 1450 format!("${:.6}", usage.dollars()), 1451 ]; 1452 parts.extend(request_id.clone()); 1453 parts.join(" · ") 1454 } 1455 Some(Outcome::Failed { request_id, took, .. }) => { 1456 let mut parts = vec!["failed".to_owned(), format!("{} ms", took.as_millis())]; 1457 parts.extend(request_id.clone()); 1458 parts.join(" · ") 1459 } 1460 }; 1461 html! { (text) } 1462} 1463 1464/// The link to send. `slug` is the question's, once it has been answered 1465/// and so has one; until then, and for a question Jev did not answer, the 1466/// link is the question itself. 1467pub fn share(input: &str, slug: &str) -> Markup { 1468 let address = address(input, slug); 1469 html! { 1470 div #share .share { 1471 span { "Send it to them:" } 1472 a #link href=(address) { (address) } 1473 button .ghost type="button" data-copy="link" { "Copy link" } 1474 } 1475 } 1476} 1477 1478/// Where a question's page is: `/q/<slug>` if it has a slug, and the 1479/// question in the query if it has none. 1480pub fn address(input: &str, slug: &str) -> String { 1481 if slug.is_empty() { format!("/?q={}", url_encode(input)) } else { format!("/q/{slug}") } 1482} 1483 1484/// Percent-encodes everything but unreserved characters, for a query value. 1485/// `text` percent-encoded, for a URL or a header. 1486pub fn url_encoded(text: &str) -> String { 1487 url_encode(text) 1488} 1489 1490fn url_encode(text: &str) -> String { 1491 let mut out = String::with_capacity(text.len()); 1492 for byte in text.bytes() { 1493 match byte { 1494 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(byte as char), 1495 b' ' => out.push('+'), 1496 _ => out.push_str(&format!("%{byte:02X}")), 1497 } 1498 } 1499 out 1500} 1501 1502fn probability(p: f64) -> String { 1503 format!("{p:.2}") 1504} 1505 1506#[cfg(test)] 1507mod tests;