feat(chat): replace built-in chat with LibreChat SSO integration
All checks were successful
CI / Format (push) Successful in 26s
CI / Clippy (push) Successful in 3m0s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Deploy (push) Has been skipped

Replaces the custom chat page with an external LibreChat instance that
shares Keycloak SSO for seamless auto-login. Removes Tools and Knowledge
Base pages as these are now handled by LibreChat's built-in capabilities.

- Add LibreChat service to docker-compose with Ollama backend config
- Add Keycloak OIDC client (certifai-librechat) with prompt=none for
  silent SSO
- Create librechat.yaml with CERTifAI branding, Ollama endpoint, and
  custom page title/logo
- Change sidebar Chat link to external URL (opens LibreChat in new tab)
- Remove chat page, tools page, knowledge base page and all related
  components (chat_sidebar, chat_bubble, chat_input_bar, etc.)
- Remove tool_card, file_row components and tool/knowledge models
- Remove chat_stream SSE handler (no longer needed)
- Clean up i18n files: remove chat, tools, knowledge sections
- Dashboard article summarization via Ollama remains intact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-02-23 13:37:17 +01:00
parent d814e22f9d
commit 74a225224c
32 changed files with 200 additions and 2118 deletions

View File

@@ -39,6 +39,11 @@ SEARXNG_URL=http://localhost:8888
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b
# ---------------------------------------------------------------------------
# LibreChat (external chat via SSO) [OPTIONAL - default: http://localhost:3080]
# ---------------------------------------------------------------------------
LIBRECHAT_URL=http://localhost:3080
# ---------------------------------------------------------------------------
# LLM Providers (comma-separated list) [OPTIONAL]
# ---------------------------------------------------------------------------

View File

