feat(chat): added chat interface and connection to ollama #10

Merged
sharang merged 6 commits from feat/CAI-20 into main 2026-02-20 19:40:26 +00:00
28 changed files with 3148 additions and 196 deletions

4
.gitignore vendored
View File

@@ -12,9 +12,11 @@
# Logs
*.log
# Keycloak runtime data (but keep realm-export.json)
# Keycloak runtime data (but keep config and theme)
keycloak/*
!keycloak/realm-export.json
!keycloak/themes/
!keycloak/themes/**
# Node modules
node_modules/

45
Cargo.lock generated
View File

@@ -760,9 +760,11 @@ dependencies = [
name = "dashboard"
version = "0.1.0"
dependencies = [
"async-stream",
"async-stripe",
"axum",
"base64 0.22.1",
"bytes",
"chrono",
"dioxus",
"dioxus-cli-config",
@@ -774,6 +776,7 @@ dependencies = [
"maud",
"mongodb",
"petname",
"pulldown-cmark",
"rand 0.10.0",
"reqwest 0.13.2",
"scraper",
@@ -784,10 +787,12 @@ dependencies = [
"thiserror 2.0.18",
"time",
"tokio",
"tokio-stream",
"tower-http",
"tower-sessions",
"tracing",
"url",
"wasm-bindgen",
"web-sys",
]
@@ -1127,7 +1132,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.4.2",
"web-sys",
"xxhash-rust",
]
@@ -1531,7 +1536,7 @@ dependencies = [
"tracing",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.4.2",
"web-sys",
]
@@ -3297,6 +3302,24 @@ dependencies = [
"psl-types",
]
[[package]]
name = "pulldown-cmark"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
dependencies = [
"bitflags",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quinn"
version = "0.11.9"
@@ -3573,7 +3596,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots 1.0.6",
]
@@ -3588,6 +3611,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2 0.4.13",
"http 1.4.0",
"http-body 1.0.1",
@@ -3610,12 +3634,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"web-sys",
]
@@ -5147,6 +5173,19 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"

View File

@@ -36,7 +36,7 @@ mongodb = { version = "3.2", default-features = false, features = [
"compat-3-0-0",
], optional = true }
futures = { version = "0.3.31", default-features = false }
reqwest = { version = "0.13", optional = true, features = ["json", "form"] }
reqwest = { version = "0.13", optional = true, features = ["json", "form", "stream"] }
tower-sessions = { version = "0.15", default-features = false, features = [
"axum-core",
"memory-store",
@@ -61,11 +61,14 @@ secrecy = { version = "0.10", default-features = false, optional = true }
serde_json = { version = "1.0.133", default-features = false }
maud = { version = "0.27", default-features = false }
url = { version = "2.5.4", default-features = false, optional = true }
wasm-bindgen = { version = "0.2", optional = true }
web-sys = { version = "0.3", optional = true, features = [
"Clipboard",
"Document",
"Element",
"EventSource",
"HtmlElement",
"MessageEvent",
"Navigator",
"Storage",
"Window",
@@ -81,10 +84,14 @@ 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 }
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
tokio-stream = { version = "0.1", optional = true, features = ["sync"] }
async-stream = { version = "0.3", optional = true }
bytes = { version = "1", optional = true }
[features]
# default = ["web"]
web = ["dioxus/web", "dep:reqwest", "dep:web-sys"]
web = ["dioxus/web", "dep:reqwest", "dep:web-sys", "dep:wasm-bindgen"]
server = [
"dioxus/server",
"dep:axum",
@@ -100,6 +107,9 @@ server = [
"dep:scraper",
"dep:secrecy",
"dep:petname",
"dep:tokio-stream",
"dep:async-stream",
"dep:bytes",
]
[[bin]]

View File

@@ -215,6 +215,31 @@ h6 {
color: var(--accent);
}
.sidebar-legal {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 4px;
}
.legal-link {
font-size: 11px;
color: var(--text-dimmest);
text-decoration: none;
transition: color 0.15s ease;
}
.legal-link:hover {
color: var(--text-secondary);
}
.legal-sep {
font-size: 10px;
color: var(--text-dimmest);
opacity: 0.5;
}
.sidebar-version {
font-size: 11px;
color: var(--text-dimmest);
@@ -1884,6 +1909,44 @@ h6 {
color: var(--accent);
}
/* -- Chat Action Bar -- */
.chat-action-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 24px 0;
background-color: var(--bg-sidebar);
}
.chat-action-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
background: none;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.chat-action-btn:hover:not(:disabled) {
color: var(--text-primary);
background-color: var(--bg-card);
border-color: var(--border-secondary);
}
.chat-action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.chat-action-label {
font-family: 'Inter', sans-serif;
}
/* -- Chat Input Bar -- */
.chat-input-bar {
display: flex;
@@ -1918,6 +1981,289 @@ h6 {
padding: 10px 20px;
}
/* -- Chat Model Selector Bar -- */
.chat-model-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 24px;
border-bottom: 1px solid var(--border-primary);
background-color: var(--bg-sidebar);
}
.chat-model-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.chat-model-select {
padding: 6px 12px;
background-color: var(--bg-card);
border: 1px solid var(--border-secondary);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-family: 'Inter', sans-serif;
outline: none;
cursor: pointer;
min-width: 160px;
}
.chat-model-select:focus {
border-color: var(--accent);
}
/* -- Chat Namespace Headers -- */
.chat-namespace-header {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
padding: 12px 12px 4px;
}
/* -- Chat Session Item Layout -- */
.chat-session-item {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.chat-session-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.chat-session-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-session-actions {
display: none;
gap: 4px;
flex-shrink: 0;
}
.chat-session-item:hover .chat-session-actions {
display: flex;
}
.btn-icon-sm {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--text-faint);
transition: all 0.15s ease;
padding: 0;
}
.btn-icon-sm:hover {
background-color: var(--bg-surface);
color: var(--text-primary);
}
.btn-icon-danger:hover {
color: #ef4444;
}
/* -- Inline Rename -- */
.chat-session-rename-input {
width: 100%;
padding: 8px 10px;
background-color: var(--bg-card);
border: 1px solid var(--accent);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-family: 'Inter', sans-serif;
outline: none;
}
/* -- Chat Message List -- */
.chat-message-list {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* -- Chat Empty Hint -- */
.chat-empty-hint {
font-size: 13px;
color: var(--text-faint);
padding: 8px 12px;
}
/* -- Thinking Indicator -- */
.chat-bubble--thinking {
background-color: transparent;
border: none;
padding: 8px 0;
}
.chat-thinking {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-faint);
font-size: 14px;
}
.chat-thinking-text {
opacity: 0.7;
}
.chat-thinking-dots {
display: flex;
gap: 4px;
}
.chat-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--accent);
animation: dot-pulse 1.4s ease-in-out infinite;
}
.chat-dot:nth-child(2) {
animation-delay: 0.2s;
}
.chat-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dot-pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* -- Streaming Bubble -- */
.chat-bubble--streaming {
border: 1px solid var(--accent);
border-style: dashed;
}
.chat-streaming-cursor {
display: inline-block;
width: 8px;
height: 16px;
background-color: var(--accent);
margin-left: 2px;
animation: blink-cursor 1s steps(2) infinite;
vertical-align: text-bottom;
}
@keyframes blink-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* -- Chat Prose (Markdown in Assistant Bubbles) -- */
.chat-prose {
white-space: normal;
}
.chat-prose p {
margin: 0 0 12px;
}
.chat-prose p:last-child {
margin-bottom: 0;
}
.chat-prose pre {
background-color: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 12px 16px;
overflow-x: auto;
margin: 8px 0;
font-size: 13px;
line-height: 1.5;
}
.chat-prose code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
}
.chat-prose :not(pre) > code {
background-color: rgba(145, 164, 210, 0.15);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.chat-prose ul,
.chat-prose ol {
padding-left: 20px;
margin: 8px 0;
}
.chat-prose li {
margin-bottom: 4px;
}
.chat-prose blockquote {
border-left: 3px solid var(--accent);
padding-left: 12px;
color: var(--text-secondary);
margin: 8px 0;
font-style: italic;
}
.chat-prose table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
font-size: 13px;
}
.chat-prose th,
.chat-prose td {
border: 1px solid var(--border-secondary);
padding: 8px;
text-align: left;
}
.chat-prose th {
background-color: rgba(145, 164, 210, 0.1);
font-weight: 600;
}
.chat-prose a {
color: var(--accent);
text-decoration: underline;
}
.chat-prose h1,
.chat-prose h2,
.chat-prose h3 {
font-family: 'Space Grotesk', sans-serif;
margin: 16px 0 8px;
color: var(--text-heading);
}
.chat-prose h1 { font-size: 20px; }
.chat-prose h2 { font-size: 17px; }
.chat-prose h3 { font-size: 15px; }
/* ===== Tools Page ===== */
.tools-page {
max-width: 1200px;

View File

@@ -162,6 +162,59 @@
}
}
@layer utilities {
.diff {
@layer daisyui.l1.l2.l3 {
position: relative;
display: grid;
width: 100%;
overflow: hidden;
webkit-user-select: none;
user-select: none;
grid-template-rows: 1fr 1.8rem 1fr;
direction: ltr;
container-type: inline-size;
grid-template-columns: auto 1fr;
&:focus-visible, &:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
}
&:focus-visible {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
}
&:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
}
}
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
&:focus {
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
}
}
&:has(.diff-item-1:focus) {
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
}
}
}
}
.modal {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
@@ -1383,6 +1436,81 @@
padding: calc(0.25rem * 4);
}
}
.textarea {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid #0000;
min-height: calc(0.25rem * 20);
flex-shrink: 1;
appearance: none;
border-radius: var(--radius-field);
background-color: var(--color-base-100);
padding-block: calc(0.25rem * 2);
vertical-align: middle;
width: clamp(3rem, 20rem, 100%);
padding-inline-start: 0.75rem;
padding-inline-end: 0.75rem;
font-size: max(var(--font-size, 0.875rem), 0.875rem);
touch-action: manipulation;
border-color: var(--input-color);
box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
}
--input-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
--input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
}
textarea {
appearance: none;
background-color: transparent;
border: none;
&:focus, &:focus-within {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
}
&:focus, &:focus-within {
--input-color: var(--color-base-content);
box-shadow: 0 1px var(--input-color);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
}
outline: 2px solid var(--input-color);
outline-offset: 2px;
isolation: isolate;
}
@media (pointer: coarse) {
@supports (-webkit-touch-callout: none) {
&:focus, &:focus-within {
--font-size: 1rem;
}
}
}
&:has(> textarea[disabled]), &:is(:disabled, [disabled]) {
cursor: not-allowed;
border-color: var(--color-base-200);
background-color: var(--color-base-200);
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
}
&::placeholder {
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
}
}
box-shadow: none;
}
&:has(> textarea[disabled]) > textarea[disabled] {
cursor: not-allowed;
}
}
}
.stack {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -1680,9 +1808,6 @@
font-weight: 600;
}
}
.block {
display: block;
}
.grid {
display: grid;
}
@@ -1724,6 +1849,14 @@
border-color: currentColor;
}
}
.glass {
border: none;
backdrop-filter: blur(var(--glass-blur, 40px));
background-color: #0000;
background-image: linear-gradient( 135deg, oklch(100% 0 0 / var(--glass-opacity, 30%)) 0%, oklch(0% 0 0 / 0%) 100% ), linear-gradient( var(--glass-reflect-degree, 100deg), oklch(100% 0 0 / var(--glass-reflect-opacity, 5%)) 25%, oklch(0% 0 0 / 0%) 25% );
box-shadow: 0 0 0 1px oklch(100% 0 0 / var(--glass-border-opacity, 20%)) inset, 0 0 0 2px oklch(0% 0 0 / 5%);
text-shadow: 0 1px oklch(0% 0 0 / var(--glass-text-shadow-opacity, 5%));
}
.p-6 {
padding: calc(var(--spacing) * 6);
}

