feat(dashboard): added dashboard content and features (#7)
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
20
src/app.rs
20
src/app.rs
@@ -52,6 +52,7 @@ pub enum Route {
|
||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
const MANIFEST: Asset = asset!("/assets/manifest.json");
|
||||
|
||||
/// Google Fonts URL for Inter (body) and Space Grotesk (headings).
|
||||
const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
|
||||
@@ -64,6 +65,14 @@ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
|
||||
pub fn App() -> Element {
|
||||
rsx! {
|
||||
document::Link { rel: "icon", href: FAVICON }
|
||||
document::Link { rel: "manifest", href: MANIFEST }
|
||||
document::Meta { name: "theme-color", content: "#4B3FE0" }
|
||||
document::Meta { name: "apple-mobile-web-app-capable", content: "yes" }
|
||||
document::Meta {
|
||||
name: "apple-mobile-web-app-status-bar-style",
|
||||
content: "black-translucent",
|
||||
}
|
||||
document::Link { rel: "apple-touch-icon", href: FAVICON }
|
||||
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
|
||||
document::Link {
|
||||
rel: "preconnect",
|
||||
@@ -73,6 +82,17 @@ pub fn App() -> Element {
|
||||
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
|
||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||
|
||||
// Register PWA service worker
|
||||
document::Script {
|
||||
r#"
|
||||
if ('serviceWorker' in navigator) {{
|
||||
navigator.serviceWorker.register('/assets/sw.js')
|
||||
.catch(function(e) {{ console.warn('SW registration failed:', e); }});
|
||||
}}
|
||||
"#
|
||||
}
|
||||
|
||||
div { "data-theme": "certifai-dark", Router::<Route> {} }
|
||||
}
|
||||
}
|
||||
|
||||
158
src/components/article_detail.rs
Normal file
158
src/components/article_detail.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/components/dashboard_sidebar.rs
Normal file
112
src/components/dashboard_sidebar.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
|
||||
|
||||
/// Right sidebar for the dashboard, showing Ollama status, trending topics,
|
||||
/// and recent search history.
|
||||
///
|
||||
/// Appears when no article card is selected. Disappears when the user opens
|
||||
/// the article detail split view.
|
||||
///
|
||||
/// # Props
|
||||
///
|
||||
/// * `ollama_url` - Ollama instance URL for status polling
|
||||
/// * `trending` - Trending topic keywords extracted from recent news headlines
|
||||
/// * `recent_searches` - Recent search topics stored in localStorage
|
||||
/// * `on_topic_click` - Fires when a trending or recent topic is clicked
|
||||
#[component]
|
||||
pub fn DashboardSidebar(
|
||||
ollama_url: String,
|
||||
trending: Vec<String>,
|
||||
recent_searches: Vec<String>,
|
||||
on_topic_click: EventHandler<String>,
|
||||
) -> Element {
|
||||
// Fetch Ollama status once on mount.
|
||||
// use_resource with no signal dependencies runs exactly once and
|
||||
// won't re-fire on parent re-renders (unlike use_effect).
|
||||
let url = ollama_url.clone();
|
||||
let status_resource = use_resource(move || {
|
||||
let u = url.clone();
|
||||
async move {
|
||||
get_ollama_status(u).await.unwrap_or(OllamaStatus {
|
||||
online: false,
|
||||
models: Vec::new(),
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
let current_status: OllamaStatus =
|
||||
status_resource
|
||||
.read()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or(OllamaStatus {
|
||||
online: false,
|
||||
models: Vec::new(),
|
||||
});
|
||||
|
||||
rsx! {
|
||||
aside { class: "dashboard-sidebar",
|
||||
|
||||
// -- Ollama Status Section --
|
||||
div { class: "sidebar-section",
|
||||
h4 { class: "sidebar-section-title", "Ollama Status" }
|
||||
div { class: "sidebar-status-row",
|
||||
span { class: if current_status.online { "sidebar-status-dot sidebar-status-dot--online" } else { "sidebar-status-dot sidebar-status-dot--offline" } }
|
||||
span { class: "sidebar-status-label",
|
||||
if current_status.online {
|
||||
"Online"
|
||||
} else {
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current_status.models.is_empty() {
|
||||
div { class: "sidebar-model-list",
|
||||
for model in current_status.models.iter() {
|
||||
span { class: "sidebar-model-tag", "{model}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Trending Topics Section --
|
||||
if !trending.is_empty() {
|
||||
div { class: "sidebar-section",
|
||||
h4 { class: "sidebar-section-title", "Trending" }
|
||||
for topic in trending.iter() {
|
||||
{
|
||||
let t = topic.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "sidebar-topic-link",
|
||||
onclick: move |_| on_topic_click.call(t.clone()),
|
||||
"{topic}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Recent Searches Section --
|
||||
if !recent_searches.is_empty() {
|
||||
div { class: "sidebar-section",
|
||||
h4 { class: "sidebar-section-title", "Recent Searches" }
|
||||
for search in recent_searches.iter() {
|
||||
{
|
||||
let s = search.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "sidebar-topic-link",
|
||||
onclick: move |_| on_topic_click.call(s.clone()),
|
||||
"{search}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
mod app_shell;
|
||||
mod article_detail;
|
||||
mod card;
|
||||
mod chat_bubble;
|
||||
mod dashboard_sidebar;
|
||||
mod file_row;
|
||||
mod login;
|
||||
mod member_row;
|
||||
@@ -12,8 +14,10 @@ pub mod sub_nav;
|
||||
mod tool_card;
|
||||
|
||||
pub use app_shell::*;
|
||||
pub use article_detail::*;
|
||||
pub use card::*;
|
||||
pub use chat_bubble::*;
|
||||
pub use dashboard_sidebar::*;
|
||||
pub use file_row::*;
|
||||
pub use login::*;
|
||||
pub use member_row::*;
|
||||
|
||||
@@ -1,40 +1,67 @@
|
||||
use crate::models::{NewsCard as NewsCardModel, NewsCategory};
|
||||
use crate::models::NewsCard as NewsCardModel;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a news feed card with title, source, category badge, and summary.
|
||||
///
|
||||
/// When a thumbnail URL is present but the image fails to load, the card
|
||||
/// automatically switches to the centered no-thumbnail layout.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `card` - The news card model data to render
|
||||
/// * `on_click` - Event handler triggered when the card is clicked
|
||||
/// * `selected` - Whether this card is currently selected (highlighted)
|
||||
#[component]
|
||||
pub fn NewsCardView(card: NewsCardModel) -> Element {
|
||||
let badge_class = format!("news-badge news-badge--{}", card.category.css_class());
|
||||
pub fn NewsCardView(
|
||||
card: NewsCardModel,
|
||||
on_click: EventHandler<NewsCardModel>,
|
||||
#[props(default = false)] selected: bool,
|
||||
) -> Element {
|
||||
// Derive a CSS class from the category string (lowercase, hyphenated)
|
||||
let css_suffix = card.category.to_lowercase().replace(' ', "-");
|
||||
let badge_class = format!("news-badge news-badge--{css_suffix}");
|
||||
|
||||
// Track whether the thumbnail loaded successfully.
|
||||
// Starts as true if a URL is provided; set to false on image error.
|
||||
let has_thumb_url = card.thumbnail_url.is_some();
|
||||
let mut thumb_ok = use_signal(|| has_thumb_url);
|
||||
|
||||
let show_thumb = has_thumb_url && *thumb_ok.read();
|
||||
let selected_cls = if selected { " news-card--selected" } else { "" };
|
||||
let thumb_cls = if show_thumb {
|
||||
""
|
||||
} else {
|
||||
" news-card--no-thumb"
|
||||
};
|
||||
let card_class = format!("news-card{selected_cls}{thumb_cls}");
|
||||
|
||||
// Clone the card for the click handler closure
|
||||
let card_for_click = card.clone();
|
||||
|
||||
rsx! {
|
||||
article { class: "news-card",
|
||||
article {
|
||||
class: "{card_class}",
|
||||
onclick: move |_| on_click.call(card_for_click.clone()),
|
||||
if let Some(ref thumb) = card.thumbnail_url {
|
||||
div { class: "news-card-thumb",
|
||||
img {
|
||||
src: "{thumb}",
|
||||
alt: "{card.title}",
|
||||
loading: "lazy",
|
||||
if *thumb_ok.read() {
|
||||
div { class: "news-card-thumb",
|
||||
img {
|
||||
src: "{thumb}",
|
||||
alt: "",
|
||||
loading: "lazy",
|
||||
// Hide the thumbnail container if the image fails to load
|
||||
onerror: move |_| thumb_ok.set(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "news-card-body",
|
||||
div { class: "news-card-meta",
|
||||
span { class: "{badge_class}", "{card.category.label()}" }
|
||||
span { class: "{badge_class}", "{card.category}" }
|
||||
span { class: "news-card-source", "{card.source}" }
|
||||
span { class: "news-card-date", "{card.published_at}" }
|
||||
}
|
||||
h3 { class: "news-card-title",
|
||||
a {
|
||||
href: "{card.url}",
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
"{card.title}"
|
||||
}
|
||||
}
|
||||
h3 { class: "news-card-title", "{card.title}" }
|
||||
p { class: "news-card-summary", "{card.summary}" }
|
||||
}
|
||||
}
|
||||
@@ -48,7 +75,12 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "Llama 4 Released with 1M Context Window".into(),
|
||||
source: "Meta AI Blog".into(),
|
||||
summary: "Meta releases Llama 4 with a 1 million token context window.".into(),
|
||||
category: NewsCategory::Llm,
|
||||
content: "Meta has officially released Llama 4, their latest \
|
||||
open-weight large language model featuring a groundbreaking \
|
||||
1 million token context window. This represents a major \
|
||||
leap in context length capabilities."
|
||||
.into(),
|
||||
category: "AI".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-18".into(),
|
||||
@@ -57,7 +89,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "EU AI Act Enforcement Begins".into(),
|
||||
source: "TechCrunch".into(),
|
||||
summary: "The EU AI Act enters its enforcement phase across member states.".into(),
|
||||
category: NewsCategory::Privacy,
|
||||
content: "The EU AI Act has officially entered its enforcement \
|
||||
phase. Member states are now required to comply with the \
|
||||
comprehensive regulatory framework governing AI systems."
|
||||
.into(),
|
||||
category: "Privacy".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-17".into(),
|
||||
@@ -66,7 +102,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "LangChain v0.4 Introduces Native MCP Support".into(),
|
||||
source: "LangChain Blog".into(),
|
||||
summary: "New version adds first-class MCP server integration.".into(),
|
||||
category: NewsCategory::Agents,
|
||||
content: "LangChain v0.4 introduces native Model Context Protocol \
|
||||
support, enabling seamless integration with MCP servers for \
|
||||
tool use and context management in agent workflows."
|
||||
.into(),
|
||||
category: "Technology".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-16".into(),
|
||||
@@ -75,7 +115,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "Ollama Adds Multi-GPU Scheduling".into(),
|
||||
source: "Ollama".into(),
|
||||
summary: "Run large models across multiple GPUs with automatic sharding.".into(),
|
||||
category: NewsCategory::Infrastructure,
|
||||
content: "Ollama now supports multi-GPU scheduling with automatic \
|
||||
model sharding. Users can run models across multiple GPUs \
|
||||
for improved inference performance."
|
||||
.into(),
|
||||
category: "Infrastructure".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-15".into(),
|
||||
@@ -84,7 +128,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "Mistral Open Sources Codestral 2".into(),
|
||||
source: "Mistral AI".into(),
|
||||
summary: "Codestral 2 achieves state-of-the-art on HumanEval benchmarks.".into(),
|
||||
category: NewsCategory::OpenSource,
|
||||
content: "Mistral AI has open-sourced Codestral 2, a code \
|
||||
generation model that achieves state-of-the-art results \
|
||||
on HumanEval and other coding benchmarks."
|
||||
.into(),
|
||||
category: "Open Source".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-14".into(),
|
||||
@@ -93,7 +141,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "NVIDIA Releases NeMo 3.0 Framework".into(),
|
||||
source: "NVIDIA Developer".into(),
|
||||
summary: "Updated framework simplifies enterprise LLM fine-tuning.".into(),
|
||||
category: NewsCategory::Infrastructure,
|
||||
content: "NVIDIA has released NeMo 3.0, an updated framework \
|
||||
that simplifies enterprise LLM fine-tuning with improved \
|
||||
distributed training capabilities."
|
||||
.into(),
|
||||
category: "Infrastructure".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-13".into(),
|
||||
@@ -102,7 +154,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "Anthropic Claude 4 Sets New Reasoning Records".into(),
|
||||
source: "Anthropic".into(),
|
||||
summary: "Claude 4 achieves top scores across major reasoning benchmarks.".into(),
|
||||
category: NewsCategory::Llm,
|
||||
content: "Anthropic's Claude 4 has set new records across major \
|
||||
reasoning benchmarks, demonstrating significant improvements \
|
||||
in mathematical and logical reasoning capabilities."
|
||||
.into(),
|
||||
category: "AI".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-12".into(),
|
||||
@@ -111,7 +167,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "CrewAI Raises $52M for Agent Orchestration".into(),
|
||||
source: "VentureBeat".into(),
|
||||
summary: "Series B funding to expand multi-agent orchestration platform.".into(),
|
||||
category: NewsCategory::Agents,
|
||||
content: "CrewAI has raised $52M in Series B funding to expand \
|
||||
its multi-agent orchestration platform, enabling teams \
|
||||
to build and deploy complex AI agent workflows."
|
||||
.into(),
|
||||
category: "Technology".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-11".into(),
|
||||
@@ -120,7 +180,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "DeepSeek V4 Released Under Apache 2.0".into(),
|
||||
source: "DeepSeek".into(),
|
||||
summary: "Latest open-weight model competes with proprietary offerings.".into(),
|
||||
category: NewsCategory::OpenSource,
|
||||
content: "DeepSeek has released V4 under the Apache 2.0 license, \
|
||||
an open-weight model that competes with proprietary \
|
||||
offerings in both performance and efficiency."
|
||||
.into(),
|
||||
category: "Open Source".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-10".into(),
|
||||
@@ -129,7 +193,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
title: "GDPR Fines for AI Training Data Reach Record High".into(),
|
||||
source: "Reuters".into(),
|
||||
summary: "European regulators issue largest penalties yet for AI data misuse.".into(),
|
||||
category: NewsCategory::Privacy,
|
||||
content: "European regulators have issued record-high GDPR fines \
|
||||
for AI training data misuse, signaling stricter enforcement \
|
||||
of data protection laws in the AI sector."
|
||||
.into(),
|
||||
category: "Privacy".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-09".into(),
|
||||
|
||||
324
src/infrastructure/llm.rs
Normal file
324
src/infrastructure/llm.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
mod inner {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single message in the OpenAI-compatible chat format used by Ollama.
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Request body for Ollama's OpenAI-compatible chat completions endpoint.
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct OllamaChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// Disable streaming so we get a single JSON response.
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
/// A single choice in the Ollama chat completions response.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ChatChoice {
|
||||
pub message: ChatResponseMessage,
|
||||
}
|
||||
|
||||
/// The assistant message returned inside a choice.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ChatResponseMessage {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Top-level response from Ollama's `/v1/chat/completions` endpoint.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct OllamaChatResponse {
|
||||
pub choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
/// Fetch the full text content of a webpage by downloading its HTML
|
||||
/// and extracting the main article body, skipping navigation, headers,
|
||||
/// footers, and sidebars.
|
||||
///
|
||||
/// Uses a tiered extraction strategy:
|
||||
/// 1. Try content within `<article>`, `<main>`, or `[role="main"]`
|
||||
/// 2. Fall back to all `<p>` tags outside excluded containers
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - The article URL to fetch
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The extracted text, or `None` if the fetch/parse fails.
|
||||
/// Text is capped at 8000 characters to stay within LLM context limits.
|
||||
pub(super) async fn fetch_article_text(url: &str) -> Option<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("User-Agent", "CERTifAI/1.0 (Article Summarizer)")
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let html = resp.text().await.ok()?;
|
||||
let document = scraper::Html::parse_document(&html);
|
||||
|
||||
// Strategy 1: Extract from semantic article containers.
|
||||
// Most news sites wrap the main content in <article>, <main>,
|
||||
// or an element with role="main".
|
||||
let article_selector = scraper::Selector::parse("article, main, [role='main']").ok()?;
|
||||
let paragraph_sel = scraper::Selector::parse("p, h1, h2, h3, li").ok()?;
|
||||
|
||||
let mut text_parts: Vec<String> = Vec::with_capacity(64);
|
||||
|
||||
for container in document.select(&article_selector) {
|
||||
for element in container.select(¶graph_sel) {
|
||||
collect_text_fragment(element, &mut text_parts);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: If article containers yielded little text, fall back
|
||||
// to all <p> tags that are NOT inside nav/header/footer/aside.
|
||||
if joined_len(&text_parts) < 200 {
|
||||
text_parts.clear();
|
||||
let all_p = scraper::Selector::parse("p").ok()?;
|
||||
|
||||
// Tags whose descendants should be excluded from extraction
|
||||
const EXCLUDED_TAGS: &[&str] = &["nav", "header", "footer", "aside", "script", "style"];
|
||||
|
||||
for element in document.select(&all_p) {
|
||||
// Walk ancestors and skip if inside an excluded container.
|
||||
// Checks tag names directly to avoid ego_tree version issues.
|
||||
let inside_excluded = element.ancestors().any(|ancestor| {
|
||||
ancestor
|
||||
.value()
|
||||
.as_element()
|
||||
.is_some_and(|el| EXCLUDED_TAGS.contains(&el.name.local.as_ref()))
|
||||
});
|
||||
if !inside_excluded {
|
||||
collect_text_fragment(element, &mut text_parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let full_text = text_parts.join("\n\n");
|
||||
if full_text.len() < 100 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Cap at 8000 chars to stay within reasonable LLM context
|
||||
let truncated: String = full_text.chars().take(8000).collect();
|
||||
Some(truncated)
|
||||
}
|
||||
|
||||
/// Extract text from an HTML element and append it to the parts list
|
||||
/// if it meets a minimum length threshold.
|
||||
fn collect_text_fragment(element: scraper::ElementRef<'_>, parts: &mut Vec<String>) {
|
||||
let text: String = element.text().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = text.trim().to_string();
|
||||
// Skip very short fragments (nav items, buttons, etc.)
|
||||
if trimmed.len() >= 30 {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum the total character length of all collected text parts.
|
||||
fn joined_len(parts: &[String]) -> usize {
|
||||
parts.iter().map(|s| s.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarize an article using a local Ollama instance.
|
||||
///
|
||||
/// First attempts to fetch the full article text from the provided URL.
|
||||
/// If that fails (paywall, timeout, etc.), falls back to the search snippet.
|
||||
/// This mirrors how Perplexity fetches and reads source pages before answering.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `snippet` - The search result snippet (fallback content)
|
||||
/// * `article_url` - The original article URL to fetch full text from
|
||||
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
|
||||
/// * `model` - The Ollama model ID to use (e.g. "llama3.1:8b")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A summary string generated by the LLM, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||
#[server(endpoint = "/api/summarize")]
|
||||
pub async fn summarize_article(
|
||||
snippet: String,
|
||||
article_url: String,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||
|
||||
// Fall back to env var or default if the URL is empty
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into())
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
// Fall back to env var or default if the model is empty
|
||||
let model = if model.is_empty() {
|
||||
std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into())
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
// Try to fetch the full article; fall back to the search snippet
|
||||
let article_text = fetch_article_text(&article_url).await.unwrap_or(snippet);
|
||||
|
||||
let request_body = OllamaChatRequest {
|
||||
model,
|
||||
stream: false,
|
||||
messages: vec![ChatMessage {
|
||||
role: "user".into(),
|
||||
content: format!(
|
||||
"You are a news summarizer. Summarize the following article text \
|
||||
in 2-3 concise paragraphs. Focus only on the key points and \
|
||||
implications. Do NOT comment on the source, the date, the URL, \
|
||||
the formatting, or whether the content seems complete or not. \
|
||||
Just summarize whatever content is provided.\n\n\
|
||||
{article_text}"
|
||||
),
|
||||
}],
|
||||
};
|
||||
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Ollama returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: OllamaChatResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
|
||||
|
||||
body.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
||||
}
|
||||
|
||||
/// A lightweight chat message for the follow-up conversation.
|
||||
/// Uses simple String role ("system"/"user"/"assistant") for Ollama compatibility.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FollowUpMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Send a follow-up question about an article using a local Ollama instance.
|
||||
///
|
||||
/// Accepts the full conversation history (system context + prior turns) and
|
||||
/// returns the assistant's next response. The system message should contain
|
||||
/// the article text and summary so the LLM has full context.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `messages` - The conversation history including system context
|
||||
/// * `ollama_url` - Base URL of the Ollama instance
|
||||
/// * `model` - The Ollama model ID to use
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The assistant's response text, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||
#[server(endpoint = "/api/chat")]
|
||||
pub async fn chat_followup(
|
||||
messages: Vec<FollowUpMessage>,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into())
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
let model = if model.is_empty() {
|
||||
std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into())
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
// Convert FollowUpMessage to inner ChatMessage for the request
|
||||
let chat_messages: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.map(|m| ChatMessage {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let request_body = OllamaChatRequest {
|
||||
model,
|
||||
stream: false,
|
||||
messages: chat_messages,
|
||||
};
|
||||
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Ollama returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: OllamaChatResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
|
||||
|
||||
body.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
#![cfg(feature = "server")]
|
||||
// Server function modules (compiled for both web and server features;
|
||||
// the #[server] macro generates client stubs for the web target)
|
||||
pub mod llm;
|
||||
pub mod ollama;
|
||||
pub mod searxng;
|
||||
|
||||
// Server-only modules (Axum handlers, state, etc.)
|
||||
#[cfg(feature = "server")]
|
||||
mod auth;
|
||||
#[cfg(feature = "server")]
|
||||
mod error;
|
||||
#[cfg(feature = "server")]
|
||||
mod server;
|
||||
#[cfg(feature = "server")]
|
||||
mod state;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub use auth::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use error::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use server::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use state::*;
|
||||
|
||||
91
src/infrastructure/ollama.rs
Normal file
91
src/infrastructure/ollama.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Status of a local Ollama instance, including connectivity and loaded models.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `online` - Whether the Ollama API responded successfully
|
||||
/// * `models` - List of model names currently available on the instance
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OllamaStatus {
|
||||
pub online: bool,
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Response from Ollama's `GET /api/tags` endpoint.
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaTagsResponse {
|
||||
models: Vec<OllamaModel>,
|
||||
}
|
||||
|
||||
/// A single model entry from Ollama's tags API.
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaModel {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Check the status of a local Ollama instance by querying its tags endpoint.
|
||||
///
|
||||
/// Calls `GET <ollama_url>/api/tags` to list available models and determine
|
||||
/// whether the instance is reachable.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An `OllamaStatus` with `online: true` and model names if reachable,
|
||||
/// or `online: false` with an empty model list on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` only on serialization issues; network failures
|
||||
/// are caught and returned as `online: false`
|
||||
#[server(endpoint = "/api/ollama-status")]
|
||||
pub async fn get_ollama_status(ollama_url: String) -> Result<OllamaStatus, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into())
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
||||
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => {
|
||||
return Ok(OllamaStatus {
|
||||
online: false,
|
||||
models: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let body: OllamaTagsResponse = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return Ok(OllamaStatus {
|
||||
online: true,
|
||||
models: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let models = body.models.into_iter().map(|m| m.name).collect();
|
||||
|
||||
Ok(OllamaStatus {
|
||||
online: true,
|
||||
models,
|
||||
})
|
||||
}
|
||||
285
src/infrastructure/searxng.rs
Normal file
285
src/infrastructure/searxng.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
use crate::models::NewsCard;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
// Server-side helpers and types are only needed for the server build.
|
||||
// The #[server] macro generates a client stub for the web build that
|
||||
// sends a network request instead of executing this function body.
|
||||
#[cfg(feature = "server")]
|
||||
mod inner {
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Individual result from the SearXNG search API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SearxngResult {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub content: Option<String>,
|
||||
#[serde(rename = "publishedDate")]
|
||||
pub published_date: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
/// Relevance score assigned by SearXNG (higher = more relevant).
|
||||
#[serde(default)]
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
/// Top-level response from the SearXNG search API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SearxngResponse {
|
||||
pub results: Vec<SearxngResult>,
|
||||
}
|
||||
|
||||
/// Extract the domain name from a URL to use as the source label.
|
||||
///
|
||||
/// Strips common prefixes like "www." for cleaner display.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url_str` - The full URL string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The domain host or a fallback "Web" string
|
||||
pub(super) fn extract_source(url_str: &str) -> String {
|
||||
url::Url::parse(url_str)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(String::from))
|
||||
.map(|host| host.strip_prefix("www.").unwrap_or(&host).to_string())
|
||||
.unwrap_or_else(|| "Web".into())
|
||||
}
|
||||
|
||||
/// Deduplicate and rank search results for quality, similar to Perplexity.
|
||||
///
|
||||
/// Applies the following filters in order:
|
||||
/// 1. Remove results with empty content (no snippet = low value)
|
||||
/// 2. Deduplicate by domain (keep highest-scored result per domain)
|
||||
/// 3. Sort by SearXNG relevance score (descending)
|
||||
/// 4. Cap at `max_results`
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `results` - Raw search results from SearXNG
|
||||
/// * `max_results` - Maximum number of results to return
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Filtered, deduplicated, and ranked results
|
||||
pub(super) fn rank_and_deduplicate(
|
||||
mut results: Vec<SearxngResult>,
|
||||
max_results: usize,
|
||||
) -> Vec<SearxngResult> {
|
||||
// Filter out results with no meaningful content
|
||||
results.retain(|r| r.content.as_ref().is_some_and(|c| c.trim().len() >= 20));
|
||||
|
||||
// Sort by score descending so we keep the best result per domain
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// Deduplicate by domain: keep only the first (highest-scored) per domain
|
||||
let mut seen_domains = HashSet::new();
|
||||
results.retain(|r| {
|
||||
let domain = extract_source(&r.url);
|
||||
seen_domains.insert(domain)
|
||||
});
|
||||
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for news using the SearXNG meta-search engine.
|
||||
///
|
||||
/// Uses Perplexity-style query enrichment and result ranking:
|
||||
/// - Queries the "news" and "general" categories for fresh, relevant results
|
||||
/// - Filters to the last month for recency
|
||||
/// - Deduplicates by domain for source diversity
|
||||
/// - Ranks by SearXNG relevance score
|
||||
/// - Filters out results without meaningful content
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `query` - The search query string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Up to 15 high-quality `NewsCard` results, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the SearXNG request fails or response parsing fails
|
||||
#[server(endpoint = "/api/search")]
|
||||
pub async fn search_topic(query: String) -> Result<Vec<NewsCard>, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
use inner::{extract_source, rank_and_deduplicate, SearxngResponse};
|
||||
|
||||
let searxng_url =
|
||||
std::env::var("SEARXNG_URL").unwrap_or_else(|_| "http://localhost:8888".into());
|
||||
|
||||
// Enrich the query with "latest news" context for better results,
|
||||
// similar to how Perplexity reformulates queries before searching.
|
||||
let enriched_query = format!("{query} latest news");
|
||||
|
||||
// Build URL with query parameters using the url crate's encoder
|
||||
// to avoid reqwest version conflicts between our dep and dioxus's.
|
||||
// Key SearXNG params:
|
||||
// categories=news,general - prioritize news sources + supplement with general
|
||||
// time_range=month - only recent results (last 30 days)
|
||||
// language=en - English results
|
||||
// format=json - machine-readable output
|
||||
let encoded_query: String =
|
||||
url::form_urlencoded::byte_serialize(enriched_query.as_bytes()).collect();
|
||||
let search_url = format!(
|
||||
"{searxng_url}/search?q={encoded_query}&format=json&language=en\
|
||||
&categories=news,general&time_range=month"
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("SearXNG request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(ServerFnError::new(format!(
|
||||
"SearXNG returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: SearxngResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse SearXNG response: {e}")))?;
|
||||
|
||||
// Apply Perplexity-style ranking: filter empties, deduplicate domains, sort by score
|
||||
let ranked = rank_and_deduplicate(body.results, 15);
|
||||
|
||||
let cards: Vec<NewsCard> = ranked
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let summary = r
|
||||
.content
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect::<String>();
|
||||
let content = r.content.unwrap_or_default();
|
||||
NewsCard {
|
||||
title: r.title,
|
||||
source: extract_source(&r.url),
|
||||
summary,
|
||||
content,
|
||||
category: query.clone(),
|
||||
url: r.url,
|
||||
thumbnail_url: r.thumbnail,
|
||||
published_at: r.published_date.unwrap_or_else(|| "Recent".into()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(cards)
|
||||
}
|
||||
|
||||
/// Fetch trending topic keywords by running a broad news search and
|
||||
/// extracting the most frequent meaningful terms from result titles.
|
||||
///
|
||||
/// This approach works regardless of whether SearXNG has autocomplete
|
||||
/// configured, since it uses the standard search API.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Up to 8 trending keyword strings, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the SearXNG search request fails
|
||||
#[server(endpoint = "/api/trending")]
|
||||
pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
use inner::SearxngResponse;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let searxng_url =
|
||||
std::env::var("SEARXNG_URL").unwrap_or_else(|_| "http://localhost:8888".into());
|
||||
|
||||
let encoded_query: String =
|
||||
url::form_urlencoded::byte_serialize(b"trending technology AI").collect();
|
||||
let search_url = format!(
|
||||
"{searxng_url}/search?q={encoded_query}&format=json&language=en\
|
||||
&categories=news&time_range=week"
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
||||
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("SearXNG trending search failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(ServerFnError::new(format!(
|
||||
"SearXNG trending search returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: SearxngResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse trending response: {e}")))?;
|
||||
|
||||
// Common stop words to exclude from trending keywords
|
||||
const STOP_WORDS: &[&str] = &[
|
||||
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
|
||||
"from", "is", "are", "was", "were", "be", "been", "has", "have", "had", "do", "does",
|
||||
"did", "will", "would", "could", "should", "may", "can", "not", "no", "it", "its", "this",
|
||||
"that", "these", "how", "what", "why", "who", "when", "new", "says", "said", "about",
|
||||
"after", "over", "into", "up", "out", "as", "all", "more", "than", "just", "now", "also",
|
||||
"us", "we", "you", "your", "our", "if", "so", "like", "get", "make", "year", "years",
|
||||
"one", "two",
|
||||
];
|
||||
|
||||
// Count word frequency across all result titles. Words are lowercased
|
||||
// and must be at least 3 characters to filter out noise.
|
||||
let mut word_counts: HashMap<String, u32> = HashMap::new();
|
||||
for result in &body.results {
|
||||
for word in result.title.split_whitespace() {
|
||||
// Strip punctuation from edges, lowercase
|
||||
let clean: String = word
|
||||
.trim_matches(|c: char| !c.is_alphanumeric())
|
||||
.to_lowercase();
|
||||
if clean.len() >= 3 && !STOP_WORDS.contains(&clean.as_str()) {
|
||||
*word_counts.entry(clean).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frequency descending, take top 8
|
||||
let mut sorted: Vec<(String, u32)> = word_counts.into_iter().collect();
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
// Capitalize first letter for display
|
||||
let topics: Vec<String> = sorted
|
||||
.into_iter()
|
||||
.filter(|(_, count)| *count >= 2)
|
||||
.take(8)
|
||||
.map(|(word, _)| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||
None => word,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(topics)
|
||||
}
|
||||
@@ -1,44 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Categories for classifying AI news articles.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum NewsCategory {
|
||||
/// Large language model announcements and updates
|
||||
Llm,
|
||||
/// AI agent frameworks and tooling
|
||||
Agents,
|
||||
/// Data privacy and regulatory compliance
|
||||
Privacy,
|
||||
/// AI infrastructure and deployment
|
||||
Infrastructure,
|
||||
/// Open-source AI project releases
|
||||
OpenSource,
|
||||
}
|
||||
|
||||
impl NewsCategory {
|
||||
/// Returns the display label for a news category.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Llm => "LLM",
|
||||
Self::Agents => "Agents",
|
||||
Self::Privacy => "Privacy",
|
||||
Self::Infrastructure => "Infrastructure",
|
||||
Self::OpenSource => "Open Source",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the CSS class suffix for styling category badges.
|
||||
pub fn css_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Llm => "llm",
|
||||
Self::Agents => "agents",
|
||||
Self::Privacy => "privacy",
|
||||
Self::Infrastructure => "infrastructure",
|
||||
Self::OpenSource => "open-source",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single news feed card representing an AI-related article.
|
||||
///
|
||||
/// # Fields
|
||||
@@ -46,7 +7,8 @@ impl NewsCategory {
|
||||
/// * `title` - Headline of the article
|
||||
/// * `source` - Publishing outlet or author
|
||||
/// * `summary` - Brief summary text
|
||||
/// * `category` - Classification category
|
||||
/// * `content` - Full content snippet from search results
|
||||
/// * `category` - Display label for the search topic (e.g. "AI", "Finance")
|
||||
/// * `url` - Link to the full article
|
||||
/// * `thumbnail_url` - Optional thumbnail image URL
|
||||
/// * `published_at` - ISO 8601 date string
|
||||
@@ -55,7 +17,8 @@ pub struct NewsCard {
|
||||
pub title: String,
|
||||
pub source: String,
|
||||
pub summary: String,
|
||||
pub category: NewsCategory,
|
||||
pub content: String,
|
||||
pub category: String,
|
||||
pub url: String,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub published_at: String,
|
||||
|
||||
@@ -1,40 +1,131 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_sdk::storage::use_persistent;
|
||||
|
||||
use crate::components::{NewsCardView, PageHeader};
|
||||
use crate::models::NewsCategory;
|
||||
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
|
||||
use crate::infrastructure::llm::FollowUpMessage;
|
||||
use crate::models::NewsCard;
|
||||
|
||||
/// Dashboard page displaying an AI news feed grid with category filters.
|
||||
/// Maximum number of recent searches to retain in localStorage.
|
||||
const MAX_RECENT_SEARCHES: usize = 10;
|
||||
|
||||
/// Default search topics shown on the dashboard, inspired by Perplexica.
|
||||
const DEFAULT_TOPICS: &[&str] = &[
|
||||
"AI",
|
||||
"Technology",
|
||||
"Science",
|
||||
"Finance",
|
||||
"Writing",
|
||||
"Research",
|
||||
];
|
||||
|
||||
/// Dashboard page displaying AI news from SearXNG with topic-based filtering,
|
||||
/// a split-view article detail panel, and LLM-powered summarization.
|
||||
///
|
||||
/// Replaces the previous `OverviewPage`. Shows mock news items
|
||||
/// that will eventually be sourced from the SearXNG instance.
|
||||
/// State is persisted across sessions using localStorage:
|
||||
/// - `certifai_topics`: custom user-defined search topics
|
||||
/// - `certifai_ollama_url`: Ollama instance URL for summarization
|
||||
/// - `certifai_ollama_model`: Ollama model ID for summarization
|
||||
#[component]
|
||||
pub fn DashboardPage() -> Element {
|
||||
let news = use_signal(crate::components::news_card::mock_news);
|
||||
let mut active_filter = use_signal(|| Option::<NewsCategory>::None);
|
||||
// Persistent state stored in localStorage
|
||||
let mut custom_topics = use_persistent("certifai_topics".to_string(), Vec::<String>::new);
|
||||
// Default to empty so the server functions use OLLAMA_URL / OLLAMA_MODEL
|
||||
// from .env. Only stores a non-empty value when the user explicitly saves
|
||||
// an override via the Settings panel.
|
||||
let mut ollama_url = use_persistent("certifai_ollama_url".to_string(), String::new);
|
||||
let mut ollama_model = use_persistent("certifai_ollama_model".to_string(), String::new);
|
||||
|
||||
// Collect filtered news items based on active category filter
|
||||
let filtered: Vec<_> = {
|
||||
let items = news.read();
|
||||
let filter = active_filter.read();
|
||||
match &*filter {
|
||||
Some(cat) => items
|
||||
.iter()
|
||||
.filter(|n| n.category == *cat)
|
||||
.cloned()
|
||||
.collect(),
|
||||
None => items.clone(),
|
||||
// Reactive signals for UI state
|
||||
let mut active_topic = use_signal(|| "AI".to_string());
|
||||
let mut selected_card = use_signal(|| Option::<NewsCard>::None);
|
||||
let mut summary = use_signal(|| Option::<String>::None);
|
||||
let mut is_summarizing = use_signal(|| false);
|
||||
let mut show_add_input = use_signal(|| false);
|
||||
let mut new_topic_text = use_signal(String::new);
|
||||
let mut show_settings = use_signal(|| false);
|
||||
let mut settings_url = use_signal(String::new);
|
||||
let mut settings_model = use_signal(String::new);
|
||||
// Chat follow-up state
|
||||
let mut chat_messages = use_signal(Vec::<FollowUpMessage>::new);
|
||||
let mut is_chatting = use_signal(|| false);
|
||||
// Stores the article text context for the chat system message
|
||||
let mut article_context = use_signal(String::new);
|
||||
|
||||
// Recent search history, persisted in localStorage (capped at MAX_RECENT_SEARCHES)
|
||||
let mut recent_searches =
|
||||
use_persistent("certifai_recent_searches".to_string(), Vec::<String>::new);
|
||||
|
||||
// Build the complete topic list: defaults + custom
|
||||
let all_topics: Vec<String> = {
|
||||
let custom = custom_topics.read();
|
||||
let mut topics: Vec<String> = DEFAULT_TOPICS.iter().map(|s| (*s).to_string()).collect();
|
||||
for t in custom.iter() {
|
||||
if !topics.contains(t) {
|
||||
topics.push(t.clone());
|
||||
}
|
||||
}
|
||||
topics
|
||||
};
|
||||
|
||||
// All available filter categories
|
||||
let categories = [
|
||||
("All", None),
|
||||
("LLM", Some(NewsCategory::Llm)),
|
||||
("Agents", Some(NewsCategory::Agents)),
|
||||
("Privacy", Some(NewsCategory::Privacy)),
|
||||
("Infrastructure", Some(NewsCategory::Infrastructure)),
|
||||
("Open Source", Some(NewsCategory::OpenSource)),
|
||||
];
|
||||
// Fetch trending topics once on mount (no signal deps = runs once).
|
||||
// use_resource handles deduplication and won't re-fetch on re-renders.
|
||||
let trending_resource = use_resource(|| async {
|
||||
match crate::infrastructure::searxng::get_trending_topics().await {
|
||||
Ok(topics) => topics,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch trending topics: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Push a topic to the front of recent searches (deduplicating, capped).
|
||||
// Defined as a closure so it can be called from multiple click handlers.
|
||||
let mut record_search = move |topic: &str| {
|
||||
let mut searches = recent_searches.read().clone();
|
||||
searches.retain(|t| t != topic);
|
||||
searches.insert(0, topic.to_string());
|
||||
searches.truncate(MAX_RECENT_SEARCHES);
|
||||
*recent_searches.write() = searches;
|
||||
};
|
||||
|
||||
// Fetch news reactively when active_topic changes.
|
||||
// use_resource tracks the signal read inside the closure and only
|
||||
// re-fetches when active_topic actually changes -- unlike use_effect
|
||||
// which can re-fire on unrelated re-renders.
|
||||
let search_resource = use_resource(move || {
|
||||
let topic = active_topic.read().clone();
|
||||
async move { crate::infrastructure::searxng::search_topic(topic).await }
|
||||
});
|
||||
|
||||
// Check if an article is selected for split view
|
||||
let has_selection = selected_card.read().is_some();
|
||||
let container_class = if has_selection {
|
||||
"dashboard-split"
|
||||
} else {
|
||||
"dashboard-with-sidebar"
|
||||
};
|
||||
|
||||
// Resolve trending from resource (empty while loading / on error)
|
||||
let trending_topics: Vec<String> = trending_resource
|
||||
.read()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Resolve search state from resource
|
||||
let search_state = search_resource.read();
|
||||
let is_loading = search_state.is_none();
|
||||
let search_error: Option<String> = search_state
|
||||
.as_ref()
|
||||
.and_then(|r| r.as_ref().err().map(|e| format!("Search failed: {e}")));
|
||||
let news_cards: Vec<NewsCard> = match search_state.as_ref() {
|
||||
Some(Ok(c)) => c.clone(),
|
||||
Some(Err(_)) => crate::components::news_card::mock_news(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// Drop the borrow before entering rsx! so signals can be written in handlers
|
||||
drop(search_state);
|
||||
|
||||
rsx! {
|
||||
section { class: "dashboard-page",
|
||||
@@ -42,24 +133,308 @@ pub fn DashboardPage() -> Element {
|
||||
title: "Dashboard".to_string(),
|
||||
subtitle: "AI news and updates".to_string(),
|
||||
}
|
||||
|
||||
// Topic tabs row
|
||||
div { class: "dashboard-filters",
|
||||
for (label , cat) in categories {
|
||||
for topic in &all_topics {
|
||||
{
|
||||
let is_active = *active_filter.read() == cat;
|
||||
let class = if is_active {
|
||||
let is_active = *active_topic.read() == *topic;
|
||||
let class_name = if is_active {
|
||||
"filter-tab filter-tab--active"
|
||||
} else {
|
||||
"filter-tab"
|
||||
};
|
||||
let is_custom = !DEFAULT_TOPICS.contains(&topic.as_str());
|
||||
let topic_click = topic.clone();
|
||||
let topic_remove = topic.clone();
|
||||
rsx! {
|
||||
button { class: "{class}", onclick: move |_| active_filter.set(cat.clone()), "{label}" }
|
||||
div { class: "topic-tab-wrapper",
|
||||
button {
|
||||
class: "{class_name}",
|
||||
onclick: move |_| {
|
||||
record_search(&topic_click);
|
||||
active_topic.set(topic_click.clone());
|
||||
selected_card.set(None);
|
||||
summary.set(None);
|
||||
},
|
||||
"{topic}"
|
||||
}
|
||||
if is_custom {
|
||||
button {
|
||||
class: "topic-remove",
|
||||
onclick: move |_| {
|
||||
let mut topics = custom_topics.read().clone();
|
||||
topics.retain(|t| *t != topic_remove);
|
||||
*custom_topics.write() = topics;
|
||||
// If we removed the active topic, reset
|
||||
if *active_topic.read() == topic_remove {
|
||||
active_topic.set("AI".to_string());
|
||||
}
|
||||
},
|
||||
"x"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add topic button / inline input
|
||||
if *show_add_input.read() {
|
||||
div { class: "topic-input-wrapper",
|
||||
input {
|
||||
class: "topic-input",
|
||||
r#type: "text",
|
||||
placeholder: "Topic name...",
|
||||
value: "{new_topic_text}",
|
||||
oninput: move |e| new_topic_text.set(e.value()),
|
||||
onkeypress: move |e| {
|
||||
if e.key() == Key::Enter {
|
||||
let val = new_topic_text.read().trim().to_string();
|
||||
if !val.is_empty() {
|
||||
let mut topics = custom_topics.read().clone();
|
||||
if !topics.contains(&val) && !DEFAULT_TOPICS.contains(&val.as_str()) {
|
||||
topics.push(val.clone());
|
||||
*custom_topics.write() = topics;
|
||||
record_search(&val);
|
||||
active_topic.set(val);
|
||||
}
|
||||
}
|
||||
new_topic_text.set(String::new());
|
||||
show_add_input.set(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "topic-cancel-btn",
|
||||
onclick: move |_| {
|
||||
show_add_input.set(false);
|
||||
new_topic_text.set(String::new());
|
||||
},
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
button {
|
||||
class: "topic-add-btn",
|
||||
onclick: move |_| show_add_input.set(true),
|
||||
"+"
|
||||
}
|
||||
}
|
||||
|
||||
// Settings toggle
|
||||
button {
|
||||
class: "filter-tab settings-toggle",
|
||||
onclick: move |_| {
|
||||
let currently_shown = *show_settings.read();
|
||||
if !currently_shown {
|
||||
settings_url.set(ollama_url.read().clone());
|
||||
settings_model.set(ollama_model.read().clone());
|
||||
}
|
||||
show_settings.set(!currently_shown);
|
||||
},
|
||||
"Settings"
|
||||
}
|
||||
}
|
||||
div { class: "news-grid",
|
||||
for card in filtered {
|
||||
NewsCardView { key: "{card.title}", card }
|
||||
|
||||
// Settings panel (collapsible)
|
||||
if *show_settings.read() {
|
||||
div { class: "settings-panel",
|
||||
h4 { class: "settings-panel-title", "Ollama Settings" }
|
||||
p { class: "settings-hint",
|
||||
"Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env"
|
||||
}
|
||||
div { class: "settings-field",
|
||||
label { "Ollama URL" }
|
||||
input {
|
||||
class: "settings-input",
|
||||
r#type: "text",
|
||||
placeholder: "Uses OLLAMA_URL from .env",
|
||||
value: "{settings_url}",
|
||||
oninput: move |e| settings_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "settings-field",
|
||||
label { "Model" }
|
||||
input {
|
||||
class: "settings-input",
|
||||
r#type: "text",
|
||||
placeholder: "Uses OLLAMA_MODEL from .env",
|
||||
value: "{settings_model}",
|
||||
oninput: move |e| settings_model.set(e.value()),
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| {
|
||||
*ollama_url.write() = settings_url.read().trim().to_string();
|
||||
*ollama_model.write() = settings_model.read().trim().to_string();
|
||||
show_settings.set(false);
|
||||
},
|
||||
"Save"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading / error state
|
||||
if is_loading {
|
||||
div { class: "dashboard-loading", "Searching..." }
|
||||
}
|
||||
if let Some(ref err) = search_error {
|
||||
div { class: "settings-hint", "{err}" }
|
||||
}
|
||||
|
||||
// Main content area: grid + optional detail panel
|
||||
div { class: "{container_class}",
|
||||
// Left: news grid
|
||||
div { class: if has_selection { "dashboard-left" } else { "dashboard-full-grid" },
|
||||
div { class: if has_selection { "news-grid news-grid--compact" } else { "news-grid" },
|
||||
for card in news_cards.iter() {
|
||||
{
|
||||
let is_selected = selected_card
|
||||
|
||||
// Auto-summarize on card selection
|
||||
.read()
|
||||
// Store context for follow-up chat
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.url == card.url && s.title == card.title);
|
||||
rsx! {
|
||||
NewsCardView {
|
||||
key: "{card.title}-{card.url}",
|
||||
card: card.clone(),
|
||||
selected: is_selected,
|
||||
on_click: move |c: NewsCard| {
|
||||
let snippet = c.content.clone();
|
||||
let article_url = c.url.clone();
|
||||
selected_card.set(Some(c));
|
||||
summary.set(None);
|
||||
chat_messages.set(Vec::new());
|
||||
article_context.set(String::new());
|
||||
|
||||
|
||||
let oll_url = ollama_url.read().clone();
|
||||
let mdl = ollama_model.read().clone();
|
||||
spawn(async move {
|
||||
is_summarizing.set(true);
|
||||
match crate::infrastructure::llm::summarize_article(
|
||||
snippet.clone(),
|
||||
article_url,
|
||||
oll_url,
|
||||
mdl,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(text) => {
|
||||
article_context
|
||||
.set(
|
||||
format!(
|
||||
"Article content:\n{snippet}\n\n\
|
||||
AI Summary:\n{text}",
|
||||
),
|
||||
);
|
||||
summary.set(Some(text));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Summarization failed: {e}");
|
||||
summary.set(Some(format!("Summarization failed: {e}")));
|
||||
}
|
||||
}
|
||||
is_summarizing.set(false);
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right: article detail panel (when card selected)
|
||||
if let Some(ref card) = *selected_card.read() {
|
||||
div { class: "dashboard-right",
|
||||
ArticleDetail {
|
||||
card: card.clone(),
|
||||
on_close: move |_| {
|
||||
selected_card.set(None);
|
||||
summary.set(None);
|
||||
chat_messages.set(Vec::new());
|
||||
},
|
||||
summary: summary.read().clone(),
|
||||
is_summarizing: *is_summarizing.read(),
|
||||
chat_messages: chat_messages.read().clone(),
|
||||
is_chatting: *is_chatting.read(),
|
||||
on_chat_send: move |question: String| {
|
||||
let oll_url = ollama_url.read().clone();
|
||||
let mdl = ollama_model.read().clone();
|
||||
let ctx = article_context.read().clone();
|
||||
|
||||
// Append user message to chat
|
||||
chat_messages
|
||||
|
||||
// Build full message history for Ollama
|
||||
|
||||
.write()
|
||||
.push(FollowUpMessage {
|
||||
role: "user".into(),
|
||||
content: question,
|
||||
});
|
||||
let msgs = {
|
||||
let history = chat_messages.read();
|
||||
let mut all = vec![
|
||||
FollowUpMessage {
|
||||
role: "system".into(),
|
||||
content: format!(
|
||||
"You are a helpful assistant. The user is reading \
|
||||
a news article. Use the following context to answer \
|
||||
their questions. Do NOT comment on the source, \
|
||||
dates, URLs, or formatting.\n\n{ctx}",
|
||||
),
|
||||
},
|
||||
];
|
||||
all.extend(history.iter().cloned());
|
||||
all
|
||||
};
|
||||
spawn(async move {
|
||||
is_chatting.set(true);
|
||||
match crate::infrastructure::llm::chat_followup(msgs, oll_url, mdl).await {
|
||||
Ok(reply) => {
|
||||
chat_messages
|
||||
.write()
|
||||
.push(FollowUpMessage {
|
||||
role: "assistant".into(),
|
||||
content: reply,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Chat failed: {e}");
|
||||
chat_messages
|
||||
.write()
|
||||
.push(FollowUpMessage {
|
||||
role: "assistant".into(),
|
||||
content: format!("Error: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
is_chatting.set(false);
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right: sidebar (when no card selected)
|
||||
if !has_selection {
|
||||
DashboardSidebar {
|
||||
ollama_url: ollama_url.read().clone(),
|
||||
trending: trending_topics.clone(),
|
||||
recent_searches: recent_searches.read().clone(),
|
||||
on_topic_click: move |topic: String| {
|
||||
record_search(&topic);
|
||||
active_topic.set(topic);
|
||||
selected_card.set(None);
|
||||
summary.set(None);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user