Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4718e41433 | ||
|
|
7f0edbd0a1 | ||
|
|
f6a0b4b89b |
@@ -70,6 +70,14 @@ pub struct EngineDeployEvent {
|
|||||||
pub message: Option<String>,
|
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)]
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct DownloadPayload {
|
pub struct DownloadPayload {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -347,20 +355,62 @@ pub async fn deploy_model(
|
|||||||
threads: None,
|
threads: None,
|
||||||
log_file: core2.logs_dir.join(format!("engine-{}.log", model.file_name)),
|
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 {
|
match engine2.start(cfg).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
progress_task.abort();
|
||||||
*base2.write().await = engine2.base_url().await;
|
*base2.write().await = engine2.base_url().await;
|
||||||
let _ = app2.emit(
|
let _ = app2.emit(
|
||||||
"engine://deploy",
|
"engine://deploy",
|
||||||
EngineDeployEvent {
|
EngineDeployEvent {
|
||||||
model_id: model_id2,
|
model_id: model_id2.clone(),
|
||||||
file_name: file_name2,
|
file_name: file_name2.clone(),
|
||||||
state: "ready".into(),
|
state: "ready".into(),
|
||||||
message: None,
|
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) => {
|
Err(e) => {
|
||||||
|
progress_task.abort();
|
||||||
let _ = app2.emit(
|
let _ = app2.emit(
|
||||||
"engine://deploy",
|
"engine://deploy",
|
||||||
EngineDeployEvent {
|
EngineDeployEvent {
|
||||||
@@ -540,7 +590,12 @@ pub fn download_enqueue(
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
let core = state.core.clone();
|
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 {
|
let opts = xianren_download::DownloadOptions {
|
||||||
url: payload.url.clone(),
|
url: payload.url.clone(),
|
||||||
dest,
|
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]
|
#[tauri::command]
|
||||||
pub async fn server_start(
|
pub async fn server_start(
|
||||||
state: State<'_, App>,
|
state: State<'_, App>,
|
||||||
|
|||||||
+50
-1
@@ -7,7 +7,7 @@ use xianren_core::CoreApp;
|
|||||||
use xianren_core::models as models_db;
|
use xianren_core::models as models_db;
|
||||||
use xianren_core::settings as settings_db;
|
use xianren_core::settings as settings_db;
|
||||||
use xianren_engine::EngineManager;
|
use xianren_engine::EngineManager;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub core: Arc<CoreApp>,
|
pub core: Arc<CoreApp>,
|
||||||
@@ -42,6 +42,34 @@ pub fn run() {
|
|||||||
// 启动时自动扫描模型目录
|
// 启动时自动扫描模型目录
|
||||||
let app_state = app.state::<App>();
|
let app_state = app.state::<App>();
|
||||||
let db = app_state.core.db.lock().unwrap();
|
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")
|
let model_dir = settings_db::get(&db, "model_dir")
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -92,3 +120,24 @@ pub fn run() {
|
|||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
let mut cmd = Command::new(&cfg.binary_path);
|
let mut cmd = Command::new(&cfg.binary_path);
|
||||||
|
// Windows:以无窗口方式启动引擎(不弹出终端)
|
||||||
|
#[cfg(windows)]
|
||||||
|
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||||
cmd.arg("--model")
|
cmd.arg("--model")
|
||||||
.arg(&cfg.model_path)
|
.arg(&cfg.model_path)
|
||||||
.arg("--host")
|
.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"
|
$modelPath = "Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf"
|
||||||
$url = "$Endpoint/$modelPath"
|
$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 "Downloading $url"
|
||||||
Write-Host "-> $outFile"
|
Write-Host "-> $outFile"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Engine = "$env:APPDATA\XianrenStudio\engines\cpu\llama-server.exe",
|
[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
|
[int]$Port = 8088
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+67
-18
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Route, Routes } from "react-router-dom";
|
import { NavLink, Route, Routes } from "react-router-dom";
|
||||||
import { EngineDeployEvent, onEvent } from "./api";
|
import { EngineDeployEvent, EngineDeployProgressEvent, onEvent } from "./api";
|
||||||
import { useStore } from "./store";
|
import { useStore } from "./store";
|
||||||
|
import Icon from "./components/Icon";
|
||||||
import ModelsPage from "./pages/ModelsPage";
|
import ModelsPage from "./pages/ModelsPage";
|
||||||
import ChatPage from "./pages/ChatPage";
|
import ChatPage from "./pages/ChatPage";
|
||||||
import ModelPlazaPage from "./pages/ModelPlazaPage";
|
import ModelPlazaPage from "./pages/ModelPlazaPage";
|
||||||
@@ -9,19 +10,31 @@ import ServerPage from "./pages/ServerPage";
|
|||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/chat", label: "对话" },
|
{ to: "/chat", label: "对话", icon: "chat" },
|
||||||
{ to: "/", label: "模型管理", end: true },
|
{ to: "/", label: "模型管理", icon: "box", end: true },
|
||||||
{ to: "/plaza", label: "模型广场" },
|
{ to: "/plaza", label: "模型广场", icon: "plaza" },
|
||||||
{ to: "/server", label: "本地服务" },
|
{ to: "/server", label: "服务管理", icon: "server" },
|
||||||
{ to: "/settings", label: "设置" },
|
{ to: "/settings", label: "设置", icon: "settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [collapsed, setCollapsed] = useState(
|
||||||
|
() => localStorage.getItem("xianren-nav-collapsed") === "1",
|
||||||
|
);
|
||||||
const refreshModels = useStore((s) => s.refreshModels);
|
const refreshModels = useStore((s) => s.refreshModels);
|
||||||
const refreshEngine = useStore((s) => s.refreshEngine);
|
const refreshEngine = useStore((s) => s.refreshEngine);
|
||||||
const refreshServer = useStore((s) => s.refreshServer);
|
const refreshServer = useStore((s) => s.refreshServer);
|
||||||
const refreshConversations = useStore((s) => s.refreshConversations);
|
const refreshConversations = useStore((s) => s.refreshConversations);
|
||||||
const setDeployState = useStore((s) => s.setDeployState);
|
const setDeployState = useStore((s) => s.setDeployState);
|
||||||
|
const setDeployProgress = useStore((s) => s.setDeployProgress);
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
setCollapsed((v) => {
|
||||||
|
const next = !v;
|
||||||
|
localStorage.setItem("xianren-nav-collapsed", next ? "1" : "0");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshModels();
|
refreshModels();
|
||||||
@@ -36,12 +49,16 @@ export default function App() {
|
|||||||
setDeployState(e.model_id, e.state);
|
setDeployState(e.model_id, e.state);
|
||||||
refreshEngine();
|
refreshEngine();
|
||||||
});
|
});
|
||||||
|
const un6 = onEvent<EngineDeployProgressEvent>("engine://deploy-progress", (e) => {
|
||||||
|
setDeployProgress(e.model_id, { percent: e.percent, stage: e.stage });
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
un1.then((f) => f());
|
un1.then((f) => f());
|
||||||
un2.then((f) => f());
|
un2.then((f) => f());
|
||||||
un3.then((f) => f());
|
un3.then((f) => f());
|
||||||
un4.then((f) => f());
|
un4.then((f) => f());
|
||||||
un5.then((f) => f());
|
un5.then((f) => f());
|
||||||
|
un6.then((f) => f());
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
refreshModels,
|
refreshModels,
|
||||||
@@ -49,37 +66,57 @@ export default function App() {
|
|||||||
refreshServer,
|
refreshServer,
|
||||||
refreshConversations,
|
refreshConversations,
|
||||||
setDeployState,
|
setDeployState,
|
||||||
|
setDeployProgress,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
<aside className="flex w-56 flex-col gap-1 border-r border-border bg-panel p-3">
|
<aside
|
||||||
<div className="mb-4 flex items-center gap-2 px-2">
|
className={`flex flex-col gap-1 border-r border-border bg-panel p-2 transition-all duration-200 ${
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-accent-2 text-lg font-bold">
|
collapsed ? "w-16" : "w-56"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mb-4 flex items-center ${collapsed ? "justify-center" : "gap-2 px-2"}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-accent-2 text-lg font-bold">
|
||||||
仙
|
仙
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{!collapsed ? (
|
||||||
<div className="text-sm font-semibold">仙人工作室</div>
|
<div className="min-w-0">
|
||||||
<div className="text-xs text-slate-400">本地大模型工作台</div>
|
<div className="truncate text-sm font-semibold">仙人工作室</div>
|
||||||
</div>
|
<div className="truncate text-xs text-slate-400">本地大模型工作台</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.end}
|
end={item.end}
|
||||||
|
title={item.label}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`rounded-lg px-3 py-2 text-sm transition-colors ${
|
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||||
|
collapsed ? "justify-center px-0" : ""
|
||||||
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-panel-2 text-white"
|
? "bg-panel-2 text-white"
|
||||||
: "text-slate-300 hover:bg-panel-2/60"
|
: "text-slate-300 hover:bg-panel-2/60"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.label}
|
<Icon name={item.icon} className="h-[18px] w-[18px] shrink-0" />
|
||||||
|
{!collapsed ? <span className="truncate">{item.label}</span> : null}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
<EngineChip />
|
<EngineChip collapsed={collapsed} />
|
||||||
|
<button
|
||||||
|
className="mt-1 flex items-center justify-center rounded-lg px-2 py-1.5 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
title={collapsed ? "展开侧边栏" : "折叠侧边栏"}
|
||||||
|
>
|
||||||
|
<Icon name={collapsed ? "chevron-right" : "chevron-left"} />
|
||||||
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="flex-1 overflow-hidden">
|
<main className="flex-1 overflow-hidden">
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -94,9 +131,21 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EngineChip() {
|
function EngineChip({ collapsed }: { collapsed: boolean }) {
|
||||||
const engine = useStore((s) => s.engine);
|
const engine = useStore((s) => s.engine);
|
||||||
const running = engine?.running ?? false;
|
const running = engine?.running ?? false;
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-auto flex justify-center rounded-lg py-2"
|
||||||
|
title={running ? `引擎运行中${engine?.model ? ` · ${engine.model}` : ""}` : "引擎未启动"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-2.5 w-2.5 rounded-full ${running ? "bg-emerald-400" : "bg-slate-500"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="mt-auto rounded-lg border border-border bg-panel-2 px-3 py-2 text-xs text-slate-300">
|
<div className="mt-auto rounded-lg border border-border bg-panel-2 px-3 py-2 text-xs text-slate-300">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ export interface EngineDeployEvent {
|
|||||||
message: string | null;
|
message: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EngineDeployProgressEvent {
|
||||||
|
model_id: string;
|
||||||
|
file_name: string;
|
||||||
|
percent: number;
|
||||||
|
stage: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadProgressEvent {
|
export interface DownloadProgressEvent {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const paths: Record<string, ReactNode> = {
|
||||||
|
chat: <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />,
|
||||||
|
box: (
|
||||||
|
<>
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
|
<path d="M3.27 6.96L12 12.01l8.73-5.05" />
|
||||||
|
<path d="M12 22.08V12" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
plaza: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
server: (
|
||||||
|
<>
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2" />
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2" />
|
||||||
|
<path d="M6 6h.01M6 18h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
settings: (
|
||||||
|
<>
|
||||||
|
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" />
|
||||||
|
<path d="M1 14h6M9 8h6M17 16h6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"chevron-left": <path d="M15 18l-6-6 6-6" />,
|
||||||
|
"chevron-right": <path d="M9 18l6-6-6-6" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Icon({
|
||||||
|
name,
|
||||||
|
className = "h-4 w-4",
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{paths[name]}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
+149
-84
@@ -10,6 +10,7 @@ import {
|
|||||||
onEvent,
|
onEvent,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
|
||||||
interface LocalMessage {
|
interface LocalMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,8 +39,19 @@ export default function ChatPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [params, setParams] = useState<ChatParams>(defaultParams);
|
const [params, setParams] = useState<ChatParams>(defaultParams);
|
||||||
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
||||||
|
const [rightOpen, setRightOpen] = useState(
|
||||||
|
() => localStorage.getItem("xianren-right-open") !== "0",
|
||||||
|
);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function toggleRight() {
|
||||||
|
setRightOpen((v) => {
|
||||||
|
const next = !v;
|
||||||
|
localStorage.setItem("xianren-right-open", next ? "1" : "0");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (models.length > 0 && !selectedModelId) {
|
if (models.length > 0 && !selectedModelId) {
|
||||||
setSelectedModelId(models[0].id);
|
setSelectedModelId(models[0].id);
|
||||||
@@ -169,6 +181,8 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const engineRunning = useStore((s) => s.engine?.running ?? false);
|
const engineRunning = useStore((s) => s.engine?.running ?? false);
|
||||||
const engineModel = useStore((s) => s.engine?.model ?? null);
|
const engineModel = useStore((s) => s.engine?.model ?? null);
|
||||||
|
const deployStates = useStore((s) => s.deployStates);
|
||||||
|
const deployProgress = useStore((s) => s.deployProgress);
|
||||||
const selectedIsRemote = useMemo(
|
const selectedIsRemote = useMemo(
|
||||||
() => models.find((m) => m.id === selectedModelId)?.kind === "remote",
|
() => models.find((m) => m.id === selectedModelId)?.kind === "remote",
|
||||||
[models, selectedModelId],
|
[models, selectedModelId],
|
||||||
@@ -207,46 +221,6 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 border-t border-border pt-3">
|
|
||||||
<label className="mb-1 block text-xs text-slate-400">当前模型</label>
|
|
||||||
<select
|
|
||||||
className="input w-full"
|
|
||||||
value={selectedModelId}
|
|
||||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
|
||||||
>
|
|
||||||
{models.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.file_name}
|
|
||||||
{m.kind === "remote" ? "(API)" : ""}
|
|
||||||
{engineRunning && engineModel === m.file_name ? " ●已部署" : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
@@ -291,7 +265,121 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border bg-panel p-4">
|
<div className="border-t border-border bg-panel p-4">
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-400">
|
<div className="flex items-end gap-3">
|
||||||
|
<textarea
|
||||||
|
className="input min-h-[72px] flex-1 resize-none"
|
||||||
|
placeholder={`向本地模型提问…(当前 ${engineRunning ? "引擎运行中" : "引擎未启动,发送时将自动启动"})`}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-primary h-[72px] px-6"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={streaming || !input.trim()}
|
||||||
|
>
|
||||||
|
{streaming ? "生成中…" : "发送"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧边栏:当前模型 + 部署/请求参数(可收起) */}
|
||||||
|
{rightOpen ? (
|
||||||
|
<div className="flex w-72 flex-col overflow-y-auto border-l border-border bg-panel p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs uppercase text-slate-400">参数面板</span>
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
|
||||||
|
onClick={toggleRight}
|
||||||
|
title="收起参数面板"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 text-xs text-slate-400">当前模型</div>
|
||||||
|
<select
|
||||||
|
className="input w-full"
|
||||||
|
value={selectedModelId}
|
||||||
|
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||||
|
>
|
||||||
|
{models.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.file_name}
|
||||||
|
{m.kind === "remote" ? "(API)" : ""}
|
||||||
|
{engineRunning && engineModel === m.file_name ? " ●已部署" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{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">
|
||||||
|
<div className="mb-3 text-xs uppercase text-slate-400">部署参数</div>
|
||||||
|
<ParamSlider
|
||||||
|
label="上下文长度 ctx_size"
|
||||||
|
value={params.ctx_size}
|
||||||
|
min={512}
|
||||||
|
max={32768}
|
||||||
|
step={512}
|
||||||
|
onChange={(v) => setParams({ ...params, ctx_size: v })}
|
||||||
|
/>
|
||||||
|
<ParamSlider
|
||||||
|
label="GPU 层数 ngl(99=全部)"
|
||||||
|
value={params.ngl}
|
||||||
|
min={0}
|
||||||
|
max={99}
|
||||||
|
step={1}
|
||||||
|
onChange={(v) => setParams({ ...params, ngl: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
|
<div className="mb-3 text-xs uppercase text-slate-400">请求参数</div>
|
||||||
<ParamSlider
|
<ParamSlider
|
||||||
label="temperature"
|
label="temperature"
|
||||||
value={params.temperature}
|
value={params.temperature}
|
||||||
@@ -316,47 +404,22 @@ export default function ChatPage() {
|
|||||||
step={64}
|
step={64}
|
||||||
onChange={(v) => setParams({ ...params, max_tokens: v })}
|
onChange={(v) => setParams({ ...params, max_tokens: v })}
|
||||||
/>
|
/>
|
||||||
<ParamSlider
|
|
||||||
label="ctx_size"
|
|
||||||
value={params.ctx_size}
|
|
||||||
min={512}
|
|
||||||
max={32768}
|
|
||||||
step={512}
|
|
||||||
onChange={(v) => setParams({ ...params, ctx_size: v })}
|
|
||||||
/>
|
|
||||||
<ParamSlider
|
|
||||||
label="ngl(全部=99)"
|
|
||||||
value={params.ngl}
|
|
||||||
min={0}
|
|
||||||
max={99}
|
|
||||||
step={1}
|
|
||||||
onChange={(v) => setParams({ ...params, ngl: v })}
|
|
||||||
/>
|
|
||||||
<span className="ml-auto text-slate-500">{engineLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end gap-3">
|
|
||||||
<textarea
|
|
||||||
className="input min-h-[72px] flex-1 resize-none"
|
|
||||||
placeholder={`向本地模型提问…(当前 ${engineRunning ? "引擎运行中" : "引擎未启动,发送时将自动启动"})`}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn-primary h-[72px] px-6"
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={streaming || !input.trim()}
|
|
||||||
>
|
|
||||||
{streaming ? "生成中…" : "发送"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex w-9 flex-col items-center border-l border-border bg-panel py-2">
|
||||||
|
<button
|
||||||
|
className="rounded p-1.5 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
|
||||||
|
onClick={toggleRight}
|
||||||
|
title="展开参数面板"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-left" />
|
||||||
|
</button>
|
||||||
|
<span className="mt-3 whitespace-nowrap text-[10px] text-slate-500 [writing-mode:vertical-rl]">
|
||||||
|
参数
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -377,18 +440,20 @@ function ParamSlider({
|
|||||||
onChange: (v: number) => void;
|
onChange: (v: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="flex items-center gap-2">
|
<label className="mb-3 block">
|
||||||
<span className="w-24">{label}</span>
|
<div className="mb-1 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="font-mono text-slate-300">{value}</span>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="w-28 accent-indigo-500"
|
className="w-full accent-indigo-500"
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
<span className="w-12 font-mono text-slate-300">{value}</span>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ export default function ModelsPage() {
|
|||||||
const refreshModels = useStore((s) => s.refreshModels);
|
const refreshModels = useStore((s) => s.refreshModels);
|
||||||
const engine = useStore((s) => s.engine);
|
const engine = useStore((s) => s.engine);
|
||||||
const deployStates = useStore((s) => s.deployStates);
|
const deployStates = useStore((s) => s.deployStates);
|
||||||
|
const deployProgress = useStore((s) => s.deployProgress);
|
||||||
const [showRemoteForm, setShowRemoteForm] = useState(false);
|
const [showRemoteForm, setShowRemoteForm] = useState(false);
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [remote, setRemote] = useState({
|
const [remote, setRemote] = useState({
|
||||||
@@ -249,6 +250,7 @@ export default function ModelsPage() {
|
|||||||
engineRunning={engine?.running ?? false}
|
engineRunning={engine?.running ?? false}
|
||||||
deployed={engine?.model === m.file_name}
|
deployed={engine?.model === m.file_name}
|
||||||
deployState={deployStates[m.id]}
|
deployState={deployStates[m.id]}
|
||||||
|
deployProgress={deployProgress[m.id]}
|
||||||
onDeploy={handleDeploy}
|
onDeploy={handleDeploy}
|
||||||
onStop={handleStopEngine}
|
onStop={handleStopEngine}
|
||||||
/>
|
/>
|
||||||
@@ -276,6 +278,7 @@ function DeployButton({
|
|||||||
engineRunning,
|
engineRunning,
|
||||||
deployed,
|
deployed,
|
||||||
deployState,
|
deployState,
|
||||||
|
deployProgress,
|
||||||
onDeploy,
|
onDeploy,
|
||||||
onStop,
|
onStop,
|
||||||
}: {
|
}: {
|
||||||
@@ -283,6 +286,7 @@ function DeployButton({
|
|||||||
engineRunning: boolean;
|
engineRunning: boolean;
|
||||||
deployed: boolean;
|
deployed: boolean;
|
||||||
deployState?: string;
|
deployState?: string;
|
||||||
|
deployProgress?: { percent: number; stage: string };
|
||||||
onDeploy: (id: string) => void;
|
onDeploy: (id: string) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -303,10 +307,19 @@ function DeployButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (deployState === "loading") {
|
if (deployState === "loading") {
|
||||||
|
const p = deployProgress ?? { percent: 8, stage: "启动引擎进程" };
|
||||||
return (
|
return (
|
||||||
<button className="btn-secondary !px-3 !py-1 text-xs" disabled>
|
<div className="w-36">
|
||||||
部署中…
|
<div className="h-1.5 overflow-hidden rounded-full bg-panel-2">
|
||||||
</button>
|
<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 (
|
return (
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function ServerPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-6">
|
<div className="h-full overflow-auto p-6">
|
||||||
<h1 className="text-xl font-semibold">本地 API 服务</h1>
|
<h1 className="text-xl font-semibold">服务管理</h1>
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
启动后即可用 OpenAI SDK / 任意 HTTP 客户端调用本地模型
|
启动后即可用 OpenAI SDK / 任意 HTTP 客户端调用本地模型
|
||||||
</p>
|
</p>
|
||||||
@@ -91,4 +91,3 @@ export default function ServerPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,7 +7,9 @@ interface Store {
|
|||||||
server: ServerStatus | null;
|
server: ServerStatus | null;
|
||||||
conversations: Conversation[];
|
conversations: Conversation[];
|
||||||
deployStates: Record<string, string>;
|
deployStates: Record<string, string>;
|
||||||
|
deployProgress: Record<string, { percent: number; stage: string }>;
|
||||||
setDeployState: (modelId: string, state: string) => void;
|
setDeployState: (modelId: string, state: string) => void;
|
||||||
|
setDeployProgress: (modelId: string, progress: { percent: number; stage: string }) => void;
|
||||||
refreshModels: () => Promise<void>;
|
refreshModels: () => Promise<void>;
|
||||||
refreshEngine: () => Promise<void>;
|
refreshEngine: () => Promise<void>;
|
||||||
refreshServer: () => Promise<void>;
|
refreshServer: () => Promise<void>;
|
||||||
@@ -20,8 +22,11 @@ export const useStore = create<Store>((set) => ({
|
|||||||
server: null,
|
server: null,
|
||||||
conversations: [],
|
conversations: [],
|
||||||
deployStates: {},
|
deployStates: {},
|
||||||
|
deployProgress: {},
|
||||||
setDeployState: (modelId, state) =>
|
setDeployState: (modelId, state) =>
|
||||||
set((s) => ({ deployStates: { ...s.deployStates, [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() }),
|
refreshModels: async () => set({ models: await api.listModels() }),
|
||||||
refreshEngine: async () => set({ engine: await api.engineStatus() }),
|
refreshEngine: async () => set({ engine: await api.engineStatus() }),
|
||||||
refreshServer: async () => set({ server: await api.serverStatus() }),
|
refreshServer: async () => set({ server: await api.serverStatus() }),
|
||||||
|
|||||||
Reference in New Issue
Block a user