View File

@@ -15,6 +15,7 @@ services:
- --import-realm
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
- ./keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
interval: 10s

View File

@@ -9,6 +9,7 @@
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"loginTheme": "certifai",
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,

View File

@@ -0,0 +1,583 @@
/* CERTifAI Keycloak Login Theme
* Overrides PatternFly v4 / legacy Keycloak classes to match the dashboard.
*
* Actual page structure (Keycloak 26 with parent=keycloak):
* html.login-pf > body
* div.login-pf-page
* div#kc-header.login-pf-page-header
* div#kc-header-wrapper
* div.card-pf
* header.login-pf-header > h1#kc-page-title
* div#kc-content > div#kc-content-wrapper
* form#kc-form-login
* .form-group (email)
* .form-group (password + .pf-c-input-group)
* .form-group.login-pf-settings (forgot pwd)
* .form-group #kc-form-buttons (submit: input#kc-login.pf-c-button.pf-m-primary)
* div#kc-info.login-pf-signup (register link)
*
* Classes used: pf-c-* (PF v4), login-pf-*, card-pf, form-group
*/
/* ===== Google Fonts ===== */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap');
/* ===== CSS Variables ===== */
:root {
--cai-bg-body: #0f1116;
--cai-bg-card: #1a1d26;
--cai-bg-surface: #1e222d;
--cai-bg-input: #12141a;
--cai-text-primary: #e2e8f0;
--cai-text-heading: #f1f5f9;
--cai-text-muted: #8892a8;
--cai-text-faint: #5a6478;
--cai-border-primary: #1e222d;
--cai-border-secondary: #2a2f3d;
--cai-accent: #91a4d2;
--cai-accent-secondary: #6d85c6;
--cai-brand-indigo: #4B3FE0;
--cai-brand-teal: #38B2AC;
--cai-error: #f87171;
--cai-success: #4ade80;
}
/* ===== Animations ===== */
/* Slow-moving ambient gradient behind the page */
@keyframes ambientShift {
0% { background-position: 0% 0%; }
25% { background-position: 100% 50%; }
50% { background-position: 50% 100%; }
75% { background-position: 0% 50%; }
100% { background-position: 0% 0%; }
}
/* Subtle glow pulse on the card */
@keyframes cardGlow {
0%, 100% { box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 60px rgba(75, 63, 224, 0.04); }
50% { box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 80px rgba(56, 178, 172, 0.06); }
}
/* Gentle float for the logo */
@keyframes logoFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
/* Gradient shimmer on the button */
@keyframes buttonShimmer {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* ===== Base Page ===== */
html.login-pf {
background-color: var(--cai-bg-body) !important;
}
html.login-pf body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
background:
radial-gradient(ellipse at 20% 20%, rgba(75, 63, 224, 0.07) 0%, transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(56, 178, 172, 0.05) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(109, 133, 198, 0.03) 0%, transparent 70%),
var(--cai-bg-body) !important;
background-size: 200% 200%, 200% 200%, 100% 100%, 100% 100% !important;
animation: ambientShift 20s ease-in-out infinite !important;
color: var(--cai-text-primary) !important;
min-height: 100vh;
}
/* ===== Page Layout ===== */
.login-pf-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 40px 24px;
position: relative;
}
/* ===== Header (Logo + Realm Name) ===== */
#kc-header.login-pf-page-header {
background: transparent !important;
background-image: none !important;
padding: 0 0 32px !important;
text-align: center;
max-width: 440px;
width: 100%;
margin: 0;
}
#kc-header-wrapper {
font-family: 'Space Grotesk', sans-serif !important;
font-size: 28px !important;
font-weight: 700 !important;
color: var(--cai-text-heading) !important;
letter-spacing: -0.02em;
text-transform: none !important;
padding: 0 !important;
}
/* Logo via ::before pseudo-element */
#kc-header-wrapper::before {
content: '';
display: block;
width: 64px;
height: 64px;
margin: 0 auto 16px;
background-image: url('../img/logo.svg');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
animation: logoFloat 4s ease-in-out infinite;
filter: drop-shadow(0 0 12px rgba(75, 63, 224, 0.3));
}
/* ===== Login Card ===== */
.card-pf {
background-color: var(--cai-bg-card) !important;
border: 1px solid var(--cai-border-secondary) !important;
border-radius: 12px !important;
max-width: 440px;
width: 100%;
padding: 32px !important;
margin: 0 !important;
animation: cardGlow 6s ease-in-out infinite;
position: relative;
overflow: hidden;
}
/* Subtle gradient border effect on the card via ::before overlay */
.card-pf::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(
90deg,
transparent,
var(--cai-brand-indigo),
var(--cai-brand-teal),
var(--cai-accent-secondary),
transparent
);
opacity: 0.5;
}
/* ===== Card Header (Sign In Title) ===== */
.login-pf-header {
border-bottom: none !important;
padding: 0 0 24px !important;
margin: 0 !important;
}
#kc-page-title {
font-family: 'Space Grotesk', sans-serif !important;
font-size: 22px !important;
font-weight: 600 !important;
color: var(--cai-text-heading) !important;
text-align: center;
margin: 0 !important;
}
/* ===== Form Groups ===== */
.form-group {
margin-bottom: 20px !important;
}
/* ===== Labels ===== */
.pf-c-form__label,
.pf-c-form__label-text,
.login-pf-page .form-group label,
.card-pf label {
font-family: 'Inter', sans-serif !important;
font-size: 13px !important;
font-weight: 500 !important;
color: var(--cai-text-muted) !important;
margin-bottom: 6px !important;
display: block;
}
/* ===== Text Inputs ===== */
.pf-c-form-control,
.login-pf-page .form-control,
.card-pf input[type="text"],
.card-pf input[type="password"],
.card-pf input[type="email"] {
background-color: var(--cai-bg-input) !important;
border: 1px solid var(--cai-border-secondary) !important;
border-radius: 8px !important;
color: var(--cai-text-primary) !important;
font-family: 'Inter', sans-serif !important;
font-size: 14px !important;
padding: 10px 14px !important;
height: auto !important;
line-height: 1.5 !important;
transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
box-shadow: none !important;
outline: none !important;
}
.pf-c-form-control:focus,
.pf-c-form-control:focus-within,
.card-pf input[type="text"]:focus,
.card-pf input[type="password"]:focus,
.card-pf input[type="email"]:focus {
border-color: var(--cai-accent) !important;
box-shadow: 0 0 0 1px var(--cai-accent), 0 0 12px rgba(145, 164, 210, 0.1) !important;
outline: none !important;
}
.pf-c-form-control::placeholder,
.card-pf input::placeholder {
color: var(--cai-text-faint) !important;
}
/* Override browser autofill yellow background */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 9999px var(--cai-bg-input) inset !important;
-webkit-text-fill-color: var(--cai-text-primary) !important;
caret-color: var(--cai-text-primary) !important;
transition: background-color 5000s ease-in-out 0s !important;
background-color: var(--cai-bg-input) !important;
color: var(--cai-text-primary) !important;
}
/* Firefox autofill override */
input:autofill {
background-color: var(--cai-bg-input) !important;
color: var(--cai-text-primary) !important;
border-color: var(--cai-border-secondary) !important;
}
/* Additional specificity for autofill inside input-group */
.pf-c-input-group input:-webkit-autofill,
.card-pf input:-webkit-autofill,
.form-group input:-webkit-autofill,
#username:-webkit-autofill,
#password:-webkit-autofill {
-webkit-box-shadow: 0 0 0 9999px var(--cai-bg-input) inset !important;
-webkit-text-fill-color: var(--cai-text-primary) !important;
background-color: var(--cai-bg-input) !important;
}
/* ===== Password Input Group ===== */
/* FIX: The .pf-c-input-group has white bg from PF4, causing white corners
* behind the rounded child elements. Set transparent + matching border-radius. */
.pf-c-input-group {
display: flex !important;
align-items: stretch !important;
background-color: transparent !important;
background: transparent !important;
border-radius: 8px !important;
overflow: hidden !important;
}
.pf-c-input-group > .pf-c-form-control,
.pf-c-input-group > input.pf-c-form-control,
.pf-c-input-group > input[type="password"],
#password {
border-radius: 8px 0 0 8px !important;
border-right: none !important;
flex: 1;
}
/* Password visibility toggle */
.pf-c-button.pf-m-control,
.pf-c-input-group > .pf-c-button.pf-m-control {
background-color: var(--cai-bg-surface) !important;
color: var(--cai-text-muted) !important;
border-top: 1px solid var(--cai-border-secondary) !important;
border-right: 1px solid var(--cai-border-secondary) !important;
border-bottom: 1px solid var(--cai-border-secondary) !important;
border-left: 1px solid var(--cai-border-primary) !important;
border-radius: 0 8px 8px 0 !important;
padding: 0 14px !important;
transition: color 0.2s ease, background-color 0.2s ease !important;
line-height: 1 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.pf-c-button.pf-m-control:hover,
.pf-c-input-group > .pf-c-button.pf-m-control:hover {
color: var(--cai-accent) !important;
background-color: rgba(145, 164, 210, 0.08) !important;
}
.pf-c-button.pf-m-control:focus,
.pf-c-input-group > .pf-c-button.pf-m-control:focus {
box-shadow: none !important;
outline: none !important;
}
/* ===== Primary Button (Sign In) ===== */
.pf-c-button.pf-m-primary,
input.pf-c-button.pf-m-primary,
#kc-login {
background: linear-gradient(135deg,
var(--cai-accent),
var(--cai-accent-secondary),
var(--cai-brand-indigo),
var(--cai-accent-secondary),
var(--cai-accent)) !important;
background-size: 300% 100% !important;
animation: buttonShimmer 6s ease-in-out infinite !important;
border: none !important;
border-radius: 8px !important;
color: #0a0c10 !important;
font-family: 'Inter', sans-serif !important;
font-size: 14px !important;
font-weight: 600 !important;
padding: 12px 20px !important;
cursor: pointer !important;
transition: opacity 0.15s ease, box-shadow 0.2s ease !important;
text-shadow: none !important;
box-shadow: 0 2px 12px rgba(109, 133, 198, 0.2) !important;
width: 100%;
text-align: center;
}
.pf-c-button.pf-m-primary:hover,
input.pf-c-button.pf-m-primary:hover,
#kc-login:hover {
opacity: 0.95;
box-shadow: 0 4px 20px rgba(109, 133, 198, 0.35) !important;
}
.pf-c-button.pf-m-primary:focus,
#kc-login:focus {
box-shadow: 0 0 0 2px var(--cai-accent), 0 4px 20px rgba(109, 133, 198, 0.3) !important;
outline: none !important;
}
/* ===== Links ===== */
.login-pf-page a,
.card-pf a {
color: var(--cai-accent) !important;
text-decoration: none !important;
transition: color 0.15s ease !important;
}
.login-pf-page a:hover,
.card-pf a:hover {
color: var(--cai-accent-secondary) !important;
text-decoration: none !important;
}
/* Forgot Password link */
.login-pf-settings {
text-align: right;
margin-bottom: 24px !important;
}
.login-pf-settings a {
font-size: 13px !important;
}
/* ===== Registration / Info Section ===== */
#kc-info.login-pf-signup {
background-color: var(--cai-bg-surface) !important;
border-top: 1px solid var(--cai-border-primary) !important;
padding: 16px 32px !important;
margin: 0 -32px -32px !important;
border-radius: 0 0 12px 12px !important;
text-align: center;
}
#kc-info-wrapper,
#kc-registration {
font-size: 14px !important;
color: var(--cai-text-muted) !important;
}
#kc-registration span {
color: var(--cai-text-muted) !important;
}
/* ===== Alert / Error Messages ===== */
.alert,
.pf-c-alert {
background-color: var(--cai-bg-surface) !important;
border: 1px solid var(--cai-border-secondary) !important;
border-radius: 8px !important;
color: var(--cai-text-primary) !important;
padding: 12px 16px !important;
margin-bottom: 16px !important;
font-size: 14px !important;
}
.alert-error,
.alert-warning,
.pf-c-alert.pf-m-danger,
.pf-c-alert.pf-m-warning {
border-color: var(--cai-error) !important;
}
.alert-error .kc-feedback-text,
.pf-c-alert .pf-c-alert__title {
color: var(--cai-text-primary) !important;
}
.alert-success {
border-color: var(--cai-success) !important;
}
/* ===== Checkboxes (Remember Me) ===== */
.pf-c-check,
.login-pf-page .checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.pf-c-check__label,
.login-pf-page .checkbox label {
font-size: 13px !important;
color: var(--cai-text-muted) !important;
cursor: pointer;
}
.pf-c-check__input,
.login-pf-page input[type="checkbox"] {
accent-color: var(--cai-accent);
width: 16px;
height: 16px;
}
/* ===== Select / Dropdown ===== */
.card-pf select,
.login-pf-page select {
background-color: var(--cai-bg-input) !important;
border: 1px solid var(--cai-border-secondary) !important;
border-radius: 8px !important;
color: var(--cai-text-primary) !important;
padding: 10px 14px !important;
font-family: 'Inter', sans-serif !important;
font-size: 14px !important;
}
/* ===== Social Login / Identity Providers ===== */
#kc-social-providers {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--cai-border-primary);
}
#kc-social-providers ul {
list-style: none;
padding: 0;
margin: 0;
}
#kc-social-providers li {
margin-bottom: 8px;
}
#kc-social-providers a,
#kc-social-providers .pf-c-button {
background-color: var(--cai-bg-surface) !important;
border: 1px solid var(--cai-border-secondary) !important;
border-radius: 8px !important;
color: var(--cai-text-primary) !important;
padding: 10px 16px !important;
display: block;
text-align: center;
font-size: 14px !important;
font-weight: 500 !important;
transition: border-color 0.15s ease !important;
}
#kc-social-providers a:hover,
#kc-social-providers .pf-c-button:hover {
border-color: var(--cai-accent) !important;
}
/* ===== Form Buttons Row ===== */
#kc-form-buttons {
margin-top: 8px !important;
}
#kc-form-options {
margin-bottom: 4px;
}
/* ===== Tooltip ===== */
.kc-tooltip-text {
background-color: var(--cai-bg-surface) !important;
color: var(--cai-text-primary) !important;
border: 1px solid var(--cai-border-secondary) !important;
border-radius: 8px !important;
font-size: 13px !important;
}
/* ===== Scrollbar ===== */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--cai-bg-body);
}
::-webkit-scrollbar-thumb {
background: var(--cai-border-secondary);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--cai-text-faint);
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.login-pf-page {
padding: 24px 16px;
}
.card-pf {
padding: 24px !important;
}
#kc-header-wrapper {
font-size: 24px !important;
}
#kc-header-wrapper::before {
width: 48px;
height: 48px;
}
#kc-info.login-pf-signup {
margin: 0 -24px -24px !important;
padding: 16px 24px !important;
}
}
/* ===== Override PatternFly background images ===== */
.login-pf-page .login-pf-page-header,
.login-pf body {
background-image: none !important;
}
/* Remove any PF4 container-fluid stretching */
.container-fluid {
padding: 0 !important;
max-width: none !important;
}
/* Ensure the card doesn't stretch full width */
.login-pf-page > .card-pf {
max-width: 440px;
margin: 0 auto !important;
}

