pub mod db;
pub mod entities;
pub mod llama;
pub mod mcp;
#[cfg(test)]
mod tests;
use crate::llama::LlamaClient;
use dotenvy::dotenv;
use sea_orm::DatabaseConnection;
use std::env;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager};
use sea_orm::{ConnectionTrait, Statement, DatabaseBackend};
use std::sync::atomic::AtomicUsize;
pub struct AppState {
pub db: DatabaseConnection,
pub llama: Arc<LlamaClient>,
pub mcp_tx: tokio::sync::broadcast::Sender<serde_json::Value>,
pub connection_count: Arc<AtomicUsize>,
pub app_handle: tauri::AppHandle,
}
// --- Path Resolution Helpers ---
// --- Path Resolution Helpers ---
pub fn find_build_assets_dir_logic(res_dir: Option<PathBuf>, exe_dir: Option<PathBuf>) -> Option<PathBuf> {
// 1. Check Resource Dir (Release / Installed) - prioritizes nested structure
if let Some(res_dir) = res_dir {
let candidate = res_dir.join("build_assets");
if candidate.exists() {
log::info!("Found build_assets in resource_dir: {:?}", candidate);
return Some(candidate);
}
}
// 2. Check relative to Executable (Debug / Dev) - walks up directory tree
if let Some(exe_dir) = exe_dir {
let mut p = exe_dir;
for _ in 0..10 { // Increased depth to check up to 9 levels up
let candidate = p.join("build_assets");
if candidate.exists() {
log::info!("Found build_assets relative to exe: {:?}", candidate);
return Some(candidate);
}
if !p.pop() { break; }
}
}
None
}
pub fn find_build_assets_dir(app_handle: &tauri::AppHandle) -> Option<PathBuf> {
let res_dir = app_handle.path().resource_dir().ok();
let exe_dir = env::current_exe().ok().and_then(|p| p.parent().map(|p| p.to_path_buf()));
find_build_assets_dir_logic(res_dir, exe_dir)
}
pub fn resolve_resource_path(app_handle: &tauri::AppHandle, relative_path: &str) -> Option<PathBuf> {
find_build_assets_dir(app_handle).map(|dir| dir.join(relative_path))
}
fn diagnose_environment(app_handle: &tauri::AppHandle) {
log::info!("--- [DIAGNOSIS] Environment Check Start ---");
// 1. Build Assets Dir
match find_build_assets_dir(app_handle) {
Some(path) => log::info!("✅ build_assets found at: {:?}", path),
None => log::error!("❌ build_assets NOT FOUND. Sidecar and resources will fail."),
}
// 2. Critical Files Check
let critical_files = ["mcp.json", "config.json", "vec0.dll", "llama.dll"];
for file in critical_files {
match resolve_resource_path(app_handle, file) {
Some(p) if p.exists() => log::info!("✅ found {}: {:?}", file, p),
Some(p) => log::error!("❌ missing {}: path resolved to {:?} but file does not exist", file, p),
None => log::error!("❌ could not resolve path for {}", file),
}
}
// 3. Model file check (sample)
if let Some(base) = find_build_assets_dir(app_handle) {
let model_dir = base.join("models");
if model_dir.exists() {
log::info!("✅ models directory exists at: {:?}", model_dir);
} else {
log::warn!("⚠️ models directory NOT found at: {:?}", model_dir);
}
}
log::info!("--- [DIAGNOSIS] Environment Check End ---");
}
#[tauri::command]
fn get_mcp_info(app_handle: tauri::AppHandle) -> Result<serde_json::Value, String> {
let mcp_path = resolve_resource_path(&app_handle, "mcp.json")
.ok_or_else(|| "Failed to resolve mcp.json path".to_string())?;
if !mcp_path.exists() {
return Err(format!("mcp.json not found at {:?}", mcp_path));
}
let content = std::fs::read_to_string(&mcp_path).map_err(|e| e.to_string())?;
let mcp_data: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
Ok(mcp_data)
}
#[tauri::command]
async fn get_db_stats(state: tauri::State<'_, Arc<AppState>>) -> Result<serde_json::Value, String> {
use crate::entities::items;
use sea_orm::{EntityTrait, PaginatorTrait};
let count = items::Entity::find().count(&state.db).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "itemCount": count }))
}
#[tauri::command]
async fn get_sidecar_status(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
Ok(state.llama.check_health().await)
}
// --- DB Browser Commands ---
fn validate_table_name(table_name: &str) -> Result<(), String> {
if table_name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
Ok(())
} else {
Err("Invalid table name".to_string())
}
}
#[tauri::command]
async fn get_table_list(state: tauri::State<'_, Arc<AppState>>) -> Result<Vec<String>, String> {
let results = state.db.query_all(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)).await.map_err(|e| e.to_string())?;
let mut tables = Vec::new();
for res in results {
let name: String = res.try_get("", "name").map_err(|e| e.to_string())?;
tables.push(name);
}
Ok(tables)
}
#[tauri::command]
async fn get_table_schema(state: tauri::State<'_, Arc<AppState>>, table_name: String) -> Result<serde_json::Value, String> {
validate_table_name(&table_name)?;
let results = state.db.query_all(Statement::from_string(
DatabaseBackend::Sqlite,
format!("PRAGMA table_info({})", table_name)
)).await.map_err(|e| e.to_string())?;
let mut schema = Vec::new();
for res in results {
schema.push(serde_json::json!({
"cid": res.try_get::<i32>("", "cid").map_err(|e| e.to_string())?,
"name": res.try_get::<String>("", "name").map_err(|e| e.to_string())?,
"type": res.try_get::<String>("", "type").map_err(|e| e.to_string())?,
"notnull": res.try_get::<i32>("", "notnull").map_err(|e| e.to_string())?,
"dflt_value": res.try_get::<Option<String>>("", "dflt_value").map_err(|e| e.to_string())?,
"pk": res.try_get::<i32>("", "pk").map_err(|e| e.to_string())?,
}));
}
Ok(serde_json::json!(schema))
}
#[tauri::command]
async fn get_table_data(
state: tauri::State<'_, Arc<AppState>>,
table_name: String,
limit: i64,
offset: i64
) -> Result<serde_json::Value, String> {
validate_table_name(&table_name)?;
// Get column names first to handle dynamic results
let schema_res = get_table_schema(state.clone(), table_name.clone()).await?;
let columns: Vec<String> = schema_res.as_array()
.ok_or("Failed to parse schema")?
.iter()
.map(|c| c["name"].as_str().unwrap_or("").to_string())
.collect();
let results = state.db.query_all(Statement::from_string(
DatabaseBackend::Sqlite,
format!("SELECT * FROM {} LIMIT {} OFFSET {}", table_name, limit, offset)
)).await.map_err(|e| e.to_string())?;
let mut data = Vec::new();
for res in results {
let mut row = serde_json::Map::new();
for col in &columns {
// Try as different types since SeaORM Value is complex
if let Ok(val) = res.try_get::<i32>("", col) {
row.insert(col.clone(), serde_json::json!(val));
} else if let Ok(val) = res.try_get::<String>("", col) {
row.insert(col.clone(), serde_json::json!(val));
} else if let Ok(val) = res.try_get::<Option<String>>("", col) {
row.insert(col.clone(), serde_json::json!(val));
} else if let Ok(val) = res.try_get::<f64>("", col) {
row.insert(col.clone(), serde_json::json!(val));
} else if let Ok(val) = res.try_get::<Vec<u8>>("", col) {
row.insert(col.clone(), serde_json::json!(format!("<BLOB: {} bytes>", val.len())));
} else {
row.insert(col.clone(), serde_json::json!(null));
}
}
data.push(serde_json::Value::Object(row));
}
let total_count: i64 = match table_name.as_str() {
"items" => {
use crate::entities::items;
use sea_orm::{EntityTrait, PaginatorTrait};
items::Entity::find().count(&state.db).await.map_err(|e| e.to_string())? as i64
},
_ => {
let res = state.db.query_one(Statement::from_string(
DatabaseBackend::Sqlite,
format!("SELECT COUNT(*) as count FROM {}", table_name)
)).await.map_err(|e| e.to_string())?;
res.map(|r| r.try_get::<i64>("", "count").unwrap_or(0)).unwrap_or(0)
}
};
Ok(serde_json::json!({
"data": data,
"total": total_count
}))
}
fn get_config(app_handle: &tauri::AppHandle) -> serde_json::Value {
// 1. Try unified resource path first
if let Some(path) = resolve_resource_path(app_handle, "config.json") {
log::info!("Checking config.json at resolved path: {:?}", path);
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) {
return parsed;
}
}
}
}
// 2. Fallbacks for flexibility (AppConfig direct, CWD)
let mut config_paths = vec![];
if let Ok(app_data) = app_handle.path().app_config_dir() { config_paths.push(app_data.join("config.json")); }
if let Ok(cwd) = env::current_dir() { config_paths.push(cwd.join("config.json")); }
for path in config_paths {
if path.exists() {
if let Ok(content) = std::fs::read_to_string(path) {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) { return parsed; }
}
}
}
// Default fallback
serde_json::json!({
"database": { "path": "data/vector.db" },
"model": { "path": "models/embeddinggemma-300m-q4_0.gguf" },
"llama_server": { "port": 8080 }
})
}
fn resolve_db_path(app_handle: &tauri::AppHandle, config: &serde_json::Value) -> String {
if let Ok(p) = env::var("DB_PATH") { return p; }
if let Some(p) = config.get("database").and_then(|d| d.get("path")).and_then(|p| p.as_str()) {
let mut candidate = PathBuf::from(p);
if candidate.is_relative() {
if cfg!(debug_assertions) {
if let Ok(exe_path) = env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let mut pr = exe_dir.to_path_buf();
for _ in 0..4 {
if pr.join("config.json").exists() { candidate = pr.join(p); break; }
if !pr.pop() { break; }
}
}
}
} else {
let mut p_base = app_handle.path().app_data_dir().expect("App data dir not found");
p_base.push("data");
let _ = std::fs::create_dir_all(&p_base);
p_base.push("vector.db");
candidate = p_base;
}
}
return candidate.to_string_lossy().to_string();
}
// Default fallback
if cfg!(debug_assertions) { "data/vector.db".to_string() } else {
let mut p = app_handle.path().app_data_dir().expect("App data dir not found");
p.push("data");
let _ = std::fs::create_dir_all(&p);
p.push("vector.db");
p.to_string_lossy().to_string()
}
}
fn resolve_extension_path(app_handle: &tauri::AppHandle) -> String {
// 1. Check verified resource path (build_assets/vec0.dll)
if let Some(path) = resolve_resource_path(app_handle, "vec0.dll") {
if path.exists() {
return path.to_string_lossy().to_string();
}
}
// 2. Search relative to EXE for local dev
if let Ok(exe_path) = env::current_exe() {
if let Some(mut p) = exe_path.parent() {
let mut pr = p.to_path_buf();
for _ in 0..10 {
// Check in bin/
let bin_cand = pr.join("bin").join("vec0.dll");
if bin_cand.exists() { return bin_cand.to_string_lossy().to_string(); }
// Check in node_modules (legacy/other)
let nm_cand = pr.join("node_modules/sqlite-vec-windows-x64/vec0.dll");
if nm_cand.exists() { return nm_cand.to_string_lossy().to_string(); }
if !pr.pop() { break; }
}
}
}
"vec0.dll".to_string()
}
fn spawn_llama_server(app_handle: &tauri::AppHandle, _config: &serde_json::Value) {
use tauri_plugin_shell::ShellExt;
let sidecar = match app_handle.shell().sidecar("llama-server") {
Ok(s) => s,
Err(e) => {
eprintln!("CRITICAL: Failed to create sidecar handle: {}", e);
return;
}
};
// 1. Resolve base_dir using unified logic
let base_dir = match find_build_assets_dir(app_handle) {
Some(p) => p,
None => {
log::error!("CRITICAL: Could not find build_assets. Sidecar will likely fail due to missing DLLs/Models.");
// Fallback to something sane? Or just exe_dir?
env::current_exe()
.map(|p| p.parent().unwrap_or(&p).to_path_buf())
.unwrap_or_else(|_| PathBuf::from("."))
}
};
// 2. Pre-launch Integrity Check (DLLs)
let required_dlls = ["llama.dll", "ggml.dll"]; // Add others if known
for dll in required_dlls {
let dll_path = base_dir.join(dll);
if !dll_path.exists() {
log::error!("CRITICAL: Required DLL not found at: {:?}. Llama server may fail to start.", dll_path);
} else {
log::info!("Verified DLL exists: {:?}", dll_path);
}
}
// Model path should be in base_dir/models/...
let mut model_path = base_dir.join("models").join("embeddinggemma-300m-q4_0.gguf");
if !model_path.exists() {
log::warn!("Model not found at default location: {:?}. Checking fallback...", model_path);
// Double check if config has an absolute path?
// For now, assuming standard layout.
}
log::info!("Using base_dir for sidecar: {:?}", base_dir);
log::info!("Using model path for sidecar: {:?}", model_path);
// Prepare arguments
let args = [
"--model", model_path.to_str().unwrap_or(""),
"--port", "8080",
"--embedding",
"--host", "127.0.0.1",
"-c", "8192", "-b", "8192", "-ub", "8192",
"--parallel", "1"
];
// 3. Update PATH environment variable to include base_dir and bin dir
let old_path = std::env::var("PATH").unwrap_or_default();
let bin_dir = base_dir.parent().map(|p| p.join("bin")).unwrap_or_else(|| PathBuf::from("bin"));
let new_path = format!("{};{};{}", base_dir.display(), bin_dir.display(), old_path).replace("\\\\?\\", "");
log::info!("Setting Sidecar PATH: {}", new_path);
let mut sidecar_cmd = sidecar.args(args).env("PATH", new_path);
// Set current directory to base_dir as well
if base_dir.exists() {
sidecar_cmd = sidecar_cmd.current_dir(&base_dir);
}
let (mut rx, _child) = match sidecar_cmd.spawn() {
Ok(res) => res,
Err(e) => {
log::error!("CRITICAL: Failed to spawn sidecar: {}", e);
return;
}
};
println!("llama-server started (Sidecar)");
// Handle output
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line) => {
let s = String::from_utf8_lossy(&line);
if s.contains("HTTP server listening") {
println!("llama-server: Ready");
}
}
tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
eprintln!("llama-server error: {}", String::from_utf8_lossy(&line));
}
tauri_plugin_shell::process::CommandEvent::Terminated(status) => {
println!("llama-server terminated with status: {:?}", status.code);
break;
}
_ => {}
}
}
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.setup(|app| {
// 1. Initialize Logging FIRST for crash diagnostics
let mut log_builder = tauri_plugin_log::Builder::default()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None }),
])
.max_file_size(10 * 1024 * 1024)
.level(log::LevelFilter::Info);
if cfg!(debug_assertions) {
log_builder = log_builder.target(tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Folder { path: std::path::PathBuf::from("logs"), file_name: None }));
}
let _ = app.handle().plugin(log_builder.build());
log::info!("Application starting (Tauri 2)...");
// 2. DIAGNOSIS (New)
diagnose_environment(app.handle());
// 3. Load env
dotenv().ok();
let app_handle = app.handle().clone();
// 3. Start Sidecar
let config = get_config(&app_handle);
spawn_llama_server(&app_handle, &config);
// 4. Database and MCP initialization
tauri::async_runtime::block_on(async move {
let db_path = resolve_db_path(&app_handle, &config);
let ext_path = resolve_extension_path(&app_handle);
log::info!("DB Path: {}, Ext Path: {}", db_path, ext_path);
let vec_dim = env::var("VEC_DIM").unwrap_or_else(|_| "768".to_string()).parse::<usize>().unwrap_or(768);
let conn = db::init_db(&db_path, &ext_path, vec_dim).await.expect("Failed to init db");
let state = Arc::new(AppState {
db: conn,
llama: Arc::new(LlamaClient::new(
env::var("LLAMA_CPP_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()),
env::var("LLAMA_CPP_EMBEDDING_MODEL").unwrap_or_else(|_| "nomic-embed-text".to_string()),
env::var("LLAMA_CPP_MODEL").unwrap_or_else(|_| "mistral".to_string()),
)),
mcp_tx: tokio::sync::broadcast::channel(100).0,
connection_count: Arc::new(AtomicUsize::new(0)),
app_handle: app_handle.clone(),
});
app_handle.manage(state.clone());
let port = env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string()).parse::<u16>().unwrap_or(3000);
tokio::spawn(async move { mcp::start_mcp_server(state, port).await; });
});
// 5. Tray setup
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let show_i = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"quit" => app.exit(0),
"show" => if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); }
_ => {}
})
.on_tray_icon_event(|tray, event| if let TrayIconEvent::DoubleClick { .. } = event {
let app = tray.app_handle();
if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); }
})
.build(app)?;
log::info!("Tauri setup completed");
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_mcp_info,
get_db_stats,
get_sidecar_status,
get_table_list,
get_table_schema,
get_table_data
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}