feat: use librechat instead of own chat (#14)
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
@@ -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]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
- ./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:
|
||||
@@ -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": [
|
||||
|
||||
40
librechat/librechat.yaml
Normal file
40
librechat/librechat.yaml
Normal file
@@ -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
|
||||
25
librechat/logo.svg
Normal file
25
librechat/logo.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<!-- Shield body -->
|
||||
<path d="M32 4L8 16v16c0 14.4 10.24 27.2 24 32 13.76-4.8 24-17.6 24-32V16L32 4z"
|
||||
fill="#4B3FE0" fill-opacity="0.12" stroke="#4B3FE0" stroke-width="2"
|
||||
stroke-linejoin="round"/>
|
||||
<!-- Inner shield highlight -->
|
||||
<path d="M32 10L14 19v11c0 11.6 7.68 22 18 26 10.32-4 18-14.4 18-26V19L32 10z"
|
||||
fill="none" stroke="#4B3FE0" stroke-width="1" stroke-opacity="0.3"
|
||||
stroke-linejoin="round"/>
|
||||
<!-- Neural network nodes -->
|
||||
<circle cx="32" cy="24" r="3.5" fill="#38B2AC"/>
|
||||
<circle cx="22" cy="36" r="3" fill="#38B2AC"/>
|
||||
<circle cx="42" cy="36" r="3" fill="#38B2AC"/>
|
||||
<circle cx="27" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
|
||||
<circle cx="37" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
|
||||
<!-- Neural network edges -->
|
||||
<line x1="32" y1="24" x2="22" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<line x1="32" y1="24" x2="42" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<line x1="22" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<line x1="22" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<line x1="42" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<line x1="42" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<!-- Cross edge for connectivity -->
|
||||
<line x1="22" y1="36" x2="42" y2="36" stroke="#38B2AC" stroke-width="0.8" stroke-opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
743
librechat/openidStrategy.js
Normal file
743
librechat/openidStrategy.js
Normal file
@@ -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<string>} 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<Object|null>}
|
||||
*/
|
||||
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<Buffer | string>} 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<string[] | null>} 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<Object>} 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<Configuration | null>} 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,
|
||||
};
|
||||
@@ -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")]
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
use crate::i18n::{t, Locale};
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes};
|
||||
|
||||
/// Action bar displayed above the chat input with copy, share, and edit buttons.
|
||||
///
|
||||
/// Only visible when there is at least one message in the conversation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `on_copy` - Copies the last assistant response to the clipboard
|
||||
/// * `on_share` - Copies the full conversation as text to the clipboard
|
||||
/// * `on_edit` - Places the last user message back in the input for editing
|
||||
/// * `has_messages` - Whether any messages exist (hides the bar when empty)
|
||||
/// * `has_assistant_message` - Whether an assistant message exists (disables copy if not)
|
||||
/// * `has_user_message` - Whether a user message exists (disables edit if not)
|
||||
#[component]
|
||||
pub fn ChatActionBar(
|
||||
on_copy: EventHandler<()>,
|
||||
on_share: EventHandler<()>,
|
||||
on_edit: EventHandler<()>,
|
||||
has_messages: bool,
|
||||
has_assistant_message: bool,
|
||||
has_user_message: bool,
|
||||
) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
if !has_messages {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-action-bar",
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
disabled: !has_assistant_message,
|
||||
title: "{t(l, \"chat.copy_response\")}",
|
||||
onclick: move |_| on_copy.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaCopy,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "{t(l, \"common.copy\")}" }
|
||||
}
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
title: "{t(l, \"chat.copy_conversation\")}",
|
||||
onclick: move |_| on_share.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaShareNodes,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "{t(l, \"common.share\")}" }
|
||||
}
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
disabled: !has_user_message,
|
||||
title: "{t(l, \"chat.edit_last\")}",
|
||||
onclick: move |_| on_edit.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaPenToSquare,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "{t(l, \"common.edit\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::models::{ChatMessage, ChatRole};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Render markdown content to HTML using `pulldown-cmark`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `md` - Raw markdown string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// HTML string suitable for `dangerous_inner_html`
|
||||
fn markdown_to_html(md: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser};
|
||||
|
||||
let mut opts = Options::empty();
|
||||
opts.insert(Options::ENABLE_TABLES);
|
||||
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
opts.insert(Options::ENABLE_TASKLISTS);
|
||||
|
||||
let parser = Parser::new_ext(md, opts);
|
||||
let mut html = String::with_capacity(md.len() * 2);
|
||||
pulldown_cmark::html::push_html(&mut html, parser);
|
||||
html
|
||||
}
|
||||
|
||||
/// Renders a single chat message bubble with role-based styling.
|
||||
///
|
||||
/// User messages are displayed as plain text, right-aligned.
|
||||
/// Assistant messages are rendered as markdown with `pulldown-cmark`.
|
||||
/// System messages are hidden from the UI.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - The chat message to render
|
||||
#[component]
|
||||
pub fn ChatBubble(message: ChatMessage) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
// System messages are not rendered in the UI
|
||||
if message.role == ChatRole::System {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
let bubble_class = match message.role {
|
||||
ChatRole::User => "chat-bubble chat-bubble--user",
|
||||
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
|
||||
ChatRole::System => unreachable!(),
|
||||
};
|
||||
|
||||
let role_label = match message.role {
|
||||
ChatRole::User => t(l, "chat.you"),
|
||||
ChatRole::Assistant => t(l, "chat.assistant"),
|
||||
ChatRole::System => unreachable!(),
|
||||
};
|
||||
|
||||
// Format timestamp for display (show time only if today)
|
||||
let display_time = if message.timestamp.len() >= 16 {
|
||||
// Extract HH:MM from ISO 8601
|
||||
message.timestamp[11..16].to_string()
|
||||
} else {
|
||||
message.timestamp.clone()
|
||||
};
|
||||
|
||||
let is_assistant = message.role == ChatRole::Assistant;
|
||||
|
||||
rsx! {
|
||||
div { class: "{bubble_class}",
|
||||
div { class: "chat-bubble-header",
|
||||
span { class: "chat-bubble-role", "{role_label}" }
|
||||
span { class: "chat-bubble-time", "{display_time}" }
|
||||
}
|
||||
if is_assistant {
|
||||
// Render markdown for assistant messages
|
||||
div {
|
||||
class: "chat-bubble-content chat-prose",
|
||||
dangerous_inner_html: "{markdown_to_html(&message.content)}",
|
||||
}
|
||||
} else {
|
||||
div { class: "chat-bubble-content", "{message.content}" }
|
||||
}
|
||||
if !message.attachments.is_empty() {
|
||||
div { class: "chat-bubble-attachments",
|
||||
for att in &message.attachments {
|
||||
span { class: "chat-attachment", "{att.name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a streaming assistant message bubble.
|
||||
///
|
||||
/// While waiting for tokens, shows a "Thinking..." indicator with
|
||||
/// a pulsing dot animation. Once tokens arrive, renders them as
|
||||
/// markdown with a blinking cursor.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The accumulated streaming content so far
|
||||
#[component]
|
||||
pub fn StreamingBubble(content: String) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
if content.is_empty() {
|
||||
// Thinking state -- no tokens yet
|
||||
rsx! {
|
||||
div { class: "chat-bubble chat-bubble--assistant chat-bubble--thinking",
|
||||
div { class: "chat-thinking",
|
||||
span { class: "chat-thinking-dots",
|
||||
span { class: "chat-dot" }
|
||||
span { class: "chat-dot" }
|
||||
span { class: "chat-dot" }
|
||||
}
|
||||
span { class: "chat-thinking-text",
|
||||
"{t(l, \"chat.thinking\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let html = markdown_to_html(&content);
|
||||
rsx! {
|
||||
div { class: "chat-bubble chat-bubble--assistant chat-bubble--streaming",
|
||||
div { class: "chat-bubble-header",
|
||||
span { class: "chat-bubble-role",
|
||||
"{t(l, \"chat.assistant\")}"
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "chat-bubble-content chat-prose",
|
||||
dangerous_inner_html: "{html}",
|
||||
}
|
||||
span { class: "chat-streaming-cursor" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
use crate::i18n::{t, Locale};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Chat input bar with a textarea and send button.
|
||||
///
|
||||
/// Enter sends the message; Shift+Enter inserts a newline.
|
||||
/// The input is disabled during streaming.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `input_text` - Two-way bound input text signal
|
||||
/// * `on_send` - Callback fired with the message text when sent
|
||||
/// * `is_streaming` - Whether to disable the input (streaming in progress)
|
||||
#[component]
|
||||
pub fn ChatInputBar(
|
||||
input_text: Signal<String>,
|
||||
on_send: EventHandler<String>,
|
||||
is_streaming: bool,
|
||||
) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let mut input = input_text;
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-input-bar",
|
||||
textarea {
|
||||
class: "chat-input",
|
||||
placeholder: "{t(l, \"chat.type_message\")}",
|
||||
disabled: is_streaming,
|
||||
rows: "1",
|
||||
value: "{input}",
|
||||
oninput: move |e: Event<FormData>| {
|
||||
input.set(e.value());
|
||||
},
|
||||
onkeypress: move |e: Event<KeyboardData>| {
|
||||
// Enter sends, Shift+Enter adds newline
|
||||
if e.key() == Key::Enter && !e.modifiers().shift() {
|
||||
e.prevent_default();
|
||||
let text = input.read().trim().to_string();
|
||||
if !text.is_empty() {
|
||||
on_send.call(text);
|
||||
input.set(String::new());
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "btn-primary chat-send-btn",
|
||||
disabled: is_streaming || input.read().trim().is_empty(),
|
||||
onclick: move |_| {
|
||||
let text = input.read().trim().to_string();
|
||||
if !text.is_empty() {
|
||||
on_send.call(text);
|
||||
input.set(String::new());
|
||||
}
|
||||
},
|
||||
if is_streaming {
|
||||
// Stop icon during streaming
|
||||
dioxus_free_icons::Icon {
|
||||
icon: dioxus_free_icons::icons::fa_solid_icons::FaStop,
|
||||
width: 16, height: 16,
|
||||
}
|
||||
} else {
|
||||
dioxus_free_icons::Icon {
|
||||
icon: dioxus_free_icons::icons::fa_solid_icons::FaPaperPlane,
|
||||
width: 16, height: 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
use crate::components::{ChatBubble, StreamingBubble};
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::models::ChatMessage;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Scrollable message list that renders all messages in a chat session.
|
||||
///
|
||||
/// Auto-scrolls to the bottom when new messages arrive or during streaming.
|
||||
/// Shows a streaming bubble with a blinking cursor when `is_streaming` is true.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `messages` - All loaded messages for the current session
|
||||
/// * `streaming_content` - Accumulated content from the SSE stream
|
||||
/// * `is_streaming` - Whether a response is currently streaming
|
||||
#[component]
|
||||
pub fn ChatMessageList(
|
||||
messages: Vec<ChatMessage>,
|
||||
streaming_content: String,
|
||||
is_streaming: bool,
|
||||
) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "chat-message-list",
|
||||
id: "chat-message-list",
|
||||
if messages.is_empty() && !is_streaming {
|
||||
div { class: "chat-empty",
|
||||
p { "{t(l, \"chat.send_to_start\")}" }
|
||||
}
|
||||
}
|
||||
for msg in &messages {
|
||||
ChatBubble { key: "{msg.id}", message: msg.clone() }
|
||||
}
|
||||
if is_streaming {
|
||||
StreamingBubble { content: streaming_content }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
use crate::i18n::{t, Locale};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Dropdown bar for selecting the LLM model for the current chat session.
|
||||
///
|
||||
/// Displays the currently selected model and a list of available models
|
||||
/// from the Ollama instance. Fires `on_change` when the user selects
|
||||
/// a different model.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `selected_model` - The currently active model ID
|
||||
/// * `available_models` - List of model names from Ollama
|
||||
/// * `on_change` - Callback fired with the new model name
|
||||
#[component]
|
||||
pub fn ChatModelSelector(
|
||||
selected_model: String,
|
||||
available_models: Vec<String>,
|
||||
on_change: EventHandler<String>,
|
||||
) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-model-bar",
|
||||
label { class: "chat-model-label",
|
||||
"{t(l, \"chat.model_label\")}"
|
||||
}
|
||||
select {
|
||||
class: "chat-model-select",
|
||||
value: "{selected_model}",
|
||||
onchange: move |e: Event<FormData>| {
|
||||
on_change.call(e.value());
|
||||
},
|
||||
for model in &available_models {
|
||||
option {
|
||||
value: "{model}",
|
||||
selected: *model == selected_model,
|
||||
"{model}"
|
||||
}
|
||||
}
|
||||
if available_models.is_empty() {
|
||||
option { disabled: true,
|
||||
"{t(l, \"chat.no_models\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
use crate::i18n::{t, tw, Locale};
|
||||
use crate::models::{ChatNamespace, ChatSession};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Chat sidebar displaying grouped session list with actions.
|
||||
///
|
||||
/// Sessions are split into "News Chats" and "General" sections.
|
||||
/// Each session item shows the title and relative date, with
|
||||
/// rename and delete actions on hover.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `sessions` - All chat sessions for the user
|
||||
/// * `active_session_id` - Currently selected session ID (highlighted)
|
||||
/// * `on_select` - Callback when a session is clicked
|
||||
/// * `on_new` - Callback to create a new chat session
|
||||
/// * `on_rename` - Callback with `(session_id, new_title)`
|
||||
/// * `on_delete` - Callback with `session_id`
|
||||
#[component]
|
||||
pub fn ChatSidebar(
|
||||
sessions: Vec<ChatSession>,
|
||||
active_session_id: Option<String>,
|
||||
on_select: EventHandler<String>,
|
||||
on_new: EventHandler<()>,
|
||||
on_rename: EventHandler<(String, String)>,
|
||||
on_delete: EventHandler<String>,
|
||||
) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
// Split sessions by namespace
|
||||
let news_sessions: Vec<&ChatSession> = sessions
|
||||
.iter()
|
||||
.filter(|s| s.namespace == ChatNamespace::News)
|
||||
.collect();
|
||||
let general_sessions: Vec<&ChatSession> = sessions
|
||||
.iter()
|
||||
.filter(|s| s.namespace == ChatNamespace::General)
|
||||
.collect();
|
||||
|
||||
// Signal for inline rename state: Option<(session_id, current_value)>
|
||||
let rename_state: Signal<Option<(String, String)>> = use_signal(|| None);
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-sidebar-panel",
|
||||
div { class: "chat-sidebar-header",
|
||||
h3 { "{t(l, \"chat.conversations\")}" }
|
||||
button {
|
||||
class: "btn-icon",
|
||||
title: "{t(l, \"chat.new_chat\")}",
|
||||
onclick: move |_| on_new.call(()),
|
||||
"+"
|
||||
}
|
||||
}
|
||||
div { class: "chat-session-list",
|
||||
// News Chats section
|
||||
if !news_sessions.is_empty() {
|
||||
div { class: "chat-namespace-header",
|
||||
"{t(l, \"chat.news_chats\")}"
|
||||
}
|
||||
for session in &news_sessions {
|
||||
SessionItem {
|
||||
session: (*session).clone(),
|
||||
is_active: active_session_id.as_deref() == Some(&session.id),
|
||||
rename_state: rename_state,
|
||||
on_select: on_select,
|
||||
on_rename: on_rename,
|
||||
on_delete: on_delete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// General section
|
||||
div { class: "chat-namespace-header",
|
||||
if news_sessions.is_empty() {
|
||||
"{t(l, \"chat.all_chats\")}"
|
||||
} else {
|
||||
"{t(l, \"chat.general\")}"
|
||||
}
|
||||
}
|
||||
if general_sessions.is_empty() {
|
||||
p { class: "chat-empty-hint",
|
||||
"{t(l, \"chat.no_conversations\")}"
|
||||
}
|
||||
}
|
||||
for session in &general_sessions {
|
||||
SessionItem {
|
||||
session: (*session).clone(),
|
||||
is_active: active_session_id.as_deref() == Some(&session.id),
|
||||
rename_state: rename_state,
|
||||
on_select: on_select,
|
||||
on_rename: on_rename,
|
||||
on_delete: on_delete,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual session item component. Handles rename inline editing.
|
||||
#[component]
|
||||
fn SessionItem(
|
||||
session: ChatSession,
|
||||
is_active: bool,
|
||||
rename_state: Signal<Option<(String, String)>>,
|
||||
on_select: EventHandler<String>,
|
||||
on_rename: EventHandler<(String, String)>,
|
||||
on_delete: EventHandler<String>,
|
||||
) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let mut rename_sig = rename_state;
|
||||
let item_class = if is_active {
|
||||
"chat-session-item chat-session-item--active"
|
||||
} else {
|
||||
"chat-session-item"
|
||||
};
|
||||
|
||||
let is_renaming = rename_sig
|
||||
.read()
|
||||
.as_ref()
|
||||
.is_some_and(|(id, _)| id == &session.id);
|
||||
|
||||
let session_id = session.id.clone();
|
||||
let session_title = session.title.clone();
|
||||
let date_display = format_relative_date(&session.updated_at, l);
|
||||
|
||||
if is_renaming {
|
||||
let rename_value = rename_sig
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|(_, v)| v.clone())
|
||||
.unwrap_or_default();
|
||||
let sid = session_id.clone();
|
||||
|
||||
rsx! {
|
||||
div { class: "{item_class}",
|
||||
input {
|
||||
class: "chat-session-rename-input",
|
||||
r#type: "text",
|
||||
value: "{rename_value}",
|
||||
autofocus: true,
|
||||
oninput: move |e: Event<FormData>| {
|
||||
let val = e.value();
|
||||
let id = sid.clone();
|
||||
rename_sig.set(Some((id, val)));
|
||||
},
|
||||
onkeypress: move |e: Event<KeyboardData>| {
|
||||
if e.key() == Key::Enter {
|
||||
if let Some((id, val)) = rename_sig.read().clone() {
|
||||
if !val.trim().is_empty() {
|
||||
on_rename.call((id, val));
|
||||
}
|
||||
}
|
||||
rename_sig.set(None);
|
||||
} else if e.key() == Key::Escape {
|
||||
rename_sig.set(None);
|
||||
}
|
||||
},
|
||||
onfocusout: move |_| {
|
||||
if let Some((ref id, ref val)) = *rename_sig.read() {
|
||||
if !val.trim().is_empty() {
|
||||
on_rename.call((id.clone(), val.clone()));
|
||||
}
|
||||
}
|
||||
rename_sig.set(None);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let sid_select = session_id.clone();
|
||||
let sid_delete = session_id.clone();
|
||||
let sid_rename = session_id.clone();
|
||||
let title_for_rename = session_title.clone();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "{item_class}",
|
||||
onclick: move |_| on_select.call(sid_select.clone()),
|
||||
div { class: "chat-session-info",
|
||||
span { class: "chat-session-title", "{session_title}" }
|
||||
span { class: "chat-session-date", "{date_display}" }
|
||||
}
|
||||
div { class: "chat-session-actions",
|
||||
button {
|
||||
class: "btn-icon-sm",
|
||||
title: "{t(l, \"common.rename\")}",
|
||||
onclick: move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
rename_sig.set(Some((
|
||||
sid_rename.clone(),
|
||||
title_for_rename.clone(),
|
||||
)));
|
||||
},
|
||||
dioxus_free_icons::Icon {
|
||||
icon: dioxus_free_icons::icons::fa_solid_icons::FaPen,
|
||||
width: 12, height: 12,
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn-icon-sm btn-icon-danger",
|
||||
title: "{t(l, \"common.delete\")}",
|
||||
onclick: move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
on_delete.call(sid_delete.clone());
|
||||
},
|
||||
dioxus_free_icons::Icon {
|
||||
icon: dioxus_free_icons::icons::fa_solid_icons::FaTrash,
|
||||
width: 12, height: 12,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an ISO 8601 timestamp as a relative date string.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `iso` - ISO 8601 timestamp string
|
||||
/// * `locale` - The locale to use for translated time labels
|
||||
fn format_relative_date(iso: &str, locale: Locale) -> String {
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) {
|
||||
let now = chrono::Utc::now();
|
||||
let diff = now.signed_duration_since(dt);
|
||||
|
||||
if diff.num_minutes() < 1 {
|
||||
t(locale, "chat.just_now")
|
||||
} else if diff.num_hours() < 1 {
|
||||
tw(
|
||||
locale,
|
||||
"chat.minutes_ago",
|
||||
&[("n", &diff.num_minutes().to_string())],
|
||||
)
|
||||
} else if diff.num_hours() < 24 {
|
||||
tw(
|
||||
locale,
|
||||
"chat.hours_ago",
|
||||
&[("n", &diff.num_hours().to_string())],
|
||||
)
|
||||
} else if diff.num_days() < 7 {
|
||||
tw(
|
||||
locale,
|
||||
"chat.days_ago",
|
||||
&[("n", &diff.num_days().to_string())],
|
||||
)
|
||||
} else {
|
||||
dt.format("%b %d").to_string()
|
||||
}
|
||||
} else {
|
||||
iso.to_string()
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::models::KnowledgeFile;
|
||||
|
||||
/// Renders a table row for a knowledge base file.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file` - The knowledge file data to render
|
||||
/// * `on_delete` - Callback fired when the delete button is clicked
|
||||
#[component]
|
||||
pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler<String>) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
// Format file size for human readability (Python devs: similar to humanize.naturalsize)
|
||||
let size_display = format_size(file.size_bytes);
|
||||
|
||||
rsx! {
|
||||
tr { class: "file-row",
|
||||
td { class: "file-row-name",
|
||||
span { class: "file-row-icon", "{file.kind.icon()}" }
|
||||
"{file.name}"
|
||||
}
|
||||
td { "{file.kind.label()}" }
|
||||
td { "{size_display}" }
|
||||
td { "{file.chunk_count} {t(l, \"common.chunks\")}" }
|
||||
td { "{file.uploaded_at}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn-icon btn-danger",
|
||||
onclick: {
|
||||
let id = file.id.clone();
|
||||
move |_| on_delete.call(id.clone())
|
||||
},
|
||||
"{t(l, \"common.delete\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a byte count into a human-readable string (e.g. "1.2 MB").
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.1} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
|
||||
BsGlobe2, BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill,
|
||||
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2,
|
||||
BsGrid, BsHouseDoor, BsMoonFill, BsSunFill,
|
||||
};
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::Route;
|
||||
|
||||
/// Destination for a sidebar link: either an internal route or an external URL.
|
||||
enum NavTarget {
|
||||
/// Internal Dioxus route (rendered as `Link { to: route }`).
|
||||
Internal(Route),
|
||||
/// External URL opened in a new tab (rendered as `<a href>`).
|
||||
External(&'static str),
|
||||
}
|
||||
|
||||
/// Navigation entry for the sidebar.
|
||||
///
|
||||
/// `key` is a stable identifier used for active-route detection and never
|
||||
@@ -15,7 +23,7 @@ use crate::Route;
|
||||
struct NavItem {
|
||||
key: &'static str,
|
||||
label: String,
|
||||
route: Route,
|
||||
target: NavTarget,
|
||||
/// Bootstrap icon element rendered beside the label.
|
||||
icon: Element,
|
||||
}
|
||||
@@ -45,43 +53,32 @@ pub fn Sidebar(
|
||||
NavItem {
|
||||
key: "dashboard",
|
||||
label: t(locale_val, "nav.dashboard"),
|
||||
route: Route::DashboardPage {},
|
||||
target: NavTarget::Internal(Route::DashboardPage {}),
|
||||
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "providers",
|
||||
label: t(locale_val, "nav.providers"),
|
||||
route: Route::ProvidersPage {},
|
||||
target: NavTarget::Internal(Route::ProvidersPage {}),
|
||||
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "chat",
|
||||
label: t(locale_val, "nav.chat"),
|
||||
route: Route::ChatPage {},
|
||||
// Opens LibreChat in a new tab; SSO via shared Keycloak realm.
|
||||
target: NavTarget::External("http://localhost:3080"),
|
||||
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "tools",
|
||||
label: t(locale_val, "nav.tools"),
|
||||
route: Route::ToolsPage {},
|
||||
icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "knowledge_base",
|
||||
label: t(locale_val, "nav.knowledge_base"),
|
||||
route: Route::KnowledgePage {},
|
||||
icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "developer",
|
||||
label: t(locale_val, "nav.developer"),
|
||||
route: Route::AgentsPage {},
|
||||
target: NavTarget::Internal(Route::AgentsPage {}),
|
||||
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "organization",
|
||||
label: t(locale_val, "nav.organization"),
|
||||
route: Route::OrgPricingPage {},
|
||||
target: NavTarget::Internal(Route::OrgPricingPage {}),
|
||||
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
|
||||
},
|
||||
];
|
||||
@@ -100,25 +97,45 @@ pub fn Sidebar(
|
||||
nav { class: "sidebar-nav",
|
||||
for item in nav_items {
|
||||
{
|
||||
// Active detection for nested routes: highlight the parent nav
|
||||
// item when any child route within the nested shell is active.
|
||||
let is_active = match ¤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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::models::McpTool;
|
||||
|
||||
/// Renders an MCP tool card with name, description, status indicator, and toggle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `tool` - The MCP tool data to render
|
||||
/// * `on_toggle` - Callback fired when the enable/disable toggle is clicked
|
||||
#[component]
|
||||
pub fn ToolCard(tool: McpTool, on_toggle: EventHandler<String>) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let status_class = format!("tool-status tool-status--{}", tool.status.css_class());
|
||||
let toggle_class = if tool.enabled {
|
||||
"tool-toggle tool-toggle--on"
|
||||
} else {
|
||||
"tool-toggle tool-toggle--off"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "tool-card",
|
||||
div { class: "tool-card-header",
|
||||
div { class: "tool-card-icon", "\u{2699}" }
|
||||
span { class: "{status_class}", "" }
|
||||
}
|
||||
h3 { class: "tool-card-name", "{tool.name}" }
|
||||
p { class: "tool-card-desc", "{tool.description}" }
|
||||
div { class: "tool-card-footer",
|
||||
span { class: "tool-card-category", "{tool.category.label()}" }
|
||||
button {
|
||||
class: "{toggle_class}",
|
||||
onclick: {
|
||||
let id = tool.id.clone();
|
||||
move |_| on_toggle.call(id.clone())
|
||||
},
|
||||
if tool.enabled {
|
||||
"{t(l, \"common.on\")}"
|
||||
} else {
|
||||
"{t(l, \"common.off\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
//! SSE streaming endpoint for chat completions.
|
||||
//!
|
||||
//! Exposes `GET /api/chat/stream?session_id=<id>` which:
|
||||
//! 1. Authenticates the user via tower-sessions
|
||||
//! 2. Loads the session and its messages from MongoDB
|
||||
//! 3. Streams LLM tokens as SSE events to the frontend
|
||||
//! 4. Persists the complete assistant message on finish
|
||||
|
||||
use axum::{
|
||||
extract::Query,
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
IntoResponse, Response,
|
||||
},
|
||||
Extension,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use super::{
|
||||
auth::LOGGED_IN_USER_SESS_KEY,
|
||||
chat::{doc_to_chat_message, doc_to_chat_session},
|
||||
provider_client::{send_chat_request, ProviderMessage},
|
||||
server_state::ServerState,
|
||||
state::UserStateInner,
|
||||
};
|
||||
use crate::models::{ChatMessage, ChatRole};
|
||||
|
||||
/// Query parameters for the SSE stream endpoint.
|
||||
#[derive(Deserialize)]
|
||||
pub struct StreamQuery {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
/// SSE streaming handler for chat completions.
|
||||
///
|
||||
/// Reads the session's provider/model config, loads conversation history,
|
||||
/// sends to the LLM with `stream: true`, and forwards tokens as SSE events.
|
||||
///
|
||||
/// # SSE Event Format
|
||||
///
|
||||
/// - `data: {"token": "..."}` -- partial token
|
||||
/// - `data: {"done": true, "message_id": "..."}` -- stream complete
|
||||
/// - `data: {"error": "..."}` -- on failure
|
||||
pub async fn chat_stream_handler(
|
||||
session: Session,
|
||||
Extension(state): Extension<ServerState>,
|
||||
Query(params): Query<StreamQuery>,
|
||||
) -> Response {
|
||||
// Authenticate
|
||||
let user_state: Option<UserStateInner> = match session.get(LOGGED_IN_USER_SESS_KEY).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::UNAUTHORIZED, "session error").into_response(),
|
||||
};
|
||||
let user = match user_state {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::UNAUTHORIZED, "not authenticated").into_response(),
|
||||
};
|
||||
|
||||
// Load session from MongoDB (raw document to handle ObjectId -> String)
|
||||
let chat_session = {
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
let oid = match ObjectId::parse_str(¶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<ProviderMessage> = messages
|
||||
.iter()
|
||||
.map(|m| ProviderMessage {
|
||||
role: match m.role {
|
||||
ChatRole::User => "user".to_string(),
|
||||
ChatRole::Assistant => "assistant".to_string(),
|
||||
ChatRole::System => "system".to_string(),
|
||||
},
|
||||
content: m.content.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let provider = chat_session.provider.clone();
|
||||
let model = chat_session.model.clone();
|
||||
let session_id = params.session_id.clone();
|
||||
|
||||
// TODO: Load user's API key from preferences for non-Ollama providers.
|
||||
// For now, Ollama (no key needed) is the default path.
|
||||
let api_key: Option<String> = None;
|
||||
|
||||
// Send streaming request to LLM
|
||||
let llm_resp = match send_chat_request(
|
||||
&state,
|
||||
&provider,
|
||||
&model,
|
||||
&provider_msgs,
|
||||
api_key.as_deref(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!("LLM request failed: {e}");
|
||||
return (StatusCode::BAD_GATEWAY, "LLM request failed").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !llm_resp.status().is_success() {
|
||||
let status = llm_resp.status();
|
||||
let body = llm_resp.text().await.unwrap_or_default();
|
||||
tracing::error!("LLM returned {status}: {body}");
|
||||
return (StatusCode::BAD_GATEWAY, format!("LLM error: {status}")).into_response();
|
||||
}
|
||||
|
||||
// Stream the response bytes as SSE events
|
||||
let byte_stream = llm_resp.bytes_stream();
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(byte_stream, state_clone, session_id, provider.clone());
|
||||
|
||||
Sse::new(sse_stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Build an SSE stream that parses OpenAI-compatible streaming chunks
|
||||
/// and emits token events. On completion, persists the full message.
|
||||
fn build_sse_stream(
|
||||
byte_stream: impl Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
|
||||
state: ServerState,
|
||||
session_id: String,
|
||||
_provider: String,
|
||||
) -> impl Stream<Item = Result<Event, std::convert::Infallible>> + Send + 'static {
|
||||
// Use an async stream to process chunks
|
||||
async_stream::stream! {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut full_content = String::new();
|
||||
let mut buffer = String::new();
|
||||
|
||||
// Pin the byte stream for iteration
|
||||
let mut stream = std::pin::pin!(byte_stream);
|
||||
|
||||
while let Some(chunk_result) = StreamExt::next(&mut stream).await {
|
||||
let chunk = match chunk_result {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
let err_json = serde_json::json!({ "error": e.to_string() });
|
||||
yield Ok(Event::default().data(err_json.to_string()));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
buffer.push_str(&text);
|
||||
|
||||
// Process complete SSE lines from the buffer.
|
||||
// OpenAI streaming format: `data: {...}\n\n`
|
||||
while let Some(line_end) = buffer.find('\n') {
|
||||
let line = buffer[..line_end].trim().to_string();
|
||||
buffer = buffer[line_end + 1..].to_string();
|
||||
|
||||
if line.is_empty() || line == "data: [DONE]" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(json_str) = line.strip_prefix("data: ") {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||||
// Extract token from OpenAI delta format
|
||||
if let Some(token) = parsed["choices"][0]["delta"]["content"].as_str() {
|
||||
full_content.push_str(token);
|
||||
let event_data = serde_json::json!({ "token": token });
|
||||
yield Ok(Event::default().data(event_data.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the complete assistant message
|
||||
if !full_content.is_empty() {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let message = ChatMessage {
|
||||
id: String::new(),
|
||||
session_id: session_id.clone(),
|
||||
role: ChatRole::Assistant,
|
||||
content: full_content,
|
||||
attachments: Vec::new(),
|
||||
timestamp: now.clone(),
|
||||
};
|
||||
|
||||
let msg_id = match state.db.chat_messages().insert_one(&message).await {
|
||||
Ok(result) => result
|
||||
.inserted_id
|
||||
.as_object_id()
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::error!("failed to persist assistant message: {e}");
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Update session timestamp
|
||||
if let Ok(session_oid) =
|
||||
mongodb::bson::oid::ObjectId::parse_str(&session_id)
|
||||
{
|
||||
let _ = state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.update_one(
|
||||
mongodb::bson::doc! { "_id": session_oid },
|
||||
mongodb::bson::doc! { "$set": { "updated_at": &now } },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let done_data = serde_json::json!({ "done": true, "message_id": msg_id });
|
||||
yield Ok(Event::default().data(done_data.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
use crate::components::{
|
||||
ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar,
|
||||
};
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::infrastructure::chat::{
|
||||
chat_complete, create_chat_session, delete_chat_session, list_chat_messages,
|
||||
list_chat_sessions, rename_chat_session, save_chat_message,
|
||||
};
|
||||
use crate::infrastructure::ollama::get_ollama_status;
|
||||
use crate::models::{ChatMessage, ChatRole};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// LibreChat-inspired chat interface with MongoDB persistence and SSE streaming.
|
||||
///
|
||||
/// Layout: sidebar (session list) | main panel (model selector, messages, input).
|
||||
/// Messages stream via `EventSource` connected to `/api/chat/stream`.
|
||||
#[component]
|
||||
pub fn ChatPage() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
// ---- Signals ----
|
||||
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut messages: Signal<Vec<ChatMessage>> = use_signal(Vec::new);
|
||||
let mut input_text: Signal<String> = use_signal(String::new);
|
||||
let mut is_streaming: Signal<bool> = use_signal(|| false);
|
||||
let mut streaming_content: Signal<String> = use_signal(String::new);
|
||||
let mut selected_model: Signal<String> = use_signal(String::new);
|
||||
|
||||
// ---- Resources ----
|
||||
// Load sessions list (re-fetches when dependency changes)
|
||||
let mut sessions_resource =
|
||||
use_resource(move || async move { list_chat_sessions().await.unwrap_or_default() });
|
||||
|
||||
// Load available Ollama models
|
||||
let models_resource = use_resource(move || async move {
|
||||
get_ollama_status(String::new())
|
||||
.await
|
||||
.map(|s| s.models)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let sessions = sessions_resource.read().clone().unwrap_or_default();
|
||||
|
||||
let available_models = models_resource.read().clone().unwrap_or_default();
|
||||
|
||||
// Set default model if not yet chosen
|
||||
if selected_model.read().is_empty() {
|
||||
if let Some(first) = available_models.first() {
|
||||
selected_model.set(first.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Load messages when active session changes.
|
||||
// The signal read MUST happen inside the closure so use_resource
|
||||
// tracks it as a dependency and re-fetches on change.
|
||||
let _messages_loader = use_resource(move || {
|
||||
let session_id = active_session_id.read().clone();
|
||||
async move {
|
||||
if let Some(id) = session_id {
|
||||
match list_chat_messages(id).await {
|
||||
Ok(msgs) => messages.set(msgs),
|
||||
Err(e) => tracing::error!("failed to load messages: {e}"),
|
||||
}
|
||||
} else {
|
||||
messages.set(Vec::new());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Callbacks ----
|
||||
// Create new session
|
||||
let on_new = move |_: ()| {
|
||||
let model = selected_model.read().clone();
|
||||
let new_chat_title = t(l, "chat.new_chat");
|
||||
spawn(async move {
|
||||
match create_chat_session(
|
||||
new_chat_title,
|
||||
"General".to_string(),
|
||||
"ollama".to_string(),
|
||||
model,
|
||||
String::new(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
active_session_id.set(Some(session.id));
|
||||
messages.set(Vec::new());
|
||||
sessions_resource.restart();
|
||||
}
|
||||
Err(e) => tracing::error!("failed to create session: {e}"),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Select session
|
||||
let on_select = move |id: String| {
|
||||
active_session_id.set(Some(id));
|
||||
};
|
||||
|
||||
// Rename session
|
||||
let on_rename = move |(id, new_title): (String, String)| {
|
||||
spawn(async move {
|
||||
if let Err(e) = rename_chat_session(id, new_title).await {
|
||||
tracing::error!("failed to rename: {e}");
|
||||
}
|
||||
sessions_resource.restart();
|
||||
});
|
||||
};
|
||||
|
||||
// Delete session
|
||||
let on_delete = move |id: String| {
|
||||
let is_active = active_session_id.read().as_deref() == Some(&id);
|
||||
spawn(async move {
|
||||
if let Err(e) = delete_chat_session(id).await {
|
||||
tracing::error!("failed to delete: {e}");
|
||||
}
|
||||
if is_active {
|
||||
active_session_id.set(None);
|
||||
messages.set(Vec::new());
|
||||
}
|
||||
sessions_resource.restart();
|
||||
});
|
||||
};
|
||||
|
||||
// Model change
|
||||
let on_model_change = move |model: String| {
|
||||
selected_model.set(model);
|
||||
};
|
||||
|
||||
// Send message
|
||||
let on_send = move |text: String| {
|
||||
let session_id = active_session_id.read().clone();
|
||||
let model = selected_model.read().clone();
|
||||
|
||||
spawn(async move {
|
||||
// If no active session, create one first
|
||||
let sid = if let Some(id) = session_id {
|
||||
id
|
||||
} else {
|
||||
match create_chat_session(
|
||||
// Use first ~50 chars of message as title
|
||||
text.chars().take(50).collect::<String>(),
|
||||
"General".to_string(),
|
||||
"ollama".to_string(),
|
||||
model,
|
||||
String::new(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
let id = session.id.clone();
|
||||
active_session_id.set(Some(id.clone()));
|
||||
sessions_resource.restart();
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to create session: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save user message
|
||||
match save_chat_message(sid.clone(), "user".to_string(), text).await {
|
||||
Ok(msg) => {
|
||||
messages.write().push(msg);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to save message: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show thinking indicator
|
||||
is_streaming.set(true);
|
||||
streaming_content.set(String::new());
|
||||
|
||||
// Build message history as JSON for the server
|
||||
let history: Vec<serde_json::Value> = messages
|
||||
.read()
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let role = match m.role {
|
||||
ChatRole::User => "user",
|
||||
ChatRole::Assistant => "assistant",
|
||||
ChatRole::System => "system",
|
||||
};
|
||||
serde_json::json!({"role": role, "content": m.content})
|
||||
})
|
||||
.collect();
|
||||
let messages_json = serde_json::to_string(&history).unwrap_or_default();
|
||||
|
||||
// Non-streaming completion
|
||||
match chat_complete(sid.clone(), messages_json).await {
|
||||
Ok(response) => {
|
||||
// Save assistant message
|
||||
match save_chat_message(sid, "assistant".to_string(), response).await {
|
||||
Ok(msg) => {
|
||||
messages.write().push(msg);
|
||||
}
|
||||
Err(e) => tracing::error!("failed to save assistant msg: {e}"),
|
||||
}
|
||||
sessions_resource.restart();
|
||||
}
|
||||
Err(e) => tracing::error!("chat completion failed: {e}"),
|
||||
}
|
||||
is_streaming.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Action bar state ----
|
||||
let has_messages = !messages.read().is_empty();
|
||||
let has_assistant_message = messages
|
||||
.read()
|
||||
.iter()
|
||||
.any(|m| m.role == ChatRole::Assistant);
|
||||
let has_user_message = messages.read().iter().any(|m| m.role == ChatRole::User);
|
||||
|
||||
// Copy last assistant response to clipboard
|
||||
let on_copy = move |_: ()| {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let last_assistant = messages
|
||||
.read()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == ChatRole::Assistant)
|
||||
.map(|m| m.content.clone());
|
||||
if let Some(text) = last_assistant {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let clipboard = window.navigator().clipboard();
|
||||
let _ = clipboard.write_text(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Copy full conversation as text to clipboard
|
||||
let on_share = move |_: ()| {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let you_label = t(l, "chat.you");
|
||||
let assistant_label = t(l, "chat.assistant");
|
||||
let text: String = messages
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|m| m.role != ChatRole::System)
|
||||
.map(|m| {
|
||||
let label = match m.role {
|
||||
ChatRole::User => &you_label,
|
||||
ChatRole::Assistant => &assistant_label,
|
||||
// Filtered out above, but required for exhaustive match
|
||||
ChatRole::System => "System",
|
||||
};
|
||||
format!("{label}:\n{}\n", m.content)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if let Some(window) = web_sys::window() {
|
||||
let clipboard = window.navigator().clipboard();
|
||||
let _ = clipboard.write_text(&text);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Edit last user message: remove it and place text back in input
|
||||
let on_edit = move |_: ()| {
|
||||
let last_user = messages
|
||||
.read()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == ChatRole::User)
|
||||
.map(|m| m.content.clone());
|
||||
if let Some(text) = last_user {
|
||||
// Remove the last user message (and any assistant reply after it)
|
||||
let mut msgs = messages.read().clone();
|
||||
if let Some(pos) = msgs.iter().rposition(|m| m.role == ChatRole::User) {
|
||||
msgs.truncate(pos);
|
||||
messages.set(msgs);
|
||||
}
|
||||
input_text.set(text);
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll to bottom when messages or streaming content changes
|
||||
let msg_count = messages.read().len();
|
||||
let stream_len = streaming_content.read().len();
|
||||
use_effect(move || {
|
||||
// Track dependencies
|
||||
let _ = msg_count;
|
||||
let _ = stream_len;
|
||||
// Scroll the message list to bottom
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(doc) = window.document() {
|
||||
if let Some(el) = doc.get_element_by_id("chat-message-list") {
|
||||
let height = el.scroll_height();
|
||||
el.set_scroll_top(height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
section { class: "chat-page",
|
||||
ChatSidebar {
|
||||
sessions: sessions,
|
||||
active_session_id: active_session_id.read().clone(),
|
||||
on_select: on_select,
|
||||
on_new: on_new,
|
||||
on_rename: on_rename,
|
||||
on_delete: on_delete,
|
||||
}
|
||||
div { class: "chat-main-panel",
|
||||
ChatModelSelector {
|
||||
selected_model: selected_model.read().clone(),
|
||||
available_models: available_models,
|
||||
on_change: on_model_change,
|
||||
}
|
||||
ChatMessageList {
|
||||
messages: messages.read().clone(),
|
||||
streaming_content: streaming_content.read().clone(),
|
||||
is_streaming: *is_streaming.read(),
|
||||
}
|
||||
ChatActionBar {
|
||||
on_copy: on_copy,
|
||||
on_share: on_share,
|
||||
on_edit: on_edit,
|
||||
has_messages: has_messages,
|
||||
has_assistant_message: has_assistant_message,
|
||||
has_user_message: has_user_message,
|
||||
}
|
||||
ChatInputBar {
|
||||
input_text: input_text,
|
||||
on_send: on_send,
|
||||
is_streaming: *is_streaming.read(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{FileRow, PageHeader};
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::models::{FileKind, KnowledgeFile};
|
||||
|
||||
/// Knowledge Base page with file explorer table and upload controls.
|
||||
///
|
||||
/// Displays uploaded documents used for RAG retrieval with their
|
||||
/// metadata, chunk counts, and management actions.
|
||||
#[component]
|
||||
pub fn KnowledgePage() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let mut files = use_signal(mock_files);
|
||||
let mut search_query = use_signal(String::new);
|
||||
|
||||
// Filter files by search query (case-insensitive name match)
|
||||
let query = search_query.read().to_lowercase();
|
||||
let filtered: Vec<_> = files
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Remove a file by ID
|
||||
let on_delete = move |id: String| {
|
||||
files.write().retain(|f| f.id != id);
|
||||
};
|
||||
|
||||
rsx! {
|
||||
section { class: "knowledge-page",
|
||||
PageHeader {
|
||||
title: t(l, "knowledge.title"),
|
||||
subtitle: t(l, "knowledge.subtitle"),
|
||||
actions: rsx! {
|
||||
button { class: "btn-primary", {t(l, "common.upload_file")} }
|
||||
},
|
||||
}
|
||||
div { class: "knowledge-toolbar",
|
||||
input {
|
||||
class: "form-input knowledge-search",
|
||||
r#type: "text",
|
||||
placeholder: t(l, "knowledge.search_placeholder"),
|
||||
value: "{search_query}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
search_query.set(evt.value());
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "knowledge-table-wrapper",
|
||||
table { class: "knowledge-table",
|
||||
thead {
|
||||
tr {
|
||||
th { {t(l, "knowledge.name")} }
|
||||
th { {t(l, "knowledge.type")} }
|
||||
th { {t(l, "knowledge.size")} }
|
||||
th { {t(l, "knowledge.chunks")} }
|
||||
th { {t(l, "knowledge.uploaded")} }
|
||||
th { {t(l, "knowledge.actions")} }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for file in filtered {
|
||||
FileRow { key: "{file.id}", file, on_delete }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock knowledge base files.
|
||||
fn mock_files() -> Vec<KnowledgeFile> {
|
||||
vec![
|
||||
KnowledgeFile {
|
||||
id: "f1".into(),
|
||||
name: "company-handbook.pdf".into(),
|
||||
kind: FileKind::Pdf,
|
||||
size_bytes: 2_450_000,
|
||||
uploaded_at: "2026-02-15".into(),
|
||||
chunk_count: 142,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f2".into(),
|
||||
name: "api-reference.md".into(),
|
||||
kind: FileKind::Text,
|
||||
size_bytes: 89_000,
|
||||
uploaded_at: "2026-02-14".into(),
|
||||
chunk_count: 34,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f3".into(),
|
||||
name: "sales-data-q4.csv".into(),
|
||||
kind: FileKind::Spreadsheet,
|
||||
size_bytes: 1_200_000,
|
||||
uploaded_at: "2026-02-12".into(),
|
||||
chunk_count: 67,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f4".into(),
|
||||
name: "deployment-guide.pdf".into(),
|
||||
kind: FileKind::Pdf,
|
||||
size_bytes: 540_000,
|
||||
uploaded_at: "2026-02-10".into(),
|
||||
chunk_count: 28,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f5".into(),
|
||||
name: "onboarding-checklist.md".into(),
|
||||
kind: FileKind::Text,
|
||||
size_bytes: 12_000,
|
||||
uploaded_at: "2026-02-08".into(),
|
||||
chunk_count: 8,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f6".into(),
|
||||
name: "architecture-diagram.png".into(),
|
||||
kind: FileKind::Image,
|
||||
size_bytes: 3_800_000,
|
||||
uploaded_at: "2026-02-05".into(),
|
||||
chunk_count: 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{PageHeader, ToolCard};
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::models::{McpTool, ToolCategory, ToolStatus};
|
||||
|
||||
/// Tools page displaying a grid of MCP tool cards with toggle switches.
|
||||
///
|
||||
/// Shows all available MCP tools with their status and allows
|
||||
/// enabling/disabling them via toggle buttons.
|
||||
#[component]
|
||||
pub fn ToolsPage() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
// Track which tool IDs have been toggled off/on by the user.
|
||||
// The canonical tool definitions (including translated names) come
|
||||
// from `mock_tools(l)` on every render so they react to locale changes.
|
||||
let mut enabled_overrides = use_signal(HashMap::<String, bool>::new);
|
||||
|
||||
// Build the display list: translated names from mock_tools, with
|
||||
// enabled state merged from user overrides.
|
||||
let tool_list: Vec<McpTool> = mock_tools(l)
|
||||
.into_iter()
|
||||
.map(|mut tool| {
|
||||
if let Some(&enabled) = enabled_overrides.read().get(&tool.id) {
|
||||
tool.enabled = enabled;
|
||||
}
|
||||
tool
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Toggle a tool's enabled state by its ID.
|
||||
// Reads the current state from overrides (or falls back to the default
|
||||
// enabled value from mock_tools) and flips it.
|
||||
let on_toggle = move |id: String| {
|
||||
let defaults = mock_tools(l);
|
||||
let current = enabled_overrides
|
||||
.read()
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(|| {
|
||||
defaults
|
||||
.iter()
|
||||
.find(|tool| tool.id == id)
|
||||
.map(|tool| tool.enabled)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
enabled_overrides.write().insert(id, !current);
|
||||
};
|
||||
|
||||
rsx! {
|
||||
section { class: "tools-page",
|
||||
PageHeader {
|
||||
title: t(l, "tools.title"),
|
||||
subtitle: t(l, "tools.subtitle"),
|
||||
}
|
||||
div { class: "tools-grid",
|
||||
for tool in tool_list {
|
||||
ToolCard { key: "{tool.id}", tool, on_toggle }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock MCP tools for the tools grid with translated names.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `l` - The current locale for translating tool names and descriptions
|
||||
fn mock_tools(l: Locale) -> Vec<McpTool> {
|
||||
vec![
|
||||
McpTool {
|
||||
id: "calculator".into(),
|
||||
name: t(l, "tools.calculator"),
|
||||
description: t(l, "tools.calculator_desc"),
|
||||
category: ToolCategory::Compute,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "calculator".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "tavily".into(),
|
||||
name: t(l, "tools.tavily"),
|
||||
description: t(l, "tools.tavily_desc"),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "search".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "searxng".into(),
|
||||
name: t(l, "tools.searxng"),
|
||||
description: t(l, "tools.searxng_desc"),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "globe".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "file-reader".into(),
|
||||
name: t(l, "tools.file_reader"),
|
||||
description: t(l, "tools.file_reader_desc"),
|
||||
category: ToolCategory::FileSystem,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "file".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "code-exec".into(),
|
||||
name: t(l, "tools.code_executor"),
|
||||
description: t(l, "tools.code_executor_desc"),
|
||||
category: ToolCategory::Code,
|
||||
status: ToolStatus::Inactive,
|
||||
enabled: false,
|
||||
icon: "terminal".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "web-scraper".into(),
|
||||
name: t(l, "tools.web_scraper"),
|
||||
description: t(l, "tools.web_scraper_desc"),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "download".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "email".into(),
|
||||
name: t(l, "tools.email_sender"),
|
||||
description: t(l, "tools.email_sender_desc"),
|
||||
category: ToolCategory::Communication,
|
||||
status: ToolStatus::Inactive,
|
||||
enabled: false,
|
||||
icon: "mail".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "git".into(),
|
||||
name: t(l, "tools.git_ops"),
|
||||
description: t(l, "tools.git_ops_desc"),
|
||||
category: ToolCategory::Code,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "git".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user