Add RAG embedding and AI chat feature
Some checks failed
CI / Security Audit (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Failing after 3s

Implement end-to-end RAG pipeline: AST-aware code chunking, LiteLLM
embedding generation, MongoDB vector storage with brute-force cosine
similarity fallback for self-hosted instances, and a chat API with
RAG-augmented responses. Add dedicated /chat/:repo_id dashboard page
with embedding build controls, message history, and source reference
cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-04 23:29:40 +01:00
parent db454867f3
commit 89c30a62dd
25 changed files with 1692 additions and 25 deletions

View File

@@ -1710,3 +1710,240 @@ tbody tr:last-child td {
white-space: nowrap;
margin-left: auto;
}
/* ── AI Chat ── */
.chat-embedding-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
color: var(--text-secondary);
}
.chat-embedding-banner .btn-sm {
padding: 6px 14px;
font-size: 12px;
background: var(--accent-muted);
color: var(--accent);
border: 1px solid var(--border-accent);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s var(--ease-out);
}
.chat-embedding-banner .btn-sm:hover:not(:disabled) {
background: var(--accent);
color: var(--bg-primary);
}
.chat-embedding-banner .btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 240px);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-tertiary);
text-align: center;
}
.chat-empty h3 {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 8px;
}
.chat-empty p {
font-size: 13px;
max-width: 400px;
}
.chat-message {
max-width: 80%;
padding: 12px 16px;
border-radius: var(--radius);
font-size: 14px;
line-height: 1.6;
}
.chat-message-user {
align-self: flex-end;
background: var(--accent-muted);
border: 1px solid var(--border-accent);
color: var(--text-primary);
}
.chat-message-assistant {
align-self: flex-start;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-primary);
}
.chat-message-role {
font-family: var(--font-display);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.chat-message-content {
white-space: pre-wrap;
word-break: break-word;
}
.chat-typing {
color: var(--text-tertiary);
font-style: italic;
}
.chat-sources {
margin-top: 12px;
border-top: 1px solid var(--border);
padding-top: 10px;
}
.chat-sources-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
display: block;
margin-bottom: 8px;
}
.chat-source-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin-bottom: 6px;
}
.chat-source-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.chat-source-name {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
color: var(--accent);
}
.chat-source-location {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-tertiary);
}
.chat-source-snippet {
margin: 0;
padding: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow-x: auto;
max-height: 120px;
}
.chat-source-snippet code {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
white-space: pre;
}
.chat-input-area {
display: flex;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
}
.chat-input {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 14px;
padding: 10px 14px;
resize: none;
min-height: 42px;
max-height: 120px;
outline: none;
transition: border-color 0.2s var(--ease-out);
}
.chat-input:focus {
border-color: var(--accent);
}
.chat-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat-send-btn {
padding: 10px 20px;
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-display);
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.2s var(--ease-out);
align-self: flex-end;
}
.chat-send-btn:hover:not(:disabled) {
background: var(--accent-hover);
box-shadow: var(--accent-glow);
}
.chat-send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -26,6 +26,10 @@ pub enum Route {
GraphExplorerPage { repo_id: String },
#[route("/graph/:repo_id/impact/:finding_id")]
ImpactAnalysisPage { repo_id: String, finding_id: String },
#[route("/chat")]
ChatIndexPage {},
#[route("/chat/:repo_id")]
ChatPage { repo_id: String },
#[route("/dast")]
DastOverviewPage {},
#[route("/dast/targets")]

View File

@@ -46,6 +46,11 @@ pub fn Sidebar() -> Element {
route: Route::GraphIndexPage {},
icon: rsx! { Icon { icon: BsDiagram3, width: 18, height: 18 } },
},
NavItem {
label: "AI Chat",
route: Route::ChatIndexPage {},
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
},
NavItem {
label: "DAST",
route: Route::DastOverviewPage {},
@@ -58,7 +63,11 @@ pub fn Sidebar() -> Element {
},
];
let sidebar_class = if collapsed() { "sidebar collapsed" } else { "sidebar" };
let sidebar_class = if collapsed() {
"sidebar collapsed"
} else {
"sidebar"
};
rsx! {
nav { class: "{sidebar_class}",
@@ -76,6 +85,7 @@ pub fn Sidebar() -> Element {
(Route::GraphIndexPage {}, Route::GraphIndexPage {}) => true,
(Route::GraphExplorerPage { .. }, Route::GraphIndexPage {}) => true,
(Route::ImpactAnalysisPage { .. }, Route::GraphIndexPage {}) => true,
(Route::ChatPage { .. }, Route::ChatIndexPage {}) => true,
(Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,

View File

@@ -0,0 +1,126 @@
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
// ── Response types ──
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ChatApiResponse {
pub data: ChatResponseData,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ChatResponseData {
pub message: String,
#[serde(default)]
pub sources: Vec<SourceRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SourceRef {
pub file_path: String,
pub qualified_name: String,
pub start_line: u32,
pub end_line: u32,
pub language: String,
pub snippet: String,
pub score: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmbeddingStatusResponse {
pub data: Option<EmbeddingBuildData>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmbeddingBuildData {
pub repo_id: String,
pub status: String,
pub total_chunks: u32,
pub embedded_chunks: u32,
pub embedding_model: String,
pub error_message: Option<String>,
#[serde(default)]
pub started_at: Option<serde_json::Value>,
#[serde(default)]
pub completed_at: Option<serde_json::Value>,
}
// ── Chat message history type ──
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatHistoryMessage {
pub role: String,
pub content: String,
}
// ── Server functions ──
#[server]
pub async fn send_chat_message(
repo_id: String,
message: String,
history: Vec<ChatHistoryMessage>,
) -> Result<ChatApiResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/chat/{repo_id}", 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!("Request failed: {e}")))?;
let text = resp
.text()
.await
.map_err(|e| ServerFnError::new(format!("Failed to read response: {e}")))?;
let body: ChatApiResponse = serde_json::from_str(&text)
.map_err(|e| ServerFnError::new(format!("Failed to parse response: {e} — body: {text}")))?;
Ok(body)
}
#[server]
pub async fn trigger_embedding_build(repo_id: String) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/chat/{repo_id}/build-embeddings",
state.agent_api_url
);
let client = reqwest::Client::new();
client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(())
}
#[server]
pub async fn fetch_embedding_status(
repo_id: String,
) -> Result<EmbeddingStatusResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/chat/{repo_id}/status", state.agent_api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: EmbeddingStatusResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}

View File

@@ -1,5 +1,6 @@
// Server function modules (compiled for both web and server;
// the #[server] macro generates client stubs for the web target)
pub mod chat;
pub mod dast;
pub mod findings;
pub mod graph;

View File

@@ -0,0 +1,232 @@
use dioxus::prelude::*;
use crate::components::page_header::PageHeader;
use crate::infrastructure::chat::{
fetch_embedding_status, send_chat_message, trigger_embedding_build, ChatHistoryMessage,
SourceRef,
};
/// A UI-level chat message
#[derive(Clone, Debug)]
struct UiChatMessage {
role: String,
content: String,
sources: Vec<SourceRef>,
}
#[component]
pub fn ChatPage(repo_id: String) -> Element {
let mut messages: Signal<Vec<UiChatMessage>> = use_signal(Vec::new);
let mut input_text = use_signal(String::new);
let mut loading = use_signal(|| false);
let mut building = use_signal(|| false);
let repo_id_for_status = repo_id.clone();
let mut embedding_status = use_resource(move || {
let rid = repo_id_for_status.clone();
async move { fetch_embedding_status(rid).await.ok() }
});
let has_embeddings = {
let status = embedding_status.read();
match &*status {
Some(Some(resp)) => resp
.data
.as_ref()
.map(|d| d.status == "completed")
.unwrap_or(false),
_ => false,
}
};
let embedding_status_text = {
let status = embedding_status.read();
match &*status {
Some(Some(resp)) => match &resp.data {
Some(d) => match d.status.as_str() {
"completed" => format!(
"Embeddings ready: {}/{} chunks",
d.embedded_chunks, d.total_chunks
),
"running" => format!(
"Building embeddings: {}/{}...",
d.embedded_chunks, d.total_chunks
),
"failed" => format!(
"Embedding build failed: {}",
d.error_message.as_deref().unwrap_or("unknown error")
),
s => format!("Status: {s}"),
},
None => "No embeddings built yet".to_string(),
},
Some(None) => "Failed to check embedding status".to_string(),
None => "Checking embedding status...".to_string(),
}
};
let repo_id_for_build = repo_id.clone();
let on_build = move |_| {
let rid = repo_id_for_build.clone();
building.set(true);
spawn(async move {
let _ = trigger_embedding_build(rid).await;
building.set(false);
embedding_status.restart();
});
};
let repo_id_for_send = repo_id.clone();
let mut do_send = move || {
let text = input_text.read().trim().to_string();
if text.is_empty() || *loading.read() {
return;
}
let rid = repo_id_for_send.clone();
let user_msg = text.clone();
// Add user message to UI
messages.write().push(UiChatMessage {
role: "user".to_string(),
content: user_msg.clone(),
sources: Vec::new(),
});
input_text.set(String::new());
loading.set(true);
spawn(async move {
// Build history from existing messages
let history: Vec<ChatHistoryMessage> = messages
.read()
.iter()
.filter(|m| m.role == "user" || m.role == "assistant")
.rev()
.skip(1) // skip the message we just added
.take(10) // limit history
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|m| ChatHistoryMessage {
role: m.role.clone(),
content: m.content.clone(),
})
.collect();
match send_chat_message(rid, user_msg, history).await {
Ok(resp) => {
messages.write().push(UiChatMessage {
role: "assistant".to_string(),
content: resp.data.message,
sources: resp.data.sources,
});
}
Err(e) => {
messages.write().push(UiChatMessage {
role: "assistant".to_string(),
content: format!("Error: {e}"),
sources: Vec::new(),
});
}
}
loading.set(false);
});
};
let mut do_send_click = do_send.clone();
rsx! {
PageHeader { title: "AI Chat" }
// Embedding status banner
div { class: "chat-embedding-banner",
span { "{embedding_status_text}" }
button {
class: "btn btn-sm",
disabled: *building.read(),
onclick: on_build,
if *building.read() { "Building..." } else { "Build Embeddings" }
}
}
div { class: "chat-container",
// Message list
div { class: "chat-messages",
if messages.read().is_empty() && !*loading.read() {
div { class: "chat-empty",
h3 { "Ask anything about your codebase" }
p { "Build embeddings first, then ask questions about functions, architecture, patterns, and more." }
}
}
for (i, msg) in messages.read().iter().enumerate() {
{
let class = if msg.role == "user" {
"chat-message chat-message-user"
} else {
"chat-message chat-message-assistant"
};
let content = msg.content.clone();
let sources = msg.sources.clone();
rsx! {
div { class: class, key: "{i}",
div { class: "chat-message-role",
if msg.role == "user" { "You" } else { "Assistant" }
}
div { class: "chat-message-content", "{content}" }
if !sources.is_empty() {
div { class: "chat-sources",
span { class: "chat-sources-label", "Sources:" }
for src in sources {
div { class: "chat-source-card",
div { class: "chat-source-header",
span { class: "chat-source-name",
"{src.qualified_name}"
}
span { class: "chat-source-location",
"{src.file_path}:{src.start_line}-{src.end_line}"
}
}
pre { class: "chat-source-snippet",
code { "{src.snippet}" }
}
}
}
}
}
}
}
}
}
if *loading.read() {
div { class: "chat-message chat-message-assistant",
div { class: "chat-message-role", "Assistant" }
div { class: "chat-message-content chat-typing", "Thinking..." }
}
}
}
// Input area
div { class: "chat-input-area",
textarea {
class: "chat-input",
placeholder: "Ask about your codebase...",
value: "{input_text}",
disabled: !has_embeddings,
oninput: move |e| input_text.set(e.value()),
onkeydown: move |e: Event<KeyboardData>| {
if e.key() == Key::Enter && !e.modifiers().shift() {
e.prevent_default();
do_send();
}
},
}
button {
class: "btn chat-send-btn",
disabled: *loading.read() || !has_embeddings,
onclick: move |_| do_send_click(),
"Send"
}
}
}
}
}

View File

@@ -0,0 +1,71 @@
use dioxus::prelude::*;
use crate::app::Route;
use crate::components::page_header::PageHeader;
use crate::infrastructure::chat::fetch_embedding_status;
use crate::infrastructure::repositories::fetch_repositories;
#[component]
pub fn ChatIndexPage() -> Element {
let repos = use_resource(|| async { fetch_repositories(1).await.ok() });
rsx! {
PageHeader {
title: "AI Chat",
description: "Ask questions about your codebase using RAG-augmented AI",
}
match &*repos.read() {
Some(Some(data)) => {
let repo_list = &data.data;
if repo_list.is_empty() {
rsx! {
div { class: "card",
p { "No repositories found. Add a repository first." }
}
}
} else {
rsx! {
div { class: "graph-index-grid",
for repo in repo_list {
{
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
let name = repo.name.clone();
let url = repo.git_url.clone();
let branch = repo.default_branch.clone();
rsx! {
Link {
to: Route::ChatPage { repo_id },
class: "graph-repo-card",
div { class: "graph-repo-card-header",
div { class: "graph-repo-card-icon", "\u{1F4AC}" }
h3 { class: "graph-repo-card-name", "{name}" }
}
if !url.is_empty() {
p { class: "graph-repo-card-url", "{url}" }
}
div { class: "graph-repo-card-meta",
span { class: "graph-repo-card-tag",
"\u{E0A0} {branch}"
}
span { class: "graph-repo-card-tag",
"AI Chat"
}
}
}
}
}
}
}
}
}
},
Some(None) => rsx! {
div { class: "card", p { "Failed to load repositories." } }
},
None => rsx! {
div { class: "loading", "Loading repositories..." }
},
}
}
}

View File

@@ -1,3 +1,5 @@
pub mod chat;
pub mod chat_index;
pub mod dast_finding_detail;
pub mod dast_findings;
pub mod dast_overview;
@@ -13,6 +15,8 @@ pub mod repositories;
pub mod sbom;
pub mod settings;
pub use chat::ChatPage;
pub use chat_index::ChatIndexPage;
pub use dast_finding_detail::DastFindingDetailPage;
pub use dast_findings::DastFindingsPage;
pub use dast_overview::DastOverviewPage;