From 451cc75cd90cbf29b5b1fbd2e7b1897dd91bb18e Mon Sep 17 00:00:00 2001 From: Xianren Studio Date: Thu, 13 Aug 2026 15:37:28 +0800 Subject: [PATCH] feat: model dir auto-scan, remote API models, model plaza with HF/ModelScope downloads --- Cargo.lock | 2 + README.md | 7 + apps/desktop/Cargo.toml | 2 + apps/desktop/src/commands.rs | 479 +++++++++++++++++++++++++++----- apps/desktop/src/lib.rs | 32 ++- crates/core/src/app.rs | 19 ++ crates/core/src/models.rs | 151 +++++++++- crates/core/src/schema.sql | 5 +- crates/engine/src/lib.rs | 3 +- crates/engine/src/manager.rs | 2 +- crates/engine/src/remote.rs | 44 +++ ui/src/App.tsx | 9 +- ui/src/api.ts | 37 ++- ui/src/pages/ChatPage.tsx | 50 ++-- ui/src/pages/DownloadsPage.tsx | 116 -------- ui/src/pages/ModelPlazaPage.tsx | 274 ++++++++++++++++++ ui/src/pages/ModelsPage.tsx | 139 ++++++++- 17 files changed, 1132 insertions(+), 239 deletions(-) create mode 100644 crates/engine/src/remote.rs delete mode 100644 ui/src/pages/DownloadsPage.tsx create mode 100644 ui/src/pages/ModelPlazaPage.tsx diff --git a/Cargo.lock b/Cargo.lock index 2ec1d6c..0f8a1b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5523,12 +5523,14 @@ name = "xianren-desktop" version = "0.1.0" dependencies = [ "futures", + "reqwest 0.12.28", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", "tokio", + "tracing", "tracing-subscriber", "uuid", "xianren-api", diff --git a/README.md b/README.md index 52b3590..2fe8a89 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ 一款对标 LM Studio 的 Windows 本地大模型桌面应用:浏览/下载/管理 GGUF 模型,本机 CPU/GPU 推理聊天,并提供 OpenAI 兼容的本地 API 服务。 +## 当前功能 + +- **模型库**:启动时自动扫描模型目录(可手动重新扫描),支持导入本地 GGUF、添加 OpenAI 兼容在线 API 模型(聊天时可直接选用) +- **模型广场**:搜索 Hugging Face / ModelScope 上的 GGUF 模型,按量化版本一键下载(ModelScope 文件自动附带 SHA256 校验),下载进度实时显示 +- **聊天**:流式输出、Markdown/代码高亮、采样参数调节、多会话管理;本地模型与在线 API 模型统一入口 +- **本地 API 服务**:OpenAI 兼容端点(/v1/models、/v1/chat/completions、/v1/embeddings),仅本机监听,可选 API Key + ## 技术栈 - 桌面壳:Tauri 2(Rust)+ WebView2 diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index 88208b1..a5f5862 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -20,9 +20,11 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tracing-subscriber.workspace = true +tracing.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true +reqwest.workspace = true futures.workspace = true uuid.workspace = true xianren-core = { path = "../../crates/core" } diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index b830f39..54ce048 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -2,13 +2,16 @@ 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 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_core::{CoreApp, ModelInfo, Message}; use xianren_engine::{ChatMessage, ChatRequest, EngineConfig}; +use xianren_engine::remote::RemoteConfig; #[derive(Serialize)] pub struct AppInfo { @@ -64,6 +67,8 @@ pub struct DownloadPayload { pub url: String, pub file_name: String, pub sha256: Option, + pub repo_id: Option, + pub source: Option, } #[derive(Serialize, Clone)] @@ -121,10 +126,14 @@ pub fn import_model(state: State<'_, App>, path: String) -> Result drain_stream_and_persist(app, core, conversation_id, stream).await, + Err(e) => { + let _ = app.emit( + "chat://error", + ChatErrorEvent { + conversation_id, + message: e.to_string(), + }, + ); + } + } + }); + return Ok(()); + } + + // 本地 GGUF 模型:确保引擎运行后走 llama-server tokio::spawn(async move { if !engine.status().await.running { let bin = PathBuf::from(&engine_bin); @@ -337,7 +384,6 @@ pub async fn chat_send( *base.write().await = engine.base_url().await; } - let started = std::time::Instant::now(); let req = ChatRequest { model: model.repo_id, messages: history, @@ -347,63 +393,13 @@ pub async fn chat_send( 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, - }, - ); - } + Ok(stream) => drain_stream_and_persist(app, core, conversation_id, stream).await, Err(e) => { let _ = app.emit( "chat://error", ChatErrorEvent { - conversation_id: payload.conversation_id.clone(), + conversation_id, message: e.to_string(), }, ); @@ -456,19 +452,26 @@ pub fn download_enqueue( 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, - &pid.url, - "download", + &repo_id, + &source, + "local", &pid.file_name, &path.to_string_lossy(), size, - guess_quant(&pid.file_name).as_deref(), + models_db::guess_quant(&pid.file_name).as_deref(), None, pid.sha256.as_deref(), - serde_json::json!({ "url": pid.url }), + None, + None, + None, + serde_json::json!({ "url": pid.url.clone() }), ); + let _ = app.emit("models://updated", serde_json::json!({})); let _ = app.emit( "download://done", DownloadProgressEvent { @@ -503,6 +506,352 @@ pub fn download_enqueue( Ok(id) } +/// 扫描设置中的模型目录(自动 + 手动共用),并广播模型列表更新。 +#[tauri::command] +pub fn scan_models(app: AppHandle, state: State<'_, App>) -> Result, 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 { + 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, 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::>() + }) + .unwrap_or_default(), + })); + } + } + } + } + Ok(results) +} + +/// 列出模型仓库中的 GGUF 文件(用于模型广场选择量化版本)。 +#[tauri::command] +pub async fn list_model_files( + repo_id: String, + source: String, +) -> Result, 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) +} + +/// 统一的流式事件处理:转发 token、错误,完成后持久化助手消息。 +async fn drain_stream_and_persist( + app: AppHandle, + core: Arc, + conversation_id: String, + mut stream: futures::stream::BoxStream<'static, xianren_engine::Result>, +) { + use futures::StreamExt; + + let started = Instant::now(); + let mut full = String::new(); + while let Some(item) = stream.next().await { + match item { + Ok(text) => { + full.push_str(&text); + let _ = app.emit( + "chat://token", + ChatTokenEvent { + conversation_id: conversation_id.clone(), + text, + }, + ); + } + 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(); + let db = core.db.lock().unwrap(); + let message_id = + sessions_db::add_message(&db, &conversation_id, "assistant", &full, None, None) + .unwrap_or_default(); + let _ = sessions_db::touch_conversation(&db, &conversation_id); + let _ = app.emit( + "chat://done", + ChatDoneEvent { + conversation_id, + message_id, + content: full, + tokens_in: None, + tokens_out: None, + elapsed_ms, + }, + ); +} + #[tauri::command] pub async fn server_start( state: State<'_, App>, @@ -569,15 +918,3 @@ pub async fn server_status(state: State<'_, App>) -> Result Result<(), String> { tauri_plugin_opener::open_path(path, None::<&str>).map_err(|e| e.to_string()) } - -fn guess_quant(file_name: &str) -> Option { - 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()) -} diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index b47b1f5..0bb3280 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -2,8 +2,12 @@ mod commands; use std::sync::Arc; use tokio::sync::RwLock; +use tauri::{Emitter, Manager}; use xianren_core::CoreApp; +use xianren_core::models as models_db; +use xianren_core::settings as settings_db; use xianren_engine::EngineManager; +use std::path::PathBuf; pub struct App { pub core: Arc, @@ -34,6 +38,29 @@ pub fn run() { server: tokio::sync::Mutex::new(None), }, ); + + // 启动时自动扫描模型目录 + let app_state = app.state::(); + let db = app_state.core.db.lock().unwrap(); + let model_dir = settings_db::get(&db, "model_dir") + .ok() + .flatten() + .map(PathBuf::from) + .unwrap_or_else(|| app_state.core.models_dir.clone()); + match models_db::scan_directory(&db, &model_dir) { + Ok(result) => { + tracing::info!( + added = result.added, + updated = result.updated, + missing = result.missing, + "auto scan complete" + ); + } + Err(e) => { + tracing::warn!(error = %e, "auto scan failed"); + } + } + let _ = app.emit("models://updated", ()); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -41,6 +68,10 @@ pub fn run() { commands::list_models, commands::import_model, commands::remove_model, + commands::scan_models, + commands::add_remote_model, + commands::search_models, + commands::list_model_files, commands::settings_get, commands::settings_set, commands::list_conversations, @@ -60,4 +91,3 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } - diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 3b7ae56..7c0ac46 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -77,5 +77,24 @@ fn open_db(path: &Path) -> Result { conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; conn.execute_batch(include_str!("schema.sql"))?; + // 旧库迁移:为已存在的 models 表补充新增列 + ensure_column(&conn, "models", "kind", "TEXT NOT NULL DEFAULT 'local'")?; + ensure_column(&conn, "models", "base_url", "TEXT")?; + ensure_column(&conn, "models", "api_key", "TEXT")?; + ensure_column(&conn, "models", "api_model", "TEXT")?; Ok(conn) } + +fn ensure_column(conn: &Connection, table: &str, column: &str, decl: &str) -> rusqlite::Result<()> { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; + let columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1))? + .collect::>()?; + if !columns.iter().any(|c| c == column) { + conn.execute( + &format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"), + [], + )?; + } + Ok(()) +} diff --git a/crates/core/src/models.rs b/crates/core/src/models.rs index 150b896..5e47add 100644 --- a/crates/core/src/models.rs +++ b/crates/core/src/models.rs @@ -1,12 +1,15 @@ use crate::error::Result; use rusqlite::{params, Connection}; use serde::Serialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Serialize)] pub struct ModelInfo { pub id: String, pub repo_id: String, pub source: String, + pub kind: String, pub file_name: String, pub file_path: String, pub file_size: i64, @@ -14,36 +17,54 @@ pub struct ModelInfo { pub family: Option, pub status: String, pub sha256: Option, + pub base_url: Option, + pub api_key: Option, + pub api_model: Option, pub meta: serde_json::Value, pub created_at: String, } +#[derive(Debug, Default, Serialize)] +pub struct ScanResult { + pub added: usize, + pub updated: usize, + pub missing: usize, +} + pub fn insert( db: &Connection, repo_id: &str, source: &str, + kind: &str, file_name: &str, file_path: &str, file_size: i64, quant: Option<&str>, family: Option<&str>, sha256: Option<&str>, + base_url: Option<&str>, + api_key: Option<&str>, + api_model: Option<&str>, meta: serde_json::Value, ) -> Result { let id = uuid::Uuid::new_v4().to_string(); db.execute( - "INSERT INTO models (id, repo_id, source, file_name, file_path, file_size, quant, family, status, sha256, meta_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'ready', ?9, ?10)", + "INSERT INTO models (id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'ready', ?10, ?11, ?12, ?13, ?14)", params![ id, repo_id, source, + kind, file_name, file_path, file_size, quant, family, sha256, + base_url, + api_key, + api_model, meta.to_string() ], )?; @@ -52,7 +73,7 @@ pub fn insert( pub fn list(db: &Connection) -> Result> { let mut stmt = db.prepare( - "SELECT id, repo_id, source, file_name, file_path, file_size, quant, family, status, sha256, meta_json, created_at + "SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at FROM models ORDER BY created_at DESC", )?; let rows = stmt.query_map([], row_to_model)?; @@ -65,7 +86,7 @@ pub fn list(db: &Connection) -> Result> { pub fn get(db: &Connection, id: &str) -> Result> { let mut stmt = db.prepare( - "SELECT id, repo_id, source, file_name, file_path, file_size, quant, family, status, sha256, meta_json, created_at + "SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at FROM models WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], row_to_model)?; @@ -80,21 +101,125 @@ pub fn remove(db: &Connection, id: &str) -> Result<()> { Ok(()) } +pub fn update_file_info(db: &Connection, id: &str, size: i64, status: &str) -> Result<()> { + db.execute( + "UPDATE models SET file_size = ?1, status = ?2 WHERE id = ?3", + params![size, status, id], + )?; + Ok(()) +} + +/// 扫描目录(含子目录)中的 .gguf 文件,与注册表同步。 +pub fn scan_directory(db: &Connection, dir: &Path) -> Result { + if !dir.exists() { + return Ok(ScanResult::default()); + } + let mut found = Vec::new(); + collect_gguf_files(dir, &mut found)?; + + let existing = list(db)?; + let mut by_path: HashMap = HashMap::new(); + for m in existing { + by_path.insert(m.file_path.to_lowercase(), m); + } + + let mut result = ScanResult::default(); + for path in found { + let meta = std::fs::metadata(&path)?; + let name = path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let path_str = path.to_string_lossy().to_string(); + let key = path_str.to_lowercase(); + let size = meta.len() as i64; + + if let Some(m) = by_path.remove(&key) { + if m.file_size != size || m.status != "ready" { + update_file_info(db, &m.id, size, "ready")?; + result.updated += 1; + } + } else { + insert( + db, + &name, + "scan", + "local", + &name, + &path_str, + size, + guess_quant(&name).as_deref(), + None, + None, + None, + None, + None, + serde_json::json!({}), + )?; + result.added += 1; + } + } + + for m in by_path.values() { + if m.kind == "local" && m.status != "missing" { + update_file_info(db, &m.id, m.file_size, "missing")?; + result.missing += 1; + } + } + Ok(result) +} + +fn collect_gguf_files(dir: &Path, out: &mut Vec) -> Result<()> { + let entries = std::fs::read_dir(dir)?; + for entry in entries.flatten() { + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_gguf_files(&path, out)?; + } else if file_type.is_file() { + let is_gguf = path + .extension() + .map(|e| e.eq_ignore_ascii_case("gguf")) + .unwrap_or(false); + let is_part = path.to_string_lossy().contains(".part"); + if is_gguf && !is_part { + out.push(path); + } + } + } + Ok(()) +} + fn row_to_model(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let meta_json: String = row.get(10)?; + let meta_json: String = row.get(14)?; Ok(ModelInfo { id: row.get(0)?, repo_id: row.get(1)?, source: row.get(2)?, - file_name: row.get(3)?, - file_path: row.get(4)?, - file_size: row.get(5)?, - quant: row.get(6)?, - family: row.get(7)?, - status: row.get(8)?, - sha256: row.get(9)?, + kind: row.get(3)?, + file_name: row.get(4)?, + file_path: row.get(5)?, + file_size: row.get(6)?, + quant: row.get(7)?, + family: row.get(8)?, + status: row.get(9)?, + sha256: row.get(10)?, + base_url: row.get(11)?, + api_key: row.get(12)?, + api_model: row.get(13)?, meta: serde_json::from_str(&meta_json).unwrap_or_else(|_| serde_json::json!({})), - created_at: row.get(11)?, + created_at: row.get(15)?, }) } +pub fn guess_quant(file_name: &str) -> Option { + 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()) +} diff --git a/crates/core/src/schema.sql b/crates/core/src/schema.sql index 2479d72..3581640 100644 --- a/crates/core/src/schema.sql +++ b/crates/core/src/schema.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS models ( id TEXT PRIMARY KEY, repo_id TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'local', + kind TEXT NOT NULL DEFAULT 'local', file_name TEXT NOT NULL, file_path TEXT NOT NULL, file_size INTEGER NOT NULL DEFAULT 0, @@ -9,6 +10,9 @@ CREATE TABLE IF NOT EXISTS models ( family TEXT, status TEXT NOT NULL DEFAULT 'ready', sha256 TEXT, + base_url TEXT, + api_key TEXT, + api_model TEXT, meta_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -36,4 +40,3 @@ CREATE TABLE IF NOT EXISTS messages ( tokens_out INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); - diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3e7b90b..ff849aa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,8 +1,9 @@ pub mod error; pub mod manager; +pub mod remote; pub mod types; pub use error::{EngineError, Result}; pub use manager::EngineManager; +pub use remote::{stream_chat_remote, RemoteConfig}; pub use types::{ChatMessage, ChatRequest, EngineConfig, EngineStatus}; - diff --git a/crates/engine/src/manager.rs b/crates/engine/src/manager.rs index ec48144..620cdfa 100644 --- a/crates/engine/src/manager.rs +++ b/crates/engine/src/manager.rs @@ -203,7 +203,7 @@ fn free_port() -> Result { } /// 把 reqwest 的字节流解析为 SSE 行级文本流(增量 token)。 -fn sse_text_stream( +pub(crate) fn sse_text_stream( bytes: impl Stream> + Unpin + Send diff --git a/crates/engine/src/remote.rs b/crates/engine/src/remote.rs new file mode 100644 index 0000000..7e6da27 --- /dev/null +++ b/crates/engine/src/remote.rs @@ -0,0 +1,44 @@ +use crate::error::{EngineError, Result}; +use crate::manager::sse_text_stream; +use crate::types::ChatRequest; + +/// OpenAI 兼容的远程模型端点配置。 +#[derive(Debug, Clone)] +pub struct RemoteConfig { + pub base_url: String, + pub api_key: Option, + pub model: String, +} + +/// 调用远程 OpenAI 兼容 API 并返回流式增量文本。 +pub async fn stream_chat_remote( + cfg: &RemoteConfig, + req: ChatRequest, +) -> Result>> { + let base = normalize_base(&cfg.base_url); + let url = format!("{base}/chat/completions"); + let client = reqwest::Client::new(); + let mut builder = client.post(&url).json(&req); + if let Some(key) = &cfg.api_key { + if !key.trim().is_empty() { + builder = builder.bearer_auth(key); + } + } + let response = builder.send().await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(EngineError::EngineHttp(status, body)); + } + Ok(sse_text_stream(response.bytes_stream())) +} + +/// 把用户填写的 base url 归一化为 `:///v1` 形式。 +fn normalize_base(base: &str) -> String { + let mut s = base.trim().trim_end_matches('/').to_string(); + if !s.ends_with("/v1") { + s.push_str("/v1"); + } + s +} + diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d2ceb4d..5a2c924 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,14 +4,14 @@ import { onEvent } from "./api"; import { useStore } from "./store"; import ModelsPage from "./pages/ModelsPage"; import ChatPage from "./pages/ChatPage"; -import DownloadsPage from "./pages/DownloadsPage"; +import ModelPlazaPage from "./pages/ModelPlazaPage"; import ServerPage from "./pages/ServerPage"; import SettingsPage from "./pages/SettingsPage"; const navItems = [ { to: "/", label: "模型库", end: true }, { to: "/chat", label: "聊天" }, - { to: "/downloads", label: "下载" }, + { to: "/plaza", label: "模型广场" }, { to: "/server", label: "本地服务" }, { to: "/settings", label: "设置" }, ]; @@ -30,10 +30,12 @@ export default function App() { const un1 = onEvent("engine://status", () => refreshEngine()); const un2 = onEvent("server://status", () => refreshServer()); const un3 = onEvent("download://done", () => refreshModels()); + const un4 = onEvent("models://updated", () => refreshModels()); return () => { un1.then((f) => f()); un2.then((f) => f()); un3.then((f) => f()); + un4.then((f) => f()); }; }, [refreshModels, refreshEngine, refreshServer, refreshConversations]); @@ -71,7 +73,7 @@ export default function App() { } /> } /> - } /> + } /> } /> } /> @@ -97,4 +99,3 @@ function EngineChip() { ); } - diff --git a/ui/src/api.ts b/ui/src/api.ts index d78cbbc..b5e14f9 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -14,6 +14,7 @@ export interface ModelInfo { id: string; repo_id: string; source: string; + kind: string; file_name: string; file_path: string; file_size: number; @@ -21,10 +22,28 @@ export interface ModelInfo { family: string | null; status: string; sha256: string | null; + base_url: string | null; + api_key: string | null; + api_model: string | null; meta: Record; created_at: string; } +export interface RepoSummary { + repo_id: string; + author: string; + name: string; + downloads: number | null; + likes: number | null; + tags: string[]; +} + +export interface ModelFile { + path: string; + size: number; + sha256: string | null; +} + export interface EngineStatus { running: boolean; port: number | null; @@ -110,6 +129,13 @@ export const api = { listModels: () => invoke("list_models"), importModel: (path: string) => invoke("import_model", { path }), removeModel: (id: string) => invoke("remove_model", { id }), + scanModels: () => invoke("scan_models"), + addRemoteModel: (name: string, baseUrl: string, apiKey: string, apiModel: string) => + invoke("add_remote_model", { name, baseUrl, apiKey, apiModel }), + searchModels: (query: string, source: string) => + invoke("search_models", { query, source }), + listModelFiles: (repoId: string, source: string) => + invoke("list_model_files", { repoId, source }), settingsGet: () => invoke>("settings_get"), settingsSet: (key: string, value: string) => invoke("settings_set", { key, value }), @@ -125,11 +151,19 @@ export const api = { engineStop: () => invoke("engine_stop"), engineStatus: () => invoke("engine_status"), chatSend: (payload: ChatSendPayload) => invoke("chat_send", { payload }), - downloadEnqueue: (url: string, fileName: string, sha256?: string) => + downloadEnqueue: ( + url: string, + fileName: string, + sha256?: string, + repoId?: string, + source?: string, + ) => invoke("download_enqueue", { url, fileName, sha256: sha256 ?? null, + repoId: repoId ?? null, + source: source ?? null, }), serverStart: (port: number, apiKey: string) => invoke("server_start", { port, apiKey }), @@ -141,4 +175,3 @@ export const api = { export function onEvent(channel: string, handler: (payload: T) => void) { return listen(channel, (event) => handler(event.payload)); } - diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index f094e20..e69737a 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -168,9 +168,14 @@ export default function ChatPage() { } const engineRunning = useStore((s) => s.engine?.running ?? false); + const selectedIsRemote = useMemo( + () => models.find((m) => m.id === selectedModelId)?.kind === "remote", + [models, selectedModelId], + ); const engineLabel = useMemo(() => { if (!models.length) return "无可用模型"; - return models.find((m) => m.id === selectedModelId)?.file_name ?? "选择模型"; + const m = models.find((x) => x.id === selectedModelId); + return m ? `${m.file_name}${m.kind === "remote" ? " · 在线API" : " · 本地"}` : "选择模型"; }, [models, selectedModelId]); return ( @@ -211,27 +216,34 @@ export default function ChatPage() { {models.map((m) => ( ))} -
- - -
+ {selectedIsRemote ? ( +
+ 在线 API 模型,无需本地引擎 +
+ ) : ( +
+ + +
+ )} diff --git a/ui/src/pages/DownloadsPage.tsx b/ui/src/pages/DownloadsPage.tsx deleted file mode 100644 index c2a54aa..0000000 --- a/ui/src/pages/DownloadsPage.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useEffect, useState } from "react"; -import { api, DownloadProgressEvent, onEvent } from "../api"; - -export default function DownloadsPage() { - const [items, setItems] = useState>({}); - - useEffect(() => { - const un1 = onEvent("download://progress", (e) => { - setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "downloading" } })); - }); - const un2 = onEvent("download://done", (e) => { - setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "done" } })); - }); - const un3 = onEvent("download://error", (e) => { - setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "error" } })); - }); - return () => { - un1.then((f) => f()); - un2.then((f) => f()); - un3.then((f) => f()); - }; - }, []); - - async function handleAdd() { - const url = prompt("请输入 GGUF 文件直链(支持 hf-mirror / huggingface / modelscope):"); - if (!url) return; - const name = url.split("/").pop() || "model.gguf"; - const fileName = prompt("保存为(文件名):", name); - if (!fileName) return; - const sha256 = prompt("SHA256(可选,留空跳过校验):", "") || undefined; - const id = await api.downloadEnqueue(url, fileName, sha256); - setItems((prev) => ({ - ...prev, - [id]: { - id, - url, - file_name: fileName, - downloaded: 0, - total: null, - percent: null, - speed_bps: 0, - status: "downloading", - }, - })); - } - - const list = Object.values(items).sort((a, b) => b.id.localeCompare(a.id)); - - return ( -
-
-
-

下载

-

- 分片断点续传,支持校验。完成后的模型会自动进入模型库 -

-
- -
- - {list.length === 0 ? ( -
- 暂无下载任务。点击右上角添加 GGUF 直链。 -
- ) : ( -
- {list.map((item) => ( -
-
-
-
{item.file_name}
-
{item.url}
-
-
- {item.status === "done" - ? "已完成 ✓" - : item.status === "error" - ? "失败" - : `${item.percent?.toFixed(1) ?? 0}%`} - {item.status === "downloading" && item.speed_bps > 0 - ? ` · ${fmtSpeed(item.speed_bps)}` - : ""} -
-
-
-
-
- {item.status === "downloading" && item.total ? ( -
- {fmtSize(item.downloaded)} / {fmtSize(item.total)} -
- ) : null} -
- ))} -
- )} -
- ); -} - -function fmtSize(bytes: number) { - const mb = bytes / 1024 / 1024; - return mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(0)} MB`; -} - -function fmtSpeed(bps: number) { - const mbps = bps / 1024 / 1024; - return mbps >= 1 ? `${mbps.toFixed(1)} MB/s` : `${(bps / 1024).toFixed(0)} KB/s`; -} diff --git a/ui/src/pages/ModelPlazaPage.tsx b/ui/src/pages/ModelPlazaPage.tsx new file mode 100644 index 0000000..4b8825d --- /dev/null +++ b/ui/src/pages/ModelPlazaPage.tsx @@ -0,0 +1,274 @@ +import { useEffect, useState } from "react"; +import { api, DownloadProgressEvent, ModelFile, onEvent, RepoSummary } from "../api"; + +function fmtSize(bytes: number) { + if (!bytes) return "-"; + const gb = bytes / 1024 / 1024 / 1024; + return gb >= 1 ? `${gb.toFixed(2)} GB` : `${(bytes / 1024 / 1024).toFixed(0)} MB`; +} + +function fmtCount(n: number | null) { + if (n == null) return "-"; + return n >= 10000 ? `${(n / 10000).toFixed(1)}w` : n.toLocaleString(); +} + +export default function ModelPlazaPage() { + const [query, setQuery] = useState(""); + const [source, setSource] = useState<"hf" | "modelscope">("hf"); + const [endpoint, setEndpoint] = useState("https://hf-mirror.com"); + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [files, setFiles] = useState>({}); + const [loadingRepo, setLoadingRepo] = useState(null); + const [expanded, setExpanded] = useState(null); + const [manualUrl, setManualUrl] = useState(""); + const [downloads, setDownloads] = useState>({}); + + useEffect(() => { + api.settingsGet().then((s) => { + if (s.hf_endpoint) setEndpoint(s.hf_endpoint); + }); + }, []); + + useEffect(() => { + const un1 = onEvent("download://progress", (e) => { + setDownloads((prev) => ({ ...prev, [e.id]: { ...e, status: "downloading" } })); + }); + const un2 = onEvent("download://done", (e) => { + setDownloads((prev) => ({ ...prev, [e.id]: { ...e, status: "done" } })); + }); + const un3 = onEvent("download://error", (e) => { + setDownloads((prev) => ({ ...prev, [e.id]: { ...e, status: "error" } })); + }); + return () => { + un1.then((f) => f()); + un2.then((f) => f()); + un3.then((f) => f()); + }; + }, []); + + async function handleSearch() { + if (!query.trim()) return; + setLoading(true); + setError(null); + setRepos([]); + try { + setRepos(await api.searchModels(query.trim(), source)); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + } + + async function toggleExpand(repoId: string) { + if (expanded === repoId) { + setExpanded(null); + return; + } + setExpanded(repoId); + if (!files[repoId]) { + setLoadingRepo(repoId); + setError(null); + try { + const list = await api.listModelFiles(repoId, source); + setFiles((prev) => ({ ...prev, [repoId]: list })); + } catch (e) { + setError(String(e)); + } finally { + setLoadingRepo(null); + } + } + } + + function resolveUrl(repoId: string, path: string) { + if (source === "modelscope") { + return `https://modelscope.cn/models/${repoId}/resolve/master/${path}`; + } + return `${endpoint}/${repoId}/resolve/main/${path}`; + } + + async function handleDownload(repoId: string, file: ModelFile) { + const url = resolveUrl(repoId, file.path); + const fileName = file.path.split("/").pop() || "model.gguf"; + try { + await api.downloadEnqueue(url, fileName, file.sha256 ?? undefined, repoId, source); + } catch (e) { + alert(String(e)); + } + } + + async function handleManualDownload() { + const url = manualUrl.trim(); + if (!url) return; + const fileName = url.split("/").pop() || "model.gguf"; + try { + await api.downloadEnqueue(url, fileName); + setManualUrl(""); + } catch (e) { + alert(String(e)); + } + } + + const downloadList = Object.values(downloads).sort((a, b) => b.id.localeCompare(a.id)); + + return ( +
+
+

