lmjtfy.git / apps / lmjtfy / src / view.rs
view.rsannotatedview.rssource1507 lines · 62.1 KB · raw
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;