diff --git a/compliance-agent/src/api/handlers/help_chat.rs b/compliance-agent/src/api/handlers/help_chat.rs new file mode 100644 index 0000000..78a64cf --- /dev/null +++ b/compliance-agent/src/api/handlers/help_chat.rs @@ -0,0 +1,187 @@ +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use axum::extract::Extension; +use axum::http::StatusCode; +use axum::Json; +use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; + +use super::dto::{AgentExt, ApiResponse}; + +// ── DTOs ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct HelpChatMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct HelpChatRequest { + pub message: String, + #[serde(default)] + pub history: Vec, +} + +#[derive(Debug, Serialize)] +pub struct HelpChatResponse { + pub message: String, +} + +// ── Doc cache ──────────────────────────────────────────────────────────────── + +static DOC_CONTEXT: OnceLock = OnceLock::new(); + +/// Walk upward from `start` until we find a directory containing both +/// `README.md` and a `docs/` subdirectory. +fn find_project_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if current.join("README.md").is_file() && current.join("docs").is_dir() { + return Some(current); + } + if !current.pop() { + return None; + } + } +} + +/// Read README.md + all docs/**/*.md (excluding node_modules). +fn load_docs(root: &Path) -> String { + let mut parts: Vec = Vec::new(); + + // Root README first + if let Ok(content) = std::fs::read_to_string(root.join("README.md")) { + parts.push(format!("\n{content}")); + } + + // docs/**/*.md, skipping node_modules + for entry in WalkDir::new(root.join("docs")) + .follow_links(false) + .into_iter() + .filter_entry(|e| { + !e.path() + .components() + .any(|c| c.as_os_str() == "node_modules") + }) + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if !path.is_file() { + continue; + } + if path + .extension() + .and_then(|s| s.to_str()) + .map(|s| !s.eq_ignore_ascii_case("md")) + .unwrap_or(true) + { + continue; + } + + let rel = path.strip_prefix(root).unwrap_or(path); + if let Ok(content) = std::fs::read_to_string(path) { + parts.push(format!("\n{content}", rel.display())); + } + } + + if parts.is_empty() { + tracing::warn!( + "help_chat: no documentation files found under {}", + root.display() + ); + } else { + tracing::info!( + "help_chat: loaded {} documentation file(s) from {}", + parts.len(), + root.display() + ); + } + + parts.join("\n\n---\n\n") +} + +/// Returns a reference to the cached doc context string, initialised on +/// first call via `OnceLock`. +fn doc_context() -> &'static str { + DOC_CONTEXT.get_or_init(|| { + let start = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from(".")); + + match find_project_root(&start) { + Some(root) => load_docs(&root), + None => { + // Fallback: try current working directory + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + if cwd.join("README.md").is_file() { + return load_docs(&cwd); + } + tracing::error!( + "help_chat: could not locate project root from {}; doc context will be empty", + start.display() + ); + String::new() + } + } + }) +} + +// ── Handler ────────────────────────────────────────────────────────────────── + +/// POST /api/v1/help/chat — Answer questions about the compliance-scanner +/// using the project documentation as grounding context. +#[tracing::instrument(skip_all)] +pub async fn help_chat( + Extension(agent): AgentExt, + Json(req): Json, +) -> Result>, StatusCode> { + let context = doc_context(); + + let system_prompt = if context.is_empty() { + "You are a helpful assistant for the Compliance Scanner project. \ + Answer questions about how to use and configure it. \ + No documentation was loaded at startup, so rely on your general knowledge." + .to_string() + } else { + format!( + "You are a helpful assistant for the Compliance Scanner project. \ + Answer questions about how to use, configure, and understand it \ + using the documentation below as your primary source of truth.\n\n\ + Rules:\n\ + - Prefer information from the provided docs over general knowledge\n\ + - Quote or reference the relevant doc section when it helps\n\ + - If the docs do not cover the topic, say so clearly\n\ + - Be concise — lead with the answer, then explain if needed\n\ + - Use markdown formatting for readability\n\n\ + ## Project Documentation\n\n{context}" + ) + }; + + let mut messages: Vec<(String, String)> = Vec::with_capacity(req.history.len() + 2); + messages.push(("system".to_string(), system_prompt)); + + for msg in &req.history { + messages.push((msg.role.clone(), msg.content.clone())); + } + messages.push(("user".to_string(), req.message)); + + let response_text = agent + .llm + .chat_with_messages(messages, Some(0.3)) + .await + .map_err(|e| { + tracing::error!("LLM help chat failed: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(ApiResponse { + data: HelpChatResponse { + message: response_text, + }, + total: None, + page: None, + })) +} diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index c860312..7902b26 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod dto; pub mod findings; pub mod graph; pub mod health; +pub mod help_chat; pub mod issues; pub mod pentest_handlers; pub use pentest_handlers as pentest; diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index feeb51f..e2f0ec0 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -99,6 +99,8 @@ pub fn build_router() -> Router { "/api/v1/chat/{repo_id}/status", get(handlers::chat::embedding_status), ) + // Help chat (documentation-grounded Q&A) + .route("/api/v1/help/chat", post(handlers::help_chat::help_chat)) // Pentest API endpoints .route( "/api/v1/pentest/lookup-repo", diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index 52547e5..4dff597 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -3645,3 +3645,205 @@ tbody tr:last-child td { .wizard-toggle.active .wizard-toggle-knob { transform: translateX(16px); } + +/* ═══════════════════════════════════════════════════════════════ + HELP CHAT WIDGET + Floating assistant for documentation Q&A + ═══════════════════════════════════════════════════════════════ */ + +.help-chat-toggle { + position: fixed; + bottom: 24px; + right: 28px; + z-index: 50; + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--accent); + color: var(--bg-primary); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 20px rgba(0, 200, 255, 0.3); + transition: transform 0.15s, box-shadow 0.15s; +} +.help-chat-toggle:hover { + transform: scale(1.08); + box-shadow: 0 6px 28px rgba(0, 200, 255, 0.4); +} + +.help-chat-panel { + position: fixed; + bottom: 24px; + right: 28px; + z-index: 51; + width: 400px; + height: 520px; + background: var(--bg-secondary); + border: 1px solid var(--border-bright); + border-radius: 16px; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5), var(--accent-glow); +} + +.help-chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + background: var(--bg-primary); +} +.help-chat-title { + display: flex; + align-items: center; + gap: 8px; + font-family: 'Outfit', sans-serif; + font-weight: 600; + font-size: 14px; + color: var(--text-primary); +} +.help-chat-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; +} +.help-chat-close:hover { + color: var(--text-primary); + background: var(--bg-elevated); +} + +.help-chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.help-chat-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: var(--text-secondary); + font-size: 13px; + gap: 8px; +} +.help-chat-hint { + font-size: 12px; + color: var(--text-tertiary); + font-style: italic; +} + +.help-msg { + max-width: 88%; + animation: helpMsgIn 0.15s ease-out; +} +@keyframes helpMsgIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.help-msg-user { + align-self: flex-end; +} +.help-msg-assistant { + align-self: flex-start; +} +.help-msg-content { + padding: 10px 14px; + border-radius: 12px; + font-size: 13px; + line-height: 1.55; + word-wrap: break-word; +} +.help-msg-user .help-msg-content { + background: var(--accent); + color: var(--bg-primary); + border-bottom-right-radius: 4px; +} +.help-msg-assistant .help-msg-content { + background: var(--bg-elevated); + color: var(--text-primary); + border: 1px solid var(--border); + border-bottom-left-radius: 4px; +} +.help-msg-assistant .help-msg-content code { + background: rgba(0, 200, 255, 0.1); + padding: 1px 5px; + border-radius: 3px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; +} +.help-msg-loading { + padding: 10px 14px; + border-radius: 12px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-bottom-left-radius: 4px; + color: var(--text-secondary); + font-size: 13px; + animation: helpPulse 1.2s ease-in-out infinite; +} +@keyframes helpPulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} + +.help-chat-input { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-top: 1px solid var(--border); + background: var(--bg-primary); +} +.help-chat-input input { + flex: 1; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 14px; + color: var(--text-primary); + font-size: 13px; + font-family: 'DM Sans', sans-serif; + outline: none; + transition: border-color 0.15s; +} +.help-chat-input input:focus { + border-color: var(--accent); +} +.help-chat-input input::placeholder { + color: var(--text-tertiary); +} +.help-chat-send { + width: 36px; + height: 36px; + border-radius: 8px; + background: var(--accent); + color: var(--bg-primary); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s; +} +.help-chat-send:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.help-chat-send:not(:disabled):hover { + background: var(--accent-hover); +} diff --git a/compliance-dashboard/src/app.rs b/compliance-dashboard/src/app.rs index eb850ea..cb276b5 100644 --- a/compliance-dashboard/src/app.rs +++ b/compliance-dashboard/src/app.rs @@ -44,8 +44,6 @@ pub enum Route { PentestSessionPage { session_id: String }, #[route("/mcp-servers")] McpServersPage {}, - #[route("/settings")] - SettingsPage {}, } const FAVICON: Asset = asset!("/assets/favicon.svg"); diff --git a/compliance-dashboard/src/components/app_shell.rs b/compliance-dashboard/src/components/app_shell.rs index f4d8893..c5c3c43 100644 --- a/compliance-dashboard/src/components/app_shell.rs +++ b/compliance-dashboard/src/components/app_shell.rs @@ -1,6 +1,7 @@ use dioxus::prelude::*; use crate::app::Route; +use crate::components::help_chat::HelpChat; use crate::components::sidebar::Sidebar; use crate::components::toast::{ToastContainer, Toasts}; use crate::infrastructure::auth_check::check_auth; @@ -21,6 +22,7 @@ pub fn AppShell() -> Element { Outlet:: {} } ToastContainer {} + HelpChat {} } } } diff --git a/compliance-dashboard/src/components/help_chat.rs b/compliance-dashboard/src/components/help_chat.rs new file mode 100644 index 0000000..c79f912 --- /dev/null +++ b/compliance-dashboard/src/components/help_chat.rs @@ -0,0 +1,198 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; + +use crate::infrastructure::help_chat::{send_help_chat_message, HelpChatHistoryMessage}; + +// ── Message model ──────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +struct ChatMsg { + role: String, + content: String, +} + +// ── Component ──────────────────────────────────────────────────────────────── + +#[component] +pub fn HelpChat() -> Element { + let mut is_open = use_signal(|| false); + let mut messages = use_signal(Vec::::new); + let mut input_text = use_signal(String::new); + let mut is_loading = use_signal(|| false); + + // Send message handler + let on_send = move |_| { + let text = input_text().trim().to_string(); + if text.is_empty() || is_loading() { + return; + } + + // Push user message + messages.write().push(ChatMsg { + role: "user".into(), + content: text.clone(), + }); + input_text.set(String::new()); + is_loading.set(true); + + // Build history for API call (exclude last user message, it goes as `message`) + let history: Vec = messages() + .iter() + .rev() + .skip(1) // skip the user message we just added + .rev() + .map(|m| HelpChatHistoryMessage { + role: m.role.clone(), + content: m.content.clone(), + }) + .collect(); + + spawn(async move { + match send_help_chat_message(text, history).await { + Ok(resp) => { + messages.write().push(ChatMsg { + role: "assistant".into(), + content: resp.data.message, + }); + } + Err(e) => { + messages.write().push(ChatMsg { + role: "assistant".into(), + content: format!("Error: {e}"), + }); + } + } + is_loading.set(false); + }); + }; + + // Key handler for Enter to send + let on_keydown = move |e: KeyboardEvent| { + if e.key() == Key::Enter && !e.modifiers().shift() { + e.prevent_default(); + let text = input_text().trim().to_string(); + if text.is_empty() || is_loading() { + return; + } + messages.write().push(ChatMsg { + role: "user".into(), + content: text.clone(), + }); + input_text.set(String::new()); + is_loading.set(true); + + let history: Vec = messages() + .iter() + .rev() + .skip(1) + .rev() + .map(|m| HelpChatHistoryMessage { + role: m.role.clone(), + content: m.content.clone(), + }) + .collect(); + + spawn(async move { + match send_help_chat_message(text, history).await { + Ok(resp) => { + messages.write().push(ChatMsg { + role: "assistant".into(), + content: resp.data.message, + }); + } + Err(e) => { + messages.write().push(ChatMsg { + role: "assistant".into(), + content: format!("Error: {e}"), + }); + } + } + is_loading.set(false); + }); + } + }; + + rsx! { + // Floating toggle button + if !is_open() { + button { + class: "help-chat-toggle", + onclick: move |_| is_open.set(true), + title: "Help", + Icon { icon: BsQuestionCircle, width: 22, height: 22 } + } + } + + // Chat panel + if is_open() { + div { class: "help-chat-panel", + // Header + div { class: "help-chat-header", + span { class: "help-chat-title", + Icon { icon: BsRobot, width: 16, height: 16 } + "Help Assistant" + } + button { + class: "help-chat-close", + onclick: move |_| is_open.set(false), + Icon { icon: BsX, width: 18, height: 18 } + } + } + + // Messages area + div { class: "help-chat-messages", + if messages().is_empty() { + div { class: "help-chat-empty", + p { "Ask me anything about the Compliance Scanner." } + p { class: "help-chat-hint", + "e.g. \"How do I add a repository?\" or \"What is SBOM?\"" + } + } + } + for (i, msg) in messages().iter().enumerate() { + div { + key: "{i}", + class: if msg.role == "user" { "help-msg help-msg-user" } else { "help-msg help-msg-assistant" }, + div { class: "help-msg-content", + dangerous_inner_html: if msg.role == "assistant" { + // Basic markdown rendering: bold, code, newlines + msg.content + .replace("**", "") + .replace("\n\n", "

