Files
xianren_studio/apps/desktop/src/commands.rs
T

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(())
}