From 208450e618f7be24a60b308800ee8714940bdf6d Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 24 Feb 2026 10:45:41 +0000 Subject: [PATCH] feat: use librechat instead of own chat (#14) Co-authored-by: Sharang Parnerkar Reviewed-on: https://gitea.meghsakha.com/sharang/certifai/pulls/14 --- .env.example | 5 + assets/i18n/de.json | 55 -- assets/i18n/en.json | 55 -- assets/i18n/es.json | 55 -- assets/i18n/fr.json | 55 -- assets/i18n/pt.json | 55 -- docker-compose.yml | 69 ++- keycloak/realm-export.json | 33 ++ librechat/librechat.yaml | 40 ++ librechat/logo.svg | 25 + librechat/openidStrategy.js | 743 ++++++++++++++++++++++++++ src/app.rs | 6 - src/components/chat_action_bar.rs | 69 --- src/components/chat_bubble.rs | 142 ----- src/components/chat_input_bar.rs | 73 --- src/components/chat_message_list.rs | 42 -- src/components/chat_model_selector.rs | 50 -- src/components/chat_sidebar.rs | 258 --------- src/components/file_row.rs | 59 -- src/components/mod.rs | 16 - src/components/sidebar.rs | 93 ++-- src/components/tool_card.rs | 49 -- src/i18n/mod.rs | 8 +- src/infrastructure/chat_stream.rs | 266 --------- src/infrastructure/mod.rs | 4 - src/infrastructure/server.rs | 3 +- src/models/knowledge.rs | 60 --- src/models/mod.rs | 4 - src/models/tool.rs | 73 --- src/pages/chat.rs | 344 ------------ src/pages/knowledge.rs | 128 ----- src/pages/mod.rs | 6 - src/pages/tools.rs | 149 ------ 33 files changed, 968 insertions(+), 2124 deletions(-) create mode 100644 librechat/librechat.yaml create mode 100644 librechat/logo.svg create mode 100644 librechat/openidStrategy.js delete mode 100644 src/components/chat_action_bar.rs delete mode 100644 src/components/chat_bubble.rs delete mode 100644 src/components/chat_input_bar.rs delete mode 100644 src/components/chat_message_list.rs delete mode 100644 src/components/chat_model_selector.rs delete mode 100644 src/components/chat_sidebar.rs delete mode 100644 src/components/file_row.rs delete mode 100644 src/components/tool_card.rs delete mode 100644 src/infrastructure/chat_stream.rs delete mode 100644 src/models/knowledge.rs delete mode 100644 src/models/tool.rs delete mode 100644 src/pages/chat.rs delete mode 100644 src/pages/knowledge.rs delete mode 100644 src/pages/tools.rs diff --git a/.env.example b/.env.example index bc49c38..6182d8f 100644 --- a/.env.example +++ b/.env.example @@ -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] # --------------------------------------------------------------------------- diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 4ca034c..515c528 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -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.", diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 662b0b7..774f1fa 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -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.", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index eef7960..6a0a4b1 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -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.", diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index 113e6f6..9ab76f1 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -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.", diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index 85ee33e..1d4e7d4 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -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.", diff --git a/docker-compose.yml b/docker-compose.yml index 7194306..5503a04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,12 @@ -version: '3.8' - services: keycloak: image: quay.io/keycloak/keycloak:26.0 container_name: certifai-keycloak environment: - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: admin + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin KC_DB: dev-mem + KC_HEALTH_ENABLED: "true" ports: - "8080:8080" command: @@ -17,10 +16,11 @@ services: - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro - ./keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"] + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/master HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q '200 OK'"] interval: 10s timeout: 5s - retries: 5 + retries: 10 + start_period: 30s mongo: image: mongo:latest @@ -40,4 +40,59 @@ services: environment: - SEARXNG_BASE_URL=http://localhost:8888 volumes: - - ./searxng:/etc/searxng:rw \ No newline at end of file + - ./searxng:/etc/searxng:rw + + librechat: + image: ghcr.io/danny-avila/librechat:latest + container_name: certifai-librechat + restart: unless-stopped + # Use host networking so localhost:8080 (Keycloak) is reachable for + # OIDC discovery, and the browser redirect URLs match the issuer. + network_mode: host + depends_on: + keycloak: + condition: service_healthy + mongo: + condition: service_started + environment: + # MongoDB (use localhost since we're on host network) + MONGO_URI: mongodb://root:example@localhost:27017/librechat?authSource=admin + DOMAIN_CLIENT: http://localhost:3080 + DOMAIN_SERVER: http://localhost:3080 + # Allow HTTP for local dev OIDC (Keycloak on localhost without TLS) + NODE_TLS_REJECT_UNAUTHORIZED: "0" + NODE_ENV: development + # Keycloak OIDC SSO + OPENID_ISSUER: http://localhost:8080/realms/certifai + OPENID_CLIENT_ID: certifai-librechat + OPENID_CLIENT_SECRET: certifai-librechat-secret + OPENID_SESSION_SECRET: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6" + 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" + # JWT / encryption secrets (required by LibreChat) + CREDS_KEY: "97e95d72cdda06774a264f9fb7768097a6815dc1e930898d2e39c9a3a253b157" + CREDS_IV: "2ea456ab25279089b0ff9e7aca1df6e6" + JWT_SECRET: "767b962176666eab56e180e6f2d3fe95145dc6b978e37d4eb8d1da5421c5fb26" + JWT_REFRESH_SECRET: "51a43a1fca4b7b501b37e226a638645d962066e0686b82248921f3160e96501e" + # 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 + # Patch: allow HTTP issuer for local dev (openid-client v6 enforces HTTPS) + - ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro + - librechat-data:/app/data + +volumes: + librechat-data: \ No newline at end of file diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 7e3aa42..eb945ee 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -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": [ diff --git a/librechat/librechat.yaml b/librechat/librechat.yaml new file mode 100644 index 0000000..7ba5233 --- /dev/null +++ b/librechat/librechat.yaml @@ -0,0 +1,40 @@ +# CERTifAI LibreChat Configuration +# Ollama backend for self-hosted LLM inference. +version: 1.2.8 + +cache: true + +registration: + socialLogins: + - openid + +interface: + privacyPolicy: + externalUrl: https://dash-dev.meghsakha.com/privacy + termsOfService: + externalUrl: https://dash-dev.meghsakha.com/impressum + endpointsMenu: true + modelSelect: true + parameters: true + +endpoints: + custom: + - name: "Ollama" + apiKey: "ollama" + baseURL: "https://mac-mini-von-benjamin-2:11434/v1/" + models: + default: + - "llama3.1:8b" + - "qwen3:30b-a3b" + fetch: true + titleConvo: true + titleModel: "current_model" + summarize: false + summaryModel: "current_model" + forcePrompt: false + modelDisplayLabel: "CERTifAI Ollama" + dropParams: + - stop + - user + - frequency_penalty + - presence_penalty diff --git a/librechat/logo.svg b/librechat/logo.svg new file mode 100644 index 0000000..ac16408 --- /dev/null +++ b/librechat/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/librechat/openidStrategy.js b/librechat/openidStrategy.js new file mode 100644 index 0000000..b2c5575 --- /dev/null +++ b/librechat/openidStrategy.js @@ -0,0 +1,743 @@ +const undici = require('undici'); +const { get } = require('lodash'); +const fetch = require('node-fetch'); +const passport = require('passport'); +const client = require('openid-client'); +const jwtDecode = require('jsonwebtoken/decode'); +const { HttpsProxyAgent } = require('https-proxy-agent'); +const { hashToken, logger } = require('@librechat/data-schemas'); +const { Strategy: OpenIDStrategy } = require('openid-client/passport'); +const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider'); +const { + isEnabled, + logHeaders, + safeStringify, + findOpenIDUser, + getBalanceConfig, + isEmailDomainAllowed, +} = require('@librechat/api'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { findUser, createUser, updateUser } = require('~/models'); +const { getAppConfig } = require('~/server/services/Config'); +const getLogStores = require('~/cache/getLogStores'); + +/** + * @typedef {import('openid-client').ClientMetadata} ClientMetadata + * @typedef {import('openid-client').Configuration} Configuration + **/ + +/** + * @param {string} url + * @param {client.CustomFetchOptions} options + */ +async function customFetch(url, options) { + const urlStr = url.toString(); + logger.debug(`[openidStrategy] Request to: ${urlStr}`); + const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS); + if (debugOpenId) { + logger.debug(`[openidStrategy] Request method: ${options.method || 'GET'}`); + logger.debug(`[openidStrategy] Request headers: ${logHeaders(options.headers)}`); + if (options.body) { + let bodyForLogging = ''; + if (options.body instanceof URLSearchParams) { + bodyForLogging = options.body.toString(); + } else if (typeof options.body === 'string') { + bodyForLogging = options.body; + } else { + bodyForLogging = safeStringify(options.body); + } + logger.debug(`[openidStrategy] Request body: ${bodyForLogging}`); + } + } + + try { + /** @type {undici.RequestInit} */ + let fetchOptions = options; + if (process.env.PROXY) { + logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`); + fetchOptions = { + ...options, + dispatcher: new undici.ProxyAgent(process.env.PROXY), + }; + } + + const response = await undici.fetch(url, fetchOptions); + + if (debugOpenId) { + logger.debug(`[openidStrategy] Response status: ${response.status} ${response.statusText}`); + logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`); + } + + if (response.status === 200 && response.headers.has('www-authenticate')) { + const wwwAuth = response.headers.get('www-authenticate'); + logger.warn(`[openidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}. +This violates RFC 7235 and may cause issues with strict OAuth clients. Removing header for compatibility.`); + + /** Cloned response without the WWW-Authenticate header */ + const responseBody = await response.arrayBuffer(); + const newHeaders = new Headers(); + for (const [key, value] of response.headers.entries()) { + if (key.toLowerCase() !== 'www-authenticate') { + newHeaders.append(key, value); + } + } + + return new Response(responseBody, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + } + + return response; + } catch (error) { + logger.error(`[openidStrategy] Fetch error: ${error.message}`); + throw error; + } +} + +/** @typedef {Configuration | null} */ +let openidConfig = null; + +/** + * Custom OpenID Strategy + * + * Note: Originally overrode currentUrl() to work around Express 4's req.host not including port. + * With Express 5, req.host now includes the port by default, but we continue to use DOMAIN_SERVER + * for consistency and explicit configuration control. + * More info: https://github.com/panva/openid-client/pull/713 + */ +class CustomOpenIDStrategy extends OpenIDStrategy { + currentUrl(req) { + const hostAndProtocol = process.env.DOMAIN_SERVER; + return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`); + } + + authorizationRequestParams(req, options) { + const params = super.authorizationRequestParams(req, options); + if (options?.state && !params.has('state')) { + params.set('state', options.state); + } + + if (process.env.OPENID_AUDIENCE) { + params.set('audience', process.env.OPENID_AUDIENCE); + logger.debug( + `[openidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`, + ); + } + + // Parse OPENID_AUTH_EXTRA_PARAMS (format: "key=value" or "key1=value1,key2=value2") + if (process.env.OPENID_AUTH_EXTRA_PARAMS) { + const extraParts = process.env.OPENID_AUTH_EXTRA_PARAMS.split(','); + for (const part of extraParts) { + const [key, ...rest] = part.trim().split('='); + if (key && rest.length > 0) { + params.set(key.trim(), rest.join('=').trim()); + logger.debug(`[openidStrategy] Adding extra auth param: ${key.trim()}=${rest.join('=').trim()}`); + } + } + } + + /** Generate nonce for federated providers that require it */ + const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); + if (shouldGenerateNonce && !params.has('nonce') && this._sessionKey) { + const crypto = require('crypto'); + const nonce = crypto.randomBytes(16).toString('hex'); + params.set('nonce', nonce); + logger.debug('[openidStrategy] Generated nonce for federated provider:', nonce); + } + + return params; + } +} + +/** + * Exchange the access token for a new access token using the on-behalf-of flow if required. + * @param {Configuration} config + * @param {string} accessToken access token to be exchanged if necessary + * @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token + * @param {boolean} fromCache - Indicates whether to use cached tokens. + * @returns {Promise} The new access token if exchanged, otherwise the original access token. + */ +const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => { + const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS); + const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED); + if (onBehalfFlowRequired) { + if (fromCache) { + const cachedToken = await tokensCache.get(sub); + if (cachedToken) { + return cachedToken.access_token; + } + } + const grantResponse = await client.genericGrantRequest( + config, + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + { + scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE || 'user.read', + assertion: accessToken, + requested_token_use: 'on_behalf_of', + }, + ); + await tokensCache.set( + sub, + { + access_token: grantResponse.access_token, + }, + grantResponse.expires_in * 1000, + ); + return grantResponse.access_token; + } + return accessToken; +}; + +/** + * get user info from openid provider + * @param {Configuration} config + * @param {string} accessToken access token + * @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token + * @returns {Promise} + */ +const getUserInfo = async (config, accessToken, sub) => { + try { + const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub); + return await client.fetchUserInfo(config, exchangedAccessToken, sub); + } catch (error) { + logger.error('[openidStrategy] getUserInfo: Error fetching user info:', error); + return null; + } +}; + +/** + * Downloads an image from a URL using an access token. + * @param {string} url + * @param {Configuration} config + * @param {string} accessToken access token + * @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token + * @returns {Promise} The image buffer or an empty string if the download fails. + */ +const downloadImage = async (url, config, accessToken, sub) => { + const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true); + if (!url) { + return ''; + } + + try { + const options = { + method: 'GET', + headers: { + Authorization: `Bearer ${exchangedAccessToken}`, + }, + }; + + if (process.env.PROXY) { + options.agent = new HttpsProxyAgent(process.env.PROXY); + } + + const response = await fetch(url, options); + + if (response.ok) { + const buffer = await response.buffer(); + return buffer; + } else { + throw new Error(`${response.statusText} (HTTP ${response.status})`); + } + } catch (error) { + logger.error( + `[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`, + ); + return ''; + } +}; + +/** + * Determines the full name of a user based on OpenID userinfo and environment configuration. + * + * @param {Object} userinfo - The user information object from OpenID Connect + * @param {string} [userinfo.given_name] - The user's first name + * @param {string} [userinfo.family_name] - The user's last name + * @param {string} [userinfo.username] - The user's username + * @param {string} [userinfo.email] - The user's email address + * @returns {string} The determined full name of the user + */ +function getFullName(userinfo) { + if (process.env.OPENID_NAME_CLAIM) { + return userinfo[process.env.OPENID_NAME_CLAIM]; + } + + if (userinfo.given_name && userinfo.family_name) { + return `${userinfo.given_name} ${userinfo.family_name}`; + } + + if (userinfo.given_name) { + return userinfo.given_name; + } + + if (userinfo.family_name) { + return userinfo.family_name; + } + + return userinfo.username || userinfo.email; +} + +/** + * Converts an input into a string suitable for a username. + * If the input is a string, it will be returned as is. + * If the input is an array, elements will be joined with underscores. + * In case of undefined or other falsy values, a default value will be returned. + * + * @param {string | string[] | undefined} input - The input value to be converted into a username. + * @param {string} [defaultValue=''] - The default value to return if the input is falsy. + * @returns {string} The processed input as a string suitable for a username. + */ +function convertToUsername(input, defaultValue = '') { + if (typeof input === 'string') { + return input; + } else if (Array.isArray(input)) { + return input.join('_'); + } + + return defaultValue; +} + +/** + * Resolve Azure AD groups when group overage is in effect (groups moved to _claim_names/_claim_sources). + * + * NOTE: Microsoft recommends treating _claim_names/_claim_sources as a signal only and using Microsoft Graph + * to resolve group membership instead of calling the endpoint in _claim_sources directly. + * + * @param {string} accessToken - Access token with Microsoft Graph permissions + * @returns {Promise} Resolved group IDs or null on failure + * @see https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#groups-overage-claim + * @see https://learn.microsoft.com/en-us/graph/api/directoryobject-getmemberobjects + */ +async function resolveGroupsFromOverage(accessToken) { + try { + if (!accessToken) { + logger.error('[openidStrategy] Access token missing; cannot resolve group overage'); + return null; + } + + // Use /me/getMemberObjects so least-privileged delegated permission User.Read is sufficient + // when resolving the signed-in user's group membership. + const url = 'https://graph.microsoft.com/v1.0/me/getMemberObjects'; + + logger.debug( + `[openidStrategy] Detected group overage, resolving groups via Microsoft Graph getMemberObjects: ${url}`, + ); + + const fetchOptions = { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ securityEnabledOnly: false }), + }; + + if (process.env.PROXY) { + const { ProxyAgent } = undici; + fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY); + } + + const response = await undici.fetch(url, fetchOptions); + if (!response.ok) { + logger.error( + `[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP ${response.status} ${response.statusText}`, + ); + return null; + } + + const data = await response.json(); + const values = Array.isArray(data?.value) ? data.value : null; + if (!values) { + logger.error( + '[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects', + ); + return null; + } + const groupIds = values.filter((id) => typeof id === 'string'); + + logger.debug( + `[openidStrategy] Successfully resolved ${groupIds.length} groups via Microsoft Graph getMemberObjects`, + ); + return groupIds; + } catch (err) { + logger.error( + '[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:', + err, + ); + return null; + } +} + +/** + * Process OpenID authentication tokenset and userinfo + * This is the core logic extracted from the passport strategy callback + * Can be reused by both the passport strategy and proxy authentication + * + * @param {Object} tokenset - The OpenID tokenset containing access_token, id_token, etc. + * @param {boolean} existingUsersOnly - If true, only existing users will be processed + * @returns {Promise} The authenticated user object with tokenset + */ +async function processOpenIDAuth(tokenset, existingUsersOnly = false) { + const claims = tokenset.claims ? tokenset.claims() : tokenset; + const userinfo = { + ...claims, + }; + + if (tokenset.access_token) { + const providerUserinfo = await getUserInfo(openidConfig, tokenset.access_token, claims.sub); + Object.assign(userinfo, providerUserinfo); + } + + const appConfig = await getAppConfig(); + /** Azure AD sometimes doesn't return email, use preferred_username as fallback */ + const email = userinfo.email || userinfo.preferred_username || userinfo.upn; + if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + logger.error( + `[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`, + ); + throw new Error('Email domain not allowed'); + } + + const result = await findOpenIDUser({ + findUser, + email: email, + openidId: claims.sub || userinfo.sub, + idOnTheSource: claims.oid || userinfo.oid, + strategyName: 'openidStrategy', + }); + let user = result.user; + const error = result.error; + + if (error) { + throw new Error(ErrorTypes.AUTH_FAILED); + } + + const fullName = getFullName(userinfo); + + const requiredRole = process.env.OPENID_REQUIRED_ROLE; + if (requiredRole) { + const requiredRoles = requiredRole + .split(',') + .map((role) => role.trim()) + .filter(Boolean); + const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH; + const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND; + + let decodedToken = ''; + if (requiredRoleTokenKind === 'access' && tokenset.access_token) { + decodedToken = jwtDecode(tokenset.access_token); + } else if (requiredRoleTokenKind === 'id' && tokenset.id_token) { + decodedToken = jwtDecode(tokenset.id_token); + } + + let roles = get(decodedToken, requiredRoleParameterPath); + + // Handle Azure AD group overage for ID token groups: when hasgroups or _claim_* indicate overage, + // resolve groups via Microsoft Graph instead of relying on token group values. + if ( + !Array.isArray(roles) && + typeof roles !== 'string' && + requiredRoleTokenKind === 'id' && + requiredRoleParameterPath === 'groups' && + decodedToken && + (decodedToken.hasgroups || + (decodedToken._claim_names?.groups && + decodedToken._claim_sources?.[decodedToken._claim_names.groups])) + ) { + const overageGroups = await resolveGroupsFromOverage(tokenset.access_token); + if (overageGroups) { + roles = overageGroups; + } + } + + if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) { + logger.error( + `[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`, + ); + const rolesList = + requiredRoles.length === 1 + ? `"${requiredRoles[0]}"` + : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; + throw new Error(`You must have ${rolesList} role to log in.`); + } + + const roleValues = Array.isArray(roles) ? roles : [roles]; + + if (!requiredRoles.some((role) => roleValues.includes(role))) { + const rolesList = + requiredRoles.length === 1 + ? `"${requiredRoles[0]}"` + : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; + throw new Error(`You must have ${rolesList} role to log in.`); + } + } + + let username = ''; + if (process.env.OPENID_USERNAME_CLAIM) { + username = userinfo[process.env.OPENID_USERNAME_CLAIM]; + } else { + username = convertToUsername( + userinfo.preferred_username || userinfo.username || userinfo.email, + ); + } + + if (existingUsersOnly && !user) { + throw new Error('User does not exist'); + } + + if (!user) { + user = { + provider: 'openid', + openidId: userinfo.sub, + username, + email: email || '', + emailVerified: userinfo.email_verified || false, + name: fullName, + idOnTheSource: userinfo.oid, + }; + + const balanceConfig = getBalanceConfig(appConfig); + user = await createUser(user, balanceConfig, true, true); + } else { + user.provider = 'openid'; + user.openidId = userinfo.sub; + user.username = username; + user.name = fullName; + user.idOnTheSource = userinfo.oid; + if (email && email !== user.email) { + user.email = email; + user.emailVerified = userinfo.email_verified || false; + } + } + + const adminRole = process.env.OPENID_ADMIN_ROLE; + const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + + if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { + let adminRoleObject; + switch (adminRoleTokenKind) { + case 'access': + adminRoleObject = jwtDecode(tokenset.access_token); + break; + case 'id': + adminRoleObject = jwtDecode(tokenset.id_token); + break; + case 'userinfo': + adminRoleObject = userinfo; + break; + default: + logger.error( + `[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, + ); + throw new Error('Invalid admin role token kind'); + } + + const adminRoles = get(adminRoleObject, adminRoleParameterPath); + + if ( + adminRoles && + (adminRoles === true || + adminRoles === adminRole || + (Array.isArray(adminRoles) && adminRoles.includes(adminRole))) + ) { + user.role = SystemRoles.ADMIN; + logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`); + } else if (user.role === SystemRoles.ADMIN) { + user.role = SystemRoles.USER; + logger.info( + `[openidStrategy] User ${username} demoted from admin - role no longer present in token`, + ); + } + } + + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { + /** @type {string | undefined} */ + const imageUrl = userinfo.picture; + + let fileName; + if (crypto) { + fileName = (await hashToken(userinfo.sub)) + '.png'; + } else { + fileName = userinfo.sub + '.png'; + } + + const imageBuffer = await downloadImage( + imageUrl, + openidConfig, + tokenset.access_token, + userinfo.sub, + ); + if (imageBuffer) { + const { saveBuffer } = getStrategyFunctions( + appConfig?.fileStrategy ?? process.env.CDN_PROVIDER, + ); + const imagePath = await saveBuffer({ + fileName, + userId: user._id.toString(), + buffer: imageBuffer, + }); + user.avatar = imagePath ?? ''; + } + } + + user = await updateUser(user._id, user); + + logger.info( + `[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `, + { + user: { + openidId: user.openidId, + username: user.username, + email: user.email, + name: user.name, + }, + }, + ); + + return { + ...user, + tokenset, + federatedTokens: { + access_token: tokenset.access_token, + id_token: tokenset.id_token, + refresh_token: tokenset.refresh_token, + expires_at: tokenset.expires_at, + }, + }; +} + +/** + * @param {boolean | undefined} [existingUsersOnly] + */ +function createOpenIDCallback(existingUsersOnly) { + return async (tokenset, done) => { + try { + const user = await processOpenIDAuth(tokenset, existingUsersOnly); + done(null, user); + } catch (err) { + if (err.message === 'Email domain not allowed') { + return done(null, false, { message: err.message }); + } + if (err.message === ErrorTypes.AUTH_FAILED) { + return done(null, false, { message: err.message }); + } + if (err.message && err.message.includes('role to log in')) { + return done(null, false, { message: err.message }); + } + logger.error('[openidStrategy] login failed', err); + done(err); + } + }; +} + +/** + * Sets up the OpenID strategy specifically for admin authentication. + * @param {Configuration} openidConfig + */ +const setupOpenIdAdmin = (openidConfig) => { + try { + if (!openidConfig) { + throw new Error('OpenID configuration not initialized'); + } + + const openidAdminLogin = new CustomOpenIDStrategy( + { + config: openidConfig, + scope: process.env.OPENID_SCOPE, + usePKCE: isEnabled(process.env.OPENID_USE_PKCE), + clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, + callbackURL: process.env.DOMAIN_SERVER + '/api/admin/oauth/openid/callback', + }, + createOpenIDCallback(true), + ); + + passport.use('openidAdmin', openidAdminLogin); + } catch (err) { + logger.error('[openidStrategy] setupOpenIdAdmin', err); + } +}; + +/** + * Sets up the OpenID strategy for authentication. + * This function configures the OpenID client, handles proxy settings, + * and defines the OpenID strategy for Passport.js. + * + * @async + * @function setupOpenId + * @returns {Promise} A promise that resolves when the OpenID strategy is set up and returns the openid client config object. + * @throws {Error} If an error occurs during the setup process. + */ +async function setupOpenId() { + try { + const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); + + /** @type {ClientMetadata} */ + const clientMetadata = { + client_id: process.env.OPENID_CLIENT_ID, + client_secret: process.env.OPENID_CLIENT_SECRET, + }; + + if (shouldGenerateNonce) { + clientMetadata.response_types = ['code']; + clientMetadata.grant_types = ['authorization_code']; + clientMetadata.token_endpoint_auth_method = 'client_secret_post'; + } + + /** @type {Configuration} */ + openidConfig = await client.discovery( + new URL(process.env.OPENID_ISSUER), + process.env.OPENID_CLIENT_ID, + clientMetadata, + undefined, + { + [client.customFetch]: customFetch, + execute: [client.allowInsecureRequests], + }, + ); + + logger.info(`[openidStrategy] OpenID authentication configuration`, { + generateNonce: shouldGenerateNonce, + reason: shouldGenerateNonce + ? 'OPENID_GENERATE_NONCE=true - Will generate nonce and use explicit metadata for federated providers' + : 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata', + }); + + const openidLogin = new CustomOpenIDStrategy( + { + config: openidConfig, + scope: process.env.OPENID_SCOPE, + callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL, + clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, + usePKCE: isEnabled(process.env.OPENID_USE_PKCE), + }, + createOpenIDCallback(), + ); + passport.use('openid', openidLogin); + setupOpenIdAdmin(openidConfig); + return openidConfig; + } catch (err) { + logger.error('[openidStrategy]', err); + return null; + } +} + +/** + * @function getOpenIdConfig + * @description Returns the OpenID client instance. + * @throws {Error} If the OpenID client is not initialized. + * @returns {Configuration} + */ +function getOpenIdConfig() { + if (!openidConfig) { + throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); + } + return openidConfig; +} + +module.exports = { + setupOpenId, + getOpenIdConfig, +}; diff --git a/src/app.rs b/src/app.rs index be6778c..8bce752 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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")] diff --git a/src/components/chat_action_bar.rs b/src/components/chat_action_bar.rs deleted file mode 100644 index 0dee6be..0000000 --- a/src/components/chat_action_bar.rs +++ /dev/null @@ -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::>(); - 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\")}" } - } - } - } -} diff --git a/src/components/chat_bubble.rs b/src/components/chat_bubble.rs deleted file mode 100644 index 5007186..0000000 --- a/src/components/chat_bubble.rs +++ /dev/null @@ -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::>(); - 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::>(); - 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" } - } - } - } -} diff --git a/src/components/chat_input_bar.rs b/src/components/chat_input_bar.rs deleted file mode 100644 index d8dc50e..0000000 --- a/src/components/chat_input_bar.rs +++ /dev/null @@ -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, - on_send: EventHandler, - is_streaming: bool, -) -> Element { - let locale = use_context::>(); - 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| { - input.set(e.value()); - }, - onkeypress: move |e: Event| { - // 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, - } - } - } - } - } -} diff --git a/src/components/chat_message_list.rs b/src/components/chat_message_list.rs deleted file mode 100644 index a9175ce..0000000 --- a/src/components/chat_message_list.rs +++ /dev/null @@ -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, - streaming_content: String, - is_streaming: bool, -) -> Element { - let locale = use_context::>(); - 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 } - } - } - } -} diff --git a/src/components/chat_model_selector.rs b/src/components/chat_model_selector.rs deleted file mode 100644 index c0d3a9e..0000000 --- a/src/components/chat_model_selector.rs +++ /dev/null @@ -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, - on_change: EventHandler, -) -> Element { - let locale = use_context::>(); - 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| { - 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\")}" - } - } - } - } - } -} diff --git a/src/components/chat_sidebar.rs b/src/components/chat_sidebar.rs deleted file mode 100644 index cb1c88c..0000000 --- a/src/components/chat_sidebar.rs +++ /dev/null @@ -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, - active_session_id: Option, - on_select: EventHandler, - on_new: EventHandler<()>, - on_rename: EventHandler<(String, String)>, - on_delete: EventHandler, -) -> Element { - let locale = use_context::>(); - 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> = 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>, - on_select: EventHandler, - on_rename: EventHandler<(String, String)>, - on_delete: EventHandler, -) -> Element { - let locale = use_context::>(); - 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| { - let val = e.value(); - let id = sid.clone(); - rename_sig.set(Some((id, val))); - }, - onkeypress: move |e: Event| { - 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| { - 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| { - 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() - } -} diff --git a/src/components/file_row.rs b/src/components/file_row.rs deleted file mode 100644 index fd495dc..0000000 --- a/src/components/file_row.rs +++ /dev/null @@ -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) -> Element { - let locale = use_context::>(); - 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") - } -} diff --git a/src/components/mod.rs b/src/components/mod.rs index 3018046..614a89b 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -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::*; diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs index a1d81c6..2327f16 100644 --- a/src/components/sidebar.rs +++ b/src/components/sidebar.rs @@ -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 ``). + 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 ¤t_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 ¤t_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}" } + } + } } } } diff --git a/src/components/tool_card.rs b/src/components/tool_card.rs deleted file mode 100644 index d90bfc2..0000000 --- a/src/components/tool_card.rs +++ /dev/null @@ -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) -> Element { - let locale = use_context::>(); - 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\")}" - } - } - } - } - } -} diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 2f52baa..6f099c8 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -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] diff --git a/src/infrastructure/chat_stream.rs b/src/infrastructure/chat_stream.rs deleted file mode 100644 index 6a66405..0000000 --- a/src/infrastructure/chat_stream.rs +++ /dev/null @@ -1,266 +0,0 @@ -//! SSE streaming endpoint for chat completions. -//! -//! Exposes `GET /api/chat/stream?session_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, - Query(params): Query, -) -> Response { - // Authenticate - let user_state: Option = 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(¶ms.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": ¶ms.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 = 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 = 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> + Send + 'static, - state: ServerState, - session_id: String, - _provider: String, -) -> impl Stream> + 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::(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())); - } - } -} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 3c7a2a0..8a96c2f 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -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::*; diff --git a/src/infrastructure/server.rs b/src/infrastructure/server.rs index 5676ac3..8ce30e2 100644 --- a/src/infrastructure/server.rs +++ b/src/infrastructure/server.rs @@ -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)) diff --git a/src/models/knowledge.rs b/src/models/knowledge.rs deleted file mode 100644 index 1a507bf..0000000 --- a/src/models/knowledge.rs +++ /dev/null @@ -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, -} diff --git a/src/models/mod.rs b/src/models/mod.rs index c50bb0c..933d0be 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -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::*; diff --git a/src/models/tool.rs b/src/models/tool.rs deleted file mode 100644 index 263404c..0000000 --- a/src/models/tool.rs +++ /dev/null @@ -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, -} diff --git a/src/pages/chat.rs b/src/pages/chat.rs deleted file mode 100644 index 2e66310..0000000 --- a/src/pages/chat.rs +++ /dev/null @@ -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::>(); - let l = *locale.read(); - - // ---- Signals ---- - let mut active_session_id: Signal> = use_signal(|| None); - let mut messages: Signal> = use_signal(Vec::new); - let mut input_text: Signal = use_signal(String::new); - let mut is_streaming: Signal = use_signal(|| false); - let mut streaming_content: Signal = use_signal(String::new); - let mut selected_model: Signal = 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::(), - "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 = 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::>() - .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(), - } - } - } - } -} diff --git a/src/pages/knowledge.rs b/src/pages/knowledge.rs deleted file mode 100644 index 3e2f682..0000000 --- a/src/pages/knowledge.rs +++ /dev/null @@ -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::>(); - 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| { - 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 { - 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, - }, - ] -} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 46575a5..294cece 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -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::*; diff --git a/src/pages/tools.rs b/src/pages/tools.rs deleted file mode 100644 index a08d0f0..0000000 --- a/src/pages/tools.rs +++ /dev/null @@ -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::>(); - 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::::new); - - // Build the display list: translated names from mock_tools, with - // enabled state merged from user overrides. - let tool_list: Vec = 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 { - 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(), - }, - ] -}