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(¤t);
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(", ")),
)
},
))
}