@@ -38,8 +38,6 @@
"dashboard": "Dashboard",
"providers": "Provider",
"chat": "Chat",
"tools": "Werkzeuge",
"knowledge_base": "Wissensdatenbank",
"developer": "Entwickler",
"organization": "Organisation",
"switch_light": "Zum hellen Modus wechseln",
@@ -72,28 +70,6 @@
"trending": "Im Trend",
"recent_searches": "Letzte Suchen"
},
"chat": {
"new_chat": "Neuer Chat",
"general": "Allgemein",
"conversations": "Unterhaltungen",
"news_chats": "Nachrichten-Chats",
"all_chats": "Alle Chats",
"no_conversations": "Noch keine Unterhaltungen",
"type_message": "Nachricht eingeben...",
"model_label": "Modell:",
"no_models": "Keine Modelle verfuegbar",
"send_to_start": "Senden Sie eine Nachricht, um die Unterhaltung zu starten.",
"you": "Sie",
"assistant": "Assistent",
"thinking": "Denkt nach...",
"copy_response": "Letzte Antwort kopieren",
"copy_conversation": "Unterhaltung kopieren",
"edit_last": "Letzte Nachricht bearbeiten",
"just_now": "gerade eben",
"minutes_ago": "vor {n} Min.",
"hours_ago": "vor {n} Std.",
"days_ago": "vor {n} T."
},
"providers": {
"title": "Provider",
"subtitle": "Konfigurieren Sie Ihre LLM- und Embedding-Backends",
@@ -107,37 +83,6 @@
"active_config": "Aktive Konfiguration",
"embedding": "Embedding"
},
"tools": {
"title": "Werkzeuge",
"subtitle": "MCP-Server und Werkzeugintegrationen verwalten",
"calculator": "Taschenrechner",
"calculator_desc": "Mathematische Berechnungen und Einheitenumrechnung",
"tavily": "Tavily-Suche",
"tavily_desc": "KI-optimierte Websuche-API fuer Echtzeitinformationen",
"searxng": "SearXNG",
"searxng_desc": "Datenschutzfreundliche Metasuchmaschine",
"file_reader": "Dateileser",
"file_reader_desc": "Lokale Dateien in verschiedenen Formaten lesen und analysieren",
"code_executor": "Code-Ausfuehrer",
"code_executor_desc": "Isolierte Codeausfuehrung fuer Python und JavaScript",
"web_scraper": "Web-Scraper",
"web_scraper_desc": "Strukturierte Daten aus Webseiten extrahieren",
"email_sender": "E-Mail-Versand",
"email_sender_desc": "E-Mails ueber konfigurierten SMTP-Server versenden",
"git_ops": "Git-Operationen",
"git_ops_desc": "Mit Git-Repositories fuer Versionskontrolle interagieren"
},
"knowledge": {
"title": "Wissensdatenbank",
"subtitle": "Dokumente fuer RAG-Abfragen verwalten",
"search_placeholder": "Dateien suchen...",
"name": "Name",
"type": "Typ",
"size": "Groesse",
"chunks": "Abschnitte",
"uploaded": "Hochgeladen",
"actions": "Aktionen"
},
"developer": {
"agents_title": "Agent Builder",
"agents_desc": "Erstellen und verwalten Sie KI-Agenten mit LangGraph. Erstellen Sie mehrstufige Schlussfolgerungspipelines, werkzeugnutzende Agenten und autonome Workflows.",

View File

@@ -38,8 +38,6 @@
"dashboard": "Dashboard",
"providers": "Providers",
"chat": "Chat",
"tools": "Tools",
"knowledge_base": "Knowledge Base",
"developer": "Developer",
"organization": "Organization",
"switch_light": "Switch to light mode",
@@ -72,28 +70,6 @@
"trending": "Trending",
"recent_searches": "Recent Searches"
},
"chat": {
"new_chat": "New Chat",
"general": "General",
"conversations": "Conversations",
"news_chats": "News Chats",
"all_chats": "All Chats",
"no_conversations": "No conversations yet",
"type_message": "Type a message...",
"model_label": "Model:",
"no_models": "No models available",
"send_to_start": "Send a message to start the conversation.",
"you": "You",
"assistant": "Assistant",
"thinking": "Thinking...",
"copy_response": "Copy last response",
"copy_conversation": "Copy conversation",
"edit_last": "Edit last message",
"just_now": "just now",
"minutes_ago": "{n}m ago",
"hours_ago": "{n}h ago",
"days_ago": "{n}d ago"
},
"providers": {
"title": "Providers",
"subtitle": "Configure your LLM and embedding backends",
@@ -107,37 +83,6 @@
"active_config": "Active Configuration",
"embedding": "Embedding"
},
"tools": {
"title": "Tools",
"subtitle": "Manage MCP servers and tool integrations",
"calculator": "Calculator",
"calculator_desc": "Mathematical computation and unit conversion",
"tavily": "Tavily Search",
"tavily_desc": "AI-optimized web search API for real-time information",
"searxng": "SearXNG",
"searxng_desc": "Privacy-respecting metasearch engine",
"file_reader": "File Reader",
"file_reader_desc": "Read and parse local files in various formats",
"code_executor": "Code Executor",
"code_executor_desc": "Sandboxed code execution for Python and JavaScript",
"web_scraper": "Web Scraper",
"web_scraper_desc": "Extract structured data from web pages",
"email_sender": "Email Sender",
"email_sender_desc": "Send emails via configured SMTP server",
"git_ops": "Git Operations",
"git_ops_desc": "Interact with Git repositories for version control"
},
"knowledge": {
"title": "Knowledge Base",
"subtitle": "Manage documents for RAG retrieval",
"search_placeholder": "Search files...",
"name": "Name",
"type": "Type",
"size": "Size",
"chunks": "Chunks",
"uploaded": "Uploaded",
"actions": "Actions"
},
"developer": {
"agents_title": "Agent Builder",
"agents_desc": "Build and manage AI agents with LangGraph. Create multi-step reasoning pipelines, tool-using agents, and autonomous workflows.",

View File

@@ -38,8 +38,6 @@
"dashboard": "Panel de control",
"providers": "Proveedores",
"chat": "Chat",
"tools": "Herramientas",
"knowledge_base": "Base de conocimiento",
"developer": "Desarrollador",
"organization": "Organizacion",
"switch_light": "Cambiar a modo claro",
@@ -72,28 +70,6 @@
"trending": "Tendencias",
"recent_searches": "Busquedas recientes"
},
"chat": {
"new_chat": "Nuevo chat",
"general": "General",
"conversations": "Conversaciones",
"news_chats": "Chats de noticias",
"all_chats": "Todos los chats",
"no_conversations": "Aun no hay conversaciones",
"type_message": "Escriba un mensaje...",
"model_label": "Modelo:",
"no_models": "No hay modelos disponibles",
"send_to_start": "Envie un mensaje para iniciar la conversacion.",
"you": "Usted",
"assistant": "Asistente",
"thinking": "Pensando...",
"copy_response": "Copiar ultima respuesta",
"copy_conversation": "Copiar conversacion",
"edit_last": "Editar ultimo mensaje",
"just_now": "justo ahora",
"minutes_ago": "hace {n}m",
"hours_ago": "hace {n}h",
"days_ago": "hace {n}d"
},
"providers": {
"title": "Proveedores",
"subtitle": "Configure sus backends de LLM y embeddings",
@@ -107,37 +83,6 @@
"active_config": "Configuracion activa",
"embedding": "Embedding"
},
"tools": {
"title": "Herramientas",
"subtitle": "Gestione servidores MCP e integraciones de herramientas",
"calculator": "Calculadora",
"calculator_desc": "Calculo matematico y conversion de unidades",
"tavily": "Tavily Search",
"tavily_desc": "API de busqueda web optimizada con IA para informacion en tiempo real",
"searxng": "SearXNG",
"searxng_desc": "Motor de metabusqueda que respeta la privacidad",
"file_reader": "Lector de archivos",
"file_reader_desc": "Leer y analizar archivos locales en varios formatos",
"code_executor": "Ejecutor de codigo",
"code_executor_desc": "Ejecucion de codigo en entorno aislado para Python y JavaScript",
"web_scraper": "Web Scraper",
"web_scraper_desc": "Extraer datos estructurados de paginas web",
"email_sender": "Envio de correo",
"email_sender_desc": "Enviar correos electronicos a traves del servidor SMTP configurado",
"git_ops": "Operaciones Git",
"git_ops_desc": "Interactuar con repositorios Git para control de versiones"
},
"knowledge": {
"title": "Base de conocimiento",
"subtitle": "Gestione documentos para recuperacion RAG",
"search_placeholder": "Buscar archivos...",
"name": "Nombre",
"type": "Tipo",
"size": "Tamano",
"chunks": "Fragmentos",
"uploaded": "Subido",
"actions": "Acciones"
},
"developer": {
"agents_title": "Constructor de agentes",
"agents_desc": "Construya y gestione agentes de IA con LangGraph. Cree pipelines de razonamiento de varios pasos, agentes que utilizan herramientas y flujos de trabajo autonomos.",

View File

@@ -38,8 +38,6 @@
"dashboard": "Tableau de bord",
"providers": "Fournisseurs",
"chat": "Chat",
"tools": "Outils",
"knowledge_base": "Base de connaissances",
"developer": "Developpeur",
"organization": "Organisation",
"switch_light": "Passer en mode clair",
@@ -72,28 +70,6 @@
"trending": "Tendances",
"recent_searches": "Recherches recentes"
},
"chat": {
"new_chat": "Nouvelle conversation",
"general": "General",
"conversations": "Conversations",
"news_chats": "Conversations actualites",
"all_chats": "Toutes les conversations",
"no_conversations": "Aucune conversation pour le moment",
"type_message": "Saisissez un message...",
"model_label": "Modele :",
"no_models": "Aucun modele disponible",
"send_to_start": "Envoyez un message pour demarrer la conversation.",
"you": "Vous",
"assistant": "Assistant",
"thinking": "Reflexion en cours...",
"copy_response": "Copier la derniere reponse",
"copy_conversation": "Copier la conversation",
"edit_last": "Modifier le dernier message",
"just_now": "a l'instant",
"minutes_ago": "il y a {n} min",
"hours_ago": "il y a {n} h",
"days_ago": "il y a {n} j"
},
"providers": {
"title": "Fournisseurs",
"subtitle": "Configurez vos backends LLM et d'embeddings",
@@ -107,37 +83,6 @@
"active_config": "Configuration active",
"embedding": "Embedding"
},
"tools": {
"title": "Outils",
"subtitle": "Gerez les serveurs MCP et les integrations d'outils",
"calculator": "Calculatrice",
"calculator_desc": "Calcul mathematique et conversion d'unites",
"tavily": "Recherche Tavily",
"tavily_desc": "API de recherche web optimisee par IA pour des informations en temps reel",
"searxng": "SearXNG",
"searxng_desc": "Metamoteur de recherche respectueux de la vie privee",
"file_reader": "Lecteur de fichiers",
"file_reader_desc": "Lire et analyser des fichiers locaux dans divers formats",
"code_executor": "Executeur de code",
"code_executor_desc": "Execution de code en bac a sable pour Python et JavaScript",
"web_scraper": "Extracteur web",
"web_scraper_desc": "Extraire des donnees structurees a partir de pages web",
"email_sender": "Envoi d'e-mails",
"email_sender_desc": "Envoyer des e-mails via le serveur SMTP configure",
"git_ops": "Operations Git",
"git_ops_desc": "Interagir avec les depots Git pour le controle de version"
},
"knowledge": {
"title": "Base de connaissances",
"subtitle": "Gerez les documents pour la recuperation RAG",
"search_placeholder": "Rechercher des fichiers...",
"name": "Nom",
"type": "Type",
"size": "Taille",
"chunks": "Segments",
"uploaded": "Importe",
"actions": "Actions"
},
"developer": {
"agents_title": "Constructeur d'agents",
"agents_desc": "Construisez et gerez des agents IA avec LangGraph. Creez des pipelines de raisonnement multi-etapes, des agents utilisant des outils et des flux de travail autonomes.",

View File

@@ -38,8 +38,6 @@
"dashboard": "Painel",
"providers": "Fornecedores",
"chat": "Chat",
"tools": "Ferramentas",
"knowledge_base": "Base de Conhecimento",
"developer": "Programador",
"organization": "Organizacao",
"switch_light": "Mudar para modo claro",
@@ -72,28 +70,6 @@
"trending": "Em destaque",
"recent_searches": "Pesquisas recentes"
},
"chat": {
"new_chat": "Nova conversa",
"general": "Geral",
"conversations": "Conversas",
"news_chats": "Conversas de noticias",
"all_chats": "Todas as conversas",
"no_conversations": "Ainda sem conversas",
"type_message": "Escreva uma mensagem...",
"model_label": "Modelo:",
"no_models": "Nenhum modelo disponivel",
"send_to_start": "Envie uma mensagem para iniciar a conversa.",
"you": "Voce",
"assistant": "Assistente",
"thinking": "A pensar...",
"copy_response": "Copiar ultima resposta",
"copy_conversation": "Copiar conversa",
"edit_last": "Editar ultima mensagem",
"just_now": "agora mesmo",
"minutes_ago": "ha {n}m",
"hours_ago": "ha {n}h",
"days_ago": "ha {n}d"
},
"providers": {
"title": "Fornecedores",
"subtitle": "Configure os seus backends de LLM e embeddings",
@@ -107,37 +83,6 @@
"active_config": "Configuracao Ativa",
"embedding": "Embedding"
},
"tools": {
"title": "Ferramentas",
"subtitle": "Gerir servidores MCP e integracoes de ferramentas",
"calculator": "Calculadora",
"calculator_desc": "Calculo matematico e conversao de unidades",
"tavily": "Pesquisa Tavily",
"tavily_desc": "API de pesquisa web otimizada por IA para informacao em tempo real",
"searxng": "SearXNG",
"searxng_desc": "Motor de metapesquisa que respeita a privacidade",
"file_reader": "Leitor de Ficheiros",
"file_reader_desc": "Ler e analisar ficheiros locais em varios formatos",
"code_executor": "Executor de Codigo",
"code_executor_desc": "Execucao de codigo em sandbox para Python e JavaScript",
"web_scraper": "Web Scraper",
"web_scraper_desc": "Extrair dados estruturados de paginas web",
"email_sender": "Envio de Email",
"email_sender_desc": "Enviar emails atraves do servidor SMTP configurado",
"git_ops": "Operacoes Git",
"git_ops_desc": "Interagir com repositorios Git para controlo de versoes"
},
"knowledge": {
"title": "Base de Conhecimento",
"subtitle": "Gerir documentos para recuperacao RAG",
"search_placeholder": "Pesquisar ficheiros...",
"name": "Nome",
"type": "Tipo",
"size": "Tamanho",
"chunks": "Fragmentos",
"uploaded": "Carregado",
"actions": "Acoes"
},
"developer": {
"agents_title": "Construtor de Agentes",
"agents_desc": "Construa e gira agentes de IA com LangGraph. Crie pipelines de raciocinio multi-etapa, agentes com ferramentas e fluxos de trabalho autonomos.",

View File

@@ -40,4 +40,45 @@ services:
environment:
- SEARXNG_BASE_URL=http://localhost:8888
volumes:
- ./searxng:/etc/searxng:rw
- ./searxng:/etc/searxng:rw
librechat:
image: ghcr.io/danny-avila/librechat:latest
container_name: certifai-librechat
restart: unless-stopped
ports:
- "3080:3080"
depends_on:
keycloak:
condition: service_healthy
mongo:
condition: service_started
environment:
# MongoDB (shared instance, separate database)
MONGO_URI: mongodb://root:example@mongo:27017/librechat?authSource=admin
# Keycloak OIDC SSO
OPENID_ISSUER: http://localhost:8080/realms/certifai
OPENID_CLIENT_ID: certifai-librechat
OPENID_CLIENT_SECRET: certifai-librechat-secret
OPENID_CALLBACK_URL: /oauth/openid/callback
OPENID_SCOPE: openid profile email
OPENID_BUTTON_LABEL: Login with CERTifAI
OPENID_AUTH_EXTRA_PARAMS: prompt=none
# Disable local auth (SSO only)
ALLOW_EMAIL_LOGIN: "false"
ALLOW_REGISTRATION: "false"
ALLOW_SOCIAL_LOGIN: "true"
ALLOW_SOCIAL_REGISTRATION: "true"
# App settings
APP_TITLE: CERTifAI Chat
CUSTOM_FOOTER: CERTifAI - Sovereign GenAI Infrastructure
HOST: 0.0.0.0
PORT: "3080"
NO_INDEX: "true"
volumes:
- ./librechat/librechat.yaml:/app/librechat.yaml:ro
- ./librechat/logo.svg:/app/client/public/assets/logo.svg:ro
- librechat-data:/app/data
volumes:
librechat-data:

View File

@@ -78,6 +78,39 @@
"optionalClientScopes": [
"offline_access"
]
},
{
"clientId": "certifai-librechat",
"name": "CERTifAI Chat",
"description": "LibreChat OIDC client for CERTifAI",
"enabled": true,
"publicClient": false,
"directAccessGrantsEnabled": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"serviceAccountsEnabled": false,
"protocol": "openid-connect",
"secret": "certifai-librechat-secret",
"rootUrl": "http://localhost:3080",
"baseUrl": "http://localhost:3080",
"redirectUris": [
"http://localhost:3080/*"
],
"webOrigins": [
"http://localhost:3080",
"http://localhost:8000"
],
"attributes": {
"post.logout.redirect.uris": "http://localhost:3080"
},
"defaultClientScopes": [
"openid",
"profile",
"email"
],
"optionalClientScopes": [
"offline_access"
]
}
],
"clientScopes": [

35
librechat/librechat.yaml Normal file
View File

@@ -0,0 +1,35 @@
# CERTifAI LibreChat Configuration
# Ollama backend for self-hosted LLM inference.
version: 1.2.1
cache: true
registration:
socialLogins:
- openid
interface:
privacyPolicy:
externalUrl: http://localhost:8000/privacy
termsOfService:
externalUrl: http://localhost:8000/impressum
endpointsMenu: true
modelSelect: true
parameters: true
endpoints:
ollama:
titleModel: "current_model"
# Use the Docker host network alias when running inside compose.
# Override OLLAMA_URL in .env for external Ollama instances.
url: "http://host.docker.internal:11434"
models:
fetch: true
summarize: true
forcePrompt: false
dropParams:
- stop
- user
- frequency_penalty
- presence_penalty
modelDisplayLabel: "CERTifAI Ollama"

25
librechat/logo.svg Normal file
View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- Shield body -->
<path d="M32 4L8 16v16c0 14.4 10.24 27.2 24 32 13.76-4.8 24-17.6 24-32V16L32 4z"
fill="#4B3FE0" fill-opacity="0.12" stroke="#4B3FE0" stroke-width="2"
stroke-linejoin="round"/>
<!-- Inner shield highlight -->
<path d="M32 10L14 19v11c0 11.6 7.68 22 18 26 10.32-4 18-14.4 18-26V19L32 10z"
fill="none" stroke="#4B3FE0" stroke-width="1" stroke-opacity="0.3"
stroke-linejoin="round"/>
<!-- Neural network nodes -->
<circle cx="32" cy="24" r="3.5" fill="#38B2AC"/>
<circle cx="22" cy="36" r="3" fill="#38B2AC"/>
<circle cx="42" cy="36" r="3" fill="#38B2AC"/>
<circle cx="27" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
<circle cx="37" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
<!-- Neural network edges -->
<line x1="32" y1="24" x2="22" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
<line x1="32" y1="24" x2="42" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
<line x1="22" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<line x1="22" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<line x1="42" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<line x1="42" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<!-- Cross edge for connectivity -->
<line x1="22" y1="36" x2="42" y2="36" stroke="#38B2AC" stroke-width="0.8" stroke-opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -22,12 +22,6 @@ pub enum Route {
DashboardPage {},
#[route("/providers")]
ProvidersPage {},
#[route("/chat")]
ChatPage {},
#[route("/tools")]
ToolsPage {},
#[route("/knowledge")]
KnowledgePage {},
#[layout(DeveloperShell)]
#[route("/developer/agents")]

View File

@@ -1,69 +0,0 @@
use crate::i18n::{t, Locale};
use dioxus::prelude::*;
use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes};
/// Action bar displayed above the chat input with copy, share, and edit buttons.
///
/// Only visible when there is at least one message in the conversation.
///
/// # Arguments
///
/// * `on_copy` - Copies the last assistant response to the clipboard
/// * `on_share` - Copies the full conversation as text to the clipboard
/// * `on_edit` - Places the last user message back in the input for editing
/// * `has_messages` - Whether any messages exist (hides the bar when empty)
/// * `has_assistant_message` - Whether an assistant message exists (disables copy if not)
/// * `has_user_message` - Whether a user message exists (disables edit if not)
#[component]
pub fn ChatActionBar(
on_copy: EventHandler<()>,
on_share: EventHandler<()>,
on_edit: EventHandler<()>,
has_messages: bool,
has_assistant_message: bool,
has_user_message: bool,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
if !has_messages {
return rsx! {};
}
rsx! {
div { class: "chat-action-bar",
button {
class: "chat-action-btn",
disabled: !has_assistant_message,
title: "{t(l, \"chat.copy_response\")}",
onclick: move |_| on_copy.call(()),
dioxus_free_icons::Icon {
icon: FaCopy,
width: 14, height: 14,
}
span { class: "chat-action-label", "{t(l, \"common.copy\")}" }
}
button {
class: "chat-action-btn",
title: "{t(l, \"chat.copy_conversation\")}",
onclick: move |_| on_share.call(()),
dioxus_free_icons::Icon {
icon: FaShareNodes,
width: 14, height: 14,
}
span { class: "chat-action-label", "{t(l, \"common.share\")}" }
}
button {
class: "chat-action-btn",
disabled: !has_user_message,
title: "{t(l, \"chat.edit_last\")}",
onclick: move |_| on_edit.call(()),
dioxus_free_icons::Icon {
icon: FaPenToSquare,
width: 14, height: 14,
}
span { class: "chat-action-label", "{t(l, \"common.edit\")}" }
}
}
}
}

View File

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

View File

@@ -1,73 +0,0 @@
use crate::i18n::{t, Locale};
use dioxus::prelude::*;
/// Chat input bar with a textarea and send button.
///
/// Enter sends the message; Shift+Enter inserts a newline.
/// The input is disabled during streaming.
///
/// # Arguments
///
/// * `input_text` - Two-way bound input text signal
/// * `on_send` - Callback fired with the message text when sent
/// * `is_streaming` - Whether to disable the input (streaming in progress)
#[component]
pub fn ChatInputBar(
input_text: Signal<String>,
on_send: EventHandler<String>,
is_streaming: bool,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let mut input = input_text;
rsx! {
div { class: "chat-input-bar",
textarea {
class: "chat-input",
placeholder: "{t(l, \"chat.type_message\")}",
disabled: is_streaming,
rows: "1",
value: "{input}",
oninput: move |e: Event<FormData>| {
input.set(e.value());
},
onkeypress: move |e: Event<KeyboardData>| {
// Enter sends, Shift+Enter adds newline
if e.key() == Key::Enter && !e.modifiers().shift() {
e.prevent_default();
let text = input.read().trim().to_string();
if !text.is_empty() {
on_send.call(text);
input.set(String::new());
}
}
},
}
button {
class: "btn-primary chat-send-btn",
disabled: is_streaming || input.read().trim().is_empty(),
onclick: move |_| {
let text = input.read().trim().to_string();
if !text.is_empty() {
on_send.call(text);
input.set(String::new());
}
},
if is_streaming {
// Stop icon during streaming
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaStop,
width: 16, height: 16,
}
} else {
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaPaperPlane,
width: 16, height: 16,
}
}
}
}
}
}

