Compare commits
5 Commits
feat/keycl
...
feat/CAI-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37478ba8f9 | ||
|
|
f102d96c09 | ||
|
|
c44177cbc2 | ||
|
|
73ad7bd16d | ||
|
|
421f99537e |
75
.env.example
75
.env.example
@@ -1,80 +1,9 @@
|
||||
# ============================================================================
|
||||
# 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 Configuration (frontend public client)
|
||||
KEYCLOAK_URL=http://localhost:8080
|
||||
KEYCLOAK_REALM=certifai
|
||||
KEYCLOAK_CLIENT_ID=certifai-dashboard
|
||||
|
||||
# Keycloak admin / service-account client (server-to-server calls) [OPTIONAL]
|
||||
KEYCLOAK_ADMIN_CLIENT_ID=
|
||||
KEYCLOAK_ADMIN_CLIENT_SECRET=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application Configuration [REQUIRED]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application Configuration
|
||||
APP_URL=http://localhost:8000
|
||||
REDIRECT_URI=http://localhost:8000/auth/callback
|
||||
ALLOWED_ORIGINS=http://localhost:8000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 [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=
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Code quality checks (run in parallel)
|
||||
# ---------------------------------------------------------------------------
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: docker
|
||||
container:
|
||||
image: rust:1.89-bookworm
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- run: rustup component add rustfmt
|
||||
- run: cargo fmt --check
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: docker
|
||||
container:
|
||||
image: rust:1.89-bookworm
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- run: rustup component add clippy
|
||||
# Lint both feature sets independently
|
||||
- name: Clippy (server)
|
||||
run: cargo clippy --features server --no-default-features -- -D warnings
|
||||
- name: Clippy (web)
|
||||
run: cargo clippy --features web --no-default-features -- -D warnings
|
||||
|
||||
audit:
|
||||
name: Security Audit
|
||||
runs-on: docker
|
||||
if: github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: rust:1.89-bookworm
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- run: cargo install cargo-audit
|
||||
- run: cargo audit
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Tests (only after all quality checks pass)
|
||||
# ---------------------------------------------------------------------------
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: docker
|
||||
needs: [fmt, clippy, audit]
|
||||
container:
|
||||
image: rust:1.89-bookworm
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- name: Run tests (server)
|
||||
run: cargo test --features server --no-default-features
|
||||
- name: Run tests (web)
|
||||
run: cargo test --features web --no-default-features
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3: Deploy (only after tests pass, only on main)
|
||||
# ---------------------------------------------------------------------------
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: docker
|
||||
needs: [test]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: Trigger Coolify deploy
|
||||
run: |
|
||||
apk add --no-cache curl
|
||||
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -12,13 +12,5 @@
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Keycloak runtime data (but keep config and theme)
|
||||
keycloak/*
|
||||
!keycloak/realm-export.json
|
||||
!keycloak/themes/
|
||||
!keycloak/themes/**
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
searxng/
|
||||
# Keycloak data
|
||||
keycloak/
|
||||
|
||||
23
CLAUDE.md
23
CLAUDE.md
@@ -237,12 +237,23 @@ The SaaS application dashboard is the landing page for the company admin to view
|
||||
|
||||
This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
|
||||
|
||||
## 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.
|
||||
## Features management
|
||||
|
||||
All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers.
|
||||
|
||||
## Clean architecture
|
||||
For the backend development, clean architecture is preferred. SOLID principles MUST be strictly followed. Clearly defined types, traits and their implementations MUST be used when generating new code. Individual files MUST be created if a file is exceeding more than 160 lines of code excluding any tests. The folder structure for clean architecure SHOULD BE as:
|
||||
- service1/
|
||||
- Infrastructure/
|
||||
- Domain/
|
||||
- Application/
|
||||
- Presentation/
|
||||
- service2/
|
||||
- Infrastructure/
|
||||
- Domain/
|
||||
- Application/
|
||||
- Presentation/
|
||||
With each major service split in separate folders.
|
||||
|
||||
## Git Workflow
|
||||
|
||||
|
||||
317
Cargo.lock
generated
317
Cargo.lock
generated
@@ -698,29 +698,6 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -760,11 +737,8 @@ dependencies = [
|
||||
name = "dashboard"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-stripe",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dioxus",
|
||||
"dioxus-cli-config",
|
||||
@@ -776,23 +750,18 @@ dependencies = [
|
||||
"maud",
|
||||
"mongodb",
|
||||
"petname",
|
||||
"pulldown-cmark",
|
||||
"rand 0.10.0",
|
||||
"reqwest 0.13.2",
|
||||
"scraper",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
"tracing",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -848,17 +817,6 @@ dependencies = [
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -1090,7 +1048,7 @@ dependencies = [
|
||||
"const-str",
|
||||
"const_format",
|
||||
"content_disposition",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"dioxus-asset-resolver",
|
||||
"dioxus-cli-config",
|
||||
"dioxus-core",
|
||||
@@ -1132,7 +1090,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"xxhash-rust",
|
||||
]
|
||||
@@ -1536,7 +1494,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -1586,33 +1544,12 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "ego-tree"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1735,16 +1672,6 @@ version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
@@ -1848,15 +1775,6 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generational-box"
|
||||
version = "0.7.3"
|
||||
@@ -2095,18 +2013,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever",
|
||||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
@@ -2671,12 +2577,6 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.1.4"
|
||||
@@ -2776,31 +2676,6 @@ dependencies = [
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2927,7 +2802,7 @@ dependencies = [
|
||||
"bitflags",
|
||||
"bson",
|
||||
"derive-where",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
@@ -3020,12 +2895,6 @@ dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -3132,58 +3001,6 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
@@ -3240,12 +3057,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -3302,24 +3113,6 @@ 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"
|
||||
@@ -3596,7 +3389,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
@@ -3611,7 +3404,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.13",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
@@ -3634,14 +3426,12 @@ 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",
|
||||
]
|
||||
|
||||
@@ -3838,20 +3628,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sct"
|
||||
version = "0.7.1"
|
||||
@@ -3894,25 +3670,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more 0.99.20",
|
||||
"fxhash",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -4082,15 +3839,6 @@ dependencies = [
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -4138,12 +3886,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -4247,31 +3989,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -4398,17 +4115,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||
dependencies = [
|
||||
"futf",
|
||||
"mac",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -5173,19 +4879,6 @@ 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"
|
||||
|
||||
27
Cargo.toml
27
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", "stream"] }
|
||||
reqwest = { version = "0.13", optional = true, features = ["json", "form"] }
|
||||
tower-sessions = { version = "0.15", default-features = false, features = [
|
||||
"axum-core",
|
||||
"memory-store",
|
||||
@@ -61,17 +61,9 @@ 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
|
||||
@@ -81,17 +73,10 @@ dioxus-free-icons = { version = "0.10", features = [
|
||||
"bootstrap",
|
||||
"font-awesome-solid",
|
||||
] }
|
||||
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", "dep:wasm-bindgen"]
|
||||
web = ["dioxus/web", "dep:reqwest", "dep:web-sys"]
|
||||
server = [
|
||||
"dioxus/server",
|
||||
"dep:axum",
|
||||
@@ -102,14 +87,6 @@ server = [
|
||||
"dep:time",
|
||||
"dep:rand",
|
||||
"dep:url",
|
||||
"dep:sha2",
|
||||
"dep:base64",
|
||||
"dep:scraper",
|
||||
"dep:secrecy",
|
||||
"dep:petname",
|
||||
"dep:tokio-stream",
|
||||
"dep:async-stream",
|
||||
"dep:bytes",
|
||||
]
|
||||
|
||||
[[bin]]
|
||||
|
||||
57
Dockerfile
57
Dockerfile
@@ -1,57 +0,0 @@
|
||||
# Stage 1: Generate dependency recipe for caching
|
||||
FROM rust:1.89-bookworm AS chef
|
||||
RUN cargo install cargo-chef
|
||||
WORKDIR /app
|
||||
|
||||
FROM chef AS planner
|
||||
COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
# Stage 2: Build dependencies + application
|
||||
FROM chef AS builder
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev curl unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install bun (for Tailwind CSS build step)
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:$PATH"
|
||||
|
||||
# Install dx CLI from source (binstall binaries require GLIBC >= 2.38)
|
||||
RUN cargo install dioxus-cli@0.7.3 --locked
|
||||
|
||||
# Cook dependencies from recipe (cached layer)
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
|
||||
# Install frontend dependencies (DaisyUI, Tailwind) for the build.rs CSS step
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Bundle the fullstack application
|
||||
RUN dx bundle --release --fullstack
|
||||
|
||||
# Stage 3: Minimal runtime image
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash app
|
||||
USER app
|
||||
WORKDIR /home/app
|
||||
|
||||
# Copy the bundled output from builder
|
||||
COPY --from=builder --chown=app:app /app/target/dx/dashboard/release/web/ ./
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENV IP=0.0.0.0
|
||||
ENV PORT=8000
|
||||
|
||||
ENTRYPOINT ["./dashboard"]
|
||||
151
README.md
151
README.md
@@ -1,132 +1,47 @@
|
||||
<p align="center">
|
||||
<img src="assets/favicon.svg" width="96" height="96" alt="CERTifAI Logo" />
|
||||
</p>
|
||||
# CERTifAI
|
||||
|
||||
<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.
|
||||
|
||||
---
|
||||
## Development environment
|
||||
|
||||
## About
|
||||
This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
|
||||
|
||||
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.
|
||||
## Features management
|
||||
|
||||
> **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**:
|
||||
|
||||
- **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
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| 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) |
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- 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
|
||||
```
|
||||
All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers.
|
||||
|
||||
## Clean architecture
|
||||
For the backend development, clean architecture is preferred. SOLID principles MUST be strictly followed. Clearly defined types, traits and their implementations MUST be used when generating new code. Individual files MUST be created if a file is exceeding more than 160 lines of code excluding any tests. The folder structure for clean architecure SHOULD BE as:
|
||||
- service1/
|
||||
- Infrastructure/
|
||||
- Domain/
|
||||
- Application/
|
||||
- Presentation/
|
||||
- service2/
|
||||
- Infrastructure/
|
||||
- Domain/
|
||||
- Application/
|
||||
- Presentation/
|
||||
With each major service split in separate folders.
|
||||
|
||||
## Git Workflow
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
## CI
|
||||
|
||||
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>
|
||||
The CI is run on gitea actions with runner tags `docker`.
|
||||
|
||||
BIN
assets/favicon.ico
Normal file
BIN
assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
3004
assets/main.css
3004
assets/main.css
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "CERTifAI Dashboard",
|
||||
"short_name": "CERTifAI",
|
||||
"description": "Self-hosted GenAI infrastructure management dashboard",
|
||||
"start_url": "/dashboard",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f1117",
|
||||
"theme_color": "#4B3FE0",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/logo.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
67
assets/sw.js
67
assets/sw.js
@@ -1,67 +0,0 @@
|
||||
// CERTifAI Service Worker — network-first with offline fallback cache.
|
||||
// Static assets (CSS, JS, WASM, fonts) use cache-first for speed.
|
||||
// API and navigation requests always try the network first.
|
||||
|
||||
const CACHE_NAME = "certifai-v1";
|
||||
|
||||
// Pre-cache the app shell on install
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) =>
|
||||
cache.addAll([
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/assets/logo.svg",
|
||||
"/assets/favicon.svg",
|
||||
])
|
||||
)
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Clean up old caches on activate
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip non-GET and API requests (let them go straight to network)
|
||||
if (event.request.method !== "GET" || url.pathname.startsWith("/api/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets (hashed filenames make this safe)
|
||||
const isStatic = /\.(js|wasm|css|ico|svg|png|jpg|woff2?)(\?|$)/.test(url.pathname);
|
||||
if (isStatic) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) =>
|
||||
cached || fetch(event.request).then((resp) => {
|
||||
const clone = resp.clone();
|
||||
caches.open(CACHE_NAME).then((c) => c.put(event.request, clone));
|
||||
return resp;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for navigation / HTML
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((resp) => {
|
||||
const clone = resp.clone();
|
||||
caches.open(CACHE_NAME).then((c) => c.put(event.request, clone));
|
||||
return resp;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
2114
assets/tailwind.css
2114
assets/tailwind.css
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,9 @@ fn main() {
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
dashboard::infrastructure::server_start(dashboard::App)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Unable to start server: {e}");
|
||||
})
|
||||
.expect("Server start failed")
|
||||
tracing::info!("Starting server...");
|
||||
dashboard::infrastructure::server::server_start(dashboard::App)
|
||||
.map_err(|e| tracing::error! {"Failed to start server: {:?}", e})
|
||||
.expect("Failed to start server");
|
||||
}
|
||||
}
|
||||
|
||||
15
build.rs
15
build.rs
@@ -1,9 +1,8 @@
|
||||
#[allow(clippy::expect_used)]
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::process::Command;
|
||||
println!("cargo:rerun-if-changed=./styles/input.css");
|
||||
|
||||
// Tailwind build is optional - skip gracefully in CI or environments without bun
|
||||
match Command::new("bunx")
|
||||
Command::new("bunx")
|
||||
.args([
|
||||
"@tailwindcss/cli",
|
||||
"-i",
|
||||
@@ -12,15 +11,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"./assets/tailwind.css",
|
||||
])
|
||||
.status()
|
||||
{
|
||||
Ok(status) if !status.success() => {
|
||||
println!("cargo:warning=tailwind build exited with {status}, skipping CSS generation");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("cargo:warning=bunx not found ({e}), skipping tailwind CSS generation");
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
.expect("could not run tailwind");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
33
bun.lock
33
bun.lock
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "certifai",
|
||||
"dependencies": {
|
||||
"daisyui": "^5.5.18",
|
||||
"tailwindcss": "^4.1.18",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
52
cliff.toml
52
cliff.toml
@@ -1,52 +0,0 @@
|
||||
[changelog]
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
"""
|
||||
body = """
|
||||
{%- macro remote_url() -%}
|
||||
https://gitea.meghsakha.com/{{ remote.github.owner }}/{{ remote.github.repo }}
|
||||
{%- endmacro -%}
|
||||
|
||||
{% if version -%}
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else -%}
|
||||
## [Unreleased]
|
||||
{% endif -%}
|
||||
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }}\
|
||||
{% if commit.breaking %} [**breaking**]{% endif %}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
footer = """
|
||||
---
|
||||
*Generated by [git-cliff](https://git-cliff.org)*
|
||||
"""
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
split_commits = false
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^doc", group = "Documentation" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^refactor", group = "Refactoring" },
|
||||
{ message = "^style", group = "Styling" },
|
||||
{ message = "^test", group = "Testing" },
|
||||
{ message = "^ci", group = "CI/CD" },
|
||||
{ message = "^chore", group = "Miscellaneous" },
|
||||
{ message = "^build", group = "Build" },
|
||||
]
|
||||
protect_breaking_commits = false
|
||||
filter_commits = false
|
||||
tag_pattern = "v[0-9].*"
|
||||
sort_commits = "oldest"
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -29,15 +28,4 @@ services:
|
||||
- 27017:27017
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: example
|
||||
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: certifai-searxng
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:8080"
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8888
|
||||
volumes:
|
||||
- ./searxng:/etc/searxng:rw
|
||||
MONGO_INITDB_ROOT_PASSWORD: example
|
||||
@@ -1,247 +0,0 @@
|
||||
{
|
||||
"id": "certifai",
|
||||
"realm": "certifai",
|
||||
"displayName": "CERTifAI",
|
||||
"enabled": true,
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": true,
|
||||
"registrationEmailAsUsername": true,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"loginTheme": "certifai",
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": true,
|
||||
"permanentLockout": false,
|
||||
"maxFailureWaitSeconds": 900,
|
||||
"minimumQuickLoginWaitSeconds": 60,
|
||||
"waitIncrementSeconds": 60,
|
||||
"quickLoginCheckMilliSeconds": 1000,
|
||||
"maxDeltaTimeSeconds": 43200,
|
||||
"failureFactor": 5,
|
||||
"defaultSignatureAlgorithm": "RS256",
|
||||
"accessTokenLifespan": 300,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"offlineSessionIdleTimeout": 2592000,
|
||||
"accessCodeLifespan": 60,
|
||||
"accessCodeLifespanUserAction": 300,
|
||||
"accessCodeLifespanLogin": 1800,
|
||||
"roles": {
|
||||
"realm": [
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "CERTifAI administrator with full access",
|
||||
"composite": false,
|
||||
"clientRole": false
|
||||
},
|
||||
{
|
||||
"name": "user",
|
||||
"description": "Standard CERTifAI user",
|
||||
"composite": false,
|
||||
"clientRole": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"defaultRoles": [
|
||||
"user"
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "certifai-dashboard",
|
||||
"name": "CERTifAI Dashboard",
|
||||
"description": "CERTifAI administration dashboard",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"protocol": "openid-connect",
|
||||
"rootUrl": "http://localhost:8000",
|
||||
"baseUrl": "http://localhost:8000",
|
||||
"redirectUris": [
|
||||
"http://localhost:8000/auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:8000"
|
||||
],
|
||||
"attributes": {
|
||||
"post.logout.redirect.uris": "http://localhost:8000",
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"defaultClientScopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"offline_access"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
{
|
||||
"name": "openid",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "sub",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-sub-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "profile",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "User profile information"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "full name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-full-name-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "given name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "firstName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true",
|
||||
"claim.name": "given_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "family name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "lastName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true",
|
||||
"claim.name": "family_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "picture",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "picture",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true",
|
||||
"claim.name": "picture",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Email address"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "email",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "email",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true",
|
||||
"claim.name": "email",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email verified",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "emailVerified",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true",
|
||||
"claim.name": "email_verified",
|
||||
"jsonType.label": "boolean"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "admin@certifai.local",
|
||||
"email": "admin@certifai.local",
|
||||
"firstName": "Admin",
|
||||
"lastName": "User",
|
||||
"enabled": true,
|
||||
"emailVerified": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "admin",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": [
|
||||
"admin",
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "user@certifai.local",
|
||||
"email": "user@certifai.local",
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"enabled": true,
|
||||
"emailVerified": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "user",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": [
|
||||
"user"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,933 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,4 +0,0 @@
|
||||
parent=keycloak
|
||||
import=common/keycloak
|
||||
styles=css/login.css
|
||||
scripts=js/footer.js
|
||||
16
package.json
16
package.json
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "certifai",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"daisyui": "^5.5.18",
|
||||
"tailwindcss": "^4.1.18"
|
||||
}
|
||||
}
|
||||
197
src/app.rs
197
src/app.rs
@@ -1,119 +1,122 @@
|
||||
use crate::{components::*, pages::*};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Application routes.
|
||||
///
|
||||
/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live
|
||||
/// outside the `AppShell` layout. Authenticated pages are wrapped in
|
||||
/// `AppShell` which renders the sidebar. `DeveloperShell` and `OrgShell`
|
||||
/// provide nested tab navigation within the app shell.
|
||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[layout(Navbar)]
|
||||
#[route("/")]
|
||||
LandingPage {},
|
||||
#[route("/impressum")]
|
||||
ImpressumPage {},
|
||||
#[route("/privacy")]
|
||||
PrivacyPage {},
|
||||
#[layout(AppShell)]
|
||||
#[route("/dashboard")]
|
||||
DashboardPage {},
|
||||
#[route("/providers")]
|
||||
ProvidersPage {},
|
||||
#[route("/chat")]
|
||||
ChatPage {},
|
||||
#[route("/tools")]
|
||||
ToolsPage {},
|
||||
#[route("/knowledge")]
|
||||
KnowledgePage {},
|
||||
|
||||
#[layout(DeveloperShell)]
|
||||
#[route("/developer/agents")]
|
||||
AgentsPage {},
|
||||
#[route("/developer/flow")]
|
||||
FlowPage {},
|
||||
#[route("/developer/analytics")]
|
||||
AnalyticsPage {},
|
||||
#[end_layout]
|
||||
|
||||
#[layout(OrgShell)]
|
||||
#[route("/organization/pricing")]
|
||||
OrgPricingPage {},
|
||||
#[route("/organization/dashboard")]
|
||||
OrgDashboardPage {},
|
||||
#[end_layout]
|
||||
#[end_layout]
|
||||
|
||||
OverviewPage {},
|
||||
#[route("/login?:redirect_url")]
|
||||
Login { redirect_url: String },
|
||||
#[route("/blog/:id")]
|
||||
Blog { id: i32 },
|
||||
}
|
||||
|
||||
const FAVICON: Asset = asset!("/assets/favicon.svg");
|
||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||
const HEADER_SVG: Asset = asset!("/assets/header.svg");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
const MANIFEST: Asset = asset!("/assets/manifest.json");
|
||||
|
||||
/// Google Fonts URL for Inter (body) and Space Grotesk (headings).
|
||||
const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
|
||||
family=Inter:wght@400;500;600&\
|
||||
family=Space+Grotesk:wght@500;600;700&\
|
||||
display=swap";
|
||||
|
||||
/// Root application component. Loads global assets and mounts the router.
|
||||
#[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" }
|
||||
document::Meta { name: "apple-mobile-web-app-capable", content: "yes" }
|
||||
document::Meta {
|
||||
name: "apple-mobile-web-app-status-bar-style",
|
||||
content: "black-translucent",
|
||||
}
|
||||
document::Link { rel: "apple-touch-icon", href: FAVICON }
|
||||
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
|
||||
document::Link {
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossorigin: "anonymous",
|
||||
}
|
||||
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
|
||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||
|
||||
// Register PWA service worker
|
||||
document::Script {
|
||||
r#"
|
||||
if ('serviceWorker' in navigator) {{
|
||||
navigator.serviceWorker.register('/assets/sw.js')
|
||||
.catch(function(e) {{ console.warn('SW registration failed:', e); }});
|
||||
}}
|
||||
"#
|
||||
}
|
||||
|
||||
// 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);
|
||||
}})();
|
||||
"#
|
||||
}
|
||||
|
||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Hero() -> Element {
|
||||
rsx! {
|
||||
div { id: "hero",
|
||||
img { src: HEADER_SVG, id: "header" }
|
||||
div { id: "links",
|
||||
a { href: "https://dioxuslabs.com/learn/0.7/", "📚 Learn Dioxus" }
|
||||
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
|
||||
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
|
||||
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
|
||||
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus",
|
||||
"💫 VSCode Extension"
|
||||
}
|
||||
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Home page
|
||||
#[component]
|
||||
fn Home() -> Element {
|
||||
rsx! {
|
||||
Hero {}
|
||||
Echo {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blog page
|
||||
#[component]
|
||||
pub fn Blog(id: i32) -> Element {
|
||||
rsx! {
|
||||
div { id: "blog",
|
||||
|
||||
// Content
|
||||
h1 { "This is blog #{id}!" }
|
||||
p {
|
||||
"In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components."
|
||||
}
|
||||
|
||||
// Navigation links
|
||||
Link { to: Route::Blog { id: id - 1 }, "Previous" }
|
||||
span { " <---> " }
|
||||
Link { to: Route::Blog { id: id + 1 }, "Next" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared navbar component.
|
||||
#[component]
|
||||
fn Navbar() -> Element {
|
||||
rsx! {
|
||||
div { id: "navbar",
|
||||
Link { to: Route::OverviewPage {}, "Home" }
|
||||
Link { to: Route::Blog { id: 1 }, "Blog" }
|
||||
}
|
||||
|
||||
Outlet::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo component that demonstrates fullstack server functions.
|
||||
#[component]
|
||||
fn Echo() -> Element {
|
||||
let mut response = use_signal(|| String::new());
|
||||
|
||||
rsx! {
|
||||
div { id: "echo",
|
||||
h4 { "ServerFn Echo" }
|
||||
input {
|
||||
placeholder: "Type here to echo...",
|
||||
oninput: move |event| async move {
|
||||
let data = echo_server(event.value()).await.unwrap();
|
||||
response.set(data);
|
||||
},
|
||||
}
|
||||
|
||||
if !response().is_empty() {
|
||||
p {
|
||||
"Server echoed: "
|
||||
i { "{response}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo the user input on the server.
|
||||
#[post("/api/echo")]
|
||||
async fn echo_server(input: String) -> Result<String, ServerFnError> {
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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.
|
||||
///
|
||||
/// 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 {
|
||||
// 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..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
use crate::infrastructure::llm::FollowUpMessage;
|
||||
use crate::models::NewsCard;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Side panel displaying the full details of a selected news article.
|
||||
///
|
||||
/// Shows the article title, source, date, category badge, full content,
|
||||
/// a link to the original article, an AI summary bubble, and a follow-up
|
||||
/// chat window for asking questions about the article.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `card` - The selected news card data
|
||||
/// * `on_close` - Handler to close the detail panel
|
||||
/// * `summary` - Optional AI-generated summary text
|
||||
/// * `is_summarizing` - Whether a summarization request is in progress
|
||||
/// * `chat_messages` - Follow-up chat conversation history (user + assistant turns)
|
||||
/// * `is_chatting` - Whether a chat response is being generated
|
||||
/// * `on_chat_send` - Handler called with the user's follow-up question
|
||||
#[component]
|
||||
pub fn ArticleDetail(
|
||||
card: NewsCard,
|
||||
on_close: EventHandler,
|
||||
summary: Option<String>,
|
||||
#[props(default = false)] is_summarizing: bool,
|
||||
chat_messages: Vec<FollowUpMessage>,
|
||||
#[props(default = false)] is_chatting: bool,
|
||||
on_chat_send: EventHandler<String>,
|
||||
) -> Element {
|
||||
let css_suffix = card.category.to_lowercase().replace(' ', "-");
|
||||
let badge_class = format!("news-badge news-badge--{css_suffix}");
|
||||
let mut chat_input = use_signal(String::new);
|
||||
let has_summary = summary.is_some() && !is_summarizing;
|
||||
|
||||
// Build favicon URL using DuckDuckGo's privacy-friendly icon service
|
||||
let favicon_url = format!("https://icons.duckduckgo.com/ip3/{}.ico", card.source);
|
||||
|
||||
rsx! {
|
||||
aside { class: "article-detail-panel",
|
||||
// Close button
|
||||
button {
|
||||
class: "article-detail-close",
|
||||
onclick: move |_| on_close.call(()),
|
||||
"X"
|
||||
}
|
||||
|
||||
div { class: "article-detail-content",
|
||||
// Header
|
||||
h2 { class: "article-detail-title", "{card.title}" }
|
||||
|
||||
div { class: "article-detail-meta",
|
||||
span { class: "{badge_class}", "{card.category}" }
|
||||
span { class: "article-detail-source",
|
||||
img {
|
||||
class: "source-favicon",
|
||||
src: "{favicon_url}",
|
||||
alt: "",
|
||||
width: "16",
|
||||
height: "16",
|
||||
}
|
||||
"{card.source}"
|
||||
}
|
||||
span { class: "article-detail-date", "{card.published_at}" }
|
||||
}
|
||||
|
||||
// Content body
|
||||
div { class: "article-detail-body",
|
||||
p { "{card.content}" }
|
||||
}
|
||||
|
||||
// Link to original
|
||||
a {
|
||||
class: "article-detail-link",
|
||||
href: "{card.url}",
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
"Read original article"
|
||||
}
|
||||
|
||||
// AI Summary bubble (below the link)
|
||||
div { class: "ai-summary-bubble",
|
||||
if is_summarizing {
|
||||
div { class: "ai-summary-bubble-loading",
|
||||
div { class: "ai-summary-dot-pulse" }
|
||||
span { "Summarizing..." }
|
||||
}
|
||||
} else if let Some(ref text) = summary {
|
||||
p { class: "ai-summary-bubble-text", "{text}" }
|
||||
span { class: "ai-summary-bubble-label", "Summarized with AI" }
|
||||
}
|
||||
}
|
||||
|
||||
// Follow-up chat window (visible after summary is ready)
|
||||
if has_summary {
|
||||
div { class: "article-chat",
|
||||
// Chat message history
|
||||
if !chat_messages.is_empty() {
|
||||
div { class: "article-chat-messages",
|
||||
for msg in chat_messages.iter() {
|
||||
{
|
||||
let bubble_class = if msg.role == "user" {
|
||||
"chat-msg chat-msg--user"
|
||||
} else {
|
||||
"chat-msg chat-msg--assistant"
|
||||
};
|
||||
rsx! {
|
||||
div { class: "{bubble_class}",
|
||||
p { "{msg.content}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_chatting {
|
||||
div { class: "chat-msg chat-msg--assistant chat-msg--typing",
|
||||
div { class: "ai-summary-dot-pulse" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chat input
|
||||
div { class: "article-chat-input",
|
||||
input {
|
||||
class: "article-chat-textbox",
|
||||
r#type: "text",
|
||||
placeholder: "Ask a follow-up question...",
|
||||
value: "{chat_input}",
|
||||
disabled: is_chatting,
|
||||
oninput: move |e| chat_input.set(e.value()),
|
||||
onkeypress: move |e| {
|
||||
if e.key() == Key::Enter && !is_chatting {
|
||||
let val = chat_input.read().trim().to_string();
|
||||
if !val.is_empty() {
|
||||
on_chat_send.call(val);
|
||||
chat_input.set(String::new());
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "article-chat-send",
|
||||
disabled: is_chatting,
|
||||
onclick: move |_| {
|
||||
let val = chat_input.read().trim().to_string();
|
||||
if !val.is_empty() {
|
||||
on_chat_send.call(val);
|
||||
chat_input.set(String::new());
|
||||
}
|
||||
},
|
||||
"Send"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Reusable dashboard card with icon, title, description and click-through link.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `title` - Card heading text.
|
||||
/// * `description` - Short description shown beneath the title.
|
||||
/// * `href` - URL the card links to when clicked.
|
||||
/// * `icon` - Element rendered as the card icon (typically a `dioxus_free_icons::Icon`).
|
||||
#[component]
|
||||
pub fn DashboardCard(title: String, description: String, href: String, icon: Element) -> Element {
|
||||
rsx! {
|
||||
a { class: "dashboard-card", href: "{href}",
|
||||
div { class: "card-icon", {icon} }
|
||||
h3 { class: "card-title", "{title}" }
|
||||
p { class: "card-description", "{description}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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,131 +0,0 @@
|
||||
use crate::models::{ChatMessage, ChatRole};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Render markdown content to HTML using `pulldown-cmark`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `md` - Raw markdown string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// HTML string suitable for `dangerous_inner_html`
|
||||
fn markdown_to_html(md: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser};
|
||||
|
||||
let mut opts = Options::empty();
|
||||
opts.insert(Options::ENABLE_TABLES);
|
||||
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
opts.insert(Options::ENABLE_TASKLISTS);
|
||||
|
||||
let parser = Parser::new_ext(md, opts);
|
||||
let mut html = String::with_capacity(md.len() * 2);
|
||||
pulldown_cmark::html::push_html(&mut html, parser);
|
||||
html
|
||||
}
|
||||
|
||||
/// Renders a single chat message bubble with role-based styling.
|
||||
///
|
||||
/// User messages are displayed as plain text, right-aligned.
|
||||
/// Assistant messages are rendered as markdown with `pulldown-cmark`.
|
||||
/// System messages are hidden from the UI.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - The chat message to render
|
||||
#[component]
|
||||
pub fn ChatBubble(message: ChatMessage) -> Element {
|
||||
// System messages are not rendered in the UI
|
||||
if message.role == ChatRole::System {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
let bubble_class = match message.role {
|
||||
ChatRole::User => "chat-bubble chat-bubble--user",
|
||||
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
|
||||
ChatRole::System => unreachable!(),
|
||||
};
|
||||
|
||||
let role_label = match message.role {
|
||||
ChatRole::User => "You",
|
||||
ChatRole::Assistant => "Assistant",
|
||||
ChatRole::System => unreachable!(),
|
||||
};
|
||||
|
||||
// Format timestamp for display (show time only if today)
|
||||
let display_time = if message.timestamp.len() >= 16 {
|
||||
// Extract HH:MM from ISO 8601
|
||||
message.timestamp[11..16].to_string()
|
||||
} else {
|
||||
message.timestamp.clone()
|
||||
};
|
||||
|
||||
let is_assistant = message.role == ChatRole::Assistant;
|
||||
|
||||
rsx! {
|
||||
div { class: "{bubble_class}",
|
||||
div { class: "chat-bubble-header",
|
||||
span { class: "chat-bubble-role", "{role_label}" }
|
||||
span { class: "chat-bubble-time", "{display_time}" }
|
||||
}
|
||||
if is_assistant {
|
||||
// Render markdown for assistant messages
|
||||
div {
|
||||
class: "chat-bubble-content chat-prose",
|
||||
dangerous_inner_html: "{markdown_to_html(&message.content)}",
|
||||
}
|
||||
} else {
|
||||
div { class: "chat-bubble-content", "{message.content}" }
|
||||
}
|
||||
if !message.attachments.is_empty() {
|
||||
div { class: "chat-bubble-attachments",
|
||||
for att in &message.attachments {
|
||||
span { class: "chat-attachment", "{att.name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a streaming assistant message bubble.
|
||||
///
|
||||
/// While waiting for tokens, shows a "Thinking..." indicator with
|
||||
/// a pulsing dot animation. Once tokens arrive, renders them as
|
||||
/// markdown with a blinking cursor.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The accumulated streaming content so far
|
||||
#[component]
|
||||
pub fn StreamingBubble(content: String) -> Element {
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
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,112 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
|
||||
|
||||
/// Right sidebar for the dashboard, showing Ollama status, trending topics,
|
||||
/// and recent search history.
|
||||
///
|
||||
/// Appears when no article card is selected. Disappears when the user opens
|
||||
/// the article detail split view.
|
||||
///
|
||||
/// # Props
|
||||
///
|
||||
/// * `ollama_url` - Ollama instance URL for status polling
|
||||
/// * `trending` - Trending topic keywords extracted from recent news headlines
|
||||
/// * `recent_searches` - Recent search topics stored in localStorage
|
||||
/// * `on_topic_click` - Fires when a trending or recent topic is clicked
|
||||
#[component]
|
||||
pub fn DashboardSidebar(
|
||||
ollama_url: String,
|
||||
trending: Vec<String>,
|
||||
recent_searches: Vec<String>,
|
||||
on_topic_click: EventHandler<String>,
|
||||
) -> Element {
|
||||
// Fetch Ollama status once on mount.
|
||||
// use_resource with no signal dependencies runs exactly once and
|
||||
// won't re-fire on parent re-renders (unlike use_effect).
|
||||
let url = ollama_url.clone();
|
||||
let status_resource = use_resource(move || {
|
||||
let u = url.clone();
|
||||
async move {
|
||||
get_ollama_status(u).await.unwrap_or(OllamaStatus {
|
||||
online: false,
|
||||
models: Vec::new(),
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
let current_status: OllamaStatus =
|
||||
status_resource
|
||||
.read()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or(OllamaStatus {
|
||||
online: false,
|
||||
models: Vec::new(),
|
||||
});
|
||||
|
||||
rsx! {
|
||||
aside { class: "dashboard-sidebar",
|
||||
|
||||
// -- Ollama Status Section --
|
||||
div { class: "sidebar-section",
|
||||
h4 { class: "sidebar-section-title", "Ollama Status" }
|
||||
div { class: "sidebar-status-row",
|
||||
span { class: if current_status.online { "sidebar-status-dot sidebar-status-dot--online" } else { "sidebar-status-dot sidebar-status-dot--offline" } }
|
||||
span { class: "sidebar-status-label",
|
||||
if current_status.online {
|
||||
"Online"
|
||||
} else {
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current_status.models.is_empty() {
|
||||
div { class: "sidebar-model-list",
|
||||
for model in current_status.models.iter() {
|
||||
span { class: "sidebar-model-tag", "{model}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Trending Topics Section --
|
||||
if !trending.is_empty() {
|
||||
div { class: "sidebar-section",
|
||||
h4 { class: "sidebar-section-title", "Trending" }
|
||||
for topic in trending.iter() {
|
||||
{
|
||||
let t = topic.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "sidebar-topic-link",
|
||||
onclick: move |_| on_topic_click.call(t.clone()),
|
||||
"{topic}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Recent Searches Section --
|
||||
if !recent_searches.is_empty() {
|
||||
div { class: "sidebar-section",
|
||||
h4 { class: "sidebar-section-title", "Recent Searches" }
|
||||
for search in recent_searches.iter() {
|
||||
{
|
||||
let s = search.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "sidebar-topic-link",
|
||||
onclick: move |_| on_topic_click.call(s.clone()),
|
||||
"{search}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
use crate::models::KnowledgeFile;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a table row for a knowledge base file.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file` - The knowledge file data to render
|
||||
/// * `on_delete` - Callback fired when the delete button is clicked
|
||||
#[component]
|
||||
pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler<String>) -> Element {
|
||||
// Format file size for human readability (Python devs: similar to humanize.naturalsize)
|
||||
let size_display = format_size(file.size_bytes);
|
||||
|
||||
rsx! {
|
||||
tr { class: "file-row",
|
||||
td { class: "file-row-name",
|
||||
span { class: "file-row-icon", "{file.kind.icon()}" }
|
||||
"{file.name}"
|
||||
}
|
||||
td { "{file.kind.label()}" }
|
||||
td { "{size_display}" }
|
||||
td { "{file.chunk_count} chunks" }
|
||||
td { "{file.uploaded_at}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn-icon btn-danger",
|
||||
onclick: {
|
||||
let id = file.id.clone();
|
||||
move |_| on_delete.call(id.clone())
|
||||
},
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a byte count into a human-readable string (e.g. "1.2 MB").
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.1} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,11 @@
|
||||
use crate::Route;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Login redirect component.
|
||||
///
|
||||
/// Redirects the user to the external OAuth authentication endpoint.
|
||||
/// If no `redirect_url` is provided, defaults to `/dashboard`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `redirect_url` - URL to redirect to after successful authentication
|
||||
#[component]
|
||||
pub fn Login(redirect_url: String) -> Element {
|
||||
let navigator = use_navigator();
|
||||
|
||||
use_effect(move || {
|
||||
// Default to /dashboard when redirect_url is empty.
|
||||
let destination = if redirect_url.is_empty() {
|
||||
"/dashboard".to_string()
|
||||
} else {
|
||||
redirect_url.clone()
|
||||
};
|
||||
let target = format!("/auth?redirect_url={destination}");
|
||||
let target = format!("/auth?redirect_url={}", redirect_url);
|
||||
navigator.push(NavigationTarget::<Route>::External(target));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
use crate::models::{MemberRole, OrgMember};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a table row for an organization member with a role dropdown.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `member` - The organization member data to render
|
||||
/// * `on_role_change` - Callback fired with (member_id, new_role) when role changes
|
||||
#[component]
|
||||
pub fn MemberRow(member: OrgMember, on_role_change: EventHandler<(String, String)>) -> Element {
|
||||
rsx! {
|
||||
tr { class: "member-row",
|
||||
td { class: "member-row-name", "{member.name}" }
|
||||
td { "{member.email}" }
|
||||
td {
|
||||
select {
|
||||
class: "member-role-select",
|
||||
value: "{member.role.label()}",
|
||||
onchange: {
|
||||
let id = member.id.clone();
|
||||
move |evt: Event<FormData>| {
|
||||
on_role_change.call((id.clone(), evt.value()));
|
||||
}
|
||||
},
|
||||
for role in MemberRole::all() {
|
||||
option {
|
||||
value: "{role.label()}",
|
||||
selected: *role == member.role,
|
||||
"{role.label()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
td { "{member.joined_at}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,2 @@
|
||||
mod app_shell;
|
||||
mod article_detail;
|
||||
mod card;
|
||||
mod chat_action_bar;
|
||||
mod chat_bubble;
|
||||
mod chat_input_bar;
|
||||
mod chat_message_list;
|
||||
mod chat_model_selector;
|
||||
mod chat_sidebar;
|
||||
mod dashboard_sidebar;
|
||||
mod file_row;
|
||||
mod login;
|
||||
mod member_row;
|
||||
pub mod news_card;
|
||||
mod page_header;
|
||||
mod pricing_card;
|
||||
pub mod sidebar;
|
||||
pub mod sub_nav;
|
||||
mod tool_card;
|
||||
|
||||
pub use app_shell::*;
|
||||
pub use article_detail::*;
|
||||
pub use card::*;
|
||||
pub use chat_action_bar::*;
|
||||
pub use chat_bubble::*;
|
||||
pub use chat_input_bar::*;
|
||||
pub use chat_message_list::*;
|
||||
pub use chat_model_selector::*;
|
||||
pub use chat_sidebar::*;
|
||||
pub use dashboard_sidebar::*;
|
||||
pub use file_row::*;
|
||||
pub use login::*;
|
||||
pub use member_row::*;
|
||||
pub use news_card::*;
|
||||
pub use page_header::*;
|
||||
pub use pricing_card::*;
|
||||
pub use sub_nav::*;
|
||||
pub use tool_card::*;
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
use crate::models::NewsCard as NewsCardModel;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a news feed card with title, source, category badge, and summary.
|
||||
///
|
||||
/// When a thumbnail URL is present but the image fails to load, the card
|
||||
/// automatically switches to the centered no-thumbnail layout.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `card` - The news card model data to render
|
||||
/// * `on_click` - Event handler triggered when the card is clicked
|
||||
/// * `selected` - Whether this card is currently selected (highlighted)
|
||||
#[component]
|
||||
pub fn NewsCardView(
|
||||
card: NewsCardModel,
|
||||
on_click: EventHandler<NewsCardModel>,
|
||||
#[props(default = false)] selected: bool,
|
||||
) -> Element {
|
||||
// Derive a CSS class from the category string (lowercase, hyphenated)
|
||||
let css_suffix = card.category.to_lowercase().replace(' ', "-");
|
||||
let badge_class = format!("news-badge news-badge--{css_suffix}");
|
||||
|
||||
// Track whether the thumbnail loaded successfully.
|
||||
// Starts as true if a URL is provided; set to false on image error.
|
||||
let has_thumb_url = card.thumbnail_url.is_some();
|
||||
let mut thumb_ok = use_signal(|| has_thumb_url);
|
||||
|
||||
let show_thumb = has_thumb_url && *thumb_ok.read();
|
||||
let selected_cls = if selected { " news-card--selected" } else { "" };
|
||||
let thumb_cls = if show_thumb {
|
||||
""
|
||||
} else {
|
||||
" news-card--no-thumb"
|
||||
};
|
||||
let card_class = format!("news-card{selected_cls}{thumb_cls}");
|
||||
|
||||
// Clone the card for the click handler closure
|
||||
let card_for_click = card.clone();
|
||||
|
||||
rsx! {
|
||||
article {
|
||||
class: "{card_class}",
|
||||
onclick: move |_| on_click.call(card_for_click.clone()),
|
||||
if let Some(ref thumb) = card.thumbnail_url {
|
||||
if *thumb_ok.read() {
|
||||
div { class: "news-card-thumb",
|
||||
img {
|
||||
src: "{thumb}",
|
||||
alt: "",
|
||||
loading: "lazy",
|
||||
// Hide the thumbnail container if the image fails to load
|
||||
onerror: move |_| thumb_ok.set(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "news-card-body",
|
||||
div { class: "news-card-meta",
|
||||
span { class: "{badge_class}", "{card.category}" }
|
||||
span { class: "news-card-source", "{card.source}" }
|
||||
span { class: "news-card-date", "{card.published_at}" }
|
||||
}
|
||||
h3 { class: "news-card-title", "{card.title}" }
|
||||
p { class: "news-card-summary", "{card.summary}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock news data for the dashboard.
|
||||
pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
vec![
|
||||
NewsCardModel {
|
||||
title: "Llama 4 Released with 1M Context Window".into(),
|
||||
source: "Meta AI Blog".into(),
|
||||
summary: "Meta releases Llama 4 with a 1 million token context window.".into(),
|
||||
content: "Meta has officially released Llama 4, their latest \
|
||||
open-weight large language model featuring a groundbreaking \
|
||||
1 million token context window. This represents a major \
|
||||
leap in context length capabilities."
|
||||
.into(),
|
||||
category: "AI".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-18".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "EU AI Act Enforcement Begins".into(),
|
||||
source: "TechCrunch".into(),
|
||||
summary: "The EU AI Act enters its enforcement phase across member states.".into(),
|
||||
content: "The EU AI Act has officially entered its enforcement \
|
||||
phase. Member states are now required to comply with the \
|
||||
comprehensive regulatory framework governing AI systems."
|
||||
.into(),
|
||||
category: "Privacy".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-17".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "LangChain v0.4 Introduces Native MCP Support".into(),
|
||||
source: "LangChain Blog".into(),
|
||||
summary: "New version adds first-class MCP server integration.".into(),
|
||||
content: "LangChain v0.4 introduces native Model Context Protocol \
|
||||
support, enabling seamless integration with MCP servers for \
|
||||
tool use and context management in agent workflows."
|
||||
.into(),
|
||||
category: "Technology".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-16".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "Ollama Adds Multi-GPU Scheduling".into(),
|
||||
source: "Ollama".into(),
|
||||
summary: "Run large models across multiple GPUs with automatic sharding.".into(),
|
||||
content: "Ollama now supports multi-GPU scheduling with automatic \
|
||||
model sharding. Users can run models across multiple GPUs \
|
||||
for improved inference performance."
|
||||
.into(),
|
||||
category: "Infrastructure".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-15".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "Mistral Open Sources Codestral 2".into(),
|
||||
source: "Mistral AI".into(),
|
||||
summary: "Codestral 2 achieves state-of-the-art on HumanEval benchmarks.".into(),
|
||||
content: "Mistral AI has open-sourced Codestral 2, a code \
|
||||
generation model that achieves state-of-the-art results \
|
||||
on HumanEval and other coding benchmarks."
|
||||
.into(),
|
||||
category: "Open Source".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-14".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "NVIDIA Releases NeMo 3.0 Framework".into(),
|
||||
source: "NVIDIA Developer".into(),
|
||||
summary: "Updated framework simplifies enterprise LLM fine-tuning.".into(),
|
||||
content: "NVIDIA has released NeMo 3.0, an updated framework \
|
||||
that simplifies enterprise LLM fine-tuning with improved \
|
||||
distributed training capabilities."
|
||||
.into(),
|
||||
category: "Infrastructure".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-13".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "Anthropic Claude 4 Sets New Reasoning Records".into(),
|
||||
source: "Anthropic".into(),
|
||||
summary: "Claude 4 achieves top scores across major reasoning benchmarks.".into(),
|
||||
content: "Anthropic's Claude 4 has set new records across major \
|
||||
reasoning benchmarks, demonstrating significant improvements \
|
||||
in mathematical and logical reasoning capabilities."
|
||||
.into(),
|
||||
category: "AI".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-12".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "CrewAI Raises $52M for Agent Orchestration".into(),
|
||||
source: "VentureBeat".into(),
|
||||
summary: "Series B funding to expand multi-agent orchestration platform.".into(),
|
||||
content: "CrewAI has raised $52M in Series B funding to expand \
|
||||
its multi-agent orchestration platform, enabling teams \
|
||||
to build and deploy complex AI agent workflows."
|
||||
.into(),
|
||||
category: "Technology".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-11".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "DeepSeek V4 Released Under Apache 2.0".into(),
|
||||
source: "DeepSeek".into(),
|
||||
summary: "Latest open-weight model competes with proprietary offerings.".into(),
|
||||
content: "DeepSeek has released V4 under the Apache 2.0 license, \
|
||||
an open-weight model that competes with proprietary \
|
||||
offerings in both performance and efficiency."
|
||||
.into(),
|
||||
category: "Open Source".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-10".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "GDPR Fines for AI Training Data Reach Record High".into(),
|
||||
source: "Reuters".into(),
|
||||
summary: "European regulators issue largest penalties yet for AI data misuse.".into(),
|
||||
content: "European regulators have issued record-high GDPR fines \
|
||||
for AI training data misuse, signaling stricter enforcement \
|
||||
of data protection laws in the AI sector."
|
||||
.into(),
|
||||
category: "Privacy".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-09".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Reusable page header with title, subtitle, and an optional action slot.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `title` - Main heading text for the page
|
||||
/// * `subtitle` - Secondary descriptive text below the title
|
||||
/// * `actions` - Optional element rendered on the right side (e.g. buttons)
|
||||
#[component]
|
||||
pub fn PageHeader(title: String, subtitle: String, actions: Option<Element>) -> Element {
|
||||
rsx! {
|
||||
div { class: "page-header",
|
||||
div { class: "page-header-text",
|
||||
h1 { class: "page-title", "{title}" }
|
||||
p { class: "page-subtitle", "{subtitle}" }
|
||||
}
|
||||
if let Some(actions) = actions {
|
||||
div { class: "page-header-actions", {actions} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
use crate::models::PricingPlan;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a pricing plan card with features list and call-to-action button.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `plan` - The pricing plan data to render
|
||||
/// * `on_select` - Callback fired when the CTA button is clicked
|
||||
#[component]
|
||||
pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Element {
|
||||
let card_class = if plan.highlighted {
|
||||
"pricing-card pricing-card--highlighted"
|
||||
} else {
|
||||
"pricing-card"
|
||||
};
|
||||
|
||||
let seats_label = match plan.max_seats {
|
||||
Some(n) => format!("Up to {n} seats"),
|
||||
None => "Unlimited seats".to_string(),
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "{card_class}",
|
||||
h3 { class: "pricing-card-name", "{plan.name}" }
|
||||
div { class: "pricing-card-price",
|
||||
span { class: "pricing-card-amount", "{plan.price_eur}" }
|
||||
span { class: "pricing-card-period", " EUR / month" }
|
||||
}
|
||||
p { class: "pricing-card-seats", "{seats_label}" }
|
||||
ul { class: "pricing-card-features",
|
||||
for feature in &plan.features {
|
||||
li { "{feature}" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "pricing-card-cta",
|
||||
onclick: {
|
||||
let id = plan.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
},
|
||||
"Get Started"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
|
||||
BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill,
|
||||
};
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::Route;
|
||||
|
||||
/// Navigation entry for the sidebar.
|
||||
struct NavItem {
|
||||
label: &'static str,
|
||||
route: Route,
|
||||
/// Bootstrap icon element rendered beside the label.
|
||||
icon: Element,
|
||||
}
|
||||
|
||||
/// Fixed left sidebar containing header, navigation, logout, and footer.
|
||||
///
|
||||
/// # 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(name: String, email: String, avatar_url: String) -> Element {
|
||||
let nav_items: Vec<NavItem> = vec![
|
||||
NavItem {
|
||||
label: "Dashboard",
|
||||
route: Route::DashboardPage {},
|
||||
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Providers",
|
||||
route: Route::ProvidersPage {},
|
||||
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Chat",
|
||||
route: Route::ChatPage {},
|
||||
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Tools",
|
||||
route: Route::ToolsPage {},
|
||||
icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Knowledge Base",
|
||||
route: Route::KnowledgePage {},
|
||||
icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Developer",
|
||||
route: Route::AgentsPage {},
|
||||
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Organization",
|
||||
route: Route::OrgPricingPage {},
|
||||
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
|
||||
},
|
||||
];
|
||||
|
||||
// Determine current path to highlight the active nav link.
|
||||
let current_route = use_route::<Route>();
|
||||
|
||||
rsx! {
|
||||
aside { class: "sidebar",
|
||||
SidebarHeader { name, email: email.clone(), avatar_url }
|
||||
|
||||
nav { class: "sidebar-nav",
|
||||
for item in nav_items {
|
||||
{
|
||||
// Active detection for nested routes: highlight the parent nav
|
||||
// item when any child route within the nested shell is active.
|
||||
let is_active = match ¤t_route {
|
||||
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
|
||||
item.label == "Developer"
|
||||
}
|
||||
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
|
||||
item.label == "Organization"
|
||||
}
|
||||
_ => item.route == current_route,
|
||||
};
|
||||
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
|
||||
rsx! {
|
||||
Link { to: item.route, class: cls,
|
||||
{item.icon}
|
||||
span { "{item.label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "sidebar-bottom-actions",
|
||||
Link {
|
||||
to: NavigationTarget::<Route>::External("/logout".into()),
|
||||
class: "sidebar-link logout-btn",
|
||||
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
|
||||
span { "Logout" }
|
||||
}
|
||||
ThemeToggle {}
|
||||
}
|
||||
|
||||
SidebarFooter {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(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}" }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer section with version string and placeholder social links.
|
||||
#[component]
|
||||
fn SidebarFooter() -> Element {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
rsx! {
|
||||
footer { class: "sidebar-footer",
|
||||
div { class: "sidebar-social",
|
||||
a { href: "#", class: "social-link", title: "GitHub",
|
||||
Icon { icon: BsGithub, width: 16, height: 16 }
|
||||
}
|
||||
a { href: "#", class: "social-link", title: "Impressum",
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
use crate::app::Route;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// A single tab item for the sub-navigation bar.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `label` - Display text for the tab
|
||||
/// * `route` - Route to navigate to when clicked
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct SubNavItem {
|
||||
pub label: &'static str,
|
||||
pub route: Route,
|
||||
}
|
||||
|
||||
/// Horizontal tab navigation bar used inside nested shell layouts.
|
||||
///
|
||||
/// Highlights the active tab based on the current route.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `items` - List of tab items to render
|
||||
#[component]
|
||||
pub fn SubNav(items: Vec<SubNavItem>) -> Element {
|
||||
let current_route = use_route::<Route>();
|
||||
|
||||
rsx! {
|
||||
nav { class: "sub-nav",
|
||||
for item in &items {
|
||||
{
|
||||
let is_active = item.route == current_route;
|
||||
let class = if is_active {
|
||||
"sub-nav-item sub-nav-item--active"
|
||||
} else {
|
||||
"sub-nav-item"
|
||||
};
|
||||
rsx! {
|
||||
Link { class: "{class}", to: item.route.clone(), "{item.label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
use crate::models::McpTool;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders an MCP tool card with name, description, status indicator, and toggle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `tool` - The MCP tool data to render
|
||||
/// * `on_toggle` - Callback fired when the enable/disable toggle is clicked
|
||||
#[component]
|
||||
pub fn ToolCard(tool: McpTool, on_toggle: EventHandler<String>) -> Element {
|
||||
let status_class = format!("tool-status tool-status--{}", tool.status.css_class());
|
||||
let toggle_class = if tool.enabled {
|
||||
"tool-toggle tool-toggle--on"
|
||||
} else {
|
||||
"tool-toggle tool-toggle--off"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "tool-card",
|
||||
div { class: "tool-card-header",
|
||||
div { class: "tool-card-icon", "\u{2699}" }
|
||||
span { class: "{status_class}", "" }
|
||||
}
|
||||
h3 { class: "tool-card-name", "{tool.name}" }
|
||||
p { class: "tool-card-desc", "{tool.description}" }
|
||||
div { class: "tool-card-footer",
|
||||
span { class: "tool-card-category", "{tool.category.label()}" }
|
||||
button {
|
||||
class: "{toggle_class}",
|
||||
onclick: {
|
||||
let id = tool.id.clone();
|
||||
move |_| on_toggle.call(id.clone())
|
||||
},
|
||||
if tool.enabled {
|
||||
"ON"
|
||||
} else {
|
||||
"OFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,306 +1,109 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use super::error::{Error, Result};
|
||||
use axum::Extension;
|
||||
use axum::{
|
||||
extract::Query,
|
||||
response::{IntoResponse, Redirect},
|
||||
Extension,
|
||||
extract::FromRequestParts,
|
||||
http::request::Parts,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use rand::RngExt;
|
||||
use tower_sessions::Session;
|
||||
use url::Url;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::infrastructure::{
|
||||
server_state::ServerState,
|
||||
state::{User, UserStateInner},
|
||||
Error,
|
||||
};
|
||||
|
||||
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
||||
|
||||
/// Data stored alongside each pending OAuth state. Holds the optional
|
||||
/// post-login redirect URL and the PKCE code verifier needed for the
|
||||
/// token exchange.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingOAuthEntry {
|
||||
redirect_url: Option<String>,
|
||||
code_verifier: String,
|
||||
pub struct KeycloakVariables {
|
||||
pub base_url: String,
|
||||
pub realm: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub enable_test_user: bool,
|
||||
}
|
||||
|
||||
/// In-memory store for pending OAuth states. Keyed by the random state
|
||||
/// string. This avoids dependence on the session cookie surviving the
|
||||
/// Keycloak redirect round-trip (the `dx serve` proxy can drop
|
||||
/// `Set-Cookie` headers on 307 responses).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
|
||||
/// Session data available to the backend when the user is logged in
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LoggedInData {
|
||||
pub id: String,
|
||||
// ID Token value associated with the authenticated session.
|
||||
pub token_id: String,
|
||||
pub username: String,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
impl PendingOAuthStore {
|
||||
/// Insert a pending state with an optional redirect URL and PKCE verifier.
|
||||
fn insert(&self, state: String, entry: PendingOAuthEntry) {
|
||||
// RwLock::write only panics if the lock is poisoned, which
|
||||
// indicates a prior panic -- propagating is acceptable here.
|
||||
#[allow(clippy::expect_used)]
|
||||
self.0
|
||||
.write()
|
||||
.expect("pending oauth store lock poisoned")
|
||||
.insert(state, entry);
|
||||
}
|
||||
/// Used for extracting in the server functions.
|
||||
/// If the `data` is `Some`, the user is logged in.
|
||||
pub struct UserSession {
|
||||
data: Option<LoggedInData>,
|
||||
}
|
||||
|
||||
/// Remove and return the entry if the state was pending.
|
||||
/// Returns `None` if the state was never stored (CSRF failure).
|
||||
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||
#[allow(clippy::expect_used)]
|
||||
self.0
|
||||
.write()
|
||||
.expect("pending oauth store lock poisoned")
|
||||
.remove(state)
|
||||
impl UserSession {
|
||||
/// Get the [`LoggedInData`].
|
||||
///
|
||||
/// Raises a [`Error::UserNotLoggedIn`] error if the user is not logged in.
|
||||
pub fn data(self) -> Result<LoggedInData> {
|
||||
self.data.ok_or(Error::UserNotLoggedIn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random state string for CSRF protection.
|
||||
fn generate_state() -> String {
|
||||
let bytes: [u8; 32] = rand::rng().random();
|
||||
// Encode as hex to produce a URL-safe string without padding.
|
||||
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||
use std::fmt::Write;
|
||||
// write! on a String is infallible, safe to ignore the result.
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
})
|
||||
}
|
||||
const LOGGED_IN_USER_SESSION_KEY: &str = "logged_in_data";
|
||||
|
||||
/// Generate a PKCE code verifier (43-128 char URL-safe random string).
|
||||
///
|
||||
/// Uses 32 random bytes encoded as base64url (no padding) to produce
|
||||
/// a 43-character verifier per RFC 7636.
|
||||
fn generate_code_verifier() -> String {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
impl<S: std::marker::Sync + std::marker::Send> FromRequestParts<S> for UserSession {
|
||||
type Rejection = Error;
|
||||
|
||||
let bytes: [u8; 32] = rand::rng().random();
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self> {
|
||||
let session = parts
|
||||
.extensions
|
||||
.get::<tower_sessions::Session>()
|
||||
.cloned()
|
||||
.ok_or(Error::AuthSessionLayerNotFound(
|
||||
"Auth Session Layer not found".to_string(),
|
||||
))?;
|
||||
|
||||
/// Derive the S256 code challenge from a code verifier per RFC 7636.
|
||||
///
|
||||
/// `code_challenge = BASE64URL(SHA256(code_verifier))`
|
||||
fn derive_code_challenge(verifier: &str) -> String {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use sha2::{Digest, Sha256};
|
||||
let data: Option<LoggedInData> = session
|
||||
.get::<LoggedInData>(LOGGED_IN_USER_SESSION_KEY)
|
||||
.await?;
|
||||
|
||||
let digest = Sha256::digest(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest)
|
||||
}
|
||||
|
||||
/// Redirect the user to Keycloak's authorization page.
|
||||
///
|
||||
/// Generates a random CSRF state, stores it (along with the optional
|
||||
/// redirect URL) in the server-side `PendingOAuthStore`, and redirects
|
||||
/// the browser to Keycloak.
|
||||
///
|
||||
/// # Query Parameters
|
||||
///
|
||||
/// * `redirect_url` - Optional URL to redirect to after successful login.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 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 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(
|
||||
csrf_state.clone(),
|
||||
PendingOAuthEntry {
|
||||
redirect_url,
|
||||
code_verifier,
|
||||
},
|
||||
);
|
||||
|
||||
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", &kc.client_id)
|
||||
.append_pair("redirect_uri", &kc.redirect_uri)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("scope", "openid profile email")
|
||||
.append_pair("state", &csrf_state)
|
||||
.append_pair("code_challenge", &code_challenge)
|
||||
.append_pair("code_challenge_method", "S256");
|
||||
|
||||
Ok(Redirect::temporary(url.as_str()))
|
||||
}
|
||||
|
||||
/// Token endpoint response from Keycloak.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Userinfo endpoint response from Keycloak.
|
||||
#[derive(serde::Deserialize)]
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Handle the OAuth callback from Keycloak after the user authenticates.
|
||||
///
|
||||
/// Validates the CSRF state against the `PendingOAuthStore`, exchanges
|
||||
/// the authorization code for tokens, fetches user info, stores the
|
||||
/// logged-in user in the tower-sessions session, and redirects to the app.
|
||||
///
|
||||
/// # Query Parameters
|
||||
///
|
||||
/// * `code` - The authorization code from Keycloak.
|
||||
/// * `state` - The CSRF state to verify against the pending store.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` on CSRF mismatch, token exchange failure, or session issues.
|
||||
#[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 kc = state.keycloak;
|
||||
|
||||
// --- CSRF validation via the in-memory pending store ---
|
||||
let returned_state = params
|
||||
.get("state")
|
||||
.ok_or_else(|| Error::StateError("missing state parameter".into()))?;
|
||||
|
||||
let entry = pending
|
||||
.take(returned_state)
|
||||
.ok_or_else(|| Error::StateError("unknown or expired oauth state".into()))?;
|
||||
|
||||
// --- Exchange code for tokens (with PKCE code_verifier) ---
|
||||
let code = params
|
||||
.get("code")
|
||||
.ok_or_else(|| Error::StateError("missing code parameter".into()))?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let token_resp = client
|
||||
.post(kc.token_endpoint())
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", kc.client_id.as_str()),
|
||||
("redirect_uri", kc.redirect_uri.as_str()),
|
||||
("code", code),
|
||||
("code_verifier", &entry.code_verifier),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("token request failed: {e}")))?;
|
||||
|
||||
if !token_resp.status().is_success() {
|
||||
let body = token_resp.text().await.unwrap_or_default();
|
||||
return Err(Error::StateError(format!("token exchange failed: {body}")));
|
||||
Ok(Self { data })
|
||||
}
|
||||
|
||||
let tokens: TokenResponse = token_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("token parse failed: {e}")))?;
|
||||
|
||||
// --- Fetch userinfo ---
|
||||
let userinfo: UserinfoResponse = client
|
||||
.get(kc.userinfo_endpoint())
|
||||
.bearer_auth(&tokens.access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("userinfo request failed: {e}")))?
|
||||
.json()
|
||||
.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,
|
||||
access_token: tokens.access_token,
|
||||
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(),
|
||||
},
|
||||
};
|
||||
|
||||
set_login_session(session, user_state).await?;
|
||||
|
||||
let target = entry
|
||||
.redirect_url
|
||||
.filter(|u| !u.is_empty())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
|
||||
Ok(Redirect::temporary(&target))
|
||||
}
|
||||
|
||||
/// Clear the user session and redirect to Keycloak's logout endpoint.
|
||||
///
|
||||
/// After Keycloak finishes its own logout flow it will redirect
|
||||
/// back to the application root.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if the session cannot be flushed or the URL is malformed.
|
||||
/// Helper function to log the user in by setting the session data
|
||||
pub async fn login(session: &tower_sessions::Session, data: &LoggedInData) -> Result<()> {
|
||||
session.insert(LOGGED_IN_USER_SESSION_KEY, data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handler to run when the user wants to logout
|
||||
#[axum::debug_handler]
|
||||
pub async fn logout(
|
||||
session: Session,
|
||||
Extension(state): Extension<ServerState>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let kc = state.keycloak;
|
||||
state: Extension<super::server_state::ServerState>,
|
||||
session: tower_sessions::Session,
|
||||
) -> Result<Response> {
|
||||
let dashboard_base_url = "http://localhost:8000";
|
||||
let redirect_uri = format!("{dashboard_base_url}/");
|
||||
let encoded_redirect_uri: String =
|
||||
form_urlencoded::byte_serialize(redirect_uri.as_bytes()).collect();
|
||||
|
||||
// Flush all session data.
|
||||
session
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("session flush failed: {e}")))?;
|
||||
// clear the session value for this session
|
||||
if let Some(login_data) = session
|
||||
.remove::<LoggedInData>(LOGGED_IN_USER_SESSION_KEY)
|
||||
.await?
|
||||
{
|
||||
let kc_base_url = &state.keycloak_variables.base_url;
|
||||
let kc_realm = &state.keycloak_variables.realm;
|
||||
let kc_client_id = &state.keycloak_variables.client_id;
|
||||
|
||||
let mut url = Url::parse(&kc.logout_endpoint())
|
||||
.map_err(|e| Error::StateError(format!("invalid logout endpoint URL: {e}")))?;
|
||||
// Needed for running locally.
|
||||
// This will not panic on production and it will return the original so we can keep it
|
||||
let routed_kc_base_url = kc_base_url.replace("keycloak", "localhost");
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("client_id", &kc.client_id)
|
||||
.append_pair("post_logout_redirect_uri", &kc.app_url);
|
||||
let token_id = login_data.token_id;
|
||||
|
||||
Ok(Redirect::temporary(url.as_str()))
|
||||
}
|
||||
|
||||
/// Persist user data into the session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if the session store write fails.
|
||||
pub async fn set_login_session(session: Session, data: UserStateInner) -> Result<(), Error> {
|
||||
session
|
||||
.insert(LOGGED_IN_USER_SESS_KEY, data)
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
|
||||
// redirect to Keycloak logout endpoint
|
||||
let logout_url = format!(
|
||||
"{routed_kc_base_url}/realms/{kc_realm}/protocol/openid-connect/logout\
|
||||
?post_logout_redirect_uri={encoded_redirect_uri}\
|
||||
&client_id={kc_client_id}\
|
||||
&id_token_hint={token_id}"
|
||||
);
|
||||
Ok(Redirect::to(&logout_url).into_response())
|
||||
} else {
|
||||
// No id_token in session; just redirect to homepage
|
||||
Ok(Redirect::to(&redirect_uri).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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()),
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
//! 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()
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
//! SSE streaming endpoint for chat completions.
|
||||
//!
|
||||
//! Exposes `GET /api/chat/stream?session_id=<id>` which:
|
||||
//! 1. Authenticates the user via tower-sessions
|
||||
//! 2. Loads the session and its messages from MongoDB
|
||||
//! 3. Streams LLM tokens as SSE events to the frontend
|
||||
//! 4. Persists the complete assistant message on finish
|
||||
|
||||
use axum::{
|
||||
extract::Query,
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
IntoResponse, Response,
|
||||
},
|
||||
Extension,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use super::{
|
||||
auth::LOGGED_IN_USER_SESS_KEY,
|
||||
chat::{doc_to_chat_message, doc_to_chat_session},
|
||||
provider_client::{send_chat_request, ProviderMessage},
|
||||
server_state::ServerState,
|
||||
state::UserStateInner,
|
||||
};
|
||||
use crate::models::{ChatMessage, ChatRole};
|
||||
|
||||
/// Query parameters for the SSE stream endpoint.
|
||||
#[derive(Deserialize)]
|
||||
pub struct StreamQuery {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
/// SSE streaming handler for chat completions.
|
||||
///
|
||||
/// Reads the session's provider/model config, loads conversation history,
|
||||
/// sends to the LLM with `stream: true`, and forwards tokens as SSE events.
|
||||
///
|
||||
/// # SSE Event Format
|
||||
///
|
||||
/// - `data: {"token": "..."}` -- partial token
|
||||
/// - `data: {"done": true, "message_id": "..."}` -- stream complete
|
||||
/// - `data: {"error": "..."}` -- on failure
|
||||
pub async fn chat_stream_handler(
|
||||
session: Session,
|
||||
Extension(state): Extension<ServerState>,
|
||||
Query(params): Query<StreamQuery>,
|
||||
) -> Response {
|
||||
// Authenticate
|
||||
let user_state: Option<UserStateInner> = match session.get(LOGGED_IN_USER_SESS_KEY).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::UNAUTHORIZED, "session error").into_response(),
|
||||
};
|
||||
let user = match user_state {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::UNAUTHORIZED, "not authenticated").into_response(),
|
||||
};
|
||||
|
||||
// Load session from MongoDB (raw document to handle ObjectId -> String)
|
||||
let chat_session = {
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
let oid = match ObjectId::parse_str(¶ms.session_id) {
|
||||
Ok(o) => o,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "invalid session_id").into_response(),
|
||||
};
|
||||
match state
|
||||
.db
|
||||
.raw_collection("chat_sessions")
|
||||
.find_one(doc! { "_id": oid, "user_sub": &user.sub })
|
||||
.await
|
||||
{
|
||||
Ok(Some(doc)) => doc_to_chat_session(&doc),
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "session not found").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("db error loading session: {e}");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Load messages (raw documents to handle ObjectId -> String)
|
||||
let messages = {
|
||||
use mongodb::bson::doc;
|
||||
use mongodb::options::FindOptions;
|
||||
|
||||
let opts = FindOptions::builder().sort(doc! { "timestamp": 1 }).build();
|
||||
|
||||
match state
|
||||
.db
|
||||
.raw_collection("chat_messages")
|
||||
.find(doc! { "session_id": ¶ms.session_id })
|
||||
.with_options(opts)
|
||||
.await
|
||||
{
|
||||
Ok(mut cursor) => {
|
||||
use futures::TryStreamExt;
|
||||
let mut msgs = Vec::new();
|
||||
while let Some(doc) = TryStreamExt::try_next(&mut cursor).await.unwrap_or(None) {
|
||||
msgs.push(doc_to_chat_message(&doc));
|
||||
}
|
||||
msgs
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("db error loading messages: {e}");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to provider format
|
||||
let provider_msgs: Vec<ProviderMessage> = messages
|
||||
.iter()
|
||||
.map(|m| ProviderMessage {
|
||||
role: match m.role {
|
||||
ChatRole::User => "user".to_string(),
|
||||
ChatRole::Assistant => "assistant".to_string(),
|
||||
ChatRole::System => "system".to_string(),
|
||||
},
|
||||
content: m.content.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let provider = chat_session.provider.clone();
|
||||
let model = chat_session.model.clone();
|
||||
let session_id = params.session_id.clone();
|
||||
|
||||
// TODO: Load user's API key from preferences for non-Ollama providers.
|
||||
// For now, Ollama (no key needed) is the default path.
|
||||
let api_key: Option<String> = None;
|
||||
|
||||
// Send streaming request to LLM
|
||||
let llm_resp = match send_chat_request(
|
||||
&state,
|
||||
&provider,
|
||||
&model,
|
||||
&provider_msgs,
|
||||
api_key.as_deref(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!("LLM request failed: {e}");
|
||||
return (StatusCode::BAD_GATEWAY, "LLM request failed").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !llm_resp.status().is_success() {
|
||||
let status = llm_resp.status();
|
||||
let body = llm_resp.text().await.unwrap_or_default();
|
||||
tracing::error!("LLM returned {status}: {body}");
|
||||
return (StatusCode::BAD_GATEWAY, format!("LLM error: {status}")).into_response();
|
||||
}
|
||||
|
||||
// Stream the response bytes as SSE events
|
||||
let byte_stream = llm_resp.bytes_stream();
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(byte_stream, state_clone, session_id, provider.clone());
|
||||
|
||||
Sse::new(sse_stream)
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Build an SSE stream that parses OpenAI-compatible streaming chunks
|
||||
/// and emits token events. On completion, persists the full message.
|
||||
fn build_sse_stream(
|
||||
byte_stream: impl Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
|
||||
state: ServerState,
|
||||
session_id: String,
|
||||
_provider: String,
|
||||
) -> impl Stream<Item = Result<Event, std::convert::Infallible>> + Send + 'static {
|
||||
// Use an async stream to process chunks
|
||||
async_stream::stream! {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut full_content = String::new();
|
||||
let mut buffer = String::new();
|
||||
|
||||
// Pin the byte stream for iteration
|
||||
let mut stream = std::pin::pin!(byte_stream);
|
||||
|
||||
while let Some(chunk_result) = StreamExt::next(&mut stream).await {
|
||||
let chunk = match chunk_result {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
let err_json = serde_json::json!({ "error": e.to_string() });
|
||||
yield Ok(Event::default().data(err_json.to_string()));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
buffer.push_str(&text);
|
||||
|
||||
// Process complete SSE lines from the buffer.
|
||||
// OpenAI streaming format: `data: {...}\n\n`
|
||||
while let Some(line_end) = buffer.find('\n') {
|
||||
let line = buffer[..line_end].trim().to_string();
|
||||
buffer = buffer[line_end + 1..].to_string();
|
||||
|
||||
if line.is_empty() || line == "data: [DONE]" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(json_str) = line.strip_prefix("data: ") {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||||
// Extract token from OpenAI delta format
|
||||
if let Some(token) = parsed["choices"][0]["delta"]["content"].as_str() {
|
||||
full_content.push_str(token);
|
||||
let event_data = serde_json::json!({ "token": token });
|
||||
yield Ok(Event::default().data(event_data.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the complete assistant message
|
||||
if !full_content.is_empty() {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let message = ChatMessage {
|
||||
id: String::new(),
|
||||
session_id: session_id.clone(),
|
||||
role: ChatRole::Assistant,
|
||||
content: full_content,
|
||||
attachments: Vec::new(),
|
||||
timestamp: now.clone(),
|
||||
};
|
||||
|
||||
let msg_id = match state.db.chat_messages().insert_one(&message).await {
|
||||
Ok(result) => result
|
||||
.inserted_id
|
||||
.as_object_id()
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::error!("failed to persist assistant message: {e}");
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Update session timestamp
|
||||
if let Ok(session_oid) =
|
||||
mongodb::bson::oid::ObjectId::parse_str(&session_id)
|
||||
{
|
||||
let _ = state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.update_one(
|
||||
mongodb::bson::doc! { "_id": session_oid },
|
||||
mongodb::bson::doc! { "$set": { "updated_at": &now } },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let done_data = serde_json::json!({ "done": true, "message_id": msg_id });
|
||||
yield Ok(Event::default().data(done_data.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
//! 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 })
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
49
src/infrastructure/db.rs
Normal file
49
src/infrastructure/db.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use super::error::Result;
|
||||
use super::user::{KeyCloakSub, UserEntity};
|
||||
use mongodb::{bson::doc, Client, Collection};
|
||||
pub struct Database {
|
||||
client: Client,
|
||||
}
|
||||
impl Database {
|
||||
pub async fn new(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
/// Impl of project related DB actions
|
||||
impl Database {}
|
||||
|
||||
/// Impl of user-related actions
|
||||
impl Database {
|
||||
async fn users_collection(&self) -> Collection<UserEntity> {
|
||||
self.client
|
||||
.database("dashboard")
|
||||
.collection::<UserEntity>("users")
|
||||
}
|
||||
|
||||
pub async fn get_user_by_kc_sub(&self, kc_sub: KeyCloakSub) -> Result<Option<UserEntity>> {
|
||||
let c = self.users_collection().await;
|
||||
let result = c
|
||||
.find_one(doc! {
|
||||
"kc_sub" : kc_sub.0
|
||||
})
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(&self, user_id: &str) -> Result<Option<UserEntity>> {
|
||||
let c = self.users_collection().await;
|
||||
|
||||
let user_id: mongodb::bson::oid::ObjectId = user_id.parse()?;
|
||||
|
||||
let filter = doc! { "_id" : user_id };
|
||||
let result = c.find_one(filter).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn insert_user(&self, user: &UserEntity) -> Result<()> {
|
||||
let c = self.users_collection().await;
|
||||
let _ = c.insert_one(user).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,77 @@
|
||||
use axum::response::IntoResponse;
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
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.
|
||||
use crate::Route;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
StateError(String),
|
||||
NotFound(String),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
DatabaseError(String),
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
#[error("ReqwestError: {0}")]
|
||||
ReqwestError(#[from] reqwest::Error),
|
||||
|
||||
#[error("ServerStateError: {0}")]
|
||||
ServerStateError(String),
|
||||
|
||||
#[error("SessionError: {0}")]
|
||||
SessionError(#[from] tower_sessions::session::Error),
|
||||
|
||||
#[error("AuthSessionLayerNotFound: {0}")]
|
||||
AuthSessionLayerNotFound(String),
|
||||
|
||||
#[error("UserNotLoggedIn")]
|
||||
UserNotLoggedIn,
|
||||
|
||||
#[error("MongoDbError: {0}")]
|
||||
MongoDbError(#[from] mongodb::error::Error),
|
||||
|
||||
#[error("MongoBsonError: {0}")]
|
||||
MongoBsonError(#[from] mongodb::bson::ser::Error),
|
||||
|
||||
#[error("MongoObjectIdParseError: {0}")]
|
||||
MongoObjectIdParseError(#[from] mongodb::bson::oid::Error),
|
||||
|
||||
#[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())
|
||||
}
|
||||
#[error("GeneralError: {0}")]
|
||||
GeneralError(String),
|
||||
|
||||
#[error("SerdeError: {0}")]
|
||||
SerdeError(#[from] serde_json::Error),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let msg = self.to_string();
|
||||
tracing::error!("Converting Error to Response: {msg}");
|
||||
#[tracing::instrument]
|
||||
fn into_response(self) -> Response {
|
||||
let message = self.to_string();
|
||||
tracing::error!("Converting Error to Reponse: {message}");
|
||||
match self {
|
||||
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()
|
||||
Error::NotFound(_) => (StatusCode::NOT_FOUND, message).into_response(),
|
||||
Error::BadRequest(_) => (StatusCode::BAD_REQUEST, message).into_response(),
|
||||
// ideally we would like to redirect with the original URL as the target, but we do not have access to it here
|
||||
Error::UserNotLoggedIn => Redirect::to(
|
||||
&Route::Login {
|
||||
redirect_url: Route::OverviewPage {}.to_string(),
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.into_response(),
|
||||
Error::Forbidden(_) => (StatusCode::FORBIDDEN, message).into_response(),
|
||||
|
||||
// INTERNAL_SERVER_ERROR variants
|
||||
_ => {
|
||||
tracing::error!("Internal Server Error: {:?}", message);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, message).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
mod inner {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single message in the OpenAI-compatible chat format used by Ollama.
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Request body for Ollama's OpenAI-compatible chat completions endpoint.
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct OllamaChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// Disable streaming so we get a single JSON response.
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
/// A single choice in the Ollama chat completions response.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ChatChoice {
|
||||
pub message: ChatResponseMessage,
|
||||
}
|
||||
|
||||
/// The assistant message returned inside a choice.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ChatResponseMessage {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Top-level response from Ollama's `/v1/chat/completions` endpoint.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct OllamaChatResponse {
|
||||
pub choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
/// Fetch the full text content of a webpage by downloading its HTML
|
||||
/// and extracting the main article body, skipping navigation, headers,
|
||||
/// footers, and sidebars.
|
||||
///
|
||||
/// Uses a tiered extraction strategy:
|
||||
/// 1. Try content within `<article>`, `<main>`, or `[role="main"]`
|
||||
/// 2. Fall back to all `<p>` tags outside excluded containers
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - The article URL to fetch
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The extracted text, or `None` if the fetch/parse fails.
|
||||
/// Text is capped at 8000 characters to stay within LLM context limits.
|
||||
pub(super) async fn fetch_article_text(url: &str) -> Option<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("User-Agent", "CERTifAI/1.0 (Article Summarizer)")
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let html = resp.text().await.ok()?;
|
||||
let document = scraper::Html::parse_document(&html);
|
||||
|
||||
// Strategy 1: Extract from semantic article containers.
|
||||
// Most news sites wrap the main content in <article>, <main>,
|
||||
// or an element with role="main".
|
||||
let article_selector = scraper::Selector::parse("article, main, [role='main']").ok()?;
|
||||
let paragraph_sel = scraper::Selector::parse("p, h1, h2, h3, li").ok()?;
|
||||
|
||||
let mut text_parts: Vec<String> = Vec::with_capacity(64);
|
||||
|
||||
for container in document.select(&article_selector) {
|
||||
for element in container.select(¶graph_sel) {
|
||||
collect_text_fragment(element, &mut text_parts);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: If article containers yielded little text, fall back
|
||||
// to all <p> tags that are NOT inside nav/header/footer/aside.
|
||||
if joined_len(&text_parts) < 200 {
|
||||
text_parts.clear();
|
||||
let all_p = scraper::Selector::parse("p").ok()?;
|
||||
|
||||
// Tags whose descendants should be excluded from extraction
|
||||
const EXCLUDED_TAGS: &[&str] = &["nav", "header", "footer", "aside", "script", "style"];
|
||||
|
||||
for element in document.select(&all_p) {
|
||||
// Walk ancestors and skip if inside an excluded container.
|
||||
// Checks tag names directly to avoid ego_tree version issues.
|
||||
let inside_excluded = element.ancestors().any(|ancestor| {
|
||||
ancestor
|
||||
.value()
|
||||
.as_element()
|
||||
.is_some_and(|el| EXCLUDED_TAGS.contains(&el.name.local.as_ref()))
|
||||
});
|
||||
if !inside_excluded {
|
||||
collect_text_fragment(element, &mut text_parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let full_text = text_parts.join("\n\n");
|
||||
if full_text.len() < 100 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Cap at 8000 chars to stay within reasonable LLM context
|
||||
let truncated: String = full_text.chars().take(8000).collect();
|
||||
Some(truncated)
|
||||
}
|
||||
|
||||
/// Extract text from an HTML element and append it to the parts list
|
||||
/// if it meets a minimum length threshold.
|
||||
fn collect_text_fragment(element: scraper::ElementRef<'_>, parts: &mut Vec<String>) {
|
||||
let text: String = element.text().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = text.trim().to_string();
|
||||
// Skip very short fragments (nav items, buttons, etc.)
|
||||
if trimmed.len() >= 30 {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum the total character length of all collected text parts.
|
||||
fn joined_len(parts: &[String]) -> usize {
|
||||
parts.iter().map(|s| s.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarize an article using a local Ollama instance.
|
||||
///
|
||||
/// First attempts to fetch the full article text from the provided URL.
|
||||
/// If that fails (paywall, timeout, etc.), falls back to the search snippet.
|
||||
/// This mirrors how Perplexity fetches and reads source pages before answering.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `snippet` - The search result snippet (fallback content)
|
||||
/// * `article_url` - The original article URL to fetch full text from
|
||||
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
|
||||
/// * `model` - The Ollama model ID to use (e.g. "llama3.1:8b")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A summary string generated by the LLM, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||
#[post("/api/summarize")]
|
||||
pub async fn summarize_article(
|
||||
snippet: String,
|
||||
article_url: String,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||
|
||||
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() {
|
||||
state.services.ollama_url.clone()
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
let model = if model.is_empty() {
|
||||
state.services.ollama_model.clone()
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
// Try to fetch the full article; fall back to the search snippet
|
||||
let article_text = fetch_article_text(&article_url).await.unwrap_or(snippet);
|
||||
|
||||
let request_body = OllamaChatRequest {
|
||||
model,
|
||||
stream: false,
|
||||
messages: vec![ChatMessage {
|
||||
role: "user".into(),
|
||||
content: format!(
|
||||
"You are a news summarizer. Summarize the following article text \
|
||||
in 2-3 concise paragraphs. Focus only on the key points and \
|
||||
implications. Do NOT comment on the source, the date, the URL, \
|
||||
the formatting, or whether the content seems complete or not. \
|
||||
Just summarize whatever content is provided.\n\n\
|
||||
{article_text}"
|
||||
),
|
||||
}],
|
||||
};
|
||||
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Ollama returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: OllamaChatResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
|
||||
|
||||
body.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
||||
}
|
||||
|
||||
/// A lightweight chat message for the follow-up conversation.
|
||||
/// Uses simple String role ("system"/"user"/"assistant") for Ollama compatibility.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FollowUpMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Send a follow-up question about an article using a local Ollama instance.
|
||||
///
|
||||
/// Accepts the full conversation history (system context + prior turns) and
|
||||
/// returns the assistant's next response. The system message should contain
|
||||
/// the article text and summary so the LLM has full context.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `messages` - The conversation history including system context
|
||||
/// * `ollama_url` - Base URL of the Ollama instance
|
||||
/// * `model` - The Ollama model ID to use
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The assistant's response text, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||
#[post("/api/chat")]
|
||||
pub async fn chat_followup(
|
||||
messages: Vec<FollowUpMessage>,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
state.services.ollama_url.clone()
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
let model = if model.is_empty() {
|
||||
state.services.ollama_model.clone()
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
// Convert FollowUpMessage to inner ChatMessage for the request
|
||||
let chat_messages: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.map(|m| ChatMessage {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let request_body = OllamaChatRequest {
|
||||
model,
|
||||
stream: false,
|
||||
messages: chat_messages,
|
||||
};
|
||||
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Ollama returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: OllamaChatResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
|
||||
|
||||
body.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
||||
}
|
||||
302
src/infrastructure/login.rs
Normal file
302
src/infrastructure/login.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use super::error::Result;
|
||||
use super::user::{KeyCloakSub, UserEntity};
|
||||
use crate::Route;
|
||||
use axum::{
|
||||
extract::Query,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
Extension,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use tracing::{info, warn};
|
||||
use url::form_urlencoded;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CallbackCode {
|
||||
code: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
const LOGIN_REDIRECT_URL_SESSION_KEY: &str = "login.redirect.url";
|
||||
const TEST_USER_SUB: KeyCloakSub = KeyCloakSub(String::new());
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LoginRedirectQuery {
|
||||
redirect_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Handler that redirects the user to the login page of Keycloack.
|
||||
#[axum::debug_handler]
|
||||
pub async fn redirect_to_keycloack_login(
|
||||
state: Extension<super::server_state::ServerState>,
|
||||
user_session: super::auth::UserSession,
|
||||
session: tower_sessions::Session,
|
||||
query: Query<LoginRedirectQuery>,
|
||||
) -> Result<Response> {
|
||||
// check if already logged in before redirecting again
|
||||
if user_session.data().is_ok() {
|
||||
return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response());
|
||||
}
|
||||
|
||||
if let Some(url) = &query.redirect_url {
|
||||
if !url.is_empty() {
|
||||
session.insert(LOGIN_REDIRECT_URL_SESSION_KEY, &url).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// if this is a test user then skip login
|
||||
if state.keycloak_variables.enable_test_user {
|
||||
return login_test_user(state, session).await;
|
||||
}
|
||||
|
||||
let kc_base_url = &state.keycloak_variables.base_url;
|
||||
let kc_realm = &state.keycloak_variables.realm;
|
||||
let kc_client_id = &state.keycloak_variables.client_id;
|
||||
let redirect_uri = format!("http://localhost:8000/auth/callback");
|
||||
let encoded_redirect_uri: String =
|
||||
form_urlencoded::byte_serialize(redirect_uri.as_bytes()).collect();
|
||||
|
||||
// Needed for running locally.
|
||||
// This will not panic on production and it will return the original so we can keep it
|
||||
let routed_kc_base_url = kc_base_url.replace("keycloak", "localhost");
|
||||
|
||||
Ok(Redirect::to(
|
||||
format!("{routed_kc_base_url}/realms/{kc_realm}/protocol/openid-connect/auth?client_id={kc_client_id}&response_type=code&scope=openid%20profile%20email&redirect_uri={encoded_redirect_uri}").as_str())
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Helper function that automatically logs the user in as a test user.
|
||||
async fn login_test_user(
|
||||
state: Extension<super::server_state::ServerState>,
|
||||
session: tower_sessions::Session,
|
||||
) -> Result<Response> {
|
||||
let user = state.db.get_user_by_kc_sub(TEST_USER_SUB).await?;
|
||||
|
||||
// if we do not have a test user already, create one
|
||||
let user = if let Some(user) = user {
|
||||
info!("Existing test user logged in");
|
||||
user
|
||||
} else {
|
||||
info!("Test User not found, inserting ...");
|
||||
|
||||
let user = UserEntity {
|
||||
_id: mongodb::bson::oid::ObjectId::new(),
|
||||
created_at: mongodb::bson::DateTime::now(),
|
||||
kc_sub: TEST_USER_SUB,
|
||||
email: "exampleuser@domain.com".to_string(),
|
||||
};
|
||||
|
||||
state.db.insert_user(&user).await?;
|
||||
user
|
||||
};
|
||||
|
||||
info!("Test User successfuly logged in: {:?}", user);
|
||||
|
||||
let data = super::auth::LoggedInData {
|
||||
id: user._id.to_string(),
|
||||
token_id: String::new(),
|
||||
username: "tester".to_string(),
|
||||
avatar_url: None,
|
||||
};
|
||||
super::auth::login(&session, &data).await?;
|
||||
|
||||
// redirect to the URL stored in the session if available
|
||||
let redirect_url = session
|
||||
.remove::<String>(LOGIN_REDIRECT_URL_SESSION_KEY)
|
||||
.await?
|
||||
.unwrap_or_else(|| Route::OverviewPage {}.to_string());
|
||||
|
||||
Ok(Redirect::to(&redirect_url).into_response())
|
||||
}
|
||||
|
||||
/// Handler function executed once KC redirects back to us. Creates database entries if
|
||||
/// needed and initializes the user session to mark the user as "logged in".
|
||||
#[axum::debug_handler]
|
||||
pub async fn handle_login_callback(
|
||||
state: Extension<super::server_state::ServerState>,
|
||||
session: tower_sessions::Session,
|
||||
Query(params): Query<CallbackCode>,
|
||||
) -> Result<Response> {
|
||||
// now make sure the user actually authorized the app and that there was no error
|
||||
let Some(code) = params.code else {
|
||||
warn!("Code was not provided, error: {:?}", params.error);
|
||||
return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response());
|
||||
};
|
||||
|
||||
// if on dev environment we get the internal kc url
|
||||
let kc_base_url = std::env::var("KEYCLOAK_ADMIN_URL")
|
||||
.unwrap_or_else(|_| state.keycloak_variables.base_url.clone());
|
||||
let kc_realm = &state.keycloak_variables.realm;
|
||||
let kc_client_id = &state.keycloak_variables.client_id;
|
||||
let kc_client_secret = &state.keycloak_variables.client_secret;
|
||||
let redirect_uri = format!("http://localhost:8000/auth/callback");
|
||||
|
||||
// exchange the code for an access token
|
||||
let token = exchange_code(
|
||||
&code,
|
||||
&kc_base_url,
|
||||
kc_realm,
|
||||
kc_client_id,
|
||||
kc_client_secret,
|
||||
redirect_uri.as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// use the access token to get the user information
|
||||
let user_info = get_user_info(&token, &kc_base_url, kc_realm).await?;
|
||||
|
||||
// Check if the user is a member of the organization (only on dev and demo environments)
|
||||
let base_url = state.keycloak_variables.base_url.clone();
|
||||
let is_for_devs = base_url.contains("dev") || base_url.contains("demo");
|
||||
if is_for_devs {
|
||||
let Some(github_login) = user_info.github_login.as_ref() else {
|
||||
return Err(crate::infrastructure::error::Error::Forbidden(
|
||||
"GitHub login not available.".to_string(),
|
||||
));
|
||||
};
|
||||
if !is_org_member(github_login).await? {
|
||||
return Err(crate::infrastructure::error::Error::Forbidden(
|
||||
"You are not a member of the organization.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// now check if we have a user already
|
||||
let kc_sub = KeyCloakSub(user_info.sub);
|
||||
|
||||
let user = state.db.get_user_by_kc_sub(kc_sub.clone()).await?;
|
||||
|
||||
// if we do not have a user already, create one
|
||||
let user = if let Some(user) = user {
|
||||
info!("Existing user logged in");
|
||||
user
|
||||
} else {
|
||||
info!("User not found, creating ...");
|
||||
|
||||
let user = UserEntity {
|
||||
_id: mongodb::bson::oid::ObjectId::new(),
|
||||
created_at: mongodb::bson::DateTime::now(),
|
||||
kc_sub,
|
||||
email: user_info.email.clone(),
|
||||
};
|
||||
|
||||
state.db.insert_user(&user).await?;
|
||||
user
|
||||
};
|
||||
|
||||
info!("User successfuly logged in");
|
||||
|
||||
// we now have access token and information about the user that just logged in, as well as an
|
||||
// existing or newly created user database entity.
|
||||
// Store information in session storage that we want (eg name and avatar url + databae id) to make the user "logged in"!
|
||||
// Redirect the user somewhere
|
||||
let data = super::auth::LoggedInData {
|
||||
id: user._id.to_string(),
|
||||
token_id: token.id_token,
|
||||
username: user_info.preferred_username,
|
||||
avatar_url: user_info.picture,
|
||||
};
|
||||
super::auth::login(&session, &data).await?;
|
||||
|
||||
// redirect to the URL stored in the session if available
|
||||
let redirect_url = session
|
||||
.remove::<String>(LOGIN_REDIRECT_URL_SESSION_KEY)
|
||||
.await?
|
||||
.unwrap_or_else(|| Route::OverviewPage {}.to_string());
|
||||
|
||||
Ok(Redirect::to(&redirect_url).into_response())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[allow(dead_code)] // not all fields are currently used
|
||||
struct AccessToken {
|
||||
access_token: String,
|
||||
expires_in: u64,
|
||||
refresh_token: String,
|
||||
refresh_expires_in: u64,
|
||||
id_token: String,
|
||||
}
|
||||
|
||||
/// Exchange KC code for an access token
|
||||
async fn exchange_code(
|
||||
code: &str,
|
||||
kc_base_url: &str,
|
||||
kc_realm: &str,
|
||||
kc_client_id: &str,
|
||||
kc_client_secret: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<AccessToken> {
|
||||
let res = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/token",
|
||||
))
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", kc_client_id),
|
||||
("client_secret", kc_client_secret),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let res: AccessToken = res.json().await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Query the openid-connect endpoint to get the user info by using the access token.
|
||||
async fn get_user_info(token: &AccessToken, kc_base_url: &str, kc_realm: &str) -> Result<UserInfo> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/userinfo");
|
||||
|
||||
let mut request = client.get(&url).bearer_auth(token.access_token.clone());
|
||||
|
||||
// If KEYCLOAK_ADMIN_URL is NOT set (i.e. we're on the local Keycloak),
|
||||
// add the HOST header for local testing.
|
||||
if std::env::var("KEYCLOAK_ADMIN_URL").is_err() {
|
||||
request = request.header("HOST", "localhost:8888");
|
||||
}
|
||||
|
||||
let res = request.send().await?;
|
||||
let res: UserInfo = res.json().await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Contains selected fields from the user information call to KC
|
||||
/// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
#[derive(serde::Deserialize)]
|
||||
#[allow(dead_code)] // not all fields are currently used
|
||||
struct UserInfo {
|
||||
sub: String, // subject element of the ID Token
|
||||
name: String,
|
||||
given_name: String,
|
||||
family_name: String,
|
||||
preferred_username: String,
|
||||
email: String,
|
||||
picture: Option<String>,
|
||||
github_login: Option<String>,
|
||||
}
|
||||
|
||||
/// Check if a user is a member of the organization
|
||||
const GITHUB_ORG: &str = "etospheres-labs";
|
||||
async fn is_org_member(username: &str) -> Result<bool> {
|
||||
let url = format!("https://api.github.com/orgs/{GITHUB_ORG}/members/{username}");
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json") // GitHub requires a User-Agent header.
|
||||
.header("User-Agent", "etopay-app")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
match response.status() {
|
||||
StatusCode::NO_CONTENT => Ok(true),
|
||||
status => {
|
||||
tracing::warn!(
|
||||
"{}: User '{}' is not a member of the organization",
|
||||
status.as_str(),
|
||||
username
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,10 @@
|
||||
// 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;
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
// 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")]
|
||||
mod login;
|
||||
|
||||
pub mod auth;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod 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::*;
|
||||
pub mod user;
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Status of a local Ollama instance, including connectivity and loaded models.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `online` - Whether the Ollama API responded successfully
|
||||
/// * `models` - List of model names currently available on the instance
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OllamaStatus {
|
||||
pub online: bool,
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Response from Ollama's `GET /api/tags` endpoint.
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaTagsResponse {
|
||||
models: Vec<OllamaModel>,
|
||||
}
|
||||
|
||||
/// A single model entry from Ollama's tags API.
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaModel {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Check the status of a local Ollama instance by querying its tags endpoint.
|
||||
///
|
||||
/// Calls `GET <ollama_url>/api/tags` to list available models and determine
|
||||
/// whether the instance is reachable.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An `OllamaStatus` with `online: true` and model names if reachable,
|
||||
/// or `online: false` with an empty model list on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` only on serialization issues; network failures
|
||||
/// are caught and returned as `online: false`
|
||||
#[post("/api/ollama-status")]
|
||||
pub async fn get_ollama_status(ollama_url: String) -> Result<OllamaStatus, ServerFnError> {
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
state.services.ollama_url.clone()
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
||||
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => {
|
||||
return Ok(OllamaStatus {
|
||||
online: false,
|
||||
models: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let body: OllamaTagsResponse = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return Ok(OllamaStatus {
|
||||
online: true,
|
||||
models: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let models = body.models.into_iter().map(|m| m.name).collect();
|
||||
|
||||
Ok(OllamaStatus {
|
||||
online: true,
|
||||
models,
|
||||
})
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
use crate::models::NewsCard;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
// Server-side helpers and types are only needed for the server build.
|
||||
// The #[server] macro generates a client stub for the web build that
|
||||
// sends a network request instead of executing this function body.
|
||||
#[cfg(feature = "server")]
|
||||
mod inner {
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Individual result from the SearXNG search API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SearxngResult {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub content: Option<String>,
|
||||
#[serde(rename = "publishedDate")]
|
||||
pub published_date: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
/// Relevance score assigned by SearXNG (higher = more relevant).
|
||||
#[serde(default)]
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
/// Top-level response from the SearXNG search API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SearxngResponse {
|
||||
pub results: Vec<SearxngResult>,
|
||||
}
|
||||
|
||||
/// Extract the domain name from a URL to use as the source label.
|
||||
///
|
||||
/// Strips common prefixes like "www." for cleaner display.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url_str` - The full URL string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The domain host or a fallback "Web" string
|
||||
pub(super) fn extract_source(url_str: &str) -> String {
|
||||
url::Url::parse(url_str)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(String::from))
|
||||
.map(|host| host.strip_prefix("www.").unwrap_or(&host).to_string())
|
||||
.unwrap_or_else(|| "Web".into())
|
||||
}
|
||||
|
||||
/// Deduplicate and rank search results for quality, similar to Perplexity.
|
||||
///
|
||||
/// Applies the following filters in order:
|
||||
/// 1. Remove results with empty content (no snippet = low value)
|
||||
/// 2. Deduplicate by domain (keep highest-scored result per domain)
|
||||
/// 3. Sort by SearXNG relevance score (descending)
|
||||
/// 4. Cap at `max_results`
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `results` - Raw search results from SearXNG
|
||||
/// * `max_results` - Maximum number of results to return
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Filtered, deduplicated, and ranked results
|
||||
pub(super) fn rank_and_deduplicate(
|
||||
mut results: Vec<SearxngResult>,
|
||||
max_results: usize,
|
||||
) -> Vec<SearxngResult> {
|
||||
// Filter out results with no meaningful content
|
||||
results.retain(|r| r.content.as_ref().is_some_and(|c| c.trim().len() >= 20));
|
||||
|
||||
// Sort by score descending so we keep the best result per domain
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// Deduplicate by domain: keep only the first (highest-scored) per domain
|
||||
let mut seen_domains = HashSet::new();
|
||||
results.retain(|r| {
|
||||
let domain = extract_source(&r.url);
|
||||
seen_domains.insert(domain)
|
||||
});
|
||||
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for news using the SearXNG meta-search engine.
|
||||
///
|
||||
/// Uses Perplexity-style query enrichment and result ranking:
|
||||
/// - Queries the "news" and "general" categories for fresh, relevant results
|
||||
/// - Filters to the last month for recency
|
||||
/// - Deduplicates by domain for source diversity
|
||||
/// - Ranks by SearXNG relevance score
|
||||
/// - Filters out results without meaningful content
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `query` - The search query string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Up to 15 high-quality `NewsCard` results, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the SearXNG request fails or response parsing fails
|
||||
#[post("/api/search")]
|
||||
pub async fn search_topic(query: String) -> Result<Vec<NewsCard>, ServerFnError> {
|
||||
use inner::{extract_source, rank_and_deduplicate, SearxngResponse};
|
||||
|
||||
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");
|
||||
|
||||
// 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
|
||||
.post(&search_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("SearXNG request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(ServerFnError::new(format!(
|
||||
"SearXNG returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: SearxngResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse SearXNG response: {e}")))?;
|
||||
|
||||
// Apply Perplexity-style ranking: filter empties, deduplicate domains, sort by score
|
||||
let ranked = rank_and_deduplicate(body.results, 15);
|
||||
|
||||
let cards: Vec<NewsCard> = ranked
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let summary = r
|
||||
.content
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect::<String>();
|
||||
let content = r.content.unwrap_or_default();
|
||||
NewsCard {
|
||||
title: r.title,
|
||||
source: extract_source(&r.url),
|
||||
summary,
|
||||
content,
|
||||
category: query.clone(),
|
||||
url: r.url,
|
||||
thumbnail_url: r.thumbnail,
|
||||
published_at: r.published_date.unwrap_or_else(|| "Recent".into()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(cards)
|
||||
}
|
||||
|
||||
/// Fetch trending topic keywords by running a broad news search and
|
||||
/// extracting the most frequent meaningful terms from result titles.
|
||||
///
|
||||
/// This approach works regardless of whether SearXNG has autocomplete
|
||||
/// configured, since it uses the standard search API.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Up to 8 trending keyword strings, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the SearXNG search request fails
|
||||
#[get("/api/trending")]
|
||||
pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
|
||||
use inner::SearxngResponse;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let searxng_url = state.services.searxng_url.clone();
|
||||
|
||||
// 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))
|
||||
.build()
|
||||
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
||||
|
||||
let resp = client
|
||||
.post(&search_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("SearXNG trending search failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(ServerFnError::new(format!(
|
||||
"SearXNG trending search returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: SearxngResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse trending response: {e}")))?;
|
||||
|
||||
// Common stop words to exclude from trending keywords
|
||||
const STOP_WORDS: &[&str] = &[
|
||||
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
|
||||
"from", "is", "are", "was", "were", "be", "been", "has", "have", "had", "do", "does",
|
||||
"did", "will", "would", "could", "should", "may", "can", "not", "no", "it", "its", "this",
|
||||
"that", "these", "how", "what", "why", "who", "when", "new", "says", "said", "about",
|
||||
"after", "over", "into", "up", "out", "as", "all", "more", "than", "just", "now", "also",
|
||||
"us", "we", "you", "your", "our", "if", "so", "like", "get", "make", "year", "years",
|
||||
"one", "two",
|
||||
];
|
||||
|
||||
// Count word frequency across all result titles. Words are lowercased
|
||||
// and must be at least 3 characters to filter out noise.
|
||||
let mut word_counts: HashMap<String, u32> = HashMap::new();
|
||||
for result in &body.results {
|
||||
for word in result.title.split_whitespace() {
|
||||
// Strip punctuation from edges, lowercase
|
||||
let clean: String = word
|
||||
.trim_matches(|c: char| !c.is_alphanumeric())
|
||||
.to_lowercase();
|
||||
if clean.len() >= 3 && !STOP_WORDS.contains(&clean.as_str()) {
|
||||
*word_counts.entry(clean).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frequency descending, take top 8
|
||||
let mut sorted: Vec<(String, u32)> = word_counts.into_iter().collect();
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
// Capitalize first letter for display
|
||||
let topics: Vec<String> = sorted
|
||||
.into_iter()
|
||||
.filter(|(_, count)| *count >= 2)
|
||||
.take(8)
|
||||
.map(|(word, _)| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||
None => word,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(topics)
|
||||
}
|
||||
@@ -1,97 +1,105 @@
|
||||
use super::error::Error;
|
||||
use super::server_state::ServerState;
|
||||
use crate::infrastructure::{auth::KeycloakVariables, server_state::ServerStateInner};
|
||||
|
||||
use axum::{routing::*, Extension};
|
||||
use dioxus::dioxus_core::Element;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::{middleware, Extension};
|
||||
use dioxus_logger::tracing::info;
|
||||
use reqwest::{
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
Method,
|
||||
};
|
||||
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,
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_sessions::{
|
||||
cookie::{Key, SameSite},
|
||||
Expiry, MemoryStore, SessionManagerLayer,
|
||||
};
|
||||
|
||||
/// Start the Axum server with Dioxus fullstack, session management,
|
||||
/// 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, config loading, DB connection,
|
||||
/// or TCP listener fails.
|
||||
pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> {
|
||||
pub fn server_start(app_fn: fn() -> Element) -> Result<(), Error> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tokio::runtime::Runtime::new()?.block_on(async move {
|
||||
// Load .env once at startup.
|
||||
dotenvy::dotenv().ok();
|
||||
info!("Connecting to the database ...");
|
||||
|
||||
// ---- 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()?));
|
||||
let mongodb_uri = get_env_variable("MONGODB_URI");
|
||||
let client = mongodb::Client::with_uri_str(mongodb_uri).await?;
|
||||
|
||||
tracing::info!("Configuration loaded");
|
||||
let db = super::db::Database::new(client).await;
|
||||
info!("Connected");
|
||||
|
||||
// ---- 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 keycloak_variables: KeycloakVariables = KeycloakVariables {
|
||||
base_url: get_env_variable("BASE_URL_AUTH"),
|
||||
realm: get_env_variable("KC_REALM"),
|
||||
client_id: get_env_variable("KC_CLIENT_ID"),
|
||||
client_secret: get_env_variable("KC_CLIENT_SECRET"),
|
||||
enable_test_user: std::env::var("ENABLE_TEST_USER").is_ok_and(|v| v == "yes"),
|
||||
};
|
||||
|
||||
let db = Database::connect(&mongo_uri, &mongo_db).await?;
|
||||
tracing::info!("Connected to MongoDB (database: {mongo_db})");
|
||||
|
||||
// ---- Build ServerState ----
|
||||
let server_state: ServerState = ServerStateInner {
|
||||
let state: ServerState = ServerStateInner {
|
||||
db,
|
||||
keycloak,
|
||||
smtp,
|
||||
services,
|
||||
stripe,
|
||||
llm_providers,
|
||||
keycloak_variables: Box::leak(Box::new(keycloak_variables)),
|
||||
}
|
||||
.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).
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24)))
|
||||
// This uses `tower-sessions` to establish a layer that will provide the session
|
||||
// as a request extension.
|
||||
let key = Key::generate(); // This is only used for demonstration purposes; provide a proper
|
||||
// cryptographic key in a real application.
|
||||
let session_store = MemoryStore::default();
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
// only allow session cookie in HTTPS connections (also works on localhost)
|
||||
.with_secure(true)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::days(1)))
|
||||
// Allow the session cookie to be sent when request originates from outside our
|
||||
// domain. Required for the browser to pass the cookie when returning from github auth page.
|
||||
.with_same_site(SameSite::Lax)
|
||||
.with_signed(key);
|
||||
|
||||
// ---- Build router ----
|
||||
let cors = CorsLayer::new()
|
||||
// allow `GET` and `POST` when accessing the resource
|
||||
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
|
||||
// .allow_credentials(true)
|
||||
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE])
|
||||
// allow requests from any origin
|
||||
.allow_origin(Any);
|
||||
|
||||
// Build our application web api router.
|
||||
let web_api_router = Router::new()
|
||||
// .route("/webhook/gitlab", post(super::gitlab::webhook_handler))
|
||||
.route("/auth", get(super::login::redirect_to_keycloack_login))
|
||||
.route("/auth/logout", get(super::auth::logout))
|
||||
.route("/auth/callback", get(super::login::handle_login_callback))
|
||||
// Server side render the application, serve static assets, and register the server functions.
|
||||
.serve_dioxus_application(ServeConfig::default(), app_fn)
|
||||
.layer(Extension(state))
|
||||
.layer(session_layer)
|
||||
.layer(cors)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http());
|
||||
|
||||
// Start it.
|
||||
let addr = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
// 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(server_state))
|
||||
.layer(middleware::from_fn(require_auth))
|
||||
.layer(session);
|
||||
|
||||
tracing::info!("Serving at {addr}");
|
||||
axum::serve(listener, router.into_make_service()).await?;
|
||||
info!("Server address: {}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
axum::serve(listener, web_api_router.into_make_service()).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Tries to load the value from an environment as String.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the environment variable key to try to load
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the environment variable does not exist.
|
||||
fn get_env_variable(key: &str) -> String {
|
||||
std::env::var(key).unwrap_or_else(|_| {
|
||||
tracing::error!("{key} environment variable not set. {key} must be set!");
|
||||
panic!("Environment variable {key} not present")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,74 +1,55 @@
|
||||
//! 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?;
|
||||
//! ```
|
||||
//! Implements a [`ServerState`] that is available in the dioxus server functions
|
||||
//! as well as in axum handlers.
|
||||
//! Taken from https://github.com/dxps/dioxus_playground/tree/44a4ddb223e6afe50ef195e61aa2b7182762c7da/dioxus-05-fullstack-routing-axum-pgdb
|
||||
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
use super::auth::KeycloakVariables;
|
||||
use super::error::{Error, Result};
|
||||
|
||||
use super::{
|
||||
config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig},
|
||||
database::Database,
|
||||
Error,
|
||||
};
|
||||
use axum::http;
|
||||
|
||||
/// 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.
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// This is stored as an "extension" object in the axum webserver
|
||||
/// We can get it in the dioxus server functions using
|
||||
/// ```rust
|
||||
/// let state: crate::infrastructure::server_state::ServerState = extract().await?;
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct ServerState(Arc<ServerStateInner>);
|
||||
|
||||
impl Deref for ServerState {
|
||||
type Target = ServerStateInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ServerStateInner {
|
||||
pub db: crate::infrastructure::db::Database,
|
||||
pub keycloak_variables: &'static KeycloakVariables,
|
||||
}
|
||||
|
||||
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,
|
||||
S: std::marker::Sync + std::marker::Send,
|
||||
{
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
async fn from_request_parts(parts: &mut http::request::Parts, _: &S) -> Result<Self> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<ServerState>()
|
||||
.cloned()
|
||||
.ok_or(Error::StateError("ServerState extension not found".into()))
|
||||
.ok_or(Error::ServerStateError(
|
||||
"ServerState extension should exist".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cheap-to-clone handle to per-session user data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserState(Arc<UserStateInner>);
|
||||
|
||||
impl Deref for UserState {
|
||||
type Target = UserStateInner;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserStateInner> for UserState {
|
||||
fn from(value: UserStateInner) -> Self {
|
||||
Self(Arc::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 identifier from Keycloak (unique user ID).
|
||||
pub sub: String,
|
||||
/// OAuth2 access token.
|
||||
pub access_token: String,
|
||||
/// OAuth2 refresh token.
|
||||
pub refresh_token: String,
|
||||
/// Basic user profile.
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
/// Basic user profile stored alongside the session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct User {
|
||||
/// Email address.
|
||||
pub email: String,
|
||||
/// Display name (preferred_username or full name from Keycloak).
|
||||
pub name: String,
|
||||
/// Avatar / profile picture URL.
|
||||
pub avatar_url: String,
|
||||
}
|
||||
21
src/infrastructure/user.rs
Normal file
21
src/infrastructure/user.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Wraps a `String` to store the sub from KC
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyCloakSub(pub String);
|
||||
|
||||
/// database entity to store our users
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserEntity {
|
||||
/// Our unique id of the user, for now this is just the mongodb assigned id
|
||||
pub _id: mongodb::bson::oid::ObjectId,
|
||||
|
||||
/// Time the user was created
|
||||
pub created_at: mongodb::bson::DateTime,
|
||||
|
||||
/// KC subject element of the ID Token
|
||||
pub kc_sub: KeyCloakSub,
|
||||
|
||||
/// User email as provided during signup with the identity provider
|
||||
pub email: String,
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
mod app;
|
||||
mod components;
|
||||
pub mod infrastructure;
|
||||
mod models;
|
||||
mod pages;
|
||||
|
||||
pub use app::*;
|
||||
pub use components::*;
|
||||
|
||||
pub use models::*;
|
||||
pub use pages::*;
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The role of a participant in a chat conversation.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ChatRole {
|
||||
/// Message sent by the human user
|
||||
User,
|
||||
/// Message generated by the AI assistant
|
||||
Assistant,
|
||||
/// System-level instruction (not displayed in UI)
|
||||
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 {
|
||||
/// Image file (png, jpg, webp, etc.)
|
||||
Image,
|
||||
/// Document file (pdf, docx, txt, etc.)
|
||||
Document,
|
||||
/// Source code file
|
||||
Code,
|
||||
}
|
||||
|
||||
/// A file attachment on a chat message.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `name` - Original filename
|
||||
/// * `kind` - Type of attachment for rendering
|
||||
/// * `size_bytes` - File size in bytes
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Attachment {
|
||||
pub name: String,
|
||||
pub kind: AttachmentKind,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
/// 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` - 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` - 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,
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An AI agent entry managed through the developer tools.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique agent identifier
|
||||
/// * `name` - Human-readable agent name
|
||||
/// * `description` - What this agent does
|
||||
/// * `status` - Current running status label
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// A workflow/flow entry from the flow builder.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique flow identifier
|
||||
/// * `name` - Human-readable flow name
|
||||
/// * `node_count` - Number of nodes in the flow graph
|
||||
/// * `last_run` - ISO 8601 timestamp of the last execution
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FlowEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub node_count: u32,
|
||||
pub last_run: Option<String>,
|
||||
}
|
||||
|
||||
/// A single analytics metric for the developer dashboard.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `label` - Display name of the metric
|
||||
/// * `value` - Current value as a formatted string
|
||||
/// * `change_pct` - Percentage change from previous period (positive = increase)
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnalyticsMetric {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub change_pct: f64,
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The type of file stored in the knowledge base.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum FileKind {
|
||||
/// PDF document
|
||||
Pdf,
|
||||
/// Plain text or markdown file
|
||||
Text,
|
||||
/// Spreadsheet (csv, xlsx)
|
||||
Spreadsheet,
|
||||
/// Source code file
|
||||
Code,
|
||||
/// Image file
|
||||
Image,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
/// Returns the display label for a file kind.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pdf => "PDF",
|
||||
Self::Text => "Text",
|
||||
Self::Spreadsheet => "Spreadsheet",
|
||||
Self::Code => "Code",
|
||||
Self::Image => "Image",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an icon identifier for rendering.
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pdf => "file-pdf",
|
||||
Self::Text => "file-text",
|
||||
Self::Spreadsheet => "file-spreadsheet",
|
||||
Self::Code => "file-code",
|
||||
Self::Image => "file-image",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file stored in the knowledge base for RAG retrieval.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique file identifier
|
||||
/// * `name` - Original filename
|
||||
/// * `kind` - Type classification of the file
|
||||
/// * `size_bytes` - File size in bytes
|
||||
/// * `uploaded_at` - ISO 8601 upload timestamp
|
||||
/// * `chunk_count` - Number of vector chunks created from this file
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KnowledgeFile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: FileKind,
|
||||
pub size_bytes: u64,
|
||||
pub uploaded_at: String,
|
||||
pub chunk_count: u32,
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
mod chat;
|
||||
mod developer;
|
||||
mod knowledge;
|
||||
mod news;
|
||||
mod organization;
|
||||
mod provider;
|
||||
mod tool;
|
||||
mod user;
|
||||
|
||||
pub use chat::*;
|
||||
pub use developer::*;
|
||||
pub use knowledge::*;
|
||||
pub use news::*;
|
||||
pub use organization::*;
|
||||
pub use provider::*;
|
||||
pub use tool::*;
|
||||
pub use user::*;
|
||||
@@ -1,25 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single news feed card representing an AI-related article.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `title` - Headline of the article
|
||||
/// * `source` - Publishing outlet or author
|
||||
/// * `summary` - Brief summary text
|
||||
/// * `content` - Full content snippet from search results
|
||||
/// * `category` - Display label for the search topic (e.g. "AI", "Finance")
|
||||
/// * `url` - Link to the full article
|
||||
/// * `thumbnail_url` - Optional thumbnail image URL
|
||||
/// * `published_at` - ISO 8601 date string
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NewsCard {
|
||||
pub title: String,
|
||||
pub source: String,
|
||||
pub summary: String,
|
||||
pub content: String,
|
||||
pub category: String,
|
||||
pub url: String,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub published_at: String,
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Role assigned to an organization member.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum MemberRole {
|
||||
/// Full administrative access
|
||||
Admin,
|
||||
/// Standard user access
|
||||
Member,
|
||||
/// Read-only access
|
||||
Viewer,
|
||||
}
|
||||
|
||||
impl MemberRole {
|
||||
/// Returns the display label for a member role.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Admin => "Admin",
|
||||
Self::Member => "Member",
|
||||
Self::Viewer => "Viewer",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all available roles for populating dropdowns.
|
||||
pub fn all() -> &'static [Self] {
|
||||
&[Self::Admin, Self::Member, Self::Viewer]
|
||||
}
|
||||
}
|
||||
|
||||
/// A member of the organization.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique member identifier
|
||||
/// * `name` - Display name
|
||||
/// * `email` - Email address
|
||||
/// * `role` - Assigned role within the organization
|
||||
/// * `joined_at` - ISO 8601 join date
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OrgMember {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub role: MemberRole,
|
||||
pub joined_at: String,
|
||||
}
|
||||
|
||||
/// A pricing plan tier.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique plan identifier
|
||||
/// * `name` - Plan display name (e.g. "Starter", "Team", "Enterprise")
|
||||
/// * `price_eur` - Monthly price in euros
|
||||
/// * `features` - List of included features
|
||||
/// * `highlighted` - Whether this plan should be visually emphasized
|
||||
/// * `max_seats` - Maximum number of seats, None for unlimited
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PricingPlan {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub price_eur: u32,
|
||||
pub features: Vec<String>,
|
||||
pub highlighted: bool,
|
||||
pub max_seats: Option<u32>,
|
||||
}
|
||||
|
||||
/// Billing usage statistics for the current cycle.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `seats_used` - Number of active seats
|
||||
/// * `seats_total` - Total seats in the plan
|
||||
/// * `tokens_used` - Tokens consumed this billing cycle
|
||||
/// * `tokens_limit` - Token limit for the billing cycle
|
||||
/// * `billing_cycle_end` - ISO 8601 date when the current cycle ends
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BillingUsage {
|
||||
pub seats_used: u32,
|
||||
pub seats_total: u32,
|
||||
pub tokens_used: u64,
|
||||
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,74 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Supported LLM provider backends.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum LlmProvider {
|
||||
/// Self-hosted models via Ollama
|
||||
Ollama,
|
||||
/// Hugging Face Inference API
|
||||
HuggingFace,
|
||||
/// OpenAI-compatible endpoints
|
||||
OpenAi,
|
||||
/// Anthropic Claude API
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl LlmProvider {
|
||||
/// Returns the display name for a provider.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ollama => "Ollama",
|
||||
Self::HuggingFace => "Hugging Face",
|
||||
Self::OpenAi => "OpenAI",
|
||||
Self::Anthropic => "Anthropic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A model available from a provider.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique model identifier (e.g. "llama3.1:8b")
|
||||
/// * `name` - Human-readable display name
|
||||
/// * `provider` - Which provider hosts this model
|
||||
/// * `context_window` - Maximum context length in tokens
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub provider: LlmProvider,
|
||||
pub context_window: u32,
|
||||
}
|
||||
|
||||
/// An embedding model available from a provider.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique embedding model identifier
|
||||
/// * `name` - Human-readable display name
|
||||
/// * `provider` - Which provider hosts this model
|
||||
/// * `dimensions` - Output embedding dimensions
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddingEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub provider: LlmProvider,
|
||||
pub dimensions: u32,
|
||||
}
|
||||
|
||||
/// Active provider configuration state.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `provider` - Currently selected provider
|
||||
/// * `selected_model` - ID of the active chat model
|
||||
/// * `selected_embedding` - ID of the active embedding model
|
||||
/// * `api_key_set` - Whether an API key has been configured
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub provider: LlmProvider,
|
||||
pub selected_model: String,
|
||||
pub selected_embedding: String,
|
||||
pub api_key_set: bool,
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Category grouping for MCP tools.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ToolCategory {
|
||||
/// Web search and browsing tools
|
||||
Search,
|
||||
/// File and document processing tools
|
||||
FileSystem,
|
||||
/// Computation and math tools
|
||||
Compute,
|
||||
/// Code execution and analysis tools
|
||||
Code,
|
||||
/// Communication and notification tools
|
||||
Communication,
|
||||
}
|
||||
|
||||
impl ToolCategory {
|
||||
/// Returns the display label for a tool category.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Search => "Search",
|
||||
Self::FileSystem => "File System",
|
||||
Self::Compute => "Compute",
|
||||
Self::Code => "Code",
|
||||
Self::Communication => "Communication",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of an MCP tool instance.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ToolStatus {
|
||||
/// Tool is running and available
|
||||
Active,
|
||||
/// Tool is installed but not running
|
||||
Inactive,
|
||||
/// Tool encountered an error
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ToolStatus {
|
||||
/// Returns the CSS class suffix for status styling.
|
||||
pub fn css_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Inactive => "inactive",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An MCP (Model Context Protocol) tool entry.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique tool identifier
|
||||
/// * `name` - Human-readable display name
|
||||
/// * `description` - Brief description of what the tool does
|
||||
/// * `category` - Classification category
|
||||
/// * `status` - Current running status
|
||||
/// * `enabled` - Whether the tool is toggled on by the user
|
||||
/// * `icon` - Icon identifier for rendering
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct McpTool {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: ToolCategory,
|
||||
pub status: ToolStatus,
|
||||
pub enabled: bool,
|
||||
pub icon: String,
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Basic user display data used by frontend components.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct UserData {
|
||||
pub name: 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,
|
||||
}
|
||||
|
||||
/// 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,336 +0,0 @@
|
||||
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::*;
|
||||
|
||||
/// LibreChat-inspired chat interface with MongoDB persistence and SSE streaming.
|
||||
///
|
||||
/// Layout: sidebar (session list) | main panel (model selector, messages, input).
|
||||
/// Messages stream via `EventSource` connected to `/api/chat/stream`.
|
||||
#[component]
|
||||
pub fn ChatPage() -> Element {
|
||||
// ---- Signals ----
|
||||
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut messages: Signal<Vec<ChatMessage>> = use_signal(Vec::new);
|
||||
let mut input_text: Signal<String> = use_signal(String::new);
|
||||
let mut is_streaming: Signal<bool> = use_signal(|| false);
|
||||
let mut streaming_content: Signal<String> = use_signal(String::new);
|
||||
let mut selected_model: Signal<String> = use_signal(String::new);
|
||||
|
||||
// ---- Resources ----
|
||||
// Load sessions list (re-fetches when dependency changes)
|
||||
let mut sessions_resource =
|
||||
use_resource(move || async move { list_chat_sessions().await.unwrap_or_default() });
|
||||
|
||||
// Load available Ollama models
|
||||
let models_resource = use_resource(move || async move {
|
||||
get_ollama_status(String::new())
|
||||
.await
|
||||
.map(|s| s.models)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let sessions = sessions_resource.read().clone().unwrap_or_default();
|
||||
|
||||
let available_models = models_resource.read().clone().unwrap_or_default();
|
||||
|
||||
// Set default model if not yet chosen
|
||||
if selected_model.read().is_empty() {
|
||||
if let Some(first) = available_models.first() {
|
||||
selected_model.set(first.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Load messages when active session changes.
|
||||
// The signal read MUST happen inside the closure so use_resource
|
||||
// tracks it as a dependency and re-fetches on change.
|
||||
let _messages_loader = use_resource(move || {
|
||||
let session_id = active_session_id.read().clone();
|
||||
async move {
|
||||
if let Some(id) = session_id {
|
||||
match list_chat_messages(id).await {
|
||||
Ok(msgs) => messages.set(msgs),
|
||||
Err(e) => tracing::error!("failed to load messages: {e}"),
|
||||
}
|
||||
} else {
|
||||
messages.set(Vec::new());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Callbacks ----
|
||||
// Create new session
|
||||
let on_new = move |_: ()| {
|
||||
let model = selected_model.read().clone();
|
||||
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",
|
||||
ChatModelSelector {
|
||||
selected_model: selected_model.read().clone(),
|
||||
available_models: available_models,
|
||||
on_change: on_model_change,
|
||||
}
|
||||
ChatMessageList {
|
||||
messages: messages.read().clone(),
|
||||
streaming_content: streaming_content.read().clone(),
|
||||
is_streaming: *is_streaming.read(),
|
||||
}
|
||||
ChatActionBar {
|
||||
on_copy: on_copy,
|
||||
on_share: on_share,
|
||||
on_edit: on_edit,
|
||||
has_messages: has_messages,
|
||||
has_assistant_message: has_assistant_message,
|
||||
has_user_message: has_user_message,
|
||||
}
|
||||
ChatInputBar {
|
||||
input_text: input_text,
|
||||
on_send: on_send,
|
||||
is_streaming: *is_streaming.read(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
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;
|
||||
|
||||
/// Maximum number of recent searches to retain in localStorage.
|
||||
const MAX_RECENT_SEARCHES: usize = 10;
|
||||
|
||||
/// Default search topics shown on the dashboard, inspired by Perplexica.
|
||||
const DEFAULT_TOPICS: &[&str] = &[
|
||||
"AI",
|
||||
"Technology",
|
||||
"Science",
|
||||
"Finance",
|
||||
"Writing",
|
||||
"Research",
|
||||
];
|
||||
|
||||
/// Dashboard page displaying AI news from SearXNG with topic-based filtering,
|
||||
/// a split-view article detail panel, and LLM-powered summarization.
|
||||
///
|
||||
/// State is persisted across sessions using localStorage:
|
||||
/// - `certifai_topics`: custom user-defined search topics
|
||||
/// - `certifai_ollama_url`: Ollama instance URL for summarization
|
||||
/// - `certifai_ollama_model`: Ollama model ID for summarization
|
||||
#[component]
|
||||
pub fn DashboardPage() -> Element {
|
||||
// Persistent state stored in localStorage
|
||||
let mut custom_topics = use_persistent("certifai_topics".to_string(), Vec::<String>::new);
|
||||
// Default to empty so the server functions use OLLAMA_URL / OLLAMA_MODEL
|
||||
// from .env. Only stores a non-empty value when the user explicitly saves
|
||||
// an override via the Settings panel.
|
||||
let mut ollama_url = use_persistent("certifai_ollama_url".to_string(), String::new);
|
||||
let mut ollama_model = use_persistent("certifai_ollama_model".to_string(), String::new);
|
||||
|
||||
// Reactive signals for UI state
|
||||
let mut active_topic = use_signal(|| "AI".to_string());
|
||||
let mut selected_card = use_signal(|| Option::<NewsCard>::None);
|
||||
let mut summary = use_signal(|| Option::<String>::None);
|
||||
let mut is_summarizing = use_signal(|| false);
|
||||
let mut show_add_input = use_signal(|| false);
|
||||
let mut new_topic_text = use_signal(String::new);
|
||||
let mut show_settings = use_signal(|| false);
|
||||
let mut settings_url = use_signal(String::new);
|
||||
let mut settings_model = use_signal(String::new);
|
||||
// Chat follow-up state
|
||||
let mut chat_messages = use_signal(Vec::<FollowUpMessage>::new);
|
||||
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 =
|
||||
use_persistent("certifai_recent_searches".to_string(), Vec::<String>::new);
|
||||
|
||||
// Build the complete topic list: defaults + custom
|
||||
let all_topics: Vec<String> = {
|
||||
let custom = custom_topics.read();
|
||||
let mut topics: Vec<String> = DEFAULT_TOPICS.iter().map(|s| (*s).to_string()).collect();
|
||||
for t in custom.iter() {
|
||||
if !topics.contains(t) {
|
||||
topics.push(t.clone());
|
||||
}
|
||||
}
|
||||
topics
|
||||
};
|
||||
|
||||
// Fetch trending topics once on mount (no signal deps = runs once).
|
||||
// use_resource handles deduplication and won't re-fetch on re-renders.
|
||||
let trending_resource = use_resource(|| async {
|
||||
match crate::infrastructure::searxng::get_trending_topics().await {
|
||||
Ok(topics) => topics,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch trending topics: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Push a topic to the front of recent searches (deduplicating, capped).
|
||||
// Defined as a closure so it can be called from multiple click handlers.
|
||||
let mut record_search = move |topic: &str| {
|
||||
let mut searches = recent_searches.read().clone();
|
||||
searches.retain(|t| t != topic);
|
||||
searches.insert(0, topic.to_string());
|
||||
searches.truncate(MAX_RECENT_SEARCHES);
|
||||
*recent_searches.write() = searches;
|
||||
};
|
||||
|
||||
// Fetch news reactively when active_topic changes.
|
||||
// use_resource tracks the signal read inside the closure and only
|
||||
// re-fetches when active_topic actually changes -- unlike use_effect
|
||||
// which can re-fire on unrelated re-renders.
|
||||
let search_resource = use_resource(move || {
|
||||
let topic = active_topic.read().clone();
|
||||
async move { crate::infrastructure::searxng::search_topic(topic).await }
|
||||
});
|
||||
|
||||
// Check if an article is selected for split view
|
||||
let has_selection = selected_card.read().is_some();
|
||||
let container_class = if has_selection {
|
||||
"dashboard-split"
|
||||
} else {
|
||||
"dashboard-with-sidebar"
|
||||
};
|
||||
|
||||
// Resolve trending from resource (empty while loading / on error)
|
||||
let trending_topics: Vec<String> = trending_resource
|
||||
.read()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Resolve search state from resource
|
||||
let search_state = search_resource.read();
|
||||
let is_loading = search_state.is_none();
|
||||
let search_error: Option<String> = search_state
|
||||
.as_ref()
|
||||
.and_then(|r| r.as_ref().err().map(|e| format!("Search failed: {e}")));
|
||||
let news_cards: Vec<NewsCard> = match search_state.as_ref() {
|
||||
Some(Ok(c)) => c.clone(),
|
||||
Some(Err(_)) => crate::components::news_card::mock_news(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// Drop the borrow before entering rsx! so signals can be written in handlers
|
||||
drop(search_state);
|
||||
|
||||
rsx! {
|
||||
section { class: "dashboard-page",
|
||||
PageHeader {
|
||||
title: "Dashboard".to_string(),
|
||||
subtitle: "AI news and updates".to_string(),
|
||||
}
|
||||
|
||||
// Topic tabs row
|
||||
div { class: "dashboard-filters",
|
||||
for topic in &all_topics {
|
||||
{
|
||||
let is_active = *active_topic.read() == *topic;
|
||||
let class_name = if is_active {
|
||||
"filter-tab filter-tab--active"
|
||||
} else {
|
||||
"filter-tab"
|
||||
};
|
||||
let is_custom = !DEFAULT_TOPICS.contains(&topic.as_str());
|
||||
let topic_click = topic.clone();
|
||||
let topic_remove = topic.clone();
|
||||
rsx! {
|
||||
div { class: "topic-tab-wrapper",
|
||||
button {
|
||||
class: "{class_name}",
|
||||
onclick: move |_| {
|
||||
record_search(&topic_click);
|
||||
active_topic.set(topic_click.clone());
|
||||
selected_card.set(None);
|
||||
summary.set(None);
|
||||
},
|
||||
"{topic}"
|
||||
}
|
||||
if is_custom {
|
||||
button {
|
||||
class: "topic-remove",
|
||||
onclick: move |_| {
|
||||
let mut topics = custom_topics.read().clone();
|
||||
topics.retain(|t| *t != topic_remove);
|
||||
*custom_topics.write() = topics;
|
||||
// If we removed the active topic, reset
|
||||
if *active_topic.read() == topic_remove {
|
||||
active_topic.set("AI".to_string());
|
||||
}
|
||||
},
|
||||
"x"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add topic button / inline input
|
||||
if *show_add_input.read() {
|
||||
div { class: "topic-input-wrapper",
|
||||
input {
|
||||
class: "topic-input",
|
||||
r#type: "text",
|
||||
placeholder: "Topic name...",
|
||||
value: "{new_topic_text}",
|
||||
oninput: move |e| new_topic_text.set(e.value()),
|
||||
onkeypress: move |e| {
|
||||
if e.key() == Key::Enter {
|
||||
let val = new_topic_text.read().trim().to_string();
|
||||
if !val.is_empty() {
|
||||
let mut topics = custom_topics.read().clone();
|
||||
if !topics.contains(&val) && !DEFAULT_TOPICS.contains(&val.as_str()) {
|
||||
topics.push(val.clone());
|
||||
*custom_topics.write() = topics;
|
||||
record_search(&val);
|
||||
active_topic.set(val);
|
||||
}
|
||||
}
|
||||
new_topic_text.set(String::new());
|
||||
show_add_input.set(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "topic-cancel-btn",
|
||||
onclick: move |_| {
|
||||
show_add_input.set(false);
|
||||
new_topic_text.set(String::new());
|
||||
},
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
button {
|
||||
class: "topic-add-btn",
|
||||
onclick: move |_| show_add_input.set(true),
|
||||
"+"
|
||||
}
|
||||
}
|
||||
|
||||
// Settings toggle
|
||||
button {
|
||||
class: "filter-tab settings-toggle",
|
||||
onclick: move |_| {
|
||||
let currently_shown = *show_settings.read();
|
||||
if !currently_shown {
|
||||
settings_url.set(ollama_url.read().clone());
|
||||
settings_model.set(ollama_model.read().clone());
|
||||
}
|
||||
show_settings.set(!currently_shown);
|
||||
},
|
||||
"Settings"
|
||||
}
|
||||
}
|
||||
|
||||
// Settings panel (collapsible)
|
||||
if *show_settings.read() {
|
||||
div { class: "settings-panel",
|
||||
h4 { class: "settings-panel-title", "Ollama Settings" }
|
||||
p { class: "settings-hint",
|
||||
"Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env"
|
||||
}
|
||||
div { class: "settings-field",
|
||||
label { "Ollama URL" }
|
||||
input {
|
||||
class: "settings-input",
|
||||
r#type: "text",
|
||||
placeholder: "Uses OLLAMA_URL from .env",
|
||||
value: "{settings_url}",
|
||||
oninput: move |e| settings_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "settings-field",
|
||||
label { "Model" }
|
||||
input {
|
||||
class: "settings-input",
|
||||
r#type: "text",
|
||||
placeholder: "Uses OLLAMA_MODEL from .env",
|
||||
value: "{settings_model}",
|
||||
oninput: move |e| settings_model.set(e.value()),
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| {
|
||||
*ollama_url.write() = settings_url.read().trim().to_string();
|
||||
*ollama_model.write() = settings_model.read().trim().to_string();
|
||||
show_settings.set(false);
|
||||
},
|
||||
"Save"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading / error state
|
||||
if is_loading {
|
||||
div { class: "dashboard-loading", "Searching..." }
|
||||
}
|
||||
if let Some(ref err) = search_error {
|
||||
div { class: "settings-hint", "{err}" }
|
||||
}
|
||||
|
||||
// Main content area: grid + optional detail panel
|
||||
div { class: "{container_class}",
|
||||
// Left: news grid
|
||||
div { class: if has_selection { "dashboard-left" } else { "dashboard-full-grid" },
|
||||
div { class: if has_selection { "news-grid news-grid--compact" } else { "news-grid" },
|
||||
for card in news_cards.iter() {
|
||||
{
|
||||
let is_selected = selected_card
|
||||
|
||||
// Auto-summarize on card selection
|
||||
.read()
|
||||
// Store context for follow-up chat
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.url == card.url && s.title == card.title);
|
||||
rsx! {
|
||||
NewsCardView {
|
||||
key: "{card.title}-{card.url}",
|
||||
card: card.clone(),
|
||||
selected: is_selected,
|
||||
on_click: move |c: NewsCard| {
|
||||
let snippet = c.content.clone();
|
||||
let article_url = c.url.clone();
|
||||
selected_card.set(Some(c));
|
||||
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();
|
||||
let mdl = ollama_model.read().clone();
|
||||
spawn(async move {
|
||||
is_summarizing.set(true);
|
||||
match crate::infrastructure::llm::summarize_article(
|
||||
snippet.clone(),
|
||||
article_url,
|
||||
oll_url,
|
||||
mdl,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(text) => {
|
||||
article_context
|
||||
.set(
|
||||
format!(
|
||||
"Article content:\n{snippet}\n\n\
|
||||
AI Summary:\n{text}",
|
||||
),
|
||||
);
|
||||
summary.set(Some(text));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Summarization failed: {e}");
|
||||
summary.set(Some(format!("Summarization failed: {e}")));
|
||||
}
|
||||
}
|
||||
is_summarizing.set(false);
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right: article detail panel (when card selected)
|
||||
if let Some(ref card) = *selected_card.read() {
|
||||
div { class: "dashboard-right",
|
||||
ArticleDetail {
|
||||
card: card.clone(),
|
||||
on_close: move |_| {
|
||||
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(),
|
||||
chat_messages: chat_messages.read().clone(),
|
||||
is_chatting: *is_chatting.read(),
|
||||
on_chat_send: move |question: String| {
|
||||
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 local chat
|
||||
chat_messages.write().push(FollowUpMessage {
|
||||
role: "user".into(),
|
||||
content: question.clone(),
|
||||
});
|
||||
|
||||
// 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: system_msg.clone(),
|
||||
}];
|
||||
all.extend(history.iter().cloned());
|
||||
all
|
||||
};
|
||||
|
||||
spawn(async move {
|
||||
is_chatting.set(true);
|
||||
|
||||
// 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) => {
|
||||
// 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}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
is_chatting.set(false);
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right: sidebar (when no card selected)
|
||||
if !has_selection {
|
||||
DashboardSidebar {
|
||||
ollama_url: ollama_url.read().clone(),
|
||||
trending: trending_topics.clone(),
|
||||
recent_searches: recent_searches.read().clone(),
|
||||
on_topic_click: move |topic: String| {
|
||||
record_search(&topic);
|
||||
active_topic.set(topic);
|
||||
selected_card.set(None);
|
||||
summary.set(None);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Agents page placeholder for the LangGraph agent builder.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||
/// Will eventually integrate with the LangGraph framework.
|
||||
#[component]
|
||||
pub fn AgentsPage() -> Element {
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "A" }
|
||||
h2 { "Agent Builder" }
|
||||
p { class: "placeholder-desc",
|
||||
"Build and manage AI agents with LangGraph. \
|
||||
Create multi-step reasoning pipelines, tool-using agents, \
|
||||
and autonomous workflows."
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "Launch Agent Builder" }
|
||||
span { class: "placeholder-badge", "Coming Soon" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::models::AnalyticsMetric;
|
||||
|
||||
/// Analytics page placeholder for LangFuse integration.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button,
|
||||
/// plus a mock stats bar showing sample metrics.
|
||||
#[component]
|
||||
pub fn AnalyticsPage() -> Element {
|
||||
let metrics = mock_metrics();
|
||||
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "analytics-stats-bar",
|
||||
for metric in &metrics {
|
||||
div { class: "analytics-stat",
|
||||
span { class: "analytics-stat-value", "{metric.value}" }
|
||||
span { class: "analytics-stat-label", "{metric.label}" }
|
||||
span { class: if metric.change_pct >= 0.0 { "analytics-stat-change analytics-stat-change--up" } else { "analytics-stat-change analytics-stat-change--down" },
|
||||
"{metric.change_pct:+.1}%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "L" }
|
||||
h2 { "Analytics & Observability" }
|
||||
p { class: "placeholder-desc",
|
||||
"Monitor and analyze your AI pipelines with LangFuse. \
|
||||
Track token usage, latency, costs, and quality metrics \
|
||||
across all your deployments."
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "Launch LangFuse" }
|
||||
span { class: "placeholder-badge", "Coming Soon" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock analytics metrics for the stats bar.
|
||||
fn mock_metrics() -> Vec<AnalyticsMetric> {
|
||||
vec![
|
||||
AnalyticsMetric {
|
||||
label: "Total Requests".into(),
|
||||
value: "12,847".into(),
|
||||
change_pct: 14.2,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: "Avg Latency".into(),
|
||||
value: "245ms".into(),
|
||||
change_pct: -8.5,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: "Tokens Used".into(),
|
||||
value: "2.4M".into(),
|
||||
change_pct: 22.1,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: "Error Rate".into(),
|
||||
value: "0.3%".into(),
|
||||
change_pct: -12.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Flow page placeholder for the LangFlow visual workflow builder.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||
/// Will eventually integrate with LangFlow for visual flow design.
|
||||
#[component]
|
||||
pub fn FlowPage() -> Element {
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "F" }
|
||||
h2 { "Flow Builder" }
|
||||
p { class: "placeholder-desc",
|
||||
"Design visual AI workflows with LangFlow. \
|
||||
Drag-and-drop nodes to create data processing pipelines, \
|
||||
prompt chains, and integration flows."
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "Launch Flow Builder" }
|
||||
span { class: "placeholder-badge", "Coming Soon" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
mod agents;
|
||||
mod analytics;
|
||||
mod flow;
|
||||
|
||||
pub use agents::*;
|
||||
pub use analytics::*;
|
||||
pub use flow::*;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::sub_nav::{SubNav, SubNavItem};
|
||||
|
||||
/// Shell layout for the Developer section.
|
||||
///
|
||||
/// Renders a horizontal tab bar (Agents, Flow, Analytics) above
|
||||
/// the child route outlet. Sits inside the main `AppShell` layout.
|
||||
#[component]
|
||||
pub fn DeveloperShell() -> Element {
|
||||
let tabs = vec![
|
||||
SubNavItem {
|
||||
label: "Agents",
|
||||
route: Route::AgentsPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: "Flow",
|
||||
route: Route::FlowPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: "Analytics",
|
||||
route: Route::AnalyticsPage {},
|
||||
},
|
||||
];
|
||||
|
||||
rsx! {
|
||||
div { class: "developer-shell",
|
||||
SubNav { items: tabs }
|
||||
div { class: "shell-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::Route;
|
||||
|
||||
/// Impressum (legal notice) page required by German/EU law.
|
||||
///
|
||||
/// Displays placeholder company information. This page is publicly
|
||||
/// accessible without authentication.
|
||||
#[component]
|
||||
pub fn ImpressumPage() -> Element {
|
||||
rsx! {
|
||||
div { class: "legal-page",
|
||||
nav { class: "legal-nav",
|
||||
Link { to: Route::LandingPage {}, class: "landing-logo",
|
||||
span { class: "landing-logo-icon",
|
||||
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
||||
}
|
||||
span { "CERTifAI" }
|
||||
}
|
||||
}
|
||||
main { class: "legal-content",
|
||||
h1 { "Impressum" }
|
||||
|
||||
h2 { "Information according to 5 TMG" }
|
||||
p {
|
||||
"CERTifAI GmbH"
|
||||
br {}
|
||||
"Musterstrasse 1"
|
||||
br {}
|
||||
"10115 Berlin"
|
||||
br {}
|
||||
"Germany"
|
||||
}
|
||||
|
||||
h2 { "Represented by" }
|
||||
p { "Managing Director: [Name]" }
|
||||
|
||||
h2 { "Contact" }
|
||||
p {
|
||||
"Email: info@certifai.example"
|
||||
br {}
|
||||
"Phone: +49 (0) 30 1234567"
|
||||
}
|
||||
|
||||
h2 { "Commercial Register" }
|
||||
p {
|
||||
"Registered at: Amtsgericht Berlin-Charlottenburg"
|
||||
br {}
|
||||
"Registration number: HRB XXXXXX"
|
||||
}
|
||||
|
||||
h2 { "VAT ID" }
|
||||
p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" }
|
||||
|
||||
h2 { "Responsible for content according to 55 Abs. 2 RStV" }
|
||||
p {
|
||||
"[Name]"
|
||||
br {}
|
||||
"CERTifAI GmbH"
|
||||
br {}
|
||||
"Musterstrasse 1"
|
||||
br {}
|
||||
"10115 Berlin"
|
||||
}
|
||||
}
|
||||
footer { class: "legal-footer",
|
||||
Link { to: Route::LandingPage {}, "Back to Home" }
|
||||
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{FileRow, PageHeader};
|
||||
use crate::models::{FileKind, KnowledgeFile};
|
||||
|
||||
/// Knowledge Base page with file explorer table and upload controls.
|
||||
///
|
||||
/// Displays uploaded documents used for RAG retrieval with their
|
||||
/// metadata, chunk counts, and management actions.
|
||||
#[component]
|
||||
pub fn KnowledgePage() -> Element {
|
||||
let mut files = use_signal(mock_files);
|
||||
let mut search_query = use_signal(String::new);
|
||||
|
||||
// Filter files by search query (case-insensitive name match)
|
||||
let query = search_query.read().to_lowercase();
|
||||
let filtered: Vec<_> = files
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Remove a file by ID
|
||||
let on_delete = move |id: String| {
|
||||
files.write().retain(|f| f.id != id);
|
||||
};
|
||||
|
||||
rsx! {
|
||||
section { class: "knowledge-page",
|
||||
PageHeader {
|
||||
title: "Knowledge Base".to_string(),
|
||||
subtitle: "Manage documents for RAG retrieval".to_string(),
|
||||
actions: rsx! {
|
||||
button { class: "btn-primary", "Upload File" }
|
||||
},
|
||||
}
|
||||
div { class: "knowledge-toolbar",
|
||||
input {
|
||||
class: "form-input knowledge-search",
|
||||
r#type: "text",
|
||||
placeholder: "Search files...",
|
||||
value: "{search_query}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
search_query.set(evt.value());
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "knowledge-table-wrapper",
|
||||
table { class: "knowledge-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Type" }
|
||||
th { "Size" }
|
||||
th { "Chunks" }
|
||||
th { "Uploaded" }
|
||||
th { "Actions" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for file in filtered {
|
||||
FileRow { key: "{file.id}", file, on_delete }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock knowledge base files.
|
||||
fn mock_files() -> Vec<KnowledgeFile> {
|
||||
vec![
|
||||
KnowledgeFile {
|
||||
id: "f1".into(),
|
||||
name: "company-handbook.pdf".into(),
|
||||
kind: FileKind::Pdf,
|
||||
size_bytes: 2_450_000,
|
||||
uploaded_at: "2026-02-15".into(),
|
||||
chunk_count: 142,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f2".into(),
|
||||
name: "api-reference.md".into(),
|
||||
kind: FileKind::Text,
|
||||
size_bytes: 89_000,
|
||||
uploaded_at: "2026-02-14".into(),
|
||||
chunk_count: 34,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f3".into(),
|
||||
name: "sales-data-q4.csv".into(),
|
||||
kind: FileKind::Spreadsheet,
|
||||
size_bytes: 1_200_000,
|
||||
uploaded_at: "2026-02-12".into(),
|
||||
chunk_count: 67,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f4".into(),
|
||||
name: "deployment-guide.pdf".into(),
|
||||
kind: FileKind::Pdf,
|
||||
size_bytes: 540_000,
|
||||
uploaded_at: "2026-02-10".into(),
|
||||
chunk_count: 28,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f5".into(),
|
||||
name: "onboarding-checklist.md".into(),
|
||||
kind: FileKind::Text,
|
||||
size_bytes: 12_000,
|
||||
uploaded_at: "2026-02-08".into(),
|
||||
chunk_count: 8,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f6".into(),
|
||||
name: "architecture-diagram.png".into(),
|
||||
kind: FileKind::Image,
|
||||
size_bytes: 3_800_000,
|
||||
uploaded_at: "2026-02-05".into(),
|
||||
chunk_count: 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsArrowRight, BsGlobe2, BsKey, BsRobot, BsServer, BsShieldCheck,
|
||||
};
|
||||
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::Route;
|
||||
|
||||
/// Public landing page for the CERTifAI platform.
|
||||
///
|
||||
/// Displays a marketing-oriented page with hero section, feature grid,
|
||||
/// how-it-works steps, and call-to-action banners. This page is accessible
|
||||
/// without authentication.
|
||||
#[component]
|
||||
pub fn LandingPage() -> Element {
|
||||
rsx! {
|
||||
div { class: "landing",
|
||||
LandingNav {}
|
||||
HeroSection {}
|
||||
SocialProof {}
|
||||
FeaturesGrid {}
|
||||
HowItWorks {}
|
||||
CtaBanner {}
|
||||
LandingFooter {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
|
||||
#[component]
|
||||
fn LandingNav() -> Element {
|
||||
rsx! {
|
||||
nav { class: "landing-nav",
|
||||
div { class: "landing-nav-inner",
|
||||
Link { to: Route::LandingPage {}, class: "landing-logo",
|
||||
span { class: "landing-logo-icon",
|
||||
Icon { icon: BsShieldCheck, width: 24, height: 24 }
|
||||
}
|
||||
span { "CERTifAI" }
|
||||
}
|
||||
div { class: "landing-nav-links",
|
||||
a { href: "#features", "Features" }
|
||||
a { href: "#how-it-works", "How It Works" }
|
||||
a { href: "#pricing", "Pricing" }
|
||||
}
|
||||
div { class: "landing-nav-actions",
|
||||
Link {
|
||||
to: Route::Login {
|
||||
redirect_url: "/dashboard".into(),
|
||||
},
|
||||
class: "btn btn-ghost btn-sm",
|
||||
"Log In"
|
||||
}
|
||||
Link {
|
||||
to: Route::Login {
|
||||
redirect_url: "/dashboard".into(),
|
||||
},
|
||||
class: "btn btn-primary btn-sm",
|
||||
"Get Started"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hero section with headline, subtitle, and CTA buttons.
|
||||
#[component]
|
||||
fn HeroSection() -> Element {
|
||||
rsx! {
|
||||
section { class: "hero-section",
|
||||
div { class: "hero-content",
|
||||
div { class: "hero-badge badge badge-outline", "Privacy-First GenAI Infrastructure" }
|
||||
h1 { class: "hero-title",
|
||||
"Your AI. Your Data."
|
||||
br {}
|
||||
span { class: "hero-title-accent", "Your Infrastructure." }
|
||||
}
|
||||
p { class: "hero-subtitle",
|
||||
"Self-hosted, GDPR-compliant generative AI platform for "
|
||||
"enterprises that refuse to compromise on data sovereignty. "
|
||||
"Deploy LLMs, agents, and MCP servers on your own terms."
|
||||
}
|
||||
div { class: "hero-actions",
|
||||
Link {
|
||||
to: Route::Login {
|
||||
redirect_url: "/dashboard".into(),
|
||||
},
|
||||
class: "btn btn-primary btn-lg",
|
||||
"Get Started"
|
||||
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
||||
}
|
||||
a { href: "#features", class: "btn btn-outline btn-lg", "Learn More" }
|
||||
}
|
||||
}
|
||||
div { class: "hero-graphic",
|
||||
// Abstract shield/network SVG motif
|
||||
svg {
|
||||
view_box: "0 0 400 400",
|
||||
fill: "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// Gradient definitions
|
||||
defs {
|
||||
linearGradient {
|
||||
id: "grad1",
|
||||
x1: "0%",
|
||||
y1: "0%",
|
||||
x2: "100%",
|
||||
y2: "100%",
|
||||
stop { offset: "0%", stop_color: "#91a4d2" }
|
||||
stop { offset: "100%", stop_color: "#6d85c6" }
|
||||
}
|
||||
linearGradient {
|
||||
id: "grad2",
|
||||
x1: "0%",
|
||||
y1: "100%",
|
||||
x2: "100%",
|
||||
y2: "0%",
|
||||
stop { offset: "0%", stop_color: "#f97066" }
|
||||
stop { offset: "100%", stop_color: "#f9a066" }
|
||||
}
|
||||
radialGradient {
|
||||
id: "glow",
|
||||
cx: "50%",
|
||||
cy: "50%",
|
||||
r: "50%",
|
||||
stop {
|
||||
offset: "0%",
|
||||
stop_color: "rgba(145,164,210,0.3)",
|
||||
}
|
||||
stop {
|
||||
offset: "100%",
|
||||
stop_color: "rgba(145,164,210,0)",
|
||||
}
|
||||
}
|
||||
}
|
||||
// Background glow
|
||||
circle {
|
||||
cx: "200",
|
||||
cy: "200",
|
||||
r: "180",
|
||||
fill: "url(#glow)",
|
||||
}
|
||||
// Shield outline
|
||||
path {
|
||||
d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \
|
||||
C130 360 60 300 60 230 L60 110 Z",
|
||||
stroke: "url(#grad1)",
|
||||
stroke_width: "2",
|
||||
fill: "none",
|
||||
opacity: "0.6",
|
||||
}
|
||||
// Inner shield
|
||||
path {
|
||||
d: "M200 80 L310 135 L310 225 C310 280 255 330 200 345 \
|
||||
C145 330 90 280 90 225 L90 135 Z",
|
||||
stroke: "url(#grad1)",
|
||||
stroke_width: "1.5",
|
||||
fill: "rgba(145,164,210,0.05)",
|
||||
opacity: "0.8",
|
||||
}
|
||||
// Network nodes
|
||||
circle {
|
||||
cx: "200",
|
||||
cy: "180",
|
||||
r: "8",
|
||||
fill: "url(#grad1)",
|
||||
}
|
||||
circle {
|
||||
cx: "150",
|
||||
cy: "230",
|
||||
r: "6",
|
||||
fill: "url(#grad2)",
|
||||
}
|
||||
circle {
|
||||
cx: "250",
|
||||
cy: "230",
|
||||
r: "6",
|
||||
fill: "url(#grad2)",
|
||||
}
|
||||
circle {
|
||||
cx: "200",
|
||||
cy: "280",
|
||||
r: "6",
|
||||
fill: "url(#grad1)",
|
||||
}
|
||||
circle {
|
||||
cx: "130",
|
||||
cy: "170",
|
||||
r: "4",
|
||||
fill: "#91a4d2",
|
||||
opacity: "0.6",
|
||||
}
|
||||
circle {
|
||||
cx: "270",
|
||||
cy: "170",
|
||||
r: "4",
|
||||
fill: "#91a4d2",
|
||||
opacity: "0.6",
|
||||
}
|
||||
// Network connections
|
||||
line {
|
||||
x1: "200",
|
||||
y1: "180",
|
||||
x2: "150",
|
||||
y2: "230",
|
||||
stroke: "#91a4d2",
|
||||
stroke_width: "1",
|
||||
opacity: "0.4",
|
||||
}
|
||||
line {
|
||||
x1: "200",
|
||||
y1: "180",
|
||||
x2: "250",
|
||||
y2: "230",
|
||||
stroke: "#91a4d2",
|
||||
stroke_width: "1",
|
||||
opacity: "0.4",
|
||||
}
|
||||
line {
|
||||
x1: "150",
|
||||
y1: "230",
|
||||
x2: "200",
|
||||
y2: "280",
|
||||
stroke: "#91a4d2",
|
||||
stroke_width: "1",
|
||||
opacity: "0.4",
|
||||
}
|
||||
line {
|
||||
x1: "250",
|
||||
y1: "230",
|
||||
x2: "200",
|
||||
y2: "280",
|
||||
stroke: "#91a4d2",
|
||||
stroke_width: "1",
|
||||
opacity: "0.4",
|
||||
}
|
||||
line {
|
||||
x1: "200",
|
||||
y1: "180",
|
||||
x2: "130",
|
||||
y2: "170",
|
||||
stroke: "#91a4d2",
|
||||
stroke_width: "1",
|
||||
opacity: "0.3",
|
||||
}
|
||||
line {
|
||||
x1: "200",
|
||||
y1: "180",
|
||||
x2: "270",
|
||||
y2: "170",
|
||||
stroke: "#91a4d2",
|
||||
stroke_width: "1",
|
||||
opacity: "0.3",
|
||||
}
|
||||
// Checkmark inside shield center
|
||||
path {
|
||||
d: "M180 200 L195 215 L225 185",
|
||||
stroke: "url(#grad1)",
|
||||
stroke_width: "3",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
fill: "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Social proof / trust indicator strip.
|
||||
#[component]
|
||||
fn SocialProof() -> Element {
|
||||
rsx! {
|
||||
section { class: "social-proof",
|
||||
p { class: "social-proof-text",
|
||||
"Built for enterprises that value "
|
||||
span { class: "social-proof-highlight", "data sovereignty" }
|
||||
}
|
||||
div { class: "social-proof-stats",
|
||||
div { class: "proof-stat",
|
||||
span { class: "proof-stat-value", "100%" }
|
||||
span { class: "proof-stat-label", "On-Premise" }
|
||||
}
|
||||
div { class: "proof-divider" }
|
||||
div { class: "proof-stat",
|
||||
span { class: "proof-stat-value", "GDPR" }
|
||||
span { class: "proof-stat-label", "Compliant" }
|
||||
}
|
||||
div { class: "proof-divider" }
|
||||
div { class: "proof-stat",
|
||||
span { class: "proof-stat-value", "EU" }
|
||||
span { class: "proof-stat-label", "Data Residency" }
|
||||
}
|
||||
div { class: "proof-divider" }
|
||||
div { class: "proof-stat",
|
||||
span { class: "proof-stat-value", "Zero" }
|
||||
span { class: "proof-stat-label", "Third-Party Sharing" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature cards grid section.
|
||||
#[component]
|
||||
fn FeaturesGrid() -> Element {
|
||||
rsx! {
|
||||
section { id: "features", class: "features-section",
|
||||
h2 { class: "section-title", "Everything You Need" }
|
||||
p { class: "section-subtitle",
|
||||
"A complete, self-hosted GenAI stack under your full control."
|
||||
}
|
||||
div { class: "features-grid",
|
||||
FeatureCard {
|
||||
icon: rsx! {
|
||||
Icon { icon: BsServer, width: 28, height: 28 }
|
||||
},
|
||||
title: "Self-Hosted Infrastructure",
|
||||
description: "Deploy on your own hardware or private cloud. \
|
||||
Full control over your AI stack with no external dependencies.",
|
||||
}
|
||||
FeatureCard {
|
||||
icon: rsx! {
|
||||
Icon { icon: BsShieldCheck, width: 28, height: 28 }
|
||||
},
|
||||
title: "GDPR Compliant",
|
||||
description: "EU data residency guaranteed. Your data never \
|
||||
leaves your infrastructure or gets shared with third parties.",
|
||||
}
|
||||
FeatureCard {
|
||||
icon: rsx! {
|
||||
Icon { icon: FaCubes, width: 28, height: 28 }
|
||||
},
|
||||
title: "LLM Management",
|
||||
description: "Deploy, monitor, and manage multiple language \
|
||||
models. Switch between models with zero downtime.",
|
||||
}
|
||||
FeatureCard {
|
||||
icon: rsx! {
|
||||
Icon { icon: BsRobot, width: 28, height: 28 }
|
||||
},
|
||||
title: "Agent Builder",
|
||||
description: "Create custom AI agents with integrated Langchain \
|
||||
and Langfuse for full observability and control.",
|
||||
}
|
||||
FeatureCard {
|
||||
icon: rsx! {
|
||||
Icon { icon: BsGlobe2, width: 28, height: 28 }
|
||||
},
|
||||
title: "MCP Server Management",
|
||||
description: "Manage Model Context Protocol servers to extend \
|
||||
your AI capabilities with external tool integrations.",
|
||||
}
|
||||
FeatureCard {
|
||||
icon: rsx! {
|
||||
Icon { icon: BsKey, width: 28, height: 28 }
|
||||
},
|
||||
title: "API Key Management",
|
||||
description: "Generate API keys, track usage per seat, and \
|
||||
set fine-grained permissions for every integration.",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual feature card.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `icon` - The icon element to display
|
||||
/// * `title` - Feature title
|
||||
/// * `description` - Feature description text
|
||||
#[component]
|
||||
fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> Element {
|
||||
rsx! {
|
||||
div { class: "card feature-card",
|
||||
div { class: "feature-card-icon", {icon} }
|
||||
h3 { class: "feature-card-title", "{title}" }
|
||||
p { class: "feature-card-desc", "{description}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-step "How It Works" section.
|
||||
#[component]
|
||||
fn HowItWorks() -> Element {
|
||||
rsx! {
|
||||
section { id: "how-it-works", class: "how-it-works-section",
|
||||
h2 { class: "section-title", "Up and Running in Minutes" }
|
||||
p { class: "section-subtitle", "Three steps to sovereign AI infrastructure." }
|
||||
div { class: "steps-grid",
|
||||
StepCard {
|
||||
number: "01",
|
||||
title: "Deploy",
|
||||
description: "Install CERTifAI on your infrastructure \
|
||||
with a single command. Supports Docker, Kubernetes, \
|
||||
and bare metal.",
|
||||
}
|
||||
StepCard {
|
||||
number: "02",
|
||||
title: "Configure",
|
||||
description: "Connect your identity provider, select \
|
||||
your models, and set up team permissions through \
|
||||
the admin dashboard.",
|
||||
}
|
||||
StepCard {
|
||||
number: "03",
|
||||
title: "Scale",
|
||||
description: "Add users, deploy more models, and \
|
||||
integrate with your existing tools via API keys \
|
||||
and MCP servers.",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual step card.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `number` - Step number string (e.g. "01")
|
||||
/// * `title` - Step title
|
||||
/// * `description` - Step description text
|
||||
#[component]
|
||||
fn StepCard(number: &'static str, title: &'static str, description: &'static str) -> Element {
|
||||
rsx! {
|
||||
div { class: "step-card",
|
||||
span { class: "step-number", "{number}" }
|
||||
h3 { class: "step-title", "{title}" }
|
||||
p { class: "step-desc", "{description}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call-to-action banner before the footer.
|
||||
#[component]
|
||||
fn CtaBanner() -> Element {
|
||||
rsx! {
|
||||
section { class: "cta-banner",
|
||||
h2 { class: "cta-title", "Ready to take control of your AI infrastructure?" }
|
||||
p { class: "cta-subtitle",
|
||||
"Start deploying sovereign GenAI today. No credit card required."
|
||||
}
|
||||
div { class: "cta-actions",
|
||||
Link {
|
||||
to: Route::Login {
|
||||
redirect_url: "/dashboard".into(),
|
||||
},
|
||||
class: "btn btn-primary btn-lg",
|
||||
"Get Started Free"
|
||||
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
||||
}
|
||||
Link {
|
||||
to: Route::Login {
|
||||
redirect_url: "/dashboard".into(),
|
||||
},
|
||||
class: "btn btn-outline btn-lg",
|
||||
"Log In"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Landing page footer with links and copyright.
|
||||
#[component]
|
||||
fn LandingFooter() -> Element {
|
||||
rsx! {
|
||||
footer { class: "landing-footer",
|
||||
div { class: "landing-footer-inner",
|
||||
div { class: "footer-brand",
|
||||
div { class: "landing-logo",
|
||||
span { class: "landing-logo-icon",
|
||||
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
||||
}
|
||||
span { "CERTifAI" }
|
||||
}
|
||||
p { class: "footer-tagline", "Sovereign GenAI infrastructure for enterprises." }
|
||||
}
|
||||
div { class: "footer-links-group",
|
||||
h4 { class: "footer-links-heading", "Product" }
|
||||
a { href: "#features", "Features" }
|
||||
a { href: "#how-it-works", "How It Works" }
|
||||
a { href: "#pricing", "Pricing" }
|
||||
}
|
||||
div { class: "footer-links-group",
|
||||
h4 { class: "footer-links-heading", "Legal" }
|
||||
Link { to: Route::ImpressumPage {}, "Impressum" }
|
||||
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
|
||||
}
|
||||
div { class: "footer-links-group",
|
||||
h4 { class: "footer-links-heading", "Resources" }
|
||||
a { href: "#", "Documentation" }
|
||||
a { href: "#", "API Reference" }
|
||||
a { href: "#", "Support" }
|
||||
}
|
||||
}
|
||||
div { class: "footer-bottom",
|
||||
p { "2026 CERTifAI. All rights reserved." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,2 @@
|
||||
mod chat;
|
||||
mod dashboard;
|
||||
pub mod developer;
|
||||
mod impressum;
|
||||
mod knowledge;
|
||||
mod landing;
|
||||
pub mod organization;
|
||||
mod privacy;
|
||||
mod providers;
|
||||
mod tools;
|
||||
|
||||
pub use chat::*;
|
||||
pub use dashboard::*;
|
||||
pub use developer::*;
|
||||
pub use impressum::*;
|
||||
pub use knowledge::*;
|
||||
pub use landing::*;
|
||||
pub use organization::*;
|
||||
pub use privacy::*;
|
||||
pub use providers::*;
|
||||
pub use tools::*;
|
||||
mod overview;
|
||||
pub use overview::*;
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{MemberRow, PageHeader};
|
||||
use crate::models::{BillingUsage, MemberRole, OrgMember};
|
||||
|
||||
/// Organization dashboard with billing stats, member table, and invite modal.
|
||||
///
|
||||
/// Shows current billing usage, a table of organization members
|
||||
/// with role management, and a button to invite new members.
|
||||
#[component]
|
||||
pub fn OrgDashboardPage() -> Element {
|
||||
let members = use_signal(mock_members);
|
||||
let usage = mock_usage();
|
||||
let mut show_invite = use_signal(|| false);
|
||||
let mut invite_email = use_signal(String::new);
|
||||
|
||||
let members_list = members.read().clone();
|
||||
|
||||
// Format token counts for display
|
||||
let tokens_display = format_tokens(usage.tokens_used);
|
||||
let tokens_limit_display = format_tokens(usage.tokens_limit);
|
||||
|
||||
rsx! {
|
||||
section { class: "org-dashboard-page",
|
||||
PageHeader {
|
||||
title: "Organization".to_string(),
|
||||
subtitle: "Manage members and billing".to_string(),
|
||||
actions: rsx! {
|
||||
button { class: "btn-primary", onclick: move |_| show_invite.set(true), "Invite Member" }
|
||||
},
|
||||
}
|
||||
|
||||
// Stats bar
|
||||
div { class: "org-stats-bar",
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{usage.seats_used}/{usage.seats_total}" }
|
||||
span { class: "org-stat-label", "Seats Used" }
|
||||
}
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{tokens_display}" }
|
||||
span { class: "org-stat-label", "of {tokens_limit_display} tokens" }
|
||||
}
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{usage.billing_cycle_end}" }
|
||||
span { class: "org-stat-label", "Cycle Ends" }
|
||||
}
|
||||
}
|
||||
|
||||
// Members table
|
||||
div { class: "org-table-wrapper",
|
||||
table { class: "org-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Email" }
|
||||
th { "Role" }
|
||||
th { "Joined" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for member in members_list {
|
||||
MemberRow {
|
||||
key: "{member.id}",
|
||||
member,
|
||||
on_role_change: move |_| {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invite modal
|
||||
if *show_invite.read() {
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
div {
|
||||
class: "modal-content",
|
||||
// Prevent clicks inside modal from closing it
|
||||
onclick: move |evt: Event<MouseData>| evt.stop_propagation(),
|
||||
h3 { "Invite New Member" }
|
||||
div { class: "form-group",
|
||||
label { "Email Address" }
|
||||
input {
|
||||
class: "form-input",
|
||||
r#type: "email",
|
||||
placeholder: "colleague@company.com",
|
||||
value: "{invite_email}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
invite_email.set(evt.value());
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn-secondary",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn-primary",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
"Send Invite"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a token count into a human-readable string (e.g. "1.2M").
|
||||
fn format_tokens(count: u64) -> String {
|
||||
const M: u64 = 1_000_000;
|
||||
const K: u64 = 1_000;
|
||||
|
||||
if count >= M {
|
||||
format!("{:.1}M", count as f64 / M as f64)
|
||||
} else if count >= K {
|
||||
format!("{:.0}K", count as f64 / K as f64)
|
||||
} else {
|
||||
count.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock organization members.
|
||||
fn mock_members() -> Vec<OrgMember> {
|
||||
vec![
|
||||
OrgMember {
|
||||
id: "m1".into(),
|
||||
name: "Max Mustermann".into(),
|
||||
email: "max@example.com".into(),
|
||||
role: MemberRole::Admin,
|
||||
joined_at: "2026-01-10".into(),
|
||||
},
|
||||
OrgMember {
|
||||
id: "m2".into(),
|
||||
name: "Erika Musterfrau".into(),
|
||||
email: "erika@example.com".into(),
|
||||
role: MemberRole::Member,
|
||||
joined_at: "2026-01-15".into(),
|
||||
},
|
||||
OrgMember {
|
||||
id: "m3".into(),
|
||||
name: "Johann Schmidt".into(),
|
||||
email: "johann@example.com".into(),
|
||||
role: MemberRole::Member,
|
||||
joined_at: "2026-02-01".into(),
|
||||
},
|
||||
OrgMember {
|
||||
id: "m4".into(),
|
||||
name: "Anna Weber".into(),
|
||||
email: "anna@example.com".into(),
|
||||
role: MemberRole::Viewer,
|
||||
joined_at: "2026-02-10".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns mock billing usage data.
|
||||
fn mock_usage() -> BillingUsage {
|
||||
BillingUsage {
|
||||
seats_used: 4,
|
||||
seats_total: 25,
|
||||
tokens_used: 847_000,
|
||||
tokens_limit: 1_000_000,
|
||||
billing_cycle_end: "2026-03-01".into(),
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
mod dashboard;
|
||||
mod pricing;
|
||||
|
||||
pub use dashboard::*;
|
||||
pub use pricing::*;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::sub_nav::{SubNav, SubNavItem};
|
||||
|
||||
/// Shell layout for the Organization section.
|
||||
///
|
||||
/// Renders a horizontal tab bar (Pricing, Dashboard) above
|
||||
/// the child route outlet. Sits inside the main `AppShell` layout.
|
||||
#[component]
|
||||
pub fn OrgShell() -> Element {
|
||||
let tabs = vec![
|
||||
SubNavItem {
|
||||
label: "Pricing",
|
||||
route: Route::OrgPricingPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: "Dashboard",
|
||||
route: Route::OrgDashboardPage {},
|
||||
},
|
||||
];
|
||||
|
||||
rsx! {
|
||||
div { class: "org-shell",
|
||||
SubNav { items: tabs }
|
||||
div { class: "shell-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::{PageHeader, PricingCard};
|
||||
use crate::models::PricingPlan;
|
||||
|
||||
/// Organization pricing page displaying three plan tiers.
|
||||
///
|
||||
/// Clicking "Get Started" on any plan navigates to the
|
||||
/// organization dashboard.
|
||||
#[component]
|
||||
pub fn OrgPricingPage() -> Element {
|
||||
let navigator = use_navigator();
|
||||
let plans = mock_plans();
|
||||
|
||||
rsx! {
|
||||
section { class: "pricing-page",
|
||||
PageHeader {
|
||||
title: "Pricing".to_string(),
|
||||
subtitle: "Choose the plan that fits your organization".to_string(),
|
||||
}
|
||||
div { class: "pricing-grid",
|
||||
for plan in plans {
|
||||
PricingCard {
|
||||
key: "{plan.id}",
|
||||
plan,
|
||||
on_select: move |_| {
|
||||
navigator.push(Route::OrgDashboardPage {});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock pricing plans.
|
||||
fn mock_plans() -> Vec<PricingPlan> {
|
||||
vec![
|
||||
PricingPlan {
|
||||
id: "starter".into(),
|
||||
name: "Starter".into(),
|
||||
price_eur: 49,
|
||||
features: vec![
|
||||
"Up to 5 users".into(),
|
||||
"1 LLM provider".into(),
|
||||
"100K tokens/month".into(),
|
||||
"Community support".into(),
|
||||
"Basic analytics".into(),
|
||||
],
|
||||
highlighted: false,
|
||||
max_seats: Some(5),
|
||||
},
|
||||
PricingPlan {
|
||||
id: "team".into(),
|
||||
name: "Team".into(),
|
||||
price_eur: 199,
|
||||
features: vec![
|
||||
"Up to 25 users".into(),
|
||||
"All LLM providers".into(),
|
||||
"1M tokens/month".into(),
|
||||
"Priority support".into(),
|
||||
"Advanced analytics".into(),
|
||||
"Custom MCP tools".into(),
|
||||
"SSO integration".into(),
|
||||
],
|
||||
highlighted: true,
|
||||
max_seats: Some(25),
|
||||
},
|
||||
PricingPlan {
|
||||
id: "enterprise".into(),
|
||||
name: "Enterprise".into(),
|
||||
price_eur: 499,
|
||||
features: vec![
|
||||
"Unlimited users".into(),
|
||||
"All LLM providers".into(),
|
||||
"Unlimited tokens".into(),
|
||||
"Dedicated support".into(),
|
||||
"Full observability".into(),
|
||||
"Custom integrations".into(),
|
||||
"SLA guarantee".into(),
|
||||
"On-premise deployment".into(),
|
||||
],
|
||||
highlighted: false,
|
||||
max_seats: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
8
src/pages/overview.rs
Normal file
8
src/pages/overview.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn OverviewPage() -> Element {
|
||||
rsx! {
|
||||
h1 { "Hello" }
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::Route;
|
||||
|
||||
/// Privacy Policy page.
|
||||
///
|
||||
/// Displays the platform's privacy policy. Publicly accessible
|
||||
/// without authentication.
|
||||
#[component]
|
||||
pub fn PrivacyPage() -> Element {
|
||||
rsx! {
|
||||
div { class: "legal-page",
|
||||
nav { class: "legal-nav",
|
||||
Link { to: Route::LandingPage {}, class: "landing-logo",
|
||||
span { class: "landing-logo-icon",
|
||||
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
||||
}
|
||||
span { "CERTifAI" }
|
||||
}
|
||||
}
|
||||
main { class: "legal-content",
|
||||
h1 { "Privacy Policy" }
|
||||
p { class: "legal-updated", "Last updated: February 2026" }
|
||||
|
||||
h2 { "1. Introduction" }
|
||||
p {
|
||||
"CERTifAI GmbH (\"we\", \"our\", \"us\") is committed to "
|
||||
"protecting your personal data. This privacy policy explains "
|
||||
"how we collect, use, and safeguard your information when you "
|
||||
"use our platform."
|
||||
}
|
||||
|
||||
h2 { "2. Data Controller" }
|
||||
p {
|
||||
"CERTifAI GmbH"
|
||||
br {}
|
||||
"Musterstrasse 1, 10115 Berlin, Germany"
|
||||
br {}
|
||||
"Email: privacy@certifai.example"
|
||||
}
|
||||
|
||||
h2 { "3. Data We Collect" }
|
||||
p {
|
||||
"We collect only the minimum data necessary to provide "
|
||||
"our services:"
|
||||
}
|
||||
ul {
|
||||
li {
|
||||
strong { "Account data: " }
|
||||
"Name, email address, and organization details "
|
||||
"provided during registration."
|
||||
}
|
||||
li {
|
||||
strong { "Usage data: " }
|
||||
"API call logs, token counts, and feature usage "
|
||||
"metrics for billing and analytics."
|
||||
}
|
||||
li {
|
||||
strong { "Technical data: " }
|
||||
"IP addresses, browser type, and session identifiers "
|
||||
"for security and platform stability."
|
||||
}
|
||||
}
|
||||
|
||||
h2 { "4. How We Use Your Data" }
|
||||
ul {
|
||||
li { "To provide and maintain the CERTifAI platform" }
|
||||
li { "To manage your account and subscription" }
|
||||
li { "To communicate service updates and security notices" }
|
||||
li { "To comply with legal obligations" }
|
||||
}
|
||||
|
||||
h2 { "5. Data Storage and Sovereignty" }
|
||||
p {
|
||||
"CERTifAI is a self-hosted platform. All AI workloads, "
|
||||
"model data, and inference results remain entirely within "
|
||||
"your own infrastructure. We do not access, store, or "
|
||||
"process your AI data on our servers."
|
||||
}
|
||||
|
||||
h2 { "6. Your Rights (GDPR)" }
|
||||
p { "Under the GDPR, you have the right to:" }
|
||||
ul {
|
||||
li { "Access your personal data" }
|
||||
li { "Rectify inaccurate data" }
|
||||
li { "Request erasure of your data" }
|
||||
li { "Restrict or object to processing" }
|
||||
li { "Data portability" }
|
||||
li { "Lodge a complaint with a supervisory authority" }
|
||||
}
|
||||
|
||||
h2 { "7. Contact" }
|
||||
p {
|
||||
"For privacy-related inquiries, contact us at "
|
||||
"privacy@certifai.example."
|
||||
}
|
||||
}
|
||||
footer { class: "legal-footer",
|
||||
Link { to: Route::LandingPage {}, "Back to Home" }
|
||||
Link { to: Route::ImpressumPage {}, "Impressum" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::PageHeader;
|
||||
use crate::models::{EmbeddingEntry, LlmProvider, ModelEntry, ProviderConfig};
|
||||
|
||||
/// Providers page for configuring LLM and embedding model backends.
|
||||
///
|
||||
/// Two-column layout: left side has a configuration form, right side
|
||||
/// shows the currently active provider status.
|
||||
#[component]
|
||||
pub fn ProvidersPage() -> Element {
|
||||
let mut selected_provider = use_signal(|| LlmProvider::Ollama);
|
||||
let mut selected_model = use_signal(|| "llama3.1:8b".to_string());
|
||||
let mut selected_embedding = use_signal(|| "nomic-embed-text".to_string());
|
||||
let mut api_key = use_signal(String::new);
|
||||
let mut saved = use_signal(|| false);
|
||||
|
||||
let models = mock_models();
|
||||
let embeddings = mock_embeddings();
|
||||
|
||||
// Filter models/embeddings by selected provider
|
||||
let provider_val = selected_provider.read().clone();
|
||||
let available_models: Vec<_> = models
|
||||
.iter()
|
||||
.filter(|m| m.provider == provider_val)
|
||||
.collect();
|
||||
let available_embeddings: Vec<_> = embeddings
|
||||
.iter()
|
||||
.filter(|e| e.provider == provider_val)
|
||||
.collect();
|
||||
|
||||
let active_config = ProviderConfig {
|
||||
provider: provider_val.clone(),
|
||||
selected_model: selected_model.read().clone(),
|
||||
selected_embedding: selected_embedding.read().clone(),
|
||||
api_key_set: !api_key.read().is_empty(),
|
||||
};
|
||||
|
||||
rsx! {
|
||||
section { class: "providers-page",
|
||||
PageHeader {
|
||||
title: "Providers".to_string(),
|
||||
subtitle: "Configure your LLM and embedding backends".to_string(),
|
||||
}
|
||||
div { class: "providers-layout",
|
||||
div { class: "providers-form",
|
||||
div { class: "form-group",
|
||||
label { "Provider" }
|
||||
select {
|
||||
class: "form-select",
|
||||
value: "{provider_val.label()}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
let val = evt.value();
|
||||
let prov = match val.as_str() {
|
||||
"Hugging Face" => LlmProvider::HuggingFace,
|
||||
"OpenAI" => LlmProvider::OpenAi,
|
||||
"Anthropic" => LlmProvider::Anthropic,
|
||||
_ => LlmProvider::Ollama,
|
||||
};
|
||||
selected_provider.set(prov);
|
||||
saved.set(false);
|
||||
},
|
||||
option { value: "Ollama", "Ollama" }
|
||||
option { value: "Hugging Face", "Hugging Face" }
|
||||
option { value: "OpenAI", "OpenAI" }
|
||||
option { value: "Anthropic", "Anthropic" }
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Model" }
|
||||
select {
|
||||
class: "form-select",
|
||||
value: "{selected_model}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
selected_model.set(evt.value());
|
||||
saved.set(false);
|
||||
},
|
||||
for m in &available_models {
|
||||
option { value: "{m.id}", "{m.name} ({m.context_window}k ctx)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Embedding Model" }
|
||||
select {
|
||||
class: "form-select",
|
||||
value: "{selected_embedding}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
selected_embedding.set(evt.value());
|
||||
saved.set(false);
|
||||
},
|
||||
for e in &available_embeddings {
|
||||
option { value: "{e.id}", "{e.name} ({e.dimensions}d)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "API Key" }
|
||||
input {
|
||||
class: "form-input",
|
||||
r#type: "password",
|
||||
placeholder: "Enter API key...",
|
||||
value: "{api_key}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
api_key.set(evt.value());
|
||||
saved.set(false);
|
||||
},
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn-primary",
|
||||
onclick: move |_| saved.set(true),
|
||||
"Save Configuration"
|
||||
}
|
||||
if *saved.read() {
|
||||
p { class: "form-success", "Configuration saved." }
|
||||
}
|
||||
}
|
||||
div { class: "providers-status",
|
||||
h3 { "Active Configuration" }
|
||||
div { class: "status-card",
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "Provider" }
|
||||
span { class: "status-value", "{active_config.provider.label()}" }
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "Model" }
|
||||
span { class: "status-value", "{active_config.selected_model}" }
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "Embedding" }
|
||||
span { class: "status-value", "{active_config.selected_embedding}" }
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "API Key" }
|
||||
span { class: "status-value",
|
||||
if active_config.api_key_set {
|
||||
"Set"
|
||||
} else {
|
||||
"Not set"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock model entries for all providers.
|
||||
fn mock_models() -> Vec<ModelEntry> {
|
||||
vec![
|
||||
ModelEntry {
|
||||
id: "llama3.1:8b".into(),
|
||||
name: "Llama 3.1 8B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "llama3.1:70b".into(),
|
||||
name: "Llama 3.1 70B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "mistral:7b".into(),
|
||||
name: "Mistral 7B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 32,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "meta-llama/Llama-3.1-8B".into(),
|
||||
name: "Llama 3.1 8B".into(),
|
||||
provider: LlmProvider::HuggingFace,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "gpt-4o".into(),
|
||||
name: "GPT-4o".into(),
|
||||
provider: LlmProvider::OpenAi,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "claude-sonnet-4-6".into(),
|
||||
name: "Claude Sonnet 4.6".into(),
|
||||
provider: LlmProvider::Anthropic,
|
||||
context_window: 200,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns mock embedding entries for all providers.
|
||||
fn mock_embeddings() -> Vec<EmbeddingEntry> {
|
||||
vec![
|
||||
EmbeddingEntry {
|
||||
id: "nomic-embed-text".into(),
|
||||
name: "Nomic Embed Text".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
dimensions: 768,
|
||||
},
|
||||
EmbeddingEntry {
|
||||
id: "sentence-transformers/all-MiniLM-L6-v2".into(),
|
||||
name: "MiniLM-L6-v2".into(),
|
||||
provider: LlmProvider::HuggingFace,
|
||||
dimensions: 384,
|
||||
},
|
||||
EmbeddingEntry {
|
||||
id: "text-embedding-3-small".into(),
|
||||
name: "Embedding 3 Small".into(),
|
||||
provider: LlmProvider::OpenAi,
|
||||
dimensions: 1536,
|
||||
},
|
||||
EmbeddingEntry {
|
||||
id: "voyage-3".into(),
|
||||
name: "Voyage 3".into(),
|
||||
provider: LlmProvider::Anthropic,
|
||||
dimensions: 1024,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{PageHeader, ToolCard};
|
||||
use crate::models::{McpTool, ToolCategory, ToolStatus};
|
||||
|
||||
/// Tools page displaying a grid of MCP tool cards with toggle switches.
|
||||
///
|
||||
/// Shows all available MCP tools with their status and allows
|
||||
/// enabling/disabling them via toggle buttons.
|
||||
#[component]
|
||||
pub fn ToolsPage() -> Element {
|
||||
let mut tools = use_signal(mock_tools);
|
||||
|
||||
// Toggle a tool's enabled state by its ID
|
||||
let on_toggle = move |id: String| {
|
||||
tools.write().iter_mut().for_each(|t| {
|
||||
if t.id == id {
|
||||
t.enabled = !t.enabled;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let tool_list = tools.read().clone();
|
||||
|
||||
rsx! {
|
||||
section { class: "tools-page",
|
||||
PageHeader {
|
||||
title: "Tools".to_string(),
|
||||
subtitle: "Manage MCP servers and tool integrations".to_string(),
|
||||
}
|
||||
div { class: "tools-grid",
|
||||
for tool in tool_list {
|
||||
ToolCard { key: "{tool.id}", tool, on_toggle }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock MCP tools for the tools grid.
|
||||
fn mock_tools() -> Vec<McpTool> {
|
||||
vec![
|
||||
McpTool {
|
||||
id: "calculator".into(),
|
||||
name: "Calculator".into(),
|
||||
description: "Mathematical computation and unit conversion".into(),
|
||||
category: ToolCategory::Compute,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "calculator".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "tavily".into(),
|
||||
name: "Tavily Search".into(),
|
||||
description: "AI-optimized web search API for real-time information".into(),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "search".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "searxng".into(),
|
||||
name: "SearXNG".into(),
|
||||
description: "Privacy-respecting metasearch engine".into(),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "globe".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "file-reader".into(),
|
||||
name: "File Reader".into(),
|
||||
description: "Read and parse local files in various formats".into(),
|
||||
category: ToolCategory::FileSystem,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "file".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "code-exec".into(),
|
||||
name: "Code Executor".into(),
|
||||
description: "Sandboxed code execution for Python and JavaScript".into(),
|
||||
category: ToolCategory::Code,
|
||||
status: ToolStatus::Inactive,
|
||||
enabled: false,
|
||||
icon: "terminal".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "web-scraper".into(),
|
||||
name: "Web Scraper".into(),
|
||||
description: "Extract structured data from web pages".into(),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "download".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "email".into(),
|
||||
name: "Email Sender".into(),
|
||||
description: "Send emails via configured SMTP server".into(),
|
||||
category: ToolCategory::Communication,
|
||||
status: ToolStatus::Inactive,
|
||||
enabled: false,
|
||||
icon: "mail".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "git".into(),
|
||||
name: "Git Operations".into(),
|
||||
description: "Interact with Git repositories for version control".into(),
|
||||
category: ToolCategory::Code,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "git".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
112
styles/input.css
112
styles/input.css
@@ -1,112 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
/* ===== CERTifAI Dark Theme (default) ===== */
|
||||
@plugin "daisyui/theme" {
|
||||
name: "certifai-dark";
|
||||
default: true;
|
||||
prefersdark: true;
|
||||
color-scheme: dark;
|
||||
|
||||
/* Base: deep navy-charcoal */
|
||||
--color-base-100: oklch(18% 0.02 260);
|
||||
--color-base-200: oklch(14% 0.02 260);
|
||||
--color-base-300: oklch(11% 0.02 260);
|
||||
--color-base-content: oklch(90% 0.01 260);
|
||||
|
||||
/* Primary: electric indigo */
|
||||
--color-primary: oklch(62% 0.26 275);
|
||||
--color-primary-content: oklch(98% 0.01 275);
|
||||
|
||||
/* Secondary: coral */
|
||||
--color-secondary: oklch(68% 0.18 25);
|
||||
--color-secondary-content: oklch(98% 0.01 25);
|
||||
|
||||
/* Accent: teal */
|
||||
--color-accent: oklch(72% 0.15 185);
|
||||
--color-accent-content: oklch(12% 0.03 185);
|
||||
|
||||
/* Neutral */
|
||||
--color-neutral: oklch(25% 0.02 260);
|
||||
--color-neutral-content: oklch(85% 0.01 260);
|
||||
|
||||
/* Semantic */
|
||||
--color-info: oklch(70% 0.18 230);
|
||||
--color-info-content: oklch(98% 0.01 230);
|
||||
|
||||
--color-success: oklch(68% 0.19 145);
|
||||
--color-success-content: oklch(98% 0.01 145);
|
||||
|
||||
--color-warning: oklch(82% 0.22 85);
|
||||
--color-warning-content: oklch(18% 0.04 85);
|
||||
|
||||
--color-error: oklch(65% 0.26 25);
|
||||
--color-error-content: oklch(98% 0.01 25);
|
||||
|
||||
/* Sharp, modern radii */
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* ===== CERTifAI Light Theme ===== */
|
||||
@plugin "daisyui/theme" {
|
||||
name: "certifai-light";
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: light;
|
||||
|
||||
/* Base: clean off-white */
|
||||
--color-base-100: oklch(98% 0.005 260);
|
||||
--color-base-200: oklch(95% 0.008 260);
|
||||
--color-base-300: oklch(91% 0.012 260);
|
||||
--color-base-content: oklch(20% 0.03 260);
|
||||
|
||||
/* Primary: indigo (adjusted for light bg) */
|
||||
--color-primary: oklch(50% 0.26 275);
|
||||
--color-primary-content: oklch(98% 0.01 275);
|
||||
|
||||
/* Secondary: coral (adjusted for light bg) */
|
||||
--color-secondary: oklch(58% 0.18 25);
|
||||
--color-secondary-content: oklch(98% 0.01 25);
|
||||
|
||||
/* Accent: teal (adjusted for light bg) */
|
||||
--color-accent: oklch(55% 0.15 185);
|
||||
--color-accent-content: oklch(98% 0.01 185);
|
||||
|
||||
/* Neutral */
|
||||
--color-neutral: oklch(35% 0.02 260);
|
||||
--color-neutral-content: oklch(98% 0.01 260);
|
||||
|
||||
/* Semantic */
|
||||
--color-info: oklch(55% 0.18 230);
|
||||
--color-info-content: oklch(98% 0.01 230);
|
||||
|
||||
--color-success: oklch(52% 0.19 145);
|
||||
--color-success-content: oklch(98% 0.01 145);
|
||||
|
||||
--color-warning: oklch(72% 0.22 85);
|
||||
--color-warning-content: oklch(18% 0.04 85);
|
||||
|
||||
--color-error: oklch(55% 0.26 25);
|
||||
--color-error-content: oklch(98% 0.01 25);
|
||||
|
||||
/* Same sharp radii */
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
1
tailwind.css
Normal file
1
tailwind.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
Reference in New Issue
Block a user