From 4718e41433e56bd3b7cb9d8fb5f8b5451de3853a Mon Sep 17 00:00:00 2001 From: Xianren Studio Date: Thu, 13 Aug 2026 17:04:40 +0800 Subject: [PATCH] fix: model repo subdir layout, no-window engine launch, deploy progress, engine path auto-repair --- apps/desktop/src/commands.rs | 109 +++++++++++++++++++++++++++++++- apps/desktop/src/lib.rs | 51 ++++++++++++++- crates/engine/src/manager.rs | 3 + scripts/download-test-model.ps1 | 5 +- scripts/smoke-test.ps1 | 2 +- ui/src/App.tsx | 8 ++- ui/src/api.ts | 7 ++ ui/src/pages/ChatPage.tsx | 18 ++++++ ui/src/pages/ModelsPage.tsx | 19 +++++- ui/src/store.ts | 5 ++ 10 files changed, 217 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index 44a3a73..d79cf37 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -70,6 +70,14 @@ pub struct EngineDeployEvent { pub message: Option, } +#[derive(Serialize, Clone)] +pub struct EngineDeployProgressEvent { + pub model_id: String, + pub file_name: String, + pub percent: u32, + pub stage: String, +} + #[derive(Deserialize, Clone)] pub struct DownloadPayload { pub url: String, @@ -347,20 +355,62 @@ pub async fn deploy_model( threads: None, log_file: core2.logs_dir.join(format!("engine-{}.log", model.file_name)), }; + // 后台轮询引擎日志,解析模型加载进度并广播 + let log_path = cfg.log_file.clone(); + let progress_app = app2.clone(); + let progress_model = model_id2.clone(); + let progress_name = file_name2.clone(); + let progress_task = tokio::spawn(async move { + let mut last_size = 0u64; + loop { + if let Ok(meta) = tokio::fs::metadata(&log_path).await { + let size = meta.len(); + if size != last_size { + last_size = size; + if let Ok(bytes) = tokio::fs::read(&log_path).await { + let text = String::from_utf8_lossy(&bytes); + let (percent, stage) = estimate_load_progress(&text); + let _ = progress_app.emit( + "engine://deploy-progress", + EngineDeployProgressEvent { + model_id: progress_model.clone(), + file_name: progress_name.clone(), + percent, + stage: stage.to_string(), + }, + ); + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + }); + match engine2.start(cfg).await { Ok(()) => { + progress_task.abort(); *base2.write().await = engine2.base_url().await; let _ = app2.emit( "engine://deploy", EngineDeployEvent { - model_id: model_id2, - file_name: file_name2, + model_id: model_id2.clone(), + file_name: file_name2.clone(), state: "ready".into(), message: None, }, ); + let _ = app2.emit( + "engine://deploy-progress", + EngineDeployProgressEvent { + model_id: model_id2.clone(), + file_name: file_name2.clone(), + percent: 100, + stage: "服务已就绪".into(), + }, + ); } Err(e) => { + progress_task.abort(); let _ = app2.emit( "engine://deploy", EngineDeployEvent { @@ -540,7 +590,12 @@ pub fn download_enqueue( ) -> Result { let id = uuid::Uuid::new_v4().to_string(); let core = state.core.clone(); - let dest = core.models_dir.join(&payload.file_name); + // 模型按 /// 组织,不直接放模型目录根 + let subdir = derive_repo_subdir(&payload); + let dest_dir = core.models_dir.join(&subdir); + std::fs::create_dir_all(&dest_dir) + .map_err(|e| format!("failed to create model directory {}: {e}", dest_dir.display()))?; + let dest = dest_dir.join(&payload.file_name); let opts = xianren_download::DownloadOptions { url: payload.url.clone(), dest, @@ -975,6 +1030,54 @@ async fn drain_stream_and_persist( ); } +/// 根据 repo_id 或 URL 推断下载模型应存放的子目录(owner/repo)。 +fn derive_repo_subdir(payload: &DownloadPayload) -> String { + if let Some(repo_id) = &payload.repo_id { + let parts: Vec<&str> = repo_id.split('/').collect(); + if parts.len() >= 2 && !parts[0].trim().is_empty() && !parts[1].trim().is_empty() { + return format!("{}/{}", parts[0].trim(), parts[1].trim()); + } + } + if let Some(rest) = payload.url.split("://").nth(1) { + let segments: Vec<&str> = rest.split('/').collect(); + let start = if segments.get(1) == Some(&"models") { + 2 + } else { + 1 + }; + if segments.len() >= start + 2 + && !segments[start].trim().is_empty() + && !segments[start + 1].trim().is_empty() + { + return format!("{}/{}", segments[start].trim(), segments[start + 1].trim()); + } + } + "manual".to_string() +} + +/// 根据引擎日志内容估算模型加载进度。 +fn estimate_load_progress(log: &str) -> (u32, &'static str) { + if log.contains("listening on http") + || log.contains("server is listening") + || log.contains("HTTP server listening") + { + (100, "服务已就绪") + } else if log.contains("model loaded") || log.contains("llama_new_context_with_model") { + (90, "推理上下文就绪") + } else if log.contains("load_model: initializing") { + (70, "初始化推理上下文") + } else if log.contains("load_tensors") || log.contains("model size") { + (55, "加载模型权重") + } else if log.contains("load_model: loading model") + || log.contains("llama_model_load") + || log.contains("loading model") + { + (25, "读取模型文件") + } else { + (8, "启动引擎进程") + } +} + #[tauri::command] pub async fn server_start( state: State<'_, App>, diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index 1c14ccb..bcde759 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -7,7 +7,7 @@ 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; +use std::path::{Path, PathBuf}; pub struct App { pub core: Arc, @@ -42,6 +42,34 @@ pub fn run() { // 启动时自动扫描模型目录 let app_state = app.state::(); let db = app_state.core.db.lock().unwrap(); + // 校验引擎路径:旧版本可能残留指向无效 exe 的配置(缺 DLL),自动修复 + let configured = settings_db::get(&db, "engine_bin").ok().flatten(); + let configured_ok = configured + .as_ref() + .map(|p| engine_binary_usable(PathBuf::from(p).as_path())) + .unwrap_or(false); + if !configured_ok { + let mut found: Option = None; + for dir in ["cpu", "vulkan", "cuda"] { + let candidate = app_state.core.engines_dir.join(dir).join("llama-server.exe"); + if engine_binary_usable(&candidate) { + found = Some(candidate); + break; + } + } + if found.is_none() { + let candidate = app_state.core.engines_dir.join("llama-server-cpu.exe"); + if engine_binary_usable(&candidate) { + found = Some(candidate); + } + } + if let Some(path) = found { + let _ = settings_db::set(&db, "engine_bin", &path.to_string_lossy()); + tracing::info!(path = %path.display(), "engine binary auto-fixed"); + } else { + tracing::warn!("no usable llama-server binary found"); + } + } let model_dir = settings_db::get(&db, "model_dir") .ok() .flatten() @@ -92,3 +120,24 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +/// 判断引擎二进制是否可用:文件存在,且同目录包含 DLL(llama.cpp 发布版依赖同目录 DLL)。 +fn engine_binary_usable(path: &Path) -> bool { + if !path.is_file() { + return false; + } + let Some(parent) = path.parent() else { + return false; + }; + std::fs::read_dir(parent) + .map(|entries| { + entries.flatten().any(|entry| { + entry + .path() + .extension() + .map(|ext| ext.eq_ignore_ascii_case("dll")) + .unwrap_or(false) + }) + }) + .unwrap_or(false) +} diff --git a/crates/engine/src/manager.rs b/crates/engine/src/manager.rs index 620cdfa..37e59a2 100644 --- a/crates/engine/src/manager.rs +++ b/crates/engine/src/manager.rs @@ -47,6 +47,9 @@ impl EngineManager { .unwrap_or_else(|| "unknown".to_string()); let mut cmd = Command::new(&cfg.binary_path); + // Windows:以无窗口方式启动引擎(不弹出终端) + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW cmd.arg("--model") .arg(&cfg.model_path) .arg("--host") diff --git a/scripts/download-test-model.ps1 b/scripts/download-test-model.ps1 index 901b308..544446e 100644 --- a/scripts/download-test-model.ps1 +++ b/scripts/download-test-model.ps1 @@ -12,7 +12,10 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null $modelPath = "Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf" $url = "$Endpoint/$modelPath" -$outFile = Join-Path $OutDir "qwen2.5-0.5b-instruct-q4_k_m.gguf" +$rel = "Qwen/Qwen2.5-0.5B-Instruct-GGUF" +$targetDir = Join-Path $OutDir $rel +New-Item -ItemType Directory -Force -Path $targetDir | Out-Null +$outFile = Join-Path $targetDir "qwen2.5-0.5b-instruct-q4_k_m.gguf" Write-Host "Downloading $url" Write-Host "-> $outFile" diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 index 8ab3520..6c4203e 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -1,6 +1,6 @@ param( [string]$Engine = "$env:APPDATA\XianrenStudio\engines\cpu\llama-server.exe", - [string]$Model = "$env:APPDATA\XianrenStudio\models\qwen2.5-0.5b-instruct-q4_k_m.gguf", + [string]$Model = "$env:APPDATA\XianrenStudio\models\Qwen\Qwen2.5-0.5B-Instruct-GGUF\qwen2.5-0.5b-instruct-q4_k_m.gguf", [int]$Port = 8088 ) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 32a6a04..a0e302d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { NavLink, Route, Routes } from "react-router-dom"; -import { EngineDeployEvent, onEvent } from "./api"; +import { EngineDeployEvent, EngineDeployProgressEvent, onEvent } from "./api"; import { useStore } from "./store"; import Icon from "./components/Icon"; import ModelsPage from "./pages/ModelsPage"; @@ -26,6 +26,7 @@ export default function App() { const refreshServer = useStore((s) => s.refreshServer); const refreshConversations = useStore((s) => s.refreshConversations); const setDeployState = useStore((s) => s.setDeployState); + const setDeployProgress = useStore((s) => s.setDeployProgress); function toggleCollapsed() { setCollapsed((v) => { @@ -48,12 +49,16 @@ export default function App() { setDeployState(e.model_id, e.state); refreshEngine(); }); + const un6 = onEvent("engine://deploy-progress", (e) => { + setDeployProgress(e.model_id, { percent: e.percent, stage: e.stage }); + }); return () => { un1.then((f) => f()); un2.then((f) => f()); un3.then((f) => f()); un4.then((f) => f()); un5.then((f) => f()); + un6.then((f) => f()); }; }, [ refreshModels, @@ -61,6 +66,7 @@ export default function App() { refreshServer, refreshConversations, setDeployState, + setDeployProgress, ]); return ( diff --git a/ui/src/api.ts b/ui/src/api.ts index fe42719..87d121a 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -115,6 +115,13 @@ export interface EngineDeployEvent { message: string | null; } +export interface EngineDeployProgressEvent { + model_id: string; + file_name: string; + percent: number; + stage: string; +} + export interface DownloadProgressEvent { id: string; url: string; diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index 2ada170..d3d5223 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -181,6 +181,8 @@ export default function ChatPage() { const engineRunning = useStore((s) => s.engine?.running ?? false); const engineModel = useStore((s) => s.engine?.model ?? null); + const deployStates = useStore((s) => s.deployStates); + const deployProgress = useStore((s) => s.deployProgress); const selectedIsRemote = useMemo( () => models.find((m) => m.id === selectedModelId)?.kind === "remote", [models, selectedModelId], @@ -338,6 +340,22 @@ export default function ChatPage() { )} + {deployStates[selectedModelId] === "loading" ? ( +
+
+
+
+
+ {deployProgress[selectedModelId]?.stage ?? "启动引擎进程"}{" "} + {deployProgress[selectedModelId]?.percent ?? 8}% +
+
+ ) : null}
{engineLabel}
diff --git a/ui/src/pages/ModelsPage.tsx b/ui/src/pages/ModelsPage.tsx index 1050c29..6bacced 100644 --- a/ui/src/pages/ModelsPage.tsx +++ b/ui/src/pages/ModelsPage.tsx @@ -13,6 +13,7 @@ export default function ModelsPage() { const refreshModels = useStore((s) => s.refreshModels); const engine = useStore((s) => s.engine); const deployStates = useStore((s) => s.deployStates); + const deployProgress = useStore((s) => s.deployProgress); const [showRemoteForm, setShowRemoteForm] = useState(false); const [scanning, setScanning] = useState(false); const [remote, setRemote] = useState({ @@ -249,6 +250,7 @@ export default function ModelsPage() { engineRunning={engine?.running ?? false} deployed={engine?.model === m.file_name} deployState={deployStates[m.id]} + deployProgress={deployProgress[m.id]} onDeploy={handleDeploy} onStop={handleStopEngine} /> @@ -276,6 +278,7 @@ function DeployButton({ engineRunning, deployed, deployState, + deployProgress, onDeploy, onStop, }: { @@ -283,6 +286,7 @@ function DeployButton({ engineRunning: boolean; deployed: boolean; deployState?: string; + deployProgress?: { percent: number; stage: string }; onDeploy: (id: string) => void; onStop: () => void; }) { @@ -303,10 +307,19 @@ function DeployButton({ ); } if (deployState === "loading") { + const p = deployProgress ?? { percent: 8, stage: "启动引擎进程" }; return ( - +
+
+
+
+
+ {p.stage} {p.percent}% +
+
); } return ( diff --git a/ui/src/store.ts b/ui/src/store.ts index 6c37333..afa0edc 100644 --- a/ui/src/store.ts +++ b/ui/src/store.ts @@ -7,7 +7,9 @@ interface Store { server: ServerStatus | null; conversations: Conversation[]; deployStates: Record; + deployProgress: Record; setDeployState: (modelId: string, state: string) => void; + setDeployProgress: (modelId: string, progress: { percent: number; stage: string }) => void; refreshModels: () => Promise; refreshEngine: () => Promise; refreshServer: () => Promise; @@ -20,8 +22,11 @@ export const useStore = create((set) => ({ server: null, conversations: [], deployStates: {}, + deployProgress: {}, setDeployState: (modelId, state) => set((s) => ({ deployStates: { ...s.deployStates, [modelId]: state } })), + setDeployProgress: (modelId, progress) => + set((s) => ({ deployProgress: { ...s.deployProgress, [modelId]: progress } })), refreshModels: async () => set({ models: await api.listModels() }), refreshEngine: async () => set({ engine: await api.engineStatus() }), refreshServer: async () => set({ server: await api.serverStatus() }),