View File

@@ -1,42 +0,0 @@
use crate::components::{ChatBubble, StreamingBubble};
use crate::i18n::{t, Locale};
use crate::models::ChatMessage;
use dioxus::prelude::*;
/// Scrollable message list that renders all messages in a chat session.
///
/// Auto-scrolls to the bottom when new messages arrive or during streaming.
/// Shows a streaming bubble with a blinking cursor when `is_streaming` is true.
///
/// # Arguments
///
/// * `messages` - All loaded messages for the current session
/// * `streaming_content` - Accumulated content from the SSE stream
/// * `is_streaming` - Whether a response is currently streaming
#[component]
pub fn ChatMessageList(
messages: Vec<ChatMessage>,
streaming_content: String,
is_streaming: bool,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
div {
class: "chat-message-list",
id: "chat-message-list",
if messages.is_empty() && !is_streaming {
div { class: "chat-empty",
p { "{t(l, \"chat.send_to_start\")}" }
}
}
for msg in &messages {
ChatBubble { key: "{msg.id}", message: msg.clone() }
}
if is_streaming {
StreamingBubble { content: streaming_content }
}
}
}
}

View File

@@ -1,50 +0,0 @@
use crate::i18n::{t, Locale};
use dioxus::prelude::*;
/// Dropdown bar for selecting the LLM model for the current chat session.
///
/// Displays the currently selected model and a list of available models
/// from the Ollama instance. Fires `on_change` when the user selects
/// a different model.
///
/// # Arguments
///
/// * `selected_model` - The currently active model ID
/// * `available_models` - List of model names from Ollama
/// * `on_change` - Callback fired with the new model name
#[component]
pub fn ChatModelSelector(
selected_model: String,
available_models: Vec<String>,
on_change: EventHandler<String>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
div { class: "chat-model-bar",
label { class: "chat-model-label",
"{t(l, \"chat.model_label\")}"
}
select {
class: "chat-model-select",
value: "{selected_model}",
onchange: move |e: Event<FormData>| {
on_change.call(e.value());
},
for model in &available_models {
option {
value: "{model}",
selected: *model == selected_model,
"{model}"
}
}
if available_models.is_empty() {
option { disabled: true,
"{t(l, \"chat.no_models\")}"
}
}
}
}
}
}

