feat: add floating help chat widget, remove settings page
Some checks failed
CI / Detect Changes (pull_request) Has been cancelled
CI / Deploy Agent (pull_request) Has been cancelled
CI / Deploy Dashboard (pull_request) Has been cancelled
CI / Deploy Docs (pull_request) Has been cancelled
CI / Deploy MCP (pull_request) Has been cancelled
CI / Check (pull_request) Has been cancelled

Add a documentation-grounded help chat assistant accessible from every
page via a floating button in the bottom-right corner.

Backend (compliance-agent):
- New POST /api/v1/help/chat endpoint
- Loads README.md + docs/**/*.md at first request (OnceLock cache)
- Excludes node_modules, uses walkdir for discovery
- Falls back to degraded prompt if docs not found
- Uses LiteLLM via existing chat_with_messages infrastructure

Dashboard (compliance-dashboard):
- New HelpChat component with toggle button, message area, input
- Styled to match Obsidian Control theme (dark, accent cyan)
- Renders in AppShell so it's available on every page
- Multi-turn conversation with history
- Server function proxies to agent API

Also:
- Remove Settings page (route, sidebar entry, page file)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-30 09:43:39 +02:00
parent bae24f9cf8
commit 263a4e654a
13 changed files with 653 additions and 151 deletions

View File

@@ -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);
}

View File

@@ -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");

View File

@@ -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::<Route> {}
}
ToastContainer {}
HelpChat {}
}
}
}

View File

@@ -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::<ChatMsg>::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<HelpChatHistoryMessage> = 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<HelpChatHistoryMessage> = 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("**", "<strong>")
.replace("\n\n", "<br><br>")
.replace("\n- ", "<br>- ")
.replace("`", "<code>")
} 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 }
}
}
}
}
}
}

View File

@@ -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;

View File

@@ -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");

View File

@@ -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<HelpChatHistoryMessage>,
) -> Result<HelpChatApiResponse, ServerFnError> {
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)
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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."
}
}
}
}