Compare commits

..
2 Commits
17 changed files with 1155 additions and 240 deletions
Generated
+2
View File
@@ -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",
+25 -1
View File
@@ -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 2Rust+ WebView2
@@ -47,6 +54,24 @@ npm --prefix ui install
npm --prefix apps/desktop run dev
```
> **重要:调试版(debug)exe 不能直接双击运行。**
> `target\debug\xianren-desktop.exe` 在调试构建下会去连 Vite 开发服务器
> `http://localhost:5173`),不先启动前端就会显示“拒绝连接”,并会附带一个空终端窗口。
> 开发时请始终使用 `npm --prefix apps/desktop run dev`。
**直接双击运行的正式版**
```powershell
# 编译正式版(内嵌前端页面,无终端窗口;必须带 custom-protocol 特性)
cargo build --release -p xianren-desktop --features custom-protocol
# 运行
.\target\release\xianren-desktop.exe
```
正式版双击后:设置页确认引擎路径 → 模型库导入 GGUF → 聊天页选择模型即可对话。
如需安装包,可用 `npm --prefix apps/desktop run build`tauri build 会自动启用该特性并打 NSIS 安装包)。
## 常用脚本
| 脚本 | 用途 |
@@ -66,4 +91,3 @@ npm --prefix apps/desktop run dev
- `xianren.db`SQLite 数据库
可在应用“设置”页修改模型目录与引擎路径。
+7
View File
@@ -8,6 +8,11 @@ rust-version.workspace = true
name = "xianren_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
# 生产构建启用该特性后,前端资源会内嵌进 exe 并通过自定义协议加载;
# 开发模式(tauri dev)不启用,继续使用 Vite 开发服务器。
custom-protocol = ["tauri/custom-protocol"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -15,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" }
+408 -71
View File
@@ -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<String>,
pub repo_id: Option<String>,
pub source: Option<String>,
}
#[derive(Serialize, Clone)]
@@ -121,10 +126,14 @@ pub fn import_model(state: State<'_, App>, path: String) -> Result<ModelInfo, St
&db,
&name,
"local",
"local",
&name,
&path,
size,
guess_quant(&name).as_deref(),
models_db::guess_quant(&name).as_deref(),
None,
None,
None,
None,
None,
serde_json::json!({}),
@@ -299,6 +308,44 @@ pub async fn chat_send(
(history, model, bin)
};
let conversation_id = payload.conversation_id.clone();
// 远程 API 模型:直接调用 OpenAI 兼容端点
if model.kind == "remote" {
let cfg = RemoteConfig {
base_url: model.base_url.clone().unwrap_or_default(),
api_key: model.api_key.clone(),
model: model
.api_model
.clone()
.unwrap_or_else(|| model.repo_id.clone()),
};
let req = ChatRequest {
model: cfg.model.clone(),
messages: history,
temperature: Some(payload.params.temperature),
top_p: Some(payload.params.top_p),
max_tokens: Some(payload.params.max_tokens),
stream: true,
};
tokio::spawn(async move {
match xianren_engine::stream_chat_remote(&cfg, req).await {
Ok(stream) => 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<Vec<ModelInfo>, 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<ModelInfo, String> {
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<Vec<serde_json::Value>, 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::<Vec<_>>()
})
.unwrap_or_default(),
}));
}
}
}
}
Ok(results)
}
/// 列出模型仓库中的 GGUF 文件(用于模型广场选择量化版本)。
#[tauri::command]
pub async fn list_model_files(
repo_id: String,
source: String,
) -> Result<Vec<serde_json::Value>, 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<CoreApp>,
conversation_id: String,
mut stream: futures::stream::BoxStream<'static, xianren_engine::Result<String>>,
) {
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<serde_json::Value, S
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())
}
+31 -1
View File
@@ -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<CoreApp>,
@@ -34,6 +38,29 @@ pub fn run() {
server: tokio::sync::Mutex::new(None),
},
);
// 启动时自动扫描模型目录
let app_state = app.state::<App>();
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");
}
+19
View File
@@ -77,5 +77,24 @@ fn open_db(path: &Path) -> Result<Connection> {
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<String> = stmt
.query_map([], |row| row.get::<_, String>(1))?
.collect::<std::result::Result<_, _>>()?;
if !columns.iter().any(|c| c == column) {
conn.execute(
&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"),
[],
)?;
}
Ok(())
}
+138 -13
View File
@@ -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<String>,
pub status: String,
pub sha256: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub api_model: Option<String>,
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<String> {
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<Vec<ModelInfo>> {
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<Vec<ModelInfo>> {
pub fn get(db: &Connection, id: &str) -> Result<Option<ModelInfo>> {
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<ScanResult> {
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<String, ModelInfo> = 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<PathBuf>) -> 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<ModelInfo> {
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<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())
}
+4 -1
View File
@@ -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'))
);
+2 -1
View File
@@ -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};
+1 -1
View File
@@ -203,7 +203,7 @@ fn free_port() -> Result<u16> {
}
/// 把 reqwest 的字节流解析为 SSE 行级文本流(增量 token)。
fn sse_text_stream(
pub(crate) fn sse_text_stream(
bytes: impl Stream<Item = std::result::Result<bytes::Bytes, reqwest::Error>>
+ Unpin
+ Send
+44
View File
@@ -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<String>,
pub model: String,
}
/// 调用远程 OpenAI 兼容 API 并返回流式增量文本。
pub async fn stream_chat_remote(
cfg: &RemoteConfig,
req: ChatRequest,
) -> Result<futures::stream::BoxStream<'static, Result<String>>> {
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 归一化为 `<scheme>://<host>/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
}
+5 -4
View File
@@ -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() {
<Routes>
<Route path="/" element={<ModelsPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/downloads" element={<DownloadsPage />} />
<Route path="/plaza" element={<ModelPlazaPage />} />
<Route path="/server" element={<ServerPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
@@ -97,4 +99,3 @@ function EngineChip() {
</div>
);
}
+35 -2
View File
@@ -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<string, unknown>;
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<ModelInfo[]>("list_models"),
importModel: (path: string) => invoke<ModelInfo>("import_model", { path }),
removeModel: (id: string) => invoke<void>("remove_model", { id }),
scanModels: () => invoke<ModelInfo[]>("scan_models"),
addRemoteModel: (name: string, baseUrl: string, apiKey: string, apiModel: string) =>
invoke<ModelInfo>("add_remote_model", { name, baseUrl, apiKey, apiModel }),
searchModels: (query: string, source: string) =>
invoke<RepoSummary[]>("search_models", { query, source }),
listModelFiles: (repoId: string, source: string) =>
invoke<ModelFile[]>("list_model_files", { repoId, source }),
settingsGet: () => invoke<Record<string, string>>("settings_get"),
settingsSet: (key: string, value: string) =>
invoke<void>("settings_set", { key, value }),
@@ -125,11 +151,19 @@ export const api = {
engineStop: () => invoke<void>("engine_stop"),
engineStatus: () => invoke<EngineStatus>("engine_status"),
chatSend: (payload: ChatSendPayload) => invoke<string>("chat_send", { payload }),
downloadEnqueue: (url: string, fileName: string, sha256?: string) =>
downloadEnqueue: (
url: string,
fileName: string,
sha256?: string,
repoId?: string,
source?: string,
) =>
invoke<string>("download_enqueue", {
url,
fileName,
sha256: sha256 ?? null,
repoId: repoId ?? null,
source: source ?? null,
}),
serverStart: (port: number, apiKey: string) =>
invoke<ServerStatus>("server_start", { port, apiKey }),
@@ -141,4 +175,3 @@ export const api = {
export function onEvent<T>(channel: string, handler: (payload: T) => void) {
return listen<T>(channel, (event) => handler(event.payload));
}
+31 -19
View File
@@ -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) => (
<option key={m.id} value={m.id}>
{m.file_name}
{m.kind === "remote" ? "API" : ""}
</option>
))}
</select>
<div className="mt-2 flex gap-2">
<button
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
onClick={() =>
api.engineStart(selectedModelId, params).catch((e) => alert(String(e)))
}
disabled={engineRunning}
>
</button>
<button
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
onClick={() => api.engineStop().catch((e) => alert(String(e)))}
disabled={!engineRunning}
>
</button>
</div>
{selectedIsRemote ? (
<div className="mt-2 rounded-lg border border-sky-500/30 bg-sky-500/10 px-3 py-1.5 text-xs text-sky-300">
线 API
</div>
) : (
<div className="mt-2 flex gap-2">
<button
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
onClick={() =>
api.engineStart(selectedModelId, params).catch((e) => alert(String(e)))
}
disabled={engineRunning}
>
</button>
<button
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
onClick={() => api.engineStop().catch((e) => alert(String(e)))}
disabled={!engineRunning}
>
</button>
</div>
)}
</div>
</div>
-116
View File
@@ -1,116 +0,0 @@
import { useEffect, useState } from "react";
import { api, DownloadProgressEvent, onEvent } from "../api";
export default function DownloadsPage() {
const [items, setItems] = useState<Record<string, DownloadProgressEvent>>({});
useEffect(() => {
const un1 = onEvent<DownloadProgressEvent>("download://progress", (e) => {
setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "downloading" } }));
});
const un2 = onEvent<DownloadProgressEvent>("download://done", (e) => {
setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "done" } }));
});
const un3 = onEvent<DownloadProgressEvent>("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 (
<div className="h-full overflow-auto p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="mt-1 text-sm text-slate-400">
</p>
</div>
<button className="btn-primary" onClick={handleAdd}>
+
</button>
</div>
{list.length === 0 ? (
<div className="mt-16 text-center text-sm text-slate-500">
GGUF
</div>
) : (
<div className="space-y-3">
{list.map((item) => (
<div key={item.id} className="rounded-xl border border-border bg-panel p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.file_name}</div>
<div className="truncate text-xs text-slate-400">{item.url}</div>
</div>
<div className="ml-3 text-right text-xs text-slate-400">
{item.status === "done"
? "已完成 ✓"
: item.status === "error"
? "失败"
: `${item.percent?.toFixed(1) ?? 0}%`}
{item.status === "downloading" && item.speed_bps > 0
? ` · ${fmtSpeed(item.speed_bps)}`
: ""}
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-panel-2">
<div
className={`h-full rounded-full ${
item.status === "error" ? "bg-red-500" : "bg-gradient-to-r from-accent to-accent-2"
}`}
style={{ width: `${item.percent ?? 0}%` }}
/>
</div>
{item.status === "downloading" && item.total ? (
<div className="mt-1 text-xs text-slate-500">
{fmtSize(item.downloaded)} / {fmtSize(item.total)}
</div>
) : null}
</div>
))}
</div>
)}
</div>
);
}
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`;
}
+274
View File
@@ -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<RepoSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<Record<string, ModelFile[]>>({});
const [loadingRepo, setLoadingRepo] = useState<string | null>(null);
const [expanded, setExpanded] = useState<string | null>(null);
const [manualUrl, setManualUrl] = useState("");
const [downloads, setDownloads] = useState<Record<string, DownloadProgressEvent>>({});
useEffect(() => {
api.settingsGet().then((s) => {
if (s.hf_endpoint) setEndpoint(s.hf_endpoint);
});
}, []);
useEffect(() => {
const un1 = onEvent<DownloadProgressEvent>("download://progress", (e) => {
setDownloads((prev) => ({ ...prev, [e.id]: { ...e, status: "downloading" } }));
});
const un2 = onEvent<DownloadProgressEvent>("download://done", (e) => {
setDownloads((prev) => ({ ...prev, [e.id]: { ...e, status: "done" } }));
});
const un3 = onEvent<DownloadProgressEvent>("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 (
<div className="h-full overflow-auto p-6">
<div className="mb-4">
<h1 className="text-xl font-semibold">广</h1>
<p className="mt-1 text-sm text-slate-400">
Hugging Face / ModelScope GGUF
</p>
</div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<input
className="input w-72"
placeholder="搜索模型,例如 qwen / llama / deepseek"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<select
className="input w-36"
value={source}
onChange={(e) => {
setSource(e.target.value as "hf" | "modelscope");
setRepos([]);
setFiles({});
setExpanded(null);
}}
>
<option value="hf">Hugging Face</option>
<option value="modelscope">ModelScope</option>
</select>
<button className="btn-primary" onClick={handleSearch} disabled={loading || !query.trim()}>
{loading ? "搜索中…" : "搜索"}
</button>
<div className="ml-auto flex items-center gap-2">
<input
className="input w-80"
placeholder="或直接粘贴 GGUF 直链下载(可选)"
value={manualUrl}
onChange={(e) => setManualUrl(e.target.value)}
/>
<button className="btn-secondary" onClick={handleManualDownload}>
</button>
</div>
</div>
{error ? (
<div className="mb-4 rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
) : null}
{repos.length === 0 && !loading ? (
<div className="mt-16 text-center text-sm text-slate-500">
GGUF
</div>
) : (
<div className="space-y-2">
{repos.map((repo) => (
<div key={repo.repo_id} className="rounded-xl border border-border bg-panel">
<button
className="flex w-full items-center gap-3 px-4 py-3 text-left"
onClick={() => toggleExpand(repo.repo_id)}
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{repo.author}/{repo.name}
</div>
<div className="mt-0.5 flex gap-3 text-xs text-slate-400">
<span> {fmtCount(repo.downloads)}</span>
<span> {fmtCount(repo.likes)}</span>
{repo.tags.slice(0, 4).map((t) => (
<span key={t} className="rounded bg-panel-2 px-1.5 py-0.5">
{t}
</span>
))}
</div>
</div>
<span className="text-xs text-slate-500">
{loadingRepo === repo.repo_id ? "加载中…" : expanded === repo.repo_id ? "收起 ▲" : "展开 ▼"}
</span>
</button>
{expanded === repo.repo_id ? (
<div className="border-t border-border px-4 py-3">
{(files[repo.repo_id] ?? []).length === 0 ? (
<div className="text-xs text-slate-500">
GGUF
</div>
) : (
<div className="space-y-1.5">
{files[repo.repo_id].map((f) => (
<div
key={f.path}
className="flex items-center gap-3 rounded-lg bg-panel-2/60 px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs">{f.path}</div>
<div className="text-xs text-slate-400">{fmtSize(f.size)}</div>
</div>
<button
className="btn-primary !px-3 !py-1 text-xs"
onClick={() => handleDownload(repo.repo_id, f)}
>
</button>
</div>
))}
</div>
)}
</div>
) : null}
</div>
))}
</div>
)}
{downloadList.length > 0 ? (
<div className="mt-6">
<div className="mb-2 text-xs uppercase text-slate-400"></div>
<div className="space-y-2">
{downloadList.map((item) => (
<div key={item.id} className="rounded-xl border border-border bg-panel p-3">
<div className="flex items-center justify-between text-sm">
<span className="truncate">{item.file_name}</span>
<span className="ml-3 text-xs text-slate-400">
{item.status === "done"
? "已完成 ✓"
: item.status === "error"
? "失败"
: `${item.percent?.toFixed(1) ?? 0}%`}
{item.status === "downloading" && item.speed_bps > 0
? ` · ${fmtSpeed(item.speed_bps)}`
: ""}
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-panel-2">
<div
className={`h-full rounded-full ${
item.status === "error"
? "bg-red-500"
: "bg-gradient-to-r from-accent to-accent-2"
}`}
style={{ width: `${item.percent ?? 0}%` }}
/>
</div>
</div>
))}
</div>
</div>
) : null}
</div>
);
}
function fmtSpeed(bps: number) {
const mbps = bps / 1024 / 1024;
return mbps >= 1 ? `${mbps.toFixed(1)} MB/s` : `${(bps / 1024).toFixed(0)} KB/s`;
}
+129 -10
View File
@@ -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() {
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="mt-1 text-sm text-slate-400">
GGUF
GGUF 线 API
</p>
</div>
<div className="flex gap-2">
<button className="btn-secondary" onClick={handleOpenDir}>
</button>
<button className="btn-primary" onClick={handleImport}>
<button className="btn-secondary" onClick={handleScan} disabled={scanning}>
{scanning ? "扫描中…" : "扫描模型目录"}
</button>
<button className="btn-secondary" onClick={handleImport}>
+
</button>
<button className="btn-primary" onClick={() => setShowRemoteForm((v) => !v)}>
+ 线
</button>
</div>
</div>
{showRemoteForm ? (
<div className="mb-4 rounded-xl border border-border bg-panel p-5">
<div className="mb-3 text-sm font-medium"> OpenAI 线 API </div>
<div className="grid grid-cols-2 gap-3">
<label className="block text-sm">
<span className="text-xs text-slate-400"></span>
<input
className="input mt-1"
placeholder="例如:GPT-4oOpenAI"
value={remote.name}
onChange={(e) => setRemote({ ...remote, name: e.target.value })}
/>
</label>
<label className="block text-sm">
<span className="text-xs text-slate-400"> ID</span>
<input
className="input mt-1"
placeholder="例如:gpt-4o"
value={remote.api_model}
onChange={(e) => setRemote({ ...remote, api_model: e.target.value })}
/>
</label>
<label className="block text-sm">
<span className="text-xs text-slate-400">Base URL /v1</span>
<input
className="input mt-1"
placeholder="例如:https://api.openai.com/v1"
value={remote.base_url}
onChange={(e) => setRemote({ ...remote, base_url: e.target.value })}
/>
</label>
<label className="block text-sm">
<span className="text-xs text-slate-400">API Key</span>
<input
className="input mt-1"
type="password"
placeholder="sk-..."
value={remote.api_key}
onChange={(e) => setRemote({ ...remote, api_key: e.target.value })}
/>
</label>
</div>
<div className="mt-4 flex gap-2">
<button className="btn-primary" onClick={handleAddRemote}>
</button>
<button className="btn-secondary" onClick={() => setShowRemoteForm(false)}>
</button>
</div>
</div>
) : null}
{models.length === 0 ? (
<div className="mt-16 text-center text-sm text-slate-500">
GGUF
GGUF线 API 广
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border bg-panel">
@@ -71,9 +170,9 @@ export default function ModelsPage() {
<thead>
<tr className="border-b border-border text-left text-xs uppercase text-slate-400">
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
</tr>
@@ -83,17 +182,37 @@ export default function ModelsPage() {
<tr key={m.id} className="border-b border-border/60 last:border-0">
<td className="px-4 py-3">
<div className="font-medium">{m.file_name}</div>
<div className="text-xs text-slate-400">{m.file_path}</div>
<div className="text-xs text-slate-400">
{m.kind === "remote"
? `${m.base_url ?? ""} · ${m.api_model ?? ""}`
: m.file_path}
</div>
</td>
<td className="px-4 py-3">
<span
className={`rounded px-2 py-0.5 text-xs ${
m.kind === "remote"
? "bg-sky-500/20 text-sky-300"
: "bg-emerald-500/20 text-emerald-300"
}`}
>
{m.kind === "remote" ? "在线 API" : "本地"}
</span>
</td>
<td className="px-4 py-3">
<span className="rounded bg-panel-2 px-2 py-0.5 font-mono text-xs">
{m.quant ?? "-"}
</span>
</td>
<td className="px-4 py-3 text-slate-300">{fmtSize(m.file_size)}</td>
<td className="px-4 py-3 text-slate-400">{m.source}</td>
<td className="px-4 py-3 text-slate-300">
{m.kind === "remote" ? "-" : fmtSize(m.file_size)}
</td>
<td className="px-4 py-3">
<span className="text-emerald-400">{m.status}</span>
{m.status === "ready" ? (
<span className="text-emerald-400">{m.status}</span>
) : (
<span className="text-amber-400">{m.status}</span>
)}
</td>
<td className="px-4 py-3 text-right">
<button