Compare commits
7 Commits
feat/litel
...
99967e0082
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99967e0082 | ||
|
|
833320b21e | ||
|
|
79e93eac08 | ||
|
|
5f3939bb6c | ||
|
|
e13afb9d1b | ||
|
|
809ca15979 | ||
|
|
74a225224c |
12
.env.example
12
.env.example
@@ -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]
|
||||||
@@ -67,11 +66,10 @@ STRIPE_WEBHOOK_SECRET=
|
|||||||
STRIPE_PUBLISHABLE_KEY=
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# LangChain / LangGraph / LangFlow / Langfuse [OPTIONAL]
|
# LangChain / LangGraph / Langfuse [OPTIONAL]
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
LANGCHAIN_URL=
|
LANGCHAIN_URL=
|
||||||
LANGGRAPH_URL=
|
LANGGRAPH_URL=
|
||||||
LANGFLOW_URL=
|
|
||||||
LANGFUSE_URL=
|
LANGFUSE_URL=
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -120,139 +120,6 @@ jobs:
|
|||||||
run: sccache --show-stats
|
run: sccache --show-stats
|
||||||
if: always()
|
if: always()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Stage 4: E2E tests (only on main, after deploy)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
e2e:
|
|
||||||
name: E2E Tests
|
|
||||||
runs-on: docker
|
|
||||||
needs: [deploy]
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
container:
|
|
||||||
image: rust:1.89-bookworm
|
|
||||||
# MongoDB and SearXNG can start immediately (no repo files needed).
|
|
||||||
# Keycloak requires realm-export.json from the repo, so it is started
|
|
||||||
# manually after checkout via docker CLI.
|
|
||||||
services:
|
|
||||||
mongo:
|
|
||||||
image: mongo:latest
|
|
||||||
env:
|
|
||||||
MONGO_INITDB_ROOT_USERNAME: root
|
|
||||||
MONGO_INITDB_ROOT_PASSWORD: example
|
|
||||||
ports:
|
|
||||||
- 27017:27017
|
|
||||||
searxng:
|
|
||||||
image: searxng/searxng:latest
|
|
||||||
env:
|
|
||||||
SEARXNG_BASE_URL: http://localhost:8888
|
|
||||||
ports:
|
|
||||||
- 8888:8080
|
|
||||||
env:
|
|
||||||
KEYCLOAK_URL: http://localhost:8080
|
|
||||||
KEYCLOAK_REALM: certifai
|
|
||||||
KEYCLOAK_CLIENT_ID: certifai-dashboard
|
|
||||||
MONGODB_URI: mongodb://root:example@mongo:27017
|
|
||||||
MONGODB_DATABASE: certifai
|
|
||||||
SEARXNG_URL: http://searxng:8080
|
|
||||||
LANGGRAPH_URL: ""
|
|
||||||
LANGFLOW_URL: ""
|
|
||||||
LANGFUSE_URL: ""
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
run: |
|
|
||||||
git init
|
|
||||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
|
||||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
|
||||||
git checkout FETCH_HEAD
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq --no-install-recommends \
|
|
||||||
unzip curl docker.io \
|
|
||||||
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 \
|
|
||||||
libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
|
|
||||||
libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
|
|
||||||
libcairo2 libasound2 libatspi2.0-0 libxshmfence1
|
|
||||||
- name: Start Keycloak
|
|
||||||
run: |
|
|
||||||
docker run -d --name ci-keycloak --network host \
|
|
||||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
|
||||||
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
|
||||||
-e KC_DB=dev-mem \
|
|
||||||
-e KC_HEALTH_ENABLED=true \
|
|
||||||
-v "$PWD/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro" \
|
|
||||||
-v "$PWD/keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro" \
|
|
||||||
quay.io/keycloak/keycloak:26.0 start-dev --import-realm
|
|
||||||
|
|
||||||
echo "Waiting for Keycloak..."
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if curl -sf http://localhost:8080/realms/certifai > /dev/null 2>&1; then
|
|
||||||
echo "Keycloak is ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$i" -eq 60 ]; then
|
|
||||||
echo "Keycloak failed to start within 60s"
|
|
||||||
docker logs ci-keycloak
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
- name: Install sccache
|
|
||||||
run: |
|
|
||||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
|
|
||||||
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
|
|
||||||
chmod +x /usr/local/bin/sccache
|
|
||||||
- name: Install dioxus-cli
|
|
||||||
run: cargo install dioxus-cli --locked
|
|
||||||
- name: Install bun
|
|
||||||
run: |
|
|
||||||
curl -fsSL https://bun.sh/install | bash
|
|
||||||
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
|
||||||
- name: Install Playwright
|
|
||||||
run: |
|
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
|
||||||
bun install
|
|
||||||
bunx playwright install chromium
|
|
||||||
- name: Build app
|
|
||||||
run: dx build --release
|
|
||||||
- name: Start app and run E2E tests
|
|
||||||
run: |
|
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
|
||||||
# Start the app in the background
|
|
||||||
dx serve --release --port 8000 &
|
|
||||||
APP_PID=$!
|
|
||||||
|
|
||||||
# Wait for the app to be ready
|
|
||||||
echo "Waiting for app to start..."
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if curl -sf http://localhost:8000 > /dev/null 2>&1; then
|
|
||||||
echo "App is ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$i" -eq 60 ]; then
|
|
||||||
echo "App failed to start within 60s"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
BASE_URL=http://localhost:8000 bunx playwright test --reporter=list
|
|
||||||
|
|
||||||
kill "$APP_PID" 2>/dev/null || true
|
|
||||||
- name: Upload test report
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 7
|
|
||||||
- name: Cleanup Keycloak
|
|
||||||
if: always()
|
|
||||||
run: docker rm -f ci-keycloak 2>/dev/null || true
|
|
||||||
- name: Show sccache stats
|
|
||||||
run: sccache --show-stats
|
|
||||||
if: always()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Stage 3: Deploy (only after tests pass, only on main)
|
# Stage 3: Deploy (only after tests pass, only on main)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,8 +22,3 @@ keycloak/*
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
searxng/
|
searxng/
|
||||||
|
|
||||||
# Playwright
|
|
||||||
e2e/.auth/
|
|
||||||
playwright-report/
|
|
||||||
test-results/
|
|
||||||
|
|||||||
66
Cargo.lock
generated
66
Cargo.lock
generated
@@ -773,11 +773,9 @@ dependencies = [
|
|||||||
"dioxus-sdk",
|
"dioxus-sdk",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
"js-sys",
|
|
||||||
"maud",
|
"maud",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
"petname",
|
"petname",
|
||||||
"pretty_assertions",
|
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
@@ -785,7 +783,6 @@ dependencies = [
|
|||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
@@ -885,12 +882,6 @@ dependencies = [
|
|||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "diff"
|
|
||||||
version = "0.1.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -3255,16 +3246,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pretty_assertions"
|
|
||||||
version = "1.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
|
|
||||||
dependencies = [
|
|
||||||
"diff",
|
|
||||||
"yansi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -3842,15 +3823,6 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "scc"
|
|
||||||
version = "2.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
|
||||||
dependencies = [
|
|
||||||
"sdd",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.28"
|
version = "0.1.28"
|
||||||
@@ -3890,12 +3862,6 @@ dependencies = [
|
|||||||
"untrusted",
|
"untrusted",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sdd"
|
|
||||||
version = "3.0.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrecy"
|
name = "secrecy"
|
||||||
version = "0.10.3"
|
version = "0.10.3"
|
||||||
@@ -4116,32 +4082,6 @@ dependencies = [
|
|||||||
"syn 2.0.116",
|
"syn 2.0.116",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serial_test"
|
|
||||||
version = "3.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
|
|
||||||
dependencies = [
|
|
||||||
"futures-executor",
|
|
||||||
"futures-util",
|
|
||||||
"log",
|
|
||||||
"once_cell",
|
|
||||||
"parking_lot",
|
|
||||||
"scc",
|
|
||||||
"serial_test_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serial_test_derive"
|
|
||||||
version = "3.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.116",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "servo_arc"
|
name = "servo_arc"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -5743,12 +5683,6 @@ version = "0.8.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "yansi"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yazi"
|
name = "yazi"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -113,10 +112,6 @@ server = [
|
|||||||
"dep:bytes",
|
"dep:bytes",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
pretty_assertions = "1.4"
|
|
||||||
serial_test = "3.2"
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "dashboard"
|
name = "dashboard"
|
||||||
path = "bin/main.rs"
|
path = "bin/main.rs"
|
||||||
|
|||||||
@@ -58,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"
|
||||||
},
|
},
|
||||||
@@ -96,38 +96,7 @@
|
|||||||
"total_requests": "Anfragen gesamt",
|
"total_requests": "Anfragen gesamt",
|
||||||
"avg_latency": "Durchschn. Latenz",
|
"avg_latency": "Durchschn. Latenz",
|
||||||
"tokens_used": "Verbrauchte Token",
|
"tokens_used": "Verbrauchte Token",
|
||||||
"error_rate": "Fehlerrate",
|
"error_rate": "Fehlerrate"
|
||||||
"not_configured": "Nicht konfiguriert",
|
|
||||||
"open_new_tab": "In neuem Tab oeffnen",
|
|
||||||
"agents_status_connected": "Verbunden",
|
|
||||||
"agents_status_not_connected": "Nicht verbunden",
|
|
||||||
"agents_config_hint": "Setzen Sie LANGGRAPH_URL in .env, um eine Verbindung herzustellen",
|
|
||||||
"agents_quick_start": "Schnellstart",
|
|
||||||
"agents_docs": "Dokumentation",
|
|
||||||
"agents_docs_desc": "Offizielle LangGraph-Dokumentation und API-Anleitungen.",
|
|
||||||
"agents_getting_started": "Erste Schritte",
|
|
||||||
"agents_getting_started_desc": "Schritt-fuer-Schritt-Anleitung zum Erstellen Ihres ersten Agenten.",
|
|
||||||
"agents_github": "GitHub",
|
|
||||||
"agents_github_desc": "Quellcode, Issues und Community-Beitraege.",
|
|
||||||
"agents_examples": "Beispiele",
|
|
||||||
"agents_examples_desc": "Einsatzbereite Vorlagen und Beispielprojekte fuer Agenten.",
|
|
||||||
"agents_api_ref": "API-Referenz",
|
|
||||||
"agents_api_ref_desc": "Lokale Swagger-Dokumentation fuer Ihre LangGraph-Instanz.",
|
|
||||||
"agents_running_title": "Laufende Agenten",
|
|
||||||
"agents_none": "Keine Agenten registriert. Stellen Sie einen Assistenten in LangGraph bereit, um ihn hier zu sehen.",
|
|
||||||
"agents_col_name": "Name",
|
|
||||||
"agents_col_id": "ID",
|
|
||||||
"agents_col_description": "Beschreibung",
|
|
||||||
"agents_col_status": "Status",
|
|
||||||
"analytics_status_connected": "Verbunden",
|
|
||||||
"analytics_status_not_connected": "Nicht verbunden",
|
|
||||||
"analytics_config_hint": "Setzen Sie LANGFUSE_URL in .env, um eine Verbindung herzustellen",
|
|
||||||
"analytics_sso_hint": "Langfuse nutzt Keycloak-SSO. Sie werden automatisch mit Ihrem CERTifAI-Konto angemeldet.",
|
|
||||||
"analytics_quick_actions": "Schnellaktionen",
|
|
||||||
"analytics_traces": "Traces",
|
|
||||||
"analytics_traces_desc": "Alle LLM-Aufrufe, Latenzen und Token-Verbrauch anzeigen und filtern.",
|
|
||||||
"analytics_dashboard": "Dashboard",
|
|
||||||
"analytics_dashboard_desc": "Ueberblick ueber Kosten, Qualitaetsmetriken und Nutzungstrends."
|
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organisation",
|
"title": "Organisation",
|
||||||
@@ -144,16 +113,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,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"
|
||||||
},
|
},
|
||||||
@@ -96,38 +96,7 @@
|
|||||||
"total_requests": "Total Requests",
|
"total_requests": "Total Requests",
|
||||||
"avg_latency": "Avg Latency",
|
"avg_latency": "Avg Latency",
|
||||||
"tokens_used": "Tokens Used",
|
"tokens_used": "Tokens Used",
|
||||||
"error_rate": "Error Rate",
|
"error_rate": "Error Rate"
|
||||||
"not_configured": "Not Configured",
|
|
||||||
"open_new_tab": "Open in New Tab",
|
|
||||||
"agents_status_connected": "Connected",
|
|
||||||
"agents_status_not_connected": "Not Connected",
|
|
||||||
"agents_config_hint": "Set LANGGRAPH_URL in .env to connect",
|
|
||||||
"agents_quick_start": "Quick Start",
|
|
||||||
"agents_docs": "Documentation",
|
|
||||||
"agents_docs_desc": "Official LangGraph documentation and API guides.",
|
|
||||||
"agents_getting_started": "Getting Started",
|
|
||||||
"agents_getting_started_desc": "Step-by-step tutorial to build your first agent.",
|
|
||||||
"agents_github": "GitHub",
|
|
||||||
"agents_github_desc": "Source code, issues, and community contributions.",
|
|
||||||
"agents_examples": "Examples",
|
|
||||||
"agents_examples_desc": "Ready-to-use templates and example agent projects.",
|
|
||||||
"agents_api_ref": "API Reference",
|
|
||||||
"agents_api_ref_desc": "Local Swagger docs for your LangGraph instance.",
|
|
||||||
"agents_running_title": "Running Agents",
|
|
||||||
"agents_none": "No agents registered. Deploy an assistant to LangGraph to see it here.",
|
|
||||||
"agents_col_name": "Name",
|
|
||||||
"agents_col_id": "ID",
|
|
||||||
"agents_col_description": "Description",
|
|
||||||
"agents_col_status": "Status",
|
|
||||||
"analytics_status_connected": "Connected",
|
|
||||||
"analytics_status_not_connected": "Not Connected",
|
|
||||||
"analytics_config_hint": "Set LANGFUSE_URL in .env to connect",
|
|
||||||
"analytics_sso_hint": "Langfuse uses Keycloak SSO. You will be signed in automatically with your CERTifAI account.",
|
|
||||||
"analytics_quick_actions": "Quick Actions",
|
|
||||||
"analytics_traces": "Traces",
|
|
||||||
"analytics_traces_desc": "View and filter all LLM call traces, latencies, and token usage.",
|
|
||||||
"analytics_dashboard": "Dashboard",
|
|
||||||
"analytics_dashboard_desc": "Overview of costs, quality metrics, and usage trends."
|
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organization",
|
"title": "Organization",
|
||||||
@@ -144,16 +113,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,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"
|
||||||
},
|
},
|
||||||
@@ -96,38 +96,7 @@
|
|||||||
"total_requests": "Total de solicitudes",
|
"total_requests": "Total de solicitudes",
|
||||||
"avg_latency": "Latencia promedio",
|
"avg_latency": "Latencia promedio",
|
||||||
"tokens_used": "Tokens utilizados",
|
"tokens_used": "Tokens utilizados",
|
||||||
"error_rate": "Tasa de errores",
|
"error_rate": "Tasa de errores"
|
||||||
"not_configured": "No configurado",
|
|
||||||
"open_new_tab": "Abrir en nueva pestana",
|
|
||||||
"agents_status_connected": "Conectado",
|
|
||||||
"agents_status_not_connected": "No conectado",
|
|
||||||
"agents_config_hint": "Configure LANGGRAPH_URL en .env para conectar",
|
|
||||||
"agents_quick_start": "Inicio rapido",
|
|
||||||
"agents_docs": "Documentacion",
|
|
||||||
"agents_docs_desc": "Documentacion oficial de LangGraph y guias de API.",
|
|
||||||
"agents_getting_started": "Primeros pasos",
|
|
||||||
"agents_getting_started_desc": "Tutorial paso a paso para crear su primer agente.",
|
|
||||||
"agents_github": "GitHub",
|
|
||||||
"agents_github_desc": "Codigo fuente, issues y contribuciones de la comunidad.",
|
|
||||||
"agents_examples": "Ejemplos",
|
|
||||||
"agents_examples_desc": "Plantillas y proyectos de agentes listos para usar.",
|
|
||||||
"agents_api_ref": "Referencia API",
|
|
||||||
"agents_api_ref_desc": "Documentacion Swagger local para su instancia de LangGraph.",
|
|
||||||
"agents_running_title": "Agentes en ejecucion",
|
|
||||||
"agents_none": "No hay agentes registrados. Despliegue un asistente en LangGraph para verlo aqui.",
|
|
||||||
"agents_col_name": "Nombre",
|
|
||||||
"agents_col_id": "ID",
|
|
||||||
"agents_col_description": "Descripcion",
|
|
||||||
"agents_col_status": "Estado",
|
|
||||||
"analytics_status_connected": "Conectado",
|
|
||||||
"analytics_status_not_connected": "No conectado",
|
|
||||||
"analytics_config_hint": "Configure LANGFUSE_URL en .env para conectar",
|
|
||||||
"analytics_sso_hint": "Langfuse utiliza SSO de Keycloak. Iniciara sesion automaticamente con su cuenta CERTifAI.",
|
|
||||||
"analytics_quick_actions": "Acciones rapidas",
|
|
||||||
"analytics_traces": "Trazas",
|
|
||||||
"analytics_traces_desc": "Ver y filtrar todas las llamadas LLM, latencias y uso de tokens.",
|
|
||||||
"analytics_dashboard": "Panel de control",
|
|
||||||
"analytics_dashboard_desc": "Resumen de costos, metricas de calidad y tendencias de uso."
|
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organizacion",
|
"title": "Organizacion",
|
||||||
@@ -144,16 +113,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,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"
|
||||||
},
|
},
|
||||||
@@ -96,38 +96,7 @@
|
|||||||
"total_requests": "Requetes totales",
|
"total_requests": "Requetes totales",
|
||||||
"avg_latency": "Latence moyenne",
|
"avg_latency": "Latence moyenne",
|
||||||
"tokens_used": "Tokens utilises",
|
"tokens_used": "Tokens utilises",
|
||||||
"error_rate": "Taux d'erreur",
|
"error_rate": "Taux d'erreur"
|
||||||
"not_configured": "Non configure",
|
|
||||||
"open_new_tab": "Ouvrir dans un nouvel onglet",
|
|
||||||
"agents_status_connected": "Connecte",
|
|
||||||
"agents_status_not_connected": "Non connecte",
|
|
||||||
"agents_config_hint": "Definissez LANGGRAPH_URL dans .env pour vous connecter",
|
|
||||||
"agents_quick_start": "Demarrage rapide",
|
|
||||||
"agents_docs": "Documentation",
|
|
||||||
"agents_docs_desc": "Documentation officielle de LangGraph et guides API.",
|
|
||||||
"agents_getting_started": "Premiers pas",
|
|
||||||
"agents_getting_started_desc": "Tutoriel etape par etape pour creer votre premier agent.",
|
|
||||||
"agents_github": "GitHub",
|
|
||||||
"agents_github_desc": "Code source, issues et contributions de la communaute.",
|
|
||||||
"agents_examples": "Exemples",
|
|
||||||
"agents_examples_desc": "Modeles et projets d'agents prets a l'emploi.",
|
|
||||||
"agents_api_ref": "Reference API",
|
|
||||||
"agents_api_ref_desc": "Documentation Swagger locale pour votre instance LangGraph.",
|
|
||||||
"agents_running_title": "Agents en cours",
|
|
||||||
"agents_none": "Aucun agent enregistre. Deployez un assistant dans LangGraph pour le voir ici.",
|
|
||||||
"agents_col_name": "Nom",
|
|
||||||
"agents_col_id": "ID",
|
|
||||||
"agents_col_description": "Description",
|
|
||||||
"agents_col_status": "Statut",
|
|
||||||
"analytics_status_connected": "Connecte",
|
|
||||||
"analytics_status_not_connected": "Non connecte",
|
|
||||||
"analytics_config_hint": "Definissez LANGFUSE_URL dans .env pour vous connecter",
|
|
||||||
"analytics_sso_hint": "Langfuse utilise le SSO Keycloak. Vous serez connecte automatiquement avec votre compte CERTifAI.",
|
|
||||||
"analytics_quick_actions": "Actions rapides",
|
|
||||||
"analytics_traces": "Traces",
|
|
||||||
"analytics_traces_desc": "Afficher et filtrer tous les appels LLM, latences et consommation de tokens.",
|
|
||||||
"analytics_dashboard": "Tableau de bord",
|
|
||||||
"analytics_dashboard_desc": "Apercu des couts, metriques de qualite et tendances d'utilisation."
|
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organisation",
|
"title": "Organisation",
|
||||||
@@ -144,16 +113,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,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"
|
||||||
},
|
},
|
||||||
@@ -96,38 +96,7 @@
|
|||||||
"total_requests": "Total de Pedidos",
|
"total_requests": "Total de Pedidos",
|
||||||
"avg_latency": "Latencia Media",
|
"avg_latency": "Latencia Media",
|
||||||
"tokens_used": "Tokens Utilizados",
|
"tokens_used": "Tokens Utilizados",
|
||||||
"error_rate": "Taxa de Erros",
|
"error_rate": "Taxa de Erros"
|
||||||
"not_configured": "Nao configurado",
|
|
||||||
"open_new_tab": "Abrir em novo separador",
|
|
||||||
"agents_status_connected": "Conectado",
|
|
||||||
"agents_status_not_connected": "Nao conectado",
|
|
||||||
"agents_config_hint": "Defina LANGGRAPH_URL no .env para conectar",
|
|
||||||
"agents_quick_start": "Inicio rapido",
|
|
||||||
"agents_docs": "Documentacao",
|
|
||||||
"agents_docs_desc": "Documentacao oficial do LangGraph e guias de API.",
|
|
||||||
"agents_getting_started": "Primeiros passos",
|
|
||||||
"agents_getting_started_desc": "Tutorial passo a passo para criar o seu primeiro agente.",
|
|
||||||
"agents_github": "GitHub",
|
|
||||||
"agents_github_desc": "Codigo fonte, issues e contribuicoes da comunidade.",
|
|
||||||
"agents_examples": "Exemplos",
|
|
||||||
"agents_examples_desc": "Modelos e projetos de agentes prontos a usar.",
|
|
||||||
"agents_api_ref": "Referencia API",
|
|
||||||
"agents_api_ref_desc": "Documentacao Swagger local para a sua instancia LangGraph.",
|
|
||||||
"agents_running_title": "Agentes em execucao",
|
|
||||||
"agents_none": "Nenhum agente registado. Implemente um assistente no LangGraph para o ver aqui.",
|
|
||||||
"agents_col_name": "Nome",
|
|
||||||
"agents_col_id": "ID",
|
|
||||||
"agents_col_description": "Descricao",
|
|
||||||
"agents_col_status": "Estado",
|
|
||||||
"analytics_status_connected": "Conectado",
|
|
||||||
"analytics_status_not_connected": "Nao conectado",
|
|
||||||
"analytics_config_hint": "Defina LANGFUSE_URL no .env para conectar",
|
|
||||||
"analytics_sso_hint": "O Langfuse utiliza SSO do Keycloak. Sera autenticado automaticamente com a sua conta CERTifAI.",
|
|
||||||
"analytics_quick_actions": "Acoes rapidas",
|
|
||||||
"analytics_traces": "Traces",
|
|
||||||
"analytics_traces_desc": "Ver e filtrar todas as chamadas LLM, latencias e uso de tokens.",
|
|
||||||
"analytics_dashboard": "Painel",
|
|
||||||
"analytics_dashboard_desc": "Resumo de custos, metricas de qualidade e tendencias de uso."
|
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organizacao",
|
"title": "Organizacao",
|
||||||
@@ -144,16 +113,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"
|
||||||
},
|
},
|
||||||
|
|||||||
390
assets/main.css
390
assets/main.css
@@ -2591,58 +2591,6 @@ h6 {
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Tool Embed (iframe integration) ===== */
|
|
||||||
.tool-embed {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
height: calc(100vh - 60px);
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-embed-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 20px;
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
border-bottom: 1px solid var(--border-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-embed-title {
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-embed-popout-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent);
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
border-radius: 6px;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-embed-popout-btn:hover {
|
|
||||||
background-color: var(--accent);
|
|
||||||
color: var(--bg-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-embed-iframe {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Analytics Stats Bar ===== */
|
/* ===== Analytics Stats Bar ===== */
|
||||||
.analytics-stats-bar {
|
.analytics-stats-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3375,341 +3323,3 @@ h6 {
|
|||||||
padding: 20px 16px;
|
padding: 20px 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Agents Page ===== */
|
|
||||||
.agents-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 32px;
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-hero {
|
|
||||||
max-width: 720px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-hero-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-hero-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
min-width: 48px;
|
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
|
||||||
color: var(--avatar-text);
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-hero-title {
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-heading);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-hero-desc {
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-status-dot--on {
|
|
||||||
background-color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-status-dot--off {
|
|
||||||
background-color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-status-url {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-status-hint {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-section-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-heading);
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-card {
|
|
||||||
display: block;
|
|
||||||
text-decoration: none;
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-primary);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
transition: border-color 0.2s, transform 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-card:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-card-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
min-width: 36px;
|
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
|
||||||
color: var(--avatar-text);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-card-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-heading);
|
|
||||||
margin: 12px 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-card-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-card--disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
pointer-events: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- Agents table -- */
|
|
||||||
.agents-table-section {
|
|
||||||
max-width: 960px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-table thead th {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-faint);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-table tbody td {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-table tbody tr:hover {
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-cell-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-heading);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-cell-id {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-cell-desc {
|
|
||||||
max-width: 300px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-cell-none {
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-badge--active {
|
|
||||||
background-color: rgba(34, 197, 94, 0.15);
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-table-loading,
|
|
||||||
.agents-table-empty {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-style: italic;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.agents-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.agents-page,
|
|
||||||
.analytics-page {
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Analytics Page ===== */
|
|
||||||
.analytics-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 32px;
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-hero {
|
|
||||||
max-width: 720px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-hero-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-hero-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
min-width: 48px;
|
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
|
||||||
color: var(--avatar-text);
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-hero-title {
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-heading);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-hero-desc {
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-sso-hint {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-launch-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
|
||||||
color: var(--avatar-text);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-launch-btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-stats-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.analytics-stats-bar {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
@@ -162,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;
|
||||||
@@ -1057,98 +1110,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.range {
|
.chat-bubble {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
appearance: none;
|
position: relative;
|
||||||
webkit-appearance: none;
|
display: block;
|
||||||
--range-thumb: var(--color-base-100);
|
width: fit-content;
|
||||||
--range-thumb-size: calc(var(--size-selector, 0.25rem) * 6);
|
border-radius: var(--radius-field);
|
||||||
--range-progress: currentColor;
|
background-color: var(--color-base-300);
|
||||||
--range-fill: 1;
|
padding-inline: calc(0.25rem * 4);
|
||||||
--range-p: 0.25rem;
|
padding-block: calc(0.25rem * 2);
|
||||||
--range-bg: currentColor;
|
color: var(--color-base-content);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
grid-row-end: 3;
|
||||||
--range-bg: color-mix(in oklab, currentColor 10%, #0000);
|
min-height: 2rem;
|
||||||
}
|
min-width: 2.5rem;
|
||||||
cursor: pointer;
|
max-width: 90%;
|
||||||
overflow: hidden;
|
&:before {
|
||||||
background-color: transparent;
|
position: absolute;
|
||||||
vertical-align: middle;
|
bottom: calc(0.25rem * 0);
|
||||||
width: clamp(3rem, 20rem, 100%);
|
height: calc(0.25rem * 3);
|
||||||
--radius-selector-max: calc(
|
width: calc(0.25rem * 3);
|
||||||
var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
|
background-color: inherit;
|
||||||
);
|
content: "";
|
||||||
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
|
mask-repeat: no-repeat;
|
||||||
border: none;
|
mask-image: var(--mask-chat);
|
||||||
height: var(--range-thumb-size);
|
mask-position: 0px -1px;
|
||||||
[dir="rtl"] & {
|
mask-size: 0.8125rem;
|
||||||
--range-dir: -1;
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
&::-webkit-slider-runnable-track {
|
|
||||||
width: 100%;
|
|
||||||
background-color: var(--range-bg);
|
|
||||||
border-radius: var(--radius-selector);
|
|
||||||
height: calc(var(--range-thumb-size) * 0.5);
|
|
||||||
}
|
|
||||||
@media (forced-colors: active) {
|
|
||||||
&::-webkit-slider-runnable-track {
|
|
||||||
border: 1px solid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (forced-colors: active) {
|
|
||||||
&::-moz-range-track {
|
|
||||||
border: 1px solid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&::-webkit-slider-thumb {
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
|
|
||||||
background-color: var(--range-thumb);
|
|
||||||
height: var(--range-thumb-size);
|
|
||||||
width: var(--range-thumb-size);
|
|
||||||
border: var(--range-p) solid;
|
|
||||||
appearance: none;
|
|
||||||
webkit-appearance: none;
|
|
||||||
top: 50%;
|
|
||||||
color: var(--range-progress);
|
|
||||||
transform: translateY(-50%);
|
|
||||||
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
|
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
|
||||||
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&::-moz-range-track {
|
|
||||||
width: 100%;
|
|
||||||
background-color: var(--range-bg);
|
|
||||||
border-radius: var(--radius-selector);
|
|
||||||
height: calc(var(--range-thumb-size) * 0.5);
|
|
||||||
}
|
|
||||||
&::-moz-range-thumb {
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
|
|
||||||
background-color: currentColor;
|
|
||||||
height: var(--range-thumb-size);
|
|
||||||
width: var(--range-thumb-size);
|
|
||||||
border: var(--range-p) solid;
|
|
||||||
top: 50%;
|
|
||||||
color: var(--range-progress);
|
|
||||||
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
|
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
|
||||||
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 30%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1539,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;
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -8,7 +8,6 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.52.0",
|
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -17,8 +16,6 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
||||||
@@ -27,12 +24,6 @@
|
|||||||
|
|
||||||
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
|
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
|
||||||
|
|
||||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
|
||||||
|
|
||||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -95,164 +94,5 @@ services:
|
|||||||
- ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro
|
- ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro
|
||||||
- librechat-data:/app/data
|
- librechat-data:/app/data
|
||||||
|
|
||||||
langflow:
|
|
||||||
image: langflowai/langflow:latest
|
|
||||||
container_name: certifai-langflow
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "7860:7860"
|
|
||||||
environment:
|
|
||||||
LANGFLOW_AUTO_LOGIN: "true"
|
|
||||||
|
|
||||||
langgraph:
|
|
||||||
image: langchain/langgraph-trial:3.12
|
|
||||||
container_name: certifai-langgraph
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
langgraph-db:
|
|
||||||
condition: service_started
|
|
||||||
langgraph-redis:
|
|
||||||
condition: service_started
|
|
||||||
ports:
|
|
||||||
- "8123:8000"
|
|
||||||
environment:
|
|
||||||
DATABASE_URI: postgresql://langgraph:langgraph@langgraph-db:5432/langgraph
|
|
||||||
REDIS_URI: redis://langgraph-redis:6379
|
|
||||||
|
|
||||||
langgraph-db:
|
|
||||||
image: postgres:16
|
|
||||||
container_name: certifai-langgraph-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: langgraph
|
|
||||||
POSTGRES_PASSWORD: langgraph
|
|
||||||
POSTGRES_DB: langgraph
|
|
||||||
volumes:
|
|
||||||
- langgraph-db-data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
langgraph-redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: certifai-langgraph-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
langfuse:
|
|
||||||
image: langfuse/langfuse:3
|
|
||||||
container_name: certifai-langfuse
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
keycloak:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-db:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-clickhouse:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-redis:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-minio:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://langfuse:langfuse@langfuse-db:5432/langfuse
|
|
||||||
NEXTAUTH_URL: http://localhost:3000
|
|
||||||
NEXTAUTH_SECRET: certifai-langfuse-dev-secret
|
|
||||||
SALT: certifai-langfuse-dev-salt
|
|
||||||
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
|
|
||||||
# Keycloak OIDC SSO - shared realm with CERTifAI dashboard
|
|
||||||
AUTH_KEYCLOAK_CLIENT_ID: certifai-langfuse
|
|
||||||
AUTH_KEYCLOAK_CLIENT_SECRET: certifai-langfuse-secret
|
|
||||||
AUTH_KEYCLOAK_ISSUER: http://keycloak:8080/realms/certifai
|
|
||||||
AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING: "true"
|
|
||||||
# Disable local email/password auth (SSO only)
|
|
||||||
AUTH_DISABLE_USERNAME_PASSWORD: "true"
|
|
||||||
CLICKHOUSE_URL: http://langfuse-clickhouse:8123
|
|
||||||
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
|
|
||||||
CLICKHOUSE_USER: clickhouse
|
|
||||||
CLICKHOUSE_PASSWORD: clickhouse
|
|
||||||
CLICKHOUSE_CLUSTER_ENABLED: "false"
|
|
||||||
REDIS_HOST: langfuse-redis
|
|
||||||
REDIS_PORT: "6379"
|
|
||||||
REDIS_AUTH: langfuse-dev-redis
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_REGION: auto
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://langfuse-minio:9000
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://langfuse-minio:9000
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
|
|
||||||
|
|
||||||
langfuse-db:
|
|
||||||
image: postgres:16
|
|
||||||
container_name: certifai-langfuse-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: langfuse
|
|
||||||
POSTGRES_PASSWORD: langfuse
|
|
||||||
POSTGRES_DB: langfuse
|
|
||||||
volumes:
|
|
||||||
- langfuse-db-data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U langfuse"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
langfuse-clickhouse:
|
|
||||||
image: clickhouse/clickhouse-server:latest
|
|
||||||
container_name: certifai-langfuse-clickhouse
|
|
||||||
restart: unless-stopped
|
|
||||||
user: "101:101"
|
|
||||||
environment:
|
|
||||||
CLICKHOUSE_DB: default
|
|
||||||
CLICKHOUSE_USER: clickhouse
|
|
||||||
CLICKHOUSE_PASSWORD: clickhouse
|
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 262144
|
|
||||||
hard: 262144
|
|
||||||
volumes:
|
|
||||||
- langfuse-clickhouse-data:/var/lib/clickhouse
|
|
||||||
- langfuse-clickhouse-logs:/var/log/clickhouse-server
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
langfuse-redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: certifai-langfuse-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: redis-server --requirepass langfuse-dev-redis
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "-a", "langfuse-dev-redis", "ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
langfuse-minio:
|
|
||||||
image: cgr.dev/chainguard/minio
|
|
||||||
container_name: certifai-langfuse-minio
|
|
||||||
restart: unless-stopped
|
|
||||||
entrypoint: sh
|
|
||||||
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: minio
|
|
||||||
MINIO_ROOT_PASSWORD: miniosecret
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "mc ready local || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
librechat-data:
|
librechat-data:
|
||||||
langgraph-db-data:
|
|
||||||
langfuse-db-data:
|
|
||||||
langfuse-clickhouse-data:
|
|
||||||
langfuse-clickhouse-logs:
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { test as setup, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
const AUTH_FILE = "e2e/.auth/user.json";
|
|
||||||
|
|
||||||
setup("authenticate via Keycloak", async ({ page }) => {
|
|
||||||
// Navigate to a protected route to trigger the auth redirect chain:
|
|
||||||
// /dashboard -> /auth (Axum) -> Keycloak login page
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
|
|
||||||
// Wait for Keycloak login form to appear
|
|
||||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
|
||||||
|
|
||||||
// Fill Keycloak credentials
|
|
||||||
await page.fill("#username", process.env.TEST_USER ?? "admin@certifai.local");
|
|
||||||
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
|
|
||||||
await page.click("#kc-login");
|
|
||||||
|
|
||||||
// Wait for redirect back to the app dashboard
|
|
||||||
await page.waitForURL("**/dashboard", { timeout: 15_000 });
|
|
||||||
await expect(page.locator(".sidebar")).toBeVisible();
|
|
||||||
|
|
||||||
// Persist authenticated state (cookies + localStorage)
|
|
||||||
await page.context().storageState({ path: AUTH_FILE });
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
// These tests use a fresh browser context (no saved auth state)
|
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
|
||||||
|
|
||||||
test.describe("Authentication flow", () => {
|
|
||||||
test("unauthenticated visit to /dashboard redirects to Keycloak", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
|
|
||||||
// Should end up on Keycloak login page
|
|
||||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
|
||||||
await expect(page.locator("#kc-login")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("valid credentials log in and redirect to dashboard", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
|
||||||
|
|
||||||
await page.fill(
|
|
||||||
"#username",
|
|
||||||
process.env.TEST_USER ?? "admin@certifai.local"
|
|
||||||
);
|
|
||||||
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
|
|
||||||
await page.click("#kc-login");
|
|
||||||
|
|
||||||
await page.waitForURL("**/dashboard", { timeout: 15_000 });
|
|
||||||
await expect(page.locator(".dashboard-page")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dashboard shows sidebar with user info after login", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
|
||||||
|
|
||||||
await page.fill(
|
|
||||||
"#username",
|
|
||||||
process.env.TEST_USER ?? "admin@certifai.local"
|
|
||||||
);
|
|
||||||
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
|
|
||||||
await page.click("#kc-login");
|
|
||||||
|
|
||||||
await page.waitForURL("**/dashboard", { timeout: 15_000 });
|
|
||||||
await expect(page.locator(".sidebar-name")).toBeVisible();
|
|
||||||
await expect(page.locator(".sidebar-email")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logout redirects away from dashboard", async ({ page }) => {
|
|
||||||
// First log in
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
|
||||||
|
|
||||||
await page.fill(
|
|
||||||
"#username",
|
|
||||||
process.env.TEST_USER ?? "admin@certifai.local"
|
|
||||||
);
|
|
||||||
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
|
|
||||||
await page.click("#kc-login");
|
|
||||||
|
|
||||||
await page.waitForURL("**/dashboard", { timeout: 15_000 });
|
|
||||||
|
|
||||||
// Click logout
|
|
||||||
await page.locator('a.logout-btn, a[href="/logout"]').click();
|
|
||||||
|
|
||||||
// Should no longer be on the dashboard
|
|
||||||
await expect(page).not.toHaveURL(/\/dashboard/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Dashboard", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
// Wait for WASM hydration and auth check to complete
|
|
||||||
await page.waitForSelector(".dashboard-page", { timeout: 15_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dashboard page loads with page header", async ({ page }) => {
|
|
||||||
await expect(page.locator(".page-header")).toContainText("Dashboard");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("default topic chips are visible", async ({ page }) => {
|
|
||||||
const topics = ["AI", "Technology", "Science", "Finance", "Writing", "Research"];
|
|
||||||
|
|
||||||
for (const topic of topics) {
|
|
||||||
await expect(
|
|
||||||
page.locator(".filter-tab", { hasText: topic })
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking a topic chip triggers search", async ({ page }) => {
|
|
||||||
const chip = page.locator(".filter-tab", { hasText: "AI" });
|
|
||||||
await chip.click();
|
|
||||||
|
|
||||||
// Either a loading state or results should appear
|
|
||||||
const searchingOrResults = page
|
|
||||||
.locator(".dashboard-loading, .news-grid, .dashboard-empty");
|
|
||||||
await expect(searchingOrResults.first()).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("news cards render after search completes", async ({ page }) => {
|
|
||||||
// Click a topic to trigger search
|
|
||||||
await page.locator(".filter-tab", { hasText: "Technology" }).click();
|
|
||||||
|
|
||||||
// Wait for loading to finish
|
|
||||||
await page.waitForSelector(".dashboard-loading", {
|
|
||||||
state: "hidden",
|
|
||||||
timeout: 15_000,
|
|
||||||
}).catch(() => {
|
|
||||||
// Loading may already be done
|
|
||||||
});
|
|
||||||
|
|
||||||
// Either news cards or an empty state message should be visible
|
|
||||||
const content = page.locator(".news-grid .news-card, .dashboard-empty");
|
|
||||||
await expect(content.first()).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking a news card opens article detail panel", async ({ page }) => {
|
|
||||||
// Trigger a search and wait for results
|
|
||||||
await page.locator(".filter-tab", { hasText: "AI" }).click();
|
|
||||||
|
|
||||||
await page.waitForSelector(".dashboard-loading", {
|
|
||||||
state: "hidden",
|
|
||||||
timeout: 15_000,
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
const firstCard = page.locator(".news-card").first();
|
|
||||||
// Only test if cards are present (search results depend on live data)
|
|
||||||
if (await firstCard.isVisible().catch(() => false)) {
|
|
||||||
await firstCard.click();
|
|
||||||
await expect(page.locator(".dashboard-right, .dashboard-split")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings toggle opens settings panel", async ({ page }) => {
|
|
||||||
const settingsBtn = page.locator(".settings-toggle");
|
|
||||||
await settingsBtn.click();
|
|
||||||
|
|
||||||
await expect(page.locator(".settings-panel")).toBeVisible();
|
|
||||||
await expect(page.locator(".settings-panel-title")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Developer section", () => {
|
|
||||||
test("agents page loads with sub-nav tabs", async ({ page }) => {
|
|
||||||
await page.goto("/developer/agents");
|
|
||||||
await page.waitForSelector(".developer-shell", { timeout: 15_000 });
|
|
||||||
|
|
||||||
const nav = page.locator(".sub-nav");
|
|
||||||
await expect(nav.locator("a", { hasText: "Agents" })).toBeVisible();
|
|
||||||
await expect(nav.locator("a", { hasText: "Flow" })).toBeVisible();
|
|
||||||
await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agents page renders informational landing", async ({ page }) => {
|
|
||||||
await page.goto("/developer/agents");
|
|
||||||
await page.waitForSelector(".agents-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
// Hero section
|
|
||||||
await expect(page.locator(".agents-hero-title")).toContainText(
|
|
||||||
"Agent Builder"
|
|
||||||
);
|
|
||||||
await expect(page.locator(".agents-hero-desc")).toBeVisible();
|
|
||||||
|
|
||||||
// Connection status indicator is present
|
|
||||||
await expect(page.locator(".agents-status")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agents page shows Not Connected when URL is empty", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/developer/agents");
|
|
||||||
await page.waitForSelector(".agents-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
await expect(page.locator(".agents-status")).toContainText(
|
|
||||||
"Not Connected"
|
|
||||||
);
|
|
||||||
await expect(page.locator(".agents-status-dot--off")).toBeVisible();
|
|
||||||
await expect(page.locator(".agents-status-hint")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agents page shows quick start cards", async ({ page }) => {
|
|
||||||
await page.goto("/developer/agents");
|
|
||||||
await page.waitForSelector(".agents-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
const grid = page.locator(".agents-grid");
|
|
||||||
const cards = grid.locator(".agents-card");
|
|
||||||
await expect(cards).toHaveCount(5);
|
|
||||||
|
|
||||||
// Verify card titles are rendered
|
|
||||||
await expect(
|
|
||||||
grid.locator(".agents-card-title", { hasText: "Documentation" })
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
grid.locator(".agents-card-title", { hasText: "Getting Started" })
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
grid.locator(".agents-card-title", { hasText: "GitHub" })
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
grid.locator(".agents-card-title", { hasText: "Examples" })
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
grid.locator(".agents-card-title", { hasText: "API Reference" })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agents page disables API Reference card when not connected", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/developer/agents");
|
|
||||||
await page.waitForSelector(".agents-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
// When LANGGRAPH_URL is empty, the API Reference card should be disabled
|
|
||||||
const statusHint = page.locator(".agents-status-hint");
|
|
||||||
if (await statusHint.isVisible()) {
|
|
||||||
const apiCard = page.locator(".agents-card--disabled");
|
|
||||||
await expect(apiCard).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
apiCard.locator(".agents-card-title")
|
|
||||||
).toContainText("API Reference");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agents page shows running agents section", async ({ page }) => {
|
|
||||||
await page.goto("/developer/agents");
|
|
||||||
await page.waitForSelector(".agents-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
// The running agents section title should always be visible
|
|
||||||
await expect(
|
|
||||||
page.locator(".agents-section-title", { hasText: "Running Agents" })
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Either the table, loading state, or empty message should appear
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
const table = page.locator(".agents-table");
|
|
||||||
const empty = page.locator(".agents-table-empty");
|
|
||||||
|
|
||||||
const hasTable = await table.isVisible();
|
|
||||||
const hasEmpty = await empty.isVisible();
|
|
||||||
expect(hasTable || hasEmpty).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agents page shows connected status when URL is configured", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// This test only passes when LANGGRAPH_URL is set in the environment.
|
|
||||||
await page.goto("/developer/agents");
|
|
||||||
await page.waitForSelector(".agents-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
const connectedDot = page.locator(".agents-status-dot--on");
|
|
||||||
const disconnectedDot = page.locator(".agents-status-dot--off");
|
|
||||||
|
|
||||||
if (await connectedDot.isVisible()) {
|
|
||||||
await expect(page.locator(".agents-status")).toContainText("Connected");
|
|
||||||
await expect(page.locator(".agents-status-url")).toBeVisible();
|
|
||||||
// API Reference card should NOT be disabled
|
|
||||||
await expect(page.locator(".agents-card--disabled")).toHaveCount(0);
|
|
||||||
} else {
|
|
||||||
await expect(disconnectedDot).toBeVisible();
|
|
||||||
await expect(page.locator(".agents-status")).toContainText(
|
|
||||||
"Not Connected"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("analytics page renders informational landing", async ({ page }) => {
|
|
||||||
await page.goto("/developer/analytics");
|
|
||||||
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
// Hero section
|
|
||||||
await expect(page.locator(".analytics-hero-title")).toBeVisible();
|
|
||||||
await expect(page.locator(".analytics-hero-desc")).toBeVisible();
|
|
||||||
|
|
||||||
// Connection status indicator
|
|
||||||
await expect(page.locator(".agents-status")).toBeVisible();
|
|
||||||
|
|
||||||
// Metrics bar
|
|
||||||
await expect(page.locator(".analytics-stats-bar")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("analytics page shows Not Connected when URL is empty", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/developer/analytics");
|
|
||||||
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
await expect(page.locator(".agents-status")).toContainText(
|
|
||||||
"Not Connected"
|
|
||||||
);
|
|
||||||
await expect(page.locator(".agents-status-dot--off")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("analytics page shows quick action cards", async ({ page }) => {
|
|
||||||
await page.goto("/developer/analytics");
|
|
||||||
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
const grid = page.locator(".agents-grid");
|
|
||||||
const cards = grid.locator(".agents-card, .agents-card--disabled");
|
|
||||||
await expect(cards).toHaveCount(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("analytics page shows SSO hint when connected", async ({ page }) => {
|
|
||||||
// Only meaningful when LANGFUSE_URL is configured.
|
|
||||||
await page.goto("/developer/analytics");
|
|
||||||
await page.waitForSelector(".analytics-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
const connectedDot = page.locator(".agents-status-dot--on");
|
|
||||||
if (await connectedDot.isVisible()) {
|
|
||||||
await expect(page.locator(".analytics-sso-hint")).toBeVisible();
|
|
||||||
await expect(page.locator(".analytics-launch-btn")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Sidebar navigation", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
await page.waitForSelector(".sidebar", { timeout: 15_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sidebar links route to correct pages", async ({ page }) => {
|
|
||||||
const navTests = [
|
|
||||||
{ label: "Providers", url: /\/providers/ },
|
|
||||||
{ label: "Developer", url: /\/developer\/agents/ },
|
|
||||||
{ label: "Organization", url: /\/organization\/pricing/ },
|
|
||||||
{ label: "Dashboard", url: /\/dashboard/ },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { label, url } of navTests) {
|
|
||||||
await page.locator(".sidebar-link", { hasText: label }).click();
|
|
||||||
await expect(page).toHaveURL(url, { timeout: 10_000 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("browser back/forward navigation works", async ({ page }) => {
|
|
||||||
// Navigate to Providers
|
|
||||||
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/providers/);
|
|
||||||
|
|
||||||
// Navigate to Developer
|
|
||||||
await page.locator(".sidebar-link", { hasText: "Developer" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/developer/);
|
|
||||||
|
|
||||||
// Go back
|
|
||||||
await page.goBack();
|
|
||||||
await expect(page).toHaveURL(/\/providers/);
|
|
||||||
|
|
||||||
// Go forward
|
|
||||||
await page.goForward();
|
|
||||||
await expect(page).toHaveURL(/\/developer/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logo link navigates to dashboard", async ({ page }) => {
|
|
||||||
// Navigate away first
|
|
||||||
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/providers/);
|
|
||||||
|
|
||||||
// Click the logo/brand in sidebar header
|
|
||||||
const logo = page.locator(".sidebar-brand, .sidebar-logo, .sidebar a").first();
|
|
||||||
await logo.click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Organization section", () => {
|
|
||||||
test("pricing page loads with three pricing cards", async ({ page }) => {
|
|
||||||
await page.goto("/organization/pricing");
|
|
||||||
await page.waitForSelector(".org-shell", { timeout: 15_000 });
|
|
||||||
|
|
||||||
const cards = page.locator(".pricing-card");
|
|
||||||
await expect(cards).toHaveCount(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pricing cards show Starter, Team, Enterprise tiers", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/organization/pricing");
|
|
||||||
await page.waitForSelector(".org-shell", { timeout: 15_000 });
|
|
||||||
|
|
||||||
await expect(page.locator(".pricing-card", { hasText: "Starter" })).toBeVisible();
|
|
||||||
await expect(page.locator(".pricing-card", { hasText: "Team" })).toBeVisible();
|
|
||||||
await expect(page.locator(".pricing-card", { hasText: "Enterprise" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("organization dashboard loads with billing stats", async ({ page }) => {
|
|
||||||
await page.goto("/organization/dashboard");
|
|
||||||
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
await expect(page.locator(".page-header")).toContainText("Organization");
|
|
||||||
await expect(page.locator(".org-stats-bar")).toBeVisible();
|
|
||||||
await expect(page.locator(".org-stat").first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("member table is visible on org dashboard", async ({ page }) => {
|
|
||||||
await page.goto("/organization/dashboard");
|
|
||||||
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
|
|
||||||
|
|
||||||
await expect(page.locator(".org-table")).toBeVisible();
|
|
||||||
await expect(page.locator(".org-table thead")).toContainText("Name");
|
|
||||||
await expect(page.locator(".org-table thead")).toContainText("Email");
|
|
||||||
await expect(page.locator(".org-table thead")).toContainText("Role");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Providers page", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("/providers");
|
|
||||||
await page.waitForSelector(".providers-page", { timeout: 15_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("providers page loads with header", async ({ page }) => {
|
|
||||||
await expect(page.locator(".page-header")).toContainText("Providers");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("provider dropdown has Ollama selected by default", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const providerSelect = page
|
|
||||||
.locator(".form-group")
|
|
||||||
.filter({ hasText: "Provider" })
|
|
||||||
.locator("select");
|
|
||||||
|
|
||||||
await expect(providerSelect).toHaveValue(/ollama/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("changing provider updates the model dropdown", async ({ page }) => {
|
|
||||||
const providerSelect = page
|
|
||||||
.locator(".form-group")
|
|
||||||
.filter({ hasText: "Provider" })
|
|
||||||
.locator("select");
|
|
||||||
|
|
||||||
// Get current model options
|
|
||||||
const modelSelect = page
|
|
||||||
.locator(".form-group")
|
|
||||||
.filter({ hasText: /^Model/ })
|
|
||||||
.locator("select");
|
|
||||||
const initialOptions = await modelSelect.locator("option").allTextContents();
|
|
||||||
|
|
||||||
// Change to a different provider
|
|
||||||
await providerSelect.selectOption({ label: "OpenAI" });
|
|
||||||
|
|
||||||
// Wait for model list to update
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
const updatedOptions = await modelSelect.locator("option").allTextContents();
|
|
||||||
|
|
||||||
// Model options should differ between providers
|
|
||||||
expect(updatedOptions).not.toEqual(initialOptions);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("save button shows confirmation feedback", async ({ page }) => {
|
|
||||||
const saveBtn = page.locator("button", { hasText: "Save Configuration" });
|
|
||||||
await saveBtn.click();
|
|
||||||
|
|
||||||
await expect(page.locator(".form-success")).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(page.locator(".form-success")).toContainText("saved");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Public pages", () => {
|
|
||||||
test("landing page loads with heading and nav links", async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await expect(page.locator(".landing-logo").first()).toHaveText("CERTifAI");
|
|
||||||
await expect(page.locator(".landing-nav-links")).toBeVisible();
|
|
||||||
await expect(page.locator('a[href="#features"]')).toBeVisible();
|
|
||||||
await expect(page.locator('a[href="#how-it-works"]')).toBeVisible();
|
|
||||||
await expect(page.locator('a[href="#pricing"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("landing page Log In link navigates to login route", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
const loginLink = page
|
|
||||||
.locator(".landing-nav-actions a, .landing-nav-actions Link")
|
|
||||||
.filter({ hasText: "Log In" });
|
|
||||||
await loginLink.click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("impressum page loads with legal content", async ({ page }) => {
|
|
||||||
await page.goto("/impressum");
|
|
||||||
|
|
||||||
await expect(page.locator("h1")).toHaveText("Impressum");
|
|
||||||
await expect(
|
|
||||||
page.locator("h2", { hasText: "Information according to" })
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.locator(".legal-content")).toContainText(
|
|
||||||
"CERTifAI GmbH"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("privacy page loads with privacy content", async ({ page }) => {
|
|
||||||
await page.goto("/privacy");
|
|
||||||
|
|
||||||
await expect(page.locator("h1")).toHaveText("Privacy Policy");
|
|
||||||
await expect(
|
|
||||||
page.locator("h2", { hasText: "Introduction" })
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("h2", { hasText: "Your Rights" })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("footer links are present on landing page", async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
const footer = page.locator(".landing-footer");
|
|
||||||
await expect(footer.locator('a:has-text("Impressum")')).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
footer.locator('a:has-text("Privacy Policy")')
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -79,39 +79,6 @@
|
|||||||
"offline_access"
|
"offline_access"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"clientId": "certifai-langfuse",
|
|
||||||
"name": "CERTifAI Langfuse",
|
|
||||||
"description": "Langfuse OIDC client for CERTifAI",
|
|
||||||
"enabled": true,
|
|
||||||
"publicClient": false,
|
|
||||||
"directAccessGrantsEnabled": false,
|
|
||||||
"standardFlowEnabled": true,
|
|
||||||
"implicitFlowEnabled": false,
|
|
||||||
"serviceAccountsEnabled": false,
|
|
||||||
"protocol": "openid-connect",
|
|
||||||
"secret": "certifai-langfuse-secret",
|
|
||||||
"rootUrl": "http://localhost:3000",
|
|
||||||
"baseUrl": "http://localhost:3000",
|
|
||||||
"redirectUris": [
|
|
||||||
"http://localhost:3000/*"
|
|
||||||
],
|
|
||||||
"webOrigins": [
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:8000"
|
|
||||||
],
|
|
||||||
"attributes": {
|
|
||||||
"post.logout.redirect.uris": "http://localhost:3000"
|
|
||||||
},
|
|
||||||
"defaultClientScopes": [
|
|
||||||
"openid",
|
|
||||||
"profile",
|
|
||||||
"email"
|
|
||||||
],
|
|
||||||
"optionalClientScopes": [
|
|
||||||
"offline_access"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"clientId": "certifai-librechat",
|
"clientId": "certifai-librechat",
|
||||||
"name": "CERTifAI Chat",
|
"name": "CERTifAI Chat",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.52.0",
|
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./e2e",
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: [["html"], ["list"]],
|
|
||||||
timeout: 30_000,
|
|
||||||
|
|
||||||
use: {
|
|
||||||
baseURL: process.env.BASE_URL ?? "http://localhost:8000",
|
|
||||||
actionTimeout: 10_000,
|
|
||||||
trace: "on-first-retry",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
},
|
|
||||||
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "setup",
|
|
||||||
testMatch: /auth\.setup\.ts/,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "public",
|
|
||||||
testMatch: /public\.spec\.ts/,
|
|
||||||
use: { ...devices["Desktop Chrome"] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "authenticated",
|
|
||||||
testMatch: /\.spec\.ts$/,
|
|
||||||
testIgnore: /public\.spec\.ts$/,
|
|
||||||
dependencies: ["setup"],
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
storageState: "e2e/.auth/user.json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,7 @@ use dioxus_free_icons::Icon;
|
|||||||
use crate::components::sidebar::Sidebar;
|
use crate::components::sidebar::Sidebar;
|
||||||
use crate::i18n::{t, tw, Locale};
|
use crate::i18n::{t, tw, Locale};
|
||||||
use crate::infrastructure::auth_check::check_auth;
|
use crate::infrastructure::auth_check::check_auth;
|
||||||
use crate::models::{AuthInfo, ServiceUrlsContext};
|
use crate::models::AuthInfo;
|
||||||
use crate::Route;
|
use crate::Route;
|
||||||
|
|
||||||
/// Application shell layout that wraps all authenticated pages.
|
/// Application shell layout that wraps all authenticated pages.
|
||||||
@@ -29,16 +29,6 @@ pub fn AppShell() -> Element {
|
|||||||
|
|
||||||
match auth_snapshot {
|
match auth_snapshot {
|
||||||
Some(Ok(info)) if info.authenticated => {
|
Some(Ok(info)) if info.authenticated => {
|
||||||
// Provide developer tool URLs as context so child pages
|
|
||||||
// can read them without prop-drilling through layouts.
|
|
||||||
use_context_provider(|| {
|
|
||||||
Signal::new(ServiceUrlsContext {
|
|
||||||
langgraph_url: info.langgraph_url.clone(),
|
|
||||||
langflow_url: info.langflow_url.clone(),
|
|
||||||
langfuse_url: info.langfuse_url.clone(),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let menu_open = *mobile_menu_open.read();
|
let menu_open = *mobile_menu_open.read();
|
||||||
let sidebar_cls = if menu_open {
|
let sidebar_cls = if menu_open {
|
||||||
"sidebar sidebar--open"
|
"sidebar sidebar--open"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ mod page_header;
|
|||||||
mod pricing_card;
|
mod pricing_card;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod sub_nav;
|
pub mod sub_nav;
|
||||||
mod tool_embed;
|
|
||||||
|
|
||||||
pub use app_shell::*;
|
pub use app_shell::*;
|
||||||
pub use article_detail::*;
|
pub use article_detail::*;
|
||||||
@@ -21,4 +20,3 @@ pub use news_card::*;
|
|||||||
pub use page_header::*;
|
pub use page_header::*;
|
||||||
pub use pricing_card::*;
|
pub use pricing_card::*;
|
||||||
pub use sub_nav::*;
|
pub use sub_nav::*;
|
||||||
pub use tool_embed::*;
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
use crate::i18n::{t, Locale};
|
|
||||||
|
|
||||||
/// Properties for the [`ToolEmbed`] component.
|
|
||||||
///
|
|
||||||
/// # Fields
|
|
||||||
///
|
|
||||||
/// * `url` - Service URL; when empty, a "Not Configured" placeholder is shown
|
|
||||||
/// * `title` - Display title for the tool (e.g. "Agent Builder")
|
|
||||||
/// * `description` - Description text shown in the placeholder card
|
|
||||||
/// * `icon` - Single-character icon for the placeholder card
|
|
||||||
/// * `launch_label` - Label for the disabled button in the placeholder
|
|
||||||
#[derive(Props, Clone, PartialEq)]
|
|
||||||
pub struct ToolEmbedProps {
|
|
||||||
/// Service URL. Empty string means "not configured".
|
|
||||||
pub url: String,
|
|
||||||
/// Display title shown in the toolbar / placeholder heading.
|
|
||||||
pub title: String,
|
|
||||||
/// Description shown in the "not configured" placeholder.
|
|
||||||
pub description: String,
|
|
||||||
/// Single-character icon for the placeholder card.
|
|
||||||
pub icon: &'static str,
|
|
||||||
/// Label for the disabled launch button in placeholder mode.
|
|
||||||
pub launch_label: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hybrid iframe / placeholder component for developer tool pages.
|
|
||||||
///
|
|
||||||
/// When `url` is non-empty, renders a toolbar (title + pop-out button)
|
|
||||||
/// above a full-height iframe embedding the service. When `url` is
|
|
||||||
/// empty, renders the existing placeholder card with a "Not Configured"
|
|
||||||
/// badge instead of "Coming Soon".
|
|
||||||
#[component]
|
|
||||||
pub fn ToolEmbed(props: ToolEmbedProps) -> Element {
|
|
||||||
let locale = use_context::<Signal<Locale>>();
|
|
||||||
let l = *locale.read();
|
|
||||||
|
|
||||||
if props.url.is_empty() {
|
|
||||||
// Not configured -- show placeholder card
|
|
||||||
rsx! {
|
|
||||||
section { class: "placeholder-page",
|
|
||||||
div { class: "placeholder-card",
|
|
||||||
div { class: "placeholder-icon", "{props.icon}" }
|
|
||||||
h2 { "{props.title}" }
|
|
||||||
p { class: "placeholder-desc", "{props.description}" }
|
|
||||||
button {
|
|
||||||
class: "btn-primary",
|
|
||||||
disabled: true,
|
|
||||||
"{props.launch_label}"
|
|
||||||
}
|
|
||||||
span { class: "placeholder-badge",
|
|
||||||
"{t(l, \"developer.not_configured\")}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// URL is set -- render toolbar + iframe
|
|
||||||
let pop_out_url = props.url.clone();
|
|
||||||
rsx! {
|
|
||||||
div { class: "tool-embed",
|
|
||||||
div { class: "tool-embed-toolbar",
|
|
||||||
span { class: "tool-embed-title", "{props.title}" }
|
|
||||||
a {
|
|
||||||
class: "tool-embed-popout-btn",
|
|
||||||
href: "{pop_out_url}",
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
"{t(l, \"developer.open_new_tab\")}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
iframe {
|
|
||||||
class: "tool-embed-iframe",
|
|
||||||
src: "{props.url}",
|
|
||||||
title: "{props.title}",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,9 +24,9 @@ pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
|||||||
/// post-login redirect URL and the PKCE code verifier needed for the
|
/// post-login redirect URL and the PKCE code verifier needed for the
|
||||||
/// token exchange.
|
/// token exchange.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct PendingOAuthEntry {
|
struct PendingOAuthEntry {
|
||||||
pub(crate) redirect_url: Option<String>,
|
redirect_url: Option<String>,
|
||||||
pub(crate) code_verifier: String,
|
code_verifier: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory store for pending OAuth states. Keyed by the random state
|
/// In-memory store for pending OAuth states. Keyed by the random state
|
||||||
@@ -38,7 +38,7 @@ pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
|
|||||||
|
|
||||||
impl PendingOAuthStore {
|
impl PendingOAuthStore {
|
||||||
/// Insert a pending state with an optional redirect URL and PKCE verifier.
|
/// Insert a pending state with an optional redirect URL and PKCE verifier.
|
||||||
pub(crate) fn insert(&self, state: String, entry: PendingOAuthEntry) {
|
fn insert(&self, state: String, entry: PendingOAuthEntry) {
|
||||||
// RwLock::write only panics if the lock is poisoned, which
|
// RwLock::write only panics if the lock is poisoned, which
|
||||||
// indicates a prior panic -- propagating is acceptable here.
|
// indicates a prior panic -- propagating is acceptable here.
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
@@ -50,7 +50,7 @@ impl PendingOAuthStore {
|
|||||||
|
|
||||||
/// Remove and return the entry if the state was pending.
|
/// Remove and return the entry if the state was pending.
|
||||||
/// Returns `None` if the state was never stored (CSRF failure).
|
/// Returns `None` if the state was never stored (CSRF failure).
|
||||||
pub(crate) fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
self.0
|
self.0
|
||||||
.write()
|
.write()
|
||||||
@@ -60,8 +60,7 @@ impl PendingOAuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a cryptographically random state string for CSRF protection.
|
/// Generate a cryptographically random state string for CSRF protection.
|
||||||
#[cfg_attr(test, allow(dead_code))]
|
fn generate_state() -> String {
|
||||||
pub(crate) fn generate_state() -> String {
|
|
||||||
let bytes: [u8; 32] = rand::rng().random();
|
let bytes: [u8; 32] = rand::rng().random();
|
||||||
// Encode as hex to produce a URL-safe string without padding.
|
// Encode as hex to produce a URL-safe string without padding.
|
||||||
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
|
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||||
@@ -76,7 +75,7 @@ pub(crate) fn generate_state() -> String {
|
|||||||
///
|
///
|
||||||
/// Uses 32 random bytes encoded as base64url (no padding) to produce
|
/// Uses 32 random bytes encoded as base64url (no padding) to produce
|
||||||
/// a 43-character verifier per RFC 7636.
|
/// a 43-character verifier per RFC 7636.
|
||||||
pub(crate) fn generate_code_verifier() -> String {
|
fn generate_code_verifier() -> String {
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
|
||||||
let bytes: [u8; 32] = rand::rng().random();
|
let bytes: [u8; 32] = rand::rng().random();
|
||||||
@@ -86,7 +85,7 @@ pub(crate) fn generate_code_verifier() -> String {
|
|||||||
/// Derive the S256 code challenge from a code verifier per RFC 7636.
|
/// Derive the S256 code challenge from a code verifier per RFC 7636.
|
||||||
///
|
///
|
||||||
/// `code_challenge = BASE64URL(SHA256(code_verifier))`
|
/// `code_challenge = BASE64URL(SHA256(code_verifier))`
|
||||||
pub(crate) fn derive_code_challenge(verifier: &str) -> String {
|
fn derive_code_challenge(verifier: &str) -> String {
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
@@ -305,117 +304,3 @@ pub async fn set_login_session(session: Session, data: UserStateInner) -> Result
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
|
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// generate_state()
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_state_length_is_64() {
|
|
||||||
let state = generate_state();
|
|
||||||
assert_eq!(state.len(), 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_state_chars_are_hex() {
|
|
||||||
let state = generate_state();
|
|
||||||
assert!(state.chars().all(|c| c.is_ascii_hexdigit()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_state_two_calls_differ() {
|
|
||||||
let a = generate_state();
|
|
||||||
let b = generate_state();
|
|
||||||
assert_ne!(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// generate_code_verifier()
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn code_verifier_length_is_43() {
|
|
||||||
let verifier = generate_code_verifier();
|
|
||||||
assert_eq!(verifier.len(), 43);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn code_verifier_chars_are_url_safe_base64() {
|
|
||||||
let verifier = generate_code_verifier();
|
|
||||||
// URL-safe base64 without padding uses [A-Za-z0-9_-]
|
|
||||||
assert!(verifier
|
|
||||||
.chars()
|
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// derive_code_challenge()
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn code_challenge_deterministic() {
|
|
||||||
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
|
||||||
let a = derive_code_challenge(verifier);
|
|
||||||
let b = derive_code_challenge(verifier);
|
|
||||||
assert_eq!(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn code_challenge_rfc7636_test_vector() {
|
|
||||||
// RFC 7636 Appendix B test vector:
|
|
||||||
// verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
||||||
// expected challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
|
||||||
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
|
||||||
let challenge = derive_code_challenge(verifier);
|
|
||||||
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// PendingOAuthStore
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pending_store_insert_and_take() {
|
|
||||||
let store = PendingOAuthStore::default();
|
|
||||||
store.insert(
|
|
||||||
"state-1".into(),
|
|
||||||
PendingOAuthEntry {
|
|
||||||
redirect_url: Some("/dashboard".into()),
|
|
||||||
code_verifier: "verifier-1".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let entry = store.take("state-1");
|
|
||||||
assert!(entry.is_some());
|
|
||||||
let entry = entry.unwrap();
|
|
||||||
assert_eq!(entry.redirect_url, Some("/dashboard".into()));
|
|
||||||
assert_eq!(entry.code_verifier, "verifier-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pending_store_take_removes_entry() {
|
|
||||||
let store = PendingOAuthStore::default();
|
|
||||||
store.insert(
|
|
||||||
"state-2".into(),
|
|
||||||
PendingOAuthEntry {
|
|
||||||
redirect_url: None,
|
|
||||||
code_verifier: "v2".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let _ = store.take("state-2");
|
|
||||||
// Second take should return None since the entry was removed.
|
|
||||||
assert!(store.take("state-2").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pending_store_take_unknown_returns_none() {
|
|
||||||
let store = PendingOAuthStore::default();
|
|
||||||
assert!(store.take("nonexistent").is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,17 +25,8 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
|||||||
|
|
||||||
match user_state {
|
match user_state {
|
||||||
Some(u) => {
|
Some(u) => {
|
||||||
let librechat_url =
|
let librechat_url = std::env::var("LIBRECHAT_URL")
|
||||||
std::env::var("LIBRECHAT_URL").unwrap_or_else(|_| "http://localhost:3080".into());
|
.unwrap_or_else(|_| "http://localhost:3080".into());
|
||||||
|
|
||||||
// Extract service URLs from server state so the frontend can
|
|
||||||
// embed developer tools (LangGraph, LangFlow, Langfuse).
|
|
||||||
let state: crate::infrastructure::server_state::ServerState =
|
|
||||||
FullstackContext::extract().await?;
|
|
||||||
let langgraph_url = state.services.langgraph_url.clone();
|
|
||||||
let langflow_url = state.services.langflow_url.clone();
|
|
||||||
let langfuse_url = state.services.langfuse_url.clone();
|
|
||||||
|
|
||||||
Ok(AuthInfo {
|
Ok(AuthInfo {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
sub: u.sub,
|
sub: u.sub,
|
||||||
@@ -43,9 +34,6 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
|||||||
name: u.user.name,
|
name: u.user.name,
|
||||||
avatar_url: u.user.avatar_url,
|
avatar_url: u.user.avatar_url,
|
||||||
librechat_url,
|
librechat_url,
|
||||||
langgraph_url,
|
|
||||||
langflow_url,
|
|
||||||
langfuse_url,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => Ok(AuthInfo::default()),
|
None => Ok(AuthInfo::default()),
|
||||||
|
|||||||
@@ -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)
|
||||||
///
|
///
|
||||||
@@ -440,12 +440,7 @@ pub async fn chat_complete(
|
|||||||
let session = doc_to_chat_session(&session_doc);
|
let session = doc_to_chat_session(&session_doc);
|
||||||
|
|
||||||
// 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, &session.provider, &session.model);
|
||||||
&state.services.litellm_url,
|
|
||||||
&state.services.litellm_model,
|
|
||||||
&session.provider,
|
|
||||||
&session.model,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse messages from JSON
|
// Parse messages from JSON
|
||||||
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
|
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
|
||||||
@@ -485,22 +480,10 @@ 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 server defaults.
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `litellm_url` - Default LiteLLM base URL from config
|
|
||||||
/// * `litellm_model` - Default LiteLLM model from config
|
|
||||||
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface")
|
|
||||||
/// * `model` - Model ID (may be empty for LiteLLM default)
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A `(base_url, model)` tuple resolved for the given provider.
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub(crate) fn resolve_provider_url(
|
fn resolve_provider_url(
|
||||||
litellm_url: &str,
|
state: &crate::infrastructure::ServerState,
|
||||||
litellm_model: &str,
|
|
||||||
provider: &str,
|
provider: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
) -> (String, String) {
|
) -> (String, String) {
|
||||||
@@ -511,231 +494,14 @@ 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(),
|
state.services.ollama_url.clone(),
|
||||||
if model.is_empty() {
|
if model.is_empty() {
|
||||||
litellm_model.to_string()
|
state.services.ollama_model.clone()
|
||||||
} else {
|
} else {
|
||||||
model.to_string()
|
model.to_string()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// BSON document conversion tests (server feature required)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
mod server_tests {
|
|
||||||
use super::super::{doc_to_chat_message, doc_to_chat_session, resolve_provider_url};
|
|
||||||
use crate::models::{ChatNamespace, ChatRole};
|
|
||||||
use mongodb::bson::{doc, oid::ObjectId, Document};
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
// -- doc_to_chat_session --
|
|
||||||
|
|
||||||
fn sample_session_doc() -> (ObjectId, Document) {
|
|
||||||
let oid = ObjectId::new();
|
|
||||||
let doc = doc! {
|
|
||||||
"_id": oid,
|
|
||||||
"user_sub": "user-42",
|
|
||||||
"title": "Test Session",
|
|
||||||
"namespace": "News",
|
|
||||||
"provider": "openai",
|
|
||||||
"model": "gpt-4",
|
|
||||||
"created_at": "2025-01-01T00:00:00Z",
|
|
||||||
"updated_at": "2025-01-02T00:00:00Z",
|
|
||||||
"article_url": "https://example.com/article",
|
|
||||||
};
|
|
||||||
(oid, doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_session_extracts_id_as_hex() {
|
|
||||||
let (oid, doc) = sample_session_doc();
|
|
||||||
let session = doc_to_chat_session(&doc);
|
|
||||||
assert_eq!(session.id, oid.to_hex());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_session_maps_news_namespace() {
|
|
||||||
let (_, doc) = sample_session_doc();
|
|
||||||
let session = doc_to_chat_session(&doc);
|
|
||||||
assert_eq!(session.namespace, ChatNamespace::News);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_session_defaults_to_general_for_unknown() {
|
|
||||||
let mut doc = sample_session_doc().1;
|
|
||||||
doc.insert("namespace", "SomethingElse");
|
|
||||||
let session = doc_to_chat_session(&doc);
|
|
||||||
assert_eq!(session.namespace, ChatNamespace::General);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_session_extracts_all_string_fields() {
|
|
||||||
let (_, doc) = sample_session_doc();
|
|
||||||
let session = doc_to_chat_session(&doc);
|
|
||||||
assert_eq!(session.user_sub, "user-42");
|
|
||||||
assert_eq!(session.title, "Test Session");
|
|
||||||
assert_eq!(session.provider, "openai");
|
|
||||||
assert_eq!(session.model, "gpt-4");
|
|
||||||
assert_eq!(session.created_at, "2025-01-01T00:00:00Z");
|
|
||||||
assert_eq!(session.updated_at, "2025-01-02T00:00:00Z");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_session_handles_missing_article_url() {
|
|
||||||
let oid = ObjectId::new();
|
|
||||||
let doc = doc! {
|
|
||||||
"_id": oid,
|
|
||||||
"user_sub": "u",
|
|
||||||
"title": "t",
|
|
||||||
"provider": "litellm",
|
|
||||||
"model": "m",
|
|
||||||
"created_at": "c",
|
|
||||||
"updated_at": "u",
|
|
||||||
};
|
|
||||||
let session = doc_to_chat_session(&doc);
|
|
||||||
assert_eq!(session.article_url, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_session_filters_empty_article_url() {
|
|
||||||
let oid = ObjectId::new();
|
|
||||||
let doc = doc! {
|
|
||||||
"_id": oid,
|
|
||||||
"user_sub": "u",
|
|
||||||
"title": "t",
|
|
||||||
"namespace": "News",
|
|
||||||
"provider": "litellm",
|
|
||||||
"model": "m",
|
|
||||||
"created_at": "c",
|
|
||||||
"updated_at": "u",
|
|
||||||
"article_url": "",
|
|
||||||
};
|
|
||||||
let session = doc_to_chat_session(&doc);
|
|
||||||
assert_eq!(session.article_url, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- doc_to_chat_message --
|
|
||||||
|
|
||||||
fn sample_message_doc() -> (ObjectId, Document) {
|
|
||||||
let oid = ObjectId::new();
|
|
||||||
let doc = doc! {
|
|
||||||
"_id": oid,
|
|
||||||
"session_id": "sess-1",
|
|
||||||
"role": "Assistant",
|
|
||||||
"content": "Hello there!",
|
|
||||||
"timestamp": "2025-01-01T12:00:00Z",
|
|
||||||
};
|
|
||||||
(oid, doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_message_extracts_id_as_hex() {
|
|
||||||
let (oid, doc) = sample_message_doc();
|
|
||||||
let msg = doc_to_chat_message(&doc);
|
|
||||||
assert_eq!(msg.id, oid.to_hex());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_message_maps_assistant_role() {
|
|
||||||
let (_, doc) = sample_message_doc();
|
|
||||||
let msg = doc_to_chat_message(&doc);
|
|
||||||
assert_eq!(msg.role, ChatRole::Assistant);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_message_maps_system_role() {
|
|
||||||
let mut doc = sample_message_doc().1;
|
|
||||||
doc.insert("role", "System");
|
|
||||||
let msg = doc_to_chat_message(&doc);
|
|
||||||
assert_eq!(msg.role, ChatRole::System);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_message_defaults_to_user_for_unknown() {
|
|
||||||
let mut doc = sample_message_doc().1;
|
|
||||||
doc.insert("role", "SomethingElse");
|
|
||||||
let msg = doc_to_chat_message(&doc);
|
|
||||||
assert_eq!(msg.role, ChatRole::User);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_message_extracts_content_and_timestamp() {
|
|
||||||
let (_, doc) = sample_message_doc();
|
|
||||||
let msg = doc_to_chat_message(&doc);
|
|
||||||
assert_eq!(msg.content, "Hello there!");
|
|
||||||
assert_eq!(msg.timestamp, "2025-01-01T12:00:00Z");
|
|
||||||
assert_eq!(msg.session_id, "sess-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_to_chat_message_attachments_always_empty() {
|
|
||||||
let (_, doc) = sample_message_doc();
|
|
||||||
let msg = doc_to_chat_message(&doc);
|
|
||||||
assert!(msg.attachments.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- resolve_provider_url --
|
|
||||||
|
|
||||||
const TEST_LITELLM_URL: &str = "http://localhost:4000";
|
|
||||||
const TEST_LITELLM_MODEL: &str = "qwen3-32b";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_openai_returns_api_openai() {
|
|
||||||
let (url, model) =
|
|
||||||
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "openai", "gpt-4o");
|
|
||||||
assert_eq!(url, "https://api.openai.com");
|
|
||||||
assert_eq!(model, "gpt-4o");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_anthropic_returns_api_anthropic() {
|
|
||||||
let (url, model) = resolve_provider_url(
|
|
||||||
TEST_LITELLM_URL,
|
|
||||||
TEST_LITELLM_MODEL,
|
|
||||||
"anthropic",
|
|
||||||
"claude-3-opus",
|
|
||||||
);
|
|
||||||
assert_eq!(url, "https://api.anthropic.com");
|
|
||||||
assert_eq!(model, "claude-3-opus");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_huggingface_returns_model_url() {
|
|
||||||
let (url, model) = resolve_provider_url(
|
|
||||||
TEST_LITELLM_URL,
|
|
||||||
TEST_LITELLM_MODEL,
|
|
||||||
"huggingface",
|
|
||||||
"meta-llama/Llama-2-7b",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
url,
|
|
||||||
"https://api-inference.huggingface.co/models/meta-llama/Llama-2-7b"
|
|
||||||
);
|
|
||||||
assert_eq!(model, "meta-llama/Llama-2-7b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_unknown_defaults_to_litellm() {
|
|
||||||
let (url, model) =
|
|
||||||
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "litellm", "qwen3-32b");
|
|
||||||
assert_eq!(url, TEST_LITELLM_URL);
|
|
||||||
assert_eq!(model, "qwen3-32b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_empty_model_falls_back_to_server_default() {
|
|
||||||
let (url, model) =
|
|
||||||
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "litellm", "");
|
|
||||||
assert_eq!(url, TEST_LITELLM_URL);
|
|
||||||
assert_eq!(model, TEST_LITELLM_MODEL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -141,23 +141,19 @@ 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.
|
||||||
pub langchain_url: String,
|
pub langchain_url: String,
|
||||||
/// LangGraph service URL.
|
/// LangGraph service URL.
|
||||||
pub langgraph_url: String,
|
pub langgraph_url: String,
|
||||||
/// LangFlow visual workflow builder URL.
|
|
||||||
pub langflow_url: String,
|
|
||||||
/// Langfuse observability URL.
|
/// Langfuse observability URL.
|
||||||
pub langfuse_url: String,
|
pub langfuse_url: String,
|
||||||
/// Vector database URL.
|
/// Vector database URL.
|
||||||
@@ -180,15 +176,13 @@ 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"),
|
||||||
langgraph_url: optional_env("LANGGRAPH_URL"),
|
langgraph_url: optional_env("LANGGRAPH_URL"),
|
||||||
langflow_url: optional_env("LANGFLOW_URL"),
|
|
||||||
langfuse_url: optional_env("LANGFUSE_URL"),
|
langfuse_url: optional_env("LANGFUSE_URL"),
|
||||||
vectordb_url: optional_env("VECTORDB_URL"),
|
vectordb_url: optional_env("VECTORDB_URL"),
|
||||||
s3_url: optional_env("S3_URL"),
|
s3_url: optional_env("S3_URL"),
|
||||||
@@ -234,7 +228,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.
|
||||||
@@ -257,160 +251,3 @@ impl LlmProvidersConfig {
|
|||||||
Ok(Self { providers })
|
Ok(Self { providers })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
use serial_test::serial;
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// KeycloakConfig endpoint methods (no env vars needed)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn sample_keycloak() -> KeycloakConfig {
|
|
||||||
KeycloakConfig {
|
|
||||||
url: "https://auth.example.com".into(),
|
|
||||||
realm: "myrealm".into(),
|
|
||||||
client_id: "dashboard".into(),
|
|
||||||
redirect_uri: "https://app.example.com/callback".into(),
|
|
||||||
app_url: "https://app.example.com".into(),
|
|
||||||
admin_client_id: String::new(),
|
|
||||||
admin_client_secret: SecretString::from(String::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn keycloak_auth_endpoint() {
|
|
||||||
let kc = sample_keycloak();
|
|
||||||
assert_eq!(
|
|
||||||
kc.auth_endpoint(),
|
|
||||||
"https://auth.example.com/realms/myrealm/protocol/openid-connect/auth"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn keycloak_token_endpoint() {
|
|
||||||
let kc = sample_keycloak();
|
|
||||||
assert_eq!(
|
|
||||||
kc.token_endpoint(),
|
|
||||||
"https://auth.example.com/realms/myrealm/protocol/openid-connect/token"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn keycloak_userinfo_endpoint() {
|
|
||||||
let kc = sample_keycloak();
|
|
||||||
assert_eq!(
|
|
||||||
kc.userinfo_endpoint(),
|
|
||||||
"https://auth.example.com/realms/myrealm/protocol/openid-connect/userinfo"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn keycloak_logout_endpoint() {
|
|
||||||
let kc = sample_keycloak();
|
|
||||||
assert_eq!(
|
|
||||||
kc.logout_endpoint(),
|
|
||||||
"https://auth.example.com/realms/myrealm/protocol/openid-connect/logout"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// LlmProvidersConfig::from_env()
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn llm_providers_empty_string() {
|
|
||||||
std::env::set_var("LLM_PROVIDERS", "");
|
|
||||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
||||||
assert!(cfg.providers.is_empty());
|
|
||||||
std::env::remove_var("LLM_PROVIDERS");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn llm_providers_single() {
|
|
||||||
std::env::set_var("LLM_PROVIDERS", "litellm");
|
|
||||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
||||||
assert_eq!(cfg.providers, vec!["litellm"]);
|
|
||||||
std::env::remove_var("LLM_PROVIDERS");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn llm_providers_multiple() {
|
|
||||||
std::env::set_var("LLM_PROVIDERS", "litellm,openai,anthropic");
|
|
||||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
||||||
assert_eq!(cfg.providers, vec!["litellm", "openai", "anthropic"]);
|
|
||||||
std::env::remove_var("LLM_PROVIDERS");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn llm_providers_trims_whitespace() {
|
|
||||||
std::env::set_var("LLM_PROVIDERS", " litellm , openai ");
|
|
||||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
||||||
assert_eq!(cfg.providers, vec!["litellm", "openai"]);
|
|
||||||
std::env::remove_var("LLM_PROVIDERS");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn llm_providers_filters_empty_entries() {
|
|
||||||
std::env::set_var("LLM_PROVIDERS", "litellm,,openai,");
|
|
||||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
||||||
assert_eq!(cfg.providers, vec!["litellm", "openai"]);
|
|
||||||
std::env::remove_var("LLM_PROVIDERS");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// ServiceUrls::from_env() defaults
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn service_urls_default_litellm_url() {
|
|
||||||
std::env::remove_var("LITELLM_URL");
|
|
||||||
let svc = ServiceUrls::from_env().unwrap();
|
|
||||||
assert_eq!(svc.litellm_url, "http://localhost:4000");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn service_urls_default_litellm_model() {
|
|
||||||
std::env::remove_var("LITELLM_MODEL");
|
|
||||||
let svc = ServiceUrls::from_env().unwrap();
|
|
||||||
assert_eq!(svc.litellm_model, "qwen3-32b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn service_urls_default_searxng_url() {
|
|
||||||
std::env::remove_var("SEARXNG_URL");
|
|
||||||
let svc = ServiceUrls::from_env().unwrap();
|
|
||||||
assert_eq!(svc.searxng_url, "http://localhost:8888");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn service_urls_custom_litellm_url() {
|
|
||||||
std::env::set_var("LITELLM_URL", "http://litellm-host:4000");
|
|
||||||
let svc = ServiceUrls::from_env().unwrap();
|
|
||||||
assert_eq!(svc.litellm_url, "http://litellm-host:4000");
|
|
||||||
std::env::remove_var("LITELLM_URL");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn required_env_missing_returns_config_error() {
|
|
||||||
std::env::remove_var("__TEST_REQUIRED_MISSING__");
|
|
||||||
let result = required_env("__TEST_REQUIRED_MISSING__");
|
|
||||||
assert!(result.is_err());
|
|
||||||
let err_msg = result.unwrap_err().to_string();
|
|
||||||
assert!(err_msg.contains("__TEST_REQUIRED_MISSING__"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,53 +41,3 @@ impl IntoResponse for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use axum::response::IntoResponse;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn state_error_display() {
|
|
||||||
let err = Error::StateError("bad state".into());
|
|
||||||
assert_eq!(err.to_string(), "bad state");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn database_error_display() {
|
|
||||||
let err = Error::DatabaseError("connection lost".into());
|
|
||||||
assert_eq!(err.to_string(), "database error: connection lost");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_error_display() {
|
|
||||||
let err = Error::ConfigError("missing var".into());
|
|
||||||
assert_eq!(err.to_string(), "configuration error: missing var");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn state_error_into_response_500() {
|
|
||||||
let resp = Error::StateError("oops".into()).into_response();
|
|
||||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn database_error_into_response_503() {
|
|
||||||
let resp = Error::DatabaseError("down".into()).into_response();
|
|
||||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_error_into_response_500() {
|
|
||||||
let resp = Error::ConfigError("bad cfg".into()).into_response();
|
|
||||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn io_error_into_response_500() {
|
|
||||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
|
|
||||||
let resp = Error::IoError(io_err).into_response();
|
|
||||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::models::AgentEntry;
|
|
||||||
|
|
||||||
/// Raw assistant object returned by the LangGraph `POST /assistants/search`
|
|
||||||
/// endpoint. Only the fields we display are deserialized; unknown keys are
|
|
||||||
/// silently ignored thanks to serde defaults.
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct LangGraphAssistant {
|
|
||||||
assistant_id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
graph_id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
metadata: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch the list of assistants (agents) from a LangGraph instance.
|
|
||||||
///
|
|
||||||
/// Calls `POST <langgraph_url>/assistants/search` with an empty body to
|
|
||||||
/// retrieve every registered assistant. Each result is mapped to the
|
|
||||||
/// frontend-friendly `AgentEntry` model.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A vector of `AgentEntry` structs. Returns an empty vector when the
|
|
||||||
/// LangGraph URL is not configured or the service is unreachable.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `ServerFnError` on network or deserialization failures that
|
|
||||||
/// indicate a misconfigured (but present) LangGraph instance.
|
|
||||||
#[server(endpoint = "list-langgraph-agents")]
|
|
||||||
pub async fn list_langgraph_agents() -> Result<Vec<AgentEntry>, ServerFnError> {
|
|
||||||
let state: crate::infrastructure::ServerState =
|
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
|
||||||
|
|
||||||
let base_url = state.services.langgraph_url.clone();
|
|
||||||
if base_url.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = format!("{}/assistants/search", base_url.trim_end_matches('/'));
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(5))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
|
||||||
|
|
||||||
// LangGraph expects a POST with a JSON body (empty object = no filters).
|
|
||||||
let resp = match client
|
|
||||||
.post(&url)
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body("{}")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) if r.status().is_success() => r,
|
|
||||||
Ok(r) => {
|
|
||||||
let status = r.status();
|
|
||||||
let body = r.text().await.unwrap_or_default();
|
|
||||||
tracing::error!("LangGraph returned {status}: {body}");
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("LangGraph request failed: {e}");
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let assistants: Vec<LangGraphAssistant> = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ServerFnError::new(format!("Failed to parse LangGraph response: {e}")))?;
|
|
||||||
|
|
||||||
let entries = assistants
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| {
|
|
||||||
// Use the assistant name if present, otherwise fall back to graph_id.
|
|
||||||
let name = if a.name.is_empty() {
|
|
||||||
a.graph_id.clone()
|
|
||||||
} else {
|
|
||||||
a.name
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract a description from metadata if available.
|
|
||||||
let description = a
|
|
||||||
.metadata
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
AgentEntry {
|
|
||||||
id: a.assistant_id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
status: "active".to_string(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,25 +72,7 @@ mod inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let html = resp.text().await.ok()?;
|
let html = resp.text().await.ok()?;
|
||||||
parse_article_html(&html)
|
let document = scraper::Html::parse_document(&html);
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse article text from raw HTML without any network I/O.
|
|
||||||
///
|
|
||||||
/// Uses a tiered extraction strategy:
|
|
||||||
/// 1. Try content within `<article>`, `<main>`, or `[role="main"]`
|
|
||||||
/// 2. Fall back to all `<p>` tags outside excluded containers
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `html` - Raw HTML string to parse
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// The extracted text, or `None` if extraction yields < 100 chars.
|
|
||||||
/// Output is capped at 8000 characters.
|
|
||||||
pub(crate) fn parse_article_html(html: &str) -> Option<String> {
|
|
||||||
let document = scraper::Html::parse_document(html);
|
|
||||||
|
|
||||||
// Strategy 1: Extract from semantic article containers.
|
// Strategy 1: Extract from semantic article containers.
|
||||||
// Most news sites wrap the main content in <article>, <main>,
|
// Most news sites wrap the main content in <article>, <main>,
|
||||||
@@ -152,12 +134,12 @@ mod inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sum the total character length of all collected text parts.
|
/// Sum the total character length of all collected text parts.
|
||||||
pub(crate) fn joined_len(parts: &[String]) -> usize {
|
fn joined_len(parts: &[String]) -> usize {
|
||||||
parts.iter().map(|s| s.len()).sum()
|
parts.iter().map(|s| s.len()).sum()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 +149,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 +158,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 +205,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 +249,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 +258,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 +291,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,182 +299,29 @@ 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)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// FollowUpMessage serde tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn followup_message_serde_round_trip() {
|
|
||||||
let msg = FollowUpMessage {
|
|
||||||
role: "assistant".into(),
|
|
||||||
content: "Here is my answer.".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&msg).expect("serialize FollowUpMessage");
|
|
||||||
let back: FollowUpMessage =
|
|
||||||
serde_json::from_str(&json).expect("deserialize FollowUpMessage");
|
|
||||||
assert_eq!(msg, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn followup_message_deserialize_from_json_literal() {
|
|
||||||
let json = r#"{"role":"system","content":"You are helpful."}"#;
|
|
||||||
let msg: FollowUpMessage = serde_json::from_str(json).expect("deserialize literal");
|
|
||||||
assert_eq!(msg.role, "system");
|
|
||||||
assert_eq!(msg.content, "You are helpful.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// joined_len and parse_article_html tests (server feature required)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
mod server_tests {
|
|
||||||
use super::super::inner::{joined_len, parse_article_html};
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn joined_len_empty_input() {
|
|
||||||
assert_eq!(joined_len(&[]), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn joined_len_sums_correctly() {
|
|
||||||
let parts = vec!["abc".into(), "de".into(), "fghij".into()];
|
|
||||||
assert_eq!(joined_len(&parts), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// parse_article_html tests
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Helper: generate a string of given length from a repeated word.
|
|
||||||
fn lorem(len: usize) -> String {
|
|
||||||
"Lorem ipsum dolor sit amet consectetur adipiscing elit "
|
|
||||||
.repeat((len / 55) + 1)
|
|
||||||
.chars()
|
|
||||||
.take(len)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn article_tag_extracts_text() {
|
|
||||||
let body = lorem(250);
|
|
||||||
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
|
|
||||||
let result = parse_article_html(&html);
|
|
||||||
assert!(result.is_some(), "expected Some for article tag");
|
|
||||||
assert!(result.unwrap().contains("Lorem"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn main_tag_extracts_text() {
|
|
||||||
let body = lorem(250);
|
|
||||||
let html = format!("<html><body><main><p>{body}</p></main></body></html>");
|
|
||||||
let result = parse_article_html(&html);
|
|
||||||
assert!(result.is_some(), "expected Some for main tag");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fallback_to_p_tags_when_article_main_yield_little() {
|
|
||||||
// No <article>/<main>, so falls back to <p> tags
|
|
||||||
let body = lorem(250);
|
|
||||||
let html = format!("<html><body><div><p>{body}</p></div></body></html>");
|
|
||||||
let result = parse_article_html(&html);
|
|
||||||
assert!(result.is_some(), "expected fallback to <p> tags");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn excludes_nav_footer_aside_content() {
|
|
||||||
// Content only inside excluded containers -- should be excluded
|
|
||||||
let body = lorem(250);
|
|
||||||
let html = format!(
|
|
||||||
"<html><body>\
|
|
||||||
<nav><p>{body}</p></nav>\
|
|
||||||
<footer><p>{body}</p></footer>\
|
|
||||||
<aside><p>{body}</p></aside>\
|
|
||||||
</body></html>"
|
|
||||||
);
|
|
||||||
let result = parse_article_html(&html);
|
|
||||||
assert!(result.is_none(), "expected None for excluded-only content");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_none_when_text_too_short() {
|
|
||||||
let html = "<html><body><p>Short.</p></body></html>";
|
|
||||||
let result = parse_article_html(html);
|
|
||||||
assert!(result.is_none(), "expected None for short text");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn truncates_at_8000_chars() {
|
|
||||||
let body = lorem(10000);
|
|
||||||
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
|
|
||||||
let result = parse_article_html(&html).expect("expected Some");
|
|
||||||
assert!(
|
|
||||||
result.len() <= 8000,
|
|
||||||
"expected <= 8000 chars, got {}",
|
|
||||||
result.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skips_fragments_under_30_chars() {
|
|
||||||
// Only fragments < 30 chars -- should yield None
|
|
||||||
let html = "<html><body><article>\
|
|
||||||
<p>Short frag one</p>\
|
|
||||||
<p>Another tiny bit</p>\
|
|
||||||
</article></body></html>";
|
|
||||||
let result = parse_article_html(html);
|
|
||||||
assert!(result.is_none(), "expected None for tiny fragments");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extracts_from_role_main_attribute() {
|
|
||||||
let body = lorem(250);
|
|
||||||
let html = format!(
|
|
||||||
"<html><body>\
|
|
||||||
<div role=\"main\"><p>{body}</p></div>\
|
|
||||||
</body></html>"
|
|
||||||
);
|
|
||||||
let result = parse_article_html(&html);
|
|
||||||
assert!(result.is_some(), "expected Some for role=main");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
// the #[server] macro generates client stubs for the web target)
|
// the #[server] macro generates client stubs for the web target)
|
||||||
pub mod auth_check;
|
pub mod auth_check;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
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.)
|
||||||
|
|||||||
92
src/infrastructure/ollama.rs
Normal file
92
src/infrastructure/ollama.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,42 +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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn provider_message_serde_round_trip() {
|
|
||||||
let msg = ProviderMessage {
|
|
||||||
role: "assistant".into(),
|
|
||||||
content: "Hello, world!".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&msg).expect("serialize ProviderMessage");
|
|
||||||
let back: ProviderMessage =
|
|
||||||
serde_json::from_str(&json).expect("deserialize ProviderMessage");
|
|
||||||
assert_eq!(msg.role, back.role);
|
|
||||||
assert_eq!(msg.content, back.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn provider_message_deserialize_from_json_literal() {
|
|
||||||
let json = r#"{"role":"user","content":"What is Rust?"}"#;
|
|
||||||
let msg: ProviderMessage = serde_json::from_str(json).expect("deserialize from literal");
|
|
||||||
assert_eq!(msg.role, "user");
|
|
||||||
assert_eq!(msg.content, "What is Rust?");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use dioxus::prelude::*;
|
|||||||
// The #[server] macro generates a client stub for the web build that
|
// The #[server] macro generates a client stub for the web build that
|
||||||
// sends a network request instead of executing this function body.
|
// sends a network request instead of executing this function body.
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub(crate) mod inner {
|
mod inner {
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// Individual result from the SearXNG search API.
|
/// Individual result from the SearXNG search API.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct SearxngResult {
|
pub(super) struct SearxngResult {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
@@ -25,7 +25,7 @@ pub(crate) mod inner {
|
|||||||
|
|
||||||
/// Top-level response from the SearXNG search API.
|
/// Top-level response from the SearXNG search API.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct SearxngResponse {
|
pub(super) struct SearxngResponse {
|
||||||
pub results: Vec<SearxngResult>,
|
pub results: Vec<SearxngResult>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ pub(crate) mod inner {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The domain host or a fallback "Web" string
|
/// The domain host or a fallback "Web" string
|
||||||
pub(crate) fn extract_source(url_str: &str) -> String {
|
pub(super) fn extract_source(url_str: &str) -> String {
|
||||||
url::Url::parse(url_str)
|
url::Url::parse(url_str)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|u| u.host_str().map(String::from))
|
.and_then(|u| u.host_str().map(String::from))
|
||||||
@@ -64,7 +64,7 @@ pub(crate) mod inner {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Filtered, deduplicated, and ranked results
|
/// Filtered, deduplicated, and ranked results
|
||||||
pub(crate) fn rank_and_deduplicate(
|
pub(super) fn rank_and_deduplicate(
|
||||||
mut results: Vec<SearxngResult>,
|
mut results: Vec<SearxngResult>,
|
||||||
max_results: usize,
|
max_results: usize,
|
||||||
) -> Vec<SearxngResult> {
|
) -> Vec<SearxngResult> {
|
||||||
@@ -285,166 +285,3 @@ pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
|
|||||||
|
|
||||||
Ok(topics)
|
Ok(topics)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "server"))]
|
|
||||||
mod tests {
|
|
||||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
||||||
|
|
||||||
use super::inner::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// extract_source()
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_source_strips_www() {
|
|
||||||
assert_eq!(
|
|
||||||
extract_source("https://www.example.com/page"),
|
|
||||||
"example.com"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_source_returns_domain() {
|
|
||||||
assert_eq!(
|
|
||||||
extract_source("https://techcrunch.com/article"),
|
|
||||||
"techcrunch.com"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_source_invalid_url_returns_web() {
|
|
||||||
assert_eq!(extract_source("not-a-url"), "Web");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_source_no_scheme_returns_web() {
|
|
||||||
// url::Url::parse requires a scheme; bare domain fails
|
|
||||||
assert_eq!(extract_source("example.com/path"), "Web");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// rank_and_deduplicate()
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn make_result(url: &str, content: &str, score: f64) -> SearxngResult {
|
|
||||||
SearxngResult {
|
|
||||||
title: "Title".into(),
|
|
||||||
url: url.into(),
|
|
||||||
content: if content.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(content.into())
|
|
||||||
},
|
|
||||||
published_date: None,
|
|
||||||
thumbnail: None,
|
|
||||||
score,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_filters_empty_content() {
|
|
||||||
let results = vec![
|
|
||||||
make_result("https://a.com", "", 10.0),
|
|
||||||
make_result(
|
|
||||||
"https://b.com",
|
|
||||||
"This is meaningful content that passes the length filter",
|
|
||||||
5.0,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let ranked = rank_and_deduplicate(results, 10);
|
|
||||||
assert_eq!(ranked.len(), 1);
|
|
||||||
assert_eq!(ranked[0].url, "https://b.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_filters_short_content() {
|
|
||||||
let results = vec![
|
|
||||||
make_result("https://a.com", "short", 10.0),
|
|
||||||
make_result(
|
|
||||||
"https://b.com",
|
|
||||||
"This content is long enough to pass the 20-char filter threshold",
|
|
||||||
5.0,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let ranked = rank_and_deduplicate(results, 10);
|
|
||||||
assert_eq!(ranked.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_deduplicates_by_domain_keeps_highest() {
|
|
||||||
let results = vec![
|
|
||||||
make_result(
|
|
||||||
"https://example.com/page1",
|
|
||||||
"First result with enough content here for the filter",
|
|
||||||
3.0,
|
|
||||||
),
|
|
||||||
make_result(
|
|
||||||
"https://example.com/page2",
|
|
||||||
"Second result with enough content here for the filter",
|
|
||||||
8.0,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let ranked = rank_and_deduplicate(results, 10);
|
|
||||||
assert_eq!(ranked.len(), 1);
|
|
||||||
// Should keep the highest-scored one (page2 with score 8.0)
|
|
||||||
assert_eq!(ranked[0].url, "https://example.com/page2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_sorts_by_score_descending() {
|
|
||||||
let results = vec![
|
|
||||||
make_result(
|
|
||||||
"https://a.com/p",
|
|
||||||
"Content A that is long enough to pass the filter check",
|
|
||||||
1.0,
|
|
||||||
),
|
|
||||||
make_result(
|
|
||||||
"https://b.com/p",
|
|
||||||
"Content B that is long enough to pass the filter check",
|
|
||||||
5.0,
|
|
||||||
),
|
|
||||||
make_result(
|
|
||||||
"https://c.com/p",
|
|
||||||
"Content C that is long enough to pass the filter check",
|
|
||||||
3.0,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let ranked = rank_and_deduplicate(results, 10);
|
|
||||||
assert_eq!(ranked.len(), 3);
|
|
||||||
assert!(ranked[0].score >= ranked[1].score);
|
|
||||||
assert!(ranked[1].score >= ranked[2].score);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_truncates_to_max_results() {
|
|
||||||
let results: Vec<_> = (0..20)
|
|
||||||
.map(|i| {
|
|
||||||
make_result(
|
|
||||||
&format!("https://site{i}.com/page"),
|
|
||||||
&format!("Content for site {i} that is long enough to pass the filter"),
|
|
||||||
i as f64,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let ranked = rank_and_deduplicate(results, 5);
|
|
||||||
assert_eq!(ranked.len(), 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_empty_input_returns_empty() {
|
|
||||||
let ranked = rank_and_deduplicate(vec![], 10);
|
|
||||||
assert!(ranked.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_all_filtered_returns_empty() {
|
|
||||||
let results = vec![
|
|
||||||
make_result("https://a.com", "", 10.0),
|
|
||||||
make_result("https://b.com", "too short", 5.0),
|
|
||||||
];
|
|
||||||
let ranked = rank_and_deduplicate(results, 10);
|
|
||||||
assert!(ranked.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -44,91 +44,3 @@ pub struct User {
|
|||||||
/// Avatar / profile picture URL.
|
/// Avatar / profile picture URL.
|
||||||
pub avatar_url: String,
|
pub avatar_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_state_inner_default_has_empty_strings() {
|
|
||||||
let inner = UserStateInner::default();
|
|
||||||
assert_eq!(inner.sub, "");
|
|
||||||
assert_eq!(inner.access_token, "");
|
|
||||||
assert_eq!(inner.refresh_token, "");
|
|
||||||
assert_eq!(inner.user.email, "");
|
|
||||||
assert_eq!(inner.user.name, "");
|
|
||||||
assert_eq!(inner.user.avatar_url, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_default_has_empty_strings() {
|
|
||||||
let user = User::default();
|
|
||||||
assert_eq!(user.email, "");
|
|
||||||
assert_eq!(user.name, "");
|
|
||||||
assert_eq!(user.avatar_url, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_state_inner_serde_round_trip() {
|
|
||||||
let inner = UserStateInner {
|
|
||||||
sub: "user-123".into(),
|
|
||||||
access_token: "tok-abc".into(),
|
|
||||||
refresh_token: "ref-xyz".into(),
|
|
||||||
user: User {
|
|
||||||
email: "a@b.com".into(),
|
|
||||||
name: "Alice".into(),
|
|
||||||
avatar_url: "https://img.example.com/a.png".into(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&inner).expect("serialize UserStateInner");
|
|
||||||
let back: UserStateInner = serde_json::from_str(&json).expect("deserialize UserStateInner");
|
|
||||||
assert_eq!(inner.sub, back.sub);
|
|
||||||
assert_eq!(inner.access_token, back.access_token);
|
|
||||||
assert_eq!(inner.refresh_token, back.refresh_token);
|
|
||||||
assert_eq!(inner.user.email, back.user.email);
|
|
||||||
assert_eq!(inner.user.name, back.user.name);
|
|
||||||
assert_eq!(inner.user.avatar_url, back.user.avatar_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_state_from_inner_and_deref() {
|
|
||||||
let inner = UserStateInner {
|
|
||||||
sub: "sub-1".into(),
|
|
||||||
access_token: "at".into(),
|
|
||||||
refresh_token: "rt".into(),
|
|
||||||
user: User {
|
|
||||||
email: "e@e.com".into(),
|
|
||||||
name: "Eve".into(),
|
|
||||||
avatar_url: "".into(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let state = UserState::from(inner);
|
|
||||||
// Deref should give access to inner fields
|
|
||||||
assert_eq!(state.sub, "sub-1");
|
|
||||||
assert_eq!(state.user.name, "Eve");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_serde_round_trip() {
|
|
||||||
let user = User {
|
|
||||||
email: "bob@test.com".into(),
|
|
||||||
name: "Bob".into(),
|
|
||||||
avatar_url: "https://avatars.io/bob".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&user).expect("serialize User");
|
|
||||||
let back: User = serde_json::from_str(&json).expect("deserialize User");
|
|
||||||
assert_eq!(user.email, back.email);
|
|
||||||
assert_eq!(user.name, back.name);
|
|
||||||
assert_eq!(user.avatar_url, back.avatar_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_state_clone_is_cheap() {
|
|
||||||
let inner = UserStateInner::default();
|
|
||||||
let state = UserState::from(inner);
|
|
||||||
let cloned = state.clone();
|
|
||||||
// Both point to the same Arc allocation
|
|
||||||
assert_eq!(state.sub, cloned.sub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -105,163 +105,3 @@ pub struct ChatMessage {
|
|||||||
pub attachments: Vec<Attachment>,
|
pub attachments: Vec<Attachment>,
|
||||||
pub timestamp: String,
|
pub timestamp: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_namespace_default_is_general() {
|
|
||||||
assert_eq!(ChatNamespace::default(), ChatNamespace::General);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_role_serde_round_trip() {
|
|
||||||
for role in [ChatRole::User, ChatRole::Assistant, ChatRole::System] {
|
|
||||||
let json =
|
|
||||||
serde_json::to_string(&role).unwrap_or_else(|_| panic!("serialize {:?}", role));
|
|
||||||
let back: ChatRole =
|
|
||||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
|
|
||||||
assert_eq!(role, back);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_namespace_serde_round_trip() {
|
|
||||||
for ns in [ChatNamespace::General, ChatNamespace::News] {
|
|
||||||
let json = serde_json::to_string(&ns).unwrap_or_else(|_| panic!("serialize {:?}", ns));
|
|
||||||
let back: ChatNamespace =
|
|
||||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", ns));
|
|
||||||
assert_eq!(ns, back);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn attachment_kind_serde_round_trip() {
|
|
||||||
for kind in [
|
|
||||||
AttachmentKind::Image,
|
|
||||||
AttachmentKind::Document,
|
|
||||||
AttachmentKind::Code,
|
|
||||||
] {
|
|
||||||
let json =
|
|
||||||
serde_json::to_string(&kind).unwrap_or_else(|_| panic!("serialize {:?}", kind));
|
|
||||||
let back: AttachmentKind =
|
|
||||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", kind));
|
|
||||||
assert_eq!(kind, back);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn attachment_serde_round_trip() {
|
|
||||||
let att = Attachment {
|
|
||||||
name: "photo.png".into(),
|
|
||||||
kind: AttachmentKind::Image,
|
|
||||||
size_bytes: 2048,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&att).expect("serialize Attachment");
|
|
||||||
let back: Attachment = serde_json::from_str(&json).expect("deserialize Attachment");
|
|
||||||
assert_eq!(att, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_session_serde_round_trip() {
|
|
||||||
let session = ChatSession {
|
|
||||||
id: "abc123".into(),
|
|
||||||
user_sub: "user-1".into(),
|
|
||||||
title: "Test Chat".into(),
|
|
||||||
namespace: ChatNamespace::General,
|
|
||||||
provider: "litellm".into(),
|
|
||||||
model: "qwen3-32b".into(),
|
|
||||||
created_at: "2025-01-01T00:00:00Z".into(),
|
|
||||||
updated_at: "2025-01-01T01:00:00Z".into(),
|
|
||||||
article_url: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&session).expect("serialize ChatSession");
|
|
||||||
let back: ChatSession = serde_json::from_str(&json).expect("deserialize ChatSession");
|
|
||||||
assert_eq!(session, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_session_id_alias_deserialization() {
|
|
||||||
// MongoDB returns `_id` instead of `id`
|
|
||||||
let json = r#"{
|
|
||||||
"_id": "mongo-id",
|
|
||||||
"user_sub": "u1",
|
|
||||||
"title": "t",
|
|
||||||
"provider": "litellm",
|
|
||||||
"model": "m",
|
|
||||||
"created_at": "2025-01-01",
|
|
||||||
"updated_at": "2025-01-01"
|
|
||||||
}"#;
|
|
||||||
let session: ChatSession = serde_json::from_str(json).expect("deserialize with _id");
|
|
||||||
assert_eq!(session.id, "mongo-id");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_session_empty_id_skips_serialization() {
|
|
||||||
let session = ChatSession {
|
|
||||||
id: String::new(),
|
|
||||||
user_sub: "u1".into(),
|
|
||||||
title: "t".into(),
|
|
||||||
namespace: ChatNamespace::default(),
|
|
||||||
provider: "litellm".into(),
|
|
||||||
model: "m".into(),
|
|
||||||
created_at: "2025-01-01".into(),
|
|
||||||
updated_at: "2025-01-01".into(),
|
|
||||||
article_url: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&session).expect("serialize");
|
|
||||||
// `id` field should be absent when empty due to skip_serializing_if
|
|
||||||
assert!(!json.contains("\"id\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_session_none_article_url_skips_serialization() {
|
|
||||||
let session = ChatSession {
|
|
||||||
id: "s1".into(),
|
|
||||||
user_sub: "u1".into(),
|
|
||||||
title: "t".into(),
|
|
||||||
namespace: ChatNamespace::default(),
|
|
||||||
provider: "litellm".into(),
|
|
||||||
model: "m".into(),
|
|
||||||
created_at: "2025-01-01".into(),
|
|
||||||
updated_at: "2025-01-01".into(),
|
|
||||||
article_url: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&session).expect("serialize");
|
|
||||||
assert!(!json.contains("article_url"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_message_serde_round_trip() {
|
|
||||||
let msg = ChatMessage {
|
|
||||||
id: "msg-1".into(),
|
|
||||||
session_id: "s1".into(),
|
|
||||||
role: ChatRole::User,
|
|
||||||
content: "Hello AI".into(),
|
|
||||||
attachments: vec![Attachment {
|
|
||||||
name: "doc.pdf".into(),
|
|
||||||
kind: AttachmentKind::Document,
|
|
||||||
size_bytes: 4096,
|
|
||||||
}],
|
|
||||||
timestamp: "2025-01-01T00:00:00Z".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&msg).expect("serialize ChatMessage");
|
|
||||||
let back: ChatMessage = serde_json::from_str(&json).expect("deserialize ChatMessage");
|
|
||||||
assert_eq!(msg, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_message_id_alias_deserialization() {
|
|
||||||
let json = r#"{
|
|
||||||
"_id": "mongo-msg-id",
|
|
||||||
"session_id": "s1",
|
|
||||||
"role": "User",
|
|
||||||
"content": "hi",
|
|
||||||
"timestamp": "2025-01-01"
|
|
||||||
}"#;
|
|
||||||
let msg: ChatMessage = serde_json::from_str(json).expect("deserialize with _id");
|
|
||||||
assert_eq!(msg.id, "mongo-msg-id");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,63 +45,3 @@ pub struct AnalyticsMetric {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
pub change_pct: f64,
|
pub change_pct: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agent_entry_serde_round_trip() {
|
|
||||||
let agent = AgentEntry {
|
|
||||||
id: "a1".into(),
|
|
||||||
name: "RAG Agent".into(),
|
|
||||||
description: "Retrieval-augmented generation".into(),
|
|
||||||
status: "running".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&agent).expect("serialize AgentEntry");
|
|
||||||
let back: AgentEntry = serde_json::from_str(&json).expect("deserialize AgentEntry");
|
|
||||||
assert_eq!(agent, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flow_entry_serde_round_trip() {
|
|
||||||
let flow = FlowEntry {
|
|
||||||
id: "f1".into(),
|
|
||||||
name: "Data Pipeline".into(),
|
|
||||||
node_count: 5,
|
|
||||||
last_run: Some("2025-06-01T12:00:00Z".into()),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&flow).expect("serialize FlowEntry");
|
|
||||||
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize FlowEntry");
|
|
||||||
assert_eq!(flow, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flow_entry_with_none_last_run() {
|
|
||||||
let flow = FlowEntry {
|
|
||||||
id: "f2".into(),
|
|
||||||
name: "New Flow".into(),
|
|
||||||
node_count: 0,
|
|
||||||
last_run: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&flow).expect("serialize");
|
|
||||||
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize");
|
|
||||||
assert_eq!(flow, back);
|
|
||||||
assert_eq!(back.last_run, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn analytics_metric_negative_change_pct() {
|
|
||||||
let metric = AnalyticsMetric {
|
|
||||||
label: "Latency".into(),
|
|
||||||
value: "120ms".into(),
|
|
||||||
change_pct: -15.5,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&metric).expect("serialize AnalyticsMetric");
|
|
||||||
let back: AnalyticsMetric =
|
|
||||||
serde_json::from_str(&json).expect("deserialize AnalyticsMetric");
|
|
||||||
assert_eq!(metric, back);
|
|
||||||
assert!(back.change_pct < 0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ mod developer;
|
|||||||
mod news;
|
mod news;
|
||||||
mod organization;
|
mod organization;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod services;
|
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
@@ -11,5 +10,4 @@ pub use developer::*;
|
|||||||
pub use news::*;
|
pub use news::*;
|
||||||
pub use organization::*;
|
pub use organization::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
pub use services::*;
|
|
||||||
pub use user::*;
|
pub use user::*;
|
||||||
|
|||||||
@@ -23,61 +23,3 @@ pub struct NewsCard {
|
|||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
pub published_at: String,
|
pub published_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn news_card_serde_round_trip() {
|
|
||||||
let card = NewsCard {
|
|
||||||
title: "AI Breakthrough".into(),
|
|
||||||
source: "techcrunch.com".into(),
|
|
||||||
summary: "New model released".into(),
|
|
||||||
content: "Full article content here".into(),
|
|
||||||
category: "AI".into(),
|
|
||||||
url: "https://example.com/article".into(),
|
|
||||||
thumbnail_url: Some("https://example.com/thumb.jpg".into()),
|
|
||||||
published_at: "2025-06-01".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&card).expect("serialize NewsCard");
|
|
||||||
let back: NewsCard = serde_json::from_str(&json).expect("deserialize NewsCard");
|
|
||||||
assert_eq!(card, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn news_card_thumbnail_none() {
|
|
||||||
let card = NewsCard {
|
|
||||||
title: "No Thumb".into(),
|
|
||||||
source: "bbc.com".into(),
|
|
||||||
summary: "Summary".into(),
|
|
||||||
content: "Content".into(),
|
|
||||||
category: "Tech".into(),
|
|
||||||
url: "https://bbc.com/article".into(),
|
|
||||||
thumbnail_url: None,
|
|
||||||
published_at: "2025-06-01".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&card).expect("serialize");
|
|
||||||
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
|
|
||||||
assert_eq!(card, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn news_card_thumbnail_some() {
|
|
||||||
let card = NewsCard {
|
|
||||||
title: "With Thumb".into(),
|
|
||||||
source: "cnn.com".into(),
|
|
||||||
summary: "Summary".into(),
|
|
||||||
content: "Content".into(),
|
|
||||||
category: "News".into(),
|
|
||||||
url: "https://cnn.com/article".into(),
|
|
||||||
thumbnail_url: Some("https://cnn.com/img.jpg".into()),
|
|
||||||
published_at: "2025-06-01".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&card).expect("serialize");
|
|
||||||
assert!(json.contains("img.jpg"));
|
|
||||||
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
|
|
||||||
assert_eq!(card.thumbnail_url, back.thumbnail_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -152,200 +116,3 @@ pub struct OrgBillingRecord {
|
|||||||
/// Number of tokens consumed during this cycle.
|
/// Number of tokens consumed during this cycle.
|
||||||
pub tokens_used: u64,
|
pub tokens_used: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn member_role_label_admin() {
|
|
||||||
assert_eq!(MemberRole::Admin.label(), "Admin");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn member_role_label_member() {
|
|
||||||
assert_eq!(MemberRole::Member.label(), "Member");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn member_role_label_viewer() {
|
|
||||||
assert_eq!(MemberRole::Viewer.label(), "Viewer");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn member_role_all_returns_three_in_order() {
|
|
||||||
let all = MemberRole::all();
|
|
||||||
assert_eq!(all.len(), 3);
|
|
||||||
assert_eq!(all[0], MemberRole::Admin);
|
|
||||||
assert_eq!(all[1], MemberRole::Member);
|
|
||||||
assert_eq!(all[2], MemberRole::Viewer);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn member_role_serde_round_trip() {
|
|
||||||
for role in MemberRole::all() {
|
|
||||||
let json =
|
|
||||||
serde_json::to_string(role).unwrap_or_else(|_| panic!("serialize {:?}", role));
|
|
||||||
let back: MemberRole =
|
|
||||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
|
|
||||||
assert_eq!(*role, back);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn org_member_serde_round_trip() {
|
|
||||||
let member = OrgMember {
|
|
||||||
id: "m1".into(),
|
|
||||||
name: "Alice".into(),
|
|
||||||
email: "alice@example.com".into(),
|
|
||||||
role: MemberRole::Admin,
|
|
||||||
joined_at: "2025-01-01T00:00:00Z".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&member).expect("serialize OrgMember");
|
|
||||||
let back: OrgMember = serde_json::from_str(&json).expect("deserialize OrgMember");
|
|
||||||
assert_eq!(member, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pricing_plan_with_max_seats() {
|
|
||||||
let plan = PricingPlan {
|
|
||||||
id: "team".into(),
|
|
||||||
name: "Team".into(),
|
|
||||||
price_eur: 49,
|
|
||||||
features: vec!["SSO".into(), "Priority".into()],
|
|
||||||
highlighted: true,
|
|
||||||
max_seats: Some(25),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
|
|
||||||
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
|
|
||||||
assert_eq!(plan, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pricing_plan_without_max_seats() {
|
|
||||||
let plan = PricingPlan {
|
|
||||||
id: "enterprise".into(),
|
|
||||||
name: "Enterprise".into(),
|
|
||||||
price_eur: 199,
|
|
||||||
features: vec!["Unlimited".into()],
|
|
||||||
highlighted: false,
|
|
||||||
max_seats: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
|
|
||||||
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
|
|
||||||
assert_eq!(plan, back);
|
|
||||||
assert!(json.contains("null") || !json.contains("max_seats"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn billing_usage_serde_round_trip() {
|
|
||||||
let usage = BillingUsage {
|
|
||||||
seats_used: 5,
|
|
||||||
seats_total: 10,
|
|
||||||
tokens_used: 1_000_000,
|
|
||||||
tokens_limit: 5_000_000,
|
|
||||||
billing_cycle_end: "2025-12-31".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&usage).expect("serialize BillingUsage");
|
|
||||||
let back: BillingUsage = serde_json::from_str(&json).expect("deserialize BillingUsage");
|
|
||||||
assert_eq!(usage, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn org_settings_default() {
|
|
||||||
let settings = OrgSettings::default();
|
|
||||||
assert_eq!(settings.org_id, "");
|
|
||||||
assert_eq!(settings.plan_id, "");
|
|
||||||
assert!(settings.enabled_features.is_empty());
|
|
||||||
assert_eq!(settings.stripe_customer_id, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn org_billing_record_default() {
|
|
||||||
let record = OrgBillingRecord::default();
|
|
||||||
assert_eq!(record.org_id, "");
|
|
||||||
assert_eq!(record.cycle_start, "");
|
|
||||||
assert_eq!(record.cycle_end, "");
|
|
||||||
assert_eq!(record.seats_used, 0);
|
|
||||||
assert_eq!(record.tokens_used, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn litellm_usage_stats_default() {
|
|
||||||
let stats = LitellmUsageStats::default();
|
|
||||||
assert_eq!(stats.total_spend, 0.0);
|
|
||||||
assert_eq!(stats.total_prompt_tokens, 0);
|
|
||||||
assert_eq!(stats.total_completion_tokens, 0);
|
|
||||||
assert_eq!(stats.total_tokens, 0);
|
|
||||||
assert!(stats.model_breakdown.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn litellm_usage_stats_serde_round_trip() {
|
|
||||||
let stats = LitellmUsageStats {
|
|
||||||
total_spend: 12.34,
|
|
||||||
total_prompt_tokens: 50_000,
|
|
||||||
total_completion_tokens: 25_000,
|
|
||||||
total_tokens: 75_000,
|
|
||||||
model_breakdown: vec![
|
|
||||||
ModelUsage {
|
|
||||||
model: "gpt-4".into(),
|
|
||||||
spend: 10.0,
|
|
||||||
prompt_tokens: 40_000,
|
|
||||||
completion_tokens: 20_000,
|
|
||||||
total_tokens: 60_000,
|
|
||||||
},
|
|
||||||
ModelUsage {
|
|
||||||
model: "claude-3-opus".into(),
|
|
||||||
spend: 2.34,
|
|
||||||
prompt_tokens: 10_000,
|
|
||||||
completion_tokens: 5_000,
|
|
||||||
total_tokens: 15_000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&stats).expect("serialize LitellmUsageStats");
|
|
||||||
let back: LitellmUsageStats =
|
|
||||||
serde_json::from_str(&json).expect("deserialize LitellmUsageStats");
|
|
||||||
assert_eq!(stats, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn model_usage_default() {
|
|
||||||
let usage = ModelUsage::default();
|
|
||||||
assert_eq!(usage.model, "");
|
|
||||||
assert_eq!(usage.spend, 0.0);
|
|
||||||
assert_eq!(usage.prompt_tokens, 0);
|
|
||||||
assert_eq!(usage.completion_tokens, 0);
|
|
||||||
assert_eq!(usage.total_tokens, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn model_usage_serde_round_trip() {
|
|
||||||
let usage = ModelUsage {
|
|
||||||
model: "gpt-4-turbo".into(),
|
|
||||||
spend: 5.67,
|
|
||||||
prompt_tokens: 30_000,
|
|
||||||
completion_tokens: 15_000,
|
|
||||||
total_tokens: 45_000,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&usage).expect("serialize ModelUsage");
|
|
||||||
let back: ModelUsage = serde_json::from_str(&json).expect("deserialize ModelUsage");
|
|
||||||
assert_eq!(usage, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn litellm_usage_stats_empty_breakdown_round_trip() {
|
|
||||||
let stats = LitellmUsageStats {
|
|
||||||
total_spend: 0.0,
|
|
||||||
total_prompt_tokens: 0,
|
|
||||||
total_completion_tokens: 0,
|
|
||||||
total_tokens: 0,
|
|
||||||
model_breakdown: Vec::new(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&stats).expect("serialize empty stats");
|
|
||||||
let back: LitellmUsageStats = serde_json::from_str(&json).expect("deserialize empty stats");
|
|
||||||
assert_eq!(stats, back);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -72,84 +72,3 @@ pub struct ProviderConfig {
|
|||||||
pub selected_embedding: String,
|
pub selected_embedding: String,
|
||||||
pub api_key_set: bool,
|
pub api_key_set: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn llm_provider_label_litellm() {
|
|
||||||
assert_eq!(LlmProvider::LiteLlm.label(), "LiteLLM");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn llm_provider_label_hugging_face() {
|
|
||||||
assert_eq!(LlmProvider::HuggingFace.label(), "Hugging Face");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn llm_provider_label_openai() {
|
|
||||||
assert_eq!(LlmProvider::OpenAi.label(), "OpenAI");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn llm_provider_label_anthropic() {
|
|
||||||
assert_eq!(LlmProvider::Anthropic.label(), "Anthropic");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn llm_provider_serde_round_trip() {
|
|
||||||
for variant in [
|
|
||||||
LlmProvider::LiteLlm,
|
|
||||||
LlmProvider::HuggingFace,
|
|
||||||
LlmProvider::OpenAi,
|
|
||||||
LlmProvider::Anthropic,
|
|
||||||
] {
|
|
||||||
let json = serde_json::to_string(&variant)
|
|
||||||
.unwrap_or_else(|_| panic!("serialize {:?}", variant));
|
|
||||||
let back: LlmProvider =
|
|
||||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", variant));
|
|
||||||
assert_eq!(variant, back);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn model_entry_serde_round_trip() {
|
|
||||||
let entry = ModelEntry {
|
|
||||||
id: "qwen3-32b".into(),
|
|
||||||
name: "Qwen3 32B".into(),
|
|
||||||
provider: LlmProvider::LiteLlm,
|
|
||||||
context_window: 32,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&entry).expect("serialize ModelEntry");
|
|
||||||
let back: ModelEntry = serde_json::from_str(&json).expect("deserialize ModelEntry");
|
|
||||||
assert_eq!(entry, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn embedding_entry_serde_round_trip() {
|
|
||||||
let entry = EmbeddingEntry {
|
|
||||||
id: "nomic-embed".into(),
|
|
||||||
name: "Nomic Embed".into(),
|
|
||||||
provider: LlmProvider::HuggingFace,
|
|
||||||
dimensions: 768,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&entry).expect("serialize EmbeddingEntry");
|
|
||||||
let back: EmbeddingEntry = serde_json::from_str(&json).expect("deserialize EmbeddingEntry");
|
|
||||||
assert_eq!(entry, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn provider_config_serde_round_trip() {
|
|
||||||
let cfg = ProviderConfig {
|
|
||||||
provider: LlmProvider::Anthropic,
|
|
||||||
selected_model: "claude-3".into(),
|
|
||||||
selected_embedding: "embed-v1".into(),
|
|
||||||
api_key_set: true,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&cfg).expect("serialize ProviderConfig");
|
|
||||||
let back: ProviderConfig = serde_json::from_str(&json).expect("deserialize ProviderConfig");
|
|
||||||
assert_eq!(cfg, back);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Frontend-facing URLs for developer tool services.
|
|
||||||
///
|
|
||||||
/// Provided as a context signal in `AppShell` so that developer pages
|
|
||||||
/// can read the configured URLs without threading props through layouts.
|
|
||||||
/// An empty string indicates the service is not configured.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ServiceUrlsContext {
|
|
||||||
/// LangGraph agent builder URL (empty if not configured)
|
|
||||||
pub langgraph_url: String,
|
|
||||||
/// LangFlow visual workflow builder URL (empty if not configured)
|
|
||||||
pub langflow_url: String,
|
|
||||||
/// Langfuse observability URL (empty if not configured)
|
|
||||||
pub langfuse_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_urls_are_empty() {
|
|
||||||
let ctx = ServiceUrlsContext::default();
|
|
||||||
assert_eq!(ctx.langgraph_url, "");
|
|
||||||
assert_eq!(ctx.langflow_url, "");
|
|
||||||
assert_eq!(ctx.langfuse_url, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn serde_round_trip() {
|
|
||||||
let ctx = ServiceUrlsContext {
|
|
||||||
langgraph_url: "http://localhost:8123".into(),
|
|
||||||
langflow_url: "http://localhost:7860".into(),
|
|
||||||
langfuse_url: "http://localhost:3000".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&ctx).expect("serialize ServiceUrlsContext");
|
|
||||||
let back: ServiceUrlsContext =
|
|
||||||
serde_json::from_str(&json).expect("deserialize ServiceUrlsContext");
|
|
||||||
assert_eq!(ctx, back);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,23 +24,17 @@ pub struct AuthInfo {
|
|||||||
pub avatar_url: String,
|
pub avatar_url: String,
|
||||||
/// LibreChat instance URL for the sidebar chat link
|
/// LibreChat instance URL for the sidebar chat link
|
||||||
pub librechat_url: String,
|
pub librechat_url: String,
|
||||||
/// LangGraph agent builder URL (empty if not configured)
|
|
||||||
pub langgraph_url: String,
|
|
||||||
/// LangFlow visual workflow builder URL (empty if not configured)
|
|
||||||
pub langflow_url: String,
|
|
||||||
/// Langfuse observability URL (empty if not configured)
|
|
||||||
pub langfuse_url: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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")]
|
||||||
@@ -51,8 +45,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.
|
||||||
@@ -66,97 +60,13 @@ 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
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub provider_config: UserProviderConfig,
|
pub provider_config: UserProviderConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_data_default() {
|
|
||||||
let ud = UserData::default();
|
|
||||||
assert_eq!(ud.name, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auth_info_default_not_authenticated() {
|
|
||||||
let info = AuthInfo::default();
|
|
||||||
assert!(!info.authenticated);
|
|
||||||
assert_eq!(info.sub, "");
|
|
||||||
assert_eq!(info.email, "");
|
|
||||||
assert_eq!(info.name, "");
|
|
||||||
assert_eq!(info.avatar_url, "");
|
|
||||||
assert_eq!(info.librechat_url, "");
|
|
||||||
assert_eq!(info.langgraph_url, "");
|
|
||||||
assert_eq!(info.langflow_url, "");
|
|
||||||
assert_eq!(info.langfuse_url, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auth_info_serde_round_trip() {
|
|
||||||
let info = AuthInfo {
|
|
||||||
authenticated: true,
|
|
||||||
sub: "sub-123".into(),
|
|
||||||
email: "test@example.com".into(),
|
|
||||||
name: "Test User".into(),
|
|
||||||
avatar_url: "https://example.com/avatar.png".into(),
|
|
||||||
librechat_url: "https://chat.example.com".into(),
|
|
||||||
langgraph_url: "http://localhost:8123".into(),
|
|
||||||
langflow_url: "http://localhost:7860".into(),
|
|
||||||
langfuse_url: "http://localhost:3000".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
|
|
||||||
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");
|
|
||||||
assert_eq!(info, back);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_preferences_default() {
|
|
||||||
let prefs = UserPreferences::default();
|
|
||||||
assert_eq!(prefs.sub, "");
|
|
||||||
assert_eq!(prefs.org_id, "");
|
|
||||||
assert!(prefs.custom_topics.is_empty());
|
|
||||||
assert!(prefs.recent_searches.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_provider_config_optional_keys_skip_none() {
|
|
||||||
let cfg = UserProviderConfig {
|
|
||||||
default_provider: "litellm".into(),
|
|
||||||
default_model: "qwen3-32b".into(),
|
|
||||||
openai_api_key: None,
|
|
||||||
anthropic_api_key: None,
|
|
||||||
huggingface_api_key: None,
|
|
||||||
litellm_url_override: String::new(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&cfg).expect("serialize UserProviderConfig");
|
|
||||||
assert!(!json.contains("openai_api_key"));
|
|
||||||
assert!(!json.contains("anthropic_api_key"));
|
|
||||||
assert!(!json.contains("huggingface_api_key"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_provider_config_serde_round_trip_with_keys() {
|
|
||||||
let cfg = UserProviderConfig {
|
|
||||||
default_provider: "openai".into(),
|
|
||||||
default_model: "gpt-4o".into(),
|
|
||||||
openai_api_key: Some("sk-test".into()),
|
|
||||||
anthropic_api_key: Some("ak-test".into()),
|
|
||||||
huggingface_api_key: None,
|
|
||||||
litellm_url_override: "http://custom:4000".into(),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&cfg).expect("serialize");
|
|
||||||
let back: UserProviderConfig = serde_json::from_str(&json).expect("deserialize");
|
|
||||||
assert_eq!(cfg, back);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -1,239 +1,26 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus_free_icons::icons::bs_icons::{
|
|
||||||
BsBook, BsBoxArrowUpRight, BsCodeSquare, BsCpu, BsGithub, BsLightningCharge,
|
|
||||||
};
|
|
||||||
use dioxus_free_icons::Icon;
|
|
||||||
|
|
||||||
use crate::i18n::{t, Locale};
|
use crate::i18n::{t, Locale};
|
||||||
use crate::models::ServiceUrlsContext;
|
|
||||||
|
|
||||||
/// Agents informational landing page for LangGraph.
|
/// Agents page placeholder for the LangGraph agent builder.
|
||||||
///
|
///
|
||||||
/// Since LangGraph is API-only (no web UI), this page displays a hero section
|
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||||
/// explaining its role, a connection status indicator, a card grid linking
|
/// Will eventually integrate with the LangGraph framework.
|
||||||
/// to documentation, and a live table of registered agents fetched from the
|
|
||||||
/// LangGraph assistants API.
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AgentsPage() -> Element {
|
pub fn AgentsPage() -> Element {
|
||||||
let locale = use_context::<Signal<Locale>>();
|
let locale = use_context::<Signal<Locale>>();
|
||||||
let svc = use_context::<Signal<ServiceUrlsContext>>();
|
|
||||||
let l = *locale.read();
|
let l = *locale.read();
|
||||||
let url = svc.read().langgraph_url.clone();
|
|
||||||
|
|
||||||
// Derive whether a LangGraph URL is configured
|
|
||||||
let connected = !url.is_empty();
|
|
||||||
// Build the API reference URL from the configured base, falling back to "#"
|
|
||||||
let api_ref_href = if connected {
|
|
||||||
format!("{}/docs", url)
|
|
||||||
} else {
|
|
||||||
"#".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch agents from LangGraph when connected
|
|
||||||
let agents_resource = use_resource(move || async move {
|
|
||||||
match crate::infrastructure::langgraph::list_langgraph_agents().await {
|
|
||||||
Ok(agents) => agents,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to fetch agents: {e}");
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "agents-page",
|
section { class: "placeholder-page",
|
||||||
// -- Hero section --
|
div { class: "placeholder-card",
|
||||||
div { class: "agents-hero",
|
div { class: "placeholder-icon", "A" }
|
||||||
div { class: "agents-hero-row",
|
h2 { "{t(l, \"developer.agents_title\")}" }
|
||||||
div { class: "agents-hero-icon",
|
p { class: "placeholder-desc",
|
||||||
Icon { icon: BsCpu, width: 24, height: 24 }
|
"{t(l, \"developer.agents_desc\")}"
|
||||||
}
|
|
||||||
h2 { class: "agents-hero-title",
|
|
||||||
{t(l, "developer.agents_title")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p { class: "agents-hero-desc",
|
|
||||||
{t(l, "developer.agents_desc")}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Connection status --
|
|
||||||
if connected {
|
|
||||||
div { class: "agents-status",
|
|
||||||
span {
|
|
||||||
class: "agents-status-dot agents-status-dot--on",
|
|
||||||
}
|
|
||||||
span { {t(l, "developer.agents_status_connected")} }
|
|
||||||
code { class: "agents-status-url", {url.clone()} }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
div { class: "agents-status",
|
|
||||||
span {
|
|
||||||
class: "agents-status-dot agents-status-dot--off",
|
|
||||||
}
|
|
||||||
span { {t(l, "developer.agents_status_not_connected")} }
|
|
||||||
span { class: "agents-status-hint",
|
|
||||||
{t(l, "developer.agents_config_hint")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Running Agents table --
|
|
||||||
div { class: "agents-table-section",
|
|
||||||
h3 { class: "agents-section-title",
|
|
||||||
{t(l, "developer.agents_running_title")}
|
|
||||||
}
|
|
||||||
|
|
||||||
match agents_resource.read().as_ref() {
|
|
||||||
None => {
|
|
||||||
rsx! {
|
|
||||||
p { class: "agents-table-loading",
|
|
||||||
{t(l, "common.loading")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(agents) if agents.is_empty() => {
|
|
||||||
rsx! {
|
|
||||||
p { class: "agents-table-empty",
|
|
||||||
{t(l, "developer.agents_none")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(agents) => {
|
|
||||||
rsx! {
|
|
||||||
div { class: "agents-table-wrap",
|
|
||||||
table { class: "agents-table",
|
|
||||||
thead {
|
|
||||||
tr {
|
|
||||||
th { {t(l, "developer.agents_col_name")} }
|
|
||||||
th { {t(l, "developer.agents_col_id")} }
|
|
||||||
th { {t(l, "developer.agents_col_description")} }
|
|
||||||
th { {t(l, "developer.agents_col_status")} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tbody {
|
|
||||||
for agent in agents.iter() {
|
|
||||||
tr { key: "{agent.id}",
|
|
||||||
td { class: "agents-cell-name",
|
|
||||||
{agent.name.clone()}
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
code { class: "agents-cell-id",
|
|
||||||
{agent.id.clone()}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
td { class: "agents-cell-desc",
|
|
||||||
if agent.description.is_empty() {
|
|
||||||
span { class: "agents-cell-none", "--" }
|
|
||||||
} else {
|
|
||||||
{agent.description.clone()}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
span { class: "agents-badge agents-badge--active",
|
|
||||||
{agent.status.clone()}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Quick Start card grid --
|
|
||||||
h3 { class: "agents-section-title",
|
|
||||||
{t(l, "developer.agents_quick_start")}
|
|
||||||
}
|
|
||||||
|
|
||||||
div { class: "agents-grid",
|
|
||||||
// Documentation
|
|
||||||
a {
|
|
||||||
class: "agents-card",
|
|
||||||
href: "https://langchain-ai.github.io/langgraph/",
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
div { class: "agents-card-icon",
|
|
||||||
Icon { icon: BsBook, width: 18, height: 18 }
|
|
||||||
}
|
|
||||||
div { class: "agents-card-title",
|
|
||||||
{t(l, "developer.agents_docs")}
|
|
||||||
}
|
|
||||||
div { class: "agents-card-desc",
|
|
||||||
{t(l, "developer.agents_docs_desc")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getting Started
|
|
||||||
a {
|
|
||||||
class: "agents-card",
|
|
||||||
href: "https://langchain-ai.github.io/langgraph/tutorials/introduction/",
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
div { class: "agents-card-icon",
|
|
||||||
Icon { icon: BsLightningCharge, width: 18, height: 18 }
|
|
||||||
}
|
|
||||||
div { class: "agents-card-title",
|
|
||||||
{t(l, "developer.agents_getting_started")}
|
|
||||||
}
|
|
||||||
div { class: "agents-card-desc",
|
|
||||||
{t(l, "developer.agents_getting_started_desc")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitHub
|
|
||||||
a {
|
|
||||||
class: "agents-card",
|
|
||||||
href: "https://github.com/langchain-ai/langgraph",
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
div { class: "agents-card-icon",
|
|
||||||
Icon { icon: BsGithub, width: 18, height: 18 }
|
|
||||||
}
|
|
||||||
div { class: "agents-card-title",
|
|
||||||
{t(l, "developer.agents_github")}
|
|
||||||
}
|
|
||||||
div { class: "agents-card-desc",
|
|
||||||
{t(l, "developer.agents_github_desc")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Examples
|
|
||||||
a {
|
|
||||||
class: "agents-card",
|
|
||||||
href: "https://github.com/langchain-ai/langgraph/tree/main/examples",
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
div { class: "agents-card-icon",
|
|
||||||
Icon { icon: BsCodeSquare, width: 18, height: 18 }
|
|
||||||
}
|
|
||||||
div { class: "agents-card-title",
|
|
||||||
{t(l, "developer.agents_examples")}
|
|
||||||
}
|
|
||||||
div { class: "agents-card-desc",
|
|
||||||
{t(l, "developer.agents_examples_desc")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Reference (disabled when URL is empty)
|
|
||||||
a {
|
|
||||||
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
|
|
||||||
href: "{api_ref_href}",
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
div { class: "agents-card-icon",
|
|
||||||
Icon { icon: BsBoxArrowUpRight, width: 18, height: 18 }
|
|
||||||
}
|
|
||||||
div { class: "agents-card-title",
|
|
||||||
{t(l, "developer.agents_api_ref")}
|
|
||||||
}
|
|
||||||
div { class: "agents-card-desc",
|
|
||||||
{t(l, "developer.agents_api_ref_desc")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" }
|
||||||
|
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +1,40 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus_free_icons::icons::bs_icons::{
|
|
||||||
BsBarChartLine, BsBoxArrowUpRight, BsGraphUp, BsSpeedometer,
|
|
||||||
};
|
|
||||||
use dioxus_free_icons::Icon;
|
|
||||||
|
|
||||||
use crate::i18n::{t, Locale};
|
use crate::i18n::{t, Locale};
|
||||||
use crate::models::{AnalyticsMetric, ServiceUrlsContext};
|
use crate::models::AnalyticsMetric;
|
||||||
|
|
||||||
/// Analytics & Observability page for Langfuse.
|
/// Analytics page placeholder for LangFuse integration.
|
||||||
///
|
///
|
||||||
/// Langfuse is configured with Keycloak SSO (shared realm with CERTifAI).
|
/// Shows a "Coming Soon" card with a disabled launch button,
|
||||||
/// When users open Langfuse, the existing Keycloak session auto-authenticates
|
/// plus a mock stats bar showing sample metrics.
|
||||||
/// them transparently. This page shows a metrics bar, connection status,
|
|
||||||
/// and a prominent button to open Langfuse in a new tab.
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AnalyticsPage() -> Element {
|
pub fn AnalyticsPage() -> Element {
|
||||||
let locale = use_context::<Signal<Locale>>();
|
let locale = use_context::<Signal<Locale>>();
|
||||||
let svc = use_context::<Signal<ServiceUrlsContext>>();
|
|
||||||
let l = *locale.read();
|
let l = *locale.read();
|
||||||
let url = svc.read().langfuse_url.clone();
|
|
||||||
|
|
||||||
let connected = !url.is_empty();
|
|
||||||
let metrics = mock_metrics(l);
|
let metrics = mock_metrics(l);
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "analytics-page",
|
section { class: "placeholder-page",
|
||||||
// -- Hero section --
|
|
||||||
div { class: "analytics-hero",
|
|
||||||
div { class: "analytics-hero-row",
|
|
||||||
div { class: "analytics-hero-icon",
|
|
||||||
Icon { icon: BsGraphUp, width: 24, height: 24 }
|
|
||||||
}
|
|
||||||
h2 { class: "analytics-hero-title",
|
|
||||||
{t(l, "developer.analytics_title")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p { class: "analytics-hero-desc",
|
|
||||||
{t(l, "developer.analytics_desc")}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Connection status --
|
|
||||||
if connected {
|
|
||||||
div { class: "agents-status",
|
|
||||||
span {
|
|
||||||
class: "agents-status-dot agents-status-dot--on",
|
|
||||||
}
|
|
||||||
span { {t(l, "developer.analytics_status_connected")} }
|
|
||||||
code { class: "agents-status-url", {url.clone()} }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
div { class: "agents-status",
|
|
||||||
span {
|
|
||||||
class: "agents-status-dot agents-status-dot--off",
|
|
||||||
}
|
|
||||||
span { {t(l, "developer.analytics_status_not_connected")} }
|
|
||||||
span { class: "agents-status-hint",
|
|
||||||
{t(l, "developer.analytics_config_hint")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- SSO info --
|
|
||||||
if connected {
|
|
||||||
p { class: "analytics-sso-hint",
|
|
||||||
{t(l, "developer.analytics_sso_hint")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Metrics bar --
|
|
||||||
div { class: "analytics-stats-bar",
|
div { class: "analytics-stats-bar",
|
||||||
for metric in &metrics {
|
for metric in &metrics {
|
||||||
div { class: "analytics-stat",
|
div { class: "analytics-stat",
|
||||||
span { class: "analytics-stat-value", "{metric.value}" }
|
span { class: "analytics-stat-value", "{metric.value}" }
|
||||||
span { class: "analytics-stat-label", "{metric.label}" }
|
span { class: "analytics-stat-label", "{metric.label}" }
|
||||||
span {
|
span { class: if metric.change_pct >= 0.0 { "analytics-stat-change analytics-stat-change--up" } else { "analytics-stat-change analytics-stat-change--down" },
|
||||||
class: if metric.change_pct >= 0.0 {
|
|
||||||
"analytics-stat-change analytics-stat-change--up"
|
|
||||||
} else {
|
|
||||||
"analytics-stat-change analytics-stat-change--down"
|
|
||||||
},
|
|
||||||
"{metric.change_pct:+.1}%"
|
"{metric.change_pct:+.1}%"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
div { class: "placeholder-card",
|
||||||
// -- Open Langfuse button --
|
div { class: "placeholder-icon", "L" }
|
||||||
if connected {
|
h2 { "{t(l, \"developer.analytics_title\")}" }
|
||||||
a {
|
p { class: "placeholder-desc",
|
||||||
class: "analytics-launch-btn",
|
"{t(l, \"developer.analytics_desc\")}"
|
||||||
href: "{url}",
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
Icon { icon: BsBoxArrowUpRight, width: 16, height: 16 }
|
|
||||||
span { {t(l, "developer.launch_analytics")} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Quick actions --
|
|
||||||
h3 { class: "agents-section-title",
|
|
||||||
{t(l, "developer.analytics_quick_actions")}
|
|
||||||
}
|
|
||||||
|
|
||||||
div { class: "agents-grid",
|
|
||||||
// Traces
|
|
||||||
a {
|
|
||||||
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
|
|
||||||
href: if connected { format!("{url}/project") } else { "#".to_string() },
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
div { class: "agents-card-icon",
|
|
||||||
Icon { icon: BsBarChartLine, width: 18, height: 18 }
|
|
||||||
}
|
|
||||||
div { class: "agents-card-title",
|
|
||||||
{t(l, "developer.analytics_traces")}
|
|
||||||
}
|
|
||||||
div { class: "agents-card-desc",
|
|
||||||
{t(l, "developer.analytics_traces_desc")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
a {
|
|
||||||
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
|
|
||||||
href: if connected { format!("{url}/project") } else { "#".to_string() },
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
div { class: "agents-card-icon",
|
|
||||||
Icon { icon: BsSpeedometer, width: 18, height: 18 }
|
|
||||||
}
|
|
||||||
div { class: "agents-card-title",
|
|
||||||
{t(l, "developer.analytics_dashboard")}
|
|
||||||
}
|
|
||||||
div { class: "agents-card-desc",
|
|
||||||
{t(l, "developer.analytics_dashboard_desc")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" }
|
||||||
|
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::components::ToolEmbed;
|
|
||||||
use crate::i18n::{t, Locale};
|
use crate::i18n::{t, Locale};
|
||||||
use crate::models::ServiceUrlsContext;
|
|
||||||
|
|
||||||
/// Flow page embedding the LangFlow visual workflow builder.
|
/// Flow page placeholder for the LangFlow visual workflow builder.
|
||||||
///
|
///
|
||||||
/// When `langflow_url` is configured, embeds the service in an iframe
|
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||||
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
|
/// Will eventually integrate with LangFlow for visual flow design.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn FlowPage() -> Element {
|
pub fn FlowPage() -> Element {
|
||||||
let locale = use_context::<Signal<Locale>>();
|
let locale = use_context::<Signal<Locale>>();
|
||||||
let svc = use_context::<Signal<ServiceUrlsContext>>();
|
|
||||||
let l = *locale.read();
|
let l = *locale.read();
|
||||||
let url = svc.read().langflow_url.clone();
|
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
ToolEmbed {
|
section { class: "placeholder-page",
|
||||||
url,
|
div { class: "placeholder-card",
|
||||||
title: t(l, "developer.flow_title"),
|
div { class: "placeholder-icon", "F" }
|
||||||
description: t(l, "developer.flow_desc"),
|
h2 { "{t(l, \"developer.flow_title\")}" }
|
||||||
icon: "F",
|
p { class: "placeholder-desc",
|
||||||
launch_label: t(l, "developer.launch_flow"),
|
"{t(l, \"developer.flow_desc\")}"
|
||||||
|
}
|
||||||
|
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_flow\")}" }
|
||||||
|
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user