AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/ui/src/kit/chat.rs

Latest change 8981a62302ff7a56d4372a05dcc249fd4539987f - chat kit: messages shrink to the thread width (long tool summaries truncate) by Ólafur Búi Ólafsson

//! Chat components in the assistant-ui style: a [`thread`] of [`message`]s
//! (Markdown bodies, [`tool_call`] cards, [`system_line`] dividers), a
//! [`composer`] with `@mention` and `/command` autocomplete, and a
//! [`presence`] row. Everything is generic: callers own the data (signals of
//! [`ChatMessage`]) and the callbacks.
//!
//! Every component takes a [`ChatSize`]: `Normal` for desktop and phone,
//! `Tv` for 10-foot screens (larger type, bigger hit targets, focusable
//! elements with a thick focus ring for remote navigation).
//!
//! Message text is Markdown rendered by `akurai-markdown`, which escapes
//! `<`, `>` and `&`; this module additionally neutralises the trusted-only
//! `preview` fence and drops links whose scheme is not http(s)/mailto, so
//! user or model text can never inject markup or script.

use super::{cn, icons, uid, Tone};
use crate::dom::{each, show, when, El, IntoView, NodeRef};
use crate::html::{article, button, div, li, pre, span, textarea, ul};
use crate::platform;
use crate::reactive::{effect, memo, signal, Signal};
use std::cell::Cell;
use std::rc::Rc;

/// Render scale of the chat components.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ChatSize {
    /// Desktop and phone.
    #[default]
    Normal,
    /// 10-foot UI: larger type, focusable messages, thick focus rings.
    Tv,
}

fn pick(size: ChatSize, normal: &'static str, tv: &'static str) -> &'static str {
    match size {
        ChatSize::Normal => normal,
        ChatSize::Tv => tv,
    }
}

const TV_FOCUS: &str = "focus-visible:ring-4 focus-visible:ring-accent";

/// Who wrote a message.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AuthorKind {
    #[default]
    Person,
    Assistant,
    Device,
    System,
}

/// A chat participant. `color` is any CSS colour.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct ChatAuthor {
    pub id: String,
    pub name: String,
    pub color: String,
    pub kind: AuthorKind,
}

/// Progress of a tool call.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ToolStatus {
    #[default]
    Running,
    Done,
    Error,
}

/// A tool invocation shown inside a message.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct ToolCallView {
    pub id: String,
    pub name: String,
    pub summary: String,
    pub status: ToolStatus,
    pub result: Option<String>,
}

/// One block of a message body.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ChatPart {
    /// Markdown text.
    Text(String),
    Tool(ToolCallView),
}

/// A chat message. Messages whose author kind is [`AuthorKind::System`] are
/// drawn as a [`system_line`] using their first text part.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChatMessage {
    pub id: String,
    pub author: ChatAuthor,
    /// Unix seconds.
    pub created: i64,
    pub parts: Vec<ChatPart>,
    /// Shows a blinking caret after the body while the text is still arriving.
    pub streaming: bool,
}

/// A slash command offered by the [`composer`]; `name` without the slash.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SlashCommand {
    pub name: String,
    pub hint: String,
}

// ── Markdown ────────────────────────────────────────────────────────────────

/// Render untrusted Markdown to safe HTML: raw HTML is escaped by the
/// renderer, the trusted-only `preview` fence is turned into plain code and
/// links with a scheme other than http, https or mailto lose their `href`.
pub fn markdown_html(md: &str) -> String {
    let defanged: Vec<&str> = md
        .lines()
        .map(|l| match l.trim().strip_prefix("```") {
            Some(info) if info.trim() == "preview" => "```text",
            _ => l,
        })
        .collect();
    sanitize_links(&akurai_markdown::to_html(&defanged.join("\n")))
}

fn sanitize_links(html: &str) -> String {
    const ATTR: &str = "href=\"";
    let mut out = String::with_capacity(html.len());
    let mut rest = html;
    while let Some(i) = rest.find(ATTR) {
        let (head, tail) = rest.split_at(i + ATTR.len());
        out.push_str(&head[..i]);
        let end = tail.find('"').unwrap_or(tail.len());
        let url: String = tail[..end]
            .chars()
            .filter(|c| !c.is_control() && !c.is_whitespace())
            .collect::<String>()
            .to_lowercase();
        let ok = ["http://", "https://", "mailto:", "/", "#"]
            .iter()
            .any(|p| url.starts_with(p));
        out.push_str(if ok { ATTR } else { "data-blocked-href=\"" });
        rest = tail;
    }
    out.push_str(rest);
    out
}