View File

@@ -1,258 +0,0 @@
use crate::i18n::{t, tw, Locale};
use crate::models::{ChatNamespace, ChatSession};
use dioxus::prelude::*;
/// Chat sidebar displaying grouped session list with actions.
///
/// Sessions are split into "News Chats" and "General" sections.
/// Each session item shows the title and relative date, with
/// rename and delete actions on hover.
///
/// # Arguments
///
/// * `sessions` - All chat sessions for the user
/// * `active_session_id` - Currently selected session ID (highlighted)
/// * `on_select` - Callback when a session is clicked
/// * `on_new` - Callback to create a new chat session
/// * `on_rename` - Callback with `(session_id, new_title)`
/// * `on_delete` - Callback with `session_id`
#[component]
pub fn ChatSidebar(
sessions: Vec<ChatSession>,
active_session_id: Option<String>,
on_select: EventHandler<String>,
on_new: EventHandler<()>,
on_rename: EventHandler<(String, String)>,
on_delete: EventHandler<String>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Split sessions by namespace
let news_sessions: Vec<&ChatSession> = sessions
.iter()
.filter(|s| s.namespace == ChatNamespace::News)
.collect();
let general_sessions: Vec<&ChatSession> = sessions
.iter()
.filter(|s| s.namespace == ChatNamespace::General)
.collect();
// Signal for inline rename state: Option<(session_id, current_value)>
let rename_state: Signal<Option<(String, String)>> = use_signal(|| None);
rsx! {
div { class: "chat-sidebar-panel",
div { class: "chat-sidebar-header",
h3 { "{t(l, \"chat.conversations\")}" }
button {
class: "btn-icon",
title: "{t(l, \"chat.new_chat\")}",
onclick: move |_| on_new.call(()),
"+"
}
}
div { class: "chat-session-list",
// News Chats section
if !news_sessions.is_empty() {
div { class: "chat-namespace-header",
"{t(l, \"chat.news_chats\")}"
}
for session in &news_sessions {
SessionItem {
session: (*session).clone(),
is_active: active_session_id.as_deref() == Some(&session.id),
rename_state: rename_state,
on_select: on_select,
on_rename: on_rename,
on_delete: on_delete,
}
}
}
// General section
div { class: "chat-namespace-header",
if news_sessions.is_empty() {
"{t(l, \"chat.all_chats\")}"
} else {
"{t(l, \"chat.general\")}"
}
}
if general_sessions.is_empty() {
p { class: "chat-empty-hint",
"{t(l, \"chat.no_conversations\")}"
}
}
for session in &general_sessions {
SessionItem {
session: (*session).clone(),
is_active: active_session_id.as_deref() == Some(&session.id),
rename_state: rename_state,
on_select: on_select,
on_rename: on_rename,
on_delete: on_delete,
}
}
}
}
}
}
/// Individual session item component. Handles rename inline editing.
#[component]
fn SessionItem(
session: ChatSession,
is_active: bool,
rename_state: Signal<Option<(String, String)>>,
on_select: EventHandler<String>,
on_rename: EventHandler<(String, String)>,
on_delete: EventHandler<String>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let mut rename_sig = rename_state;
let item_class = if is_active {
"chat-session-item chat-session-item--active"
} else {
"chat-session-item"
};
let is_renaming = rename_sig
.read()
.as_ref()
.is_some_and(|(id, _)| id == &session.id);
let session_id = session.id.clone();
let session_title = session.title.clone();
let date_display = format_relative_date(&session.updated_at, l);
if is_renaming {
let rename_value = rename_sig
.read()
.as_ref()
.map(|(_, v)| v.clone())
.unwrap_or_default();
let sid = session_id.clone();
rsx! {
div { class: "{item_class}",
input {
class: "chat-session-rename-input",
r#type: "text",
value: "{rename_value}",
autofocus: true,
oninput: move |e: Event<FormData>| {
let val = e.value();
let id = sid.clone();
rename_sig.set(Some((id, val)));
},
onkeypress: move |e: Event<KeyboardData>| {
if e.key() == Key::Enter {
if let Some((id, val)) = rename_sig.read().clone() {
if !val.trim().is_empty() {
on_rename.call((id, val));
}
}
rename_sig.set(None);
} else if e.key() == Key::Escape {
rename_sig.set(None);
}
},
onfocusout: move |_| {
if let Some((ref id, ref val)) = *rename_sig.read() {
if !val.trim().is_empty() {
on_rename.call((id.clone(), val.clone()));
}
}
rename_sig.set(None);
},
}
}
}
} else {
let sid_select = session_id.clone();
let sid_delete = session_id.clone();
let sid_rename = session_id.clone();
let title_for_rename = session_title.clone();
rsx! {
div {
class: "{item_class}",
onclick: move |_| on_select.call(sid_select.clone()),
div { class: "chat-session-info",
span { class: "chat-session-title", "{session_title}" }
span { class: "chat-session-date", "{date_display}" }
}
div { class: "chat-session-actions",
button {
class: "btn-icon-sm",
title: "{t(l, \"common.rename\")}",
onclick: move |e: Event<MouseData>| {
e.stop_propagation();
rename_sig.set(Some((
sid_rename.clone(),
title_for_rename.clone(),
)));
},
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaPen,
width: 12, height: 12,
}
}
button {
class: "btn-icon-sm btn-icon-danger",
title: "{t(l, \"common.delete\")}",
onclick: move |e: Event<MouseData>| {
e.stop_propagation();
on_delete.call(sid_delete.clone());
},
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaTrash,
width: 12, height: 12,
}
}
}
}
}
}
}
/// Format an ISO 8601 timestamp as a relative date string.
///
/// # Arguments
///
/// * `iso` - ISO 8601 timestamp string
/// * `locale` - The locale to use for translated time labels
fn format_relative_date(iso: &str, locale: Locale) -> String {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) {
let now = chrono::Utc::now();
let diff = now.signed_duration_since(dt);
if diff.num_minutes() < 1 {
t(locale, "chat.just_now")
} else if diff.num_hours() < 1 {
tw(
locale,
"chat.minutes_ago",
&[("n", &diff.num_minutes().to_string())],
)
} else if diff.num_hours() < 24 {
tw(
locale,
"chat.hours_ago",
&[("n", &diff.num_hours().to_string())],
)
} else if diff.num_days() < 7 {
tw(
locale,
"chat.days_ago",
&[("n", &diff.num_days().to_string())],
)
} else {
dt.format("%b %d").to_string()
}
} else {
iso.to_string()
}
}

