11 Commits

Author SHA1 Message Date
Sharang Parnerkar
70095734d0 feat(analytics): integrate Langfuse with Keycloak SSO
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m49s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
CI / Format (pull_request) Successful in 3s
CI / Security Audit (pull_request) Has been cancelled
CI / Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Deploy (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
Add certifai-langfuse OIDC client to Keycloak realm export and configure
the Langfuse Docker service with Keycloak SSO env vars (shared realm,
account linking, local auth disabled). Replace the iframe-based analytics
page with an informational landing since cross-origin SSO breaks in
iframes. Users open Langfuse in a new tab where the active Keycloak
session authenticates them transparently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:54:47 +01:00
Sharang Parnerkar
c165841766 feat(developer): replace placeholder letters with Bootstrap icons on agents page
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m48s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
Use dioxus_free_icons Bootstrap icons: BsCpu for hero, BsBook for docs,
BsLightningCharge for getting started, BsGithub for GitHub,
BsCodeSquare for examples, BsBoxArrowUpRight for API reference.
Also fix conditional serde import for server feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:37:58 +01:00
Sharang Parnerkar
b846699dcf test(e2e): update developer agents page tests for informational landing
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Failing after 1m47s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
Replace obsolete iframe/placeholder tests with new tests covering the
hero section, connection status, quick-start card grid, disabled API
Reference card, and running agents table section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:35:44 +01:00
Sharang Parnerkar
91a4b6ab34 feat(developer): replace agents iframe with informational landing and live agent table
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Failing after 1m45s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
LangGraph is API-only with no web UI, so the ToolEmbed iframe pattern
doesn't work. Replace it with an informational landing page featuring a
hero section, connection status indicator, quick-start card grid linking
to docs/GitHub/examples, and a live table of registered agents fetched
from the LangGraph POST /assistants/search endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:37:33 +01:00
Sharang Parnerkar
40afc88317 feat(developer): add hybrid iframe integration for developer tools
All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 2m47s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
Replace placeholder pages with ToolEmbed component that embeds
LangGraph, LangFlow, and Langfuse in iframes when configured, or
shows "Not Configured" placeholders when URLs are empty. Add
ServiceUrlsContext for passing service URLs through Dioxus context.

Add docker-compose services for local development: LangFlow,
LangGraph (trial), Langfuse with full dependency stack (Postgres,
ClickHouse, Redis, MinIO).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:49:56 +01:00
Sharang Parnerkar
2efec74eca ci: add E2E test job with Playwright and service containers
All checks were successful
CI / Format (push) Successful in 3s
CI / Deploy (push) Has been skipped
CI / Deploy (pull_request) Has been skipped
CI / Clippy (push) Successful in 2m56s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 3s
CI / Clippy (pull_request) Successful in 2m45s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / E2E Tests (pull_request) Has been skipped
Run Playwright browser tests on main and PRs to main after quality
checks pass. Spins up MongoDB and SearXNG as services, starts Keycloak
manually post-checkout (needs realm-export.json from repo), builds and
serves the Dioxus app, then runs the full E2E suite. Deploy now gates
on both unit and E2E tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:27:19 +01:00
Sharang Parnerkar
0e3e1a707f test: add Playwright E2E test suite (30 tests)
Add browser-level end-to-end tests covering public pages, Keycloak
OAuth authentication flow, dashboard interactions, providers config,
developer section, organization pages, and sidebar navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:27:19 +01:00
Sharang Parnerkar
d4274387a9 test: add infrastructure logic unit tests (37 new tests)
Add Phase 2 test coverage for infrastructure modules:
- state.rs: 6 tests (defaults, serde round-trips, UserState deref/clone)
- provider_client.rs: 2 tests (ProviderMessage serde)
- llm.rs: 12 tests (FollowUpMessage serde, joined_len, parse_article_html
  extraction with article/main/role=main tags, fallback, exclusions,
  truncation, fragment skipping)
- chat.rs: 17 tests (doc_to_chat_session, doc_to_chat_message BSON
  conversion, resolve_provider_url for all providers)

Refactor: extract parse_article_html from fetch_article_text for testability
without HTTP. Refactor resolve_provider_url to accept explicit params
instead of full ServerState, avoiding need for MongoDB in tests.

Total test count: 129 (up from 92).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:27:19 +01:00
Sharang Parnerkar
6890ed9b42 test: add comprehensive unit test suite (~85 new tests)
Add unit tests across all model and server infrastructure layers,
increasing test count from 7 to 92. Covers serde round-trips, enum
methods, defaults, config parsing, error mapping, PKCE crypto (with
RFC 7636 test vector), OAuth store, and SearXNG ranking/dedup logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:27:19 +01:00
Sharang Parnerkar
e244711644 feat(librechat): add OIDC HTTP patch and prompt=none for seamless SSO
Switch to host networking so LibreChat can reach Keycloak on localhost.
Patch openidStrategy.js to allow HTTP OIDC issuers for local dev
(openid-client v6 enforces HTTPS by default). Add support for
OPENID_AUTH_EXTRA_PARAMS env var and set prompt=none for automatic
SSO login when a Keycloak session exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:27:06 +01:00
Sharang Parnerkar
1f7748c5b4 fix(librechat): remove prompt=none for local dev compatibility
prompt=none causes silent failure when no Keycloak session exists yet.
Standard OIDC flow still provides seamless SSO when the user has an
active Keycloak session from the dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:27:06 +01:00
50 changed files with 1585 additions and 7244 deletions

View File

@@ -34,11 +34,10 @@ MONGODB_DATABASE=certifai
SEARXNG_URL=http://localhost:8888
# ---------------------------------------------------------------------------
# LiteLLM proxy [OPTIONAL - defaults shown]
# Ollama LLM instance [OPTIONAL - defaults shown]
# ---------------------------------------------------------------------------
LITELLM_URL=http://localhost:4000
LITELLM_MODEL=qwen3-32b
LITELLM_API_KEY=
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b
# ---------------------------------------------------------------------------
# LibreChat (external chat via SSO) [OPTIONAL - default: http://localhost:3080]
@@ -48,7 +47,7 @@ LIBRECHAT_URL=http://localhost:3080
# ---------------------------------------------------------------------------
# LLM Providers (comma-separated list) [OPTIONAL]
# ---------------------------------------------------------------------------
LLM_PROVIDERS=litellm
LLM_PROVIDERS=ollama
# ---------------------------------------------------------------------------
# SMTP (transactional email) [OPTIONAL]
@@ -74,11 +73,6 @@ LANGGRAPH_URL=
LANGFLOW_URL=
LANGFUSE_URL=
# ---------------------------------------------------------------------------
# Compliance scanner (external tool, opens in new tab) [OPTIONAL]
# ---------------------------------------------------------------------------
COMPLIANCE_SCANNER_URL=
# ---------------------------------------------------------------------------
# Vector database [OPTIONAL]
# ---------------------------------------------------------------------------

View File

@@ -121,13 +121,13 @@ jobs:
if: always()
# ---------------------------------------------------------------------------
# Stage 4: E2E tests (only on main, after deploy)
# Stage 2b: E2E tests (only on main / PRs to main, after quality checks)
# ---------------------------------------------------------------------------
e2e:
name: E2E Tests
runs-on: docker
needs: [deploy]
if: github.ref == 'refs/heads/main'
needs: [fmt, clippy, audit]
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
container:
image: rust:1.89-bookworm
# MongoDB and SearXNG can start immediately (no repo files needed).
@@ -259,33 +259,13 @@ jobs:
deploy:
name: Deploy
runs-on: docker
needs: [test]
needs: [test, e2e]
if: github.ref == 'refs/heads/main'
container:
image: docker:27-cli
image: alpine:latest
steps:
- name: Checkout
- name: Trigger Coolify deploy
run: |
apk add --no-cache git curl openssl
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Build and push image
run: |
IMAGE=registry.meghsakha.com/certifai-dashboard
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login registry.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
docker build -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
docker push "$IMAGE:latest"
docker push "$IMAGE:${GITHUB_SHA}"
- name: Trigger orca redeploy
run: |
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/certifai"},"head_commit":{"id":"%s","message":"CI deploy"}}' "${GITHUB_SHA}")
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
echo "Calling orca webhook for sharang/certifai@${GITHUB_SHA}"
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$PAYLOAD")
echo "$RESP"
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"

726
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -61,7 +61,6 @@ 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",
@@ -92,7 +91,7 @@ bytes = { version = "1", optional = true }
[features]
# default = ["web"]
web = ["dioxus/web", "dep:reqwest", "dep:web-sys", "dep:wasm-bindgen", "dep:js-sys"]
web = ["dioxus/web", "dep:reqwest", "dep:web-sys", "dep:wasm-bindgen"]
server = [
"dioxus/server",
"dep:axum",

View File

@@ -46,8 +46,7 @@
"agents": "Agenten",
"flow": "Flow",
"analytics": "Analytics",
"pricing": "Preise",
"compliance": "Compliance"
"pricing": "Preise"
},
"auth": {
"redirecting_login": "Weiterleitung zur Anmeldung...",
@@ -59,15 +58,15 @@
"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",
"ollama_settings": "Ollama-Einstellungen",
"settings_hint": "Leer lassen, um OLLAMA_URL / OLLAMA_MODEL aus .env zu verwenden",
"ollama_url": "Ollama-URL",
"ollama_url_placeholder": "Verwendet OLLAMA_URL aus .env",
"model": "Modell",
"model_placeholder": "Verwendet LITELLM_MODEL aus .env",
"model_placeholder": "Verwendet OLLAMA_MODEL aus .env",
"searching": "Suche laeuft...",
"search_failed": "Suche fehlgeschlagen: {e}",
"litellm_status": "LiteLLM-Status",
"ollama_status": "Ollama-Status",
"trending": "Im Trend",
"recent_searches": "Letzte Suchen"
},
@@ -145,16 +144,6 @@
"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"
},
@@ -225,13 +214,7 @@
"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"
"copyright": "2026 CERTifAI. Alle Rechte vorbehalten."
},
"article": {
"read_original": "Originalartikel lesen",

View File

@@ -46,8 +46,7 @@
"agents": "Agents",
"flow": "Flow",
"analytics": "Analytics",
"pricing": "Pricing",
"compliance": "Compliance"
"pricing": "Pricing"
},
"auth": {
"redirecting_login": "Redirecting to login...",
@@ -59,15 +58,15 @@
"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",
"ollama_settings": "Ollama Settings",
"settings_hint": "Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env",
"ollama_url": "Ollama URL",
"ollama_url_placeholder": "Uses OLLAMA_URL from .env",
"model": "Model",
"model_placeholder": "Uses LITELLM_MODEL from .env",
"model_placeholder": "Uses OLLAMA_MODEL from .env",
"searching": "Searching...",
"search_failed": "Search failed: {e}",
"litellm_status": "LiteLLM Status",
"ollama_status": "Ollama Status",
"trending": "Trending",
"recent_searches": "Recent Searches"
},
@@ -145,16 +144,6 @@
"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"
},
@@ -225,13 +214,7 @@
"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"
"copyright": "2026 CERTifAI. All rights reserved."
},
"article": {
"read_original": "Read original article",

View File

@@ -46,8 +46,7 @@
"agents": "Agentes",
"flow": "Flujo",
"analytics": "Estadisticas",
"pricing": "Precios",
"compliance": "Cumplimiento"
"pricing": "Precios"
},
"auth": {
"redirecting_login": "Redirigiendo al inicio de sesion...",
@@ -59,15 +58,15 @@
"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",
"ollama_settings": "Configuracion de Ollama",
"settings_hint": "Dejar vacio para usar OLLAMA_URL / OLLAMA_MODEL del archivo .env",
"ollama_url": "URL de Ollama",
"ollama_url_placeholder": "Usa OLLAMA_URL del archivo .env",
"model": "Modelo",
"model_placeholder": "Usa LITELLM_MODEL del archivo .env",
"model_placeholder": "Usa OLLAMA_MODEL del archivo .env",
"searching": "Buscando...",
"search_failed": "La busqueda fallo: {e}",
"litellm_status": "Estado de LiteLLM",
"ollama_status": "Estado de Ollama",
"trending": "Tendencias",
"recent_searches": "Busquedas recientes"
},
@@ -145,16 +144,6 @@
"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"
},
@@ -225,13 +214,7 @@
"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"
"copyright": "2026 CERTifAI. Todos los derechos reservados."
},
"article": {
"read_original": "Leer articulo original",

View File

@@ -46,8 +46,7 @@
"agents": "Agents",
"flow": "Flux",
"analytics": "Analytique",
"pricing": "Tarifs",
"compliance": "Conformite"
"pricing": "Tarifs"
},
"auth": {
"redirecting_login": "Redirection vers la connexion...",
@@ -59,15 +58,15 @@
"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",
"ollama_settings": "Parametres Ollama",
"settings_hint": "Laissez vide pour utiliser OLLAMA_URL / OLLAMA_MODEL du fichier .env",
"ollama_url": "URL Ollama",
"ollama_url_placeholder": "Utilise OLLAMA_URL du fichier .env",
"model": "Modele",
"model_placeholder": "Utilise LITELLM_MODEL du fichier .env",
"model_placeholder": "Utilise OLLAMA_MODEL du fichier .env",
"searching": "Recherche en cours...",
"search_failed": "Echec de la recherche : {e}",
"litellm_status": "Statut LiteLLM",
"ollama_status": "Statut Ollama",
"trending": "Tendances",
"recent_searches": "Recherches recentes"
},
@@ -145,16 +144,6 @@
"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"
},
@@ -225,13 +214,7 @@
"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"
"copyright": "2026 CERTifAI. Tous droits reserves."
},
"article": {
"read_original": "Lire l'article original",

View File

@@ -46,8 +46,7 @@
"agents": "Agentes",
"flow": "Fluxo",
"analytics": "Analise",
"pricing": "Precos",
"compliance": "Conformidade"
"pricing": "Precos"
},
"auth": {
"redirecting_login": "A redirecionar para o inicio de sessao...",
@@ -59,15 +58,15 @@
"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",
"ollama_settings": "Definicoes do Ollama",
"settings_hint": "Deixe vazio para usar OLLAMA_URL / OLLAMA_MODEL do .env",
"ollama_url": "URL do Ollama",
"ollama_url_placeholder": "Utiliza OLLAMA_URL do .env",
"model": "Modelo",
"model_placeholder": "Utiliza LITELLM_MODEL do .env",
"model_placeholder": "Utiliza OLLAMA_MODEL do .env",
"searching": "A pesquisar...",
"search_failed": "A pesquisa falhou: {e}",
"litellm_status": "Estado do LiteLLM",
"ollama_status": "Estado do Ollama",
"trending": "Em destaque",
"recent_searches": "Pesquisas recentes"
},
@@ -145,16 +144,6 @@
"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"
},
@@ -225,13 +214,7 @@
"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"
"copyright": "2026 CERTifAI. Todos os direitos reservados."
},
"article": {
"read_original": "Ler artigo original",

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
@@ -9,15 +9,6 @@
"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);
}
@@ -171,6 +162,59 @@
}
}
@layer utilities {
.diff {
@layer daisyui.l1.l2.l3 {
position: relative;
display: grid;
width: 100%;
overflow: hidden;
webkit-user-select: none;
user-select: none;
grid-template-rows: 1fr 1.8rem 1fr;
direction: ltr;
container-type: inline-size;
grid-template-columns: auto 1fr;
&:focus-visible, &:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
}
&:focus-visible {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
}
&:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
}
}
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
&:focus {
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
}
}
&:has(.diff-item-1:focus) {
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
}
}
}
}
.modal {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
@@ -1066,98 +1110,31 @@
}
}
}
.range {
.chat-bubble {
@layer daisyui.l1.l2.l3 {
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%;
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;
}
}
}
@@ -1548,6 +1525,81 @@
padding: calc(0.25rem * 4);
}
}
.textarea {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid #0000;
min-height: calc(0.25rem * 20);
flex-shrink: 1;
appearance: none;
border-radius: var(--radius-field);
background-color: var(--color-base-100);
padding-block: calc(0.25rem * 2);
vertical-align: middle;
width: clamp(3rem, 20rem, 100%);
padding-inline-start: 0.75rem;
padding-inline-end: 0.75rem;
font-size: max(var(--font-size, 0.875rem), 0.875rem);
touch-action: manipulation;
border-color: var(--input-color);
box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
}
--input-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
--input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
}
textarea {
appearance: none;
background-color: transparent;
border: none;
&:focus, &:focus-within {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
}
&:focus, &:focus-within {
--input-color: var(--color-base-content);
box-shadow: 0 1px var(--input-color);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
}
outline: 2px solid var(--input-color);
outline-offset: 2px;
isolation: isolate;
}
@media (pointer: coarse) {
@supports (-webkit-touch-callout: none) {
&:focus, &:focus-within {
--font-size: 1rem;
}
}
}
&:has(> textarea[disabled]), &:is(:disabled, [disabled]) {
cursor: not-allowed;
border-color: var(--color-base-200);
background-color: var(--color-base-200);
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
}
&::placeholder {
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
}
}
box-shadow: none;
}
&:has(> textarea[disabled]) > textarea[disabled] {
cursor: not-allowed;
}
}
}
.stack {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -1642,14 +1694,6 @@
}
}
}
.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) {
@@ -1835,23 +1879,6 @@
}
}
}
.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;
@@ -1870,9 +1897,6 @@
font-weight: 600;
}
}
.flex {
display: flex;
}
.grid {
display: grid;
}
@@ -1885,9 +1909,6 @@
.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,);
}
@@ -1909,12 +1930,13 @@
}
}
}
.flex-wrap {
flex-wrap: wrap;
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
.badge-outline {
@layer daisyui.l1.l2 {
color: var(--badge-color);
--badge-bg: #0000;
background-image: none;
border-color: currentColor;
}
}
.glass {
border: none;
@@ -1933,6 +1955,10 @@
.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)) {
@@ -1960,15 +1986,6 @@
.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 ) {
@@ -2383,7 +2400,7 @@
syntax: "*";
inherits: false;
}
@property --tw-border-style {
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
@@ -2441,42 +2458,6 @@
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 {
@@ -2485,7 +2466,7 @@
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-border-style: solid;
--tw-outline-style: solid;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
@@ -2499,15 +2480,6 @@
--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

@@ -1,907 +0,0 @@
<!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

@@ -1,942 +0,0 @@
<!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

@@ -1,963 +0,0 @@
<!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

@@ -1,963 +0,0 @@
<!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

@@ -1,928 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -55,8 +55,6 @@ services:
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
@@ -72,6 +70,7 @@ services:
OPENID_CALLBACK_URL: /oauth/openid/callback
OPENID_SCOPE: openid profile email
OPENID_BUTTON_LABEL: Login with CERTifAI
OPENID_AUTH_EXTRA_PARAMS: prompt=none
# Disable local auth (SSO only)
ALLOW_EMAIL_LOGIN: "false"
ALLOW_REGISTRATION: "false"

View File

@@ -1,5 +1,5 @@
# CERTifAI LibreChat Configuration
# LiteLLM proxy for unified multi-provider LLM access.
# Ollama backend for self-hosted LLM inference.
version: 1.2.8
cache: true
@@ -19,16 +19,22 @@ interface:
endpoints:
custom:
- name: "LiteLLM"
apiKey: "${LITELLM_API_KEY}"
baseURL: "https://llm-dev.meghsakha.com/v1/"
- name: "Ollama"
apiKey: "ollama"
baseURL: "https://mac-mini-von-benjamin-2:11434/v1/"
models:
default:
- "Qwen3-Coder-30B-A3B-Instruct"
- "llama3.1:8b"
- "qwen3:30b-a3b"
fetch: true
titleConvo: true
titleModel: "current_model"
summarize: false
summaryModel: "current_model"
forcePrompt: false
modelDisplayLabel: "CERTifAI LiteLLM"
modelDisplayLabel: "CERTifAI Ollama"
dropParams:
- stop
- user
- frequency_penalty
- presence_penalty

View File

@@ -49,10 +49,10 @@ 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 Literata (body) and Sora (headings).
/// Google Fonts URL for Inter (body) and Space Grotesk (headings).
const GOOGLE_FONTS: &str = "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&\
family=Inter:wght@400;500;600&\
family=Space+Grotesk:wght@500;600;700&\
display=swap";
/// Root application component. Loads global assets and mounts the router.
@@ -85,14 +85,14 @@ pub fn App() -> Element {
src: "https://seggwat.com/static/widgets/v1/seggwat-feedback.js",
r#defer: true,
"data-project-key": "a04b8cf1-9177-42ce-8a7b-084f38b99799",
"data-button-color": "#8b5cf6",
"data-button-color": "#6d85c6",
"data-button-position": "right-side",
"data-enable-screenshots": "true",
}
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "manifest", href: MANIFEST }
document::Meta { name: "theme-color", content: "#0c0a1d" }
document::Meta { name: "theme-color", content: "#4B3FE0" }
document::Meta { name: "apple-mobile-web-app-capable", content: "yes" }
document::Meta {
name: "apple-mobile-web-app-status-bar-style",

View File

@@ -76,7 +76,6 @@ pub fn AppShell() -> Element {
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),
}

View File

@@ -1,9 +1,9 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::infrastructure::litellm::{get_litellm_status, LitellmStatus};
use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
/// Right sidebar for the dashboard, showing LiteLLM status, trending topics,
/// Right sidebar for the dashboard, showing Ollama status, trending topics,
/// and recent search history.
///
/// Appears when no article card is selected. Disappears when the user opens
@@ -11,13 +11,13 @@ use crate::infrastructure::litellm::{get_litellm_status, LitellmStatus};
///
/// # Props
///
/// * `litellm_url` - LiteLLM proxy URL for status polling
/// * `ollama_url` - Ollama instance URL for status polling
/// * `trending` - Trending topic keywords extracted from recent news headlines
/// * `recent_searches` - Recent search topics stored in localStorage
/// * `on_topic_click` - Fires when a trending or recent topic is clicked
#[component]
pub fn DashboardSidebar(
litellm_url: String,
ollama_url: String,
trending: Vec<String>,
recent_searches: Vec<String>,
on_topic_click: EventHandler<String>,
@@ -25,26 +25,26 @@ pub fn DashboardSidebar(
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Fetch LiteLLM status once on mount.
// Fetch Ollama status once on mount.
// use_resource with no signal dependencies runs exactly once and
// won't re-fire on parent re-renders (unlike use_effect).
let url = litellm_url.clone();
let url = ollama_url.clone();
let status_resource = use_resource(move || {
let u = url.clone();
async move {
get_litellm_status(u).await.unwrap_or(LitellmStatus {
get_ollama_status(u).await.unwrap_or(OllamaStatus {
online: false,
models: Vec::new(),
})
}
});
let current_status: LitellmStatus =
let current_status: OllamaStatus =
status_resource
.read()
.as_ref()
.cloned()
.unwrap_or(LitellmStatus {
.unwrap_or(OllamaStatus {
online: false,
models: Vec::new(),
});
@@ -52,9 +52,9 @@ pub fn DashboardSidebar(
rsx! {
aside { class: "dashboard-sidebar",
// -- LiteLLM Status Section --
// -- Ollama Status Section --
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.litellm_status\")}" }
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.ollama_status\")}" }
div { class: "sidebar-status-row",
span { class: if current_status.online { "sidebar-status-dot sidebar-status-dot--online" } else { "sidebar-status-dot sidebar-status-dot--offline" } }
span { class: "sidebar-status-label",

View File

@@ -112,12 +112,12 @@ pub fn mock_news() -> Vec<NewsCardModel> {
published_at: "2026-02-16".into(),
},
NewsCardModel {
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."
title: "Ollama Adds Multi-GPU Scheduling".into(),
source: "Ollama".into(),
summary: "Run large models across multiple GPUs with automatic sharding.".into(),
content: "Ollama now supports multi-GPU scheduling with automatic \
model sharding. Users can run models across multiple GPUs \
for improved inference performance."
.into(),
category: "Infrastructure".into(),
url: "#".into(),

View File

@@ -1,7 +1,7 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2,
BsGrid, BsHouseDoor, BsMoonFill, BsShieldCheck, BsSunFill,
BsGrid, BsHouseDoor, BsMoonFill, BsSunFill,
};
use dioxus_free_icons::Icon;
@@ -44,14 +44,13 @@ pub fn Sidebar(
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![
let nav_items: Vec<NavItem> = vec![
NavItem {
key: "dashboard",
label: t(locale_val, "nav.dashboard"),
@@ -85,16 +84,6 @@ pub fn Sidebar(
},
];
// 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");

View File

@@ -35,7 +35,6 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
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,
@@ -47,7 +46,6 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
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. "litellm")
/// * `provider` - LLM provider name (e.g. "ollama")
/// * `model` - Model ID (e.g. "llama3.1:8b")
/// * `article_url` - Source article URL (only for `News` namespace, empty if none)
///
@@ -441,8 +441,8 @@ pub async fn chat_complete(
// Resolve provider URL and model
let (base_url, model) = resolve_provider_url(
&state.services.litellm_url,
&state.services.litellm_model,
&state.services.ollama_url,
&state.services.ollama_model,
&session.provider,
&session.model,
);
@@ -485,22 +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 LiteLLM defaults.
/// Resolve the base URL for a provider, falling back to Ollama defaults.
///
/// # Arguments
///
/// * `litellm_url` - Default LiteLLM base URL from config
/// * `litellm_model` - Default LiteLLM model from config
/// * `ollama_url` - Default Ollama base URL from config
/// * `ollama_model` - Default Ollama model from config
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface")
/// * `model` - Model ID (may be empty for LiteLLM default)
/// * `model` - Model ID (may be empty for Ollama default)
///
/// # Returns
///
/// A `(base_url, model)` tuple resolved for the given provider.
#[cfg(feature = "server")]
pub(crate) fn resolve_provider_url(
litellm_url: &str,
litellm_model: &str,
ollama_url: &str,
ollama_model: &str,
provider: &str,
model: &str,
) -> (String, String) {
@@ -511,11 +511,11 @@ pub(crate) fn resolve_provider_url(
format!("https://api-inference.huggingface.co/models/{}", model),
model.to_string(),
),
// Default to LiteLLM
// Default to Ollama
_ => (
litellm_url.to_string(),
ollama_url.to_string(),
if model.is_empty() {
litellm_model.to_string()
ollama_model.to_string()
} else {
model.to_string()
},
@@ -595,7 +595,7 @@ mod tests {
"_id": oid,
"user_sub": "u",
"title": "t",
"provider": "litellm",
"provider": "ollama",
"model": "m",
"created_at": "c",
"updated_at": "u",
@@ -612,7 +612,7 @@ mod tests {
"user_sub": "u",
"title": "t",
"namespace": "News",
"provider": "litellm",
"provider": "ollama",
"model": "m",
"created_at": "c",
"updated_at": "u",
@@ -684,13 +684,13 @@ mod tests {
// -- resolve_provider_url --
const TEST_LITELLM_URL: &str = "http://localhost:4000";
const TEST_LITELLM_MODEL: &str = "qwen3-32b";
const TEST_OLLAMA_URL: &str = "http://localhost:11434";
const TEST_OLLAMA_MODEL: &str = "llama3.1:8b";
#[test]
fn resolve_openai_returns_api_openai() {
let (url, model) =
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "openai", "gpt-4o");
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "openai", "gpt-4o");
assert_eq!(url, "https://api.openai.com");
assert_eq!(model, "gpt-4o");
}
@@ -698,8 +698,8 @@ mod tests {
#[test]
fn resolve_anthropic_returns_api_anthropic() {
let (url, model) = resolve_provider_url(
TEST_LITELLM_URL,
TEST_LITELLM_MODEL,
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
"anthropic",
"claude-3-opus",
);
@@ -710,8 +710,8 @@ mod tests {
#[test]
fn resolve_huggingface_returns_model_url() {
let (url, model) = resolve_provider_url(
TEST_LITELLM_URL,
TEST_LITELLM_MODEL,
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
"huggingface",
"meta-llama/Llama-2-7b",
);
@@ -723,19 +723,19 @@ mod tests {
}
#[test]
fn resolve_unknown_defaults_to_litellm() {
fn resolve_unknown_defaults_to_ollama() {
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");
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "mistral:7b");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, "mistral:7b");
}
#[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);
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, TEST_OLLAMA_MODEL);
}
}
}

View File

@@ -141,15 +141,13 @@ impl SmtpConfig {
// ServiceUrls
// ---------------------------------------------------------------------------
/// URLs and credentials for external services (LiteLLM, SearXNG, S3, etc.).
/// URLs and credentials for external services (Ollama, SearXNG, S3, etc.).
#[derive(Debug)]
pub struct ServiceUrls {
/// 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,
/// Ollama LLM instance base URL.
pub ollama_url: String,
/// Default Ollama model to use.
pub ollama_model: String,
/// SearXNG meta-search engine base URL.
pub searxng_url: String,
/// LangChain service URL.
@@ -168,8 +166,6 @@ 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 {
@@ -182,10 +178,9 @@ impl ServiceUrls {
/// Currently infallible but returns `Result` for consistency.
pub fn from_env() -> Result<Self, Error> {
Ok(Self {
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"),
ollama_url: std::env::var("OLLAMA_URL")
.unwrap_or_else(|_| "http://localhost:11434".into()),
ollama_model: std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()),
searxng_url: std::env::var("SEARXNG_URL")
.unwrap_or_else(|_| "http://localhost:8888".into()),
langchain_url: optional_env("LANGCHAIN_URL"),
@@ -196,7 +191,6 @@ impl ServiceUrls {
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"),
})
}
}
@@ -237,7 +231,7 @@ impl StripeConfig {
/// Comma-separated list of enabled LLM provider identifiers.
///
/// For example: `LLM_PROVIDERS=litellm,openai,anthropic`
/// For example: `LLM_PROVIDERS=ollama,openai,anthropic`
#[derive(Debug)]
pub struct LlmProvidersConfig {
/// Parsed provider names.
@@ -337,36 +331,36 @@ mod tests {
#[test]
#[serial]
fn llm_providers_single() {
std::env::set_var("LLM_PROVIDERS", "litellm");
std::env::set_var("LLM_PROVIDERS", "ollama");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["litellm"]);
assert_eq!(cfg.providers, vec!["ollama"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_multiple() {
std::env::set_var("LLM_PROVIDERS", "litellm,openai,anthropic");
std::env::set_var("LLM_PROVIDERS", "ollama,openai,anthropic");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["litellm", "openai", "anthropic"]);
assert_eq!(cfg.providers, vec!["ollama", "openai", "anthropic"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_trims_whitespace() {
std::env::set_var("LLM_PROVIDERS", " litellm , openai ");
std::env::set_var("LLM_PROVIDERS", " ollama , openai ");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["litellm", "openai"]);
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_filters_empty_entries() {
std::env::set_var("LLM_PROVIDERS", "litellm,,openai,");
std::env::set_var("LLM_PROVIDERS", "ollama,,openai,");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["litellm", "openai"]);
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
@@ -376,18 +370,18 @@ mod tests {
#[test]
#[serial]
fn service_urls_default_litellm_url() {
std::env::remove_var("LITELLM_URL");
fn service_urls_default_ollama_url() {
std::env::remove_var("OLLAMA_URL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.litellm_url, "http://localhost:4000");
assert_eq!(svc.ollama_url, "http://localhost:11434");
}
#[test]
#[serial]
fn service_urls_default_litellm_model() {
std::env::remove_var("LITELLM_MODEL");
fn service_urls_default_ollama_model() {
std::env::remove_var("OLLAMA_MODEL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.litellm_model, "qwen3-32b");
assert_eq!(svc.ollama_model, "llama3.1:8b");
}
#[test]
@@ -400,11 +394,11 @@ mod tests {
#[test]
#[serial]
fn service_urls_custom_litellm_url() {
std::env::set_var("LITELLM_URL", "http://litellm-host:4000");
fn service_urls_custom_ollama_url() {
std::env::set_var("OLLAMA_URL", "http://gpu-host:11434");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.litellm_url, "http://litellm-host:4000");
std::env::remove_var("LITELLM_URL");
assert_eq!(svc.ollama_url, "http://gpu-host:11434");
std::env::remove_var("OLLAMA_URL");
}
#[test]

View File

@@ -1,403 +0,0 @@
#[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 LiteLLM.
/// A single message in the OpenAI-compatible chat format used by Ollama.
#[derive(Serialize)]
pub(super) struct ChatMessage {
pub role: String,
pub content: String,
}
/// Request body for the OpenAI-compatible chat completions endpoint.
/// Request body for Ollama's OpenAI-compatible chat completions endpoint.
#[derive(Serialize)]
pub(super) struct ChatCompletionRequest {
pub(super) struct OllamaChatRequest {
pub model: String,
pub messages: Vec<ChatMessage>,
/// Disable streaming so we get a single JSON response.
pub stream: bool,
}
/// A single choice in the chat completions response.
/// A single choice in the Ollama 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 the `/v1/chat/completions` endpoint.
/// Top-level response from Ollama's `/v1/chat/completions` endpoint.
#[derive(Deserialize)]
pub(super) struct ChatCompletionResponse {
pub(super) struct OllamaChatResponse {
pub choices: Vec<ChatChoice>,
}
@@ -157,7 +157,7 @@ mod inner {
}
}
/// Summarize an article using a LiteLLM proxy.
/// Summarize an article using a local Ollama instance.
///
/// First attempts to fetch the full article text from the provided URL.
/// If that fails (paywall, timeout, etc.), falls back to the search snippet.
@@ -167,8 +167,8 @@ mod inner {
///
/// * `snippet` - The search result snippet (fallback content)
/// * `article_url` - The original article URL to fetch full text from
/// * `litellm_url` - Base URL of the LiteLLM proxy (e.g. "http://localhost:4000")
/// * `model` - The model ID to use (e.g. "qwen3-32b")
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
/// * `model` - The Ollama model ID to use (e.g. "llama3.1:8b")
///
/// # Returns
///
@@ -176,38 +176,36 @@ mod inner {
///
/// # Errors
///
/// Returns `ServerFnError` if the LiteLLM request fails or response parsing fails
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
#[post("/api/summarize")]
pub async fn summarize_article(
snippet: String,
article_url: String,
litellm_url: String,
ollama_url: String,
model: String,
) -> Result<String, ServerFnError> {
use inner::{fetch_article_text, ChatCompletionRequest, ChatCompletionResponse, ChatMessage};
use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse};
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
// Use caller-provided values or fall back to ServerState config
let base_url = if litellm_url.is_empty() {
state.services.litellm_url.clone()
let base_url = if ollama_url.is_empty() {
state.services.ollama_url.clone()
} else {
litellm_url
ollama_url
};
let model = if model.is_empty() {
state.services.litellm_model.clone()
state.services.ollama_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 = ChatCompletionRequest {
let request_body = OllamaChatRequest {
model,
stream: false,
messages: vec![ChatMessage {
@@ -225,48 +223,42 @@ pub async fn summarize_article(
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let mut request = client
let resp = client
.post(&url)
.header("content-type", "application/json")
.json(&request_body);
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = request
.json(&request_body)
.send()
.await
.map_err(|e| ServerFnError::new(format!("LiteLLM request failed: {e}")))?;
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!(
"LiteLLM returned {status}: {body}"
"Ollama returned {status}: {body}"
)));
}
let body: ChatCompletionResponse = resp
let body: OllamaChatResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(format!("Failed to parse LiteLLM response: {e}")))?;
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
body.choices
.first()
.map(|choice| choice.message.content.clone())
.ok_or_else(|| ServerFnError::new("Empty response from LiteLLM"))
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
}
/// A lightweight chat message for the follow-up conversation.
/// Uses simple String role ("system"/"user"/"assistant") for OpenAI compatibility.
/// Uses simple String role ("system"/"user"/"assistant") for Ollama compatibility.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FollowUpMessage {
pub role: String,
pub content: String,
}
/// Send a follow-up question about an article using a LiteLLM proxy.
/// Send a follow-up question about an article using a local Ollama instance.
///
/// Accepts the full conversation history (system context + prior turns) and
/// returns the assistant's next response. The system message should contain
@@ -275,8 +267,8 @@ pub struct FollowUpMessage {
/// # Arguments
///
/// * `messages` - The conversation history including system context
/// * `litellm_url` - Base URL of the LiteLLM proxy
/// * `model` - The model ID to use
/// * `ollama_url` - Base URL of the Ollama instance
/// * `model` - The Ollama model ID to use
///
/// # Returns
///
@@ -284,32 +276,30 @@ pub struct FollowUpMessage {
///
/// # Errors
///
/// Returns `ServerFnError` if the LiteLLM request fails or response parsing fails
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
#[post("/api/chat")]
pub async fn chat_followup(
messages: Vec<FollowUpMessage>,
litellm_url: String,
ollama_url: String,
model: String,
) -> Result<String, ServerFnError> {
use inner::{ChatCompletionRequest, ChatCompletionResponse, ChatMessage};
use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse};
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = if litellm_url.is_empty() {
state.services.litellm_url.clone()
let base_url = if ollama_url.is_empty() {
state.services.ollama_url.clone()
} else {
litellm_url
ollama_url
};
let model = if model.is_empty() {
state.services.litellm_model.clone()
state.services.ollama_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()
@@ -319,7 +309,7 @@ pub async fn chat_followup(
})
.collect();
let request_body = ChatCompletionRequest {
let request_body = OllamaChatRequest {
model,
stream: false,
messages: chat_messages,
@@ -327,37 +317,31 @@ pub async fn chat_followup(
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let mut request = client
let resp = client
.post(&url)
.header("content-type", "application/json")
.json(&request_body);
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = request
.json(&request_body)
.send()
.await
.map_err(|e| ServerFnError::new(format!("LiteLLM request failed: {e}")))?;
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!(
"LiteLLM returned {status}: {body}"
"Ollama returned {status}: {body}"
)));
}
let body: ChatCompletionResponse = resp
let body: OllamaChatResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(format!("Failed to parse LiteLLM response: {e}")))?;
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
body.choices
.first()
.map(|choice| choice.message.content.clone())
.ok_or_else(|| ServerFnError::new("Empty response from LiteLLM"))
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
}
#[cfg(test)]

View File

@@ -3,8 +3,8 @@
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.)

View File

@@ -0,0 +1,92 @@
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 LiteLLM, OpenAI, Anthropic, or
//! Routes chat completion requests to Ollama, OpenAI, Anthropic, or
//! HuggingFace based on the session's provider setting. All providers
//! except Anthropic use the OpenAI-compatible chat completions format.
@@ -20,11 +20,11 @@ pub struct ProviderMessage {
///
/// # Arguments
///
/// * `state` - Server state (for default LiteLLM URL/model)
/// * `provider` - Provider name (`"litellm"`, `"openai"`, `"anthropic"`, `"huggingface"`)
/// * `state` - Server state (for default Ollama URL/model)
/// * `provider` - Provider name (`"ollama"`, `"openai"`, `"anthropic"`, `"huggingface"`)
/// * `model` - Model ID
/// * `messages` - Conversation history
/// * `api_key` - API key (required for non-LiteLLM providers; LiteLLM uses server config)
/// * `api_key` - API key (required for non-Ollama providers)
/// * `stream` - Whether to request streaming
///
/// # Returns
@@ -123,11 +123,11 @@ pub async fn send_chat_request(
.send()
.await
}
// Default: LiteLLM proxy (OpenAI-compatible endpoint)
// Default: Ollama (OpenAI-compatible endpoint)
_ => {
let base_url = &state.services.litellm_url;
let base_url = &state.services.ollama_url;
let resolved_model = if model.is_empty() {
&state.services.litellm_model
&state.services.ollama_model
} else {
model
};
@@ -137,15 +137,12 @@ pub async fn send_chat_request(
"messages": messages,
"stream": stream,
});
let litellm_key = &state.services.litellm_api_key;
let mut request = client
client
.post(&url)
.header("content-type", "application/json")
.json(&body);
if !litellm_key.is_empty() {
request = request.header("Authorization", format!("Bearer {litellm_key}"));
}
request.send().await
.json(&body)
.send()
.await
}
}
}

View File

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

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. "litellm", "openai")
/// * `model` - Model ID used (e.g. "qwen3-32b")
/// * `provider` - LLM provider used (e.g. "ollama", "openai")
/// * `model` - Model ID used (e.g. "llama3.1:8b")
/// * `created_at` - ISO 8601 creation timestamp
/// * `updated_at` - ISO 8601 last-activity timestamp
/// * `article_url` - Source article URL (for News namespace sessions)
@@ -171,8 +171,8 @@ mod tests {
user_sub: "user-1".into(),
title: "Test Chat".into(),
namespace: ChatNamespace::General,
provider: "litellm".into(),
model: "qwen3-32b".into(),
provider: "ollama".into(),
model: "llama3.1:8b".into(),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T01:00:00Z".into(),
article_url: None,
@@ -189,7 +189,7 @@ mod tests {
"_id": "mongo-id",
"user_sub": "u1",
"title": "t",
"provider": "litellm",
"provider": "ollama",
"model": "m",
"created_at": "2025-01-01",
"updated_at": "2025-01-01"
@@ -205,7 +205,7 @@ mod tests {
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "litellm".into(),
provider: "ollama".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),
@@ -223,7 +223,7 @@ mod tests {
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "litellm".into(),
provider: "ollama".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),

View File

@@ -83,42 +83,6 @@ 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
@@ -270,82 +234,4 @@ mod tests {
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 {
/// LiteLLM proxy for unified model access
LiteLlm,
/// Self-hosted models via Ollama
Ollama,
/// 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::LiteLlm => "LiteLLM",
Self::Ollama => "Ollama",
Self::HuggingFace => "Hugging Face",
Self::OpenAi => "OpenAI",
Self::Anthropic => "Anthropic",
@@ -29,7 +29,7 @@ impl LlmProvider {
///
/// # Fields
///
/// * `id` - Unique model identifier (e.g. "qwen3-32b")
/// * `id` - Unique model identifier (e.g. "llama3.1:8b")
/// * `name` - Human-readable display name
/// * `provider` - Which provider hosts this model
/// * `context_window` - Maximum context length in tokens
@@ -79,8 +79,8 @@ mod tests {
use pretty_assertions::assert_eq;
#[test]
fn llm_provider_label_litellm() {
assert_eq!(LlmProvider::LiteLlm.label(), "LiteLLM");
fn llm_provider_label_ollama() {
assert_eq!(LlmProvider::Ollama.label(), "Ollama");
}
#[test]
@@ -101,7 +101,7 @@ mod tests {
#[test]
fn llm_provider_serde_round_trip() {
for variant in [
LlmProvider::LiteLlm,
LlmProvider::Ollama,
LlmProvider::HuggingFace,
LlmProvider::OpenAi,
LlmProvider::Anthropic,
@@ -117,10 +117,10 @@ mod tests {
#[test]
fn model_entry_serde_round_trip() {
let entry = ModelEntry {
id: "qwen3-32b".into(),
name: "Qwen3 32B".into(),
provider: LlmProvider::LiteLlm,
context_window: 32,
id: "llama3.1:8b".into(),
name: "Llama 3.1 8B".into(),
provider: LlmProvider::Ollama,
context_window: 8192,
};
let json = serde_json::to_string(&entry).expect("serialize ModelEntry");
let back: ModelEntry = serde_json::from_str(&json).expect("deserialize ModelEntry");

View File

@@ -30,19 +30,17 @@ pub struct AuthInfo {
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-LiteLLM providers.
/// to, and stores API keys for non-Ollama providers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UserProviderConfig {
/// Default provider name (e.g. "litellm", "openai")
/// Default provider name (e.g. "ollama", "openai")
pub default_provider: String,
/// Default model ID (e.g. "qwen3-32b", "gpt-4o")
/// Default model ID (e.g. "llama3.1:8b", "gpt-4o")
pub default_model: String,
/// OpenAI API key (empty if not configured)
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -53,8 +51,8 @@ pub struct UserProviderConfig {
/// HuggingFace API key
#[serde(default, skip_serializing_if = "Option::is_none")]
pub huggingface_api_key: Option<String>,
/// Custom LiteLLM URL override (empty = use server default)
pub litellm_url_override: String,
/// Custom Ollama URL override (empty = use server default)
pub ollama_url_override: String,
}
/// Per-user preferences stored in MongoDB.
@@ -68,10 +66,10 @@ pub struct UserPreferences {
pub org_id: String,
/// User-selected news/search topics
pub custom_topics: Vec<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,
/// Per-user Ollama URL override (empty = use server default)
pub ollama_url_override: String,
/// Per-user Ollama model override (empty = use server default)
pub ollama_model_override: String,
/// Recently searched queries for quick access
pub recent_searches: Vec<String>,
/// LLM provider configuration
@@ -102,7 +100,6 @@ mod tests {
assert_eq!(info.langgraph_url, "");
assert_eq!(info.langflow_url, "");
assert_eq!(info.langfuse_url, "");
assert_eq!(info.compliance_scanner_url, "");
}
#[test]
@@ -117,7 +114,6 @@ mod tests {
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");
@@ -136,12 +132,12 @@ mod tests {
#[test]
fn user_provider_config_optional_keys_skip_none() {
let cfg = UserProviderConfig {
default_provider: "litellm".into(),
default_model: "qwen3-32b".into(),
default_provider: "ollama".into(),
default_model: "llama3.1:8b".into(),
openai_api_key: None,
anthropic_api_key: None,
huggingface_api_key: None,
litellm_url_override: String::new(),
ollama_url_override: String::new(),
};
let json = serde_json::to_string(&cfg).expect("serialize UserProviderConfig");
assert!(!json.contains("openai_api_key"));
@@ -157,7 +153,7 @@ mod tests {
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(),
ollama_url_override: "http://custom:11434".into(),
};
let json = serde_json::to_string(&cfg).expect("serialize");
let back: UserProviderConfig = serde_json::from_str(&json).expect("deserialize");

View File

@@ -25,8 +25,8 @@ const DEFAULT_TOPICS: &[&str] = &[
///
/// State is persisted across sessions using localStorage:
/// - `certifai_topics`: custom user-defined search topics
/// - `certifai_litellm_url`: LiteLLM proxy URL for summarization
/// - `certifai_litellm_model`: LiteLLM model ID for summarization
/// - `certifai_ollama_url`: Ollama instance URL for summarization
/// - `certifai_ollama_model`: Ollama model ID for summarization
#[component]
pub fn DashboardPage() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -34,11 +34,11 @@ pub fn DashboardPage() -> Element {
// Persistent state stored in localStorage
let mut custom_topics = use_persistent("certifai_topics".to_string(), Vec::<String>::new);
// Default to empty so the server functions use LITELLM_URL / LITELLM_MODEL
// Default to empty so the server functions use OLLAMA_URL / OLLAMA_MODEL
// from .env. Only stores a non-empty value when the user explicitly saves
// an override via the Settings panel.
let mut litellm_url = use_persistent("certifai_litellm_url".to_string(), String::new);
let mut litellm_model = use_persistent("certifai_litellm_model".to_string(), String::new);
let mut ollama_url = use_persistent("certifai_ollama_url".to_string(), String::new);
let mut ollama_model = use_persistent("certifai_ollama_model".to_string(), String::new);
// Reactive signals for UI state
let mut active_topic = use_signal(|| "AI".to_string());
@@ -235,8 +235,8 @@ pub fn DashboardPage() -> Element {
onclick: move |_| {
let currently_shown = *show_settings.read();
if !currently_shown {
settings_url.set(litellm_url.read().clone());
settings_model.set(litellm_model.read().clone());
settings_url.set(ollama_url.read().clone());
settings_model.set(ollama_model.read().clone());
}
show_settings.set(!currently_shown);
},
@@ -247,16 +247,16 @@ pub fn DashboardPage() -> Element {
// Settings panel (collapsible)
if *show_settings.read() {
div { class: "settings-panel",
h4 { class: "settings-panel-title", "{t(l, \"dashboard.litellm_settings\")}" }
h4 { class: "settings-panel-title", "{t(l, \"dashboard.ollama_settings\")}" }
p { class: "settings-hint",
"{t(l, \"dashboard.settings_hint\")}"
}
div { class: "settings-field",
label { "{t(l, \"dashboard.litellm_url\")}" }
label { "{t(l, \"dashboard.ollama_url\")}" }
input {
class: "settings-input",
r#type: "text",
placeholder: "{t(l, \"dashboard.litellm_url_placeholder\")}",
placeholder: "{t(l, \"dashboard.ollama_url_placeholder\")}",
value: "{settings_url}",
oninput: move |e| settings_url.set(e.value()),
}
@@ -274,8 +274,8 @@ pub fn DashboardPage() -> Element {
button {
class: "btn btn-primary",
onclick: move |_| {
*litellm_url.write() = settings_url.read().trim().to_string();
*litellm_model.write() = settings_model.read().trim().to_string();
*ollama_url.write() = settings_url.read().trim().to_string();
*ollama_model.write() = settings_model.read().trim().to_string();
show_settings.set(false);
},
"{t(l, \"common.save\")}"
@@ -320,14 +320,14 @@ pub fn DashboardPage() -> Element {
news_session_id.set(None);
let ll_url = litellm_url.read().clone();
let mdl = litellm_model.read().clone();
let oll_url = ollama_url.read().clone();
let mdl = ollama_model.read().clone();
spawn(async move {
is_summarizing.set(true);
match crate::infrastructure::llm::summarize_article(
snippet.clone(),
article_url,
ll_url,
oll_url,
mdl,
)
.await
@@ -373,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 ll_url = litellm_url.read().clone();
let mdl = litellm_model.read().clone();
let oll_url = ollama_url.read().clone();
let mdl = ollama_model.read().clone();
let ctx = article_context.read().clone();
// Capture article info for News session creation
let card_title = selected_card
@@ -394,7 +394,7 @@ pub fn DashboardPage() -> Element {
content: question.clone(),
});
// Build full message history for LiteLLM
// Build full message history for Ollama
let system_msg = format!(
"You are a helpful assistant. The user is reading \
a news article. Use the following context to answer \
@@ -422,7 +422,7 @@ pub fn DashboardPage() -> Element {
match create_chat_session(
card_title,
"News".to_string(),
"litellm".to_string(),
"ollama".to_string(),
mdl.clone(),
card_url,
)
@@ -458,7 +458,7 @@ pub fn DashboardPage() -> Element {
}
match crate::infrastructure::llm::chat_followup(
msgs, ll_url, mdl,
msgs, oll_url, mdl,
)
.await
{
@@ -495,7 +495,7 @@ pub fn DashboardPage() -> Element {
// Right: sidebar (when no card selected)
if !has_selection {
DashboardSidebar {
litellm_url: litellm_url.read().clone(),
ollama_url: ollama_url.read().clone(),
trending: trending_topics.clone(),
recent_searches: recent_searches.read().clone(),
on_topic_click: move |topic: String| {

View File

@@ -1,5 +1,8 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{BsArrowRight, BsShieldCheck};
use dioxus_free_icons::icons::bs_icons::{
BsArrowRight, BsGlobe2, BsKey, BsRobot, BsServer, BsShieldCheck,
};
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
use dioxus_free_icons::Icon;
use crate::i18n::{t, Locale};
@@ -9,15 +12,14 @@ use crate::Route;
///
/// Displays a marketing-oriented page with hero section, feature grid,
/// how-it-works steps, and call-to-action banners. This page is accessible
/// without authentication. Uses the Glass Aurora design with glassmorphic
/// effects, aurora gradients, and centered hero layout.
/// without authentication.
#[component]
pub fn LandingPage() -> Element {
rsx! {
div { class: "landing",
LandingNav {}
HeroSection {}
TrustBar {}
SocialProof {}
FeaturesGrid {}
HowItWorks {}
CtaBanner {}
@@ -27,7 +29,6 @@ pub fn LandingPage() -> Element {
}
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
/// Uses Glass Aurora glassmorphic nav with backdrop-filter blur.
#[component]
fn LandingNav() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -68,8 +69,7 @@ fn LandingNav() -> Element {
}
}
/// Hero section with pill badges, headline, subtitle, CTA buttons, and
/// a glass-preview stat panel. Centered layout per Glass Aurora design.
/// Hero section with headline, subtitle, and CTA buttons.
#[component]
fn HeroSection() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -78,11 +78,7 @@ fn HeroSection() -> Element {
rsx! {
section { class: "hero-section",
div { class: "hero-content",
div { class: "hero-pills",
span { class: "pill accent", {t(l, "landing.pill_gdpr")} }
span { class: "pill", {t(l, "landing.pill_self_hosted")} }
span { class: "pill", {t(l, "landing.pill_eu")} }
}
div { class: "hero-badge badge badge-outline", {t(l, "landing.badge")} }
h1 { class: "hero-title",
{t(l, "landing.hero_title_1")}
br {}
@@ -104,63 +100,221 @@ fn HeroSection() -> Element {
{t(l, "landing.learn_more")}
}
}
div { class: "preview-container",
div { class: "glass-preview",
div { class: "preview-stat",
div { class: "preview-stat-value", "5" }
div { class: "preview-stat-label",
{t(l, "landing.preview_models")}
}
}
div { class: "preview-stat",
div { class: "preview-stat-value", "847K" }
div { class: "preview-stat-label",
{t(l, "landing.preview_tokens")}
}
}
div { class: "preview-stat",
div { class: "preview-stat-value", "$47.82" }
div { class: "preview-stat-label",
{t(l, "landing.preview_spend")}
}
div { class: "hero-graphic",
// Abstract shield/network SVG motif
svg {
view_box: "0 0 400 400",
fill: "none",
width: "100%",
height: "100%",
// Gradient definitions
defs {
linearGradient {
id: "grad1",
x1: "0%",
y1: "0%",
x2: "100%",
y2: "100%",
stop { offset: "0%", stop_color: "#91a4d2" }
stop { offset: "100%", stop_color: "#6d85c6" }
}
linearGradient {
id: "grad2",
x1: "0%",
y1: "100%",
x2: "100%",
y2: "0%",
stop { offset: "0%", stop_color: "#f97066" }
stop { offset: "100%", stop_color: "#f9a066" }
}
radialGradient {
id: "glow",
cx: "50%",
cy: "50%",
r: "50%",
stop {
offset: "0%",
stop_color: "rgba(145,164,210,0.3)",
}
stop {
offset: "100%",
stop_color: "rgba(145,164,210,0)",
}
}
}
// Background glow
circle {
cx: "200",
cy: "200",
r: "180",
fill: "url(#glow)",
}
// Shield outline
path {
d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \
C130 360 60 300 60 230 L60 110 Z",
stroke: "url(#grad1)",
stroke_width: "2",
fill: "none",
opacity: "0.6",
}
// Inner shield
path {
d: "M200 80 L310 135 L310 225 C310 280 255 330 200 345 \
C145 330 90 280 90 225 L90 135 Z",
stroke: "url(#grad1)",
stroke_width: "1.5",
fill: "rgba(145,164,210,0.05)",
opacity: "0.8",
}
// Network nodes
circle {
cx: "200",
cy: "180",
r: "8",
fill: "url(#grad1)",
}
circle {
cx: "150",
cy: "230",
r: "6",
fill: "url(#grad2)",
}
circle {
cx: "250",
cy: "230",
r: "6",
fill: "url(#grad2)",
}
circle {
cx: "200",
cy: "280",
r: "6",
fill: "url(#grad1)",
}
circle {
cx: "130",
cy: "170",
r: "4",
fill: "#91a4d2",
opacity: "0.6",
}
circle {
cx: "270",
cy: "170",
r: "4",
fill: "#91a4d2",
opacity: "0.6",
}
// Network connections
line {
x1: "200",
y1: "180",
x2: "150",
y2: "230",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.4",
}
line {
x1: "200",
y1: "180",
x2: "250",
y2: "230",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.4",
}
line {
x1: "150",
y1: "230",
x2: "200",
y2: "280",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.4",
}
line {
x1: "250",
y1: "230",
x2: "200",
y2: "280",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.4",
}
line {
x1: "200",
y1: "180",
x2: "130",
y2: "170",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.3",
}
line {
x1: "200",
y1: "180",
x2: "270",
y2: "170",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.3",
}
// Checkmark inside shield center
path {
d: "M180 200 L195 215 L225 185",
stroke: "url(#grad1)",
stroke_width: "3",
stroke_linecap: "round",
stroke_linejoin: "round",
fill: "none",
}
}
}
}
}
}
/// Trust bar with aurora dot indicators and stat labels.
/// Replaces the previous text-based social proof section.
/// Social proof / trust indicator strip.
#[component]
fn TrustBar() -> Element {
fn SocialProof() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { class: "trust-bar",
div { class: "trust-item",
div { class: "trust-dot" }
span { "100% " {t(l, "landing.on_premise")} }
section { class: "social-proof",
p { class: "social-proof-text",
{t(l, "landing.social_proof")}
span { class: "social-proof-highlight", {t(l, "landing.data_sovereignty")} }
}
div { class: "trust-item",
div { class: "trust-dot" }
span { "GDPR " {t(l, "landing.compliant")} }
}
div { class: "trust-item",
div { class: "trust-dot" }
span { "EU " {t(l, "landing.data_residency")} }
}
div { class: "trust-item",
div { class: "trust-dot" }
span { "Zero " {t(l, "landing.third_party")} }
div { class: "social-proof-stats",
div { class: "proof-stat",
span { class: "proof-stat-value", "100%" }
span { class: "proof-stat-label", {t(l, "landing.on_premise")} }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "GDPR" }
span { class: "proof-stat-label", {t(l, "landing.compliant")} }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "EU" }
span { class: "proof-stat-label", {t(l, "landing.data_residency")} }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "Zero" }
span { class: "proof-stat-label", {t(l, "landing.third_party")} }
}
}
}
}
}
/// Feature cards grid section. Uses gradient icon bars instead of SVG icons.
/// Feature cards grid section.
#[component]
fn FeaturesGrid() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -174,26 +328,44 @@ fn FeaturesGrid() -> Element {
}
div { class: "features-grid",
FeatureCard {
icon: rsx! {
Icon { icon: BsServer, width: 28, height: 28 }
},
title: t(l, "landing.feat_infra_title"),
description: t(l, "landing.feat_infra_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsShieldCheck, width: 28, height: 28 }
},
title: t(l, "landing.feat_gdpr_title"),
description: t(l, "landing.feat_gdpr_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: FaCubes, width: 28, height: 28 }
},
title: t(l, "landing.feat_llm_title"),
description: t(l, "landing.feat_llm_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsRobot, width: 28, height: 28 }
},
title: t(l, "landing.feat_agent_title"),
description: t(l, "landing.feat_agent_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsGlobe2, width: 28, height: 28 }
},
title: t(l, "landing.feat_mcp_title"),
description: t(l, "landing.feat_mcp_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsKey, width: 28, height: 28 }
},
title: t(l, "landing.feat_api_title"),
description: t(l, "landing.feat_api_desc"),
}
@@ -202,17 +374,18 @@ fn FeaturesGrid() -> Element {
}
}
/// Individual feature card with a gradient icon bar accent.
/// Individual feature card.
///
/// # Arguments
///
/// * `icon` - The icon element to display
/// * `title` - Feature title (owned String from translation lookup)
/// * `description` - Feature description text (owned String from translation lookup)
#[component]
fn FeatureCard(title: String, description: String) -> Element {
fn FeatureCard(icon: Element, title: String, description: String) -> Element {
rsx! {
div { class: "card feature-card",
div { class: "feature-icon-bar" }
div { class: "feature-card-icon", {icon} }
h3 { class: "feature-card-title", "{title}" }
p { class: "feature-card-desc", "{description}" }
}
@@ -268,35 +441,33 @@ fn StepCard(number: &'static str, title: String, description: String) -> Element
}
}
/// Call-to-action banner wrapped in a glass box with aurora top border.
/// Call-to-action banner before the footer.
#[component]
fn CtaBanner() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { class: "cta-section",
div { class: "cta-box",
h2 { class: "cta-title", {t(l, "landing.cta_title")} }
p { class: "cta-subtitle",
{t(l, "landing.cta_subtitle")}
section { class: "cta-banner",
h2 { class: "cta-title", {t(l, "landing.cta_title")} }
p { class: "cta-subtitle",
{t(l, "landing.cta_subtitle")}
}
div { class: "cta-actions",
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-lg",
{t(l, "landing.get_started_free")}
Icon { icon: BsArrowRight, width: 18, height: 18 }
}
div { class: "cta-actions",
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-lg",
{t(l, "landing.get_started_free")}
Icon { icon: BsArrowRight, width: 18, height: 18 }
}
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-outline btn-lg",
{t(l, "common.log_in")}
}
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-outline btn-lg",
{t(l, "common.log_in")}
}
}
}
@@ -304,7 +475,6 @@ fn CtaBanner() -> Element {
}
/// Landing page footer with links and copyright.
/// Uses glass border-top styling per Glass Aurora design.
#[component]
fn LandingFooter() -> Element {
let locale = use_context::<Signal<Locale>>();

View File

@@ -2,14 +2,12 @@ use dioxus::prelude::*;
use crate::components::{MemberRow, PageHeader};
use crate::i18n::{t, tw, Locale};
use crate::infrastructure::litellm::get_litellm_usage;
use crate::models::{BillingUsage, LitellmUsageStats, MemberRole, OrgMember};
use crate::models::{BillingUsage, MemberRole, OrgMember};
/// Organization dashboard with billing stats, member table, and invite modal.
///
/// Shows current billing usage (fetched from LiteLLM), a per-model
/// breakdown table, a table of organization members with role
/// management, and a button to invite new members.
/// Shows current billing usage, a table of organization members
/// with role management, and a button to invite new members.
#[component]
pub fn OrgDashboardPage() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -22,20 +20,6 @@ pub fn OrgDashboardPage() -> Element {
let members_list = members.read().clone();
// Compute date range: 1st of current month to today
let (start_date, end_date) = current_month_range();
// Fetch real usage stats from LiteLLM via server function.
// use_resource memoises and won't re-fire on parent re-renders.
let usage_resource = use_resource(move || {
let start = start_date.clone();
let end = end_date.clone();
async move { get_litellm_usage(start, end).await }
});
// Clone out of Signal to avoid holding the borrow across rsx!
let usage_snapshot = usage_resource.read().clone();
// Format token counts for display
let tokens_display = format_tokens(usage.tokens_used);
let tokens_limit_display = format_tokens(usage.tokens_limit);
@@ -46,39 +30,26 @@ pub fn OrgDashboardPage() -> Element {
title: t(l, "org.title"),
subtitle: t(l, "org.subtitle"),
actions: rsx! {
button {
class: "btn-primary",
onclick: move |_| show_invite.set(true),
{t(l, "org.invite_member")}
}
button { class: "btn-primary", onclick: move |_| show_invite.set(true), {t(l, "org.invite_member")} }
},
}
// Stats bar
div { class: "org-stats-bar",
div { class: "org-stat",
span { class: "org-stat-value",
"{usage.seats_used}/{usage.seats_total}"
}
span { class: "org-stat-value", "{usage.seats_used}/{usage.seats_total}" }
span { class: "org-stat-label", {t(l, "org.seats_used")} }
}
div { class: "org-stat",
span { class: "org-stat-value", "{tokens_display}" }
span { class: "org-stat-label",
{tw(l, "org.of_tokens", &[("limit", &tokens_limit_display)])}
}
span { class: "org-stat-label", {tw(l, "org.of_tokens", &[("limit", &tokens_limit_display)])} }
}
div { class: "org-stat",
span { class: "org-stat-value",
"{usage.billing_cycle_end}"
}
span { class: "org-stat-value", "{usage.billing_cycle_end}" }
span { class: "org-stat-label", {t(l, "org.cycle_ends")} }
}
}
// LiteLLM usage stats section
{render_usage_section(l, &usage_snapshot)}
// Members table
div { class: "org-table-wrapper",
table { class: "org-table",
@@ -143,144 +114,6 @@ pub fn OrgDashboardPage() -> Element {
}
}
/// Render the LiteLLM usage stats section: totals bar + per-model table.
///
/// Shows a loading state while the resource is pending, an error/empty
/// message on failure, and the full breakdown on success.
fn render_usage_section(
l: Locale,
snapshot: &Option<Result<LitellmUsageStats, ServerFnError>>,
) -> Element {
match snapshot {
None => rsx! {
div { class: "org-usage-loading",
span { {t(l, "org.loading_usage")} }
}
},
Some(Err(_)) => rsx! {
div { class: "org-usage-unavailable",
span { {t(l, "org.usage_unavailable")} }
}
},
Some(Ok(stats)) if stats.total_tokens == 0 && stats.model_breakdown.is_empty() => {
rsx! {
div { class: "org-usage-unavailable",
span { {t(l, "org.usage_unavailable")} }
}
}
}
Some(Ok(stats)) => {
let spend_display = format!("${:.2}", stats.total_spend);
let total_display = format_tokens(stats.total_tokens);
// Free-tier LiteLLM doesn't provide prompt/completion split
let has_token_split =
stats.total_prompt_tokens > 0 || stats.total_completion_tokens > 0;
rsx! {
// Usage totals bar
div { class: "org-stats-bar",
div { class: "org-stat",
span { class: "org-stat-value", "{spend_display}" }
span { class: "org-stat-label",
{t(l, "org.total_spend")}
}
}
div { class: "org-stat",
span { class: "org-stat-value",
"{total_display}"
}
span { class: "org-stat-label",
{t(l, "org.total_tokens")}
}
}
// Only show prompt/completion split when available
if has_token_split {
div { class: "org-stat",
span { class: "org-stat-value",
{format_tokens(stats.total_prompt_tokens)}
}
span { class: "org-stat-label",
{t(l, "org.prompt_tokens")}
}
}
div { class: "org-stat",
span { class: "org-stat-value",
{format_tokens(stats.total_completion_tokens)}
}
span { class: "org-stat-label",
{t(l, "org.completion_tokens")}
}
}
}
}
// Per-model breakdown table
if !stats.model_breakdown.is_empty() {
h3 { class: "org-section-title",
{t(l, "org.model_usage")}
}
div { class: "org-table-wrapper",
table { class: "org-table",
thead {
tr {
th { {t(l, "org.model")} }
th { {t(l, "org.tokens")} }
th { {t(l, "org.spend")} }
}
}
tbody {
for model in &stats.model_breakdown {
tr { key: "{model.model}",
td { "{model.model}" }
td {
{format_tokens(model.total_tokens)}
}
td {
{format!(
"${:.2}", model.spend
)}
}
}
}
}
}
}
}
}
}
}
}
/// Compute the date range for the current billing month.
///
/// Returns `(start_date, end_date)` as `YYYY-MM-DD` strings where
/// start_date is the 1st of the current month and end_date is today.
///
/// On the web target this uses `js_sys::Date` to read the browser clock.
/// On the server target (SSR) it falls back to `chrono::Utc::now()`.
fn current_month_range() -> (String, String) {
#[cfg(feature = "web")]
{
// js_sys::Date accesses the browser's local clock in WASM.
let now = js_sys::Date::new_0();
let year = now.get_full_year();
// JS months are 0-indexed, so add 1 for calendar month
let month = now.get_month() + 1;
let day = now.get_date();
let start = format!("{year:04}-{month:02}-01");
let end = format!("{year:04}-{month:02}-{day:02}");
(start, end)
}
#[cfg(not(feature = "web"))]
{
use chrono::Datelike;
let today = chrono::Utc::now().date_naive();
let start = format!("{:04}-{:02}-01", today.year(), today.month());
let end = today.format("%Y-%m-%d").to_string();
(start, end)
}
}
/// Formats a token count into a human-readable string (e.g. "1.2M").
fn format_tokens(count: u64) -> String {
const M: u64 = 1_000_000;

View File

@@ -13,8 +13,8 @@ pub fn ProvidersPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let mut selected_provider = use_signal(|| LlmProvider::LiteLlm);
let mut selected_model = use_signal(|| "qwen3-32b".to_string());
let mut selected_provider = use_signal(|| LlmProvider::Ollama);
let mut selected_model = use_signal(|| "llama3.1:8b".to_string());
let mut selected_embedding = use_signal(|| "nomic-embed-text".to_string());
let mut api_key = use_signal(String::new);
let mut saved = use_signal(|| false);
@@ -59,12 +59,12 @@ pub fn ProvidersPage() -> Element {
"Hugging Face" => LlmProvider::HuggingFace,
"OpenAI" => LlmProvider::OpenAi,
"Anthropic" => LlmProvider::Anthropic,
_ => LlmProvider::LiteLlm,
_ => LlmProvider::Ollama,
};
selected_provider.set(prov);
saved.set(false);
},
option { value: "LiteLLM", "LiteLLM" }
option { value: "Ollama", "Ollama" }
option { value: "Hugging Face", "Hugging Face" }
option { value: "OpenAI", "OpenAI" }
option { value: "Anthropic", "Anthropic" }
@@ -156,28 +156,22 @@ pub fn ProvidersPage() -> Element {
fn mock_models() -> Vec<ModelEntry> {
vec![
ModelEntry {
id: "qwen3-32b".into(),
name: "Qwen3 32B".into(),
provider: LlmProvider::LiteLlm,
context_window: 32,
},
ModelEntry {
id: "llama-3.3-70b".into(),
name: "Llama 3.3 70B".into(),
provider: LlmProvider::LiteLlm,
id: "llama3.1:8b".into(),
name: "Llama 3.1 8B".into(),
provider: LlmProvider::Ollama,
context_window: 128,
},
ModelEntry {
id: "mistral-small-24b".into(),
name: "Mistral Small 24B".into(),
provider: LlmProvider::LiteLlm,
context_window: 32,
id: "llama3.1:70b".into(),
name: "Llama 3.1 70B".into(),
provider: LlmProvider::Ollama,
context_window: 128,
},
ModelEntry {
id: "deepseek-r1-70b".into(),
name: "DeepSeek R1 70B".into(),
provider: LlmProvider::LiteLlm,
context_window: 64,
id: "mistral:7b".into(),
name: "Mistral 7B".into(),
provider: LlmProvider::Ollama,
context_window: 32,
},
ModelEntry {
id: "meta-llama/Llama-3.1-8B".into(),
@@ -206,7 +200,7 @@ fn mock_embeddings() -> Vec<EmbeddingEntry> {
EmbeddingEntry {
id: "nomic-embed-text".into(),
name: "Nomic Embed Text".into(),
provider: LlmProvider::LiteLlm,
provider: LlmProvider::Ollama,
dimensions: 768,
},
EmbeddingEntry {