use crate::shared::state::AppState; use axum::{ extract::{Path, State}, response::{Html, IntoResponse}, routing::{get, post}, Json, Router, }; use diesel::prelude::*; use serde::{Deserialize, Serialize}; use std::sync::Arc; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchQuery { pub q: Option, pub collection: Option, pub filters: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchRequest { pub query: Option, pub collection: Option, pub filters: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewCollectionRequest { pub name: String, pub description: Option, } #[derive(Debug, QueryableByName)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct KbDocumentRow { #[diesel(sql_type = diesel::sql_types::Text)] pub id: String, #[diesel(sql_type = diesel::sql_types::Text)] pub title: String, #[diesel(sql_type = diesel::sql_types::Text)] pub content: String, #[diesel(sql_type = diesel::sql_types::Text)] pub collection_id: String, #[diesel(sql_type = diesel::sql_types::Text)] pub source_path: String, } #[derive(Debug, QueryableByName)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct CollectionRow { #[diesel(sql_type = diesel::sql_types::Text)] pub id: String, #[diesel(sql_type = diesel::sql_types::Text)] pub name: String, #[diesel(sql_type = diesel::sql_types::Text)] pub description: String, } pub fn configure_research_routes() -> Router> { Router::new() // Collections - match frontend hx-* endpoints .route("/api/research/collections", get(handle_list_collections)) .route( "/api/research/collections/new", post(handle_create_collection), ) .route("/api/research/collections/{id}", get(handle_get_collection)) // Search .route("/api/research/search", post(handle_search)) .route("/api/research/recent", get(handle_recent_searches)) .route("/api/research/trending", get(handle_trending_tags)) .route("/api/research/prompts", get(handle_prompts)) // Export .route( "/api/research/export-citations", get(handle_export_citations), ) } /// GET /api/research/collections - List all collections pub async fn handle_list_collections(State(state): State>) -> impl IntoResponse { let conn = state.conn.clone(); let collections = tokio::task::spawn_blocking(move || { let mut db_conn = match conn.get() { Ok(c) => c, Err(e) => { log::error!("DB connection error: {}", e); return get_default_collections(); } }; let result: Result, _> = diesel::sql_query("SELECT id, name, description FROM kb_collections ORDER BY name ASC") .load(&mut db_conn); match result { Ok(colls) if !colls.is_empty() => colls .into_iter() .map(|c| (c.id, c.name, c.description)) .collect(), _ => get_default_collections(), } }) .await .unwrap_or_else(|_| get_default_collections()); let mut html = String::new(); for (id, name, description) in &collections { html.push_str("
"); html.push_str("
"); html.push_str("
"); html.push_str(""); html.push_str(&html_escape(name)); html.push_str(""); html.push_str(""); html.push_str(&html_escape(description)); html.push_str(""); html.push_str("
"); html.push_str(""); html.push_str("
"); } if collections.is_empty() { html.push_str("
"); html.push_str("

No collections yet

"); html.push_str("
"); } Html(html) } fn get_default_collections() -> Vec<(String, String, String)> { vec![ ( "general".to_string(), "General Knowledge".to_string(), "Default knowledge base".to_string(), ), ( "docs".to_string(), "Documentation".to_string(), "Product documentation".to_string(), ), ( "faq".to_string(), "FAQ".to_string(), "Frequently asked questions".to_string(), ), ] } /// POST /api/research/collections/new - Create a new collection pub async fn handle_create_collection( State(state): State>, Json(payload): Json, ) -> impl IntoResponse { let conn = state.conn.clone(); let id = uuid::Uuid::new_v4().to_string(); let name = payload.name.clone(); let description = payload.description.unwrap_or_default(); let id_clone = id.clone(); let name_clone = name.clone(); let desc_clone = description.clone(); let _ = tokio::task::spawn_blocking(move || { let mut db_conn = match conn.get() { Ok(c) => c, Err(e) => { log::error!("DB connection error: {}", e); return; } }; let _ = diesel::sql_query( "INSERT INTO kb_collections (id, name, description) VALUES ($1, $2, $3)", ) .bind::(&id) .bind::(&name) .bind::(&description) .execute(&mut db_conn); }) .await; let mut html = String::new(); html.push_str("
"); html.push_str("
"); html.push_str("
"); html.push_str(""); html.push_str(&html_escape(&name_clone)); html.push_str(""); html.push_str(""); html.push_str(&html_escape(&desc_clone)); html.push_str(""); html.push_str("
"); html.push_str("
"); Html(html) } /// GET /api/research/collections/{id} - Get collection contents pub async fn handle_get_collection( State(state): State>, Path(id): Path, ) -> impl IntoResponse { let conn = state.conn.clone(); let documents = tokio::task::spawn_blocking(move || { let mut db_conn = match conn.get() { Ok(c) => c, Err(e) => { log::error!("DB connection error: {}", e); return Vec::new(); } }; diesel::sql_query( "SELECT id, title, content, collection_id, source_path FROM kb_documents WHERE collection_id = $1 ORDER BY title ASC LIMIT 50", ) .bind::(&id) .load::(&mut db_conn) .unwrap_or_default() }) .await .unwrap_or_default(); let mut html = String::new(); html.push_str("
"); html.push_str("
"); html.push_str("

Collection Contents

"); html.push_str(""); html.push_str(&documents.len().to_string()); html.push_str(" documents"); html.push_str("
"); html.push_str("
"); if documents.is_empty() { html.push_str("
"); html.push_str("

No documents in this collection

"); html.push_str("

Add documents to build your knowledge base

"); html.push_str("
"); } else { for doc in &documents { html.push_str(&format_search_result( &doc.id, &doc.title, &doc.content, &doc.source_path, )); } } html.push_str("
"); html.push_str("
"); Html(html) } /// POST /api/research/search - Semantic search pub async fn handle_search( State(state): State>, Json(payload): Json, ) -> impl IntoResponse { let query = payload.query.unwrap_or_default(); if query.trim().is_empty() { return Html("

Enter a search query to find relevant documents

".to_string()); } let conn = state.conn.clone(); let collection = payload.collection; let results = tokio::task::spawn_blocking(move || { let mut db_conn = match conn.get() { Ok(c) => c, Err(e) => { log::error!("DB connection error: {}", e); return Vec::new(); } }; let search_pattern = format!("%{}%", query.to_lowercase()); let docs = if let Some(coll) = collection { diesel::sql_query( "SELECT id, title, content, collection_id, source_path FROM kb_documents WHERE (LOWER(title) LIKE $1 OR LOWER(content) LIKE $1) AND collection_id = $2 ORDER BY title ASC LIMIT 20", ) .bind::(&search_pattern) .bind::(&coll) .load::(&mut db_conn) .unwrap_or_default() } else { diesel::sql_query( "SELECT id, title, content, collection_id, source_path FROM kb_documents WHERE LOWER(title) LIKE $1 OR LOWER(content) LIKE $1 ORDER BY title ASC LIMIT 20", ) .bind::(&search_pattern) .load::(&mut db_conn) .unwrap_or_default() }; docs }) .await .unwrap_or_default(); let mut html = String::new(); html.push_str("
"); html.push_str("
"); html.push_str("

Search Results

"); html.push_str(""); html.push_str(&results.len().to_string()); html.push_str(" results found"); html.push_str("
"); html.push_str("
"); if results.is_empty() { html.push_str("
"); html.push_str("
"); html.push_str("

No results found

"); html.push_str("

Try different keywords or check your spelling

"); html.push_str("
"); } else { for doc in &results { html.push_str(&format_search_result( &doc.id, &doc.title, &doc.content, &doc.source_path, )); } } html.push_str("
"); html.push_str("
"); Html(html) } fn format_search_result(id: &str, title: &str, content: &str, source: &str) -> String { let snippet = if content.len() > 200 { format!("{}...", &content[..200]) } else { content.to_string() }; let mut html = String::new(); html.push_str("
"); html.push_str("
"); html.push_str("

"); html.push_str(&html_escape(title)); html.push_str("

"); html.push_str(""); html.push_str(&html_escape(source)); html.push_str(""); html.push_str("
"); html.push_str("

"); html.push_str(&html_escape(&snippet)); html.push_str("

"); html.push_str("
"); html.push_str(""); html.push_str(""); html.push_str(""); html.push_str("
"); html.push_str("
"); html } /// GET /api/research/recent - Recent searches pub async fn handle_recent_searches(State(_state): State>) -> impl IntoResponse { let recent_searches = vec![ "How to get started", "API documentation", "Configuration guide", "Best practices", "Troubleshooting", ]; let mut html = String::new(); for search in &recent_searches { html.push_str( "
"); html.push_str("🕐"); html.push_str(""); html.push_str(&html_escape(search)); html.push_str(""); html.push_str("
"); } if recent_searches.is_empty() { html.push_str("
"); html.push_str("

No recent searches

"); html.push_str("
"); } Html(html) } /// GET /api/research/trending - Trending tags pub async fn handle_trending_tags(State(_state): State>) -> impl IntoResponse { let tags = vec![ ("getting-started", 42), ("api", 38), ("integration", 25), ("configuration", 22), ("deployment", 18), ("security", 15), ("performance", 12), ("troubleshooting", 10), ]; let mut html = String::new(); html.push_str("
"); for (tag, count) in &tags { html.push_str( ""); html.push_str(&html_escape(tag)); html.push_str(" ("); html.push_str(&count.to_string()); html.push_str(")"); html.push_str(""); } html.push_str("
"); Html(html) } /// GET /api/research/prompts - Get research prompts/suggestions pub async fn handle_prompts(State(_state): State>) -> impl IntoResponse { let prompts = vec![ ( "", "Getting Started", "Learn the basics and set up your first bot", ), ("", "Configuration", "Customize settings and preferences"), ( "🔌", "Integrations", "Connect with external services and APIs", ), ("", "Deployment", "Deploy your bot to production"), ("", "Security", "Best practices for securing your bot"), ("", "Analytics", "Monitor and analyze bot performance"), ]; let mut html = String::new(); html.push_str("
"); for (icon, title, description) in &prompts { html.push_str( "
"); html.push_str("
"); html.push_str(icon); html.push_str("
"); html.push_str("
"); html.push_str("

"); html.push_str(&html_escape(title)); html.push_str("

"); html.push_str("

"); html.push_str(&html_escape(description)); html.push_str("

"); html.push_str("
"); html.push_str("
"); } html.push_str("
"); Html(html) } /// GET /api/research/export-citations - Export citations pub async fn handle_export_citations(State(_state): State>) -> impl IntoResponse { Html("".to_string()) } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") }