/// A Markdown body that follows `text` reactively.
pub fn markdown(text: Signal<String>, size: ChatSize) -> El {
    div()
        .class(cn(&[
            "ak-md break-words",
            pick(size, "text-sm", "text-2xl"),
        ]))
        .prop("innerHTML", move || markdown_html(&text.get()))
}

// ── Small pieces ────────────────────────────────────────────────────────────

/// `HH:MM` in the browser's timezone for a unix timestamp in seconds.
pub fn format_clock(unix_secs: i64) -> String {
    let local = unix_secs + i64::from(platform::tz_offset_minutes()) * 60;
    let day = local.rem_euclid(86_400);
    format!("{:02}:{:02}", day / 3600, day % 3600 / 60)
}

/// A name with a dot in the author's colour (and an `AI` tag for assistants).
pub fn author_chip(author: &ChatAuthor, size: ChatSize) -> El {
    span()
        .class(cn(&[
            "inline-flex items-center gap-1.5 font-medium text-fg",
            pick(size, "text-sm", "text-xl"),
        ]))
        .child((
            span()
                .attr("aria-hidden", "true")
                .class(pick(
                    size,
                    "size-2.5 shrink-0 rounded-sm",
                    "size-4 shrink-0 rounded-sm",
                ))
                .style(format!("background-color:{}", author.color)),
            author.name.clone(),
            (author.kind == AuthorKind::Assistant).then(|| super::badge(Tone::Accent).child("AI")),
            (author.kind == AuthorKind::Device)
                .then(|| super::badge(Tone::Neutral).child("device")),
        ))
}

/// A centred, muted divider line, e.g. "memory cleared".
pub fn system_line(text: impl Into<String>) -> El {
    system_row(text.into())
}

fn system_row(text: impl IntoView) -> El {
    div()
        .role("separator")
        .class("flex items-center gap-3 py-2 text-xs text-muted")
        .child((
            div().class("h-px flex-1 bg-border"),
            span().class("shrink-0").child(text),
            div().class("h-px flex-1 bg-border"),
        ))
}

/// A collapsible card for one tool call: name, summary and status badge in
/// the header, the result when expanded.
pub fn tool_call(tool: Signal<ToolCallView>, size: ChatSize) -> El {
    let open = signal(false);
    let panel = uid("tool");
    let controls = panel.clone();
    div()
        .class("my-1 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-bg-2 text-fg")
        .child((
            button()
                .typ("button")
                .attr("aria-expanded", move || open.get().to_string())
                .attr("aria-controls", controls)
                .class(cn(&[
                    "flex w-full items-center gap-2 rounded-md px-3 py-2 text-left hover:bg-panel focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
                    pick(size, "text-sm", "text-xl"),
                    if size == ChatSize::Tv { TV_FOCUS } else { "" },
                ]))
                .on_click(move |_| open.update(|o| *o = !*o))
                .child((
                    icons::chevron_down().class(move || {
                        if open.get() {
                            "size-4 shrink-0 transition-transform"
                        } else {
                            "size-4 shrink-0 -rotate-90 transition-transform"
                        }
                    }),
                    span()
                        .class("shrink-0 font-medium")
                        .child(move || tool.get().name),
                    span()
                        .class("min-w-0 flex-1 truncate text-muted")
                        .child(move || tool.get().summary),
                    move || {
                        let (tone, label) = match tool.get().status {
                            ToolStatus::Running => (Tone::Accent, "Running"),
                            ToolStatus::Done => (Tone::Ok, "Done"),
                            ToolStatus::Error => (Tone::Danger, "Error"),
                        };
                        super::badge(tone).child(label)
                    },
                )),
            when(
                move || open.get(),
                move || {
                    div()
                        .id(panel.clone())
                        .class("border-t border-border px-3 py-2")
                        .child(pre().class("whitespace-pre-wrap break-words font-mono text-xs text-muted").child(
                            move || match tool.get().result {
                                Some(r) => r,
                                None => "No result yet.".to_string(),
                            },
                        ))
                },
            ),
        ))
}

