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
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:
187
compliance-agent/src/api/handlers/help_chat.rs
Normal file
187
compliance-agent/src/api/handlers/help_chat.rs
Normal file
@@ -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<HelpChatMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HelpChatResponse {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ── Doc cache ────────────────────────────────────────────────────────────────
|
||||
|
||||
static DOC_CONTEXT: OnceLock<String> = 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<PathBuf> {
|
||||
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<String> = Vec::new();
|
||||
|
||||
// Root README first
|
||||
if let Ok(content) = std::fs::read_to_string(root.join("README.md")) {
|
||||
parts.push(format!("<!-- file: README.md -->\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!("<!-- file: {} -->\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<HelpChatRequest>,
|
||||
) -> Result<Json<ApiResponse<HelpChatResponse>>, 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,
|
||||
}))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
198
compliance-dashboard/src/components/help_chat.rs
Normal file
198
compliance-dashboard/src/components/help_chat.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
59
compliance-dashboard/src/infrastructure/help_chat.rs
Normal file
59
compliance-dashboard/src/infrastructure/help_chat.rs
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user