// 使用中モデル名をグローバルで保持
#[allow(dead_code)]
static MODEL_NAME: &str = "gemma-3-270m-it-Q4_K_M.gguf";
// モデル名を返すAPI
#[tauri::command]
#[allow(dead_code)]
fn get_model_name() -> String {
MODEL_NAME.to_string()
}
// 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;
mod mcp;
use std::sync::{Arc, Mutex};
use tauri::Manager;
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
#[allow(dead_code)]
struct AppState {
db_pool: sqlx::SqlitePool,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let llama_child: Arc<Mutex<Option<CommandChild>>> = Arc::new(Mutex::new(None));
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.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])
.setup({
let llama_child = llama_child.clone();
move |app| {
// 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");
// llama-serverの起動をTauriのsidecar APIで行う
let resource_dir = app.path().resource_dir().unwrap_or_default();
let bin_dir = resource_dir.join("bin");
let model_path = bin_dir.join("gemma-3-270m-it-Q4_K_M.gguf");
// vec0.dll はビルド時に実行ルート(resource_dir)にコピーされる
let mut vec0_path = resource_dir.join("vec0.dll");
// 開発時 (target/debug) かつ bin にある場合のフォールバック
if !vec0_path.exists() && bin_dir.join("vec0.dll").exists() {
vec0_path = bin_dir.join("vec0.dll");
}
// 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 llama_child_tray = llama_child.clone();
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" => {
if let Some(child) = llama_child_tray.lock().unwrap().take() {
let _ = child.kill();
}
app.exit(0);
}
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show().unwrap();
let _ = window.set_focus().unwrap();
}
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide().unwrap();
} else {
let _ = window.show().unwrap();
let _ = window.set_focus().unwrap();
}
}
}
})
.build(app)?;
log::info!("Initializing TelosDB at {:?}", db_path);
log::info!("Bin directory: {:?}", bin_dir);
log::info!("Model path (Gemma-3): {:?}", model_path);
log::info!("vec0.dll path: {:?}", vec0_path);
if !vec0_path.exists() {
log::error!(
"vec0.dll NOT FOUND at {:?}. Vector search and DB init will fail.",
vec0_path
);
}
// llama-server自動起動(Tauri sidecar API使用)
if model_path.exists() {
let (mut rx, child) = app
.shell()
.sidecar("llama-server")
.expect("failed to create sidecar")
.args([
"--model",
model_path.to_str().unwrap(),
"--port",
"8080",
"--embedding",
"--pooling",
"mean",
])
.spawn()
.expect("failed to spawn sidecar");
log::info!("llama-server sidecar started (Qwen3-4B)");
*llama_child.lock().unwrap() = Some(child);
std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() {
match event {
CommandEvent::Stdout(line) => {
log::info!("llama-server: {}", String::from_utf8_lossy(&line))
}
CommandEvent::Stderr(line) => {
let s = String::from_utf8_lossy(&line);
if !s.contains("post_embedding") { // 埋め込みリクエスト時のノイズを軽減
log::error!("llama-server: {}", s)
}
}
_ => {}
}
}
});
// llama-serverの起動待ちと次元の動的取得
let pool = tauri::async_runtime::block_on(async {
let client = reqwest::Client::new();
let mut dimension = 768; // デフォルト値
log::info!("Waiting for llama-server to be ready...");
for _ in 0..30 {
if let Ok(resp) = client.get("http://127.0.0.1:8080/health").send().await {
if resp.status().is_success() {
log::info!("llama-server is healthy.");
// ダミーの埋め込みリクエストで次元を取得
let payload = serde_json::json!({
"input": ["dim_check"],
"model": "default"
});
if let Ok(resp) = client.post("http://127.0.0.1:8080/v1/embeddings").json(&payload).send().await {
if let Ok(json) = resp.json::<serde_json::Value>().await {
if let Some(emb) = json["data"][0]["embedding"].as_array() {
dimension = emb.len();
log::info!("Detected embedding dimension: {}", dimension);
}
}
}
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
// DB初期化(一本化されたロジック)
match db::initialize_database(&db_path, &vec0_path, dimension).await {
Ok(pool) => {
log::info!("Database initialized (dim={}).", dimension);
// ベクトルの不整合をチェックして修復(ヒーリング)
let pool_clone = pool.clone();
let client_clone = client.clone();
tauri::async_runtime::spawn(async move {
let res = db::sync_vectors(&pool_clone, |content| {
let c = client_clone.clone();
async move {
let payload = serde_json::json!({
"input": [content],
"model": "default"
});
let resp = c.post("http://127.0.0.1:8080/v1/embeddings")
.json(&payload)
.send()
.await
.map_err(|e| e.to_string())?;
let json = resp.json::<serde_json::Value>().await.map_err(|e| e.to_string())?;
let emb = json["data"][0]["embedding"].as_array().ok_or("No embedding in response")?;
Ok(emb.iter().map(|v| v.as_f64().unwrap_or(0.0) as f32).collect())
}
}).await;
match res {
Ok(0) => log::info!("Vector synchronization complete: All items already have embeddings."),
Ok(count) => log::info!("Vector synchronization complete: {} missing embeddings healed.", count),
Err(e) => log::error!("Vector synchronization failed: {}", e),
}
});
pool
}
Err(e) => {
log::error!("Database initialization failed: {}", e);
panic!("DB Init Error: {}", e);
}
}
});
app.manage(AppState {
db_pool: pool.clone(),
});
// MCP Server (Poolを直接渡すように修正)
use tokio::sync::RwLock;
let llama_status = Arc::new(RwLock::new("unknown".to_string()));
tauri::async_runtime::spawn({
let llama_status = llama_status.clone();
let pool = pool.clone();
async move {
mcp::run_server(3001, pool, llama_status, MODEL_NAME.to_string()).await;
}
});
} else {
log::error!("{} not found at {:?}", MODEL_NAME, model_path);
}
Ok(())
}
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Prevent window from closing, just hide it
api.prevent_close();
let _ = window.hide().unwrap();
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}