/// One message: author chip, time, Markdown and tool-call parts that update
/// live as `msg` changes, and a caret while `streaming`.
pub fn message(msg: Signal<ChatMessage>, size: ChatSize) -> El {
    let first = msg.get_untracked();
    let author = first.author;
    let created = first.created;
    article()
        .attr("tabindex", (size == ChatSize::Tv).then_some("0"))
        .attr(
            "data-author-kind",
            match author.kind {
                AuthorKind::Person => "person",
                AuthorKind::Assistant => "assistant",
                AuthorKind::Device => "device",
                AuthorKind::System => "system",
            },
        )
        .class(cn(&[
            "grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1 rounded-md border-l-2 px-3 py-2 focus-visible:outline-none",
            if author.kind == AuthorKind::Assistant {
                "bg-panel/60"
            } else {
                ""
            },
            if size == ChatSize::Tv { TV_FOCUS } else { "" },
        ]))
        .style(format!("border-left-color:{}", author.color))
        .child((
            div().class("flex items-baseline gap-2").child((
                author_chip(&author, size),
                span()
                    .class(cn(&["text-muted", pick(size, "text-xs", "text-lg")]))
                    .child(format_clock(created)),
            )),
            div().child(each(
                move || {
                    msg.get()
                        .parts
                        .into_iter()
                        .enumerate()
                        .collect::<Vec<(usize, ChatPart)>>()
                },
                |(i, part)| match part {
                    ChatPart::Tool(t) => format!("t{}", t.id),
                    ChatPart::Text(_) => format!("x{i}"),
                },
                move |part: Signal<(usize, ChatPart)>| match part.get_untracked().1 {
                    ChatPart::Text(_) => {
                        let text = memo(move || match part.get().1 {
                            ChatPart::Text(s) => s,
                            ChatPart::Tool(_) => String::new(),
                        });
                        markdown(text, size).into_view()
                    }
                    ChatPart::Tool(_) => {
                        let tool = memo(move || match part.get().1 {
                            ChatPart::Tool(t) => t,
                            ChatPart::Text(_) => ToolCallView::default(),
                        });
                        tool_call(tool, size).into_view()
                    }
                },
            )),
            when(
                move || msg.get().streaming,
                || {
                    span()
                        .attr("aria-label", "Typing")
                        .class("ak-caret inline-block h-4 w-1.5 bg-accent")
                },
            ),
        ))
}

// ── Thread ──────────────────────────────────────────────────────────────────

fn scroll_to_bottom(node: &NodeRef) {
    if let Some(n) = node.get() {
        let height = platform::prop(n, "scrollHeight");
        crate::host::set_prop_str(n, "scrollTop", &height);
    }
}

fn near_bottom(node: crate::Node) -> bool {
    let num = |name| platform::prop(node, name).parse::<f64>().unwrap_or(0.0);
    num("scrollHeight") - num("scrollTop") - num("clientHeight") < 48.0
}

/// A scrolling, keyed list of messages. It sticks to the bottom as messages
/// arrive or stream, unless the reader scrolled up; then a "New messages"
/// pill appears to jump back down. Give the returned element a height (for
/// example `flex-1 min-h-0` in a flex column).
pub fn thread(messages: Signal<Vec<ChatMessage>>, size: ChatSize) -> El {
    let scroller = NodeRef::new();
    let stuck = signal(true);
    let unread = signal(false);

    let list = div()
        .role("log")
        .attr("aria-live", "polite")
        .attr("aria-label", "Conversation")
        .class(cn(&[
            "flex min-h-0 flex-1 flex-col overflow-y-auto p-2",
            pick(size, "gap-1", "gap-3"),
        ]))
        .node_ref(&scroller)
        .on("scroll", {
            let scroller = scroller.clone();
            move |_| {
                if let Some(n) = scroller.get() {
                    let at_bottom = near_bottom(n);
                    stuck.set_if_changed(at_bottom);
                    if at_bottom {
                        unread.set_if_changed(false);
                    }
                }
            }
        })
        .child(each(
            move || messages.get(),
            |m| m.id.clone(),
            move |m: Signal<ChatMessage>| {
                let first = m.get_untracked();
                if first.author.kind == AuthorKind::System {
                    system_row(move || match m.get().parts.first() {
                        Some(ChatPart::Text(t)) => t.clone(),
                        _ => String::new(),
                    })
                    .into_view()
                } else {
                    message(m, size).into_view()
                }
            },
        ));

    let seen = Rc::new(Cell::new(0usize));
    effect({
        let scroller = scroller.clone();
        move || {
            let len = messages.with(|m| m.len());
            // `with` tracks the signal, so streaming text keeps following too.
            let grew = len > seen.replace(len);
            if stuck.get_untracked() {
                let scroller = scroller.clone();
                platform::set_timeout(0, move || scroll_to_bottom(&scroller));
            } else if grew {
                unread.set_if_changed(true);
            }
        }
    });

    div()
        .class("relative flex min-h-0 flex-1 flex-col")
        .child((
            list,
            when(
                move || unread.get(),
                {
                    let scroller = scroller.clone();
                    move || {
                        let scroller = scroller.clone();
                        button()
                            .typ("button")
                            .class(cn(&[
                                "absolute bottom-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-md border border-border bg-bg-2 px-3 py-1.5 font-medium text-fg shadow-lg hover:bg-panel focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
                                pick(size, "text-xs", "text-xl"),
                                if size == ChatSize::Tv { TV_FOCUS } else { "" },
                            ]))
                            .on_click(move |_| {
                                stuck.set(true);
                                unread.set(false);
                                scroll_to_bottom(&scroller);
                            })
                            .child((icons::chevron_down(), "New messages"))
                    }
                },
            ),
        ))
}