模型广场

+

+ 搜索 Hugging Face / ModelScope 上的 GGUF 模型,选择量化版本直接下载 +

+
+ +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> + + +
+ setManualUrl(e.target.value)} + /> + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {repos.length === 0 && !loading ? ( +
+ 输入关键词搜索模型仓库,点击仓库可查看 GGUF 量化版本 +
+ ) : ( +
+ {repos.map((repo) => ( +
+ + + {expanded === repo.repo_id ? ( +
+ {(files[repo.repo_id] ?? []).length === 0 ? ( +
+ 该仓库暂无 GGUF 文件(或已按量化筛选) +
+ ) : ( +
+ {files[repo.repo_id].map((f) => ( +
+
+
{f.path}
+
{fmtSize(f.size)}
+
+ +
+ ))} +
+ )} +
+ ) : null} +
+ ))} +
+ )} + + {downloadList.length > 0 ? ( +
+
下载队列
+
+ {downloadList.map((item) => ( +
+
+ {item.file_name} + + {item.status === "done" + ? "已完成 ✓" + : item.status === "error" + ? "失败" + : `${item.percent?.toFixed(1) ?? 0}%`} + {item.status === "downloading" && item.speed_bps > 0 + ? ` · ${fmtSpeed(item.speed_bps)}` + : ""} + +
+
+
+
+
+ ))} +
+
+ ) : null} +
+ ); +} + +function fmtSpeed(bps: number) { + const mbps = bps / 1024 / 1024; + return mbps >= 1 ? `${mbps.toFixed(1)} MB/s` : `${(bps / 1024).toFixed(0)} KB/s`; +} diff --git a/ui/src/pages/ModelsPage.tsx b/ui/src/pages/ModelsPage.tsx index a582ff1..fba74ac 100644 --- a/ui/src/pages/ModelsPage.tsx +++ b/ui/src/pages/ModelsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { api } from "../api"; import { useStore } from "../store"; @@ -11,11 +11,31 @@ function fmtSize(bytes: number) { export default function ModelsPage() { const models = useStore((s) => s.models); const refreshModels = useStore((s) => s.refreshModels); + const [showRemoteForm, setShowRemoteForm] = useState(false); + const [scanning, setScanning] = useState(false); + const [remote, setRemote] = useState({ + name: "", + base_url: "", + api_key: "", + api_model: "", + }); useEffect(() => { refreshModels(); }, [refreshModels]); + async function handleScan() { + setScanning(true); + try { + await api.scanModels(); + await refreshModels(); + } catch (e) { + alert(String(e)); + } finally { + setScanning(false); + } + } + async function handleImport() { const path = prompt("请输入本地 GGUF 文件路径:"); if (!path) return; @@ -27,8 +47,28 @@ export default function ModelsPage() { } } + async function handleAddRemote() { + if (!remote.name.trim() || !remote.base_url.trim() || !remote.api_model.trim()) { + alert("名称、Base URL、模型 ID 均不能为空"); + return; + } + try { + await api.addRemoteModel( + remote.name.trim(), + remote.base_url.trim(), + remote.api_key.trim(), + remote.api_model.trim(), + ); + setShowRemoteForm(false); + setRemote({ name: "", base_url: "", api_key: "", api_model: "" }); + await refreshModels(); + } catch (e) { + alert(String(e)); + } + } + async function handleRemove(id: string, name: string) { - if (!confirm(`确定从列表移除「${name}」吗?(不会删除文件)`)) return; + if (!confirm(`确定从列表移除「${name}」吗?(不会删除本地文件)`)) return; try { await api.removeModel(id); await refreshModels(); @@ -48,22 +88,81 @@ export default function ModelsPage() {

模型库

- 管理本地 GGUF 模型,可导入文件或从下载页拉取新模型 + 启动时自动扫描模型目录,也可手动扫描;支持添加本地 GGUF 与在线 API 模型

- + +
+ {showRemoteForm ? ( +
+
添加 OpenAI 兼容的在线 API 模型
+
+ + + + +
+
+ + +
+
+ ) : null} + {models.length === 0 ? (
- 暂无模型。点击右上角导入本地 GGUF 文件,或前往「下载」页拉取模型。 + 暂无模型。可导入本地 GGUF、添加在线 API 模型,或到「模型广场」下载模型。
) : (
@@ -71,9 +170,9 @@ export default function ModelsPage() { 模型 + 类型 量化 大小 - 来源 状态 @@ -83,17 +182,37 @@ export default function ModelsPage() {
{m.file_name}
-
{m.file_path}
+
+ {m.kind === "remote" + ? `${m.base_url ?? ""} · ${m.api_model ?? ""}` + : m.file_path} +
+ + + + {m.kind === "remote" ? "在线 API" : "本地"} + {m.quant ?? "-"} - {fmtSize(m.file_size)} - {m.source} + + {m.kind === "remote" ? "-" : fmtSize(m.file_size)} + - {m.status} + {m.status === "ready" ? ( + {m.status} + ) : ( + {m.status} + )}