View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- Shield body -->
<path d="M32 4L8 16v16c0 14.4 10.24 27.2 24 32 13.76-4.8 24-17.6 24-32V16L32 4z"
fill="#4B3FE0" fill-opacity="0.12" stroke="#4B3FE0" stroke-width="2"
stroke-linejoin="round"/>
<!-- Inner shield highlight -->
<path d="M32 10L14 19v11c0 11.6 7.68 22 18 26 10.32-4 18-14.4 18-26V19L32 10z"
fill="none" stroke="#4B3FE0" stroke-width="1" stroke-opacity="0.3"
stroke-linejoin="round"/>
<!-- Neural network nodes -->
<circle cx="32" cy="24" r="3.5" fill="#38B2AC"/>
<circle cx="22" cy="36" r="3" fill="#38B2AC"/>
<circle cx="42" cy="36" r="3" fill="#38B2AC"/>
<circle cx="27" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
<circle cx="37" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
<!-- Neural network edges -->
<line x1="32" y1="24" x2="22" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
<line x1="32" y1="24" x2="42" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
<line x1="22" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<line x1="22" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<line x1="42" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<line x1="42" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<!-- Cross edge for connectivity -->
<line x1="22" y1="36" x2="42" y2="36" stroke="#38B2AC" stroke-width="0.8" stroke-opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
parent=keycloak
import=common/keycloak
styles=css/login.css

View File

@@ -0,0 +1,65 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes};
/// Action bar displayed above the chat input with copy, share, and edit buttons.
///
/// Only visible when there is at least one message in the conversation.
///
/// # Arguments
///
/// * `on_copy` - Copies the last assistant response to the clipboard
/// * `on_share` - Copies the full conversation as text to the clipboard
/// * `on_edit` - Places the last user message back in the input for editing
/// * `has_messages` - Whether any messages exist (hides the bar when empty)
/// * `has_assistant_message` - Whether an assistant message exists (disables copy if not)
/// * `has_user_message` - Whether a user message exists (disables edit if not)
#[component]
pub fn ChatActionBar(
on_copy: EventHandler<()>,
on_share: EventHandler<()>,
on_edit: EventHandler<()>,
has_messages: bool,
has_assistant_message: bool,
has_user_message: bool,
) -> Element {
if !has_messages {
return rsx! {};
}
rsx! {
div { class: "chat-action-bar",
button {
class: "chat-action-btn",
disabled: !has_assistant_message,
title: "Copy last response",
onclick: move |_| on_copy.call(()),
dioxus_free_icons::Icon {
icon: FaCopy,
width: 14, height: 14,
}
span { class: "chat-action-label", "Copy" }
}
button {
class: "chat-action-btn",
title: "Copy conversation",
onclick: move |_| on_share.call(()),
dioxus_free_icons::Icon {
icon: FaShareNodes,
width: 14, height: 14,
}
span { class: "chat-action-label", "Share" }
}
button {
class: "chat-action-btn",
disabled: !has_user_message,
title: "Edit last message",
onclick: move |_| on_edit.call(()),
dioxus_free_icons::Icon {
icon: FaPenToSquare,
width: 14, height: 14,
}
span { class: "chat-action-label", "Edit" }
}
}
}
}

View File

