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 / 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 (push) Successful in 3s
CI / Clippy (push) Successful in 2m48s
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 SEARXNG_URL=http://localhost:8888
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# LiteLLM proxy [OPTIONAL - defaults shown] # Ollama LLM instance [OPTIONAL - defaults shown]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
LITELLM_URL=http://localhost:4000 OLLAMA_URL=http://localhost:11434
LITELLM_MODEL=qwen3-32b OLLAMA_MODEL=llama3.1:8b
LITELLM_API_KEY=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# LibreChat (external chat via SSO) [OPTIONAL - default: http://localhost:3080] # 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 (comma-separated list) [OPTIONAL]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
LLM_PROVIDERS=litellm LLM_PROVIDERS=ollama
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SMTP (transactional email) [OPTIONAL] # SMTP (transactional email) [OPTIONAL]
@@ -74,11 +73,6 @@ LANGGRAPH_URL=
LANGFLOW_URL= LANGFLOW_URL=
LANGFUSE_URL= LANGFUSE_URL=
# ---------------------------------------------------------------------------
# Compliance scanner (external tool, opens in new tab) [OPTIONAL]
# ---------------------------------------------------------------------------
COMPLIANCE_SCANNER_URL=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vector database [OPTIONAL] # Vector database [OPTIONAL]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -121,13 +121,13 @@ jobs:
if: always() 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: e2e:
name: E2E Tests name: E2E Tests
runs-on: docker runs-on: docker
needs: [deploy] needs: [fmt, clippy, audit]
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
container: container:
image: rust:1.89-bookworm image: rust:1.89-bookworm
# MongoDB and SearXNG can start immediately (no repo files needed). # MongoDB and SearXNG can start immediately (no repo files needed).
@@ -259,33 +259,13 @@ jobs:
deploy: deploy:
name: Deploy name: Deploy
runs-on: docker runs-on: docker
needs: [test] needs: [test, e2e]
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
container: container:
image: docker:27-cli image: alpine:latest
steps: steps:
- name: Checkout - name: Trigger Coolify deploy
run: | run: |
apk add --no-cache git curl openssl apk add --no-cache curl
git init curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
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"

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 } serde_json = { version = "1.0.133", default-features = false }
maud = { version = "0.27", default-features = false } maud = { version = "0.27", default-features = false }
url = { version = "2.5.4", default-features = false, optional = true } url = { version = "2.5.4", default-features = false, optional = true }
js-sys = { version = "0.3", optional = true }
wasm-bindgen = { version = "0.2", optional = true } wasm-bindgen = { version = "0.2", optional = true }
web-sys = { version = "0.3", optional = true, features = [ web-sys = { version = "0.3", optional = true, features = [
"Clipboard", "Clipboard",
@@ -92,7 +91,7 @@ bytes = { version = "1", optional = true }
[features] [features]
# default = ["web"] # 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 = [ server = [
"dioxus/server", "dioxus/server",
"dep:axum", "dep:axum",

View File

@@ -46,8 +46,7 @@
"agents": "Agenten", "agents": "Agenten",
"flow": "Flow", "flow": "Flow",
"analytics": "Analytics", "analytics": "Analytics",
"pricing": "Preise", "pricing": "Preise"
"compliance": "Compliance"
}, },
"auth": { "auth": {
"redirecting_login": "Weiterleitung zur Anmeldung...", "redirecting_login": "Weiterleitung zur Anmeldung...",
@@ -59,15 +58,15 @@
"title": "Dashboard", "title": "Dashboard",
"subtitle": "KI-Nachrichten und Neuigkeiten", "subtitle": "KI-Nachrichten und Neuigkeiten",
"topic_placeholder": "Themenname...", "topic_placeholder": "Themenname...",
"litellm_settings": "LiteLLM-Einstellungen", "ollama_settings": "Ollama-Einstellungen",
"settings_hint": "Leer lassen, um LITELLM_URL / LITELLM_MODEL aus .env zu verwenden", "settings_hint": "Leer lassen, um OLLAMA_URL / OLLAMA_MODEL aus .env zu verwenden",
"litellm_url": "LiteLLM-URL", "ollama_url": "Ollama-URL",
"litellm_url_placeholder": "Verwendet LITELLM_URL aus .env", "ollama_url_placeholder": "Verwendet OLLAMA_URL aus .env",
"model": "Modell", "model": "Modell",
"model_placeholder": "Verwendet LITELLM_MODEL aus .env", "model_placeholder": "Verwendet OLLAMA_MODEL aus .env",
"searching": "Suche laeuft...", "searching": "Suche laeuft...",
"search_failed": "Suche fehlgeschlagen: {e}", "search_failed": "Suche fehlgeschlagen: {e}",
"litellm_status": "LiteLLM-Status", "ollama_status": "Ollama-Status",
"trending": "Im Trend", "trending": "Im Trend",
"recent_searches": "Letzte Suchen" "recent_searches": "Letzte Suchen"
}, },
@@ -145,16 +144,6 @@
"email_address": "E-Mail-Adresse", "email_address": "E-Mail-Adresse",
"email_placeholder": "kollege@firma.de", "email_placeholder": "kollege@firma.de",
"send_invite": "Einladung senden", "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_title": "Preise",
"pricing_subtitle": "Waehlen Sie den passenden Plan fuer Ihre Organisation" "pricing_subtitle": "Waehlen Sie den passenden Plan fuer Ihre Organisation"
}, },
@@ -225,13 +214,7 @@
"documentation": "Dokumentation", "documentation": "Dokumentation",
"api_reference": "API-Referenz", "api_reference": "API-Referenz",
"support": "Support", "support": "Support",
"copyright": "2026 CERTifAI. Alle Rechte vorbehalten.", "copyright": "2026 CERTifAI. Alle Rechte vorbehalten."
"pill_gdpr": "DSGVO-Nativ",
"pill_self_hosted": "Selbst gehostet",
"pill_eu": "EU-Souveraen",
"preview_models": "Aktive Modelle",
"preview_tokens": "Tokens / Monat",
"preview_spend": "Gesamtausgaben"
}, },
"article": { "article": {
"read_original": "Originalartikel lesen", "read_original": "Originalartikel lesen",

View File

@@ -46,8 +46,7 @@
"agents": "Agents", "agents": "Agents",
"flow": "Flow", "flow": "Flow",
"analytics": "Analytics", "analytics": "Analytics",
"pricing": "Pricing", "pricing": "Pricing"
"compliance": "Compliance"
}, },
"auth": { "auth": {
"redirecting_login": "Redirecting to login...", "redirecting_login": "Redirecting to login...",
@@ -59,15 +58,15 @@
"title": "Dashboard", "title": "Dashboard",
"subtitle": "AI news and updates", "subtitle": "AI news and updates",
"topic_placeholder": "Topic name...", "topic_placeholder": "Topic name...",
"litellm_settings": "LiteLLM Settings", "ollama_settings": "Ollama Settings",
"settings_hint": "Leave empty to use LITELLM_URL / LITELLM_MODEL from .env", "settings_hint": "Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env",
"litellm_url": "LiteLLM URL", "ollama_url": "Ollama URL",
"litellm_url_placeholder": "Uses LITELLM_URL from .env", "ollama_url_placeholder": "Uses OLLAMA_URL from .env",
"model": "Model", "model": "Model",
"model_placeholder": "Uses LITELLM_MODEL from .env", "model_placeholder": "Uses OLLAMA_MODEL from .env",
"searching": "Searching...", "searching": "Searching...",
"search_failed": "Search failed: {e}", "search_failed": "Search failed: {e}",
"litellm_status": "LiteLLM Status", "ollama_status": "Ollama Status",
"trending": "Trending", "trending": "Trending",
"recent_searches": "Recent Searches" "recent_searches": "Recent Searches"
}, },
@@ -145,16 +144,6 @@
"email_address": "Email Address", "email_address": "Email Address",
"email_placeholder": "colleague@company.com", "email_placeholder": "colleague@company.com",
"send_invite": "Send Invite", "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_title": "Pricing",
"pricing_subtitle": "Choose the plan that fits your organization" "pricing_subtitle": "Choose the plan that fits your organization"
}, },
@@ -225,13 +214,7 @@
"documentation": "Documentation", "documentation": "Documentation",
"api_reference": "API Reference", "api_reference": "API Reference",
"support": "Support", "support": "Support",
"copyright": "2026 CERTifAI. All rights reserved.", "copyright": "2026 CERTifAI. All rights reserved."
"pill_gdpr": "GDPR Native",
"pill_self_hosted": "Self-Hosted",
"pill_eu": "EU Sovereign",
"preview_models": "Active Models",
"preview_tokens": "Tokens / Month",
"preview_spend": "Total Spend"
}, },
"article": { "article": {
"read_original": "Read original article", "read_original": "Read original article",

View File

@@ -46,8 +46,7 @@
"agents": "Agentes", "agents": "Agentes",
"flow": "Flujo", "flow": "Flujo",
"analytics": "Estadisticas", "analytics": "Estadisticas",
"pricing": "Precios", "pricing": "Precios"
"compliance": "Cumplimiento"
}, },
"auth": { "auth": {
"redirecting_login": "Redirigiendo al inicio de sesion...", "redirecting_login": "Redirigiendo al inicio de sesion...",
@@ -59,15 +58,15 @@
"title": "Panel de control", "title": "Panel de control",
"subtitle": "Noticias y actualizaciones de IA", "subtitle": "Noticias y actualizaciones de IA",
"topic_placeholder": "Nombre del tema...", "topic_placeholder": "Nombre del tema...",
"litellm_settings": "Configuracion de LiteLLM", "ollama_settings": "Configuracion de Ollama",
"settings_hint": "Dejar vacio para usar LITELLM_URL / LITELLM_MODEL del archivo .env", "settings_hint": "Dejar vacio para usar OLLAMA_URL / OLLAMA_MODEL del archivo .env",
"litellm_url": "URL de LiteLLM", "ollama_url": "URL de Ollama",
"litellm_url_placeholder": "Usa LITELLM_URL del archivo .env", "ollama_url_placeholder": "Usa OLLAMA_URL del archivo .env",
"model": "Modelo", "model": "Modelo",
"model_placeholder": "Usa LITELLM_MODEL del archivo .env", "model_placeholder": "Usa OLLAMA_MODEL del archivo .env",
"searching": "Buscando...", "searching": "Buscando...",
"search_failed": "La busqueda fallo: {e}", "search_failed": "La busqueda fallo: {e}",
"litellm_status": "Estado de LiteLLM", "ollama_status": "Estado de Ollama",
"trending": "Tendencias", "trending": "Tendencias",
"recent_searches": "Busquedas recientes" "recent_searches": "Busquedas recientes"
}, },
@@ -145,16 +144,6 @@
"email_address": "Direccion de correo electronico", "email_address": "Direccion de correo electronico",
"email_placeholder": "colega@empresa.com", "email_placeholder": "colega@empresa.com",
"send_invite": "Enviar invitacion", "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_title": "Precios",
"pricing_subtitle": "Elija el plan que se adapte a su organizacion" "pricing_subtitle": "Elija el plan que se adapte a su organizacion"
}, },
@@ -225,13 +214,7 @@
"documentation": "Documentacion", "documentation": "Documentacion",
"api_reference": "Referencia API", "api_reference": "Referencia API",
"support": "Soporte", "support": "Soporte",
"copyright": "2026 CERTifAI. Todos los derechos reservados.", "copyright": "2026 CERTifAI. Todos los derechos reservados."
"pill_gdpr": "RGPD Nativo",
"pill_self_hosted": "Autoalojado",
"pill_eu": "Soberania UE",
"preview_models": "Modelos Activos",
"preview_tokens": "Tokens / Mes",
"preview_spend": "Gasto Total"
}, },
"article": { "article": {
"read_original": "Leer articulo original", "read_original": "Leer articulo original",

View File

@@ -46,8 +46,7 @@
"agents": "Agents", "agents": "Agents",
"flow": "Flux", "flow": "Flux",
"analytics": "Analytique", "analytics": "Analytique",
"pricing": "Tarifs", "pricing": "Tarifs"
"compliance": "Conformite"
}, },
"auth": { "auth": {
"redirecting_login": "Redirection vers la connexion...", "redirecting_login": "Redirection vers la connexion...",
@@ -59,15 +58,15 @@
"title": "Tableau de bord", "title": "Tableau de bord",
"subtitle": "Actualites et mises a jour IA", "subtitle": "Actualites et mises a jour IA",
"topic_placeholder": "Nom du sujet...", "topic_placeholder": "Nom du sujet...",
"litellm_settings": "Parametres LiteLLM", "ollama_settings": "Parametres Ollama",
"settings_hint": "Laissez vide pour utiliser LITELLM_URL / LITELLM_MODEL du fichier .env", "settings_hint": "Laissez vide pour utiliser OLLAMA_URL / OLLAMA_MODEL du fichier .env",
"litellm_url": "URL LiteLLM", "ollama_url": "URL Ollama",
"litellm_url_placeholder": "Utilise LITELLM_URL du fichier .env", "ollama_url_placeholder": "Utilise OLLAMA_URL du fichier .env",
"model": "Modele", "model": "Modele",
"model_placeholder": "Utilise LITELLM_MODEL du fichier .env", "model_placeholder": "Utilise OLLAMA_MODEL du fichier .env",
"searching": "Recherche en cours...", "searching": "Recherche en cours...",
"search_failed": "Echec de la recherche : {e}", "search_failed": "Echec de la recherche : {e}",
"litellm_status": "Statut LiteLLM", "ollama_status": "Statut Ollama",
"trending": "Tendances", "trending": "Tendances",
"recent_searches": "Recherches recentes" "recent_searches": "Recherches recentes"
}, },
@@ -145,16 +144,6 @@
"email_address": "Adresse e-mail", "email_address": "Adresse e-mail",
"email_placeholder": "collegue@entreprise.com", "email_placeholder": "collegue@entreprise.com",
"send_invite": "Envoyer l'invitation", "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_title": "Tarifs",
"pricing_subtitle": "Choisissez le plan adapte a votre organisation" "pricing_subtitle": "Choisissez le plan adapte a votre organisation"
}, },
@@ -225,13 +214,7 @@
"documentation": "Documentation", "documentation": "Documentation",
"api_reference": "Reference API", "api_reference": "Reference API",
"support": "Support", "support": "Support",
"copyright": "2026 CERTifAI. Tous droits reserves.", "copyright": "2026 CERTifAI. Tous droits reserves."
"pill_gdpr": "RGPD Natif",
"pill_self_hosted": "Auto-heberge",
"pill_eu": "Souverainete UE",
"preview_models": "Modeles Actifs",
"preview_tokens": "Tokens / Mois",
"preview_spend": "Depenses Totales"
}, },
"article": { "article": {
"read_original": "Lire l'article original", "read_original": "Lire l'article original",

View File

@@ -46,8 +46,7 @@
"agents": "Agentes", "agents": "Agentes",
"flow": "Fluxo", "flow": "Fluxo",
"analytics": "Analise", "analytics": "Analise",
"pricing": "Precos", "pricing": "Precos"
"compliance": "Conformidade"
}, },
"auth": { "auth": {
"redirecting_login": "A redirecionar para o inicio de sessao...", "redirecting_login": "A redirecionar para o inicio de sessao...",
@@ -59,15 +58,15 @@
"title": "Painel", "title": "Painel",
"subtitle": "Noticias e atualizacoes de IA", "subtitle": "Noticias e atualizacoes de IA",
"topic_placeholder": "Nome do topico...", "topic_placeholder": "Nome do topico...",
"litellm_settings": "Definicoes do LiteLLM", "ollama_settings": "Definicoes do Ollama",
"settings_hint": "Deixe vazio para usar LITELLM_URL / LITELLM_MODEL do .env", "settings_hint": "Deixe vazio para usar OLLAMA_URL / OLLAMA_MODEL do .env",
"litellm_url": "URL do LiteLLM", "ollama_url": "URL do Ollama",
"litellm_url_placeholder": "Utiliza LITELLM_URL do .env", "ollama_url_placeholder": "Utiliza OLLAMA_URL do .env",
"model": "Modelo", "model": "Modelo",
"model_placeholder": "Utiliza LITELLM_MODEL do .env", "model_placeholder": "Utiliza OLLAMA_MODEL do .env",
"searching": "A pesquisar...", "searching": "A pesquisar...",
"search_failed": "A pesquisa falhou: {e}", "search_failed": "A pesquisa falhou: {e}",
"litellm_status": "Estado do LiteLLM", "ollama_status": "Estado do Ollama",
"trending": "Em destaque", "trending": "Em destaque",
"recent_searches": "Pesquisas recentes" "recent_searches": "Pesquisas recentes"
}, },
@@ -145,16 +144,6 @@
"email_address": "Endereco de Email", "email_address": "Endereco de Email",
"email_placeholder": "colleague@company.com", "email_placeholder": "colleague@company.com",
"send_invite": "Enviar Convite", "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_title": "Precos",
"pricing_subtitle": "Escolha o plano adequado a sua organizacao" "pricing_subtitle": "Escolha o plano adequado a sua organizacao"
}, },
@@ -225,13 +214,7 @@
"documentation": "Documentacao", "documentation": "Documentacao",
"api_reference": "Referencia API", "api_reference": "Referencia API",
"support": "Suporte", "support": "Suporte",
"copyright": "2026 CERTifAI. Todos os direitos reservados.", "copyright": "2026 CERTifAI. Todos os direitos reservados."
"pill_gdpr": "RGPD Nativo",
"pill_self_hosted": "Auto-Alojado",
"pill_eu": "Soberania UE",
"preview_models": "Modelos Ativos",
"preview_tokens": "Tokens / Mes",
"preview_spend": "Gasto Total"
}, },
"article": { "article": {
"read_original": "Ler artigo original", "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 properties;
@layer theme, base, components, utilities; @layer theme, base, components, utilities;
@layer theme { @layer theme {
@@ -9,15 +9,6 @@
"Courier New", monospace; "Courier New", monospace;
--color-black: #000; --color-black: #000;
--spacing: 0.25rem; --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-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono); --default-mono-font-family: var(--font-mono);
} }
@@ -171,6 +162,59 @@
} }
} }
@layer utilities { @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 { .modal {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
pointer-events: none; pointer-events: none;
@@ -1066,98 +1110,31 @@
} }
} }
} }
.range { .chat-bubble {
@layer daisyui.l1.l2.l3 { @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; position: relative;
box-sizing: border-box; display: block;
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max))); width: fit-content;
background-color: var(--range-thumb); border-radius: var(--radius-field);
height: var(--range-thumb-size); background-color: var(--color-base-300);
width: var(--range-thumb-size); padding-inline: calc(0.25rem * 4);
border: var(--range-p) solid; padding-block: calc(0.25rem * 2);
appearance: none; color: var(--color-base-content);
webkit-appearance: none; grid-row-end: 3;
top: 50%; min-height: 2rem;
color: var(--range-progress); min-width: 2.5rem;
transform: translateY(-50%); max-width: 90%;
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)); &:before {
@supports (color: color-mix(in lab, red, red)) { position: absolute;
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)); bottom: calc(0.25rem * 0);
} height: calc(0.25rem * 3);
} width: calc(0.25rem * 3);
&::-moz-range-track { background-color: inherit;
width: 100%; content: "";
background-color: var(--range-bg); mask-repeat: no-repeat;
border-radius: var(--radius-selector); mask-image: var(--mask-chat);
height: calc(var(--range-thumb-size) * 0.5); mask-position: 0px -1px;
} mask-size: 0.8125rem;
&::-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%;
} }
} }
} }
@@ -1548,6 +1525,81 @@
padding: calc(0.25rem * 4); 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 { .stack {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: inline-grid; 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 { .container {
width: 100%; width: 100%;
@media (width >= 40rem) { @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 { .chat {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: grid; display: grid;
@@ -1870,9 +1897,6 @@
font-weight: 600; font-weight: 600;
} }
} }
.flex {
display: flex;
}
.grid { .grid {
display: grid; display: grid;
} }
@@ -1885,9 +1909,6 @@
.table { .table {
display: table; display: table;
} }
.border-collapse {
border-collapse: collapse;
}
.transform { .transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); 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 { .badge-outline {
flex-wrap: wrap; @layer daisyui.l1.l2 {
color: var(--badge-color);
--badge-bg: #0000;
background-image: none;
border-color: currentColor;
} }
.border {
border-style: var(--tw-border-style);
border-width: 1px;
} }
.glass { .glass {
border: none; border: none;
@@ -1933,6 +1955,10 @@
.lowercase { .lowercase {
text-transform: lowercase; text-transform: lowercase;
} }
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.btn-ghost { .btn-ghost {
@layer daisyui.l1 { @layer daisyui.l1 {
&:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) { &:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
@@ -1960,15 +1986,6 @@
.filter { .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,); 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 { .btn-outline {
@layer daisyui.l1 { @layer daisyui.l1 {
&:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) { &:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) {
@@ -2383,7 +2400,7 @@
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-border-style { @property --tw-outline-style {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
initial-value: solid; initial-value: solid;
@@ -2441,42 +2458,6 @@
syntax: "*"; syntax: "*";
inherits: false; 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 { @layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { @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 { *, ::before, ::after, ::backdrop {
@@ -2485,7 +2466,7 @@
--tw-rotate-z: initial; --tw-rotate-z: initial;
--tw-skew-x: initial; --tw-skew-x: initial;
--tw-skew-y: initial; --tw-skew-y: initial;
--tw-border-style: solid; --tw-outline-style: solid;
--tw-blur: initial; --tw-blur: initial;
--tw-brightness: initial; --tw-brightness: initial;
--tw-contrast: initial; --tw-contrast: initial;
@@ -2499,15 +2480,6 @@
--tw-drop-shadow-color: initial; --tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%; --tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial; --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: mongo:
condition: service_started condition: service_started
environment: 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) # MongoDB (use localhost since we're on host network)
MONGO_URI: mongodb://root:example@localhost:27017/librechat?authSource=admin MONGO_URI: mongodb://root:example@localhost:27017/librechat?authSource=admin
DOMAIN_CLIENT: http://localhost:3080 DOMAIN_CLIENT: http://localhost:3080
@@ -72,6 +70,7 @@ services:
OPENID_CALLBACK_URL: /oauth/openid/callback OPENID_CALLBACK_URL: /oauth/openid/callback
OPENID_SCOPE: openid profile email OPENID_SCOPE: openid profile email
OPENID_BUTTON_LABEL: Login with CERTifAI OPENID_BUTTON_LABEL: Login with CERTifAI
OPENID_AUTH_EXTRA_PARAMS: prompt=none
# Disable local auth (SSO only) # Disable local auth (SSO only)
ALLOW_EMAIL_LOGIN: "false" ALLOW_EMAIL_LOGIN: "false"
ALLOW_REGISTRATION: "false" ALLOW_REGISTRATION: "false"

View File

@@ -1,5 +1,5 @@
# CERTifAI LibreChat Configuration # CERTifAI LibreChat Configuration
# LiteLLM proxy for unified multi-provider LLM access. # Ollama backend for self-hosted LLM inference.
version: 1.2.8 version: 1.2.8
cache: true cache: true
@@ -19,16 +19,22 @@ interface:
endpoints: endpoints:
custom: custom:
- name: "LiteLLM" - name: "Ollama"
apiKey: "${LITELLM_API_KEY}" apiKey: "ollama"
baseURL: "https://llm-dev.meghsakha.com/v1/" baseURL: "https://mac-mini-von-benjamin-2:11434/v1/"
models: models:
default: default:
- "Qwen3-Coder-30B-A3B-Instruct" - "llama3.1:8b"
- "qwen3:30b-a3b"
fetch: true fetch: true
titleConvo: true titleConvo: true
titleModel: "current_model" titleModel: "current_model"
summarize: false summarize: false
summaryModel: "current_model" summaryModel: "current_model"
forcePrompt: false 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 TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
const MANIFEST: Asset = asset!("/assets/manifest.json"); 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?\ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
family=Sora:wght@300;400;500;600;700;800&\ family=Inter:wght@400;500;600&\
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=Space+Grotesk:wght@500;600;700&\
display=swap"; display=swap";
/// Root application component. Loads global assets and mounts the router. /// 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", src: "https://seggwat.com/static/widgets/v1/seggwat-feedback.js",
r#defer: true, r#defer: true,
"data-project-key": "a04b8cf1-9177-42ce-8a7b-084f38b99799", "data-project-key": "a04b8cf1-9177-42ce-8a7b-084f38b99799",
"data-button-color": "#8b5cf6", "data-button-color": "#6d85c6",
"data-button-position": "right-side", "data-button-position": "right-side",
"data-enable-screenshots": "true", "data-enable-screenshots": "true",
} }
document::Link { rel: "icon", href: FAVICON } document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "manifest", href: MANIFEST } 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-capable", content: "yes" }
document::Meta { document::Meta {
name: "apple-mobile-web-app-status-bar-style", name: "apple-mobile-web-app-status-bar-style",

View File

@@ -76,7 +76,6 @@ pub fn AppShell() -> Element {
name: info.name, name: info.name,
avatar_url: info.avatar_url, avatar_url: info.avatar_url,
librechat_url: info.librechat_url, librechat_url: info.librechat_url,
compliance_scanner_url: info.compliance_scanner_url,
class: sidebar_cls, class: sidebar_cls,
on_nav: move |_| mobile_menu_open.set(false), on_nav: move |_| mobile_menu_open.set(false),
} }

View File

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

View File

@@ -112,12 +112,12 @@ pub fn mock_news() -> Vec<NewsCardModel> {
published_at: "2026-02-16".into(), published_at: "2026-02-16".into(),
}, },
NewsCardModel { NewsCardModel {
title: "LiteLLM Adds Multi-Provider Routing".into(), title: "Ollama Adds Multi-GPU Scheduling".into(),
source: "LiteLLM".into(), source: "Ollama".into(),
summary: "Route requests across multiple LLM providers with automatic fallback.".into(), summary: "Run large models across multiple GPUs with automatic sharding.".into(),
content: "LiteLLM now supports multi-provider routing with automatic \ content: "Ollama now supports multi-GPU scheduling with automatic \
fallback. Users can route requests across multiple providers \ model sharding. Users can run models across multiple GPUs \
for improved reliability and cost optimization." for improved inference performance."
.into(), .into(),
category: "Infrastructure".into(), category: "Infrastructure".into(),
url: "#".into(), url: "#".into(),

View File

@@ -1,7 +1,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{ use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2, BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2,
BsGrid, BsHouseDoor, BsMoonFill, BsShieldCheck, BsSunFill, BsGrid, BsHouseDoor, BsMoonFill, BsSunFill,
}; };
use dioxus_free_icons::Icon; use dioxus_free_icons::Icon;
@@ -44,14 +44,13 @@ pub fn Sidebar(
email: String, email: String,
avatar_url: String, avatar_url: String,
#[props(default = "http://localhost:3080".to_string())] librechat_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 = "sidebar".to_string())] class: String,
#[props(default)] on_nav: EventHandler<()>, #[props(default)] on_nav: EventHandler<()>,
) -> Element { ) -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
let locale_val = *locale.read(); let locale_val = *locale.read();
let mut nav_items: Vec<NavItem> = vec![ let nav_items: Vec<NavItem> = vec![
NavItem { NavItem {
key: "dashboard", key: "dashboard",
label: t(locale_val, "nav.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. // Determine current path to highlight the active nav link.
let current_route = use_route::<Route>(); let current_route = use_route::<Route>();
let logout_label = t(locale_val, "common.logout"); 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 langgraph_url = state.services.langgraph_url.clone();
let langflow_url = state.services.langflow_url.clone(); let langflow_url = state.services.langflow_url.clone();
let langfuse_url = state.services.langfuse_url.clone(); let langfuse_url = state.services.langfuse_url.clone();
let compliance_scanner_url = state.services.compliance_scanner_url.clone();
Ok(AuthInfo { Ok(AuthInfo {
authenticated: true, authenticated: true,
@@ -47,7 +46,6 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
langgraph_url, langgraph_url,
langflow_url, langflow_url,
langfuse_url, langfuse_url,
compliance_scanner_url,
}) })
} }
None => Ok(AuthInfo::default()), 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 /// * `title` - Display title for the session
/// * `namespace` - Namespace string: `"General"` or `"News"` /// * `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") /// * `model` - Model ID (e.g. "llama3.1:8b")
/// * `article_url` - Source article URL (only for `News` namespace, empty if none) /// * `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 // Resolve provider URL and model
let (base_url, model) = resolve_provider_url( let (base_url, model) = resolve_provider_url(
&state.services.litellm_url, &state.services.ollama_url,
&state.services.litellm_model, &state.services.ollama_model,
&session.provider, &session.provider,
&session.model, &session.model,
); );
@@ -485,22 +485,22 @@ pub async fn chat_complete(
.ok_or_else(|| ServerFnError::new("empty LLM response")) .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 /// # Arguments
/// ///
/// * `litellm_url` - Default LiteLLM base URL from config /// * `ollama_url` - Default Ollama base URL from config
/// * `litellm_model` - Default LiteLLM model from config /// * `ollama_model` - Default Ollama model from config
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface") /// * `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 /// # Returns
/// ///
/// A `(base_url, model)` tuple resolved for the given provider. /// A `(base_url, model)` tuple resolved for the given provider.
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub(crate) fn resolve_provider_url( pub(crate) fn resolve_provider_url(
litellm_url: &str, ollama_url: &str,
litellm_model: &str, ollama_model: &str,
provider: &str, provider: &str,
model: &str, model: &str,
) -> (String, String) { ) -> (String, String) {
@@ -511,11 +511,11 @@ pub(crate) fn resolve_provider_url(
format!("https://api-inference.huggingface.co/models/{}", model), format!("https://api-inference.huggingface.co/models/{}", model),
model.to_string(), model.to_string(),
), ),
// Default to LiteLLM // Default to Ollama
_ => ( _ => (
litellm_url.to_string(), ollama_url.to_string(),
if model.is_empty() { if model.is_empty() {
litellm_model.to_string() ollama_model.to_string()
} else { } else {
model.to_string() model.to_string()
}, },
@@ -595,7 +595,7 @@ mod tests {
"_id": oid, "_id": oid,
"user_sub": "u", "user_sub": "u",
"title": "t", "title": "t",
"provider": "litellm", "provider": "ollama",
"model": "m", "model": "m",
"created_at": "c", "created_at": "c",
"updated_at": "u", "updated_at": "u",
@@ -612,7 +612,7 @@ mod tests {
"user_sub": "u", "user_sub": "u",
"title": "t", "title": "t",
"namespace": "News", "namespace": "News",
"provider": "litellm", "provider": "ollama",
"model": "m", "model": "m",
"created_at": "c", "created_at": "c",
"updated_at": "u", "updated_at": "u",
@@ -684,13 +684,13 @@ mod tests {
// -- resolve_provider_url -- // -- resolve_provider_url --
const TEST_LITELLM_URL: &str = "http://localhost:4000"; const TEST_OLLAMA_URL: &str = "http://localhost:11434";
const TEST_LITELLM_MODEL: &str = "qwen3-32b"; const TEST_OLLAMA_MODEL: &str = "llama3.1:8b";
#[test] #[test]
fn resolve_openai_returns_api_openai() { fn resolve_openai_returns_api_openai() {
let (url, model) = 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!(url, "https://api.openai.com");
assert_eq!(model, "gpt-4o"); assert_eq!(model, "gpt-4o");
} }
@@ -698,8 +698,8 @@ mod tests {
#[test] #[test]
fn resolve_anthropic_returns_api_anthropic() { fn resolve_anthropic_returns_api_anthropic() {
let (url, model) = resolve_provider_url( let (url, model) = resolve_provider_url(
TEST_LITELLM_URL, TEST_OLLAMA_URL,
TEST_LITELLM_MODEL, TEST_OLLAMA_MODEL,
"anthropic", "anthropic",
"claude-3-opus", "claude-3-opus",
); );
@@ -710,8 +710,8 @@ mod tests {
#[test] #[test]
fn resolve_huggingface_returns_model_url() { fn resolve_huggingface_returns_model_url() {
let (url, model) = resolve_provider_url( let (url, model) = resolve_provider_url(
TEST_LITELLM_URL, TEST_OLLAMA_URL,
TEST_LITELLM_MODEL, TEST_OLLAMA_MODEL,
"huggingface", "huggingface",
"meta-llama/Llama-2-7b", "meta-llama/Llama-2-7b",
); );
@@ -723,19 +723,19 @@ mod tests {
} }
#[test] #[test]
fn resolve_unknown_defaults_to_litellm() { fn resolve_unknown_defaults_to_ollama() {
let (url, model) = let (url, model) =
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "litellm", "qwen3-32b"); resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "mistral:7b");
assert_eq!(url, TEST_LITELLM_URL); assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, "qwen3-32b"); assert_eq!(model, "mistral:7b");
} }
#[test] #[test]
fn resolve_empty_model_falls_back_to_server_default() { fn resolve_empty_model_falls_back_to_server_default() {
let (url, model) = let (url, model) =
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "litellm", ""); resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "");
assert_eq!(url, TEST_LITELLM_URL); assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, TEST_LITELLM_MODEL); assert_eq!(model, TEST_OLLAMA_MODEL);
} }
} }
} }

View File

@@ -141,15 +141,13 @@ impl SmtpConfig {
// ServiceUrls // ServiceUrls
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// URLs and credentials for external services (LiteLLM, SearXNG, S3, etc.). /// URLs and credentials for external services (Ollama, SearXNG, S3, etc.).
#[derive(Debug)] #[derive(Debug)]
pub struct ServiceUrls { pub struct ServiceUrls {
/// LiteLLM proxy base URL. /// Ollama LLM instance base URL.
pub litellm_url: String, pub ollama_url: String,
/// Default LiteLLM model to use. /// Default Ollama model to use.
pub litellm_model: String, pub ollama_model: String,
/// LiteLLM API key for authenticated requests.
pub litellm_api_key: String,
/// SearXNG meta-search engine base URL. /// SearXNG meta-search engine base URL.
pub searxng_url: String, pub searxng_url: String,
/// LangChain service URL. /// LangChain service URL.
@@ -168,8 +166,6 @@ pub struct ServiceUrls {
pub s3_access_key: String, pub s3_access_key: String,
/// S3 secret key (wrapped for debug safety). /// S3 secret key (wrapped for debug safety).
pub s3_secret_key: SecretString, pub s3_secret_key: SecretString,
/// Compliance scanner URL (external tool opened in a new tab).
pub compliance_scanner_url: String,
} }
impl ServiceUrls { impl ServiceUrls {
@@ -182,10 +178,9 @@ impl ServiceUrls {
/// Currently infallible but returns `Result` for consistency. /// Currently infallible but returns `Result` for consistency.
pub fn from_env() -> Result<Self, Error> { pub fn from_env() -> Result<Self, Error> {
Ok(Self { Ok(Self {
litellm_url: std::env::var("LITELLM_URL") ollama_url: std::env::var("OLLAMA_URL")
.unwrap_or_else(|_| "http://localhost:4000".into()), .unwrap_or_else(|_| "http://localhost:11434".into()),
litellm_model: std::env::var("LITELLM_MODEL").unwrap_or_else(|_| "qwen3-32b".into()), ollama_model: std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()),
litellm_api_key: optional_env("LITELLM_API_KEY"),
searxng_url: std::env::var("SEARXNG_URL") searxng_url: std::env::var("SEARXNG_URL")
.unwrap_or_else(|_| "http://localhost:8888".into()), .unwrap_or_else(|_| "http://localhost:8888".into()),
langchain_url: optional_env("LANGCHAIN_URL"), langchain_url: optional_env("LANGCHAIN_URL"),
@@ -196,7 +191,6 @@ impl ServiceUrls {
s3_url: optional_env("S3_URL"), s3_url: optional_env("S3_URL"),
s3_access_key: optional_env("S3_ACCESS_KEY"), s3_access_key: optional_env("S3_ACCESS_KEY"),
s3_secret_key: SecretString::from(optional_env("S3_SECRET_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. /// Comma-separated list of enabled LLM provider identifiers.
/// ///
/// For example: `LLM_PROVIDERS=litellm,openai,anthropic` /// For example: `LLM_PROVIDERS=ollama,openai,anthropic`
#[derive(Debug)] #[derive(Debug)]
pub struct LlmProvidersConfig { pub struct LlmProvidersConfig {
/// Parsed provider names. /// Parsed provider names.
@@ -337,36 +331,36 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn llm_providers_single() { fn llm_providers_single() {
std::env::set_var("LLM_PROVIDERS", "litellm"); std::env::set_var("LLM_PROVIDERS", "ollama");
let cfg = LlmProvidersConfig::from_env().unwrap(); let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["litellm"]); assert_eq!(cfg.providers, vec!["ollama"]);
std::env::remove_var("LLM_PROVIDERS"); std::env::remove_var("LLM_PROVIDERS");
} }
#[test] #[test]
#[serial] #[serial]
fn llm_providers_multiple() { 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(); 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"); std::env::remove_var("LLM_PROVIDERS");
} }
#[test] #[test]
#[serial] #[serial]
fn llm_providers_trims_whitespace() { 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(); 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"); std::env::remove_var("LLM_PROVIDERS");
} }
#[test] #[test]
#[serial] #[serial]
fn llm_providers_filters_empty_entries() { 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(); 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"); std::env::remove_var("LLM_PROVIDERS");
} }
@@ -376,18 +370,18 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn service_urls_default_litellm_url() { fn service_urls_default_ollama_url() {
std::env::remove_var("LITELLM_URL"); std::env::remove_var("OLLAMA_URL");
let svc = ServiceUrls::from_env().unwrap(); let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.litellm_url, "http://localhost:4000"); assert_eq!(svc.ollama_url, "http://localhost:11434");
} }
#[test] #[test]
#[serial] #[serial]
fn service_urls_default_litellm_model() { fn service_urls_default_ollama_model() {
std::env::remove_var("LITELLM_MODEL"); std::env::remove_var("OLLAMA_MODEL");
let svc = ServiceUrls::from_env().unwrap(); let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.litellm_model, "qwen3-32b"); assert_eq!(svc.ollama_model, "llama3.1:8b");
} }
#[test] #[test]
@@ -400,11 +394,11 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn service_urls_custom_litellm_url() { fn service_urls_custom_ollama_url() {
std::env::set_var("LITELLM_URL", "http://litellm-host:4000"); std::env::set_var("OLLAMA_URL", "http://gpu-host:11434");
let svc = ServiceUrls::from_env().unwrap(); let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.litellm_url, "http://litellm-host:4000"); assert_eq!(svc.ollama_url, "http://gpu-host:11434");
std::env::remove_var("LITELLM_URL"); std::env::remove_var("OLLAMA_URL");
} }
#[test] #[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 { mod inner {
use serde::{Deserialize, Serialize}; 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)] #[derive(Serialize)]
pub(super) struct ChatMessage { pub(super) struct ChatMessage {
pub role: String, pub role: String,
pub content: 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)] #[derive(Serialize)]
pub(super) struct ChatCompletionRequest { pub(super) struct OllamaChatRequest {
pub model: String, pub model: String,
pub messages: Vec<ChatMessage>, pub messages: Vec<ChatMessage>,
/// Disable streaming so we get a single JSON response. /// Disable streaming so we get a single JSON response.
pub stream: bool, pub stream: bool,
} }
/// A single choice in the chat completions response. /// A single choice in the Ollama chat completions response.
#[derive(Deserialize)] #[derive(Deserialize)]
pub(super) struct ChatChoice { pub(super) struct ChatChoice {
pub message: ChatResponseMessage, pub message: ChatResponseMessage,
@@ -32,9 +32,9 @@ mod inner {
pub content: String, pub content: String,
} }
/// Top-level response from the `/v1/chat/completions` endpoint. /// Top-level response from Ollama's `/v1/chat/completions` endpoint.
#[derive(Deserialize)] #[derive(Deserialize)]
pub(super) struct ChatCompletionResponse { pub(super) struct OllamaChatResponse {
pub choices: Vec<ChatChoice>, 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. /// First attempts to fetch the full article text from the provided URL.
/// If that fails (paywall, timeout, etc.), falls back to the search snippet. /// 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) /// * `snippet` - The search result snippet (fallback content)
/// * `article_url` - The original article URL to fetch full text from /// * `article_url` - The original article URL to fetch full text from
/// * `litellm_url` - Base URL of the LiteLLM proxy (e.g. "http://localhost:4000") /// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
/// * `model` - The model ID to use (e.g. "qwen3-32b") /// * `model` - The Ollama model ID to use (e.g. "llama3.1:8b")
/// ///
/// # Returns /// # Returns
/// ///
@@ -176,38 +176,36 @@ mod inner {
/// ///
/// # Errors /// # 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")] #[post("/api/summarize")]
pub async fn summarize_article( pub async fn summarize_article(
snippet: String, snippet: String,
article_url: String, article_url: String,
litellm_url: String, ollama_url: String,
model: String, model: String,
) -> Result<String, ServerFnError> { ) -> Result<String, ServerFnError> {
use inner::{fetch_article_text, ChatCompletionRequest, ChatCompletionResponse, ChatMessage}; use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse};
let state: crate::infrastructure::ServerState = let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?; dioxus_fullstack::FullstackContext::extract().await?;
// Use caller-provided values or fall back to ServerState config // Use caller-provided values or fall back to ServerState config
let base_url = if litellm_url.is_empty() { let base_url = if ollama_url.is_empty() {
state.services.litellm_url.clone() state.services.ollama_url.clone()
} else { } else {
litellm_url ollama_url
}; };
let model = if model.is_empty() { let model = if model.is_empty() {
state.services.litellm_model.clone() state.services.ollama_model.clone()
} else { } else {
model model
}; };
let api_key = state.services.litellm_api_key.clone();
// Try to fetch the full article; fall back to the search snippet // 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 article_text = fetch_article_text(&article_url).await.unwrap_or(snippet);
let request_body = ChatCompletionRequest { let request_body = OllamaChatRequest {
model, model,
stream: false, stream: false,
messages: vec![ChatMessage { messages: vec![ChatMessage {
@@ -225,48 +223,42 @@ pub async fn summarize_article(
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let mut request = client let resp = client
.post(&url) .post(&url)
.header("content-type", "application/json") .header("content-type", "application/json")
.json(&request_body); .json(&request_body)
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = request
.send() .send()
.await .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() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!( return Err(ServerFnError::new(format!(
"LiteLLM returned {status}: {body}" "Ollama returned {status}: {body}"
))); )));
} }
let body: ChatCompletionResponse = resp let body: OllamaChatResponse = resp
.json() .json()
.await .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 body.choices
.first() .first()
.map(|choice| choice.message.content.clone()) .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. /// 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)] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FollowUpMessage { pub struct FollowUpMessage {
pub role: String, pub role: String,
pub content: 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 /// Accepts the full conversation history (system context + prior turns) and
/// returns the assistant's next response. The system message should contain /// returns the assistant's next response. The system message should contain
@@ -275,8 +267,8 @@ pub struct FollowUpMessage {
/// # Arguments /// # Arguments
/// ///
/// * `messages` - The conversation history including system context /// * `messages` - The conversation history including system context
/// * `litellm_url` - Base URL of the LiteLLM proxy /// * `ollama_url` - Base URL of the Ollama instance
/// * `model` - The model ID to use /// * `model` - The Ollama model ID to use
/// ///
/// # Returns /// # Returns
/// ///
@@ -284,32 +276,30 @@ pub struct FollowUpMessage {
/// ///
/// # Errors /// # 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")] #[post("/api/chat")]
pub async fn chat_followup( pub async fn chat_followup(
messages: Vec<FollowUpMessage>, messages: Vec<FollowUpMessage>,
litellm_url: String, ollama_url: String,
model: String, model: String,
) -> Result<String, ServerFnError> { ) -> Result<String, ServerFnError> {
use inner::{ChatCompletionRequest, ChatCompletionResponse, ChatMessage}; use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse};
let state: crate::infrastructure::ServerState = let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?; dioxus_fullstack::FullstackContext::extract().await?;
let base_url = if litellm_url.is_empty() { let base_url = if ollama_url.is_empty() {
state.services.litellm_url.clone() state.services.ollama_url.clone()
} else { } else {
litellm_url ollama_url
}; };
let model = if model.is_empty() { let model = if model.is_empty() {
state.services.litellm_model.clone() state.services.ollama_model.clone()
} else { } else {
model model
}; };
let api_key = state.services.litellm_api_key.clone();
// Convert FollowUpMessage to inner ChatMessage for the request // Convert FollowUpMessage to inner ChatMessage for the request
let chat_messages: Vec<ChatMessage> = messages let chat_messages: Vec<ChatMessage> = messages
.into_iter() .into_iter()
@@ -319,7 +309,7 @@ pub async fn chat_followup(
}) })
.collect(); .collect();
let request_body = ChatCompletionRequest { let request_body = OllamaChatRequest {
model, model,
stream: false, stream: false,
messages: chat_messages, messages: chat_messages,
@@ -327,37 +317,31 @@ pub async fn chat_followup(
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let mut request = client let resp = client
.post(&url) .post(&url)
.header("content-type", "application/json") .header("content-type", "application/json")
.json(&request_body); .json(&request_body)
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = request
.send() .send()
.await .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() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!( return Err(ServerFnError::new(format!(
"LiteLLM returned {status}: {body}" "Ollama returned {status}: {body}"
))); )));
} }
let body: ChatCompletionResponse = resp let body: OllamaChatResponse = resp
.json() .json()
.await .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 body.choices
.first() .first()
.map(|choice| choice.message.content.clone()) .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)] #[cfg(test)]

View File

@@ -3,8 +3,8 @@
pub mod auth_check; pub mod auth_check;
pub mod chat; pub mod chat;
pub mod langgraph; pub mod langgraph;
pub mod litellm;
pub mod llm; pub mod llm;
pub mod ollama;
pub mod searxng; pub mod searxng;
// Server-only modules (Axum handlers, state, configs, DB, etc.) // 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. //! 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 //! HuggingFace based on the session's provider setting. All providers
//! except Anthropic use the OpenAI-compatible chat completions format. //! except Anthropic use the OpenAI-compatible chat completions format.
@@ -20,11 +20,11 @@ pub struct ProviderMessage {
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `state` - Server state (for default LiteLLM URL/model) /// * `state` - Server state (for default Ollama URL/model)
/// * `provider` - Provider name (`"litellm"`, `"openai"`, `"anthropic"`, `"huggingface"`) /// * `provider` - Provider name (`"ollama"`, `"openai"`, `"anthropic"`, `"huggingface"`)
/// * `model` - Model ID /// * `model` - Model ID
/// * `messages` - Conversation history /// * `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 /// * `stream` - Whether to request streaming
/// ///
/// # Returns /// # Returns
@@ -123,11 +123,11 @@ pub async fn send_chat_request(
.send() .send()
.await .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() { let resolved_model = if model.is_empty() {
&state.services.litellm_model &state.services.ollama_model
} else { } else {
model model
}; };
@@ -137,15 +137,12 @@ pub async fn send_chat_request(
"messages": messages, "messages": messages,
"stream": stream, "stream": stream,
}); });
let litellm_key = &state.services.litellm_api_key; client
let mut request = client
.post(&url) .post(&url)
.header("content-type", "application/json") .header("content-type", "application/json")
.json(&body); .json(&body)
if !litellm_key.is_empty() { .send()
request = request.header("Authorization", format!("Bearer {litellm_key}")); .await
}
request.send().await
} }
} }
} }

View File

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

View File

@@ -60,8 +60,8 @@ pub struct Attachment {
/// * `user_sub` - Keycloak subject ID (session owner) /// * `user_sub` - Keycloak subject ID (session owner)
/// * `title` - Display title (auto-generated or user-renamed) /// * `title` - Display title (auto-generated or user-renamed)
/// * `namespace` - Grouping for sidebar sections /// * `namespace` - Grouping for sidebar sections
/// * `provider` - LLM provider used (e.g. "litellm", "openai") /// * `provider` - LLM provider used (e.g. "ollama", "openai")
/// * `model` - Model ID used (e.g. "qwen3-32b") /// * `model` - Model ID used (e.g. "llama3.1:8b")
/// * `created_at` - ISO 8601 creation timestamp /// * `created_at` - ISO 8601 creation timestamp
/// * `updated_at` - ISO 8601 last-activity timestamp /// * `updated_at` - ISO 8601 last-activity timestamp
/// * `article_url` - Source article URL (for News namespace sessions) /// * `article_url` - Source article URL (for News namespace sessions)
@@ -171,8 +171,8 @@ mod tests {
user_sub: "user-1".into(), user_sub: "user-1".into(),
title: "Test Chat".into(), title: "Test Chat".into(),
namespace: ChatNamespace::General, namespace: ChatNamespace::General,
provider: "litellm".into(), provider: "ollama".into(),
model: "qwen3-32b".into(), model: "llama3.1:8b".into(),
created_at: "2025-01-01T00:00:00Z".into(), created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T01:00:00Z".into(), updated_at: "2025-01-01T01:00:00Z".into(),
article_url: None, article_url: None,
@@ -189,7 +189,7 @@ mod tests {
"_id": "mongo-id", "_id": "mongo-id",
"user_sub": "u1", "user_sub": "u1",
"title": "t", "title": "t",
"provider": "litellm", "provider": "ollama",
"model": "m", "model": "m",
"created_at": "2025-01-01", "created_at": "2025-01-01",
"updated_at": "2025-01-01" "updated_at": "2025-01-01"
@@ -205,7 +205,7 @@ mod tests {
user_sub: "u1".into(), user_sub: "u1".into(),
title: "t".into(), title: "t".into(),
namespace: ChatNamespace::default(), namespace: ChatNamespace::default(),
provider: "litellm".into(), provider: "ollama".into(),
model: "m".into(), model: "m".into(),
created_at: "2025-01-01".into(), created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(), updated_at: "2025-01-01".into(),
@@ -223,7 +223,7 @@ mod tests {
user_sub: "u1".into(), user_sub: "u1".into(),
title: "t".into(), title: "t".into(),
namespace: ChatNamespace::default(), namespace: ChatNamespace::default(),
provider: "litellm".into(), provider: "ollama".into(),
model: "m".into(), model: "m".into(),
created_at: "2025-01-01".into(), created_at: "2025-01-01".into(),
updated_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, 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. /// Organisation-level settings stored in MongoDB.
/// ///
/// These complement Keycloak's Organizations feature with /// These complement Keycloak's Organizations feature with
@@ -270,82 +234,4 @@ mod tests {
assert_eq!(record.seats_used, 0); assert_eq!(record.seats_used, 0);
assert_eq!(record.tokens_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. /// Supported LLM provider backends.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LlmProvider { pub enum LlmProvider {
/// LiteLLM proxy for unified model access /// Self-hosted models via Ollama
LiteLlm, Ollama,
/// Hugging Face Inference API /// Hugging Face Inference API
HuggingFace, HuggingFace,
/// OpenAI-compatible endpoints /// OpenAI-compatible endpoints
@@ -17,7 +17,7 @@ impl LlmProvider {
/// Returns the display name for a provider. /// Returns the display name for a provider.
pub fn label(&self) -> &'static str { pub fn label(&self) -> &'static str {
match self { match self {
Self::LiteLlm => "LiteLLM", Self::Ollama => "Ollama",
Self::HuggingFace => "Hugging Face", Self::HuggingFace => "Hugging Face",
Self::OpenAi => "OpenAI", Self::OpenAi => "OpenAI",
Self::Anthropic => "Anthropic", Self::Anthropic => "Anthropic",
@@ -29,7 +29,7 @@ impl LlmProvider {
/// ///
/// # Fields /// # Fields
/// ///
/// * `id` - Unique model identifier (e.g. "qwen3-32b") /// * `id` - Unique model identifier (e.g. "llama3.1:8b")
/// * `name` - Human-readable display name /// * `name` - Human-readable display name
/// * `provider` - Which provider hosts this model /// * `provider` - Which provider hosts this model
/// * `context_window` - Maximum context length in tokens /// * `context_window` - Maximum context length in tokens
@@ -79,8 +79,8 @@ mod tests {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test] #[test]
fn llm_provider_label_litellm() { fn llm_provider_label_ollama() {
assert_eq!(LlmProvider::LiteLlm.label(), "LiteLLM"); assert_eq!(LlmProvider::Ollama.label(), "Ollama");
} }
#[test] #[test]
@@ -101,7 +101,7 @@ mod tests {
#[test] #[test]
fn llm_provider_serde_round_trip() { fn llm_provider_serde_round_trip() {
for variant in [ for variant in [
LlmProvider::LiteLlm, LlmProvider::Ollama,
LlmProvider::HuggingFace, LlmProvider::HuggingFace,
LlmProvider::OpenAi, LlmProvider::OpenAi,
LlmProvider::Anthropic, LlmProvider::Anthropic,
@@ -117,10 +117,10 @@ mod tests {
#[test] #[test]
fn model_entry_serde_round_trip() { fn model_entry_serde_round_trip() {
let entry = ModelEntry { let entry = ModelEntry {
id: "qwen3-32b".into(), id: "llama3.1:8b".into(),
name: "Qwen3 32B".into(), name: "Llama 3.1 8B".into(),
provider: LlmProvider::LiteLlm, provider: LlmProvider::Ollama,
context_window: 32, context_window: 8192,
}; };
let json = serde_json::to_string(&entry).expect("serialize ModelEntry"); let json = serde_json::to_string(&entry).expect("serialize ModelEntry");
let back: ModelEntry = serde_json::from_str(&json).expect("deserialize 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, pub langflow_url: String,
/// Langfuse observability URL (empty if not configured) /// Langfuse observability URL (empty if not configured)
pub langfuse_url: String, pub langfuse_url: String,
/// Compliance scanner URL (empty if not configured)
pub compliance_scanner_url: String,
} }
/// Per-user LLM provider configuration stored in MongoDB. /// Per-user LLM provider configuration stored in MongoDB.
/// ///
/// Controls which provider and model the user's chat sessions default /// 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)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UserProviderConfig { pub struct UserProviderConfig {
/// Default provider name (e.g. "litellm", "openai") /// Default provider name (e.g. "ollama", "openai")
pub default_provider: String, 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, pub default_model: String,
/// OpenAI API key (empty if not configured) /// OpenAI API key (empty if not configured)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -53,8 +51,8 @@ pub struct UserProviderConfig {
/// HuggingFace API key /// HuggingFace API key
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub huggingface_api_key: Option<String>, pub huggingface_api_key: Option<String>,
/// Custom LiteLLM URL override (empty = use server default) /// Custom Ollama URL override (empty = use server default)
pub litellm_url_override: String, pub ollama_url_override: String,
} }
/// Per-user preferences stored in MongoDB. /// Per-user preferences stored in MongoDB.
@@ -68,10 +66,10 @@ pub struct UserPreferences {
pub org_id: String, pub org_id: String,
/// User-selected news/search topics /// User-selected news/search topics
pub custom_topics: Vec<String>, pub custom_topics: Vec<String>,
/// Per-user LiteLLM URL override (empty = use server default) /// Per-user Ollama URL override (empty = use server default)
pub litellm_url_override: String, pub ollama_url_override: String,
/// Per-user LiteLLM model override (empty = use server default) /// Per-user Ollama model override (empty = use server default)
pub litellm_model_override: String, pub ollama_model_override: String,
/// Recently searched queries for quick access /// Recently searched queries for quick access
pub recent_searches: Vec<String>, pub recent_searches: Vec<String>,
/// LLM provider configuration /// LLM provider configuration
@@ -102,7 +100,6 @@ mod tests {
assert_eq!(info.langgraph_url, ""); assert_eq!(info.langgraph_url, "");
assert_eq!(info.langflow_url, ""); assert_eq!(info.langflow_url, "");
assert_eq!(info.langfuse_url, ""); assert_eq!(info.langfuse_url, "");
assert_eq!(info.compliance_scanner_url, "");
} }
#[test] #[test]
@@ -117,7 +114,6 @@ mod tests {
langgraph_url: "http://localhost:8123".into(), langgraph_url: "http://localhost:8123".into(),
langflow_url: "http://localhost:7860".into(), langflow_url: "http://localhost:7860".into(),
langfuse_url: "http://localhost:3000".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 json = serde_json::to_string(&info).expect("serialize AuthInfo");
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo"); let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");
@@ -136,12 +132,12 @@ mod tests {
#[test] #[test]
fn user_provider_config_optional_keys_skip_none() { fn user_provider_config_optional_keys_skip_none() {
let cfg = UserProviderConfig { let cfg = UserProviderConfig {
default_provider: "litellm".into(), default_provider: "ollama".into(),
default_model: "qwen3-32b".into(), default_model: "llama3.1:8b".into(),
openai_api_key: None, openai_api_key: None,
anthropic_api_key: None, anthropic_api_key: None,
huggingface_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"); let json = serde_json::to_string(&cfg).expect("serialize UserProviderConfig");
assert!(!json.contains("openai_api_key")); assert!(!json.contains("openai_api_key"));
@@ -157,7 +153,7 @@ mod tests {
openai_api_key: Some("sk-test".into()), openai_api_key: Some("sk-test".into()),
anthropic_api_key: Some("ak-test".into()), anthropic_api_key: Some("ak-test".into()),
huggingface_api_key: None, 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 json = serde_json::to_string(&cfg).expect("serialize");
let back: UserProviderConfig = serde_json::from_str(&json).expect("deserialize"); 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: /// State is persisted across sessions using localStorage:
/// - `certifai_topics`: custom user-defined search topics /// - `certifai_topics`: custom user-defined search topics
/// - `certifai_litellm_url`: LiteLLM proxy URL for summarization /// - `certifai_ollama_url`: Ollama instance URL for summarization
/// - `certifai_litellm_model`: LiteLLM model ID for summarization /// - `certifai_ollama_model`: Ollama model ID for summarization
#[component] #[component]
pub fn DashboardPage() -> Element { pub fn DashboardPage() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
@@ -34,11 +34,11 @@ pub fn DashboardPage() -> Element {
// Persistent state stored in localStorage // Persistent state stored in localStorage
let mut custom_topics = use_persistent("certifai_topics".to_string(), Vec::<String>::new); 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 // from .env. Only stores a non-empty value when the user explicitly saves
// an override via the Settings panel. // an override via the Settings panel.
let mut litellm_url = use_persistent("certifai_litellm_url".to_string(), String::new); let mut ollama_url = use_persistent("certifai_ollama_url".to_string(), String::new);
let mut litellm_model = use_persistent("certifai_litellm_model".to_string(), String::new); let mut ollama_model = use_persistent("certifai_ollama_model".to_string(), String::new);
// Reactive signals for UI state // Reactive signals for UI state
let mut active_topic = use_signal(|| "AI".to_string()); let mut active_topic = use_signal(|| "AI".to_string());
@@ -235,8 +235,8 @@ pub fn DashboardPage() -> Element {
onclick: move |_| { onclick: move |_| {
let currently_shown = *show_settings.read(); let currently_shown = *show_settings.read();
if !currently_shown { if !currently_shown {
settings_url.set(litellm_url.read().clone()); settings_url.set(ollama_url.read().clone());
settings_model.set(litellm_model.read().clone()); settings_model.set(ollama_model.read().clone());
} }
show_settings.set(!currently_shown); show_settings.set(!currently_shown);
}, },
@@ -247,16 +247,16 @@ pub fn DashboardPage() -> Element {
// Settings panel (collapsible) // Settings panel (collapsible)
if *show_settings.read() { if *show_settings.read() {
div { class: "settings-panel", 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", p { class: "settings-hint",
"{t(l, \"dashboard.settings_hint\")}" "{t(l, \"dashboard.settings_hint\")}"
} }
div { class: "settings-field", div { class: "settings-field",
label { "{t(l, \"dashboard.litellm_url\")}" } label { "{t(l, \"dashboard.ollama_url\")}" }
input { input {
class: "settings-input", class: "settings-input",
r#type: "text", r#type: "text",
placeholder: "{t(l, \"dashboard.litellm_url_placeholder\")}", placeholder: "{t(l, \"dashboard.ollama_url_placeholder\")}",
value: "{settings_url}", value: "{settings_url}",
oninput: move |e| settings_url.set(e.value()), oninput: move |e| settings_url.set(e.value()),
} }
@@ -274,8 +274,8 @@ pub fn DashboardPage() -> Element {
button { button {
class: "btn btn-primary", class: "btn btn-primary",
onclick: move |_| { onclick: move |_| {
*litellm_url.write() = settings_url.read().trim().to_string(); *ollama_url.write() = settings_url.read().trim().to_string();
*litellm_model.write() = settings_model.read().trim().to_string(); *ollama_model.write() = settings_model.read().trim().to_string();
show_settings.set(false); show_settings.set(false);
}, },
"{t(l, \"common.save\")}" "{t(l, \"common.save\")}"
@@ -320,14 +320,14 @@ pub fn DashboardPage() -> Element {
news_session_id.set(None); news_session_id.set(None);
let ll_url = litellm_url.read().clone(); let oll_url = ollama_url.read().clone();
let mdl = litellm_model.read().clone(); let mdl = ollama_model.read().clone();
spawn(async move { spawn(async move {
is_summarizing.set(true); is_summarizing.set(true);
match crate::infrastructure::llm::summarize_article( match crate::infrastructure::llm::summarize_article(
snippet.clone(), snippet.clone(),
article_url, article_url,
ll_url, oll_url,
mdl, mdl,
) )
.await .await
@@ -373,8 +373,8 @@ pub fn DashboardPage() -> Element {
chat_messages: chat_messages.read().clone(), chat_messages: chat_messages.read().clone(),
is_chatting: *is_chatting.read(), is_chatting: *is_chatting.read(),
on_chat_send: move |question: String| { on_chat_send: move |question: String| {
let ll_url = litellm_url.read().clone(); let oll_url = ollama_url.read().clone();
let mdl = litellm_model.read().clone(); let mdl = ollama_model.read().clone();
let ctx = article_context.read().clone(); let ctx = article_context.read().clone();
// Capture article info for News session creation // Capture article info for News session creation
let card_title = selected_card let card_title = selected_card
@@ -394,7 +394,7 @@ pub fn DashboardPage() -> Element {
content: question.clone(), content: question.clone(),
}); });
// Build full message history for LiteLLM // Build full message history for Ollama
let system_msg = format!( let system_msg = format!(
"You are a helpful assistant. The user is reading \ "You are a helpful assistant. The user is reading \
a news article. Use the following context to answer \ a news article. Use the following context to answer \
@@ -422,7 +422,7 @@ pub fn DashboardPage() -> Element {
match create_chat_session( match create_chat_session(
card_title, card_title,
"News".to_string(), "News".to_string(),
"litellm".to_string(), "ollama".to_string(),
mdl.clone(), mdl.clone(),
card_url, card_url,
) )
@@ -458,7 +458,7 @@ pub fn DashboardPage() -> Element {
} }
match crate::infrastructure::llm::chat_followup( match crate::infrastructure::llm::chat_followup(
msgs, ll_url, mdl, msgs, oll_url, mdl,
) )
.await .await
{ {
@@ -495,7 +495,7 @@ pub fn DashboardPage() -> Element {
// Right: sidebar (when no card selected) // Right: sidebar (when no card selected)
if !has_selection { if !has_selection {
DashboardSidebar { DashboardSidebar {
litellm_url: litellm_url.read().clone(), ollama_url: ollama_url.read().clone(),
trending: trending_topics.clone(), trending: trending_topics.clone(),
recent_searches: recent_searches.read().clone(), recent_searches: recent_searches.read().clone(),
on_topic_click: move |topic: String| { on_topic_click: move |topic: String| {

View File

@@ -1,5 +1,8 @@
use dioxus::prelude::*; 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 dioxus_free_icons::Icon;
use crate::i18n::{t, Locale}; use crate::i18n::{t, Locale};
@@ -9,15 +12,14 @@ use crate::Route;
/// ///
/// Displays a marketing-oriented page with hero section, feature grid, /// Displays a marketing-oriented page with hero section, feature grid,
/// how-it-works steps, and call-to-action banners. This page is accessible /// how-it-works steps, and call-to-action banners. This page is accessible
/// without authentication. Uses the Glass Aurora design with glassmorphic /// without authentication.
/// effects, aurora gradients, and centered hero layout.
#[component] #[component]
pub fn LandingPage() -> Element { pub fn LandingPage() -> Element {
rsx! { rsx! {
div { class: "landing", div { class: "landing",
LandingNav {} LandingNav {}
HeroSection {} HeroSection {}
TrustBar {} SocialProof {}
FeaturesGrid {} FeaturesGrid {}
HowItWorks {} HowItWorks {}
CtaBanner {} CtaBanner {}
@@ -27,7 +29,6 @@ pub fn LandingPage() -> Element {
} }
/// Sticky top navigation bar with logo, nav links, and CTA buttons. /// Sticky top navigation bar with logo, nav links, and CTA buttons.
/// Uses Glass Aurora glassmorphic nav with backdrop-filter blur.
#[component] #[component]
fn LandingNav() -> Element { fn LandingNav() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
@@ -68,8 +69,7 @@ fn LandingNav() -> Element {
} }
} }
/// Hero section with pill badges, headline, subtitle, CTA buttons, and /// Hero section with headline, subtitle, and CTA buttons.
/// a glass-preview stat panel. Centered layout per Glass Aurora design.
#[component] #[component]
fn HeroSection() -> Element { fn HeroSection() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
@@ -78,11 +78,7 @@ fn HeroSection() -> Element {
rsx! { rsx! {
section { class: "hero-section", section { class: "hero-section",
div { class: "hero-content", div { class: "hero-content",
div { class: "hero-pills", div { class: "hero-badge badge badge-outline", {t(l, "landing.badge")} }
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")} }
}
h1 { class: "hero-title", h1 { class: "hero-title",
{t(l, "landing.hero_title_1")} {t(l, "landing.hero_title_1")}
br {} br {}
@@ -104,26 +100,176 @@ fn HeroSection() -> Element {
{t(l, "landing.learn_more")} {t(l, "landing.learn_more")}
} }
} }
div { class: "preview-container", }
div { class: "glass-preview", div { class: "hero-graphic",
div { class: "preview-stat", // Abstract shield/network SVG motif
div { class: "preview-stat-value", "5" } svg {
div { class: "preview-stat-label", view_box: "0 0 400 400",
{t(l, "landing.preview_models")} 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)",
} }
} }
div { class: "preview-stat",
div { class: "preview-stat-value", "847K" }
div { class: "preview-stat-label",
{t(l, "landing.preview_tokens")}
} }
// Background glow
circle {
cx: "200",
cy: "200",
r: "180",
fill: "url(#glow)",
} }
div { class: "preview-stat", // Shield outline
div { class: "preview-stat-value", "$47.82" } path {
div { class: "preview-stat-label", d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \
{t(l, "landing.preview_spend")} 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",
} }
} }
} }
@@ -131,36 +277,44 @@ fn HeroSection() -> Element {
} }
} }
/// Trust bar with aurora dot indicators and stat labels. /// Social proof / trust indicator strip.
/// Replaces the previous text-based social proof section.
#[component] #[component]
fn TrustBar() -> Element { fn SocialProof() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
let l = *locale.read(); let l = *locale.read();
rsx! { rsx! {
section { class: "trust-bar", section { class: "social-proof",
div { class: "trust-item", p { class: "social-proof-text",
div { class: "trust-dot" } {t(l, "landing.social_proof")}
span { "100% " {t(l, "landing.on_premise")} } span { class: "social-proof-highlight", {t(l, "landing.data_sovereignty")} }
} }
div { class: "trust-item", div { class: "social-proof-stats",
div { class: "trust-dot" } div { class: "proof-stat",
span { "GDPR " {t(l, "landing.compliant")} } span { class: "proof-stat-value", "100%" }
span { class: "proof-stat-label", {t(l, "landing.on_premise")} }
} }
div { class: "trust-item", div { class: "proof-divider" }
div { class: "trust-dot" } div { class: "proof-stat",
span { "EU " {t(l, "landing.data_residency")} } 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")} }
} }
div { class: "trust-item",
div { class: "trust-dot" }
span { "Zero " {t(l, "landing.third_party")} }
} }
} }
} }
} }
/// Feature cards grid section. Uses gradient icon bars instead of SVG icons. /// Feature cards grid section.
#[component] #[component]
fn FeaturesGrid() -> Element { fn FeaturesGrid() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
@@ -174,26 +328,44 @@ fn FeaturesGrid() -> Element {
} }
div { class: "features-grid", div { class: "features-grid",
FeatureCard { FeatureCard {
icon: rsx! {
Icon { icon: BsServer, width: 28, height: 28 }
},
title: t(l, "landing.feat_infra_title"), title: t(l, "landing.feat_infra_title"),
description: t(l, "landing.feat_infra_desc"), description: t(l, "landing.feat_infra_desc"),
} }
FeatureCard { FeatureCard {
icon: rsx! {
Icon { icon: BsShieldCheck, width: 28, height: 28 }
},
title: t(l, "landing.feat_gdpr_title"), title: t(l, "landing.feat_gdpr_title"),
description: t(l, "landing.feat_gdpr_desc"), description: t(l, "landing.feat_gdpr_desc"),
} }
FeatureCard { FeatureCard {
icon: rsx! {
Icon { icon: FaCubes, width: 28, height: 28 }
},
title: t(l, "landing.feat_llm_title"), title: t(l, "landing.feat_llm_title"),
description: t(l, "landing.feat_llm_desc"), description: t(l, "landing.feat_llm_desc"),
} }
FeatureCard { FeatureCard {
icon: rsx! {
Icon { icon: BsRobot, width: 28, height: 28 }
},
title: t(l, "landing.feat_agent_title"), title: t(l, "landing.feat_agent_title"),
description: t(l, "landing.feat_agent_desc"), description: t(l, "landing.feat_agent_desc"),
} }
FeatureCard { FeatureCard {
icon: rsx! {
Icon { icon: BsGlobe2, width: 28, height: 28 }
},
title: t(l, "landing.feat_mcp_title"), title: t(l, "landing.feat_mcp_title"),
description: t(l, "landing.feat_mcp_desc"), description: t(l, "landing.feat_mcp_desc"),
} }
FeatureCard { FeatureCard {
icon: rsx! {
Icon { icon: BsKey, width: 28, height: 28 }
},
title: t(l, "landing.feat_api_title"), title: t(l, "landing.feat_api_title"),
description: t(l, "landing.feat_api_desc"), 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 /// # Arguments
/// ///
/// * `icon` - The icon element to display
/// * `title` - Feature title (owned String from translation lookup) /// * `title` - Feature title (owned String from translation lookup)
/// * `description` - Feature description text (owned String from translation lookup) /// * `description` - Feature description text (owned String from translation lookup)
#[component] #[component]
fn FeatureCard(title: String, description: String) -> Element { fn FeatureCard(icon: Element, title: String, description: String) -> Element {
rsx! { rsx! {
div { class: "card feature-card", div { class: "card feature-card",
div { class: "feature-icon-bar" } div { class: "feature-card-icon", {icon} }
h3 { class: "feature-card-title", "{title}" } h3 { class: "feature-card-title", "{title}" }
p { class: "feature-card-desc", "{description}" } p { class: "feature-card-desc", "{description}" }
} }
@@ -268,15 +441,14 @@ 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] #[component]
fn CtaBanner() -> Element { fn CtaBanner() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
let l = *locale.read(); let l = *locale.read();
rsx! { rsx! {
section { class: "cta-section", section { class: "cta-banner",
div { class: "cta-box",
h2 { class: "cta-title", {t(l, "landing.cta_title")} } h2 { class: "cta-title", {t(l, "landing.cta_title")} }
p { class: "cta-subtitle", p { class: "cta-subtitle",
{t(l, "landing.cta_subtitle")} {t(l, "landing.cta_subtitle")}
@@ -300,11 +472,9 @@ fn CtaBanner() -> Element {
} }
} }
} }
}
} }
/// Landing page footer with links and copyright. /// Landing page footer with links and copyright.
/// Uses glass border-top styling per Glass Aurora design.
#[component] #[component]
fn LandingFooter() -> Element { fn LandingFooter() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();

View File

@@ -2,14 +2,12 @@ use dioxus::prelude::*;
use crate::components::{MemberRow, PageHeader}; use crate::components::{MemberRow, PageHeader};
use crate::i18n::{t, tw, Locale}; use crate::i18n::{t, tw, Locale};
use crate::infrastructure::litellm::get_litellm_usage; use crate::models::{BillingUsage, MemberRole, OrgMember};
use crate::models::{BillingUsage, LitellmUsageStats, MemberRole, OrgMember};
/// Organization dashboard with billing stats, member table, and invite modal. /// Organization dashboard with billing stats, member table, and invite modal.
/// ///
/// Shows current billing usage (fetched from LiteLLM), a per-model /// Shows current billing usage, a table of organization members
/// breakdown table, a table of organization members with role /// with role management, and a button to invite new members.
/// management, and a button to invite new members.
#[component] #[component]
pub fn OrgDashboardPage() -> Element { pub fn OrgDashboardPage() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
@@ -22,20 +20,6 @@ pub fn OrgDashboardPage() -> Element {
let members_list = members.read().clone(); 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 // Format token counts for display
let tokens_display = format_tokens(usage.tokens_used); let tokens_display = format_tokens(usage.tokens_used);
let tokens_limit_display = format_tokens(usage.tokens_limit); let tokens_limit_display = format_tokens(usage.tokens_limit);
@@ -46,39 +30,26 @@ pub fn OrgDashboardPage() -> Element {
title: t(l, "org.title"), title: t(l, "org.title"),
subtitle: t(l, "org.subtitle"), subtitle: t(l, "org.subtitle"),
actions: rsx! { actions: rsx! {
button { button { class: "btn-primary", onclick: move |_| show_invite.set(true), {t(l, "org.invite_member")} }
class: "btn-primary",
onclick: move |_| show_invite.set(true),
{t(l, "org.invite_member")}
}
}, },
} }
// Stats bar // Stats bar
div { class: "org-stats-bar", div { class: "org-stats-bar",
div { class: "org-stat", div { class: "org-stat",
span { class: "org-stat-value", span { class: "org-stat-value", "{usage.seats_used}/{usage.seats_total}" }
"{usage.seats_used}/{usage.seats_total}"
}
span { class: "org-stat-label", {t(l, "org.seats_used")} } span { class: "org-stat-label", {t(l, "org.seats_used")} }
} }
div { class: "org-stat", div { class: "org-stat",
span { class: "org-stat-value", "{tokens_display}" } span { class: "org-stat-value", "{tokens_display}" }
span { class: "org-stat-label", span { class: "org-stat-label", {tw(l, "org.of_tokens", &[("limit", &tokens_limit_display)])} }
{tw(l, "org.of_tokens", &[("limit", &tokens_limit_display)])}
}
} }
div { class: "org-stat", div { class: "org-stat",
span { class: "org-stat-value", span { class: "org-stat-value", "{usage.billing_cycle_end}" }
"{usage.billing_cycle_end}"
}
span { class: "org-stat-label", {t(l, "org.cycle_ends")} } span { class: "org-stat-label", {t(l, "org.cycle_ends")} }
} }
} }
// LiteLLM usage stats section
{render_usage_section(l, &usage_snapshot)}
// Members table // Members table
div { class: "org-table-wrapper", div { class: "org-table-wrapper",
table { class: "org-table", 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"). /// Formats a token count into a human-readable string (e.g. "1.2M").
fn format_tokens(count: u64) -> String { fn format_tokens(count: u64) -> String {
const M: u64 = 1_000_000; const M: u64 = 1_000_000;

View File

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