1442 lines
47 KiB
Rust
1442 lines
47 KiB
Rust
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<String>,
|
|
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<String>,
|
|
}
|
|
|
|
#[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<i64>,
|
|
pub tokens_out: Option<i64>,
|
|
pub tokens_estimated: bool,
|
|
pub elapsed_ms: u64,
|
|
pub first_token_ms: Option<u64>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub repo_id: Option<String>,
|
|
pub source: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Clone)]
|
|
pub struct DownloadProgressEvent {
|
|
pub id: String,
|
|
pub url: String,
|
|
pub file_name: String,
|
|
pub downloaded: u64,
|
|
pub total: Option<u64>,
|
|
pub percent: Option<f64>,
|
|
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<Vec<ModelInfo>, 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<ModelInfo, String> {
|
|
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<HashMap<String, String>, 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<Vec<xianren_core::Conversation>, 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<String>,
|
|
) -> Result<xianren_core::Conversation, String> {
|
|
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<Vec<Message>, 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<xianren_engine::EngineStatus, 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())?;
|
|
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<xianren_engine::EngineStatus, String> {
|
|
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<String>| {
|
|
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<serde_json::Value, String> {
|
|
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::<Vec<_>>();
|
|
|
|
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<CoreApp>,
|
|
engine: EngineManager,
|
|
base: Arc<RwLock<Option<String>>>,
|
|
conversation_id: String,
|
|
history: Vec<ChatMessage>,
|
|
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<u64> = None;
|
|
let mut prompt_tokens: Option<i64> = None;
|
|
let mut completion_tokens: Option<i64> = 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<ChatMessage>) -> Vec<ChatMessage> {
|
|
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<String, String> {
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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<Vec<xianren_core::sessions::MessageVersion>, 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<String, String> {
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let core = state.core.clone();
|
|
// 模型按 <models_dir>/<owner>/<repo>/<file> 组织,不直接放模型目录根
|
|
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<Vec<ModelInfo>, 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<ModelInfo, String> {
|
|
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<Vec<serde_json::Value>, 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::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default(),
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(results)
|
|
}
|
|
|
|
/// 列出模型仓库中的 GGUF 文件(用于模型广场选择量化版本)。
|
|
#[tauri::command]
|
|
pub async fn list_model_files(
|
|
repo_id: String,
|
|
source: String,
|
|
) -> Result<Vec<serde_json::Value>, 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<serde_json::Value, String> {
|
|
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<serde_json::Value, String> {
|
|
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(())
|
|
}
|