Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4718e41433 |
@@ -70,6 +70,14 @@ pub struct EngineDeployEvent {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let core = state.core.clone();
|
||||
let dest = core.models_dir.join(&payload.file_name);
|
||||
// 模型按 <models_dir>/<owner>/<repo>/<file> 组织,不直接放模型目录根
|
||||
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>,
|
||||
|
||||
+50
-1
@@ -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<CoreApp>,
|
||||
@@ -42,6 +42,34 @@ pub fn run() {
|
||||
// 启动时自动扫描模型目录
|
||||
let app_state = app.state::<App>();
|
||||
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<PathBuf> = 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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
+7
-1
@@ -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<EngineDeployProgressEvent>("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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{deployStates[selectedModelId] === "loading" ? (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-panel-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-accent to-accent-2 transition-all duration-300"
|
||||
style={{
|
||||
width: `${deployProgress[selectedModelId]?.percent ?? 8}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-slate-400">
|
||||
{deployProgress[selectedModelId]?.stage ?? "启动引擎进程"}{" "}
|
||||
{deployProgress[selectedModelId]?.percent ?? 8}%
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-1 truncate text-xs text-slate-500">{engineLabel}</div>
|
||||
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
|
||||
@@ -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 (
|
||||
<button className="btn-secondary !px-3 !py-1 text-xs" disabled>
|
||||
部署中…
|
||||
</button>
|
||||
<div className="w-36">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-panel-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-accent to-accent-2 transition-all duration-300"
|
||||
style={{ width: `${p.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[10px] text-slate-400">
|
||||
{p.stage} {p.percent}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,9 @@ interface Store {
|
||||
server: ServerStatus | null;
|
||||
conversations: Conversation[];
|
||||
deployStates: Record<string, string>;
|
||||
deployProgress: Record<string, { percent: number; stage: string }>;
|
||||
setDeployState: (modelId: string, state: string) => void;
|
||||
setDeployProgress: (modelId: string, progress: { percent: number; stage: string }) => void;
|
||||
refreshModels: () => Promise<void>;
|
||||
refreshEngine: () => Promise<void>;
|
||||
refreshServer: () => Promise<void>;
|
||||
@@ -20,8 +22,11 @@ export const useStore = create<Store>((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() }),
|
||||
|
||||
Reference in New Issue
Block a user