From ebe3591445f3a94962c655d1e90d77d8489860b2 Mon Sep 17 00:00:00 2001 From: Xianren Studio Date: Thu, 13 Aug 2026 15:55:09 +0800 Subject: [PATCH] feat: nav reorder, model deploy button with background loading --- README.md | 2 +- apps/desktop/src/commands.rs | 127 ++++++++++++++++++++++++++++++++++- apps/desktop/src/lib.rs | 1 + ui/src/App.tsx | 20 ++++-- ui/src/api.ts | 9 +++ ui/src/pages/ChatPage.tsx | 2 + ui/src/pages/ModelsPage.tsx | 106 ++++++++++++++++++++++++++--- ui/src/store.ts | 6 +- 8 files changed, 255 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2fe8a89..0b9d229 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## 当前功能 -- **模型库**:启动时自动扫描模型目录(可手动重新扫描),支持导入本地 GGUF、添加 OpenAI 兼容在线 API 模型(聊天时可直接选用) +- **模型管理**:启动时自动扫描模型目录(可手动重新扫描),支持导入本地 GGUF、添加 OpenAI 兼容在线 API 模型;本地模型可一键「部署」后台加载,部署成功后对话页可直接选用 - **模型广场**:搜索 Hugging Face / ModelScope 上的 GGUF 模型,按量化版本一键下载(ModelScope 文件自动附带 SHA256 校验),下载进度实时显示 - **聊天**:流式输出、Markdown/代码高亮、采样参数调节、多会话管理;本地模型与在线 API 模型统一入口 - **本地 API 服务**:OpenAI 兼容端点(/v1/models、/v1/chat/completions、/v1/embeddings),仅本机监听,可选 API Key diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index 54ce048..44a3a73 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -62,6 +62,14 @@ pub struct ChatErrorEvent { pub message: String, } +#[derive(Serialize, Clone)] +pub struct EngineDeployEvent { + pub model_id: String, + pub file_name: String, + pub state: String, + pub message: Option, +} + #[derive(Deserialize, Clone)] pub struct DownloadPayload { pub url: String, @@ -196,6 +204,7 @@ pub fn get_messages(state: State<'_, App>, conversation_id: String) -> Result, model_id: String, params: ChatParams, @@ -237,13 +246,16 @@ pub async fn engine_start( }; engine.start(cfg).await.map_err(|e| e.to_string())?; *base.write().await = engine.base_url().await; - Ok(engine.status().await) + let status = engine.status().await; + let _ = app.emit("engine://status", status.clone()); + Ok(status) } #[tauri::command] -pub async fn engine_stop(state: State<'_, App>) -> Result<(), String> { +pub async fn engine_stop(app: AppHandle, state: State<'_, App>) -> Result<(), String> { state.engine.stop().await.map_err(|e| e.to_string())?; *state.engine_base.write().await = None; + let _ = app.emit("engine://status", state.engine.status().await); Ok(()) } @@ -254,6 +266,117 @@ pub async fn engine_status( Ok(state.engine.status().await) } +/// 后台部署(加载)本地模型:启动 llama-server 并等待就绪,通过事件广播进度。 +#[tauri::command] +pub async fn deploy_model( + app: AppHandle, + state: State<'_, App>, + model_id: String, + params: ChatParams, +) -> Result<(), String> { + let core = state.core.clone(); + let engine = state.engine.clone(); + let base = state.engine_base.clone(); + + let (model, engine_bin) = { + let db = core.db.lock().unwrap(); + let model = models_db::get(&db, &model_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "model not found".to_string())?; + if model.kind != "local" { + return Err("在线 API 模型无需部署".into()); + } + let bin = settings_db::get(&db, "engine_bin") + .map_err(|e| e.to_string())? + .unwrap_or_default(); + (model, bin) + }; + + let file_name = model.file_name.clone(); + let emit_deploy = |state_name: &str, message: Option| { + let _ = app.emit( + "engine://deploy", + EngineDeployEvent { + model_id: model_id.clone(), + file_name: file_name.clone(), + state: state_name.into(), + message, + }, + ); + }; + + let current = engine.status().await; + if current.running { + if current.model.as_deref() == Some(file_name.as_str()) { + emit_deploy("ready", None); + return Ok(()); + } + // 部署另一个模型前,先停掉当前引擎 + let _ = engine.stop().await; + *base.write().await = None; + let _ = app.emit("engine://status", engine.status().await); + } + + let bin = PathBuf::from(engine_bin); + if !bin.exists() { + emit_deploy( + "error", + Some(format!( + "引擎文件不存在:{},请到设置页配置 llama-server 路径", + bin.display() + )), + ); + return Ok(()); + } + + emit_deploy("loading", None); + let app2 = app.clone(); + let core2 = core.clone(); + let engine2 = engine.clone(); + let base2 = base.clone(); + let model_id2 = model_id.clone(); + let file_name2 = file_name.clone(); + + tokio::spawn(async move { + let cfg = EngineConfig { + binary_path: bin, + model_path: PathBuf::from(model.file_path), + host: "127.0.0.1".into(), + ctx_size: params.ctx_size, + ngl: params.ngl, + threads: None, + log_file: core2.logs_dir.join(format!("engine-{}.log", model.file_name)), + }; + match engine2.start(cfg).await { + Ok(()) => { + *base2.write().await = engine2.base_url().await; + let _ = app2.emit( + "engine://deploy", + EngineDeployEvent { + model_id: model_id2, + file_name: file_name2, + state: "ready".into(), + message: None, + }, + ); + } + Err(e) => { + let _ = app2.emit( + "engine://deploy", + EngineDeployEvent { + model_id: model_id2, + file_name: file_name2, + state: "error".into(), + message: Some(e.to_string()), + }, + ); + } + } + let _ = app2.emit("engine://status", engine2.status().await); + }); + Ok(()) +} + #[tauri::command] pub async fn chat_send( app: AppHandle, diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index 0bb3280..1c14ccb 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -81,6 +81,7 @@ pub fn run() { commands::engine_start, commands::engine_stop, commands::engine_status, + commands::deploy_model, commands::chat_send, commands::download_enqueue, commands::server_start, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5a2c924..0988a41 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { NavLink, Route, Routes } from "react-router-dom"; -import { onEvent } from "./api"; +import { EngineDeployEvent, onEvent } from "./api"; import { useStore } from "./store"; import ModelsPage from "./pages/ModelsPage"; import ChatPage from "./pages/ChatPage"; @@ -9,8 +9,8 @@ import ServerPage from "./pages/ServerPage"; import SettingsPage from "./pages/SettingsPage"; const navItems = [ - { to: "/", label: "模型库", end: true }, - { to: "/chat", label: "聊天" }, + { to: "/chat", label: "对话" }, + { to: "/", label: "模型管理", end: true }, { to: "/plaza", label: "模型广场" }, { to: "/server", label: "本地服务" }, { to: "/settings", label: "设置" }, @@ -21,6 +21,7 @@ export default function App() { const refreshEngine = useStore((s) => s.refreshEngine); const refreshServer = useStore((s) => s.refreshServer); const refreshConversations = useStore((s) => s.refreshConversations); + const setDeployState = useStore((s) => s.setDeployState); useEffect(() => { refreshModels(); @@ -31,13 +32,24 @@ export default function App() { const un2 = onEvent("server://status", () => refreshServer()); const un3 = onEvent("download://done", () => refreshModels()); const un4 = onEvent("models://updated", () => refreshModels()); + const un5 = onEvent("engine://deploy", (e) => { + setDeployState(e.model_id, e.state); + refreshEngine(); + }); return () => { un1.then((f) => f()); un2.then((f) => f()); un3.then((f) => f()); un4.then((f) => f()); + un5.then((f) => f()); }; - }, [refreshModels, refreshEngine, refreshServer, refreshConversations]); + }, [ + refreshModels, + refreshEngine, + refreshServer, + refreshConversations, + setDeployState, + ]); return (
diff --git a/ui/src/api.ts b/ui/src/api.ts index b5e14f9..fe42719 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -108,6 +108,13 @@ export interface ChatErrorEvent { message: string; } +export interface EngineDeployEvent { + model_id: string; + file_name: string; + state: string; + message: string | null; +} + export interface DownloadProgressEvent { id: string; url: string; @@ -148,6 +155,8 @@ export const api = { invoke("get_messages", { conversationId }), engineStart: (modelId: string, params: ChatParams) => invoke("engine_start", { modelId, params }), + deployModel: (modelId: string, params: ChatParams) => + invoke("deploy_model", { modelId, params }), engineStop: () => invoke("engine_stop"), engineStatus: () => invoke("engine_status"), chatSend: (payload: ChatSendPayload) => invoke("chat_send", { payload }), diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index e69737a..42ebc71 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -168,6 +168,7 @@ export default function ChatPage() { } const engineRunning = useStore((s) => s.engine?.running ?? false); + const engineModel = useStore((s) => s.engine?.model ?? null); const selectedIsRemote = useMemo( () => models.find((m) => m.id === selectedModelId)?.kind === "remote", [models, selectedModelId], @@ -217,6 +218,7 @@ export default function ChatPage() { ))} diff --git a/ui/src/pages/ModelsPage.tsx b/ui/src/pages/ModelsPage.tsx index fba74ac..1050c29 100644 --- a/ui/src/pages/ModelsPage.tsx +++ b/ui/src/pages/ModelsPage.tsx @@ -11,6 +11,8 @@ function fmtSize(bytes: number) { export default function ModelsPage() { const models = useStore((s) => s.models); const refreshModels = useStore((s) => s.refreshModels); + const engine = useStore((s) => s.engine); + const deployStates = useStore((s) => s.deployStates); const [showRemoteForm, setShowRemoteForm] = useState(false); const [scanning, setScanning] = useState(false); const [remote, setRemote] = useState({ @@ -82,13 +84,36 @@ export default function ModelsPage() { await api.openPath(info.models_dir); } + async function handleDeploy(id: string, params?: Partial) { + try { + await api.deployModel(id, { + temperature: 0.7, + top_p: 0.9, + max_tokens: 2048, + ctx_size: 4096, + ngl: 99, + ...params, + }); + } catch (e) { + alert(String(e)); + } + } + + async function handleStopEngine() { + try { + await api.engineStop(); + } catch (e) { + alert(String(e)); + } + } + return (
-

模型库

+

模型管理

- 启动时自动扫描模型目录,也可手动扫描;支持添加本地 GGUF 与在线 API 模型 + 启动时自动扫描模型目录,也可手动扫描;本地模型可点击「部署」后台加载

@@ -208,19 +233,33 @@ export default function ModelsPage() { {m.kind === "remote" ? "-" : fmtSize(m.file_size)} - {m.status === "ready" ? ( + {m.kind === "remote" ? ( + - + ) : m.status === "ready" ? ( {m.status} ) : ( {m.status} )} - - + +
+ {m.kind === "local" ? ( + + ) : null} + +
))} @@ -232,3 +271,50 @@ export default function ModelsPage() { ); } +function DeployButton({ + model, + engineRunning, + deployed, + deployState, + onDeploy, + onStop, +}: { + model: { id: string; file_name: string }; + engineRunning: boolean; + deployed: boolean; + deployState?: string; + onDeploy: (id: string) => void; + onStop: () => void; +}) { + if (deployed) { + return ( +
+ + ● 已部署 + + +
+ ); + } + if (deployState === "loading") { + return ( + + ); + } + return ( + + ); +} diff --git a/ui/src/store.ts b/ui/src/store.ts index a0a42dc..6c37333 100644 --- a/ui/src/store.ts +++ b/ui/src/store.ts @@ -6,6 +6,8 @@ interface Store { engine: EngineStatus | null; server: ServerStatus | null; conversations: Conversation[]; + deployStates: Record; + setDeployState: (modelId: string, state: string) => void; refreshModels: () => Promise; refreshEngine: () => Promise; refreshServer: () => Promise; @@ -17,10 +19,12 @@ export const useStore = create((set) => ({ engine: null, server: null, conversations: [], + deployStates: {}, + setDeployState: (modelId, state) => + set((s) => ({ deployStates: { ...s.deployStates, [modelId]: state } })), refreshModels: async () => set({ models: await api.listModels() }), refreshEngine: async () => set({ engine: await api.engineStatus() }), refreshServer: async () => set({ server: await api.serverStatus() }), refreshConversations: async () => set({ conversations: await api.listConversations() }), })); -