View File

@@ -1,59 +0,0 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::models::KnowledgeFile;
/// Renders a table row for a knowledge base file.
///
/// # Arguments
///
/// * `file` - The knowledge file data to render
/// * `on_delete` - Callback fired when the delete button is clicked
#[component]
pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler<String>) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Format file size for human readability (Python devs: similar to humanize.naturalsize)
let size_display = format_size(file.size_bytes);
rsx! {
tr { class: "file-row",
td { class: "file-row-name",
span { class: "file-row-icon", "{file.kind.icon()}" }
"{file.name}"
}
td { "{file.kind.label()}" }
td { "{size_display}" }
td { "{file.chunk_count} {t(l, \"common.chunks\")}" }
td { "{file.uploaded_at}" }
td {
button {
class: "btn-icon btn-danger",
onclick: {
let id = file.id.clone();
move |_| on_delete.call(id.clone())
},
"{t(l, \"common.delete\")}"
}
}
}
}
}
/// Formats a byte count into a human-readable string (e.g. "1.2 MB").
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}

View File

@@ -1,14 +1,7 @@
mod app_shell;
mod article_detail;
mod card;
mod chat_action_bar;
mod chat_bubble;
mod chat_input_bar;
mod chat_message_list;
mod chat_model_selector;
mod chat_sidebar;
mod dashboard_sidebar;
mod file_row;
mod login;
mod member_row;
pub mod news_card;
@@ -16,23 +9,14 @@ mod page_header;
mod pricing_card;
pub mod sidebar;
pub mod sub_nav;
mod tool_card;
pub use app_shell::*;
pub use article_detail::*;
pub use card::*;
pub use chat_action_bar::*;
pub use chat_bubble::*;
pub use chat_input_bar::*;
pub use chat_message_list::*;
pub use chat_model_selector::*;
pub use chat_sidebar::*;
pub use dashboard_sidebar::*;
pub use file_row::*;
pub use login::*;
pub use member_row::*;
pub use news_card::*;
pub use page_header::*;
pub use pricing_card::*;
pub use sub_nav::*;
pub use tool_card::*;

