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
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "xianren-api"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
axum.workspace = true
tracing.workspace = true
thiserror.workspace = true
+4
View File
@@ -0,0 +1,4 @@
mod server;
pub use server::{start, ApiServer, ApiState};
+202
View File
@@ -0,0 +1,202 @@
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{header, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use std::net::SocketAddr;
use std::sync::Arc;
use thiserror::Error;
use tokio::net::TcpListener;
use tokio::sync::RwLock;
#[derive(Clone)]
pub struct ApiState {
pub engine_base: Arc<RwLock<Option<String>>>,
pub list_models: Arc<dyn Fn() -> Vec<Value> + Send + Sync>,
pub api_key: Option<String>,
}
impl ApiState {
pub fn new(
engine_base: Arc<RwLock<Option<String>>>,
list_models: impl Fn() -> Vec<Value> + Send + Sync + 'static,
api_key: Option<String>,
) -> Self {
Self {
engine_base,
list_models: Arc::new(list_models),
api_key,
}
}
}
pub struct ApiServer {
pub addr: SocketAddr,
task: tokio::task::JoinHandle<()>,
}
impl ApiServer {
pub fn shutdown(self) {
self.task.abort();
}
}
#[derive(Debug, Error)]
pub enum ApiError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, ApiError>;
/// 启动 OpenAI 兼容本地 API 服务。
pub async fn start(port: u16, state: ApiState) -> Result<ApiServer> {
let app = Router::new()
.route("/health", get(health))
.route("/v1/models", get(list_models))
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/embeddings", post(embeddings))
.layer(middleware::from_fn_with_state(state.clone(), auth))
.with_state(state);
let listener = TcpListener::bind(("127.0.0.1", port)).await?;
let addr = listener.local_addr()?;
tracing::info!(%addr, "local api server listening");
let task = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
tracing::error!(error = %e, "api server stopped");
}
});
Ok(ApiServer { addr, task })
}
async fn health() -> impl IntoResponse {
Json(json!({ "status": "ok" }))
}
async fn list_models(State(state): State<ApiState>) -> impl IntoResponse {
Json(json!({
"object": "list",
"data": (state.list_models)()
}))
}
async fn chat_completions(State(state): State<ApiState>, body: Json<Value>) -> Response {
let base = state.engine_base.read().await.clone();
let Some(base) = base else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": { "message": "no local engine is running" } })),
)
.into_response();
};
let wants_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let client = reqwest::Client::new();
match client
.post(format!("{base}/v1/chat/completions"))
.json(&body.0)
.send()
.await
{
Ok(resp) => {
if wants_stream {
let stream = resp.bytes_stream();
Response::builder()
.header(header::CONTENT_TYPE, "text/event-stream")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from_stream(stream))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
} else {
match resp.bytes().await {
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(value) => Json(value).into_response(),
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "message": format!("bad upstream response: {e}") } })),
)
.into_response(),
},
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "message": e.to_string() } })),
)
.into_response(),
}
}
}
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "message": e.to_string() } })),
)
.into_response(),
}
}
async fn embeddings(State(state): State<ApiState>, body: Json<Value>) -> Response {
let base = state.engine_base.read().await.clone();
let Some(base) = base else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": { "message": "no local engine is running" } })),
)
.into_response();
};
let client = reqwest::Client::new();
match client
.post(format!("{base}/v1/embeddings"))
.json(&body.0)
.send()
.await
{
Ok(resp) => match resp.bytes().await {
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(value) => Json(value).into_response(),
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "message": format!("bad upstream response: {e}") } })),
)
.into_response(),
},
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "message": e.to_string() } })),
)
.into_response(),
},
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "message": e.to_string() } })),
)
.into_response(),
}
}
async fn auth(State(state): State<ApiState>, req: Request, next: Next) -> Response {
if let Some(expected) = &state.api_key {
if expected.is_empty() {
return next.run(req).await;
}
let provided = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|v| v.to_string());
if provided.as_deref() != Some(expected.as_str()) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": { "message": "invalid api key" } })),
)
.into_response();
}
}
next.run(req).await
}