Compare commits
9 Commits
feat/compl
...
ba5e4b9a5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba5e4b9a5d | ||
|
|
83772cc256 | ||
|
|
661be22e82 | ||
|
|
8b16eba1ad | ||
|
|
58420b4547 | ||
|
|
d473f7570b | ||
|
|
46b2ee5dfa | ||
|
|
80faa4fa86 | ||
|
|
e0a4d2d888 |
@@ -7,3 +7,10 @@ KEYCLOAK_CLIENT_ID=certifai-dashboard
|
|||||||
APP_URL=http://localhost:8000
|
APP_URL=http://localhost:8000
|
||||||
REDIRECT_URI=http://localhost:8000/auth/callback
|
REDIRECT_URI=http://localhost:8000/auth/callback
|
||||||
ALLOWED_ORIGINS=http://localhost:8000
|
ALLOWED_ORIGINS=http://localhost:8000
|
||||||
|
|
||||||
|
# SearXNG meta-search engine
|
||||||
|
SEARXNG_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# Ollama LLM instance (used for article summarization and chat)
|
||||||
|
OLLAMA_URL=http://mac-mini-von-benjamin-2:11434
|
||||||
|
OLLAMA_MODEL=qwen3:30b-a3b
|
||||||
|
|||||||
@@ -96,76 +96,19 @@ jobs:
|
|||||||
run: cargo test --features web --no-default-features
|
run: cargo test --features web --no-default-features
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Stage 3: Build Docker image and push to registry
|
# Stage 3: Deploy (only after tests pass, only on main)
|
||||||
# Only on main and release/* branches
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
build-and-push:
|
deploy:
|
||||||
name: Build & Push Image
|
name: Deploy
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
needs: [test]
|
needs: [test]
|
||||||
if: >-
|
if: github.ref == 'refs/heads/main'
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
|
|
||||||
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: Determine image tag
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
|
||||||
# Replace / with - for valid Docker tags (e.g. release/1.0 -> release-1.0)
|
|
||||||
BRANCH_SAFE=$(echo "$BRANCH" | tr '/' '-')
|
|
||||||
SHA=$(echo "$GITHUB_SHA" | head -c 8)
|
|
||||||
echo "tag=${BRANCH_SAFE}-${SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Log in to container registry
|
|
||||||
run: >-
|
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}"
|
|
||||||
| docker login https://registry.meghsakha.com
|
|
||||||
-u "${{ secrets.REGISTRY_USERNAME }}"
|
|
||||||
--password-stdin
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: >-
|
|
||||||
docker build
|
|
||||||
-t registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
|
|
||||||
-t registry.meghsakha.com/certifai/dashboard:latest
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Docker image
|
|
||||||
run: |
|
|
||||||
docker push registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
|
|
||||||
docker push registry.meghsakha.com/certifai/dashboard:latest
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Stage 3b: Generate changelog from conventional commits
|
|
||||||
# Only on main and release/* branches
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
changelog:
|
|
||||||
name: Changelog
|
|
||||||
runs-on: docker
|
|
||||||
needs: [test]
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
|
|
||||||
container:
|
container:
|
||||||
image: rust:1.89-bookworm
|
image: alpine:latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (full history)
|
- name: Trigger Coolify deploy
|
||||||
run: |
|
run: |
|
||||||
git clone "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
|
apk add --no-cache curl
|
||||||
git checkout "${GITHUB_SHA}"
|
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||||
- name: Install git-cliff
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||||
run: cargo install git-cliff --locked
|
|
||||||
- name: Generate changelog
|
|
||||||
run: git cliff --output CHANGELOG.md
|
|
||||||
- name: Upload changelog artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: changelog
|
|
||||||
path: CHANGELOG.md
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@ keycloak/*
|
|||||||
|
|
||||||
# Node modules
|
# Node modules
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
searxng/
|
||||||
|
|||||||
@@ -237,10 +237,6 @@ The SaaS application dashboard is the landing page for the company admin to view
|
|||||||
|
|
||||||
This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
|
This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
|
||||||
|
|
||||||
## Features management
|
|
||||||
|
|
||||||
All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers.
|
|
||||||
|
|
||||||
## Code structure
|
## Code structure
|
||||||
The following folder structure is maintained for separation of concerns:
|
The following folder structure is maintained for separation of concerns:
|
||||||
- src/components/*.rs : All components that are required to be rendered are placed here. These are frontend only, reusable components that are specific for the application.
|
- src/components/*.rs : All components that are required to be rendered are placed here. These are frontend only, reusable components that are specific for the application.
|
||||||
|
|||||||
270
Cargo.lock
generated
270
Cargo.lock
generated
@@ -698,6 +698,29 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser"
|
||||||
|
version = "0.34.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
|
||||||
|
dependencies = [
|
||||||
|
"cssparser-macros",
|
||||||
|
"dtoa-short",
|
||||||
|
"itoa",
|
||||||
|
"phf",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser-macros"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.116",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.21.3"
|
version = "0.21.3"
|
||||||
@@ -753,6 +776,7 @@ dependencies = [
|
|||||||
"petname",
|
"petname",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
|
"scraper",
|
||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -819,6 +843,17 @@ dependencies = [
|
|||||||
"syn 2.0.116",
|
"syn 2.0.116",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more"
|
||||||
|
version = "0.99.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.116",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -1050,7 +1085,7 @@ dependencies = [
|
|||||||
"const-str",
|
"const-str",
|
||||||
"const_format",
|
"const_format",
|
||||||
"content_disposition",
|
"content_disposition",
|
||||||
"derive_more",
|
"derive_more 2.1.1",
|
||||||
"dioxus-asset-resolver",
|
"dioxus-asset-resolver",
|
||||||
"dioxus-cli-config",
|
"dioxus-cli-config",
|
||||||
"dioxus-core",
|
"dioxus-core",
|
||||||
@@ -1546,12 +1581,33 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtoa"
|
||||||
|
version = "1.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtoa-short"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||||
|
dependencies = [
|
||||||
|
"dtoa",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dunce"
|
name = "dunce"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ego-tree"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -1674,6 +1730,16 @@ version = "2.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futf"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||||
|
dependencies = [
|
||||||
|
"mac",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1777,6 +1843,15 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fxhash"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generational-box"
|
name = "generational-box"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -2015,6 +2090,18 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "html5ever"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"mac",
|
||||||
|
"markup5ever",
|
||||||
|
"match_token",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.2.12"
|
version = "0.2.12"
|
||||||
@@ -2579,6 +2666,12 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "macro-string"
|
name = "macro-string"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2678,6 +2771,31 @@ dependencies = [
|
|||||||
"syn 2.0.116",
|
"syn 2.0.116",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markup5ever"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"phf",
|
||||||
|
"phf_codegen",
|
||||||
|
"string_cache",
|
||||||
|
"string_cache_codegen",
|
||||||
|
"tendril",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "match_token"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.116",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2804,7 +2922,7 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
"bson",
|
"bson",
|
||||||
"derive-where",
|
"derive-where",
|
||||||
"derive_more",
|
"derive_more 2.1.1",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -2897,6 +3015,12 @@ dependencies = [
|
|||||||
"jni-sys",
|
"jni-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3003,6 +3127,58 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||||
|
dependencies = [
|
||||||
|
"phf_macros",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_codegen"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
"rand 0.8.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_macros"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.116",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
@@ -3059,6 +3235,12 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "precomputed-hash"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -3630,6 +3812,20 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scraper"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15"
|
||||||
|
dependencies = [
|
||||||
|
"cssparser",
|
||||||
|
"ego-tree",
|
||||||
|
"html5ever",
|
||||||
|
"precomputed-hash",
|
||||||
|
"selectors",
|
||||||
|
"tendril",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sct"
|
name = "sct"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -3672,6 +3868,25 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "selectors"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cssparser",
|
||||||
|
"derive_more 0.99.20",
|
||||||
|
"fxhash",
|
||||||
|
"log",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"phf",
|
||||||
|
"phf_codegen",
|
||||||
|
"precomputed-hash",
|
||||||
|
"servo_arc",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -3841,6 +4056,15 @@ dependencies = [
|
|||||||
"syn 2.0.116",
|
"syn 2.0.116",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "servo_arc"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -3888,6 +4112,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -3991,6 +4221,31 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||||
|
dependencies = [
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"parking_lot",
|
||||||
|
"phf_shared",
|
||||||
|
"precomputed-hash",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache_codegen"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -4117,6 +4372,17 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tendril"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||||
|
dependencies = [
|
||||||
|
"futf",
|
||||||
|
"mac",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ dioxus-free-icons = { version = "0.10", features = [
|
|||||||
] }
|
] }
|
||||||
sha2 = { version = "0.10.9", default-features = false, optional = true }
|
sha2 = { version = "0.10.9", default-features = false, optional = true }
|
||||||
base64 = { version = "0.22.1", default-features = false, optional = true }
|
base64 = { version = "0.22.1", default-features = false, optional = true }
|
||||||
|
scraper = { version = "0.22", default-features = false, optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# default = ["web"]
|
# default = ["web"]
|
||||||
@@ -91,6 +92,7 @@ server = [
|
|||||||
"dep:url",
|
"dep:url",
|
||||||
"dep:sha2",
|
"dep:sha2",
|
||||||
"dep:base64",
|
"dep:base64",
|
||||||
|
"dep:scraper",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -1,5 +1,11 @@
|
|||||||
# CERTifAI
|
# CERTifAI
|
||||||
|
|
||||||
|
[](https://gitea.meghsakha.com/sharang/certifai/actions?workflow=ci.yml)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](https://dioxuslabs.com/)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://gdpr.eu/)
|
||||||
|
|
||||||
This project is a SaaS application dashboard for administation of self-hosted private GenAI (generative AI) toolbox for companies and individuals. The purpose of the dashboard is to manage LLMs, Agents, MCP Servers and other GenAI related features.
|
This project is a SaaS application dashboard for administation of self-hosted private GenAI (generative AI) toolbox for companies and individuals. The purpose of the dashboard is to manage LLMs, Agents, MCP Servers and other GenAI related features.
|
||||||
|
|
||||||
The purpose of `CERTifAI`is to provide self-hosted or GDPR-Conform GenAI infrastructure to companies who do not wish to subscribe to non-EU cloud providers to protect their intellectual property from being used as training data.
|
The purpose of `CERTifAI`is to provide self-hosted or GDPR-Conform GenAI infrastructure to companies who do not wish to subscribe to non-EU cloud providers to protect their intellectual property from being used as training data.
|
||||||
@@ -15,15 +21,32 @@ The SaaS application dashboard is the landing page for the company admin to view
|
|||||||
- Request support: Request support or new features using feedback form
|
- Request support: Request support or new features using feedback form
|
||||||
- GenAI: View currently running LLMs, Agents, MCP Servers. Modify or add more resources, switch to a different model, launch tools like Langchain + Langfuse for creating new agents,tavily for internet search or more complex tools for use with GenAI. View endpoints and generate API Keys for integrations in other applications.
|
- GenAI: View currently running LLMs, Agents, MCP Servers. Modify or add more resources, switch to a different model, launch tools like Langchain + Langfuse for creating new agents,tavily for internet search or more complex tools for use with GenAI. View endpoints and generate API Keys for integrations in other applications.
|
||||||
|
|
||||||
## Development environment
|
## Dashboard
|
||||||
|
|
||||||
This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
|
The main dashboard provides a news feed powered by SearXNG and Ollama:
|
||||||
|
|
||||||
## Features management
|
- **Topic-based search**: Browse AI, Technology, Science, Finance and custom topics. Add or remove topics on the fly; selections persist in localStorage.
|
||||||
|
- **Article detail + AI summary**: Click any card to open a split-view panel. The full article is fetched, summarized by Ollama, and a follow-up chat lets you ask questions.
|
||||||
|
- **Sidebar** (visible when no article is selected):
|
||||||
|
- **Ollama Status** -- green/red indicator with the list of loaded models.
|
||||||
|
- **Trending** -- keywords extracted from recent news headlines via SearXNG.
|
||||||
|
- **Recent Searches** -- last 10 topics you searched, persisted in localStorage.
|
||||||
|
|
||||||
All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers.
|
## Development environment
|
||||||
|
|
||||||
## Code structure
|
This project is written in Dioxus 0.7 with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
|
||||||
|
|
||||||
|
### External services
|
||||||
|
|
||||||
|
| Service | Purpose | Default URL |
|
||||||
|
|----------|--------------------------------|----------------------------|
|
||||||
|
| Keycloak | Identity provider / SSO | `http://localhost:8080` |
|
||||||
|
| SearXNG | Meta-search engine for news | `http://localhost:8888` |
|
||||||
|
| Ollama | Local LLM for summarization | `http://localhost:11434` |
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and adjust the URLs and model name to match your setup.
|
||||||
|
|
||||||
|
## Code structure
|
||||||
The following folder structure is maintained for separation of concerns:
|
The following folder structure is maintained for separation of concerns:
|
||||||
- src/components/*.rs : All components that are required to be rendered are placed here. These are frontend only, reusable components that are specific for the application.
|
- src/components/*.rs : All components that are required to be rendered are placed here. These are frontend only, reusable components that are specific for the application.
|
||||||
- src/infrastructure/*.rs : All backend related functions from the dioxus fullstack are placed here. This entire module is behind the feature "server".
|
- src/infrastructure/*.rs : All backend related functions from the dioxus fullstack are placed here. This entire module is behind the feature "server".
|
||||||
|
|||||||
2258
assets/main.css
2258
assets/main.css
File diff suppressed because it is too large
Load Diff
1005
assets/tailwind.css
1005
assets/tailwind.css
File diff suppressed because it is too large
Load Diff
@@ -28,4 +28,15 @@ services:
|
|||||||
- 27017:27017
|
- 27017:27017
|
||||||
environment:
|
environment:
|
||||||
MONGO_INITDB_ROOT_USERNAME: root
|
MONGO_INITDB_ROOT_USERNAME: root
|
||||||
MONGO_INITDB_ROOT_PASSWORD: example
|
MONGO_INITDB_ROOT_PASSWORD: example
|
||||||
|
|
||||||
|
searxng:
|
||||||
|
image: searxng/searxng:latest
|
||||||
|
container_name: certifai-searxng
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8888:8080"
|
||||||
|
environment:
|
||||||
|
- SEARXNG_BASE_URL=http://localhost:8888
|
||||||
|
volumes:
|
||||||
|
- ./searxng:/etc/searxng:rw
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# CAI-1
|
|
||||||
|
|
||||||
This feature creates a new login/registration page for the GenAI admin dashboard. The user management is provided by Keycloak, which also serves the login/registration flow. The dioxus app should detect if a user is already logged-in or not, and if not, redirect the user to the keycloak landing page and after successful login, capture the user's access token in a state and save a session state.
|
|
||||||
|
|
||||||
Steps to follow:
|
|
||||||
- Create a docker-compose file for hosting a local keycloak and create a realm for testing and a client for Oauth.
|
|
||||||
- Setup the environment variables using .env. Fill the environment with keycloak URL, realm, client ID and secret.
|
|
||||||
- Create a user state in Dioxus which manages the session and the access token. Add other user identifying information like email address to the state.
|
|
||||||
- Modify dioxus to check the state and load the correct URL based on the state.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# CERTifAI 2
|
|
||||||
|
|
||||||
This feature defines the types for database as well as the API between the dashboard backend and frontend.
|
|
||||||
42
src/app.rs
42
src/app.rs
@@ -3,16 +3,48 @@ use dioxus::prelude::*;
|
|||||||
|
|
||||||
/// Application routes.
|
/// Application routes.
|
||||||
///
|
///
|
||||||
/// `OverviewPage` is wrapped in the `AppShell` layout so the sidebar
|
/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live
|
||||||
/// renders around every authenticated page. The `/login` route remains
|
/// outside the `AppShell` layout. Authenticated pages are wrapped in
|
||||||
/// outside the shell (unauthenticated).
|
/// `AppShell` which renders the sidebar. `DeveloperShell` and `OrgShell`
|
||||||
|
/// provide nested tab navigation within the app shell.
|
||||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
|
#[route("/")]
|
||||||
|
LandingPage {},
|
||||||
|
#[route("/impressum")]
|
||||||
|
ImpressumPage {},
|
||||||
|
#[route("/privacy")]
|
||||||
|
PrivacyPage {},
|
||||||
#[layout(AppShell)]
|
#[layout(AppShell)]
|
||||||
#[route("/")]
|
#[route("/dashboard")]
|
||||||
OverviewPage {},
|
DashboardPage {},
|
||||||
|
#[route("/providers")]
|
||||||
|
ProvidersPage {},
|
||||||
|
#[route("/chat")]
|
||||||
|
ChatPage {},
|
||||||
|
#[route("/tools")]
|
||||||
|
ToolsPage {},
|
||||||
|
#[route("/knowledge")]
|
||||||
|
KnowledgePage {},
|
||||||
|
|
||||||
|
#[layout(DeveloperShell)]
|
||||||
|
#[route("/developer/agents")]
|
||||||
|
AgentsPage {},
|
||||||
|
#[route("/developer/flow")]
|
||||||
|
FlowPage {},
|
||||||
|
#[route("/developer/analytics")]
|
||||||
|
AnalyticsPage {},
|
||||||
|
#[end_layout]
|
||||||
|
|
||||||
|
#[layout(OrgShell)]
|
||||||
|
#[route("/organization/pricing")]
|
||||||
|
OrgPricingPage {},
|
||||||
|
#[route("/organization/dashboard")]
|
||||||
|
OrgDashboardPage {},
|
||||||
|
#[end_layout]
|
||||||
#[end_layout]
|
#[end_layout]
|
||||||
|
|
||||||
#[route("/login?:redirect_url")]
|
#[route("/login?:redirect_url")]
|
||||||
Login { redirect_url: String },
|
Login { redirect_url: String },
|
||||||
}
|
}
|
||||||
|
|||||||
158
src/components/article_detail.rs
Normal file
158
src/components/article_detail.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
use crate::infrastructure::llm::FollowUpMessage;
|
||||||
|
use crate::models::NewsCard;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Side panel displaying the full details of a selected news article.
|
||||||
|
///
|
||||||
|
/// Shows the article title, source, date, category badge, full content,
|
||||||
|
/// a link to the original article, an AI summary bubble, and a follow-up
|
||||||
|
/// chat window for asking questions about the article.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `card` - The selected news card data
|
||||||
|
/// * `on_close` - Handler to close the detail panel
|
||||||
|
/// * `summary` - Optional AI-generated summary text
|
||||||
|
/// * `is_summarizing` - Whether a summarization request is in progress
|
||||||
|
/// * `chat_messages` - Follow-up chat conversation history (user + assistant turns)
|
||||||
|
/// * `is_chatting` - Whether a chat response is being generated
|
||||||
|
/// * `on_chat_send` - Handler called with the user's follow-up question
|
||||||
|
#[component]
|
||||||
|
pub fn ArticleDetail(
|
||||||
|
card: NewsCard,
|
||||||
|
on_close: EventHandler,
|
||||||
|
summary: Option<String>,
|
||||||
|
#[props(default = false)] is_summarizing: bool,
|
||||||
|
chat_messages: Vec<FollowUpMessage>,
|
||||||
|
#[props(default = false)] is_chatting: bool,
|
||||||
|
on_chat_send: EventHandler<String>,
|
||||||
|
) -> Element {
|
||||||
|
let css_suffix = card.category.to_lowercase().replace(' ', "-");
|
||||||
|
let badge_class = format!("news-badge news-badge--{css_suffix}");
|
||||||
|
let mut chat_input = use_signal(String::new);
|
||||||
|
let has_summary = summary.is_some() && !is_summarizing;
|
||||||
|
|
||||||
|
// Build favicon URL using DuckDuckGo's privacy-friendly icon service
|
||||||
|
let favicon_url = format!("https://icons.duckduckgo.com/ip3/{}.ico", card.source);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
aside { class: "article-detail-panel",
|
||||||
|
// Close button
|
||||||
|
button {
|
||||||
|
class: "article-detail-close",
|
||||||
|
onclick: move |_| on_close.call(()),
|
||||||
|
"X"
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "article-detail-content",
|
||||||
|
// Header
|
||||||
|
h2 { class: "article-detail-title", "{card.title}" }
|
||||||
|
|
||||||
|
div { class: "article-detail-meta",
|
||||||
|
span { class: "{badge_class}", "{card.category}" }
|
||||||
|
span { class: "article-detail-source",
|
||||||
|
img {
|
||||||
|
class: "source-favicon",
|
||||||
|
src: "{favicon_url}",
|
||||||
|
alt: "",
|
||||||
|
width: "16",
|
||||||
|
height: "16",
|
||||||
|
}
|
||||||
|
"{card.source}"
|
||||||
|
}
|
||||||
|
span { class: "article-detail-date", "{card.published_at}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content body
|
||||||
|
div { class: "article-detail-body",
|
||||||
|
p { "{card.content}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to original
|
||||||
|
a {
|
||||||
|
class: "article-detail-link",
|
||||||
|
href: "{card.url}",
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener",
|
||||||
|
"Read original article"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Summary bubble (below the link)
|
||||||
|
div { class: "ai-summary-bubble",
|
||||||
|
if is_summarizing {
|
||||||
|
div { class: "ai-summary-bubble-loading",
|
||||||
|
div { class: "ai-summary-dot-pulse" }
|
||||||
|
span { "Summarizing..." }
|
||||||
|
}
|
||||||
|
} else if let Some(ref text) = summary {
|
||||||
|
p { class: "ai-summary-bubble-text", "{text}" }
|
||||||
|
span { class: "ai-summary-bubble-label", "Summarized with AI" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow-up chat window (visible after summary is ready)
|
||||||
|
if has_summary {
|
||||||
|
div { class: "article-chat",
|
||||||
|
// Chat message history
|
||||||
|
if !chat_messages.is_empty() {
|
||||||
|
div { class: "article-chat-messages",
|
||||||
|
for msg in chat_messages.iter() {
|
||||||
|
{
|
||||||
|
let bubble_class = if msg.role == "user" {
|
||||||
|
"chat-msg chat-msg--user"
|
||||||
|
} else {
|
||||||
|
"chat-msg chat-msg--assistant"
|
||||||
|
};
|
||||||
|
rsx! {
|
||||||
|
div { class: "{bubble_class}",
|
||||||
|
p { "{msg.content}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_chatting {
|
||||||
|
div { class: "chat-msg chat-msg--assistant chat-msg--typing",
|
||||||
|
div { class: "ai-summary-dot-pulse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat input
|
||||||
|
div { class: "article-chat-input",
|
||||||
|
input {
|
||||||
|
class: "article-chat-textbox",
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Ask a follow-up question...",
|
||||||
|
value: "{chat_input}",
|
||||||
|
disabled: is_chatting,
|
||||||
|
oninput: move |e| chat_input.set(e.value()),
|
||||||
|
onkeypress: move |e| {
|
||||||
|
if e.key() == Key::Enter && !is_chatting {
|
||||||
|
let val = chat_input.read().trim().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
on_chat_send.call(val);
|
||||||
|
chat_input.set(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "article-chat-send",
|
||||||
|
disabled: is_chatting,
|
||||||
|
onclick: move |_| {
|
||||||
|
let val = chat_input.read().trim().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
on_chat_send.call(val);
|
||||||
|
chat_input.set(String::new());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Send"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/components/chat_bubble.rs
Normal file
41
src/components/chat_bubble.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use crate::models::{ChatMessage, ChatRole};
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Renders a single chat message bubble with role-based styling.
|
||||||
|
///
|
||||||
|
/// User messages are right-aligned; assistant messages are left-aligned.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `message` - The chat message to render
|
||||||
|
#[component]
|
||||||
|
pub fn ChatBubble(message: ChatMessage) -> Element {
|
||||||
|
let bubble_class = match message.role {
|
||||||
|
ChatRole::User => "chat-bubble chat-bubble--user",
|
||||||
|
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
|
||||||
|
ChatRole::System => "chat-bubble chat-bubble--system",
|
||||||
|
};
|
||||||
|
|
||||||
|
let role_label = match message.role {
|
||||||
|
ChatRole::User => "You",
|
||||||
|
ChatRole::Assistant => "Assistant",
|
||||||
|
ChatRole::System => "System",
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "{bubble_class}",
|
||||||
|
div { class: "chat-bubble-header",
|
||||||
|
span { class: "chat-bubble-role", "{role_label}" }
|
||||||
|
span { class: "chat-bubble-time", "{message.timestamp}" }
|
||||||
|
}
|
||||||
|
div { class: "chat-bubble-content", "{message.content}" }
|
||||||
|
if !message.attachments.is_empty() {
|
||||||
|
div { class: "chat-bubble-attachments",
|
||||||
|
for att in &message.attachments {
|
||||||
|
span { class: "chat-attachment", "{att.name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/components/dashboard_sidebar.rs
Normal file
112
src/components/dashboard_sidebar.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
|
||||||
|
|
||||||
|
/// Right sidebar for the dashboard, showing Ollama status, trending topics,
|
||||||
|
/// and recent search history.
|
||||||
|
///
|
||||||
|
/// Appears when no article card is selected. Disappears when the user opens
|
||||||
|
/// the article detail split view.
|
||||||
|
///
|
||||||
|
/// # Props
|
||||||
|
///
|
||||||
|
/// * `ollama_url` - Ollama instance URL for status polling
|
||||||
|
/// * `trending` - Trending topic keywords extracted from recent news headlines
|
||||||
|
/// * `recent_searches` - Recent search topics stored in localStorage
|
||||||
|
/// * `on_topic_click` - Fires when a trending or recent topic is clicked
|
||||||
|
#[component]
|
||||||
|
pub fn DashboardSidebar(
|
||||||
|
ollama_url: String,
|
||||||
|
trending: Vec<String>,
|
||||||
|
recent_searches: Vec<String>,
|
||||||
|
on_topic_click: EventHandler<String>,
|
||||||
|
) -> Element {
|
||||||
|
// Fetch Ollama status once on mount.
|
||||||
|
// use_resource with no signal dependencies runs exactly once and
|
||||||
|
// won't re-fire on parent re-renders (unlike use_effect).
|
||||||
|
let url = ollama_url.clone();
|
||||||
|
let status_resource = use_resource(move || {
|
||||||
|
let u = url.clone();
|
||||||
|
async move {
|
||||||
|
get_ollama_status(u).await.unwrap_or(OllamaStatus {
|
||||||
|
online: false,
|
||||||
|
models: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let current_status: OllamaStatus =
|
||||||
|
status_resource
|
||||||
|
.read()
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(OllamaStatus {
|
||||||
|
online: false,
|
||||||
|
models: Vec::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
aside { class: "dashboard-sidebar",
|
||||||
|
|
||||||
|
// -- Ollama Status Section --
|
||||||
|
div { class: "sidebar-section",
|
||||||
|
h4 { class: "sidebar-section-title", "Ollama Status" }
|
||||||
|
div { class: "sidebar-status-row",
|
||||||
|
span { class: if current_status.online { "sidebar-status-dot sidebar-status-dot--online" } else { "sidebar-status-dot sidebar-status-dot--offline" } }
|
||||||
|
span { class: "sidebar-status-label",
|
||||||
|
if current_status.online {
|
||||||
|
"Online"
|
||||||
|
} else {
|
||||||
|
"Offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current_status.models.is_empty() {
|
||||||
|
div { class: "sidebar-model-list",
|
||||||
|
for model in current_status.models.iter() {
|
||||||
|
span { class: "sidebar-model-tag", "{model}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Trending Topics Section --
|
||||||
|
if !trending.is_empty() {
|
||||||
|
div { class: "sidebar-section",
|
||||||
|
h4 { class: "sidebar-section-title", "Trending" }
|
||||||
|
for topic in trending.iter() {
|
||||||
|
{
|
||||||
|
let t = topic.clone();
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
class: "sidebar-topic-link",
|
||||||
|
onclick: move |_| on_topic_click.call(t.clone()),
|
||||||
|
"{topic}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Recent Searches Section --
|
||||||
|
if !recent_searches.is_empty() {
|
||||||
|
div { class: "sidebar-section",
|
||||||
|
h4 { class: "sidebar-section-title", "Recent Searches" }
|
||||||
|
for search in recent_searches.iter() {
|
||||||
|
{
|
||||||
|
let s = search.clone();
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
class: "sidebar-topic-link",
|
||||||
|
onclick: move |_| on_topic_click.call(s.clone()),
|
||||||
|
"{search}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/components/file_row.rs
Normal file
54
src/components/file_row.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use crate::models::KnowledgeFile;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Renders a table row for a knowledge base file.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `file` - The knowledge file data to render
|
||||||
|
/// * `on_delete` - Callback fired when the delete button is clicked
|
||||||
|
#[component]
|
||||||
|
pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler<String>) -> Element {
|
||||||
|
// Format file size for human readability (Python devs: similar to humanize.naturalsize)
|
||||||
|
let size_display = format_size(file.size_bytes);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
tr { class: "file-row",
|
||||||
|
td { class: "file-row-name",
|
||||||
|
span { class: "file-row-icon", "{file.kind.icon()}" }
|
||||||
|
"{file.name}"
|
||||||
|
}
|
||||||
|
td { "{file.kind.label()}" }
|
||||||
|
td { "{size_display}" }
|
||||||
|
td { "{file.chunk_count} chunks" }
|
||||||
|
td { "{file.uploaded_at}" }
|
||||||
|
td {
|
||||||
|
button {
|
||||||
|
class: "btn-icon btn-danger",
|
||||||
|
onclick: {
|
||||||
|
let id = file.id.clone();
|
||||||
|
move |_| on_delete.call(id.clone())
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a byte count into a human-readable string (e.g. "1.2 MB").
|
||||||
|
fn format_size(bytes: u64) -> String {
|
||||||
|
const KB: u64 = 1024;
|
||||||
|
const MB: u64 = KB * 1024;
|
||||||
|
const GB: u64 = MB * 1024;
|
||||||
|
|
||||||
|
if bytes >= GB {
|
||||||
|
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||||
|
} else if bytes >= MB {
|
||||||
|
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||||
|
} else if bytes >= KB {
|
||||||
|
format!("{:.1} KB", bytes as f64 / KB as f64)
|
||||||
|
} else {
|
||||||
|
format!("{bytes} B")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
use crate::Route;
|
use crate::Route;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Login redirect component.
|
||||||
|
///
|
||||||
|
/// Redirects the user to the external OAuth authentication endpoint.
|
||||||
|
/// If no `redirect_url` is provided, defaults to `/dashboard`.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `redirect_url` - URL to redirect to after successful authentication
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Login(redirect_url: String) -> Element {
|
pub fn Login(redirect_url: String) -> Element {
|
||||||
let navigator = use_navigator();
|
let navigator = use_navigator();
|
||||||
|
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let target = format!("/auth?redirect_url={}", redirect_url);
|
// Default to /dashboard when redirect_url is empty.
|
||||||
|
let destination = if redirect_url.is_empty() {
|
||||||
|
"/dashboard".to_string()
|
||||||
|
} else {
|
||||||
|
redirect_url.clone()
|
||||||
|
};
|
||||||
|
let target = format!("/auth?redirect_url={destination}");
|
||||||
navigator.push(NavigationTarget::<Route>::External(target));
|
navigator.push(NavigationTarget::<Route>::External(target));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
38
src/components/member_row.rs
Normal file
38
src/components/member_row.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use crate::models::{MemberRole, OrgMember};
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Renders a table row for an organization member with a role dropdown.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `member` - The organization member data to render
|
||||||
|
/// * `on_role_change` - Callback fired with (member_id, new_role) when role changes
|
||||||
|
#[component]
|
||||||
|
pub fn MemberRow(member: OrgMember, on_role_change: EventHandler<(String, String)>) -> Element {
|
||||||
|
rsx! {
|
||||||
|
tr { class: "member-row",
|
||||||
|
td { class: "member-row-name", "{member.name}" }
|
||||||
|
td { "{member.email}" }
|
||||||
|
td {
|
||||||
|
select {
|
||||||
|
class: "member-role-select",
|
||||||
|
value: "{member.role.label()}",
|
||||||
|
onchange: {
|
||||||
|
let id = member.id.clone();
|
||||||
|
move |evt: Event<FormData>| {
|
||||||
|
on_role_change.call((id.clone(), evt.value()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
for role in MemberRole::all() {
|
||||||
|
option {
|
||||||
|
value: "{role.label()}",
|
||||||
|
selected: *role == member.role,
|
||||||
|
"{role.label()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { "{member.joined_at}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,28 @@
|
|||||||
mod app_shell;
|
mod app_shell;
|
||||||
|
mod article_detail;
|
||||||
mod card;
|
mod card;
|
||||||
|
mod chat_bubble;
|
||||||
|
mod dashboard_sidebar;
|
||||||
|
mod file_row;
|
||||||
mod login;
|
mod login;
|
||||||
|
mod member_row;
|
||||||
|
pub mod news_card;
|
||||||
|
mod page_header;
|
||||||
|
mod pricing_card;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
|
pub mod sub_nav;
|
||||||
|
mod tool_card;
|
||||||
|
|
||||||
pub use app_shell::*;
|
pub use app_shell::*;
|
||||||
|
pub use article_detail::*;
|
||||||
pub use card::*;
|
pub use card::*;
|
||||||
|
pub use chat_bubble::*;
|
||||||
|
pub use dashboard_sidebar::*;
|
||||||
|
pub use file_row::*;
|
||||||
pub use login::*;
|
pub use login::*;
|
||||||
|
pub use member_row::*;
|
||||||
|
pub use news_card::*;
|
||||||
|
pub use page_header::*;
|
||||||
|
pub use pricing_card::*;
|
||||||
|
pub use sub_nav::*;
|
||||||
|
pub use tool_card::*;
|
||||||
|
|||||||
206
src/components/news_card.rs
Normal file
206
src/components/news_card.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use crate::models::NewsCard as NewsCardModel;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Renders a news feed card with title, source, category badge, and summary.
|
||||||
|
///
|
||||||
|
/// When a thumbnail URL is present but the image fails to load, the card
|
||||||
|
/// automatically switches to the centered no-thumbnail layout.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `card` - The news card model data to render
|
||||||
|
/// * `on_click` - Event handler triggered when the card is clicked
|
||||||
|
/// * `selected` - Whether this card is currently selected (highlighted)
|
||||||
|
#[component]
|
||||||
|
pub fn NewsCardView(
|
||||||
|
card: NewsCardModel,
|
||||||
|
on_click: EventHandler<NewsCardModel>,
|
||||||
|
#[props(default = false)] selected: bool,
|
||||||
|
) -> Element {
|
||||||
|
// Derive a CSS class from the category string (lowercase, hyphenated)
|
||||||
|
let css_suffix = card.category.to_lowercase().replace(' ', "-");
|
||||||
|
let badge_class = format!("news-badge news-badge--{css_suffix}");
|
||||||
|
|
||||||
|
// Track whether the thumbnail loaded successfully.
|
||||||
|
// Starts as true if a URL is provided; set to false on image error.
|
||||||
|
let has_thumb_url = card.thumbnail_url.is_some();
|
||||||
|
let mut thumb_ok = use_signal(|| has_thumb_url);
|
||||||
|
|
||||||
|
let show_thumb = has_thumb_url && *thumb_ok.read();
|
||||||
|
let selected_cls = if selected { " news-card--selected" } else { "" };
|
||||||
|
let thumb_cls = if show_thumb {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" news-card--no-thumb"
|
||||||
|
};
|
||||||
|
let card_class = format!("news-card{selected_cls}{thumb_cls}");
|
||||||
|
|
||||||
|
// Clone the card for the click handler closure
|
||||||
|
let card_for_click = card.clone();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
article {
|
||||||
|
class: "{card_class}",
|
||||||
|
onclick: move |_| on_click.call(card_for_click.clone()),
|
||||||
|
if let Some(ref thumb) = card.thumbnail_url {
|
||||||
|
if *thumb_ok.read() {
|
||||||
|
div { class: "news-card-thumb",
|
||||||
|
img {
|
||||||
|
src: "{thumb}",
|
||||||
|
alt: "",
|
||||||
|
loading: "lazy",
|
||||||
|
// Hide the thumbnail container if the image fails to load
|
||||||
|
onerror: move |_| thumb_ok.set(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "news-card-body",
|
||||||
|
div { class: "news-card-meta",
|
||||||
|
span { class: "{badge_class}", "{card.category}" }
|
||||||
|
span { class: "news-card-source", "{card.source}" }
|
||||||
|
span { class: "news-card-date", "{card.published_at}" }
|
||||||
|
}
|
||||||
|
h3 { class: "news-card-title", "{card.title}" }
|
||||||
|
p { class: "news-card-summary", "{card.summary}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock news data for the dashboard.
|
||||||
|
pub fn mock_news() -> Vec<NewsCardModel> {
|
||||||
|
vec![
|
||||||
|
NewsCardModel {
|
||||||
|
title: "Llama 4 Released with 1M Context Window".into(),
|
||||||
|
source: "Meta AI Blog".into(),
|
||||||
|
summary: "Meta releases Llama 4 with a 1 million token context window.".into(),
|
||||||
|
content: "Meta has officially released Llama 4, their latest \
|
||||||
|
open-weight large language model featuring a groundbreaking \
|
||||||
|
1 million token context window. This represents a major \
|
||||||
|
leap in context length capabilities."
|
||||||
|
.into(),
|
||||||
|
category: "AI".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-18".into(),
|
||||||
|
},
|
||||||
|
NewsCardModel {
|
||||||
|
title: "EU AI Act Enforcement Begins".into(),
|
||||||
|
source: "TechCrunch".into(),
|
||||||
|
summary: "The EU AI Act enters its enforcement phase across member states.".into(),
|
||||||
|
content: "The EU AI Act has officially entered its enforcement \
|
||||||
|
phase. Member states are now required to comply with the \
|
||||||
|
comprehensive regulatory framework governing AI systems."
|
||||||
|
.into(),
|
||||||
|
category: "Privacy".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-17".into(),
|
||||||
|
},
|
||||||
|
NewsCardModel {
|
||||||
|
title: "LangChain v0.4 Introduces Native MCP Support".into(),
|
||||||
|
source: "LangChain Blog".into(),
|
||||||
|
summary: "New version adds first-class MCP server integration.".into(),
|
||||||
|
content: "LangChain v0.4 introduces native Model Context Protocol \
|
||||||
|
support, enabling seamless integration with MCP servers for \
|
||||||
|
tool use and context management in agent workflows."
|
||||||
|
.into(),
|
||||||
|
category: "Technology".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-16".into(),
|
||||||
|
},
|
||||||
|
NewsCardModel {
|
||||||
|
title: "Ollama Adds Multi-GPU Scheduling".into(),
|
||||||
|
source: "Ollama".into(),
|
||||||
|
summary: "Run large models across multiple GPUs with automatic sharding.".into(),
|
||||||
|
content: "Ollama now supports multi-GPU scheduling with automatic \
|
||||||
|
model sharding. Users can run models across multiple GPUs \
|
||||||
|
for improved inference performance."
|
||||||
|
.into(),
|
||||||
|
category: "Infrastructure".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-15".into(),
|
||||||
|
},
|
||||||
|
NewsCardModel {
|
||||||
|
title: "Mistral Open Sources Codestral 2".into(),
|
||||||
|
source: "Mistral AI".into(),
|
||||||
|
summary: "Codestral 2 achieves state-of-the-art on HumanEval benchmarks.".into(),
|
||||||
|
content: "Mistral AI has open-sourced Codestral 2, a code \
|
||||||
|
generation model that achieves state-of-the-art results \
|
||||||
|
on HumanEval and other coding benchmarks."
|
||||||
|
.into(),
|
||||||
|
category: "Open Source".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-14".into(),
|
||||||
|
},
|
||||||
|
NewsCardModel {
|
||||||
|
title: "NVIDIA Releases NeMo 3.0 Framework".into(),
|
||||||
|
source: "NVIDIA Developer".into(),
|
||||||
|
summary: "Updated framework simplifies enterprise LLM fine-tuning.".into(),
|
||||||
|
content: "NVIDIA has released NeMo 3.0, an updated framework \
|
||||||
|
that simplifies enterprise LLM fine-tuning with improved \
|
||||||
|
distributed training capabilities."
|
||||||
|
.into(),
|
||||||
|
category: "Infrastructure".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-13".into(),
|
||||||
|
},
|
||||||
|
NewsCardModel {
|
||||||
|
title: "Anthropic Claude 4 Sets New Reasoning Records".into(),
|
||||||
|
source: "Anthropic".into(),
|
||||||
|
summary: "Claude 4 achieves top scores across major reasoning benchmarks.".into(),
|
||||||
|
content: "Anthropic's Claude 4 has set new records across major \
|
||||||
|
reasoning benchmarks, demonstrating significant improvements \
|
||||||
|
in mathematical and logical reasoning capabilities."
|
||||||
|
.into(),
|
||||||
|
category: "AI".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-12".into(),
|
||||||
|
},
|
||||||
|
NewsCardModel {
|
||||||
|
title: "CrewAI Raises $52M for Agent Orchestration".into(),
|
||||||
|
source: "VentureBeat".into(),
|
||||||
|
summary: "Series B funding to expand multi-agent orchestration platform.".into(),
|
||||||
|
content: "CrewAI has raised $52M in Series B funding to expand \
|
||||||
|
its multi-agent orchestration platform, enabling teams \
|
||||||
|
to build and deploy complex AI agent workflows."
|
||||||
|
.into(),
|
||||||
|
category: "Technology".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-11".into(),
|
||||||
|
},
|
||||||
|
NewsCardModel {
|
||||||
|
title: "DeepSeek V4 Released Under Apache 2.0".into(),
|
||||||
|
source: "DeepSeek".into(),
|
||||||
|
summary: "Latest open-weight model competes with proprietary offerings.".into(),
|
||||||
|
content: "DeepSeek has released V4 under the Apache 2.0 license, \
|
||||||
|
an open-weight model that competes with proprietary \
|
||||||
|
offerings in both performance and efficiency."
|
||||||
|
.into(),
|
||||||
|
category: "Open Source".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-10".into(),
|
||||||
|
},
|
||||||
|
NewsCardModel {
|
||||||
|
title: "GDPR Fines for AI Training Data Reach Record High".into(),
|
||||||
|
source: "Reuters".into(),
|
||||||
|
summary: "European regulators issue largest penalties yet for AI data misuse.".into(),
|
||||||
|
content: "European regulators have issued record-high GDPR fines \
|
||||||
|
for AI training data misuse, signaling stricter enforcement \
|
||||||
|
of data protection laws in the AI sector."
|
||||||
|
.into(),
|
||||||
|
category: "Privacy".into(),
|
||||||
|
url: "#".into(),
|
||||||
|
thumbnail_url: None,
|
||||||
|
published_at: "2026-02-09".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
23
src/components/page_header.rs
Normal file
23
src/components/page_header.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Reusable page header with title, subtitle, and an optional action slot.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `title` - Main heading text for the page
|
||||||
|
/// * `subtitle` - Secondary descriptive text below the title
|
||||||
|
/// * `actions` - Optional element rendered on the right side (e.g. buttons)
|
||||||
|
#[component]
|
||||||
|
pub fn PageHeader(title: String, subtitle: String, actions: Option<Element>) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "page-header",
|
||||||
|
div { class: "page-header-text",
|
||||||
|
h1 { class: "page-title", "{title}" }
|
||||||
|
p { class: "page-subtitle", "{subtitle}" }
|
||||||
|
}
|
||||||
|
if let Some(actions) = actions {
|
||||||
|
div { class: "page-header-actions", {actions} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/components/pricing_card.rs
Normal file
46
src/components/pricing_card.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use crate::models::PricingPlan;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Renders a pricing plan card with features list and call-to-action button.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `plan` - The pricing plan data to render
|
||||||
|
/// * `on_select` - Callback fired when the CTA button is clicked
|
||||||
|
#[component]
|
||||||
|
pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Element {
|
||||||
|
let card_class = if plan.highlighted {
|
||||||
|
"pricing-card pricing-card--highlighted"
|
||||||
|
} else {
|
||||||
|
"pricing-card"
|
||||||
|
};
|
||||||
|
|
||||||
|
let seats_label = match plan.max_seats {
|
||||||
|
Some(n) => format!("Up to {n} seats"),
|
||||||
|
None => "Unlimited seats".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "{card_class}",
|
||||||
|
h3 { class: "pricing-card-name", "{plan.name}" }
|
||||||
|
div { class: "pricing-card-price",
|
||||||
|
span { class: "pricing-card-amount", "{plan.price_eur}" }
|
||||||
|
span { class: "pricing-card-period", " EUR / month" }
|
||||||
|
}
|
||||||
|
p { class: "pricing-card-seats", "{seats_label}" }
|
||||||
|
ul { class: "pricing-card-features",
|
||||||
|
for feature in &plan.features {
|
||||||
|
li { "{feature}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "pricing-card-cta",
|
||||||
|
onclick: {
|
||||||
|
let id = plan.id.clone();
|
||||||
|
move |_| on_select.call(id.clone())
|
||||||
|
},
|
||||||
|
"Get Started"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus_free_icons::icons::bs_icons::{
|
use dioxus_free_icons::icons::bs_icons::{
|
||||||
BsBoxArrowRight, BsFileEarmarkText, BsGear, BsGithub, BsGrid, BsHouseDoor, BsRobot,
|
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
|
||||||
|
BsGrid, BsHouseDoor, BsPuzzle,
|
||||||
};
|
};
|
||||||
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
|
|
||||||
use dioxus_free_icons::Icon;
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::Route;
|
use crate::Route;
|
||||||
@@ -25,29 +25,39 @@ struct NavItem {
|
|||||||
pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||||
let nav_items: Vec<NavItem> = vec![
|
let nav_items: Vec<NavItem> = vec![
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Overview",
|
label: "Dashboard",
|
||||||
route: Route::OverviewPage {},
|
route: Route::DashboardPage {},
|
||||||
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
|
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
|
||||||
},
|
},
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Documentation",
|
label: "Providers",
|
||||||
route: Route::OverviewPage {},
|
route: Route::ProvidersPage {},
|
||||||
icon: rsx! { Icon { icon: BsFileEarmarkText, width: 18, height: 18 } },
|
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
|
||||||
},
|
},
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Agents",
|
label: "Chat",
|
||||||
route: Route::OverviewPage {},
|
route: Route::ChatPage {},
|
||||||
icon: rsx! { Icon { icon: BsRobot, width: 18, height: 18 } },
|
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
||||||
},
|
},
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Models",
|
label: "Tools",
|
||||||
route: Route::OverviewPage {},
|
route: Route::ToolsPage {},
|
||||||
icon: rsx! { Icon { icon: FaCubes, width: 18, height: 18 } },
|
icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } },
|
||||||
},
|
},
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Settings",
|
label: "Knowledge Base",
|
||||||
route: Route::OverviewPage {},
|
route: Route::KnowledgePage {},
|
||||||
icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } },
|
icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } },
|
||||||
|
},
|
||||||
|
NavItem {
|
||||||
|
label: "Developer",
|
||||||
|
route: Route::AgentsPage {},
|
||||||
|
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
|
||||||
|
},
|
||||||
|
NavItem {
|
||||||
|
label: "Organization",
|
||||||
|
route: Route::OrgPricingPage {},
|
||||||
|
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -56,15 +66,22 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
|||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
aside { class: "sidebar",
|
aside { class: "sidebar",
|
||||||
// -- Header: avatar circle + email --
|
|
||||||
SidebarHeader { email: email.clone(), avatar_url }
|
SidebarHeader { email: email.clone(), avatar_url }
|
||||||
|
|
||||||
// -- Navigation links --
|
|
||||||
nav { class: "sidebar-nav",
|
nav { class: "sidebar-nav",
|
||||||
for item in nav_items {
|
for item in nav_items {
|
||||||
{
|
{
|
||||||
// Simple active check: highlight Overview only when on `/`.
|
// Active detection for nested routes: highlight the parent nav
|
||||||
let is_active = item.route == current_route;
|
// item when any child route within the nested shell is active.
|
||||||
|
let is_active = match ¤t_route {
|
||||||
|
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
|
||||||
|
item.label == "Developer"
|
||||||
|
}
|
||||||
|
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
|
||||||
|
item.label == "Organization"
|
||||||
|
}
|
||||||
|
_ => item.route == current_route,
|
||||||
|
};
|
||||||
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
|
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
|
||||||
rsx! {
|
rsx! {
|
||||||
Link { to: item.route, class: cls,
|
Link { to: item.route, class: cls,
|
||||||
@@ -76,7 +93,6 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Logout button --
|
|
||||||
div { class: "sidebar-logout",
|
div { class: "sidebar-logout",
|
||||||
Link {
|
Link {
|
||||||
to: NavigationTarget::<Route>::External("/auth/logout".into()),
|
to: NavigationTarget::<Route>::External("/auth/logout".into()),
|
||||||
@@ -86,7 +102,6 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Footer: version + social links --
|
|
||||||
SidebarFooter {}
|
SidebarFooter {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/components/sub_nav.rs
Normal file
44
src/components/sub_nav.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use crate::app::Route;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// A single tab item for the sub-navigation bar.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `label` - Display text for the tab
|
||||||
|
/// * `route` - Route to navigate to when clicked
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct SubNavItem {
|
||||||
|
pub label: &'static str,
|
||||||
|
pub route: Route,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Horizontal tab navigation bar used inside nested shell layouts.
|
||||||
|
///
|
||||||
|
/// Highlights the active tab based on the current route.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `items` - List of tab items to render
|
||||||
|
#[component]
|
||||||
|
pub fn SubNav(items: Vec<SubNavItem>) -> Element {
|
||||||
|
let current_route = use_route::<Route>();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
nav { class: "sub-nav",
|
||||||
|
for item in &items {
|
||||||
|
{
|
||||||
|
let is_active = item.route == current_route;
|
||||||
|
let class = if is_active {
|
||||||
|
"sub-nav-item sub-nav-item--active"
|
||||||
|
} else {
|
||||||
|
"sub-nav-item"
|
||||||
|
};
|
||||||
|
rsx! {
|
||||||
|
Link { class: "{class}", to: item.route.clone(), "{item.label}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/components/tool_card.rs
Normal file
44
src/components/tool_card.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use crate::models::McpTool;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Renders an MCP tool card with name, description, status indicator, and toggle.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `tool` - The MCP tool data to render
|
||||||
|
/// * `on_toggle` - Callback fired when the enable/disable toggle is clicked
|
||||||
|
#[component]
|
||||||
|
pub fn ToolCard(tool: McpTool, on_toggle: EventHandler<String>) -> Element {
|
||||||
|
let status_class = format!("tool-status tool-status--{}", tool.status.css_class());
|
||||||
|
let toggle_class = if tool.enabled {
|
||||||
|
"tool-toggle tool-toggle--on"
|
||||||
|
} else {
|
||||||
|
"tool-toggle tool-toggle--off"
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "tool-card",
|
||||||
|
div { class: "tool-card-header",
|
||||||
|
div { class: "tool-card-icon", "\u{2699}" }
|
||||||
|
span { class: "{status_class}", "" }
|
||||||
|
}
|
||||||
|
h3 { class: "tool-card-name", "{tool.name}" }
|
||||||
|
p { class: "tool-card-desc", "{tool.description}" }
|
||||||
|
div { class: "tool-card-footer",
|
||||||
|
span { class: "tool-card-category", "{tool.category.label()}" }
|
||||||
|
button {
|
||||||
|
class: "{toggle_class}",
|
||||||
|
onclick: {
|
||||||
|
let id = tool.id.clone();
|
||||||
|
move |_| on_toggle.call(id.clone())
|
||||||
|
},
|
||||||
|
if tool.enabled {
|
||||||
|
"ON"
|
||||||
|
} else {
|
||||||
|
"OFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/infrastructure/llm.rs
Normal file
324
src/infrastructure/llm.rs
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
mod inner {
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A single message in the OpenAI-compatible chat format used by Ollama.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct ChatMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for Ollama's OpenAI-compatible chat completions endpoint.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct OllamaChatRequest {
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
/// Disable streaming so we get a single JSON response.
|
||||||
|
pub stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single choice in the Ollama chat completions response.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct ChatChoice {
|
||||||
|
pub message: ChatResponseMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The assistant message returned inside a choice.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct ChatResponseMessage {
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level response from Ollama's `/v1/chat/completions` endpoint.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct OllamaChatResponse {
|
||||||
|
pub choices: Vec<ChatChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the full text content of a webpage by downloading its HTML
|
||||||
|
/// and extracting the main article body, skipping navigation, headers,
|
||||||
|
/// footers, and sidebars.
|
||||||
|
///
|
||||||
|
/// Uses a tiered extraction strategy:
|
||||||
|
/// 1. Try content within `<article>`, `<main>`, or `[role="main"]`
|
||||||
|
/// 2. Fall back to all `<p>` tags outside excluded containers
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url` - The article URL to fetch
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The extracted text, or `None` if the fetch/parse fails.
|
||||||
|
/// Text is capped at 8000 characters to stay within LLM context limits.
|
||||||
|
pub(super) async fn fetch_article_text(url: &str) -> Option<String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(url)
|
||||||
|
.header("User-Agent", "CERTifAI/1.0 (Article Summarizer)")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = resp.text().await.ok()?;
|
||||||
|
let document = scraper::Html::parse_document(&html);
|
||||||
|
|
||||||
|
// Strategy 1: Extract from semantic article containers.
|
||||||
|
// Most news sites wrap the main content in <article>, <main>,
|
||||||
|
// or an element with role="main".
|
||||||
|
let article_selector = scraper::Selector::parse("article, main, [role='main']").ok()?;
|
||||||
|
let paragraph_sel = scraper::Selector::parse("p, h1, h2, h3, li").ok()?;
|
||||||
|
|
||||||
|
let mut text_parts: Vec<String> = Vec::with_capacity(64);
|
||||||
|
|
||||||
|
for container in document.select(&article_selector) {
|
||||||
|
for element in container.select(¶graph_sel) {
|
||||||
|
collect_text_fragment(element, &mut text_parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: If article containers yielded little text, fall back
|
||||||
|
// to all <p> tags that are NOT inside nav/header/footer/aside.
|
||||||
|
if joined_len(&text_parts) < 200 {
|
||||||
|
text_parts.clear();
|
||||||
|
let all_p = scraper::Selector::parse("p").ok()?;
|
||||||
|
|
||||||
|
// Tags whose descendants should be excluded from extraction
|
||||||
|
const EXCLUDED_TAGS: &[&str] = &["nav", "header", "footer", "aside", "script", "style"];
|
||||||
|
|
||||||
|
for element in document.select(&all_p) {
|
||||||
|
// Walk ancestors and skip if inside an excluded container.
|
||||||
|
// Checks tag names directly to avoid ego_tree version issues.
|
||||||
|
let inside_excluded = element.ancestors().any(|ancestor| {
|
||||||
|
ancestor
|
||||||
|
.value()
|
||||||
|
.as_element()
|
||||||
|
.is_some_and(|el| EXCLUDED_TAGS.contains(&el.name.local.as_ref()))
|
||||||
|
});
|
||||||
|
if !inside_excluded {
|
||||||
|
collect_text_fragment(element, &mut text_parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_text = text_parts.join("\n\n");
|
||||||
|
if full_text.len() < 100 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 8000 chars to stay within reasonable LLM context
|
||||||
|
let truncated: String = full_text.chars().take(8000).collect();
|
||||||
|
Some(truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text from an HTML element and append it to the parts list
|
||||||
|
/// if it meets a minimum length threshold.
|
||||||
|
fn collect_text_fragment(element: scraper::ElementRef<'_>, parts: &mut Vec<String>) {
|
||||||
|
let text: String = element.text().collect::<Vec<_>>().join(" ");
|
||||||
|
let trimmed = text.trim().to_string();
|
||||||
|
// Skip very short fragments (nav items, buttons, etc.)
|
||||||
|
if trimmed.len() >= 30 {
|
||||||
|
parts.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum the total character length of all collected text parts.
|
||||||
|
fn joined_len(parts: &[String]) -> usize {
|
||||||
|
parts.iter().map(|s| s.len()).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summarize an article using a local Ollama instance.
|
||||||
|
///
|
||||||
|
/// First attempts to fetch the full article text from the provided URL.
|
||||||
|
/// If that fails (paywall, timeout, etc.), falls back to the search snippet.
|
||||||
|
/// This mirrors how Perplexity fetches and reads source pages before answering.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `snippet` - The search result snippet (fallback content)
|
||||||
|
/// * `article_url` - The original article URL to fetch full text from
|
||||||
|
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
|
||||||
|
/// * `model` - The Ollama model ID to use (e.g. "llama3.1:8b")
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A summary string generated by the LLM, or a `ServerFnError` on failure
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||||
|
#[server(endpoint = "/api/summarize")]
|
||||||
|
pub async fn summarize_article(
|
||||||
|
snippet: String,
|
||||||
|
article_url: String,
|
||||||
|
ollama_url: String,
|
||||||
|
model: String,
|
||||||
|
) -> Result<String, ServerFnError> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||||
|
|
||||||
|
// Fall back to env var or default if the URL is empty
|
||||||
|
let base_url = if ollama_url.is_empty() {
|
||||||
|
std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into())
|
||||||
|
} else {
|
||||||
|
ollama_url
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fall back to env var or default if the model is empty
|
||||||
|
let model = if model.is_empty() {
|
||||||
|
std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into())
|
||||||
|
} else {
|
||||||
|
model
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to fetch the full article; fall back to the search snippet
|
||||||
|
let article_text = fetch_article_text(&article_url).await.unwrap_or(snippet);
|
||||||
|
|
||||||
|
let request_body = OllamaChatRequest {
|
||||||
|
model,
|
||||||
|
stream: false,
|
||||||
|
messages: vec![ChatMessage {
|
||||||
|
role: "user".into(),
|
||||||
|
content: format!(
|
||||||
|
"You are a news summarizer. Summarize the following article text \
|
||||||
|
in 2-3 concise paragraphs. Focus only on the key points and \
|
||||||
|
implications. Do NOT comment on the source, the date, the URL, \
|
||||||
|
the formatting, or whether the content seems complete or not. \
|
||||||
|
Just summarize whatever content is provided.\n\n\
|
||||||
|
{article_text}"
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ServerFnError::new(format!(
|
||||||
|
"Ollama returned {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: OllamaChatResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
|
||||||
|
|
||||||
|
body.choices
|
||||||
|
.first()
|
||||||
|
.map(|choice| choice.message.content.clone())
|
||||||
|
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lightweight chat message for the follow-up conversation.
|
||||||
|
/// Uses simple String role ("system"/"user"/"assistant") for Ollama compatibility.
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct FollowUpMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a follow-up question about an article using a local Ollama instance.
|
||||||
|
///
|
||||||
|
/// Accepts the full conversation history (system context + prior turns) and
|
||||||
|
/// returns the assistant's next response. The system message should contain
|
||||||
|
/// the article text and summary so the LLM has full context.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `messages` - The conversation history including system context
|
||||||
|
/// * `ollama_url` - Base URL of the Ollama instance
|
||||||
|
/// * `model` - The Ollama model ID to use
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The assistant's response text, or a `ServerFnError` on failure
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||||
|
#[server(endpoint = "/api/chat")]
|
||||||
|
pub async fn chat_followup(
|
||||||
|
messages: Vec<FollowUpMessage>,
|
||||||
|
ollama_url: String,
|
||||||
|
model: String,
|
||||||
|
) -> Result<String, ServerFnError> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||||
|
|
||||||
|
let base_url = if ollama_url.is_empty() {
|
||||||
|
std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into())
|
||||||
|
} else {
|
||||||
|
ollama_url
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = if model.is_empty() {
|
||||||
|
std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into())
|
||||||
|
} else {
|
||||||
|
model
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert FollowUpMessage to inner ChatMessage for the request
|
||||||
|
let chat_messages: Vec<ChatMessage> = messages
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| ChatMessage {
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let request_body = OllamaChatRequest {
|
||||||
|
model,
|
||||||
|
stream: false,
|
||||||
|
messages: chat_messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ServerFnError::new(format!(
|
||||||
|
"Ollama returned {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: OllamaChatResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
|
||||||
|
|
||||||
|
body.choices
|
||||||
|
.first()
|
||||||
|
.map(|choice| choice.message.content.clone())
|
||||||
|
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
||||||
|
}
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
#![cfg(feature = "server")]
|
// Server function modules (compiled for both web and server features;
|
||||||
|
// the #[server] macro generates client stubs for the web target)
|
||||||
|
pub mod llm;
|
||||||
|
pub mod ollama;
|
||||||
|
pub mod searxng;
|
||||||
|
|
||||||
|
// Server-only modules (Axum handlers, state, etc.)
|
||||||
|
#[cfg(feature = "server")]
|
||||||
mod auth;
|
mod auth;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
mod error;
|
mod error;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
mod server;
|
mod server;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub use server::*;
|
pub use server::*;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub use state::*;
|
pub use state::*;
|
||||||
|
|||||||
91
src/infrastructure/ollama.rs
Normal file
91
src/infrastructure/ollama.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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`
|
||||||
|
#[server(endpoint = "/api/ollama-status")]
|
||||||
|
pub async fn get_ollama_status(ollama_url: String) -> Result<OllamaStatus, ServerFnError> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
let base_url = if ollama_url.is_empty() {
|
||||||
|
std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into())
|
||||||
|
} 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
285
src/infrastructure/searxng.rs
Normal file
285
src/infrastructure/searxng.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
use crate::models::NewsCard;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
// Server-side helpers and types are only needed for the server build.
|
||||||
|
// The #[server] macro generates a client stub for the web build that
|
||||||
|
// sends a network request instead of executing this function body.
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
mod inner {
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// Individual result from the SearXNG search API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct SearxngResult {
|
||||||
|
pub title: String,
|
||||||
|
pub url: String,
|
||||||
|
pub content: Option<String>,
|
||||||
|
#[serde(rename = "publishedDate")]
|
||||||
|
pub published_date: Option<String>,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
/// Relevance score assigned by SearXNG (higher = more relevant).
|
||||||
|
#[serde(default)]
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level response from the SearXNG search API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct SearxngResponse {
|
||||||
|
pub results: Vec<SearxngResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the domain name from a URL to use as the source label.
|
||||||
|
///
|
||||||
|
/// Strips common prefixes like "www." for cleaner display.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `url_str` - The full URL string
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The domain host or a fallback "Web" string
|
||||||
|
pub(super) fn extract_source(url_str: &str) -> String {
|
||||||
|
url::Url::parse(url_str)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.host_str().map(String::from))
|
||||||
|
.map(|host| host.strip_prefix("www.").unwrap_or(&host).to_string())
|
||||||
|
.unwrap_or_else(|| "Web".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicate and rank search results for quality, similar to Perplexity.
|
||||||
|
///
|
||||||
|
/// Applies the following filters in order:
|
||||||
|
/// 1. Remove results with empty content (no snippet = low value)
|
||||||
|
/// 2. Deduplicate by domain (keep highest-scored result per domain)
|
||||||
|
/// 3. Sort by SearXNG relevance score (descending)
|
||||||
|
/// 4. Cap at `max_results`
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `results` - Raw search results from SearXNG
|
||||||
|
/// * `max_results` - Maximum number of results to return
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Filtered, deduplicated, and ranked results
|
||||||
|
pub(super) fn rank_and_deduplicate(
|
||||||
|
mut results: Vec<SearxngResult>,
|
||||||
|
max_results: usize,
|
||||||
|
) -> Vec<SearxngResult> {
|
||||||
|
// Filter out results with no meaningful content
|
||||||
|
results.retain(|r| r.content.as_ref().is_some_and(|c| c.trim().len() >= 20));
|
||||||
|
|
||||||
|
// Sort by score descending so we keep the best result per domain
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
b.score
|
||||||
|
.partial_cmp(&a.score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduplicate by domain: keep only the first (highest-scored) per domain
|
||||||
|
let mut seen_domains = HashSet::new();
|
||||||
|
results.retain(|r| {
|
||||||
|
let domain = extract_source(&r.url);
|
||||||
|
seen_domains.insert(domain)
|
||||||
|
});
|
||||||
|
|
||||||
|
results.truncate(max_results);
|
||||||
|
results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search for news using the SearXNG meta-search engine.
|
||||||
|
///
|
||||||
|
/// Uses Perplexity-style query enrichment and result ranking:
|
||||||
|
/// - Queries the "news" and "general" categories for fresh, relevant results
|
||||||
|
/// - Filters to the last month for recency
|
||||||
|
/// - Deduplicates by domain for source diversity
|
||||||
|
/// - Ranks by SearXNG relevance score
|
||||||
|
/// - Filters out results without meaningful content
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `query` - The search query string
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Up to 15 high-quality `NewsCard` results, or a `ServerFnError` on failure
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `ServerFnError` if the SearXNG request fails or response parsing fails
|
||||||
|
#[server(endpoint = "/api/search")]
|
||||||
|
pub async fn search_topic(query: String) -> Result<Vec<NewsCard>, ServerFnError> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
use inner::{extract_source, rank_and_deduplicate, SearxngResponse};
|
||||||
|
|
||||||
|
let searxng_url =
|
||||||
|
std::env::var("SEARXNG_URL").unwrap_or_else(|_| "http://localhost:8888".into());
|
||||||
|
|
||||||
|
// Enrich the query with "latest news" context for better results,
|
||||||
|
// similar to how Perplexity reformulates queries before searching.
|
||||||
|
let enriched_query = format!("{query} latest news");
|
||||||
|
|
||||||
|
// Build URL with query parameters using the url crate's encoder
|
||||||
|
// to avoid reqwest version conflicts between our dep and dioxus's.
|
||||||
|
// Key SearXNG params:
|
||||||
|
// categories=news,general - prioritize news sources + supplement with general
|
||||||
|
// time_range=month - only recent results (last 30 days)
|
||||||
|
// language=en - English results
|
||||||
|
// format=json - machine-readable output
|
||||||
|
let encoded_query: String =
|
||||||
|
url::form_urlencoded::byte_serialize(enriched_query.as_bytes()).collect();
|
||||||
|
let search_url = format!(
|
||||||
|
"{searxng_url}/search?q={encoded_query}&format=json&language=en\
|
||||||
|
&categories=news,general&time_range=month"
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get(&search_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("SearXNG request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(ServerFnError::new(format!(
|
||||||
|
"SearXNG returned status {}",
|
||||||
|
resp.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: SearxngResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to parse SearXNG response: {e}")))?;
|
||||||
|
|
||||||
|
// Apply Perplexity-style ranking: filter empties, deduplicate domains, sort by score
|
||||||
|
let ranked = rank_and_deduplicate(body.results, 15);
|
||||||
|
|
||||||
|
let cards: Vec<NewsCard> = ranked
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let summary = r
|
||||||
|
.content
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.chars()
|
||||||
|
.take(200)
|
||||||
|
.collect::<String>();
|
||||||
|
let content = r.content.unwrap_or_default();
|
||||||
|
NewsCard {
|
||||||
|
title: r.title,
|
||||||
|
source: extract_source(&r.url),
|
||||||
|
summary,
|
||||||
|
content,
|
||||||
|
category: query.clone(),
|
||||||
|
url: r.url,
|
||||||
|
thumbnail_url: r.thumbnail,
|
||||||
|
published_at: r.published_date.unwrap_or_else(|| "Recent".into()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch trending topic keywords by running a broad news search and
|
||||||
|
/// extracting the most frequent meaningful terms from result titles.
|
||||||
|
///
|
||||||
|
/// This approach works regardless of whether SearXNG has autocomplete
|
||||||
|
/// configured, since it uses the standard search API.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Up to 8 trending keyword strings, or a `ServerFnError` on failure
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `ServerFnError` if the SearXNG search request fails
|
||||||
|
#[server(endpoint = "/api/trending")]
|
||||||
|
pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
use inner::SearxngResponse;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let searxng_url =
|
||||||
|
std::env::var("SEARXNG_URL").unwrap_or_else(|_| "http://localhost:8888".into());
|
||||||
|
|
||||||
|
let encoded_query: String =
|
||||||
|
url::form_urlencoded::byte_serialize(b"trending technology AI").collect();
|
||||||
|
let search_url = format!(
|
||||||
|
"{searxng_url}/search?q={encoded_query}&format=json&language=en\
|
||||||
|
&categories=news&time_range=week"
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&search_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("SearXNG trending search failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(ServerFnError::new(format!(
|
||||||
|
"SearXNG trending search returned status {}",
|
||||||
|
resp.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: SearxngResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to parse trending response: {e}")))?;
|
||||||
|
|
||||||
|
// Common stop words to exclude from trending keywords
|
||||||
|
const STOP_WORDS: &[&str] = &[
|
||||||
|
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
|
||||||
|
"from", "is", "are", "was", "were", "be", "been", "has", "have", "had", "do", "does",
|
||||||
|
"did", "will", "would", "could", "should", "may", "can", "not", "no", "it", "its", "this",
|
||||||
|
"that", "these", "how", "what", "why", "who", "when", "new", "says", "said", "about",
|
||||||
|
"after", "over", "into", "up", "out", "as", "all", "more", "than", "just", "now", "also",
|
||||||
|
"us", "we", "you", "your", "our", "if", "so", "like", "get", "make", "year", "years",
|
||||||
|
"one", "two",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Count word frequency across all result titles. Words are lowercased
|
||||||
|
// and must be at least 3 characters to filter out noise.
|
||||||
|
let mut word_counts: HashMap<String, u32> = HashMap::new();
|
||||||
|
for result in &body.results {
|
||||||
|
for word in result.title.split_whitespace() {
|
||||||
|
// Strip punctuation from edges, lowercase
|
||||||
|
let clean: String = word
|
||||||
|
.trim_matches(|c: char| !c.is_alphanumeric())
|
||||||
|
.to_lowercase();
|
||||||
|
if clean.len() >= 3 && !STOP_WORDS.contains(&clean.as_str()) {
|
||||||
|
*word_counts.entry(clean).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by frequency descending, take top 8
|
||||||
|
let mut sorted: Vec<(String, u32)> = word_counts.into_iter().collect();
|
||||||
|
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
// Capitalize first letter for display
|
||||||
|
let topics: Vec<String> = sorted
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, count)| *count >= 2)
|
||||||
|
.take(8)
|
||||||
|
.map(|(word, _)| {
|
||||||
|
let mut chars = word.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||||
|
None => word,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(topics)
|
||||||
|
}
|
||||||
71
src/models/chat.rs
Normal file
71
src/models/chat.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The role of a participant in a chat conversation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum ChatRole {
|
||||||
|
/// Message sent by the human user
|
||||||
|
User,
|
||||||
|
/// Message generated by the AI assistant
|
||||||
|
Assistant,
|
||||||
|
/// System-level instruction (not displayed in UI)
|
||||||
|
System,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type of file attached to a chat message.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum AttachmentKind {
|
||||||
|
/// Image file (png, jpg, webp, etc.)
|
||||||
|
Image,
|
||||||
|
/// Document file (pdf, docx, txt, etc.)
|
||||||
|
Document,
|
||||||
|
/// Source code file
|
||||||
|
Code,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file attachment on a chat message.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `name` - Original filename
|
||||||
|
/// * `kind` - Type of attachment for rendering
|
||||||
|
/// * `size_bytes` - File size in bytes
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Attachment {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: AttachmentKind,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single message in a chat conversation.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique message identifier
|
||||||
|
/// * `role` - Who sent this message
|
||||||
|
/// * `content` - The message text content
|
||||||
|
/// * `attachments` - Optional file attachments
|
||||||
|
/// * `timestamp` - ISO 8601 timestamp string
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub id: String,
|
||||||
|
pub role: ChatRole,
|
||||||
|
pub content: String,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chat session containing a conversation history.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique session identifier
|
||||||
|
/// * `title` - Display title (usually derived from first message)
|
||||||
|
/// * `messages` - Ordered list of messages in the session
|
||||||
|
/// * `created_at` - ISO 8601 creation timestamp
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ChatSession {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
47
src/models/developer.rs
Normal file
47
src/models/developer.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// An AI agent entry managed through the developer tools.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique agent identifier
|
||||||
|
/// * `name` - Human-readable agent name
|
||||||
|
/// * `description` - What this agent does
|
||||||
|
/// * `status` - Current running status label
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AgentEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A workflow/flow entry from the flow builder.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique flow identifier
|
||||||
|
/// * `name` - Human-readable flow name
|
||||||
|
/// * `node_count` - Number of nodes in the flow graph
|
||||||
|
/// * `last_run` - ISO 8601 timestamp of the last execution
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct FlowEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub node_count: u32,
|
||||||
|
pub last_run: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single analytics metric for the developer dashboard.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `label` - Display name of the metric
|
||||||
|
/// * `value` - Current value as a formatted string
|
||||||
|
/// * `change_pct` - Percentage change from previous period (positive = increase)
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AnalyticsMetric {
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub change_pct: f64,
|
||||||
|
}
|
||||||
60
src/models/knowledge.rs
Normal file
60
src/models/knowledge.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The type of file stored in the knowledge base.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum FileKind {
|
||||||
|
/// PDF document
|
||||||
|
Pdf,
|
||||||
|
/// Plain text or markdown file
|
||||||
|
Text,
|
||||||
|
/// Spreadsheet (csv, xlsx)
|
||||||
|
Spreadsheet,
|
||||||
|
/// Source code file
|
||||||
|
Code,
|
||||||
|
/// Image file
|
||||||
|
Image,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileKind {
|
||||||
|
/// Returns the display label for a file kind.
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Pdf => "PDF",
|
||||||
|
Self::Text => "Text",
|
||||||
|
Self::Spreadsheet => "Spreadsheet",
|
||||||
|
Self::Code => "Code",
|
||||||
|
Self::Image => "Image",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an icon identifier for rendering.
|
||||||
|
pub fn icon(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Pdf => "file-pdf",
|
||||||
|
Self::Text => "file-text",
|
||||||
|
Self::Spreadsheet => "file-spreadsheet",
|
||||||
|
Self::Code => "file-code",
|
||||||
|
Self::Image => "file-image",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file stored in the knowledge base for RAG retrieval.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique file identifier
|
||||||
|
/// * `name` - Original filename
|
||||||
|
/// * `kind` - Type classification of the file
|
||||||
|
/// * `size_bytes` - File size in bytes
|
||||||
|
/// * `uploaded_at` - ISO 8601 upload timestamp
|
||||||
|
/// * `chunk_count` - Number of vector chunks created from this file
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct KnowledgeFile {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: FileKind,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub uploaded_at: String,
|
||||||
|
pub chunk_count: u32,
|
||||||
|
}
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
|
mod chat;
|
||||||
|
mod developer;
|
||||||
|
mod knowledge;
|
||||||
|
mod news;
|
||||||
|
mod organization;
|
||||||
|
mod provider;
|
||||||
|
mod tool;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
|
pub use chat::*;
|
||||||
|
pub use developer::*;
|
||||||
|
pub use knowledge::*;
|
||||||
|
pub use news::*;
|
||||||
|
pub use organization::*;
|
||||||
|
pub use provider::*;
|
||||||
|
pub use tool::*;
|
||||||
pub use user::*;
|
pub use user::*;
|
||||||
|
|||||||
25
src/models/news.rs
Normal file
25
src/models/news.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A single news feed card representing an AI-related article.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `title` - Headline of the article
|
||||||
|
/// * `source` - Publishing outlet or author
|
||||||
|
/// * `summary` - Brief summary text
|
||||||
|
/// * `content` - Full content snippet from search results
|
||||||
|
/// * `category` - Display label for the search topic (e.g. "AI", "Finance")
|
||||||
|
/// * `url` - Link to the full article
|
||||||
|
/// * `thumbnail_url` - Optional thumbnail image URL
|
||||||
|
/// * `published_at` - ISO 8601 date string
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct NewsCard {
|
||||||
|
pub title: String,
|
||||||
|
pub source: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub content: String,
|
||||||
|
pub category: String,
|
||||||
|
pub url: String,
|
||||||
|
pub thumbnail_url: Option<String>,
|
||||||
|
pub published_at: String,
|
||||||
|
}
|
||||||
84
src/models/organization.rs
Normal file
84
src/models/organization.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Role assigned to an organization member.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum MemberRole {
|
||||||
|
/// Full administrative access
|
||||||
|
Admin,
|
||||||
|
/// Standard user access
|
||||||
|
Member,
|
||||||
|
/// Read-only access
|
||||||
|
Viewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemberRole {
|
||||||
|
/// Returns the display label for a member role.
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Admin => "Admin",
|
||||||
|
Self::Member => "Member",
|
||||||
|
Self::Viewer => "Viewer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all available roles for populating dropdowns.
|
||||||
|
pub fn all() -> &'static [Self] {
|
||||||
|
&[Self::Admin, Self::Member, Self::Viewer]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A member of the organization.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique member identifier
|
||||||
|
/// * `name` - Display name
|
||||||
|
/// * `email` - Email address
|
||||||
|
/// * `role` - Assigned role within the organization
|
||||||
|
/// * `joined_at` - ISO 8601 join date
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct OrgMember {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub email: String,
|
||||||
|
pub role: MemberRole,
|
||||||
|
pub joined_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pricing plan tier.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique plan identifier
|
||||||
|
/// * `name` - Plan display name (e.g. "Starter", "Team", "Enterprise")
|
||||||
|
/// * `price_eur` - Monthly price in euros
|
||||||
|
/// * `features` - List of included features
|
||||||
|
/// * `highlighted` - Whether this plan should be visually emphasized
|
||||||
|
/// * `max_seats` - Maximum number of seats, None for unlimited
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct PricingPlan {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub price_eur: u32,
|
||||||
|
pub features: Vec<String>,
|
||||||
|
pub highlighted: bool,
|
||||||
|
pub max_seats: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Billing usage statistics for the current cycle.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `seats_used` - Number of active seats
|
||||||
|
/// * `seats_total` - Total seats in the plan
|
||||||
|
/// * `tokens_used` - Tokens consumed this billing cycle
|
||||||
|
/// * `tokens_limit` - Token limit for the billing cycle
|
||||||
|
/// * `billing_cycle_end` - ISO 8601 date when the current cycle ends
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct BillingUsage {
|
||||||
|
pub seats_used: u32,
|
||||||
|
pub seats_total: u32,
|
||||||
|
pub tokens_used: u64,
|
||||||
|
pub tokens_limit: u64,
|
||||||
|
pub billing_cycle_end: String,
|
||||||
|
}
|
||||||
74
src/models/provider.rs
Normal file
74
src/models/provider.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Supported LLM provider backends.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum LlmProvider {
|
||||||
|
/// Self-hosted models via Ollama
|
||||||
|
Ollama,
|
||||||
|
/// Hugging Face Inference API
|
||||||
|
HuggingFace,
|
||||||
|
/// OpenAI-compatible endpoints
|
||||||
|
OpenAi,
|
||||||
|
/// Anthropic Claude API
|
||||||
|
Anthropic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlmProvider {
|
||||||
|
/// Returns the display name for a provider.
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Ollama => "Ollama",
|
||||||
|
Self::HuggingFace => "Hugging Face",
|
||||||
|
Self::OpenAi => "OpenAI",
|
||||||
|
Self::Anthropic => "Anthropic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A model available from a provider.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique model identifier (e.g. "llama3.1:8b")
|
||||||
|
/// * `name` - Human-readable display name
|
||||||
|
/// * `provider` - Which provider hosts this model
|
||||||
|
/// * `context_window` - Maximum context length in tokens
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ModelEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub provider: LlmProvider,
|
||||||
|
pub context_window: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An embedding model available from a provider.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique embedding model identifier
|
||||||
|
/// * `name` - Human-readable display name
|
||||||
|
/// * `provider` - Which provider hosts this model
|
||||||
|
/// * `dimensions` - Output embedding dimensions
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EmbeddingEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub provider: LlmProvider,
|
||||||
|
pub dimensions: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active provider configuration state.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `provider` - Currently selected provider
|
||||||
|
/// * `selected_model` - ID of the active chat model
|
||||||
|
/// * `selected_embedding` - ID of the active embedding model
|
||||||
|
/// * `api_key_set` - Whether an API key has been configured
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
pub provider: LlmProvider,
|
||||||
|
pub selected_model: String,
|
||||||
|
pub selected_embedding: String,
|
||||||
|
pub api_key_set: bool,
|
||||||
|
}
|
||||||
73
src/models/tool.rs
Normal file
73
src/models/tool.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Category grouping for MCP tools.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum ToolCategory {
|
||||||
|
/// Web search and browsing tools
|
||||||
|
Search,
|
||||||
|
/// File and document processing tools
|
||||||
|
FileSystem,
|
||||||
|
/// Computation and math tools
|
||||||
|
Compute,
|
||||||
|
/// Code execution and analysis tools
|
||||||
|
Code,
|
||||||
|
/// Communication and notification tools
|
||||||
|
Communication,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolCategory {
|
||||||
|
/// Returns the display label for a tool category.
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Search => "Search",
|
||||||
|
Self::FileSystem => "File System",
|
||||||
|
Self::Compute => "Compute",
|
||||||
|
Self::Code => "Code",
|
||||||
|
Self::Communication => "Communication",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status of an MCP tool instance.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum ToolStatus {
|
||||||
|
/// Tool is running and available
|
||||||
|
Active,
|
||||||
|
/// Tool is installed but not running
|
||||||
|
Inactive,
|
||||||
|
/// Tool encountered an error
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolStatus {
|
||||||
|
/// Returns the CSS class suffix for status styling.
|
||||||
|
pub fn css_class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Active => "active",
|
||||||
|
Self::Inactive => "inactive",
|
||||||
|
Self::Error => "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An MCP (Model Context Protocol) tool entry.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// * `id` - Unique tool identifier
|
||||||
|
/// * `name` - Human-readable display name
|
||||||
|
/// * `description` - Brief description of what the tool does
|
||||||
|
/// * `category` - Classification category
|
||||||
|
/// * `status` - Current running status
|
||||||
|
/// * `enabled` - Whether the tool is toggled on by the user
|
||||||
|
/// * `icon` - Icon identifier for rendering
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct McpTool {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub category: ToolCategory,
|
||||||
|
pub status: ToolStatus,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub icon: String,
|
||||||
|
}
|
||||||
145
src/pages/chat.rs
Normal file
145
src/pages/chat.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::ChatBubble;
|
||||||
|
use crate::models::{ChatMessage, ChatRole, ChatSession};
|
||||||
|
|
||||||
|
/// ChatGPT-style chat interface with session list and message area.
|
||||||
|
///
|
||||||
|
/// Full-height layout: left panel shows session history,
|
||||||
|
/// right panel shows messages and input bar.
|
||||||
|
#[component]
|
||||||
|
pub fn ChatPage() -> Element {
|
||||||
|
let sessions = use_signal(mock_sessions);
|
||||||
|
let mut active_session_id = use_signal(|| "session-1".to_string());
|
||||||
|
let mut input_text = use_signal(String::new);
|
||||||
|
|
||||||
|
// Clone data out of signals before entering the rsx! block to avoid
|
||||||
|
// holding a `Signal::read()` borrow across potential await points.
|
||||||
|
let sessions_list = sessions.read().clone();
|
||||||
|
let current_id = active_session_id.read().clone();
|
||||||
|
let active_session = sessions_list.iter().find(|s| s.id == current_id).cloned();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
section { class: "chat-page",
|
||||||
|
div { class: "chat-sidebar-panel",
|
||||||
|
div { class: "chat-sidebar-header",
|
||||||
|
h3 { "Conversations" }
|
||||||
|
button { class: "btn-icon", "+" }
|
||||||
|
}
|
||||||
|
div { class: "chat-session-list",
|
||||||
|
for session in &sessions_list {
|
||||||
|
{
|
||||||
|
let is_active = session.id == current_id;
|
||||||
|
let class = if is_active {
|
||||||
|
"chat-session-item chat-session-item--active"
|
||||||
|
} else {
|
||||||
|
"chat-session-item"
|
||||||
|
};
|
||||||
|
let id = session.id.clone();
|
||||||
|
rsx! {
|
||||||
|
button { class: "{class}", onclick: move |_| active_session_id.set(id.clone()),
|
||||||
|
div { class: "chat-session-title", "{session.title}" }
|
||||||
|
div { class: "chat-session-date", "{session.created_at}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "chat-main-panel",
|
||||||
|
if let Some(session) = &active_session {
|
||||||
|
div { class: "chat-messages",
|
||||||
|
for msg in &session.messages {
|
||||||
|
ChatBubble { key: "{msg.id}", message: msg.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
div { class: "chat-empty",
|
||||||
|
p { "Select a conversation or start a new one." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "chat-input-bar",
|
||||||
|
button { class: "btn-icon chat-attach-btn", "+" }
|
||||||
|
input {
|
||||||
|
class: "chat-input",
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Type a message...",
|
||||||
|
value: "{input_text}",
|
||||||
|
oninput: move |evt: Event<FormData>| {
|
||||||
|
input_text.set(evt.value());
|
||||||
|
},
|
||||||
|
}
|
||||||
|
button { class: "btn-primary chat-send-btn", "Send" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock chat sessions with sample messages.
|
||||||
|
fn mock_sessions() -> Vec<ChatSession> {
|
||||||
|
vec![
|
||||||
|
ChatSession {
|
||||||
|
id: "session-1".into(),
|
||||||
|
title: "RAG Pipeline Setup".into(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage {
|
||||||
|
id: "msg-1".into(),
|
||||||
|
role: ChatRole::User,
|
||||||
|
content: "How do I set up a RAG pipeline with Ollama?".into(),
|
||||||
|
attachments: vec![],
|
||||||
|
timestamp: "10:30".into(),
|
||||||
|
},
|
||||||
|
ChatMessage {
|
||||||
|
id: "msg-2".into(),
|
||||||
|
role: ChatRole::Assistant,
|
||||||
|
content: "To set up a RAG pipeline with Ollama, you'll need to: \
|
||||||
|
1) Install Ollama and pull your preferred model, \
|
||||||
|
2) Set up a vector database (e.g. ChromaDB), \
|
||||||
|
3) Create an embedding pipeline for your documents, \
|
||||||
|
4) Wire the retrieval step into your prompt chain."
|
||||||
|
.into(),
|
||||||
|
attachments: vec![],
|
||||||
|
timestamp: "10:31".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
created_at: "2026-02-18".into(),
|
||||||
|
},
|
||||||
|
ChatSession {
|
||||||
|
id: "session-2".into(),
|
||||||
|
title: "GDPR Compliance Check".into(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage {
|
||||||
|
id: "msg-3".into(),
|
||||||
|
role: ChatRole::User,
|
||||||
|
content: "What data does CERTifAI store about users?".into(),
|
||||||
|
attachments: vec![],
|
||||||
|
timestamp: "09:15".into(),
|
||||||
|
},
|
||||||
|
ChatMessage {
|
||||||
|
id: "msg-4".into(),
|
||||||
|
role: ChatRole::Assistant,
|
||||||
|
content: "CERTifAI stores only the minimum data required: \
|
||||||
|
email address, session tokens, and usage metrics. \
|
||||||
|
All data stays on your infrastructure."
|
||||||
|
.into(),
|
||||||
|
attachments: vec![],
|
||||||
|
timestamp: "09:16".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
created_at: "2026-02-17".into(),
|
||||||
|
},
|
||||||
|
ChatSession {
|
||||||
|
id: "session-3".into(),
|
||||||
|
title: "MCP Server Configuration".into(),
|
||||||
|
messages: vec![ChatMessage {
|
||||||
|
id: "msg-5".into(),
|
||||||
|
role: ChatRole::User,
|
||||||
|
content: "How do I add a new MCP server?".into(),
|
||||||
|
attachments: vec![],
|
||||||
|
timestamp: "14:00".into(),
|
||||||
|
}],
|
||||||
|
created_at: "2026-02-16".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
442
src/pages/dashboard.rs
Normal file
442
src/pages/dashboard.rs
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_sdk::storage::use_persistent;
|
||||||
|
|
||||||
|
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
|
||||||
|
use crate::infrastructure::llm::FollowUpMessage;
|
||||||
|
use crate::models::NewsCard;
|
||||||
|
|
||||||
|
/// Maximum number of recent searches to retain in localStorage.
|
||||||
|
const MAX_RECENT_SEARCHES: usize = 10;
|
||||||
|
|
||||||
|
/// Default search topics shown on the dashboard, inspired by Perplexica.
|
||||||
|
const DEFAULT_TOPICS: &[&str] = &[
|
||||||
|
"AI",
|
||||||
|
"Technology",
|
||||||
|
"Science",
|
||||||
|
"Finance",
|
||||||
|
"Writing",
|
||||||
|
"Research",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Dashboard page displaying AI news from SearXNG with topic-based filtering,
|
||||||
|
/// a split-view article detail panel, and LLM-powered summarization.
|
||||||
|
///
|
||||||
|
/// State is persisted across sessions using localStorage:
|
||||||
|
/// - `certifai_topics`: custom user-defined search topics
|
||||||
|
/// - `certifai_ollama_url`: Ollama instance URL for summarization
|
||||||
|
/// - `certifai_ollama_model`: Ollama model ID for summarization
|
||||||
|
#[component]
|
||||||
|
pub fn DashboardPage() -> Element {
|
||||||
|
// Persistent state stored in localStorage
|
||||||
|
let mut custom_topics = use_persistent("certifai_topics".to_string(), Vec::<String>::new);
|
||||||
|
// Default to empty so the server functions use OLLAMA_URL / OLLAMA_MODEL
|
||||||
|
// from .env. Only stores a non-empty value when the user explicitly saves
|
||||||
|
// an override via the Settings panel.
|
||||||
|
let mut ollama_url = use_persistent("certifai_ollama_url".to_string(), String::new);
|
||||||
|
let mut ollama_model = use_persistent("certifai_ollama_model".to_string(), String::new);
|
||||||
|
|
||||||
|
// Reactive signals for UI state
|
||||||
|
let mut active_topic = use_signal(|| "AI".to_string());
|
||||||
|
let mut selected_card = use_signal(|| Option::<NewsCard>::None);
|
||||||
|
let mut summary = use_signal(|| Option::<String>::None);
|
||||||
|
let mut is_summarizing = use_signal(|| false);
|
||||||
|
let mut show_add_input = use_signal(|| false);
|
||||||
|
let mut new_topic_text = use_signal(String::new);
|
||||||
|
let mut show_settings = use_signal(|| false);
|
||||||
|
let mut settings_url = use_signal(String::new);
|
||||||
|
let mut settings_model = use_signal(String::new);
|
||||||
|
// Chat follow-up state
|
||||||
|
let mut chat_messages = use_signal(Vec::<FollowUpMessage>::new);
|
||||||
|
let mut is_chatting = use_signal(|| false);
|
||||||
|
// Stores the article text context for the chat system message
|
||||||
|
let mut article_context = use_signal(String::new);
|
||||||
|
|
||||||
|
// Recent search history, persisted in localStorage (capped at MAX_RECENT_SEARCHES)
|
||||||
|
let mut recent_searches =
|
||||||
|
use_persistent("certifai_recent_searches".to_string(), Vec::<String>::new);
|
||||||
|
|
||||||
|
// Build the complete topic list: defaults + custom
|
||||||
|
let all_topics: Vec<String> = {
|
||||||
|
let custom = custom_topics.read();
|
||||||
|
let mut topics: Vec<String> = DEFAULT_TOPICS.iter().map(|s| (*s).to_string()).collect();
|
||||||
|
for t in custom.iter() {
|
||||||
|
if !topics.contains(t) {
|
||||||
|
topics.push(t.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
topics
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch trending topics once on mount (no signal deps = runs once).
|
||||||
|
// use_resource handles deduplication and won't re-fetch on re-renders.
|
||||||
|
let trending_resource = use_resource(|| async {
|
||||||
|
match crate::infrastructure::searxng::get_trending_topics().await {
|
||||||
|
Ok(topics) => topics,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to fetch trending topics: {e}");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push a topic to the front of recent searches (deduplicating, capped).
|
||||||
|
// Defined as a closure so it can be called from multiple click handlers.
|
||||||
|
let mut record_search = move |topic: &str| {
|
||||||
|
let mut searches = recent_searches.read().clone();
|
||||||
|
searches.retain(|t| t != topic);
|
||||||
|
searches.insert(0, topic.to_string());
|
||||||
|
searches.truncate(MAX_RECENT_SEARCHES);
|
||||||
|
*recent_searches.write() = searches;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch news reactively when active_topic changes.
|
||||||
|
// use_resource tracks the signal read inside the closure and only
|
||||||
|
// re-fetches when active_topic actually changes -- unlike use_effect
|
||||||
|
// which can re-fire on unrelated re-renders.
|
||||||
|
let search_resource = use_resource(move || {
|
||||||
|
let topic = active_topic.read().clone();
|
||||||
|
async move { crate::infrastructure::searxng::search_topic(topic).await }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if an article is selected for split view
|
||||||
|
let has_selection = selected_card.read().is_some();
|
||||||
|
let container_class = if has_selection {
|
||||||
|
"dashboard-split"
|
||||||
|
} else {
|
||||||
|
"dashboard-with-sidebar"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve trending from resource (empty while loading / on error)
|
||||||
|
let trending_topics: Vec<String> = trending_resource
|
||||||
|
.read()
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Resolve search state from resource
|
||||||
|
let search_state = search_resource.read();
|
||||||
|
let is_loading = search_state.is_none();
|
||||||
|
let search_error: Option<String> = search_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.as_ref().err().map(|e| format!("Search failed: {e}")));
|
||||||
|
let news_cards: Vec<NewsCard> = match search_state.as_ref() {
|
||||||
|
Some(Ok(c)) => c.clone(),
|
||||||
|
Some(Err(_)) => crate::components::news_card::mock_news(),
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
// Drop the borrow before entering rsx! so signals can be written in handlers
|
||||||
|
drop(search_state);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
section { class: "dashboard-page",
|
||||||
|
PageHeader {
|
||||||
|
title: "Dashboard".to_string(),
|
||||||
|
subtitle: "AI news and updates".to_string(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topic tabs row
|
||||||
|
div { class: "dashboard-filters",
|
||||||
|
for topic in &all_topics {
|
||||||
|
{
|
||||||
|
let is_active = *active_topic.read() == *topic;
|
||||||
|
let class_name = if is_active {
|
||||||
|
"filter-tab filter-tab--active"
|
||||||
|
} else {
|
||||||
|
"filter-tab"
|
||||||
|
};
|
||||||
|
let is_custom = !DEFAULT_TOPICS.contains(&topic.as_str());
|
||||||
|
let topic_click = topic.clone();
|
||||||
|
let topic_remove = topic.clone();
|
||||||
|
rsx! {
|
||||||
|
div { class: "topic-tab-wrapper",
|
||||||
|
button {
|
||||||
|
class: "{class_name}",
|
||||||
|
onclick: move |_| {
|
||||||
|
record_search(&topic_click);
|
||||||
|
active_topic.set(topic_click.clone());
|
||||||
|
selected_card.set(None);
|
||||||
|
summary.set(None);
|
||||||
|
},
|
||||||
|
"{topic}"
|
||||||
|
}
|
||||||
|
if is_custom {
|
||||||
|
button {
|
||||||
|
class: "topic-remove",
|
||||||
|
onclick: move |_| {
|
||||||
|
let mut topics = custom_topics.read().clone();
|
||||||
|
topics.retain(|t| *t != topic_remove);
|
||||||
|
*custom_topics.write() = topics;
|
||||||
|
// If we removed the active topic, reset
|
||||||
|
if *active_topic.read() == topic_remove {
|
||||||
|
active_topic.set("AI".to_string());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add topic button / inline input
|
||||||
|
if *show_add_input.read() {
|
||||||
|
div { class: "topic-input-wrapper",
|
||||||
|
input {
|
||||||
|
class: "topic-input",
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Topic name...",
|
||||||
|
value: "{new_topic_text}",
|
||||||
|
oninput: move |e| new_topic_text.set(e.value()),
|
||||||
|
onkeypress: move |e| {
|
||||||
|
if e.key() == Key::Enter {
|
||||||
|
let val = new_topic_text.read().trim().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
let mut topics = custom_topics.read().clone();
|
||||||
|
if !topics.contains(&val) && !DEFAULT_TOPICS.contains(&val.as_str()) {
|
||||||
|
topics.push(val.clone());
|
||||||
|
*custom_topics.write() = topics;
|
||||||
|
record_search(&val);
|
||||||
|
active_topic.set(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new_topic_text.set(String::new());
|
||||||
|
show_add_input.set(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "topic-cancel-btn",
|
||||||
|
onclick: move |_| {
|
||||||
|
show_add_input.set(false);
|
||||||
|
new_topic_text.set(String::new());
|
||||||
|
},
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button {
|
||||||
|
class: "topic-add-btn",
|
||||||
|
onclick: move |_| show_add_input.set(true),
|
||||||
|
"+"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings toggle
|
||||||
|
button {
|
||||||
|
class: "filter-tab settings-toggle",
|
||||||
|
onclick: move |_| {
|
||||||
|
let currently_shown = *show_settings.read();
|
||||||
|
if !currently_shown {
|
||||||
|
settings_url.set(ollama_url.read().clone());
|
||||||
|
settings_model.set(ollama_model.read().clone());
|
||||||
|
}
|
||||||
|
show_settings.set(!currently_shown);
|
||||||
|
},
|
||||||
|
"Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings panel (collapsible)
|
||||||
|
if *show_settings.read() {
|
||||||
|
div { class: "settings-panel",
|
||||||
|
h4 { class: "settings-panel-title", "Ollama Settings" }
|
||||||
|
p { class: "settings-hint",
|
||||||
|
"Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env"
|
||||||
|
}
|
||||||
|
div { class: "settings-field",
|
||||||
|
label { "Ollama URL" }
|
||||||
|
input {
|
||||||
|
class: "settings-input",
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Uses OLLAMA_URL from .env",
|
||||||
|
value: "{settings_url}",
|
||||||
|
oninput: move |e| settings_url.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "settings-field",
|
||||||
|
label { "Model" }
|
||||||
|
input {
|
||||||
|
class: "settings-input",
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Uses OLLAMA_MODEL from .env",
|
||||||
|
value: "{settings_model}",
|
||||||
|
oninput: move |e| settings_model.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: move |_| {
|
||||||
|
*ollama_url.write() = settings_url.read().trim().to_string();
|
||||||
|
*ollama_model.write() = settings_model.read().trim().to_string();
|
||||||
|
show_settings.set(false);
|
||||||
|
},
|
||||||
|
"Save"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading / error state
|
||||||
|
if is_loading {
|
||||||
|
div { class: "dashboard-loading", "Searching..." }
|
||||||
|
}
|
||||||
|
if let Some(ref err) = search_error {
|
||||||
|
div { class: "settings-hint", "{err}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content area: grid + optional detail panel
|
||||||
|
div { class: "{container_class}",
|
||||||
|
// Left: news grid
|
||||||
|
div { class: if has_selection { "dashboard-left" } else { "dashboard-full-grid" },
|
||||||
|
div { class: if has_selection { "news-grid news-grid--compact" } else { "news-grid" },
|
||||||
|
for card in news_cards.iter() {
|
||||||
|
{
|
||||||
|
let is_selected = selected_card
|
||||||
|
|
||||||
|
// Auto-summarize on card selection
|
||||||
|
.read()
|
||||||
|
// Store context for follow-up chat
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| s.url == card.url && s.title == card.title);
|
||||||
|
rsx! {
|
||||||
|
NewsCardView {
|
||||||
|
key: "{card.title}-{card.url}",
|
||||||
|
card: card.clone(),
|
||||||
|
selected: is_selected,
|
||||||
|
on_click: move |c: NewsCard| {
|
||||||
|
let snippet = c.content.clone();
|
||||||
|
let article_url = c.url.clone();
|
||||||
|
selected_card.set(Some(c));
|
||||||
|
summary.set(None);
|
||||||
|
chat_messages.set(Vec::new());
|
||||||
|
article_context.set(String::new());
|
||||||
|
|
||||||
|
|
||||||
|
let oll_url = ollama_url.read().clone();
|
||||||
|
let mdl = ollama_model.read().clone();
|
||||||
|
spawn(async move {
|
||||||
|
is_summarizing.set(true);
|
||||||
|
match crate::infrastructure::llm::summarize_article(
|
||||||
|
snippet.clone(),
|
||||||
|
article_url,
|
||||||
|
oll_url,
|
||||||
|
mdl,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(text) => {
|
||||||
|
article_context
|
||||||
|
.set(
|
||||||
|
format!(
|
||||||
|
"Article content:\n{snippet}\n\n\
|
||||||
|
AI Summary:\n{text}",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
summary.set(Some(text));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Summarization failed: {e}");
|
||||||
|
summary.set(Some(format!("Summarization failed: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is_summarizing.set(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right: article detail panel (when card selected)
|
||||||
|
if let Some(ref card) = *selected_card.read() {
|
||||||
|
div { class: "dashboard-right",
|
||||||
|
ArticleDetail {
|
||||||
|
card: card.clone(),
|
||||||
|
on_close: move |_| {
|
||||||
|
selected_card.set(None);
|
||||||
|
summary.set(None);
|
||||||
|
chat_messages.set(Vec::new());
|
||||||
|
},
|
||||||
|
summary: summary.read().clone(),
|
||||||
|
is_summarizing: *is_summarizing.read(),
|
||||||
|
chat_messages: chat_messages.read().clone(),
|
||||||
|
is_chatting: *is_chatting.read(),
|
||||||
|
on_chat_send: move |question: String| {
|
||||||
|
let oll_url = ollama_url.read().clone();
|
||||||
|
let mdl = ollama_model.read().clone();
|
||||||
|
let ctx = article_context.read().clone();
|
||||||
|
|
||||||
|
// Append user message to chat
|
||||||
|
chat_messages
|
||||||
|
|
||||||
|
// Build full message history for Ollama
|
||||||
|
|
||||||
|
.write()
|
||||||
|
.push(FollowUpMessage {
|
||||||
|
role: "user".into(),
|
||||||
|
content: question,
|
||||||
|
});
|
||||||
|
let msgs = {
|
||||||
|
let history = chat_messages.read();
|
||||||
|
let mut all = vec![
|
||||||
|
FollowUpMessage {
|
||||||
|
role: "system".into(),
|
||||||
|
content: format!(
|
||||||
|
"You are a helpful assistant. The user is reading \
|
||||||
|
a news article. Use the following context to answer \
|
||||||
|
their questions. Do NOT comment on the source, \
|
||||||
|
dates, URLs, or formatting.\n\n{ctx}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
all.extend(history.iter().cloned());
|
||||||
|
all
|
||||||
|
};
|
||||||
|
spawn(async move {
|
||||||
|
is_chatting.set(true);
|
||||||
|
match crate::infrastructure::llm::chat_followup(msgs, oll_url, mdl).await {
|
||||||
|
Ok(reply) => {
|
||||||
|
chat_messages
|
||||||
|
.write()
|
||||||
|
.push(FollowUpMessage {
|
||||||
|
role: "assistant".into(),
|
||||||
|
content: reply,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Chat failed: {e}");
|
||||||
|
chat_messages
|
||||||
|
.write()
|
||||||
|
.push(FollowUpMessage {
|
||||||
|
role: "assistant".into(),
|
||||||
|
content: format!("Error: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is_chatting.set(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right: sidebar (when no card selected)
|
||||||
|
if !has_selection {
|
||||||
|
DashboardSidebar {
|
||||||
|
ollama_url: ollama_url.read().clone(),
|
||||||
|
trending: trending_topics.clone(),
|
||||||
|
recent_searches: recent_searches.read().clone(),
|
||||||
|
on_topic_click: move |topic: String| {
|
||||||
|
record_search(&topic);
|
||||||
|
active_topic.set(topic);
|
||||||
|
selected_card.set(None);
|
||||||
|
summary.set(None);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/pages/developer/agents.rs
Normal file
24
src/pages/developer/agents.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Agents page placeholder for the LangGraph agent builder.
|
||||||
|
///
|
||||||
|
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||||
|
/// Will eventually integrate with the LangGraph framework.
|
||||||
|
#[component]
|
||||||
|
pub fn AgentsPage() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { class: "placeholder-page",
|
||||||
|
div { class: "placeholder-card",
|
||||||
|
div { class: "placeholder-icon", "A" }
|
||||||
|
h2 { "Agent Builder" }
|
||||||
|
p { class: "placeholder-desc",
|
||||||
|
"Build and manage AI agents with LangGraph. \
|
||||||
|
Create multi-step reasoning pipelines, tool-using agents, \
|
||||||
|
and autonomous workflows."
|
||||||
|
}
|
||||||
|
button { class: "btn-primary", disabled: true, "Launch Agent Builder" }
|
||||||
|
span { class: "placeholder-badge", "Coming Soon" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/pages/developer/analytics.rs
Normal file
65
src/pages/developer/analytics.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::models::AnalyticsMetric;
|
||||||
|
|
||||||
|
/// Analytics page placeholder for LangFuse integration.
|
||||||
|
///
|
||||||
|
/// Shows a "Coming Soon" card with a disabled launch button,
|
||||||
|
/// plus a mock stats bar showing sample metrics.
|
||||||
|
#[component]
|
||||||
|
pub fn AnalyticsPage() -> Element {
|
||||||
|
let metrics = mock_metrics();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
section { class: "placeholder-page",
|
||||||
|
div { class: "analytics-stats-bar",
|
||||||
|
for metric in &metrics {
|
||||||
|
div { class: "analytics-stat",
|
||||||
|
span { class: "analytics-stat-value", "{metric.value}" }
|
||||||
|
span { class: "analytics-stat-label", "{metric.label}" }
|
||||||
|
span { class: if metric.change_pct >= 0.0 { "analytics-stat-change analytics-stat-change--up" } else { "analytics-stat-change analytics-stat-change--down" },
|
||||||
|
"{metric.change_pct:+.1}%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "placeholder-card",
|
||||||
|
div { class: "placeholder-icon", "L" }
|
||||||
|
h2 { "Analytics & Observability" }
|
||||||
|
p { class: "placeholder-desc",
|
||||||
|
"Monitor and analyze your AI pipelines with LangFuse. \
|
||||||
|
Track token usage, latency, costs, and quality metrics \
|
||||||
|
across all your deployments."
|
||||||
|
}
|
||||||
|
button { class: "btn-primary", disabled: true, "Launch LangFuse" }
|
||||||
|
span { class: "placeholder-badge", "Coming Soon" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock analytics metrics for the stats bar.
|
||||||
|
fn mock_metrics() -> Vec<AnalyticsMetric> {
|
||||||
|
vec![
|
||||||
|
AnalyticsMetric {
|
||||||
|
label: "Total Requests".into(),
|
||||||
|
value: "12,847".into(),
|
||||||
|
change_pct: 14.2,
|
||||||
|
},
|
||||||
|
AnalyticsMetric {
|
||||||
|
label: "Avg Latency".into(),
|
||||||
|
value: "245ms".into(),
|
||||||
|
change_pct: -8.5,
|
||||||
|
},
|
||||||
|
AnalyticsMetric {
|
||||||
|
label: "Tokens Used".into(),
|
||||||
|
value: "2.4M".into(),
|
||||||
|
change_pct: 22.1,
|
||||||
|
},
|
||||||
|
AnalyticsMetric {
|
||||||
|
label: "Error Rate".into(),
|
||||||
|
value: "0.3%".into(),
|
||||||
|
change_pct: -12.0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
24
src/pages/developer/flow.rs
Normal file
24
src/pages/developer/flow.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Flow page placeholder for the LangFlow visual workflow builder.
|
||||||
|
///
|
||||||
|
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||||
|
/// Will eventually integrate with LangFlow for visual flow design.
|
||||||
|
#[component]
|
||||||
|
pub fn FlowPage() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { class: "placeholder-page",
|
||||||
|
div { class: "placeholder-card",
|
||||||
|
div { class: "placeholder-icon", "F" }
|
||||||
|
h2 { "Flow Builder" }
|
||||||
|
p { class: "placeholder-desc",
|
||||||
|
"Design visual AI workflows with LangFlow. \
|
||||||
|
Drag-and-drop nodes to create data processing pipelines, \
|
||||||
|
prompt chains, and integration flows."
|
||||||
|
}
|
||||||
|
button { class: "btn-primary", disabled: true, "Launch Flow Builder" }
|
||||||
|
span { class: "placeholder-badge", "Coming Soon" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/pages/developer/mod.rs
Normal file
41
src/pages/developer/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
mod agents;
|
||||||
|
mod analytics;
|
||||||
|
mod flow;
|
||||||
|
|
||||||
|
pub use agents::*;
|
||||||
|
pub use analytics::*;
|
||||||
|
pub use flow::*;
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::app::Route;
|
||||||
|
use crate::components::sub_nav::{SubNav, SubNavItem};
|
||||||
|
|
||||||
|
/// Shell layout for the Developer section.
|
||||||
|
///
|
||||||
|
/// Renders a horizontal tab bar (Agents, Flow, Analytics) above
|
||||||
|
/// the child route outlet. Sits inside the main `AppShell` layout.
|
||||||
|
#[component]
|
||||||
|
pub fn DeveloperShell() -> Element {
|
||||||
|
let tabs = vec![
|
||||||
|
SubNavItem {
|
||||||
|
label: "Agents",
|
||||||
|
route: Route::AgentsPage {},
|
||||||
|
},
|
||||||
|
SubNavItem {
|
||||||
|
label: "Flow",
|
||||||
|
route: Route::FlowPage {},
|
||||||
|
},
|
||||||
|
SubNavItem {
|
||||||
|
label: "Analytics",
|
||||||
|
route: Route::AnalyticsPage {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "developer-shell",
|
||||||
|
SubNav { items: tabs }
|
||||||
|
div { class: "shell-content", Outlet::<Route> {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/pages/impressum.rs
Normal file
74
src/pages/impressum.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
/// Impressum (legal notice) page required by German/EU law.
|
||||||
|
///
|
||||||
|
/// Displays placeholder company information. This page is publicly
|
||||||
|
/// accessible without authentication.
|
||||||
|
#[component]
|
||||||
|
pub fn ImpressumPage() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "legal-page",
|
||||||
|
nav { class: "legal-nav",
|
||||||
|
Link { to: Route::LandingPage {}, class: "landing-logo",
|
||||||
|
span { class: "landing-logo-icon",
|
||||||
|
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
||||||
|
}
|
||||||
|
span { "CERTifAI" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main { class: "legal-content",
|
||||||
|
h1 { "Impressum" }
|
||||||
|
|
||||||
|
h2 { "Information according to 5 TMG" }
|
||||||
|
p {
|
||||||
|
"CERTifAI GmbH"
|
||||||
|
br {}
|
||||||
|
"Musterstrasse 1"
|
||||||
|
br {}
|
||||||
|
"10115 Berlin"
|
||||||
|
br {}
|
||||||
|
"Germany"
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "Represented by" }
|
||||||
|
p { "Managing Director: [Name]" }
|
||||||
|
|
||||||
|
h2 { "Contact" }
|
||||||
|
p {
|
||||||
|
"Email: info@certifai.example"
|
||||||
|
br {}
|
||||||
|
"Phone: +49 (0) 30 1234567"
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "Commercial Register" }
|
||||||
|
p {
|
||||||
|
"Registered at: Amtsgericht Berlin-Charlottenburg"
|
||||||
|
br {}
|
||||||
|
"Registration number: HRB XXXXXX"
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "VAT ID" }
|
||||||
|
p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" }
|
||||||
|
|
||||||
|
h2 { "Responsible for content according to 55 Abs. 2 RStV" }
|
||||||
|
p {
|
||||||
|
"[Name]"
|
||||||
|
br {}
|
||||||
|
"CERTifAI GmbH"
|
||||||
|
br {}
|
||||||
|
"Musterstrasse 1"
|
||||||
|
br {}
|
||||||
|
"10115 Berlin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer { class: "legal-footer",
|
||||||
|
Link { to: Route::LandingPage {}, "Back to Home" }
|
||||||
|
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/pages/knowledge.rs
Normal file
124
src/pages/knowledge.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::{FileRow, PageHeader};
|
||||||
|
use crate::models::{FileKind, KnowledgeFile};
|
||||||
|
|
||||||
|
/// Knowledge Base page with file explorer table and upload controls.
|
||||||
|
///
|
||||||
|
/// Displays uploaded documents used for RAG retrieval with their
|
||||||
|
/// metadata, chunk counts, and management actions.
|
||||||
|
#[component]
|
||||||
|
pub fn KnowledgePage() -> Element {
|
||||||
|
let mut files = use_signal(mock_files);
|
||||||
|
let mut search_query = use_signal(String::new);
|
||||||
|
|
||||||
|
// Filter files by search query (case-insensitive name match)
|
||||||
|
let query = search_query.read().to_lowercase();
|
||||||
|
let filtered: Vec<_> = files
|
||||||
|
.read()
|
||||||
|
.iter()
|
||||||
|
.filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Remove a file by ID
|
||||||
|
let on_delete = move |id: String| {
|
||||||
|
files.write().retain(|f| f.id != id);
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
section { class: "knowledge-page",
|
||||||
|
PageHeader {
|
||||||
|
title: "Knowledge Base".to_string(),
|
||||||
|
subtitle: "Manage documents for RAG retrieval".to_string(),
|
||||||
|
actions: rsx! {
|
||||||
|
button { class: "btn-primary", "Upload File" }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
div { class: "knowledge-toolbar",
|
||||||
|
input {
|
||||||
|
class: "form-input knowledge-search",
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Search files...",
|
||||||
|
value: "{search_query}",
|
||||||
|
oninput: move |evt: Event<FormData>| {
|
||||||
|
search_query.set(evt.value());
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "knowledge-table-wrapper",
|
||||||
|
table { class: "knowledge-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Name" }
|
||||||
|
th { "Type" }
|
||||||
|
th { "Size" }
|
||||||
|
th { "Chunks" }
|
||||||
|
th { "Uploaded" }
|
||||||
|
th { "Actions" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for file in filtered {
|
||||||
|
FileRow { key: "{file.id}", file, on_delete }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock knowledge base files.
|
||||||
|
fn mock_files() -> Vec<KnowledgeFile> {
|
||||||
|
vec![
|
||||||
|
KnowledgeFile {
|
||||||
|
id: "f1".into(),
|
||||||
|
name: "company-handbook.pdf".into(),
|
||||||
|
kind: FileKind::Pdf,
|
||||||
|
size_bytes: 2_450_000,
|
||||||
|
uploaded_at: "2026-02-15".into(),
|
||||||
|
chunk_count: 142,
|
||||||
|
},
|
||||||
|
KnowledgeFile {
|
||||||
|
id: "f2".into(),
|
||||||
|
name: "api-reference.md".into(),
|
||||||
|
kind: FileKind::Text,
|
||||||
|
size_bytes: 89_000,
|
||||||
|
uploaded_at: "2026-02-14".into(),
|
||||||
|
chunk_count: 34,
|
||||||
|
},
|
||||||
|
KnowledgeFile {
|
||||||
|
id: "f3".into(),
|
||||||
|
name: "sales-data-q4.csv".into(),
|
||||||
|
kind: FileKind::Spreadsheet,
|
||||||
|
size_bytes: 1_200_000,
|
||||||
|
uploaded_at: "2026-02-12".into(),
|
||||||
|
chunk_count: 67,
|
||||||
|
},
|
||||||
|
KnowledgeFile {
|
||||||
|
id: "f4".into(),
|
||||||
|
name: "deployment-guide.pdf".into(),
|
||||||
|
kind: FileKind::Pdf,
|
||||||
|
size_bytes: 540_000,
|
||||||
|
uploaded_at: "2026-02-10".into(),
|
||||||
|
chunk_count: 28,
|
||||||
|
},
|
||||||
|
KnowledgeFile {
|
||||||
|
id: "f5".into(),
|
||||||
|
name: "onboarding-checklist.md".into(),
|
||||||
|
kind: FileKind::Text,
|
||||||
|
size_bytes: 12_000,
|
||||||
|
uploaded_at: "2026-02-08".into(),
|
||||||
|
chunk_count: 8,
|
||||||
|
},
|
||||||
|
KnowledgeFile {
|
||||||
|
id: "f6".into(),
|
||||||
|
name: "architecture-diagram.png".into(),
|
||||||
|
kind: FileKind::Image,
|
||||||
|
size_bytes: 3_800_000,
|
||||||
|
uploaded_at: "2026-02-05".into(),
|
||||||
|
chunk_count: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
508
src/pages/landing.rs
Normal file
508
src/pages/landing.rs
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::{
|
||||||
|
BsArrowRight, BsGlobe2, BsKey, BsRobot, BsServer, BsShieldCheck,
|
||||||
|
};
|
||||||
|
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
/// Public landing page for the CERTifAI platform.
|
||||||
|
///
|
||||||
|
/// Displays a marketing-oriented page with hero section, feature grid,
|
||||||
|
/// how-it-works steps, and call-to-action banners. This page is accessible
|
||||||
|
/// without authentication.
|
||||||
|
#[component]
|
||||||
|
pub fn LandingPage() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "landing",
|
||||||
|
LandingNav {}
|
||||||
|
HeroSection {}
|
||||||
|
SocialProof {}
|
||||||
|
FeaturesGrid {}
|
||||||
|
HowItWorks {}
|
||||||
|
CtaBanner {}
|
||||||
|
LandingFooter {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
|
||||||
|
#[component]
|
||||||
|
fn LandingNav() -> Element {
|
||||||
|
rsx! {
|
||||||
|
nav { class: "landing-nav",
|
||||||
|
div { class: "landing-nav-inner",
|
||||||
|
Link { to: Route::LandingPage {}, class: "landing-logo",
|
||||||
|
span { class: "landing-logo-icon",
|
||||||
|
Icon { icon: BsShieldCheck, width: 24, height: 24 }
|
||||||
|
}
|
||||||
|
span { "CERTifAI" }
|
||||||
|
}
|
||||||
|
div { class: "landing-nav-links",
|
||||||
|
a { href: "#features", "Features" }
|
||||||
|
a { href: "#how-it-works", "How It Works" }
|
||||||
|
a { href: "#pricing", "Pricing" }
|
||||||
|
}
|
||||||
|
div { class: "landing-nav-actions",
|
||||||
|
Link {
|
||||||
|
to: Route::Login {
|
||||||
|
redirect_url: "/dashboard".into(),
|
||||||
|
},
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
"Log In"
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
to: Route::Login {
|
||||||
|
redirect_url: "/dashboard".into(),
|
||||||
|
},
|
||||||
|
class: "btn btn-primary btn-sm",
|
||||||
|
"Get Started"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hero section with headline, subtitle, and CTA buttons.
|
||||||
|
#[component]
|
||||||
|
fn HeroSection() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { class: "hero-section",
|
||||||
|
div { class: "hero-content",
|
||||||
|
div { class: "hero-badge badge badge-outline", "Privacy-First GenAI Infrastructure" }
|
||||||
|
h1 { class: "hero-title",
|
||||||
|
"Your AI. Your Data."
|
||||||
|
br {}
|
||||||
|
span { class: "hero-title-accent", "Your Infrastructure." }
|
||||||
|
}
|
||||||
|
p { class: "hero-subtitle",
|
||||||
|
"Self-hosted, GDPR-compliant generative AI platform for "
|
||||||
|
"enterprises that refuse to compromise on data sovereignty. "
|
||||||
|
"Deploy LLMs, agents, and MCP servers on your own terms."
|
||||||
|
}
|
||||||
|
div { class: "hero-actions",
|
||||||
|
Link {
|
||||||
|
to: Route::Login {
|
||||||
|
redirect_url: "/dashboard".into(),
|
||||||
|
},
|
||||||
|
class: "btn btn-primary btn-lg",
|
||||||
|
"Get Started"
|
||||||
|
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
||||||
|
}
|
||||||
|
a { href: "#features", class: "btn btn-outline btn-lg", "Learn More" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "hero-graphic",
|
||||||
|
// Abstract shield/network SVG motif
|
||||||
|
svg {
|
||||||
|
view_box: "0 0 400 400",
|
||||||
|
fill: "none",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
// Gradient definitions
|
||||||
|
defs {
|
||||||
|
linearGradient {
|
||||||
|
id: "grad1",
|
||||||
|
x1: "0%",
|
||||||
|
y1: "0%",
|
||||||
|
x2: "100%",
|
||||||
|
y2: "100%",
|
||||||
|
stop { offset: "0%", stop_color: "#91a4d2" }
|
||||||
|
stop { offset: "100%", stop_color: "#6d85c6" }
|
||||||
|
}
|
||||||
|
linearGradient {
|
||||||
|
id: "grad2",
|
||||||
|
x1: "0%",
|
||||||
|
y1: "100%",
|
||||||
|
x2: "100%",
|
||||||
|
y2: "0%",
|
||||||
|
stop { offset: "0%", stop_color: "#f97066" }
|
||||||
|
stop { offset: "100%", stop_color: "#f9a066" }
|
||||||
|
}
|
||||||
|
radialGradient {
|
||||||
|
id: "glow",
|
||||||
|
cx: "50%",
|
||||||
|
cy: "50%",
|
||||||
|
r: "50%",
|
||||||
|
stop {
|
||||||
|
offset: "0%",
|
||||||
|
stop_color: "rgba(145,164,210,0.3)",
|
||||||
|
}
|
||||||
|
stop {
|
||||||
|
offset: "100%",
|
||||||
|
stop_color: "rgba(145,164,210,0)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Background glow
|
||||||
|
circle {
|
||||||
|
cx: "200",
|
||||||
|
cy: "200",
|
||||||
|
r: "180",
|
||||||
|
fill: "url(#glow)",
|
||||||
|
}
|
||||||
|
// Shield outline
|
||||||
|
path {
|
||||||
|
d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \
|
||||||
|
C130 360 60 300 60 230 L60 110 Z",
|
||||||
|
stroke: "url(#grad1)",
|
||||||
|
stroke_width: "2",
|
||||||
|
fill: "none",
|
||||||
|
opacity: "0.6",
|
||||||
|
}
|
||||||
|
// Inner shield
|
||||||
|
path {
|
||||||
|
d: "M200 80 L310 135 L310 225 C310 280 255 330 200 345 \
|
||||||
|
C145 330 90 280 90 225 L90 135 Z",
|
||||||
|
stroke: "url(#grad1)",
|
||||||
|
stroke_width: "1.5",
|
||||||
|
fill: "rgba(145,164,210,0.05)",
|
||||||
|
opacity: "0.8",
|
||||||
|
}
|
||||||
|
// Network nodes
|
||||||
|
circle {
|
||||||
|
cx: "200",
|
||||||
|
cy: "180",
|
||||||
|
r: "8",
|
||||||
|
fill: "url(#grad1)",
|
||||||
|
}
|
||||||
|
circle {
|
||||||
|
cx: "150",
|
||||||
|
cy: "230",
|
||||||
|
r: "6",
|
||||||
|
fill: "url(#grad2)",
|
||||||
|
}
|
||||||
|
circle {
|
||||||
|
cx: "250",
|
||||||
|
cy: "230",
|
||||||
|
r: "6",
|
||||||
|
fill: "url(#grad2)",
|
||||||
|
}
|
||||||
|
circle {
|
||||||
|
cx: "200",
|
||||||
|
cy: "280",
|
||||||
|
r: "6",
|
||||||
|
fill: "url(#grad1)",
|
||||||
|
}
|
||||||
|
circle {
|
||||||
|
cx: "130",
|
||||||
|
cy: "170",
|
||||||
|
r: "4",
|
||||||
|
fill: "#91a4d2",
|
||||||
|
opacity: "0.6",
|
||||||
|
}
|
||||||
|
circle {
|
||||||
|
cx: "270",
|
||||||
|
cy: "170",
|
||||||
|
r: "4",
|
||||||
|
fill: "#91a4d2",
|
||||||
|
opacity: "0.6",
|
||||||
|
}
|
||||||
|
// Network connections
|
||||||
|
line {
|
||||||
|
x1: "200",
|
||||||
|
y1: "180",
|
||||||
|
x2: "150",
|
||||||
|
y2: "230",
|
||||||
|
stroke: "#91a4d2",
|
||||||
|
stroke_width: "1",
|
||||||
|
opacity: "0.4",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "200",
|
||||||
|
y1: "180",
|
||||||
|
x2: "250",
|
||||||
|
y2: "230",
|
||||||
|
stroke: "#91a4d2",
|
||||||
|
stroke_width: "1",
|
||||||
|
opacity: "0.4",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "150",
|
||||||
|
y1: "230",
|
||||||
|
x2: "200",
|
||||||
|
y2: "280",
|
||||||
|
stroke: "#91a4d2",
|
||||||
|
stroke_width: "1",
|
||||||
|
opacity: "0.4",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "250",
|
||||||
|
y1: "230",
|
||||||
|
x2: "200",
|
||||||
|
y2: "280",
|
||||||
|
stroke: "#91a4d2",
|
||||||
|
stroke_width: "1",
|
||||||
|
opacity: "0.4",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "200",
|
||||||
|
y1: "180",
|
||||||
|
x2: "130",
|
||||||
|
y2: "170",
|
||||||
|
stroke: "#91a4d2",
|
||||||
|
stroke_width: "1",
|
||||||
|
opacity: "0.3",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "200",
|
||||||
|
y1: "180",
|
||||||
|
x2: "270",
|
||||||
|
y2: "170",
|
||||||
|
stroke: "#91a4d2",
|
||||||
|
stroke_width: "1",
|
||||||
|
opacity: "0.3",
|
||||||
|
}
|
||||||
|
// Checkmark inside shield center
|
||||||
|
path {
|
||||||
|
d: "M180 200 L195 215 L225 185",
|
||||||
|
stroke: "url(#grad1)",
|
||||||
|
stroke_width: "3",
|
||||||
|
stroke_linecap: "round",
|
||||||
|
stroke_linejoin: "round",
|
||||||
|
fill: "none",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Social proof / trust indicator strip.
|
||||||
|
#[component]
|
||||||
|
fn SocialProof() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { class: "social-proof",
|
||||||
|
p { class: "social-proof-text",
|
||||||
|
"Built for enterprises that value "
|
||||||
|
span { class: "social-proof-highlight", "data sovereignty" }
|
||||||
|
}
|
||||||
|
div { class: "social-proof-stats",
|
||||||
|
div { class: "proof-stat",
|
||||||
|
span { class: "proof-stat-value", "100%" }
|
||||||
|
span { class: "proof-stat-label", "On-Premise" }
|
||||||
|
}
|
||||||
|
div { class: "proof-divider" }
|
||||||
|
div { class: "proof-stat",
|
||||||
|
span { class: "proof-stat-value", "GDPR" }
|
||||||
|
span { class: "proof-stat-label", "Compliant" }
|
||||||
|
}
|
||||||
|
div { class: "proof-divider" }
|
||||||
|
div { class: "proof-stat",
|
||||||
|
span { class: "proof-stat-value", "EU" }
|
||||||
|
span { class: "proof-stat-label", "Data Residency" }
|
||||||
|
}
|
||||||
|
div { class: "proof-divider" }
|
||||||
|
div { class: "proof-stat",
|
||||||
|
span { class: "proof-stat-value", "Zero" }
|
||||||
|
span { class: "proof-stat-label", "Third-Party Sharing" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feature cards grid section.
|
||||||
|
#[component]
|
||||||
|
fn FeaturesGrid() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { id: "features", class: "features-section",
|
||||||
|
h2 { class: "section-title", "Everything You Need" }
|
||||||
|
p { class: "section-subtitle",
|
||||||
|
"A complete, self-hosted GenAI stack under your full control."
|
||||||
|
}
|
||||||
|
div { class: "features-grid",
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! {
|
||||||
|
Icon { icon: BsServer, width: 28, height: 28 }
|
||||||
|
},
|
||||||
|
title: "Self-Hosted Infrastructure",
|
||||||
|
description: "Deploy on your own hardware or private cloud. \
|
||||||
|
Full control over your AI stack with no external dependencies.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! {
|
||||||
|
Icon { icon: BsShieldCheck, width: 28, height: 28 }
|
||||||
|
},
|
||||||
|
title: "GDPR Compliant",
|
||||||
|
description: "EU data residency guaranteed. Your data never \
|
||||||
|
leaves your infrastructure or gets shared with third parties.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! {
|
||||||
|
Icon { icon: FaCubes, width: 28, height: 28 }
|
||||||
|
},
|
||||||
|
title: "LLM Management",
|
||||||
|
description: "Deploy, monitor, and manage multiple language \
|
||||||
|
models. Switch between models with zero downtime.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! {
|
||||||
|
Icon { icon: BsRobot, width: 28, height: 28 }
|
||||||
|
},
|
||||||
|
title: "Agent Builder",
|
||||||
|
description: "Create custom AI agents with integrated Langchain \
|
||||||
|
and Langfuse for full observability and control.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! {
|
||||||
|
Icon { icon: BsGlobe2, width: 28, height: 28 }
|
||||||
|
},
|
||||||
|
title: "MCP Server Management",
|
||||||
|
description: "Manage Model Context Protocol servers to extend \
|
||||||
|
your AI capabilities with external tool integrations.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! {
|
||||||
|
Icon { icon: BsKey, width: 28, height: 28 }
|
||||||
|
},
|
||||||
|
title: "API Key Management",
|
||||||
|
description: "Generate API keys, track usage per seat, and \
|
||||||
|
set fine-grained permissions for every integration.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual feature card.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `icon` - The icon element to display
|
||||||
|
/// * `title` - Feature title
|
||||||
|
/// * `description` - Feature description text
|
||||||
|
#[component]
|
||||||
|
fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "card feature-card",
|
||||||
|
div { class: "feature-card-icon", {icon} }
|
||||||
|
h3 { class: "feature-card-title", "{title}" }
|
||||||
|
p { class: "feature-card-desc", "{description}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three-step "How It Works" section.
|
||||||
|
#[component]
|
||||||
|
fn HowItWorks() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { id: "how-it-works", class: "how-it-works-section",
|
||||||
|
h2 { class: "section-title", "Up and Running in Minutes" }
|
||||||
|
p { class: "section-subtitle", "Three steps to sovereign AI infrastructure." }
|
||||||
|
div { class: "steps-grid",
|
||||||
|
StepCard {
|
||||||
|
number: "01",
|
||||||
|
title: "Deploy",
|
||||||
|
description: "Install CERTifAI on your infrastructure \
|
||||||
|
with a single command. Supports Docker, Kubernetes, \
|
||||||
|
and bare metal.",
|
||||||
|
}
|
||||||
|
StepCard {
|
||||||
|
number: "02",
|
||||||
|
title: "Configure",
|
||||||
|
description: "Connect your identity provider, select \
|
||||||
|
your models, and set up team permissions through \
|
||||||
|
the admin dashboard.",
|
||||||
|
}
|
||||||
|
StepCard {
|
||||||
|
number: "03",
|
||||||
|
title: "Scale",
|
||||||
|
description: "Add users, deploy more models, and \
|
||||||
|
integrate with your existing tools via API keys \
|
||||||
|
and MCP servers.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual step card.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `number` - Step number string (e.g. "01")
|
||||||
|
/// * `title` - Step title
|
||||||
|
/// * `description` - Step description text
|
||||||
|
#[component]
|
||||||
|
fn StepCard(number: &'static str, title: &'static str, description: &'static str) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "step-card",
|
||||||
|
span { class: "step-number", "{number}" }
|
||||||
|
h3 { class: "step-title", "{title}" }
|
||||||
|
p { class: "step-desc", "{description}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call-to-action banner before the footer.
|
||||||
|
#[component]
|
||||||
|
fn CtaBanner() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { class: "cta-banner",
|
||||||
|
h2 { class: "cta-title", "Ready to take control of your AI infrastructure?" }
|
||||||
|
p { class: "cta-subtitle",
|
||||||
|
"Start deploying sovereign GenAI today. No credit card required."
|
||||||
|
}
|
||||||
|
div { class: "cta-actions",
|
||||||
|
Link {
|
||||||
|
to: Route::Login {
|
||||||
|
redirect_url: "/dashboard".into(),
|
||||||
|
},
|
||||||
|
class: "btn btn-primary btn-lg",
|
||||||
|
"Get Started Free"
|
||||||
|
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
to: Route::Login {
|
||||||
|
redirect_url: "/dashboard".into(),
|
||||||
|
},
|
||||||
|
class: "btn btn-outline btn-lg",
|
||||||
|
"Log In"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Landing page footer with links and copyright.
|
||||||
|
#[component]
|
||||||
|
fn LandingFooter() -> Element {
|
||||||
|
rsx! {
|
||||||
|
footer { class: "landing-footer",
|
||||||
|
div { class: "landing-footer-inner",
|
||||||
|
div { class: "footer-brand",
|
||||||
|
div { class: "landing-logo",
|
||||||
|
span { class: "landing-logo-icon",
|
||||||
|
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
||||||
|
}
|
||||||
|
span { "CERTifAI" }
|
||||||
|
}
|
||||||
|
p { class: "footer-tagline", "Sovereign GenAI infrastructure for enterprises." }
|
||||||
|
}
|
||||||
|
div { class: "footer-links-group",
|
||||||
|
h4 { class: "footer-links-heading", "Product" }
|
||||||
|
a { href: "#features", "Features" }
|
||||||
|
a { href: "#how-it-works", "How It Works" }
|
||||||
|
a { href: "#pricing", "Pricing" }
|
||||||
|
}
|
||||||
|
div { class: "footer-links-group",
|
||||||
|
h4 { class: "footer-links-heading", "Legal" }
|
||||||
|
Link { to: Route::ImpressumPage {}, "Impressum" }
|
||||||
|
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
|
||||||
|
}
|
||||||
|
div { class: "footer-links-group",
|
||||||
|
h4 { class: "footer-links-heading", "Resources" }
|
||||||
|
a { href: "#", "Documentation" }
|
||||||
|
a { href: "#", "API Reference" }
|
||||||
|
a { href: "#", "Support" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "footer-bottom",
|
||||||
|
p { "2026 CERTifAI. All rights reserved." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,21 @@
|
|||||||
mod overview;
|
mod chat;
|
||||||
pub use overview::*;
|
mod dashboard;
|
||||||
|
pub mod developer;
|
||||||
|
mod impressum;
|
||||||
|
mod knowledge;
|
||||||
|
mod landing;
|
||||||
|
pub mod organization;
|
||||||
|
mod privacy;
|
||||||
|
mod providers;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
|
pub use chat::*;
|
||||||
|
pub use dashboard::*;
|
||||||
|
pub use developer::*;
|
||||||
|
pub use impressum::*;
|
||||||
|
pub use knowledge::*;
|
||||||
|
pub use landing::*;
|
||||||
|
pub use organization::*;
|
||||||
|
pub use privacy::*;
|
||||||
|
pub use providers::*;
|
||||||
|
pub use tools::*;
|
||||||
|
|||||||
170
src/pages/organization/dashboard.rs
Normal file
170
src/pages/organization/dashboard.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::{MemberRow, PageHeader};
|
||||||
|
use crate::models::{BillingUsage, MemberRole, OrgMember};
|
||||||
|
|
||||||
|
/// Organization dashboard with billing stats, member table, and invite modal.
|
||||||
|
///
|
||||||
|
/// Shows current billing usage, a table of organization members
|
||||||
|
/// with role management, and a button to invite new members.
|
||||||
|
#[component]
|
||||||
|
pub fn OrgDashboardPage() -> Element {
|
||||||
|
let members = use_signal(mock_members);
|
||||||
|
let usage = mock_usage();
|
||||||
|
let mut show_invite = use_signal(|| false);
|
||||||
|
let mut invite_email = use_signal(String::new);
|
||||||
|
|
||||||
|
let members_list = members.read().clone();
|
||||||
|
|
||||||
|
// Format token counts for display
|
||||||
|
let tokens_display = format_tokens(usage.tokens_used);
|
||||||
|
let tokens_limit_display = format_tokens(usage.tokens_limit);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
section { class: "org-dashboard-page",
|
||||||
|
PageHeader {
|
||||||
|
title: "Organization".to_string(),
|
||||||
|
subtitle: "Manage members and billing".to_string(),
|
||||||
|
actions: rsx! {
|
||||||
|
button { class: "btn-primary", onclick: move |_| show_invite.set(true), "Invite Member" }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats bar
|
||||||
|
div { class: "org-stats-bar",
|
||||||
|
div { class: "org-stat",
|
||||||
|
span { class: "org-stat-value", "{usage.seats_used}/{usage.seats_total}" }
|
||||||
|
span { class: "org-stat-label", "Seats Used" }
|
||||||
|
}
|
||||||
|
div { class: "org-stat",
|
||||||
|
span { class: "org-stat-value", "{tokens_display}" }
|
||||||
|
span { class: "org-stat-label", "of {tokens_limit_display} tokens" }
|
||||||
|
}
|
||||||
|
div { class: "org-stat",
|
||||||
|
span { class: "org-stat-value", "{usage.billing_cycle_end}" }
|
||||||
|
span { class: "org-stat-label", "Cycle Ends" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Members table
|
||||||
|
div { class: "org-table-wrapper",
|
||||||
|
table { class: "org-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Name" }
|
||||||
|
th { "Email" }
|
||||||
|
th { "Role" }
|
||||||
|
th { "Joined" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for member in members_list {
|
||||||
|
MemberRow {
|
||||||
|
key: "{member.id}",
|
||||||
|
member,
|
||||||
|
on_role_change: move |_| {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite modal
|
||||||
|
if *show_invite.read() {
|
||||||
|
div {
|
||||||
|
class: "modal-overlay",
|
||||||
|
onclick: move |_| show_invite.set(false),
|
||||||
|
div {
|
||||||
|
class: "modal-content",
|
||||||
|
// Prevent clicks inside modal from closing it
|
||||||
|
onclick: move |evt: Event<MouseData>| evt.stop_propagation(),
|
||||||
|
h3 { "Invite New Member" }
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Email Address" }
|
||||||
|
input {
|
||||||
|
class: "form-input",
|
||||||
|
r#type: "email",
|
||||||
|
placeholder: "colleague@company.com",
|
||||||
|
value: "{invite_email}",
|
||||||
|
oninput: move |evt: Event<FormData>| {
|
||||||
|
invite_email.set(evt.value());
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn-secondary",
|
||||||
|
onclick: move |_| show_invite.set(false),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn-primary",
|
||||||
|
onclick: move |_| show_invite.set(false),
|
||||||
|
"Send Invite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a token count into a human-readable string (e.g. "1.2M").
|
||||||
|
fn format_tokens(count: u64) -> String {
|
||||||
|
const M: u64 = 1_000_000;
|
||||||
|
const K: u64 = 1_000;
|
||||||
|
|
||||||
|
if count >= M {
|
||||||
|
format!("{:.1}M", count as f64 / M as f64)
|
||||||
|
} else if count >= K {
|
||||||
|
format!("{:.0}K", count as f64 / K as f64)
|
||||||
|
} else {
|
||||||
|
count.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock organization members.
|
||||||
|
fn mock_members() -> Vec<OrgMember> {
|
||||||
|
vec![
|
||||||
|
OrgMember {
|
||||||
|
id: "m1".into(),
|
||||||
|
name: "Max Mustermann".into(),
|
||||||
|
email: "max@example.com".into(),
|
||||||
|
role: MemberRole::Admin,
|
||||||
|
joined_at: "2026-01-10".into(),
|
||||||
|
},
|
||||||
|
OrgMember {
|
||||||
|
id: "m2".into(),
|
||||||
|
name: "Erika Musterfrau".into(),
|
||||||
|
email: "erika@example.com".into(),
|
||||||
|
role: MemberRole::Member,
|
||||||
|
joined_at: "2026-01-15".into(),
|
||||||
|
},
|
||||||
|
OrgMember {
|
||||||
|
id: "m3".into(),
|
||||||
|
name: "Johann Schmidt".into(),
|
||||||
|
email: "johann@example.com".into(),
|
||||||
|
role: MemberRole::Member,
|
||||||
|
joined_at: "2026-02-01".into(),
|
||||||
|
},
|
||||||
|
OrgMember {
|
||||||
|
id: "m4".into(),
|
||||||
|
name: "Anna Weber".into(),
|
||||||
|
email: "anna@example.com".into(),
|
||||||
|
role: MemberRole::Viewer,
|
||||||
|
joined_at: "2026-02-10".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock billing usage data.
|
||||||
|
fn mock_usage() -> BillingUsage {
|
||||||
|
BillingUsage {
|
||||||
|
seats_used: 4,
|
||||||
|
seats_total: 25,
|
||||||
|
tokens_used: 847_000,
|
||||||
|
tokens_limit: 1_000_000,
|
||||||
|
billing_cycle_end: "2026-03-01".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/pages/organization/mod.rs
Normal file
35
src/pages/organization/mod.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
mod dashboard;
|
||||||
|
mod pricing;
|
||||||
|
|
||||||
|
pub use dashboard::*;
|
||||||
|
pub use pricing::*;
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::app::Route;
|
||||||
|
use crate::components::sub_nav::{SubNav, SubNavItem};
|
||||||
|
|
||||||
|
/// Shell layout for the Organization section.
|
||||||
|
///
|
||||||
|
/// Renders a horizontal tab bar (Pricing, Dashboard) above
|
||||||
|
/// the child route outlet. Sits inside the main `AppShell` layout.
|
||||||
|
#[component]
|
||||||
|
pub fn OrgShell() -> Element {
|
||||||
|
let tabs = vec![
|
||||||
|
SubNavItem {
|
||||||
|
label: "Pricing",
|
||||||
|
route: Route::OrgPricingPage {},
|
||||||
|
},
|
||||||
|
SubNavItem {
|
||||||
|
label: "Dashboard",
|
||||||
|
route: Route::OrgDashboardPage {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "org-shell",
|
||||||
|
SubNav { items: tabs }
|
||||||
|
div { class: "shell-content", Outlet::<Route> {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/pages/organization/pricing.rs
Normal file
88
src/pages/organization/pricing.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::app::Route;
|
||||||
|
use crate::components::{PageHeader, PricingCard};
|
||||||
|
use crate::models::PricingPlan;
|
||||||
|
|
||||||
|
/// Organization pricing page displaying three plan tiers.
|
||||||
|
///
|
||||||
|
/// Clicking "Get Started" on any plan navigates to the
|
||||||
|
/// organization dashboard.
|
||||||
|
#[component]
|
||||||
|
pub fn OrgPricingPage() -> Element {
|
||||||
|
let navigator = use_navigator();
|
||||||
|
let plans = mock_plans();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
section { class: "pricing-page",
|
||||||
|
PageHeader {
|
||||||
|
title: "Pricing".to_string(),
|
||||||
|
subtitle: "Choose the plan that fits your organization".to_string(),
|
||||||
|
}
|
||||||
|
div { class: "pricing-grid",
|
||||||
|
for plan in plans {
|
||||||
|
PricingCard {
|
||||||
|
key: "{plan.id}",
|
||||||
|
plan,
|
||||||
|
on_select: move |_| {
|
||||||
|
navigator.push(Route::OrgDashboardPage {});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock pricing plans.
|
||||||
|
fn mock_plans() -> Vec<PricingPlan> {
|
||||||
|
vec![
|
||||||
|
PricingPlan {
|
||||||
|
id: "starter".into(),
|
||||||
|
name: "Starter".into(),
|
||||||
|
price_eur: 49,
|
||||||
|
features: vec![
|
||||||
|
"Up to 5 users".into(),
|
||||||
|
"1 LLM provider".into(),
|
||||||
|
"100K tokens/month".into(),
|
||||||
|
"Community support".into(),
|
||||||
|
"Basic analytics".into(),
|
||||||
|
],
|
||||||
|
highlighted: false,
|
||||||
|
max_seats: Some(5),
|
||||||
|
},
|
||||||
|
PricingPlan {
|
||||||
|
id: "team".into(),
|
||||||
|
name: "Team".into(),
|
||||||
|
price_eur: 199,
|
||||||
|
features: vec![
|
||||||
|
"Up to 25 users".into(),
|
||||||
|
"All LLM providers".into(),
|
||||||
|
"1M tokens/month".into(),
|
||||||
|
"Priority support".into(),
|
||||||
|
"Advanced analytics".into(),
|
||||||
|
"Custom MCP tools".into(),
|
||||||
|
"SSO integration".into(),
|
||||||
|
],
|
||||||
|
highlighted: true,
|
||||||
|
max_seats: Some(25),
|
||||||
|
},
|
||||||
|
PricingPlan {
|
||||||
|
id: "enterprise".into(),
|
||||||
|
name: "Enterprise".into(),
|
||||||
|
price_eur: 499,
|
||||||
|
features: vec![
|
||||||
|
"Unlimited users".into(),
|
||||||
|
"All LLM providers".into(),
|
||||||
|
"Unlimited tokens".into(),
|
||||||
|
"Dedicated support".into(),
|
||||||
|
"Full observability".into(),
|
||||||
|
"Custom integrations".into(),
|
||||||
|
"SLA guarantee".into(),
|
||||||
|
"On-premise deployment".into(),
|
||||||
|
],
|
||||||
|
highlighted: false,
|
||||||
|
max_seats: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_free_icons::icons::bs_icons::BsBook;
|
|
||||||
use dioxus_free_icons::icons::fa_solid_icons::{FaChartLine, FaCubes, FaGears};
|
|
||||||
use dioxus_free_icons::Icon;
|
|
||||||
|
|
||||||
use crate::components::DashboardCard;
|
|
||||||
use crate::Route;
|
|
||||||
|
|
||||||
/// Overview dashboard page rendered inside the `AppShell` layout.
|
|
||||||
///
|
|
||||||
/// Displays a welcome heading and a grid of quick-access cards
|
|
||||||
/// for the main GenAI platform tools.
|
|
||||||
#[component]
|
|
||||||
pub fn OverviewPage() -> Element {
|
|
||||||
// Check authentication status on mount via a server function.
|
|
||||||
let auth_check = use_resource(check_auth);
|
|
||||||
let navigator = use_navigator();
|
|
||||||
|
|
||||||
// Once the server responds, redirect unauthenticated users to /auth.
|
|
||||||
use_effect(move || {
|
|
||||||
if let Some(Ok(false)) = auth_check() {
|
|
||||||
navigator.push(NavigationTarget::<Route>::External(
|
|
||||||
"/auth?redirect_url=/".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
match auth_check() {
|
|
||||||
// Still waiting for the server to respond.
|
|
||||||
None => rsx! {},
|
|
||||||
// Not authenticated -- render nothing while the redirect fires.
|
|
||||||
Some(Ok(false)) => rsx! {},
|
|
||||||
// Authenticated -- render the overview dashboard.
|
|
||||||
Some(Ok(true)) => rsx! {
|
|
||||||
section { class: "overview-page",
|
|
||||||
h1 { class: "overview-heading", "GenAI Dashboard" }
|
|
||||||
div { class: "dashboard-grid",
|
|
||||||
DashboardCard {
|
|
||||||
title: "Documentation".to_string(),
|
|
||||||
description: "Guides & API Reference".to_string(),
|
|
||||||
href: "#".to_string(),
|
|
||||||
icon: rsx! {
|
|
||||||
Icon { icon: BsBook, width: 28, height: 28 }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
DashboardCard {
|
|
||||||
title: "Langfuse".to_string(),
|
|
||||||
description: "Observability & Analytics".to_string(),
|
|
||||||
href: "#".to_string(),
|
|
||||||
icon: rsx! {
|
|
||||||
Icon { icon: FaChartLine, width: 28, height: 28 }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
DashboardCard {
|
|
||||||
title: "Langchain".to_string(),
|
|
||||||
description: "Agent Framework".to_string(),
|
|
||||||
href: "#".to_string(),
|
|
||||||
icon: rsx! {
|
|
||||||
Icon { icon: FaGears, width: 28, height: 28 }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
DashboardCard {
|
|
||||||
title: "Hugging Face".to_string(),
|
|
||||||
description: "Browse Models".to_string(),
|
|
||||||
href: "#".to_string(),
|
|
||||||
icon: rsx! {
|
|
||||||
Icon { icon: FaCubes, width: 28, height: 28 }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Server error -- surface it so it is not silently swallowed.
|
|
||||||
Some(Err(err)) => rsx! {
|
|
||||||
p { "Error: {err}" }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether the current request has an active logged-in session.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// `true` if the session contains a logged-in user, `false` otherwise.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `ServerFnError` if the session cannot be extracted from the request.
|
|
||||||
#[server]
|
|
||||||
async fn check_auth() -> Result<bool, ServerFnError> {
|
|
||||||
use crate::infrastructure::{UserStateInner, LOGGED_IN_USER_SESS_KEY};
|
|
||||||
use tower_sessions::Session;
|
|
||||||
|
|
||||||
// Extract the tower_sessions::Session from the Axum request.
|
|
||||||
let session: Session = FullstackContext::extract().await?;
|
|
||||||
let user: Option<UserStateInner> = session
|
|
||||||
.get(LOGGED_IN_USER_SESS_KEY)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
|
||||||
|
|
||||||
Ok(user.is_some())
|
|
||||||
}
|
|
||||||
106
src/pages/privacy.rs
Normal file
106
src/pages/privacy.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
/// Privacy Policy page.
|
||||||
|
///
|
||||||
|
/// Displays the platform's privacy policy. Publicly accessible
|
||||||
|
/// without authentication.
|
||||||
|
#[component]
|
||||||
|
pub fn PrivacyPage() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "legal-page",
|
||||||
|
nav { class: "legal-nav",
|
||||||
|
Link { to: Route::LandingPage {}, class: "landing-logo",
|
||||||
|
span { class: "landing-logo-icon",
|
||||||
|
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
||||||
|
}
|
||||||
|
span { "CERTifAI" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main { class: "legal-content",
|
||||||
|
h1 { "Privacy Policy" }
|
||||||
|
p { class: "legal-updated", "Last updated: February 2026" }
|
||||||
|
|
||||||
|
h2 { "1. Introduction" }
|
||||||
|
p {
|
||||||
|
"CERTifAI GmbH (\"we\", \"our\", \"us\") is committed to "
|
||||||
|
"protecting your personal data. This privacy policy explains "
|
||||||
|
"how we collect, use, and safeguard your information when you "
|
||||||
|
"use our platform."
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "2. Data Controller" }
|
||||||
|
p {
|
||||||
|
"CERTifAI GmbH"
|
||||||
|
br {}
|
||||||
|
"Musterstrasse 1, 10115 Berlin, Germany"
|
||||||
|
br {}
|
||||||
|
"Email: privacy@certifai.example"
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "3. Data We Collect" }
|
||||||
|
p {
|
||||||
|
"We collect only the minimum data necessary to provide "
|
||||||
|
"our services:"
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
li {
|
||||||
|
strong { "Account data: " }
|
||||||
|
"Name, email address, and organization details "
|
||||||
|
"provided during registration."
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
strong { "Usage data: " }
|
||||||
|
"API call logs, token counts, and feature usage "
|
||||||
|
"metrics for billing and analytics."
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
strong { "Technical data: " }
|
||||||
|
"IP addresses, browser type, and session identifiers "
|
||||||
|
"for security and platform stability."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "4. How We Use Your Data" }
|
||||||
|
ul {
|
||||||
|
li { "To provide and maintain the CERTifAI platform" }
|
||||||
|
li { "To manage your account and subscription" }
|
||||||
|
li { "To communicate service updates and security notices" }
|
||||||
|
li { "To comply with legal obligations" }
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "5. Data Storage and Sovereignty" }
|
||||||
|
p {
|
||||||
|
"CERTifAI is a self-hosted platform. All AI workloads, "
|
||||||
|
"model data, and inference results remain entirely within "
|
||||||
|
"your own infrastructure. We do not access, store, or "
|
||||||
|
"process your AI data on our servers."
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "6. Your Rights (GDPR)" }
|
||||||
|
p { "Under the GDPR, you have the right to:" }
|
||||||
|
ul {
|
||||||
|
li { "Access your personal data" }
|
||||||
|
li { "Rectify inaccurate data" }
|
||||||
|
li { "Request erasure of your data" }
|
||||||
|
li { "Restrict or object to processing" }
|
||||||
|
li { "Data portability" }
|
||||||
|
li { "Lodge a complaint with a supervisory authority" }
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "7. Contact" }
|
||||||
|
p {
|
||||||
|
"For privacy-related inquiries, contact us at "
|
||||||
|
"privacy@certifai.example."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer { class: "legal-footer",
|
||||||
|
Link { to: Route::LandingPage {}, "Back to Home" }
|
||||||
|
Link { to: Route::ImpressumPage {}, "Impressum" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/pages/providers.rs
Normal file
221
src/pages/providers.rs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::PageHeader;
|
||||||
|
use crate::models::{EmbeddingEntry, LlmProvider, ModelEntry, ProviderConfig};
|
||||||
|
|
||||||
|
/// Providers page for configuring LLM and embedding model backends.
|
||||||
|
///
|
||||||
|
/// Two-column layout: left side has a configuration form, right side
|
||||||
|
/// shows the currently active provider status.
|
||||||
|
#[component]
|
||||||
|
pub fn ProvidersPage() -> Element {
|
||||||
|
let mut selected_provider = use_signal(|| LlmProvider::Ollama);
|
||||||
|
let mut selected_model = use_signal(|| "llama3.1:8b".to_string());
|
||||||
|
let mut selected_embedding = use_signal(|| "nomic-embed-text".to_string());
|
||||||
|
let mut api_key = use_signal(String::new);
|
||||||
|
let mut saved = use_signal(|| false);
|
||||||
|
|
||||||
|
let models = mock_models();
|
||||||
|
let embeddings = mock_embeddings();
|
||||||
|
|
||||||
|
// Filter models/embeddings by selected provider
|
||||||
|
let provider_val = selected_provider.read().clone();
|
||||||
|
let available_models: Vec<_> = models
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.provider == provider_val)
|
||||||
|
.collect();
|
||||||
|
let available_embeddings: Vec<_> = embeddings
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.provider == provider_val)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let active_config = ProviderConfig {
|
||||||
|
provider: provider_val.clone(),
|
||||||
|
selected_model: selected_model.read().clone(),
|
||||||
|
selected_embedding: selected_embedding.read().clone(),
|
||||||
|
api_key_set: !api_key.read().is_empty(),
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
section { class: "providers-page",
|
||||||
|
PageHeader {
|
||||||
|
title: "Providers".to_string(),
|
||||||
|
subtitle: "Configure your LLM and embedding backends".to_string(),
|
||||||
|
}
|
||||||
|
div { class: "providers-layout",
|
||||||
|
div { class: "providers-form",
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Provider" }
|
||||||
|
select {
|
||||||
|
class: "form-select",
|
||||||
|
value: "{provider_val.label()}",
|
||||||
|
onchange: move |evt: Event<FormData>| {
|
||||||
|
let val = evt.value();
|
||||||
|
let prov = match val.as_str() {
|
||||||
|
"Hugging Face" => LlmProvider::HuggingFace,
|
||||||
|
"OpenAI" => LlmProvider::OpenAi,
|
||||||
|
"Anthropic" => LlmProvider::Anthropic,
|
||||||
|
_ => LlmProvider::Ollama,
|
||||||
|
};
|
||||||
|
selected_provider.set(prov);
|
||||||
|
saved.set(false);
|
||||||
|
},
|
||||||
|
option { value: "Ollama", "Ollama" }
|
||||||
|
option { value: "Hugging Face", "Hugging Face" }
|
||||||
|
option { value: "OpenAI", "OpenAI" }
|
||||||
|
option { value: "Anthropic", "Anthropic" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Model" }
|
||||||
|
select {
|
||||||
|
class: "form-select",
|
||||||
|
value: "{selected_model}",
|
||||||
|
onchange: move |evt: Event<FormData>| {
|
||||||
|
selected_model.set(evt.value());
|
||||||
|
saved.set(false);
|
||||||
|
},
|
||||||
|
for m in &available_models {
|
||||||
|
option { value: "{m.id}", "{m.name} ({m.context_window}k ctx)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Embedding Model" }
|
||||||
|
select {
|
||||||
|
class: "form-select",
|
||||||
|
value: "{selected_embedding}",
|
||||||
|
onchange: move |evt: Event<FormData>| {
|
||||||
|
selected_embedding.set(evt.value());
|
||||||
|
saved.set(false);
|
||||||
|
},
|
||||||
|
for e in &available_embeddings {
|
||||||
|
option { value: "{e.id}", "{e.name} ({e.dimensions}d)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "API Key" }
|
||||||
|
input {
|
||||||
|
class: "form-input",
|
||||||
|
r#type: "password",
|
||||||
|
placeholder: "Enter API key...",
|
||||||
|
value: "{api_key}",
|
||||||
|
oninput: move |evt: Event<FormData>| {
|
||||||
|
api_key.set(evt.value());
|
||||||
|
saved.set(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn-primary",
|
||||||
|
onclick: move |_| saved.set(true),
|
||||||
|
"Save Configuration"
|
||||||
|
}
|
||||||
|
if *saved.read() {
|
||||||
|
p { class: "form-success", "Configuration saved." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "providers-status",
|
||||||
|
h3 { "Active Configuration" }
|
||||||
|
div { class: "status-card",
|
||||||
|
div { class: "status-row",
|
||||||
|
span { class: "status-label", "Provider" }
|
||||||
|
span { class: "status-value", "{active_config.provider.label()}" }
|
||||||
|
}
|
||||||
|
div { class: "status-row",
|
||||||
|
span { class: "status-label", "Model" }
|
||||||
|
span { class: "status-value", "{active_config.selected_model}" }
|
||||||
|
}
|
||||||
|
div { class: "status-row",
|
||||||
|
span { class: "status-label", "Embedding" }
|
||||||
|
span { class: "status-value", "{active_config.selected_embedding}" }
|
||||||
|
}
|
||||||
|
div { class: "status-row",
|
||||||
|
span { class: "status-label", "API Key" }
|
||||||
|
span { class: "status-value",
|
||||||
|
if active_config.api_key_set {
|
||||||
|
"Set"
|
||||||
|
} else {
|
||||||
|
"Not set"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock model entries for all providers.
|
||||||
|
fn mock_models() -> Vec<ModelEntry> {
|
||||||
|
vec![
|
||||||
|
ModelEntry {
|
||||||
|
id: "llama3.1:8b".into(),
|
||||||
|
name: "Llama 3.1 8B".into(),
|
||||||
|
provider: LlmProvider::Ollama,
|
||||||
|
context_window: 128,
|
||||||
|
},
|
||||||
|
ModelEntry {
|
||||||
|
id: "llama3.1:70b".into(),
|
||||||
|
name: "Llama 3.1 70B".into(),
|
||||||
|
provider: LlmProvider::Ollama,
|
||||||
|
context_window: 128,
|
||||||
|
},
|
||||||
|
ModelEntry {
|
||||||
|
id: "mistral:7b".into(),
|
||||||
|
name: "Mistral 7B".into(),
|
||||||
|
provider: LlmProvider::Ollama,
|
||||||
|
context_window: 32,
|
||||||
|
},
|
||||||
|
ModelEntry {
|
||||||
|
id: "meta-llama/Llama-3.1-8B".into(),
|
||||||
|
name: "Llama 3.1 8B".into(),
|
||||||
|
provider: LlmProvider::HuggingFace,
|
||||||
|
context_window: 128,
|
||||||
|
},
|
||||||
|
ModelEntry {
|
||||||
|
id: "gpt-4o".into(),
|
||||||
|
name: "GPT-4o".into(),
|
||||||
|
provider: LlmProvider::OpenAi,
|
||||||
|
context_window: 128,
|
||||||
|
},
|
||||||
|
ModelEntry {
|
||||||
|
id: "claude-sonnet-4-6".into(),
|
||||||
|
name: "Claude Sonnet 4.6".into(),
|
||||||
|
provider: LlmProvider::Anthropic,
|
||||||
|
context_window: 200,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock embedding entries for all providers.
|
||||||
|
fn mock_embeddings() -> Vec<EmbeddingEntry> {
|
||||||
|
vec![
|
||||||
|
EmbeddingEntry {
|
||||||
|
id: "nomic-embed-text".into(),
|
||||||
|
name: "Nomic Embed Text".into(),
|
||||||
|
provider: LlmProvider::Ollama,
|
||||||
|
dimensions: 768,
|
||||||
|
},
|
||||||
|
EmbeddingEntry {
|
||||||
|
id: "sentence-transformers/all-MiniLM-L6-v2".into(),
|
||||||
|
name: "MiniLM-L6-v2".into(),
|
||||||
|
provider: LlmProvider::HuggingFace,
|
||||||
|
dimensions: 384,
|
||||||
|
},
|
||||||
|
EmbeddingEntry {
|
||||||
|
id: "text-embedding-3-small".into(),
|
||||||
|
name: "Embedding 3 Small".into(),
|
||||||
|
provider: LlmProvider::OpenAi,
|
||||||
|
dimensions: 1536,
|
||||||
|
},
|
||||||
|
EmbeddingEntry {
|
||||||
|
id: "voyage-3".into(),
|
||||||
|
name: "Voyage 3".into(),
|
||||||
|
provider: LlmProvider::Anthropic,
|
||||||
|
dimensions: 1024,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
116
src/pages/tools.rs
Normal file
116
src/pages/tools.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::{PageHeader, ToolCard};
|
||||||
|
use crate::models::{McpTool, ToolCategory, ToolStatus};
|
||||||
|
|
||||||
|
/// Tools page displaying a grid of MCP tool cards with toggle switches.
|
||||||
|
///
|
||||||
|
/// Shows all available MCP tools with their status and allows
|
||||||
|
/// enabling/disabling them via toggle buttons.
|
||||||
|
#[component]
|
||||||
|
pub fn ToolsPage() -> Element {
|
||||||
|
let mut tools = use_signal(mock_tools);
|
||||||
|
|
||||||
|
// Toggle a tool's enabled state by its ID
|
||||||
|
let on_toggle = move |id: String| {
|
||||||
|
tools.write().iter_mut().for_each(|t| {
|
||||||
|
if t.id == id {
|
||||||
|
t.enabled = !t.enabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let tool_list = tools.read().clone();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
section { class: "tools-page",
|
||||||
|
PageHeader {
|
||||||
|
title: "Tools".to_string(),
|
||||||
|
subtitle: "Manage MCP servers and tool integrations".to_string(),
|
||||||
|
}
|
||||||
|
div { class: "tools-grid",
|
||||||
|
for tool in tool_list {
|
||||||
|
ToolCard { key: "{tool.id}", tool, on_toggle }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mock MCP tools for the tools grid.
|
||||||
|
fn mock_tools() -> Vec<McpTool> {
|
||||||
|
vec![
|
||||||
|
McpTool {
|
||||||
|
id: "calculator".into(),
|
||||||
|
name: "Calculator".into(),
|
||||||
|
description: "Mathematical computation and unit conversion".into(),
|
||||||
|
category: ToolCategory::Compute,
|
||||||
|
status: ToolStatus::Active,
|
||||||
|
enabled: true,
|
||||||
|
icon: "calculator".into(),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
id: "tavily".into(),
|
||||||
|
name: "Tavily Search".into(),
|
||||||
|
description: "AI-optimized web search API for real-time information".into(),
|
||||||
|
category: ToolCategory::Search,
|
||||||
|
status: ToolStatus::Active,
|
||||||
|
enabled: true,
|
||||||
|
icon: "search".into(),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
id: "searxng".into(),
|
||||||
|
name: "SearXNG".into(),
|
||||||
|
description: "Privacy-respecting metasearch engine".into(),
|
||||||
|
category: ToolCategory::Search,
|
||||||
|
status: ToolStatus::Active,
|
||||||
|
enabled: true,
|
||||||
|
icon: "globe".into(),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
id: "file-reader".into(),
|
||||||
|
name: "File Reader".into(),
|
||||||
|
description: "Read and parse local files in various formats".into(),
|
||||||
|
category: ToolCategory::FileSystem,
|
||||||
|
status: ToolStatus::Active,
|
||||||
|
enabled: true,
|
||||||
|
icon: "file".into(),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
id: "code-exec".into(),
|
||||||
|
name: "Code Executor".into(),
|
||||||
|
description: "Sandboxed code execution for Python and JavaScript".into(),
|
||||||
|
category: ToolCategory::Code,
|
||||||
|
status: ToolStatus::Inactive,
|
||||||
|
enabled: false,
|
||||||
|
icon: "terminal".into(),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
id: "web-scraper".into(),
|
||||||
|
name: "Web Scraper".into(),
|
||||||
|
description: "Extract structured data from web pages".into(),
|
||||||
|
category: ToolCategory::Search,
|
||||||
|
status: ToolStatus::Active,
|
||||||
|
enabled: true,
|
||||||
|
icon: "download".into(),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
id: "email".into(),
|
||||||
|
name: "Email Sender".into(),
|
||||||
|
description: "Send emails via configured SMTP server".into(),
|
||||||
|
category: ToolCategory::Communication,
|
||||||
|
status: ToolStatus::Inactive,
|
||||||
|
enabled: false,
|
||||||
|
icon: "mail".into(),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
id: "git".into(),
|
||||||
|
name: "Git Operations".into(),
|
||||||
|
description: "Interact with Git repositories for version control".into(),
|
||||||
|
category: ToolCategory::Code,
|
||||||
|
status: ToolStatus::Active,
|
||||||
|
enabled: true,
|
||||||
|
icon: "git".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user