10 Commits

Author SHA1 Message Date
Sharang Parnerkar
6067368ace style: fix cargo fmt in auth_check.rs
All checks were successful
CI / Format (push) Successful in 3s
CI / Format (pull_request) Successful in 3s
CI / Clippy (pull_request) Successful in 2m49s
CI / Security Audit (pull_request) Has been skipped
CI / Clippy (push) Successful in 2m53s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Deploy (push) Has been skipped
CI / Deploy (pull_request) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / E2E Tests (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:21:33 +01:00
Sharang Parnerkar
dc4cb40171 feat(sidebar): add compliance scanner link from env config
Some checks failed
CI / Format (push) Failing after 3s
CI / Clippy (pull_request) Successful in 2m58s
CI / Security Audit (pull_request) Has been skipped
CI / Deploy (push) Has been skipped
CI / Deploy (pull_request) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Clippy (push) Successful in 3m5s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Failing after 3s
CI / Tests (pull_request) Has been skipped
CI / E2E Tests (pull_request) Has been skipped
Read COMPLIANCE_SCANNER_URL from environment via ServiceUrls config and
surface it through AuthInfo to the sidebar. The link renders with a
shield icon and opens in a new tab, only when the URL is configured.
Includes i18n translations for all five locales.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:10:51 +01:00
Sharang Parnerkar
ca5da3c232 feat(ui): redesign landing page and update styling
Some checks failed
CI / Format (push) Successful in 22s
CI / Security Audit (push) Successful in 1m32s
CI / Tests (push) Successful in 3m32s
CI / Clippy (push) Successful in 2m29s
CI / Deploy (push) Successful in 2s
CI / E2E Tests (push) Failing after 31s
Overhaul landing page design with updated CSS, Tailwind config, and
i18n translations across all supported languages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 08:34:58 +01:00
Sharang Parnerkar
c9c5970971 fix(litellm): gate tests on server feature to fix cargo test --features web
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m56s
CI / Security Audit (push) Successful in 1m47s
CI / Tests (push) Successful in 4m4s
CI / Deploy (push) Successful in 4s
CI / E2E Tests (push) Failing after 30s
The litellm test module uses server-only structs (ActivityModelEntry,
SpendModelEntry) that are behind #[cfg(feature = "server")]. Gate the
test module with #[cfg(all(test, feature = "server"))] so
cargo test --features web compiles without errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:11:12 +01:00
fe4f8e84ae feat: replaced ollama with litellm (#18)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m53s
CI / Security Audit (push) Successful in 1m42s
CI / Tests (push) Failing after 3m59s
CI / Deploy (push) Has been skipped
CI / E2E Tests (push) Has been skipped
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #18
2026-02-26 17:52:47 +00:00
0deaaca848 feat: added langflow, langfuse and langgraph integrations (#17)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m48s
CI / Security Audit (push) Successful in 1m41s
CI / Tests (push) Successful in 4m8s
CI / Deploy (push) Successful in 5s
CI / E2E Tests (push) Failing after 19s
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #17
2026-02-25 20:08:48 +00:00
1d7aebf37c test: added more tests (#16)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m47s
CI / Security Audit (push) Successful in 1m35s
CI / Tests (push) Successful in 3m54s
CI / E2E Tests (push) Failing after 16s
CI / Deploy (push) Has been skipped
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #16
2026-02-25 10:01:56 +00:00
9085da9fae hotfix: hard-coded URL for chat in dashboard (#15)
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m53s
CI / Security Audit (push) Successful in 1m42s
CI / Tests (push) Successful in 4m11s
CI / Deploy (push) Successful in 3s
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #15
2026-02-24 11:52:59 +00:00
208450e618 feat: use librechat instead of own chat (#14)
All checks were successful
CI / Format (push) Successful in 2s
CI / Clippy (push) Successful in 2m48s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 4m11s
CI / Deploy (push) Successful in 4s
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #14
2026-02-24 10:45:41 +00:00
d814e22f9d feat(i18n): add internationalization with DE, FR, ES, PT translations (#12)
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 3m4s
CI / Security Audit (push) Successful in 1m39s
CI / Tests (push) Successful in 4m26s
CI / Deploy (push) Successful in 5s
Add a compile-time i18n system with 270 translation keys across 5 locales
(EN, DE, FR, ES, PT). Translations are embedded via include_str! and parsed
lazily into flat HashMaps with English fallback for missing keys.

- Add src/i18n module with Locale enum, t()/tw() lookup functions, and tests
- Add JSON translation files for all 5 locales under assets/i18n/
- Provide locale Signal via Dioxus context in App, persisted to localStorage
- Replace all hardcoded UI strings across 33 component/page files
- Add compact locale picker (globe icon + ISO alpha-2 code) in sidebar header
- Add click-outside backdrop dismissal for locale dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #12
2026-02-22 16:48:51 +00:00
108 changed files with 13847 additions and 3093 deletions

5
.cargo/config.toml Normal file
View File

@@ -0,0 +1,5 @@
[build]
# Use sccache as the rustc wrapper for compile caching.
# Falls back gracefully: if sccache is not installed, cargo will warn but
# still compile. Install with: cargo install sccache
rustc-wrapper = "sccache"

View File

@@ -34,15 +34,21 @@ MONGODB_DATABASE=certifai
SEARXNG_URL=http://localhost:8888
# ---------------------------------------------------------------------------
# Ollama LLM instance [OPTIONAL - defaults shown]
# LiteLLM proxy [OPTIONAL - defaults shown]
# ---------------------------------------------------------------------------
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b
LITELLM_URL=http://localhost:4000
LITELLM_MODEL=qwen3-32b
LITELLM_API_KEY=
# ---------------------------------------------------------------------------
# LibreChat (external chat via SSO) [OPTIONAL - default: http://localhost:3080]
# ---------------------------------------------------------------------------
LIBRECHAT_URL=http://localhost:3080
# ---------------------------------------------------------------------------
# LLM Providers (comma-separated list) [OPTIONAL]
# ---------------------------------------------------------------------------
LLM_PROVIDERS=ollama
LLM_PROVIDERS=litellm
# ---------------------------------------------------------------------------
# SMTP (transactional email) [OPTIONAL]
@@ -61,12 +67,18 @@ STRIPE_WEBHOOK_SECRET=
STRIPE_PUBLISHABLE_KEY=
# ---------------------------------------------------------------------------
# LangChain / LangGraph / Langfuse [OPTIONAL]
# LangChain / LangGraph / LangFlow / Langfuse [OPTIONAL]
# ---------------------------------------------------------------------------
LANGCHAIN_URL=
LANGGRAPH_URL=
LANGFLOW_URL=
LANGFUSE_URL=
# ---------------------------------------------------------------------------
# Compliance scanner (external tool, opens in new tab) [OPTIONAL]
# ---------------------------------------------------------------------------
COMPLIANCE_SCANNER_URL=
# ---------------------------------------------------------------------------
# Vector database [OPTIONAL]
# ---------------------------------------------------------------------------

View File

@@ -11,6 +11,10 @@ on:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
# sccache caches compilation artifacts within a job so that compiling
# both --features server and --features web shares common crate work.
RUSTC_WRAPPER: /usr/local/bin/sccache
SCCACHE_DIR: /tmp/sccache
# Cancel in-progress runs for the same branch/PR
concurrency:
@@ -34,7 +38,10 @@ jobs:
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- run: rustup component add rustfmt
# Format check does not compile, so sccache is not needed here.
- run: cargo fmt --check
env:
RUSTC_WRAPPER: ""
clippy:
name: Clippy
@@ -48,12 +55,21 @@ jobs:
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Install sccache
run: |
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
chmod +x /usr/local/bin/sccache
- run: rustup component add clippy
# Lint both feature sets independently
# Lint both feature sets independently.
# sccache deduplicates shared crates between the two compilations.
- 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
- name: Show sccache stats
run: sccache --show-stats
if: always()
audit:
name: Security Audit
@@ -69,7 +85,11 @@ jobs:
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- run: cargo install cargo-audit
env:
RUSTC_WRAPPER: ""
- run: cargo audit
env:
RUSTC_WRAPPER: ""
# ---------------------------------------------------------------------------
# Stage 2: Tests (only after all quality checks pass)
@@ -87,10 +107,151 @@ jobs:
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Install sccache
run: |
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
chmod +x /usr/local/bin/sccache
- name: Run tests (server)
run: cargo test --features server --no-default-features
- name: Run tests (web)
run: cargo test --features web --no-default-features
- name: Show sccache stats
run: sccache --show-stats
if: always()
# ---------------------------------------------------------------------------
# Stage 4: E2E tests (only on main, after deploy)
# ---------------------------------------------------------------------------
e2e:
name: E2E Tests
runs-on: docker
needs: [deploy]
if: github.ref == 'refs/heads/main'
container:
image: rust:1.89-bookworm
# MongoDB and SearXNG can start immediately (no repo files needed).
# Keycloak requires realm-export.json from the repo, so it is started
# manually after checkout via docker CLI.
services:
mongo:
image: mongo:latest
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
ports:
- 27017:27017
searxng:
image: searxng/searxng:latest
env:
SEARXNG_BASE_URL: http://localhost:8888
ports:
- 8888:8080
env:
KEYCLOAK_URL: http://localhost:8080
KEYCLOAK_REALM: certifai
KEYCLOAK_CLIENT_ID: certifai-dashboard
MONGODB_URI: mongodb://root:example@mongo:27017
MONGODB_DATABASE: certifai
SEARXNG_URL: http://searxng:8080
LANGGRAPH_URL: ""
LANGFLOW_URL: ""
LANGFUSE_URL: ""
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: Install system dependencies
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
unzip curl docker.io \
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 \
libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
libcairo2 libasound2 libatspi2.0-0 libxshmfence1
- name: Start Keycloak
run: |
docker run -d --name ci-keycloak --network host \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-e KC_DB=dev-mem \
-e KC_HEALTH_ENABLED=true \
-v "$PWD/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro" \
-v "$PWD/keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro" \
quay.io/keycloak/keycloak:26.0 start-dev --import-realm
echo "Waiting for Keycloak..."
for i in $(seq 1 60); do
if curl -sf http://localhost:8080/realms/certifai > /dev/null 2>&1; then
echo "Keycloak is ready"
break
fi
if [ "$i" -eq 60 ]; then
echo "Keycloak failed to start within 60s"
docker logs ci-keycloak
exit 1
fi
sleep 2
done
- name: Install sccache
run: |
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
chmod +x /usr/local/bin/sccache
- name: Install dioxus-cli
run: cargo install dioxus-cli --locked
- name: Install bun
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
- name: Install Playwright
run: |
export PATH="$HOME/.bun/bin:$PATH"
bun install
bunx playwright install chromium
- name: Build app
run: dx build --release
- name: Start app and run E2E tests
run: |
export PATH="$HOME/.bun/bin:$PATH"
# Start the app in the background
dx serve --release --port 8000 &
APP_PID=$!
# Wait for the app to be ready
echo "Waiting for app to start..."
for i in $(seq 1 60); do
if curl -sf http://localhost:8000 > /dev/null 2>&1; then
echo "App is ready"
break
fi
if [ "$i" -eq 60 ]; then
echo "App failed to start within 60s"
exit 1
fi
sleep 1
done
BASE_URL=http://localhost:8000 bunx playwright test --reporter=list
kill "$APP_PID" 2>/dev/null || true
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Cleanup Keycloak
if: always()
run: docker rm -f ci-keycloak 2>/dev/null || true
- name: Show sccache stats
run: sccache --show-stats
if: always()
# ---------------------------------------------------------------------------
# Stage 3: Deploy (only after tests pass, only on main)
@@ -108,4 +269,3 @@ jobs:
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ keycloak/*
node_modules/
searxng/
# Playwright
e2e/.auth/
playwright-report/
test-results/

66
Cargo.lock generated
View File

@@ -773,9 +773,11 @@ dependencies = [
"dioxus-sdk",
"dotenvy",
"futures",
"js-sys",
"maud",
"mongodb",
"petname",
"pretty_assertions",
"pulldown-cmark",
"rand 0.10.0",
"reqwest 0.13.2",
@@ -783,6 +785,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"serial_test",
"sha2",
"thiserror 2.0.18",
"time",
@@ -882,6 +885,12 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -3246,6 +3255,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -3823,6 +3842,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.28"
@@ -3862,6 +3890,12 @@ dependencies = [
"untrusted",
]
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "secrecy"
version = "0.10.3"
@@ -4082,6 +4116,32 @@ dependencies = [
"syn 2.0.116",
]
[[package]]
name = "serial_test"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
dependencies = [
"futures-executor",
"futures-util",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
@@ -5683,6 +5743,12 @@ version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yazi"
version = "0.1.6"

View File

@@ -61,6 +61,7 @@ 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 }
js-sys = { version = "0.3", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
web-sys = { version = "0.3", optional = true, features = [
"Clipboard",
@@ -91,7 +92,7 @@ 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", "dep:wasm-bindgen", "dep:js-sys"]
server = [
"dioxus/server",
"dep:axum",
@@ -112,6 +113,10 @@ server = [
"dep:bytes",
]
[dev-dependencies]
pretty_assertions = "1.4"
serial_test = "3.2"
[[bin]]
name = "dashboard"
path = "bin/main.rs"

View File

@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Stage 1: Generate dependency recipe for caching
FROM rust:1.89-bookworm AS chef
RUN cargo install cargo-chef
@@ -15,16 +16,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev curl unzip \
&& rm -rf /var/lib/apt/lists/*
# Install sccache for compile caching across Docker builds
RUN curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache \
&& chmod +x /usr/local/bin/sccache
ENV RUSTC_WRAPPER=/usr/local/bin/sccache
ENV SCCACHE_DIR=/tmp/sccache
# 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
RUN --mount=type=cache,target=/tmp/sccache \
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
RUN --mount=type=cache,target=/tmp/sccache \
cargo chef cook --release --recipe-path recipe.json
# Copy source and build
COPY . .
@@ -33,7 +44,8 @@ COPY . .
RUN bun install --frozen-lockfile
# Bundle the fullstack application
RUN dx bundle --release --fullstack
RUN --mount=type=cache,target=/tmp/sccache \
dx bundle --release --fullstack
# Stage 3: Minimal runtime image
FROM debian:bookworm-slim AS runtime

295
assets/i18n/de.json Normal file
View File

@@ -0,0 +1,295 @@
{
"common": {
"loading": "Wird geladen...",
"cancel": "Abbrechen",
"save": "Speichern",
"delete": "Loeschen",
"send": "Senden",
"close": "Schliessen",
"login": "Anmelden",
"logout": "Abmelden",
"on": "EIN",
"off": "AUS",
"online": "Online",
"offline": "Offline",
"settings": "Einstellungen",
"search": "Suche",
"rename": "Umbenennen",
"copy": "Kopieren",
"share": "Teilen",
"edit": "Bearbeiten",
"get_started": "Jetzt starten",
"coming_soon": "Demnachst verfuegbar",
"back_to_home": "Zurueck zur Startseite",
"privacy_policy": "Datenschutzerklaerung",
"impressum": "Impressum",
"chunks": "Abschnitte",
"upload_file": "Datei hochladen",
"eur_per_month": "EUR / Monat",
"up_to_seats": "Bis zu {n} Plaetze",
"unlimited_seats": "Unbegrenzte Plaetze",
"set": "Gesetzt",
"not_set": "Nicht gesetzt",
"log_in": "Anmelden",
"features": "Funktionen",
"how_it_works": "So funktioniert es"
},
"nav": {
"dashboard": "Dashboard",
"providers": "Provider",
"chat": "Chat",
"developer": "Entwickler",
"organization": "Organisation",
"switch_light": "Zum hellen Modus wechseln",
"switch_dark": "Zum dunklen Modus wechseln",
"github": "GitHub",
"agents": "Agenten",
"flow": "Flow",
"analytics": "Analytics",
"pricing": "Preise",
"compliance": "Compliance"
},
"auth": {
"redirecting_login": "Weiterleitung zur Anmeldung...",
"redirecting_secure": "Weiterleitung zur sicheren Anmeldeseite...",
"auth_error": "Authentifizierungsfehler: {msg}",
"log_in": "Anmelden"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "KI-Nachrichten und Neuigkeiten",
"topic_placeholder": "Themenname...",
"litellm_settings": "LiteLLM-Einstellungen",
"settings_hint": "Leer lassen, um LITELLM_URL / LITELLM_MODEL aus .env zu verwenden",
"litellm_url": "LiteLLM-URL",
"litellm_url_placeholder": "Verwendet LITELLM_URL aus .env",
"model": "Modell",
"model_placeholder": "Verwendet LITELLM_MODEL aus .env",
"searching": "Suche laeuft...",
"search_failed": "Suche fehlgeschlagen: {e}",
"litellm_status": "LiteLLM-Status",
"trending": "Im Trend",
"recent_searches": "Letzte Suchen"
},
"providers": {
"title": "Provider",
"subtitle": "Konfigurieren Sie Ihre LLM- und Embedding-Backends",
"provider": "Provider",
"model": "Modell",
"embedding_model": "Embedding-Modell",
"api_key": "API-Schluessel",
"api_key_placeholder": "API-Schluessel eingeben...",
"save_config": "Konfiguration speichern",
"config_saved": "Konfiguration gespeichert.",
"active_config": "Aktive Konfiguration",
"embedding": "Embedding"
},
"developer": {
"agents_title": "Agent Builder",
"agents_desc": "Erstellen und verwalten Sie KI-Agenten mit LangGraph. Erstellen Sie mehrstufige Schlussfolgerungspipelines, werkzeugnutzende Agenten und autonome Workflows.",
"launch_agents": "Agent Builder starten",
"flow_title": "Flow Builder",
"flow_desc": "Entwerfen Sie visuelle KI-Workflows mit LangFlow. Ziehen Sie Knoten per Drag-and-Drop, um Datenverarbeitungspipelines, Prompt-Ketten und Integrationsflows zu erstellen.",
"launch_flow": "Flow Builder starten",
"analytics_title": "Analytics und Observability",
"analytics_desc": "Ueberwachen und analysieren Sie Ihre KI-Pipelines mit LangFuse. Verfolgen Sie Token-Verbrauch, Latenz, Kosten und Qualitaetsmetriken ueber alle Ihre Deployments hinweg.",
"launch_analytics": "LangFuse starten",
"total_requests": "Anfragen gesamt",
"avg_latency": "Durchschn. Latenz",
"tokens_used": "Verbrauchte Token",
"error_rate": "Fehlerrate",
"not_configured": "Nicht konfiguriert",
"open_new_tab": "In neuem Tab oeffnen",
"agents_status_connected": "Verbunden",
"agents_status_not_connected": "Nicht verbunden",
"agents_config_hint": "Setzen Sie LANGGRAPH_URL in .env, um eine Verbindung herzustellen",
"agents_quick_start": "Schnellstart",
"agents_docs": "Dokumentation",
"agents_docs_desc": "Offizielle LangGraph-Dokumentation und API-Anleitungen.",
"agents_getting_started": "Erste Schritte",
"agents_getting_started_desc": "Schritt-fuer-Schritt-Anleitung zum Erstellen Ihres ersten Agenten.",
"agents_github": "GitHub",
"agents_github_desc": "Quellcode, Issues und Community-Beitraege.",
"agents_examples": "Beispiele",
"agents_examples_desc": "Einsatzbereite Vorlagen und Beispielprojekte fuer Agenten.",
"agents_api_ref": "API-Referenz",
"agents_api_ref_desc": "Lokale Swagger-Dokumentation fuer Ihre LangGraph-Instanz.",
"agents_running_title": "Laufende Agenten",
"agents_none": "Keine Agenten registriert. Stellen Sie einen Assistenten in LangGraph bereit, um ihn hier zu sehen.",
"agents_col_name": "Name",
"agents_col_id": "ID",
"agents_col_description": "Beschreibung",
"agents_col_status": "Status",
"analytics_status_connected": "Verbunden",
"analytics_status_not_connected": "Nicht verbunden",
"analytics_config_hint": "Setzen Sie LANGFUSE_URL in .env, um eine Verbindung herzustellen",
"analytics_sso_hint": "Langfuse nutzt Keycloak-SSO. Sie werden automatisch mit Ihrem CERTifAI-Konto angemeldet.",
"analytics_quick_actions": "Schnellaktionen",
"analytics_traces": "Traces",
"analytics_traces_desc": "Alle LLM-Aufrufe, Latenzen und Token-Verbrauch anzeigen und filtern.",
"analytics_dashboard": "Dashboard",
"analytics_dashboard_desc": "Ueberblick ueber Kosten, Qualitaetsmetriken und Nutzungstrends."
},
"org": {
"title": "Organisation",
"subtitle": "Mitglieder und Abrechnung verwalten",
"invite_member": "Mitglied einladen",
"seats_used": "Belegte Plaetze",
"of_tokens": "von {limit} Token",
"cycle_ends": "Zyklusende",
"name": "Name",
"email": "E-Mail",
"role": "Rolle",
"joined": "Beigetreten",
"invite_title": "Neues Mitglied einladen",
"email_address": "E-Mail-Adresse",
"email_placeholder": "kollege@firma.de",
"send_invite": "Einladung senden",
"total_spend": "Gesamtausgaben",
"total_tokens": "Tokens gesamt",
"model_usage": "Nutzung nach Modell",
"model": "Modell",
"tokens": "Tokens",
"spend": "Ausgaben",
"usage_unavailable": "Nutzungsdaten nicht verfuegbar",
"loading_usage": "Nutzungsdaten werden geladen...",
"prompt_tokens": "Prompt-Tokens",
"completion_tokens": "Antwort-Tokens",
"pricing_title": "Preise",
"pricing_subtitle": "Waehlen Sie den passenden Plan fuer Ihre Organisation"
},
"pricing": {
"starter": "Starter",
"team": "Team",
"enterprise": "Enterprise",
"up_to_users": "Bis zu {n} Benutzer",
"unlimited_users": "Unbegrenzte Benutzer",
"llm_provider_1": "1 LLM-Provider",
"all_providers": "Alle LLM-Provider",
"tokens_100k": "100K Token/Monat",
"tokens_1m": "1M Token/Monat",
"unlimited_tokens": "Unbegrenzte Token",
"community_support": "Community-Support",
"priority_support": "Priorisierter Support",
"dedicated_support": "Dedizierter Support",
"basic_analytics": "Basis-Analytics",
"advanced_analytics": "Erweiterte Analytics",
"full_observability": "Volle Observability",
"custom_mcp": "Benutzerdefinierte MCP-Werkzeuge",
"sso": "SSO-Integration",
"custom_integrations": "Benutzerdefinierte Integrationen",
"sla": "SLA-Garantie",
"on_premise": "On-Premise-Bereitstellung"
},
"landing": {
"badge": "Datenschutzorientierte GenAI-Infrastruktur",
"hero_title_1": "Ihre KI. Ihre Daten.",
"hero_title_2": "Ihre Infrastruktur.",
"hero_subtitle": "Selbst gehostete, GDPR-konforme Plattform fuer generative KI fuer Unternehmen, die bei der Datensouveraenitaet keine Kompromisse eingehen. Betreiben Sie LLMs, Agenten und MCP-Server nach Ihren eigenen Regeln.",
"learn_more": "Mehr erfahren",
"social_proof": "Entwickelt fuer Unternehmen, die ",
"data_sovereignty": "Datensouveraenitaet",
"on_premise": "On-Premise",
"compliant": "Konform",
"data_residency": "Datenresidenz",
"third_party": "Weitergabe an Dritte",
"features_title": "Alles, was Sie brauchen",
"features_subtitle": "Ein vollstaendiger, selbst gehosteter GenAI-Stack unter Ihrer vollen Kontrolle.",
"feat_infra_title": "Selbst gehostete Infrastruktur",
"feat_infra_desc": "Betreiben Sie die Plattform auf Ihrer eigenen Hardware oder in Ihrer privaten Cloud. Volle Kontrolle ueber Ihren KI-Stack ohne externe Abhaengigkeiten.",
"feat_gdpr_title": "GDPR-konform",
"feat_gdpr_desc": "EU-Datenresidenz garantiert. Ihre Daten verlassen niemals Ihre Infrastruktur und werden nicht an Dritte weitergegeben.",
"feat_llm_title": "LLM-Verwaltung",
"feat_llm_desc": "Stellen Sie mehrere Sprachmodelle bereit, ueberwachen und verwalten Sie diese. Wechseln Sie zwischen Modellen ohne Ausfallzeit.",
"feat_agent_title": "Agent Builder",
"feat_agent_desc": "Erstellen Sie benutzerdefinierte KI-Agenten mit integriertem Langchain und Langfuse fuer volle Observability und Kontrolle.",
"feat_mcp_title": "MCP-Server-Verwaltung",
"feat_mcp_desc": "Verwalten Sie Model Context Protocol-Server, um Ihre KI-Faehigkeiten mit externen Werkzeugintegrationen zu erweitern.",
"feat_api_title": "API-Schluessel-Verwaltung",
"feat_api_desc": "Generieren Sie API-Schluessel, verfolgen Sie die Nutzung pro Platz und setzen Sie feingranulare Berechtigungen fuer jede Integration.",
"how_title": "In wenigen Minuten einsatzbereit",
"how_subtitle": "Drei Schritte zur souveraenen KI-Infrastruktur.",
"step_deploy": "Bereitstellen",
"step_deploy_desc": "Installieren Sie CERTifAI auf Ihrer Infrastruktur mit einem einzigen Befehl. Unterstuetzt Docker, Kubernetes und Bare-Metal.",
"step_configure": "Konfigurieren",
"step_configure_desc": "Verbinden Sie Ihren Identitaets-Provider, waehlen Sie Ihre Modelle und richten Sie Teamberechtigungen ueber das Admin-Dashboard ein.",
"step_scale": "Skalieren",
"step_scale_desc": "Fuegen Sie Benutzer hinzu, stellen Sie weitere Modelle bereit und integrieren Sie Ihre bestehenden Werkzeuge ueber API-Schluessel und MCP-Server.",
"cta_title": "Bereit, die Kontrolle ueber Ihre KI-Infrastruktur zu uebernehmen?",
"cta_subtitle": "Beginnen Sie noch heute mit dem Betrieb souveraener GenAI. Keine Kreditkarte erforderlich.",
"get_started_free": "Kostenlos starten",
"footer_tagline": "Souveraene GenAI-Infrastruktur fuer Unternehmen.",
"product": "Produkt",
"legal": "Rechtliches",
"resources": "Ressourcen",
"documentation": "Dokumentation",
"api_reference": "API-Referenz",
"support": "Support",
"copyright": "2026 CERTifAI. Alle Rechte vorbehalten.",
"pill_gdpr": "DSGVO-Nativ",
"pill_self_hosted": "Selbst gehostet",
"pill_eu": "EU-Souveraen",
"preview_models": "Aktive Modelle",
"preview_tokens": "Tokens / Monat",
"preview_spend": "Gesamtausgaben"
},
"article": {
"read_original": "Originalartikel lesen",
"summarizing": "Wird zusammengefasst...",
"summarized_with_ai": "Mit KI zusammengefasst",
"ask_followup": "Stellen Sie eine Anschlussfrage..."
},
"impressum": {
"title": "Impressum",
"info_tmg": "Angaben gemaess 5 TMG",
"company": "CERTifAI GmbH",
"address_street": "Musterstrasse 1",
"address_city": "10115 Berlin",
"address_country": "Deutschland",
"represented_by": "Vertreten durch",
"managing_director": "Geschaeftsfuehrer: [Name]",
"contact": "Kontakt",
"email": "E-Mail: info@certifai.example",
"phone": "Telefon: +49 (0) 30 1234567",
"commercial_register": "Handelsregister",
"registered_at": "Eingetragen beim: Amtsgericht Berlin-Charlottenburg",
"registration_number": "Registernummer: HRB XXXXXX",
"vat_id": "Umsatzsteuer-ID",
"vat_number": "Umsatzsteuer-Identifikationsnummer gemaess 27a UStG: DE XXXXXXXXX",
"responsible_content": "Verantwortlich fuer den Inhalt nach 55 Abs. 2 RStV"
},
"privacy": {
"title": "Datenschutzerklaerung",
"last_updated": "Zuletzt aktualisiert: Februar 2026",
"intro_title": "1. Einleitung",
"intro_text": "Die CERTifAI GmbH (\"wir\", \"unser\", \"uns\") verpflichtet sich zum Schutz Ihrer personenbezogenen Daten. Diese Datenschutzerklaerung erlaeutert, wie wir Ihre Informationen erheben, verwenden und schuetzen, wenn Sie unsere Plattform nutzen.",
"controller_title": "2. Verantwortlicher",
"controller_address": "Musterstrasse 1, 10115 Berlin, Deutschland",
"controller_email": "E-Mail: privacy@certifai.example",
"data_title": "3. Erhobene Daten",
"data_intro": "Wir erheben nur die fuer die Erbringung unserer Dienste mindestens erforderlichen Daten:",
"data_account_label": "Kontodaten: ",
"data_account_text": "Name, E-Mail-Adresse und Organisationsangaben, die bei der Registrierung angegeben werden.",
"data_usage_label": "Nutzungsdaten: ",
"data_usage_text": "API-Aufrufprotokolle, Token-Zaehler und Funktionsnutzungsmetriken fuer Abrechnung und Analyse.",
"data_technical_label": "Technische Daten: ",
"data_technical_text": "IP-Adressen, Browsertyp und Sitzungskennungen fuer Sicherheit und Plattformstabilitaet.",
"use_title": "4. Verwendung Ihrer Daten",
"use_1": "Zur Bereitstellung und Wartung der CERTifAI-Plattform",
"use_2": "Zur Verwaltung Ihres Kontos und Abonnements",
"use_3": "Zur Mitteilung von Dienstaktualisierungen und Sicherheitshinweisen",
"use_4": "Zur Erfuellung gesetzlicher Verpflichtungen",
"storage_title": "5. Datenspeicherung und Datensouveraenitaet",
"storage_text": "CERTifAI ist eine selbst gehostete Plattform. Alle KI-Workloads, Modelldaten und Inferenzergebnisse verbleiben vollstaendig innerhalb Ihrer eigenen Infrastruktur. Wir greifen nicht auf Ihre KI-Daten zu, speichern oder verarbeiten diese nicht auf unseren Servern.",
"rights_title": "6. Ihre Rechte (GDPR)",
"rights_intro": "Gemaess der GDPR haben Sie das Recht auf:",
"rights_access": "Auskunft ueber Ihre personenbezogenen Daten",
"rights_rectify": "Berichtigung unrichtiger Daten",
"rights_erasure": "Loeschung Ihrer Daten",
"rights_restrict": "Einschraenkung oder Widerspruch gegen die Verarbeitung",
"rights_portability": "Datenuebertragbarkeit",
"rights_complaint": "Beschwerde bei einer Aufsichtsbehoerde",
"contact_title": "7. Kontakt",
"contact_text": "Fuer datenschutzbezogene Anfragen kontaktieren Sie uns unter privacy@certifai.example."
}
}

295
assets/i18n/en.json Normal file
View File

@@ -0,0 +1,295 @@
{
"common": {
"loading": "Loading...",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"send": "Send",
"close": "Close",
"login": "Login",
"logout": "Logout",
"on": "ON",
"off": "OFF",
"online": "Online",
"offline": "Offline",
"settings": "Settings",
"search": "Search",
"rename": "Rename",
"copy": "Copy",
"share": "Share",
"edit": "Edit",
"get_started": "Get Started",
"coming_soon": "Coming Soon",
"back_to_home": "Back to Home",
"privacy_policy": "Privacy Policy",
"impressum": "Impressum",
"chunks": "chunks",
"upload_file": "Upload File",
"eur_per_month": "EUR / month",
"up_to_seats": "Up to {n} seats",
"unlimited_seats": "Unlimited seats",
"set": "Set",
"not_set": "Not set",
"log_in": "Log In",
"features": "Features",
"how_it_works": "How It Works"
},
"nav": {
"dashboard": "Dashboard",
"providers": "Providers",
"chat": "Chat",
"developer": "Developer",
"organization": "Organization",
"switch_light": "Switch to light mode",
"switch_dark": "Switch to dark mode",
"github": "GitHub",
"agents": "Agents",
"flow": "Flow",
"analytics": "Analytics",
"pricing": "Pricing",
"compliance": "Compliance"
},
"auth": {
"redirecting_login": "Redirecting to login...",
"redirecting_secure": "Redirecting to secure login page...",
"auth_error": "Authentication error: {msg}",
"log_in": "Login"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "AI news and updates",
"topic_placeholder": "Topic name...",
"litellm_settings": "LiteLLM Settings",
"settings_hint": "Leave empty to use LITELLM_URL / LITELLM_MODEL from .env",
"litellm_url": "LiteLLM URL",
"litellm_url_placeholder": "Uses LITELLM_URL from .env",
"model": "Model",
"model_placeholder": "Uses LITELLM_MODEL from .env",
"searching": "Searching...",
"search_failed": "Search failed: {e}",
"litellm_status": "LiteLLM Status",
"trending": "Trending",
"recent_searches": "Recent Searches"
},
"providers": {
"title": "Providers",
"subtitle": "Configure your LLM and embedding backends",
"provider": "Provider",
"model": "Model",
"embedding_model": "Embedding Model",
"api_key": "API Key",
"api_key_placeholder": "Enter API key...",
"save_config": "Save Configuration",
"config_saved": "Configuration saved.",
"active_config": "Active Configuration",
"embedding": "Embedding"
},
"developer": {
"agents_title": "Agent Builder",
"agents_desc": "Build and manage AI agents with LangGraph. Create multi-step reasoning pipelines, tool-using agents, and autonomous workflows.",
"launch_agents": "Launch Agent Builder",
"flow_title": "Flow Builder",
"flow_desc": "Design visual AI workflows with LangFlow. Drag-and-drop nodes to create data processing pipelines, prompt chains, and integration flows.",
"launch_flow": "Launch Flow Builder",
"analytics_title": "Analytics & Observability",
"analytics_desc": "Monitor and analyze your AI pipelines with LangFuse. Track token usage, latency, costs, and quality metrics across all your deployments.",
"launch_analytics": "Launch LangFuse",
"total_requests": "Total Requests",
"avg_latency": "Avg Latency",
"tokens_used": "Tokens Used",
"error_rate": "Error Rate",
"not_configured": "Not Configured",
"open_new_tab": "Open in New Tab",
"agents_status_connected": "Connected",
"agents_status_not_connected": "Not Connected",
"agents_config_hint": "Set LANGGRAPH_URL in .env to connect",
"agents_quick_start": "Quick Start",
"agents_docs": "Documentation",
"agents_docs_desc": "Official LangGraph documentation and API guides.",
"agents_getting_started": "Getting Started",
"agents_getting_started_desc": "Step-by-step tutorial to build your first agent.",
"agents_github": "GitHub",
"agents_github_desc": "Source code, issues, and community contributions.",
"agents_examples": "Examples",
"agents_examples_desc": "Ready-to-use templates and example agent projects.",
"agents_api_ref": "API Reference",
"agents_api_ref_desc": "Local Swagger docs for your LangGraph instance.",
"agents_running_title": "Running Agents",
"agents_none": "No agents registered. Deploy an assistant to LangGraph to see it here.",
"agents_col_name": "Name",
"agents_col_id": "ID",
"agents_col_description": "Description",
"agents_col_status": "Status",
"analytics_status_connected": "Connected",
"analytics_status_not_connected": "Not Connected",
"analytics_config_hint": "Set LANGFUSE_URL in .env to connect",
"analytics_sso_hint": "Langfuse uses Keycloak SSO. You will be signed in automatically with your CERTifAI account.",
"analytics_quick_actions": "Quick Actions",
"analytics_traces": "Traces",
"analytics_traces_desc": "View and filter all LLM call traces, latencies, and token usage.",
"analytics_dashboard": "Dashboard",
"analytics_dashboard_desc": "Overview of costs, quality metrics, and usage trends."
},
"org": {
"title": "Organization",
"subtitle": "Manage members and billing",
"invite_member": "Invite Member",
"seats_used": "Seats Used",
"of_tokens": "of {limit} tokens",
"cycle_ends": "Cycle Ends",
"name": "Name",
"email": "Email",
"role": "Role",
"joined": "Joined",
"invite_title": "Invite New Member",
"email_address": "Email Address",
"email_placeholder": "colleague@company.com",
"send_invite": "Send Invite",
"total_spend": "Total Spend",
"total_tokens": "Total Tokens",
"model_usage": "Usage by Model",
"model": "Model",
"tokens": "Tokens",
"spend": "Spend",
"usage_unavailable": "Usage data unavailable",
"loading_usage": "Loading usage data...",
"prompt_tokens": "Prompt Tokens",
"completion_tokens": "Completion Tokens",
"pricing_title": "Pricing",
"pricing_subtitle": "Choose the plan that fits your organization"
},
"pricing": {
"starter": "Starter",
"team": "Team",
"enterprise": "Enterprise",
"up_to_users": "Up to {n} users",
"unlimited_users": "Unlimited users",
"llm_provider_1": "1 LLM provider",
"all_providers": "All LLM providers",
"tokens_100k": "100K tokens/month",
"tokens_1m": "1M tokens/month",
"unlimited_tokens": "Unlimited tokens",
"community_support": "Community support",
"priority_support": "Priority support",
"dedicated_support": "Dedicated support",
"basic_analytics": "Basic analytics",
"advanced_analytics": "Advanced analytics",
"full_observability": "Full observability",
"custom_mcp": "Custom MCP tools",
"sso": "SSO integration",
"custom_integrations": "Custom integrations",
"sla": "SLA guarantee",
"on_premise": "On-premise deployment"
},
"landing": {
"badge": "Privacy-First GenAI Infrastructure",
"hero_title_1": "Your AI. Your Data.",
"hero_title_2": "Your Infrastructure.",
"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.",
"learn_more": "Learn More",
"social_proof": "Built for enterprises that value ",
"data_sovereignty": "data sovereignty",
"on_premise": "On-Premise",
"compliant": "Compliant",
"data_residency": "Data Residency",
"third_party": "Third-Party Sharing",
"features_title": "Everything You Need",
"features_subtitle": "A complete, self-hosted GenAI stack under your full control.",
"feat_infra_title": "Self-Hosted Infrastructure",
"feat_infra_desc": "Deploy on your own hardware or private cloud. Full control over your AI stack with no external dependencies.",
"feat_gdpr_title": "GDPR Compliant",
"feat_gdpr_desc": "EU data residency guaranteed. Your data never leaves your infrastructure or gets shared with third parties.",
"feat_llm_title": "LLM Management",
"feat_llm_desc": "Deploy, monitor, and manage multiple language models. Switch between models with zero downtime.",
"feat_agent_title": "Agent Builder",
"feat_agent_desc": "Create custom AI agents with integrated Langchain and Langfuse for full observability and control.",
"feat_mcp_title": "MCP Server Management",
"feat_mcp_desc": "Manage Model Context Protocol servers to extend your AI capabilities with external tool integrations.",
"feat_api_title": "API Key Management",
"feat_api_desc": "Generate API keys, track usage per seat, and set fine-grained permissions for every integration.",
"how_title": "Up and Running in Minutes",
"how_subtitle": "Three steps to sovereign AI infrastructure.",
"step_deploy": "Deploy",
"step_deploy_desc": "Install CERTifAI on your infrastructure with a single command. Supports Docker, Kubernetes, and bare metal.",
"step_configure": "Configure",
"step_configure_desc": "Connect your identity provider, select your models, and set up team permissions through the admin dashboard.",
"step_scale": "Scale",
"step_scale_desc": "Add users, deploy more models, and integrate with your existing tools via API keys and MCP servers.",
"cta_title": "Ready to take control of your AI infrastructure?",
"cta_subtitle": "Start deploying sovereign GenAI today. No credit card required.",
"get_started_free": "Get Started Free",
"footer_tagline": "Sovereign GenAI infrastructure for enterprises.",
"product": "Product",
"legal": "Legal",
"resources": "Resources",
"documentation": "Documentation",
"api_reference": "API Reference",
"support": "Support",
"copyright": "2026 CERTifAI. All rights reserved.",
"pill_gdpr": "GDPR Native",
"pill_self_hosted": "Self-Hosted",
"pill_eu": "EU Sovereign",
"preview_models": "Active Models",
"preview_tokens": "Tokens / Month",
"preview_spend": "Total Spend"
},
"article": {
"read_original": "Read original article",
"summarizing": "Summarizing...",
"summarized_with_ai": "Summarized with AI",
"ask_followup": "Ask a follow-up question..."
},
"impressum": {
"title": "Impressum",
"info_tmg": "Information according to 5 TMG",
"company": "CERTifAI GmbH",
"address_street": "Musterstrasse 1",
"address_city": "10115 Berlin",
"address_country": "Germany",
"represented_by": "Represented by",
"managing_director": "Managing Director: [Name]",
"contact": "Contact",
"email": "Email: info@certifai.example",
"phone": "Phone: +49 (0) 30 1234567",
"commercial_register": "Commercial Register",
"registered_at": "Registered at: Amtsgericht Berlin-Charlottenburg",
"registration_number": "Registration number: HRB XXXXXX",
"vat_id": "VAT ID",
"vat_number": "VAT identification number according to 27a UStG: DE XXXXXXXXX",
"responsible_content": "Responsible for content according to 55 Abs. 2 RStV"
},
"privacy": {
"title": "Privacy Policy",
"last_updated": "Last updated: February 2026",
"intro_title": "1. Introduction",
"intro_text": "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.",
"controller_title": "2. Data Controller",
"controller_address": "Musterstrasse 1, 10115 Berlin, Germany",
"controller_email": "Email: privacy@certifai.example",
"data_title": "3. Data We Collect",
"data_intro": "We collect only the minimum data necessary to provide our services:",
"data_account_label": "Account data: ",
"data_account_text": "Name, email address, and organization details provided during registration.",
"data_usage_label": "Usage data: ",
"data_usage_text": "API call logs, token counts, and feature usage metrics for billing and analytics.",
"data_technical_label": "Technical data: ",
"data_technical_text": "IP addresses, browser type, and session identifiers for security and platform stability.",
"use_title": "4. How We Use Your Data",
"use_1": "To provide and maintain the CERTifAI platform",
"use_2": "To manage your account and subscription",
"use_3": "To communicate service updates and security notices",
"use_4": "To comply with legal obligations",
"storage_title": "5. Data Storage and Sovereignty",
"storage_text": "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.",
"rights_title": "6. Your Rights (GDPR)",
"rights_intro": "Under the GDPR, you have the right to:",
"rights_access": "Access your personal data",
"rights_rectify": "Rectify inaccurate data",
"rights_erasure": "Request erasure of your data",
"rights_restrict": "Restrict or object to processing",
"rights_portability": "Data portability",
"rights_complaint": "Lodge a complaint with a supervisory authority",
"contact_title": "7. Contact",
"contact_text": "For privacy-related inquiries, contact us at privacy@certifai.example."
}
}

295
assets/i18n/es.json Normal file
View File

@@ -0,0 +1,295 @@
{
"common": {
"loading": "Cargando...",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"send": "Enviar",
"close": "Cerrar",
"login": "Iniciar sesion",
"logout": "Cerrar sesion",
"on": "ACTIVADO",
"off": "DESACTIVADO",
"online": "En linea",
"offline": "Sin conexion",
"settings": "Configuracion",
"search": "Buscar",
"rename": "Renombrar",
"copy": "Copiar",
"share": "Compartir",
"edit": "Editar",
"get_started": "Comenzar",
"coming_soon": "Proximamente",
"back_to_home": "Volver al inicio",
"privacy_policy": "Politica de privacidad",
"impressum": "Aviso legal",
"chunks": "fragmentos",
"upload_file": "Subir archivo",
"eur_per_month": "EUR / mes",
"up_to_seats": "Hasta {n} puestos",
"unlimited_seats": "Puestos ilimitados",
"set": "Configurado",
"not_set": "No configurado",
"log_in": "Iniciar sesion",
"features": "Funcionalidades",
"how_it_works": "Como funciona"
},
"nav": {
"dashboard": "Panel de control",
"providers": "Proveedores",
"chat": "Chat",
"developer": "Desarrollador",
"organization": "Organizacion",
"switch_light": "Cambiar a modo claro",
"switch_dark": "Cambiar a modo oscuro",
"github": "GitHub",
"agents": "Agentes",
"flow": "Flujo",
"analytics": "Estadisticas",
"pricing": "Precios",
"compliance": "Cumplimiento"
},
"auth": {
"redirecting_login": "Redirigiendo al inicio de sesion...",
"redirecting_secure": "Redirigiendo a la pagina de inicio de sesion segura...",
"auth_error": "Error de autenticacion: {msg}",
"log_in": "Iniciar sesion"
},
"dashboard": {
"title": "Panel de control",
"subtitle": "Noticias y actualizaciones de IA",
"topic_placeholder": "Nombre del tema...",
"litellm_settings": "Configuracion de LiteLLM",
"settings_hint": "Dejar vacio para usar LITELLM_URL / LITELLM_MODEL del archivo .env",
"litellm_url": "URL de LiteLLM",
"litellm_url_placeholder": "Usa LITELLM_URL del archivo .env",
"model": "Modelo",
"model_placeholder": "Usa LITELLM_MODEL del archivo .env",
"searching": "Buscando...",
"search_failed": "La busqueda fallo: {e}",
"litellm_status": "Estado de LiteLLM",
"trending": "Tendencias",
"recent_searches": "Busquedas recientes"
},
"providers": {
"title": "Proveedores",
"subtitle": "Configure sus backends de LLM y embeddings",
"provider": "Proveedor",
"model": "Modelo",
"embedding_model": "Modelo de embedding",
"api_key": "Clave API",
"api_key_placeholder": "Introduzca la clave API...",
"save_config": "Guardar configuracion",
"config_saved": "Configuracion guardada.",
"active_config": "Configuracion activa",
"embedding": "Embedding"
},
"developer": {
"agents_title": "Constructor de agentes",
"agents_desc": "Construya y gestione agentes de IA con LangGraph. Cree pipelines de razonamiento de varios pasos, agentes que utilizan herramientas y flujos de trabajo autonomos.",
"launch_agents": "Abrir constructor de agentes",
"flow_title": "Constructor de flujos",
"flow_desc": "Disene flujos de trabajo de IA visuales con LangFlow. Arrastre y suelte nodos para crear pipelines de procesamiento de datos, cadenas de prompts y flujos de integracion.",
"launch_flow": "Abrir constructor de flujos",
"analytics_title": "Estadisticas y observabilidad",
"analytics_desc": "Monitoree y analice sus pipelines de IA con LangFuse. Realice seguimiento del uso de tokens, latencia, costos y metricas de calidad en todos sus despliegues.",
"launch_analytics": "Abrir LangFuse",
"total_requests": "Total de solicitudes",
"avg_latency": "Latencia promedio",
"tokens_used": "Tokens utilizados",
"error_rate": "Tasa de errores",
"not_configured": "No configurado",
"open_new_tab": "Abrir en nueva pestana",
"agents_status_connected": "Conectado",
"agents_status_not_connected": "No conectado",
"agents_config_hint": "Configure LANGGRAPH_URL en .env para conectar",
"agents_quick_start": "Inicio rapido",
"agents_docs": "Documentacion",
"agents_docs_desc": "Documentacion oficial de LangGraph y guias de API.",
"agents_getting_started": "Primeros pasos",
"agents_getting_started_desc": "Tutorial paso a paso para crear su primer agente.",
"agents_github": "GitHub",
"agents_github_desc": "Codigo fuente, issues y contribuciones de la comunidad.",
"agents_examples": "Ejemplos",
"agents_examples_desc": "Plantillas y proyectos de agentes listos para usar.",
"agents_api_ref": "Referencia API",
"agents_api_ref_desc": "Documentacion Swagger local para su instancia de LangGraph.",
"agents_running_title": "Agentes en ejecucion",
"agents_none": "No hay agentes registrados. Despliegue un asistente en LangGraph para verlo aqui.",
"agents_col_name": "Nombre",
"agents_col_id": "ID",
"agents_col_description": "Descripcion",
"agents_col_status": "Estado",
"analytics_status_connected": "Conectado",
"analytics_status_not_connected": "No conectado",
"analytics_config_hint": "Configure LANGFUSE_URL en .env para conectar",
"analytics_sso_hint": "Langfuse utiliza SSO de Keycloak. Iniciara sesion automaticamente con su cuenta CERTifAI.",
"analytics_quick_actions": "Acciones rapidas",
"analytics_traces": "Trazas",
"analytics_traces_desc": "Ver y filtrar todas las llamadas LLM, latencias y uso de tokens.",
"analytics_dashboard": "Panel de control",
"analytics_dashboard_desc": "Resumen de costos, metricas de calidad y tendencias de uso."
},
"org": {
"title": "Organizacion",
"subtitle": "Gestione miembros y facturacion",
"invite_member": "Invitar miembro",
"seats_used": "Puestos utilizados",
"of_tokens": "de {limit} tokens",
"cycle_ends": "Fin del ciclo",
"name": "Nombre",
"email": "Correo electronico",
"role": "Rol",
"joined": "Fecha de ingreso",
"invite_title": "Invitar nuevo miembro",
"email_address": "Direccion de correo electronico",
"email_placeholder": "colega@empresa.com",
"send_invite": "Enviar invitacion",
"total_spend": "Gasto total",
"total_tokens": "Tokens totales",
"model_usage": "Uso por modelo",
"model": "Modelo",
"tokens": "Tokens",
"spend": "Gasto",
"usage_unavailable": "Datos de uso no disponibles",
"loading_usage": "Cargando datos de uso...",
"prompt_tokens": "Tokens de entrada",
"completion_tokens": "Tokens de respuesta",
"pricing_title": "Precios",
"pricing_subtitle": "Elija el plan que se adapte a su organizacion"
},
"pricing": {
"starter": "Starter",
"team": "Team",
"enterprise": "Enterprise",
"up_to_users": "Hasta {n} usuarios",
"unlimited_users": "Usuarios ilimitados",
"llm_provider_1": "1 proveedor de LLM",
"all_providers": "Todos los proveedores de LLM",
"tokens_100k": "100K tokens/mes",
"tokens_1m": "1M tokens/mes",
"unlimited_tokens": "Tokens ilimitados",
"community_support": "Soporte comunitario",
"priority_support": "Soporte prioritario",
"dedicated_support": "Soporte dedicado",
"basic_analytics": "Estadisticas basicas",
"advanced_analytics": "Estadisticas avanzadas",
"full_observability": "Observabilidad completa",
"custom_mcp": "Herramientas MCP personalizadas",
"sso": "Integracion SSO",
"custom_integrations": "Integraciones personalizadas",
"sla": "Garantia de SLA",
"on_premise": "Despliegue en infraestructura propia"
},
"landing": {
"badge": "Infraestructura GenAI con privacidad ante todo",
"hero_title_1": "Su IA. Sus datos.",
"hero_title_2": "Su infraestructura.",
"hero_subtitle": "Plataforma de IA generativa autoalojada y conforme al RGPD para empresas que no comprometen la soberania de sus datos. Despliegue LLMs, agentes y servidores MCP bajo sus propias condiciones.",
"learn_more": "Mas informacion",
"social_proof": "Creado para empresas que valoran la ",
"data_sovereignty": "soberania de datos",
"on_premise": "En infraestructura propia",
"compliant": "Conforme",
"data_residency": "Residencia de datos",
"third_party": "Comparticion con terceros",
"features_title": "Todo lo que necesita",
"features_subtitle": "Una pila GenAI completa y autoalojada bajo su total control.",
"feat_infra_title": "Infraestructura autoalojada",
"feat_infra_desc": "Despliegue en su propio hardware o nube privada. Control total sobre su pila de IA sin dependencias externas.",
"feat_gdpr_title": "Conforme al RGPD",
"feat_gdpr_desc": "Residencia de datos en la UE garantizada. Sus datos nunca abandonan su infraestructura ni se comparten con terceros.",
"feat_llm_title": "Gestion de LLM",
"feat_llm_desc": "Despliegue, monitoree y gestione multiples modelos de lenguaje. Cambie entre modelos sin tiempo de inactividad.",
"feat_agent_title": "Constructor de agentes",
"feat_agent_desc": "Cree agentes de IA personalizados con Langchain y Langfuse integrados para observabilidad y control total.",
"feat_mcp_title": "Gestion de servidores MCP",
"feat_mcp_desc": "Gestione servidores de Model Context Protocol para ampliar sus capacidades de IA con integraciones de herramientas externas.",
"feat_api_title": "Gestion de claves API",
"feat_api_desc": "Genere claves API, realice seguimiento del uso por puesto y establezca permisos detallados para cada integracion.",
"how_title": "En funcionamiento en minutos",
"how_subtitle": "Tres pasos hacia una infraestructura de IA soberana.",
"step_deploy": "Desplegar",
"step_deploy_desc": "Instale CERTifAI en su infraestructura con un solo comando. Compatible con Docker, Kubernetes e instalacion directa.",
"step_configure": "Configurar",
"step_configure_desc": "Conecte su proveedor de identidad, seleccione sus modelos y configure los permisos del equipo a traves del panel de administracion.",
"step_scale": "Escalar",
"step_scale_desc": "Anada usuarios, despliegue mas modelos e integre con sus herramientas existentes mediante claves API y servidores MCP.",
"cta_title": "Listo para tomar el control de su infraestructura de IA?",
"cta_subtitle": "Comience a desplegar IA generativa soberana hoy. No se requiere tarjeta de credito.",
"get_started_free": "Comenzar gratis",
"footer_tagline": "Infraestructura GenAI soberana para empresas.",
"product": "Producto",
"legal": "Legal",
"resources": "Recursos",
"documentation": "Documentacion",
"api_reference": "Referencia API",
"support": "Soporte",
"copyright": "2026 CERTifAI. Todos los derechos reservados.",
"pill_gdpr": "RGPD Nativo",
"pill_self_hosted": "Autoalojado",
"pill_eu": "Soberania UE",
"preview_models": "Modelos Activos",
"preview_tokens": "Tokens / Mes",
"preview_spend": "Gasto Total"
},
"article": {
"read_original": "Leer articulo original",
"summarizing": "Resumiendo...",
"summarized_with_ai": "Resumido con IA",
"ask_followup": "Haga una pregunta de seguimiento..."
},
"impressum": {
"title": "Aviso legal",
"info_tmg": "Informacion segun el 5 TMG",
"company": "CERTifAI GmbH",
"address_street": "Musterstrasse 1",
"address_city": "10115 Berlin",
"address_country": "Alemania",
"represented_by": "Representado por",
"managing_director": "Director general: [Name]",
"contact": "Contacto",
"email": "Correo electronico: info@certifai.example",
"phone": "Telefono: +49 (0) 30 1234567",
"commercial_register": "Registro mercantil",
"registered_at": "Registrado en: Amtsgericht Berlin-Charlottenburg",
"registration_number": "Numero de registro: HRB XXXXXX",
"vat_id": "Numero de IVA",
"vat_number": "Numero de identificacion fiscal segun 27a UStG: DE XXXXXXXXX",
"responsible_content": "Responsable del contenido segun 55 Abs. 2 RStV"
},
"privacy": {
"title": "Politica de privacidad",
"last_updated": "Ultima actualizacion: febrero de 2026",
"intro_title": "1. Introduccion",
"intro_text": "CERTifAI GmbH (\"nosotros\", \"nuestro/a\") se compromete a proteger sus datos personales. Esta politica de privacidad explica como recopilamos, utilizamos y protegemos su informacion cuando utiliza nuestra plataforma.",
"controller_title": "2. Responsable del tratamiento",
"controller_address": "Musterstrasse 1, 10115 Berlin, Alemania",
"controller_email": "Correo electronico: privacy@certifai.example",
"data_title": "3. Datos que recopilamos",
"data_intro": "Recopilamos unicamente los datos minimos necesarios para prestar nuestros servicios:",
"data_account_label": "Datos de cuenta: ",
"data_account_text": "Nombre, direccion de correo electronico y datos de la organizacion proporcionados durante el registro.",
"data_usage_label": "Datos de uso: ",
"data_usage_text": "Registros de llamadas API, recuento de tokens y metricas de uso de funcionalidades para facturacion y estadisticas.",
"data_technical_label": "Datos tecnicos: ",
"data_technical_text": "Direcciones IP, tipo de navegador e identificadores de sesion para la seguridad y estabilidad de la plataforma.",
"use_title": "4. Como utilizamos sus datos",
"use_1": "Para proporcionar y mantener la plataforma CERTifAI",
"use_2": "Para gestionar su cuenta y suscripcion",
"use_3": "Para comunicar actualizaciones del servicio y avisos de seguridad",
"use_4": "Para cumplir con las obligaciones legales",
"storage_title": "5. Almacenamiento y soberania de datos",
"storage_text": "CERTifAI es una plataforma autoalojada. Todas las cargas de trabajo de IA, datos de modelos y resultados de inferencia permanecen completamente dentro de su propia infraestructura. No accedemos, almacenamos ni procesamos sus datos de IA en nuestros servidores.",
"rights_title": "6. Sus derechos (RGPD)",
"rights_intro": "Segun el RGPD, usted tiene derecho a:",
"rights_access": "Acceder a sus datos personales",
"rights_rectify": "Rectificar datos inexactos",
"rights_erasure": "Solicitar la supresion de sus datos",
"rights_restrict": "Limitar u oponerse al tratamiento",
"rights_portability": "Portabilidad de datos",
"rights_complaint": "Presentar una reclamacion ante una autoridad de control",
"contact_title": "7. Contacto",
"contact_text": "Para consultas relacionadas con la privacidad, contactenos en privacy@certifai.example."
}
}

295
assets/i18n/fr.json Normal file
View File

@@ -0,0 +1,295 @@
{
"common": {
"loading": "Chargement...",
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer",
"send": "Envoyer",
"close": "Fermer",
"login": "Connexion",
"logout": "Deconnexion",
"on": "ON",
"off": "OFF",
"online": "En ligne",
"offline": "Hors ligne",
"settings": "Parametres",
"search": "Rechercher",
"rename": "Renommer",
"copy": "Copier",
"share": "Partager",
"edit": "Modifier",
"get_started": "Commencer",
"coming_soon": "Bientot disponible",
"back_to_home": "Retour a l'accueil",
"privacy_policy": "Politique de confidentialite",
"impressum": "Mentions legales",
"chunks": "segments",
"upload_file": "Importer un fichier",
"eur_per_month": "EUR / mois",
"up_to_seats": "Jusqu'a {n} postes",
"unlimited_seats": "Postes illimites",
"set": "Defini",
"not_set": "Non defini",
"log_in": "Se connecter",
"features": "Fonctionnalites",
"how_it_works": "Comment ca marche"
},
"nav": {
"dashboard": "Tableau de bord",
"providers": "Fournisseurs",
"chat": "Chat",
"developer": "Developpeur",
"organization": "Organisation",
"switch_light": "Passer en mode clair",
"switch_dark": "Passer en mode sombre",
"github": "GitHub",
"agents": "Agents",
"flow": "Flux",
"analytics": "Analytique",
"pricing": "Tarifs",
"compliance": "Conformite"
},
"auth": {
"redirecting_login": "Redirection vers la connexion...",
"redirecting_secure": "Redirection vers la page de connexion securisee...",
"auth_error": "Erreur d'authentification : {msg}",
"log_in": "Connexion"
},
"dashboard": {
"title": "Tableau de bord",
"subtitle": "Actualites et mises a jour IA",
"topic_placeholder": "Nom du sujet...",
"litellm_settings": "Parametres LiteLLM",
"settings_hint": "Laissez vide pour utiliser LITELLM_URL / LITELLM_MODEL du fichier .env",
"litellm_url": "URL LiteLLM",
"litellm_url_placeholder": "Utilise LITELLM_URL du fichier .env",
"model": "Modele",
"model_placeholder": "Utilise LITELLM_MODEL du fichier .env",
"searching": "Recherche en cours...",
"search_failed": "Echec de la recherche : {e}",
"litellm_status": "Statut LiteLLM",
"trending": "Tendances",
"recent_searches": "Recherches recentes"
},
"providers": {
"title": "Fournisseurs",
"subtitle": "Configurez vos backends LLM et d'embeddings",
"provider": "Fournisseur",
"model": "Modele",
"embedding_model": "Modele d'embedding",
"api_key": "Cle API",
"api_key_placeholder": "Saisissez la cle API...",
"save_config": "Enregistrer la configuration",
"config_saved": "Configuration enregistree.",
"active_config": "Configuration active",
"embedding": "Embedding"
},
"developer": {
"agents_title": "Constructeur d'agents",
"agents_desc": "Construisez et gerez des agents IA avec LangGraph. Creez des pipelines de raisonnement multi-etapes, des agents utilisant des outils et des flux de travail autonomes.",
"launch_agents": "Lancer le constructeur d'agents",
"flow_title": "Constructeur de flux",
"flow_desc": "Concevez des flux de travail IA visuels avec LangFlow. Glissez-deposez des noeuds pour creer des pipelines de traitement de donnees, des chaines de prompts et des flux d'integration.",
"launch_flow": "Lancer le constructeur de flux",
"analytics_title": "Analytique et observabilite",
"analytics_desc": "Surveillez et analysez vos pipelines IA avec LangFuse. Suivez l'utilisation des tokens, la latence, les couts et les metriques de qualite sur tous vos deployments.",
"launch_analytics": "Lancer LangFuse",
"total_requests": "Requetes totales",
"avg_latency": "Latence moyenne",
"tokens_used": "Tokens utilises",
"error_rate": "Taux d'erreur",
"not_configured": "Non configure",
"open_new_tab": "Ouvrir dans un nouvel onglet",
"agents_status_connected": "Connecte",
"agents_status_not_connected": "Non connecte",
"agents_config_hint": "Definissez LANGGRAPH_URL dans .env pour vous connecter",
"agents_quick_start": "Demarrage rapide",
"agents_docs": "Documentation",
"agents_docs_desc": "Documentation officielle de LangGraph et guides API.",
"agents_getting_started": "Premiers pas",
"agents_getting_started_desc": "Tutoriel etape par etape pour creer votre premier agent.",
"agents_github": "GitHub",
"agents_github_desc": "Code source, issues et contributions de la communaute.",
"agents_examples": "Exemples",
"agents_examples_desc": "Modeles et projets d'agents prets a l'emploi.",
"agents_api_ref": "Reference API",
"agents_api_ref_desc": "Documentation Swagger locale pour votre instance LangGraph.",
"agents_running_title": "Agents en cours",
"agents_none": "Aucun agent enregistre. Deployez un assistant dans LangGraph pour le voir ici.",
"agents_col_name": "Nom",
"agents_col_id": "ID",
"agents_col_description": "Description",
"agents_col_status": "Statut",
"analytics_status_connected": "Connecte",
"analytics_status_not_connected": "Non connecte",
"analytics_config_hint": "Definissez LANGFUSE_URL dans .env pour vous connecter",
"analytics_sso_hint": "Langfuse utilise le SSO Keycloak. Vous serez connecte automatiquement avec votre compte CERTifAI.",
"analytics_quick_actions": "Actions rapides",
"analytics_traces": "Traces",
"analytics_traces_desc": "Afficher et filtrer tous les appels LLM, latences et consommation de tokens.",
"analytics_dashboard": "Tableau de bord",
"analytics_dashboard_desc": "Apercu des couts, metriques de qualite et tendances d'utilisation."
},
"org": {
"title": "Organisation",
"subtitle": "Gerez les membres et la facturation",
"invite_member": "Inviter un membre",
"seats_used": "Postes utilises",
"of_tokens": "sur {limit} tokens",
"cycle_ends": "Fin du cycle",
"name": "Nom",
"email": "E-mail",
"role": "Role",
"joined": "Inscrit le",
"invite_title": "Inviter un nouveau membre",
"email_address": "Adresse e-mail",
"email_placeholder": "collegue@entreprise.com",
"send_invite": "Envoyer l'invitation",
"total_spend": "Depenses totales",
"total_tokens": "Tokens totaux",
"model_usage": "Utilisation par modele",
"model": "Modele",
"tokens": "Tokens",
"spend": "Depenses",
"usage_unavailable": "Donnees d'utilisation indisponibles",
"loading_usage": "Chargement des donnees d'utilisation...",
"prompt_tokens": "Tokens d'entree",
"completion_tokens": "Tokens de reponse",
"pricing_title": "Tarifs",
"pricing_subtitle": "Choisissez le plan adapte a votre organisation"
},
"pricing": {
"starter": "Starter",
"team": "Team",
"enterprise": "Enterprise",
"up_to_users": "Jusqu'a {n} utilisateurs",
"unlimited_users": "Utilisateurs illimites",
"llm_provider_1": "1 fournisseur LLM",
"all_providers": "Tous les fournisseurs LLM",
"tokens_100k": "100K tokens/mois",
"tokens_1m": "1M tokens/mois",
"unlimited_tokens": "Tokens illimites",
"community_support": "Support communautaire",
"priority_support": "Support prioritaire",
"dedicated_support": "Support dedie",
"basic_analytics": "Analytique de base",
"advanced_analytics": "Analytique avancee",
"full_observability": "Observabilite complete",
"custom_mcp": "Outils MCP personnalises",
"sso": "Integration SSO",
"custom_integrations": "Integrations personnalisees",
"sla": "Garantie SLA",
"on_premise": "Deploiement sur site"
},
"landing": {
"badge": "Infrastructure GenAI axee sur la confidentialite",
"hero_title_1": "Votre IA. Vos donnees.",
"hero_title_2": "Votre infrastructure.",
"hero_subtitle": "Plateforme d'IA generative auto-hebergee et conforme au RGPD pour les entreprises qui refusent de compromettre leur souverainete des donnees. Deployez des LLM, des agents et des serveurs MCP selon vos propres conditions.",
"learn_more": "En savoir plus",
"social_proof": "Concu pour les entreprises qui valorisent la ",
"data_sovereignty": "souverainete des donnees",
"on_premise": "Sur site",
"compliant": "Conforme",
"data_residency": "Residence des donnees",
"third_party": "Partage avec des tiers",
"features_title": "Tout ce dont vous avez besoin",
"features_subtitle": "Une pile GenAI complete et auto-hebergee sous votre controle total.",
"feat_infra_title": "Infrastructure auto-hebergee",
"feat_infra_desc": "Deployez sur votre propre materiel ou cloud prive. Controle total de votre pile IA sans dependances externes.",
"feat_gdpr_title": "Conforme au RGPD",
"feat_gdpr_desc": "Residence des donnees dans l'UE garantie. Vos donnees ne quittent jamais votre infrastructure et ne sont jamais partagees avec des tiers.",
"feat_llm_title": "Gestion des LLM",
"feat_llm_desc": "Deployez, surveillez et gerez plusieurs modeles de langage. Basculez entre les modeles sans interruption de service.",
"feat_agent_title": "Constructeur d'agents",
"feat_agent_desc": "Creez des agents IA personnalises avec Langchain et Langfuse integres pour une observabilite et un controle complets.",
"feat_mcp_title": "Gestion des serveurs MCP",
"feat_mcp_desc": "Gerez les serveurs Model Context Protocol pour etendre vos capacites IA avec des integrations d'outils externes.",
"feat_api_title": "Gestion des cles API",
"feat_api_desc": "Generez des cles API, suivez l'utilisation par poste et definissez des permissions granulaires pour chaque integration.",
"how_title": "Operationnel en quelques minutes",
"how_subtitle": "Trois etapes vers une infrastructure IA souveraine.",
"step_deploy": "Deployer",
"step_deploy_desc": "Installez CERTifAI sur votre infrastructure avec une seule commande. Compatible Docker, Kubernetes et bare metal.",
"step_configure": "Configurer",
"step_configure_desc": "Connectez votre fournisseur d'identite, selectionnez vos modeles et configurez les permissions d'equipe via le tableau de bord d'administration.",
"step_scale": "Evoluer",
"step_scale_desc": "Ajoutez des utilisateurs, deployez plus de modeles et integrez vos outils existants via des cles API et des serveurs MCP.",
"cta_title": "Pret a prendre le controle de votre infrastructure IA ?",
"cta_subtitle": "Commencez a deployer une IA generative souveraine des aujourd'hui. Aucune carte de credit requise.",
"get_started_free": "Commencer gratuitement",
"footer_tagline": "Infrastructure GenAI souveraine pour les entreprises.",
"product": "Produit",
"legal": "Mentions legales",
"resources": "Ressources",
"documentation": "Documentation",
"api_reference": "Reference API",
"support": "Support",
"copyright": "2026 CERTifAI. Tous droits reserves.",
"pill_gdpr": "RGPD Natif",
"pill_self_hosted": "Auto-heberge",
"pill_eu": "Souverainete UE",
"preview_models": "Modeles Actifs",
"preview_tokens": "Tokens / Mois",
"preview_spend": "Depenses Totales"
},
"article": {
"read_original": "Lire l'article original",
"summarizing": "Resume en cours...",
"summarized_with_ai": "Resume par IA",
"ask_followup": "Posez une question complementaire..."
},
"impressum": {
"title": "Mentions legales",
"info_tmg": "Informations conformement au 5 TMG",
"company": "CERTifAI GmbH",
"address_street": "Musterstrasse 1",
"address_city": "10115 Berlin",
"address_country": "Allemagne",
"represented_by": "Represente par",
"managing_director": "Directeur general : [Nom]",
"contact": "Contact",
"email": "E-mail : info@certifai.example",
"phone": "Telephone : +49 (0) 30 1234567",
"commercial_register": "Registre du commerce",
"registered_at": "Enregistre aupres de : Amtsgericht Berlin-Charlottenburg",
"registration_number": "Numero d'immatriculation : HRB XXXXXX",
"vat_id": "Numero de TVA",
"vat_number": "Numero d'identification TVA conformement au 27a UStG : DE XXXXXXXXX",
"responsible_content": "Responsable du contenu conformement au 55 al. 2 RStV"
},
"privacy": {
"title": "Politique de confidentialite",
"last_updated": "Derniere mise a jour : fevrier 2026",
"intro_title": "1. Introduction",
"intro_text": "CERTifAI GmbH (\"nous\", \"notre\", \"nos\") s'engage a proteger vos donnees personnelles. Cette politique de confidentialite explique comment nous collectons, utilisons et protegeons vos informations lorsque vous utilisez notre plateforme.",
"controller_title": "2. Responsable du traitement",
"controller_address": "Musterstrasse 1, 10115 Berlin, Allemagne",
"controller_email": "E-mail : privacy@certifai.example",
"data_title": "3. Donnees collectees",
"data_intro": "Nous ne collectons que les donnees strictement necessaires a la fourniture de nos services :",
"data_account_label": "Donnees de compte : ",
"data_account_text": "Nom, adresse e-mail et informations sur l'organisation fournis lors de l'inscription.",
"data_usage_label": "Donnees d'utilisation : ",
"data_usage_text": "Journaux d'appels API, compteurs de tokens et metriques d'utilisation des fonctionnalites pour la facturation et l'analytique.",
"data_technical_label": "Donnees techniques : ",
"data_technical_text": "Adresses IP, type de navigateur et identifiants de session pour la securite et la stabilite de la plateforme.",
"use_title": "4. Utilisation de vos donnees",
"use_1": "Pour fournir et maintenir la plateforme CERTifAI",
"use_2": "Pour gerer votre compte et votre abonnement",
"use_3": "Pour communiquer les mises a jour du service et les avis de securite",
"use_4": "Pour respecter les obligations legales",
"storage_title": "5. Stockage des donnees et souverainete",
"storage_text": "CERTifAI est une plateforme auto-hebergee. Toutes les charges de travail IA, les donnees de modeles et les resultats d'inference restent entierement au sein de votre propre infrastructure. Nous n'accedon pas, ne stockons pas et ne traitons pas vos donnees IA sur nos serveurs.",
"rights_title": "6. Vos droits (RGPD)",
"rights_intro": "En vertu du RGPD, vous avez le droit de :",
"rights_access": "Acceder a vos donnees personnelles",
"rights_rectify": "Rectifier des donnees inexactes",
"rights_erasure": "Demander l'effacement de vos donnees",
"rights_restrict": "Limiter ou vous opposer au traitement",
"rights_portability": "Portabilite des donnees",
"rights_complaint": "Deposer une plainte aupres d'une autorite de controle",
"contact_title": "7. Contact",
"contact_text": "Pour toute question relative a la confidentialite, contactez-nous a privacy@certifai.example."
}
}

295
assets/i18n/pt.json Normal file
View File

@@ -0,0 +1,295 @@
{
"common": {
"loading": "A carregar...",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"send": "Enviar",
"close": "Fechar",
"login": "Iniciar sessao",
"logout": "Terminar sessao",
"on": "LIGADO",
"off": "DESLIGADO",
"online": "Online",
"offline": "Offline",
"settings": "Definicoes",
"search": "Pesquisar",
"rename": "Renomear",
"copy": "Copiar",
"share": "Partilhar",
"edit": "Editar",
"get_started": "Comecar",
"coming_soon": "Em breve",
"back_to_home": "Voltar ao inicio",
"privacy_policy": "Politica de Privacidade",
"impressum": "Impressum",
"chunks": "fragmentos",
"upload_file": "Carregar ficheiro",
"eur_per_month": "EUR / mes",
"up_to_seats": "Ate {n} lugares",
"unlimited_seats": "Lugares ilimitados",
"set": "Definido",
"not_set": "Nao definido",
"log_in": "Iniciar Sessao",
"features": "Funcionalidades",
"how_it_works": "Como Funciona"
},
"nav": {
"dashboard": "Painel",
"providers": "Fornecedores",
"chat": "Chat",
"developer": "Programador",
"organization": "Organizacao",
"switch_light": "Mudar para modo claro",
"switch_dark": "Mudar para modo escuro",
"github": "GitHub",
"agents": "Agentes",
"flow": "Fluxo",
"analytics": "Analise",
"pricing": "Precos",
"compliance": "Conformidade"
},
"auth": {
"redirecting_login": "A redirecionar para o inicio de sessao...",
"redirecting_secure": "A redirecionar para a pagina de inicio de sessao segura...",
"auth_error": "Erro de autenticacao: {msg}",
"log_in": "Iniciar sessao"
},
"dashboard": {
"title": "Painel",
"subtitle": "Noticias e atualizacoes de IA",
"topic_placeholder": "Nome do topico...",
"litellm_settings": "Definicoes do LiteLLM",
"settings_hint": "Deixe vazio para usar LITELLM_URL / LITELLM_MODEL do .env",
"litellm_url": "URL do LiteLLM",
"litellm_url_placeholder": "Utiliza LITELLM_URL do .env",
"model": "Modelo",
"model_placeholder": "Utiliza LITELLM_MODEL do .env",
"searching": "A pesquisar...",
"search_failed": "A pesquisa falhou: {e}",
"litellm_status": "Estado do LiteLLM",
"trending": "Em destaque",
"recent_searches": "Pesquisas recentes"
},
"providers": {
"title": "Fornecedores",
"subtitle": "Configure os seus backends de LLM e embeddings",
"provider": "Fornecedor",
"model": "Modelo",
"embedding_model": "Modelo de Embedding",
"api_key": "Chave API",
"api_key_placeholder": "Introduza a chave API...",
"save_config": "Guardar Configuracao",
"config_saved": "Configuracao guardada.",
"active_config": "Configuracao Ativa",
"embedding": "Embedding"
},
"developer": {
"agents_title": "Construtor de Agentes",
"agents_desc": "Construa e gira agentes de IA com LangGraph. Crie pipelines de raciocinio multi-etapa, agentes com ferramentas e fluxos de trabalho autonomos.",
"launch_agents": "Abrir Construtor de Agentes",
"flow_title": "Construtor de Fluxos",
"flow_desc": "Desenhe fluxos de trabalho de IA visuais com LangFlow. Arraste e solte nos para criar pipelines de processamento de dados, cadeias de prompts e fluxos de integracao.",
"launch_flow": "Abrir Construtor de Fluxos",
"analytics_title": "Analise e Observabilidade",
"analytics_desc": "Monitorize e analise os seus pipelines de IA com LangFuse. Acompanhe o uso de tokens, latencia, custos e metricas de qualidade em todas as suas implementacoes.",
"launch_analytics": "Abrir LangFuse",
"total_requests": "Total de Pedidos",
"avg_latency": "Latencia Media",
"tokens_used": "Tokens Utilizados",
"error_rate": "Taxa de Erros",
"not_configured": "Nao configurado",
"open_new_tab": "Abrir em novo separador",
"agents_status_connected": "Conectado",
"agents_status_not_connected": "Nao conectado",
"agents_config_hint": "Defina LANGGRAPH_URL no .env para conectar",
"agents_quick_start": "Inicio rapido",
"agents_docs": "Documentacao",
"agents_docs_desc": "Documentacao oficial do LangGraph e guias de API.",
"agents_getting_started": "Primeiros passos",
"agents_getting_started_desc": "Tutorial passo a passo para criar o seu primeiro agente.",
"agents_github": "GitHub",
"agents_github_desc": "Codigo fonte, issues e contribuicoes da comunidade.",
"agents_examples": "Exemplos",
"agents_examples_desc": "Modelos e projetos de agentes prontos a usar.",
"agents_api_ref": "Referencia API",
"agents_api_ref_desc": "Documentacao Swagger local para a sua instancia LangGraph.",
"agents_running_title": "Agentes em execucao",
"agents_none": "Nenhum agente registado. Implemente um assistente no LangGraph para o ver aqui.",
"agents_col_name": "Nome",
"agents_col_id": "ID",
"agents_col_description": "Descricao",
"agents_col_status": "Estado",
"analytics_status_connected": "Conectado",
"analytics_status_not_connected": "Nao conectado",
"analytics_config_hint": "Defina LANGFUSE_URL no .env para conectar",
"analytics_sso_hint": "O Langfuse utiliza SSO do Keycloak. Sera autenticado automaticamente com a sua conta CERTifAI.",
"analytics_quick_actions": "Acoes rapidas",
"analytics_traces": "Traces",
"analytics_traces_desc": "Ver e filtrar todas as chamadas LLM, latencias e uso de tokens.",
"analytics_dashboard": "Painel",
"analytics_dashboard_desc": "Resumo de custos, metricas de qualidade e tendencias de uso."
},
"org": {
"title": "Organizacao",
"subtitle": "Gerir membros e faturacao",
"invite_member": "Convidar Membro",
"seats_used": "Lugares Utilizados",
"of_tokens": "de {limit} tokens",
"cycle_ends": "Fim do Ciclo",
"name": "Nome",
"email": "Email",
"role": "Funcao",
"joined": "Aderiu",
"invite_title": "Convidar Novo Membro",
"email_address": "Endereco de Email",
"email_placeholder": "colleague@company.com",
"send_invite": "Enviar Convite",
"total_spend": "Gasto total",
"total_tokens": "Tokens totais",
"model_usage": "Uso por modelo",
"model": "Modelo",
"tokens": "Tokens",
"spend": "Gasto",
"usage_unavailable": "Dados de uso indisponiveis",
"loading_usage": "Carregando dados de uso...",
"prompt_tokens": "Tokens de entrada",
"completion_tokens": "Tokens de resposta",
"pricing_title": "Precos",
"pricing_subtitle": "Escolha o plano adequado a sua organizacao"
},
"pricing": {
"starter": "Inicial",
"team": "Equipa",
"enterprise": "Empresarial",
"up_to_users": "Ate {n} utilizadores",
"unlimited_users": "Utilizadores ilimitados",
"llm_provider_1": "1 fornecedor LLM",
"all_providers": "Todos os fornecedores LLM",
"tokens_100k": "100K tokens/mes",
"tokens_1m": "1M tokens/mes",
"unlimited_tokens": "Tokens ilimitados",
"community_support": "Suporte comunitario",
"priority_support": "Suporte prioritario",
"dedicated_support": "Suporte dedicado",
"basic_analytics": "Analise basica",
"advanced_analytics": "Analise avancada",
"full_observability": "Observabilidade completa",
"custom_mcp": "Ferramentas MCP personalizadas",
"sso": "Integracao SSO",
"custom_integrations": "Integracoes personalizadas",
"sla": "Garantia de SLA",
"on_premise": "Implementacao on-premise"
},
"landing": {
"badge": "Infraestrutura GenAI com Privacidade em Primeiro Lugar",
"hero_title_1": "A Sua IA. Os Seus Dados.",
"hero_title_2": "A Sua Infraestrutura.",
"hero_subtitle": "Plataforma de IA generativa auto-alojada e em conformidade com o RGPD para empresas que nao comprometem a soberania dos dados. Implemente LLMs, agentes e servidores MCP nos seus proprios termos.",
"learn_more": "Saber Mais",
"social_proof": "Criado para empresas que valorizam a ",
"data_sovereignty": "soberania dos dados",
"on_premise": "On-Premise",
"compliant": "Em Conformidade",
"data_residency": "Residencia dos Dados",
"third_party": "Partilha com Terceiros",
"features_title": "Tudo o que Precisa",
"features_subtitle": "Uma stack GenAI completa e auto-alojada sob o seu total controlo.",
"feat_infra_title": "Infraestrutura Auto-Alojada",
"feat_infra_desc": "Implemente no seu proprio hardware ou cloud privada. Controlo total sobre a sua stack de IA sem dependencias externas.",
"feat_gdpr_title": "Em Conformidade com o RGPD",
"feat_gdpr_desc": "Residencia de dados na UE garantida. Os seus dados nunca saem da sua infraestrutura nem sao partilhados com terceiros.",
"feat_llm_title": "Gestao de LLMs",
"feat_llm_desc": "Implemente, monitorize e gira multiplos modelos de linguagem. Alterne entre modelos sem tempo de inatividade.",
"feat_agent_title": "Construtor de Agentes",
"feat_agent_desc": "Crie agentes de IA personalizados com Langchain e Langfuse integrados para total observabilidade e controlo.",
"feat_mcp_title": "Gestao de Servidores MCP",
"feat_mcp_desc": "Gira servidores Model Context Protocol para expandir as capacidades da sua IA com integracoes de ferramentas externas.",
"feat_api_title": "Gestao de Chaves API",
"feat_api_desc": "Gere chaves API, acompanhe o uso por lugar e defina permissoes granulares para cada integracao.",
"how_title": "Operacional em Minutos",
"how_subtitle": "Tres passos para uma infraestrutura de IA soberana.",
"step_deploy": "Implementar",
"step_deploy_desc": "Instale o CERTifAI na sua infraestrutura com um unico comando. Suporte para Docker, Kubernetes e bare metal.",
"step_configure": "Configurar",
"step_configure_desc": "Ligue o seu fornecedor de identidade, selecione os seus modelos e configure as permissoes da equipa atraves do painel de administracao.",
"step_scale": "Escalar",
"step_scale_desc": "Adicione utilizadores, implemente mais modelos e integre com as suas ferramentas existentes atraves de chaves API e servidores MCP.",
"cta_title": "Pronto para assumir o controlo da sua infraestrutura de IA?",
"cta_subtitle": "Comece a implementar GenAI soberana hoje. Sem necessidade de cartao de credito.",
"get_started_free": "Comecar Gratuitamente",
"footer_tagline": "Infraestrutura GenAI soberana para empresas.",
"product": "Produto",
"legal": "Legal",
"resources": "Recursos",
"documentation": "Documentacao",
"api_reference": "Referencia API",
"support": "Suporte",
"copyright": "2026 CERTifAI. Todos os direitos reservados.",
"pill_gdpr": "RGPD Nativo",
"pill_self_hosted": "Auto-Alojado",
"pill_eu": "Soberania UE",
"preview_models": "Modelos Ativos",
"preview_tokens": "Tokens / Mes",
"preview_spend": "Gasto Total"
},
"article": {
"read_original": "Ler artigo original",
"summarizing": "A resumir...",
"summarized_with_ai": "Resumido com IA",
"ask_followup": "Faca uma pergunta de seguimento..."
},
"impressum": {
"title": "Impressum",
"info_tmg": "Informacao de acordo com o 5 TMG",
"company": "CERTifAI GmbH",
"address_street": "Musterstrasse 1",
"address_city": "10115 Berlim",
"address_country": "Alemanha",
"represented_by": "Representado por",
"managing_director": "Diretor Geral: [Name]",
"contact": "Contacto",
"email": "Email: info@certifai.example",
"phone": "Telefone: +49 (0) 30 1234567",
"commercial_register": "Registo Comercial",
"registered_at": "Registado em: Amtsgericht Berlin-Charlottenburg",
"registration_number": "Numero de registo: HRB XXXXXX",
"vat_id": "NIF",
"vat_number": "Numero de identificacao fiscal de acordo com o 27a UStG: DE XXXXXXXXX",
"responsible_content": "Responsavel pelo conteudo de acordo com o 55 Abs. 2 RStV"
},
"privacy": {
"title": "Politica de Privacidade",
"last_updated": "Ultima atualizacao: fevereiro de 2026",
"intro_title": "1. Introducao",
"intro_text": "A CERTifAI GmbH (\"nos\", \"nosso\", \"nossa\") esta empenhada em proteger os seus dados pessoais. Esta politica de privacidade explica como recolhemos, utilizamos e protegemos as suas informacoes quando utiliza a nossa plataforma.",
"controller_title": "2. Responsavel pelo Tratamento de Dados",
"controller_address": "Musterstrasse 1, 10115 Berlim, Alemanha",
"controller_email": "Email: privacy@certifai.example",
"data_title": "3. Dados que Recolhemos",
"data_intro": "Recolhemos apenas os dados minimos necessarios para prestar os nossos servicos:",
"data_account_label": "Dados da conta: ",
"data_account_text": "Nome, endereco de email e detalhes da organizacao fornecidos durante o registo.",
"data_usage_label": "Dados de utilizacao: ",
"data_usage_text": "Registos de chamadas API, contagem de tokens e metricas de utilizacao de funcionalidades para faturacao e analise.",
"data_technical_label": "Dados tecnicos: ",
"data_technical_text": "Enderecos IP, tipo de navegador e identificadores de sessao para seguranca e estabilidade da plataforma.",
"use_title": "4. Como Utilizamos os Seus Dados",
"use_1": "Para fornecer e manter a plataforma CERTifAI",
"use_2": "Para gerir a sua conta e subscricao",
"use_3": "Para comunicar atualizacoes do servico e avisos de seguranca",
"use_4": "Para cumprir obrigacoes legais",
"storage_title": "5. Armazenamento e Soberania dos Dados",
"storage_text": "O CERTifAI e uma plataforma auto-alojada. Todas as cargas de trabalho de IA, dados de modelos e resultados de inferencia permanecem inteiramente dentro da sua propria infraestrutura. Nao acedemos, armazenamos nem processamos os seus dados de IA nos nossos servidores.",
"rights_title": "6. Os Seus Direitos (RGPD)",
"rights_intro": "Ao abrigo do RGPD, tem o direito de:",
"rights_access": "Aceder aos seus dados pessoais",
"rights_rectify": "Retificar dados incorretos",
"rights_erasure": "Solicitar a eliminacao dos seus dados",
"rights_restrict": "Restringir ou opor-se ao tratamento",
"rights_portability": "Portabilidade dos dados",
"rights_complaint": "Apresentar uma reclamacao junto de uma autoridade de supervisao",
"contact_title": "7. Contacto",
"contact_text": "Para questoes relacionadas com privacidade, contacte-nos em privacy@certifai.example."
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
@@ -9,6 +9,15 @@
"Courier New", monospace;
--color-black: #000;
--spacing: 0.25rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
@@ -162,59 +171,6 @@
}
}
@layer utilities {
.diff {
@layer daisyui.l1.l2.l3 {
position: relative;
display: grid;
width: 100%;
overflow: hidden;
webkit-user-select: none;
user-select: none;
grid-template-rows: 1fr 1.8rem 1fr;
direction: ltr;
container-type: inline-size;
grid-template-columns: auto 1fr;
&:focus-visible, &:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
}
&:focus-visible {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
}
&:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
}
}
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
&:focus {
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
}
}
&:has(.diff-item-1:focus) {
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
}
}
}
}
.modal {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
@@ -356,6 +312,95 @@
}
}
}
.dropdown {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-block;
position-area: var(--anchor-v, bottom) var(--anchor-h, span-right);
& > *:not(:has(~ [class*="dropdown-content"])):focus {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
.dropdown-content {
position: absolute;
}
&.dropdown-close .dropdown-content, &:not(details, .dropdown-open, .dropdown-hover:hover, :focus-within) .dropdown-content, &.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible) ~ .dropdown-content {
display: none;
transform-origin: top;
opacity: 0%;
scale: 95%;
}
&[popover], .dropdown-content {
z-index: 999;
@media (prefers-reduced-motion: no-preference) {
animation: dropdown 0.2s;
transition-property: opacity, scale, display;
transition-behavior: allow-discrete;
transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
}
@starting-style {
&[popover], .dropdown-content {
scale: 95%;
opacity: 0;
}
}
&:not(.dropdown-close) {
&.dropdown-open, &:not(.dropdown-hover):focus, &:focus-within {
> [tabindex]:first-child {
pointer-events: none;
}
.dropdown-content {
opacity: 100%;
scale: 100%;
}
}
&.dropdown-hover:hover {
.dropdown-content {
opacity: 100%;
scale: 100%;
}
}
}
&:is(details) {
summary {
&::-webkit-details-marker {
display: none;
}
}
}
&:where([popover]) {
background: #0000;
}
&[popover] {
position: fixed;
color: inherit;
@supports not (position-area: bottom) {
margin: auto;
&.dropdown-close, &.dropdown-open:not(:popover-open) {
display: none;
transform-origin: top;
opacity: 0%;
scale: 95%;
}
&::backdrop {
background-color: color-mix(in oklab, #000 30%, #0000);
}
}
&.dropdown-close, &:not(.dropdown-open, :popover-open) {
display: none;
transform-origin: top;
opacity: 0%;
scale: 95%;
}
}
}
}
.btn {
:where(&) {
@layer daisyui.l1.l2.l3 {
@@ -1021,31 +1066,98 @@
}
}
}
.chat-bubble {
.range {
@layer daisyui.l1.l2.l3 {
position: relative;
display: block;
width: fit-content;
border-radius: var(--radius-field);
background-color: var(--color-base-300);
padding-inline: calc(0.25rem * 4);
padding-block: calc(0.25rem * 2);
color: var(--color-base-content);
grid-row-end: 3;
min-height: 2rem;
min-width: 2.5rem;
max-width: 90%;
&:before {
position: absolute;
bottom: calc(0.25rem * 0);
height: calc(0.25rem * 3);
width: calc(0.25rem * 3);
background-color: inherit;
content: "";
mask-repeat: no-repeat;
mask-image: var(--mask-chat);
mask-position: 0px -1px;
mask-size: 0.8125rem;
appearance: none;
webkit-appearance: none;
--range-thumb: var(--color-base-100);
--range-thumb-size: calc(var(--size-selector, 0.25rem) * 6);
--range-progress: currentColor;
--range-fill: 1;
--range-p: 0.25rem;
--range-bg: currentColor;
@supports (color: color-mix(in lab, red, red)) {
--range-bg: color-mix(in oklab, currentColor 10%, #0000);
}
cursor: pointer;
overflow: hidden;
background-color: transparent;
vertical-align: middle;
width: clamp(3rem, 20rem, 100%);
--radius-selector-max: calc(
var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
);
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
border: none;
height: var(--range-thumb-size);
[dir="rtl"] & {
--range-dir: -1;
}
&:focus {
outline: none;
}
&:focus-visible {
outline: 2px solid;
outline-offset: 2px;
}
&::-webkit-slider-runnable-track {
width: 100%;
background-color: var(--range-bg);
border-radius: var(--radius-selector);
height: calc(var(--range-thumb-size) * 0.5);
}
@media (forced-colors: active) {
&::-webkit-slider-runnable-track {
border: 1px solid;
}
}
@media (forced-colors: active) {
&::-moz-range-track {
border: 1px solid;
}
}
&::-webkit-slider-thumb {
position: relative;
box-sizing: border-box;
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
background-color: var(--range-thumb);
height: var(--range-thumb-size);
width: var(--range-thumb-size);
border: var(--range-p) solid;
appearance: none;
webkit-appearance: none;
top: 50%;
color: var(--range-progress);
transform: translateY(-50%);
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
}
}
&::-moz-range-track {
width: 100%;
background-color: var(--range-bg);
border-radius: var(--radius-selector);
height: calc(var(--range-thumb-size) * 0.5);
}
&::-moz-range-thumb {
position: relative;
box-sizing: border-box;
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
background-color: currentColor;
height: var(--range-thumb-size);
width: var(--range-thumb-size);
border: var(--range-p) solid;
top: 50%;
color: var(--range-progress);
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
}
}
&:disabled {
cursor: not-allowed;
opacity: 30%;
}
}
}
@@ -1436,81 +1548,6 @@
padding: calc(0.25rem * 4);
}
}
.textarea {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid #0000;
min-height: calc(0.25rem * 20);
flex-shrink: 1;
appearance: none;
border-radius: var(--radius-field);
background-color: var(--color-base-100);
padding-block: calc(0.25rem * 2);
vertical-align: middle;
width: clamp(3rem, 20rem, 100%);
padding-inline-start: 0.75rem;
padding-inline-end: 0.75rem;
font-size: max(var(--font-size, 0.875rem), 0.875rem);
touch-action: manipulation;
border-color: var(--input-color);
box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
}
--input-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
--input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
}
textarea {
appearance: none;
background-color: transparent;
border: none;
&:focus, &:focus-within {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
}
&:focus, &:focus-within {
--input-color: var(--color-base-content);
box-shadow: 0 1px var(--input-color);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
}
outline: 2px solid var(--input-color);
outline-offset: 2px;
isolation: isolate;
}
@media (pointer: coarse) {
@supports (-webkit-touch-callout: none) {
&:focus, &:focus-within {
--font-size: 1rem;
}
}
}
&:has(> textarea[disabled]), &:is(:disabled, [disabled]) {
cursor: not-allowed;
border-color: var(--color-base-200);
background-color: var(--color-base-200);
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
}
&::placeholder {
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
}
}
box-shadow: none;
}
&:has(> textarea[disabled]) > textarea[disabled] {
cursor: not-allowed;
}
}
}
.stack {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -1605,6 +1642,14 @@
}
}
}
.stat-value {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
white-space: nowrap;
font-size: 2rem;
font-weight: 800;
}
}
.container {
width: 100%;
@media (width >= 40rem) {
@@ -1790,6 +1835,23 @@
}
}
}
.stat {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
width: 100%;
column-gap: calc(0.25rem * 4);
padding-inline: calc(0.25rem * 6);
padding-block: calc(0.25rem * 4);
grid-template-columns: repeat(1, 1fr);
&:not(:last-child) {
border-inline-end: var(--border) dashed currentColor;
@supports (color: color-mix(in lab, red, red)) {
border-inline-end: var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);
}
border-block-end: none;
}
}
}
.chat {
@layer daisyui.l1.l2.l3 {
display: grid;
@@ -1808,6 +1870,9 @@
font-weight: 600;
}
}
.flex {
display: flex;
}
.grid {
display: grid;
}
@@ -1820,6 +1885,9 @@
.table {
display: table;
}
.border-collapse {
border-collapse: collapse;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
@@ -1841,13 +1909,12 @@
}
}
}
.badge-outline {
@layer daisyui.l1.l2 {
color: var(--badge-color);
--badge-bg: #0000;
background-image: none;
border-color: currentColor;
}
.flex-wrap {
flex-wrap: wrap;
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.glass {
border: none;
@@ -1866,10 +1933,6 @@
.lowercase {
text-transform: lowercase;
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.btn-ghost {
@layer daisyui.l1 {
&:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
@@ -1897,6 +1960,15 @@
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.backdrop-filter {
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.btn-outline {
@layer daisyui.l1 {
&:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) {
@@ -2311,7 +2383,7 @@
syntax: "*";
inherits: false;
}
@property --tw-outline-style {
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
@@ -2369,6 +2441,42 @@
syntax: "*";
inherits: false;
}
@property --tw-backdrop-blur {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-brightness {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-contrast {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-invert {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-opacity {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-saturate {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-sepia {
syntax: "*";
inherits: false;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
@@ -2377,7 +2485,7 @@
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-outline-style: solid;
--tw-border-style: solid;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
@@ -2391,6 +2499,15 @@
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
--tw-backdrop-blur: initial;
--tw-backdrop-brightness: initial;
--tw-backdrop-contrast: initial;
--tw-backdrop-grayscale: initial;
--tw-backdrop-hue-rotate: initial;
--tw-backdrop-invert: initial;
--tw-backdrop-opacity: initial;
--tw-backdrop-saturate: initial;
--tw-backdrop-sepia: initial;
}
}
}

View File

@@ -8,6 +8,7 @@
"tailwindcss": "^4.1.18",
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/bun": "latest",
},
"peerDependencies": {
@@ -16,6 +17,8 @@
},
},
"packages": {
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@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=="],
@@ -24,6 +27,12 @@
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"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=="],

View File

@@ -0,0 +1,907 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 1: Nordic Frost</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700&family=Playfair+Display:wght@500;600;700;800&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 1: NORDIC FROST
========================================================================
Mood: Clean, minimal, premium, Scandinavian-inspired
Audience: Enterprise, banking, legal, healthcare
Palette: Cool whites, slate greys, muted teal accents
Fonts: Playfair Display (headings) + DM Sans (body)
Feel: Trustworthy, understated luxury, whisper-quiet confidence
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-primary: #f7f8fa;
--bg-secondary: #ffffff;
--bg-tertiary: #eef0f4;
--bg-dark: #1a1e2e;
--bg-dark-card: #232838;
--text-primary: #1a1e2e;
--text-secondary: #5a6178;
--text-muted: #8b92a8;
--text-inverse: #f0f1f5;
--accent: #3a8f8b;
--accent-light: #4aada8;
--accent-muted: rgba(58, 143, 139, 0.08);
--accent-border: rgba(58, 143, 139, 0.2);
--border: #e4e7ee;
--border-subtle: #eef0f4;
--shadow-sm: 0 1px 3px rgba(26, 30, 46, 0.04);
--shadow-md: 0 4px 16px rgba(26, 30, 46, 0.06);
--shadow-lg: 0 8px 32px rgba(26, 30, 46, 0.08);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
}
body {
font-family: 'DM Sans', sans-serif;
color: var(--text-primary);
background: var(--bg-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'Playfair Display', serif;
font-weight: 600;
line-height: 1.2;
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
gap: 6px;
background: var(--bg-dark);
padding: 6px;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.2);
}
.view-switcher button {
font-family: 'DM Sans', sans-serif;
font-size: 13px;
font-weight: 500;
border: none;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
background: transparent;
color: rgba(255,255,255,0.5);
transition: all 0.2s;
}
.view-switcher button.active {
background: var(--accent);
color: #fff;
}
.view-switcher button:hover:not(.active) {
color: rgba(255,255,255,0.8);
}
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING PAGE ===== */
/* -- Navbar -- */
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 64px;
background: rgba(255,255,255,0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-subtle);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'Playfair Display', serif;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.nav-logo span { color: var(--accent); }
.nav-links {
display: flex;
gap: 36px;
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover { color: var(--text-primary); }
.nav-cta {
display: flex;
gap: 12px;
align-items: center;
}
.btn-ghost {
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 500;
padding: 10px 20px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.2s;
}
.btn-ghost:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.btn-primary {
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 10px 24px;
border: none;
background: var(--accent);
color: #fff;
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.25s;
}
.btn-primary:hover { background: var(--accent-light); transform: translateY(-1px); box-shadow: var(--shadow-md); }
/* -- Hero -- */
.hero {
padding: 120px 64px 100px;
text-align: center;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: -200px;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 800px;
background: radial-gradient(circle, rgba(58,143,139,0.06) 0%, transparent 70%);
pointer-events: none;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
background: var(--accent-muted);
border: 1px solid var(--accent-border);
border-radius: 100px;
font-size: 13px;
font-weight: 500;
color: var(--accent);
margin-bottom: 32px;
animation: fadeUp 0.6s ease;
}
.hero h1 {
font-size: 64px;
letter-spacing: -1.5px;
margin-bottom: 24px;
animation: fadeUp 0.6s ease 0.1s both;
}
.hero h1 em {
font-style: italic;
color: var(--accent);
}
.hero p {
font-size: 18px;
color: var(--text-secondary);
max-width: 560px;
margin: 0 auto 40px;
animation: fadeUp 0.6s ease 0.2s both;
}
.hero-actions {
display: flex;
gap: 16px;
justify-content: center;
animation: fadeUp 0.6s ease 0.3s both;
}
.btn-outline {
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 12px 28px;
border: 1.5px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.25s;
}
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-lg { padding: 14px 32px; font-size: 15px; }
/* -- Trust Bar -- */
.trust-bar {
display: flex;
justify-content: center;
gap: 48px;
padding: 48px 64px;
border-top: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--border-subtle);
}
.trust-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--text-muted);
font-weight: 500;
}
.trust-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-muted);
border-radius: var(--radius-sm);
color: var(--accent);
font-size: 16px;
}
/* -- Features -- */
.features {
padding: 100px 64px;
}
.section-header {
text-align: center;
margin-bottom: 64px;
}
.section-header h2 {
font-size: 40px;
letter-spacing: -0.8px;
margin-bottom: 16px;
}
.section-header p {
font-size: 16px;
color: var(--text-secondary);
max-width: 480px;
margin: 0 auto;
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
max-width: 1100px;
margin: 0 auto;
}
.feature-card {
padding: 32px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
transition: all 0.3s ease;
}
.feature-card:hover {
border-color: var(--accent-border);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.feature-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-muted);
border-radius: var(--radius-md);
color: var(--accent);
font-size: 20px;
margin-bottom: 20px;
}
.feature-card h3 {
font-family: 'DM Sans', sans-serif;
font-size: 17px;
font-weight: 600;
margin-bottom: 10px;
}
.feature-card p {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.7;
}
/* -- CTA Section -- */
.cta-section {
padding: 80px 64px;
text-align: center;
}
.cta-box {
max-width: 700px;
margin: 0 auto;
padding: 64px;
background: var(--bg-dark);
border-radius: var(--radius-xl);
color: var(--text-inverse);
}
.cta-box h2 {
font-size: 36px;
margin-bottom: 16px;
letter-spacing: -0.5px;
}
.cta-box p {
font-size: 16px;
color: rgba(240,241,245,0.6);
margin-bottom: 32px;
}
.btn-white {
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 14px 32px;
border: none;
background: #fff;
color: var(--bg-dark);
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.25s;
}
.btn-white:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(255,255,255,0.15); }
/* -- Footer -- */
.landing-footer {
padding: 48px 64px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border-subtle);
font-size: 13px;
color: var(--text-muted);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-muted); text-decoration: none; }
.footer-links a:hover { color: var(--text-primary); }
/* ===== DASHBOARD PAGE ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-primary);
}
/* -- Sidebar -- */
.sidebar {
width: 260px;
min-width: 260px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 24px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-brand h2 {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.3px;
}
.sidebar-brand h2 span { color: var(--accent); }
.sidebar-user {
display: flex;
align-items: center;
gap: 12px;
padding: 20px;
border-bottom: 1px solid var(--border-subtle);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
}
.user-info { min-width: 0; }
.user-name { font-size: 14px; font-weight: 600; }
.user-email { font-size: 12px; color: var(--text-muted); }
.sidebar-nav {
flex: 1;
padding: 16px 12px;
}
.nav-section-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
padding: 12px 12px 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.nav-item:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.nav-item.active {
background: var(--accent-muted);
color: var(--accent);
font-weight: 600;
}
.nav-item svg { width: 18px; height: 18px; opacity: 0.6; }
.nav-item.active svg { opacity: 1; }
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-subtle);
font-size: 12px;
color: var(--text-muted);
}
/* -- Main Content -- */
.main-content {
flex: 1;
padding: 40px 48px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 36px;
}
.page-title { font-size: 28px; letter-spacing: -0.5px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin-top: 4px; font-family: 'DM Sans', sans-serif; }
/* -- Stats Row -- */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 36px;
}
.stat-card {
padding: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
transition: all 0.2s;
}
.stat-card:hover { box-shadow: var(--shadow-sm); }
.stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.stat-value {
font-family: 'Playfair Display', serif;
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
.stat-change {
font-size: 12px;
font-weight: 500;
margin-top: 6px;
color: var(--accent);
}
/* -- Content Grid -- */
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 28px;
}
.card-title {
font-family: 'DM Sans', sans-serif;
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title .badge {
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
background: var(--accent-muted);
color: var(--accent);
border-radius: 100px;
}
/* -- Table -- */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
text-align: left;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.data-table td {
font-size: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
}
.data-table tr:last-child td { border-bottom: none; }
.model-tag {
font-size: 12px;
font-weight: 500;
padding: 3px 10px;
background: var(--bg-tertiary);
border-radius: 100px;
color: var(--text-secondary);
}
/* -- Status Indicator -- */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.status-dot.online { background: #3a8f8b; box-shadow: 0 0 6px rgba(58,143,139,0.4); }
.status-dot.offline { background: #c4c4c4; }
/* -- Members List -- */
.member-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border-subtle);
}
.member-item:last-child { border-bottom: none; }
.member-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: #fff;
}
.member-name { font-size: 14px; font-weight: 500; }
.member-role { font-size: 12px; color: var(--text-muted); }
.member-role-badge {
margin-left: auto;
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
border-radius: 100px;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
/* ===== ANIMATIONS ===== */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeUp 0.5s ease both; }
.fade-in-1 { animation-delay: 0.1s; }
.fade-in-2 { animation-delay: 0.2s; }
.fade-in-3 { animation-delay: 0.3s; }
.fade-in-4 { animation-delay: 0.4s; }
</style>
</head>
<body>
<!-- View Switcher -->
<div class="view-switcher">
<button class="active" onclick="showView('landing')">Landing</button>
<button onclick="showView('dashboard')">Dashboard</button>
</div>
<!-- ===== LANDING PAGE ===== -->
<div id="landing" class="view active">
<nav class="landing-nav">
<div class="nav-logo">CERT<span>if</span>AI</div>
<ul class="nav-links">
<li><a href="#">Features</a></li>
<li><a href="#">How It Works</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Docs</a></li>
</ul>
<div class="nav-cta">
<button class="btn-ghost">Sign In</button>
<button class="btn-primary">Get Started</button>
</div>
</nav>
<section class="hero">
<div class="hero-badge">&#9679; GDPR-Compliant &middot; 100% On-Premise</div>
<h1>Your AI infrastructure,<br><em>your rules.</em></h1>
<p>Self-hosted generative AI for companies that refuse to compromise on data sovereignty. Deploy, configure, and scale without leaving the EU.</p>
<div class="hero-actions">
<button class="btn-primary btn-lg">Start Free Trial</button>
<button class="btn-outline btn-lg">View Documentation</button>
</div>
</section>
<div class="trust-bar">
<div class="trust-item">
<div class="trust-icon">&#9745;</div>
100% On-Premise
</div>
<div class="trust-item">
<div class="trust-icon">&#9878;</div>
GDPR Compliant
</div>
<div class="trust-item">
<div class="trust-icon">&#9873;</div>
EU Data Residency
</div>
<div class="trust-item">
<div class="trust-icon">&#8709;</div>
Zero Third-Party Access
</div>
</div>
<section class="features">
<div class="section-header">
<h2>Built for sovereignty</h2>
<p>Every component designed to keep your intellectual property exactly where it belongs.</p>
</div>
<div class="features-grid">
<div class="feature-card fade-in fade-in-1">
<div class="feature-icon">&#9881;</div>
<h3>Self-Hosted Infrastructure</h3>
<p>Deploy on your hardware or private cloud. No data ever leaves your perimeter.</p>
</div>
<div class="feature-card fade-in fade-in-2">
<div class="feature-icon">&#9741;</div>
<h3>Multi-LLM Gateway</h3>
<p>Route between providers through a single API. LiteLLM proxy with full observability.</p>
</div>
<div class="feature-card fade-in fade-in-3">
<div class="feature-icon">&#10070;</div>
<h3>Agent Orchestration</h3>
<p>Build and manage LangGraph agents with visual workflows and real-time monitoring.</p>
</div>
<div class="feature-card fade-in fade-in-4">
<div class="feature-icon">&#9211;</div>
<h3>SSO & Identity</h3>
<p>Keycloak-powered authentication. Connect your existing LDAP or SAML provider.</p>
</div>
<div class="feature-card fade-in fade-in-3">
<div class="feature-icon">&#9776;</div>
<h3>Full Observability</h3>
<p>Langfuse integration for traces, cost tracking, and prompt engineering analytics.</p>
</div>
<div class="feature-card fade-in fade-in-4">
<div class="feature-icon">&#10132;</div>
<h3>API-First Design</h3>
<p>RESTful endpoints, API key management, and MCP server support built in.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<h2>Ready to take control?</h2>
<p>Deploy your private AI infrastructure in under 30 minutes. No credit card required.</p>
<button class="btn-white">Start Your Free Trial</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI. All rights reserved.</span>
<div class="footer-links">
<a href="#">Privacy</a>
<a href="#">Impressum</a>
<a href="#">Terms</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD PAGE ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand">
<h2>CERT<span>if</span>AI</h2>
</div>
<div class="sidebar-user">
<div class="user-avatar">MM</div>
<div class="user-info">
<div class="user-name">Max Mustermann</div>
<div class="user-email">max@company.de</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-label">Main</div>
<a class="nav-item active" href="#">&#9632; Dashboard</a>
<a class="nav-item" href="#">&#9674; Providers</a>
<a class="nav-item" href="#">&#9993; Chat</a>
<div class="nav-section-label">Developer</div>
<a class="nav-item" href="#">&#9881; Agents</a>
<a class="nav-item" href="#">&#10697; Workflows</a>
<a class="nav-item" href="#">&#9776; Analytics</a>
<div class="nav-section-label">Organization</div>
<a class="nav-item" href="#">&#9733; Billing</a>
<a class="nav-item" href="#">&#10070; Members</a>
</nav>
<div class="sidebar-footer">CERTifAI v0.1.0</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">AI news and system overview</p>
</div>
<button class="btn-primary">New Search</button>
</div>
<div class="stats-row">
<div class="stat-card fade-in fade-in-1">
<div class="stat-label">Total Spend</div>
<div class="stat-value">$47.82</div>
<div class="stat-change">+12% this month</div>
</div>
<div class="stat-card fade-in fade-in-2">
<div class="stat-label">Total Tokens</div>
<div class="stat-value">847K</div>
<div class="stat-change">of 1M limit</div>
</div>
<div class="stat-card fade-in fade-in-3">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<div class="stat-change"><span class="status-dot online"></span>LiteLLM Online</div>
</div>
<div class="stat-card fade-in fade-in-4">
<div class="stat-label">Team Members</div>
<div class="stat-value">4/25</div>
<div class="stat-change">Seats used</div>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-title">
Usage by Model
<span class="badge">This Month</span>
</div>
<table class="data-table">
<thead>
<tr>
<th>Model</th>
<th>Tokens</th>
<th>Spend</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="model-tag">Qwen3-Coder-30B</span></td>
<td>342K</td>
<td>$18.40</td>
<td><span class="status-dot online"></span>Active</td>
</tr>
<tr>
<td><span class="model-tag">Llama-3.1-70B</span></td>
<td>285K</td>
<td>$15.20</td>
<td><span class="status-dot online"></span>Active</td>
</tr>
<tr>
<td><span class="model-tag">Mistral-7B</span></td>
<td>120K</td>
<td>$8.42</td>
<td><span class="status-dot online"></span>Active</td>
</tr>
<tr>
<td><span class="model-tag">Gemma-2-9B</span></td>
<td>65K</td>
<td>$3.80</td>
<td><span class="status-dot online"></span>Active</td>
</tr>
<tr>
<td><span class="model-tag">Phi-3-mini</span></td>
<td>35K</td>
<td>$2.00</td>
<td><span class="status-dot offline"></span>Idle</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-title">Team</div>
<div class="member-item">
<div class="member-avatar" style="background:#3a8f8b">MM</div>
<div>
<div class="member-name">Max Mustermann</div>
<div class="member-role">max@company.de</div>
</div>
<span class="member-role-badge">Admin</span>
</div>
<div class="member-item">
<div class="member-avatar" style="background:#6d85c6">EM</div>
<div>
<div class="member-name">Erika Musterfrau</div>
<div class="member-role">erika@company.de</div>
</div>
<span class="member-role-badge">Member</span>
</div>
<div class="member-item">
<div class="member-avatar" style="background:#8b6db8">JS</div>
<div>
<div class="member-name">Johann Schmidt</div>
<div class="member-role">johann@company.de</div>
</div>
<span class="member-role-badge">Member</span>
</div>
<div class="member-item">
<div class="member-avatar" style="background:#b8886d">AW</div>
<div>
<div class="member-name">Anna Weber</div>
<div class="member-role">anna@company.de</div>
</div>
<span class="member-role-badge">Viewer</span>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,942 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 2: Cyber Command</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 2: CYBER COMMAND
========================================================================
Mood: Dark, high-tech, cyberpunk-inspired command center
Audience: DevOps, security teams, tech-forward startups
Palette: Deep blacks, neon cyan/green accents, electric highlights
Fonts: Outfit (headings) + JetBrains Mono (body/data)
Feel: Powerful, technical, mission-critical, the Matrix meets Bloomberg Terminal
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-void: #050508;
--bg-primary: #0a0b10;
--bg-secondary: #0f1018;
--bg-card: #12141e;
--bg-surface: #181b28;
--bg-hover: #1a1d2c;
--text-primary: #d4dae8;
--text-secondary: #7a8499;
--text-muted: #484f64;
--text-bright: #f0f3fa;
--cyan: #00e5c8;
--cyan-dim: rgba(0, 229, 200, 0.12);
--cyan-border: rgba(0, 229, 200, 0.2);
--cyan-glow: rgba(0, 229, 200, 0.06);
--green: #34d399;
--red: #f87171;
--yellow: #fbbf24;
--border: #1a1d2c;
--border-bright: #252a3a;
--shadow-glow: 0 0 40px rgba(0, 229, 200, 0.05);
}
body {
font-family: 'JetBrains Mono', monospace;
color: var(--text-primary);
background: var(--bg-void);
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'Outfit', sans-serif;
font-weight: 700;
line-height: 1.15;
color: var(--text-bright);
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
display: flex;
gap: 2px;
background: var(--bg-card);
border: 1px solid var(--border-bright);
padding: 4px;
border-radius: 8px;
}
.view-switcher button {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
background: transparent;
color: var(--text-muted);
transition: all 0.2s;
}
.view-switcher button.active {
background: var(--cyan);
color: var(--bg-void);
}
.view-switcher button:hover:not(.active) { color: var(--text-primary); }
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING PAGE ===== */
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 48px;
background: rgba(10, 11, 16, 0.85);
backdrop-filter: blur(24px);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'Outfit', sans-serif;
font-size: 20px;
font-weight: 800;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-bright);
}
.nav-logo .accent { color: var(--cyan); }
.nav-links {
display: flex;
gap: 32px;
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
transition: color 0.2s;
}
.nav-links a:hover { color: var(--cyan); }
.nav-cta { display: flex; gap: 10px; }
.btn-ghost {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
padding: 10px 20px;
border: 1px solid var(--border-bright);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.btn-ghost:hover { border-color: var(--cyan-border); color: var(--cyan); }
.btn-primary {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
padding: 10px 24px;
border: 1px solid var(--cyan);
background: var(--cyan);
color: var(--bg-void);
cursor: pointer;
border-radius: 6px;
transition: all 0.25s;
}
.btn-primary:hover { box-shadow: 0 0 24px rgba(0,229,200,0.3); transform: translateY(-1px); }
/* -- Hero -- */
.hero {
padding: 140px 48px 120px;
text-align: center;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: -100px;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--cyan-glow) 0%, transparent 70%);
pointer-events: none;
}
.hero::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 229, 200, 0.01) 2px,
rgba(0, 229, 200, 0.01) 4px
);
pointer-events: none;
}
.hero-tag {
display: inline-block;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--cyan);
padding: 8px 20px;
border: 1px solid var(--cyan-border);
border-radius: 4px;
margin-bottom: 36px;
background: var(--cyan-dim);
animation: fadeIn 0.5s ease;
}
.hero h1 {
font-size: 72px;
letter-spacing: -2px;
margin-bottom: 24px;
animation: fadeUp 0.6s ease 0.1s both;
}
.hero h1 .gradient {
background: linear-gradient(135deg, var(--cyan), #34d399, var(--cyan));
background-size: 200% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s ease infinite;
}
.hero p {
font-size: 14px;
color: var(--text-secondary);
max-width: 520px;
margin: 0 auto 44px;
line-height: 1.8;
animation: fadeUp 0.6s ease 0.2s both;
}
.hero-actions {
display: flex;
gap: 16px;
justify-content: center;
animation: fadeUp 0.6s ease 0.3s both;
}
.btn-outline {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
padding: 12px 28px;
border: 1px solid var(--border-bright);
background: transparent;
color: var(--text-primary);
cursor: pointer;
border-radius: 6px;
transition: all 0.25s;
}
.btn-outline:hover { border-color: var(--cyan-border); color: var(--cyan); }
/* -- Terminal Preview -- */
.terminal-preview {
max-width: 700px;
margin: 60px auto 0;
background: var(--bg-card);
border: 1px solid var(--border-bright);
border-radius: 10px;
overflow: hidden;
animation: fadeUp 0.6s ease 0.4s both;
}
.terminal-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
}
.terminal-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.terminal-dot.r { background: var(--red); opacity: 0.7; }
.terminal-dot.y { background: var(--yellow); opacity: 0.7; }
.terminal-dot.g { background: var(--green); opacity: 0.7; }
.terminal-title {
font-size: 11px;
color: var(--text-muted);
margin-left: 8px;
}
.terminal-body {
padding: 20px;
font-size: 13px;
line-height: 1.9;
color: var(--text-secondary);
}
.terminal-body .cmd { color: var(--cyan); }
.terminal-body .comment { color: var(--text-muted); }
.terminal-body .success { color: var(--green); }
/* -- Trust Bar -- */
.trust-bar {
display: flex;
justify-content: center;
gap: 48px;
padding: 40px 48px;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.trust-item {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 10px;
}
.trust-dot {
width: 6px;
height: 6px;
background: var(--cyan);
border-radius: 50%;
box-shadow: 0 0 8px rgba(0,229,200,0.4);
}
/* -- Features -- */
.features {
padding: 100px 48px;
max-width: 1200px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 64px;
}
.section-header h2 {
font-size: 42px;
letter-spacing: -1px;
margin-bottom: 16px;
}
.section-header p {
font-size: 13px;
color: var(--text-secondary);
max-width: 480px;
margin: 0 auto;
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.feature-card {
padding: 28px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.feature-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
opacity: 0;
transition: opacity 0.3s;
}
.feature-card:hover::before { opacity: 1; }
.feature-card:hover { border-color: var(--border-bright); box-shadow: var(--shadow-glow); }
.feature-num {
font-family: 'Outfit', sans-serif;
font-size: 11px;
font-weight: 700;
color: var(--cyan);
letter-spacing: 0.1em;
margin-bottom: 16px;
opacity: 0.6;
}
.feature-card h3 {
font-family: 'Outfit', sans-serif;
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
}
.feature-card p {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.7;
}
/* -- CTA -- */
.cta-section {
padding: 80px 48px;
text-align: center;
}
.cta-box {
max-width: 720px;
margin: 0 auto;
padding: 56px;
background: var(--bg-card);
border: 1px solid var(--cyan-border);
border-radius: 12px;
box-shadow: var(--shadow-glow);
}
.cta-box h2 { font-size: 32px; margin-bottom: 12px; }
.cta-box p { font-size: 13px; color: var(--text-secondary); margin-bottom: 32px; }
/* -- Footer -- */
.landing-footer {
padding: 40px 48px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-muted); text-decoration: none; }
.footer-links a:hover { color: var(--cyan); }
/* ===== DASHBOARD ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-primary);
}
.sidebar {
width: 240px;
min-width: 240px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-brand h2 {
font-size: 16px;
font-weight: 800;
letter-spacing: 2px;
text-transform: uppercase;
}
.sidebar-brand .accent { color: var(--cyan); }
.sidebar-status {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
}
.pulse-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 8px rgba(52,211,153,0.5);
animation: pulse 2s ease infinite;
}
.sidebar-nav {
flex: 1;
padding: 12px 8px;
}
.nav-group-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
padding: 12px 12px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
border-left: 2px solid transparent;
}
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.nav-item.active {
background: var(--cyan-dim);
color: var(--cyan);
border-left-color: var(--cyan);
}
.sidebar-user {
padding: 16px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar-sm {
width: 28px;
height: 28px;
border-radius: 4px;
background: var(--cyan-dim);
color: var(--cyan);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
.user-info-sm .name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.user-info-sm .role { font-size: 10px; color: var(--text-muted); }
/* -- Main -- */
.main-content {
flex: 1;
padding: 32px 40px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.page-title { font-size: 24px; letter-spacing: -0.5px; }
.page-subtitle {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
font-family: 'JetBrains Mono', monospace;
}
/* -- Stats -- */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 28px;
}
.stat-card {
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
position: relative;
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, var(--cyan), transparent);
opacity: 0.3;
}
.stat-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 8px;
}
.stat-value {
font-family: 'Outfit', sans-serif;
font-size: 26px;
font-weight: 700;
color: var(--text-bright);
}
.stat-change {
font-size: 11px;
margin-top: 6px;
color: var(--green);
}
/* -- Grid -- */
.content-grid {
display: grid;
grid-template-columns: 5fr 3fr;
gap: 16px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
}
.card-title {
font-family: 'Outfit', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 18px;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 4px 10px;
background: var(--cyan-dim);
color: var(--cyan);
border-radius: 4px;
border: 1px solid var(--cyan-border);
}
/* -- Table -- */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.data-table td {
font-size: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
.data-table tr:last-child td { border-bottom: none; }
.model-tag {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
background: var(--bg-surface);
border: 1px solid var(--border-bright);
border-radius: 4px;
color: var(--text-primary);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.status-dot.on { background: var(--green); box-shadow: 0 0 6px rgba(52,211,153,0.5); }
.status-dot.off { background: var(--text-muted); }
/* -- Activity List -- */
.activity-item {
display: flex;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.activity-item:last-child { border-bottom: none; }
.activity-time {
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
padding-top: 2px;
min-width: 48px;
}
.activity-text {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.activity-text strong { color: var(--text-primary); font-weight: 600; }
.activity-text .hl { color: var(--cyan); }
/* ===== Animations ===== */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes shimmer {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
</head>
<body>
<div class="view-switcher">
<button class="active" onclick="showView('landing')">LANDING</button>
<button onclick="showView('dashboard')">DASHBOARD</button>
</div>
<!-- ===== LANDING ===== -->
<div id="landing" class="view active">
<nav class="landing-nav">
<div class="nav-logo">CERT<span class="accent">IF</span>AI</div>
<ul class="nav-links">
<li><a href="#">Features</a></li>
<li><a href="#">Architecture</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Docs</a></li>
</ul>
<div class="nav-cta">
<button class="btn-ghost">Sign In</button>
<button class="btn-primary">Deploy Now</button>
</div>
</nav>
<section class="hero">
<div class="hero-tag">// SELF-HOSTED &middot; GDPR NATIVE &middot; ZERO TRUST</div>
<h1>Private AI<br><span class="gradient">Command Center</span></h1>
<p>Deploy sovereign AI infrastructure that never phones home. Route LLMs, orchestrate agents, track every token&mdash;all inside your perimeter.</p>
<div class="hero-actions">
<button class="btn-primary" style="padding:14px 32px">Deploy Now</button>
<button class="btn-outline" style="padding:14px 32px">Read the Docs</button>
</div>
<div class="terminal-preview">
<div class="terminal-bar">
<div class="terminal-dot r"></div>
<div class="terminal-dot y"></div>
<div class="terminal-dot g"></div>
<span class="terminal-title">certifai-cli &mdash; deploy</span>
</div>
<div class="terminal-body">
<span class="comment"># Deploy CERTifAI to your private cluster</span><br>
<span class="cmd">$</span> certifai deploy --region eu-west-1 --gpu a100<br>
<span class="success">&#10003;</span> Keycloak SSO configured<br>
<span class="success">&#10003;</span> LiteLLM proxy ready (5 models loaded)<br>
<span class="success">&#10003;</span> LangGraph agents online<br>
<span class="success">&#10003;</span> Langfuse observability active<br>
<br>
<span class="cmd">$</span> certifai status<br>
<span class="success">All systems operational.</span> Uptime: 99.97%
</div>
</div>
</section>
<div class="trust-bar">
<div class="trust-item"><div class="trust-dot"></div>100% ON-PREMISE</div>
<div class="trust-item"><div class="trust-dot"></div>GDPR ARTICLE 28</div>
<div class="trust-item"><div class="trust-dot"></div>EU DATA RESIDENCY</div>
<div class="trust-item"><div class="trust-dot"></div>ZERO THIRD-PARTY</div>
<div class="trust-item"><div class="trust-dot"></div>SOC 2 TYPE II</div>
</div>
<section class="features">
<div class="section-header">
<h2>Full-spectrum control</h2>
<p>From model routing to cost analytics, every layer is yours to command.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-num">01</div>
<h3>LLM Gateway</h3>
<p>LiteLLM proxy routes requests across providers. One API, full model flexibility, zero vendor lock-in.</p>
</div>
<div class="feature-card">
<div class="feature-num">02</div>
<h3>Agent Orchestration</h3>
<p>LangGraph + LangFlow for building, deploying, and monitoring autonomous agent workflows.</p>
</div>
<div class="feature-card">
<div class="feature-num">03</div>
<h3>Full Observability</h3>
<p>Langfuse tracing, cost attribution, prompt versioning. Know exactly what your AI is doing.</p>
</div>
<div class="feature-card">
<div class="feature-num">04</div>
<h3>Identity & Access</h3>
<p>Keycloak SSO with SAML, OIDC, LDAP. Fine-grained RBAC across all services.</p>
</div>
<div class="feature-card">
<div class="feature-num">05</div>
<h3>MCP Servers</h3>
<p>Model Context Protocol support for tool-augmented AI with secure function calling.</p>
</div>
<div class="feature-card">
<div class="feature-num">06</div>
<h3>API-First</h3>
<p>REST endpoints, API key rotation, webhook events. Integrate CERTifAI into your existing stack.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<h2>Your cluster. Your models. Your rules.</h2>
<p>Spin up a fully operational AI stack in under 30 minutes.</p>
<button class="btn-primary" style="padding:14px 36px; font-size:13px;">Start Deployment</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI GmbH</span>
<div class="footer-links">
<a href="#">Privacy</a>
<a href="#">Impressum</a>
<a href="#">Status</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand">
<h2>CERT<span class="accent">IF</span>AI</h2>
</div>
<div class="sidebar-status">
<div class="pulse-dot"></div>
ALL SYSTEMS OPERATIONAL
</div>
<nav class="sidebar-nav">
<div class="nav-group-label">Core</div>
<a class="nav-item active" href="#">&gt;_ Dashboard</a>
<a class="nav-item" href="#">&#9674; Providers</a>
<a class="nav-item" href="#">&#9993; Chat</a>
<div class="nav-group-label">Developer</div>
<a class="nav-item" href="#">&#10070; Agents</a>
<a class="nav-item" href="#">&#10697; Workflows</a>
<a class="nav-item" href="#">&#9776; Analytics</a>
<div class="nav-group-label">Organization</div>
<a class="nav-item" href="#">&#9733; Billing</a>
<a class="nav-item" href="#">&#9823; Members</a>
</nav>
<div class="sidebar-user">
<div class="user-avatar-sm">MM</div>
<div class="user-info-sm">
<div class="name">Max Mustermann</div>
<div class="role">Admin</div>
</div>
</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Command Center</h1>
<p class="page-subtitle">// system overview &middot; feb 2026</p>
</div>
<button class="btn-primary">+ New Search</button>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Total Spend</div>
<div class="stat-value">$47.82</div>
<div class="stat-change">+12.4% vs last month</div>
</div>
<div class="stat-card">
<div class="stat-label">Tokens Processed</div>
<div class="stat-value">847K</div>
<div class="stat-change">of 1M monthly cap</div>
</div>
<div class="stat-card">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<div class="stat-change"><span class="status-dot on"></span>LiteLLM proxy online</div>
</div>
<div class="stat-card">
<div class="stat-label">Team Seats</div>
<div class="stat-value">4/25</div>
<div class="stat-change">21 available</div>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-title">
Model Usage
<span class="card-badge">LIVE</span>
</div>
<table class="data-table">
<thead>
<tr><th>Model</th><th>Tokens</th><th>Spend</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td><span class="model-tag">Qwen3-Coder-30B</span></td><td>342K</td><td>$18.40</td><td><span class="status-dot on"></span>Active</td></tr>
<tr><td><span class="model-tag">Llama-3.1-70B</span></td><td>285K</td><td>$15.20</td><td><span class="status-dot on"></span>Active</td></tr>
<tr><td><span class="model-tag">Mistral-7B</span></td><td>120K</td><td>$8.42</td><td><span class="status-dot on"></span>Active</td></tr>
<tr><td><span class="model-tag">Gemma-2-9B</span></td><td>65K</td><td>$3.80</td><td><span class="status-dot on"></span>Active</td></tr>
<tr><td><span class="model-tag">Phi-3-mini</span></td><td>35K</td><td>$2.00</td><td><span class="status-dot off"></span>Idle</td></tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-title">Activity Log</div>
<div class="activity-item">
<span class="activity-time">2m ago</span>
<span class="activity-text"><strong>Erika M.</strong> queried <span class="hl">Qwen3-Coder</span></span>
</div>
<div class="activity-item">
<span class="activity-time">8m ago</span>
<span class="activity-text"><strong>Johann S.</strong> deployed agent <span class="hl">doc-parser</span></span>
</div>
<div class="activity-item">
<span class="activity-time">14m ago</span>
<span class="activity-text"><strong>Anna W.</strong> viewed analytics trace</span>
</div>
<div class="activity-item">
<span class="activity-time">31m ago</span>
<span class="activity-text"><strong>Max M.</strong> rotated API key for <span class="hl">LiteLLM</span></span>
</div>
<div class="activity-item">
<span class="activity-time">1h ago</span>
<span class="activity-text">System: <span class="hl">Llama-3.1-70B</span> model health check passed</span>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,963 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 3: Warm Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;0,9..144,800;1,9..144,400;1,9..144,500&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 3: WARM STUDIO
========================================================================
Mood: Warm, approachable, creative studio atmosphere
Audience: Creative agencies, education, non-technical leadership
Palette: Warm cream/sand base, terracotta/amber accents, soft shadows
Fonts: Fraunces (headings) + Plus Jakarta Sans (body)
Feel: Friendly, human, inviting, like a well-designed co-working space
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-warm: #faf6f1;
--bg-white: #ffffff;
--bg-cream: #f5efe7;
--bg-sand: #ede4d8;
--bg-dark: #2c2622;
--text-dark: #2c2622;
--text-body: #5c524a;
--text-muted: #9a8e84;
--text-light: #c4b8ac;
--text-inverse: #faf6f1;
--terracotta: #c4653a;
--terracotta-light: #d77a54;
--terracotta-muted: rgba(196, 101, 58, 0.08);
--terracotta-border: rgba(196, 101, 58, 0.18);
--amber: #c99a2e;
--amber-muted: rgba(201, 154, 46, 0.1);
--sage: #6b8a6b;
--sage-muted: rgba(107, 138, 107, 0.1);
--border: #e8dfd5;
--border-subtle: #f0e9e0;
--shadow-warm: 0 2px 12px rgba(44, 38, 34, 0.06);
--shadow-lg: 0 8px 32px rgba(44, 38, 34, 0.08);
--radius: 14px;
--radius-sm: 8px;
--radius-xl: 24px;
}
body {
font-family: 'Plus Jakarta Sans', sans-serif;
color: var(--text-body);
background: var(--bg-warm);
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'Fraunces', serif;
color: var(--text-dark);
line-height: 1.2;
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
gap: 4px;
background: var(--bg-dark);
padding: 5px;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
}
.view-switcher button {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 13px;
font-weight: 600;
border: none;
padding: 8px 18px;
border-radius: 8px;
cursor: pointer;
background: transparent;
color: rgba(250,246,241,0.4);
transition: all 0.2s;
}
.view-switcher button.active {
background: var(--terracotta);
color: #fff;
}
.view-switcher button:hover:not(.active) { color: rgba(250,246,241,0.7); }
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING ===== */
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 56px;
background: rgba(250, 246, 241, 0.9);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border-subtle);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'Fraunces', serif;
font-size: 24px;
font-weight: 700;
color: var(--text-dark);
}
.nav-logo em { font-style: italic; color: var(--terracotta); }
.nav-links {
display: flex;
gap: 32px;
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--text-muted);
font-size: 15px;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover { color: var(--text-dark); }
.nav-cta { display: flex; gap: 12px; }
.btn-ghost {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 10px 22px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.2s;
}
.btn-ghost:hover { color: var(--text-dark); background: var(--bg-cream); }
.btn-primary {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 10px 26px;
border: none;
background: var(--terracotta);
color: #fff;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.25s;
}
.btn-primary:hover { background: var(--terracotta-light); transform: translateY(-1px); box-shadow: var(--shadow-warm); }
.btn-outline {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 12px 28px;
border: 1.5px solid var(--border);
background: var(--bg-white);
color: var(--text-dark);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.2s;
}
.btn-outline:hover { border-color: var(--terracotta-border); }
/* -- Hero -- */
.hero {
padding: 100px 56px 80px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
align-items: center;
max-width: 1200px;
margin: 0 auto;
}
.hero-text { animation: fadeUp 0.6s ease; }
.hero-eyebrow {
font-size: 13px;
font-weight: 600;
color: var(--terracotta);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 16px;
}
.hero h1 {
font-size: 52px;
font-weight: 800;
letter-spacing: -1px;
margin-bottom: 20px;
}
.hero h1 em {
font-style: italic;
font-weight: 500;
color: var(--terracotta);
}
.hero p {
font-size: 17px;
line-height: 1.7;
color: var(--text-body);
margin-bottom: 36px;
max-width: 480px;
}
.hero-actions { display: flex; gap: 14px; }
.hero-visual {
position: relative;
animation: fadeUp 0.6s ease 0.2s both;
}
.hero-card {
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 32px;
box-shadow: var(--shadow-lg);
}
.hero-card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.hero-card-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--sage);
}
.hero-card-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.hero-metric {
margin-bottom: 16px;
}
.hero-metric-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.hero-metric-value {
font-family: 'Fraunces', serif;
font-size: 32px;
font-weight: 700;
color: var(--text-dark);
}
.hero-bar {
height: 8px;
background: var(--bg-cream);
border-radius: 100px;
overflow: hidden;
margin-bottom: 20px;
}
.hero-bar-fill {
height: 100%;
border-radius: 100px;
background: linear-gradient(90deg, var(--terracotta), var(--amber));
width: 68%;
}
.hero-models {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.hero-model-chip {
font-size: 12px;
font-weight: 500;
padding: 6px 14px;
background: var(--bg-cream);
border-radius: 100px;
color: var(--text-body);
}
.hero-floating-badge {
position: absolute;
top: -12px;
right: -12px;
background: var(--sage);
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 8px 16px;
border-radius: 100px;
box-shadow: var(--shadow-warm);
}
/* -- Trust -- */
.trust-bar {
display: flex;
justify-content: center;
gap: 40px;
padding: 48px 56px;
background: var(--bg-white);
border-top: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--border-subtle);
}
.trust-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
}
.trust-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--terracotta-muted);
border-radius: var(--radius-sm);
color: var(--terracotta);
font-size: 14px;
}
/* -- Features -- */
.features {
padding: 100px 56px;
max-width: 1100px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 60px;
}
.section-header h2 {
font-size: 38px;
font-weight: 700;
margin-bottom: 14px;
}
.section-header p {
font-size: 16px;
color: var(--text-muted);
max-width: 440px;
margin: 0 auto;
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.feature-card {
padding: 28px;
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: all 0.3s;
}
.feature-card:hover {
box-shadow: var(--shadow-warm);
transform: translateY(-3px);
}
.feature-emoji {
font-size: 28px;
margin-bottom: 16px;
display: block;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-cream);
border-radius: 12px;
}
.feature-card h3 {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 16px;
font-weight: 700;
margin-bottom: 8px;
}
.feature-card p {
font-size: 14px;
color: var(--text-muted);
line-height: 1.7;
}
/* -- CTA -- */
.cta-section {
padding: 60px 56px 80px;
}
.cta-box {
max-width: 800px;
margin: 0 auto;
padding: 56px;
background: var(--bg-dark);
border-radius: var(--radius-xl);
text-align: center;
color: var(--text-inverse);
position: relative;
overflow: hidden;
}
.cta-box::before {
content: '';
position: absolute;
top: -60px;
right: -60px;
width: 200px;
height: 200px;
background: var(--terracotta);
border-radius: 50%;
opacity: 0.15;
}
.cta-box h2 {
font-size: 34px;
color: var(--text-inverse);
margin-bottom: 12px;
position: relative;
}
.cta-box p {
font-size: 15px;
color: rgba(250,246,241,0.6);
margin-bottom: 28px;
position: relative;
}
.btn-warm {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 14px 32px;
border: none;
background: var(--terracotta);
color: #fff;
cursor: pointer;
border-radius: var(--radius-sm);
position: relative;
transition: all 0.25s;
}
.btn-warm:hover { background: var(--terracotta-light); }
.landing-footer {
padding: 40px 56px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: var(--text-light);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-light); text-decoration: none; }
.footer-links a:hover { color: var(--text-dark); }
/* ===== DASHBOARD ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-warm);
}
.sidebar {
width: 260px;
min-width: 260px;
background: var(--bg-white);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 24px;
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-brand h2 {
font-size: 22px;
font-weight: 700;
}
.sidebar-brand em { font-style: italic; color: var(--terracotta); }
.sidebar-user {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px;
border-bottom: 1px solid var(--border-subtle);
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--terracotta), var(--amber));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.user-name { font-size: 14px; font-weight: 600; color: var(--text-dark); }
.user-email { font-size: 12px; color: var(--text-muted); }
.sidebar-nav {
flex: 1;
padding: 16px 12px;
}
.nav-section {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-light);
padding: 16px 16px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
color: var(--text-body);
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.nav-item:hover { background: var(--bg-cream); }
.nav-item.active {
background: var(--terracotta-muted);
color: var(--terracotta);
font-weight: 600;
}
.sidebar-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-subtle);
font-size: 12px;
color: var(--text-light);
}
.main-content {
flex: 1;
padding: 40px 48px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 36px;
}
.page-title {
font-size: 28px;
font-weight: 700;
}
.page-subtitle {
font-size: 15px;
color: var(--text-muted);
margin-top: 4px;
font-family: 'Plus Jakarta Sans', sans-serif;
}
/* -- Welcome Card -- */
.welcome-card {
background: linear-gradient(135deg, var(--bg-dark) 0%, #3d3530 100%);
border-radius: var(--radius-xl);
padding: 36px 40px;
margin-bottom: 28px;
color: var(--text-inverse);
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-text h2 {
font-size: 24px;
color: var(--text-inverse);
margin-bottom: 8px;
}
.welcome-text p {
font-size: 14px;
color: rgba(250,246,241,0.6);
}
.welcome-stats {
display: flex;
gap: 32px;
}
.welcome-stat-value {
font-family: 'Fraunces', serif;
font-size: 28px;
font-weight: 700;
color: var(--terracotta-light);
}
.welcome-stat-label {
font-size: 12px;
color: rgba(250,246,241,0.5);
margin-top: 2px;
}
/* -- Stats -- */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
padding: 24px;
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: all 0.2s;
}
.stat-card:hover { box-shadow: var(--shadow-warm); }
.stat-label {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 6px;
}
.stat-value {
font-family: 'Fraunces', serif;
font-size: 28px;
font-weight: 700;
color: var(--text-dark);
}
.stat-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 100px;
margin-top: 6px;
}
.stat-badge.up { background: var(--sage-muted); color: var(--sage); }
.stat-badge.neutral { background: var(--amber-muted); color: var(--amber); }
/* -- Content Grid -- */
.content-grid {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 20px;
}
.card {
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
}
.card-title {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 15px;
font-weight: 700;
color: var(--text-dark);
margin-bottom: 18px;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-size: 12px;
font-weight: 600;
color: var(--text-light);
text-align: left;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.data-table td {
font-size: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-body);
}
.data-table tr:last-child td { border-bottom: none; }
.model-chip {
font-size: 12px;
font-weight: 500;
padding: 4px 12px;
background: var(--bg-cream);
border-radius: 100px;
color: var(--text-body);
}
/* -- Quick Actions -- */
.quick-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.quick-action {
padding: 18px;
background: var(--bg-cream);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: block;
}
.quick-action:hover { background: var(--bg-sand); }
.quick-action-icon {
font-size: 20px;
margin-bottom: 8px;
}
.quick-action-title {
font-size: 13px;
font-weight: 600;
color: var(--text-dark);
margin-bottom: 2px;
}
.quick-action-desc {
font-size: 11px;
color: var(--text-muted);
}
/* ===== Animations ===== */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="view-switcher">
<button class="active" onclick="showView('landing')">Landing</button>
<button onclick="showView('dashboard')">Dashboard</button>
</div>
<!-- ===== LANDING ===== -->
<div id="landing" class="view active">
<nav class="landing-nav">
<div class="nav-logo">Cert<em>if</em>AI</div>
<ul class="nav-links">
<li><a href="#">Features</a></li>
<li><a href="#">How It Works</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Contact</a></li>
</ul>
<div class="nav-cta">
<button class="btn-ghost">Sign In</button>
<button class="btn-primary">Get Started</button>
</div>
</nav>
<section class="hero">
<div class="hero-text">
<div class="hero-eyebrow">Private AI Infrastructure</div>
<h1>AI that stays <em>in-house</em></h1>
<p>A friendly, powerful dashboard for managing your self-hosted GenAI tools. No data leaves your servers. No compromises on capability.</p>
<div class="hero-actions">
<button class="btn-primary" style="padding:14px 32px; font-size:15px;">Start Free Trial</button>
<button class="btn-outline" style="padding:14px 32px; font-size:15px;">Watch Demo</button>
</div>
</div>
<div class="hero-visual">
<div class="hero-floating-badge">5 models live</div>
<div class="hero-card">
<div class="hero-card-header">
<div class="hero-card-dot"></div>
<span class="hero-card-label">System Overview</span>
</div>
<div class="hero-metric">
<div class="hero-metric-label">Token usage this month</div>
<div class="hero-metric-value">847,000</div>
</div>
<div class="hero-bar"><div class="hero-bar-fill"></div></div>
<div class="hero-models">
<span class="hero-model-chip">Qwen3-Coder</span>
<span class="hero-model-chip">Llama 3.1</span>
<span class="hero-model-chip">Mistral</span>
<span class="hero-model-chip">Gemma 2</span>
</div>
</div>
</div>
</section>
<div class="trust-bar">
<div class="trust-item"><div class="trust-icon">&#9745;</div>100% On-Premise</div>
<div class="trust-item"><div class="trust-icon">&#9878;</div>GDPR Compliant</div>
<div class="trust-item"><div class="trust-icon">&#9873;</div>EU Data Only</div>
<div class="trust-item"><div class="trust-icon">&#9711;</div>Zero Third-Party</div>
</div>
<section class="features">
<div class="section-header">
<h2>Everything you need, nothing you don't</h2>
<p>Simple tools for managing sophisticated AI infrastructure.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-emoji">&#9881;</div>
<h3>Self-Hosted Infrastructure</h3>
<p>Your hardware, your cloud, your rules. Deploy with a single command.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#9741;</div>
<h3>Multi-Model Gateway</h3>
<p>One API for all your models. Switch providers without changing a line of code.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#10070;</div>
<h3>Visual Agent Builder</h3>
<p>Drag-and-drop workflows for building AI agents that actually work.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#9211;</div>
<h3>Single Sign-On</h3>
<p>Connect your existing identity provider. One login for everything.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#9776;</div>
<h3>Usage Analytics</h3>
<p>Track every token, every dollar, every model. Full transparency.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#10132;</div>
<h3>API & Integrations</h3>
<p>REST APIs, webhooks, and MCP server support out of the box.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<h2>Let's get you set up</h2>
<p>No credit card. No sales call. Just your AI infrastructure, ready in minutes.</p>
<button class="btn-warm">Start Your Free Trial</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI</span>
<div class="footer-links">
<a href="#">Privacy</a>
<a href="#">Impressum</a>
<a href="#">Contact</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand"><h2>Cert<em>if</em>AI</h2></div>
<div class="sidebar-user">
<div class="user-avatar">MM</div>
<div>
<div class="user-name">Max Mustermann</div>
<div class="user-email">max@company.de</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">Main</div>
<a class="nav-item active">&#9632; Dashboard</a>
<a class="nav-item">&#9674; Providers</a>
<a class="nav-item">&#9993; Chat</a>
<div class="nav-section">Developer</div>
<a class="nav-item">&#9881; Agents</a>
<a class="nav-item">&#10697; Workflows</a>
<a class="nav-item">&#9776; Analytics</a>
<div class="nav-section">Organization</div>
<a class="nav-item">&#9733; Billing</a>
<a class="nav-item">&#10070; Members</a>
</nav>
<div class="sidebar-footer">v0.1.0</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Good morning, Max</h1>
<p class="page-subtitle">Here's what's happening with your AI stack today.</p>
</div>
<button class="btn-primary">New Search</button>
</div>
<div class="welcome-card">
<div class="welcome-text">
<h2>February Overview</h2>
<p>Your team used 847K tokens across 5 models this month.</p>
</div>
<div class="welcome-stats">
<div>
<div class="welcome-stat-value">$47.82</div>
<div class="welcome-stat-label">Total spend</div>
</div>
<div>
<div class="welcome-stat-value">4/25</div>
<div class="welcome-stat-label">Seats used</div>
</div>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<span class="stat-badge up">All healthy</span>
</div>
<div class="stat-card">
<div class="stat-label">Agents Running</div>
<div class="stat-value">3</div>
<span class="stat-badge neutral">2 idle</span>
</div>
<div class="stat-card">
<div class="stat-label">Billing Cycle Ends</div>
<div class="stat-value">Mar 1</div>
<span class="stat-badge up">3 days left</span>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-title">Usage by Model</div>
<table class="data-table">
<thead>
<tr><th>Model</th><th>Tokens</th><th>Spend</th></tr>
</thead>
<tbody>
<tr><td><span class="model-chip">Qwen3-Coder-30B</span></td><td>342K</td><td>$18.40</td></tr>
<tr><td><span class="model-chip">Llama-3.1-70B</span></td><td>285K</td><td>$15.20</td></tr>
<tr><td><span class="model-chip">Mistral-7B</span></td><td>120K</td><td>$8.42</td></tr>
<tr><td><span class="model-chip">Gemma-2-9B</span></td><td>65K</td><td>$3.80</td></tr>
<tr><td><span class="model-chip">Phi-3-mini</span></td><td>35K</td><td>$2.00</td></tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-title">Quick Actions</div>
<div class="quick-actions">
<a class="quick-action">
<div class="quick-action-icon">&#9993;</div>
<div class="quick-action-title">Open Chat</div>
<div class="quick-action-desc">Ask your models anything</div>
</a>
<a class="quick-action">
<div class="quick-action-icon">&#9881;</div>
<div class="quick-action-title">Manage Agents</div>
<div class="quick-action-desc">View running agents</div>
</a>
<a class="quick-action">
<div class="quick-action-icon">&#9776;</div>
<div class="quick-action-title">View Analytics</div>
<div class="quick-action-desc">Langfuse dashboard</div>
</a>
<a class="quick-action">
<div class="quick-action-icon">&#10070;</div>
<div class="quick-action-title">Invite Team</div>
<div class="quick-action-desc">Add new members</div>
</a>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,963 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 4: Glass Aurora</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700;800&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,500;0,7..72,600;0,7..72,700;1,7..72,400&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 4: GLASS AURORA
========================================================================
Mood: Vibrant, modern, glassmorphic, bold gradients
Audience: SaaS-savvy buyers, modern enterprises, AI-native teams
Palette: Deep navy/purple base, aurora gradient accents, glass effects
Fonts: Sora (headings) + Literata (body)
Feel: Premium SaaS, forward-looking, rich, confident
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #0c0a1d;
--bg-primary: #100e24;
--bg-card: rgba(22, 19, 48, 0.7);
--bg-glass: rgba(255, 255, 255, 0.04);
--bg-glass-hover: rgba(255, 255, 255, 0.07);
--text-bright: #f4f0ff;
--text-primary: #cfc8e8;
--text-secondary: #8b82aa;
--text-muted: #5a5280;
--gradient-start: #6366f1;
--gradient-mid: #a855f7;
--gradient-end: #ec4899;
--aurora: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7, #ec4899);
--aurora-muted: linear-gradient(135deg, rgba(99,102,241,0.12), rgba(168,85,247,0.12));
--glass-border: rgba(255, 255, 255, 0.08);
--glass-border-bright: rgba(255, 255, 255, 0.12);
--green: #34d399;
--green-dim: rgba(52, 211, 153, 0.15);
--shadow-glow: 0 0 60px rgba(139, 92, 246, 0.08);
--radius: 16px;
--radius-sm: 10px;
--radius-xl: 24px;
}
body {
font-family: 'Literata', serif;
color: var(--text-primary);
background: var(--bg-deep);
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'Sora', sans-serif;
font-weight: 700;
line-height: 1.15;
color: var(--text-bright);
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
display: flex;
gap: 2px;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
padding: 4px;
border-radius: 12px;
}
.view-switcher button {
font-family: 'Sora', sans-serif;
font-size: 12px;
font-weight: 600;
border: none;
padding: 8px 18px;
border-radius: 9px;
cursor: pointer;
background: transparent;
color: var(--text-muted);
transition: all 0.25s;
}
.view-switcher button.active {
background: var(--aurora);
color: #fff;
}
.view-switcher button:hover:not(.active) { color: var(--text-primary); }
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING ===== */
.landing-bg {
position: relative;
overflow: hidden;
}
.landing-bg::before {
content: '';
position: fixed;
top: -40%;
left: -20%;
width: 80%;
height: 80%;
background: radial-gradient(ellipse, rgba(99,102,241,0.12) 0%, transparent 60%);
pointer-events: none;
}
.landing-bg::after {
content: '';
position: fixed;
bottom: -30%;
right: -20%;
width: 70%;
height: 70%;
background: radial-gradient(ellipse, rgba(236,72,153,0.08) 0%, transparent 60%);
pointer-events: none;
}
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 56px;
background: rgba(16, 14, 36, 0.6);
backdrop-filter: blur(24px);
border-bottom: 1px solid var(--glass-border);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'Sora', sans-serif;
font-size: 22px;
font-weight: 800;
background: var(--aurora);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.nav-links {
display: flex;
gap: 32px;
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--text-secondary);
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover { color: var(--text-bright); }
.nav-cta { display: flex; gap: 10px; }
.btn-ghost {
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
padding: 10px 22px;
border: 1px solid var(--glass-border);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.2s;
}
.btn-ghost:hover { border-color: var(--glass-border-bright); color: var(--text-bright); }
.btn-primary {
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 600;
padding: 10px 24px;
border: none;
background: var(--aurora);
background-size: 200% 200%;
color: #fff;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.3s;
}
.btn-primary:hover { background-position: 100% 0; transform: translateY(-1px); box-shadow: 0 4px 24px rgba(139,92,246,0.3); }
/* -- Hero -- */
.hero {
padding: 120px 56px 100px;
text-align: center;
position: relative;
}
.hero-pills {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 32px;
animation: fadeUp 0.6s ease;
}
.pill {
font-family: 'Sora', sans-serif;
font-size: 12px;
font-weight: 500;
padding: 6px 16px;
background: var(--bg-glass);
border: 1px solid var(--glass-border);
border-radius: 100px;
color: var(--text-secondary);
backdrop-filter: blur(8px);
}
.pill.accent {
background: rgba(99,102,241,0.15);
border-color: rgba(99,102,241,0.3);
color: #a5b4fc;
}
.hero h1 {
font-size: 68px;
letter-spacing: -2px;
margin-bottom: 20px;
animation: fadeUp 0.6s ease 0.1s both;
}
.hero h1 .gradient-text {
background: var(--aurora);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero p {
font-size: 17px;
color: var(--text-secondary);
max-width: 540px;
margin: 0 auto 40px;
animation: fadeUp 0.6s ease 0.2s both;
}
.hero-actions {
display: flex;
gap: 14px;
justify-content: center;
animation: fadeUp 0.6s ease 0.3s both;
}
.btn-lg { padding: 14px 36px; font-size: 14px; }
.btn-glass {
font-family: 'Sora', sans-serif;
font-size: 14px;
font-weight: 500;
padding: 14px 36px;
background: var(--bg-glass);
backdrop-filter: blur(8px);
border: 1px solid var(--glass-border);
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.25s;
}
.btn-glass:hover { background: var(--bg-glass-hover); border-color: var(--glass-border-bright); }
/* -- Glass Preview -- */
.preview-container {
max-width: 900px;
margin: 64px auto 0;
animation: fadeUp 0.8s ease 0.4s both;
}
.glass-preview {
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: 32px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.preview-stat {
padding: 20px;
background: var(--bg-glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
text-align: center;
}
.preview-stat-value {
font-family: 'Sora', sans-serif;
font-size: 28px;
font-weight: 700;
color: var(--text-bright);
margin-bottom: 4px;
}
.preview-stat-label {
font-family: 'Sora', sans-serif;
font-size: 12px;
color: var(--text-muted);
}
/* -- Trust -- */
.trust-bar {
display: flex;
justify-content: center;
gap: 40px;
padding: 48px 56px;
border-top: 1px solid var(--glass-border);
}
.trust-item {
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.trust-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--aurora);
}
/* -- Features -- */
.features {
padding: 100px 56px;
max-width: 1200px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 60px;
}
.section-header h2 { font-size: 40px; letter-spacing: -1px; margin-bottom: 12px; }
.section-header p { font-size: 16px; color: var(--text-secondary); max-width: 450px; margin: 0 auto; }
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.feature-card {
padding: 28px;
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.feature-card:hover {
border-color: var(--glass-border-bright);
box-shadow: var(--shadow-glow);
transform: translateY(-2px);
}
.feature-icon-bar {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--aurora);
margin-bottom: 18px;
}
.feature-card h3 {
font-size: 16px;
margin-bottom: 8px;
}
.feature-card p {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.7;
}
/* -- CTA -- */
.cta-section {
padding: 80px 56px;
text-align: center;
}
.cta-box {
max-width: 700px;
margin: 0 auto;
padding: 60px;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
position: relative;
overflow: hidden;
}
.cta-box::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--aurora);
}
.cta-box h2 { font-size: 32px; margin-bottom: 12px; }
.cta-box p { font-size: 15px; color: var(--text-secondary); margin-bottom: 28px; }
.landing-footer {
padding: 40px 56px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--glass-border);
font-family: 'Sora', sans-serif;
font-size: 12px;
color: var(--text-muted);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-muted); text-decoration: none; }
.footer-links a:hover { color: var(--text-bright); }
/* ===== DASHBOARD ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-deep);
}
.sidebar {
width: 256px;
min-width: 256px;
background: rgba(16, 14, 36, 0.8);
backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 22px 20px;
border-bottom: 1px solid var(--glass-border);
}
.sidebar-brand h2 {
font-size: 20px;
font-weight: 800;
background: var(--aurora);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sidebar-user {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--glass-border);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--aurora);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 700;
}
.user-name { font-family: 'Sora', sans-serif; font-size: 13px; font-weight: 600; color: var(--text-bright); }
.user-email { font-family: 'Sora', sans-serif; font-size: 11px; color: var(--text-muted); }
.sidebar-nav {
flex: 1;
padding: 12px 10px;
}
.nav-group {
font-family: 'Sora', sans-serif;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
padding: 14px 14px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-radius: var(--radius-sm);
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.nav-item:hover { background: var(--bg-glass-hover); color: var(--text-primary); }
.nav-item.active {
background: rgba(99,102,241,0.12);
color: #a5b4fc;
font-weight: 600;
}
.sidebar-footer {
padding: 14px 20px;
border-top: 1px solid var(--glass-border);
font-family: 'Sora', sans-serif;
font-size: 11px;
color: var(--text-muted);
}
.main-content {
flex: 1;
padding: 36px 44px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.page-title { font-size: 26px; letter-spacing: -0.5px; }
.page-subtitle { font-family: 'Sora', sans-serif; font-size: 13px; color: var(--text-muted); margin-top: 4px; }
/* -- Gradient Banner -- */
.gradient-banner {
background: var(--aurora);
border-radius: var(--radius-xl);
padding: 32px 36px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
overflow: hidden;
}
.gradient-banner::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
90deg,
transparent,
transparent 100px,
rgba(255,255,255,0.03) 100px,
rgba(255,255,255,0.03) 101px
);
}
.banner-text h3 { font-size: 20px; margin-bottom: 4px; position: relative; }
.banner-text p { font-size: 13px; color: rgba(255,255,255,0.7); position: relative; font-family: 'Sora', sans-serif; }
.banner-stats {
display: flex;
gap: 36px;
position: relative;
}
.banner-stat-value { font-family: 'Sora', sans-serif; font-size: 24px; font-weight: 700; color: #fff; }
.banner-stat-label { font-family: 'Sora', sans-serif; font-size: 11px; color: rgba(255,255,255,0.6); }
/* -- Stats -- */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 24px;
}
.stat-card {
padding: 22px;
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
transition: all 0.2s;
}
.stat-card:hover { border-color: var(--glass-border-bright); box-shadow: var(--shadow-glow); }
.stat-label {
font-family: 'Sora', sans-serif;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 8px;
}
.stat-value {
font-family: 'Sora', sans-serif;
font-size: 26px;
font-weight: 700;
color: var(--text-bright);
}
.stat-sub {
font-family: 'Sora', sans-serif;
font-size: 11px;
margin-top: 4px;
color: var(--green);
}
/* -- Grid -- */
.content-grid {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 14px;
}
.card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
padding: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.card-title {
font-family: 'Sora', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-bright);
}
.card-badge {
font-family: 'Sora', sans-serif;
font-size: 10px;
font-weight: 600;
padding: 4px 12px;
background: var(--green-dim);
color: var(--green);
border-radius: 100px;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-family: 'Sora', sans-serif;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 8px 0;
border-bottom: 1px solid var(--glass-border);
}
.data-table td {
font-size: 13px;
padding: 12px 0;
border-bottom: 1px solid var(--glass-border);
}
.data-table tr:last-child td { border-bottom: none; }
.model-tag {
font-family: 'Sora', sans-serif;
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
background: var(--bg-glass);
border: 1px solid var(--glass-border);
border-radius: 6px;
}
.status-online {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
background: var(--green);
box-shadow: 0 0 6px rgba(52,211,153,0.5);
}
/* -- Service Status -- */
.service-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid var(--glass-border);
}
.service-item:last-child { border-bottom: none; }
.service-name {
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.service-url {
font-size: 11px;
color: var(--text-muted);
}
.service-status {
font-family: 'Sora', sans-serif;
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 100px;
}
.service-status.online {
background: var(--green-dim);
color: var(--green);
}
.service-status.offline {
background: rgba(248,113,113,0.12);
color: #f87171;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="view-switcher">
<button class="active" onclick="showView('landing')">Landing</button>
<button onclick="showView('dashboard')">Dashboard</button>
</div>
<!-- ===== LANDING ===== -->
<div id="landing" class="view active landing-bg">
<nav class="landing-nav">
<div class="nav-logo">CERTifAI</div>
<ul class="nav-links">
<li><a href="#">Features</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Docs</a></li>
<li><a href="#">Blog</a></li>
</ul>
<div class="nav-cta">
<button class="btn-ghost">Sign In</button>
<button class="btn-primary">Get Started Free</button>
</div>
</nav>
<section class="hero">
<div class="hero-pills">
<span class="pill accent">GDPR Native</span>
<span class="pill">Self-Hosted</span>
<span class="pill">EU Sovereign</span>
</div>
<h1>Sovereign AI<br><span class="gradient-text">infrastructure</span></h1>
<p>The complete platform for deploying, managing, and scaling private generative AI. Your data never leaves your perimeter.</p>
<div class="hero-actions">
<button class="btn-primary btn-lg">Start Free Trial</button>
<button class="btn-glass">Live Demo</button>
</div>
<div class="preview-container">
<div class="glass-preview">
<div class="preview-stat">
<div class="preview-stat-value">5</div>
<div class="preview-stat-label">Active Models</div>
</div>
<div class="preview-stat">
<div class="preview-stat-value">847K</div>
<div class="preview-stat-label">Tokens / Month</div>
</div>
<div class="preview-stat">
<div class="preview-stat-value">$47.82</div>
<div class="preview-stat-label">Total Spend</div>
</div>
</div>
</div>
</section>
<div class="trust-bar">
<div class="trust-item"><div class="trust-dot"></div>100% On-Premise</div>
<div class="trust-item"><div class="trust-dot"></div>GDPR Compliant</div>
<div class="trust-item"><div class="trust-dot"></div>EU Data Residency</div>
<div class="trust-item"><div class="trust-dot"></div>Zero Third-Party</div>
</div>
<section class="features">
<div class="section-header">
<h2>Your AI, your way</h2>
<p>Every tool you need to run production AI without compromise.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>LLM Gateway</h3>
<p>Route between any model through a unified API. LiteLLM proxy with full cost tracking.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>Agent Platform</h3>
<p>Build and deploy LangGraph agents with visual workflows and real-time monitoring.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>Observability</h3>
<p>Langfuse integration for traces, prompt engineering, and cost attribution.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>Identity & SSO</h3>
<p>Keycloak-powered auth with SAML, OIDC, and LDAP. One login across services.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>MCP Servers</h3>
<p>Model Context Protocol for secure, tool-augmented AI with function calling.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>API-First</h3>
<p>REST endpoints, API keys, webhooks. Plug CERTifAI into your existing stack.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<h2>Deploy in 30 minutes</h2>
<p>No credit card required. Full access to every feature.</p>
<button class="btn-primary btn-lg">Start Free Trial</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI GmbH</span>
<div class="footer-links">
<a href="#">Privacy</a>
<a href="#">Impressum</a>
<a href="#">Terms</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand"><h2>CERTifAI</h2></div>
<div class="sidebar-user">
<div class="user-avatar">MM</div>
<div>
<div class="user-name">Max Mustermann</div>
<div class="user-email">max@company.de</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-group">Main</div>
<a class="nav-item active">&#9632; Dashboard</a>
<a class="nav-item">&#9674; Providers</a>
<a class="nav-item">&#9993; Chat</a>
<div class="nav-group">Developer</div>
<a class="nav-item">&#10070; Agents</a>
<a class="nav-item">&#10697; Workflows</a>
<a class="nav-item">&#9776; Analytics</a>
<div class="nav-group">Organization</div>
<a class="nav-item">&#9733; Billing</a>
<a class="nav-item">&#9823; Members</a>
</nav>
<div class="sidebar-footer">v0.1.0</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">AI infrastructure overview</p>
</div>
<button class="btn-primary">New Search</button>
</div>
<div class="gradient-banner">
<div class="banner-text">
<h3>February 2026</h3>
<p>Your infrastructure is healthy. All models are responding.</p>
</div>
<div class="banner-stats">
<div>
<div class="banner-stat-value">$47.82</div>
<div class="banner-stat-label">Total Spend</div>
</div>
<div>
<div class="banner-stat-value">847K</div>
<div class="banner-stat-label">Tokens Used</div>
</div>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<div class="stat-sub">All responding</div>
</div>
<div class="stat-card">
<div class="stat-label">Team Seats</div>
<div class="stat-value">4/25</div>
<div class="stat-sub">21 available</div>
</div>
<div class="stat-card">
<div class="stat-label">Running Agents</div>
<div class="stat-value">3</div>
<div class="stat-sub">via LangGraph</div>
</div>
<div class="stat-card">
<div class="stat-label">Cycle Ends</div>
<div class="stat-value">Mar 1</div>
<div class="stat-sub" style="color:var(--text-muted)">3 days left</div>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-header">
<div class="card-title">Model Usage</div>
<span class="card-badge">This Month</span>
</div>
<table class="data-table">
<thead>
<tr><th>Model</th><th>Tokens</th><th>Spend</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td><span class="model-tag">Qwen3-Coder-30B</span></td><td>342K</td><td>$18.40</td><td><span class="status-online"></span>Active</td></tr>
<tr><td><span class="model-tag">Llama-3.1-70B</span></td><td>285K</td><td>$15.20</td><td><span class="status-online"></span>Active</td></tr>
<tr><td><span class="model-tag">Mistral-7B</span></td><td>120K</td><td>$8.42</td><td><span class="status-online"></span>Active</td></tr>
<tr><td><span class="model-tag">Gemma-2-9B</span></td><td>65K</td><td>$3.80</td><td><span class="status-online"></span>Active</td></tr>
<tr><td><span class="model-tag">Phi-3-mini</span></td><td>35K</td><td>$2.00</td><td style="color:var(--text-muted)">Idle</td></tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">Services</div>
</div>
<div class="service-item">
<div>
<div class="service-name">LiteLLM Proxy</div>
<div class="service-url">llm-dev.meghsakha.com</div>
</div>
<span class="service-status online">Online</span>
</div>
<div class="service-item">
<div>
<div class="service-name">LangGraph</div>
<div class="service-url">agents.internal</div>
</div>
<span class="service-status online">Online</span>
</div>
<div class="service-item">
<div>
<div class="service-name">Langfuse</div>
<div class="service-url">analytics.internal</div>
</div>
<span class="service-status online">Online</span>
</div>
<div class="service-item">
<div>
<div class="service-name">LangFlow</div>
<div class="service-url">--</div>
</div>
<span class="service-status offline">Not Configured</span>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,928 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 5: Swiss Grid</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 5: SWISS GRID
========================================================================
Mood: Structured, editorial, Swiss design / International Typographic Style
Audience: Government, defense, Mittelstand, compliance-heavy industries
Palette: High-contrast B&W with a single signal red accent
Fonts: IBM Plex Sans (body) + IBM Plex Mono (data/code)
Feel: Authoritative, engineered, precise, like a Braun product manual
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-white: #ffffff;
--bg-light: #f5f5f5;
--bg-warm: #fafafa;
--bg-dark: #111111;
--bg-darkgrey: #1a1a1a;
--text-black: #111111;
--text-dark: #333333;
--text-body: #555555;
--text-muted: #888888;
--text-light: #bbbbbb;
--text-inverse: #ffffff;
--red: #e63525;
--red-muted: rgba(230, 53, 37, 0.06);
--red-border: rgba(230, 53, 37, 0.15);
--green: #1a8754;
--green-muted: rgba(26, 135, 84, 0.08);
--border: #e5e5e5;
--border-dark: #d0d0d0;
--shadow: 0 1px 3px rgba(0,0,0,0.04);
}
body {
font-family: 'IBM Plex Sans', sans-serif;
color: var(--text-dark);
background: var(--bg-white);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'IBM Plex Sans', sans-serif;
font-weight: 600;
line-height: 1.2;
color: var(--text-black);
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
display: flex;
gap: 0;
background: var(--bg-dark);
padding: 3px;
border-radius: 6px;
}
.view-switcher button {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
background: transparent;
color: rgba(255,255,255,0.35);
transition: all 0.15s;
}
.view-switcher button.active {
background: var(--red);
color: #fff;
}
.view-switcher button:hover:not(.active) { color: rgba(255,255,255,0.7); }
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING ===== */
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 56px;
height: 64px;
background: var(--bg-white);
border-bottom: 2px solid var(--text-black);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'IBM Plex Mono', monospace;
font-size: 18px;
font-weight: 700;
color: var(--text-black);
letter-spacing: -0.5px;
}
.nav-logo .red { color: var(--red); }
.nav-links {
display: flex;
gap: 32px;
list-style: none;
}
.nav-links a {
text-decoration: none;
font-family: 'IBM Plex Mono', monospace;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.03em;
text-transform: uppercase;
transition: color 0.15s;
}
.nav-links a:hover { color: var(--text-black); }
.nav-cta { display: flex; gap: 0; }
.btn-dark {
font-family: 'IBM Plex Sans', sans-serif;
font-size: 13px;
font-weight: 600;
padding: 10px 28px;
border: 2px solid var(--text-black);
background: var(--text-black);
color: var(--text-inverse);
cursor: pointer;
transition: all 0.15s;
}
.btn-dark:hover { background: var(--red); border-color: var(--red); }
.btn-bordered {
font-family: 'IBM Plex Sans', sans-serif;
font-size: 13px;
font-weight: 600;
padding: 10px 28px;
border: 2px solid var(--text-black);
background: transparent;
color: var(--text-black);
cursor: pointer;
transition: all 0.15s;
}
.btn-bordered:hover { background: var(--bg-light); }
/* -- Hero -- */
.hero {
padding: 100px 56px 80px;
display: grid;
grid-template-columns: 7fr 5fr;
gap: 80px;
max-width: 1200px;
margin: 0 auto;
border-bottom: 1px solid var(--border);
}
.hero-text {
animation: slideIn 0.5s ease;
}
.hero-label {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--red);
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.hero-label::before {
content: '';
width: 24px;
height: 2px;
background: var(--red);
}
.hero h1 {
font-size: 56px;
font-weight: 700;
letter-spacing: -2px;
margin-bottom: 24px;
line-height: 1.05;
}
.hero p {
font-size: 17px;
color: var(--text-body);
line-height: 1.7;
margin-bottom: 40px;
max-width: 500px;
}
.hero-actions { display: flex; gap: 0; }
.hero-actions .btn-dark { border-right: none; }
.hero-right {
display: flex;
flex-direction: column;
gap: 16px;
animation: slideIn 0.5s ease 0.15s both;
}
.hero-fact {
padding: 24px;
border: 1px solid var(--border);
background: var(--bg-warm);
}
.hero-fact-number {
font-family: 'IBM Plex Mono', monospace;
font-size: 36px;
font-weight: 700;
color: var(--text-black);
margin-bottom: 4px;
}
.hero-fact-text {
font-size: 14px;
color: var(--text-muted);
}
.hero-fact.accent {
background: var(--text-black);
border-color: var(--text-black);
}
.hero-fact.accent .hero-fact-number { color: var(--red); }
.hero-fact.accent .hero-fact-text { color: rgba(255,255,255,0.5); }
/* -- Principles Bar -- */
.principles {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid var(--border);
}
.principle {
padding: 36px 32px;
border-right: 1px solid var(--border);
}
.principle:last-child { border-right: none; }
.principle-num {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 600;
color: var(--red);
margin-bottom: 12px;
letter-spacing: 0.05em;
}
.principle h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.principle p {
font-size: 13px;
color: var(--text-muted);
line-height: 1.6;
}
/* -- Features -- */
.features {
padding: 80px 56px;
max-width: 1200px;
margin: 0 auto;
}
.section-header {
margin-bottom: 56px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: end;
}
.section-header h2 {
font-size: 36px;
letter-spacing: -1px;
}
.section-header p {
font-size: 15px;
color: var(--text-body);
line-height: 1.7;
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
border: 1px solid var(--border);
}
.feature-cell {
padding: 32px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
transition: background 0.2s;
}
.feature-cell:nth-child(3n) { border-right: none; }
.feature-cell:nth-child(n+4) { border-bottom: none; }
.feature-cell:hover { background: var(--bg-light); }
.feature-label {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--red);
margin-bottom: 12px;
}
.feature-cell h3 {
font-size: 16px;
margin-bottom: 8px;
}
.feature-cell p {
font-size: 13px;
color: var(--text-muted);
line-height: 1.6;
}
/* -- CTA -- */
.cta-section {
padding: 0 56px 80px;
}
.cta-box {
max-width: 1200px;
margin: 0 auto;
padding: 64px;
background: var(--bg-dark);
display: grid;
grid-template-columns: 1fr auto;
gap: 40px;
align-items: center;
}
.cta-box h2 {
font-size: 32px;
color: var(--text-inverse);
letter-spacing: -0.5px;
}
.cta-box p {
font-size: 15px;
color: rgba(255,255,255,0.4);
margin-top: 8px;
}
.btn-red {
font-family: 'IBM Plex Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 14px 36px;
border: none;
background: var(--red);
color: #fff;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.btn-red:hover { opacity: 0.9; }
.landing-footer {
padding: 32px 56px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 2px solid var(--text-black);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-muted);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-muted); text-decoration: none; }
.footer-links a:hover { color: var(--text-black); }
/* ===== DASHBOARD ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-light);
}
.sidebar {
width: 248px;
min-width: 248px;
background: var(--bg-dark);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 20px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.sidebar-brand h2 {
font-family: 'IBM Plex Mono', monospace;
font-size: 16px;
font-weight: 700;
color: var(--text-inverse);
letter-spacing: -0.5px;
}
.sidebar-brand .red { color: var(--red); }
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.user-avatar {
width: 32px;
height: 32px;
background: var(--red);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 700;
}
.user-name { font-size: 13px; font-weight: 600; color: var(--text-inverse); }
.user-email { font-size: 11px; color: rgba(255,255,255,0.3); }
.sidebar-nav {
flex: 1;
padding: 12px 8px;
}
.nav-section {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255,255,255,0.2);
padding: 16px 14px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: rgba(255,255,255,0.4);
cursor: pointer;
transition: all 0.12s;
text-decoration: none;
border-left: 2px solid transparent;
}
.nav-item:hover { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.03); }
.nav-item.active {
color: #fff;
border-left-color: var(--red);
background: rgba(255,255,255,0.05);
font-weight: 600;
}
.sidebar-footer {
padding: 14px 20px;
border-top: 1px solid rgba(255,255,255,0.08);
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
color: rgba(255,255,255,0.2);
}
.main-content {
flex: 1;
padding: 32px 40px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 2px solid var(--text-black);
}
.page-title { font-size: 24px; letter-spacing: -0.5px; }
.page-subtitle {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
/* -- Stats -- */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
margin-bottom: 28px;
border: 1px solid var(--border);
background: var(--bg-white);
}
.stat-cell {
padding: 24px;
border-right: 1px solid var(--border);
}
.stat-cell:last-child { border-right: none; }
.stat-label {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 8px;
}
.stat-value {
font-family: 'IBM Plex Mono', monospace;
font-size: 28px;
font-weight: 700;
color: var(--text-black);
}
.stat-bar {
height: 3px;
background: var(--border);
margin-top: 12px;
}
.stat-bar-fill {
height: 100%;
background: var(--red);
}
/* -- Content -- */
.content-grid {
display: grid;
grid-template-columns: 5fr 3fr;
gap: 0;
}
.card {
background: var(--bg-white);
border: 1px solid var(--border);
padding: 24px;
}
.card + .card { border-left: none; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.card-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.card-badge {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
padding: 3px 10px;
background: var(--red-muted);
color: var(--red);
border: 1px solid var(--red-border);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 8px 0;
border-bottom: 2px solid var(--text-black);
}
.data-table td {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
color: var(--text-dark);
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover { background: var(--bg-light); }
.model-mono {
font-weight: 600;
color: var(--text-black);
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.status-dot {
width: 6px;
height: 6px;
}
.status-dot.on { background: var(--green); }
.status-dot.off { background: var(--text-light); }
/* -- Member List -- */
.member-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.member-row:last-child { border-bottom: none; }
.member-initial {
width: 28px;
height: 28px;
background: var(--bg-dark);
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: center;
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 700;
}
.member-name { font-size: 13px; font-weight: 600; color: var(--text-black); }
.member-email { font-size: 11px; color: var(--text-muted); }
.member-role {
margin-left: auto;
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
</style>
</head>
<body>
<div class="view-switcher">
<button class="active" onclick="showView('landing')">LANDING</button>
<button onclick="showView('dashboard')">DASHBOARD</button>
</div>
<!-- ===== LANDING ===== -->
<div id="landing" class="view active">
<nav class="landing-nav">
<div class="nav-logo">CERTIF<span class="red">AI</span></div>
<ul class="nav-links">
<li><a href="#">Principles</a></li>
<li><a href="#">Capabilities</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Documentation</a></li>
</ul>
<div class="nav-cta">
<button class="btn-bordered">Sign In</button>
<button class="btn-dark">Deploy Now</button>
</div>
</nav>
<section class="hero">
<div class="hero-text">
<div class="hero-label">Private AI Infrastructure</div>
<h1>AI that answers<br>only to you.</h1>
<p>Sovereign generative AI infrastructure for organizations that treat data protection as non-negotiable. Deploy on your terms, inside your perimeter.</p>
<div class="hero-actions">
<button class="btn-dark">Request Access</button>
<button class="btn-bordered">Read Documentation</button>
</div>
</div>
<div class="hero-right">
<div class="hero-fact">
<div class="hero-fact-number">100%</div>
<div class="hero-fact-text">On-premise deployment. No data egress.</div>
</div>
<div class="hero-fact accent">
<div class="hero-fact-number">GDPR</div>
<div class="hero-fact-text">Article 28 compliant by architecture.</div>
</div>
<div class="hero-fact">
<div class="hero-fact-number">&lt;30min</div>
<div class="hero-fact-text">Full deployment including SSO and models.</div>
</div>
</div>
</section>
<div class="principles">
<div class="principle">
<div class="principle-num">01</div>
<h3>Data Sovereignty</h3>
<p>Your data never leaves your infrastructure. No telemetry, no third-party calls.</p>
</div>
<div class="principle">
<div class="principle-num">02</div>
<h3>Full Control</h3>
<p>Choose your models, set your policies, define your access rules.</p>
</div>
<div class="principle">
<div class="principle-num">03</div>
<h3>Transparency</h3>
<p>Every token tracked, every cost attributed, every trace logged.</p>
</div>
<div class="principle">
<div class="principle-num">04</div>
<h3>EU-Native</h3>
<p>Designed in Germany for EU compliance. No US-dependent services.</p>
</div>
</div>
<section class="features">
<div class="section-header">
<h2>Capabilities</h2>
<p>A complete platform for operating AI infrastructure at enterprise scale. Every component self-hosted, every interface yours to configure.</p>
</div>
<div class="features-grid">
<div class="feature-cell">
<div class="feature-label">ROUTING</div>
<h3>LLM Gateway</h3>
<p>LiteLLM proxy for unified multi-provider access through a single endpoint.</p>
</div>
<div class="feature-cell">
<div class="feature-label">ORCHESTRATION</div>
<h3>Agent Platform</h3>
<p>LangGraph for autonomous agents. LangFlow for visual workflow design.</p>
</div>
<div class="feature-cell">
<div class="feature-label">OBSERVABILITY</div>
<h3>Analytics</h3>
<p>Langfuse integration for tracing, cost tracking, and prompt versioning.</p>
</div>
<div class="feature-cell">
<div class="feature-label">IDENTITY</div>
<h3>SSO & RBAC</h3>
<p>Keycloak with SAML, OIDC, LDAP. Fine-grained role-based access control.</p>
</div>
<div class="feature-cell">
<div class="feature-label">INTEGRATION</div>
<h3>MCP & APIs</h3>
<p>Model Context Protocol servers and REST APIs for tool-augmented AI.</p>
</div>
<div class="feature-cell">
<div class="feature-label">MANAGEMENT</div>
<h3>Admin Dashboard</h3>
<p>Billing, team management, usage monitoring, and system configuration.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<div>
<h2>Ready to deploy?</h2>
<p>Full operational AI stack. Under 30 minutes. No external dependencies.</p>
</div>
<button class="btn-red">Request Access</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI GmbH. Handelsregister HRB XXXXX.</span>
<div class="footer-links">
<a href="#">Datenschutz</a>
<a href="#">Impressum</a>
<a href="#">AGB</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand"><h2>CERTIF<span class="red">AI</span></h2></div>
<div class="sidebar-user">
<div class="user-avatar">MM</div>
<div>
<div class="user-name">Max Mustermann</div>
<div class="user-email">max@company.de</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">Core</div>
<a class="nav-item active">&#9632; Dashboard</a>
<a class="nav-item">&#9674; Providers</a>
<a class="nav-item">&#9993; Chat</a>
<div class="nav-section">Developer</div>
<a class="nav-item">&#10070; Agents</a>
<a class="nav-item">&#10697; Workflows</a>
<a class="nav-item">&#9776; Analytics</a>
<div class="nav-section">Organization</div>
<a class="nav-item">&#9733; Billing</a>
<a class="nav-item">&#9823; Members</a>
</nav>
<div class="sidebar-footer">CERTifAI v0.1.0</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">System overview / February 2026</p>
</div>
<button class="btn-dark">+ New Search</button>
</div>
<div class="stats-row">
<div class="stat-cell">
<div class="stat-label">Total Spend</div>
<div class="stat-value">$47.82</div>
<div class="stat-bar"><div class="stat-bar-fill" style="width:48%"></div></div>
</div>
<div class="stat-cell">
<div class="stat-label">Tokens Used</div>
<div class="stat-value">847K</div>
<div class="stat-bar"><div class="stat-bar-fill" style="width:85%"></div></div>
</div>
<div class="stat-cell">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<div class="stat-bar"><div class="stat-bar-fill" style="width:100%"></div></div>
</div>
<div class="stat-cell">
<div class="stat-label">Team Seats</div>
<div class="stat-value">4/25</div>
<div class="stat-bar"><div class="stat-bar-fill" style="width:16%"></div></div>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-header">
<div class="card-title">Model Usage</div>
<span class="card-badge">CURRENT MONTH</span>
</div>
<table class="data-table">
<thead>
<tr><th>Model</th><th>Tokens</th><th>Spend</th><th>Status</th></tr>
</thead>
<tbody>
<tr>
<td><span class="model-mono">Qwen3-Coder-30B</span></td>
<td>342,000</td>
<td>$18.40</td>
<td><span class="status-indicator"><span class="status-dot on"></span>Active</span></td>
</tr>
<tr>
<td><span class="model-mono">Llama-3.1-70B</span></td>
<td>285,000</td>
<td>$15.20</td>
<td><span class="status-indicator"><span class="status-dot on"></span>Active</span></td>
</tr>
<tr>
<td><span class="model-mono">Mistral-7B</span></td>
<td>120,000</td>
<td>$8.42</td>
<td><span class="status-indicator"><span class="status-dot on"></span>Active</span></td>
</tr>
<tr>
<td><span class="model-mono">Gemma-2-9B</span></td>
<td>65,000</td>
<td>$3.80</td>
<td><span class="status-indicator"><span class="status-dot on"></span>Active</span></td>
</tr>
<tr>
<td><span class="model-mono">Phi-3-mini</span></td>
<td>35,000</td>
<td>$2.00</td>
<td><span class="status-indicator"><span class="status-dot off"></span>Idle</span></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">Team</div>
</div>
<div class="member-row">
<div class="member-initial">MM</div>
<div>
<div class="member-name">Max Mustermann</div>
<div class="member-email">max@company.de</div>
</div>
<span class="member-role">Admin</span>
</div>
<div class="member-row">
<div class="member-initial">EM</div>
<div>
<div class="member-name">Erika Musterfrau</div>
<div class="member-email">erika@company.de</div>
</div>
<span class="member-role">Member</span>
</div>
<div class="member-row">
<div class="member-initial">JS</div>
<div>
<div class="member-name">Johann Schmidt</div>
<div class="member-email">johann@company.de</div>
</div>
<span class="member-role">Member</span>
</div>
<div class="member-row">
<div class="member-initial">AW</div>
<div>
<div class="member-name">Anna Weber</div>
<div class="member-email">anna@company.de</div>
</div>
<span class="member-role">Viewer</span>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -1,13 +1,12 @@
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
container_name: certifai-keycloak
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_DB: dev-mem
KC_HEALTH_ENABLED: "true"
ports:
- "8080:8080"
command:
@@ -17,10 +16,11 @@ services:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
- ./keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/master HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q '200 OK'"]
interval: 10s
timeout: 5s
retries: 5
retries: 10
start_period: 30s
mongo:
image: mongo:latest
@@ -40,4 +40,219 @@ services:
environment:
- SEARXNG_BASE_URL=http://localhost:8888
volumes:
- ./searxng:/etc/searxng:rw
- ./searxng:/etc/searxng:rw
librechat:
image: ghcr.io/danny-avila/librechat:latest
container_name: certifai-librechat
restart: unless-stopped
# Use host networking so localhost:8080 (Keycloak) is reachable for
# OIDC discovery, and the browser redirect URLs match the issuer.
network_mode: host
depends_on:
keycloak:
condition: service_healthy
mongo:
condition: service_started
environment:
# LiteLLM API key (used by librechat.yaml endpoint config)
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
# MongoDB (use localhost since we're on host network)
MONGO_URI: mongodb://root:example@localhost:27017/librechat?authSource=admin
DOMAIN_CLIENT: http://localhost:3080
DOMAIN_SERVER: http://localhost:3080
# Allow HTTP for local dev OIDC (Keycloak on localhost without TLS)
NODE_TLS_REJECT_UNAUTHORIZED: "0"
NODE_ENV: development
# Keycloak OIDC SSO
OPENID_ISSUER: http://localhost:8080/realms/certifai
OPENID_CLIENT_ID: certifai-librechat
OPENID_CLIENT_SECRET: certifai-librechat-secret
OPENID_SESSION_SECRET: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6"
OPENID_CALLBACK_URL: /oauth/openid/callback
OPENID_SCOPE: openid profile email
OPENID_BUTTON_LABEL: Login with CERTifAI
# Disable local auth (SSO only)
ALLOW_EMAIL_LOGIN: "false"
ALLOW_REGISTRATION: "false"
ALLOW_SOCIAL_LOGIN: "true"
ALLOW_SOCIAL_REGISTRATION: "true"
# JWT / encryption secrets (required by LibreChat)
CREDS_KEY: "97e95d72cdda06774a264f9fb7768097a6815dc1e930898d2e39c9a3a253b157"
CREDS_IV: "2ea456ab25279089b0ff9e7aca1df6e6"
JWT_SECRET: "767b962176666eab56e180e6f2d3fe95145dc6b978e37d4eb8d1da5421c5fb26"
JWT_REFRESH_SECRET: "51a43a1fca4b7b501b37e226a638645d962066e0686b82248921f3160e96501e"
# App settings
APP_TITLE: CERTifAI Chat
CUSTOM_FOOTER: CERTifAI - Sovereign GenAI Infrastructure
HOST: 0.0.0.0
PORT: "3080"
NO_INDEX: "true"
volumes:
- ./librechat/librechat.yaml:/app/librechat.yaml:ro
- ./librechat/logo.svg:/app/client/public/assets/logo.svg:ro
# Patch: allow HTTP issuer for local dev (openid-client v6 enforces HTTPS)
- ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro
- librechat-data:/app/data
langflow:
image: langflowai/langflow:latest
container_name: certifai-langflow
restart: unless-stopped
ports:
- "7860:7860"
environment:
LANGFLOW_AUTO_LOGIN: "true"
langgraph:
image: langchain/langgraph-trial:3.12
container_name: certifai-langgraph
restart: unless-stopped
depends_on:
langgraph-db:
condition: service_started
langgraph-redis:
condition: service_started
ports:
- "8123:8000"
environment:
DATABASE_URI: postgresql://langgraph:langgraph@langgraph-db:5432/langgraph
REDIS_URI: redis://langgraph-redis:6379
langgraph-db:
image: postgres:16
container_name: certifai-langgraph-db
restart: unless-stopped
environment:
POSTGRES_USER: langgraph
POSTGRES_PASSWORD: langgraph
POSTGRES_DB: langgraph
volumes:
- langgraph-db-data:/var/lib/postgresql/data
langgraph-redis:
image: redis:7-alpine
container_name: certifai-langgraph-redis
restart: unless-stopped
langfuse:
image: langfuse/langfuse:3
container_name: certifai-langfuse
restart: unless-stopped
depends_on:
keycloak:
condition: service_healthy
langfuse-db:
condition: service_healthy
langfuse-clickhouse:
condition: service_healthy
langfuse-redis:
condition: service_healthy
langfuse-minio:
condition: service_healthy
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://langfuse:langfuse@langfuse-db:5432/langfuse
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: certifai-langfuse-dev-secret
SALT: certifai-langfuse-dev-salt
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
# Keycloak OIDC SSO - shared realm with CERTifAI dashboard
AUTH_KEYCLOAK_CLIENT_ID: certifai-langfuse
AUTH_KEYCLOAK_CLIENT_SECRET: certifai-langfuse-secret
AUTH_KEYCLOAK_ISSUER: http://keycloak:8080/realms/certifai
AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING: "true"
# Disable local email/password auth (SSO only)
AUTH_DISABLE_USERNAME_PASSWORD: "true"
CLICKHOUSE_URL: http://langfuse-clickhouse:8123
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
CLICKHOUSE_CLUSTER_ENABLED: "false"
REDIS_HOST: langfuse-redis
REDIS_PORT: "6379"
REDIS_AUTH: langfuse-dev-redis
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_EVENT_UPLOAD_REGION: auto
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://langfuse-minio:9000
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://langfuse-minio:9000
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
langfuse-db:
image: postgres:16
container_name: certifai-langfuse-db
restart: unless-stopped
environment:
POSTGRES_USER: langfuse
POSTGRES_PASSWORD: langfuse
POSTGRES_DB: langfuse
volumes:
- langfuse-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U langfuse"]
interval: 5s
timeout: 5s
retries: 10
langfuse-clickhouse:
image: clickhouse/clickhouse-server:latest
container_name: certifai-langfuse-clickhouse
restart: unless-stopped
user: "101:101"
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
- langfuse-clickhouse-data:/var/lib/clickhouse
- langfuse-clickhouse-logs:/var/log/clickhouse-server
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"]
interval: 5s
timeout: 5s
retries: 10
langfuse-redis:
image: redis:7-alpine
container_name: certifai-langfuse-redis
restart: unless-stopped
command: redis-server --requirepass langfuse-dev-redis
healthcheck:
test: ["CMD", "redis-cli", "-a", "langfuse-dev-redis", "ping"]
interval: 5s
timeout: 5s
retries: 10
langfuse-minio:
image: cgr.dev/chainguard/minio
container_name: certifai-langfuse-minio
restart: unless-stopped
entrypoint: sh
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: miniosecret
healthcheck:
test: ["CMD-SHELL", "mc ready local || exit 1"]
interval: 5s
timeout: 5s
retries: 10
volumes:
librechat-data:
langgraph-db-data:
langfuse-db-data:
langfuse-clickhouse-data:
langfuse-clickhouse-logs:

24
e2e/auth.setup.ts Normal file
View File

@@ -0,0 +1,24 @@
import { test as setup, expect } from "@playwright/test";
const AUTH_FILE = "e2e/.auth/user.json";
setup("authenticate via Keycloak", async ({ page }) => {
// Navigate to a protected route to trigger the auth redirect chain:
// /dashboard -> /auth (Axum) -> Keycloak login page
await page.goto("/dashboard");
// Wait for Keycloak login form to appear
await page.waitForSelector("#username", { timeout: 15_000 });
// Fill Keycloak credentials
await page.fill("#username", process.env.TEST_USER ?? "admin@certifai.local");
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
// Wait for redirect back to the app dashboard
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await expect(page.locator(".sidebar")).toBeVisible();
// Persist authenticated state (cookies + localStorage)
await page.context().storageState({ path: AUTH_FILE });
});

72
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,72 @@
import { test, expect } from "@playwright/test";
// These tests use a fresh browser context (no saved auth state)
test.use({ storageState: { cookies: [], origins: [] } });
test.describe("Authentication flow", () => {
test("unauthenticated visit to /dashboard redirects to Keycloak", async ({
page,
}) => {
await page.goto("/dashboard");
// Should end up on Keycloak login page
await page.waitForSelector("#username", { timeout: 15_000 });
await expect(page.locator("#kc-login")).toBeVisible();
});
test("valid credentials log in and redirect to dashboard", async ({
page,
}) => {
await page.goto("/dashboard");
await page.waitForSelector("#username", { timeout: 15_000 });
await page.fill(
"#username",
process.env.TEST_USER ?? "admin@certifai.local"
);
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await expect(page.locator(".dashboard-page")).toBeVisible();
});
test("dashboard shows sidebar with user info after login", async ({
page,
}) => {
await page.goto("/dashboard");
await page.waitForSelector("#username", { timeout: 15_000 });
await page.fill(
"#username",
process.env.TEST_USER ?? "admin@certifai.local"
);
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await expect(page.locator(".sidebar-name")).toBeVisible();
await expect(page.locator(".sidebar-email")).toBeVisible();
});
test("logout redirects away from dashboard", async ({ page }) => {
// First log in
await page.goto("/dashboard");
await page.waitForSelector("#username", { timeout: 15_000 });
await page.fill(
"#username",
process.env.TEST_USER ?? "admin@certifai.local"
);
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
await page.waitForURL("**/dashboard", { timeout: 15_000 });
// Click logout
await page.locator('a.logout-btn, a[href="/logout"]').click();
// Should no longer be on the dashboard
await expect(page).not.toHaveURL(/\/dashboard/);
});
});

75
e2e/dashboard.spec.ts Normal file
View File

@@ -0,0 +1,75 @@
import { test, expect } from "@playwright/test";
test.describe("Dashboard", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dashboard");
// Wait for WASM hydration and auth check to complete
await page.waitForSelector(".dashboard-page", { timeout: 15_000 });
});
test("dashboard page loads with page header", async ({ page }) => {
await expect(page.locator(".page-header")).toContainText("Dashboard");
});
test("default topic chips are visible", async ({ page }) => {
const topics = ["AI", "Technology", "Science", "Finance", "Writing", "Research"];
for (const topic of topics) {
await expect(
page.locator(".filter-tab", { hasText: topic })
).toBeVisible();
}
});
test("clicking a topic chip triggers search", async ({ page }) => {
const chip = page.locator(".filter-tab", { hasText: "AI" });
await chip.click();
// Either a loading state or results should appear
const searchingOrResults = page
.locator(".dashboard-loading, .news-grid, .dashboard-empty");
await expect(searchingOrResults.first()).toBeVisible({ timeout: 10_000 });
});
test("news cards render after search completes", async ({ page }) => {
// Click a topic to trigger search
await page.locator(".filter-tab", { hasText: "Technology" }).click();
// Wait for loading to finish
await page.waitForSelector(".dashboard-loading", {
state: "hidden",
timeout: 15_000,
}).catch(() => {
// Loading may already be done
});
// Either news cards or an empty state message should be visible
const content = page.locator(".news-grid .news-card, .dashboard-empty");
await expect(content.first()).toBeVisible({ timeout: 10_000 });
});
test("clicking a news card opens article detail panel", async ({ page }) => {
// Trigger a search and wait for results
await page.locator(".filter-tab", { hasText: "AI" }).click();
await page.waitForSelector(".dashboard-loading", {
state: "hidden",
timeout: 15_000,
}).catch(() => {});
const firstCard = page.locator(".news-card").first();
// Only test if cards are present (search results depend on live data)
if (await firstCard.isVisible().catch(() => false)) {
await firstCard.click();
await expect(page.locator(".dashboard-right, .dashboard-split")).toBeVisible();
}
});
test("settings toggle opens settings panel", async ({ page }) => {
const settingsBtn = page.locator(".settings-toggle");
await settingsBtn.click();
await expect(page.locator(".settings-panel")).toBeVisible();
await expect(page.locator(".settings-panel-title")).toBeVisible();
});
});

173
e2e/developer.spec.ts Normal file
View File

@@ -0,0 +1,173 @@
import { test, expect } from "@playwright/test";
test.describe("Developer section", () => {
test("agents page loads with sub-nav tabs", async ({ page }) => {
await page.goto("/developer/agents");
await page.waitForSelector(".developer-shell", { timeout: 15_000 });
const nav = page.locator(".sub-nav");
await expect(nav.locator("a", { hasText: "Agents" })).toBeVisible();
await expect(nav.locator("a", { hasText: "Flow" })).toBeVisible();
await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible();
});
test("agents page renders informational landing", async ({ page }) => {
await page.goto("/developer/agents");
await page.waitForSelector(".agents-page", { timeout: 15_000 });
// Hero section
await expect(page.locator(".agents-hero-title")).toContainText(
"Agent Builder"
);
await expect(page.locator(".agents-hero-desc")).toBeVisible();
// Connection status indicator is present
await expect(page.locator(".agents-status")).toBeVisible();
});
test("agents page shows Not Connected when URL is empty", async ({
page,
}) => {
await page.goto("/developer/agents");
await page.waitForSelector(".agents-page", { timeout: 15_000 });
await expect(page.locator(".agents-status")).toContainText(
"Not Connected"
);
await expect(page.locator(".agents-status-dot--off")).toBeVisible();
await expect(page.locator(".agents-status-hint")).toBeVisible();
});
test("agents page shows quick start cards", async ({ page }) => {
await page.goto("/developer/agents");
await page.waitForSelector(".agents-page", { timeout: 15_000 });
const grid = page.locator(".agents-grid");
const cards = grid.locator(".agents-card");
await expect(cards).toHaveCount(5);
// Verify card titles are rendered
await expect(
grid.locator(".agents-card-title", { hasText: "Documentation" })
).toBeVisible();
await expect(
grid.locator(".agents-card-title", { hasText: "Getting Started" })
).toBeVisible();
await expect(
grid.locator(".agents-card-title", { hasText: "GitHub" })
).toBeVisible();
await expect(
grid.locator(".agents-card-title", { hasText: "Examples" })
).toBeVisible();
await expect(
grid.locator(".agents-card-title", { hasText: "API Reference" })
).toBeVisible();
});
test("agents page disables API Reference card when not connected", async ({
page,
}) => {
await page.goto("/developer/agents");
await page.waitForSelector(".agents-page", { timeout: 15_000 });
// When LANGGRAPH_URL is empty, the API Reference card should be disabled
const statusHint = page.locator(".agents-status-hint");
if (await statusHint.isVisible()) {
const apiCard = page.locator(".agents-card--disabled");
await expect(apiCard).toBeVisible();
await expect(
apiCard.locator(".agents-card-title")
).toContainText("API Reference");
}
});
test("agents page shows running agents section", async ({ page }) => {
await page.goto("/developer/agents");
await page.waitForSelector(".agents-page", { timeout: 15_000 });
// The running agents section title should always be visible
await expect(
page.locator(".agents-section-title", { hasText: "Running Agents" })
).toBeVisible();
// Either the table, loading state, or empty message should appear
await page.waitForTimeout(3000);
const table = page.locator(".agents-table");
const empty = page.locator(".agents-table-empty");
const hasTable = await table.isVisible();
const hasEmpty = await empty.isVisible();
expect(hasTable || hasEmpty).toBeTruthy();
});
test("agents page shows connected status when URL is configured", async ({
page,
}) => {
// This test only passes when LANGGRAPH_URL is set in the environment.
await page.goto("/developer/agents");
await page.waitForSelector(".agents-page", { timeout: 15_000 });
const connectedDot = page.locator(".agents-status-dot--on");
const disconnectedDot = page.locator(".agents-status-dot--off");
if (await connectedDot.isVisible()) {
await expect(page.locator(".agents-status")).toContainText("Connected");
await expect(page.locator(".agents-status-url")).toBeVisible();
// API Reference card should NOT be disabled
await expect(page.locator(".agents-card--disabled")).toHaveCount(0);
} else {
await expect(disconnectedDot).toBeVisible();
await expect(page.locator(".agents-status")).toContainText(
"Not Connected"
);
}
});
test("analytics page renders informational landing", async ({ page }) => {
await page.goto("/developer/analytics");
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
// Hero section
await expect(page.locator(".analytics-hero-title")).toBeVisible();
await expect(page.locator(".analytics-hero-desc")).toBeVisible();
// Connection status indicator
await expect(page.locator(".agents-status")).toBeVisible();
// Metrics bar
await expect(page.locator(".analytics-stats-bar")).toBeVisible();
});
test("analytics page shows Not Connected when URL is empty", async ({
page,
}) => {
await page.goto("/developer/analytics");
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
await expect(page.locator(".agents-status")).toContainText(
"Not Connected"
);
await expect(page.locator(".agents-status-dot--off")).toBeVisible();
});
test("analytics page shows quick action cards", async ({ page }) => {
await page.goto("/developer/analytics");
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
const grid = page.locator(".agents-grid");
const cards = grid.locator(".agents-card, .agents-card--disabled");
await expect(cards).toHaveCount(2);
});
test("analytics page shows SSO hint when connected", async ({ page }) => {
// Only meaningful when LANGFUSE_URL is configured.
await page.goto("/developer/analytics");
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
const connectedDot = page.locator(".agents-status-dot--on");
if (await connectedDot.isVisible()) {
await expect(page.locator(".analytics-sso-hint")).toBeVisible();
await expect(page.locator(".analytics-launch-btn")).toBeVisible();
}
});
});

52
e2e/navigation.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import { test, expect } from "@playwright/test";
test.describe("Sidebar navigation", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dashboard");
await page.waitForSelector(".sidebar", { timeout: 15_000 });
});
test("sidebar links route to correct pages", async ({ page }) => {
const navTests = [
{ label: "Providers", url: /\/providers/ },
{ label: "Developer", url: /\/developer\/agents/ },
{ label: "Organization", url: /\/organization\/pricing/ },
{ label: "Dashboard", url: /\/dashboard/ },
];
for (const { label, url } of navTests) {
await page.locator(".sidebar-link", { hasText: label }).click();
await expect(page).toHaveURL(url, { timeout: 10_000 });
}
});
test("browser back/forward navigation works", async ({ page }) => {
// Navigate to Providers
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
await expect(page).toHaveURL(/\/providers/);
// Navigate to Developer
await page.locator(".sidebar-link", { hasText: "Developer" }).click();
await expect(page).toHaveURL(/\/developer/);
// Go back
await page.goBack();
await expect(page).toHaveURL(/\/providers/);
// Go forward
await page.goForward();
await expect(page).toHaveURL(/\/developer/);
});
test("logo link navigates to dashboard", async ({ page }) => {
// Navigate away first
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
await expect(page).toHaveURL(/\/providers/);
// Click the logo/brand in sidebar header
const logo = page.locator(".sidebar-brand, .sidebar-logo, .sidebar a").first();
await logo.click();
await expect(page).toHaveURL(/\/dashboard/);
});
});

41
e2e/organization.spec.ts Normal file
View File

@@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test";
test.describe("Organization section", () => {
test("pricing page loads with three pricing cards", async ({ page }) => {
await page.goto("/organization/pricing");
await page.waitForSelector(".org-shell", { timeout: 15_000 });
const cards = page.locator(".pricing-card");
await expect(cards).toHaveCount(3);
});
test("pricing cards show Starter, Team, Enterprise tiers", async ({
page,
}) => {
await page.goto("/organization/pricing");
await page.waitForSelector(".org-shell", { timeout: 15_000 });
await expect(page.locator(".pricing-card", { hasText: "Starter" })).toBeVisible();
await expect(page.locator(".pricing-card", { hasText: "Team" })).toBeVisible();
await expect(page.locator(".pricing-card", { hasText: "Enterprise" })).toBeVisible();
});
test("organization dashboard loads with billing stats", async ({ page }) => {
await page.goto("/organization/dashboard");
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
await expect(page.locator(".page-header")).toContainText("Organization");
await expect(page.locator(".org-stats-bar")).toBeVisible();
await expect(page.locator(".org-stat").first()).toBeVisible();
});
test("member table is visible on org dashboard", async ({ page }) => {
await page.goto("/organization/dashboard");
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
await expect(page.locator(".org-table")).toBeVisible();
await expect(page.locator(".org-table thead")).toContainText("Name");
await expect(page.locator(".org-table thead")).toContainText("Email");
await expect(page.locator(".org-table thead")).toContainText("Role");
});
});

55
e2e/providers.spec.ts Normal file
View File

@@ -0,0 +1,55 @@
import { test, expect } from "@playwright/test";
test.describe("Providers page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/providers");
await page.waitForSelector(".providers-page", { timeout: 15_000 });
});
test("providers page loads with header", async ({ page }) => {
await expect(page.locator(".page-header")).toContainText("Providers");
});
test("provider dropdown has Ollama selected by default", async ({
page,
}) => {
const providerSelect = page
.locator(".form-group")
.filter({ hasText: "Provider" })
.locator("select");
await expect(providerSelect).toHaveValue(/ollama/i);
});
test("changing provider updates the model dropdown", async ({ page }) => {
const providerSelect = page
.locator(".form-group")
.filter({ hasText: "Provider" })
.locator("select");
// Get current model options
const modelSelect = page
.locator(".form-group")
.filter({ hasText: /^Model/ })
.locator("select");
const initialOptions = await modelSelect.locator("option").allTextContents();
// Change to a different provider
await providerSelect.selectOption({ label: "OpenAI" });
// Wait for model list to update
await page.waitForTimeout(500);
const updatedOptions = await modelSelect.locator("option").allTextContents();
// Model options should differ between providers
expect(updatedOptions).not.toEqual(initialOptions);
});
test("save button shows confirmation feedback", async ({ page }) => {
const saveBtn = page.locator("button", { hasText: "Save Configuration" });
await saveBtn.click();
await expect(page.locator(".form-success")).toBeVisible({ timeout: 5_000 });
await expect(page.locator(".form-success")).toContainText("saved");
});
});

60
e2e/public.spec.ts Normal file
View File

@@ -0,0 +1,60 @@
import { test, expect } from "@playwright/test";
test.describe("Public pages", () => {
test("landing page loads with heading and nav links", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".landing-logo").first()).toHaveText("CERTifAI");
await expect(page.locator(".landing-nav-links")).toBeVisible();
await expect(page.locator('a[href="#features"]')).toBeVisible();
await expect(page.locator('a[href="#how-it-works"]')).toBeVisible();
await expect(page.locator('a[href="#pricing"]')).toBeVisible();
});
test("landing page Log In link navigates to login route", async ({
page,
}) => {
await page.goto("/");
const loginLink = page
.locator(".landing-nav-actions a, .landing-nav-actions Link")
.filter({ hasText: "Log In" });
await loginLink.click();
await expect(page).toHaveURL(/\/login/);
});
test("impressum page loads with legal content", async ({ page }) => {
await page.goto("/impressum");
await expect(page.locator("h1")).toHaveText("Impressum");
await expect(
page.locator("h2", { hasText: "Information according to" })
).toBeVisible();
await expect(page.locator(".legal-content")).toContainText(
"CERTifAI GmbH"
);
});
test("privacy page loads with privacy content", async ({ page }) => {
await page.goto("/privacy");
await expect(page.locator("h1")).toHaveText("Privacy Policy");
await expect(
page.locator("h2", { hasText: "Introduction" })
).toBeVisible();
await expect(
page.locator("h2", { hasText: "Your Rights" })
).toBeVisible();
});
test("footer links are present on landing page", async ({ page }) => {
await page.goto("/");
const footer = page.locator(".landing-footer");
await expect(footer.locator('a:has-text("Impressum")')).toBeVisible();
await expect(
footer.locator('a:has-text("Privacy Policy")')
).toBeVisible();
});
});

View File

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

34
librechat/librechat.yaml Normal file
View File

@@ -0,0 +1,34 @@
# CERTifAI LibreChat Configuration
# LiteLLM proxy for unified multi-provider LLM access.
version: 1.2.8
cache: true
registration:
socialLogins:
- openid
interface:
privacyPolicy:
externalUrl: https://dash-dev.meghsakha.com/privacy
termsOfService:
externalUrl: https://dash-dev.meghsakha.com/impressum
endpointsMenu: true
modelSelect: true
parameters: true
endpoints:
custom:
- name: "LiteLLM"
apiKey: "${LITELLM_API_KEY}"
baseURL: "https://llm-dev.meghsakha.com/v1/"
models:
default:
- "Qwen3-Coder-30B-A3B-Instruct"
fetch: true
titleConvo: true
titleModel: "current_model"
summarize: false
summaryModel: "current_model"
forcePrompt: false
modelDisplayLabel: "CERTifAI LiteLLM"

25
librechat/logo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

743
librechat/openidStrategy.js Normal file
View File

@@ -0,0 +1,743 @@
const undici = require('undici');
const { get } = require('lodash');
const fetch = require('node-fetch');
const passport = require('passport');
const client = require('openid-client');
const jwtDecode = require('jsonwebtoken/decode');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider');
const {
isEnabled,
logHeaders,
safeStringify,
findOpenIDUser,
getBalanceConfig,
isEmailDomainAllowed,
} = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
/**
* @typedef {import('openid-client').ClientMetadata} ClientMetadata
* @typedef {import('openid-client').Configuration} Configuration
**/
/**
* @param {string} url
* @param {client.CustomFetchOptions} options
*/
async function customFetch(url, options) {
const urlStr = url.toString();
logger.debug(`[openidStrategy] Request to: ${urlStr}`);
const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS);
if (debugOpenId) {
logger.debug(`[openidStrategy] Request method: ${options.method || 'GET'}`);
logger.debug(`[openidStrategy] Request headers: ${logHeaders(options.headers)}`);
if (options.body) {
let bodyForLogging = '';
if (options.body instanceof URLSearchParams) {
bodyForLogging = options.body.toString();
} else if (typeof options.body === 'string') {
bodyForLogging = options.body;
} else {
bodyForLogging = safeStringify(options.body);
}
logger.debug(`[openidStrategy] Request body: ${bodyForLogging}`);
}
}
try {
/** @type {undici.RequestInit} */
let fetchOptions = options;
if (process.env.PROXY) {
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
fetchOptions = {
...options,
dispatcher: new undici.ProxyAgent(process.env.PROXY),
};
}
const response = await undici.fetch(url, fetchOptions);
if (debugOpenId) {
logger.debug(`[openidStrategy] Response status: ${response.status} ${response.statusText}`);
logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`);
}
if (response.status === 200 && response.headers.has('www-authenticate')) {
const wwwAuth = response.headers.get('www-authenticate');
logger.warn(`[openidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}.
This violates RFC 7235 and may cause issues with strict OAuth clients. Removing header for compatibility.`);
/** Cloned response without the WWW-Authenticate header */
const responseBody = await response.arrayBuffer();
const newHeaders = new Headers();
for (const [key, value] of response.headers.entries()) {
if (key.toLowerCase() !== 'www-authenticate') {
newHeaders.append(key, value);
}
}
return new Response(responseBody, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}
return response;
} catch (error) {
logger.error(`[openidStrategy] Fetch error: ${error.message}`);
throw error;
}
}
/** @typedef {Configuration | null} */
let openidConfig = null;
/**
* Custom OpenID Strategy
*
* Note: Originally overrode currentUrl() to work around Express 4's req.host not including port.
* With Express 5, req.host now includes the port by default, but we continue to use DOMAIN_SERVER
* for consistency and explicit configuration control.
* More info: https://github.com/panva/openid-client/pull/713
*/
class CustomOpenIDStrategy extends OpenIDStrategy {
currentUrl(req) {
const hostAndProtocol = process.env.DOMAIN_SERVER;
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
}
authorizationRequestParams(req, options) {
const params = super.authorizationRequestParams(req, options);
if (options?.state && !params.has('state')) {
params.set('state', options.state);
}
if (process.env.OPENID_AUDIENCE) {
params.set('audience', process.env.OPENID_AUDIENCE);
logger.debug(
`[openidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`,
);
}
// Parse OPENID_AUTH_EXTRA_PARAMS (format: "key=value" or "key1=value1,key2=value2")
if (process.env.OPENID_AUTH_EXTRA_PARAMS) {
const extraParts = process.env.OPENID_AUTH_EXTRA_PARAMS.split(',');
for (const part of extraParts) {
const [key, ...rest] = part.trim().split('=');
if (key && rest.length > 0) {
params.set(key.trim(), rest.join('=').trim());
logger.debug(`[openidStrategy] Adding extra auth param: ${key.trim()}=${rest.join('=').trim()}`);
}
}
}
/** Generate nonce for federated providers that require it */
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
if (shouldGenerateNonce && !params.has('nonce') && this._sessionKey) {
const crypto = require('crypto');
const nonce = crypto.randomBytes(16).toString('hex');
params.set('nonce', nonce);
logger.debug('[openidStrategy] Generated nonce for federated provider:', nonce);
}
return params;
}
}
/**
* Exchange the access token for a new access token using the on-behalf-of flow if required.
* @param {Configuration} config
* @param {string} accessToken access token to be exchanged if necessary
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @param {boolean} fromCache - Indicates whether to use cached tokens.
* @returns {Promise<string>} The new access token if exchanged, otherwise the original access token.
*/
const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED);
if (onBehalfFlowRequired) {
if (fromCache) {
const cachedToken = await tokensCache.get(sub);
if (cachedToken) {
return cachedToken.access_token;
}
}
const grantResponse = await client.genericGrantRequest(
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE || 'user.read',
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
);
await tokensCache.set(
sub,
{
access_token: grantResponse.access_token,
},
grantResponse.expires_in * 1000,
);
return grantResponse.access_token;
}
return accessToken;
};
/**
* get user info from openid provider
* @param {Configuration} config
* @param {string} accessToken access token
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Object|null>}
*/
const getUserInfo = async (config, accessToken, sub) => {
try {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
} catch (error) {
logger.error('[openidStrategy] getUserInfo: Error fetching user info:', error);
return null;
}
};
/**
* Downloads an image from a URL using an access token.
* @param {string} url
* @param {Configuration} config
* @param {string} accessToken access token
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Buffer | string>} The image buffer or an empty string if the download fails.
*/
const downloadImage = async (url, config, accessToken, sub) => {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true);
if (!url) {
return '';
}
try {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${exchangedAccessToken}`,
},
};
if (process.env.PROXY) {
options.agent = new HttpsProxyAgent(process.env.PROXY);
}
const response = await fetch(url, options);
if (response.ok) {
const buffer = await response.buffer();
return buffer;
} else {
throw new Error(`${response.statusText} (HTTP ${response.status})`);
}
} catch (error) {
logger.error(
`[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`,
);
return '';
}
};
/**
* Determines the full name of a user based on OpenID userinfo and environment configuration.
*
* @param {Object} userinfo - The user information object from OpenID Connect
* @param {string} [userinfo.given_name] - The user's first name
* @param {string} [userinfo.family_name] - The user's last name
* @param {string} [userinfo.username] - The user's username
* @param {string} [userinfo.email] - The user's email address
* @returns {string} The determined full name of the user
*/
function getFullName(userinfo) {
if (process.env.OPENID_NAME_CLAIM) {
return userinfo[process.env.OPENID_NAME_CLAIM];
}
if (userinfo.given_name && userinfo.family_name) {
return `${userinfo.given_name} ${userinfo.family_name}`;
}
if (userinfo.given_name) {
return userinfo.given_name;
}
if (userinfo.family_name) {
return userinfo.family_name;
}
return userinfo.username || userinfo.email;
}
/**
* Converts an input into a string suitable for a username.
* If the input is a string, it will be returned as is.
* If the input is an array, elements will be joined with underscores.
* In case of undefined or other falsy values, a default value will be returned.
*
* @param {string | string[] | undefined} input - The input value to be converted into a username.
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
* @returns {string} The processed input as a string suitable for a username.
*/
function convertToUsername(input, defaultValue = '') {
if (typeof input === 'string') {
return input;
} else if (Array.isArray(input)) {
return input.join('_');
}
return defaultValue;
}
/**
* Resolve Azure AD groups when group overage is in effect (groups moved to _claim_names/_claim_sources).
*
* NOTE: Microsoft recommends treating _claim_names/_claim_sources as a signal only and using Microsoft Graph
* to resolve group membership instead of calling the endpoint in _claim_sources directly.
*
* @param {string} accessToken - Access token with Microsoft Graph permissions
* @returns {Promise<string[] | null>} Resolved group IDs or null on failure
* @see https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#groups-overage-claim
* @see https://learn.microsoft.com/en-us/graph/api/directoryobject-getmemberobjects
*/
async function resolveGroupsFromOverage(accessToken) {
try {
if (!accessToken) {
logger.error('[openidStrategy] Access token missing; cannot resolve group overage');
return null;
}
// Use /me/getMemberObjects so least-privileged delegated permission User.Read is sufficient
// when resolving the signed-in user's group membership.
const url = 'https://graph.microsoft.com/v1.0/me/getMemberObjects';
logger.debug(
`[openidStrategy] Detected group overage, resolving groups via Microsoft Graph getMemberObjects: ${url}`,
);
const fetchOptions = {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ securityEnabledOnly: false }),
};
if (process.env.PROXY) {
const { ProxyAgent } = undici;
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
}
const response = await undici.fetch(url, fetchOptions);
if (!response.ok) {
logger.error(
`[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP ${response.status} ${response.statusText}`,
);
return null;
}
const data = await response.json();
const values = Array.isArray(data?.value) ? data.value : null;
if (!values) {
logger.error(
'[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects',
);
return null;
}
const groupIds = values.filter((id) => typeof id === 'string');
logger.debug(
`[openidStrategy] Successfully resolved ${groupIds.length} groups via Microsoft Graph getMemberObjects`,
);
return groupIds;
} catch (err) {
logger.error(
'[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:',
err,
);
return null;
}
}
/**
* Process OpenID authentication tokenset and userinfo
* This is the core logic extracted from the passport strategy callback
* Can be reused by both the passport strategy and proxy authentication
*
* @param {Object} tokenset - The OpenID tokenset containing access_token, id_token, etc.
* @param {boolean} existingUsersOnly - If true, only existing users will be processed
* @returns {Promise<Object>} The authenticated user object with tokenset
*/
async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
const claims = tokenset.claims ? tokenset.claims() : tokenset;
const userinfo = {
...claims,
};
if (tokenset.access_token) {
const providerUserinfo = await getUserInfo(openidConfig, tokenset.access_token, claims.sub);
Object.assign(userinfo, providerUserinfo);
}
const appConfig = await getAppConfig();
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
);
throw new Error('Email domain not allowed');
}
const result = await findOpenIDUser({
findUser,
email: email,
openidId: claims.sub || userinfo.sub,
idOnTheSource: claims.oid || userinfo.oid,
strategyName: 'openidStrategy',
});
let user = result.user;
const error = result.error;
if (error) {
throw new Error(ErrorTypes.AUTH_FAILED);
}
const fullName = getFullName(userinfo);
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
if (requiredRole) {
const requiredRoles = requiredRole
.split(',')
.map((role) => role.trim())
.filter(Boolean);
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
let decodedToken = '';
if (requiredRoleTokenKind === 'access' && tokenset.access_token) {
decodedToken = jwtDecode(tokenset.access_token);
} else if (requiredRoleTokenKind === 'id' && tokenset.id_token) {
decodedToken = jwtDecode(tokenset.id_token);
}
let roles = get(decodedToken, requiredRoleParameterPath);
// Handle Azure AD group overage for ID token groups: when hasgroups or _claim_* indicate overage,
// resolve groups via Microsoft Graph instead of relying on token group values.
if (
!Array.isArray(roles) &&
typeof roles !== 'string' &&
requiredRoleTokenKind === 'id' &&
requiredRoleParameterPath === 'groups' &&
decodedToken &&
(decodedToken.hasgroups ||
(decodedToken._claim_names?.groups &&
decodedToken._claim_sources?.[decodedToken._claim_names.groups]))
) {
const overageGroups = await resolveGroupsFromOverage(tokenset.access_token);
if (overageGroups) {
roles = overageGroups;
}
}
if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) {
logger.error(
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
);
const rolesList =
requiredRoles.length === 1
? `"${requiredRoles[0]}"`
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
throw new Error(`You must have ${rolesList} role to log in.`);
}
const roleValues = Array.isArray(roles) ? roles : [roles];
if (!requiredRoles.some((role) => roleValues.includes(role))) {
const rolesList =
requiredRoles.length === 1
? `"${requiredRoles[0]}"`
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
throw new Error(`You must have ${rolesList} role to log in.`);
}
}
let username = '';
if (process.env.OPENID_USERNAME_CLAIM) {
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
} else {
username = convertToUsername(
userinfo.preferred_username || userinfo.username || userinfo.email,
);
}
if (existingUsersOnly && !user) {
throw new Error('User does not exist');
}
if (!user) {
user = {
provider: 'openid',
openidId: userinfo.sub,
username,
email: email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
idOnTheSource: userinfo.oid,
};
const balanceConfig = getBalanceConfig(appConfig);
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'openid';
user.openidId = userinfo.sub;
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
if (email && email !== user.email) {
user.email = email;
user.emailVerified = userinfo.email_verified || false;
}
}
const adminRole = process.env.OPENID_ADMIN_ROLE;
const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
if (adminRole && adminRoleParameterPath && adminRoleTokenKind) {
let adminRoleObject;
switch (adminRoleTokenKind) {
case 'access':
adminRoleObject = jwtDecode(tokenset.access_token);
break;
case 'id':
adminRoleObject = jwtDecode(tokenset.id_token);
break;
case 'userinfo':
adminRoleObject = userinfo;
break;
default:
logger.error(
`[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`,
);
throw new Error('Invalid admin role token kind');
}
const adminRoles = get(adminRoleObject, adminRoleParameterPath);
if (
adminRoles &&
(adminRoles === true ||
adminRoles === adminRole ||
(Array.isArray(adminRoles) && adminRoles.includes(adminRole)))
) {
user.role = SystemRoles.ADMIN;
logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`);
} else if (user.role === SystemRoles.ADMIN) {
user.role = SystemRoles.USER;
logger.info(
`[openidStrategy] User ${username} demoted from admin - role no longer present in token`,
);
}
}
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;
let fileName;
if (crypto) {
fileName = (await hashToken(userinfo.sub)) + '.png';
} else {
fileName = userinfo.sub + '.png';
}
const imageBuffer = await downloadImage(
imageUrl,
openidConfig,
tokenset.access_token,
userinfo.sub,
);
if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
}
}
user = await updateUser(user._id, user);
logger.info(
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
{
user: {
openidId: user.openidId,
username: user.username,
email: user.email,
name: user.name,
},
},
);
return {
...user,
tokenset,
federatedTokens: {
access_token: tokenset.access_token,
id_token: tokenset.id_token,
refresh_token: tokenset.refresh_token,
expires_at: tokenset.expires_at,
},
};
}
/**
* @param {boolean | undefined} [existingUsersOnly]
*/
function createOpenIDCallback(existingUsersOnly) {
return async (tokenset, done) => {
try {
const user = await processOpenIDAuth(tokenset, existingUsersOnly);
done(null, user);
} catch (err) {
if (err.message === 'Email domain not allowed') {
return done(null, false, { message: err.message });
}
if (err.message === ErrorTypes.AUTH_FAILED) {
return done(null, false, { message: err.message });
}
if (err.message && err.message.includes('role to log in')) {
return done(null, false, { message: err.message });
}
logger.error('[openidStrategy] login failed', err);
done(err);
}
};
}
/**
* Sets up the OpenID strategy specifically for admin authentication.
* @param {Configuration} openidConfig
*/
const setupOpenIdAdmin = (openidConfig) => {
try {
if (!openidConfig) {
throw new Error('OpenID configuration not initialized');
}
const openidAdminLogin = new CustomOpenIDStrategy(
{
config: openidConfig,
scope: process.env.OPENID_SCOPE,
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
callbackURL: process.env.DOMAIN_SERVER + '/api/admin/oauth/openid/callback',
},
createOpenIDCallback(true),
);
passport.use('openidAdmin', openidAdminLogin);
} catch (err) {
logger.error('[openidStrategy] setupOpenIdAdmin', err);
}
};
/**
* Sets up the OpenID strategy for authentication.
* This function configures the OpenID client, handles proxy settings,
* and defines the OpenID strategy for Passport.js.
*
* @async
* @function setupOpenId
* @returns {Promise<Configuration | null>} A promise that resolves when the OpenID strategy is set up and returns the openid client config object.
* @throws {Error} If an error occurs during the setup process.
*/
async function setupOpenId() {
try {
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
/** @type {ClientMetadata} */
const clientMetadata = {
client_id: process.env.OPENID_CLIENT_ID,
client_secret: process.env.OPENID_CLIENT_SECRET,
};
if (shouldGenerateNonce) {
clientMetadata.response_types = ['code'];
clientMetadata.grant_types = ['authorization_code'];
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
}
/** @type {Configuration} */
openidConfig = await client.discovery(
new URL(process.env.OPENID_ISSUER),
process.env.OPENID_CLIENT_ID,
clientMetadata,
undefined,
{
[client.customFetch]: customFetch,
execute: [client.allowInsecureRequests],
},
);
logger.info(`[openidStrategy] OpenID authentication configuration`, {
generateNonce: shouldGenerateNonce,
reason: shouldGenerateNonce
? 'OPENID_GENERATE_NONCE=true - Will generate nonce and use explicit metadata for federated providers'
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata',
});
const openidLogin = new CustomOpenIDStrategy(
{
config: openidConfig,
scope: process.env.OPENID_SCOPE,
callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
},
createOpenIDCallback(),
);
passport.use('openid', openidLogin);
setupOpenIdAdmin(openidConfig);
return openidConfig;
} catch (err) {
logger.error('[openidStrategy]', err);
return null;
}
}
/**
* @function getOpenIdConfig
* @description Returns the OpenID client instance.
* @throws {Error} If the OpenID client is not initialized.
* @returns {Configuration}
*/
function getOpenIdConfig() {
if (!openidConfig) {
throw new Error('OpenID client is not initialized. Please call setupOpenId first.');
}
return openidConfig;
}
module.exports = {
setupOpenId,
getOpenIdConfig,
};

View File

@@ -4,6 +4,7 @@
"type": "module",
"private": true,
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/bun": "latest"
},
"peerDependencies": {

40
playwright.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html"], ["list"]],
timeout: 30_000,
use: {
baseURL: process.env.BASE_URL ?? "http://localhost:8000",
actionTimeout: 10_000,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "setup",
testMatch: /auth\.setup\.ts/,
},
{
name: "public",
testMatch: /public\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "authenticated",
testMatch: /\.spec\.ts$/,
testIgnore: /public\.spec\.ts$/,
dependencies: ["setup"],
use: {
...devices["Desktop Chrome"],
storageState: "e2e/.auth/user.json",
},
},
],
});

View File

@@ -1,3 +1,4 @@
use crate::i18n::Locale;
use crate::{components::*, pages::*};
use dioxus::prelude::*;
@@ -21,12 +22,6 @@ pub enum Route {
DashboardPage {},
#[route("/providers")]
ProvidersPage {},
#[route("/chat")]
ChatPage {},
#[route("/tools")]
ToolsPage {},
#[route("/knowledge")]
KnowledgePage {},
#[layout(DeveloperShell)]
#[route("/developer/agents")]
@@ -54,29 +49,50 @@ const MAIN_CSS: Asset = asset!("/assets/main.css");
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).
/// Google Fonts URL for Literata (body) and Sora (headings).
const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
family=Inter:wght@400;500;600&\
family=Space+Grotesk:wght@500;600;700&\
family=Sora:wght@300;400;500;600;700;800&\
family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,500;0,7..72,600;0,7..72,700;1,7..72,400&\
display=swap";
/// Root application component. Loads global assets and mounts the router.
///
/// Provides a `Signal<Locale>` context that all child components can read
/// via `use_context::<Signal<Locale>>()` to access the current locale.
/// The locale is persisted in `localStorage` under `"certifai_locale"`.
#[component]
pub fn App() -> Element {
// Read persisted locale from localStorage on first render.
let initial_locale = {
#[cfg(feature = "web")]
{
web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.and_then(|s| s.get_item("certifai_locale").ok().flatten())
.map(|code| Locale::from_code(&code))
.unwrap_or_default()
}
#[cfg(not(feature = "web"))]
{
Locale::default()
}
};
use_context_provider(|| Signal::new(initial_locale));
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-color": "#8b5cf6",
"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: "theme-color", content: "#0c0a1d" }
document::Meta { name: "apple-mobile-web-app-capable", content: "yes" }
document::Meta {
name: "apple-mobile-web-app-status-bar-style",

View File

@@ -1,8 +1,11 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{BsList, BsX};
use dioxus_free_icons::Icon;
use crate::components::sidebar::Sidebar;
use crate::i18n::{t, tw, Locale};
use crate::infrastructure::auth_check::check_auth;
use crate::models::AuthInfo;
use crate::models::{AuthInfo, ServiceUrlsContext};
use crate::Route;
/// Application shell layout that wraps all authenticated pages.
@@ -12,6 +15,9 @@ use crate::Route;
/// sidebar with real user data and the active child route.
#[component]
pub fn AppShell() -> Element {
let locale = use_context::<Signal<Locale>>();
let mut mobile_menu_open = use_signal(|| false);
// use_resource memoises the async call and avoids infinite re-render
// loops that use_effect + spawn + signal writes can cause.
#[allow(clippy::redundant_closure)]
@@ -23,12 +29,56 @@ pub fn AppShell() -> Element {
match auth_snapshot {
Some(Ok(info)) if info.authenticated => {
// Provide developer tool URLs as context so child pages
// can read them without prop-drilling through layouts.
use_context_provider(|| {
Signal::new(ServiceUrlsContext {
langgraph_url: info.langgraph_url.clone(),
langflow_url: info.langflow_url.clone(),
langfuse_url: info.langfuse_url.clone(),
})
});
let menu_open = *mobile_menu_open.read();
let sidebar_cls = if menu_open {
"sidebar sidebar--open"
} else {
"sidebar"
};
rsx! {
div { class: "app-shell",
// Mobile top bar (visible only on small screens via CSS)
header { class: "mobile-header",
button {
class: "mobile-menu-btn",
onclick: move |_| {
let current = *mobile_menu_open.read();
mobile_menu_open.set(!current);
},
if menu_open {
Icon { icon: BsX, width: 24, height: 24 }
} else {
Icon { icon: BsList, width: 24, height: 24 }
}
}
span { class: "mobile-header-title", "CERTifAI" }
}
// Backdrop overlay when sidebar is open on mobile
if menu_open {
div {
class: "sidebar-backdrop",
onclick: move |_| mobile_menu_open.set(false),
}
}
Sidebar {
email: info.email,
name: info.name,
avatar_url: info.avatar_url,
librechat_url: info.librechat_url,
compliance_scanner_url: info.compliance_scanner_url,
class: sidebar_cls,
on_nav: move |_| mobile_menu_open.set(false),
}
main { class: "main-content", Outlet::<Route> {} }
}
@@ -40,16 +90,17 @@ pub fn AppShell() -> Element {
nav.push(NavigationTarget::<Route>::External("/auth".into()));
rsx! {
div { class: "app-shell loading",
p { "Redirecting to login..." }
p { {t(*locale.read(), "auth.redirecting_login")} }
}
}
}
Some(Err(e)) => {
let msg = e.to_string();
let error_text = tw(*locale.read(), "auth.auth_error", &[("msg", &msg)]);
rsx! {
div { class: "auth-error",
p { "Authentication error: {msg}" }
a { href: "/auth", "Login" }
p { {error_text} }
a { href: "/auth", {t(*locale.read(), "common.login")} }
}
}
}
@@ -57,7 +108,7 @@ pub fn AppShell() -> Element {
// Still loading.
rsx! {
div { class: "app-shell loading",
p { "Loading..." }
p { {t(*locale.read(), "common.loading")} }
}
}
}

View File

@@ -1,6 +1,8 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::infrastructure::llm::FollowUpMessage;
use crate::models::NewsCard;
use dioxus::prelude::*;
/// Side panel displaying the full details of a selected news article.
///
@@ -27,6 +29,9 @@ pub fn ArticleDetail(
#[props(default = false)] is_chatting: bool,
on_chat_send: EventHandler<String>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
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);
@@ -41,7 +46,7 @@ pub fn ArticleDetail(
button {
class: "article-detail-close",
onclick: move |_| on_close.call(()),
"X"
"{t(l, \"common.close\")}"
}
div { class: "article-detail-content",
@@ -74,7 +79,7 @@ pub fn ArticleDetail(
href: "{card.url}",
target: "_blank",
rel: "noopener",
"Read original article"
"{t(l, \"article.read_original\")}"
}
// AI Summary bubble (below the link)
@@ -82,11 +87,11 @@ pub fn ArticleDetail(
if is_summarizing {
div { class: "ai-summary-bubble-loading",
div { class: "ai-summary-dot-pulse" }
span { "Summarizing..." }
span { "{t(l, \"article.summarizing\")}" }
}
} else if let Some(ref text) = summary {
p { class: "ai-summary-bubble-text", "{text}" }
span { class: "ai-summary-bubble-label", "Summarized with AI" }
span { class: "ai-summary-bubble-label", "{t(l, \"article.summarized_with_ai\")}" }
}
}
@@ -123,7 +128,7 @@ pub fn ArticleDetail(
input {
class: "article-chat-textbox",
r#type: "text",
placeholder: "Ask a follow-up question...",
placeholder: "{t(l, \"article.ask_followup\")}",
value: "{chat_input}",
disabled: is_chatting,
oninput: move |e| chat_input.set(e.value()),
@@ -147,7 +152,7 @@ pub fn ArticleDetail(
chat_input.set(String::new());
}
},
"Send"
"{t(l, \"common.send\")}"
}
}
}

View File

@@ -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" }
}
}
}
}

View File

@@ -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" }
}
}
}
}

View File

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

View File

@@ -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 }
}
}
}
}

View File

@@ -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" }
}
}
}
}
}

View File

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

View File

@@ -1,8 +1,9 @@
use dioxus::prelude::*;
use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
use crate::i18n::{t, Locale};
use crate::infrastructure::litellm::{get_litellm_status, LitellmStatus};
/// Right sidebar for the dashboard, showing Ollama status, trending topics,
/// Right sidebar for the dashboard, showing LiteLLM status, trending topics,
/// and recent search history.
///
/// Appears when no article card is selected. Disappears when the user opens
@@ -10,37 +11,40 @@ use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
///
/// # Props
///
/// * `ollama_url` - Ollama instance URL for status polling
/// * `litellm_url` - LiteLLM proxy 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,
litellm_url: String,
trending: Vec<String>,
recent_searches: Vec<String>,
on_topic_click: EventHandler<String>,
) -> Element {
// Fetch Ollama status once on mount.
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Fetch LiteLLM 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 url = litellm_url.clone();
let status_resource = use_resource(move || {
let u = url.clone();
async move {
get_ollama_status(u).await.unwrap_or(OllamaStatus {
get_litellm_status(u).await.unwrap_or(LitellmStatus {
online: false,
models: Vec::new(),
})
}
});
let current_status: OllamaStatus =
let current_status: LitellmStatus =
status_resource
.read()
.as_ref()
.cloned()
.unwrap_or(OllamaStatus {
.unwrap_or(LitellmStatus {
online: false,
models: Vec::new(),
});
@@ -48,16 +52,16 @@ pub fn DashboardSidebar(
rsx! {
aside { class: "dashboard-sidebar",
// -- Ollama Status Section --
// -- LiteLLM Status Section --
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "Ollama Status" }
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.litellm_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"
"{t(l, \"common.online\")}"
} else {
"Offline"
"{t(l, \"common.offline\")}"
}
}
}
@@ -73,7 +77,7 @@ pub fn DashboardSidebar(
// -- Trending Topics Section --
if !trending.is_empty() {
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "Trending" }
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.trending\")}" }
for topic in trending.iter() {
{
let t = topic.clone();
@@ -92,7 +96,7 @@ pub fn DashboardSidebar(
// -- Recent Searches Section --
if !recent_searches.is_empty() {
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "Recent Searches" }
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.recent_searches\")}" }
for search in recent_searches.iter() {
{
let s = search.clone();

View File

@@ -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")
}
}

View File

@@ -1,6 +1,8 @@
use crate::Route;
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::Route;
/// Login redirect component.
///
/// Redirects the user to the external OAuth authentication endpoint.
@@ -12,6 +14,8 @@ use dioxus::prelude::*;
#[component]
pub fn Login(redirect_url: String) -> Element {
let navigator = use_navigator();
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
use_effect(move || {
// Default to /dashboard when redirect_url is empty.
@@ -25,6 +29,6 @@ pub fn Login(redirect_url: String) -> Element {
});
rsx!(
div { class: "text-center p-6", "Redirecting to secure login page…" }
div { class: "text-center p-6", "{t(l, \"auth.redirecting_secure\")}" }
)
}

View File

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

View File

@@ -112,12 +112,12 @@ pub fn mock_news() -> Vec<NewsCardModel> {
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."
title: "LiteLLM Adds Multi-Provider Routing".into(),
source: "LiteLLM".into(),
summary: "Route requests across multiple LLM providers with automatic fallback.".into(),
content: "LiteLLM now supports multi-provider routing with automatic \
fallback. Users can route requests across multiple providers \
for improved reliability and cost optimization."
.into(),
category: "Infrastructure".into(),
url: "#".into(),

View File

@@ -1,6 +1,8 @@
use crate::models::PricingPlan;
use dioxus::prelude::*;
use crate::i18n::{t, tw, Locale};
use crate::models::PricingPlan;
/// Renders a pricing plan card with features list and call-to-action button.
///
/// # Arguments
@@ -9,6 +11,9 @@ use dioxus::prelude::*;
/// * `on_select` - Callback fired when the CTA button is clicked
#[component]
pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let card_class = if plan.highlighted {
"pricing-card pricing-card--highlighted"
} else {
@@ -16,8 +21,8 @@ pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Elemen
};
let seats_label = match plan.max_seats {
Some(n) => format!("Up to {n} seats"),
None => "Unlimited seats".to_string(),
Some(n) => tw(l, "common.up_to_seats", &[("n", &n.to_string())]),
None => t(l, "common.unlimited_seats"),
};
rsx! {
@@ -25,7 +30,7 @@ pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Elemen
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" }
span { class: "pricing-card-period", " {t(l, \"common.eur_per_month\")}" }
}
p { class: "pricing-card-seats", "{seats_label}" }
ul { class: "pricing-card-features",
@@ -39,7 +44,7 @@ pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Elemen
let id = plan.id.clone();
move |_| on_select.call(id.clone())
},
"Get Started"
"{t(l, \"common.get_started\")}"
}
}
}

View File

@@ -1,16 +1,29 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill,
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2,
BsGrid, BsHouseDoor, BsMoonFill, BsShieldCheck, BsSunFill,
};
use dioxus_free_icons::Icon;
use crate::i18n::{t, Locale};
use crate::Route;
/// Destination for a sidebar link: either an internal route or an external URL.
enum NavTarget {
/// Internal Dioxus route (rendered as `Link { to: route }`).
Internal(Route),
/// External URL opened in a new tab (rendered as `<a href>`).
External(String),
}
/// Navigation entry for the sidebar.
///
/// `key` is a stable identifier used for active-route detection and never
/// changes across locales. `label` is the translated display string.
struct NavItem {
label: &'static str,
route: Route,
key: &'static str,
label: String,
target: NavTarget,
/// Bootstrap icon element rendered beside the label.
icon: Element,
}
@@ -22,72 +35,119 @@ struct NavItem {
/// * `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).
/// * `class` - CSS class override (e.g. to add `sidebar--open` on mobile).
/// * `on_nav` - Callback fired when a nav link is clicked (used to close
/// the mobile menu).
#[component]
pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element {
let nav_items: Vec<NavItem> = vec![
pub fn Sidebar(
name: String,
email: String,
avatar_url: String,
#[props(default = "http://localhost:3080".to_string())] librechat_url: String,
#[props(default)] compliance_scanner_url: String,
#[props(default = "sidebar".to_string())] class: String,
#[props(default)] on_nav: EventHandler<()>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let locale_val = *locale.read();
let mut nav_items: Vec<NavItem> = vec![
NavItem {
label: "Dashboard",
route: Route::DashboardPage {},
key: "dashboard",
label: t(locale_val, "nav.dashboard"),
target: NavTarget::Internal(Route::DashboardPage {}),
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
},
NavItem {
label: "Providers",
route: Route::ProvidersPage {},
key: "providers",
label: t(locale_val, "nav.providers"),
target: NavTarget::Internal(Route::ProvidersPage {}),
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
},
NavItem {
label: "Chat",
route: Route::ChatPage {},
key: "chat",
label: t(locale_val, "nav.chat"),
// Opens LibreChat in a new tab; SSO via shared Keycloak realm.
target: NavTarget::External(librechat_url.clone()),
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 {},
key: "developer",
label: t(locale_val, "nav.developer"),
target: NavTarget::Internal(Route::AgentsPage {}),
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
},
NavItem {
label: "Organization",
route: Route::OrgPricingPage {},
key: "organization",
label: t(locale_val, "nav.organization"),
target: NavTarget::Internal(Route::OrgPricingPage {}),
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
},
];
// Only show the compliance scanner link when a URL is configured.
if !compliance_scanner_url.is_empty() {
nav_items.push(NavItem {
key: "compliance",
label: t(locale_val, "nav.compliance"),
target: NavTarget::External(compliance_scanner_url.clone()),
icon: rsx! { Icon { icon: BsShieldCheck, width: 18, height: 18 } },
});
}
// Determine current path to highlight the active nav link.
let current_route = use_route::<Route>();
let logout_label = t(locale_val, "common.logout");
rsx! {
aside { class: "sidebar",
SidebarHeader { name, email: email.clone(), avatar_url }
aside { class: "{class}",
div { class: "sidebar-top-row",
SidebarHeader { name, email: email.clone(), avatar_url }
LocalePicker {}
}
nav { class: "sidebar-nav",
for item in nav_items {
{
// Active detection for nested routes: highlight the parent nav
// item when any child route within the nested shell is active.
let is_active = match &current_route {
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
item.label == "Developer"
match &item.target {
NavTarget::Internal(route) => {
// Active detection for nested routes: highlight the parent
// nav item when any child route within the nested shell
// is active.
let is_active = match &current_route {
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
item.key == "developer"
}
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
item.key == "organization"
}
_ => *route == current_route,
};
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
let route = route.clone();
rsx! {
Link {
to: route,
class: cls,
onclick: move |_| on_nav.call(()),
{item.icon}
span { "{item.label}" }
}
}
}
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
item.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}" }
NavTarget::External(url) => {
let url = url.clone();
rsx! {
a {
href: url,
target: "_blank",
rel: "noopener noreferrer",
class: "sidebar-link",
onclick: move |_| on_nav.call(()),
{item.icon}
span { "{item.label}" }
}
}
}
}
}
@@ -99,7 +159,7 @@ pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element {
to: NavigationTarget::<Route>::External("/logout".into()),
class: "sidebar-link logout-btn",
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
span { "Logout" }
span { "{logout_label}" }
}
ThemeToggle {}
}
@@ -157,6 +217,8 @@ fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element {
/// in `localStorage` so it survives page reloads.
#[component]
fn ThemeToggle() -> Element {
let locale = use_context::<Signal<Locale>>();
let mut is_dark = use_signal(|| {
// Read persisted preference from localStorage on first render.
#[cfg(feature = "web")]
@@ -215,11 +277,17 @@ fn ThemeToggle() -> Element {
};
let dark = *is_dark.read();
let locale_val = *locale.read();
let title = if dark {
t(locale_val, "nav.switch_light")
} else {
t(locale_val, "nav.switch_dark")
};
rsx! {
button {
class: "theme-toggle-btn",
title: if dark { "Switch to light mode" } else { "Switch to dark mode" },
title: "{title}",
onclick: toggle,
if dark {
Icon { icon: BsSunFill, width: 16, height: 16 }
@@ -230,25 +298,106 @@ fn ThemeToggle() -> Element {
}
}
/// Compact language picker with globe icon and ISO 3166-1 alpha-2 code.
///
/// Renders a button showing a globe icon and the current locale's two-letter
/// country code (e.g. "EN", "DE"). Clicking toggles a dropdown overlay with
/// all available locales. Persists the selection to `localStorage`.
#[component]
fn LocalePicker() -> Element {
let mut locale = use_context::<Signal<Locale>>();
let current = *locale.read();
let mut open = use_signal(|| false);
let mut select_locale = move |new_locale: Locale| {
locale.set(new_locale);
open.set(false);
#[cfg(feature = "web")]
{
if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
{
let _ = storage.set_item("certifai_locale", new_locale.code());
}
}
};
let code_upper = current.code().to_uppercase();
rsx! {
div { class: "locale-picker",
button {
class: "locale-picker-btn",
title: current.label(),
onclick: move |_| {
let cur = *open.read();
open.set(!cur);
},
Icon { icon: BsGlobe2, width: 14, height: 14 }
span { class: "locale-picker-code", "{code_upper}" }
}
if *open.read() {
// Invisible backdrop to close dropdown on outside click
div {
class: "locale-picker-backdrop",
onclick: move |_| open.set(false),
}
div { class: "locale-picker-dropdown",
for loc in Locale::all() {
{
let is_active = *loc == current;
let cls = if is_active {
"locale-picker-item locale-picker-item--active"
} else {
"locale-picker-item"
};
let loc_copy = *loc;
rsx! {
button {
class: "{cls}",
onclick: move |_| select_locale(loc_copy),
span { class: "locale-picker-item-code",
"{loc_copy.code().to_uppercase()}"
}
span { class: "locale-picker-item-label",
"{loc_copy.label()}"
}
}
}
}
}
}
}
}
}
}
/// Footer section with version string and placeholder social links.
#[component]
fn SidebarFooter() -> Element {
let locale = use_context::<Signal<Locale>>();
let locale_val = *locale.read();
let version = env!("CARGO_PKG_VERSION");
let github_title = t(locale_val, "nav.github");
let impressum_title = t(locale_val, "common.impressum");
let privacy_label = t(locale_val, "common.privacy_policy");
let impressum_label = t(locale_val, "common.impressum");
rsx! {
footer { class: "sidebar-footer",
div { class: "sidebar-social",
a { href: "#", class: "social-link", title: "GitHub",
a { href: "#", class: "social-link", title: "{github_title}",
Icon { icon: BsGithub, width: 16, height: 16 }
}
a { href: "#", class: "social-link", title: "Impressum",
a { href: "#", class: "social-link", title: "{impressum_title}",
Icon { icon: BsGrid, width: 16, height: 16 }
}
}
div { class: "sidebar-legal",
Link { to: Route::PrivacyPage {}, class: "legal-link", "Privacy Policy" }
Link { to: Route::PrivacyPage {}, class: "legal-link", "{privacy_label}" }
span { class: "legal-sep", "|" }
Link { to: Route::ImpressumPage {}, class: "legal-link", "Impressum" }
Link { to: Route::ImpressumPage {}, class: "legal-link", "{impressum_label}" }
}
p { class: "sidebar-version", "v{version}" }
}

View File

@@ -9,7 +9,7 @@ use dioxus::prelude::*;
/// * `route` - Route to navigate to when clicked
#[derive(Clone, PartialEq)]
pub struct SubNavItem {
pub label: &'static str,
pub label: String,
pub route: Route,
}

View File

@@ -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"
}
}
}
}
}
}

View File

@@ -0,0 +1,81 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
/// Properties for the [`ToolEmbed`] component.
///
/// # Fields
///
/// * `url` - Service URL; when empty, a "Not Configured" placeholder is shown
/// * `title` - Display title for the tool (e.g. "Agent Builder")
/// * `description` - Description text shown in the placeholder card
/// * `icon` - Single-character icon for the placeholder card
/// * `launch_label` - Label for the disabled button in the placeholder
#[derive(Props, Clone, PartialEq)]
pub struct ToolEmbedProps {
/// Service URL. Empty string means "not configured".
pub url: String,
/// Display title shown in the toolbar / placeholder heading.
pub title: String,
/// Description shown in the "not configured" placeholder.
pub description: String,
/// Single-character icon for the placeholder card.
pub icon: &'static str,
/// Label for the disabled launch button in placeholder mode.
pub launch_label: String,
}
/// Hybrid iframe / placeholder component for developer tool pages.
///
/// When `url` is non-empty, renders a toolbar (title + pop-out button)
/// above a full-height iframe embedding the service. When `url` is
/// empty, renders the existing placeholder card with a "Not Configured"
/// badge instead of "Coming Soon".
#[component]
pub fn ToolEmbed(props: ToolEmbedProps) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
if props.url.is_empty() {
// Not configured -- show placeholder card
rsx! {
section { class: "placeholder-page",
div { class: "placeholder-card",
div { class: "placeholder-icon", "{props.icon}" }
h2 { "{props.title}" }
p { class: "placeholder-desc", "{props.description}" }
button {
class: "btn-primary",
disabled: true,
"{props.launch_label}"
}
span { class: "placeholder-badge",
"{t(l, \"developer.not_configured\")}"
}
}
}
}
} else {
// URL is set -- render toolbar + iframe
let pop_out_url = props.url.clone();
rsx! {
div { class: "tool-embed",
div { class: "tool-embed-toolbar",
span { class: "tool-embed-title", "{props.title}" }
a {
class: "tool-embed-popout-btn",
href: "{pop_out_url}",
target: "_blank",
rel: "noopener noreferrer",
"{t(l, \"developer.open_new_tab\")}"
}
}
iframe {
class: "tool-embed-iframe",
src: "{props.url}",
title: "{props.title}",
}
}
}
}
}

242
src/i18n/mod.rs Normal file
View File

@@ -0,0 +1,242 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use serde_json::Value;
/// Supported application locales.
///
/// Each variant maps to an ISO 639-1 code and a human-readable label
/// displayed in the language picker.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Locale {
#[default]
En,
De,
Fr,
Es,
Pt,
}
impl Locale {
/// ISO 639-1 language code.
pub fn code(self) -> &'static str {
match self {
Locale::En => "en",
Locale::De => "de",
Locale::Fr => "fr",
Locale::Es => "es",
Locale::Pt => "pt",
}
}
/// Human-readable label in the locale's own language.
pub fn label(self) -> &'static str {
match self {
Locale::En => "English",
Locale::De => "Deutsch",
Locale::Fr => "Francais",
Locale::Es => "Espanol",
Locale::Pt => "Portugues",
}
}
/// All available locales.
pub fn all() -> &'static [Locale] {
&[Locale::En, Locale::De, Locale::Fr, Locale::Es, Locale::Pt]
}
/// Parse a locale from its ISO 639-1 code.
///
/// Returns `Locale::En` for unrecognized codes.
pub fn from_code(code: &str) -> Self {
match code {
"de" => Locale::De,
"fr" => Locale::Fr,
"es" => Locale::Es,
"pt" => Locale::Pt,
_ => Locale::En,
}
}
}
type TranslationMap = HashMap<String, String>;
/// All translations loaded at compile time and parsed lazily on first access.
///
/// Uses `LazyLock` (stable since Rust 1.80) to avoid runtime file I/O.
/// Each locale's JSON is embedded via `include_str!` and flattened into
/// dot-separated keys (e.g. `"nav.dashboard"` -> `"Dashboard"`).
static TRANSLATIONS: LazyLock<HashMap<&'static str, TranslationMap>> = LazyLock::new(|| {
let mut map = HashMap::with_capacity(5);
map.insert(
"en",
parse_translations(include_str!("../../assets/i18n/en.json")),
);
map.insert(
"de",
parse_translations(include_str!("../../assets/i18n/de.json")),
);
map.insert(
"fr",
parse_translations(include_str!("../../assets/i18n/fr.json")),
);
map.insert(
"es",
parse_translations(include_str!("../../assets/i18n/es.json")),
);
map.insert(
"pt",
parse_translations(include_str!("../../assets/i18n/pt.json")),
);
map
});
/// Parse a JSON string into a flat `key -> value` map.
///
/// Nested objects are flattened with dot separators:
/// `{ "nav": { "home": "Home" } }` becomes `"nav.home" -> "Home"`.
fn parse_translations(json: &str) -> TranslationMap {
// SAFETY: translation JSON files are bundled at compile time and are
// validated during development. A malformed file will panic here during
// the first access, which surfaces immediately in testing.
let value: Value = serde_json::from_str(json).unwrap_or(Value::Object(Default::default()));
let mut map = TranslationMap::new();
flatten_json("", &value, &mut map);
map
}
/// Recursively flatten a JSON value into dot-separated keys.
fn flatten_json(prefix: &str, value: &Value, map: &mut TranslationMap) {
match value {
Value::Object(obj) => {
for (key, val) in obj {
let new_prefix = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
flatten_json(&new_prefix, val, map);
}
}
Value::String(s) => {
map.insert(prefix.to_string(), s.clone());
}
// Non-string leaf values are skipped (numbers, bools, nulls)
_ => {}
}
}
/// Look up a translation for the given locale and key.
///
/// Falls back to English if the key is missing in the target locale.
/// Returns the raw key if not found in any locale (useful for debugging
/// missing translations).
///
/// # Arguments
///
/// * `locale` - The target locale
/// * `key` - Dot-separated translation key (e.g. `"nav.dashboard"`)
///
/// # Returns
///
/// The translated string, or the key itself as a fallback.
pub fn t(locale: Locale, key: &str) -> String {
TRANSLATIONS
.get(locale.code())
.and_then(|map| map.get(key))
.cloned()
.unwrap_or_else(|| {
// Fallback to English
TRANSLATIONS
.get("en")
.and_then(|map| map.get(key))
.cloned()
.unwrap_or_else(|| key.to_string())
})
}
/// Look up a translation and substitute variables.
///
/// Variables in the translation string use `{name}` syntax.
/// Each `(name, value)` pair in `vars` replaces `{name}` with `value`.
///
/// # Arguments
///
/// * `locale` - The target locale
/// * `key` - Dot-separated translation key
/// * `vars` - Slice of `(name, value)` pairs for substitution
///
/// # Returns
///
/// The translated string with all variables substituted.
///
/// # Examples
///
/// ```
/// use dashboard::i18n::{tw, Locale};
/// let text = tw(Locale::En, "common.up_to_seats", &[("n", "5")]);
/// assert_eq!(text, "Up to 5 seats");
/// ```
pub fn tw(locale: Locale, key: &str, vars: &[(&str, &str)]) -> String {
let mut result = t(locale, key);
for (name, value) in vars {
result = result.replace(&format!("{{{name}}}"), value);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn english_lookup() {
let result = t(Locale::En, "nav.dashboard");
assert_eq!(result, "Dashboard");
}
#[test]
fn german_lookup() {
let result = t(Locale::De, "nav.dashboard");
assert_eq!(result, "Dashboard");
}
#[test]
fn fallback_to_english() {
// If a key exists in English but not in another locale, English is returned
let en = t(Locale::En, "common.loading");
let result = t(Locale::De, "common.loading");
// German should have its own translation, but if missing, falls back to EN
assert!(!result.is_empty());
// Just verify it doesn't return the key itself
assert_ne!(result, "common.loading");
let _ = en; // suppress unused warning
}
#[test]
fn missing_key_returns_key() {
let result = t(Locale::En, "nonexistent.key");
assert_eq!(result, "nonexistent.key");
}
#[test]
fn variable_substitution() {
let result = tw(Locale::En, "common.up_to_seats", &[("n", "5")]);
assert_eq!(result, "Up to 5 seats");
}
#[test]
fn locale_from_code() {
assert_eq!(Locale::from_code("de"), Locale::De);
assert_eq!(Locale::from_code("fr"), Locale::Fr);
assert_eq!(Locale::from_code("unknown"), Locale::En);
}
#[test]
fn all_locales_loaded() {
for locale in Locale::all() {
let result = t(*locale, "nav.dashboard");
assert!(!result.is_empty());
}
}
}

View File

@@ -24,9 +24,9 @@ pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
/// 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(crate) struct PendingOAuthEntry {
pub(crate) redirect_url: Option<String>,
pub(crate) code_verifier: String,
}
/// In-memory store for pending OAuth states. Keyed by the random state
@@ -38,7 +38,7 @@ pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
impl PendingOAuthStore {
/// Insert a pending state with an optional redirect URL and PKCE verifier.
fn insert(&self, state: String, entry: PendingOAuthEntry) {
pub(crate) 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)]
@@ -50,7 +50,7 @@ impl PendingOAuthStore {
/// 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> {
pub(crate) fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
#[allow(clippy::expect_used)]
self.0
.write()
@@ -60,7 +60,8 @@ impl PendingOAuthStore {
}
/// Generate a cryptographically random state string for CSRF protection.
fn generate_state() -> String {
#[cfg_attr(test, allow(dead_code))]
pub(crate) 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| {
@@ -75,7 +76,7 @@ fn generate_state() -> String {
///
/// Uses 32 random bytes encoded as base64url (no padding) to produce
/// a 43-character verifier per RFC 7636.
fn generate_code_verifier() -> String {
pub(crate) fn generate_code_verifier() -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let bytes: [u8; 32] = rand::rng().random();
@@ -85,7 +86,7 @@ fn generate_code_verifier() -> 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 {
pub(crate) fn derive_code_challenge(verifier: &str) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use sha2::{Digest, Sha256};
@@ -304,3 +305,117 @@ pub async fn set_login_session(session: Session, data: UserStateInner) -> Result
.await
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// generate_state()
// -----------------------------------------------------------------------
#[test]
fn generate_state_length_is_64() {
let state = generate_state();
assert_eq!(state.len(), 64);
}
#[test]
fn generate_state_chars_are_hex() {
let state = generate_state();
assert!(state.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn generate_state_two_calls_differ() {
let a = generate_state();
let b = generate_state();
assert_ne!(a, b);
}
// -----------------------------------------------------------------------
// generate_code_verifier()
// -----------------------------------------------------------------------
#[test]
fn code_verifier_length_is_43() {
let verifier = generate_code_verifier();
assert_eq!(verifier.len(), 43);
}
#[test]
fn code_verifier_chars_are_url_safe_base64() {
let verifier = generate_code_verifier();
// URL-safe base64 without padding uses [A-Za-z0-9_-]
assert!(verifier
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
}
// -----------------------------------------------------------------------
// derive_code_challenge()
// -----------------------------------------------------------------------
#[test]
fn code_challenge_deterministic() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let a = derive_code_challenge(verifier);
let b = derive_code_challenge(verifier);
assert_eq!(a, b);
}
#[test]
fn code_challenge_rfc7636_test_vector() {
// RFC 7636 Appendix B test vector:
// verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// expected challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge = derive_code_challenge(verifier);
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
// -----------------------------------------------------------------------
// PendingOAuthStore
// -----------------------------------------------------------------------
#[test]
fn pending_store_insert_and_take() {
let store = PendingOAuthStore::default();
store.insert(
"state-1".into(),
PendingOAuthEntry {
redirect_url: Some("/dashboard".into()),
code_verifier: "verifier-1".into(),
},
);
let entry = store.take("state-1");
assert!(entry.is_some());
let entry = entry.unwrap();
assert_eq!(entry.redirect_url, Some("/dashboard".into()));
assert_eq!(entry.code_verifier, "verifier-1");
}
#[test]
fn pending_store_take_removes_entry() {
let store = PendingOAuthStore::default();
store.insert(
"state-2".into(),
PendingOAuthEntry {
redirect_url: None,
code_verifier: "v2".into(),
},
);
let _ = store.take("state-2");
// Second take should return None since the entry was removed.
assert!(store.take("state-2").is_none());
}
#[test]
fn pending_store_take_unknown_returns_none() {
let store = PendingOAuthStore::default();
assert!(store.take("nonexistent").is_none());
}
}

View File

@@ -24,13 +24,32 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
.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,
}),
Some(u) => {
let librechat_url =
std::env::var("LIBRECHAT_URL").unwrap_or_else(|_| "http://localhost:3080".into());
// Extract service URLs from server state so the frontend can
// embed developer tools (LangGraph, LangFlow, Langfuse).
let state: crate::infrastructure::server_state::ServerState =
FullstackContext::extract().await?;
let langgraph_url = state.services.langgraph_url.clone();
let langflow_url = state.services.langflow_url.clone();
let langfuse_url = state.services.langfuse_url.clone();
let compliance_scanner_url = state.services.compliance_scanner_url.clone();
Ok(AuthInfo {
authenticated: true,
sub: u.sub,
email: u.user.email,
name: u.user.name,
avatar_url: u.user.avatar_url,
librechat_url,
langgraph_url,
langflow_url,
langfuse_url,
compliance_scanner_url,
})
}
None => Ok(AuthInfo::default()),
}
}

View File

@@ -134,7 +134,7 @@ pub async fn list_chat_sessions() -> Result<Vec<ChatSession>, ServerFnError> {
///
/// * `title` - Display title for the session
/// * `namespace` - Namespace string: `"General"` or `"News"`
/// * `provider` - LLM provider name (e.g. "ollama")
/// * `provider` - LLM provider name (e.g. "litellm")
/// * `model` - Model ID (e.g. "llama3.1:8b")
/// * `article_url` - Source article URL (only for `News` namespace, empty if none)
///
@@ -440,7 +440,12 @@ pub async fn chat_complete(
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);
let (base_url, model) = resolve_provider_url(
&state.services.litellm_url,
&state.services.litellm_model,
&session.provider,
&session.model,
);
// Parse messages from JSON
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
@@ -480,10 +485,22 @@ pub async fn chat_complete(
.ok_or_else(|| ServerFnError::new("empty LLM response"))
}
/// Resolve the base URL for a provider, falling back to server defaults.
/// Resolve the base URL for a provider, falling back to LiteLLM defaults.
///
/// # Arguments
///
/// * `litellm_url` - Default LiteLLM base URL from config
/// * `litellm_model` - Default LiteLLM model from config
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface")
/// * `model` - Model ID (may be empty for LiteLLM default)
///
/// # Returns
///
/// A `(base_url, model)` tuple resolved for the given provider.
#[cfg(feature = "server")]
fn resolve_provider_url(
state: &crate::infrastructure::ServerState,
pub(crate) fn resolve_provider_url(
litellm_url: &str,
litellm_model: &str,
provider: &str,
model: &str,
) -> (String, String) {
@@ -494,14 +511,231 @@ fn resolve_provider_url(
format!("https://api-inference.huggingface.co/models/{}", model),
model.to_string(),
),
// Default to Ollama
// Default to LiteLLM
_ => (
state.services.ollama_url.clone(),
litellm_url.to_string(),
if model.is_empty() {
state.services.ollama_model.clone()
litellm_model.to_string()
} else {
model.to_string()
},
),
}
}
#[cfg(test)]
mod tests {
// -----------------------------------------------------------------------
// BSON document conversion tests (server feature required)
// -----------------------------------------------------------------------
#[cfg(feature = "server")]
mod server_tests {
use super::super::{doc_to_chat_message, doc_to_chat_session, resolve_provider_url};
use crate::models::{ChatNamespace, ChatRole};
use mongodb::bson::{doc, oid::ObjectId, Document};
use pretty_assertions::assert_eq;
// -- doc_to_chat_session --
fn sample_session_doc() -> (ObjectId, Document) {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "user-42",
"title": "Test Session",
"namespace": "News",
"provider": "openai",
"model": "gpt-4",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-02T00:00:00Z",
"article_url": "https://example.com/article",
};
(oid, doc)
}
#[test]
fn doc_to_chat_session_extracts_id_as_hex() {
let (oid, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.id, oid.to_hex());
}
#[test]
fn doc_to_chat_session_maps_news_namespace() {
let (_, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.namespace, ChatNamespace::News);
}
#[test]
fn doc_to_chat_session_defaults_to_general_for_unknown() {
let mut doc = sample_session_doc().1;
doc.insert("namespace", "SomethingElse");
let session = doc_to_chat_session(&doc);
assert_eq!(session.namespace, ChatNamespace::General);
}
#[test]
fn doc_to_chat_session_extracts_all_string_fields() {
let (_, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.user_sub, "user-42");
assert_eq!(session.title, "Test Session");
assert_eq!(session.provider, "openai");
assert_eq!(session.model, "gpt-4");
assert_eq!(session.created_at, "2025-01-01T00:00:00Z");
assert_eq!(session.updated_at, "2025-01-02T00:00:00Z");
}
#[test]
fn doc_to_chat_session_handles_missing_article_url() {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "u",
"title": "t",
"provider": "litellm",
"model": "m",
"created_at": "c",
"updated_at": "u",
};
let session = doc_to_chat_session(&doc);
assert_eq!(session.article_url, None);
}
#[test]
fn doc_to_chat_session_filters_empty_article_url() {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "u",
"title": "t",
"namespace": "News",
"provider": "litellm",
"model": "m",
"created_at": "c",
"updated_at": "u",
"article_url": "",
};
let session = doc_to_chat_session(&doc);
assert_eq!(session.article_url, None);
}
// -- doc_to_chat_message --
fn sample_message_doc() -> (ObjectId, Document) {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"session_id": "sess-1",
"role": "Assistant",
"content": "Hello there!",
"timestamp": "2025-01-01T12:00:00Z",
};
(oid, doc)
}
#[test]
fn doc_to_chat_message_extracts_id_as_hex() {
let (oid, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.id, oid.to_hex());
}
#[test]
fn doc_to_chat_message_maps_assistant_role() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::Assistant);
}
#[test]
fn doc_to_chat_message_maps_system_role() {
let mut doc = sample_message_doc().1;
doc.insert("role", "System");
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::System);
}
#[test]
fn doc_to_chat_message_defaults_to_user_for_unknown() {
let mut doc = sample_message_doc().1;
doc.insert("role", "SomethingElse");
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::User);
}
#[test]
fn doc_to_chat_message_extracts_content_and_timestamp() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.content, "Hello there!");
assert_eq!(msg.timestamp, "2025-01-01T12:00:00Z");
assert_eq!(msg.session_id, "sess-1");
}
#[test]
fn doc_to_chat_message_attachments_always_empty() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert!(msg.attachments.is_empty());
}
// -- resolve_provider_url --
const TEST_LITELLM_URL: &str = "http://localhost:4000";
const TEST_LITELLM_MODEL: &str = "qwen3-32b";
#[test]
fn resolve_openai_returns_api_openai() {
let (url, model) =
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "openai", "gpt-4o");
assert_eq!(url, "https://api.openai.com");
assert_eq!(model, "gpt-4o");
}
#[test]
fn resolve_anthropic_returns_api_anthropic() {
let (url, model) = resolve_provider_url(
TEST_LITELLM_URL,
TEST_LITELLM_MODEL,
"anthropic",
"claude-3-opus",
);
assert_eq!(url, "https://api.anthropic.com");
assert_eq!(model, "claude-3-opus");
}
#[test]
fn resolve_huggingface_returns_model_url() {
let (url, model) = resolve_provider_url(
TEST_LITELLM_URL,
TEST_LITELLM_MODEL,
"huggingface",
"meta-llama/Llama-2-7b",
);
assert_eq!(
url,
"https://api-inference.huggingface.co/models/meta-llama/Llama-2-7b"
);
assert_eq!(model, "meta-llama/Llama-2-7b");
}
#[test]
fn resolve_unknown_defaults_to_litellm() {
let (url, model) =
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "litellm", "qwen3-32b");
assert_eq!(url, TEST_LITELLM_URL);
assert_eq!(model, "qwen3-32b");
}
#[test]
fn resolve_empty_model_falls_back_to_server_default() {
let (url, model) =
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "litellm", "");
assert_eq!(url, TEST_LITELLM_URL);
assert_eq!(model, TEST_LITELLM_MODEL);
}
}
}

View File

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

View File

@@ -141,19 +141,23 @@ impl SmtpConfig {
// ServiceUrls
// ---------------------------------------------------------------------------
/// URLs and credentials for external services (Ollama, SearXNG, S3, etc.).
/// URLs and credentials for external services (LiteLLM, 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,
/// LiteLLM proxy base URL.
pub litellm_url: String,
/// Default LiteLLM model to use.
pub litellm_model: String,
/// LiteLLM API key for authenticated requests.
pub litellm_api_key: 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,
/// LangFlow visual workflow builder URL.
pub langflow_url: String,
/// Langfuse observability URL.
pub langfuse_url: String,
/// Vector database URL.
@@ -164,6 +168,8 @@ pub struct ServiceUrls {
pub s3_access_key: String,
/// S3 secret key (wrapped for debug safety).
pub s3_secret_key: SecretString,
/// Compliance scanner URL (external tool opened in a new tab).
pub compliance_scanner_url: String,
}
impl ServiceUrls {
@@ -176,18 +182,21 @@ impl ServiceUrls {
/// 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()),
litellm_url: std::env::var("LITELLM_URL")
.unwrap_or_else(|_| "http://localhost:4000".into()),
litellm_model: std::env::var("LITELLM_MODEL").unwrap_or_else(|_| "qwen3-32b".into()),
litellm_api_key: optional_env("LITELLM_API_KEY"),
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"),
langflow_url: optional_env("LANGFLOW_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")),
compliance_scanner_url: optional_env("COMPLIANCE_SCANNER_URL"),
})
}
}
@@ -228,7 +237,7 @@ impl StripeConfig {
/// Comma-separated list of enabled LLM provider identifiers.
///
/// For example: `LLM_PROVIDERS=ollama,openai,anthropic`
/// For example: `LLM_PROVIDERS=litellm,openai,anthropic`
#[derive(Debug)]
pub struct LlmProvidersConfig {
/// Parsed provider names.
@@ -251,3 +260,160 @@ impl LlmProvidersConfig {
Ok(Self { providers })
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use pretty_assertions::assert_eq;
use serial_test::serial;
// -----------------------------------------------------------------------
// KeycloakConfig endpoint methods (no env vars needed)
// -----------------------------------------------------------------------
fn sample_keycloak() -> KeycloakConfig {
KeycloakConfig {
url: "https://auth.example.com".into(),
realm: "myrealm".into(),
client_id: "dashboard".into(),
redirect_uri: "https://app.example.com/callback".into(),
app_url: "https://app.example.com".into(),
admin_client_id: String::new(),
admin_client_secret: SecretString::from(String::new()),
}
}
#[test]
fn keycloak_auth_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.auth_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/auth"
);
}
#[test]
fn keycloak_token_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.token_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/token"
);
}
#[test]
fn keycloak_userinfo_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.userinfo_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/userinfo"
);
}
#[test]
fn keycloak_logout_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.logout_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/logout"
);
}
// -----------------------------------------------------------------------
// LlmProvidersConfig::from_env()
// -----------------------------------------------------------------------
#[test]
#[serial]
fn llm_providers_empty_string() {
std::env::set_var("LLM_PROVIDERS", "");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert!(cfg.providers.is_empty());
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_single() {
std::env::set_var("LLM_PROVIDERS", "litellm");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["litellm"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_multiple() {
std::env::set_var("LLM_PROVIDERS", "litellm,openai,anthropic");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["litellm", "openai", "anthropic"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_trims_whitespace() {
std::env::set_var("LLM_PROVIDERS", " litellm , openai ");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["litellm", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_filters_empty_entries() {
std::env::set_var("LLM_PROVIDERS", "litellm,,openai,");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["litellm", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
// -----------------------------------------------------------------------
// ServiceUrls::from_env() defaults
// -----------------------------------------------------------------------
#[test]
#[serial]
fn service_urls_default_litellm_url() {
std::env::remove_var("LITELLM_URL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.litellm_url, "http://localhost:4000");
}
#[test]
#[serial]
fn service_urls_default_litellm_model() {
std::env::remove_var("LITELLM_MODEL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.litellm_model, "qwen3-32b");
}
#[test]
#[serial]
fn service_urls_default_searxng_url() {
std::env::remove_var("SEARXNG_URL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.searxng_url, "http://localhost:8888");
}
#[test]
#[serial]
fn service_urls_custom_litellm_url() {
std::env::set_var("LITELLM_URL", "http://litellm-host:4000");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.litellm_url, "http://litellm-host:4000");
std::env::remove_var("LITELLM_URL");
}
#[test]
#[serial]
fn required_env_missing_returns_config_error() {
std::env::remove_var("__TEST_REQUIRED_MISSING__");
let result = required_env("__TEST_REQUIRED_MISSING__");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("__TEST_REQUIRED_MISSING__"));
}
}

View File

@@ -41,3 +41,53 @@ impl IntoResponse for Error {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::response::IntoResponse;
use pretty_assertions::assert_eq;
#[test]
fn state_error_display() {
let err = Error::StateError("bad state".into());
assert_eq!(err.to_string(), "bad state");
}
#[test]
fn database_error_display() {
let err = Error::DatabaseError("connection lost".into());
assert_eq!(err.to_string(), "database error: connection lost");
}
#[test]
fn config_error_display() {
let err = Error::ConfigError("missing var".into());
assert_eq!(err.to_string(), "configuration error: missing var");
}
#[test]
fn state_error_into_response_500() {
let resp = Error::StateError("oops".into()).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn database_error_into_response_503() {
let resp = Error::DatabaseError("down".into()).into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn config_error_into_response_500() {
let resp = Error::ConfigError("bad cfg".into()).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn io_error_into_response_500() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let resp = Error::IoError(io_err).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}

View File

@@ -0,0 +1,108 @@
use dioxus::prelude::*;
#[cfg(feature = "server")]
use serde::Deserialize;
use crate::models::AgentEntry;
/// Raw assistant object returned by the LangGraph `POST /assistants/search`
/// endpoint. Only the fields we display are deserialized; unknown keys are
/// silently ignored thanks to serde defaults.
#[cfg(feature = "server")]
#[derive(Deserialize)]
struct LangGraphAssistant {
assistant_id: String,
#[serde(default)]
name: String,
#[serde(default)]
graph_id: String,
#[serde(default)]
metadata: serde_json::Value,
}
/// Fetch the list of assistants (agents) from a LangGraph instance.
///
/// Calls `POST <langgraph_url>/assistants/search` with an empty body to
/// retrieve every registered assistant. Each result is mapped to the
/// frontend-friendly `AgentEntry` model.
///
/// # Returns
///
/// A vector of `AgentEntry` structs. Returns an empty vector when the
/// LangGraph URL is not configured or the service is unreachable.
///
/// # Errors
///
/// Returns `ServerFnError` on network or deserialization failures that
/// indicate a misconfigured (but present) LangGraph instance.
#[server(endpoint = "list-langgraph-agents")]
pub async fn list_langgraph_agents() -> Result<Vec<AgentEntry>, ServerFnError> {
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = state.services.langgraph_url.clone();
if base_url.is_empty() {
return Ok(Vec::new());
}
let url = format!("{}/assistants/search", 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}")))?;
// LangGraph expects a POST with a JSON body (empty object = no filters).
let resp = match client
.post(&url)
.header("content-type", "application/json")
.body("{}")
.send()
.await
{
Ok(r) if r.status().is_success() => r,
Ok(r) => {
let status = r.status();
let body = r.text().await.unwrap_or_default();
tracing::error!("LangGraph returned {status}: {body}");
return Ok(Vec::new());
}
Err(e) => {
tracing::error!("LangGraph request failed: {e}");
return Ok(Vec::new());
}
};
let assistants: Vec<LangGraphAssistant> = resp
.json()
.await
.map_err(|e| ServerFnError::new(format!("Failed to parse LangGraph response: {e}")))?;
let entries = assistants
.into_iter()
.map(|a| {
// Use the assistant name if present, otherwise fall back to graph_id.
let name = if a.name.is_empty() {
a.graph_id.clone()
} else {
a.name
};
// Extract a description from metadata if available.
let description = a
.metadata
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
AgentEntry {
id: a.assistant_id,
name,
description,
status: "active".to_string(),
}
})
.collect();
Ok(entries)
}

View File

@@ -0,0 +1,403 @@
#[cfg(feature = "server")]
use std::collections::HashMap;
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::LitellmUsageStats;
#[cfg(feature = "server")]
use crate::models::ModelUsage;
/// Status of a LiteLLM proxy instance, including connectivity and available models.
///
/// # Fields
///
/// * `online` - Whether the LiteLLM API responded successfully
/// * `models` - List of model IDs available through the proxy
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LitellmStatus {
pub online: bool,
pub models: Vec<String>,
}
/// Response from LiteLLM's `GET /v1/models` endpoint (OpenAI-compatible).
#[cfg(feature = "server")]
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<ModelObject>,
}
/// A single model entry from the OpenAI-compatible models list.
#[cfg(feature = "server")]
#[derive(Deserialize)]
struct ModelObject {
id: String,
}
/// Check the status of a LiteLLM proxy by querying its models endpoint.
///
/// Calls `GET <litellm_url>/v1/models` to list available models and determine
/// whether the instance is reachable. Sends the API key as a Bearer token
/// if configured.
///
/// # Arguments
///
/// * `litellm_url` - Base URL of the LiteLLM proxy (e.g. "http://localhost:4000")
///
/// # Returns
///
/// A `LitellmStatus` with `online: true` and model IDs 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/litellm-status")]
pub async fn get_litellm_status(litellm_url: String) -> Result<LitellmStatus, ServerFnError> {
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = if litellm_url.is_empty() {
state.services.litellm_url.clone()
} else {
litellm_url
};
let api_key = state.services.litellm_api_key.clone();
let url = format!("{}/v1/models", 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 mut request = client.get(&url);
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = match request.send().await {
Ok(r) if r.status().is_success() => r,
_ => {
return Ok(LitellmStatus {
online: false,
models: Vec::new(),
});
}
};
let body: ModelsResponse = match resp.json().await {
Ok(b) => b,
Err(_) => {
return Ok(LitellmStatus {
online: true,
models: Vec::new(),
});
}
};
let models = body.data.into_iter().map(|m| m.id).collect();
Ok(LitellmStatus {
online: true,
models,
})
}
/// Response from LiteLLM's `GET /global/activity` endpoint.
///
/// Returns aggregate token counts and API request totals for a date range.
/// Available on the free tier (no Enterprise license needed).
#[cfg(feature = "server")]
#[derive(Debug, Deserialize)]
struct ActivityResponse {
/// Total tokens across all models in the date range
#[serde(default)]
sum_total_tokens: u64,
}
/// Per-model entry from `GET /global/activity/model`.
///
/// Each entry contains a model name and its aggregated token total.
#[cfg(feature = "server")]
#[derive(Debug, Deserialize)]
struct ActivityModelEntry {
/// Model identifier (may be empty for unattributed traffic)
#[serde(default)]
model: String,
/// Sum of tokens used by this model in the date range
#[serde(default)]
sum_total_tokens: u64,
}
/// Per-model spend entry from `GET /global/spend/models`.
///
/// Each entry maps a model name to its total spend in USD.
#[cfg(feature = "server")]
#[derive(Debug, Deserialize)]
struct SpendModelEntry {
/// Model identifier
#[serde(default)]
model: String,
/// Total spend in USD
#[serde(default)]
total_spend: f64,
}
/// Merge per-model token counts and spend data into `ModelUsage` entries.
///
/// Joins `activity_models` (tokens) and `spend_models` (spend) by model
/// name using a HashMap for O(n + m) merge. Entries with empty model
/// names are skipped.
///
/// # Arguments
///
/// * `activity_models` - Per-model token data from `/global/activity/model`
/// * `spend_models` - Per-model spend data from `/global/spend/models`
///
/// # Returns
///
/// Merged list sorted by total tokens descending
#[cfg(feature = "server")]
fn merge_model_data(
activity_models: Vec<ActivityModelEntry>,
spend_models: Vec<SpendModelEntry>,
) -> Vec<ModelUsage> {
let mut model_map: HashMap<String, ModelUsage> = HashMap::new();
for entry in activity_models {
if entry.model.is_empty() {
continue;
}
model_map
.entry(entry.model.clone())
.or_insert_with(|| ModelUsage {
model: entry.model,
..Default::default()
})
.total_tokens = entry.sum_total_tokens;
}
for entry in spend_models {
if entry.model.is_empty() {
continue;
}
model_map
.entry(entry.model.clone())
.or_insert_with(|| ModelUsage {
model: entry.model,
..Default::default()
})
.spend = entry.total_spend;
}
let mut result: Vec<ModelUsage> = model_map.into_values().collect();
result.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
result
}
/// Fetch aggregated usage statistics from LiteLLM's free-tier APIs.
///
/// Combines three endpoints to build a complete usage picture:
/// - `GET /global/activity` - total token counts
/// - `GET /global/activity/model` - per-model token breakdown
/// - `GET /global/spend/models` - per-model spend in USD
///
/// # Arguments
///
/// * `start_date` - Start of the reporting period in `YYYY-MM-DD` format
/// * `end_date` - End of the reporting period in `YYYY-MM-DD` format
///
/// # Returns
///
/// Aggregated usage stats; returns default (zeroed) stats on network
/// failure or permission errors
///
/// # Errors
///
/// Returns `ServerFnError` only on HTTP client construction failure
#[post("/api/litellm-usage")]
pub async fn get_litellm_usage(
start_date: String,
end_date: String,
) -> Result<LitellmUsageStats, ServerFnError> {
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = &state.services.litellm_url;
let api_key = &state.services.litellm_api_key;
if base_url.is_empty() {
return Ok(LitellmUsageStats::default());
}
let base = base_url.trim_end_matches('/');
let date_params = format!("start_date={start_date}&end_date={end_date}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
// Helper closure to build an authenticated GET request
let auth_get = |url: String| {
let mut req = client.get(url);
if !api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {api_key}"));
}
req
};
// Fire all three requests concurrently to minimise latency
let (activity_res, model_activity_res, model_spend_res) = tokio::join!(
auth_get(format!("{base}/global/activity?{date_params}")).send(),
auth_get(format!("{base}/global/activity/model?{date_params}")).send(),
auth_get(format!("{base}/global/spend/models?{date_params}")).send(),
);
// Parse total token count from /global/activity
let total_tokens = match activity_res {
Ok(r) if r.status().is_success() => r
.json::<ActivityResponse>()
.await
.map(|a| a.sum_total_tokens)
.unwrap_or(0),
_ => 0,
};
// Parse per-model token breakdown from /global/activity/model
let activity_models: Vec<ActivityModelEntry> = match model_activity_res {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
_ => Vec::new(),
};
// Parse per-model spend from /global/spend/models
let spend_models: Vec<SpendModelEntry> = match model_spend_res {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
_ => Vec::new(),
};
let total_spend: f64 = spend_models.iter().map(|m| m.total_spend).sum();
let model_breakdown = merge_model_data(activity_models, spend_models);
Ok(LitellmUsageStats {
total_spend,
// Free-tier endpoints don't provide prompt/completion split;
// total_tokens comes from /global/activity.
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_tokens,
model_breakdown,
})
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn merge_empty_inputs() {
let result = merge_model_data(Vec::new(), Vec::new());
assert!(result.is_empty());
}
#[test]
fn merge_activity_only() {
let activity = vec![ActivityModelEntry {
model: "gpt-4".into(),
sum_total_tokens: 1500,
}];
let result = merge_model_data(activity, Vec::new());
assert_eq!(result.len(), 1);
assert_eq!(result[0].model, "gpt-4");
assert_eq!(result[0].total_tokens, 1500);
assert_eq!(result[0].spend, 0.0);
}
#[test]
fn merge_spend_only() {
let spend = vec![SpendModelEntry {
model: "gpt-4".into(),
total_spend: 2.5,
}];
let result = merge_model_data(Vec::new(), spend);
assert_eq!(result.len(), 1);
assert_eq!(result[0].model, "gpt-4");
assert_eq!(result[0].spend, 2.5);
assert_eq!(result[0].total_tokens, 0);
}
#[test]
fn merge_joins_by_model_name() {
let activity = vec![
ActivityModelEntry {
model: "gpt-4".into(),
sum_total_tokens: 5000,
},
ActivityModelEntry {
model: "claude-3".into(),
sum_total_tokens: 3000,
},
];
let spend = vec![
SpendModelEntry {
model: "gpt-4".into(),
total_spend: 1.0,
},
SpendModelEntry {
model: "claude-3".into(),
total_spend: 0.5,
},
];
let result = merge_model_data(activity, spend);
assert_eq!(result.len(), 2);
// Sorted by tokens descending: gpt-4 (5000) before claude-3 (3000)
assert_eq!(result[0].model, "gpt-4");
assert_eq!(result[0].total_tokens, 5000);
assert_eq!(result[0].spend, 1.0);
assert_eq!(result[1].model, "claude-3");
assert_eq!(result[1].total_tokens, 3000);
assert_eq!(result[1].spend, 0.5);
}
#[test]
fn merge_skips_empty_model_names() {
let activity = vec![
ActivityModelEntry {
model: "".into(),
sum_total_tokens: 100,
},
ActivityModelEntry {
model: "gpt-4".into(),
sum_total_tokens: 500,
},
];
let spend = vec![SpendModelEntry {
model: "".into(),
total_spend: 0.01,
}];
let result = merge_model_data(activity, spend);
assert_eq!(result.len(), 1);
assert_eq!(result[0].model, "gpt-4");
}
#[test]
fn merge_unmatched_models_appear_in_both_directions() {
let activity = vec![ActivityModelEntry {
model: "tokens-only".into(),
sum_total_tokens: 1000,
}];
let spend = vec![SpendModelEntry {
model: "spend-only".into(),
total_spend: 0.5,
}];
let result = merge_model_data(activity, spend);
assert_eq!(result.len(), 2);
// tokens-only has 1000 tokens, spend-only has 0 tokens
assert_eq!(result[0].model, "tokens-only");
assert_eq!(result[0].total_tokens, 1000);
assert_eq!(result[1].model, "spend-only");
assert_eq!(result[1].spend, 0.5);
}
}

View File

@@ -4,23 +4,23 @@ use dioxus::prelude::*;
mod inner {
use serde::{Deserialize, Serialize};
/// A single message in the OpenAI-compatible chat format used by Ollama.
/// A single message in the OpenAI-compatible chat format used by LiteLLM.
#[derive(Serialize)]
pub(super) struct ChatMessage {
pub role: String,
pub content: String,
}
/// Request body for Ollama's OpenAI-compatible chat completions endpoint.
/// Request body for the OpenAI-compatible chat completions endpoint.
#[derive(Serialize)]
pub(super) struct OllamaChatRequest {
pub(super) struct ChatCompletionRequest {
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.
/// A single choice in the chat completions response.
#[derive(Deserialize)]
pub(super) struct ChatChoice {
pub message: ChatResponseMessage,
@@ -32,9 +32,9 @@ mod inner {
pub content: String,
}
/// Top-level response from Ollama's `/v1/chat/completions` endpoint.
/// Top-level response from the `/v1/chat/completions` endpoint.
#[derive(Deserialize)]
pub(super) struct OllamaChatResponse {
pub(super) struct ChatCompletionResponse {
pub choices: Vec<ChatChoice>,
}
@@ -72,7 +72,25 @@ mod inner {
}
let html = resp.text().await.ok()?;
let document = scraper::Html::parse_document(&html);
parse_article_html(&html)
}
/// Parse article text from raw HTML without any network I/O.
///
/// 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
///
/// * `html` - Raw HTML string to parse
///
/// # Returns
///
/// The extracted text, or `None` if extraction yields < 100 chars.
/// Output is capped at 8000 characters.
pub(crate) fn parse_article_html(html: &str) -> Option<String> {
let document = scraper::Html::parse_document(html);
// Strategy 1: Extract from semantic article containers.
// Most news sites wrap the main content in <article>, <main>,
@@ -134,12 +152,12 @@ mod inner {
}
/// Sum the total character length of all collected text parts.
fn joined_len(parts: &[String]) -> usize {
pub(crate) fn joined_len(parts: &[String]) -> usize {
parts.iter().map(|s| s.len()).sum()
}
}
/// Summarize an article using a local Ollama instance.
/// Summarize an article using a LiteLLM proxy.
///
/// First attempts to fetch the full article text from the provided URL.
/// If that fails (paywall, timeout, etc.), falls back to the search snippet.
@@ -149,8 +167,8 @@ mod inner {
///
/// * `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")
/// * `litellm_url` - Base URL of the LiteLLM proxy (e.g. "http://localhost:4000")
/// * `model` - The model ID to use (e.g. "qwen3-32b")
///
/// # Returns
///
@@ -158,36 +176,38 @@ mod inner {
///
/// # Errors
///
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
/// Returns `ServerFnError` if the LiteLLM request fails or response parsing fails
#[post("/api/summarize")]
pub async fn summarize_article(
snippet: String,
article_url: String,
ollama_url: String,
litellm_url: String,
model: String,
) -> Result<String, ServerFnError> {
use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse};
use inner::{fetch_article_text, ChatCompletionRequest, ChatCompletionResponse, ChatMessage};
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()
let base_url = if litellm_url.is_empty() {
state.services.litellm_url.clone()
} else {
ollama_url
litellm_url
};
let model = if model.is_empty() {
state.services.ollama_model.clone()
state.services.litellm_model.clone()
} else {
model
};
let api_key = state.services.litellm_api_key.clone();
// 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 {
let request_body = ChatCompletionRequest {
model,
stream: false,
messages: vec![ChatMessage {
@@ -205,42 +225,48 @@ pub async fn summarize_article(
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client
let mut request = client
.post(&url)
.header("content-type", "application/json")
.json(&request_body)
.json(&request_body);
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = request
.send()
.await
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
.map_err(|e| ServerFnError::new(format!("LiteLLM 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}"
"LiteLLM returned {status}: {body}"
)));
}
let body: OllamaChatResponse = resp
let body: ChatCompletionResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
.map_err(|e| ServerFnError::new(format!("Failed to parse LiteLLM response: {e}")))?;
body.choices
.first()
.map(|choice| choice.message.content.clone())
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
.ok_or_else(|| ServerFnError::new("Empty response from LiteLLM"))
}
/// A lightweight chat message for the follow-up conversation.
/// Uses simple String role ("system"/"user"/"assistant") for Ollama compatibility.
/// Uses simple String role ("system"/"user"/"assistant") for OpenAI 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.
/// Send a follow-up question about an article using a LiteLLM proxy.
///
/// Accepts the full conversation history (system context + prior turns) and
/// returns the assistant's next response. The system message should contain
@@ -249,8 +275,8 @@ pub struct FollowUpMessage {
/// # Arguments
///
/// * `messages` - The conversation history including system context
/// * `ollama_url` - Base URL of the Ollama instance
/// * `model` - The Ollama model ID to use
/// * `litellm_url` - Base URL of the LiteLLM proxy
/// * `model` - The model ID to use
///
/// # Returns
///
@@ -258,30 +284,32 @@ pub struct FollowUpMessage {
///
/// # Errors
///
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
/// Returns `ServerFnError` if the LiteLLM request fails or response parsing fails
#[post("/api/chat")]
pub async fn chat_followup(
messages: Vec<FollowUpMessage>,
ollama_url: String,
litellm_url: String,
model: String,
) -> Result<String, ServerFnError> {
use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse};
use inner::{ChatCompletionRequest, ChatCompletionResponse, ChatMessage};
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = if ollama_url.is_empty() {
state.services.ollama_url.clone()
let base_url = if litellm_url.is_empty() {
state.services.litellm_url.clone()
} else {
ollama_url
litellm_url
};
let model = if model.is_empty() {
state.services.ollama_model.clone()
state.services.litellm_model.clone()
} else {
model
};
let api_key = state.services.litellm_api_key.clone();
// Convert FollowUpMessage to inner ChatMessage for the request
let chat_messages: Vec<ChatMessage> = messages
.into_iter()
@@ -291,7 +319,7 @@ pub async fn chat_followup(
})
.collect();
let request_body = OllamaChatRequest {
let request_body = ChatCompletionRequest {
model,
stream: false,
messages: chat_messages,
@@ -299,29 +327,182 @@ pub async fn chat_followup(
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client
let mut request = client
.post(&url)
.header("content-type", "application/json")
.json(&request_body)
.json(&request_body);
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = request
.send()
.await
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
.map_err(|e| ServerFnError::new(format!("LiteLLM 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}"
"LiteLLM returned {status}: {body}"
)));
}
let body: OllamaChatResponse = resp
let body: ChatCompletionResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
.map_err(|e| ServerFnError::new(format!("Failed to parse LiteLLM response: {e}")))?;
body.choices
.first()
.map(|choice| choice.message.content.clone())
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
.ok_or_else(|| ServerFnError::new("Empty response from LiteLLM"))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// FollowUpMessage serde tests
// -----------------------------------------------------------------------
#[test]
fn followup_message_serde_round_trip() {
let msg = FollowUpMessage {
role: "assistant".into(),
content: "Here is my answer.".into(),
};
let json = serde_json::to_string(&msg).expect("serialize FollowUpMessage");
let back: FollowUpMessage =
serde_json::from_str(&json).expect("deserialize FollowUpMessage");
assert_eq!(msg, back);
}
#[test]
fn followup_message_deserialize_from_json_literal() {
let json = r#"{"role":"system","content":"You are helpful."}"#;
let msg: FollowUpMessage = serde_json::from_str(json).expect("deserialize literal");
assert_eq!(msg.role, "system");
assert_eq!(msg.content, "You are helpful.");
}
// -----------------------------------------------------------------------
// joined_len and parse_article_html tests (server feature required)
// -----------------------------------------------------------------------
#[cfg(feature = "server")]
mod server_tests {
use super::super::inner::{joined_len, parse_article_html};
use pretty_assertions::assert_eq;
#[test]
fn joined_len_empty_input() {
assert_eq!(joined_len(&[]), 0);
}
#[test]
fn joined_len_sums_correctly() {
let parts = vec!["abc".into(), "de".into(), "fghij".into()];
assert_eq!(joined_len(&parts), 10);
}
// -------------------------------------------------------------------
// parse_article_html tests
// -------------------------------------------------------------------
// Helper: generate a string of given length from a repeated word.
fn lorem(len: usize) -> String {
"Lorem ipsum dolor sit amet consectetur adipiscing elit "
.repeat((len / 55) + 1)
.chars()
.take(len)
.collect()
}
#[test]
fn article_tag_extracts_text() {
let body = lorem(250);
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for article tag");
assert!(result.unwrap().contains("Lorem"));
}
#[test]
fn main_tag_extracts_text() {
let body = lorem(250);
let html = format!("<html><body><main><p>{body}</p></main></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for main tag");
}
#[test]
fn fallback_to_p_tags_when_article_main_yield_little() {
// No <article>/<main>, so falls back to <p> tags
let body = lorem(250);
let html = format!("<html><body><div><p>{body}</p></div></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected fallback to <p> tags");
}
#[test]
fn excludes_nav_footer_aside_content() {
// Content only inside excluded containers -- should be excluded
let body = lorem(250);
let html = format!(
"<html><body>\
<nav><p>{body}</p></nav>\
<footer><p>{body}</p></footer>\
<aside><p>{body}</p></aside>\
</body></html>"
);
let result = parse_article_html(&html);
assert!(result.is_none(), "expected None for excluded-only content");
}
#[test]
fn returns_none_when_text_too_short() {
let html = "<html><body><p>Short.</p></body></html>";
let result = parse_article_html(html);
assert!(result.is_none(), "expected None for short text");
}
#[test]
fn truncates_at_8000_chars() {
let body = lorem(10000);
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
let result = parse_article_html(&html).expect("expected Some");
assert!(
result.len() <= 8000,
"expected <= 8000 chars, got {}",
result.len()
);
}
#[test]
fn skips_fragments_under_30_chars() {
// Only fragments < 30 chars -- should yield None
let html = "<html><body><article>\
<p>Short frag one</p>\
<p>Another tiny bit</p>\
</article></body></html>";
let result = parse_article_html(html);
assert!(result.is_none(), "expected None for tiny fragments");
}
#[test]
fn extracts_from_role_main_attribute() {
let body = lorem(250);
let html = format!(
"<html><body>\
<div role=\"main\"><p>{body}</p></div>\
</body></html>"
);
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for role=main");
}
}
}

View File

@@ -2,8 +2,9 @@
// the #[server] macro generates client stubs for the web target)
pub mod auth_check;
pub mod chat;
pub mod langgraph;
pub mod litellm;
pub mod llm;
pub mod ollama;
pub mod searxng;
// Server-only modules (Axum handlers, state, configs, DB, etc.)
@@ -12,8 +13,6 @@ mod auth;
#[cfg(feature = "server")]
mod auth_middleware;
#[cfg(feature = "server")]
mod chat_stream;
#[cfg(feature = "server")]
pub mod config;
#[cfg(feature = "server")]
pub mod database;
@@ -33,8 +32,6 @@ pub use auth::*;
#[cfg(feature = "server")]
pub use auth_middleware::*;
#[cfg(feature = "server")]
pub use chat_stream::*;
#[cfg(feature = "server")]
pub use error::*;
#[cfg(feature = "server")]
pub use server::*;

View File

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

View File

@@ -1,6 +1,6 @@
//! Unified LLM provider dispatch.
//!
//! Routes chat completion requests to Ollama, OpenAI, Anthropic, or
//! Routes chat completion requests to LiteLLM, OpenAI, Anthropic, or
//! HuggingFace based on the session's provider setting. All providers
//! except Anthropic use the OpenAI-compatible chat completions format.
@@ -20,11 +20,11 @@ pub struct ProviderMessage {
///
/// # Arguments
///
/// * `state` - Server state (for default Ollama URL/model)
/// * `provider` - Provider name (`"ollama"`, `"openai"`, `"anthropic"`, `"huggingface"`)
/// * `state` - Server state (for default LiteLLM URL/model)
/// * `provider` - Provider name (`"litellm"`, `"openai"`, `"anthropic"`, `"huggingface"`)
/// * `model` - Model ID
/// * `messages` - Conversation history
/// * `api_key` - API key (required for non-Ollama providers)
/// * `api_key` - API key (required for non-LiteLLM providers; LiteLLM uses server config)
/// * `stream` - Whether to request streaming
///
/// # Returns
@@ -123,11 +123,11 @@ pub async fn send_chat_request(
.send()
.await
}
// Default: Ollama (OpenAI-compatible endpoint)
// Default: LiteLLM proxy (OpenAI-compatible endpoint)
_ => {
let base_url = &state.services.ollama_url;
let base_url = &state.services.litellm_url;
let resolved_model = if model.is_empty() {
&state.services.ollama_model
&state.services.litellm_model
} else {
model
};
@@ -137,12 +137,42 @@ pub async fn send_chat_request(
"messages": messages,
"stream": stream,
});
client
let litellm_key = &state.services.litellm_api_key;
let mut request = client
.post(&url)
.header("content-type", "application/json")
.json(&body)
.send()
.await
.json(&body);
if !litellm_key.is_empty() {
request = request.header("Authorization", format!("Bearer {litellm_key}"));
}
request.send().await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn provider_message_serde_round_trip() {
let msg = ProviderMessage {
role: "assistant".into(),
content: "Hello, world!".into(),
};
let json = serde_json::to_string(&msg).expect("serialize ProviderMessage");
let back: ProviderMessage =
serde_json::from_str(&json).expect("deserialize ProviderMessage");
assert_eq!(msg.role, back.role);
assert_eq!(msg.content, back.content);
}
#[test]
fn provider_message_deserialize_from_json_literal() {
let json = r#"{"role":"user","content":"What is Rust?"}"#;
let msg: ProviderMessage = serde_json::from_str(json).expect("deserialize from literal");
assert_eq!(msg.role, "user");
assert_eq!(msg.content, "What is Rust?");
}
}

View File

@@ -5,13 +5,13 @@ use dioxus::prelude::*;
// 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 {
pub(crate) mod inner {
use serde::Deserialize;
use std::collections::HashSet;
/// Individual result from the SearXNG search API.
#[derive(Debug, Deserialize)]
pub(super) struct SearxngResult {
pub(crate) struct SearxngResult {
pub title: String,
pub url: String,
pub content: Option<String>,
@@ -25,7 +25,7 @@ mod inner {
/// Top-level response from the SearXNG search API.
#[derive(Debug, Deserialize)]
pub(super) struct SearxngResponse {
pub(crate) struct SearxngResponse {
pub results: Vec<SearxngResult>,
}
@@ -40,7 +40,7 @@ mod inner {
/// # Returns
///
/// The domain host or a fallback "Web" string
pub(super) fn extract_source(url_str: &str) -> String {
pub(crate) fn extract_source(url_str: &str) -> String {
url::Url::parse(url_str)
.ok()
.and_then(|u| u.host_str().map(String::from))
@@ -64,7 +64,7 @@ mod inner {
/// # Returns
///
/// Filtered, deduplicated, and ranked results
pub(super) fn rank_and_deduplicate(
pub(crate) fn rank_and_deduplicate(
mut results: Vec<SearxngResult>,
max_results: usize,
) -> Vec<SearxngResult> {
@@ -285,3 +285,166 @@ pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
Ok(topics)
}
#[cfg(all(test, feature = "server"))]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::inner::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// extract_source()
// -----------------------------------------------------------------------
#[test]
fn extract_source_strips_www() {
assert_eq!(
extract_source("https://www.example.com/page"),
"example.com"
);
}
#[test]
fn extract_source_returns_domain() {
assert_eq!(
extract_source("https://techcrunch.com/article"),
"techcrunch.com"
);
}
#[test]
fn extract_source_invalid_url_returns_web() {
assert_eq!(extract_source("not-a-url"), "Web");
}
#[test]
fn extract_source_no_scheme_returns_web() {
// url::Url::parse requires a scheme; bare domain fails
assert_eq!(extract_source("example.com/path"), "Web");
}
// -----------------------------------------------------------------------
// rank_and_deduplicate()
// -----------------------------------------------------------------------
fn make_result(url: &str, content: &str, score: f64) -> SearxngResult {
SearxngResult {
title: "Title".into(),
url: url.into(),
content: if content.is_empty() {
None
} else {
Some(content.into())
},
published_date: None,
thumbnail: None,
score,
}
}
#[test]
fn rank_filters_empty_content() {
let results = vec![
make_result("https://a.com", "", 10.0),
make_result(
"https://b.com",
"This is meaningful content that passes the length filter",
5.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].url, "https://b.com");
}
#[test]
fn rank_filters_short_content() {
let results = vec![
make_result("https://a.com", "short", 10.0),
make_result(
"https://b.com",
"This content is long enough to pass the 20-char filter threshold",
5.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 1);
}
#[test]
fn rank_deduplicates_by_domain_keeps_highest() {
let results = vec![
make_result(
"https://example.com/page1",
"First result with enough content here for the filter",
3.0,
),
make_result(
"https://example.com/page2",
"Second result with enough content here for the filter",
8.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 1);
// Should keep the highest-scored one (page2 with score 8.0)
assert_eq!(ranked[0].url, "https://example.com/page2");
}
#[test]
fn rank_sorts_by_score_descending() {
let results = vec![
make_result(
"https://a.com/p",
"Content A that is long enough to pass the filter check",
1.0,
),
make_result(
"https://b.com/p",
"Content B that is long enough to pass the filter check",
5.0,
),
make_result(
"https://c.com/p",
"Content C that is long enough to pass the filter check",
3.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 3);
assert!(ranked[0].score >= ranked[1].score);
assert!(ranked[1].score >= ranked[2].score);
}
#[test]
fn rank_truncates_to_max_results() {
let results: Vec<_> = (0..20)
.map(|i| {
make_result(
&format!("https://site{i}.com/page"),
&format!("Content for site {i} that is long enough to pass the filter"),
i as f64,
)
})
.collect();
let ranked = rank_and_deduplicate(results, 5);
assert_eq!(ranked.len(), 5);
}
#[test]
fn rank_empty_input_returns_empty() {
let ranked = rank_and_deduplicate(vec![], 10);
assert!(ranked.is_empty());
}
#[test]
fn rank_all_filtered_returns_empty() {
let results = vec![
make_result("https://a.com", "", 10.0),
make_result("https://b.com", "too short", 5.0),
];
let ranked = rank_and_deduplicate(results, 10);
assert!(ranked.is_empty());
}
}

View File

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

View File

@@ -45,7 +45,7 @@ pub struct ServerStateInner {
pub keycloak: &'static KeycloakConfig,
/// Outbound email settings.
pub smtp: &'static SmtpConfig,
/// URLs for Ollama, SearXNG, LangChain, S3, etc.
/// URLs for LiteLLM, SearXNG, LangChain, S3, etc.
pub services: &'static ServiceUrls,
/// Stripe billing keys.
pub stripe: &'static StripeConfig,

View File

@@ -44,3 +44,91 @@ pub struct User {
/// Avatar / profile picture URL.
pub avatar_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn user_state_inner_default_has_empty_strings() {
let inner = UserStateInner::default();
assert_eq!(inner.sub, "");
assert_eq!(inner.access_token, "");
assert_eq!(inner.refresh_token, "");
assert_eq!(inner.user.email, "");
assert_eq!(inner.user.name, "");
assert_eq!(inner.user.avatar_url, "");
}
#[test]
fn user_default_has_empty_strings() {
let user = User::default();
assert_eq!(user.email, "");
assert_eq!(user.name, "");
assert_eq!(user.avatar_url, "");
}
#[test]
fn user_state_inner_serde_round_trip() {
let inner = UserStateInner {
sub: "user-123".into(),
access_token: "tok-abc".into(),
refresh_token: "ref-xyz".into(),
user: User {
email: "a@b.com".into(),
name: "Alice".into(),
avatar_url: "https://img.example.com/a.png".into(),
},
};
let json = serde_json::to_string(&inner).expect("serialize UserStateInner");
let back: UserStateInner = serde_json::from_str(&json).expect("deserialize UserStateInner");
assert_eq!(inner.sub, back.sub);
assert_eq!(inner.access_token, back.access_token);
assert_eq!(inner.refresh_token, back.refresh_token);
assert_eq!(inner.user.email, back.user.email);
assert_eq!(inner.user.name, back.user.name);
assert_eq!(inner.user.avatar_url, back.user.avatar_url);
}
#[test]
fn user_state_from_inner_and_deref() {
let inner = UserStateInner {
sub: "sub-1".into(),
access_token: "at".into(),
refresh_token: "rt".into(),
user: User {
email: "e@e.com".into(),
name: "Eve".into(),
avatar_url: "".into(),
},
};
let state = UserState::from(inner);
// Deref should give access to inner fields
assert_eq!(state.sub, "sub-1");
assert_eq!(state.user.name, "Eve");
}
#[test]
fn user_serde_round_trip() {
let user = User {
email: "bob@test.com".into(),
name: "Bob".into(),
avatar_url: "https://avatars.io/bob".into(),
};
let json = serde_json::to_string(&user).expect("serialize User");
let back: User = serde_json::from_str(&json).expect("deserialize User");
assert_eq!(user.email, back.email);
assert_eq!(user.name, back.name);
assert_eq!(user.avatar_url, back.avatar_url);
}
#[test]
fn user_state_clone_is_cheap() {
let inner = UserStateInner::default();
let state = UserState::from(inner);
let cloned = state.clone();
// Both point to the same Arc allocation
assert_eq!(state.sub, cloned.sub);
}
}

View File

@@ -1,11 +1,12 @@
mod app;
mod components;
pub mod i18n;
pub mod infrastructure;
mod models;
mod pages;
pub use app::*;
pub use components::*;
pub use i18n::*;
pub use models::*;
pub use pages::*;

View File

@@ -60,8 +60,8 @@ pub struct Attachment {
/// * `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")
/// * `provider` - LLM provider used (e.g. "litellm", "openai")
/// * `model` - Model ID used (e.g. "qwen3-32b")
/// * `created_at` - ISO 8601 creation timestamp
/// * `updated_at` - ISO 8601 last-activity timestamp
/// * `article_url` - Source article URL (for News namespace sessions)
@@ -105,3 +105,163 @@ pub struct ChatMessage {
pub attachments: Vec<Attachment>,
pub timestamp: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn chat_namespace_default_is_general() {
assert_eq!(ChatNamespace::default(), ChatNamespace::General);
}
#[test]
fn chat_role_serde_round_trip() {
for role in [ChatRole::User, ChatRole::Assistant, ChatRole::System] {
let json =
serde_json::to_string(&role).unwrap_or_else(|_| panic!("serialize {:?}", role));
let back: ChatRole =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
assert_eq!(role, back);
}
}
#[test]
fn chat_namespace_serde_round_trip() {
for ns in [ChatNamespace::General, ChatNamespace::News] {
let json = serde_json::to_string(&ns).unwrap_or_else(|_| panic!("serialize {:?}", ns));
let back: ChatNamespace =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", ns));
assert_eq!(ns, back);
}
}
#[test]
fn attachment_kind_serde_round_trip() {
for kind in [
AttachmentKind::Image,
AttachmentKind::Document,
AttachmentKind::Code,
] {
let json =
serde_json::to_string(&kind).unwrap_or_else(|_| panic!("serialize {:?}", kind));
let back: AttachmentKind =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", kind));
assert_eq!(kind, back);
}
}
#[test]
fn attachment_serde_round_trip() {
let att = Attachment {
name: "photo.png".into(),
kind: AttachmentKind::Image,
size_bytes: 2048,
};
let json = serde_json::to_string(&att).expect("serialize Attachment");
let back: Attachment = serde_json::from_str(&json).expect("deserialize Attachment");
assert_eq!(att, back);
}
#[test]
fn chat_session_serde_round_trip() {
let session = ChatSession {
id: "abc123".into(),
user_sub: "user-1".into(),
title: "Test Chat".into(),
namespace: ChatNamespace::General,
provider: "litellm".into(),
model: "qwen3-32b".into(),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T01:00:00Z".into(),
article_url: None,
};
let json = serde_json::to_string(&session).expect("serialize ChatSession");
let back: ChatSession = serde_json::from_str(&json).expect("deserialize ChatSession");
assert_eq!(session, back);
}
#[test]
fn chat_session_id_alias_deserialization() {
// MongoDB returns `_id` instead of `id`
let json = r#"{
"_id": "mongo-id",
"user_sub": "u1",
"title": "t",
"provider": "litellm",
"model": "m",
"created_at": "2025-01-01",
"updated_at": "2025-01-01"
}"#;
let session: ChatSession = serde_json::from_str(json).expect("deserialize with _id");
assert_eq!(session.id, "mongo-id");
}
#[test]
fn chat_session_empty_id_skips_serialization() {
let session = ChatSession {
id: String::new(),
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "litellm".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),
article_url: None,
};
let json = serde_json::to_string(&session).expect("serialize");
// `id` field should be absent when empty due to skip_serializing_if
assert!(!json.contains("\"id\""));
}
#[test]
fn chat_session_none_article_url_skips_serialization() {
let session = ChatSession {
id: "s1".into(),
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "litellm".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),
article_url: None,
};
let json = serde_json::to_string(&session).expect("serialize");
assert!(!json.contains("article_url"));
}
#[test]
fn chat_message_serde_round_trip() {
let msg = ChatMessage {
id: "msg-1".into(),
session_id: "s1".into(),
role: ChatRole::User,
content: "Hello AI".into(),
attachments: vec![Attachment {
name: "doc.pdf".into(),
kind: AttachmentKind::Document,
size_bytes: 4096,
}],
timestamp: "2025-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&msg).expect("serialize ChatMessage");
let back: ChatMessage = serde_json::from_str(&json).expect("deserialize ChatMessage");
assert_eq!(msg, back);
}
#[test]
fn chat_message_id_alias_deserialization() {
let json = r#"{
"_id": "mongo-msg-id",
"session_id": "s1",
"role": "User",
"content": "hi",
"timestamp": "2025-01-01"
}"#;
let msg: ChatMessage = serde_json::from_str(json).expect("deserialize with _id");
assert_eq!(msg.id, "mongo-msg-id");
}
}

View File

@@ -45,3 +45,63 @@ pub struct AnalyticsMetric {
pub value: String,
pub change_pct: f64,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn agent_entry_serde_round_trip() {
let agent = AgentEntry {
id: "a1".into(),
name: "RAG Agent".into(),
description: "Retrieval-augmented generation".into(),
status: "running".into(),
};
let json = serde_json::to_string(&agent).expect("serialize AgentEntry");
let back: AgentEntry = serde_json::from_str(&json).expect("deserialize AgentEntry");
assert_eq!(agent, back);
}
#[test]
fn flow_entry_serde_round_trip() {
let flow = FlowEntry {
id: "f1".into(),
name: "Data Pipeline".into(),
node_count: 5,
last_run: Some("2025-06-01T12:00:00Z".into()),
};
let json = serde_json::to_string(&flow).expect("serialize FlowEntry");
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize FlowEntry");
assert_eq!(flow, back);
}
#[test]
fn flow_entry_with_none_last_run() {
let flow = FlowEntry {
id: "f2".into(),
name: "New Flow".into(),
node_count: 0,
last_run: None,
};
let json = serde_json::to_string(&flow).expect("serialize");
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize");
assert_eq!(flow, back);
assert_eq!(back.last_run, None);
}
#[test]
fn analytics_metric_negative_change_pct() {
let metric = AnalyticsMetric {
label: "Latency".into(),
value: "120ms".into(),
change_pct: -15.5,
};
let json = serde_json::to_string(&metric).expect("serialize AnalyticsMetric");
let back: AnalyticsMetric =
serde_json::from_str(&json).expect("deserialize AnalyticsMetric");
assert_eq!(metric, back);
assert!(back.change_pct < 0.0);
}
}

View File

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

View File

@@ -1,17 +1,15 @@
mod chat;
mod developer;
mod knowledge;
mod news;
mod organization;
mod provider;
mod tool;
mod services;
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 services::*;
pub use user::*;

View File

@@ -23,3 +23,61 @@ pub struct NewsCard {
pub thumbnail_url: Option<String>,
pub published_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn news_card_serde_round_trip() {
let card = NewsCard {
title: "AI Breakthrough".into(),
source: "techcrunch.com".into(),
summary: "New model released".into(),
content: "Full article content here".into(),
category: "AI".into(),
url: "https://example.com/article".into(),
thumbnail_url: Some("https://example.com/thumb.jpg".into()),
published_at: "2025-06-01".into(),
};
let json = serde_json::to_string(&card).expect("serialize NewsCard");
let back: NewsCard = serde_json::from_str(&json).expect("deserialize NewsCard");
assert_eq!(card, back);
}
#[test]
fn news_card_thumbnail_none() {
let card = NewsCard {
title: "No Thumb".into(),
source: "bbc.com".into(),
summary: "Summary".into(),
content: "Content".into(),
category: "Tech".into(),
url: "https://bbc.com/article".into(),
thumbnail_url: None,
published_at: "2025-06-01".into(),
};
let json = serde_json::to_string(&card).expect("serialize");
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
assert_eq!(card, back);
}
#[test]
fn news_card_thumbnail_some() {
let card = NewsCard {
title: "With Thumb".into(),
source: "cnn.com".into(),
summary: "Summary".into(),
content: "Content".into(),
category: "News".into(),
url: "https://cnn.com/article".into(),
thumbnail_url: Some("https://cnn.com/img.jpg".into()),
published_at: "2025-06-01".into(),
};
let json = serde_json::to_string(&card).expect("serialize");
assert!(json.contains("img.jpg"));
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
assert_eq!(card.thumbnail_url, back.thumbnail_url);
}
}

View File

@@ -83,6 +83,42 @@ pub struct BillingUsage {
pub billing_cycle_end: String,
}
/// Aggregated token usage statistics from LiteLLM's spend tracking API.
///
/// # Fields
///
/// * `total_spend` - Total cost in USD across all models
/// * `total_prompt_tokens` - Sum of prompt (input) tokens
/// * `total_completion_tokens` - Sum of completion (output) tokens
/// * `total_tokens` - Sum of all tokens (prompt + completion)
/// * `model_breakdown` - Per-model usage breakdown
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LitellmUsageStats {
pub total_spend: f64,
pub total_prompt_tokens: u64,
pub total_completion_tokens: u64,
pub total_tokens: u64,
pub model_breakdown: Vec<ModelUsage>,
}
/// Token and spend usage for a single LLM model.
///
/// # Fields
///
/// * `model` - Model identifier (e.g. "gpt-4", "claude-3-opus")
/// * `spend` - Cost in USD for this model
/// * `prompt_tokens` - Prompt (input) tokens consumed
/// * `completion_tokens` - Completion (output) tokens generated
/// * `total_tokens` - Total tokens (prompt + completion)
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelUsage {
pub model: String,
pub spend: f64,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
}
/// Organisation-level settings stored in MongoDB.
///
/// These complement Keycloak's Organizations feature with
@@ -116,3 +152,200 @@ pub struct OrgBillingRecord {
/// Number of tokens consumed during this cycle.
pub tokens_used: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn member_role_label_admin() {
assert_eq!(MemberRole::Admin.label(), "Admin");
}
#[test]
fn member_role_label_member() {
assert_eq!(MemberRole::Member.label(), "Member");
}
#[test]
fn member_role_label_viewer() {
assert_eq!(MemberRole::Viewer.label(), "Viewer");
}
#[test]
fn member_role_all_returns_three_in_order() {
let all = MemberRole::all();
assert_eq!(all.len(), 3);
assert_eq!(all[0], MemberRole::Admin);
assert_eq!(all[1], MemberRole::Member);
assert_eq!(all[2], MemberRole::Viewer);
}
#[test]
fn member_role_serde_round_trip() {
for role in MemberRole::all() {
let json =
serde_json::to_string(role).unwrap_or_else(|_| panic!("serialize {:?}", role));
let back: MemberRole =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
assert_eq!(*role, back);
}
}
#[test]
fn org_member_serde_round_trip() {
let member = OrgMember {
id: "m1".into(),
name: "Alice".into(),
email: "alice@example.com".into(),
role: MemberRole::Admin,
joined_at: "2025-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&member).expect("serialize OrgMember");
let back: OrgMember = serde_json::from_str(&json).expect("deserialize OrgMember");
assert_eq!(member, back);
}
#[test]
fn pricing_plan_with_max_seats() {
let plan = PricingPlan {
id: "team".into(),
name: "Team".into(),
price_eur: 49,
features: vec!["SSO".into(), "Priority".into()],
highlighted: true,
max_seats: Some(25),
};
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
assert_eq!(plan, back);
}
#[test]
fn pricing_plan_without_max_seats() {
let plan = PricingPlan {
id: "enterprise".into(),
name: "Enterprise".into(),
price_eur: 199,
features: vec!["Unlimited".into()],
highlighted: false,
max_seats: None,
};
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
assert_eq!(plan, back);
assert!(json.contains("null") || !json.contains("max_seats"));
}
#[test]
fn billing_usage_serde_round_trip() {
let usage = BillingUsage {
seats_used: 5,
seats_total: 10,
tokens_used: 1_000_000,
tokens_limit: 5_000_000,
billing_cycle_end: "2025-12-31".into(),
};
let json = serde_json::to_string(&usage).expect("serialize BillingUsage");
let back: BillingUsage = serde_json::from_str(&json).expect("deserialize BillingUsage");
assert_eq!(usage, back);
}
#[test]
fn org_settings_default() {
let settings = OrgSettings::default();
assert_eq!(settings.org_id, "");
assert_eq!(settings.plan_id, "");
assert!(settings.enabled_features.is_empty());
assert_eq!(settings.stripe_customer_id, "");
}
#[test]
fn org_billing_record_default() {
let record = OrgBillingRecord::default();
assert_eq!(record.org_id, "");
assert_eq!(record.cycle_start, "");
assert_eq!(record.cycle_end, "");
assert_eq!(record.seats_used, 0);
assert_eq!(record.tokens_used, 0);
}
#[test]
fn litellm_usage_stats_default() {
let stats = LitellmUsageStats::default();
assert_eq!(stats.total_spend, 0.0);
assert_eq!(stats.total_prompt_tokens, 0);
assert_eq!(stats.total_completion_tokens, 0);
assert_eq!(stats.total_tokens, 0);
assert!(stats.model_breakdown.is_empty());
}
#[test]
fn litellm_usage_stats_serde_round_trip() {
let stats = LitellmUsageStats {
total_spend: 12.34,
total_prompt_tokens: 50_000,
total_completion_tokens: 25_000,
total_tokens: 75_000,
model_breakdown: vec![
ModelUsage {
model: "gpt-4".into(),
spend: 10.0,
prompt_tokens: 40_000,
completion_tokens: 20_000,
total_tokens: 60_000,
},
ModelUsage {
model: "claude-3-opus".into(),
spend: 2.34,
prompt_tokens: 10_000,
completion_tokens: 5_000,
total_tokens: 15_000,
},
],
};
let json = serde_json::to_string(&stats).expect("serialize LitellmUsageStats");
let back: LitellmUsageStats =
serde_json::from_str(&json).expect("deserialize LitellmUsageStats");
assert_eq!(stats, back);
}
#[test]
fn model_usage_default() {
let usage = ModelUsage::default();
assert_eq!(usage.model, "");
assert_eq!(usage.spend, 0.0);
assert_eq!(usage.prompt_tokens, 0);
assert_eq!(usage.completion_tokens, 0);
assert_eq!(usage.total_tokens, 0);
}
#[test]
fn model_usage_serde_round_trip() {
let usage = ModelUsage {
model: "gpt-4-turbo".into(),
spend: 5.67,
prompt_tokens: 30_000,
completion_tokens: 15_000,
total_tokens: 45_000,
};
let json = serde_json::to_string(&usage).expect("serialize ModelUsage");
let back: ModelUsage = serde_json::from_str(&json).expect("deserialize ModelUsage");
assert_eq!(usage, back);
}
#[test]
fn litellm_usage_stats_empty_breakdown_round_trip() {
let stats = LitellmUsageStats {
total_spend: 0.0,
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_tokens: 0,
model_breakdown: Vec::new(),
};
let json = serde_json::to_string(&stats).expect("serialize empty stats");
let back: LitellmUsageStats = serde_json::from_str(&json).expect("deserialize empty stats");
assert_eq!(stats, back);
}
}

View File

@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
/// Supported LLM provider backends.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LlmProvider {
/// Self-hosted models via Ollama
Ollama,
/// LiteLLM proxy for unified model access
LiteLlm,
/// Hugging Face Inference API
HuggingFace,
/// OpenAI-compatible endpoints
@@ -17,7 +17,7 @@ impl LlmProvider {
/// Returns the display name for a provider.
pub fn label(&self) -> &'static str {
match self {
Self::Ollama => "Ollama",
Self::LiteLlm => "LiteLLM",
Self::HuggingFace => "Hugging Face",
Self::OpenAi => "OpenAI",
Self::Anthropic => "Anthropic",
@@ -29,7 +29,7 @@ impl LlmProvider {
///
/// # Fields
///
/// * `id` - Unique model identifier (e.g. "llama3.1:8b")
/// * `id` - Unique model identifier (e.g. "qwen3-32b")
/// * `name` - Human-readable display name
/// * `provider` - Which provider hosts this model
/// * `context_window` - Maximum context length in tokens
@@ -72,3 +72,84 @@ pub struct ProviderConfig {
pub selected_embedding: String,
pub api_key_set: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn llm_provider_label_litellm() {
assert_eq!(LlmProvider::LiteLlm.label(), "LiteLLM");
}
#[test]
fn llm_provider_label_hugging_face() {
assert_eq!(LlmProvider::HuggingFace.label(), "Hugging Face");
}
#[test]
fn llm_provider_label_openai() {
assert_eq!(LlmProvider::OpenAi.label(), "OpenAI");
}
#[test]
fn llm_provider_label_anthropic() {
assert_eq!(LlmProvider::Anthropic.label(), "Anthropic");
}
#[test]
fn llm_provider_serde_round_trip() {
for variant in [
LlmProvider::LiteLlm,
LlmProvider::HuggingFace,
LlmProvider::OpenAi,
LlmProvider::Anthropic,
] {
let json = serde_json::to_string(&variant)
.unwrap_or_else(|_| panic!("serialize {:?}", variant));
let back: LlmProvider =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", variant));
assert_eq!(variant, back);
}
}
#[test]
fn model_entry_serde_round_trip() {
let entry = ModelEntry {
id: "qwen3-32b".into(),
name: "Qwen3 32B".into(),
provider: LlmProvider::LiteLlm,
context_window: 32,
};
let json = serde_json::to_string(&entry).expect("serialize ModelEntry");
let back: ModelEntry = serde_json::from_str(&json).expect("deserialize ModelEntry");
assert_eq!(entry, back);
}
#[test]
fn embedding_entry_serde_round_trip() {
let entry = EmbeddingEntry {
id: "nomic-embed".into(),
name: "Nomic Embed".into(),
provider: LlmProvider::HuggingFace,
dimensions: 768,
};
let json = serde_json::to_string(&entry).expect("serialize EmbeddingEntry");
let back: EmbeddingEntry = serde_json::from_str(&json).expect("deserialize EmbeddingEntry");
assert_eq!(entry, back);
}
#[test]
fn provider_config_serde_round_trip() {
let cfg = ProviderConfig {
provider: LlmProvider::Anthropic,
selected_model: "claude-3".into(),
selected_embedding: "embed-v1".into(),
api_key_set: true,
};
let json = serde_json::to_string(&cfg).expect("serialize ProviderConfig");
let back: ProviderConfig = serde_json::from_str(&json).expect("deserialize ProviderConfig");
assert_eq!(cfg, back);
}
}

43
src/models/services.rs Normal file
View File

@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
/// Frontend-facing URLs for developer tool services.
///
/// Provided as a context signal in `AppShell` so that developer pages
/// can read the configured URLs without threading props through layouts.
/// An empty string indicates the service is not configured.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ServiceUrlsContext {
/// LangGraph agent builder URL (empty if not configured)
pub langgraph_url: String,
/// LangFlow visual workflow builder URL (empty if not configured)
pub langflow_url: String,
/// Langfuse observability URL (empty if not configured)
pub langfuse_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn default_urls_are_empty() {
let ctx = ServiceUrlsContext::default();
assert_eq!(ctx.langgraph_url, "");
assert_eq!(ctx.langflow_url, "");
assert_eq!(ctx.langfuse_url, "");
}
#[test]
fn serde_round_trip() {
let ctx = ServiceUrlsContext {
langgraph_url: "http://localhost:8123".into(),
langflow_url: "http://localhost:7860".into(),
langfuse_url: "http://localhost:3000".into(),
};
let json = serde_json::to_string(&ctx).expect("serialize ServiceUrlsContext");
let back: ServiceUrlsContext =
serde_json::from_str(&json).expect("deserialize ServiceUrlsContext");
assert_eq!(ctx, back);
}
}

View File

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

View File

@@ -22,17 +22,27 @@ pub struct AuthInfo {
pub name: String,
/// Avatar URL (from Keycloak picture claim)
pub avatar_url: String,
/// LibreChat instance URL for the sidebar chat link
pub librechat_url: String,
/// LangGraph agent builder URL (empty if not configured)
pub langgraph_url: String,
/// LangFlow visual workflow builder URL (empty if not configured)
pub langflow_url: String,
/// Langfuse observability URL (empty if not configured)
pub langfuse_url: String,
/// Compliance scanner URL (empty if not configured)
pub compliance_scanner_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.
/// to, and stores API keys for non-LiteLLM providers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UserProviderConfig {
/// Default provider name (e.g. "ollama", "openai")
/// Default provider name (e.g. "litellm", "openai")
pub default_provider: String,
/// Default model ID (e.g. "llama3.1:8b", "gpt-4o")
/// Default model ID (e.g. "qwen3-32b", "gpt-4o")
pub default_model: String,
/// OpenAI API key (empty if not configured)
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -43,8 +53,8 @@ pub struct UserProviderConfig {
/// 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,
/// Custom LiteLLM URL override (empty = use server default)
pub litellm_url_override: String,
}
/// Per-user preferences stored in MongoDB.
@@ -58,13 +68,99 @@ pub struct UserPreferences {
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,
/// Per-user LiteLLM URL override (empty = use server default)
pub litellm_url_override: String,
/// Per-user LiteLLM model override (empty = use server default)
pub litellm_model_override: String,
/// Recently searched queries for quick access
pub recent_searches: Vec<String>,
/// LLM provider configuration
#[serde(default)]
pub provider_config: UserProviderConfig,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn user_data_default() {
let ud = UserData::default();
assert_eq!(ud.name, "");
}
#[test]
fn auth_info_default_not_authenticated() {
let info = AuthInfo::default();
assert!(!info.authenticated);
assert_eq!(info.sub, "");
assert_eq!(info.email, "");
assert_eq!(info.name, "");
assert_eq!(info.avatar_url, "");
assert_eq!(info.librechat_url, "");
assert_eq!(info.langgraph_url, "");
assert_eq!(info.langflow_url, "");
assert_eq!(info.langfuse_url, "");
assert_eq!(info.compliance_scanner_url, "");
}
#[test]
fn auth_info_serde_round_trip() {
let info = AuthInfo {
authenticated: true,
sub: "sub-123".into(),
email: "test@example.com".into(),
name: "Test User".into(),
avatar_url: "https://example.com/avatar.png".into(),
librechat_url: "https://chat.example.com".into(),
langgraph_url: "http://localhost:8123".into(),
langflow_url: "http://localhost:7860".into(),
langfuse_url: "http://localhost:3000".into(),
compliance_scanner_url: "http://localhost:9090".into(),
};
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");
assert_eq!(info, back);
}
#[test]
fn user_preferences_default() {
let prefs = UserPreferences::default();
assert_eq!(prefs.sub, "");
assert_eq!(prefs.org_id, "");
assert!(prefs.custom_topics.is_empty());
assert!(prefs.recent_searches.is_empty());
}
#[test]
fn user_provider_config_optional_keys_skip_none() {
let cfg = UserProviderConfig {
default_provider: "litellm".into(),
default_model: "qwen3-32b".into(),
openai_api_key: None,
anthropic_api_key: None,
huggingface_api_key: None,
litellm_url_override: String::new(),
};
let json = serde_json::to_string(&cfg).expect("serialize UserProviderConfig");
assert!(!json.contains("openai_api_key"));
assert!(!json.contains("anthropic_api_key"));
assert!(!json.contains("huggingface_api_key"));
}
#[test]
fn user_provider_config_serde_round_trip_with_keys() {
let cfg = UserProviderConfig {
default_provider: "openai".into(),
default_model: "gpt-4o".into(),
openai_api_key: Some("sk-test".into()),
anthropic_api_key: Some("ak-test".into()),
huggingface_api_key: None,
litellm_url_override: "http://custom:4000".into(),
};
let json = serde_json::to_string(&cfg).expect("serialize");
let back: UserProviderConfig = serde_json::from_str(&json).expect("deserialize");
assert_eq!(cfg, back);
}
}

View File

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

View File

@@ -2,6 +2,7 @@ use dioxus::prelude::*;
use dioxus_sdk::storage::use_persistent;
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
use crate::i18n::{t, Locale};
use crate::infrastructure::chat::{create_chat_session, save_chat_message};
use crate::infrastructure::llm::FollowUpMessage;
use crate::models::NewsCard;
@@ -24,17 +25,20 @@ const DEFAULT_TOPICS: &[&str] = &[
///
/// 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
/// - `certifai_litellm_url`: LiteLLM proxy URL for summarization
/// - `certifai_litellm_model`: LiteLLM model ID for summarization
#[component]
pub fn DashboardPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// 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
// Default to empty so the server functions use LITELLM_URL / LITELLM_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);
let mut litellm_url = use_persistent("certifai_litellm_url".to_string(), String::new);
let mut litellm_model = use_persistent("certifai_litellm_model".to_string(), String::new);
// Reactive signals for UI state
let mut active_topic = use_signal(|| "AI".to_string());
@@ -133,8 +137,8 @@ pub fn DashboardPage() -> Element {
rsx! {
section { class: "dashboard-page",
PageHeader {
title: "Dashboard".to_string(),
subtitle: "AI news and updates".to_string(),
title: t(l, "dashboard.title"),
subtitle: t(l, "dashboard.subtitle"),
}
// Topic tabs row
@@ -188,7 +192,7 @@ pub fn DashboardPage() -> Element {
input {
class: "topic-input",
r#type: "text",
placeholder: "Topic name...",
placeholder: "{t(l, \"dashboard.topic_placeholder\")}",
value: "{new_topic_text}",
oninput: move |e| new_topic_text.set(e.value()),
onkeypress: move |e| {
@@ -214,7 +218,7 @@ pub fn DashboardPage() -> Element {
show_add_input.set(false);
new_topic_text.set(String::new());
},
"Cancel"
"{t(l, \"common.cancel\")}"
}
}
} else {
@@ -231,38 +235,38 @@ pub fn DashboardPage() -> Element {
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());
settings_url.set(litellm_url.read().clone());
settings_model.set(litellm_model.read().clone());
}
show_settings.set(!currently_shown);
},
"Settings"
"{t(l, \"common.settings\")}"
}
}
// Settings panel (collapsible)
if *show_settings.read() {
div { class: "settings-panel",
h4 { class: "settings-panel-title", "Ollama Settings" }
h4 { class: "settings-panel-title", "{t(l, \"dashboard.litellm_settings\")}" }
p { class: "settings-hint",
"Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env"
"{t(l, \"dashboard.settings_hint\")}"
}
div { class: "settings-field",
label { "Ollama URL" }
label { "{t(l, \"dashboard.litellm_url\")}" }
input {
class: "settings-input",
r#type: "text",
placeholder: "Uses OLLAMA_URL from .env",
placeholder: "{t(l, \"dashboard.litellm_url_placeholder\")}",
value: "{settings_url}",
oninput: move |e| settings_url.set(e.value()),
}
}
div { class: "settings-field",
label { "Model" }
label { "{t(l, \"dashboard.model\")}" }
input {
class: "settings-input",
r#type: "text",
placeholder: "Uses OLLAMA_MODEL from .env",
placeholder: "{t(l, \"dashboard.model_placeholder\")}",
value: "{settings_model}",
oninput: move |e| settings_model.set(e.value()),
}
@@ -270,18 +274,18 @@ pub fn DashboardPage() -> Element {
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();
*litellm_url.write() = settings_url.read().trim().to_string();
*litellm_model.write() = settings_model.read().trim().to_string();
show_settings.set(false);
},
"Save"
"{t(l, \"common.save\")}"
}
}
}
// Loading / error state
if is_loading {
div { class: "dashboard-loading", "Searching..." }
div { class: "dashboard-loading", "{t(l, \"dashboard.searching\")}" }
}
if let Some(ref err) = search_error {
div { class: "settings-hint", "{err}" }
@@ -316,14 +320,14 @@ pub fn DashboardPage() -> Element {
news_session_id.set(None);
let oll_url = ollama_url.read().clone();
let mdl = ollama_model.read().clone();
let ll_url = litellm_url.read().clone();
let mdl = litellm_model.read().clone();
spawn(async move {
is_summarizing.set(true);
match crate::infrastructure::llm::summarize_article(
snippet.clone(),
article_url,
oll_url,
ll_url,
mdl,
)
.await
@@ -369,8 +373,8 @@ pub fn DashboardPage() -> Element {
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 ll_url = litellm_url.read().clone();
let mdl = litellm_model.read().clone();
let ctx = article_context.read().clone();
// Capture article info for News session creation
let card_title = selected_card
@@ -390,7 +394,7 @@ pub fn DashboardPage() -> Element {
content: question.clone(),
});
// Build full message history for Ollama
// Build full message history for LiteLLM
let system_msg = format!(
"You are a helpful assistant. The user is reading \
a news article. Use the following context to answer \
@@ -418,7 +422,7 @@ pub fn DashboardPage() -> Element {
match create_chat_session(
card_title,
"News".to_string(),
"ollama".to_string(),
"litellm".to_string(),
mdl.clone(),
card_url,
)
@@ -454,7 +458,7 @@ pub fn DashboardPage() -> Element {
}
match crate::infrastructure::llm::chat_followup(
msgs, oll_url, mdl,
msgs, ll_url, mdl,
)
.await
{
@@ -491,7 +495,7 @@ pub fn DashboardPage() -> Element {
// Right: sidebar (when no card selected)
if !has_selection {
DashboardSidebar {
ollama_url: ollama_url.read().clone(),
litellm_url: litellm_url.read().clone(),
trending: trending_topics.clone(),
recent_searches: recent_searches.read().clone(),
on_topic_click: move |topic: String| {

View File

@@ -1,23 +1,239 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBook, BsBoxArrowUpRight, BsCodeSquare, BsCpu, BsGithub, BsLightningCharge,
};
use dioxus_free_icons::Icon;
/// Agents page placeholder for the LangGraph agent builder.
use crate::i18n::{t, Locale};
use crate::models::ServiceUrlsContext;
/// Agents informational landing page for LangGraph.
///
/// Shows a "Coming Soon" card with a disabled launch button.
/// Will eventually integrate with the LangGraph framework.
/// Since LangGraph is API-only (no web UI), this page displays a hero section
/// explaining its role, a connection status indicator, a card grid linking
/// to documentation, and a live table of registered agents fetched from the
/// LangGraph assistants API.
#[component]
pub fn AgentsPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
let l = *locale.read();
let url = svc.read().langgraph_url.clone();
// Derive whether a LangGraph URL is configured
let connected = !url.is_empty();
// Build the API reference URL from the configured base, falling back to "#"
let api_ref_href = if connected {
format!("{}/docs", url)
} else {
"#".to_string()
};
// Fetch agents from LangGraph when connected
let agents_resource = use_resource(move || async move {
match crate::infrastructure::langgraph::list_langgraph_agents().await {
Ok(agents) => agents,
Err(e) => {
tracing::error!("Failed to fetch agents: {e}");
Vec::new()
}
}
});
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."
div { class: "agents-page",
// -- Hero section --
div { class: "agents-hero",
div { class: "agents-hero-row",
div { class: "agents-hero-icon",
Icon { icon: BsCpu, width: 24, height: 24 }
}
h2 { class: "agents-hero-title",
{t(l, "developer.agents_title")}
}
}
p { class: "agents-hero-desc",
{t(l, "developer.agents_desc")}
}
// -- Connection status --
if connected {
div { class: "agents-status",
span {
class: "agents-status-dot agents-status-dot--on",
}
span { {t(l, "developer.agents_status_connected")} }
code { class: "agents-status-url", {url.clone()} }
}
} else {
div { class: "agents-status",
span {
class: "agents-status-dot agents-status-dot--off",
}
span { {t(l, "developer.agents_status_not_connected")} }
span { class: "agents-status-hint",
{t(l, "developer.agents_config_hint")}
}
}
}
}
// -- Running Agents table --
div { class: "agents-table-section",
h3 { class: "agents-section-title",
{t(l, "developer.agents_running_title")}
}
match agents_resource.read().as_ref() {
None => {
rsx! {
p { class: "agents-table-loading",
{t(l, "common.loading")}
}
}
}
Some(agents) if agents.is_empty() => {
rsx! {
p { class: "agents-table-empty",
{t(l, "developer.agents_none")}
}
}
}
Some(agents) => {
rsx! {
div { class: "agents-table-wrap",
table { class: "agents-table",
thead {
tr {
th { {t(l, "developer.agents_col_name")} }
th { {t(l, "developer.agents_col_id")} }
th { {t(l, "developer.agents_col_description")} }
th { {t(l, "developer.agents_col_status")} }
}
}
tbody {
for agent in agents.iter() {
tr { key: "{agent.id}",
td { class: "agents-cell-name",
{agent.name.clone()}
}
td {
code { class: "agents-cell-id",
{agent.id.clone()}
}
}
td { class: "agents-cell-desc",
if agent.description.is_empty() {
span { class: "agents-cell-none", "--" }
} else {
{agent.description.clone()}
}
}
td {
span { class: "agents-badge agents-badge--active",
{agent.status.clone()}
}
}
}
}
}
}
}
}
}
}
}
// -- Quick Start card grid --
h3 { class: "agents-section-title",
{t(l, "developer.agents_quick_start")}
}
div { class: "agents-grid",
// Documentation
a {
class: "agents-card",
href: "https://langchain-ai.github.io/langgraph/",
target: "_blank",
rel: "noopener noreferrer",
div { class: "agents-card-icon",
Icon { icon: BsBook, width: 18, height: 18 }
}
div { class: "agents-card-title",
{t(l, "developer.agents_docs")}
}
div { class: "agents-card-desc",
{t(l, "developer.agents_docs_desc")}
}
}
// Getting Started
a {
class: "agents-card",
href: "https://langchain-ai.github.io/langgraph/tutorials/introduction/",
target: "_blank",
rel: "noopener noreferrer",
div { class: "agents-card-icon",
Icon { icon: BsLightningCharge, width: 18, height: 18 }
}
div { class: "agents-card-title",
{t(l, "developer.agents_getting_started")}
}
div { class: "agents-card-desc",
{t(l, "developer.agents_getting_started_desc")}
}
}
// GitHub
a {
class: "agents-card",
href: "https://github.com/langchain-ai/langgraph",
target: "_blank",
rel: "noopener noreferrer",
div { class: "agents-card-icon",
Icon { icon: BsGithub, width: 18, height: 18 }
}
div { class: "agents-card-title",
{t(l, "developer.agents_github")}
}
div { class: "agents-card-desc",
{t(l, "developer.agents_github_desc")}
}
}
// Examples
a {
class: "agents-card",
href: "https://github.com/langchain-ai/langgraph/tree/main/examples",
target: "_blank",
rel: "noopener noreferrer",
div { class: "agents-card-icon",
Icon { icon: BsCodeSquare, width: 18, height: 18 }
}
div { class: "agents-card-title",
{t(l, "developer.agents_examples")}
}
div { class: "agents-card-desc",
{t(l, "developer.agents_examples_desc")}
}
}
// API Reference (disabled when URL is empty)
a {
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
href: "{api_ref_href}",
target: "_blank",
rel: "noopener noreferrer",
div { class: "agents-card-icon",
Icon { icon: BsBoxArrowUpRight, width: 18, height: 18 }
}
div { class: "agents-card-title",
{t(l, "developer.agents_api_ref")}
}
div { class: "agents-card-desc",
{t(l, "developer.agents_api_ref_desc")}
}
}
button { class: "btn-primary", disabled: true, "Launch Agent Builder" }
span { class: "placeholder-badge", "Coming Soon" }
}
}
}

View File

@@ -1,63 +1,171 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBarChartLine, BsBoxArrowUpRight, BsGraphUp, BsSpeedometer,
};
use dioxus_free_icons::Icon;
use crate::models::AnalyticsMetric;
use crate::i18n::{t, Locale};
use crate::models::{AnalyticsMetric, ServiceUrlsContext};
/// Analytics page placeholder for LangFuse integration.
/// Analytics & Observability page for Langfuse.
///
/// Shows a "Coming Soon" card with a disabled launch button,
/// plus a mock stats bar showing sample metrics.
/// Langfuse is configured with Keycloak SSO (shared realm with CERTifAI).
/// When users open Langfuse, the existing Keycloak session auto-authenticates
/// them transparently. This page shows a metrics bar, connection status,
/// and a prominent button to open Langfuse in a new tab.
#[component]
pub fn AnalyticsPage() -> Element {
let metrics = mock_metrics();
let locale = use_context::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
let l = *locale.read();
let url = svc.read().langfuse_url.clone();
let connected = !url.is_empty();
let metrics = mock_metrics(l);
rsx! {
section { class: "placeholder-page",
div { class: "analytics-page",
// -- Hero section --
div { class: "analytics-hero",
div { class: "analytics-hero-row",
div { class: "analytics-hero-icon",
Icon { icon: BsGraphUp, width: 24, height: 24 }
}
h2 { class: "analytics-hero-title",
{t(l, "developer.analytics_title")}
}
}
p { class: "analytics-hero-desc",
{t(l, "developer.analytics_desc")}
}
// -- Connection status --
if connected {
div { class: "agents-status",
span {
class: "agents-status-dot agents-status-dot--on",
}
span { {t(l, "developer.analytics_status_connected")} }
code { class: "agents-status-url", {url.clone()} }
}
} else {
div { class: "agents-status",
span {
class: "agents-status-dot agents-status-dot--off",
}
span { {t(l, "developer.analytics_status_not_connected")} }
span { class: "agents-status-hint",
{t(l, "developer.analytics_config_hint")}
}
}
}
// -- SSO info --
if connected {
p { class: "analytics-sso-hint",
{t(l, "developer.analytics_sso_hint")}
}
}
}
// -- Metrics bar --
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" },
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."
// -- Open Langfuse button --
if connected {
a {
class: "analytics-launch-btn",
href: "{url}",
target: "_blank",
rel: "noopener noreferrer",
Icon { icon: BsBoxArrowUpRight, width: 16, height: 16 }
span { {t(l, "developer.launch_analytics")} }
}
}
// -- Quick actions --
h3 { class: "agents-section-title",
{t(l, "developer.analytics_quick_actions")}
}
div { class: "agents-grid",
// Traces
a {
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
href: if connected { format!("{url}/project") } else { "#".to_string() },
target: "_blank",
rel: "noopener noreferrer",
div { class: "agents-card-icon",
Icon { icon: BsBarChartLine, width: 18, height: 18 }
}
div { class: "agents-card-title",
{t(l, "developer.analytics_traces")}
}
div { class: "agents-card-desc",
{t(l, "developer.analytics_traces_desc")}
}
}
// Dashboard
a {
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
href: if connected { format!("{url}/project") } else { "#".to_string() },
target: "_blank",
rel: "noopener noreferrer",
div { class: "agents-card-icon",
Icon { icon: BsSpeedometer, width: 18, height: 18 }
}
div { class: "agents-card-title",
{t(l, "developer.analytics_dashboard")}
}
div { class: "agents-card-desc",
{t(l, "developer.analytics_dashboard_desc")}
}
}
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> {
///
/// # Arguments
///
/// * `locale` - The current locale for translating metric labels
fn mock_metrics(locale: Locale) -> Vec<AnalyticsMetric> {
vec![
AnalyticsMetric {
label: "Total Requests".into(),
label: t(locale, "developer.total_requests"),
value: "12,847".into(),
change_pct: 14.2,
},
AnalyticsMetric {
label: "Avg Latency".into(),
label: t(locale, "developer.avg_latency"),
value: "245ms".into(),
change_pct: -8.5,
},
AnalyticsMetric {
label: "Tokens Used".into(),
label: t(locale, "developer.tokens_used"),
value: "2.4M".into(),
change_pct: 22.1,
},
AnalyticsMetric {
label: "Error Rate".into(),
label: t(locale, "developer.error_rate"),
value: "0.3%".into(),
change_pct: -12.0,
},

View File

@@ -1,24 +1,27 @@
use dioxus::prelude::*;
/// Flow page placeholder for the LangFlow visual workflow builder.
use crate::components::ToolEmbed;
use crate::i18n::{t, Locale};
use crate::models::ServiceUrlsContext;
/// Flow page embedding the LangFlow visual workflow builder.
///
/// Shows a "Coming Soon" card with a disabled launch button.
/// Will eventually integrate with LangFlow for visual flow design.
/// When `langflow_url` is configured, embeds the service in an iframe
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
#[component]
pub fn FlowPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
let l = *locale.read();
let url = svc.read().langflow_url.clone();
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" }
}
ToolEmbed {
url,
title: t(l, "developer.flow_title"),
description: t(l, "developer.flow_desc"),
icon: "F",
launch_label: t(l, "developer.launch_flow"),
}
}
}

View File

@@ -10,6 +10,7 @@ use dioxus::prelude::*;
use crate::app::Route;
use crate::components::sub_nav::{SubNav, SubNavItem};
use crate::i18n::{t, Locale};
/// Shell layout for the Developer section.
///
@@ -17,17 +18,20 @@ use crate::components::sub_nav::{SubNav, SubNavItem};
/// the child route outlet. Sits inside the main `AppShell` layout.
#[component]
pub fn DeveloperShell() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let tabs = vec![
SubNavItem {
label: "Agents",
label: t(l, "nav.agents"),
route: Route::AgentsPage {},
},
SubNavItem {
label: "Flow",
label: t(l, "nav.flow"),
route: Route::FlowPage {},
},
SubNavItem {
label: "Analytics",
label: t(l, "nav.analytics"),
route: Route::AnalyticsPage {},
},
];

View File

@@ -2,6 +2,7 @@ use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
use dioxus_free_icons::Icon;
use crate::i18n::{t, Locale};
use crate::Route;
/// Impressum (legal notice) page required by German/EU law.
@@ -10,6 +11,9 @@ use crate::Route;
/// accessible without authentication.
#[component]
pub fn ImpressumPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
div { class: "legal-page",
nav { class: "legal-nav",
@@ -21,53 +25,53 @@ pub fn ImpressumPage() -> Element {
}
}
main { class: "legal-content",
h1 { "Impressum" }
h1 { "{t(l, \"impressum.title\")}" }
h2 { "Information according to 5 TMG" }
h2 { "{t(l, \"impressum.info_tmg\")}" }
p {
"CERTifAI GmbH"
"{t(l, \"impressum.company\")}"
br {}
"Musterstrasse 1"
"{t(l, \"impressum.address_street\")}"
br {}
"10115 Berlin"
"{t(l, \"impressum.address_city\")}"
br {}
"Germany"
"{t(l, \"impressum.address_country\")}"
}
h2 { "Represented by" }
p { "Managing Director: [Name]" }
h2 { "{t(l, \"impressum.represented_by\")}" }
p { "{t(l, \"impressum.managing_director\")}" }
h2 { "Contact" }
h2 { "{t(l, \"impressum.contact\")}" }
p {
"Email: info@certifai.example"
"{t(l, \"impressum.email\")}"
br {}
"Phone: +49 (0) 30 1234567"
"{t(l, \"impressum.phone\")}"
}
h2 { "Commercial Register" }
h2 { "{t(l, \"impressum.commercial_register\")}" }
p {
"Registered at: Amtsgericht Berlin-Charlottenburg"
"{t(l, \"impressum.registered_at\")}"
br {}
"Registration number: HRB XXXXXX"
"{t(l, \"impressum.registration_number\")}"
}
h2 { "VAT ID" }
p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" }
h2 { "{t(l, \"impressum.vat_id\")}" }
p { "{t(l, \"impressum.vat_number\")}" }
h2 { "Responsible for content according to 55 Abs. 2 RStV" }
h2 { "{t(l, \"impressum.responsible_content\")}" }
p {
"[Name]"
br {}
"CERTifAI GmbH"
"{t(l, \"impressum.company\")}"
br {}
"Musterstrasse 1"
"{t(l, \"impressum.address_street\")}"
br {}
"10115 Berlin"
"{t(l, \"impressum.address_city\")}"
}
}
footer { class: "legal-footer",
Link { to: Route::LandingPage {}, "Back to Home" }
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
Link { to: Route::LandingPage {}, "{t(l, \"common.back_to_home\")}" }
Link { to: Route::PrivacyPage {}, "{t(l, \"common.privacy_policy\")}" }
}
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More