@@ -1,34 +1,82 @@
use crate::models::{ChatMessage, ChatRole};
use dioxus::prelude::*;
/// Render markdown content to HTML using `pulldown-cmark`.
///
/// # Arguments
///
/// * `md` - Raw markdown string
///
/// # Returns
///
/// HTML string suitable for `dangerous_inner_html`
fn markdown_to_html(md: &str) -> String {
use pulldown_cmark::{Options, Parser};
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(md, opts);
let mut html = String::with_capacity(md.len() * 2);
pulldown_cmark::html::push_html(&mut html, parser);
html
}
/// Renders a single chat message bubble with role-based styling.
///
/// User messages are right-aligned; assistant messages are left-aligned.
/// User messages are displayed as plain text, right-aligned.
/// Assistant messages are rendered as markdown with `pulldown-cmark`.
/// System messages are hidden from the UI.
///
/// # Arguments
///
/// * `message` - The chat message to render
#[component]
pub fn ChatBubble(message: ChatMessage) -> Element {
// System messages are not rendered in the UI
if message.role == ChatRole::System {
return rsx! {};
}
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",
ChatRole::System => unreachable!(),
};
let role_label = match message.role {
ChatRole::User => "You",
ChatRole::Assistant => "Assistant",
ChatRole::System => "System",
ChatRole::System => unreachable!(),
};
// Format timestamp for display (show time only if today)
let display_time = if message.timestamp.len() >= 16 {
// Extract HH:MM from ISO 8601
message.timestamp[11..16].to_string()
} else {
message.timestamp.clone()
};
let is_assistant = message.role == ChatRole::Assistant;
rsx! {
div { class: "{bubble_class}",
div { class: "chat-bubble-header",
span { class: "chat-bubble-role", "{role_label}" }
span { class: "chat-bubble-time", "{message.timestamp}" }
span { class: "chat-bubble-time", "{display_time}" }
}
if is_assistant {
// Render markdown for assistant messages
div {
class: "chat-bubble-content chat-prose",
dangerous_inner_html: "{markdown_to_html(&message.content)}",
}
} else {
div { class: "chat-bubble-content", "{message.content}" }
}
if !message.attachments.is_empty() {
div { class: "chat-bubble-attachments",
for att in &message.attachments {
@@ -39,3 +87,45 @@ pub fn ChatBubble(message: ChatMessage) -> Element {
}
}
}
/// Renders a streaming assistant message bubble.
///
/// While waiting for tokens, shows a "Thinking..." indicator with
/// a pulsing dot animation. Once tokens arrive, renders them as
/// markdown with a blinking cursor.
///
/// # Arguments
///
/// * `content` - The accumulated streaming content so far
#[component]
pub fn StreamingBubble(content: String) -> Element {
if content.is_empty() {
// Thinking state -- no tokens yet
rsx! {
div { class: "chat-bubble chat-bubble--assistant chat-bubble--thinking",
div { class: "chat-thinking",
span { class: "chat-thinking-dots",
span { class: "chat-dot" }
span { class: "chat-dot" }
span { class: "chat-dot" }
}
span { class: "chat-thinking-text", "Thinking..." }
}
}
}
} else {
let html = markdown_to_html(&content);
rsx! {
div { class: "chat-bubble chat-bubble--assistant chat-bubble--streaming",
div { class: "chat-bubble-header",
span { class: "chat-bubble-role", "Assistant" }
}
div {
class: "chat-bubble-content chat-prose",
dangerous_inner_html: "{html}",
}
span { class: "chat-streaming-cursor" }
}
}
}
}

View File

@@ -0,0 +1,69 @@
use dioxus::prelude::*;
/// Chat input bar with a textarea and send button.
///
/// Enter sends the message; Shift+Enter inserts a newline.
/// The input is disabled during streaming.
///
/// # Arguments
///
/// * `input_text` - Two-way bound input text signal
/// * `on_send` - Callback fired with the message text when sent
/// * `is_streaming` - Whether to disable the input (streaming in progress)
#[component]
pub fn ChatInputBar(
input_text: Signal<String>,
on_send: EventHandler<String>,
is_streaming: bool,
) -> Element {
let mut input = input_text;
rsx! {
div { class: "chat-input-bar",
textarea {
class: "chat-input",
placeholder: "Type a message...",
disabled: is_streaming,
rows: "1",
value: "{input}",
oninput: move |e: Event<FormData>| {
input.set(e.value());
},
onkeypress: move |e: Event<KeyboardData>| {
// Enter sends, Shift+Enter adds newline
if e.key() == Key::Enter && !e.modifiers().shift() {
e.prevent_default();
let text = input.read().trim().to_string();
if !text.is_empty() {
on_send.call(text);
input.set(String::new());
}
}
},
}
button {
class: "btn-primary chat-send-btn",
disabled: is_streaming || input.read().trim().is_empty(),
onclick: move |_| {
let text = input.read().trim().to_string();
if !text.is_empty() {
on_send.call(text);
input.set(String::new());
}
},
if is_streaming {
// Stop icon during streaming
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaStop,
width: 16, height: 16,
}
} else {
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaPaperPlane,
width: 16, height: 16,
}
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
use crate::components::{ChatBubble, StreamingBubble};
use crate::models::ChatMessage;
use dioxus::prelude::*;
/// Scrollable message list that renders all messages in a chat session.
///
/// Auto-scrolls to the bottom when new messages arrive or during streaming.
/// Shows a streaming bubble with a blinking cursor when `is_streaming` is true.
///
/// # Arguments
///
/// * `messages` - All loaded messages for the current session
/// * `streaming_content` - Accumulated content from the SSE stream
/// * `is_streaming` - Whether a response is currently streaming
#[component]
pub fn ChatMessageList(
messages: Vec<ChatMessage>,
streaming_content: String,
is_streaming: bool,
) -> Element {
rsx! {
div {
class: "chat-message-list",
id: "chat-message-list",
if messages.is_empty() && !is_streaming {
div { class: "chat-empty",
p { "Send a message to start the conversation." }
}
}
for msg in &messages {
ChatBubble { key: "{msg.id}", message: msg.clone() }
}
if is_streaming {
StreamingBubble { content: streaming_content }
}
}
}
}

View File

@@ -0,0 +1,42 @@
use dioxus::prelude::*;
/// Dropdown bar for selecting the LLM model for the current chat session.
///
/// Displays the currently selected model and a list of available models
/// from the Ollama instance. Fires `on_change` when the user selects
/// a different model.
///
/// # Arguments
///
/// * `selected_model` - The currently active model ID
/// * `available_models` - List of model names from Ollama
/// * `on_change` - Callback fired with the new model name
#[component]
pub fn ChatModelSelector(
selected_model: String,
available_models: Vec<String>,
on_change: EventHandler<String>,
) -> Element {
rsx! {
div { class: "chat-model-bar",
label { class: "chat-model-label", "Model:" }
select {
class: "chat-model-select",
value: "{selected_model}",
onchange: move |e: Event<FormData>| {
on_change.call(e.value());
},
for model in &available_models {
option {
value: "{model}",
selected: *model == selected_model,
"{model}"
}
}
if available_models.is_empty() {
option { disabled: true, "No models available" }
}
}
}
}
}

View File

@@ -0,0 +1,226 @@
use crate::models::{ChatNamespace, ChatSession};
use dioxus::prelude::*;
/// Chat sidebar displaying grouped session list with actions.
///
/// Sessions are split into "News Chats" and "General" sections.
/// Each session item shows the title and relative date, with
/// rename and delete actions on hover.
///
/// # Arguments
///
/// * `sessions` - All chat sessions for the user
/// * `active_session_id` - Currently selected session ID (highlighted)
/// * `on_select` - Callback when a session is clicked
/// * `on_new` - Callback to create a new chat session
/// * `on_rename` - Callback with `(session_id, new_title)`
/// * `on_delete` - Callback with `session_id`
#[component]
pub fn ChatSidebar(
sessions: Vec<ChatSession>,
active_session_id: Option<String>,
on_select: EventHandler<String>,
on_new: EventHandler<()>,
on_rename: EventHandler<(String, String)>,
on_delete: EventHandler<String>,
) -> Element {
// Split sessions by namespace
let news_sessions: Vec<&ChatSession> = sessions
.iter()
.filter(|s| s.namespace == ChatNamespace::News)
.collect();
let general_sessions: Vec<&ChatSession> = sessions
.iter()
.filter(|s| s.namespace == ChatNamespace::General)
.collect();
// Signal for inline rename state: Option<(session_id, current_value)>
let rename_state: Signal<Option<(String, String)>> = use_signal(|| None);
rsx! {
div { class: "chat-sidebar-panel",
div { class: "chat-sidebar-header",
h3 { "Conversations" }
button {
class: "btn-icon",
title: "New Chat",
onclick: move |_| on_new.call(()),
"+"
}
}
div { class: "chat-session-list",
// News Chats section
if !news_sessions.is_empty() {
div { class: "chat-namespace-header", "News Chats" }
for session in &news_sessions {
SessionItem {
session: (*session).clone(),
is_active: active_session_id.as_deref() == Some(&session.id),
rename_state: rename_state,
on_select: on_select,
on_rename: on_rename,
on_delete: on_delete,
}
}
}
// General section
div { class: "chat-namespace-header",
if news_sessions.is_empty() { "All Chats" } else { "General" }
}
if general_sessions.is_empty() {
p { class: "chat-empty-hint", "No conversations yet" }
}
for session in &general_sessions {
SessionItem {
session: (*session).clone(),
is_active: active_session_id.as_deref() == Some(&session.id),
rename_state: rename_state,
on_select: on_select,
on_rename: on_rename,
on_delete: on_delete,
}
}
}
}
}
}
/// Individual session item component. Handles rename inline editing.
#[component]
fn SessionItem(
session: ChatSession,
is_active: bool,
rename_state: Signal<Option<(String, String)>>,
on_select: EventHandler<String>,
on_rename: EventHandler<(String, String)>,
on_delete: EventHandler<String>,
) -> Element {
let mut rename_sig = rename_state;
let item_class = if is_active {
"chat-session-item chat-session-item--active"
} else {
"chat-session-item"
};
let is_renaming = rename_sig
.read()
.as_ref()
.is_some_and(|(id, _)| id == &session.id);
let session_id = session.id.clone();
let session_title = session.title.clone();
let date_display = format_relative_date(&session.updated_at);
if is_renaming {
let rename_value = rename_sig
.read()
.as_ref()
.map(|(_, v)| v.clone())
.unwrap_or_default();
let sid = session_id.clone();
rsx! {
div { class: "{item_class}",
input {
class: "chat-session-rename-input",
r#type: "text",
value: "{rename_value}",
autofocus: true,
oninput: move |e: Event<FormData>| {
let val = e.value();
let id = sid.clone();
rename_sig.set(Some((id, val)));
},
onkeypress: move |e: Event<KeyboardData>| {
if e.key() == Key::Enter {
if let Some((id, val)) = rename_sig.read().clone() {
if !val.trim().is_empty() {
on_rename.call((id, val));
}
}
rename_sig.set(None);
} else if e.key() == Key::Escape {
rename_sig.set(None);
}
},
onfocusout: move |_| {
if let Some((ref id, ref val)) = *rename_sig.read() {
if !val.trim().is_empty() {
on_rename.call((id.clone(), val.clone()));
}
}
rename_sig.set(None);
},
}
}
}
} else {
let sid_select = session_id.clone();
let sid_delete = session_id.clone();
let sid_rename = session_id.clone();
let title_for_rename = session_title.clone();
rsx! {
div {
class: "{item_class}",
onclick: move |_| on_select.call(sid_select.clone()),
div { class: "chat-session-info",
span { class: "chat-session-title", "{session_title}" }
span { class: "chat-session-date", "{date_display}" }
}
div { class: "chat-session-actions",
button {
class: "btn-icon-sm",
title: "Rename",
onclick: move |e: Event<MouseData>| {
e.stop_propagation();
rename_sig.set(Some((
sid_rename.clone(),
title_for_rename.clone(),
)));
},
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaPen,
width: 12, height: 12,
}
}
button {
class: "btn-icon-sm btn-icon-danger",
title: "Delete",
onclick: move |e: Event<MouseData>| {
e.stop_propagation();
on_delete.call(sid_delete.clone());
},
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaTrash,
width: 12, height: 12,
}
}
}
}
}
}
}
/// Format an ISO 8601 timestamp as a relative date string.
fn format_relative_date(iso: &str) -> String {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) {
let now = chrono::Utc::now();
let diff = now.signed_duration_since(dt);
if diff.num_minutes() < 1 {
"just now".to_string()
} else if diff.num_hours() < 1 {
format!("{}m ago", diff.num_minutes())
} else if diff.num_hours() < 24 {
format!("{}h ago", diff.num_hours())
} else if diff.num_days() < 7 {
format!("{}d ago", diff.num_days())
} else {
dt.format("%b %d").to_string()
}
} else {
iso.to_string()
}
}

View File

@@ -1,7 +1,12 @@
mod app_shell;
mod article_detail;
mod card;
mod chat_action_bar;
mod chat_bubble;
mod chat_input_bar;
mod chat_message_list;
mod chat_model_selector;
mod chat_sidebar;
mod dashboard_sidebar;
mod file_row;
mod login;
@@ -16,7 +21,12 @@ mod tool_card;
pub use app_shell::*;
pub use article_detail::*;
pub use card::*;
pub use chat_action_bar::*;
pub use chat_bubble::*;
pub use chat_input_bar::*;
pub use chat_message_list::*;
pub use chat_model_selector::*;
pub use chat_sidebar::*;
pub use dashboard_sidebar::*;
pub use file_row::*;
pub use login::*;

View File

@@ -245,6 +245,11 @@ fn SidebarFooter() -> Element {
Icon { icon: BsGrid, width: 16, height: 16 }
}
}
div { class: "sidebar-legal",
Link { to: Route::PrivacyPage {}, class: "legal-link", "Privacy Policy" }
span { class: "legal-sep", "|" }
Link { to: Route::ImpressumPage {}, class: "legal-link", "Impressum" }
}
p { class: "sidebar-version", "v{version}" }
}
}

