Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
451cc75cd9 | ||
|
|
0cf86c5c39 |
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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 数据库
|
||||
|
||||
可在应用“设置”页修改模型目录与引擎路径。
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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'))
|
||||
);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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
@@ -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-4o(OpenAI)"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user