use crate::App; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tauri::{AppHandle, Emitter, State}; use tokio::sync::RwLock; use xianren_api::ApiState; use xianren_core::models as models_db; use xianren_core::sessions as sessions_db; use xianren_core::settings as settings_db; use xianren_core::{CoreApp, ModelInfo, Message}; use xianren_engine::{ChatMessage, ChatRequest, EngineConfig, EngineManager}; use xianren_engine::remote::RemoteConfig; #[derive(Serialize)] pub struct AppInfo { pub version: String, pub platform: String, pub models_dir: String, pub logs_dir: String, pub engine_bin: Option, pub engine_exists: bool, pub db_path: String, } #[derive(Deserialize, Clone)] pub struct ChatParams { pub temperature: f32, pub top_p: f32, pub max_tokens: u32, pub ctx_size: usize, pub ngl: i32, } #[derive(Deserialize)] pub struct ChatSendPayload { pub conversation_id: String, pub model_id: String, pub content: String, pub params: ChatParams, #[serde(default)] pub images: Vec, } #[derive(Serialize, Clone)] pub struct ChatTokenEvent { pub conversation_id: String, pub text: String, } #[derive(Serialize, Clone)] pub struct ChatDoneEvent { pub conversation_id: String, pub message_id: String, pub content: String, pub tokens_in: Option, pub tokens_out: Option, pub tokens_estimated: bool, pub elapsed_ms: u64, pub first_token_ms: Option, } #[derive(Deserialize)] pub struct RegeneratePayload { pub conversation_id: String, pub message_id: String, pub params: ChatParams, } #[derive(Deserialize)] pub struct EditMessagePayload { pub conversation_id: String, pub message_id: String, pub content: String, pub params: ChatParams, } #[derive(Serialize, Clone)] pub struct ChatMessageUpdatedEvent { pub conversation_id: String, pub message: Message, } #[derive(Serialize, Clone)] pub struct ChatErrorEvent { pub conversation_id: String, pub message: String, } #[derive(Serialize, Clone)] pub struct EngineDeployEvent { pub model_id: String, pub file_name: String, pub state: String, pub message: Option, } #[derive(Serialize, Clone)] pub struct EngineDeployProgressEvent { pub model_id: String, pub file_name: String, pub percent: u32, pub stage: String, } #[derive(Deserialize, Clone)] pub struct DownloadPayload { pub url: String, pub file_name: String, pub sha256: Option, pub repo_id: Option, pub source: Option, } #[derive(Serialize, Clone)] pub struct DownloadProgressEvent { pub id: String, pub url: String, pub file_name: String, pub downloaded: u64, pub total: Option, pub percent: Option, pub speed_bps: u64, pub status: String, } #[tauri::command] pub fn app_info(state: State<'_, App>) -> AppInfo { let db = state.core.db.lock().unwrap(); let engine_bin = settings_db::get(&db, "engine_bin").ok().flatten(); AppInfo { version: env!("CARGO_PKG_VERSION").to_string(), platform: std::env::consts::OS.to_string(), models_dir: state.core.models_dir.to_string_lossy().to_string(), logs_dir: state.core.logs_dir.to_string_lossy().to_string(), engine_exists: engine_bin .as_ref() .is_some_and(|p| PathBuf::from(p).exists()), engine_bin, db_path: state .core .data_dir .join("xianren.db") .to_string_lossy() .to_string(), } } #[tauri::command] pub fn list_models(state: State<'_, App>) -> Result, String> { let db = state.core.db.lock().unwrap(); models_db::list(&db).map_err(|e| e.to_string()) } #[tauri::command] pub fn import_model(state: State<'_, App>, path: String) -> Result { let p = PathBuf::from(&path); if !p.exists() { return Err(format!("file not found: {path}")); } let name = p .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); let size = std::fs::metadata(&p).map(|m| m.len() as i64).unwrap_or(0); let db = state.core.db.lock().unwrap(); let id = models_db::insert( &db, &name, "local", "local", &name, &path, size, models_db::guess_quant(&name).as_deref(), None, None, None, None, None, serde_json::json!({}), ) .map_err(|e| e.to_string())?; models_db::get(&db, &id) .map_err(|e| e.to_string())? .ok_or_else(|| "model not found after insert".to_string()) } #[tauri::command] pub fn remove_model(state: State<'_, App>, id: String) -> Result<(), String> { let db = state.core.db.lock().unwrap(); models_db::remove(&db, &id).map_err(|e| e.to_string()) } #[tauri::command] pub fn settings_get(state: State<'_, App>) -> Result, String> { let db = state.core.db.lock().unwrap(); let pairs = settings_db::all(&db).map_err(|e| e.to_string())?; Ok(pairs.into_iter().collect()) } #[tauri::command] pub fn settings_set(state: State<'_, App>, key: String, value: String) -> Result<(), String> { let db = state.core.db.lock().unwrap(); settings_db::set(&db, &key, &value).map_err(|e| e.to_string()) } #[tauri::command] pub fn list_conversations(state: State<'_, App>) -> Result, String> { let db = state.core.db.lock().unwrap(); sessions_db::list_conversations(&db).map_err(|e| e.to_string()) } #[tauri::command] pub fn create_conversation( state: State<'_, App>, title: String, model_id: Option, ) -> Result { let id = uuid::Uuid::new_v4().to_string(); let db = state.core.db.lock().unwrap(); sessions_db::create_conversation(&db, &id, &title, model_id.as_deref(), None) .map_err(|e| e.to_string())?; sessions_db::get_conversation(&db, &id) .map_err(|e| e.to_string())? .ok_or_else(|| "conversation not found".to_string()) } #[tauri::command] pub fn delete_conversation(state: State<'_, App>, id: String) -> Result<(), String> { let db = state.core.db.lock().unwrap(); sessions_db::delete_conversation(&db, &id).map_err(|e| e.to_string()) } #[tauri::command] pub fn get_messages(state: State<'_, App>, conversation_id: String) -> Result, String> { let db = state.core.db.lock().unwrap(); sessions_db::list_messages(&db, &conversation_id).map_err(|e| e.to_string()) } #[tauri::command] pub async fn engine_start( app: AppHandle, state: State<'_, App>, model_id: String, params: ChatParams, ) -> Result { let core = state.core.clone(); let engine = state.engine.clone(); let base = state.engine_base.clone(); let (model, engine_bin) = { let db = core.db.lock().unwrap(); let model = models_db::get(&db, &model_id) .map_err(|e| e.to_string())? .ok_or_else(|| "model not found".to_string())?; let bin = settings_db::get(&db, "engine_bin") .map_err(|e| e.to_string())? .unwrap_or_default(); (model, bin) }; let bin = PathBuf::from(engine_bin); if !bin.exists() { return Err(format!( "engine binary not found: {},请先在设置页配置 llama-server 路径", bin.display() )); } if engine.status().await.running { return Err("engine already running".into()); } let cfg = EngineConfig { binary_path: bin, model_path: PathBuf::from(model.file_path), host: "127.0.0.1".into(), ctx_size: params.ctx_size, ngl: params.ngl, threads: None, log_file: core.logs_dir.join(format!("engine-{}.log", model.file_name)), }; engine.start(cfg).await.map_err(|e| e.to_string())?; *base.write().await = engine.base_url().await; let status = engine.status().await; let _ = app.emit("engine://status", status.clone()); Ok(status) } #[tauri::command] pub async fn engine_stop(app: AppHandle, state: State<'_, App>) -> Result<(), String> { state.engine.stop().await.map_err(|e| e.to_string())?; *state.engine_base.write().await = None; let _ = app.emit("engine://status", state.engine.status().await); Ok(()) } #[tauri::command] pub async fn engine_status( state: State<'_, App>, ) -> Result { Ok(state.engine.status().await) } /// 后台部署(加载)本地模型:启动 llama-server 并等待就绪,通过事件广播进度。 #[tauri::command] pub async fn deploy_model( app: AppHandle, state: State<'_, App>, model_id: String, params: ChatParams, ) -> Result<(), String> { let core = state.core.clone(); let engine = state.engine.clone(); let base = state.engine_base.clone(); let (model, engine_bin) = { let db = core.db.lock().unwrap(); let model = models_db::get(&db, &model_id) .map_err(|e| e.to_string())? .ok_or_else(|| "model not found".to_string())?; if model.kind != "local" { return Err("在线 API 模型无需部署".into()); } let bin = settings_db::get(&db, "engine_bin") .map_err(|e| e.to_string())? .unwrap_or_default(); (model, bin) }; let file_name = model.file_name.clone(); let emit_deploy = |state_name: &str, message: Option| { let _ = app.emit( "engine://deploy", EngineDeployEvent { model_id: model_id.clone(), file_name: file_name.clone(), state: state_name.into(), message, }, ); }; let current = engine.status().await; if current.running { if current.model.as_deref() == Some(file_name.as_str()) { emit_deploy("ready", None); return Ok(()); } // 部署另一个模型前,先停掉当前引擎 let _ = engine.stop().await; *base.write().await = None; let _ = app.emit("engine://status", engine.status().await); } let bin = PathBuf::from(engine_bin); if !bin.exists() { emit_deploy( "error", Some(format!( "引擎文件不存在:{},请到设置页配置 llama-server 路径", bin.display() )), ); return Ok(()); } emit_deploy("loading", None); let app2 = app.clone(); let core2 = core.clone(); let engine2 = engine.clone(); let base2 = base.clone(); let model_id2 = model_id.clone(); let file_name2 = file_name.clone(); tokio::spawn(async move { let cfg = EngineConfig { binary_path: bin, model_path: PathBuf::from(model.file_path), host: "127.0.0.1".into(), ctx_size: params.ctx_size, ngl: params.ngl, threads: None, log_file: core2.logs_dir.join(format!("engine-{}.log", model.file_name)), }; // 后台轮询引擎日志,解析模型加载进度并广播 let log_path = cfg.log_file.clone(); let progress_app = app2.clone(); let progress_model = model_id2.clone(); let progress_name = file_name2.clone(); let progress_task = tokio::spawn(async move { let mut last_size = 0u64; loop { if let Ok(meta) = tokio::fs::metadata(&log_path).await { let size = meta.len(); if size != last_size { last_size = size; if let Ok(bytes) = tokio::fs::read(&log_path).await { let text = String::from_utf8_lossy(&bytes); let (percent, stage) = estimate_load_progress(&text); let _ = progress_app.emit( "engine://deploy-progress", EngineDeployProgressEvent { model_id: progress_model.clone(), file_name: progress_name.clone(), percent, stage: stage.to_string(), }, ); } } } tokio::time::sleep(std::time::Duration::from_millis(250)).await; } }); match engine2.start(cfg).await { Ok(()) => { progress_task.abort(); *base2.write().await = engine2.base_url().await; let _ = app2.emit( "engine://deploy", EngineDeployEvent { model_id: model_id2.clone(), file_name: file_name2.clone(), state: "ready".into(), message: None, }, ); let _ = app2.emit( "engine://deploy-progress", EngineDeployProgressEvent { model_id: model_id2.clone(), file_name: file_name2.clone(), percent: 100, stage: "服务已就绪".into(), }, ); } Err(e) => { progress_task.abort(); let _ = app2.emit( "engine://deploy", EngineDeployEvent { model_id: model_id2, file_name: file_name2, state: "error".into(), message: Some(e.to_string()), }, ); } } let _ = app2.emit("engine://status", engine2.status().await); }); Ok(()) } #[tauri::command] pub async fn chat_send( app: AppHandle, state: State<'_, App>, payload: ChatSendPayload, ) -> Result { let core = state.core.clone(); let engine = state.engine.clone(); let base = state.engine_base.clone(); let (history, model, engine_bin, user_message_id) = { let db = core.db.lock().unwrap(); if sessions_db::get_conversation(&db, &payload.conversation_id) .map_err(|e| e.to_string())? .is_none() { let title: String = payload.content.chars().take(30).collect(); sessions_db::create_conversation( &db, &payload.conversation_id, &title, Some(&payload.model_id), None, ) .map_err(|e| e.to_string())?; } // 始终把当前使用的模型写回会话,编辑/重新生成依赖它 sessions_db::update_conversation_model(&db, &payload.conversation_id, &payload.model_id) .map_err(|e| e.to_string())?; let user_message_id = sessions_db::add_message( &db, &payload.conversation_id, "user", &payload.content, None, None, None, None, &payload.images, ) .map_err(|e| e.to_string())?; let history = sessions_db::list_messages(&db, &payload.conversation_id) .map_err(|e| e.to_string())? .into_iter() .map(|m| ChatMessage { role: m.role, content: m.content, images: m.images, }) .collect::>(); let model = models_db::get(&db, &payload.model_id) .map_err(|e| e.to_string())? .ok_or_else(|| "model not found".to_string())?; let bin = settings_db::get(&db, "engine_bin") .map_err(|e| e.to_string())? .unwrap_or_default(); (history, model, bin, user_message_id) }; let conversation_id = payload.conversation_id.clone(); let history = if model.kind == "local" { sanitize_history_for_local(history) } else { history }; tokio::spawn(run_generation_and_stream( app, core, engine, base, conversation_id.clone(), history, model, engine_bin, payload.params, )); Ok(serde_json::json!({ "conversation_id": conversation_id, "message_id": user_message_id, })) } /// 统一的生成与流式转发:支持本地引擎 / 远程 API,统计首字延迟、token 数与耗时。 async fn run_generation_and_stream( app: AppHandle, core: Arc, engine: EngineManager, base: Arc>>, conversation_id: String, history: Vec, model: ModelInfo, engine_bin: String, params: ChatParams, ) { // 本地模型:确保引擎已运行 if model.kind == "local" && !engine.status().await.running { let bin = PathBuf::from(&engine_bin); if !bin.exists() { let _ = app.emit( "chat://error", ChatErrorEvent { conversation_id: conversation_id.clone(), message: format!("engine binary not found: {},请先在设置页配置", bin.display()), }, ); return; } let cfg = EngineConfig { binary_path: bin, model_path: PathBuf::from(model.file_path), host: "127.0.0.1".into(), ctx_size: params.ctx_size, ngl: params.ngl, threads: None, log_file: core.logs_dir.join(format!("engine-{}.log", model.file_name)), }; if let Err(e) = engine.start(cfg).await { let _ = app.emit( "chat://error", ChatErrorEvent { conversation_id: conversation_id.clone(), message: e.to_string(), }, ); return; } *base.write().await = engine.base_url().await; } let upstream_model = if model.kind == "remote" { model .api_model .clone() .unwrap_or_else(|| model.repo_id.clone()) } else { model.repo_id.clone() }; let req = ChatRequest { model: upstream_model.clone(), messages: history, temperature: Some(params.temperature), top_p: Some(params.top_p), max_tokens: Some(params.max_tokens), stream: true, }; let stream_result = if model.kind == "remote" { let cfg = RemoteConfig { base_url: model.base_url.clone().unwrap_or_default(), api_key: model.api_key.clone(), model: upstream_model, }; xianren_engine::stream_chat_remote(&cfg, req).await } else { engine.stream_chat(req).await }; let mut stream = match stream_result { Ok(s) => s, Err(e) => { let _ = app.emit( "chat://error", ChatErrorEvent { conversation_id: conversation_id.clone(), message: e.to_string(), }, ); return; } }; use futures::StreamExt; let started = Instant::now(); let mut first_token_ms: Option = None; let mut prompt_tokens: Option = None; let mut completion_tokens: Option = None; let mut full = String::new(); while let Some(item) = stream.next().await { match item { Ok(xianren_engine::ChatStreamEvent::Text(text)) => { if first_token_ms.is_none() { first_token_ms = Some(started.elapsed().as_millis() as u64); } full.push_str(&text); let _ = app.emit( "chat://token", ChatTokenEvent { conversation_id: conversation_id.clone(), text, }, ); } Ok(xianren_engine::ChatStreamEvent::Usage { prompt_tokens: p, completion_tokens: c, }) => { prompt_tokens = Some(p as i64); completion_tokens = Some(c as i64); } Err(e) => { let _ = app.emit( "chat://error", ChatErrorEvent { conversation_id: conversation_id.clone(), message: e.to_string(), }, ); return; } } } let elapsed_ms = started.elapsed().as_millis() as u64; let tokens_estimated = completion_tokens.is_none(); let tokens_out = completion_tokens.or_else(|| Some(estimate_tokens(&full) as i64)); let db = core.db.lock().unwrap(); let message_id = sessions_db::add_message( &db, &conversation_id, "assistant", &full, prompt_tokens, tokens_out, Some(elapsed_ms as i64), first_token_ms.map(|v| v as i64), &[], ) .unwrap_or_default(); let _ = sessions_db::touch_conversation(&db, &conversation_id); drop(db); let _ = app.emit( "chat://done", ChatDoneEvent { conversation_id, message_id, content: full, tokens_in: prompt_tokens, tokens_out, tokens_estimated, elapsed_ms, first_token_ms, }, ); } /// 本地文本模型不支持图片:把图片替换为占位说明。 fn sanitize_history_for_local(mut history: Vec) -> Vec { for message in &mut history { if !message.images.is_empty() { for _ in &message.images { message .content .push_str("\n[图片:本地文本模型暂不支持视觉输入]"); } message.images.clear(); } } history } fn estimate_tokens(text: &str) -> usize { let mut score = 0usize; for ch in text.chars() { score += if ch.is_ascii() { 1 } else { 2 }; } score / 4 + 1 } /// 解析会话使用的模型:优先会话记录;未记录时若本地只有一个模型则自动使用,否则提示。 fn resolve_conversation_model( db: &rusqlite::Connection, conversation: &xianren_core::Conversation, ) -> Result { if let Some(id) = &conversation.model_id { return Ok(id.clone()); } let all = models_db::list(db).map_err(|e| e.to_string())?; if all.len() == 1 { return Ok(all[0].id.clone()); } Err("会话未记录模型:请先在该会话发送一条消息,或重新选择模型后重试".into()) } /// 重新生成某条助手回复:旧内容存入版本历史,截断后重新生成。 #[tauri::command] pub async fn regenerate_message( app: AppHandle, state: State<'_, App>, payload: RegeneratePayload, ) -> Result<(), String> { let core = state.core.clone(); let engine = state.engine.clone(); let base = state.engine_base.clone(); let (history, model, engine_bin, conversation_id) = { let db = core.db.lock().unwrap(); let conversation = sessions_db::get_conversation(&db, &payload.conversation_id) .map_err(|e| e.to_string())? .ok_or_else(|| "conversation not found".to_string())?; let messages = sessions_db::list_messages(&db, &payload.conversation_id) .map_err(|e| e.to_string())?; let idx = messages .iter() .position(|m| m.id == payload.message_id && m.role == "assistant") .ok_or_else(|| "assistant message not found".to_string())?; let target = &messages[idx]; let _ = sessions_db::save_message_version( &db, &target.id, &target.content, target.tokens_out, target.elapsed_ms, target.first_token_ms, ); let (rowid, _) = sessions_db::get_message_with_rowid(&db, &target.id) .map_err(|e| e.to_string())? .ok_or_else(|| "message not found".to_string())?; sessions_db::delete_messages_after(&db, &payload.conversation_id, rowid) .map_err(|e| e.to_string())?; let history = messages[..idx] .iter() .map(|m| ChatMessage { role: m.role.clone(), content: m.content.clone(), images: m.images.clone(), }) .collect::>(); let model_id = resolve_conversation_model(&db, &conversation)?; let model = models_db::get(&db, &model_id) .map_err(|e| e.to_string())? .ok_or_else(|| "model not found".to_string())?; let bin = settings_db::get(&db, "engine_bin") .map_err(|e| e.to_string())? .unwrap_or_default(); (history, model, bin, payload.conversation_id.clone()) }; let history = if model.kind == "local" { sanitize_history_for_local(history) } else { history }; tokio::spawn(run_generation_and_stream( app, core, engine, base, conversation_id, history, model, engine_bin, payload.params, )); Ok(()) } /// 编辑用户提问并重新提交:清空该问题之后的所有生成内容。 #[tauri::command] pub async fn edit_message( app: AppHandle, state: State<'_, App>, payload: EditMessagePayload, ) -> Result<(), String> { let core = state.core.clone(); let engine = state.engine.clone(); let base = state.engine_base.clone(); let (history, model, engine_bin, conversation_id) = { let db = core.db.lock().unwrap(); let conversation = sessions_db::get_conversation(&db, &payload.conversation_id) .map_err(|e| e.to_string())? .ok_or_else(|| "conversation not found".to_string())?; let (rowid, msg) = sessions_db::get_message_with_rowid(&db, &payload.message_id) .map_err(|e| e.to_string())? .ok_or_else(|| "message not found".to_string())?; if msg.role != "user" { return Err("只能编辑用户消息".into()); } sessions_db::update_message_content(&db, &payload.message_id, &payload.content) .map_err(|e| e.to_string())?; sessions_db::delete_messages_after(&db, &payload.conversation_id, rowid) .map_err(|e| e.to_string())?; let history = sessions_db::list_messages(&db, &payload.conversation_id) .map_err(|e| e.to_string())? .into_iter() .map(|m| ChatMessage { role: m.role, content: m.content, images: m.images, }) .collect::>(); let model_id = resolve_conversation_model(&db, &conversation)?; let model = models_db::get(&db, &model_id) .map_err(|e| e.to_string())? .ok_or_else(|| "model not found".to_string())?; let bin = settings_db::get(&db, "engine_bin") .map_err(|e| e.to_string())? .unwrap_or_default(); (history, model, bin, payload.conversation_id.clone()) }; let history = if model.kind == "local" { sanitize_history_for_local(history) } else { history }; tokio::spawn(run_generation_and_stream( app, core, engine, base, conversation_id, history, model, engine_bin, payload.params, )); Ok(()) } #[tauri::command] pub fn list_message_versions( state: State<'_, App>, message_id: String, ) -> Result, String> { let db = state.core.db.lock().unwrap(); sessions_db::list_message_versions(&db, &message_id).map_err(|e| e.to_string()) } #[tauri::command] pub fn apply_message_version( app: AppHandle, state: State<'_, App>, conversation_id: String, message_id: String, version_id: String, ) -> Result<(), String> { let db = state.core.db.lock().unwrap(); let version = sessions_db::get_message_version(&db, &version_id) .map_err(|e| e.to_string())? .ok_or_else(|| "version not found".to_string())?; sessions_db::apply_message_version( &db, &message_id, &version.content, version.tokens_out, version.elapsed_ms, version.first_token_ms, ) .map_err(|e| e.to_string())?; let message = sessions_db::get_message(&db, &message_id) .map_err(|e| e.to_string())? .ok_or_else(|| "message not found".to_string())?; let _ = app.emit( "chat://message-updated", ChatMessageUpdatedEvent { conversation_id, message, }, ); Ok(()) } #[tauri::command] pub fn download_enqueue( app: AppHandle, state: State<'_, App>, payload: DownloadPayload, ) -> Result { let id = uuid::Uuid::new_v4().to_string(); let core = state.core.clone(); // 模型按 /// 组织,不直接放模型目录根 let subdir = derive_repo_subdir(&payload); let dest_dir = core.models_dir.join(&subdir); std::fs::create_dir_all(&dest_dir) .map_err(|e| format!("failed to create model directory {}: {e}", dest_dir.display()))?; let dest = dest_dir.join(&payload.file_name); let opts = xianren_download::DownloadOptions { url: payload.url.clone(), dest, expected_sha256: payload.sha256.clone(), chunk_size: 8 * 1024 * 1024, }; let pid = payload; let app2 = app.clone(); let task_id = id.clone(); let progress_url = pid.url.clone(); let progress_name = pid.file_name.clone(); tokio::spawn(async move { let tid = task_id.clone(); let progress = move |pr: xianren_download::DownloadProgress| { let _ = app2.emit( "download://progress", DownloadProgressEvent { id: tid.clone(), url: progress_url.clone(), file_name: progress_name.clone(), downloaded: pr.downloaded, total: pr.total, percent: pr.percent, speed_bps: pr.speed_bps, status: "downloading".into(), }, ); }; match xianren_download::download(opts, progress).await { Ok(path) => { let size = std::fs::metadata(&path) .map(|m| m.len() as i64) .unwrap_or(0); let repo_id = pid.repo_id.clone().unwrap_or_else(|| pid.url.clone()); let source = pid.source.clone().unwrap_or_else(|| "download".to_string()); let db = core.db.lock().unwrap(); let _ = models_db::insert( &db, &repo_id, &source, "local", &pid.file_name, &path.to_string_lossy(), size, models_db::guess_quant(&pid.file_name).as_deref(), None, pid.sha256.as_deref(), None, None, None, serde_json::json!({ "url": pid.url.clone() }), ); let _ = app.emit("models://updated", serde_json::json!({})); let _ = app.emit( "download://done", DownloadProgressEvent { id: task_id.clone(), url: pid.url, file_name: pid.file_name, downloaded: size as u64, total: Some(size as u64), percent: Some(100.0), speed_bps: 0, status: "done".into(), }, ); } Err(e) => { let _ = app.emit( "download://error", DownloadProgressEvent { id: task_id.clone(), url: pid.url, file_name: pid.file_name, downloaded: 0, total: None, percent: None, speed_bps: 0, status: format!("error: {e}"), }, ); } } }); Ok(id) } /// 扫描设置中的模型目录(自动 + 手动共用),并广播模型列表更新。 #[tauri::command] pub fn scan_models(app: AppHandle, state: State<'_, App>) -> Result, String> { let core = state.core.clone(); let db = core.db.lock().unwrap(); let dir = settings_db::get(&db, "model_dir") .ok() .flatten() .map(PathBuf::from) .unwrap_or_else(|| core.models_dir.clone()); let result = models_db::scan_directory(&db, &dir).map_err(|e| e.to_string())?; let list = models_db::list(&db).map_err(|e| e.to_string())?; let _ = app.emit( "models://updated", serde_json::json!({ "added": result.added, "updated": result.updated, "missing": result.missing, }), ); Ok(list) } /// 手动添加一个 OpenAI 兼容的在线 API 模型。 #[tauri::command] pub fn add_remote_model( state: State<'_, App>, name: String, base_url: String, api_key: String, api_model: String, ) -> Result { let name = name.trim(); let base_url = base_url.trim(); let api_model = api_model.trim(); if name.is_empty() || base_url.is_empty() || api_model.is_empty() { return Err("名称、Base URL、模型 ID 均不能为空".into()); } let db = state.core.db.lock().unwrap(); let id = models_db::insert( &db, name, "remote", "remote", name, base_url, 0, None, None, None, Some(base_url), if api_key.trim().is_empty() { None } else { Some(api_key.trim()) }, Some(api_model), serde_json::json!({}), ) .map_err(|e| e.to_string())?; models_db::get(&db, &id) .map_err(|e| e.to_string())? .ok_or_else(|| "model not found after insert".to_string()) } /// 在模型广场搜索模型仓库(HuggingFace / ModelScope)。 #[tauri::command] pub async fn search_models( query: String, source: String, ) -> Result, String> { let client = reqwest::Client::new(); let mut results = Vec::new(); match source.as_str() { "modelscope" => { let body = serde_json::json!({ "page_number": 1, "page_size": 30, "search": query.trim(), }); let resp = client .put("https://www.modelscope.cn/api/v1/models") .json(&body) .send() .await .map_err(|e| e.to_string())?; if !resp.status().is_success() { return Err(format!( "ModelScope API {}: {}", resp.status(), resp.text().await.unwrap_or_default() )); } let value: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; let arr = value .pointer("/Data/Model/Models") .or_else(|| value.pointer("/Data/Models")) .or_else(|| value.get("Models")) .and_then(|v| v.as_array()); if let Some(arr) = arr { for item in arr { let repo_id = item .get("Path") .or_else(|| item.get("Id")) .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); if repo_id.is_empty() { continue; } let author = repo_id.split('/').next().unwrap_or("").to_string(); let name = item .get("Name") .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| { repo_id.split('/').nth(1).unwrap_or(&repo_id).to_string() }); results.push(serde_json::json!({ "repo_id": repo_id, "author": author, "name": name, "downloads": item.get("Downloads").and_then(|v| v.as_i64()), "likes": item.get("Likes").and_then(|v| v.as_i64()), "tags": [], })); } } } _ => { let url = reqwest::Url::parse_with_params( "https://huggingface.co/api/models", &[ ("search", query.trim()), ("limit", "30"), ("library", "gguf"), ("sort", "downloads"), ("direction", "-1"), ], ) .map_err(|e| e.to_string())?; let resp = client .get(url) .header("User-Agent", "xianren-studio") .send() .await .map_err(|e| e.to_string())?; if !resp.status().is_success() { return Err(format!( "HuggingFace API {}: {}", resp.status(), resp.text().await.unwrap_or_default() )); } let value: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; if let Some(arr) = value.as_array() { for item in arr { let repo_id = item .get("id") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); if repo_id.is_empty() { continue; } let author = repo_id.split('/').next().unwrap_or("").to_string(); let name = repo_id .split('/') .nth(1) .unwrap_or(&repo_id) .to_string(); results.push(serde_json::json!({ "repo_id": repo_id, "author": author, "name": name, "downloads": item.get("downloads").and_then(|v| v.as_i64()), "likes": item.get("likes").and_then(|v| v.as_i64()), "tags": item .get("tags") .and_then(|v| v.as_array()) .map(|a| { a.iter() .filter_map(|t| t.as_str().map(String::from)) .collect::>() }) .unwrap_or_default(), })); } } } } Ok(results) } /// 列出模型仓库中的 GGUF 文件(用于模型广场选择量化版本)。 #[tauri::command] pub async fn list_model_files( repo_id: String, source: String, ) -> Result, String> { let client = reqwest::Client::new(); let mut files = Vec::new(); match source.as_str() { "modelscope" => { let url = format!( "https://modelscope.cn/api/v1/models/{repo_id}/repo/files?Revision=master&Recursive=false" ); let resp = client.get(&url).send().await.map_err(|e| e.to_string())?; if !resp.status().is_success() { return Err(format!( "ModelScope API {}: {}", resp.status(), resp.text().await.unwrap_or_default() )); } let value: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; let arr = value .get("Data") .and_then(|d| d.get("Files")) .and_then(|v| v.as_array()) .cloned() .or_else(|| value.as_array().cloned()) .unwrap_or_default(); for item in arr { let path = item .get("Path") .or_else(|| item.get("path")) .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); if !path.to_lowercase().ends_with(".gguf") { continue; } let size = item .get("Size") .or_else(|| item.get("size")) .and_then(|v| v.as_i64()) .unwrap_or(0); let sha256 = item.get("Sha256").and_then(|v| v.as_str()); files.push(serde_json::json!({ "path": path, "size": size, "sha256": sha256, })); } } _ => { let url = format!("https://huggingface.co/api/models/{repo_id}/tree/main?recursive=false"); let resp = client .get(&url) .header("User-Agent", "xianren-studio") .send() .await .map_err(|e| e.to_string())?; if !resp.status().is_success() { return Err(format!( "HuggingFace API {}: {}", resp.status(), resp.text().await.unwrap_or_default() )); } let value: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; if let Some(arr) = value.as_array() { for item in arr { let path = item .get("path") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); if !path.to_lowercase().ends_with(".gguf") { continue; } let size = item .get("size") .and_then(|v| v.as_i64()) .unwrap_or(0); files.push(serde_json::json!({ "path": path, "size": size })); } } } } files.sort_by(|a, b| { a["size"] .as_i64() .unwrap_or(0) .cmp(&b["size"].as_i64().unwrap_or(0)) }); Ok(files) } /// 根据 repo_id 或 URL 推断下载模型应存放的子目录(owner/repo)。 fn derive_repo_subdir(payload: &DownloadPayload) -> String { if let Some(repo_id) = &payload.repo_id { let parts: Vec<&str> = repo_id.split('/').collect(); if parts.len() >= 2 && !parts[0].trim().is_empty() && !parts[1].trim().is_empty() { return format!("{}/{}", parts[0].trim(), parts[1].trim()); } } if let Some(rest) = payload.url.split("://").nth(1) { let segments: Vec<&str> = rest.split('/').collect(); let start = if segments.get(1) == Some(&"models") { 2 } else { 1 }; if segments.len() >= start + 2 && !segments[start].trim().is_empty() && !segments[start + 1].trim().is_empty() { return format!("{}/{}", segments[start].trim(), segments[start + 1].trim()); } } "manual".to_string() } /// 根据引擎日志内容估算模型加载进度。 fn estimate_load_progress(log: &str) -> (u32, &'static str) { if log.contains("listening on http") || log.contains("server is listening") || log.contains("HTTP server listening") { (100, "服务已就绪") } else if log.contains("model loaded") || log.contains("llama_new_context_with_model") { (90, "推理上下文就绪") } else if log.contains("load_model: initializing") { (70, "初始化推理上下文") } else if log.contains("load_tensors") || log.contains("model size") { (55, "加载模型权重") } else if log.contains("load_model: loading model") || log.contains("llama_model_load") || log.contains("loading model") { (25, "读取模型文件") } else { (8, "启动引擎进程") } } #[tauri::command] pub async fn server_start( state: State<'_, App>, port: u16, api_key: String, ) -> Result { let mut guard = state.server.lock().await; if guard.is_some() { return Err("server already running".into()); } let api_state = ApiState::new( state.engine_base.clone(), { let core = state.core.clone(); move || { let db = core.db.lock().unwrap(); models_db::list(&db) .unwrap_or_default() .into_iter() .map(|m| { serde_json::json!({ "id": m.repo_id, "object": "model", "created": 0, "owned_by": m.source, }) }) .collect() } }, if api_key.is_empty() { None } else { Some(api_key) }, ); let server = xianren_api::start(port, api_state) .await .map_err(|e| e.to_string())?; let addr = server.addr; *guard = Some(server); Ok(serde_json::json!({ "running": true, "port": addr.port() })) } #[tauri::command] pub async fn server_stop(state: State<'_, App>) -> Result<(), String> { let mut guard = state.server.lock().await; if let Some(server) = guard.take() { server.shutdown(); } Ok(()) } #[tauri::command] pub async fn server_status(state: State<'_, App>) -> Result { let guard = state.server.lock().await; Ok(match guard.as_ref() { Some(s) => serde_json::json!({ "running": true, "port": s.addr.port() }), None => serde_json::json!({ "running": false, "port": null }), }) } #[tauri::command] pub fn open_path(path: String) -> Result<(), String> { tauri_plugin_opener::open_path(path, None::<&str>).map_err(|e| e.to_string()) } /// 前端上报运行时错误:写入本地日志便于排查。 #[tauri::command] pub fn report_error(source: String, message: String) -> Result<(), String> { tracing::error!(source = %source, error = %message, "frontend reported error"); Ok(()) }