507
src/infrastructure/chat.rs Normal file
View File

@@ -0,0 +1,507 @@
//! Chat CRUD server functions for session and message persistence.
//!
//! Each function extracts the user's `sub` from the tower-sessions session
//! to scope all queries to the authenticated user. The `ServerState` provides
//! access to the MongoDB [`Database`](super::database::Database).
use crate::models::{ChatMessage, ChatSession};
use dioxus::prelude::*;
/// Convert a raw BSON document to a `ChatSession`, extracting `_id` as a hex string.
#[cfg(feature = "server")]
pub(crate) fn doc_to_chat_session(doc: &mongodb::bson::Document) -> ChatSession {
use crate::models::ChatNamespace;
let id = doc
.get_object_id("_id")
.map(|oid| oid.to_hex())
.unwrap_or_default();
let namespace = match doc.get_str("namespace").unwrap_or("General") {
"News" => ChatNamespace::News,
_ => ChatNamespace::General,
};
let article_url = doc
.get_str("article_url")
.ok()
.map(String::from)
.filter(|s| !s.is_empty());
ChatSession {
id,
user_sub: doc.get_str("user_sub").unwrap_or_default().to_string(),
title: doc.get_str("title").unwrap_or_default().to_string(),
namespace,
provider: doc.get_str("provider").unwrap_or_default().to_string(),
model: doc.get_str("model").unwrap_or_default().to_string(),
created_at: doc.get_str("created_at").unwrap_or_default().to_string(),
updated_at: doc.get_str("updated_at").unwrap_or_default().to_string(),
article_url,
}
}
/// Convert a raw BSON document to a `ChatMessage`, extracting `_id` as a hex string.
#[cfg(feature = "server")]
pub(crate) fn doc_to_chat_message(doc: &mongodb::bson::Document) -> ChatMessage {
use crate::models::ChatRole;
let id = doc
.get_object_id("_id")
.map(|oid| oid.to_hex())
.unwrap_or_default();
let role = match doc.get_str("role").unwrap_or("User") {
"Assistant" => ChatRole::Assistant,
"System" => ChatRole::System,
_ => ChatRole::User,
};
ChatMessage {
id,
session_id: doc.get_str("session_id").unwrap_or_default().to_string(),
role,
content: doc.get_str("content").unwrap_or_default().to_string(),
attachments: Vec::new(),
timestamp: doc.get_str("timestamp").unwrap_or_default().to_string(),
}
}
/// Helper: extract the authenticated user's `sub` from the session.
///
/// # Errors
///
/// Returns `ServerFnError` if the session is missing or unreadable.
#[cfg(feature = "server")]
async fn require_user_sub() -> Result<String, ServerFnError> {
use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY;
use crate::infrastructure::state::UserStateInner;
use dioxus_fullstack::FullstackContext;
let session: tower_sessions::Session = FullstackContext::extract().await?;
let user: UserStateInner = session
.get(LOGGED_IN_USER_SESS_KEY)
.await
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?
.ok_or_else(|| ServerFnError::new("not authenticated"))?;
Ok(user.sub)
}
/// Helper: extract the [`ServerState`] from the request context.
#[cfg(feature = "server")]
async fn require_state() -> Result<crate::infrastructure::ServerState, ServerFnError> {
dioxus_fullstack::FullstackContext::extract().await
}
/// List all chat sessions for the authenticated user, ordered by
/// `updated_at` descending (most recent first).
///
/// # Errors
///
/// Returns `ServerFnError` if authentication or the database query fails.
#[server(endpoint = "list-chat-sessions")]
pub async fn list_chat_sessions() -> Result<Vec<ChatSession>, ServerFnError> {
use mongodb::bson::doc;
use mongodb::options::FindOptions;
let user_sub = require_user_sub().await?;
let state = require_state().await?;
let opts = FindOptions::builder()
.sort(doc! { "updated_at": -1 })
.build();
let mut cursor = state
.db
.raw_collection("chat_sessions")
.find(doc! { "user_sub": &user_sub })
.with_options(opts)
.await
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
let mut sessions = Vec::new();
use futures::TryStreamExt;
while let Some(raw_doc) = cursor
.try_next()
.await
.map_err(|e| ServerFnError::new(format!("cursor error: {e}")))?
{
sessions.push(doc_to_chat_session(&raw_doc));
}
Ok(sessions)
}
/// Create a new chat session and return it with the MongoDB-generated ID.
///
/// # Arguments
///
/// * `title` - Display title for the session
/// * `namespace` - Namespace string: `"General"` or `"News"`
/// * `provider` - LLM provider name (e.g. "ollama")
/// * `model` - Model ID (e.g. "llama3.1:8b")
/// * `article_url` - Source article URL (only for `News` namespace, empty if none)
///
/// # Errors
///
/// Returns `ServerFnError` if authentication or the insert fails.
#[server(endpoint = "create-chat-session")]
pub async fn create_chat_session(
title: String,
namespace: String,
provider: String,
model: String,
article_url: String,
) -> Result<ChatSession, ServerFnError> {
use crate::models::ChatNamespace;
let user_sub = require_user_sub().await?;
let state = require_state().await?;
let ns = if namespace == "News" {
ChatNamespace::News
} else {
ChatNamespace::General
};
let url = if article_url.is_empty() {
None
} else {
Some(article_url)
};
let now = chrono::Utc::now().to_rfc3339();
let session = ChatSession {
id: String::new(), // MongoDB will generate _id
user_sub,
title,
namespace: ns,
provider,
model,
created_at: now.clone(),
updated_at: now,
article_url: url,
};
let result = state
.db
.chat_sessions()
.insert_one(&session)
.await
.map_err(|e| ServerFnError::new(format!("insert failed: {e}")))?;
// Return the session with the generated ID
let id = result
.inserted_id
.as_object_id()
.map(|oid| oid.to_hex())
.unwrap_or_default();
Ok(ChatSession { id, ..session })
}
/// Rename a chat session.
///
/// # Arguments
///
/// * `session_id` - The MongoDB document ID of the session
/// * `new_title` - The new title to set
///
/// # Errors
///
/// Returns `ServerFnError` if authentication, the session is not found,
/// or the update fails.
#[server(endpoint = "rename-chat-session")]
pub async fn rename_chat_session(
session_id: String,
new_title: String,
) -> Result<(), ServerFnError> {
use mongodb::bson::{doc, oid::ObjectId};
let user_sub = require_user_sub().await?;
let state = require_state().await?;
let oid = ObjectId::parse_str(&session_id)
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
let result = state
.db
.chat_sessions()
.update_one(
doc! { "_id": oid, "user_sub": &user_sub },
doc! { "$set": { "title": &new_title, "updated_at": chrono::Utc::now().to_rfc3339() } },
)
.await
.map_err(|e| ServerFnError::new(format!("update failed: {e}")))?;
if result.matched_count == 0 {
return Err(ServerFnError::new("session not found or not owned by user"));
}
Ok(())
}
/// Delete a chat session and all its messages.
///
/// # Arguments
///
/// * `session_id` - The MongoDB document ID of the session
///
/// # Errors
///
/// Returns `ServerFnError` if authentication or the delete fails.
#[server(endpoint = "delete-chat-session")]
pub async fn delete_chat_session(session_id: String) -> Result<(), ServerFnError> {
use mongodb::bson::{doc, oid::ObjectId};
let user_sub = require_user_sub().await?;
let state = require_state().await?;
let oid = ObjectId::parse_str(&session_id)
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
// Delete the session (scoped to user)
state
.db
.chat_sessions()
.delete_one(doc! { "_id": oid, "user_sub": &user_sub })
.await
.map_err(|e| ServerFnError::new(format!("delete session failed: {e}")))?;
// Delete all messages belonging to this session
state
.db
.chat_messages()
.delete_many(doc! { "session_id": &session_id })
.await
.map_err(|e| ServerFnError::new(format!("delete messages failed: {e}")))?;
Ok(())
}
/// Load all messages for a chat session, ordered by timestamp ascending.
///
/// # Arguments
///
/// * `session_id` - The MongoDB document ID of the session
///
/// # Errors
///
/// Returns `ServerFnError` if authentication or the query fails.
#[server(endpoint = "list-chat-messages")]
pub async fn list_chat_messages(session_id: String) -> Result<Vec<ChatMessage>, ServerFnError> {
use mongodb::bson::doc;
use mongodb::options::FindOptions;
// Verify the user owns this session
let user_sub = require_user_sub().await?;
let state = require_state().await?;
// Verify the user owns this session using ObjectId for _id matching
use mongodb::bson::oid::ObjectId;
let session_oid = ObjectId::parse_str(&session_id)
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
let session_exists = state
.db
.raw_collection("chat_sessions")
.count_documents(doc! { "_id": session_oid, "user_sub": &user_sub })
.await
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
if session_exists == 0 {
return Err(ServerFnError::new("session not found or not owned by user"));
}
let opts = FindOptions::builder().sort(doc! { "timestamp": 1 }).build();
let mut cursor = state
.db
.raw_collection("chat_messages")
.find(doc! { "session_id": &session_id })
.with_options(opts)
.await
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
let mut messages = Vec::new();
use futures::TryStreamExt;
while let Some(raw_doc) = cursor
.try_next()
.await
.map_err(|e| ServerFnError::new(format!("cursor error: {e}")))?
{
messages.push(doc_to_chat_message(&raw_doc));
}
Ok(messages)
}
/// Persist a single chat message and return it with the MongoDB-generated ID.
///
/// Also updates the parent session's `updated_at` timestamp.
///
/// # Arguments
///
/// * `session_id` - The session this message belongs to
/// * `role` - Message role string: `"user"`, `"assistant"`, or `"system"`
/// * `content` - Message text content
///
/// # Errors
///
/// Returns `ServerFnError` if authentication or the insert fails.
#[server(endpoint = "save-chat-message")]
pub async fn save_chat_message(
session_id: String,
role: String,
content: String,
) -> Result<ChatMessage, ServerFnError> {
use crate::models::ChatRole;
use mongodb::bson::{doc, oid::ObjectId};
let _user_sub = require_user_sub().await?;
let state = require_state().await?;
let chat_role = match role.as_str() {
"assistant" => ChatRole::Assistant,
"system" => ChatRole::System,
_ => ChatRole::User,
};
let now = chrono::Utc::now().to_rfc3339();
let message = ChatMessage {
id: String::new(),
session_id: session_id.clone(),
role: chat_role,
content,
attachments: Vec::new(),
timestamp: now.clone(),
};
let result = state
.db
.chat_messages()
.insert_one(&message)
.await
.map_err(|e| ServerFnError::new(format!("insert failed: {e}")))?;
let id = result
.inserted_id
.as_object_id()
.map(|oid| oid.to_hex())
.unwrap_or_default();
// Update session's updated_at timestamp
if let Ok(session_oid) = ObjectId::parse_str(&session_id) {
let _ = state
.db
.chat_sessions()
.update_one(
doc! { "_id": session_oid },
doc! { "$set": { "updated_at": &now } },
)
.await;
}
Ok(ChatMessage { id, ..message })
}
/// Non-streaming chat completion (fallback for article panel).
///
/// Sends the full conversation history to the configured LLM provider
/// and returns the complete response. Used where SSE streaming is not
/// needed (e.g. dashboard article follow-up panel).
///
/// # Arguments
///
/// * `session_id` - The chat session ID (loads provider/model config)
/// * `messages_json` - Conversation history as JSON string:
/// `[{"role":"user","content":"..."},...]`
///
/// # Errors
///
/// Returns `ServerFnError` if the LLM request fails.
#[server(endpoint = "chat-complete")]
pub async fn chat_complete(
session_id: String,
messages_json: String,
) -> Result<String, ServerFnError> {
use mongodb::bson::{doc, oid::ObjectId};
let _user_sub = require_user_sub().await?;
let state = require_state().await?;
// Load the session to get provider/model
let session_oid = ObjectId::parse_str(&session_id)
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
let session_doc = state
.db
.raw_collection("chat_sessions")
.find_one(doc! { "_id": session_oid })
.await
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?
.ok_or_else(|| ServerFnError::new("session not found"))?;
let session = doc_to_chat_session(&session_doc);
// Resolve provider URL and model
let (base_url, model) = resolve_provider_url(&state, &session.provider, &session.model);
// Parse messages from JSON
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
.map_err(|e| ServerFnError::new(format!("invalid messages JSON: {e}")))?;
let body = serde_json::json!({
"model": model,
"messages": chat_msgs,
"stream": false,
});
let client = reqwest::Client::new();
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let resp = client
.post(&url)
.header("content-type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| ServerFnError::new(format!("LLM request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!("LLM returned {status}: {text}")));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| ServerFnError::new(format!("parse error: {e}")))?;
json["choices"][0]["message"]["content"]
.as_str()
.map(String::from)
.ok_or_else(|| ServerFnError::new("empty LLM response"))
}
/// Resolve the base URL for a provider, falling back to server defaults.
#[cfg(feature = "server")]
fn resolve_provider_url(
state: &crate::infrastructure::ServerState,
provider: &str,
model: &str,
) -> (String, String) {
match provider {
"openai" => ("https://api.openai.com".to_string(), model.to_string()),
"anthropic" => ("https://api.anthropic.com".to_string(), model.to_string()),
"huggingface" => (
format!("https://api-inference.huggingface.co/models/{}", model),
model.to_string(),
),
// Default to Ollama
_ => (
state.services.ollama_url.clone(),
if model.is_empty() {
state.services.ollama_model.clone()
} else {
model.to_string()
},
),
}
}

