Compare commits
6 Commits
20b3279bb5
...
feat/keycl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6b2dfe19b | ||
| 50237f5377 | |||
| 4acb4558b7 | |||
| e68f840f2b | |||
| 5ce600e32b | |||
| 5399afd748 |
76
.env.example
76
.env.example
@@ -1,16 +1,80 @@
|
||||
# Keycloak Configuration (frontend public client)
|
||||
# ============================================================================
|
||||
# CERTifAI Dashboard - Environment Variables
|
||||
# ============================================================================
|
||||
# Copy this file to .env and fill in the values.
|
||||
# Variables marked [REQUIRED] must be set; others have sensible defaults.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keycloak Configuration (frontend public client) [REQUIRED]
|
||||
# ---------------------------------------------------------------------------
|
||||
KEYCLOAK_URL=http://localhost:8080
|
||||
KEYCLOAK_REALM=certifai
|
||||
KEYCLOAK_CLIENT_ID=certifai-dashboard
|
||||
|
||||
# Application Configuration
|
||||
# Keycloak admin / service-account client (server-to-server calls) [OPTIONAL]
|
||||
KEYCLOAK_ADMIN_CLIENT_ID=
|
||||
KEYCLOAK_ADMIN_CLIENT_SECRET=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application Configuration [REQUIRED]
|
||||
# ---------------------------------------------------------------------------
|
||||
APP_URL=http://localhost:8000
|
||||
REDIRECT_URI=http://localhost:8000/auth/callback
|
||||
ALLOWED_ORIGINS=http://localhost:8000
|
||||
|
||||
# SearXNG meta-search engine
|
||||
# ---------------------------------------------------------------------------
|
||||
# MongoDB [OPTIONAL - defaults shown]
|
||||
# ---------------------------------------------------------------------------
|
||||
MONGODB_URI=mongodb://localhost:27017
|
||||
MONGODB_DATABASE=certifai
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SearXNG meta-search engine [OPTIONAL - default: http://localhost:8888]
|
||||
# ---------------------------------------------------------------------------
|
||||
SEARXNG_URL=http://localhost:8888
|
||||
|
||||
# Ollama LLM instance (used for article summarization and chat)
|
||||
OLLAMA_URL=http://mac-mini-von-benjamin-2:11434
|
||||
OLLAMA_MODEL=qwen3:30b-a3b
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama LLM instance [OPTIONAL - defaults shown]
|
||||
# ---------------------------------------------------------------------------
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=llama3.1:8b
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM Providers (comma-separated list) [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
LLM_PROVIDERS=ollama
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMTP (transactional email) [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_ADDRESS=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stripe billing [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LangChain / LangGraph / Langfuse [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
LANGCHAIN_URL=
|
||||
LANGGRAPH_URL=
|
||||
LANGFUSE_URL=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vector database [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
VECTORDB_URL=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S3-compatible object storage [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
S3_URL=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,9 +12,11 @@
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Keycloak runtime data (but keep realm-export.json)
|
||||
# Keycloak runtime data (but keep config and theme)
|
||||
keycloak/*
|
||||
!keycloak/realm-export.json
|
||||
!keycloak/themes/
|
||||
!keycloak/themes/**
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -760,9 +760,11 @@ dependencies = [
|
||||
name = "dashboard"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-stripe",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dioxus",
|
||||
"dioxus-cli-config",
|
||||
@@ -774,6 +776,7 @@ dependencies = [
|
||||
"maud",
|
||||
"mongodb",
|
||||
"petname",
|
||||
"pulldown-cmark",
|
||||
"rand 0.10.0",
|
||||
"reqwest 0.13.2",
|
||||
"scraper",
|
||||
@@ -784,10 +787,12 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
"tracing",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -1127,7 +1132,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"xxhash-rust",
|
||||
]
|
||||
@@ -1531,7 +1536,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3297,6 +3302,24 @@ dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -3573,7 +3596,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
@@ -3588,6 +3611,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.13",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
@@ -3610,12 +3634,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -5147,6 +5173,19 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
|
||||
21
Cargo.toml
21
Cargo.toml
@@ -36,7 +36,7 @@ mongodb = { version = "3.2", default-features = false, features = [
|
||||
"compat-3-0-0",
|
||||
], optional = true }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
reqwest = { version = "0.13", optional = true, features = ["json", "form"] }
|
||||
reqwest = { version = "0.13", optional = true, features = ["json", "form", "stream"] }
|
||||
tower-sessions = { version = "0.15", default-features = false, features = [
|
||||
"axum-core",
|
||||
"memory-store",
|
||||
@@ -61,9 +61,17 @@ secrecy = { version = "0.10", default-features = false, optional = true }
|
||||
serde_json = { version = "1.0.133", default-features = false }
|
||||
maud = { version = "0.27", default-features = false }
|
||||
url = { version = "2.5.4", default-features = false, optional = true }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
web-sys = { version = "0.3", optional = true, features = [
|
||||
"Clipboard",
|
||||
"Document",
|
||||
"Element",
|
||||
"EventSource",
|
||||
"HtmlElement",
|
||||
"MessageEvent",
|
||||
"Navigator",
|
||||
"Storage",
|
||||
"Window",
|
||||
] }
|
||||
tracing = "0.1.40"
|
||||
# Debug
|
||||
@@ -76,10 +84,14 @@ dioxus-free-icons = { version = "0.10", features = [
|
||||
sha2 = { version = "0.10.9", default-features = false, optional = true }
|
||||
base64 = { version = "0.22.1", default-features = false, optional = true }
|
||||
scraper = { version = "0.22", default-features = false, optional = true }
|
||||
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
|
||||
tokio-stream = { version = "0.1", optional = true, features = ["sync"] }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
|
||||
[features]
|
||||
# default = ["web"]
|
||||
web = ["dioxus/web", "dep:reqwest", "dep:web-sys"]
|
||||
web = ["dioxus/web", "dep:reqwest", "dep:web-sys", "dep:wasm-bindgen"]
|
||||
server = [
|
||||
"dioxus/server",
|
||||
"dep:axum",
|
||||
@@ -93,6 +105,11 @@ server = [
|
||||
"dep:sha2",
|
||||
"dep:base64",
|
||||
"dep:scraper",
|
||||
"dep:secrecy",
|
||||
"dep:petname",
|
||||
"dep:tokio-stream",
|
||||
"dep:async-stream",
|
||||
"dep:bytes",
|
||||
]
|
||||
|
||||
[[bin]]
|
||||
|
||||
154
README.md
154
README.md
@@ -1,64 +1,132 @@
|
||||
# CERTifAI
|
||||
<p align="center">
|
||||
<img src="assets/favicon.svg" width="96" height="96" alt="CERTifAI Logo" />
|
||||
</p>
|
||||
|
||||
[](https://gitea.meghsakha.com/sharang/certifai/actions?workflow=ci.yml)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://dioxuslabs.com/)
|
||||
[](LICENSE)
|
||||
[](https://gdpr.eu/)
|
||||
<h1 align="center">CERTifAI</h1>
|
||||
|
||||
This project is a SaaS application dashboard for administation of self-hosted private GenAI (generative AI) toolbox for companies and individuals. The purpose of the dashboard is to manage LLMs, Agents, MCP Servers and other GenAI related features.
|
||||
|
||||
The purpose of `CERTifAI`is to provide self-hosted or GDPR-Conform GenAI infrastructure to companies who do not wish to subscribe to non-EU cloud providers to protect their intellectual property from being used as training data.
|
||||
<p align="center">
|
||||
<strong>Self-hosted, GDPR-compliant GenAI infrastructure dashboard</strong>
|
||||
</p>
|
||||
|
||||
## Overview
|
||||
<p align="center">
|
||||
<a href="https://gitea.meghsakha.com/sharang/certifai/actions?workflow=ci.yml"><img src="https://gitea.meghsakha.com/sharang/certifai/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI" /></a>
|
||||
<a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/Rust-1.89-orange?logo=rust&logoColor=white" alt="Rust" /></a>
|
||||
<a href="https://dioxuslabs.com/"><img src="https://img.shields.io/badge/Dioxus-0.7-blue?logo=webassembly&logoColor=white" alt="Dioxus" /></a>
|
||||
<a href="https://www.mongodb.com/"><img src="https://img.shields.io/badge/MongoDB-8.0-47A248?logo=mongodb&logoColor=white" alt="MongoDB" /></a>
|
||||
<a href="https://www.keycloak.org/"><img src="https://img.shields.io/badge/Keycloak-26-4D4D4D?logo=keycloak&logoColor=white" alt="Keycloak" /></a>
|
||||
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/Tailwind_CSS-4-06B6D4?logo=tailwindcss&logoColor=white" alt="Tailwind CSS" /></a>
|
||||
<a href="https://daisyui.com/"><img src="https://img.shields.io/badge/DaisyUI-5-5A0EF8?logo=daisyui&logoColor=white" alt="DaisyUI" /></a>
|
||||
</p>
|
||||
|
||||
The SaaS application dashboard is the landing page for the company admin to view, edit and manage the company internal GenAI tools. The following tasks can be performed by the administrator:
|
||||
<p align="center">
|
||||
<a href="https://gdpr.eu/"><img src="https://img.shields.io/badge/GDPR-Compliant-green" alt="GDPR" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Proprietary-red" alt="License" /></a>
|
||||
<img src="https://img.shields.io/badge/Platform-Linux%20%7C%20Docker-lightgrey?logo=linux&logoColor=white" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/PRs-Welcome-brightgreen" alt="PRs Welcome" />
|
||||
</p>
|
||||
|
||||
- User management: Can add, remove, set roles, permissions and add restrictions for other users.
|
||||
- SSO/Oauth/LDAP: Can connect to company internal SSO/LDAP or other identity provider to load users and their respective permissions.
|
||||
- Turn features on/off: Turn off/on different GenAI features
|
||||
- Billing: View the current seats being used and token usage per seat for any given billing cycle
|
||||
- Request support: Request support or new features using feedback form
|
||||
- GenAI: View currently running LLMs, Agents, MCP Servers. Modify or add more resources, switch to a different model, launch tools like Langchain + Langfuse for creating new agents,tavily for internet search or more complex tools for use with GenAI. View endpoints and generate API Keys for integrations in other applications.
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
CERTifAI is a SaaS dashboard for administering self-hosted private GenAI infrastructure. It gives companies and individuals a single pane of glass to manage LLMs, Agents, MCP Servers, and other GenAI-related services -- without sending data to non-EU cloud providers.
|
||||
|
||||
> **Why?** Protect your intellectual property from being used as training data. Stay fully GDPR-compliant with infrastructure you own.
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Capabilities |
|
||||
|------|-------------|
|
||||
| **User Management** | Add, remove, set roles, permissions, and restrictions |
|
||||
| **SSO / OAuth / LDAP** | Connect to company identity providers and sync users |
|
||||
| **Feature Flags** | Toggle GenAI features on or off per-org |
|
||||
| **Billing** | View seat usage and token consumption per billing cycle |
|
||||
| **Support** | Request support or new features via feedback form |
|
||||
| **GenAI Tools** | Manage LLMs, Agents, MCP Servers; launch Langchain, Langfuse, Tavily; view endpoints and generate API keys |
|
||||
|
||||
## Dashboard
|
||||
|
||||
The main dashboard provides a news feed powered by SearXNG and Ollama:
|
||||
The main dashboard provides a news feed powered by **SearXNG** and **Ollama**:
|
||||
|
||||
- **Topic-based search**: Browse AI, Technology, Science, Finance and custom topics. Add or remove topics on the fly; selections persist in localStorage.
|
||||
- **Article detail + AI summary**: Click any card to open a split-view panel. The full article is fetched, summarized by Ollama, and a follow-up chat lets you ask questions.
|
||||
- **Topic-based search** -- Browse AI, Technology, Science, Finance, and custom topics. Add or remove topics on the fly; selections persist in localStorage.
|
||||
- **Article detail + AI summary** -- Click any card to open a split-view panel. The full article is fetched, summarized by Ollama, and a follow-up chat lets you ask questions.
|
||||
- **Sidebar** (visible when no article is selected):
|
||||
- **Ollama Status** -- green/red indicator with the list of loaded models.
|
||||
- **Trending** -- keywords extracted from recent news headlines via SearXNG.
|
||||
- **Recent Searches** -- last 10 topics you searched, persisted in localStorage.
|
||||
- **Ollama Status** -- green/red indicator with the list of loaded models
|
||||
- **Trending** -- keywords extracted from recent news headlines via SearXNG
|
||||
- **Recent Searches** -- last 10 topics you searched, persisted in localStorage
|
||||
|
||||
## Development environment
|
||||
## Tech Stack
|
||||
|
||||
This project is written in Dioxus 0.7 with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend | [Dioxus 0.7](https://dioxuslabs.com/) (fullstack + router), Tailwind CSS 4, DaisyUI 5 |
|
||||
| Backend | Axum, tower-sessions, Dioxus server functions |
|
||||
| Database | MongoDB |
|
||||
| Auth | Keycloak 26+ (OAuth2 + PKCE, Organizations) |
|
||||
| Search | SearXNG (meta-search) |
|
||||
| LLM | Ollama (local inference) |
|
||||
|
||||
### External services
|
||||
## Getting Started
|
||||
|
||||
| Service | Purpose | Default URL |
|
||||
|----------|--------------------------------|----------------------------|
|
||||
| Keycloak | Identity provider / SSO | `http://localhost:8080` |
|
||||
| SearXNG | Meta-search engine for news | `http://localhost:8888` |
|
||||
| Ollama | Local LLM for summarization | `http://localhost:11434` |
|
||||
### Prerequisites
|
||||
|
||||
Copy `.env.example` to `.env` and adjust the URLs and model name to match your setup.
|
||||
- Rust 1.89+
|
||||
- [Dioxus CLI](https://dioxuslabs.com/learn/0.7/getting_started) (`dx`)
|
||||
- MongoDB
|
||||
- Keycloak
|
||||
- SearXNG (optional)
|
||||
- Ollama (optional)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://gitea.meghsakha.com/sharang/certifai.git
|
||||
cd certifai
|
||||
|
||||
# Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your Keycloak, MongoDB, and service URLs
|
||||
|
||||
# Run the dev server
|
||||
dx serve
|
||||
```
|
||||
|
||||
### External Services
|
||||
|
||||
| Service | Purpose | Default URL |
|
||||
|---------|---------|-------------|
|
||||
| Keycloak | Identity provider / SSO | `http://localhost:8080` |
|
||||
| MongoDB | User data and preferences | `mongodb://localhost:27017` |
|
||||
| SearXNG | Meta-search engine for news | `http://localhost:8888` |
|
||||
| Ollama | Local LLM for summarization | `http://localhost:11434` |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
components/ Frontend-only reusable UI components
|
||||
infrastructure/ Server-side: auth, config, DB, server functions
|
||||
models/ Shared data models (web + server)
|
||||
pages/ Full page views composing components + models
|
||||
assets/ Static assets (CSS, icons, manifest)
|
||||
styles/ Tailwind/DaisyUI input stylesheet
|
||||
bin/ Binary entrypoint
|
||||
```
|
||||
|
||||
## Code structure
|
||||
The following folder structure is maintained for separation of concerns:
|
||||
- src/components/*.rs : All components that are required to be rendered are placed here. These are frontend only, reusable components that are specific for the application.
|
||||
- src/infrastructure/*.rs : All backend related functions from the dioxus fullstack are placed here. This entire module is behind the feature "server".
|
||||
- src/models/*.rs : All data models for use by the frontend pages and components.
|
||||
- src/pages/*.rs : All view pages for the website, which utilize components, models to render the entire page. The pages are more towards the user as they group user-centered functions together in one view.
|
||||
|
||||
|
||||
## Git Workflow
|
||||
|
||||
We follow feature branch workflow for Git and bringing in new features. The `main` branch is the default and protected branch.
|
||||
Conventional commits MUST be used for writing commit messages. We follow semantic versioning as per [SemVer](https://semver.org)
|
||||
We follow the **feature branch workflow**. The `main` branch is the default and protected branch.
|
||||
|
||||
- [Conventional Commits](https://www.conventionalcommits.org/) are required for all commit messages
|
||||
- We follow [SemVer](https://semver.org/) for versioning
|
||||
|
||||
## CI
|
||||
|
||||
The CI is run on gitea actions with runner tags `docker`.
|
||||
CI runs on Gitea Actions with runner tag `docker`.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Built with Rust, Dioxus, and a commitment to data sovereignty.</sub>
|
||||
</p>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB |
25
assets/favicon.svg
Normal file
25
assets/favicon.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 |
909
assets/main.css
909
assets/main.css
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ self.addEventListener("install", (event) => {
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/assets/logo.svg",
|
||||
"/assets/favicon.ico",
|
||||
"/assets/favicon.svg",
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -162,6 +162,59 @@
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.diff {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
webkit-user-select: none;
|
||||
user-select: none;
|
||||
grid-template-rows: 1fr 1.8rem 1fr;
|
||||
direction: ltr;
|
||||
container-type: inline-size;
|
||||
grid-template-columns: auto 1fr;
|
||||
&:focus-visible, &:has(.diff-item-1:focus-visible) {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
outline-color: var(--color-base-content);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
outline-color: var(--color-base-content);
|
||||
.diff-resizer {
|
||||
min-width: 95cqi;
|
||||
max-width: 95cqi;
|
||||
}
|
||||
}
|
||||
&:has(.diff-item-1:focus-visible) {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
.diff-resizer {
|
||||
min-width: 5cqi;
|
||||
max-width: 5cqi;
|
||||
}
|
||||
}
|
||||
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
|
||||
&:focus {
|
||||
.diff-resizer {
|
||||
min-width: 5cqi;
|
||||
max-width: 5cqi;
|
||||
}
|
||||
}
|
||||
&:has(.diff-item-1:focus) {
|
||||
.diff-resizer {
|
||||
min-width: 95cqi;
|
||||
max-width: 95cqi;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
pointer-events: none;
|
||||
@@ -1290,9 +1343,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1386,6 +1436,81 @@
|
||||
padding: calc(0.25rem * 4);
|
||||
}
|
||||
}
|
||||
.textarea {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
border: var(--border) solid #0000;
|
||||
min-height: calc(0.25rem * 20);
|
||||
flex-shrink: 1;
|
||||
appearance: none;
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-base-100);
|
||||
padding-block: calc(0.25rem * 2);
|
||||
vertical-align: middle;
|
||||
width: clamp(3rem, 20rem, 100%);
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 0.75rem;
|
||||
font-size: max(var(--font-size, 0.875rem), 0.875rem);
|
||||
touch-action: manipulation;
|
||||
border-color: var(--input-color);
|
||||
box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
|
||||
}
|
||||
--input-color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
--input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
}
|
||||
textarea {
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
&:focus, &:focus-within {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
@media (forced-colors: active) {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:focus, &:focus-within {
|
||||
--input-color: var(--color-base-content);
|
||||
box-shadow: 0 1px var(--input-color);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
|
||||
}
|
||||
outline: 2px solid var(--input-color);
|
||||
outline-offset: 2px;
|
||||
isolation: isolate;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
&:focus, &:focus-within {
|
||||
--font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:has(> textarea[disabled]), &:is(:disabled, [disabled]) {
|
||||
cursor: not-allowed;
|
||||
border-color: var(--color-base-200);
|
||||
background-color: var(--color-base-200);
|
||||
color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||||
}
|
||||
&::placeholder {
|
||||
color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
|
||||
}
|
||||
}
|
||||
box-shadow: none;
|
||||
}
|
||||
&:has(> textarea[disabled]) > textarea[disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
.stack {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: inline-grid;
|
||||
@@ -1683,9 +1808,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
@@ -1727,12 +1849,23 @@
|
||||
border-color: currentColor;
|
||||
}
|
||||
}
|
||||
.glass {
|
||||
border: none;
|
||||
backdrop-filter: blur(var(--glass-blur, 40px));
|
||||
background-color: #0000;
|
||||
background-image: linear-gradient( 135deg, oklch(100% 0 0 / var(--glass-opacity, 30%)) 0%, oklch(0% 0 0 / 0%) 100% ), linear-gradient( var(--glass-reflect-degree, 100deg), oklch(100% 0 0 / var(--glass-reflect-opacity, 5%)) 25%, oklch(0% 0 0 / 0%) 25% );
|
||||
box-shadow: 0 0 0 1px oklch(100% 0 0 / var(--glass-border-opacity, 20%)) inset, 0 0 0 2px oklch(0% 0 0 / 5%);
|
||||
text-shadow: 0 1px oklch(0% 0 0 / var(--glass-text-shadow-opacity, 5%));
|
||||
}
|
||||
.p-6 {
|
||||
padding: calc(var(--spacing) * 6);
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.lowercase {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
- --import-realm
|
||||
volumes:
|
||||
- ./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"]
|
||||
interval: 10s
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"loginTheme": "certifai",
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": true,
|
||||
"permanentLockout": false,
|
||||
|
||||
933
keycloak/themes/certifai/login/resources/css/login.css
Normal file
933
keycloak/themes/certifai/login/resources/css/login.css
Normal file
@@ -0,0 +1,933 @@
|
||||
/* CERTifAI Keycloak Login Theme
|
||||
* Overrides PatternFly v4 / legacy Keycloak classes to match the dashboard.
|
||||
*
|
||||
* Actual page structure (Keycloak 26 with parent=keycloak):
|
||||
* html.login-pf > body
|
||||
* div.login-pf-page
|
||||
* div#kc-header.login-pf-page-header
|
||||
* div#kc-header-wrapper
|
||||
* div.card-pf
|
||||
* header.login-pf-header > h1#kc-page-title
|
||||
* div#kc-content > div#kc-content-wrapper
|
||||
* form#kc-form-login
|
||||
* .form-group (email)
|
||||
* .form-group (password + .pf-c-input-group)
|
||||
* .form-group.login-pf-settings (forgot pwd)
|
||||
* .form-group #kc-form-buttons (submit: input#kc-login.pf-c-button.pf-m-primary)
|
||||
* div#kc-info.login-pf-signup (register link)
|
||||
*
|
||||
* Classes used: pf-c-* (PF v4), login-pf-*, card-pf, form-group
|
||||
*/
|
||||
|
||||
/* ===== Google Fonts ===== */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap');
|
||||
|
||||
/* ===== CSS Variables ===== */
|
||||
:root {
|
||||
--cai-bg-body: #0f1116;
|
||||
--cai-bg-card: #1a1d26;
|
||||
--cai-bg-surface: #1e222d;
|
||||
--cai-bg-input: #12141a;
|
||||
--cai-text-primary: #e2e8f0;
|
||||
--cai-text-heading: #f1f5f9;
|
||||
--cai-text-muted: #8892a8;
|
||||
--cai-text-faint: #5a6478;
|
||||
--cai-border-primary: #1e222d;
|
||||
--cai-border-secondary: #2a2f3d;
|
||||
--cai-accent: #91a4d2;
|
||||
--cai-accent-secondary: #6d85c6;
|
||||
--cai-brand-indigo: #4B3FE0;
|
||||
--cai-brand-teal: #38B2AC;
|
||||
--cai-error: #f87171;
|
||||
--cai-success: #4ade80;
|
||||
}
|
||||
|
||||
/* ===== Animations ===== */
|
||||
|
||||
/* Slow-moving ambient gradient behind the page */
|
||||
@keyframes ambientShift {
|
||||
0% { background-position: 0% 0%; }
|
||||
25% { background-position: 100% 50%; }
|
||||
50% { background-position: 50% 100%; }
|
||||
75% { background-position: 0% 50%; }
|
||||
100% { background-position: 0% 0%; }
|
||||
}
|
||||
|
||||
/* Subtle glow pulse on the card */
|
||||
@keyframes cardGlow {
|
||||
0%, 100% { box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 60px rgba(75, 63, 224, 0.04); }
|
||||
50% { box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 80px rgba(56, 178, 172, 0.06); }
|
||||
}
|
||||
|
||||
/* Gentle float for the logo */
|
||||
@keyframes logoFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* Gradient shimmer on the button */
|
||||
@keyframes buttonShimmer {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* ===== Base Page ===== */
|
||||
html.login-pf {
|
||||
background-color: var(--cai-bg-body) !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
html.login-pf body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 20%, rgba(75, 63, 224, 0.07) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(56, 178, 172, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(109, 133, 198, 0.03) 0%, transparent 70%),
|
||||
var(--cai-bg-body) !important;
|
||||
background-size: 200% 200%, 200% 200%, 100% 100%, 100% 100% !important;
|
||||
animation: ambientShift 20s ease-in-out infinite !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== Page Layout ===== */
|
||||
.login-pf-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 40px 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ===== Header (Logo + Realm Name) ===== */
|
||||
#kc-header.login-pf-page-header {
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
padding: 0 0 32px !important;
|
||||
text-align: center;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#kc-header-wrapper {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
font-size: 28px !important;
|
||||
font-weight: 700 !important;
|
||||
color: var(--cai-text-heading) !important;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Logo via ::before pseudo-element */
|
||||
#kc-header-wrapper::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 16px;
|
||||
background-image: url('../img/logo.svg');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
animation: logoFloat 4s ease-in-out infinite;
|
||||
filter: drop-shadow(0 0 12px rgba(75, 63, 224, 0.3));
|
||||
}
|
||||
|
||||
/* ===== Login Card ===== */
|
||||
.card-pf {
|
||||
background-color: var(--cai-bg-card) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 12px !important;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
padding: 32px !important;
|
||||
margin: 0 !important;
|
||||
animation: cardGlow 6s ease-in-out infinite;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Subtle gradient border effect on the card via ::before overlay */
|
||||
.card-pf::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--cai-brand-indigo),
|
||||
var(--cai-brand-teal),
|
||||
var(--cai-accent-secondary),
|
||||
transparent
|
||||
);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ===== Card Header (Sign In Title) ===== */
|
||||
.login-pf-header {
|
||||
border-bottom: none !important;
|
||||
padding: 0 0 24px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
#kc-page-title {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
font-size: 22px !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--cai-text-heading) !important;
|
||||
text-align: center;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== Form Groups ===== */
|
||||
.form-group {
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
/* ===== Labels ===== */
|
||||
.pf-c-form__label,
|
||||
.pf-c-form__label-text,
|
||||
.login-pf-page .form-group label,
|
||||
.card-pf label {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
margin-bottom: 6px !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== Text Inputs ===== */
|
||||
.pf-c-form-control,
|
||||
.login-pf-page .form-control,
|
||||
.card-pf input[type="text"],
|
||||
.card-pf input[type="password"],
|
||||
.card-pf input[type="email"] {
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
padding: 10px 14px !important;
|
||||
height: auto !important;
|
||||
line-height: 1.5 !important;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.pf-c-form-control:focus,
|
||||
.pf-c-form-control:focus-within,
|
||||
.card-pf input[type="text"]:focus,
|
||||
.card-pf input[type="password"]:focus,
|
||||
.card-pf input[type="email"]:focus {
|
||||
border-color: var(--cai-accent) !important;
|
||||
box-shadow: 0 0 0 1px var(--cai-accent), 0 0 12px rgba(145, 164, 210, 0.1) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.pf-c-form-control::placeholder,
|
||||
.card-pf input::placeholder {
|
||||
color: var(--cai-text-faint) !important;
|
||||
}
|
||||
|
||||
/* Override browser autofill yellow background */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 9999px var(--cai-bg-input) inset !important;
|
||||
-webkit-text-fill-color: var(--cai-text-primary) !important;
|
||||
caret-color: var(--cai-text-primary) !important;
|
||||
transition: background-color 5000s ease-in-out 0s !important;
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
}
|
||||
|
||||
/* Firefox autofill override */
|
||||
input:autofill {
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
border-color: var(--cai-border-secondary) !important;
|
||||
}
|
||||
|
||||
/* Additional specificity for autofill inside input-group */
|
||||
.pf-c-input-group input:-webkit-autofill,
|
||||
.card-pf input:-webkit-autofill,
|
||||
.form-group input:-webkit-autofill,
|
||||
#username:-webkit-autofill,
|
||||
#password:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 9999px var(--cai-bg-input) inset !important;
|
||||
-webkit-text-fill-color: var(--cai-text-primary) !important;
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
}
|
||||
|
||||
/* ===== Password Input Group ===== */
|
||||
/* FIX: The .pf-c-input-group has white bg from PF4, causing white corners
|
||||
* behind the rounded child elements. Set transparent + matching border-radius. */
|
||||
.pf-c-input-group {
|
||||
display: flex !important;
|
||||
align-items: stretch !important;
|
||||
background-color: transparent !important;
|
||||
background: transparent !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.pf-c-input-group > .pf-c-form-control,
|
||||
.pf-c-input-group > input.pf-c-form-control,
|
||||
.pf-c-input-group > input[type="password"],
|
||||
#password {
|
||||
border-radius: 8px 0 0 8px !important;
|
||||
border-right: none !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Password visibility toggle */
|
||||
.pf-c-button.pf-m-control,
|
||||
.pf-c-input-group > .pf-c-button.pf-m-control {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
border-top: 1px solid var(--cai-border-secondary) !important;
|
||||
border-right: 1px solid var(--cai-border-secondary) !important;
|
||||
border-bottom: 1px solid var(--cai-border-secondary) !important;
|
||||
border-left: 1px solid var(--cai-border-primary) !important;
|
||||
border-radius: 0 8px 8px 0 !important;
|
||||
padding: 0 14px !important;
|
||||
transition: color 0.2s ease, background-color 0.2s ease !important;
|
||||
line-height: 1 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-control:hover,
|
||||
.pf-c-input-group > .pf-c-button.pf-m-control:hover {
|
||||
color: var(--cai-accent) !important;
|
||||
background-color: rgba(145, 164, 210, 0.08) !important;
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-control:focus,
|
||||
.pf-c-input-group > .pf-c-button.pf-m-control:focus {
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* ===== Primary Button (Sign In) ===== */
|
||||
.pf-c-button.pf-m-primary,
|
||||
input.pf-c-button.pf-m-primary,
|
||||
#kc-login {
|
||||
background: linear-gradient(135deg,
|
||||
var(--cai-accent),
|
||||
var(--cai-accent-secondary),
|
||||
var(--cai-brand-indigo),
|
||||
var(--cai-accent-secondary),
|
||||
var(--cai-accent)) !important;
|
||||
background-size: 300% 100% !important;
|
||||
animation: buttonShimmer 6s ease-in-out infinite !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
color: #0a0c10 !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 12px 20px !important;
|
||||
cursor: pointer !important;
|
||||
transition: opacity 0.15s ease, box-shadow 0.2s ease !important;
|
||||
text-shadow: none !important;
|
||||
box-shadow: 0 2px 12px rgba(109, 133, 198, 0.2) !important;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-primary:hover,
|
||||
input.pf-c-button.pf-m-primary:hover,
|
||||
#kc-login:hover {
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 4px 20px rgba(109, 133, 198, 0.35) !important;
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-primary:focus,
|
||||
#kc-login:focus {
|
||||
box-shadow: 0 0 0 2px var(--cai-accent), 0 4px 20px rgba(109, 133, 198, 0.3) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* ===== Links ===== */
|
||||
.login-pf-page a,
|
||||
.card-pf a {
|
||||
color: var(--cai-accent) !important;
|
||||
text-decoration: none !important;
|
||||
transition: color 0.15s ease !important;
|
||||
}
|
||||
|
||||
.login-pf-page a:hover,
|
||||
.card-pf a:hover {
|
||||
color: var(--cai-accent-secondary) !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* Forgot Password link */
|
||||
.login-pf-settings {
|
||||
text-align: right;
|
||||
margin-bottom: 24px !important;
|
||||
}
|
||||
|
||||
.login-pf-settings a {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* ===== Registration / Info Section ===== */
|
||||
#kc-info.login-pf-signup {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
border-top: 1px solid var(--cai-border-primary) !important;
|
||||
padding: 16px 32px !important;
|
||||
margin: 0 -32px -32px !important;
|
||||
border-radius: 0 0 12px 12px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#kc-info-wrapper,
|
||||
#kc-registration {
|
||||
font-size: 14px !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
}
|
||||
|
||||
#kc-registration span {
|
||||
color: var(--cai-text-muted) !important;
|
||||
}
|
||||
|
||||
/* ===== Alert / Error Messages ===== */
|
||||
.alert,
|
||||
.pf-c-alert {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
padding: 12px 16px !important;
|
||||
margin-bottom: 16px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.alert-error,
|
||||
.alert-warning,
|
||||
.pf-c-alert.pf-m-danger,
|
||||
.pf-c-alert.pf-m-warning {
|
||||
border-color: var(--cai-error) !important;
|
||||
}
|
||||
|
||||
.alert-error .kc-feedback-text,
|
||||
.pf-c-alert .pf-c-alert__title {
|
||||
color: var(--cai-text-primary) !important;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
border-color: var(--cai-success) !important;
|
||||
}
|
||||
|
||||
/* ===== Checkboxes (Remember Me) ===== */
|
||||
.pf-c-check,
|
||||
.login-pf-page .checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-c-check__label,
|
||||
.login-pf-page .checkbox label {
|
||||
font-size: 13px !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pf-c-check__input,
|
||||
.login-pf-page input[type="checkbox"] {
|
||||
accent-color: var(--cai-accent);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ===== Select / Dropdown ===== */
|
||||
.card-pf select,
|
||||
.login-pf-page select {
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
padding: 10px 14px !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* ===== Social Login / Identity Providers ===== */
|
||||
#kc-social-providers {
|
||||
margin-top: 24px !important;
|
||||
padding-top: 20px !important;
|
||||
border-top: 1px solid var(--cai-border-primary) !important;
|
||||
}
|
||||
|
||||
/* Social <hr> separator */
|
||||
#kc-social-providers > hr {
|
||||
border: none !important;
|
||||
border-top: 1px solid var(--cai-border-primary) !important;
|
||||
margin: 0 0 16px 0 !important;
|
||||
}
|
||||
|
||||
/* "Or sign in with" heading - subtle divider text */
|
||||
#kc-social-providers h2,
|
||||
#kc-social-providers > h2,
|
||||
#kc-social-providers h4 {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
color: var(--cai-text-faint) !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
text-align: center !important;
|
||||
margin: 0 0 16px 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Social button list - stacked full-width
|
||||
* Production uses: ul.pf-c-login__main-footer-links.kc-social-links
|
||||
* PF4 sets flex-direction:row on this - we must override with high specificity */
|
||||
#kc-social-providers ul.pf-c-login__main-footer-links,
|
||||
#kc-social-providers ul.kc-social-links,
|
||||
#kc-social-providers ul,
|
||||
#kc-social-providers ol {
|
||||
list-style: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 10px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
#kc-social-providers ul.pf-c-login__main-footer-links > li,
|
||||
#kc-social-providers ul.kc-social-links > li,
|
||||
#kc-social-providers li {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
flex: none !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* Social login buttons - full-width stacked with icon + label
|
||||
* Production uses: a.pf-c-button.pf-m-control.pf-m-block.kc-social-item
|
||||
* Must override .pf-c-button.pf-m-control (password toggle uses same classes) */
|
||||
#kc-social-providers a.pf-c-button.pf-m-control,
|
||||
#kc-social-providers a.kc-social-item,
|
||||
#kc-social-providers a.pf-m-block,
|
||||
#kc-social-providers a,
|
||||
#kc-social-providers .zocial {
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-top: 1px solid var(--cai-border-secondary) !important;
|
||||
border-left: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
padding: 12px 16px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 10px !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
text-align: center !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
text-decoration: none !important;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease,
|
||||
box-shadow 0.2s ease, transform 0.15s ease !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
#kc-social-providers a.pf-c-button.pf-m-control:hover,
|
||||
#kc-social-providers a.kc-social-item:hover,
|
||||
#kc-social-providers a:hover,
|
||||
#kc-social-providers .zocial:hover {
|
||||
border-color: var(--cai-accent) !important;
|
||||
background-color: rgba(145, 164, 210, 0.06) !important;
|
||||
box-shadow: 0 0 16px rgba(145, 164, 210, 0.12) !important;
|
||||
color: var(--cai-text-heading) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
/* Provider icons inside social buttons */
|
||||
#kc-social-providers .kc-social-provider-logo,
|
||||
#kc-social-providers i.fa,
|
||||
#kc-social-providers .kc-social-icon-text {
|
||||
color: var(--cai-accent) !important;
|
||||
font-size: 16px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* Provider text label */
|
||||
#kc-social-providers .kc-social-provider-name {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* Grid layout for social providers (some themes use .kc-social-grid) */
|
||||
.kc-social-grid {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 10px !important;
|
||||
}
|
||||
|
||||
.kc-social-grid > div {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
/* PF v5 grid layout override */
|
||||
.pf-v5-l-grid.pf-m-gutter {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 10px !important;
|
||||
}
|
||||
|
||||
.pf-v5-l-grid__item {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
/* Social section separator */
|
||||
#kc-social-providers::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* ===== Form Buttons Row ===== */
|
||||
#kc-form-buttons {
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
#kc-form-options {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ===== Tooltip ===== */
|
||||
.kc-tooltip-text {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* ===== Scrollbar ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--cai-bg-body);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--cai-border-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--cai-text-faint);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.login-pf-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.card-pf {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
#kc-header-wrapper {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
#kc-header-wrapper::before {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
#kc-info.login-pf-signup {
|
||||
margin: 0 -24px -24px !important;
|
||||
padding: 16px 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Override PatternFly background images ===== */
|
||||
.login-pf-page .login-pf-page-header,
|
||||
.login-pf body {
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* Remove any PF4 container-fluid stretching */
|
||||
.container-fluid {
|
||||
padding: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Ensure the card doesn't stretch full width */
|
||||
.login-pf-page > .card-pf {
|
||||
max-width: 440px;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
/* ===== Legal Footer (injected by footer.js) ===== */
|
||||
.cai-legal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cai-legal-link {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--cai-text-faint) !important;
|
||||
text-decoration: none !important;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.cai-legal-link:hover {
|
||||
color: var(--cai-text-muted) !important;
|
||||
}
|
||||
|
||||
.cai-legal-sep {
|
||||
font-size: 10px;
|
||||
color: var(--cai-text-faint);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ===== PF v5 Social Provider Overrides ===== */
|
||||
/* Production may use keycloak.v2 (PF v5) classes */
|
||||
.pf-v5-c-login__main-footer {
|
||||
padding: 0 32px 28px !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main-footer-band {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
border-top: 1px solid var(--cai-border-primary) !important;
|
||||
padding: 16px 32px !important;
|
||||
text-align: center !important;
|
||||
border-radius: 0 0 12px 12px !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main-footer-band-item {
|
||||
font-size: 14px !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
}
|
||||
|
||||
/* PF v5 social buttons */
|
||||
.pf-v5-c-login__main-footer-links-item a,
|
||||
.pf-v5-c-button.pf-m-secondary.pf-m-block {
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
padding: 12px 16px !important;
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 10px !important;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease,
|
||||
box-shadow 0.2s ease, transform 0.15s ease !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main-footer-links-item a:hover,
|
||||
.pf-v5-c-button.pf-m-secondary.pf-m-block:hover {
|
||||
border-color: var(--cai-accent) !important;
|
||||
background-color: rgba(145, 164, 210, 0.06) !important;
|
||||
box-shadow: 0 0 16px rgba(145, 164, 210, 0.12) !important;
|
||||
color: var(--cai-text-heading) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
/* PF v5 social footer links list - stacked */
|
||||
.pf-v5-c-login__main-footer-links {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 10px !important;
|
||||
list-style: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main-footer-links-item {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* PF v5 main container and card */
|
||||
.pf-v5-c-login {
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 20%, rgba(75, 63, 224, 0.07) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(56, 178, 172, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(109, 133, 198, 0.03) 0%, transparent 70%),
|
||||
var(--cai-bg-body) !important;
|
||||
background-size: 200% 200%, 200% 200%, 100% 100%, 100% 100% !important;
|
||||
animation: ambientShift 20s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login::before {
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__container {
|
||||
max-width: 440px !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__header {
|
||||
text-align: center !important;
|
||||
margin-bottom: 32px !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-brand {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
font-size: 28px !important;
|
||||
font-weight: 700 !important;
|
||||
color: var(--cai-text-heading) !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main {
|
||||
background-color: var(--cai-bg-card) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 12px !important;
|
||||
animation: cardGlow 6s ease-in-out infinite !important;
|
||||
overflow: hidden !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main::before {
|
||||
content: '' !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
height: 2px !important;
|
||||
background: linear-gradient(90deg, transparent, var(--cai-brand-indigo),
|
||||
var(--cai-brand-teal), var(--cai-accent-secondary), transparent) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main-header {
|
||||
padding: 28px 32px 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main-header .pf-v5-c-title {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
font-size: 22px !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--cai-text-heading) !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main-body {
|
||||
padding: 24px 32px !important;
|
||||
}
|
||||
|
||||
/* PF v5 form controls */
|
||||
.pf-v5-c-form-control {
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-form-control:focus-within {
|
||||
border-color: var(--cai-accent) !important;
|
||||
box-shadow: 0 0 0 1px var(--cai-accent), 0 0 12px rgba(145, 164, 210, 0.1) !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-form__label-text {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
}
|
||||
|
||||
/* PF v5 primary button */
|
||||
.pf-v5-c-button.pf-m-primary {
|
||||
background: linear-gradient(135deg, var(--cai-accent), var(--cai-accent-secondary),
|
||||
var(--cai-brand-indigo), var(--cai-accent-secondary), var(--cai-accent)) !important;
|
||||
background-size: 300% 100% !important;
|
||||
animation: buttonShimmer 6s ease-in-out infinite !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
color: #0a0c10 !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
box-shadow: 0 2px 12px rgba(109, 133, 198, 0.2) !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-button.pf-m-primary:hover {
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 4px 20px rgba(109, 133, 198, 0.35) !important;
|
||||
}
|
||||
|
||||
/* PF v5 links */
|
||||
.pf-v5-c-login a,
|
||||
.pf-v5-c-login__main a,
|
||||
.pf-v5-c-button.pf-m-link {
|
||||
color: var(--cai-accent) !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login a:hover,
|
||||
.pf-v5-c-button.pf-m-link:hover {
|
||||
color: var(--cai-accent-secondary) !important;
|
||||
}
|
||||
|
||||
/* PF v5 alerts */
|
||||
.pf-v5-c-alert.pf-m-inline {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
}
|
||||
|
||||
/* PF v5 input group (password) */
|
||||
.pf-v5-c-input-group {
|
||||
background: transparent !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-button.pf-m-control {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-left: 1px solid var(--cai-border-primary) !important;
|
||||
border-radius: 0 8px 8px 0 !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-button.pf-m-control:hover {
|
||||
color: var(--cai-accent) !important;
|
||||
background-color: rgba(145, 164, 210, 0.08) !important;
|
||||
}
|
||||
25
keycloak/themes/certifai/login/resources/img/logo.svg
Normal file
25
keycloak/themes/certifai/login/resources/img/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 |
44
keycloak/themes/certifai/login/resources/js/footer.js
Normal file
44
keycloak/themes/certifai/login/resources/js/footer.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* CERTifAI Keycloak Theme - Footer Injection
|
||||
*
|
||||
* Injects legal footer links (Privacy Policy, Impressum) below the login card.
|
||||
* Uses the APP_BASE_URL from the page's redirect_uri to construct absolute links,
|
||||
* falling back to relative paths if unavailable.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Derive the app base URL from the OAuth redirect_uri parameter
|
||||
var appBase = "";
|
||||
try {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var redirectUri = params.get("redirect_uri");
|
||||
if (redirectUri) {
|
||||
var url = new URL(redirectUri);
|
||||
appBase = url.origin;
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore parse errors; links will be relative
|
||||
}
|
||||
|
||||
// Build the footer element
|
||||
var footer = document.createElement("div");
|
||||
footer.className = "cai-legal-footer";
|
||||
footer.innerHTML =
|
||||
'<a href="' + appBase + '/privacy" class="cai-legal-link" target="_blank" rel="noopener">' +
|
||||
"Privacy Policy" +
|
||||
"</a>" +
|
||||
'<span class="cai-legal-sep">|</span>' +
|
||||
'<a href="' + appBase + '/impressum" class="cai-legal-link" target="_blank" rel="noopener">' +
|
||||
"Impressum" +
|
||||
"</a>";
|
||||
|
||||
// Insert after the card or at the end of .login-pf-page / .pf-v5-c-login__container
|
||||
var card = document.querySelector(".card-pf") ||
|
||||
document.querySelector(".pf-v5-c-login__main");
|
||||
if (card && card.parentNode) {
|
||||
card.parentNode.insertBefore(footer, card.nextSibling);
|
||||
}
|
||||
});
|
||||
})();
|
||||
4
keycloak/themes/certifai/login/theme.properties
Normal file
4
keycloak/themes/certifai/login/theme.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
parent=keycloak
|
||||
import=common/keycloak
|
||||
styles=css/login.css
|
||||
scripts=js/footer.js
|
||||
25
src/app.rs
25
src/app.rs
@@ -49,7 +49,7 @@ pub enum Route {
|
||||
Login { redirect_url: String },
|
||||
}
|
||||
|
||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
const FAVICON: Asset = asset!("/assets/favicon.svg");
|
||||
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
const MANIFEST: Asset = asset!("/assets/manifest.json");
|
||||
@@ -64,6 +64,16 @@ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
rsx! {
|
||||
// Seggwat feedback widget
|
||||
document::Script {
|
||||
src: "https://seggwat.com/static/widgets/v1/seggwat-feedback.js",
|
||||
r#defer: true,
|
||||
"data-project-key": "a04b8cf1-9177-42ce-8a7b-084f38b99799",
|
||||
"data-button-color": "#6d85c6",
|
||||
"data-button-position": "right-side",
|
||||
"data-enable-screenshots": "true",
|
||||
}
|
||||
|
||||
document::Link { rel: "icon", href: FAVICON }
|
||||
document::Link { rel: "manifest", href: MANIFEST }
|
||||
document::Meta { name: "theme-color", content: "#4B3FE0" }
|
||||
@@ -93,6 +103,17 @@ pub fn App() -> Element {
|
||||
"#
|
||||
}
|
||||
|
||||
div { "data-theme": "certifai-dark", Router::<Route> {} }
|
||||
// Apply persisted theme to <html> before first paint to avoid flash.
|
||||
// Default to certifai-dark when no preference is stored.
|
||||
document::Script {
|
||||
r#"
|
||||
(function() {{
|
||||
var t = localStorage.getItem('theme') || 'certifai-dark';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
}})();
|
||||
"#
|
||||
}
|
||||
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,65 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::sidebar::Sidebar;
|
||||
use crate::infrastructure::auth_check::check_auth;
|
||||
use crate::models::AuthInfo;
|
||||
use crate::Route;
|
||||
|
||||
/// Application shell layout that wraps all authenticated pages.
|
||||
///
|
||||
/// Renders a fixed sidebar on the left and the active child route
|
||||
/// in the scrollable main content area via `Outlet`.
|
||||
/// Calls [`check_auth`] on mount to fetch the current user's session.
|
||||
/// If unauthenticated, redirects to `/auth`. Otherwise renders the
|
||||
/// sidebar with real user data and the active child route.
|
||||
#[component]
|
||||
pub fn AppShell() -> Element {
|
||||
rsx! {
|
||||
div { class: "app-shell",
|
||||
Sidebar {
|
||||
email: "user@example.com".to_string(),
|
||||
avatar_url: String::new(),
|
||||
// use_resource memoises the async call and avoids infinite re-render
|
||||
// loops that use_effect + spawn + signal writes can cause.
|
||||
#[allow(clippy::redundant_closure)]
|
||||
let auth = use_resource(move || check_auth());
|
||||
|
||||
// Clone the inner value out of the Signal to avoid holding the
|
||||
// borrow across the rsx! return (Dioxus lifetime constraint).
|
||||
let auth_snapshot: Option<Result<AuthInfo, ServerFnError>> = auth.read().clone();
|
||||
|
||||
match auth_snapshot {
|
||||
Some(Ok(info)) if info.authenticated => {
|
||||
rsx! {
|
||||
div { class: "app-shell",
|
||||
Sidebar {
|
||||
email: info.email,
|
||||
name: info.name,
|
||||
avatar_url: info.avatar_url,
|
||||
}
|
||||
main { class: "main-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
// Not authenticated -- redirect to login.
|
||||
let nav = navigator();
|
||||
nav.push(NavigationTarget::<Route>::External("/auth".into()));
|
||||
rsx! {
|
||||
div { class: "app-shell loading",
|
||||
p { "Redirecting to login..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let msg = e.to_string();
|
||||
rsx! {
|
||||
div { class: "auth-error",
|
||||
p { "Authentication error: {msg}" }
|
||||
a { href: "/auth", "Login" }
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Still loading.
|
||||
rsx! {
|
||||
div { class: "app-shell loading",
|
||||
p { "Loading..." }
|
||||
}
|
||||
}
|
||||
main { class: "main-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
src/components/chat_action_bar.rs
Normal file
65
src/components/chat_action_bar.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
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 {
|
||||
if !has_messages {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-action-bar",
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
disabled: !has_assistant_message,
|
||||
title: "Copy last response",
|
||||
onclick: move |_| on_copy.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaCopy,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "Copy" }
|
||||
}
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
title: "Copy conversation",
|
||||
onclick: move |_| on_share.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaShareNodes,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "Share" }
|
||||
}
|
||||
button {
|
||||
class: "chat-action-btn",
|
||||
disabled: !has_user_message,
|
||||
title: "Edit last message",
|
||||
onclick: move |_| on_edit.call(()),
|
||||
dioxus_free_icons::Icon {
|
||||
icon: FaPenToSquare,
|
||||
width: 14, height: 14,
|
||||
}
|
||||
span { class: "chat-action-label", "Edit" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,82 @@
|
||||
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 right-aligned; assistant messages are left-aligned.
|
||||
/// 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 {
|
||||
// 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 => "chat-bubble chat-bubble--system",
|
||||
ChatRole::System => unreachable!(),
|
||||
};
|
||||
|
||||
let role_label = match message.role {
|
||||
ChatRole::User => "You",
|
||||
ChatRole::Assistant => "Assistant",
|
||||
ChatRole::System => "System",
|
||||
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", "{message.timestamp}" }
|
||||
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}" }
|
||||
}
|
||||
div { class: "chat-bubble-content", "{message.content}" }
|
||||
if !message.attachments.is_empty() {
|
||||
div { class: "chat-bubble-attachments",
|
||||
for att in &message.attachments {
|
||||
@@ -39,3 +87,45 @@ pub fn ChatBubble(message: ChatMessage) -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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", "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", "Assistant" }
|
||||
}
|
||||
div {
|
||||
class: "chat-bubble-content chat-prose",
|
||||
dangerous_inner_html: "{html}",
|
||||
}
|
||||
span { class: "chat-streaming-cursor" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
src/components/chat_input_bar.rs
Normal file
69
src/components/chat_input_bar.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
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 mut input = input_text;
|
||||
|
||||
rsx! {
|
||||
div { class: "chat-input-bar",
|
||||
textarea {
|
||||
class: "chat-input",
|
||||
placeholder: "Type a 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/components/chat_message_list.rs
Normal file
38
src/components/chat_message_list.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::components::{ChatBubble, StreamingBubble};
|
||||
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 {
|
||||
rsx! {
|
||||
div {
|
||||
class: "chat-message-list",
|
||||
id: "chat-message-list",
|
||||
if messages.is_empty() && !is_streaming {
|
||||
div { class: "chat-empty",
|
||||
p { "Send a message to start the conversation." }
|
||||
}
|
||||
}
|
||||
for msg in &messages {
|
||||
ChatBubble { key: "{msg.id}", message: msg.clone() }
|
||||
}
|
||||
if is_streaming {
|
||||
StreamingBubble { content: streaming_content }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/components/chat_model_selector.rs
Normal file
42
src/components/chat_model_selector.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
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 {
|
||||
rsx! {
|
||||
div { class: "chat-model-bar",
|
||||
label { class: "chat-model-label", "Model:" }
|
||||
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, "No models available" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
226
src/components/chat_sidebar.rs
Normal file
226
src/components/chat_sidebar.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
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 {
|
||||
// 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 { "Conversations" }
|
||||
button {
|
||||
class: "btn-icon",
|
||||
title: "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", "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() { "All Chats" } else { "General" }
|
||||
}
|
||||
if general_sessions.is_empty() {
|
||||
p { class: "chat-empty-hint", "No conversations yet" }
|
||||
}
|
||||
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 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);
|
||||
|
||||
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: "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: "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.
|
||||
fn format_relative_date(iso: &str) -> 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 {
|
||||
"just now".to_string()
|
||||
} else if diff.num_hours() < 1 {
|
||||
format!("{}m ago", diff.num_minutes())
|
||||
} else if diff.num_hours() < 24 {
|
||||
format!("{}h ago", diff.num_hours())
|
||||
} else if diff.num_days() < 7 {
|
||||
format!("{}d ago", diff.num_days())
|
||||
} else {
|
||||
dt.format("%b %d").to_string()
|
||||
}
|
||||
} else {
|
||||
iso.to_string()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
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;
|
||||
@@ -16,7 +21,12 @@ 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::*;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
|
||||
BsGrid, BsHouseDoor, BsPuzzle,
|
||||
BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill,
|
||||
};
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
@@ -19,10 +19,11 @@ struct NavItem {
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - User display name (shown in header if non-empty).
|
||||
/// * `email` - Email address displayed beneath the avatar placeholder.
|
||||
/// * `avatar_url` - URL for the avatar image (unused placeholder for now).
|
||||
#[component]
|
||||
pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||
pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element {
|
||||
let nav_items: Vec<NavItem> = vec![
|
||||
NavItem {
|
||||
label: "Dashboard",
|
||||
@@ -66,7 +67,7 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||
|
||||
rsx! {
|
||||
aside { class: "sidebar",
|
||||
SidebarHeader { email: email.clone(), avatar_url }
|
||||
SidebarHeader { name, email: email.clone(), avatar_url }
|
||||
|
||||
nav { class: "sidebar-nav",
|
||||
for item in nav_items {
|
||||
@@ -93,13 +94,14 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "sidebar-logout",
|
||||
div { class: "sidebar-bottom-actions",
|
||||
Link {
|
||||
to: NavigationTarget::<Route>::External("/auth/logout".into()),
|
||||
to: NavigationTarget::<Route>::External("/logout".into()),
|
||||
class: "sidebar-link logout-btn",
|
||||
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
|
||||
span { "Logout" }
|
||||
}
|
||||
ThemeToggle {}
|
||||
}
|
||||
|
||||
SidebarFooter {}
|
||||
@@ -107,30 +109,123 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar circle and email display at the top of the sidebar.
|
||||
/// Avatar circle, name, and email display at the top of the sidebar.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - User display name. If non-empty, shown above the email.
|
||||
/// * `email` - User email to display.
|
||||
/// * `avatar_url` - Placeholder for future avatar image URL.
|
||||
#[component]
|
||||
fn SidebarHeader(email: String, avatar_url: String) -> Element {
|
||||
// Extract initials from email (first two chars before @).
|
||||
let initials: String = email
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("U")
|
||||
.chars()
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase();
|
||||
fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element {
|
||||
// Derive initials: prefer name words, fall back to email prefix.
|
||||
let initials: String = if name.is_empty() {
|
||||
email
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("U")
|
||||
.chars()
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
} else {
|
||||
name.split_whitespace()
|
||||
.filter_map(|w| w.chars().next())
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "sidebar-header",
|
||||
div { class: "avatar-circle",
|
||||
span { class: "avatar-initials", "{initials}" }
|
||||
}
|
||||
p { class: "sidebar-email", "{email}" }
|
||||
div { class: "sidebar-user-info",
|
||||
if !name.is_empty() {
|
||||
p { class: "sidebar-name", "{name}" }
|
||||
}
|
||||
p { class: "sidebar-email", "{email}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle button that switches between dark and light themes.
|
||||
///
|
||||
/// Sets `data-theme` on the `<html>` element and persists the choice
|
||||
/// in `localStorage` so it survives page reloads.
|
||||
#[component]
|
||||
fn ThemeToggle() -> Element {
|
||||
let mut is_dark = use_signal(|| {
|
||||
// Read persisted preference from localStorage on first render.
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok().flatten())
|
||||
.and_then(|s| s.get_item("theme").ok().flatten())
|
||||
.is_none_or(|v| v != "certifai-light")
|
||||
}
|
||||
#[cfg(not(feature = "web"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
// Apply the persisted theme to the DOM on first render so the
|
||||
// page doesn't flash dark if the user previously chose light.
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let dark = *is_dark.read();
|
||||
use_effect(move || {
|
||||
let theme = if dark {
|
||||
"certifai-dark"
|
||||
} else {
|
||||
"certifai-light"
|
||||
};
|
||||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(el) = doc.document_element() {
|
||||
let _ = el.set_attribute("data-theme", theme);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let toggle = move |_| {
|
||||
let new_dark = !*is_dark.read();
|
||||
is_dark.set(new_dark);
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let theme = if new_dark {
|
||||
"certifai-dark"
|
||||
} else {
|
||||
"certifai-light"
|
||||
};
|
||||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(el) = doc.document_element() {
|
||||
let _ = el.set_attribute("data-theme", theme);
|
||||
}
|
||||
}
|
||||
if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
|
||||
{
|
||||
let _ = storage.set_item("theme", theme);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let dark = *is_dark.read();
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: "theme-toggle-btn",
|
||||
title: if dark { "Switch to light mode" } else { "Switch to dark mode" },
|
||||
onclick: toggle,
|
||||
if dark {
|
||||
Icon { icon: BsSunFill, width: 16, height: 16 }
|
||||
} else {
|
||||
Icon { icon: BsMoonFill, width: 16, height: 16 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +245,11 @@ fn SidebarFooter() -> Element {
|
||||
Icon { icon: BsGrid, width: 16, height: 16 }
|
||||
}
|
||||
}
|
||||
div { class: "sidebar-legal",
|
||||
Link { to: Route::PrivacyPage {}, class: "legal-link", "Privacy Policy" }
|
||||
span { class: "legal-sep", "|" }
|
||||
Link { to: Route::ImpressumPage {}, class: "legal-link", "Impressum" }
|
||||
}
|
||||
p { class: "sidebar-version", "v{version}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ use rand::RngExt;
|
||||
use tower_sessions::Session;
|
||||
use url::Url;
|
||||
|
||||
use crate::infrastructure::{state::User, Error, UserStateInner};
|
||||
use crate::infrastructure::{
|
||||
server_state::ServerState,
|
||||
state::{User, UserStateInner},
|
||||
Error,
|
||||
};
|
||||
|
||||
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
||||
|
||||
@@ -55,70 +59,6 @@ impl PendingOAuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration loaded from environment variables for Keycloak OAuth.
|
||||
struct OAuthConfig {
|
||||
keycloak_url: String,
|
||||
realm: String,
|
||||
client_id: String,
|
||||
redirect_uri: String,
|
||||
app_url: String,
|
||||
}
|
||||
|
||||
impl OAuthConfig {
|
||||
/// Load OAuth configuration from environment variables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::StateError` if any required env var is missing.
|
||||
fn from_env() -> Result<Self, Error> {
|
||||
dotenvy::dotenv().ok();
|
||||
Ok(Self {
|
||||
keycloak_url: std::env::var("KEYCLOAK_URL")
|
||||
.map_err(|_| Error::StateError("KEYCLOAK_URL not set".into()))?,
|
||||
realm: std::env::var("KEYCLOAK_REALM")
|
||||
.map_err(|_| Error::StateError("KEYCLOAK_REALM not set".into()))?,
|
||||
client_id: std::env::var("KEYCLOAK_CLIENT_ID")
|
||||
.map_err(|_| Error::StateError("KEYCLOAK_CLIENT_ID not set".into()))?,
|
||||
redirect_uri: std::env::var("REDIRECT_URI")
|
||||
.map_err(|_| Error::StateError("REDIRECT_URI not set".into()))?,
|
||||
app_url: std::env::var("APP_URL")
|
||||
.map_err(|_| Error::StateError("APP_URL not set".into()))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the Keycloak OpenID Connect authorization endpoint URL.
|
||||
fn auth_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/auth",
|
||||
self.keycloak_url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the Keycloak OpenID Connect token endpoint URL.
|
||||
fn token_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/token",
|
||||
self.keycloak_url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the Keycloak OpenID Connect userinfo endpoint URL.
|
||||
fn userinfo_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/userinfo",
|
||||
self.keycloak_url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the Keycloak OpenID Connect end-session (logout) endpoint URL.
|
||||
fn logout_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/logout",
|
||||
self.keycloak_url, self.realm
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random state string for CSRF protection.
|
||||
fn generate_state() -> String {
|
||||
let bytes: [u8; 32] = rand::rng().random();
|
||||
@@ -165,35 +105,36 @@ fn derive_code_challenge(verifier: &str) -> String {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if env vars are missing.
|
||||
/// Returns `Error` if the Keycloak config is missing or the URL is malformed.
|
||||
#[axum::debug_handler]
|
||||
pub async fn auth_login(
|
||||
Extension(state): Extension<ServerState>,
|
||||
Extension(pending): Extension<PendingOAuthStore>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let config = OAuthConfig::from_env()?;
|
||||
let state = generate_state();
|
||||
let kc = state.keycloak;
|
||||
let csrf_state = generate_state();
|
||||
let code_verifier = generate_code_verifier();
|
||||
let code_challenge = derive_code_challenge(&code_verifier);
|
||||
|
||||
let redirect_url = params.get("redirect_url").cloned();
|
||||
pending.insert(
|
||||
state.clone(),
|
||||
csrf_state.clone(),
|
||||
PendingOAuthEntry {
|
||||
redirect_url,
|
||||
code_verifier,
|
||||
},
|
||||
);
|
||||
|
||||
let mut url = Url::parse(&config.auth_endpoint())
|
||||
let mut url = Url::parse(&kc.auth_endpoint())
|
||||
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("client_id", &config.client_id)
|
||||
.append_pair("redirect_uri", &config.redirect_uri)
|
||||
.append_pair("client_id", &kc.client_id)
|
||||
.append_pair("redirect_uri", &kc.redirect_uri)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("scope", "openid profile email")
|
||||
.append_pair("state", &state)
|
||||
.append_pair("state", &csrf_state)
|
||||
.append_pair("code_challenge", &code_challenge)
|
||||
.append_pair("code_challenge_method", "S256");
|
||||
|
||||
@@ -213,6 +154,10 @@ struct UserinfoResponse {
|
||||
/// The subject identifier (unique user ID in Keycloak).
|
||||
sub: String,
|
||||
email: Option<String>,
|
||||
/// Keycloak `preferred_username` claim.
|
||||
preferred_username: Option<String>,
|
||||
/// Full name from the Keycloak profile.
|
||||
name: Option<String>,
|
||||
/// Keycloak may include a picture/avatar URL via protocol mappers.
|
||||
picture: Option<String>,
|
||||
}
|
||||
@@ -234,10 +179,11 @@ struct UserinfoResponse {
|
||||
#[axum::debug_handler]
|
||||
pub async fn auth_callback(
|
||||
session: Session,
|
||||
Extension(state): Extension<ServerState>,
|
||||
Extension(pending): Extension<PendingOAuthStore>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let config = OAuthConfig::from_env()?;
|
||||
let kc = state.keycloak;
|
||||
|
||||
// --- CSRF validation via the in-memory pending store ---
|
||||
let returned_state = params
|
||||
@@ -255,11 +201,11 @@ pub async fn auth_callback(
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let token_resp = client
|
||||
.post(config.token_endpoint())
|
||||
.post(kc.token_endpoint())
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", &config.client_id),
|
||||
("redirect_uri", &config.redirect_uri),
|
||||
("client_id", kc.client_id.as_str()),
|
||||
("redirect_uri", kc.redirect_uri.as_str()),
|
||||
("code", code),
|
||||
("code_verifier", &entry.code_verifier),
|
||||
])
|
||||
@@ -279,7 +225,7 @@ pub async fn auth_callback(
|
||||
|
||||
// --- Fetch userinfo ---
|
||||
let userinfo: UserinfoResponse = client
|
||||
.get(config.userinfo_endpoint())
|
||||
.get(kc.userinfo_endpoint())
|
||||
.bearer_auth(&tokens.access_token)
|
||||
.send()
|
||||
.await
|
||||
@@ -288,6 +234,12 @@ pub async fn auth_callback(
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("userinfo parse failed: {e}")))?;
|
||||
|
||||
// Prefer `name`, fall back to `preferred_username`, then empty.
|
||||
let display_name = userinfo
|
||||
.name
|
||||
.or(userinfo.preferred_username)
|
||||
.unwrap_or_default();
|
||||
|
||||
// --- Build user state and persist in session ---
|
||||
let user_state = UserStateInner {
|
||||
sub: userinfo.sub,
|
||||
@@ -295,6 +247,7 @@ pub async fn auth_callback(
|
||||
refresh_token: tokens.refresh_token.unwrap_or_default(),
|
||||
user: User {
|
||||
email: userinfo.email.unwrap_or_default(),
|
||||
name: display_name,
|
||||
avatar_url: userinfo.picture.unwrap_or_default(),
|
||||
},
|
||||
};
|
||||
@@ -316,10 +269,13 @@ pub async fn auth_callback(
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if env vars are missing or the session cannot be flushed.
|
||||
/// Returns `Error` if the session cannot be flushed or the URL is malformed.
|
||||
#[axum::debug_handler]
|
||||
pub async fn logout(session: Session) -> Result<impl IntoResponse, Error> {
|
||||
let config = OAuthConfig::from_env()?;
|
||||
pub async fn logout(
|
||||
session: Session,
|
||||
Extension(state): Extension<ServerState>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let kc = state.keycloak;
|
||||
|
||||
// Flush all session data.
|
||||
session
|
||||
@@ -327,12 +283,12 @@ pub async fn logout(session: Session) -> Result<impl IntoResponse, Error> {
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("session flush failed: {e}")))?;
|
||||
|
||||
let mut url = Url::parse(&config.logout_endpoint())
|
||||
let mut url = Url::parse(&kc.logout_endpoint())
|
||||
.map_err(|e| Error::StateError(format!("invalid logout endpoint URL: {e}")))?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("client_id", &config.client_id)
|
||||
.append_pair("post_logout_redirect_uri", &config.app_url);
|
||||
.append_pair("client_id", &kc.client_id)
|
||||
.append_pair("post_logout_redirect_uri", &kc.app_url);
|
||||
|
||||
Ok(Redirect::temporary(url.as_str()))
|
||||
}
|
||||
|
||||
36
src/infrastructure/auth_check.rs
Normal file
36
src/infrastructure/auth_check.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::models::AuthInfo;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Check the current user's authentication state.
|
||||
///
|
||||
/// Reads the tower-sessions session on the server and returns an
|
||||
/// [`AuthInfo`] describing the logged-in user. When no valid session
|
||||
/// exists, `authenticated` is `false` and all other fields are empty.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the session store cannot be read.
|
||||
#[server(endpoint = "check-auth")]
|
||||
pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
||||
use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY;
|
||||
use crate::infrastructure::state::UserStateInner;
|
||||
use dioxus_fullstack::FullstackContext;
|
||||
|
||||
let session: tower_sessions::Session = FullstackContext::extract().await?;
|
||||
|
||||
let user_state: Option<UserStateInner> = session
|
||||
.get(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
||||
|
||||
match user_state {
|
||||
Some(u) => Ok(AuthInfo {
|
||||
authenticated: true,
|
||||
sub: u.sub,
|
||||
email: u.user.email,
|
||||
name: u.user.name,
|
||||
avatar_url: u.user.avatar_url,
|
||||
}),
|
||||
None => Ok(AuthInfo::default()),
|
||||
}
|
||||
}
|
||||
41
src/infrastructure/auth_middleware.rs
Normal file
41
src/infrastructure/auth_middleware.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY;
|
||||
use crate::infrastructure::state::UserStateInner;
|
||||
|
||||
/// Server function endpoints that are allowed without authentication.
|
||||
///
|
||||
/// `check-auth` must be public so the frontend can determine login state.
|
||||
const PUBLIC_API_ENDPOINTS: &[&str] = &["/api/check-auth"];
|
||||
|
||||
/// Axum middleware that enforces authentication on `/api/` server
|
||||
/// function endpoints.
|
||||
///
|
||||
/// Requests whose path starts with `/api/` (except those listed in
|
||||
/// [`PUBLIC_API_ENDPOINTS`]) are rejected with `401 Unauthorized` when
|
||||
/// no valid session exists. All other paths pass through untouched.
|
||||
pub async fn require_auth(session: Session, request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path();
|
||||
|
||||
// Only gate /api/ server function routes.
|
||||
if path.starts_with("/api/") && !PUBLIC_API_ENDPOINTS.contains(&path) {
|
||||
let is_authed = session
|
||||
.get::<UserStateInner>(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if !is_authed {
|
||||
return (StatusCode::UNAUTHORIZED, "Authentication required").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
507
src/infrastructure/chat.rs
Normal file
507
src/infrastructure/chat.rs
Normal file
@@ -0,0 +1,507 @@
|
||||
//! Chat CRUD server functions for session and message persistence.
|
||||
//!
|
||||
//! Each function extracts the user's `sub` from the tower-sessions session
|
||||
//! to scope all queries to the authenticated user. The `ServerState` provides
|
||||
//! access to the MongoDB [`Database`](super::database::Database).
|
||||
|
||||
use crate::models::{ChatMessage, ChatSession};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Convert a raw BSON document to a `ChatSession`, extracting `_id` as a hex string.
|
||||
#[cfg(feature = "server")]
|
||||
pub(crate) fn doc_to_chat_session(doc: &mongodb::bson::Document) -> ChatSession {
|
||||
use crate::models::ChatNamespace;
|
||||
|
||||
let id = doc
|
||||
.get_object_id("_id")
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default();
|
||||
let namespace = match doc.get_str("namespace").unwrap_or("General") {
|
||||
"News" => ChatNamespace::News,
|
||||
_ => ChatNamespace::General,
|
||||
};
|
||||
let article_url = doc
|
||||
.get_str("article_url")
|
||||
.ok()
|
||||
.map(String::from)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
ChatSession {
|
||||
id,
|
||||
user_sub: doc.get_str("user_sub").unwrap_or_default().to_string(),
|
||||
title: doc.get_str("title").unwrap_or_default().to_string(),
|
||||
namespace,
|
||||
provider: doc.get_str("provider").unwrap_or_default().to_string(),
|
||||
model: doc.get_str("model").unwrap_or_default().to_string(),
|
||||
created_at: doc.get_str("created_at").unwrap_or_default().to_string(),
|
||||
updated_at: doc.get_str("updated_at").unwrap_or_default().to_string(),
|
||||
article_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a raw BSON document to a `ChatMessage`, extracting `_id` as a hex string.
|
||||
#[cfg(feature = "server")]
|
||||
pub(crate) fn doc_to_chat_message(doc: &mongodb::bson::Document) -> ChatMessage {
|
||||
use crate::models::ChatRole;
|
||||
|
||||
let id = doc
|
||||
.get_object_id("_id")
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default();
|
||||
let role = match doc.get_str("role").unwrap_or("User") {
|
||||
"Assistant" => ChatRole::Assistant,
|
||||
"System" => ChatRole::System,
|
||||
_ => ChatRole::User,
|
||||
};
|
||||
ChatMessage {
|
||||
id,
|
||||
session_id: doc.get_str("session_id").unwrap_or_default().to_string(),
|
||||
role,
|
||||
content: doc.get_str("content").unwrap_or_default().to_string(),
|
||||
attachments: Vec::new(),
|
||||
timestamp: doc.get_str("timestamp").unwrap_or_default().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: extract the authenticated user's `sub` from the session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the session is missing or unreadable.
|
||||
#[cfg(feature = "server")]
|
||||
async fn require_user_sub() -> Result<String, ServerFnError> {
|
||||
use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY;
|
||||
use crate::infrastructure::state::UserStateInner;
|
||||
use dioxus_fullstack::FullstackContext;
|
||||
|
||||
let session: tower_sessions::Session = FullstackContext::extract().await?;
|
||||
let user: UserStateInner = session
|
||||
.get(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?
|
||||
.ok_or_else(|| ServerFnError::new("not authenticated"))?;
|
||||
Ok(user.sub)
|
||||
}
|
||||
|
||||
/// Helper: extract the [`ServerState`] from the request context.
|
||||
#[cfg(feature = "server")]
|
||||
async fn require_state() -> Result<crate::infrastructure::ServerState, ServerFnError> {
|
||||
dioxus_fullstack::FullstackContext::extract().await
|
||||
}
|
||||
|
||||
/// List all chat sessions for the authenticated user, ordered by
|
||||
/// `updated_at` descending (most recent first).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the database query fails.
|
||||
#[server(endpoint = "list-chat-sessions")]
|
||||
pub async fn list_chat_sessions() -> Result<Vec<ChatSession>, ServerFnError> {
|
||||
use mongodb::bson::doc;
|
||||
use mongodb::options::FindOptions;
|
||||
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let opts = FindOptions::builder()
|
||||
.sort(doc! { "updated_at": -1 })
|
||||
.build();
|
||||
|
||||
let mut cursor = state
|
||||
.db
|
||||
.raw_collection("chat_sessions")
|
||||
.find(doc! { "user_sub": &user_sub })
|
||||
.with_options(opts)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
use futures::TryStreamExt;
|
||||
while let Some(raw_doc) = cursor
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("cursor error: {e}")))?
|
||||
{
|
||||
sessions.push(doc_to_chat_session(&raw_doc));
|
||||
}
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Create a new chat session and return it with the MongoDB-generated ID.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `title` - Display title for the session
|
||||
/// * `namespace` - Namespace string: `"General"` or `"News"`
|
||||
/// * `provider` - LLM provider name (e.g. "ollama")
|
||||
/// * `model` - Model ID (e.g. "llama3.1:8b")
|
||||
/// * `article_url` - Source article URL (only for `News` namespace, empty if none)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the insert fails.
|
||||
#[server(endpoint = "create-chat-session")]
|
||||
pub async fn create_chat_session(
|
||||
title: String,
|
||||
namespace: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
article_url: String,
|
||||
) -> Result<ChatSession, ServerFnError> {
|
||||
use crate::models::ChatNamespace;
|
||||
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let ns = if namespace == "News" {
|
||||
ChatNamespace::News
|
||||
} else {
|
||||
ChatNamespace::General
|
||||
};
|
||||
|
||||
let url = if article_url.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(article_url)
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let session = ChatSession {
|
||||
id: String::new(), // MongoDB will generate _id
|
||||
user_sub,
|
||||
title,
|
||||
namespace: ns,
|
||||
provider,
|
||||
model,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
article_url: url,
|
||||
};
|
||||
|
||||
let result = state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.insert_one(&session)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("insert failed: {e}")))?;
|
||||
|
||||
// Return the session with the generated ID
|
||||
let id = result
|
||||
.inserted_id
|
||||
.as_object_id()
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ChatSession { id, ..session })
|
||||
}
|
||||
|
||||
/// Rename a chat session.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The MongoDB document ID of the session
|
||||
/// * `new_title` - The new title to set
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication, the session is not found,
|
||||
/// or the update fails.
|
||||
#[server(endpoint = "rename-chat-session")]
|
||||
pub async fn rename_chat_session(
|
||||
session_id: String,
|
||||
new_title: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let oid = ObjectId::parse_str(&session_id)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
||||
|
||||
let result = state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": oid, "user_sub": &user_sub },
|
||||
doc! { "$set": { "title": &new_title, "updated_at": chrono::Utc::now().to_rfc3339() } },
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("update failed: {e}")))?;
|
||||
|
||||
if result.matched_count == 0 {
|
||||
return Err(ServerFnError::new("session not found or not owned by user"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a chat session and all its messages.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The MongoDB document ID of the session
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the delete fails.
|
||||
#[server(endpoint = "delete-chat-session")]
|
||||
pub async fn delete_chat_session(session_id: String) -> Result<(), ServerFnError> {
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let oid = ObjectId::parse_str(&session_id)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
||||
|
||||
// Delete the session (scoped to user)
|
||||
state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.delete_one(doc! { "_id": oid, "user_sub": &user_sub })
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("delete session failed: {e}")))?;
|
||||
|
||||
// Delete all messages belonging to this session
|
||||
state
|
||||
.db
|
||||
.chat_messages()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("delete messages failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all messages for a chat session, ordered by timestamp ascending.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The MongoDB document ID of the session
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the query fails.
|
||||
#[server(endpoint = "list-chat-messages")]
|
||||
pub async fn list_chat_messages(session_id: String) -> Result<Vec<ChatMessage>, ServerFnError> {
|
||||
use mongodb::bson::doc;
|
||||
use mongodb::options::FindOptions;
|
||||
|
||||
// Verify the user owns this session
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
// Verify the user owns this session using ObjectId for _id matching
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
let session_oid = ObjectId::parse_str(&session_id)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
||||
|
||||
let session_exists = state
|
||||
.db
|
||||
.raw_collection("chat_sessions")
|
||||
.count_documents(doc! { "_id": session_oid, "user_sub": &user_sub })
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
|
||||
|
||||
if session_exists == 0 {
|
||||
return Err(ServerFnError::new("session not found or not owned by user"));
|
||||
}
|
||||
|
||||
let opts = FindOptions::builder().sort(doc! { "timestamp": 1 }).build();
|
||||
|
||||
let mut cursor = state
|
||||
.db
|
||||
.raw_collection("chat_messages")
|
||||
.find(doc! { "session_id": &session_id })
|
||||
.with_options(opts)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
use futures::TryStreamExt;
|
||||
while let Some(raw_doc) = cursor
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("cursor error: {e}")))?
|
||||
{
|
||||
messages.push(doc_to_chat_message(&raw_doc));
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Persist a single chat message and return it with the MongoDB-generated ID.
|
||||
///
|
||||
/// Also updates the parent session's `updated_at` timestamp.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The session this message belongs to
|
||||
/// * `role` - Message role string: `"user"`, `"assistant"`, or `"system"`
|
||||
/// * `content` - Message text content
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the insert fails.
|
||||
#[server(endpoint = "save-chat-message")]
|
||||
pub async fn save_chat_message(
|
||||
session_id: String,
|
||||
role: String,
|
||||
content: String,
|
||||
) -> Result<ChatMessage, ServerFnError> {
|
||||
use crate::models::ChatRole;
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
|
||||
let _user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let chat_role = match role.as_str() {
|
||||
"assistant" => ChatRole::Assistant,
|
||||
"system" => ChatRole::System,
|
||||
_ => ChatRole::User,
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let message = ChatMessage {
|
||||
id: String::new(),
|
||||
session_id: session_id.clone(),
|
||||
role: chat_role,
|
||||
content,
|
||||
attachments: Vec::new(),
|
||||
timestamp: now.clone(),
|
||||
};
|
||||
|
||||
let result = state
|
||||
.db
|
||||
.chat_messages()
|
||||
.insert_one(&message)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("insert failed: {e}")))?;
|
||||
|
||||
let id = result
|
||||
.inserted_id
|
||||
.as_object_id()
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Update session's updated_at timestamp
|
||||
if let Ok(session_oid) = ObjectId::parse_str(&session_id) {
|
||||
let _ = state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": session_oid },
|
||||
doc! { "$set": { "updated_at": &now } },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(ChatMessage { id, ..message })
|
||||
}
|
||||
|
||||
/// Non-streaming chat completion (fallback for article panel).
|
||||
///
|
||||
/// Sends the full conversation history to the configured LLM provider
|
||||
/// and returns the complete response. Used where SSE streaming is not
|
||||
/// needed (e.g. dashboard article follow-up panel).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The chat session ID (loads provider/model config)
|
||||
/// * `messages_json` - Conversation history as JSON string:
|
||||
/// `[{"role":"user","content":"..."},...]`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the LLM request fails.
|
||||
#[server(endpoint = "chat-complete")]
|
||||
pub async fn chat_complete(
|
||||
session_id: String,
|
||||
messages_json: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
|
||||
let _user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
// Load the session to get provider/model
|
||||
let session_oid = ObjectId::parse_str(&session_id)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
||||
|
||||
let session_doc = state
|
||||
.db
|
||||
.raw_collection("chat_sessions")
|
||||
.find_one(doc! { "_id": session_oid })
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?
|
||||
.ok_or_else(|| ServerFnError::new("session not found"))?;
|
||||
let session = doc_to_chat_session(&session_doc);
|
||||
|
||||
// Resolve provider URL and model
|
||||
let (base_url, model) = resolve_provider_url(&state, &session.provider, &session.model);
|
||||
|
||||
// Parse messages from JSON
|
||||
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid messages JSON: {e}")))?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": chat_msgs,
|
||||
"stream": false,
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("LLM request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!("LLM returned {status}: {text}")));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("parse error: {e}")))?;
|
||||
|
||||
json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.ok_or_else(|| ServerFnError::new("empty LLM response"))
|
||||
}
|
||||
|
||||
/// Resolve the base URL for a provider, falling back to server defaults.
|
||||
#[cfg(feature = "server")]
|
||||
fn resolve_provider_url(
|
||||
state: &crate::infrastructure::ServerState,
|
||||
provider: &str,
|
||||
model: &str,
|
||||
) -> (String, String) {
|
||||
match provider {
|
||||
"openai" => ("https://api.openai.com".to_string(), model.to_string()),
|
||||
"anthropic" => ("https://api.anthropic.com".to_string(), model.to_string()),
|
||||
"huggingface" => (
|
||||
format!("https://api-inference.huggingface.co/models/{}", model),
|
||||
model.to_string(),
|
||||
),
|
||||
// Default to Ollama
|
||||
_ => (
|
||||
state.services.ollama_url.clone(),
|
||||
if model.is_empty() {
|
||||
state.services.ollama_model.clone()
|
||||
} else {
|
||||
model.to_string()
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
266
src/infrastructure/chat_stream.rs
Normal file
266
src/infrastructure/chat_stream.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
//! 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
253
src/infrastructure/config.rs
Normal file
253
src/infrastructure/config.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! Configuration structs loaded once at startup from environment variables.
|
||||
//!
|
||||
//! Each struct provides a `from_env()` constructor that reads `std::env::var`
|
||||
//! values. Required variables cause an `Error::ConfigError` on failure;
|
||||
//! optional ones default to an empty string.
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use super::Error;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read a required environment variable or return `Error::ConfigError`.
|
||||
fn required_env(name: &str) -> Result<String, Error> {
|
||||
std::env::var(name).map_err(|_| Error::ConfigError(format!("{name} is required but not set")))
|
||||
}
|
||||
|
||||
/// Read an optional environment variable, defaulting to an empty string.
|
||||
fn optional_env(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KeycloakConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keycloak OpenID Connect settings for the public (frontend) client.
|
||||
///
|
||||
/// Also carries the admin service-account credentials used for
|
||||
/// server-to-server calls (e.g. user management APIs).
|
||||
#[derive(Debug)]
|
||||
pub struct KeycloakConfig {
|
||||
/// Base URL of the Keycloak instance (e.g. `http://localhost:8080`).
|
||||
pub url: String,
|
||||
/// Keycloak realm name.
|
||||
pub realm: String,
|
||||
/// Public client ID used by the dashboard frontend.
|
||||
pub client_id: String,
|
||||
/// OAuth redirect URI registered in Keycloak.
|
||||
pub redirect_uri: String,
|
||||
/// Root URL of this application (used for post-logout redirect).
|
||||
pub app_url: String,
|
||||
/// Confidential client ID for admin/server-to-server calls.
|
||||
pub admin_client_id: String,
|
||||
/// Confidential client secret (wrapped for debug safety).
|
||||
pub admin_client_secret: SecretString,
|
||||
}
|
||||
|
||||
impl KeycloakConfig {
|
||||
/// Load Keycloak configuration from environment variables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::ConfigError` if a required variable is missing.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
url: required_env("KEYCLOAK_URL")?,
|
||||
realm: required_env("KEYCLOAK_REALM")?,
|
||||
client_id: required_env("KEYCLOAK_CLIENT_ID")?,
|
||||
redirect_uri: required_env("REDIRECT_URI")?,
|
||||
app_url: required_env("APP_URL")?,
|
||||
admin_client_id: optional_env("KEYCLOAK_ADMIN_CLIENT_ID"),
|
||||
admin_client_secret: SecretString::from(optional_env("KEYCLOAK_ADMIN_CLIENT_SECRET")),
|
||||
})
|
||||
}
|
||||
|
||||
/// OpenID Connect authorization endpoint URL.
|
||||
pub fn auth_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/auth",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// OpenID Connect token endpoint URL.
|
||||
pub fn token_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/token",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// OpenID Connect userinfo endpoint URL.
|
||||
pub fn userinfo_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/userinfo",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// OpenID Connect end-session (logout) endpoint URL.
|
||||
pub fn logout_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/logout",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SmtpConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SMTP mail settings for transactional emails (invites, alerts, etc.).
|
||||
#[derive(Debug)]
|
||||
pub struct SmtpConfig {
|
||||
/// SMTP server hostname.
|
||||
pub host: String,
|
||||
/// SMTP server port (as string for flexibility, e.g. "587").
|
||||
pub port: String,
|
||||
/// SMTP username.
|
||||
pub username: String,
|
||||
/// SMTP password (wrapped for debug safety).
|
||||
pub password: SecretString,
|
||||
/// Sender address shown in the `From:` header.
|
||||
pub from_address: String,
|
||||
}
|
||||
|
||||
impl SmtpConfig {
|
||||
/// Load SMTP configuration from environment variables.
|
||||
///
|
||||
/// All fields are optional; defaults to empty strings when absent.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Currently infallible but returns `Result` for consistency.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
host: optional_env("SMTP_HOST"),
|
||||
port: optional_env("SMTP_PORT"),
|
||||
username: optional_env("SMTP_USERNAME"),
|
||||
password: SecretString::from(optional_env("SMTP_PASSWORD")),
|
||||
from_address: optional_env("SMTP_FROM_ADDRESS"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceUrls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// URLs and credentials for external services (Ollama, SearXNG, S3, etc.).
|
||||
#[derive(Debug)]
|
||||
pub struct ServiceUrls {
|
||||
/// Ollama LLM instance base URL.
|
||||
pub ollama_url: String,
|
||||
/// Default Ollama model to use.
|
||||
pub ollama_model: String,
|
||||
/// SearXNG meta-search engine base URL.
|
||||
pub searxng_url: String,
|
||||
/// LangChain service URL.
|
||||
pub langchain_url: String,
|
||||
/// LangGraph service URL.
|
||||
pub langgraph_url: String,
|
||||
/// Langfuse observability URL.
|
||||
pub langfuse_url: String,
|
||||
/// Vector database URL.
|
||||
pub vectordb_url: String,
|
||||
/// S3-compatible object storage URL.
|
||||
pub s3_url: String,
|
||||
/// S3 access key.
|
||||
pub s3_access_key: String,
|
||||
/// S3 secret key (wrapped for debug safety).
|
||||
pub s3_secret_key: SecretString,
|
||||
}
|
||||
|
||||
impl ServiceUrls {
|
||||
/// Load service URLs from environment variables.
|
||||
///
|
||||
/// All fields are optional with sensible defaults where applicable.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Currently infallible but returns `Result` for consistency.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
ollama_url: std::env::var("OLLAMA_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:11434".into()),
|
||||
ollama_model: std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()),
|
||||
searxng_url: std::env::var("SEARXNG_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8888".into()),
|
||||
langchain_url: optional_env("LANGCHAIN_URL"),
|
||||
langgraph_url: optional_env("LANGGRAPH_URL"),
|
||||
langfuse_url: optional_env("LANGFUSE_URL"),
|
||||
vectordb_url: optional_env("VECTORDB_URL"),
|
||||
s3_url: optional_env("S3_URL"),
|
||||
s3_access_key: optional_env("S3_ACCESS_KEY"),
|
||||
s3_secret_key: SecretString::from(optional_env("S3_SECRET_KEY")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StripeConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stripe billing configuration.
|
||||
#[derive(Debug)]
|
||||
pub struct StripeConfig {
|
||||
/// Stripe secret API key (wrapped for debug safety).
|
||||
pub secret_key: SecretString,
|
||||
/// Stripe webhook signing secret (wrapped for debug safety).
|
||||
pub webhook_secret: SecretString,
|
||||
/// Stripe publishable key (safe to expose to the frontend).
|
||||
pub publishable_key: String,
|
||||
}
|
||||
|
||||
impl StripeConfig {
|
||||
/// Load Stripe configuration from environment variables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Currently infallible but returns `Result` for consistency.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
secret_key: SecretString::from(optional_env("STRIPE_SECRET_KEY")),
|
||||
webhook_secret: SecretString::from(optional_env("STRIPE_WEBHOOK_SECRET")),
|
||||
publishable_key: optional_env("STRIPE_PUBLISHABLE_KEY"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LlmProvidersConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Comma-separated list of enabled LLM provider identifiers.
|
||||
///
|
||||
/// For example: `LLM_PROVIDERS=ollama,openai,anthropic`
|
||||
#[derive(Debug)]
|
||||
pub struct LlmProvidersConfig {
|
||||
/// Parsed provider names.
|
||||
pub providers: Vec<String>,
|
||||
}
|
||||
|
||||
impl LlmProvidersConfig {
|
||||
/// Load the provider list from `LLM_PROVIDERS`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Currently infallible but returns `Result` for consistency.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
let raw = optional_env("LLM_PROVIDERS");
|
||||
let providers: Vec<String> = raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
Ok(Self { providers })
|
||||
}
|
||||
}
|
||||
68
src/infrastructure/database.rs
Normal file
68
src/infrastructure/database.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! MongoDB connection wrapper with typed collection accessors.
|
||||
|
||||
use mongodb::{bson::doc, Client, Collection};
|
||||
|
||||
use super::Error;
|
||||
use crate::models::{ChatMessage, ChatSession, OrgBillingRecord, OrgSettings, UserPreferences};
|
||||
|
||||
/// Thin wrapper around [`mongodb::Database`] that provides typed
|
||||
/// collection accessors for the application's domain models.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Database {
|
||||
inner: mongodb::Database,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Connect to MongoDB, select the given database, and verify
|
||||
/// connectivity with a `ping` command.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uri` - MongoDB connection string (e.g. `mongodb://localhost:27017`)
|
||||
/// * `db_name` - Database name to use
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::DatabaseError` if the client cannot be created
|
||||
/// or the ping fails.
|
||||
pub async fn connect(uri: &str, db_name: &str) -> Result<Self, Error> {
|
||||
let client = Client::with_uri_str(uri).await?;
|
||||
let db = client.database(db_name);
|
||||
|
||||
// Verify the connection is alive.
|
||||
db.run_command(doc! { "ping": 1 }).await?;
|
||||
|
||||
Ok(Self { inner: db })
|
||||
}
|
||||
|
||||
/// Collection for per-user preferences (theme, custom topics, etc.).
|
||||
pub fn user_preferences(&self) -> Collection<UserPreferences> {
|
||||
self.inner.collection("user_preferences")
|
||||
}
|
||||
|
||||
/// Collection for organisation-level settings.
|
||||
pub fn org_settings(&self) -> Collection<OrgSettings> {
|
||||
self.inner.collection("org_settings")
|
||||
}
|
||||
|
||||
/// Collection for per-cycle billing records.
|
||||
pub fn org_billing(&self) -> Collection<OrgBillingRecord> {
|
||||
self.inner.collection("org_billing")
|
||||
}
|
||||
|
||||
/// Collection for persisted chat sessions (sidebar listing).
|
||||
pub fn chat_sessions(&self) -> Collection<ChatSession> {
|
||||
self.inner.collection("chat_sessions")
|
||||
}
|
||||
|
||||
/// Collection for individual chat messages within sessions.
|
||||
pub fn chat_messages(&self) -> Collection<ChatMessage> {
|
||||
self.inner.collection("chat_messages")
|
||||
}
|
||||
|
||||
/// Raw BSON document collection for queries that need manual
|
||||
/// `_id` → `String` conversion (avoids `ObjectId` deserialization issues).
|
||||
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
|
||||
self.inner.collection(name)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,43 @@
|
||||
use axum::response::IntoResponse;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
/// Central error type for infrastructure-layer failures.
|
||||
///
|
||||
/// Each variant maps to an appropriate HTTP status code when converted
|
||||
/// into an Axum response.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
StateError(String),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[error("IoError: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<mongodb::error::Error> for Error {
|
||||
fn from(err: mongodb::error::Error) -> Self {
|
||||
Self::DatabaseError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let msg = self.to_string();
|
||||
tracing::error!("Converting Error to Response: {msg}");
|
||||
match self {
|
||||
Self::StateError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Unknown error").into_response(),
|
||||
Self::StateError(e) | Self::ConfigError(e) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
|
||||
}
|
||||
Self::DatabaseError(e) => (StatusCode::SERVICE_UNAVAILABLE, e).into_response(),
|
||||
Self::IoError(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,26 +159,27 @@ mod inner {
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||
#[server(endpoint = "/api/summarize")]
|
||||
#[post("/api/summarize")]
|
||||
pub async fn summarize_article(
|
||||
snippet: String,
|
||||
article_url: String,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||
|
||||
// Fall back to env var or default if the URL is empty
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
// Use caller-provided values or fall back to ServerState config
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into())
|
||||
state.services.ollama_url.clone()
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
// Fall back to env var or default if the model is empty
|
||||
let model = if model.is_empty() {
|
||||
std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into())
|
||||
state.services.ollama_model.clone()
|
||||
} else {
|
||||
model
|
||||
};
|
||||
@@ -258,23 +259,25 @@ pub struct FollowUpMessage {
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||
#[server(endpoint = "/api/chat")]
|
||||
#[post("/api/chat")]
|
||||
pub async fn chat_followup(
|
||||
messages: Vec<FollowUpMessage>,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into())
|
||||
state.services.ollama_url.clone()
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
let model = if model.is_empty() {
|
||||
std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into())
|
||||
state.services.ollama_model.clone()
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
// Server function modules (compiled for both web and server features;
|
||||
// the #[server] macro generates client stubs for the web target)
|
||||
pub mod auth_check;
|
||||
pub mod chat;
|
||||
pub mod llm;
|
||||
pub mod ollama;
|
||||
pub mod searxng;
|
||||
|
||||
// Server-only modules (Axum handlers, state, etc.)
|
||||
// Server-only modules (Axum handlers, state, configs, DB, etc.)
|
||||
#[cfg(feature = "server")]
|
||||
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;
|
||||
#[cfg(feature = "server")]
|
||||
mod error;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod provider_client;
|
||||
#[cfg(feature = "server")]
|
||||
mod server;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server_state;
|
||||
#[cfg(feature = "server")]
|
||||
mod state;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
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::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use server_state::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use state::*;
|
||||
|
||||
@@ -45,12 +45,13 @@ struct OllamaModel {
|
||||
///
|
||||
/// Returns `ServerFnError` only on serialization issues; network failures
|
||||
/// are caught and returned as `online: false`
|
||||
#[server(endpoint = "/api/ollama-status")]
|
||||
#[post("/api/ollama-status")]
|
||||
pub async fn get_ollama_status(ollama_url: String) -> Result<OllamaStatus, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into())
|
||||
state.services.ollama_url.clone()
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
148
src/infrastructure/provider_client.rs
Normal file
148
src/infrastructure/provider_client.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
//! Unified LLM provider dispatch.
|
||||
//!
|
||||
//! Routes chat completion requests to Ollama, OpenAI, Anthropic, or
|
||||
//! HuggingFace based on the session's provider setting. All providers
|
||||
//! except Anthropic use the OpenAI-compatible chat completions format.
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::server_state::ServerState;
|
||||
|
||||
/// OpenAI-compatible chat message used for request bodies.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Send a chat completion request to the configured provider.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `state` - Server state (for default Ollama URL/model)
|
||||
/// * `provider` - Provider name (`"ollama"`, `"openai"`, `"anthropic"`, `"huggingface"`)
|
||||
/// * `model` - Model ID
|
||||
/// * `messages` - Conversation history
|
||||
/// * `api_key` - API key (required for non-Ollama providers)
|
||||
/// * `stream` - Whether to request streaming
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The raw `reqwest::Response` for the caller to consume (streaming or not).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the HTTP request fails.
|
||||
pub async fn send_chat_request(
|
||||
state: &ServerState,
|
||||
provider: &str,
|
||||
model: &str,
|
||||
messages: &[ProviderMessage],
|
||||
api_key: Option<&str>,
|
||||
stream: bool,
|
||||
) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let client = Client::new();
|
||||
|
||||
match provider {
|
||||
"openai" => {
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
});
|
||||
client
|
||||
.post("https://api.openai.com/v1/chat/completions")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", api_key.unwrap_or_default()),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
"anthropic" => {
|
||||
// Anthropic uses a different API format -- translate.
|
||||
// Extract system message separately, convert roles.
|
||||
let system_msg: String = messages
|
||||
.iter()
|
||||
.filter(|m| m.role == "system")
|
||||
.map(|m| m.content.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let anthropic_msgs: Vec<serde_json::Value> = messages
|
||||
.iter()
|
||||
.filter(|m| m.role != "system")
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": anthropic_msgs,
|
||||
"max_tokens": 4096,
|
||||
"stream": stream,
|
||||
});
|
||||
if !system_msg.is_empty() {
|
||||
body["system"] = serde_json::Value::String(system_msg);
|
||||
}
|
||||
|
||||
client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-api-key", api_key.unwrap_or_default())
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
"huggingface" => {
|
||||
let url = format!(
|
||||
"https://api-inference.huggingface.co/models/{}/v1/chat/completions",
|
||||
model
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
});
|
||||
client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", api_key.unwrap_or_default()),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
// Default: Ollama (OpenAI-compatible endpoint)
|
||||
_ => {
|
||||
let base_url = &state.services.ollama_url;
|
||||
let resolved_model = if model.is_empty() {
|
||||
&state.services.ollama_model
|
||||
} else {
|
||||
model
|
||||
};
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
let body = serde_json::json!({
|
||||
"model": resolved_model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
});
|
||||
client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,35 +110,33 @@ mod inner {
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the SearXNG request fails or response parsing fails
|
||||
#[server(endpoint = "/api/search")]
|
||||
#[post("/api/search")]
|
||||
pub async fn search_topic(query: String) -> Result<Vec<NewsCard>, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
use inner::{extract_source, rank_and_deduplicate, SearxngResponse};
|
||||
|
||||
let searxng_url =
|
||||
std::env::var("SEARXNG_URL").unwrap_or_else(|_| "http://localhost:8888".into());
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let searxng_url = state.services.searxng_url.clone();
|
||||
|
||||
// Enrich the query with "latest news" context for better results,
|
||||
// similar to how Perplexity reformulates queries before searching.
|
||||
let enriched_query = format!("{query} latest news");
|
||||
|
||||
// Build URL with query parameters using the url crate's encoder
|
||||
// to avoid reqwest version conflicts between our dep and dioxus's.
|
||||
// Key SearXNG params:
|
||||
// categories=news,general - prioritize news sources + supplement with general
|
||||
// time_range=month - only recent results (last 30 days)
|
||||
// language=en - English results
|
||||
// format=json - machine-readable output
|
||||
let encoded_query: String =
|
||||
url::form_urlencoded::byte_serialize(enriched_query.as_bytes()).collect();
|
||||
let search_url = format!(
|
||||
"{searxng_url}/search?q={encoded_query}&format=json&language=en\
|
||||
&categories=news,general&time_range=month"
|
||||
);
|
||||
// Use POST with form-encoded body because SearXNG's default config
|
||||
// sets `method: "POST"` which rejects GET requests with 405.
|
||||
let search_url = format!("{searxng_url}/search");
|
||||
let params = [
|
||||
("q", enriched_query.as_str()),
|
||||
("format", "json"),
|
||||
("language", "en"),
|
||||
("categories", "news,general"),
|
||||
("time_range", "month"),
|
||||
];
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.post(&search_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("SearXNG request failed: {e}")))?;
|
||||
@@ -198,21 +196,24 @@ pub async fn search_topic(query: String) -> Result<Vec<NewsCard>, ServerFnError>
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the SearXNG search request fails
|
||||
#[server(endpoint = "/api/trending")]
|
||||
#[get("/api/trending")]
|
||||
pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
|
||||
dotenvy::dotenv().ok();
|
||||
use inner::SearxngResponse;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let searxng_url =
|
||||
std::env::var("SEARXNG_URL").unwrap_or_else(|_| "http://localhost:8888".into());
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let searxng_url = state.services.searxng_url.clone();
|
||||
|
||||
let encoded_query: String =
|
||||
url::form_urlencoded::byte_serialize(b"trending technology AI").collect();
|
||||
let search_url = format!(
|
||||
"{searxng_url}/search?q={encoded_query}&format=json&language=en\
|
||||
&categories=news&time_range=week"
|
||||
);
|
||||
// Use POST to match SearXNG's default `method: "POST"` setting
|
||||
let search_url = format!("{searxng_url}/search");
|
||||
let params = [
|
||||
("q", "trending technology AI"),
|
||||
("format", "json"),
|
||||
("language", "en"),
|
||||
("categories", "news"),
|
||||
("time_range", "week"),
|
||||
];
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
@@ -220,7 +221,8 @@ pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
|
||||
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
||||
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.post(&search_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("SearXNG trending search failed: {e}")))?;
|
||||
|
||||
@@ -1,54 +1,95 @@
|
||||
use crate::infrastructure::{
|
||||
auth_callback, auth_login, logout, PendingOAuthStore, UserState, UserStateInner,
|
||||
};
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::Extension;
|
||||
use axum::{middleware, Extension};
|
||||
use time::Duration;
|
||||
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
|
||||
|
||||
use crate::infrastructure::{
|
||||
auth_callback, auth_login, chat_stream_handler,
|
||||
config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig},
|
||||
database::Database,
|
||||
logout, require_auth,
|
||||
server_state::{ServerState, ServerStateInner},
|
||||
PendingOAuthStore,
|
||||
};
|
||||
|
||||
/// Start the Axum server with Dioxus fullstack, session management,
|
||||
/// and Keycloak OAuth routes.
|
||||
/// MongoDB, and Keycloak OAuth routes.
|
||||
///
|
||||
/// Loads all configuration from environment variables once, connects
|
||||
/// to MongoDB, and builds a [`ServerState`] shared across every request.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if the tokio runtime or TCP listener fails to start.
|
||||
/// Returns `Error` if the tokio runtime, config loading, DB connection,
|
||||
/// or TCP listener fails.
|
||||
pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> {
|
||||
tokio::runtime::Runtime::new()?.block_on(async move {
|
||||
let state: UserState = UserStateInner {
|
||||
access_token: "abcd".into(),
|
||||
sub: "abcd".into(),
|
||||
refresh_token: "abcd".into(),
|
||||
..Default::default()
|
||||
// Load .env once at startup.
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// ---- Load and leak config structs for 'static lifetime ----
|
||||
let keycloak: &'static KeycloakConfig = Box::leak(Box::new(KeycloakConfig::from_env()?));
|
||||
let smtp: &'static SmtpConfig = Box::leak(Box::new(SmtpConfig::from_env()?));
|
||||
let services: &'static ServiceUrls = Box::leak(Box::new(ServiceUrls::from_env()?));
|
||||
let stripe: &'static StripeConfig = Box::leak(Box::new(StripeConfig::from_env()?));
|
||||
let llm_providers: &'static LlmProvidersConfig =
|
||||
Box::leak(Box::new(LlmProvidersConfig::from_env()?));
|
||||
|
||||
tracing::info!("Configuration loaded");
|
||||
|
||||
// ---- Connect to MongoDB ----
|
||||
let mongo_uri =
|
||||
std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".into());
|
||||
let mongo_db = std::env::var("MONGODB_DATABASE").unwrap_or_else(|_| "certifai".into());
|
||||
|
||||
let db = Database::connect(&mongo_uri, &mongo_db).await?;
|
||||
tracing::info!("Connected to MongoDB (database: {mongo_db})");
|
||||
|
||||
// ---- Build ServerState ----
|
||||
let server_state: ServerState = ServerStateInner {
|
||||
db,
|
||||
keycloak,
|
||||
smtp,
|
||||
services,
|
||||
stripe,
|
||||
llm_providers,
|
||||
}
|
||||
.into();
|
||||
|
||||
// ---- Session layer ----
|
||||
let key = Key::generate();
|
||||
let store = MemoryStore::default();
|
||||
let session = SessionManagerLayer::new(store)
|
||||
.with_secure(false)
|
||||
// Lax is required so the browser sends the session cookie
|
||||
// on the redirect back from Keycloak (cross-origin GET).
|
||||
// Strict would silently drop the cookie on that navigation.
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24)))
|
||||
.with_signed(key);
|
||||
|
||||
// ---- Build router ----
|
||||
let addr = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
// Layers are applied AFTER serve_dioxus_application so they
|
||||
// wrap both the custom Axum routes AND the Dioxus server
|
||||
// function routes (e.g. check_auth needs Session access).
|
||||
|
||||
// Layers wrap in reverse order: session (outermost) -> auth
|
||||
// middleware -> extensions -> route handlers. The session layer
|
||||
// must be outermost so the `Session` extractor is available to
|
||||
// the auth middleware, which gates all `/api/` server function
|
||||
// routes (except `check-auth`).
|
||||
let router = axum::Router::new()
|
||||
.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(state))
|
||||
.layer(Extension(server_state))
|
||||
.layer(middleware::from_fn(require_auth))
|
||||
.layer(session);
|
||||
|
||||
info!("Serving at {addr}");
|
||||
tracing::info!("Serving at {addr}");
|
||||
axum::serve(listener, router.into_make_service()).await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
74
src/infrastructure/server_state.rs
Normal file
74
src/infrastructure/server_state.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
//! Application-wide server state available in both Axum handlers and
|
||||
//! Dioxus server functions via `extract()`.
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! // Inside a #[server] function:
|
||||
//! let state: ServerState = extract().await?;
|
||||
//! ```
|
||||
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use super::{
|
||||
config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig},
|
||||
database::Database,
|
||||
Error,
|
||||
};
|
||||
|
||||
/// Cheap-to-clone handle to the shared server state.
|
||||
///
|
||||
/// Stored as an Axum `Extension` so it is accessible from both
|
||||
/// route handlers and Dioxus `#[server]` functions.
|
||||
#[derive(Clone)]
|
||||
pub struct ServerState(Arc<ServerStateInner>);
|
||||
|
||||
impl Deref for ServerState {
|
||||
type Target = ServerStateInner;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServerStateInner> for ServerState {
|
||||
fn from(value: ServerStateInner) -> Self {
|
||||
Self(Arc::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner struct holding all long-lived application resources.
|
||||
///
|
||||
/// Config references are `&'static` because they are `Box::leak`ed
|
||||
/// at startup -- they never change at runtime.
|
||||
pub struct ServerStateInner {
|
||||
/// MongoDB connection pool.
|
||||
pub db: Database,
|
||||
/// Keycloak / OAuth2 settings.
|
||||
pub keycloak: &'static KeycloakConfig,
|
||||
/// Outbound email settings.
|
||||
pub smtp: &'static SmtpConfig,
|
||||
/// URLs for Ollama, SearXNG, LangChain, S3, etc.
|
||||
pub services: &'static ServiceUrls,
|
||||
/// Stripe billing keys.
|
||||
pub stripe: &'static StripeConfig,
|
||||
/// Enabled LLM provider list.
|
||||
pub llm_providers: &'static LlmProvidersConfig,
|
||||
}
|
||||
|
||||
// `FromRequestParts` lets us `extract::<ServerState>()` inside
|
||||
// Dioxus server functions and regular Axum handlers alike.
|
||||
impl<S> axum::extract::FromRequestParts<S> for ServerState
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<ServerState>()
|
||||
.cloned()
|
||||
.ok_or(Error::StateError("ServerState extension not found".into()))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cheap-to-clone handle to per-session user data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserState(Arc<UserStateInner>);
|
||||
|
||||
@@ -19,39 +19,28 @@ impl From<UserStateInner> for UserState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-session user data stored in the tower-sessions session store.
|
||||
///
|
||||
/// Persisted across requests for the lifetime of the session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UserStateInner {
|
||||
/// Subject in Oauth
|
||||
/// Subject identifier from Keycloak (unique user ID).
|
||||
pub sub: String,
|
||||
/// Access Token
|
||||
/// OAuth2 access token.
|
||||
pub access_token: String,
|
||||
/// Refresh Token
|
||||
/// OAuth2 refresh token.
|
||||
pub refresh_token: String,
|
||||
/// User
|
||||
/// Basic user profile.
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
/// Basic user profile stored alongside the session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct User {
|
||||
/// Email
|
||||
/// Email address.
|
||||
pub email: String,
|
||||
/// Avatar Url
|
||||
/// Display name (preferred_username or full name from Keycloak).
|
||||
pub name: String,
|
||||
/// Avatar / profile picture URL.
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for UserState
|
||||
where
|
||||
S: std::marker::Sync + std::marker::Send,
|
||||
{
|
||||
type Rejection = super::Error;
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_: &S,
|
||||
) -> Result<Self, super::Error> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<UserState>()
|
||||
.cloned()
|
||||
.ok_or(super::Error::StateError("Unable to get extension".into()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,19 @@ pub enum ChatRole {
|
||||
System,
|
||||
}
|
||||
|
||||
/// Namespace for grouping chat sessions in the sidebar.
|
||||
///
|
||||
/// Sessions are visually separated in the chat sidebar by namespace,
|
||||
/// with `News` sessions appearing under a dedicated "News Chats" header.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub enum ChatNamespace {
|
||||
/// General user-initiated chat conversations.
|
||||
#[default]
|
||||
General,
|
||||
/// Chats originating from news article follow-ups on the dashboard.
|
||||
News,
|
||||
}
|
||||
|
||||
/// The type of file attached to a chat message.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AttachmentKind {
|
||||
@@ -36,36 +49,59 @@ pub struct Attachment {
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
/// A single message in a chat conversation.
|
||||
/// A persisted chat session stored in MongoDB.
|
||||
///
|
||||
/// Messages are stored separately in the `chat_messages` collection
|
||||
/// and loaded on demand when the user opens a session.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique message identifier
|
||||
/// * `id` - MongoDB document ID (hex string)
|
||||
/// * `user_sub` - Keycloak subject ID (session owner)
|
||||
/// * `title` - Display title (auto-generated or user-renamed)
|
||||
/// * `namespace` - Grouping for sidebar sections
|
||||
/// * `provider` - LLM provider used (e.g. "ollama", "openai")
|
||||
/// * `model` - Model ID used (e.g. "llama3.1:8b")
|
||||
/// * `created_at` - ISO 8601 creation timestamp
|
||||
/// * `updated_at` - ISO 8601 last-activity timestamp
|
||||
/// * `article_url` - Source article URL (for News namespace sessions)
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatSession {
|
||||
#[serde(default, alias = "_id", skip_serializing_if = "String::is_empty")]
|
||||
pub id: String,
|
||||
pub user_sub: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub namespace: ChatNamespace,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub article_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A single persisted message within a chat session.
|
||||
///
|
||||
/// Stored in the `chat_messages` MongoDB collection, linked to a
|
||||
/// `ChatSession` via `session_id`.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - MongoDB document ID (hex string)
|
||||
/// * `session_id` - Foreign key to `ChatSession.id`
|
||||
/// * `role` - Who sent this message
|
||||
/// * `content` - The message text content
|
||||
/// * `attachments` - Optional file attachments
|
||||
/// * `timestamp` - ISO 8601 timestamp string
|
||||
/// * `content` - Message text content (may contain markdown)
|
||||
/// * `attachments` - File attachments (Phase 2, currently empty)
|
||||
/// * `timestamp` - ISO 8601 timestamp
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
#[serde(default, alias = "_id", skip_serializing_if = "String::is_empty")]
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub role: ChatRole,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// A chat session containing a conversation history.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique session identifier
|
||||
/// * `title` - Display title (usually derived from first message)
|
||||
/// * `messages` - Ordered list of messages in the session
|
||||
/// * `created_at` - ISO 8601 creation timestamp
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatSession {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -82,3 +82,37 @@ pub struct BillingUsage {
|
||||
pub tokens_limit: u64,
|
||||
pub billing_cycle_end: String,
|
||||
}
|
||||
|
||||
/// Organisation-level settings stored in MongoDB.
|
||||
///
|
||||
/// These complement Keycloak's Organizations feature with
|
||||
/// business-specific data (billing, feature flags).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OrgSettings {
|
||||
/// Keycloak organisation identifier.
|
||||
pub org_id: String,
|
||||
/// Active pricing plan identifier.
|
||||
pub plan_id: String,
|
||||
/// Feature flags toggled on for this organisation.
|
||||
pub enabled_features: Vec<String>,
|
||||
/// Stripe customer ID linked to this organisation.
|
||||
pub stripe_customer_id: String,
|
||||
}
|
||||
|
||||
/// A single billing cycle record stored in MongoDB.
|
||||
///
|
||||
/// Captures seat and token usage between two dates for
|
||||
/// invoicing and usage dashboards.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OrgBillingRecord {
|
||||
/// Keycloak organisation identifier.
|
||||
pub org_id: String,
|
||||
/// ISO 8601 start of the billing cycle.
|
||||
pub cycle_start: String,
|
||||
/// ISO 8601 end of the billing cycle.
|
||||
pub cycle_end: String,
|
||||
/// Number of seats consumed during this cycle.
|
||||
pub seats_used: u32,
|
||||
/// Number of tokens consumed during this cycle.
|
||||
pub tokens_used: u64,
|
||||
}
|
||||
|
||||
@@ -1,21 +1,70 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Basic user display data used by frontend components.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct UserData {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoggedInState {
|
||||
pub access_token: String,
|
||||
/// Authentication information returned by the `check_auth` server function.
|
||||
///
|
||||
/// The frontend uses this to determine whether the user is logged in
|
||||
/// and to display their profile (name, email, avatar).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuthInfo {
|
||||
/// Whether the user has a valid session
|
||||
pub authenticated: bool,
|
||||
/// Keycloak subject identifier (unique user ID)
|
||||
pub sub: String,
|
||||
/// User email address
|
||||
pub email: String,
|
||||
/// User display name
|
||||
pub name: String,
|
||||
/// Avatar URL (from Keycloak picture claim)
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
impl LoggedInState {
|
||||
pub fn new(access_token: String, email: String) -> Self {
|
||||
Self {
|
||||
access_token,
|
||||
email,
|
||||
}
|
||||
}
|
||||
/// Per-user LLM provider configuration stored in MongoDB.
|
||||
///
|
||||
/// Controls which provider and model the user's chat sessions default
|
||||
/// to, and stores API keys for non-Ollama providers.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserProviderConfig {
|
||||
/// Default provider name (e.g. "ollama", "openai")
|
||||
pub default_provider: String,
|
||||
/// Default model ID (e.g. "llama3.1:8b", "gpt-4o")
|
||||
pub default_model: String,
|
||||
/// OpenAI API key (empty if not configured)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openai_api_key: Option<String>,
|
||||
/// Anthropic API key
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub anthropic_api_key: Option<String>,
|
||||
/// HuggingFace API key
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub huggingface_api_key: Option<String>,
|
||||
/// Custom Ollama URL override (empty = use server default)
|
||||
pub ollama_url_override: String,
|
||||
}
|
||||
|
||||
/// Per-user preferences stored in MongoDB.
|
||||
///
|
||||
/// Keyed by `sub` (Keycloak subject) and optionally scoped to an org.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserPreferences {
|
||||
/// Keycloak subject identifier
|
||||
pub sub: String,
|
||||
/// Organization ID (from Keycloak Organizations)
|
||||
pub org_id: String,
|
||||
/// User-selected news/search topics
|
||||
pub custom_topics: Vec<String>,
|
||||
/// Per-user Ollama URL override (empty = use server default)
|
||||
pub ollama_url_override: String,
|
||||
/// Per-user Ollama model override (empty = use server default)
|
||||
pub ollama_model_override: String,
|
||||
/// Recently searched queries for quick access
|
||||
pub recent_searches: Vec<String>,
|
||||
/// LLM provider configuration
|
||||
#[serde(default)]
|
||||
pub provider_config: UserProviderConfig,
|
||||
}
|
||||
|
||||
@@ -1,145 +1,336 @@
|
||||
use crate::components::{
|
||||
ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar,
|
||||
};
|
||||
use crate::infrastructure::chat::{
|
||||
chat_complete, create_chat_session, delete_chat_session, list_chat_messages,
|
||||
list_chat_sessions, rename_chat_session, save_chat_message,
|
||||
};
|
||||
use crate::infrastructure::ollama::get_ollama_status;
|
||||
use crate::models::{ChatMessage, ChatRole};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::ChatBubble;
|
||||
use crate::models::{ChatMessage, ChatRole, ChatSession};
|
||||
|
||||
/// ChatGPT-style chat interface with session list and message area.
|
||||
/// LibreChat-inspired chat interface with MongoDB persistence and SSE streaming.
|
||||
///
|
||||
/// Full-height layout: left panel shows session history,
|
||||
/// right panel shows messages and input bar.
|
||||
/// Layout: sidebar (session list) | main panel (model selector, messages, input).
|
||||
/// Messages stream via `EventSource` connected to `/api/chat/stream`.
|
||||
#[component]
|
||||
pub fn ChatPage() -> Element {
|
||||
let sessions = use_signal(mock_sessions);
|
||||
let mut active_session_id = use_signal(|| "session-1".to_string());
|
||||
let mut input_text = use_signal(String::new);
|
||||
// ---- Signals ----
|
||||
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut messages: Signal<Vec<ChatMessage>> = use_signal(Vec::new);
|
||||
let mut input_text: Signal<String> = use_signal(String::new);
|
||||
let mut is_streaming: Signal<bool> = use_signal(|| false);
|
||||
let mut streaming_content: Signal<String> = use_signal(String::new);
|
||||
let mut selected_model: Signal<String> = use_signal(String::new);
|
||||
|
||||
// Clone data out of signals before entering the rsx! block to avoid
|
||||
// holding a `Signal::read()` borrow across potential await points.
|
||||
let sessions_list = sessions.read().clone();
|
||||
let current_id = active_session_id.read().clone();
|
||||
let active_session = sessions_list.iter().find(|s| s.id == current_id).cloned();
|
||||
// ---- Resources ----
|
||||
// Load sessions list (re-fetches when dependency changes)
|
||||
let mut sessions_resource =
|
||||
use_resource(move || async move { list_chat_sessions().await.unwrap_or_default() });
|
||||
|
||||
rsx! {
|
||||
section { class: "chat-page",
|
||||
div { class: "chat-sidebar-panel",
|
||||
div { class: "chat-sidebar-header",
|
||||
h3 { "Conversations" }
|
||||
button { class: "btn-icon", "+" }
|
||||
// Load available Ollama models
|
||||
let models_resource = use_resource(move || async move {
|
||||
get_ollama_status(String::new())
|
||||
.await
|
||||
.map(|s| s.models)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let sessions = sessions_resource.read().clone().unwrap_or_default();
|
||||
|
||||
let available_models = models_resource.read().clone().unwrap_or_default();
|
||||
|
||||
// Set default model if not yet chosen
|
||||
if selected_model.read().is_empty() {
|
||||
if let Some(first) = available_models.first() {
|
||||
selected_model.set(first.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Load messages when active session changes.
|
||||
// The signal read MUST happen inside the closure so use_resource
|
||||
// tracks it as a dependency and re-fetches on change.
|
||||
let _messages_loader = use_resource(move || {
|
||||
let session_id = active_session_id.read().clone();
|
||||
async move {
|
||||
if let Some(id) = session_id {
|
||||
match list_chat_messages(id).await {
|
||||
Ok(msgs) => messages.set(msgs),
|
||||
Err(e) => tracing::error!("failed to load messages: {e}"),
|
||||
}
|
||||
div { class: "chat-session-list",
|
||||
for session in &sessions_list {
|
||||
{
|
||||
let is_active = session.id == current_id;
|
||||
let class = if is_active {
|
||||
"chat-session-item chat-session-item--active"
|
||||
} else {
|
||||
"chat-session-item"
|
||||
};
|
||||
let id = session.id.clone();
|
||||
rsx! {
|
||||
button { class: "{class}", onclick: move |_| active_session_id.set(id.clone()),
|
||||
div { class: "chat-session-title", "{session.title}" }
|
||||
div { class: "chat-session-date", "{session.created_at}" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages.set(Vec::new());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Callbacks ----
|
||||
// Create new session
|
||||
let on_new = move |_: ()| {
|
||||
let model = selected_model.read().clone();
|
||||
spawn(async move {
|
||||
match create_chat_session(
|
||||
"New Chat".to_string(),
|
||||
"General".to_string(),
|
||||
"ollama".to_string(),
|
||||
model,
|
||||
String::new(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
active_session_id.set(Some(session.id));
|
||||
messages.set(Vec::new());
|
||||
sessions_resource.restart();
|
||||
}
|
||||
Err(e) => tracing::error!("failed to create session: {e}"),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Select session
|
||||
let on_select = move |id: String| {
|
||||
active_session_id.set(Some(id));
|
||||
};
|
||||
|
||||
// Rename session
|
||||
let on_rename = move |(id, new_title): (String, String)| {
|
||||
spawn(async move {
|
||||
if let Err(e) = rename_chat_session(id, new_title).await {
|
||||
tracing::error!("failed to rename: {e}");
|
||||
}
|
||||
sessions_resource.restart();
|
||||
});
|
||||
};
|
||||
|
||||
// Delete session
|
||||
let on_delete = move |id: String| {
|
||||
let is_active = active_session_id.read().as_deref() == Some(&id);
|
||||
spawn(async move {
|
||||
if let Err(e) = delete_chat_session(id).await {
|
||||
tracing::error!("failed to delete: {e}");
|
||||
}
|
||||
if is_active {
|
||||
active_session_id.set(None);
|
||||
messages.set(Vec::new());
|
||||
}
|
||||
sessions_resource.restart();
|
||||
});
|
||||
};
|
||||
|
||||
// Model change
|
||||
let on_model_change = move |model: String| {
|
||||
selected_model.set(model);
|
||||
};
|
||||
|
||||
// Send message
|
||||
let on_send = move |text: String| {
|
||||
let session_id = active_session_id.read().clone();
|
||||
let model = selected_model.read().clone();
|
||||
|
||||
spawn(async move {
|
||||
// If no active session, create one first
|
||||
let sid = if let Some(id) = session_id {
|
||||
id
|
||||
} else {
|
||||
match create_chat_session(
|
||||
// Use first ~50 chars of message as title
|
||||
text.chars().take(50).collect::<String>(),
|
||||
"General".to_string(),
|
||||
"ollama".to_string(),
|
||||
model,
|
||||
String::new(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
let id = session.id.clone();
|
||||
active_session_id.set(Some(id.clone()));
|
||||
sessions_resource.restart();
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to create session: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save user message
|
||||
match save_chat_message(sid.clone(), "user".to_string(), text).await {
|
||||
Ok(msg) => {
|
||||
messages.write().push(msg);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to save message: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show thinking indicator
|
||||
is_streaming.set(true);
|
||||
streaming_content.set(String::new());
|
||||
|
||||
// Build message history as JSON for the server
|
||||
let history: Vec<serde_json::Value> = messages
|
||||
.read()
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let role = match m.role {
|
||||
ChatRole::User => "user",
|
||||
ChatRole::Assistant => "assistant",
|
||||
ChatRole::System => "system",
|
||||
};
|
||||
serde_json::json!({"role": role, "content": m.content})
|
||||
})
|
||||
.collect();
|
||||
let messages_json = serde_json::to_string(&history).unwrap_or_default();
|
||||
|
||||
// Non-streaming completion
|
||||
match chat_complete(sid.clone(), messages_json).await {
|
||||
Ok(response) => {
|
||||
// Save assistant message
|
||||
match save_chat_message(sid, "assistant".to_string(), response).await {
|
||||
Ok(msg) => {
|
||||
messages.write().push(msg);
|
||||
}
|
||||
Err(e) => tracing::error!("failed to save assistant msg: {e}"),
|
||||
}
|
||||
sessions_resource.restart();
|
||||
}
|
||||
Err(e) => tracing::error!("chat completion failed: {e}"),
|
||||
}
|
||||
is_streaming.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Action bar state ----
|
||||
let has_messages = !messages.read().is_empty();
|
||||
let has_assistant_message = messages
|
||||
.read()
|
||||
.iter()
|
||||
.any(|m| m.role == ChatRole::Assistant);
|
||||
let has_user_message = messages.read().iter().any(|m| m.role == ChatRole::User);
|
||||
|
||||
// Copy last assistant response to clipboard
|
||||
let on_copy = move |_: ()| {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let last_assistant = messages
|
||||
.read()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == ChatRole::Assistant)
|
||||
.map(|m| m.content.clone());
|
||||
if let Some(text) = last_assistant {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let clipboard = window.navigator().clipboard();
|
||||
let _ = clipboard.write_text(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Copy full conversation as text to clipboard
|
||||
let on_share = move |_: ()| {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let text: String = messages
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|m| m.role != ChatRole::System)
|
||||
.map(|m| {
|
||||
let label = match m.role {
|
||||
ChatRole::User => "You",
|
||||
ChatRole::Assistant => "Assistant",
|
||||
ChatRole::System => "System",
|
||||
};
|
||||
format!("{label}:\n{}\n", m.content)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if let Some(window) = web_sys::window() {
|
||||
let clipboard = window.navigator().clipboard();
|
||||
let _ = clipboard.write_text(&text);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Edit last user message: remove it and place text back in input
|
||||
let on_edit = move |_: ()| {
|
||||
let last_user = messages
|
||||
.read()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == ChatRole::User)
|
||||
.map(|m| m.content.clone());
|
||||
if let Some(text) = last_user {
|
||||
// Remove the last user message (and any assistant reply after it)
|
||||
let mut msgs = messages.read().clone();
|
||||
if let Some(pos) = msgs.iter().rposition(|m| m.role == ChatRole::User) {
|
||||
msgs.truncate(pos);
|
||||
messages.set(msgs);
|
||||
}
|
||||
input_text.set(text);
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll to bottom when messages or streaming content changes
|
||||
let msg_count = messages.read().len();
|
||||
let stream_len = streaming_content.read().len();
|
||||
use_effect(move || {
|
||||
// Track dependencies
|
||||
let _ = msg_count;
|
||||
let _ = stream_len;
|
||||
// Scroll the message list to bottom
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(doc) = window.document() {
|
||||
if let Some(el) = doc.get_element_by_id("chat-message-list") {
|
||||
let height = el.scroll_height();
|
||||
el.set_scroll_top(height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
section { class: "chat-page",
|
||||
ChatSidebar {
|
||||
sessions: sessions,
|
||||
active_session_id: active_session_id.read().clone(),
|
||||
on_select: on_select,
|
||||
on_new: on_new,
|
||||
on_rename: on_rename,
|
||||
on_delete: on_delete,
|
||||
}
|
||||
div { class: "chat-main-panel",
|
||||
if let Some(session) = &active_session {
|
||||
div { class: "chat-messages",
|
||||
for msg in &session.messages {
|
||||
ChatBubble { key: "{msg.id}", message: msg.clone() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
div { class: "chat-empty",
|
||||
p { "Select a conversation or start a new one." }
|
||||
}
|
||||
ChatModelSelector {
|
||||
selected_model: selected_model.read().clone(),
|
||||
available_models: available_models,
|
||||
on_change: on_model_change,
|
||||
}
|
||||
div { class: "chat-input-bar",
|
||||
button { class: "btn-icon chat-attach-btn", "+" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "text",
|
||||
placeholder: "Type a message...",
|
||||
value: "{input_text}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
input_text.set(evt.value());
|
||||
},
|
||||
}
|
||||
button { class: "btn-primary chat-send-btn", "Send" }
|
||||
ChatMessageList {
|
||||
messages: messages.read().clone(),
|
||||
streaming_content: streaming_content.read().clone(),
|
||||
is_streaming: *is_streaming.read(),
|
||||
}
|
||||
ChatActionBar {
|
||||
on_copy: on_copy,
|
||||
on_share: on_share,
|
||||
on_edit: on_edit,
|
||||
has_messages: has_messages,
|
||||
has_assistant_message: has_assistant_message,
|
||||
has_user_message: has_user_message,
|
||||
}
|
||||
ChatInputBar {
|
||||
input_text: input_text,
|
||||
on_send: on_send,
|
||||
is_streaming: *is_streaming.read(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock chat sessions with sample messages.
|
||||
fn mock_sessions() -> Vec<ChatSession> {
|
||||
vec![
|
||||
ChatSession {
|
||||
id: "session-1".into(),
|
||||
title: "RAG Pipeline Setup".into(),
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
id: "msg-1".into(),
|
||||
role: ChatRole::User,
|
||||
content: "How do I set up a RAG pipeline with Ollama?".into(),
|
||||
attachments: vec![],
|
||||
timestamp: "10:30".into(),
|
||||
},
|
||||
ChatMessage {
|
||||
id: "msg-2".into(),
|
||||
role: ChatRole::Assistant,
|
||||
content: "To set up a RAG pipeline with Ollama, you'll need to: \
|
||||
1) Install Ollama and pull your preferred model, \
|
||||
2) Set up a vector database (e.g. ChromaDB), \
|
||||
3) Create an embedding pipeline for your documents, \
|
||||
4) Wire the retrieval step into your prompt chain."
|
||||
.into(),
|
||||
attachments: vec![],
|
||||
timestamp: "10:31".into(),
|
||||
},
|
||||
],
|
||||
created_at: "2026-02-18".into(),
|
||||
},
|
||||
ChatSession {
|
||||
id: "session-2".into(),
|
||||
title: "GDPR Compliance Check".into(),
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
id: "msg-3".into(),
|
||||
role: ChatRole::User,
|
||||
content: "What data does CERTifAI store about users?".into(),
|
||||
attachments: vec![],
|
||||
timestamp: "09:15".into(),
|
||||
},
|
||||
ChatMessage {
|
||||
id: "msg-4".into(),
|
||||
role: ChatRole::Assistant,
|
||||
content: "CERTifAI stores only the minimum data required: \
|
||||
email address, session tokens, and usage metrics. \
|
||||
All data stays on your infrastructure."
|
||||
.into(),
|
||||
attachments: vec![],
|
||||
timestamp: "09:16".into(),
|
||||
},
|
||||
],
|
||||
created_at: "2026-02-17".into(),
|
||||
},
|
||||
ChatSession {
|
||||
id: "session-3".into(),
|
||||
title: "MCP Server Configuration".into(),
|
||||
messages: vec![ChatMessage {
|
||||
id: "msg-5".into(),
|
||||
role: ChatRole::User,
|
||||
content: "How do I add a new MCP server?".into(),
|
||||
attachments: vec![],
|
||||
timestamp: "14:00".into(),
|
||||
}],
|
||||
created_at: "2026-02-16".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use dioxus::prelude::*;
|
||||
use dioxus_sdk::storage::use_persistent;
|
||||
|
||||
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
|
||||
use crate::infrastructure::chat::{create_chat_session, save_chat_message};
|
||||
use crate::infrastructure::llm::FollowUpMessage;
|
||||
use crate::models::NewsCard;
|
||||
|
||||
@@ -50,6 +51,8 @@ pub fn DashboardPage() -> Element {
|
||||
let mut is_chatting = use_signal(|| false);
|
||||
// Stores the article text context for the chat system message
|
||||
let mut article_context = use_signal(String::new);
|
||||
// MongoDB session ID for persisting News chat (created on first follow-up)
|
||||
let mut news_session_id: Signal<Option<String>> = use_signal(|| None);
|
||||
|
||||
// Recent search history, persisted in localStorage (capped at MAX_RECENT_SEARCHES)
|
||||
let mut recent_searches =
|
||||
@@ -310,6 +313,7 @@ pub fn DashboardPage() -> Element {
|
||||
summary.set(None);
|
||||
chat_messages.set(Vec::new());
|
||||
article_context.set(String::new());
|
||||
news_session_id.set(None);
|
||||
|
||||
|
||||
let oll_url = ollama_url.read().clone();
|
||||
@@ -329,7 +333,7 @@ pub fn DashboardPage() -> Element {
|
||||
.set(
|
||||
format!(
|
||||
"Article content:\n{snippet}\n\n\
|
||||
AI Summary:\n{text}",
|
||||
AI Summary:\n{text}",
|
||||
),
|
||||
);
|
||||
summary.set(Some(text));
|
||||
@@ -358,6 +362,7 @@ pub fn DashboardPage() -> Element {
|
||||
selected_card.set(None);
|
||||
summary.set(None);
|
||||
chat_messages.set(Vec::new());
|
||||
news_session_id.set(None);
|
||||
},
|
||||
summary: summary.read().clone(),
|
||||
is_summarizing: *is_summarizing.read(),
|
||||
@@ -367,52 +372,113 @@ pub fn DashboardPage() -> Element {
|
||||
let oll_url = ollama_url.read().clone();
|
||||
let mdl = ollama_model.read().clone();
|
||||
let ctx = article_context.read().clone();
|
||||
// Capture article info for News session creation
|
||||
let card_title = selected_card
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|c| c.title.clone())
|
||||
.unwrap_or_default();
|
||||
let card_url = selected_card
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|c| c.url.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Append user message to chat
|
||||
chat_messages
|
||||
// Append user message to local chat
|
||||
chat_messages.write().push(FollowUpMessage {
|
||||
role: "user".into(),
|
||||
content: question.clone(),
|
||||
});
|
||||
|
||||
// Build full message history for Ollama
|
||||
|
||||
.write()
|
||||
.push(FollowUpMessage {
|
||||
role: "user".into(),
|
||||
content: question,
|
||||
});
|
||||
// Build full message history for Ollama
|
||||
let system_msg = format!(
|
||||
"You are a helpful assistant. The user is reading \
|
||||
a news article. Use the following context to answer \
|
||||
their questions. Do NOT comment on the source, \
|
||||
dates, URLs, or formatting.\n\n{ctx}",
|
||||
);
|
||||
let msgs = {
|
||||
let history = chat_messages.read();
|
||||
let mut all = vec![
|
||||
FollowUpMessage {
|
||||
role: "system".into(),
|
||||
content: format!(
|
||||
"You are a helpful assistant. The user is reading \
|
||||
a news article. Use the following context to answer \
|
||||
their questions. Do NOT comment on the source, \
|
||||
dates, URLs, or formatting.\n\n{ctx}",
|
||||
),
|
||||
},
|
||||
];
|
||||
let mut all = vec![FollowUpMessage {
|
||||
role: "system".into(),
|
||||
content: system_msg.clone(),
|
||||
}];
|
||||
all.extend(history.iter().cloned());
|
||||
all
|
||||
};
|
||||
|
||||
spawn(async move {
|
||||
is_chatting.set(true);
|
||||
match crate::infrastructure::llm::chat_followup(msgs, oll_url, mdl).await {
|
||||
|
||||
// Create News session on first follow-up message
|
||||
let existing_sid = news_session_id.read().clone();
|
||||
let sid = if let Some(id) = existing_sid {
|
||||
id
|
||||
} else {
|
||||
match create_chat_session(
|
||||
card_title,
|
||||
"News".to_string(),
|
||||
"ollama".to_string(),
|
||||
mdl.clone(),
|
||||
card_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
let id = session.id.clone();
|
||||
news_session_id.set(Some(id.clone()));
|
||||
// Persist system context as first message
|
||||
let _ = save_chat_message(
|
||||
id.clone(),
|
||||
"system".to_string(),
|
||||
system_msg,
|
||||
)
|
||||
.await;
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create News session: {e}");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Persist user message
|
||||
if !sid.is_empty() {
|
||||
let _ = save_chat_message(
|
||||
sid.clone(),
|
||||
"user".to_string(),
|
||||
question,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
match crate::infrastructure::llm::chat_followup(
|
||||
msgs, oll_url, mdl,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => {
|
||||
chat_messages
|
||||
.write()
|
||||
.push(FollowUpMessage {
|
||||
role: "assistant".into(),
|
||||
content: reply,
|
||||
});
|
||||
// Persist assistant message
|
||||
if !sid.is_empty() {
|
||||
let _ = save_chat_message(
|
||||
sid,
|
||||
"assistant".to_string(),
|
||||
reply.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
chat_messages.write().push(FollowUpMessage {
|
||||
role: "assistant".into(),
|
||||
content: reply,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Chat failed: {e}");
|
||||
chat_messages
|
||||
.write()
|
||||
.push(FollowUpMessage {
|
||||
role: "assistant".into(),
|
||||
content: format!("Error: {e}"),
|
||||
});
|
||||
chat_messages.write().push(FollowUpMessage {
|
||||
role: "assistant".into(),
|
||||
content: format!("Error: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
is_chatting.set(false);
|
||||
|
||||
Reference in New Issue
Block a user