3 Commits

Author SHA1 Message Date
Sharang Parnerkar
ba5e4b9a5d feat(dashboard): add sidebar with Ollama status, trending topics, and article detail panel
Some checks failed
CI / Format (push) Failing after 6m19s
CI / Clippy (push) Successful in 2m23s
CI / Security Audit (push) Successful in 1m46s
CI / Tests (push) Has been skipped
CI / Format (pull_request) Failing after 6m24s
CI / Clippy (pull_request) Successful in 2m25s
CI / Security Audit (pull_request) Successful in 1m38s
CI / Deploy (push) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Deploy (pull_request) Has been skipped
Integrate SearXNG news search, Ollama-powered article summarization with
follow-up chat, and a dashboard sidebar showing LLM status, trending
keywords, and recent search history. Sidebar yields to a split-view
article detail panel when a card is selected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:48:26 +01:00
Sharang Parnerkar
83772cc256 fix(fmt): ran dx fmt
All checks were successful
CI / Clippy (push) Successful in 2m17s
CI / Security Audit (push) Successful in 1m38s
CI / Format (pull_request) Successful in 6m28s
CI / Tests (push) Successful in 2m51s
CI / Format (push) Successful in 6m17s
CI / Clippy (pull_request) Successful in 2m15s
CI / Security Audit (pull_request) Successful in 1m42s
CI / Tests (pull_request) Successful in 2m48s
CI / Deploy (push) Has been skipped
CI / Deploy (pull_request) Has been skipped
2026-02-19 12:20:41 +01:00
Sharang Parnerkar
661be22e82 feat(ui): add dashboard sections with sidebar navigation and mock views
Some checks failed
CI / Format (push) Failing after 6m19s
CI / Clippy (push) Successful in 2m17s
CI / Security Audit (push) Successful in 1m36s
CI / Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
Add seven sidebar sections (Dashboard, Providers, Chat, Tools,
Knowledge Base, Developer, Organization) with fully rendered mock views,
nested sub-shells for Developer and Organization, and SearXNG container
for future news feed integration. Replaces the previous OverviewPage
with a news feed dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:03:11 +01:00
47 changed files with 6071 additions and 136 deletions

View File