View File

@@ -0,0 +1,266 @@
//! SSE streaming endpoint for chat completions.
//!
//! Exposes `GET /api/chat/stream?session_id=<id>` which:
//! 1. Authenticates the user via tower-sessions
//! 2. Loads the session and its messages from MongoDB
//! 3. Streams LLM tokens as SSE events to the frontend
//! 4. Persists the complete assistant message on finish
use axum::{
extract::Query,
response::{
sse::{Event, KeepAlive, Sse},
IntoResponse, Response,
},
Extension,
};
use futures::stream::Stream;
use reqwest::StatusCode;
use serde::Deserialize;
use tower_sessions::Session;
use super::{
auth::LOGGED_IN_USER_SESS_KEY,
chat::{doc_to_chat_message, doc_to_chat_session},
provider_client::{send_chat_request, ProviderMessage},
server_state::ServerState,
state::UserStateInner,
};
use crate::models::{ChatMessage, ChatRole};
/// Query parameters for the SSE stream endpoint.
#[derive(Deserialize)]
pub struct StreamQuery {
session_id: String,
}
/// SSE streaming handler for chat completions.
///
/// Reads the session's provider/model config, loads conversation history,
/// sends to the LLM with `stream: true`, and forwards tokens as SSE events.
///
/// # SSE Event Format
///
/// - `data: {"token": "..."}` -- partial token
/// - `data: {"done": true, "message_id": "..."}` -- stream complete
/// - `data: {"error": "..."}` -- on failure
pub async fn chat_stream_handler(
session: Session,
Extension(state): Extension<ServerState>,
Query(params): Query<StreamQuery>,
) -> Response {
// Authenticate
let user_state: Option<UserStateInner> = match session.get(LOGGED_IN_USER_SESS_KEY).await {
Ok(u) => u,
Err(_) => return (StatusCode::UNAUTHORIZED, "session error").into_response(),
};
let user = match user_state {
Some(u) => u,
None => return (StatusCode::UNAUTHORIZED, "not authenticated").into_response(),
};
// Load session from MongoDB (raw document to handle ObjectId -> String)
let chat_session = {
use mongodb::bson::{doc, oid::ObjectId};
let oid = match ObjectId::parse_str(&params.session_id) {
Ok(o) => o,
Err(_) => return (StatusCode::BAD_REQUEST, "invalid session_id").into_response(),
};
match state
.db
.raw_collection("chat_sessions")
.find_one(doc! { "_id": oid, "user_sub": &user.sub })
.await
{
Ok(Some(doc)) => doc_to_chat_session(&doc),
Ok(None) => return (StatusCode::NOT_FOUND, "session not found").into_response(),
Err(e) => {
tracing::error!("db error loading session: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();
}
}
};
// Load messages (raw documents to handle ObjectId -> String)
let messages = {
use mongodb::bson::doc;
use mongodb::options::FindOptions;
let opts = FindOptions::builder().sort(doc! { "timestamp": 1 }).build();
match state
.db
.raw_collection("chat_messages")
.find(doc! { "session_id": &params.session_id })
.with_options(opts)
.await
{
Ok(mut cursor) => {
use futures::TryStreamExt;
let mut msgs = Vec::new();
while let Some(doc) = TryStreamExt::try_next(&mut cursor).await.unwrap_or(None) {
msgs.push(doc_to_chat_message(&doc));
}
msgs
}
Err(e) => {
tracing::error!("db error loading messages: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();
}
}
};
// Convert to provider format
let provider_msgs: Vec<ProviderMessage> = messages
.iter()
.map(|m| ProviderMessage {
role: match m.role {
ChatRole::User => "user".to_string(),
ChatRole::Assistant => "assistant".to_string(),
ChatRole::System => "system".to_string(),
},
content: m.content.clone(),
})
.collect();
let provider = chat_session.provider.clone();
let model = chat_session.model.clone();
let session_id = params.session_id.clone();
// TODO: Load user's API key from preferences for non-Ollama providers.
// For now, Ollama (no key needed) is the default path.
let api_key: Option<String> = None;
// Send streaming request to LLM
let llm_resp = match send_chat_request(
&state,
&provider,
&model,
&provider_msgs,
api_key.as_deref(),
true,
)
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("LLM request failed: {e}");
return (StatusCode::BAD_GATEWAY, "LLM request failed").into_response();
}
};
if !llm_resp.status().is_success() {
let status = llm_resp.status();
let body = llm_resp.text().await.unwrap_or_default();
tracing::error!("LLM returned {status}: {body}");
return (StatusCode::BAD_GATEWAY, format!("LLM error: {status}")).into_response();
}
// Stream the response bytes as SSE events
let byte_stream = llm_resp.bytes_stream();
let state_clone = state.clone();
let sse_stream = build_sse_stream(byte_stream, state_clone, session_id, provider.clone());
Sse::new(sse_stream)
.keep_alive(KeepAlive::default())
.into_response()
}
/// Build an SSE stream that parses OpenAI-compatible streaming chunks
/// and emits token events. On completion, persists the full message.
fn build_sse_stream(
byte_stream: impl Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
state: ServerState,
session_id: String,
_provider: String,
) -> impl Stream<Item = Result<Event, std::convert::Infallible>> + Send + 'static {
// Use an async stream to process chunks
async_stream::stream! {
use futures::StreamExt;
let mut full_content = String::new();
let mut buffer = String::new();
// Pin the byte stream for iteration
let mut stream = std::pin::pin!(byte_stream);
while let Some(chunk_result) = StreamExt::next(&mut stream).await {
let chunk = match chunk_result {
Ok(bytes) => bytes,
Err(e) => {
let err_json = serde_json::json!({ "error": e.to_string() });
yield Ok(Event::default().data(err_json.to_string()));
break;
}
};
let text = String::from_utf8_lossy(&chunk);
buffer.push_str(&text);
// Process complete SSE lines from the buffer.
// OpenAI streaming format: `data: {...}\n\n`
while let Some(line_end) = buffer.find('\n') {
let line = buffer[..line_end].trim().to_string();
buffer = buffer[line_end + 1..].to_string();
if line.is_empty() || line == "data: [DONE]" {
continue;
}
if let Some(json_str) = line.strip_prefix("data: ") {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str) {
// Extract token from OpenAI delta format
if let Some(token) = parsed["choices"][0]["delta"]["content"].as_str() {
full_content.push_str(token);
let event_data = serde_json::json!({ "token": token });
yield Ok(Event::default().data(event_data.to_string()));
}
}
}
}
}
// Persist the complete assistant message
if !full_content.is_empty() {
let now = chrono::Utc::now().to_rfc3339();
let message = ChatMessage {
id: String::new(),
session_id: session_id.clone(),
role: ChatRole::Assistant,
content: full_content,
attachments: Vec::new(),
timestamp: now.clone(),
};
let msg_id = match state.db.chat_messages().insert_one(&message).await {
Ok(result) => result
.inserted_id
.as_object_id()
.map(|oid| oid.to_hex())
.unwrap_or_default(),
Err(e) => {
tracing::error!("failed to persist assistant message: {e}");
String::new()
}
};
// Update session timestamp
if let Ok(session_oid) =
mongodb::bson::oid::ObjectId::parse_str(&session_id)
{
let _ = state
.db
.chat_sessions()
.update_one(
mongodb::bson::doc! { "_id": session_oid },
mongodb::bson::doc! { "$set": { "updated_at": &now } },
)
.await;
}
let done_data = serde_json::json!({ "done": true, "message_id": msg_id });
yield Ok(Event::default().data(done_data.to_string()));
}
}
}

