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
+27
View File
@@ -0,0 +1,27 @@
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum EngineError {
#[error("engine already running")]
AlreadyRunning,
#[error("engine is not running")]
NotRunning,
#[error("engine binary not found: {0}")]
BinaryNotFound(PathBuf),
#[error("model file not found: {0}")]
ModelNotFound(PathBuf),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("engine did not become ready within timeout")]
StartTimeout,
#[error("engine http error {0}: {1}")]
EngineHttp(reqwest::StatusCode, String),
#[error("engine reported error: {0}")]
EngineMessage(String),
#[error("request error: {0}")]
Reqwest(#[from] reqwest::Error),
}
pub type Result<T> = std::result::Result<T, EngineError>;
+8
View File
@@ -0,0 +1,8 @@
pub mod error;
pub mod manager;
pub mod types;
pub use error::{EngineError, Result};
pub use manager::EngineManager;
pub use types::{ChatMessage, ChatRequest, EngineConfig, EngineStatus};
+266
View File
@@ -0,0 +1,266 @@
use crate::error::{EngineError, Result};
use crate::types::{ChatRequest, EngineConfig, EngineStatus};
use futures::Stream;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tracing::{info, warn};
struct EngineHandle {
child: Child,
base_url: String,
port: u16,
model_name: String,
started_at: Instant,
}
#[derive(Clone, Default)]
pub struct EngineManager {
inner: Arc<Mutex<Option<EngineHandle>>>,
}
impl EngineManager {
pub fn new() -> Self {
Self::default()
}
pub async fn start(&self, cfg: EngineConfig) -> Result<()> {
let mut guard = self.inner.lock().await;
if guard.is_some() {
return Err(EngineError::AlreadyRunning);
}
if !cfg.binary_path.exists() {
return Err(EngineError::BinaryNotFound(cfg.binary_path.clone()));
}
if !cfg.model_path.exists() {
return Err(EngineError::ModelNotFound(cfg.model_path.clone()));
}
let port = free_port()?;
let base_url = format!("http://{}:{}", cfg.host, port);
let model_name = cfg
.model_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let mut cmd = Command::new(&cfg.binary_path);
cmd.arg("--model")
.arg(&cfg.model_path)
.arg("--host")
.arg(&cfg.host)
.arg("--port")
.arg(port.to_string())
.arg("--ctx-size")
.arg(cfg.ctx_size.to_string())
.arg("--parallel")
.arg("1")
.arg("--no-webui");
if cfg.ngl >= 0 {
cmd.arg("-ngl").arg(cfg.ngl.to_string());
}
if let Some(threads) = cfg.threads {
cmd.arg("--threads").arg(threads.to_string());
}
if let Some(parent) = cfg.log_file.parent() {
std::fs::create_dir_all(parent)?;
}
let log = std::fs::File::create(&cfg.log_file)?;
let log_clone = log.try_clone()?;
cmd.stdout(Stdio::from(log));
cmd.stderr(Stdio::from(log_clone));
info!(binary = %cfg.binary_path.display(), port, "starting llama-server");
let mut child = cmd.spawn()?;
if !wait_ready(&base_url, Duration::from_secs(180)).await {
let _ = child.kill().await;
let _ = child.wait().await;
return Err(EngineError::StartTimeout);
}
*guard = Some(EngineHandle {
child,
base_url,
port,
model_name: model_name.clone(),
started_at: Instant::now(),
});
info!(port, model = %model_name, "engine ready");
Ok(())
}
pub async fn stop(&self) -> Result<()> {
let mut guard = self.inner.lock().await;
if let Some(mut handle) = guard.take() {
let url = format!("{}/shutdown", handle.base_url);
let _ = reqwest::Client::new().post(&url).send().await;
for _ in 0..30 {
if handle.child.try_wait()?.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let _ = handle.child.kill().await;
let _ = handle.child.wait().await;
}
Ok(())
}
pub async fn status(&self) -> EngineStatus {
let guard = self.inner.lock().await;
match guard.as_ref() {
Some(h) => EngineStatus {
running: true,
port: Some(h.port),
pid: h.child.id().map(|p| p as u32),
model: Some(h.model_name.clone()),
backend: None,
ctx_size: None,
ngl: None,
uptime_secs: Some(h.started_at.elapsed().as_secs()),
},
None => EngineStatus::default(),
}
}
pub async fn base_url(&self) -> Option<String> {
let guard = self.inner.lock().await;
guard.as_ref().map(|h| h.base_url.clone())
}
pub fn is_running(&self) -> bool {
let guard = self.inner.blocking_lock();
guard.is_some()
}
/// 流式聊天:返回增量文本流(每个元素是一段 token 文本)。
pub async fn stream_chat(
&self,
req: ChatRequest,
) -> Result<futures::stream::BoxStream<'static, Result<String>>> {
let guard = self.inner.lock().await;
let handle = guard.as_ref().ok_or(EngineError::NotRunning)?;
let url = format!("{}/v1/chat/completions", handle.base_url);
let client = reqwest::Client::new();
let response = client.post(&url).json(&req).send().await?;
let status = response.status();
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(EngineError::EngineHttp(status, body));
}
let stream = response.bytes_stream();
let stream = sse_text_stream(stream);
Ok(stream)
}
/// 非流式聊天:完整返回一次。
pub async fn chat(&self, req: ChatRequest) -> Result<String> {
let guard = self.inner.lock().await;
let handle = guard.as_ref().ok_or(EngineError::NotRunning)?;
let url = format!("{}/v1/chat/completions", handle.base_url);
let client = reqwest::Client::new();
let response = client.post(&url).json(&req).send().await?;
let status = response.status();
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(EngineError::EngineHttp(status, body));
}
let value: serde_json::Value = response.json().await?;
let text = value
.pointer("/choices/0/message/content")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
Ok(text)
}
}
async fn wait_ready(base_url: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
let client = reqwest::Client::new();
loop {
match client.get(format!("{base_url}/health")).send().await {
Ok(resp) if resp.status().is_success() => return true,
_ => {
if Instant::now() > deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
}
}
}
fn free_port() -> Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
}
/// 把 reqwest 的字节流解析为 SSE 行级文本流(增量 token)。
fn sse_text_stream(
bytes: impl Stream<Item = std::result::Result<bytes::Bytes, reqwest::Error>>
+ Unpin
+ Send
+ 'static,
) -> futures::stream::BoxStream<'static, Result<String>> {
use futures::StreamExt;
Box::pin(async_stream::stream! {
let mut buf: Vec<u8> = Vec::new();
let mut stream = bytes;
let mut closed = false;
while !closed {
let chunk = match stream.next().await {
Some(Ok(c)) => c,
Some(Err(e)) => {
yield Err(EngineError::Reqwest(e));
break;
}
None => break,
};
buf.extend_from_slice(&chunk);
loop {
let newline = buf.iter().position(|&b| b == b'\n');
let Some(pos) = newline else { break };
let line: Vec<u8> = buf.drain(..=pos).collect();
let line = String::from_utf8_lossy(&line);
let line = line.trim();
if !line.starts_with("data:") {
continue;
}
let data = line["data:".len()..].trim();
if data == "[DONE]" {
closed = true;
break;
}
match serde_json::from_str::<serde_json::Value>(data) {
Ok(value) => {
if let Some(err) = value.get("error") {
yield Err(EngineError::EngineMessage(err.to_string()));
closed = true;
break;
}
let content = value
.pointer("/choices/0/delta/content")
.and_then(|v| v.as_str())
.or_else(|| value.get("content").and_then(|v| v.as_str()));
if let Some(text) = content {
yield Ok(text.to_string());
}
}
Err(e) => {
warn!(error = %e, line, "failed to parse SSE data");
}
}
}
}
})
}
+60
View File
@@ -0,0 +1,60 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
pub stream: bool,
}
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub binary_path: PathBuf,
pub model_path: PathBuf,
pub host: String,
pub ctx_size: usize,
/// 卸载到 GPU 的层数,-1 表示全部(llama.cpp 推荐 99 表示全部)
pub ngl: i32,
pub threads: Option<usize>,
pub log_file: PathBuf,
}
impl EngineConfig {
pub fn new(binary_path: PathBuf, model_path: PathBuf) -> Self {
Self {
binary_path,
model_path,
host: "127.0.0.1".to_string(),
ctx_size: 4096,
ngl: 99,
threads: None,
log_file: PathBuf::from("engine.log"),
}
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct EngineStatus {
pub running: bool,
pub port: Option<u16>,
pub pid: Option<u32>,
pub model: Option<String>,
pub backend: Option<String>,
pub ctx_size: Option<usize>,
pub ngl: Option<i32>,
pub uptime_secs: Option<u64>,
}