") + .replace("\n- ", "
- ") + .replace("`", "") + } else { + msg.content.clone() + } + } + } + } + if is_loading() { + div { class: "help-msg help-msg-assistant", + div { class: "help-msg-loading", "Thinking..." } + } + } + } + + // Input area + div { class: "help-chat-input", + input { + r#type: "text", + placeholder: "Ask a question...", + value: "{input_text}", + disabled: is_loading(), + oninput: move |e| input_text.set(e.value()), + onkeydown: on_keydown, + } + button { + class: "help-chat-send", + disabled: is_loading() || input_text().trim().is_empty(), + onclick: on_send, + Icon { icon: BsSend, width: 14, height: 14 } + } + } + } + } + } +} diff --git a/compliance-dashboard/src/components/mod.rs b/compliance-dashboard/src/components/mod.rs index db96afa..594e07e 100644 --- a/compliance-dashboard/src/components/mod.rs +++ b/compliance-dashboard/src/components/mod.rs @@ -3,6 +3,7 @@ pub mod attack_chain; pub mod code_inspector; pub mod code_snippet; pub mod file_tree; +pub mod help_chat; pub mod page_header; pub mod pagination; pub mod pentest_wizard; diff --git a/compliance-dashboard/src/components/sidebar.rs b/compliance-dashboard/src/components/sidebar.rs index b8a3177..227ccaa 100644 --- a/compliance-dashboard/src/components/sidebar.rs +++ b/compliance-dashboard/src/components/sidebar.rs @@ -52,11 +52,6 @@ pub fn Sidebar() -> Element { route: Route::PentestDashboardPage {}, icon: rsx! { Icon { icon: BsLightningCharge, width: 18, height: 18 } }, }, - NavItem { - label: "Settings", - route: Route::SettingsPage {}, - icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } }, - }, ]; let docs_url = option_env!("DOCS_URL").unwrap_or("/docs"); diff --git a/compliance-dashboard/src/infrastructure/help_chat.rs b/compliance-dashboard/src/infrastructure/help_chat.rs new file mode 100644 index 0000000..6286854 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/help_chat.rs @@ -0,0 +1,59 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +// ── Response types ── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HelpChatApiResponse { + pub data: HelpChatResponseData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HelpChatResponseData { + pub message: String, +} + +// ── History message type ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HelpChatHistoryMessage { + pub role: String, + pub content: String, +} + +// ── Server function ── + +#[server] +pub async fn send_help_chat_message( + message: String, + history: Vec, +) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!("{}/api/v1/help/chat", state.agent_api_url); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let resp = client + .post(&url) + .json(&serde_json::json!({ + "message": message, + "history": history, + })) + .send() + .await + .map_err(|e| ServerFnError::new(format!("Help chat request failed: {e}")))?; + + let text = resp + .text() + .await + .map_err(|e| ServerFnError::new(format!("Failed to read response: {e}")))?; + + let body: HelpChatApiResponse = serde_json::from_str(&text) + .map_err(|e| ServerFnError::new(format!("Failed to parse response: {e}")))?; + + Ok(body) +} diff --git a/compliance-dashboard/src/infrastructure/mod.rs b/compliance-dashboard/src/infrastructure/mod.rs index 490c63b..84ceb89 100644 --- a/compliance-dashboard/src/infrastructure/mod.rs +++ b/compliance-dashboard/src/infrastructure/mod.rs @@ -5,6 +5,7 @@ pub mod chat; pub mod dast; pub mod findings; pub mod graph; +pub mod help_chat; pub mod issues; pub mod mcp; pub mod pentest; diff --git a/compliance-dashboard/src/pages/mod.rs b/compliance-dashboard/src/pages/mod.rs index bdc9281..c54217a 100644 --- a/compliance-dashboard/src/pages/mod.rs +++ b/compliance-dashboard/src/pages/mod.rs @@ -16,7 +16,6 @@ pub mod pentest_dashboard; pub mod pentest_session; pub mod repositories; pub mod sbom; -pub mod settings; pub use chat::ChatPage; pub use chat_index::ChatIndexPage; @@ -36,4 +35,3 @@ pub use pentest_dashboard::PentestDashboardPage; pub use pentest_session::PentestSessionPage; pub use repositories::RepositoriesPage; pub use sbom::SbomPage; -pub use settings::SettingsPage; diff --git a/compliance-dashboard/src/pages/settings.rs b/compliance-dashboard/src/pages/settings.rs deleted file mode 100644 index 0f41fee..0000000 --- a/compliance-dashboard/src/pages/settings.rs +++ /dev/null @@ -1,142 +0,0 @@ -use dioxus::prelude::*; - -use crate::components::page_header::PageHeader; - -#[component] -pub fn SettingsPage() -> Element { - let mut litellm_url = use_signal(|| "http://localhost:4000".to_string()); - let mut litellm_model = use_signal(|| "gpt-4o".to_string()); - let mut github_token = use_signal(String::new); - let mut gitlab_url = use_signal(|| "https://gitlab.com".to_string()); - let mut gitlab_token = use_signal(String::new); - let mut jira_url = use_signal(String::new); - let mut jira_email = use_signal(String::new); - let mut jira_token = use_signal(String::new); - let mut jira_project = use_signal(String::new); - let mut searxng_url = use_signal(|| "http://localhost:8888".to_string()); - - rsx! { - PageHeader { - title: "Settings", - description: "Configure integrations and scanning parameters", - } - - div { class: "card", - div { class: "card-header", "LiteLLM Configuration" } - div { class: "form-group", - label { "LiteLLM URL" } - input { - r#type: "text", - value: "{litellm_url}", - oninput: move |e| litellm_url.set(e.value()), - } - } - div { class: "form-group", - label { "Model" } - input { - r#type: "text", - value: "{litellm_model}", - oninput: move |e| litellm_model.set(e.value()), - } - } - } - - div { class: "card", - div { class: "card-header", "GitHub Integration" } - div { class: "form-group", - label { "Personal Access Token" } - input { - r#type: "password", - placeholder: "ghp_...", - value: "{github_token}", - oninput: move |e| github_token.set(e.value()), - } - } - } - - div { class: "card", - div { class: "card-header", "GitLab Integration" } - div { class: "form-group", - label { "GitLab URL" } - input { - r#type: "text", - value: "{gitlab_url}", - oninput: move |e| gitlab_url.set(e.value()), - } - } - div { class: "form-group", - label { "Access Token" } - input { - r#type: "password", - placeholder: "glpat-...", - value: "{gitlab_token}", - oninput: move |e| gitlab_token.set(e.value()), - } - } - } - - div { class: "card", - div { class: "card-header", "Jira Integration" } - div { class: "form-group", - label { "Jira URL" } - input { - r#type: "text", - placeholder: "https://your-org.atlassian.net", - value: "{jira_url}", - oninput: move |e| jira_url.set(e.value()), - } - } - div { class: "form-group", - label { "Email" } - input { - r#type: "email", - value: "{jira_email}", - oninput: move |e| jira_email.set(e.value()), - } - } - div { class: "form-group", - label { "API Token" } - input { - r#type: "password", - value: "{jira_token}", - oninput: move |e| jira_token.set(e.value()), - } - } - div { class: "form-group", - label { "Project Key" } - input { - r#type: "text", - placeholder: "SEC", - value: "{jira_project}", - oninput: move |e| jira_project.set(e.value()), - } - } - } - - div { class: "card", - div { class: "card-header", "SearXNG" } - div { class: "form-group", - label { "SearXNG URL" } - input { - r#type: "text", - value: "{searxng_url}", - oninput: move |e| searxng_url.set(e.value()), - } - } - } - - div { style: "margin-top: 16px;", - button { - class: "btn btn-primary", - onclick: move |_| { - tracing::info!("Settings save not yet implemented - settings are managed via .env"); - }, - "Save Settings" - } - p { - style: "margin-top: 8px; font-size: 12px; color: var(--text-secondary);", - "Note: Settings are currently configured via environment variables (.env file). Dashboard-based settings persistence coming soon." - } - } - } -}