View File

@@ -3,7 +3,7 @@
use mongodb::{bson::doc, Client, Collection};
use super::Error;
use crate::models::{OrgBillingRecord, OrgSettings, UserPreferences};
use crate::models::{ChatMessage, ChatSession, OrgBillingRecord, OrgSettings, UserPreferences};
/// Thin wrapper around [`mongodb::Database`] that provides typed
/// collection accessors for the application's domain models.
@@ -49,4 +49,20 @@ impl Database {
pub fn org_billing(&self) -> Collection<OrgBillingRecord> {
self.inner.collection("org_billing")
}
/// Collection for persisted chat sessions (sidebar listing).
pub fn chat_sessions(&self) -> Collection<ChatSession> {
self.inner.collection("chat_sessions")
}
/// Collection for individual chat messages within sessions.
pub fn chat_messages(&self) -> Collection<ChatMessage> {
self.inner.collection("chat_messages")
}
/// Raw BSON document collection for queries that need manual
/// `_id` → `String` conversion (avoids `ObjectId` deserialization issues).
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
self.inner.collection(name)
}
}

View File

@@ -1,6 +1,7 @@
// Server function modules (compiled for both web and server features;
// the #[server] macro generates client stubs for the web target)
pub mod auth_check;
pub mod chat;
pub mod llm;
pub mod ollama;
pub mod searxng;
@@ -11,12 +12,16 @@ mod auth;
#[cfg(feature = "server")]
mod auth_middleware;
#[cfg(feature = "server")]
mod chat_stream;
#[cfg(feature = "server")]
pub mod config;
#[cfg(feature = "server")]
pub mod database;
#[cfg(feature = "server")]
mod error;
#[cfg(feature = "server")]
pub mod provider_client;
#[cfg(feature = "server")]
mod server;
#[cfg(feature = "server")]
pub mod server_state;
@@ -28,6 +33,8 @@ pub use auth::*;
#[cfg(feature = "server")]
pub use auth_middleware::*;
#[cfg(feature = "server")]
pub use chat_stream::*;
#[cfg(feature = "server")]
pub use error::*;
#[cfg(feature = "server")]
pub use server::*;

View File

@@ -0,0 +1,148 @@
//! Unified LLM provider dispatch.
//!
//! Routes chat completion requests to Ollama, OpenAI, Anthropic, or
//! HuggingFace based on the session's provider setting. All providers
//! except Anthropic use the OpenAI-compatible chat completions format.
use reqwest::Client;
use serde::{Deserialize, Serialize};
use super::server_state::ServerState;
/// OpenAI-compatible chat message used for request bodies.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderMessage {
pub role: String,
pub content: String,
}
/// Send a chat completion request to the configured provider.
///
/// # Arguments
///
/// * `state` - Server state (for default Ollama URL/model)
/// * `provider` - Provider name (`"ollama"`, `"openai"`, `"anthropic"`, `"huggingface"`)
/// * `model` - Model ID
/// * `messages` - Conversation history
/// * `api_key` - API key (required for non-Ollama providers)
/// * `stream` - Whether to request streaming
///
/// # Returns
///
/// The raw `reqwest::Response` for the caller to consume (streaming or not).
///
/// # Errors
///
/// Returns an error if the HTTP request fails.
pub async fn send_chat_request(
state: &ServerState,
provider: &str,
model: &str,
messages: &[ProviderMessage],
api_key: Option<&str>,
stream: bool,
) -> Result<reqwest::Response, reqwest::Error> {
let client = Client::new();
match provider {
"openai" => {
let body = serde_json::json!({
"model": model,
"messages": messages,
"stream": stream,
});
client
.post("https://api.openai.com/v1/chat/completions")
.header("content-type", "application/json")
.header(
"Authorization",
format!("Bearer {}", api_key.unwrap_or_default()),
)
.json(&body)
.send()
.await
}
"anthropic" => {
// Anthropic uses a different API format -- translate.
// Extract system message separately, convert roles.
let system_msg: String = messages
.iter()
.filter(|m| m.role == "system")
.map(|m| m.content.clone())
.collect::<Vec<_>>()
.join("\n");
let anthropic_msgs: Vec<serde_json::Value> = messages
.iter()
.filter(|m| m.role != "system")
.map(|m| {
serde_json::json!({
"role": m.role,
"content": m.content,
})
})
.collect();
let mut body = serde_json::json!({
"model": model,
"messages": anthropic_msgs,
"max_tokens": 4096,
"stream": stream,
});
if !system_msg.is_empty() {
body["system"] = serde_json::Value::String(system_msg);
}
client
.post("https://api.anthropic.com/v1/messages")
.header("content-type", "application/json")
.header("x-api-key", api_key.unwrap_or_default())
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
}
"huggingface" => {
let url = format!(
"https://api-inference.huggingface.co/models/{}/v1/chat/completions",
model
);
let body = serde_json::json!({
"model": model,
"messages": messages,
"stream": stream,
});
client
.post(&url)
.header("content-type", "application/json")
.header(
"Authorization",
format!("Bearer {}", api_key.unwrap_or_default()),
)
.json(&body)
.send()
.await
}
// Default: Ollama (OpenAI-compatible endpoint)
_ => {
let base_url = &state.services.ollama_url;
let resolved_model = if model.is_empty() {
&state.services.ollama_model
} else {
model
};
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": resolved_model,
"messages": messages,
"stream": stream,
});
client
.post(&url)
.header("content-type", "application/json")
.json(&body)
.send()
.await
}
}
}

View File

@@ -6,7 +6,7 @@ use time::Duration;
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
use crate::infrastructure::{
auth_callback, auth_login,
auth_callback, auth_login, chat_stream_handler,
config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig},
database::Database,
logout, require_auth,
@@ -82,6 +82,7 @@ pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> {
.route("/auth", get(auth_login))
.route("/auth/callback", get(auth_callback))
.route("/logout", get(logout))
.route("/api/chat/stream", get(chat_stream_handler))
.serve_dioxus_application(ServeConfig::new(), app)
.layer(Extension(PendingOAuthStore::default()))
.layer(Extension(server_state))

View File

@@ -11,6 +11,19 @@ pub enum ChatRole {
System,
}
/// Namespace for grouping chat sessions in the sidebar.
///
/// Sessions are visually separated in the chat sidebar by namespace,
/// with `News` sessions appearing under a dedicated "News Chats" header.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub enum ChatNamespace {
/// General user-initiated chat conversations.
#[default]
General,
/// Chats originating from news article follow-ups on the dashboard.
News,
}
/// The type of file attached to a chat message.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AttachmentKind {
@@ -36,36 +49,59 @@ pub struct Attachment {
pub size_bytes: u64,
}
/// A single message in a chat conversation.
/// A persisted chat session stored in MongoDB.
///
/// Messages are stored separately in the `chat_messages` collection
/// and loaded on demand when the user opens a session.
///
/// # Fields
///
/// * `id` - Unique message identifier
/// * `id` - MongoDB document ID (hex string)
/// * `user_sub` - Keycloak subject ID (session owner)
/// * `title` - Display title (auto-generated or user-renamed)
/// * `namespace` - Grouping for sidebar sections
/// * `provider` - LLM provider used (e.g. "ollama", "openai")
/// * `model` - Model ID used (e.g. "llama3.1:8b")
/// * `created_at` - ISO 8601 creation timestamp
/// * `updated_at` - ISO 8601 last-activity timestamp
/// * `article_url` - Source article URL (for News namespace sessions)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatSession {
#[serde(default, alias = "_id", skip_serializing_if = "String::is_empty")]
pub id: String,
pub user_sub: String,
pub title: String,
#[serde(default)]
pub namespace: ChatNamespace,
pub provider: String,
pub model: String,
pub created_at: String,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub article_url: Option<String>,
}
/// A single persisted message within a chat session.
///
/// Stored in the `chat_messages` MongoDB collection, linked to a
/// `ChatSession` via `session_id`.
///
/// # Fields
///
/// * `id` - MongoDB document ID (hex string)
/// * `session_id` - Foreign key to `ChatSession.id`
/// * `role` - Who sent this message
/// * `content` - The message text content
/// * `attachments` - Optional file attachments
/// * `timestamp` - ISO 8601 timestamp string
/// * `content` - Message text content (may contain markdown)
/// * `attachments` - File attachments (Phase 2, currently empty)
/// * `timestamp` - ISO 8601 timestamp
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatMessage {
#[serde(default, alias = "_id", skip_serializing_if = "String::is_empty")]
pub id: String,
pub session_id: String,
pub role: ChatRole,
pub content: String,
#[serde(default)]
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,
}

View File

@@ -24,6 +24,29 @@ pub struct AuthInfo {
pub avatar_url: String,
}
/// Per-user LLM provider configuration stored in MongoDB.
///
/// Controls which provider and model the user's chat sessions default
/// to, and stores API keys for non-Ollama providers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UserProviderConfig {
/// Default provider name (e.g. "ollama", "openai")
pub default_provider: String,
/// Default model ID (e.g. "llama3.1:8b", "gpt-4o")
pub default_model: String,
/// OpenAI API key (empty if not configured)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_api_key: Option<String>,
/// Anthropic API key
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anthropic_api_key: Option<String>,
/// HuggingFace API key
#[serde(default, skip_serializing_if = "Option::is_none")]
pub huggingface_api_key: Option<String>,
/// Custom Ollama URL override (empty = use server default)
pub ollama_url_override: String,
}
/// Per-user preferences stored in MongoDB.
///
/// Keyed by `sub` (Keycloak subject) and optionally scoped to an org.
@@ -41,4 +64,7 @@ pub struct UserPreferences {
pub ollama_model_override: String,
/// Recently searched queries for quick access
pub recent_searches: Vec<String>,
/// LLM provider configuration
#[serde(default)]
pub provider_config: UserProviderConfig,
}

View File

