feat(chat): added chat interface and connection to ollama (#10)
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,9 +12,11 @@
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Keycloak runtime data (but keep realm-export.json)
|
# Keycloak runtime data (but keep config and theme)
|
||||||
keycloak/*
|
keycloak/*
|
||||||
!keycloak/realm-export.json
|
!keycloak/realm-export.json
|
||||||
|
!keycloak/themes/
|
||||||
|
!keycloak/themes/**
|
||||||
|
|
||||||
# Node modules
|
# Node modules
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -760,9 +760,11 @@ dependencies = [
|
|||||||
name = "dashboard"
|
name = "dashboard"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-stream",
|
||||||
"async-stripe",
|
"async-stripe",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
"dioxus-cli-config",
|
"dioxus-cli-config",
|
||||||
@@ -774,6 +776,7 @@ dependencies = [
|
|||||||
"maud",
|
"maud",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
"petname",
|
"petname",
|
||||||
|
"pulldown-cmark",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
"scraper",
|
"scraper",
|
||||||
@@ -784,10 +787,12 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-sessions",
|
"tower-sessions",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1127,7 +1132,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"xxhash-rust",
|
"xxhash-rust",
|
||||||
]
|
]
|
||||||
@@ -1531,7 +1536,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3297,6 +3302,24 @@ dependencies = [
|
|||||||
"psl-types",
|
"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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -3573,7 +3596,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots 1.0.6",
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
@@ -3588,6 +3611,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"h2 0.4.13",
|
"h2 0.4.13",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
@@ -3610,12 +3634,14 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.26.4",
|
"tokio-rustls 0.26.4",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams 0.5.0",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5147,6 +5173,19 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "wasmparser"
|
name = "wasmparser"
|
||||||
version = "0.244.0"
|
version = "0.244.0"
|
||||||
|
|||||||
14
Cargo.toml
14
Cargo.toml
@@ -36,7 +36,7 @@ mongodb = { version = "3.2", default-features = false, features = [
|
|||||||
"compat-3-0-0",
|
"compat-3-0-0",
|
||||||
], optional = true }
|
], optional = true }
|
||||||
futures = { version = "0.3.31", default-features = false }
|
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 = [
|
tower-sessions = { version = "0.15", default-features = false, features = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"memory-store",
|
"memory-store",
|
||||||
@@ -61,11 +61,14 @@ secrecy = { version = "0.10", default-features = false, optional = true }
|
|||||||
serde_json = { version = "1.0.133", default-features = false }
|
serde_json = { version = "1.0.133", default-features = false }
|
||||||
maud = { version = "0.27", default-features = false }
|
maud = { version = "0.27", default-features = false }
|
||||||
url = { version = "2.5.4", default-features = false, optional = true }
|
url = { version = "2.5.4", default-features = false, optional = true }
|
||||||
|
wasm-bindgen = { version = "0.2", optional = true }
|
||||||
web-sys = { version = "0.3", optional = true, features = [
|
web-sys = { version = "0.3", optional = true, features = [
|
||||||
"Clipboard",
|
"Clipboard",
|
||||||
"Document",
|
"Document",
|
||||||
"Element",
|
"Element",
|
||||||
|
"EventSource",
|
||||||
"HtmlElement",
|
"HtmlElement",
|
||||||
|
"MessageEvent",
|
||||||
"Navigator",
|
"Navigator",
|
||||||
"Storage",
|
"Storage",
|
||||||
"Window",
|
"Window",
|
||||||
@@ -81,10 +84,14 @@ dioxus-free-icons = { version = "0.10", features = [
|
|||||||
sha2 = { version = "0.10.9", default-features = false, optional = true }
|
sha2 = { version = "0.10.9", default-features = false, optional = true }
|
||||||
base64 = { version = "0.22.1", default-features = false, optional = true }
|
base64 = { version = "0.22.1", default-features = false, optional = true }
|
||||||
scraper = { version = "0.22", default-features = false, optional = true }
|
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]
|
[features]
|
||||||
# default = ["web"]
|
# default = ["web"]
|
||||||
web = ["dioxus/web", "dep:reqwest", "dep:web-sys"]
|
web = ["dioxus/web", "dep:reqwest", "dep:web-sys", "dep:wasm-bindgen"]
|
||||||
server = [
|
server = [
|
||||||
"dioxus/server",
|
"dioxus/server",
|
||||||
"dep:axum",
|
"dep:axum",
|
||||||
@@ -100,6 +107,9 @@ server = [
|
|||||||
"dep:scraper",
|
"dep:scraper",
|
||||||
"dep:secrecy",
|
"dep:secrecy",
|
||||||
"dep:petname",
|
"dep:petname",
|
||||||
|
"dep:tokio-stream",
|
||||||
|
"dep:async-stream",
|
||||||
|
"dep:bytes",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
346
assets/main.css
346
assets/main.css
@@ -215,6 +215,31 @@ h6 {
|
|||||||
color: var(--accent);
|
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 {
|
.sidebar-version {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dimmest);
|
color: var(--text-dimmest);
|
||||||
@@ -1884,6 +1909,44 @@ h6 {
|
|||||||
color: var(--accent);
|
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 -- */
|
||||||
.chat-input-bar {
|
.chat-input-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1918,6 +1981,289 @@ h6 {
|
|||||||
padding: 10px 20px;
|
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 ===== */
|
||||||
.tools-page {
|
.tools-page {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
|||||||
@@ -162,6 +162,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.diff {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
grid-template-rows: 1fr 1.8rem 1fr;
|
||||||
|
direction: ltr;
|
||||||
|
container-type: inline-size;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
&:focus-visible, &:has(.diff-item-1:focus-visible) {
|
||||||
|
outline-style: var(--tw-outline-style);
|
||||||
|
outline-width: 2px;
|
||||||
|
outline-offset: 1px;
|
||||||
|
outline-color: var(--color-base-content);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline-style: var(--tw-outline-style);
|
||||||
|
outline-width: 2px;
|
||||||
|
outline-offset: 1px;
|
||||||
|
outline-color: var(--color-base-content);
|
||||||
|
.diff-resizer {
|
||||||
|
min-width: 95cqi;
|
||||||
|
max-width: 95cqi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:has(.diff-item-1:focus-visible) {
|
||||||
|
outline-style: var(--tw-outline-style);
|
||||||
|
outline-width: 2px;
|
||||||
|
outline-offset: 1px;
|
||||||
|
.diff-resizer {
|
||||||
|
min-width: 5cqi;
|
||||||
|
max-width: 5cqi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
|
||||||
|
&:focus {
|
||||||
|
.diff-resizer {
|
||||||
|
min-width: 5cqi;
|
||||||
|
max-width: 5cqi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:has(.diff-item-1:focus) {
|
||||||
|
.diff-resizer {
|
||||||
|
min-width: 95cqi;
|
||||||
|
max-width: 95cqi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.modal {
|
.modal {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -1383,6 +1436,81 @@
|
|||||||
padding: calc(0.25rem * 4);
|
padding: calc(0.25rem * 4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.textarea {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
border: var(--border) solid #0000;
|
||||||
|
min-height: calc(0.25rem * 20);
|
||||||
|
flex-shrink: 1;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
padding-block: calc(0.25rem * 2);
|
||||||
|
vertical-align: middle;
|
||||||
|
width: clamp(3rem, 20rem, 100%);
|
||||||
|
padding-inline-start: 0.75rem;
|
||||||
|
padding-inline-end: 0.75rem;
|
||||||
|
font-size: max(var(--font-size, 0.875rem), 0.875rem);
|
||||||
|
touch-action: manipulation;
|
||||||
|
border-color: var(--input-color);
|
||||||
|
box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
|
||||||
|
}
|
||||||
|
--input-color: var(--color-base-content);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
--input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
appearance: none;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
&:focus, &:focus-within {
|
||||||
|
--tw-outline-style: none;
|
||||||
|
outline-style: none;
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:focus, &:focus-within {
|
||||||
|
--input-color: var(--color-base-content);
|
||||||
|
box-shadow: 0 1px var(--input-color);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
|
||||||
|
}
|
||||||
|
outline: 2px solid var(--input-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
&:focus, &:focus-within {
|
||||||
|
--font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:has(> textarea[disabled]), &:is(:disabled, [disabled]) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--color-base-200);
|
||||||
|
background-color: var(--color-base-200);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||||||
|
}
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-base-content);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
&:has(> textarea[disabled]) > textarea[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.stack {
|
.stack {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
@@ -1680,9 +1808,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.block {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@@ -1724,6 +1849,14 @@
|
|||||||
border-color: currentColor;
|
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 {
|
.p-6 {
|
||||||
padding: calc(var(--spacing) * 6);
|
padding: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
- --import-realm
|
- --import-realm
|
||||||
volumes:
|
volumes:
|
||||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||||
|
- ./keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"loginWithEmailAllowed": true,
|
"loginWithEmailAllowed": true,
|
||||||
"duplicateEmailsAllowed": false,
|
"duplicateEmailsAllowed": false,
|
||||||
"resetPasswordAllowed": true,
|
"resetPasswordAllowed": true,
|
||||||
|
"loginTheme": "certifai",
|
||||||
"editUsernameAllowed": false,
|
"editUsernameAllowed": false,
|
||||||
"bruteForceProtected": true,
|
"bruteForceProtected": true,
|
||||||
"permanentLockout": false,
|
"permanentLockout": false,
|
||||||
|
|||||||
583
keycloak/themes/certifai/login/resources/css/login.css
Normal file
583
keycloak/themes/certifai/login/resources/css/login.css
Normal 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;
|
||||||
|
}
|
||||||
25
keycloak/themes/certifai/login/resources/img/logo.svg
Normal file
25
keycloak/themes/certifai/login/resources/img/logo.svg
Normal 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 |
3
keycloak/themes/certifai/login/theme.properties
Normal file
3
keycloak/themes/certifai/login/theme.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
parent=keycloak
|
||||||
|
import=common/keycloak
|
||||||
|
styles=css/login.css
|
||||||
65
src/components/chat_action_bar.rs
Normal file
65
src/components/chat_action_bar.rs
Normal 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,82 @@
|
|||||||
use crate::models::{ChatMessage, ChatRole};
|
use crate::models::{ChatMessage, ChatRole};
|
||||||
use dioxus::prelude::*;
|
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.
|
/// 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
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `message` - The chat message to render
|
/// * `message` - The chat message to render
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ChatBubble(message: ChatMessage) -> Element {
|
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 {
|
let bubble_class = match message.role {
|
||||||
ChatRole::User => "chat-bubble chat-bubble--user",
|
ChatRole::User => "chat-bubble chat-bubble--user",
|
||||||
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
|
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
|
||||||
ChatRole::System => "chat-bubble chat-bubble--system",
|
ChatRole::System => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let role_label = match message.role {
|
let role_label = match message.role {
|
||||||
ChatRole::User => "You",
|
ChatRole::User => "You",
|
||||||
ChatRole::Assistant => "Assistant",
|
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! {
|
rsx! {
|
||||||
div { class: "{bubble_class}",
|
div { class: "{bubble_class}",
|
||||||
div { class: "chat-bubble-header",
|
div { class: "chat-bubble-header",
|
||||||
span { class: "chat-bubble-role", "{role_label}" }
|
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}" }
|
||||||
}
|
}
|
||||||
div { class: "chat-bubble-content", "{message.content}" }
|
|
||||||
if !message.attachments.is_empty() {
|
if !message.attachments.is_empty() {
|
||||||
div { class: "chat-bubble-attachments",
|
div { class: "chat-bubble-attachments",
|
||||||
for att in &message.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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
69
src/components/chat_input_bar.rs
Normal file
69
src/components/chat_input_bar.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/components/chat_message_list.rs
Normal file
38
src/components/chat_message_list.rs
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/components/chat_model_selector.rs
Normal file
42
src/components/chat_model_selector.rs
Normal 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/components/chat_sidebar.rs
Normal file
226
src/components/chat_sidebar.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
mod app_shell;
|
mod app_shell;
|
||||||
mod article_detail;
|
mod article_detail;
|
||||||
mod card;
|
mod card;
|
||||||
|
mod chat_action_bar;
|
||||||
mod chat_bubble;
|
mod chat_bubble;
|
||||||
|
mod chat_input_bar;
|
||||||
|
mod chat_message_list;
|
||||||
|
mod chat_model_selector;
|
||||||
|
mod chat_sidebar;
|
||||||
mod dashboard_sidebar;
|
mod dashboard_sidebar;
|
||||||
mod file_row;
|
mod file_row;
|
||||||
mod login;
|
mod login;
|
||||||
@@ -16,7 +21,12 @@ mod tool_card;
|
|||||||
pub use app_shell::*;
|
pub use app_shell::*;
|
||||||
pub use article_detail::*;
|
pub use article_detail::*;
|
||||||
pub use card::*;
|
pub use card::*;
|
||||||
|
pub use chat_action_bar::*;
|
||||||
pub use chat_bubble::*;
|
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 dashboard_sidebar::*;
|
||||||
pub use file_row::*;
|
pub use file_row::*;
|
||||||
pub use login::*;
|
pub use login::*;
|
||||||
|
|||||||
@@ -245,6 +245,11 @@ fn SidebarFooter() -> Element {
|
|||||||
Icon { icon: BsGrid, width: 16, height: 16 }
|
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}" }
|
p { class: "sidebar-version", "v{version}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
507
src/infrastructure/chat.rs
Normal file
507
src/infrastructure/chat.rs
Normal 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()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
266
src/infrastructure/chat_stream.rs
Normal file
266
src/infrastructure/chat_stream.rs
Normal 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(¶ms.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": ¶ms.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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
use mongodb::{bson::doc, Client, Collection};
|
use mongodb::{bson::doc, Client, Collection};
|
||||||
|
|
||||||
use super::Error;
|
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
|
/// Thin wrapper around [`mongodb::Database`] that provides typed
|
||||||
/// collection accessors for the application's domain models.
|
/// collection accessors for the application's domain models.
|
||||||
@@ -49,4 +49,20 @@ impl Database {
|
|||||||
pub fn org_billing(&self) -> Collection<OrgBillingRecord> {
|
pub fn org_billing(&self) -> Collection<OrgBillingRecord> {
|
||||||
self.inner.collection("org_billing")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Server function modules (compiled for both web and server features;
|
// Server function modules (compiled for both web and server features;
|
||||||
// the #[server] macro generates client stubs for the web target)
|
// the #[server] macro generates client stubs for the web target)
|
||||||
pub mod auth_check;
|
pub mod auth_check;
|
||||||
|
pub mod chat;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod searxng;
|
pub mod searxng;
|
||||||
@@ -11,12 +12,16 @@ mod auth;
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
mod auth_middleware;
|
mod auth_middleware;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
mod chat_stream;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub mod config;
|
pub mod config;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod database;
|
pub mod database;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
mod error;
|
mod error;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
pub mod provider_client;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
mod server;
|
mod server;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod server_state;
|
pub mod server_state;
|
||||||
@@ -28,6 +33,8 @@ pub use auth::*;
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub use auth_middleware::*;
|
pub use auth_middleware::*;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
pub use chat_stream::*;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub use server::*;
|
pub use server::*;
|
||||||
|
|||||||
148
src/infrastructure/provider_client.rs
Normal file
148
src/infrastructure/provider_client.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use time::Duration;
|
|||||||
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
|
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
|
||||||
|
|
||||||
use crate::infrastructure::{
|
use crate::infrastructure::{
|
||||||
auth_callback, auth_login,
|
auth_callback, auth_login, chat_stream_handler,
|
||||||
config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig},
|
config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig},
|
||||||
database::Database,
|
database::Database,
|
||||||
logout, require_auth,
|
logout, require_auth,
|
||||||
@@ -82,6 +82,7 @@ pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> {
|
|||||||
.route("/auth", get(auth_login))
|
.route("/auth", get(auth_login))
|
||||||
.route("/auth/callback", get(auth_callback))
|
.route("/auth/callback", get(auth_callback))
|
||||||
.route("/logout", get(logout))
|
.route("/logout", get(logout))
|
||||||
|
.route("/api/chat/stream", get(chat_stream_handler))
|
||||||
.serve_dioxus_application(ServeConfig::new(), app)
|
.serve_dioxus_application(ServeConfig::new(), app)
|
||||||
.layer(Extension(PendingOAuthStore::default()))
|
.layer(Extension(PendingOAuthStore::default()))
|
||||||
.layer(Extension(server_state))
|
.layer(Extension(server_state))
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ pub enum ChatRole {
|
|||||||
System,
|
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.
|
/// The type of file attached to a chat message.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum AttachmentKind {
|
pub enum AttachmentKind {
|
||||||
@@ -36,36 +49,59 @@ pub struct Attachment {
|
|||||||
pub size_bytes: u64,
|
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
|
/// # 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
|
/// * `role` - Who sent this message
|
||||||
/// * `content` - The message text content
|
/// * `content` - Message text content (may contain markdown)
|
||||||
/// * `attachments` - Optional file attachments
|
/// * `attachments` - File attachments (Phase 2, currently empty)
|
||||||
/// * `timestamp` - ISO 8601 timestamp string
|
/// * `timestamp` - ISO 8601 timestamp
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ChatMessage {
|
pub struct ChatMessage {
|
||||||
|
#[serde(default, alias = "_id", skip_serializing_if = "String::is_empty")]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub session_id: String,
|
||||||
pub role: ChatRole,
|
pub role: ChatRole,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
#[serde(default)]
|
||||||
pub attachments: Vec<Attachment>,
|
pub attachments: Vec<Attachment>,
|
||||||
pub timestamp: String,
|
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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,6 +24,29 @@ pub struct AuthInfo {
|
|||||||
pub avatar_url: String,
|
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.
|
/// Per-user preferences stored in MongoDB.
|
||||||
///
|
///
|
||||||
/// Keyed by `sub` (Keycloak subject) and optionally scoped to an org.
|
/// Keyed by `sub` (Keycloak subject) and optionally scoped to an org.
|
||||||
@@ -41,4 +64,7 @@ pub struct UserPreferences {
|
|||||||
pub ollama_model_override: String,
|
pub ollama_model_override: String,
|
||||||
/// Recently searched queries for quick access
|
/// Recently searched queries for quick access
|
||||||
pub recent_searches: Vec<String>,
|
pub recent_searches: Vec<String>,
|
||||||
|
/// LLM provider configuration
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider_config: UserProviderConfig,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::components::ChatBubble;
|
/// LibreChat-inspired chat interface with MongoDB persistence and SSE streaming.
|
||||||
use crate::models::{ChatMessage, ChatRole, ChatSession};
|
|
||||||
|
|
||||||
/// ChatGPT-style chat interface with session list and message area.
|
|
||||||
///
|
///
|
||||||
/// Full-height layout: left panel shows session history,
|
/// Layout: sidebar (session list) | main panel (model selector, messages, input).
|
||||||
/// right panel shows messages and input bar.
|
/// Messages stream via `EventSource` connected to `/api/chat/stream`.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ChatPage() -> Element {
|
pub fn ChatPage() -> Element {
|
||||||
let sessions = use_signal(mock_sessions);
|
// ---- Signals ----
|
||||||
let mut active_session_id = use_signal(|| "session-1".to_string());
|
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
|
||||||
let mut input_text = use_signal(String::new);
|
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
|
// ---- Resources ----
|
||||||
// holding a `Signal::read()` borrow across potential await points.
|
// Load sessions list (re-fetches when dependency changes)
|
||||||
let sessions_list = sessions.read().clone();
|
let mut sessions_resource =
|
||||||
let current_id = active_session_id.read().clone();
|
use_resource(move || async move { list_chat_sessions().await.unwrap_or_default() });
|
||||||
let active_session = sessions_list.iter().find(|s| s.id == current_id).cloned();
|
|
||||||
|
|
||||||
rsx! {
|
// Load available Ollama models
|
||||||
section { class: "chat-page",
|
let models_resource = use_resource(move || async move {
|
||||||
div { class: "chat-sidebar-panel",
|
get_ollama_status(String::new())
|
||||||
div { class: "chat-sidebar-header",
|
.await
|
||||||
h3 { "Conversations" }
|
.map(|s| s.models)
|
||||||
button { class: "btn-icon", "+" }
|
.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}"),
|
||||||
}
|
}
|
||||||
div { class: "chat-session-list",
|
} else {
|
||||||
for session in &sessions_list {
|
messages.set(Vec::new());
|
||||||
{
|
}
|
||||||
let is_active = session.id == current_id;
|
}
|
||||||
let class = if is_active {
|
});
|
||||||
"chat-session-item chat-session-item--active"
|
|
||||||
} else {
|
// ---- Callbacks ----
|
||||||
"chat-session-item"
|
// Create new session
|
||||||
};
|
let on_new = move |_: ()| {
|
||||||
let id = session.id.clone();
|
let model = selected_model.read().clone();
|
||||||
rsx! {
|
spawn(async move {
|
||||||
button { class: "{class}", onclick: move |_| active_session_id.set(id.clone()),
|
match create_chat_session(
|
||||||
div { class: "chat-session-title", "{session.title}" }
|
"New Chat".to_string(),
|
||||||
div { class: "chat-session-date", "{session.created_at}" }
|
"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",
|
||||||
|
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",
|
div { class: "chat-main-panel",
|
||||||
if let Some(session) = &active_session {
|
ChatModelSelector {
|
||||||
div { class: "chat-messages",
|
selected_model: selected_model.read().clone(),
|
||||||
for msg in &session.messages {
|
available_models: available_models,
|
||||||
ChatBubble { key: "{msg.id}", message: msg.clone() }
|
on_change: on_model_change,
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
div { class: "chat-empty",
|
|
||||||
p { "Select a conversation or start a new one." }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
div { class: "chat-input-bar",
|
ChatMessageList {
|
||||||
button { class: "btn-icon chat-attach-btn", "+" }
|
messages: messages.read().clone(),
|
||||||
input {
|
streaming_content: streaming_content.read().clone(),
|
||||||
class: "chat-input",
|
is_streaming: *is_streaming.read(),
|
||||||
r#type: "text",
|
}
|
||||||
placeholder: "Type a message...",
|
ChatActionBar {
|
||||||
value: "{input_text}",
|
on_copy: on_copy,
|
||||||
oninput: move |evt: Event<FormData>| {
|
on_share: on_share,
|
||||||
input_text.set(evt.value());
|
on_edit: on_edit,
|
||||||
},
|
has_messages: has_messages,
|
||||||
}
|
has_assistant_message: has_assistant_message,
|
||||||
button { class: "btn-primary chat-send-btn", "Send" }
|
has_user_message: has_user_message,
|
||||||
|
}
|
||||||
|
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(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use dioxus::prelude::*;
|
|||||||
use dioxus_sdk::storage::use_persistent;
|
use dioxus_sdk::storage::use_persistent;
|
||||||
|
|
||||||
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
|
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
|
||||||
|
use crate::infrastructure::chat::{create_chat_session, save_chat_message};
|
||||||
use crate::infrastructure::llm::FollowUpMessage;
|
use crate::infrastructure::llm::FollowUpMessage;
|
||||||
use crate::models::NewsCard;
|
use crate::models::NewsCard;
|
||||||
|
|
||||||
@@ -50,6 +51,8 @@ pub fn DashboardPage() -> Element {
|
|||||||
let mut is_chatting = use_signal(|| false);
|
let mut is_chatting = use_signal(|| false);
|
||||||
// Stores the article text context for the chat system message
|
// Stores the article text context for the chat system message
|
||||||
let mut article_context = use_signal(String::new);
|
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)
|
// Recent search history, persisted in localStorage (capped at MAX_RECENT_SEARCHES)
|
||||||
let mut recent_searches =
|
let mut recent_searches =
|
||||||
@@ -310,6 +313,7 @@ pub fn DashboardPage() -> Element {
|
|||||||
summary.set(None);
|
summary.set(None);
|
||||||
chat_messages.set(Vec::new());
|
chat_messages.set(Vec::new());
|
||||||
article_context.set(String::new());
|
article_context.set(String::new());
|
||||||
|
news_session_id.set(None);
|
||||||
|
|
||||||
|
|
||||||
let oll_url = ollama_url.read().clone();
|
let oll_url = ollama_url.read().clone();
|
||||||
@@ -358,6 +362,7 @@ pub fn DashboardPage() -> Element {
|
|||||||
selected_card.set(None);
|
selected_card.set(None);
|
||||||
summary.set(None);
|
summary.set(None);
|
||||||
chat_messages.set(Vec::new());
|
chat_messages.set(Vec::new());
|
||||||
|
news_session_id.set(None);
|
||||||
},
|
},
|
||||||
summary: summary.read().clone(),
|
summary: summary.read().clone(),
|
||||||
is_summarizing: *is_summarizing.read(),
|
is_summarizing: *is_summarizing.read(),
|
||||||
@@ -367,52 +372,113 @@ pub fn DashboardPage() -> Element {
|
|||||||
let oll_url = ollama_url.read().clone();
|
let oll_url = ollama_url.read().clone();
|
||||||
let mdl = ollama_model.read().clone();
|
let mdl = ollama_model.read().clone();
|
||||||
let ctx = article_context.read().clone();
|
let ctx = article_context.read().clone();
|
||||||
|
// Capture article info for News session creation
|
||||||
|
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
|
// Append user message to local chat
|
||||||
chat_messages
|
chat_messages.write().push(FollowUpMessage {
|
||||||
|
role: "user".into(),
|
||||||
|
content: question.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
// Build full message history for Ollama
|
// Build full message history for Ollama
|
||||||
|
let system_msg = format!(
|
||||||
.write()
|
"You are a helpful assistant. The user is reading \
|
||||||
.push(FollowUpMessage {
|
a news article. Use the following context to answer \
|
||||||
role: "user".into(),
|
their questions. Do NOT comment on the source, \
|
||||||
content: question,
|
dates, URLs, or formatting.\n\n{ctx}",
|
||||||
});
|
);
|
||||||
let msgs = {
|
let msgs = {
|
||||||
let history = chat_messages.read();
|
let history = chat_messages.read();
|
||||||
let mut all = vec![
|
let mut all = vec![FollowUpMessage {
|
||||||
FollowUpMessage {
|
role: "system".into(),
|
||||||
role: "system".into(),
|
content: system_msg.clone(),
|
||||||
content: format!(
|
}];
|
||||||
"You are a helpful assistant. The user is reading \
|
|
||||||
a news article. Use the following context to answer \
|
|
||||||
their questions. Do NOT comment on the source, \
|
|
||||||
dates, URLs, or formatting.\n\n{ctx}",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
all.extend(history.iter().cloned());
|
all.extend(history.iter().cloned());
|
||||||
all
|
all
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
is_chatting.set(true);
|
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) => {
|
Ok(reply) => {
|
||||||
chat_messages
|
// Persist assistant message
|
||||||
.write()
|
if !sid.is_empty() {
|
||||||
.push(FollowUpMessage {
|
let _ = save_chat_message(
|
||||||
role: "assistant".into(),
|
sid,
|
||||||
content: reply,
|
"assistant".to_string(),
|
||||||
});
|
reply.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
chat_messages.write().push(FollowUpMessage {
|
||||||
|
role: "assistant".into(),
|
||||||
|
content: reply,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Chat failed: {e}");
|
tracing::error!("Chat failed: {e}");
|
||||||
chat_messages
|
chat_messages.write().push(FollowUpMessage {
|
||||||
.write()
|
role: "assistant".into(),
|
||||||
.push(FollowUpMessage {
|
content: format!("Error: {e}"),
|
||||||
role: "assistant".into(),
|
});
|
||||||
content: format!("Error: {e}"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is_chatting.set(false);
|
is_chatting.set(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user