@@ -7,3 +7,10 @@ KEYCLOAK_CLIENT_ID=certifai-dashboard
APP_URL=http://localhost:8000
REDIRECT_URI=http://localhost:8000/auth/callback
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

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ keycloak/*
# Node modules
node_modules/
searxng/

270
Cargo.lock generated
View File

@@ -698,6 +698,29 @@ dependencies = [
"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]]
name = "darling"
version = "0.21.3"
@@ -753,6 +776,7 @@ dependencies = [
"petname",
"rand 0.10.0",
"reqwest 0.13.2",
"scraper",
"secrecy",
"serde",
"serde_json",
@@ -819,6 +843,17 @@ dependencies = [
"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]]
name = "derive_more"
version = "2.1.1"
@@ -1050,7 +1085,7 @@ dependencies = [
"const-str",
"const_format",
"content_disposition",
"derive_more",
"derive_more 2.1.1",
"dioxus-asset-resolver",
"dioxus-cli-config",
"dioxus-core",
@@ -1546,12 +1581,33 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]]
name = "either"
version = "1.15.0"
@@ -1674,6 +1730,16 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "futures"
version = "0.3.32"
@@ -1777,6 +1843,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generational-box"
version = "0.7.3"
@@ -2015,6 +2090,18 @@ dependencies = [
"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]]
name = "http"
version = "0.2.12"
@@ -2579,6 +2666,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "macro-string"
version = "0.1.4"
@@ -2678,6 +2771,31 @@ dependencies = [
"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]]
name = "matchers"
version = "0.2.0"
@@ -2804,7 +2922,7 @@ dependencies = [
"bitflags",
"bson",
"derive-where",
"derive_more",
"derive_more 2.1.1",
"futures-core",
"futures-io",
"futures-util",
@@ -2897,6 +3015,12 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "num-conv"
version = "0.2.0"
@@ -3003,6 +3127,58 @@ dependencies = [
"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]]
name = "pin-project"
version = "1.1.10"
@@ -3059,6 +3235,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -3630,6 +3812,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "sct"
version = "0.7.1"
@@ -3672,6 +3868,25 @@ dependencies = [
"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]]
name = "semver"
version = "1.0.27"
@@ -3841,6 +4056,15 @@ dependencies = [
"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]]
name = "sha1"
version = "0.10.6"
@@ -3888,6 +4112,12 @@ dependencies = [
"libc",
]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "slab"
version = "0.4.12"
@@ -3991,6 +4221,31 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "stringprep"
version = "0.1.5"
@@ -4117,6 +4372,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "thiserror"
version = "1.0.69"

View File

@@ -75,6 +75,7 @@ dioxus-free-icons = { version = "0.10", features = [
] }
sha2 = { version = "0.10.9", 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]
# default = ["web"]
@@ -91,6 +92,7 @@ server = [
"dep:url",
"dep:sha2",
"dep:base64",
"dep:scraper",
]
[[bin]]

View File

@@ -21,12 +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
- 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:
- **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.
## Code structure
## Development environment
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:
- 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".

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,147 @@
}
}
@layer utilities {
.modal {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
visibility: hidden;
position: fixed;
inset: calc(0.25rem * 0);
margin: calc(0.25rem * 0);
display: grid;
height: 100%;
max-height: none;
width: 100%;
max-width: none;
align-items: center;
justify-items: center;
background-color: transparent;
padding: calc(0.25rem * 0);
color: inherit;
transition: visibility 0.3s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out;
overflow: clip;
overscroll-behavior: contain;
z-index: 999;
scrollbar-gutter: auto;
&::backdrop {
display: none;
}
}
@layer daisyui.l1.l2 {
&.modal-open, &[open], &:target, .modal-toggle:checked + & {
pointer-events: auto;
visibility: visible;
opacity: 100%;
transition: visibility 0s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out;
background-color: oklch(0% 0 0/ 0.4);
.modal-box {
translate: 0 0;
scale: 1;
opacity: 1;
}
:root:has(&) {
--page-has-backdrop: 1;
--page-overflow: hidden;
--page-scroll-bg: var(--page-scroll-bg-on);
--page-scroll-gutter: stable;
--page-scroll-transition: var(--page-scroll-transition-on);
animation: set-page-has-scroll forwards;
animation-timeline: scroll();
}
}
@starting-style {
&.modal-open, &[open], &:target, .modal-toggle:checked + & {
opacity: 0%;
}
}
}
}
.tab {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-flex;
cursor: pointer;
appearance: none;
flex-wrap: wrap;
align-items: center;
justify-content: center;
text-align: center;
webkit-user-select: none;
user-select: none;
&:hover {
@media (hover: hover) {
color: var(--color-base-content);
}
}
--tab-p: 0.75rem;
--tab-bg: var(--color-base-100);
--tab-border-color: var(--color-base-300);
--tab-radius-ss: 0;
--tab-radius-se: 0;
--tab-radius-es: 0;
--tab-radius-ee: 0;
--tab-order: 0;
--tab-radius-min: calc(0.75rem - var(--border));
--tab-radius-limit: min(var(--radius-field), var(--tab-radius-min));
--tab-radius-grad: #0000 calc(69% - var(--border)),
var(--tab-border-color) calc(69% - var(--border) + 0.25px),
var(--tab-border-color) 69%,
var(--tab-bg) calc(69% + 0.25px);
border-color: #0000;
order: var(--tab-order);
height: var(--tab-height);
font-size: 0.875rem;
padding-inline: var(--tab-p);
&:is(input[type="radio"]) {
min-width: fit-content;
&:after {
--tw-content: attr(aria-label);
content: var(--tw-content);
}
}
&:is(label) {
position: relative;
input {
position: absolute;
inset: calc(0.25rem * 0);
cursor: pointer;
appearance: none;
opacity: 0%;
}
}
&:checked, &:is(label:has(:checked)), &:is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]) {
& + .tab-content {
display: block;
}
}
&:not( :checked, label:has(:checked), :hover, .tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"] ) {
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
}
}
&:not(input):empty {
flex-grow: 1;
cursor: default;
}
&:focus {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
&:focus-visible, &:is(label:has(:checked:focus-visible)) {
outline: 2px solid currentColor;
outline-offset: -5px;
}
&[disabled] {
pointer-events: none;
opacity: 40%;
}
}
}
.btn {
:where(&) {
@layer daisyui.l1.l2.l3 {
@@ -314,6 +455,65 @@
.visible {
visibility: visible;
}
.list {
@layer daisyui.l1.l2.l3 {
display: flex;
flex-direction: column;
font-size: 0.875rem;
.list-row {
--list-grid-cols: minmax(0, auto) 1fr;
position: relative;
display: grid;
grid-auto-flow: column;
gap: calc(0.25rem * 4);
border-radius: var(--radius-box);
padding: calc(0.25rem * 4);
word-break: break-word;
grid-template-columns: var(--list-grid-cols);
}
& > :not(:last-child) {
&.list-row, .list-row {
&:after {
content: "";
border-bottom: var(--border) solid;
inset-inline: var(--radius-box);
position: absolute;
bottom: calc(0.25rem * 0);
border-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent);
}
}
}
}
}
@layer daisyui.l1.l2 {
.list-row {
&:has(.list-col-grow:nth-child(1)) {
--list-grid-cols: 1fr;
}
&:has(.list-col-grow:nth-child(2)) {
--list-grid-cols: minmax(0, auto) 1fr;
}
&:has(.list-col-grow:nth-child(3)) {
--list-grid-cols: minmax(0, auto) minmax(0, auto) 1fr;
}
&:has(.list-col-grow:nth-child(4)) {
--list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr;
}
&:has(.list-col-grow:nth-child(5)) {
--list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr;
}
&:has(.list-col-grow:nth-child(6)) {
--list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto)
minmax(0, auto) 1fr;
}
> * {
grid-row-start: 1;
}
}
}
}
.toggle {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid currentColor;
@@ -589,6 +789,75 @@
}
}
}
.table {
@layer daisyui.l1.l2.l3 {
font-size: 0.875rem;
position: relative;
width: 100%;
border-collapse: separate;
--tw-border-spacing-x: calc(0.25rem * 0);
--tw-border-spacing-y: calc(0.25rem * 0);
border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y);
border-radius: var(--radius-box);
text-align: left;
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
text-align: right;
}
tr.row-hover {
&, &:nth-child(even) {
&:hover {
@media (hover: hover) {
background-color: var(--color-base-200);
}
}
}
}
:where(th, td) {
padding-inline: calc(0.25rem * 4);
padding-block: calc(0.25rem * 3);
vertical-align: middle;
}
:where(thead, tfoot) {
white-space: nowrap;
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
}
font-size: 0.875rem;
font-weight: 600;
}
:where(tfoot tr:first-child :is(td, th)) {
border-top: var(--border) solid var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
border-top: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000);
}
}
:where(.table-pin-rows thead tr) {
position: sticky;
top: calc(0.25rem * 0);
z-index: 1;
background-color: var(--color-base-100);
}
:where(.table-pin-rows tfoot tr) {
position: sticky;
bottom: calc(0.25rem * 0);
z-index: 1;
background-color: var(--color-base-100);
}
:where(.table-pin-cols tr th) {
position: sticky;
right: calc(0.25rem * 0);
left: calc(0.25rem * 0);
background-color: var(--color-base-100);
}
:where(thead tr :is(td, th), tbody tr:not(:last-child) :is(td, th)) {
border-bottom: var(--border) solid var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
border-bottom: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000);
}
}
}
}
.steps {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -699,6 +968,34 @@
}
}
}
.chat-bubble {
@layer daisyui.l1.l2.l3 {
position: relative;
display: block;
width: fit-content;
border-radius: var(--radius-field);
background-color: var(--color-base-300);
padding-inline: calc(0.25rem * 4);
padding-block: calc(0.25rem * 2);
color: var(--color-base-content);
grid-row-end: 3;
min-height: 2rem;
min-width: 2.5rem;
max-width: 90%;
&:before {
position: absolute;
bottom: calc(0.25rem * 0);
height: calc(0.25rem * 3);
width: calc(0.25rem * 3);
background-color: inherit;
content: "";
mask-repeat: no-repeat;
mask-image: var(--mask-chat);
mask-position: 0px -1px;
mask-size: 0.8125rem;
}
}
}
.select {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid #0000;
@@ -934,6 +1231,15 @@
}
}
}
.stats {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-grid;
grid-auto-flow: column;
overflow-x: auto;
border-radius: var(--radius-box);
}
}
.progress {
@layer daisyui.l1.l2.l3 {
position: relative;
@@ -999,6 +1305,76 @@
.end {
inset-inline-end: var(--spacing);
}
.join {
display: inline-flex;
align-items: stretch;
--join-ss: 0;
--join-se: 0;
--join-es: 0;
--join-ee: 0;
:where(.join-item) {
border-start-start-radius: var(--join-ss, 0);
border-start-end-radius: var(--join-se, 0);
border-end-start-radius: var(--join-es, 0);
border-end-end-radius: var(--join-ee, 0);
* {
--join-ss: var(--radius-field);
--join-se: var(--radius-field);
--join-es: var(--radius-field);
--join-ee: var(--radius-field);
}
}
> .join-item:where(:first-child) {
--join-ss: var(--radius-field);
--join-se: 0;
--join-es: var(--radius-field);
--join-ee: 0;
}
:first-child:not(:last-child) {
:where(.join-item) {
--join-ss: var(--radius-field);
--join-se: 0;
--join-es: var(--radius-field);
--join-ee: 0;
}
}
> .join-item:where(:last-child) {
--join-ss: 0;
--join-se: var(--radius-field);
--join-es: 0;
--join-ee: var(--radius-field);
}
:last-child:not(:first-child) {
:where(.join-item) {
--join-ss: 0;
--join-se: var(--radius-field);
--join-es: 0;
--join-ee: var(--radius-field);
}
}
> .join-item:where(:only-child) {
--join-ss: var(--radius-field);
--join-se: var(--radius-field);
--join-es: var(--radius-field);
--join-ee: var(--radius-field);
}
:only-child {
:where(.join-item) {
--join-ss: var(--radius-field);
--join-se: var(--radius-field);
--join-es: var(--radius-field);
--join-ee: var(--radius-field);
}
}
> :where(:focus, :has(:focus)) {
z-index: 1;
}
@media (hover: hover) {
> :where(.btn:hover, :has(.btn:hover)) {
isolation: isolate;
}
}
}
.hero-content {
@layer daisyui.l1.l2.l3 {
isolation: isolate;
@@ -1122,6 +1498,51 @@
max-width: 96rem;
}
}
.filter {
@layer daisyui.l1.l2.l3 {
display: flex;
flex-wrap: wrap;
input[type="radio"] {
width: auto;
}
input {
overflow: hidden;
opacity: 100%;
scale: 1;
transition: margin 0.1s, opacity 0.3s, padding 0.3s, border-width 0.1s;
&:not(:last-child) {
margin-inline-end: calc(0.25rem * 1);
}
&.filter-reset {
aspect-ratio: 1 / 1;
&::after {
--tw-content: "×";
content: var(--tw-content);
}
}
}
&:not(:has(input:checked:not(.filter-reset))) {
.filter-reset, input[type="reset"] {
scale: 0;
border-width: 0;
margin-inline: calc(0.25rem * 0);
width: calc(0.25rem * 0);
padding-inline: calc(0.25rem * 0);
opacity: 0%;
}
}
&:has(input:checked:not(.filter-reset)) {
input:not(:checked, .filter-reset, input[type="reset"]) {
scale: 0;
border-width: 0;
margin-inline: calc(0.25rem * 0);
width: calc(0.25rem * 0);
padding-inline: calc(0.25rem * 0);
opacity: 0%;
}
}
}
}
.label {
@layer daisyui.l1.l2.l3 {
display: inline-flex;
@@ -1208,6 +1629,17 @@
padding-inline: calc(var(--size) / 2 - var(--border));
}
}
.tabs {
@layer daisyui.l1.l2.l3 {
display: flex;
flex-wrap: wrap;
--tabs-height: auto;
--tabs-direction: row;
--tab-height: calc(var(--size-field, 0.25rem) * 10);
height: var(--tabs-height);
flex-direction: var(--tabs-direction);
}
}
.footer {
@layer daisyui.l1.l2.l3 {
display: grid;
@@ -1233,6 +1665,15 @@
}
}
}
.chat {
@layer daisyui.l1.l2.l3 {
display: grid;
grid-auto-rows: min-content;
column-gap: calc(0.25rem * 3);
padding-block: calc(0.25rem * 1);
--mask-chat: url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");
}
}
.card-title {
@layer daisyui.l1.l2.l3 {
display: flex;
@@ -1242,12 +1683,21 @@
font-weight: 600;
}
}
.block {
display: block;
}
.grid {
display: grid;
}
.hidden {
display: none;
}
.inline {
display: inline;
}
.table {
display: table;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
@@ -1311,6 +1761,9 @@
}
}
}
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.btn-outline {
@layer daisyui.l1 {
&:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) {
@@ -1351,6 +1804,12 @@
--btn-fg: var(--color-primary-content);
}
}
.btn-secondary {
@layer daisyui.l1.l2.l3 {
--btn-color: var(--color-secondary);
--btn-fg: var(--color-secondary-content);
}
}
}
@layer base {
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
@@ -1724,6 +2183,59 @@
inherits: false;
initial-value: solid;
}
@property --tw-blur {
syntax: "*";
inherits: false;
}
@property --tw-brightness {
syntax: "*";
inherits: false;
}
@property --tw-contrast {
syntax: "*";
inherits: false;
}
@property --tw-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-invert {
syntax: "*";
inherits: false;
}
@property --tw-opacity {
syntax: "*";
inherits: false;
}
@property --tw-saturate {
syntax: "*";
inherits: false;
}
@property --tw-sepia {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-drop-shadow-size {
syntax: "*";
inherits: false;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
@@ -1733,6 +2245,19 @@
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-outline-style: solid;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
}
}
}

View File

@@ -28,4 +28,15 @@ services:
- 27017:27017
environment:
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

View File

@@ -4,8 +4,9 @@ use dioxus::prelude::*;
/// Application routes.
///
/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live
/// outside the `AppShell` layout. Authenticated pages like `OverviewPage`
/// are wrapped in `AppShell` which renders the sidebar.
/// outside the `AppShell` layout. Authenticated pages are wrapped in
/// `AppShell` which renders the sidebar. `DeveloperShell` and `OrgShell`
/// provide nested tab navigation within the app shell.
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Route {
@@ -17,8 +18,33 @@ pub enum Route {
PrivacyPage {},
#[layout(AppShell)]
#[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]
#[route("/login?:redirect_url")]
Login { redirect_url: String },
}

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

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

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

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

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

View File

@@ -1,8 +1,28 @@
mod app_shell;
mod article_detail;
mod card;
mod chat_bubble;
mod dashboard_sidebar;
mod file_row;
mod login;
mod member_row;
pub mod news_card;
mod page_header;
mod pricing_card;
pub mod sidebar;
pub mod sub_nav;
mod tool_card;
pub use app_shell::*;
pub use article_detail::*;
pub use card::*;
pub use chat_bubble::*;
pub use dashboard_sidebar::*;
pub use file_row::*;
pub use login::*;
pub use member_row::*;
pub use news_card::*;
pub use page_header::*;
pub use pricing_card::*;
pub use sub_nav::*;
pub use tool_card::*;

206
src/components/news_card.rs Normal file
View 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(),
},
]
}

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

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

View File

@@ -1,8 +1,8 @@
use dioxus::prelude::*;
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 crate::Route;
@@ -25,29 +25,39 @@ struct NavItem {
pub fn Sidebar(email: String, avatar_url: String) -> Element {
let nav_items: Vec<NavItem> = vec![
NavItem {
label: "Overview",
route: Route::OverviewPage {},
label: "Dashboard",
route: Route::DashboardPage {},
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
},
NavItem {
label: "Documentation",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsFileEarmarkText, width: 18, height: 18 } },
label: "Providers",
route: Route::ProvidersPage {},
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
},
NavItem {
label: "Agents",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsRobot, width: 18, height: 18 } },
label: "Chat",
route: Route::ChatPage {},
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
},
NavItem {
label: "Models",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: FaCubes, width: 18, height: 18 } },
label: "Tools",
route: Route::ToolsPage {},
icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } },
},
NavItem {
label: "Settings",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } },
label: "Knowledge Base",
route: Route::KnowledgePage {},
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! {
aside { class: "sidebar",
// -- Header: avatar circle + email --
SidebarHeader { email: email.clone(), avatar_url }
// -- Navigation links --
nav { class: "sidebar-nav",
for item in nav_items {
{
// Simple active check: highlight Overview only when on `/`.
let is_active = item.route == current_route;
// Active detection for nested routes: highlight the parent nav
// item when any child route within the nested shell is active.
let is_active = match &current_route {
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
item.label == "Developer"
}
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
item.label == "Organization"
}
_ => item.route == current_route,
};
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
rsx! {
Link { to: item.route, class: cls,
@@ -76,7 +93,6 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
}
}
// -- Logout button --
div { class: "sidebar-logout",
Link {
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 {}
}
}

44
src/components/sub_nav.rs Normal file
View 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}" }
}
}
}
}
}
}

View 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
View 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(&paragraph_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"))
}

View File

@@ -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;
#[cfg(feature = "server")]
mod error;
#[cfg(feature = "server")]
mod server;
#[cfg(feature = "server")]
mod state;
#[cfg(feature = "server")]
pub use auth::*;
#[cfg(feature = "server")]
pub use error::*;
#[cfg(feature = "server")]
pub use server::*;
#[cfg(feature = "server")]
pub use state::*;

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

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

View File

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

25
src/models/news.rs Normal file
View 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,
}

View 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
View 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
View 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
View 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
View 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);
},
}
}
}
}
}
}

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

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

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

View 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> {} }
}
}
}

124
src/pages/knowledge.rs Normal file
View 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,
},
]
}

View File

@@ -1,9 +1,21 @@
mod chat;
mod dashboard;
pub mod developer;
mod impressum;
mod knowledge;
mod landing;
mod overview;
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 overview::*;
pub use organization::*;
pub use privacy::*;
pub use providers::*;
pub use tools::*;

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

View 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> {} }
}
}
}

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

View File

@@ -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=/dashboard".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())
}

221
src/pages/providers.rs Normal file
View 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
View 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(),
},
]
}