chore: scaffold Phase 0 - Tauri2 + Rust + React + llama.cpp integration

This commit is contained in:
Xianren Studio
2026-08-13 12:48:50 +08:00
commit db03f62dbf
58 changed files with 13827 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "xianren-desktop"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[lib]
name = "xianren_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tracing-subscriber.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
futures.workspace = true
uuid.workspace = true
xianren-core = { path = "../../crates/core" }
xianren-engine = { path = "../../crates/engine" }
xianren-download = { path = "../../crates/download" }
xianren-api = { path = "../../crates/api" }
+4
View File
@@ -0,0 +1,4 @@
fn main() {
tauri_build::build()
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "主窗口默认能力",
"windows": ["main"],
"permissions": ["core:default", "opener:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"name": "xianren-desktop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0"
}
}
+583
View File
@@ -0,0 +1,583 @@
use crate::App;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter, State};
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::{ModelInfo, Message};
use xianren_engine::{ChatMessage, ChatRequest, EngineConfig};
#[derive(Serialize)]
pub struct AppInfo {
pub version: String,
pub platform: String,
pub models_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,
}
#[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 elapsed_ms: u128,
}
#[derive(Serialize, Clone)]
pub struct ChatErrorEvent {
pub conversation_id: String,
pub message: String,
}
#[derive(Deserialize, Clone)]
pub struct DownloadPayload {
pub url: String,
pub file_name: String,
pub sha256: 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(),
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",
&name,
&path,
size,
guess_quant(&name).as_deref(),
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,
) -> 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, None, 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(
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;
Ok(engine.status().await)
}
#[tauri::command]
pub async fn engine_stop(state: State<'_, App>) -> Result<(), String> {
state.engine.stop().await.map_err(|e| e.to_string())?;
*state.engine_base.write().await = None;
Ok(())
}
#[tauri::command]
pub async fn engine_status(
state: State<'_, App>,
) -> Result<xianren_engine::EngineStatus, String> {
Ok(state.engine.status().await)
}
#[tauri::command]
pub async fn chat_send(
app: AppHandle,
state: State<'_, App>,
payload: ChatSendPayload,
) -> Result<(), String> {
let core = state.core.clone();
let engine = state.engine.clone();
let base = state.engine_base.clone();
let (history, model, engine_bin) = {
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::add_message(
&db,
&payload.conversation_id,
"user",
&payload.content,
None,
None,
)
.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,
})
.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)
};
tokio::spawn(async move {
if !engine.status().await.running {
let bin = PathBuf::from(&engine_bin);
if !bin.exists() {
let _ = app.emit(
"chat://error",
ChatErrorEvent {
conversation_id: payload.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: payload.params.ctx_size,
ngl: payload.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: payload.conversation_id.clone(),
message: e.to_string(),
},
);
return;
}
*base.write().await = engine.base_url().await;
}
let started = std::time::Instant::now();
let req = ChatRequest {
model: model.repo_id,
messages: history,
temperature: Some(payload.params.temperature),
top_p: Some(payload.params.top_p),
max_tokens: Some(payload.params.max_tokens),
stream: true,
};
let mut full = String::new();
match engine.stream_chat(req).await {
Ok(mut stream) => {
use futures::StreamExt;
while let Some(item) = stream.next().await {
match item {
Ok(text) => {
full.push_str(&text);
let _ = app.emit(
"chat://token",
ChatTokenEvent {
conversation_id: payload.conversation_id.clone(),
text,
},
);
}
Err(e) => {
let _ = app.emit(
"chat://error",
ChatErrorEvent {
conversation_id: payload.conversation_id.clone(),
message: e.to_string(),
},
);
return;
}
}
}
let elapsed_ms = started.elapsed().as_millis();
let db = core.db.lock().unwrap();
let message_id = sessions_db::add_message(
&db,
&payload.conversation_id,
"assistant",
&full,
None,
None,
)
.unwrap_or_default();
let _ = sessions_db::touch_conversation(&db, &payload.conversation_id);
let _ = app.emit(
"chat://done",
ChatDoneEvent {
conversation_id: payload.conversation_id.clone(),
message_id,
content: full,
tokens_in: None,
tokens_out: None,
elapsed_ms,
},
);
}
Err(e) => {
let _ = app.emit(
"chat://error",
ChatErrorEvent {
conversation_id: payload.conversation_id.clone(),
message: e.to_string(),
},
);
}
}
});
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();
let dest = core.models_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 db = core.db.lock().unwrap();
let _ = models_db::insert(
&db,
&pid.url,
"download",
&pid.file_name,
&path.to_string_lossy(),
size,
guess_quant(&pid.file_name).as_deref(),
None,
pid.sha256.as_deref(),
serde_json::json!({ "url": pid.url }),
);
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 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())
}
fn guess_quant(file_name: &str) -> Option<String> {
const QUANTS: &[&str] = &[
"q4_k_m", "q5_k_m", "q6_k", "q8_0", "q4_0", "q4_1", "q5_0", "q5_1", "q2_k", "q3_k_m",
"f16", "f32",
];
let lower = file_name.to_lowercase();
QUANTS
.iter()
.find(|q| lower.contains(**q))
.map(|s| s.to_string())
}
+63
View File
@@ -0,0 +1,63 @@
mod commands;
use std::sync::Arc;
use tokio::sync::RwLock;
use xianren_core::CoreApp;
use xianren_engine::EngineManager;
pub struct App {
pub core: Arc<CoreApp>,
pub engine: EngineManager,
pub engine_base: Arc<RwLock<Option<String>>>,
pub server: tokio::sync::Mutex<Option<xianren_api::ApiServer>>,
}
pub fn run() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let core = CoreApp::init(None)
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
tauri::Manager::manage(
app,
App {
core: Arc::new(core),
engine: EngineManager::new(),
engine_base: Arc::new(RwLock::new(None)),
server: tokio::sync::Mutex::new(None),
},
);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::app_info,
commands::list_models,
commands::import_model,
commands::remove_model,
commands::settings_get,
commands::settings_set,
commands::list_conversations,
commands::create_conversation,
commands::delete_conversation,
commands::get_messages,
commands::engine_start,
commands::engine_stop,
commands::engine_status,
commands::chat_send,
commands::download_enqueue,
commands::server_start,
commands::server_stop,
commands::server_status,
commands::open_path,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
xianren_desktop_lib::run()
}
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Xianren Studio",
"version": "0.1.0",
"identifier": "com.xianren.studio",
"build": {
"beforeDevCommand": "npm run dev --prefix ../../ui",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "npm run build --prefix ../../ui",
"frontendDist": "../../ui/dist"
},
"app": {
"windows": [
{
"title": "仙人工作室",
"width": 1280,
"height": 820,
"minWidth": 960,
"minHeight": 640,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
]
}
}