Newer
Older
TelosDB / src / backend / src / lib.rs
// エディションとモデル名(ビルド時に feature で切り替え)
#[cfg(feature = "community")]
const EDITION: &str = "community";
#[cfg(feature = "community")]
const MODEL_NAME: &str = "LSA (Local Semantic Analysis)";
#[cfg(feature = "pro")]
const EDITION: &str = "pro";
#[cfg(feature = "pro")]
const MODEL_NAME: &str = "sentence-bert-base-ja (Pro)";

#[tauri::command]
#[allow(dead_code)]
fn get_model_name() -> String {
    MODEL_NAME.to_string()
}
/// アプリデータディレクトリの settings.json を読み、再インストール後も設定を継承する
#[tauri::command]
fn get_app_settings(app: tauri::AppHandle) -> Result<serde_json::Value, String> {
    let path = app
        .path()
        .app_data_dir()
        .map_err(|e| e.to_string())?
        .join("settings.json");
    let default = serde_json::json!({
        "min_score": 0.3,
        "limit": 5,
        "run_on_login": false,
        "monitor_paths": [],
        "watch_extensions": ["txt", "md", "json", "html", "css", "js", "mjs", "ts", "rs"]
    });
    if !path.exists() {
        log::info!("get_app_settings: no file at {:?}, returning default", path);
        return Ok(default);
    }
    let s = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
    log::info!("get_app_settings: read from {:?}, content len={}", path, s.len());
    let loaded: serde_json::Value = serde_json::from_str(&s).map_err(|e| e.to_string())?;
    let def = default.as_object().unwrap();
    let empty: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
    let obj = loaded.as_object().unwrap_or(&empty);
    let run_on_login = obj
        .get("run_on_login")
        .or(def.get("run_on_login"))
        .and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|n| n != 0)))
        .unwrap_or(false);
    let monitor_paths = obj.get("monitor_paths")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_else(|| vec![]);
    let watch_extensions = obj.get("watch_extensions")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_else(|| def.get("watch_extensions").and_then(|v| v.as_array()).cloned().unwrap_or_else(|| vec![]));
    let merged = serde_json::json!({
        "min_score": obj.get("min_score").or(def.get("min_score")).unwrap_or(&serde_json::json!(0.3)),
        "limit": obj.get("limit").or(def.get("limit")).unwrap_or(&serde_json::json!(5)),
        "run_on_login": run_on_login,
        "monitor_paths": monitor_paths,
        "watch_extensions": watch_extensions
    });
    log::info!("get_app_settings: returning run_on_login={}", run_on_login);
    Ok(merged)
}

/// アプリデータディレクトリの settings.json に保存
#[tauri::command]
fn set_app_settings(app: tauri::AppHandle, settings: serde_json::Value) -> Result<(), String> {
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    let path = dir.join("settings.json");
    let to_write = if settings.get("min_score").is_some() || settings.get("limit").is_some() || settings.get("run_on_login").is_some() || settings.get("monitor_paths").is_some() || settings.get("watch_extensions").is_some() {
        settings.clone()
    } else if let Some(inner) = settings.get("settings").and_then(|v| v.as_object()) {
        serde_json::to_value(inner).unwrap_or(settings)
    } else {
        settings
    };
    let s = serde_json::to_string_pretty(&to_write).map_err(|e| e.to_string())?;
    std::fs::write(&path, &s).map_err(|e| e.to_string())?;
    log::info!("set_app_settings: wrote to {:?}, run_on_login={:?}", path, to_write.get("run_on_login"));
    Ok(())
}

