feat: use librechat instead of own chat (#14)
CI / Format (push) Successful in 2s
CI / Clippy (push) Successful in 2m48s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 4m11s
CI / Deploy (push) Successful in 4s

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-02-24 10:45:41 +00:00
parent d814e22f9d
commit 208450e618
33 changed files with 968 additions and 2124 deletions
-344
View File
@@ -1,344 +0,0 @@
use crate::components::{
ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar,
};
use crate::i18n::{t, Locale};
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::*;
/// LibreChat-inspired chat interface with MongoDB persistence and SSE streaming.
///
/// 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 locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// ---- 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);
// ---- 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() });
// 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}"),
}
} else {
messages.set(Vec::new());
}
}
});
// ---- Callbacks ----
// Create new session
let on_new = move |_: ()| {
let model = selected_model.read().clone();
let new_chat_title = t(l, "chat.new_chat");
spawn(async move {
match create_chat_session(
new_chat_title,
"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 you_label = t(l, "chat.you");
let assistant_label = t(l, "chat.assistant");
let text: String = messages
.read()
.iter()
.filter(|m| m.role != ChatRole::System)
.map(|m| {
let label = match m.role {
ChatRole::User => &you_label,
ChatRole::Assistant => &assistant_label,
// Filtered out above, but required for exhaustive match
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",
ChatModelSelector {
selected_model: selected_model.read().clone(),
available_models: available_models,
on_change: on_model_change,
}
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(),
}
}
}
}
}
-128
View File
@@ -1,128 +0,0 @@
use dioxus::prelude::*;
use crate::components::{FileRow, PageHeader};
use crate::i18n::{t, Locale};
use crate::models::{FileKind, KnowledgeFile};
/// Knowledge Base page with file explorer table and upload controls.
///
/// Displays uploaded documents used for RAG retrieval with their
/// metadata, chunk counts, and management actions.
#[component]
pub fn KnowledgePage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let mut files = use_signal(mock_files);
let mut search_query = use_signal(String::new);
// Filter files by search query (case-insensitive name match)
let query = search_query.read().to_lowercase();
let filtered: Vec<_> = files
.read()
.iter()
.filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query))
.cloned()
.collect();
// Remove a file by ID
let on_delete = move |id: String| {
files.write().retain(|f| f.id != id);
};
rsx! {
section { class: "knowledge-page",
PageHeader {
title: t(l, "knowledge.title"),
subtitle: t(l, "knowledge.subtitle"),
actions: rsx! {
button { class: "btn-primary", {t(l, "common.upload_file")} }
},
}
div { class: "knowledge-toolbar",
input {
class: "form-input knowledge-search",
r#type: "text",
placeholder: t(l, "knowledge.search_placeholder"),
value: "{search_query}",
oninput: move |evt: Event<FormData>| {
search_query.set(evt.value());
},
}
}
div { class: "knowledge-table-wrapper",
table { class: "knowledge-table",
thead {
tr {
th { {t(l, "knowledge.name")} }
th { {t(l, "knowledge.type")} }
th { {t(l, "knowledge.size")} }
th { {t(l, "knowledge.chunks")} }
th { {t(l, "knowledge.uploaded")} }
th { {t(l, "knowledge.actions")} }
}
}
tbody {
for file in filtered {
FileRow { key: "{file.id}", file, on_delete }
}
}
}
}
}
}
}
/// Returns mock knowledge base files.
fn mock_files() -> Vec<KnowledgeFile> {
vec![
KnowledgeFile {
id: "f1".into(),
name: "company-handbook.pdf".into(),
kind: FileKind::Pdf,
size_bytes: 2_450_000,
uploaded_at: "2026-02-15".into(),
chunk_count: 142,
},
KnowledgeFile {
id: "f2".into(),
name: "api-reference.md".into(),
kind: FileKind::Text,
size_bytes: 89_000,
uploaded_at: "2026-02-14".into(),
chunk_count: 34,
},
KnowledgeFile {
id: "f3".into(),
name: "sales-data-q4.csv".into(),
kind: FileKind::Spreadsheet,
size_bytes: 1_200_000,
uploaded_at: "2026-02-12".into(),
chunk_count: 67,
},
KnowledgeFile {
id: "f4".into(),
name: "deployment-guide.pdf".into(),
kind: FileKind::Pdf,
size_bytes: 540_000,
uploaded_at: "2026-02-10".into(),
chunk_count: 28,
},
KnowledgeFile {
id: "f5".into(),
name: "onboarding-checklist.md".into(),
kind: FileKind::Text,
size_bytes: 12_000,
uploaded_at: "2026-02-08".into(),
chunk_count: 8,
},
KnowledgeFile {
id: "f6".into(),
name: "architecture-diagram.png".into(),
kind: FileKind::Image,
size_bytes: 3_800_000,
uploaded_at: "2026-02-05".into(),
chunk_count: 1,
},
]
}
-6
View File
@@ -1,21 +1,15 @@
mod chat;
mod dashboard;
pub mod developer;
mod impressum;
mod knowledge;
mod landing;
pub mod organization;
mod privacy;
mod providers;
mod tools;
pub use chat::*;
pub use dashboard::*;
pub use developer::*;
pub use impressum::*;
pub use knowledge::*;
pub use landing::*;
pub use organization::*;
pub use privacy::*;
pub use providers::*;
pub use tools::*;
-149
View File
@@ -1,149 +0,0 @@
use std::collections::HashMap;
use dioxus::prelude::*;
use crate::components::{PageHeader, ToolCard};
use crate::i18n::{t, Locale};
use crate::models::{McpTool, ToolCategory, ToolStatus};
/// Tools page displaying a grid of MCP tool cards with toggle switches.
///
/// Shows all available MCP tools with their status and allows
/// enabling/disabling them via toggle buttons.
#[component]
pub fn ToolsPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Track which tool IDs have been toggled off/on by the user.
// The canonical tool definitions (including translated names) come
// from `mock_tools(l)` on every render so they react to locale changes.
let mut enabled_overrides = use_signal(HashMap::<String, bool>::new);
// Build the display list: translated names from mock_tools, with
// enabled state merged from user overrides.
let tool_list: Vec<McpTool> = mock_tools(l)
.into_iter()
.map(|mut tool| {
if let Some(&enabled) = enabled_overrides.read().get(&tool.id) {
tool.enabled = enabled;
}
tool
})
.collect();
// Toggle a tool's enabled state by its ID.
// Reads the current state from overrides (or falls back to the default
// enabled value from mock_tools) and flips it.
let on_toggle = move |id: String| {
let defaults = mock_tools(l);
let current = enabled_overrides
.read()
.get(&id)
.copied()
.unwrap_or_else(|| {
defaults
.iter()
.find(|tool| tool.id == id)
.map(|tool| tool.enabled)
.unwrap_or(false)
});
enabled_overrides.write().insert(id, !current);
};
rsx! {
section { class: "tools-page",
PageHeader {
title: t(l, "tools.title"),
subtitle: t(l, "tools.subtitle"),
}
div { class: "tools-grid",
for tool in tool_list {
ToolCard { key: "{tool.id}", tool, on_toggle }
}
}
}
}
}
/// Returns mock MCP tools for the tools grid with translated names.
///
/// # Arguments
///
/// * `l` - The current locale for translating tool names and descriptions
fn mock_tools(l: Locale) -> Vec<McpTool> {
vec![
McpTool {
id: "calculator".into(),
name: t(l, "tools.calculator"),
description: t(l, "tools.calculator_desc"),
category: ToolCategory::Compute,
status: ToolStatus::Active,
enabled: true,
icon: "calculator".into(),
},
McpTool {
id: "tavily".into(),
name: t(l, "tools.tavily"),
description: t(l, "tools.tavily_desc"),
category: ToolCategory::Search,
status: ToolStatus::Active,
enabled: true,
icon: "search".into(),
},
McpTool {
id: "searxng".into(),
name: t(l, "tools.searxng"),
description: t(l, "tools.searxng_desc"),
category: ToolCategory::Search,
status: ToolStatus::Active,
enabled: true,
icon: "globe".into(),
},
McpTool {
id: "file-reader".into(),
name: t(l, "tools.file_reader"),
description: t(l, "tools.file_reader_desc"),
category: ToolCategory::FileSystem,
status: ToolStatus::Active,
enabled: true,
icon: "file".into(),
},
McpTool {
id: "code-exec".into(),
name: t(l, "tools.code_executor"),
description: t(l, "tools.code_executor_desc"),
category: ToolCategory::Code,
status: ToolStatus::Inactive,
enabled: false,
icon: "terminal".into(),
},
McpTool {
id: "web-scraper".into(),
name: t(l, "tools.web_scraper"),
description: t(l, "tools.web_scraper_desc"),
category: ToolCategory::Search,
status: ToolStatus::Active,
enabled: true,
icon: "download".into(),
},
McpTool {
id: "email".into(),
name: t(l, "tools.email_sender"),
description: t(l, "tools.email_sender_desc"),
category: ToolCategory::Communication,
status: ToolStatus::Inactive,
enabled: false,
icon: "mail".into(),
},
McpTool {
id: "git".into(),
name: t(l, "tools.git_ops"),
description: t(l, "tools.git_ops_desc"),
category: ToolCategory::Code,
status: ToolStatus::Active,
enabled: true,
icon: "git".into(),
},
]
}