// ── Composer ────────────────────────────────────────────────────────────────

#[derive(Clone, PartialEq)]
struct Suggestion {
    label: String,
    hint: String,
    /// The whole composer text after accepting this suggestion.
    insert: String,
}

fn suggestions(text: &str, mentions: &[String], commands: &[SlashCommand]) -> Vec<Suggestion> {
    if let Some(q) = text.strip_prefix('/') {
        if !q.contains(char::is_whitespace) {
            let q = q.to_lowercase();
            return commands
                .iter()
                .map(|c| (c, c.name.trim_start_matches('/')))
                .filter(|(_, n)| n.to_lowercase().starts_with(&q))
                .take(6)
                .map(|(c, n)| Suggestion {
                    label: format!("/{n}"),
                    hint: c.hint.clone(),
                    insert: format!("/{n} "),
                })
                .collect();
        }
    }
    let start = text
        .char_indices()
        .rev()
        .find(|(_, c)| c.is_whitespace())
        .map_or(0, |(i, c)| i + c.len_utf8());
    let Some(q) = text[start..].strip_prefix('@') else {
        return Vec::new();
    };
    let q = q.to_lowercase();
    mentions
        .iter()
        .filter(|n| n.to_lowercase().starts_with(&q))
        .take(6)
        .map(|n| Suggestion {
            label: format!("@{n}"),
            hint: String::new(),
            insert: format!("{}@{n} ", &text[..start]),
        })
        .collect()
}