// Read last N lines from the log file for UI consumption
#[tauri::command]
fn read_logs(limit: Option<usize>) -> Result<String, String> {
    let log_dir = if cfg!(debug_assertions) {
        std::env::current_dir()
            .map_err(|e| e.to_string())?
            .join("logs")
    } else {
        dirs::data_dir()
            .unwrap_or_else(|| std::env::current_dir().unwrap())
            .join("com.telosdb.app")
            .join("logs")
    };

    let log_file = log_dir.join("telos.log");
    if !log_file.exists() {
        return Ok(String::new());
    }

    let s = std::fs::read_to_string(&log_file).map_err(|e| e.to_string())?;
    let lines: Vec<&str> = s.lines().collect();
    let n = limit.unwrap_or(200);
    let start = if lines.len() > n { lines.len() - n } else { 0 };
    Ok(lines[start..].join("\n"))
}
pub mod db;
pub mod dev_static;
pub mod utils;
pub mod mcp;

use std::sync::atomic::{AtomicU64, Ordering};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::Manager;
use tauri::path::BaseDirectory;
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[allow(dead_code)]
struct AppState {
    db_pool: sqlx::SqlitePool,
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .plugin(tauri_plugin_autostart::init(
            tauri_plugin_autostart::MacosLauncher::LaunchAgent,
            None,
        ))
        .plugin({
            let log_dir = if cfg!(debug_assertions) {
                std::env::current_dir().unwrap().join("logs")
            } else {
                dirs::data_dir()
                    .unwrap_or_else(|| std::env::current_dir().unwrap())
                    .join("com.telosdb.app")
                    .join("logs")
            };
            std::fs::create_dir_all(&log_dir).ok();

            // Prune old telos logs, keep latest 7 files
            if let Ok(entries) = std::fs::read_dir(&log_dir) {
                let mut logs: Vec<_> = entries
                    .filter_map(|e| e.ok())
                    .filter_map(|de| {
                        let p = de.path();
                        if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
                            if name.starts_with("telos") && name.ends_with(".log") {
                                return Some(p);
                            }
                        }
                        None
                    })
                    .collect();

                logs.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
                while logs.len() > 7 {
                    let old = logs.remove(0);
                    let _ = std::fs::remove_file(old);
                }
            }

            tauri_plugin_log::Builder::default()
                .level(log::LevelFilter::Info)
                .filter(|metadata| {
                    if metadata.target().starts_with("reqwest")
                        || metadata.target().starts_with("hyper")
                        || metadata.target().starts_with("h2")
                        || metadata.target().starts_with("tracing")
                        || metadata.target().starts_with("html5ever")
                        || metadata.target().starts_with("selectors")
                    {
                        metadata.level() <= log::Level::Warn
                    } else {
                        true
                    }
                })
                .targets([
                    tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
                    tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
                        file_name: Some("telos.log".to_string()),
                    }),
                    tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
                ])
                .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
                .max_file_size(10 * 1024 * 1024)
                .build()
        })
            .invoke_handler(tauri::generate_handler![
            get_model_name,
            read_logs,
            get_app_settings,
            set_app_settings,
        ])
        .setup(move |app| {
                let boot_start = std::time::Instant::now();

                // Resolve paths
                let app_data_dir = app
                    .path()
                    .app_data_dir()
                    .expect("failed to get app data dir");
                let db_path = app_data_dir.join("telos.db");

                let resource_dir = app.path().resource_dir().unwrap_or_default();

                let exe_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|p| p.to_path_buf()));
                log::info!("[BOOT] Edition: {}", EDITION);
                log::info!("[BOOT] Phase 1: Paths resolved ({}ms)", boot_start.elapsed().as_millis());
                log::info!("  resource_dir: {:?}, exe_dir: {:?}", resource_dir, exe_dir);

                // vec0.dll: バンドル時は resources、開発時は exe 同階層。見つかったら app_data にコピーしてそこから読む
                let mut vec0_path: PathBuf = app
                    .path()
                    .resolve("vec0.dll", BaseDirectory::Resource)
                    .unwrap_or_else(|_| app.path().resource_dir().unwrap_or_default().join("vec0.dll"));

                if !vec0_path.exists() {
                    if let Some(ref exe_dir) = exe_dir {
                        for candidate in [
                            exe_dir.join("vec0.dll"),
                            exe_dir.join("resources").join("vec0.dll"),
                            resource_dir.join("vec0.dll"),
                            resource_dir.parent().unwrap_or(exe_dir).join("vec0.dll"),
                        ] {
                            if candidate.exists() {
                                vec0_path = candidate;
                                break;
                            }
                        }
                    }
                }

                // 見つかった dll を app_data_dir にコピーしてから使う(インストール先のパス差を吸収)
                let vec0_in_app_data = app_data_dir.join("vec0.dll");
                if vec0_path.exists() {
                    let _ = std::fs::create_dir_all(&app_data_dir);
                    if vec0_in_app_data != vec0_path {
                        if let Err(e) = std::fs::copy(&vec0_path, &vec0_in_app_data) {
                            log::warn!("Failed to copy vec0.dll to app data: {}", e);
                        } else {
                            log::info!("  Copied vec0.dll to app_data");
                        }
                    }
                    if vec0_in_app_data.exists() {
                        vec0_path = vec0_in_app_data;
                    }
                }
                log::info!("[BOOT] Phase 2: vec0.dll ready at {:?} ({}ms)", vec0_path, boot_start.elapsed().as_millis());

                // Tray Menu
                let quit_i = MenuItem::with_id(app, "quit", "終了", true, None::<&str>)?;
                let show_i = MenuItem::with_id(app, "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)
                    .show_menu_on_left_click(false)
                    .on_menu_event(move |app, event| {
                        match event.id.as_ref() {
                            "quit" => {
                                app.exit(0);
                            }
                            "show" => {
                                if let Some(window) = app.get_webview_window("main") {
                                    window.show().unwrap();
                                    window.set_focus().unwrap();
                                }
                            }
                            _ => {}
                        }
                    })
                    .on_tray_icon_event(|tray, event| {
                        if let TrayIconEvent::Click {
                            button: tauri::tray::MouseButton::Left,
                            ..
                        } = event
                        {
                            // トレイクリックが二重発火することがあるためデバウンス(一瞬開いてすぐ閉じるのを防ぐ)
                            const DEBOUNCE_MS: u64 = 400;
                            static LAST_TRAY_CLICK_MS: AtomicU64 = AtomicU64::new(0);
                            let now_ms = SystemTime::now()
                                .duration_since(UNIX_EPOCH)
                                .unwrap_or_default()
                                .as_millis() as u64;
                            let prev = LAST_TRAY_CLICK_MS.swap(now_ms, Ordering::Relaxed);
                            if prev != 0 && now_ms.saturating_sub(prev) < DEBOUNCE_MS {
                                return;
                            }

                            let app = tray.app_handle();
                            if let Some(window) = app.get_webview_window("main") {
                                if window.is_visible().unwrap_or(false) {
                                    window.hide().unwrap();
                                } else {
                                    window.show().unwrap();
                                    window.set_focus().unwrap();
                                }
                            }
                        }
                    })
                    .build(app)?;

                log::info!("[BOOT] Phase 3: Tray icon ready ({}ms)", boot_start.elapsed().as_millis());

                if !vec0_path.exists() {
                    let msg = format!(
                        "vec0.dll が見つかりません。\n試したパス:\n- {:?}\n- exe と同じフォルダ\n- exe の resources フォルダ\n\nTelosDB を再インストールしてください。",
                        vec0_path
                    );
                    log::error!("{}", msg);
                    return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg).into());
                }

                log::info!("[BOOT] Phase 4: Initializing database at {:?} ...", db_path);
                let pool = tauri::async_runtime::block_on(async {
                    #[cfg(feature = "community")]
                    let dimension = 50;
                    #[cfg(feature = "pro")]
                    let dimension = crate::utils::embedding_pro::ProEmbeddingModel::embed_dim();
                    match db::initialize_database(&db_path, &vec0_path, dimension).await {
                        Ok(pool) => {
                            log::info!("[BOOT] Phase 4: Database ready ({}ms)", boot_start.elapsed().as_millis());
                            pool
                        }
                        Err(e) => {
                            log::error!("Database initialization failed: {}", e);
                            panic!("DB Init Error: {}", e);
                        }
                    }
                });

                app.manage(AppState {
                    db_pool: pool.clone(),
                });

                if let Some(router) = dev_static::dev_static_router(exe_dir.as_deref()) {
                    let bind_addr = "127.0.0.1:8474";
                    if let Ok(listener) = tauri::async_runtime::block_on(tokio::net::TcpListener::bind(bind_addr)) {
                        log::info!("[BOOT] Dev static server listening on {}", listener.local_addr().unwrap());
                        tauri::async_runtime::spawn(async move {
                            let _ = axum::serve(listener, router).await;
                        });
                    } else {
                        log::warn!("[BOOT] Dev static server (8474) bind failed");
                    }
                } else {
                    log::info!("[BOOT] Dev static: frontend dir not resolved (exe_dir={:?}), skipping 8474", exe_dir);
                }
                log::info!("[BOOT] Phase 5: Starting MCP server on 127.0.0.1:3001 ...");
                #[cfg(feature = "pro")]
                let embedding_model_dir = {
                    let env_path = std::env::var_os("TELOS_EMBEDDING_MODEL_DIR").map(std::path::PathBuf::from);
                    // インストール後は Tauri の resolve でバンドル先を正しく解決する(resource_dir だけだと NSIS 配置先とずれる場合がある)
                    let resolved_resource = app
                        .path()
                        .resolve("embedding_model/model_quantized.onnx", BaseDirectory::Resource)
                        .ok()
                        .and_then(|p| p.parent().map(|p| p.to_path_buf()))
                        .filter(|p| p.join("model_quantized.onnx").exists());
                    let resource_path = resource_dir.join("embedding_model");
                    let dev_path = exe_dir.as_ref().and_then(|exe| {
                        exe.parent().and_then(|p| p.parent()).map(|root| root.join("embedding_model"))
                    });
                    let resource_path_log = resource_path.clone();
                    let dev_path_log = dev_path.clone();
                    let chosen = env_path
                        .filter(|p| p.join("model_quantized.onnx").exists())
                        .or(resolved_resource)
                        .or_else(|| if resource_path.join("model_quantized.onnx").exists() { Some(resource_path) } else { None })
                        .or_else(|| dev_path.filter(|p| p.join("model_quantized.onnx").exists()));
                    if let Some(ref p) = chosen {
                        log::info!("[BOOT] Pro: embedding_model dir = {:?}", p);
                    } else {
                        log::warn!(
                            "[BOOT] Pro: embedding_model not found (model_quantized.onnx required). Tried: TELOS_EMBEDDING_MODEL_DIR, resolve(Resource), {:?}, {:?}. Set TELOS_EMBEDDING_MODEL_DIR or place embedding_model in exe parent.",
                            resource_path_log,
                            dev_path_log
                        );
                    }
                    chosen
                };
                tauri::async_runtime::spawn({
                    let pool = pool.clone();
                    #[cfg(feature = "pro")]
                    let embedding_dir = embedding_model_dir;
                    async move {
                        #[cfg(feature = "community")]
                        mcp::run_server(3001, app_data_dir.clone(), pool, MODEL_NAME.to_string(), EDITION.to_string()).await;
                        #[cfg(feature = "pro")]
                        mcp::run_server(3001, app_data_dir.clone(), pool, MODEL_NAME.to_string(), EDITION.to_string(), embedding_dir).await;
                    }
                });

                if std::env::var_os("TELOS_HEADLESS").map(|v| v == "1").unwrap_or(false) {
                    if let Some(w) = app.get_webview_window("main") {
                        let _ = w.hide();
                        log::info!("[BOOT] Headless mode: main window hidden.");
                    }
                }
                log::info!("[BOOT] Setup complete ({}ms). Window ready; MCP and LSA/HNSW continue in background.", boot_start.elapsed().as_millis());
                Ok(())
        })
        .on_window_event(|window, event| {
            if let tauri::WindowEvent::CloseRequested { api, .. } = event {
                // Prevent window from closing, just hide it
                api.prevent_close();
                window.hide().unwrap();
            }
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}