View File

@@ -1,13 +1,21 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
BsGlobe2, BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill,
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2,
BsGrid, BsHouseDoor, BsMoonFill, BsSunFill,
};
use dioxus_free_icons::Icon;
use crate::i18n::{t, Locale};
use crate::Route;
/// Destination for a sidebar link: either an internal route or an external URL.
enum NavTarget {
/// Internal Dioxus route (rendered as `Link { to: route }`).
Internal(Route),
/// External URL opened in a new tab (rendered as `<a href>`).
External(&'static str),
}
/// Navigation entry for the sidebar.
///
/// `key` is a stable identifier used for active-route detection and never
@@ -15,7 +23,7 @@ use crate::Route;
struct NavItem {
key: &'static str,
label: String,
route: Route,
target: NavTarget,
/// Bootstrap icon element rendered beside the label.
icon: Element,
}
@@ -45,43 +53,32 @@ pub fn Sidebar(
NavItem {
key: "dashboard",
label: t(locale_val, "nav.dashboard"),
route: Route::DashboardPage {},
target: NavTarget::Internal(Route::DashboardPage {}),
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
},
NavItem {
key: "providers",
label: t(locale_val, "nav.providers"),
route: Route::ProvidersPage {},
target: NavTarget::Internal(Route::ProvidersPage {}),
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
},
NavItem {
key: "chat",
label: t(locale_val, "nav.chat"),
route: Route::ChatPage {},
// Opens LibreChat in a new tab; SSO via shared Keycloak realm.
target: NavTarget::External("http://localhost:3080"),
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
},
NavItem {
key: "tools",
label: t(locale_val, "nav.tools"),
route: Route::ToolsPage {},
icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } },
},
NavItem {
key: "knowledge_base",
label: t(locale_val, "nav.knowledge_base"),
route: Route::KnowledgePage {},
icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } },
},
NavItem {
key: "developer",
label: t(locale_val, "nav.developer"),
route: Route::AgentsPage {},
target: NavTarget::Internal(Route::AgentsPage {}),
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
},
NavItem {
key: "organization",
label: t(locale_val, "nav.organization"),
route: Route::OrgPricingPage {},
target: NavTarget::Internal(Route::OrgPricingPage {}),
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
},
];
@@ -100,25 +97,45 @@ pub fn Sidebar(
nav { class: "sidebar-nav",
for item in nav_items {
{
// Active detection for nested routes: highlight the parent nav
// item when any child route within the nested shell is active.
let is_active = match &current_route {
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
item.key == "developer"
match &item.target {
NavTarget::Internal(route) => {
// Active detection for nested routes: highlight the parent
// nav item when any child route within the nested shell
// is active.
let is_active = match &current_route {
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
item.key == "developer"
}
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
item.key == "organization"
}
_ => *route == current_route,
};
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
let route = route.clone();
rsx! {
Link {
to: route,
class: cls,
onclick: move |_| on_nav.call(()),
{item.icon}
span { "{item.label}" }
}
}
}
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
item.key == "organization"
}
_ => item.route == current_route,
};
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
rsx! {
Link {
to: item.route,
class: cls,
onclick: move |_| on_nav.call(()),
{item.icon}
span { "{item.label}" }
NavTarget::External(url) => {
let url = *url;
rsx! {
a {
href: url,
target: "_blank",
rel: "noopener noreferrer",
class: "sidebar-link",
onclick: move |_| on_nav.call(()),
{item.icon}
span { "{item.label}" }
}
}
}
}
}

View File

@@ -1,49 +0,0 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::models::McpTool;
/// Renders an MCP tool card with name, description, status indicator, and toggle.
///
/// # Arguments
///
/// * `tool` - The MCP tool data to render
/// * `on_toggle` - Callback fired when the enable/disable toggle is clicked
#[component]
pub fn ToolCard(tool: McpTool, on_toggle: EventHandler<String>) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let status_class = format!("tool-status tool-status--{}", tool.status.css_class());
let toggle_class = if tool.enabled {
"tool-toggle tool-toggle--on"
} else {
"tool-toggle tool-toggle--off"
};
rsx! {
div { class: "tool-card",
div { class: "tool-card-header",
div { class: "tool-card-icon", "\u{2699}" }
span { class: "{status_class}", "" }
}
h3 { class: "tool-card-name", "{tool.name}" }
p { class: "tool-card-desc", "{tool.description}" }
div { class: "tool-card-footer",
span { class: "tool-card-category", "{tool.category.label()}" }
button {
class: "{toggle_class}",
onclick: {
let id = tool.id.clone();
move |_| on_toggle.call(id.clone())
},
if tool.enabled {
"{t(l, \"common.on\")}"
} else {
"{t(l, \"common.off\")}"
}
}
}
}
}
}

View File

@@ -174,8 +174,8 @@ pub fn t(locale: Locale, key: &str) -> String {
///
/// ```
/// use dashboard::i18n::{tw, Locale};
/// let text = tw(Locale::En, "chat.minutes_ago", &[("n", "5")]);
/// assert_eq!(text, "5m ago");
/// let text = tw(Locale::En, "common.up_to_seats", &[("n", "5")]);
/// assert_eq!(text, "Up to 5 seats");
/// ```
pub fn tw(locale: Locale, key: &str, vars: &[(&str, &str)]) -> String {
let mut result = t(locale, key);
@@ -221,8 +221,8 @@ mod tests {
#[test]
fn variable_substitution() {
let result = tw(Locale::En, "chat.minutes_ago", &[("n", "5")]);
assert_eq!(result, "5m ago");
let result = tw(Locale::En, "common.up_to_seats", &[("n", "5")]);
assert_eq!(result, "Up to 5 seats");
}
#[test]

View File

@@ -1,266 +0,0 @@
//! SSE streaming endpoint for chat completions.
//!
//! Exposes `GET /api/chat/stream?session_id=<id>` which:
//! 1. Authenticates the user via tower-sessions
//! 2. Loads the session and its messages from MongoDB
//! 3. Streams LLM tokens as SSE events to the frontend
//! 4. Persists the complete assistant message on finish
use axum::{
extract::Query,
response::{
sse::{Event, KeepAlive, Sse},
IntoResponse, Response,
},
Extension,
};
use futures::stream::Stream;
use reqwest::StatusCode;
use serde::Deserialize;
use tower_sessions::Session;
use super::{
auth::LOGGED_IN_USER_SESS_KEY,
chat::{doc_to_chat_message, doc_to_chat_session},
provider_client::{send_chat_request, ProviderMessage},
server_state::ServerState,
state::UserStateInner,
};
use crate::models::{ChatMessage, ChatRole};
/// Query parameters for the SSE stream endpoint.
#[derive(Deserialize)]
pub struct StreamQuery {
session_id: String,
}
/// SSE streaming handler for chat completions.
///
/// Reads the session's provider/model config, loads conversation history,
/// sends to the LLM with `stream: true`, and forwards tokens as SSE events.
///
/// # SSE Event Format
///
/// - `data: {"token": "..."}` -- partial token
/// - `data: {"done": true, "message_id": "..."}` -- stream complete
/// - `data: {"error": "..."}` -- on failure
pub async fn chat_stream_handler(
session: Session,
Extension(state): Extension<ServerState>,
Query(params): Query<StreamQuery>,
) -> Response {
// Authenticate
let user_state: Option<UserStateInner> = match session.get(LOGGED_IN_USER_SESS_KEY).await {
Ok(u) => u,
Err(_) => return (StatusCode::UNAUTHORIZED, "session error").into_response(),
};
let user = match user_state {
Some(u) => u,
None => return (StatusCode::UNAUTHORIZED, "not authenticated").into_response(),
};
// Load session from MongoDB (raw document to handle ObjectId -> String)
let chat_session = {
use mongodb::bson::{doc, oid::ObjectId};
let oid = match ObjectId::parse_str(&params.session_id) {
Ok(o) => o,
Err(_) => return (StatusCode::BAD_REQUEST, "invalid session_id").into_response(),
};
match state
.db
.raw_collection("chat_sessions")
.find_one(doc! { "_id": oid, "user_sub": &user.sub })
.await
{
Ok(Some(doc)) => doc_to_chat_session(&doc),
Ok(None) => return (StatusCode::NOT_FOUND, "session not found").into_response(),
Err(e) => {
tracing::error!("db error loading session: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();
}
}
};
// Load messages (raw documents to handle ObjectId -> String)
let messages = {
use mongodb::bson::doc;
use mongodb::options::FindOptions;
let opts = FindOptions::builder().sort(doc! { "timestamp": 1 }).build();
match state
.db
.raw_collection("chat_messages")
.find(doc! { "session_id": &params.session_id })
.with_options(opts)
.await
{
Ok(mut cursor) => {
use futures::TryStreamExt;
let mut msgs = Vec::new();
while let Some(doc) = TryStreamExt::try_next(&mut cursor).await.unwrap_or(None) {
msgs.push(doc_to_chat_message(&doc));
}
msgs
}
Err(e) => {
tracing::error!("db error loading messages: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();
}
}
};
// Convert to provider format
let provider_msgs: Vec<ProviderMessage> = messages
.iter()
.map(|m| ProviderMessage {
role: match m.role {
ChatRole::User => "user".to_string(),
ChatRole::Assistant => "assistant".to_string(),
ChatRole::System => "system".to_string(),
},
content: m.content.clone(),
})
.collect();
let provider = chat_session.provider.clone();
let model = chat_session.model.clone();
let session_id = params.session_id.clone();
// TODO: Load user's API key from preferences for non-Ollama providers.
// For now, Ollama (no key needed) is the default path.
let api_key: Option<String> = None;
// Send streaming request to LLM
let llm_resp = match send_chat_request(
&state,
&provider,
&model,
&provider_msgs,
api_key.as_deref(),
true,
)
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("LLM request failed: {e}");
return (StatusCode::BAD_GATEWAY, "LLM request failed").into_response();
}
};
if !llm_resp.status().is_success() {
let status = llm_resp.status();
let body = llm_resp.text().await.unwrap_or_default();
tracing::error!("LLM returned {status}: {body}");
return (StatusCode::BAD_GATEWAY, format!("LLM error: {status}")).into_response();
}
// Stream the response bytes as SSE events
let byte_stream = llm_resp.bytes_stream();
let state_clone = state.clone();
let sse_stream = build_sse_stream(byte_stream, state_clone, session_id, provider.clone());
Sse::new(sse_stream)
.keep_alive(KeepAlive::default())
.into_response()
}
/// Build an SSE stream that parses OpenAI-compatible streaming chunks
/// and emits token events. On completion, persists the full message.
fn build_sse_stream(
byte_stream: impl Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
state: ServerState,
session_id: String,
_provider: String,
) -> impl Stream<Item = Result<Event, std::convert::Infallible>> + Send + 'static {
// Use an async stream to process chunks
async_stream::stream! {
use futures::StreamExt;
let mut full_content = String::new();
let mut buffer = String::new();
// Pin the byte stream for iteration
let mut stream = std::pin::pin!(byte_stream);
while let Some(chunk_result) = StreamExt::next(&mut stream).await {
let chunk = match chunk_result {
Ok(bytes) => bytes,
Err(e) => {
let err_json = serde_json::json!({ "error": e.to_string() });
yield Ok(Event::default().data(err_json.to_string()));
break;
}
};
let text = String::from_utf8_lossy(&chunk);
buffer.push_str(&text);
// Process complete SSE lines from the buffer.
// OpenAI streaming format: `data: {...}\n\n`
while let Some(line_end) = buffer.find('\n') {
let line = buffer[..line_end].trim().to_string();
buffer = buffer[line_end + 1..].to_string();
if line.is_empty() || line == "data: [DONE]" {
continue;
}
if let Some(json_str) = line.strip_prefix("data: ") {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str) {
// Extract token from OpenAI delta format
if let Some(token) = parsed["choices"][0]["delta"]["content"].as_str() {
full_content.push_str(token);
let event_data = serde_json::json!({ "token": token });
yield Ok(Event::default().data(event_data.to_string()));
}
}
}
}
}
// Persist the complete assistant message
if !full_content.is_empty() {
let now = chrono::Utc::now().to_rfc3339();
let message = ChatMessage {
id: String::new(),
session_id: session_id.clone(),
role: ChatRole::Assistant,
content: full_content,
attachments: Vec::new(),
timestamp: now.clone(),
};
let msg_id = match state.db.chat_messages().insert_one(&message).await {
Ok(result) => result
.inserted_id
.as_object_id()
.map(|oid| oid.to_hex())
.unwrap_or_default(),
Err(e) => {
tracing::error!("failed to persist assistant message: {e}");
String::new()
}
};
// Update session timestamp
if let Ok(session_oid) =
mongodb::bson::oid::ObjectId::parse_str(&session_id)
{
let _ = state
.db
.chat_sessions()
.update_one(
mongodb::bson::doc! { "_id": session_oid },
mongodb::bson::doc! { "$set": { "updated_at": &now } },
)
.await;
}
let done_data = serde_json::json!({ "done": true, "message_id": msg_id });
yield Ok(Event::default().data(done_data.to_string()));
}
}
}

View File

@@ -12,8 +12,6 @@ mod auth;
#[cfg(feature = "server")]
mod auth_middleware;
#[cfg(feature = "server")]
mod chat_stream;
#[cfg(feature = "server")]
pub mod config;
#[cfg(feature = "server")]
pub mod database;
@@ -33,8 +31,6 @@ pub use auth::*;
#[cfg(feature = "server")]
pub use auth_middleware::*;
#[cfg(feature = "server")]
pub use chat_stream::*;
#[cfg(feature = "server")]
pub use error::*;
#[cfg(feature = "server")]
pub use server::*;

View File

@@ -6,7 +6,7 @@ use time::Duration;
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
use crate::infrastructure::{
auth_callback, auth_login, chat_stream_handler,
auth_callback, auth_login,
config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig},
database::Database,
logout, require_auth,
@@ -82,7 +82,6 @@ pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> {
.route("/auth", get(auth_login))
.route("/auth/callback", get(auth_callback))
.route("/logout", get(logout))
.route("/api/chat/stream", get(chat_stream_handler))
.serve_dioxus_application(ServeConfig::new(), app)
.layer(Extension(PendingOAuthStore::default()))
.layer(Extension(server_state))

View File

@@ -1,60 +0,0 @@
use serde::{Deserialize, Serialize};
/// The type of file stored in the knowledge base.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FileKind {
/// PDF document
Pdf,
/// Plain text or markdown file
Text,
/// Spreadsheet (csv, xlsx)
Spreadsheet,
/// Source code file
Code,
/// Image file
Image,
}
impl FileKind {
/// Returns the display label for a file kind.
pub fn label(&self) -> &'static str {
match self {
Self::Pdf => "PDF",
Self::Text => "Text",
Self::Spreadsheet => "Spreadsheet",
Self::Code => "Code",
Self::Image => "Image",
}
}
/// Returns an icon identifier for rendering.
pub fn icon(&self) -> &'static str {
match self {
Self::Pdf => "file-pdf",
Self::Text => "file-text",
Self::Spreadsheet => "file-spreadsheet",
Self::Code => "file-code",
Self::Image => "file-image",
}
}
}
/// A file stored in the knowledge base for RAG retrieval.
///
/// # Fields
///
/// * `id` - Unique file identifier
/// * `name` - Original filename
/// * `kind` - Type classification of the file
/// * `size_bytes` - File size in bytes
/// * `uploaded_at` - ISO 8601 upload timestamp
/// * `chunk_count` - Number of vector chunks created from this file
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KnowledgeFile {
pub id: String,
pub name: String,
pub kind: FileKind,
pub size_bytes: u64,
pub uploaded_at: String,
pub chunk_count: u32,
}