@@ -1,145 +1,336 @@
use crate::components::{
ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar,
};
use crate::infrastructure::chat::{
chat_complete, create_chat_session, delete_chat_session, list_chat_messages,
list_chat_sessions, rename_chat_session, save_chat_message,
};
use crate::infrastructure::ollama::get_ollama_status;
use crate::models::{ChatMessage, ChatRole};
use dioxus::prelude::*;
use crate::components::ChatBubble;
use crate::models::{ChatMessage, ChatRole, ChatSession};
/// ChatGPT-style chat interface with session list and message area.
/// LibreChat-inspired chat interface with MongoDB persistence and SSE streaming.
///
/// Full-height layout: left panel shows session history,
/// right panel shows messages and input bar.
/// Layout: sidebar (session list) | main panel (model selector, messages, input).
/// Messages stream via `EventSource` connected to `/api/chat/stream`.
#[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);
// ---- Signals ----
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
let mut messages: Signal<Vec<ChatMessage>> = use_signal(Vec::new);
let mut input_text: Signal<String> = use_signal(String::new);
let mut is_streaming: Signal<bool> = use_signal(|| false);
let mut streaming_content: Signal<String> = use_signal(String::new);
let mut selected_model: Signal<String> = 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();
// ---- Resources ----
// Load sessions list (re-fetches when dependency changes)
let mut sessions_resource =
use_resource(move || async move { list_chat_sessions().await.unwrap_or_default() });
// Load available Ollama models
let models_resource = use_resource(move || async move {
get_ollama_status(String::new())
.await
.map(|s| s.models)
.unwrap_or_default()
});
let sessions = sessions_resource.read().clone().unwrap_or_default();
let available_models = models_resource.read().clone().unwrap_or_default();
// Set default model if not yet chosen
if selected_model.read().is_empty() {
if let Some(first) = available_models.first() {
selected_model.set(first.clone());
}
}
// Load messages when active session changes.
// The signal read MUST happen inside the closure so use_resource
// tracks it as a dependency and re-fetches on change.
let _messages_loader = use_resource(move || {
let session_id = active_session_id.read().clone();
async move {
if let Some(id) = session_id {
match list_chat_messages(id).await {
Ok(msgs) => messages.set(msgs),
Err(e) => tracing::error!("failed to load messages: {e}"),
}
} else {
messages.set(Vec::new());
}
}
});
// ---- Callbacks ----
// Create new session
let on_new = move |_: ()| {
let model = selected_model.read().clone();
spawn(async move {
match create_chat_session(
"New Chat".to_string(),
"General".to_string(),
"ollama".to_string(),
model,
String::new(),
)
.await
{
Ok(session) => {
active_session_id.set(Some(session.id));
messages.set(Vec::new());
sessions_resource.restart();
}
Err(e) => tracing::error!("failed to create session: {e}"),
}
});
};
// Select session
let on_select = move |id: String| {
active_session_id.set(Some(id));
};
// Rename session
let on_rename = move |(id, new_title): (String, String)| {
spawn(async move {
if let Err(e) = rename_chat_session(id, new_title).await {
tracing::error!("failed to rename: {e}");
}
sessions_resource.restart();
});
};
// Delete session
let on_delete = move |id: String| {
let is_active = active_session_id.read().as_deref() == Some(&id);
spawn(async move {
if let Err(e) = delete_chat_session(id).await {
tracing::error!("failed to delete: {e}");
}
if is_active {
active_session_id.set(None);
messages.set(Vec::new());
}
sessions_resource.restart();
});
};
// Model change
let on_model_change = move |model: String| {
selected_model.set(model);
};
// Send message
let on_send = move |text: String| {
let session_id = active_session_id.read().clone();
let model = selected_model.read().clone();
spawn(async move {
// If no active session, create one first
let sid = if let Some(id) = session_id {
id
} else {
match create_chat_session(
// Use first ~50 chars of message as title
text.chars().take(50).collect::<String>(),
"General".to_string(),
"ollama".to_string(),
model,
String::new(),
)
.await
{
Ok(session) => {
let id = session.id.clone();
active_session_id.set(Some(id.clone()));
sessions_resource.restart();
id
}
Err(e) => {
tracing::error!("failed to create session: {e}");
return;
}
}
};
// Save user message
match save_chat_message(sid.clone(), "user".to_string(), text).await {
Ok(msg) => {
messages.write().push(msg);
}
Err(e) => {
tracing::error!("failed to save message: {e}");
return;
}
}
// Show thinking indicator
is_streaming.set(true);
streaming_content.set(String::new());
// Build message history as JSON for the server
let history: Vec<serde_json::Value> = messages
.read()
.iter()
.map(|m| {
let role = match m.role {
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::System => "system",
};
serde_json::json!({"role": role, "content": m.content})
})
.collect();
let messages_json = serde_json::to_string(&history).unwrap_or_default();
// Non-streaming completion
match chat_complete(sid.clone(), messages_json).await {
Ok(response) => {
// Save assistant message
match save_chat_message(sid, "assistant".to_string(), response).await {
Ok(msg) => {
messages.write().push(msg);
}
Err(e) => tracing::error!("failed to save assistant msg: {e}"),
}
sessions_resource.restart();
}
Err(e) => tracing::error!("chat completion failed: {e}"),
}
is_streaming.set(false);
});
};
// ---- Action bar state ----
let has_messages = !messages.read().is_empty();
let has_assistant_message = messages
.read()
.iter()
.any(|m| m.role == ChatRole::Assistant);
let has_user_message = messages.read().iter().any(|m| m.role == ChatRole::User);
// Copy last assistant response to clipboard
let on_copy = move |_: ()| {
#[cfg(feature = "web")]
{
let last_assistant = messages
.read()
.iter()
.rev()
.find(|m| m.role == ChatRole::Assistant)
.map(|m| m.content.clone());
if let Some(text) = last_assistant {
if let Some(window) = web_sys::window() {
let clipboard = window.navigator().clipboard();
let _ = clipboard.write_text(&text);
}
}
}
};
// Copy full conversation as text to clipboard
let on_share = move |_: ()| {
#[cfg(feature = "web")]
{
let text: String = messages
.read()
.iter()
.filter(|m| m.role != ChatRole::System)
.map(|m| {
let label = match m.role {
ChatRole::User => "You",
ChatRole::Assistant => "Assistant",
ChatRole::System => "System",
};
format!("{label}:\n{}\n", m.content)
})
.collect::<Vec<_>>()
.join("\n");
if let Some(window) = web_sys::window() {
let clipboard = window.navigator().clipboard();
let _ = clipboard.write_text(&text);
}
}
};
// Edit last user message: remove it and place text back in input
let on_edit = move |_: ()| {
let last_user = messages
.read()
.iter()
.rev()
.find(|m| m.role == ChatRole::User)
.map(|m| m.content.clone());
if let Some(text) = last_user {
// Remove the last user message (and any assistant reply after it)
let mut msgs = messages.read().clone();
if let Some(pos) = msgs.iter().rposition(|m| m.role == ChatRole::User) {
msgs.truncate(pos);
messages.set(msgs);
}
input_text.set(text);
}
};
// Scroll to bottom when messages or streaming content changes
let msg_count = messages.read().len();
let stream_len = streaming_content.read().len();
use_effect(move || {
// Track dependencies
let _ = msg_count;
let _ = stream_len;
// Scroll the message list to bottom
#[cfg(feature = "web")]
{
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
if let Some(el) = doc.get_element_by_id("chat-message-list") {
let height = el.scroll_height();
el.set_scroll_top(height);
}
}
}
}
});
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}" }
}
}
}
}
}
ChatSidebar {
sessions: sessions,
active_session_id: active_session_id.read().clone(),
on_select: on_select,
on_new: on_new,
on_rename: on_rename,
on_delete: on_delete,
}
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() }
ChatModelSelector {
selected_model: selected_model.read().clone(),
available_models: available_models,
on_change: on_model_change,
}
ChatMessageList {
messages: messages.read().clone(),
streaming_content: streaming_content.read().clone(),
is_streaming: *is_streaming.read(),
}
} else {
div { class: "chat-empty",
p { "Select a conversation or start a new one." }
ChatActionBar {
on_copy: on_copy,
on_share: on_share,
on_edit: on_edit,
has_messages: has_messages,
has_assistant_message: has_assistant_message,
has_user_message: has_user_message,
}
}
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" }
ChatInputBar {
input_text: input_text,
on_send: on_send,
is_streaming: *is_streaming.read(),
}
}
}
}
}
/// 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(),
},
]
}

View File

@@ -2,6 +2,7 @@ use dioxus::prelude::*;
use dioxus_sdk::storage::use_persistent;
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
use crate::infrastructure::chat::{create_chat_session, save_chat_message};
use crate::infrastructure::llm::FollowUpMessage;
use crate::models::NewsCard;
@@ -50,6 +51,8 @@ pub fn DashboardPage() -> Element {
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);
// MongoDB session ID for persisting News chat (created on first follow-up)
let mut news_session_id: Signal<Option<String>> = use_signal(|| None);
// Recent search history, persisted in localStorage (capped at MAX_RECENT_SEARCHES)
let mut recent_searches =
@@ -310,6 +313,7 @@ pub fn DashboardPage() -> Element {
summary.set(None);
chat_messages.set(Vec::new());
article_context.set(String::new());
news_session_id.set(None);
let oll_url = ollama_url.read().clone();
@@ -358,6 +362,7 @@ pub fn DashboardPage() -> Element {
selected_card.set(None);
summary.set(None);
chat_messages.set(Vec::new());
news_session_id.set(None);
},
summary: summary.read().clone(),
is_summarizing: *is_summarizing.read(),
@@ -367,49 +372,110 @@ pub fn DashboardPage() -> Element {
let oll_url = ollama_url.read().clone();
let mdl = ollama_model.read().clone();
let ctx = article_context.read().clone();
// Capture article info for News session creation
let card_title = selected_card
.read()
.as_ref()
.map(|c| c.title.clone())
.unwrap_or_default();
let card_url = selected_card
.read()
.as_ref()
.map(|c| c.url.clone())
.unwrap_or_default();
// Append user message to chat
chat_messages
// Append user message to local chat
chat_messages.write().push(FollowUpMessage {
role: "user".into(),
content: question.clone(),
});
// 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!(
let system_msg = 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}",
),
},
];
);
let msgs = {
let history = chat_messages.read();
let mut all = vec![FollowUpMessage {
role: "system".into(),
content: system_msg.clone(),
}];
all.extend(history.iter().cloned());
all
};
spawn(async move {
is_chatting.set(true);
match crate::infrastructure::llm::chat_followup(msgs, oll_url, mdl).await {
// Create News session on first follow-up message
let existing_sid = news_session_id.read().clone();
let sid = if let Some(id) = existing_sid {
id
} else {
match create_chat_session(
card_title,
"News".to_string(),
"ollama".to_string(),
mdl.clone(),
card_url,
)
.await
{
Ok(session) => {
let id = session.id.clone();
news_session_id.set(Some(id.clone()));
// Persist system context as first message
let _ = save_chat_message(
id.clone(),
"system".to_string(),
system_msg,
)
.await;
id
}
Err(e) => {
tracing::error!("Failed to create News session: {e}");
String::new()
}
}
};
// Persist user message
if !sid.is_empty() {
let _ = save_chat_message(
sid.clone(),
"user".to_string(),
question,
)
.await;
}
match crate::infrastructure::llm::chat_followup(
msgs, oll_url, mdl,
)
.await
{
Ok(reply) => {
chat_messages
.write()
.push(FollowUpMessage {
// Persist assistant message
if !sid.is_empty() {
let _ = save_chat_message(
sid,
"assistant".to_string(),
reply.clone(),
)
.await;
}
chat_messages.write().push(FollowUpMessage {
role: "assistant".into(),
content: reply,
});
}
Err(e) => {
tracing::error!("Chat failed: {e}");
chat_messages
.write()
.push(FollowUpMessage {
chat_messages.write().push(FollowUpMessage {
role: "assistant".into(),
content: format!("Error: {e}"),
});