/// The message box. Enter sends, Shift+Enter inserts a newline. Typing `@`
/// suggests `mentions` (names without the `@`; avoid spaces in names), a
/// leading `/` suggests `commands`; Up/Down move, Tab or Enter accept, Esc
/// closes. While `busy` is true the Send button becomes Stop and Enter does
/// nothing. `on_send` gets the trimmed text; `on_stop` cancels the run.
pub fn composer(
    busy: Signal<bool>,
    mentions: Vec<String>,
    commands: Vec<SlashCommand>,
    size: ChatSize,
    on_send: impl Fn(String) + 'static,
    on_stop: impl Fn() + 'static,
) -> El {
    let text = signal(String::new());
    let sel = signal(0usize);
    let dismissed = signal(false);
    let ta = NodeRef::new();
    let mentions = Rc::new(mentions);
    let commands = Rc::new(commands);
    let on_stop: Rc<dyn Fn()> = Rc::new(on_stop);

    let send: Rc<dyn Fn()> = Rc::new(move || {
        if busy.get_untracked() {
            return;
        }
        let t = text.get_untracked().trim().to_string();
        if t.is_empty() {
            return;
        }
        text.set(String::new());
        on_send(t);
    });

    let accept: Rc<dyn Fn(String)> = Rc::new({
        let ta = ta.clone();
        move |insert| {
            text.set(insert);
            sel.set(0);
            ta.focus();
        }
    });

    let list_of = {
        let (mentions, commands) = (mentions.clone(), commands.clone());
        move |t: &str| suggestions(t, &mentions, &commands)
    };

    let field = textarea()
        .attr("rows", "1")
        .attr("aria-label", "Message")
        .attr("placeholder", "Message  (@ to mention, / for commands)")
        .class(cn(&[
            "min-w-0 flex-1 resize-none rounded-md border border-border bg-bg px-3 py-2 text-fg shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
            pick(size, "min-h-10 max-h-40 text-sm", "min-h-16 max-h-60 text-2xl"),
        ]))
        .node_ref(&ta)
        .bind(text)
        .on("input", move |_| {
            sel.set(0);
            dismissed.set(false);
        })
        .on("keydown", {
            let send = send.clone();
            let accept = accept.clone();
            let list_of = list_of.clone();
            move |e| {
                let key = e.key();
                let current = text.get_untracked();
                let list = list_of(&current);
                if !list.is_empty() && !dismissed.get_untracked() {
                    let at = sel.get_untracked().min(list.len() - 1);
                    match key.as_str() {
                        "ArrowDown" => {
                            e.prevent_default();
                            sel.set((at + 1) % list.len());
                            return;
                        }
                        "ArrowUp" => {
                            e.prevent_default();
                            sel.set((at + list.len() - 1) % list.len());
                            return;
                        }
                        "Escape" => {
                            dismissed.set(true);
                            return;
                        }
                        "Tab" => {
                            e.prevent_default();
                            accept(list[at].insert.clone());
                            return;
                        }
                        "Enter" if !e.shift_key() && list[at].label != current.trim_end() => {
                            e.prevent_default();
                            accept(list[at].insert.clone());
                            return;
                        }
                        _ => {}
                    }
                }
                if key == "Enter" && !e.shift_key() {
                    e.prevent_default();
                    send();
                }
            }
        });

    let action_class = cn(&[
        "inline-flex shrink-0 items-center justify-center gap-2 rounded-md font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
        pick(size, "h-10 px-4 text-sm", "h-16 px-8 text-2xl"),
        if size == ChatSize::Tv { TV_FOCUS } else { "" },
    ]);

    let popup = {
        let accept = accept.clone();
        move || {
            let items = list_of(&text.get());
            if items.is_empty() || dismissed.get() {
                return None;
            }
            let at = sel.get().min(items.len() - 1);
            Some(
                ul().role("listbox")
                    .class("absolute bottom-full left-0 right-0 z-10 mb-1 overflow-hidden rounded-md border border-border bg-bg-2 shadow-lg")
                    .children(items.into_iter().enumerate().map(|(i, s)| {
                        let accept = accept.clone();
                        let insert = s.insert.clone();
                        li().child(
                            button()
                                .typ("button")
                                .role("option")
                                .attr("aria-selected", (i == at).to_string())
                                .class(cn(&[
                                    "flex w-full items-baseline gap-3 px-3 py-2 text-left text-fg hover:bg-panel focus-visible:outline-none focus-visible:bg-panel",
                                    pick(size, "text-sm", "text-xl"),
                                    if i == at { "bg-panel" } else { "" },
                                ]))
                                .on_click(move |_| accept(insert.clone()))
                                .child((
                                    span().class("font-medium").child(s.label),
                                    span().class("text-muted").child(s.hint),
                                )),
                        )
                    })),
            )
        }
    };

    div().class("relative flex items-end gap-2 p-2").child((
        popup,
        field,
        show(
            move || busy.get(),
            {
                let on_stop = on_stop.clone();
                let class = action_class.clone();
                move || {
                    let on_stop = on_stop.clone();
                    button()
                        .typ("button")
                        .attr("aria-label", "Stop")
                        .class(cn(&[class.as_str(), "bg-danger text-bg hover:bg-danger/90"]))
                        .on_click(move |_| on_stop())
                        .child((icons::icon("M6 6h12v12H6z"), "Stop"))
                }
            },
            {
                let send = send.clone();
                let class = action_class.clone();
                move || {
                    let send = send.clone();
                    button()
                        .typ("button")
                        .attr("aria-label", "Send")
                        .prop("disabled", move || text.get().trim().is_empty())
                        .class(cn(&[class.as_str(), "bg-accent text-bg hover:bg-accent/90 disabled:pointer-events-none disabled:opacity-50"]))
                        .on_click(move |_| send())
                        .child((icons::icon("M22 2 11 13M22 2l-7 20-4-9-9-4z"), "Send"))
                }
            },
        ),
    ))
}

// ── Presence ────────────────────────────────────────────────────────────────

/// Who is here and who is busy: a row of small author chips for `online`,
/// then "X is typing" (people) or "X is thinking" (assistants) for `typing`.
pub fn presence(
    online: Signal<Vec<ChatAuthor>>,
    typing: Signal<Vec<ChatAuthor>>,
    size: ChatSize,
) -> El {
    div()
        .class(cn(&[
            "flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1 text-muted",
            pick(size, "text-xs", "text-xl"),
        ]))
        .child((
            div().class("flex flex-wrap items-center gap-3").child(each(
                move || online.get(),
                |a| a.id.clone(),
                move |a: Signal<ChatAuthor>| move || author_chip(&a.get(), size),
            )),
            move || {
                let who = typing.get();
                if who.is_empty() {
                    return None;
                }
                let phrases: Vec<String> = who
                    .iter()
                    .map(|a| {
                        let verb = if a.kind == AuthorKind::Assistant {
                            "thinking"
                        } else {
                            "typing"
                        };
                        format!("{} is {verb}", a.name)
                    })
                    .collect();
                Some(
                    span()
                        .role("status")
                        .class("ak-pulse italic")
                        .child(phrases.join(", ")),
                )
            },
        ))
}