View File

@@ -1,17 +1,13 @@
mod chat;
mod developer;
mod knowledge;
mod news;
mod organization;
mod provider;
mod tool;
mod user;
pub use chat::*;
pub use developer::*;
pub use knowledge::*;
pub use news::*;
pub use organization::*;
pub use provider::*;
pub use tool::*;
pub use user::*;

View File

@@ -1,73 +0,0 @@
use serde::{Deserialize, Serialize};
/// Category grouping for MCP tools.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ToolCategory {
/// Web search and browsing tools
Search,
/// File and document processing tools
FileSystem,
/// Computation and math tools
Compute,
/// Code execution and analysis tools
Code,
/// Communication and notification tools
Communication,
}
impl ToolCategory {
/// Returns the display label for a tool category.
pub fn label(&self) -> &'static str {
match self {
Self::Search => "Search",
Self::FileSystem => "File System",
Self::Compute => "Compute",
Self::Code => "Code",
Self::Communication => "Communication",
}
}
}
/// Status of an MCP tool instance.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ToolStatus {
/// Tool is running and available
Active,
/// Tool is installed but not running
Inactive,
/// Tool encountered an error
Error,
}
impl ToolStatus {
/// Returns the CSS class suffix for status styling.
pub fn css_class(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Inactive => "inactive",
Self::Error => "error",
}
}
}
/// An MCP (Model Context Protocol) tool entry.
///
/// # Fields
///
/// * `id` - Unique tool identifier
/// * `name` - Human-readable display name
/// * `description` - Brief description of what the tool does
/// * `category` - Classification category
/// * `status` - Current running status
/// * `enabled` - Whether the tool is toggled on by the user
/// * `icon` - Icon identifier for rendering
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct McpTool {
pub id: String,
pub name: String,
pub description: String,
pub category: ToolCategory,
pub status: ToolStatus,
pub enabled: bool,
pub icon: String,
}

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(),
}
}
}
}
}

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,
},
]
}

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::*;

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(),
},
]
}