chore: scaffold Phase 0 - Tauri2 + Rust + React + llama.cpp integration
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user