Add RAG embedding and AI chat feature
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
126
compliance-dashboard/src/infrastructure/chat.rs
Normal file
126
compliance-dashboard/src/infrastructure/chat.rs
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
232
compliance-dashboard/src/pages/chat.rs
Normal file
232
compliance-dashboard/src/pages/chat.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
compliance-dashboard/src/pages/chat_index.rs
Normal file
71
compliance-dashboard/src/pages/chat_index.rs
Normal 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..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user