Integrate SearXNG news search, Ollama-powered article summarization with follow-up chat, and a dashboard sidebar showing LLM status, trending keywords, and recent search history. Sidebar yields to a split-view article detail panel when a card is selected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
159 lines
6.7 KiB
Rust
159 lines
6.7 KiB
Rust
use crate::infrastructure::llm::FollowUpMessage;
|
|
use crate::models::NewsCard;
|
|
use dioxus::prelude::*;
|
|
|
|
/// Side panel displaying the full details of a selected news article.
|
|
///
|
|
/// Shows the article title, source, date, category badge, full content,
|
|
/// a link to the original article, an AI summary bubble, and a follow-up
|
|
/// chat window for asking questions about the article.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `card` - The selected news card data
|
|
/// * `on_close` - Handler to close the detail panel
|
|
/// * `summary` - Optional AI-generated summary text
|
|
/// * `is_summarizing` - Whether a summarization request is in progress
|
|
/// * `chat_messages` - Follow-up chat conversation history (user + assistant turns)
|
|
/// * `is_chatting` - Whether a chat response is being generated
|
|
/// * `on_chat_send` - Handler called with the user's follow-up question
|
|
#[component]
|
|
pub fn ArticleDetail(
|
|
card: NewsCard,
|
|
on_close: EventHandler,
|
|
summary: Option<String>,
|
|
#[props(default = false)] is_summarizing: bool,
|
|
chat_messages: Vec<FollowUpMessage>,
|
|
#[props(default = false)] is_chatting: bool,
|
|
on_chat_send: EventHandler<String>,
|
|
) -> Element {
|
|
let css_suffix = card.category.to_lowercase().replace(' ', "-");
|
|
let badge_class = format!("news-badge news-badge--{css_suffix}");
|
|
let mut chat_input = use_signal(String::new);
|
|
let has_summary = summary.is_some() && !is_summarizing;
|
|
|
|
// Build favicon URL using DuckDuckGo's privacy-friendly icon service
|
|
let favicon_url = format!("https://icons.duckduckgo.com/ip3/{}.ico", card.source);
|
|
|
|
rsx! {
|
|
aside { class: "article-detail-panel",
|
|
// Close button
|
|
button {
|
|
class: "article-detail-close",
|
|
onclick: move |_| on_close.call(()),
|
|
"X"
|
|
}
|
|
|
|
div { class: "article-detail-content",
|
|
// Header
|
|
h2 { class: "article-detail-title", "{card.title}" }
|
|
|
|
div { class: "article-detail-meta",
|
|
span { class: "{badge_class}", "{card.category}" }
|
|
span { class: "article-detail-source",
|
|
img {
|
|
class: "source-favicon",
|
|
src: "{favicon_url}",
|
|
alt: "",
|
|
width: "16",
|
|
height: "16",
|
|
}
|
|
"{card.source}"
|
|
}
|
|
span { class: "article-detail-date", "{card.published_at}" }
|
|
}
|
|
|
|
// Content body
|
|
div { class: "article-detail-body",
|
|
p { "{card.content}" }
|
|
}
|
|
|
|
// Link to original
|
|
a {
|
|
class: "article-detail-link",
|
|
href: "{card.url}",
|
|
target: "_blank",
|
|
rel: "noopener",
|
|
"Read original article"
|
|
}
|
|
|
|
// AI Summary bubble (below the link)
|
|
div { class: "ai-summary-bubble",
|
|
if is_summarizing {
|
|
div { class: "ai-summary-bubble-loading",
|
|
div { class: "ai-summary-dot-pulse" }
|
|
span { "Summarizing..." }
|
|
}
|
|
} else if let Some(ref text) = summary {
|
|
p { class: "ai-summary-bubble-text", "{text}" }
|
|
span { class: "ai-summary-bubble-label", "Summarized with AI" }
|
|
}
|
|
}
|
|
|
|
// Follow-up chat window (visible after summary is ready)
|
|
if has_summary {
|
|
div { class: "article-chat",
|
|
// Chat message history
|
|
if !chat_messages.is_empty() {
|
|
div { class: "article-chat-messages",
|
|
for msg in chat_messages.iter() {
|
|
{
|
|
let bubble_class = if msg.role == "user" {
|
|
"chat-msg chat-msg--user"
|
|
} else {
|
|
"chat-msg chat-msg--assistant"
|
|
};
|
|
rsx! {
|
|
div { class: "{bubble_class}",
|
|
p { "{msg.content}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if is_chatting {
|
|
div { class: "chat-msg chat-msg--assistant chat-msg--typing",
|
|
div { class: "ai-summary-dot-pulse" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Chat input
|
|
div { class: "article-chat-input",
|
|
input {
|
|
class: "article-chat-textbox",
|
|
r#type: "text",
|
|
placeholder: "Ask a follow-up question...",
|
|
value: "{chat_input}",
|
|
disabled: is_chatting,
|
|
oninput: move |e| chat_input.set(e.value()),
|
|
onkeypress: move |e| {
|
|
if e.key() == Key::Enter && !is_chatting {
|
|
let val = chat_input.read().trim().to_string();
|
|
if !val.is_empty() {
|
|
on_chat_send.call(val);
|
|
chat_input.set(String::new());
|
|
}
|
|
}
|
|
},
|
|
}
|
|
button {
|
|
class: "article-chat-send",
|
|
disabled: is_chatting,
|
|
onclick: move |_| {
|
|
let val = chat_input.read().trim().to_string();
|
|
if !val.is_empty() {
|
|
on_chat_send.call(val);
|
|
chat_input.set(String::new());
|
|
}
|
|
},
|
|
"Send"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|