feat(chat): added chat interface and connection to ollama (#10)
All checks were successful
CI / Format (push) Successful in 2s
CI / Clippy (push) Successful in 2m13s
CI / Security Audit (push) Successful in 1m37s
CI / Tests (push) Successful in 2m52s
CI / Deploy (push) Successful in 2s

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-02-20 19:40:25 +00:00
parent 4acb4558b7
commit 50237f5377
28 changed files with 3148 additions and 196 deletions

View File

@@ -1,145 +1,336 @@
use crate::components::{
ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar,
};
use crate::infrastructure::chat::{
chat_complete, create_chat_session, delete_chat_session, list_chat_messages,
list_chat_sessions, rename_chat_session, save_chat_message,
};
use crate::infrastructure::ollama::get_ollama_status;
use crate::models::{ChatMessage, ChatRole};
use dioxus::prelude::*;
use crate::components::ChatBubble;
use crate::models::{ChatMessage, ChatRole, ChatSession};
/// ChatGPT-style chat interface with session list and message area.
/// LibreChat-inspired chat interface with MongoDB persistence and SSE streaming.
///
/// Full-height layout: left panel shows session history,
/// right panel shows messages and input bar.
/// Layout: sidebar (session list) | main panel (model selector, messages, input).
/// Messages stream via `EventSource` connected to `/api/chat/stream`.
#[component]
pub fn ChatPage() -> Element {
let sessions = use_signal(mock_sessions);
let mut active_session_id = use_signal(|| "session-1".to_string());
let mut input_text = use_signal(String::new);
// ---- Signals ----
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
let mut messages: Signal<Vec<ChatMessage>> = use_signal(Vec::new);
let mut input_text: Signal<String> = use_signal(String::new);
let mut is_streaming: Signal<bool> = use_signal(|| false);
let mut streaming_content: Signal<String> = use_signal(String::new);
let mut selected_model: Signal<String> = use_signal(String::new);
// Clone data out of signals before entering the rsx! block to avoid
// holding a `Signal::read()` borrow across potential await points.
let sessions_list = sessions.read().clone();
let current_id = active_session_id.read().clone();
let active_session = sessions_list.iter().find(|s| s.id == current_id).cloned();
// ---- Resources ----
// Load sessions list (re-fetches when dependency changes)
let mut sessions_resource =
use_resource(move || async move { list_chat_sessions().await.unwrap_or_default() });
rsx! {
section { class: "chat-page",
div { class: "chat-sidebar-panel",
div { class: "chat-sidebar-header",
h3 { "Conversations" }
button { class: "btn-icon", "+" }
// Load available Ollama models
let models_resource = use_resource(move || async move {
get_ollama_status(String::new())
.await
.map(|s| s.models)
.unwrap_or_default()
});
let sessions = sessions_resource.read().clone().unwrap_or_default();
let available_models = models_resource.read().clone().unwrap_or_default();
// Set default model if not yet chosen
if selected_model.read().is_empty() {
if let Some(first) = available_models.first() {
selected_model.set(first.clone());
}
}
// Load messages when active session changes.
// The signal read MUST happen inside the closure so use_resource
// tracks it as a dependency and re-fetches on change.
let _messages_loader = use_resource(move || {
let session_id = active_session_id.read().clone();
async move {
if let Some(id) = session_id {
match list_chat_messages(id).await {
Ok(msgs) => messages.set(msgs),
Err(e) => tracing::error!("failed to load messages: {e}"),
}
div { class: "chat-session-list",
for session in &sessions_list {
{
let is_active = session.id == current_id;
let class = if is_active {
"chat-session-item chat-session-item--active"
} else {
"chat-session-item"
};
let id = session.id.clone();
rsx! {
button { class: "{class}", onclick: move |_| active_session_id.set(id.clone()),
div { class: "chat-session-title", "{session.title}" }
div { class: "chat-session-date", "{session.created_at}" }
}
}
} else {
messages.set(Vec::new());
}
}
});
// ---- Callbacks ----
// Create new session
let on_new = move |_: ()| {
let model = selected_model.read().clone();
spawn(async move {
match create_chat_session(
"New Chat".to_string(),
"General".to_string(),
"ollama".to_string(),
model,
String::new(),
)
.await
{
Ok(session) => {
active_session_id.set(Some(session.id));
messages.set(Vec::new());
sessions_resource.restart();
}
Err(e) => tracing::error!("failed to create session: {e}"),
}
});
};
// Select session
let on_select = move |id: String| {
active_session_id.set(Some(id));
};
// Rename session
let on_rename = move |(id, new_title): (String, String)| {
spawn(async move {
if let Err(e) = rename_chat_session(id, new_title).await {
tracing::error!("failed to rename: {e}");
}
sessions_resource.restart();
});
};
// Delete session
let on_delete = move |id: String| {
let is_active = active_session_id.read().as_deref() == Some(&id);
spawn(async move {
if let Err(e) = delete_chat_session(id).await {
tracing::error!("failed to delete: {e}");
}
if is_active {
active_session_id.set(None);
messages.set(Vec::new());
}
sessions_resource.restart();
});
};
// Model change
let on_model_change = move |model: String| {
selected_model.set(model);
};
// Send message
let on_send = move |text: String| {
let session_id = active_session_id.read().clone();
let model = selected_model.read().clone();
spawn(async move {
// If no active session, create one first
let sid = if let Some(id) = session_id {
id
} else {
match create_chat_session(
// Use first ~50 chars of message as title
text.chars().take(50).collect::<String>(),
"General".to_string(),
"ollama".to_string(),
model,
String::new(),
)
.await
{
Ok(session) => {
let id = session.id.clone();
active_session_id.set(Some(id.clone()));
sessions_resource.restart();
id
}
Err(e) => {
tracing::error!("failed to create session: {e}");
return;
}
}
};
// Save user message
match save_chat_message(sid.clone(), "user".to_string(), text).await {
Ok(msg) => {
messages.write().push(msg);
}
Err(e) => {
tracing::error!("failed to save message: {e}");
return;
}
}
// Show thinking indicator
is_streaming.set(true);
streaming_content.set(String::new());
// Build message history as JSON for the server
let history: Vec<serde_json::Value> = messages
.read()
.iter()
.map(|m| {
let role = match m.role {
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::System => "system",
};
serde_json::json!({"role": role, "content": m.content})
})
.collect();
let messages_json = serde_json::to_string(&history).unwrap_or_default();
// Non-streaming completion
match chat_complete(sid.clone(), messages_json).await {
Ok(response) => {
// Save assistant message
match save_chat_message(sid, "assistant".to_string(), response).await {
Ok(msg) => {
messages.write().push(msg);
}
Err(e) => tracing::error!("failed to save assistant msg: {e}"),
}
sessions_resource.restart();
}
Err(e) => tracing::error!("chat completion failed: {e}"),
}
is_streaming.set(false);
});
};
// ---- Action bar state ----
let has_messages = !messages.read().is_empty();
let has_assistant_message = messages
.read()
.iter()
.any(|m| m.role == ChatRole::Assistant);
let has_user_message = messages.read().iter().any(|m| m.role == ChatRole::User);
// Copy last assistant response to clipboard
let on_copy = move |_: ()| {
#[cfg(feature = "web")]
{
let last_assistant = messages
.read()
.iter()
.rev()
.find(|m| m.role == ChatRole::Assistant)
.map(|m| m.content.clone());
if let Some(text) = last_assistant {
if let Some(window) = web_sys::window() {
let clipboard = window.navigator().clipboard();
let _ = clipboard.write_text(&text);
}
}
}
};
// Copy full conversation as text to clipboard
let on_share = move |_: ()| {
#[cfg(feature = "web")]
{
let text: String = messages
.read()
.iter()
.filter(|m| m.role != ChatRole::System)
.map(|m| {
let label = match m.role {
ChatRole::User => "You",
ChatRole::Assistant => "Assistant",
ChatRole::System => "System",
};
format!("{label}:\n{}\n", m.content)
})
.collect::<Vec<_>>()
.join("\n");
if let Some(window) = web_sys::window() {
let clipboard = window.navigator().clipboard();
let _ = clipboard.write_text(&text);
}
}
};
// Edit last user message: remove it and place text back in input
let on_edit = move |_: ()| {
let last_user = messages
.read()
.iter()
.rev()
.find(|m| m.role == ChatRole::User)
.map(|m| m.content.clone());
if let Some(text) = last_user {
// Remove the last user message (and any assistant reply after it)
let mut msgs = messages.read().clone();
if let Some(pos) = msgs.iter().rposition(|m| m.role == ChatRole::User) {
msgs.truncate(pos);
messages.set(msgs);
}
input_text.set(text);
}
};
// Scroll to bottom when messages or streaming content changes
let msg_count = messages.read().len();
let stream_len = streaming_content.read().len();
use_effect(move || {
// Track dependencies
let _ = msg_count;
let _ = stream_len;
// Scroll the message list to bottom
#[cfg(feature = "web")]
{
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
if let Some(el) = doc.get_element_by_id("chat-message-list") {
let height = el.scroll_height();
el.set_scroll_top(height);
}
}
}
}
});
rsx! {
section { class: "chat-page",
ChatSidebar {
sessions: sessions,
active_session_id: active_session_id.read().clone(),
on_select: on_select,
on_new: on_new,
on_rename: on_rename,
on_delete: on_delete,
}
div { class: "chat-main-panel",
if let Some(session) = &active_session {
div { class: "chat-messages",
for msg in &session.messages {
ChatBubble { key: "{msg.id}", message: msg.clone() }
}
}
} else {
div { class: "chat-empty",
p { "Select a conversation or start a new one." }
}
ChatModelSelector {
selected_model: selected_model.read().clone(),
available_models: available_models,
on_change: on_model_change,
}
div { class: "chat-input-bar",
button { class: "btn-icon chat-attach-btn", "+" }
input {
class: "chat-input",
r#type: "text",
placeholder: "Type a message...",
value: "{input_text}",
oninput: move |evt: Event<FormData>| {
input_text.set(evt.value());
},
}
button { class: "btn-primary chat-send-btn", "Send" }
ChatMessageList {
messages: messages.read().clone(),
streaming_content: streaming_content.read().clone(),
is_streaming: *is_streaming.read(),
}
ChatActionBar {
on_copy: on_copy,
on_share: on_share,
on_edit: on_edit,
has_messages: has_messages,
has_assistant_message: has_assistant_message,
has_user_message: has_user_message,
}
ChatInputBar {
input_text: input_text,
on_send: on_send,
is_streaming: *is_streaming.read(),
}
}
}
}
}
/// Returns mock chat sessions with sample messages.
fn mock_sessions() -> Vec<ChatSession> {
vec![
ChatSession {
id: "session-1".into(),
title: "RAG Pipeline Setup".into(),
messages: vec![
ChatMessage {
id: "msg-1".into(),
role: ChatRole::User,
content: "How do I set up a RAG pipeline with Ollama?".into(),
attachments: vec![],
timestamp: "10:30".into(),
},
ChatMessage {
id: "msg-2".into(),
role: ChatRole::Assistant,
content: "To set up a RAG pipeline with Ollama, you'll need to: \
1) Install Ollama and pull your preferred model, \
2) Set up a vector database (e.g. ChromaDB), \
3) Create an embedding pipeline for your documents, \
4) Wire the retrieval step into your prompt chain."
.into(),
attachments: vec![],
timestamp: "10:31".into(),
},
],
created_at: "2026-02-18".into(),
},
ChatSession {
id: "session-2".into(),
title: "GDPR Compliance Check".into(),
messages: vec![
ChatMessage {
id: "msg-3".into(),
role: ChatRole::User,
content: "What data does CERTifAI store about users?".into(),
attachments: vec![],
timestamp: "09:15".into(),
},
ChatMessage {
id: "msg-4".into(),
role: ChatRole::Assistant,
content: "CERTifAI stores only the minimum data required: \
email address, session tokens, and usage metrics. \
All data stays on your infrastructure."
.into(),
attachments: vec![],
timestamp: "09:16".into(),
},
],
created_at: "2026-02-17".into(),
},
ChatSession {
id: "session-3".into(),
title: "MCP Server Configuration".into(),
messages: vec![ChatMessage {
id: "msg-5".into(),
role: ChatRole::User,
content: "How do I add a new MCP server?".into(),
attachments: vec![],
timestamp: "14:00".into(),
}],
created_at: "2026-02-16".into(),
},
]
}

View File

@@ -2,6 +2,7 @@ use dioxus::prelude::*;
use dioxus_sdk::storage::use_persistent;
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
use crate::infrastructure::chat::{create_chat_session, save_chat_message};
use crate::infrastructure::llm::FollowUpMessage;
use crate::models::NewsCard;
@@ -50,6 +51,8 @@ pub fn DashboardPage() -> Element {
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);
// MongoDB session ID for persisting News chat (created on first follow-up)
let mut news_session_id: Signal<Option<String>> = use_signal(|| None);
// Recent search history, persisted in localStorage (capped at MAX_RECENT_SEARCHES)
let mut recent_searches =
@@ -310,6 +313,7 @@ pub fn DashboardPage() -> Element {
summary.set(None);
chat_messages.set(Vec::new());
article_context.set(String::new());
news_session_id.set(None);
let oll_url = ollama_url.read().clone();
@@ -358,6 +362,7 @@ pub fn DashboardPage() -> Element {
selected_card.set(None);
summary.set(None);
chat_messages.set(Vec::new());
news_session_id.set(None);
},
summary: summary.read().clone(),
is_summarizing: *is_summarizing.read(),
@@ -367,52 +372,113 @@ pub fn DashboardPage() -> Element {
let oll_url = ollama_url.read().clone();
let mdl = ollama_model.read().clone();
let ctx = article_context.read().clone();
// Capture article info for News session creation
let card_title = selected_card
.read()
.as_ref()
.map(|c| c.title.clone())
.unwrap_or_default();
let card_url = selected_card
.read()
.as_ref()
.map(|c| c.url.clone())
.unwrap_or_default();
// Append user message to chat
chat_messages
// Append user message to local chat
chat_messages.write().push(FollowUpMessage {
role: "user".into(),
content: question.clone(),
});
// Build full message history for Ollama
.write()
.push(FollowUpMessage {
role: "user".into(),
content: question,
});
// Build full message history for Ollama
let system_msg = 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}",
);
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}",
),
},
];
let mut all = vec![FollowUpMessage {
role: "system".into(),
content: system_msg.clone(),
}];
all.extend(history.iter().cloned());
all
};
spawn(async move {
is_chatting.set(true);
match crate::infrastructure::llm::chat_followup(msgs, oll_url, mdl).await {
// Create News session on first follow-up message
let existing_sid = news_session_id.read().clone();
let sid = if let Some(id) = existing_sid {
id
} else {
match create_chat_session(
card_title,
"News".to_string(),
"ollama".to_string(),
mdl.clone(),
card_url,
)
.await
{
Ok(session) => {
let id = session.id.clone();
news_session_id.set(Some(id.clone()));
// Persist system context as first message
let _ = save_chat_message(
id.clone(),
"system".to_string(),
system_msg,
)
.await;
id
}
Err(e) => {
tracing::error!("Failed to create News session: {e}");
String::new()
}
}
};
// Persist user message
if !sid.is_empty() {
let _ = save_chat_message(
sid.clone(),
"user".to_string(),
question,
)
.await;
}
match crate::infrastructure::llm::chat_followup(
msgs, oll_url, mdl,
)
.await
{
Ok(reply) => {
chat_messages
.write()
.push(FollowUpMessage {
role: "assistant".into(),
content: reply,
});
// Persist assistant message
if !sid.is_empty() {
let _ = save_chat_message(
sid,
"assistant".to_string(),
reply.clone(),
)
.await;
}
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}"),
});
chat_messages.write().push(FollowUpMessage {
role: "assistant".into(),
content: format!("Error: {e}"),
});
}
}
is_chatting.set(false);