Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #10
132 lines
4.2 KiB
Rust
132 lines
4.2 KiB
Rust
use crate::models::{ChatMessage, ChatRole};
|
|
use dioxus::prelude::*;
|
|
|
|
/// Render markdown content to HTML using `pulldown-cmark`.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `md` - Raw markdown string
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// HTML string suitable for `dangerous_inner_html`
|
|
fn markdown_to_html(md: &str) -> String {
|
|
use pulldown_cmark::{Options, Parser};
|
|
|
|
let mut opts = Options::empty();
|
|
opts.insert(Options::ENABLE_TABLES);
|
|
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
|
opts.insert(Options::ENABLE_TASKLISTS);
|
|
|
|
let parser = Parser::new_ext(md, opts);
|
|
let mut html = String::with_capacity(md.len() * 2);
|
|
pulldown_cmark::html::push_html(&mut html, parser);
|
|
html
|
|
}
|
|
|
|
/// Renders a single chat message bubble with role-based styling.
|
|
///
|
|
/// User messages are displayed as plain text, right-aligned.
|
|
/// Assistant messages are rendered as markdown with `pulldown-cmark`.
|
|
/// System messages are hidden from the UI.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `message` - The chat message to render
|
|
#[component]
|
|
pub fn ChatBubble(message: ChatMessage) -> Element {
|
|
// System messages are not rendered in the UI
|
|
if message.role == ChatRole::System {
|
|
return rsx! {};
|
|
}
|
|
|
|
let bubble_class = match message.role {
|
|
ChatRole::User => "chat-bubble chat-bubble--user",
|
|
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
|
|
ChatRole::System => unreachable!(),
|
|
};
|
|
|
|
let role_label = match message.role {
|
|
ChatRole::User => "You",
|
|
ChatRole::Assistant => "Assistant",
|
|
ChatRole::System => unreachable!(),
|
|
};
|
|
|
|
// Format timestamp for display (show time only if today)
|
|
let display_time = if message.timestamp.len() >= 16 {
|
|
// Extract HH:MM from ISO 8601
|
|
message.timestamp[11..16].to_string()
|
|
} else {
|
|
message.timestamp.clone()
|
|
};
|
|
|
|
let is_assistant = message.role == ChatRole::Assistant;
|
|
|
|
rsx! {
|
|
div { class: "{bubble_class}",
|
|
div { class: "chat-bubble-header",
|
|
span { class: "chat-bubble-role", "{role_label}" }
|
|
span { class: "chat-bubble-time", "{display_time}" }
|
|
}
|
|
if is_assistant {
|
|
// Render markdown for assistant messages
|
|
div {
|
|
class: "chat-bubble-content chat-prose",
|
|
dangerous_inner_html: "{markdown_to_html(&message.content)}",
|
|
}
|
|
} else {
|
|
div { class: "chat-bubble-content", "{message.content}" }
|
|
}
|
|
if !message.attachments.is_empty() {
|
|
div { class: "chat-bubble-attachments",
|
|
for att in &message.attachments {
|
|
span { class: "chat-attachment", "{att.name}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Renders a streaming assistant message bubble.
|
|
///
|
|
/// While waiting for tokens, shows a "Thinking..." indicator with
|
|
/// a pulsing dot animation. Once tokens arrive, renders them as
|
|
/// markdown with a blinking cursor.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `content` - The accumulated streaming content so far
|
|
#[component]
|
|
pub fn StreamingBubble(content: String) -> Element {
|
|
if content.is_empty() {
|
|
// Thinking state -- no tokens yet
|
|
rsx! {
|
|
div { class: "chat-bubble chat-bubble--assistant chat-bubble--thinking",
|
|
div { class: "chat-thinking",
|
|
span { class: "chat-thinking-dots",
|
|
span { class: "chat-dot" }
|
|
span { class: "chat-dot" }
|
|
span { class: "chat-dot" }
|
|
}
|
|
span { class: "chat-thinking-text", "Thinking..." }
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let html = markdown_to_html(&content);
|
|
rsx! {
|
|
div { class: "chat-bubble chat-bubble--assistant chat-bubble--streaming",
|
|
div { class: "chat-bubble-header",
|
|
span { class: "chat-bubble-role", "Assistant" }
|
|
}
|
|
div {
|
|
class: "chat-bubble-content chat-prose",
|
|
dangerous_inner_html: "{html}",
|
|
}
|
|
span { class: "chat-streaming-cursor" }
|
|
}
|
|
}
|
|
}
|
|
}
|