Compare commits

..
4 Commits
13 changed files with 714 additions and 122 deletions
+1 -1
View File
@@ -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
+229 -3
View File
@@ -62,6 +62,22 @@ 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<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,
@@ -196,6 +212,7 @@ pub fn get_messages(state: State<'_, App>, conversation_id: String) -> Result<Ve
#[tauri::command]
pub async fn engine_start(
app: AppHandle,
state: State<'_, App>,
model_id: String,
params: ChatParams,
@@ -237,13 +254,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 +274,159 @@ 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<String>| {
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)),
};
// 后台轮询引擎日志,解析模型加载进度并广播
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.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 {
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,
@@ -417,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,
@@ -852,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>,
+51 -1
View File
@@ -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()
@@ -81,6 +109,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,
@@ -91,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)
}
+3
View File
@@ -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")
+4 -1
View File
@@ -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 -1
View File
@@ -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
)
+80 -19
View File
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { NavLink, Route, Routes } from "react-router-dom";
import { onEvent } from "./api";
import { EngineDeployEvent, EngineDeployProgressEvent, onEvent } from "./api";
import { useStore } from "./store";
import Icon from "./components/Icon";
import ModelsPage from "./pages/ModelsPage";
import ChatPage from "./pages/ChatPage";
import ModelPlazaPage from "./pages/ModelPlazaPage";
@@ -9,18 +10,31 @@ import ServerPage from "./pages/ServerPage";
import SettingsPage from "./pages/SettingsPage";
const navItems = [
{ to: "/", label: "模型库", end: true },
{ to: "/chat", label: "聊天" },
{ to: "/plaza", label: "模型广场" },
{ to: "/server", label: "本地服务" },
{ to: "/settings", label: "设置" },
{ to: "/chat", label: "对话", icon: "chat" },
{ to: "/", label: "模型管理", icon: "box", end: true },
{ to: "/plaza", label: "模型广场", icon: "plaza" },
{ to: "/server", label: "服务管理", icon: "server" },
{ to: "/settings", label: "设置", icon: "settings" },
];
export default function App() {
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("xianren-nav-collapsed") === "1",
);
const refreshModels = useStore((s) => s.refreshModels);
const refreshEngine = useStore((s) => s.refreshEngine);
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) => {
const next = !v;
localStorage.setItem("xianren-nav-collapsed", next ? "1" : "0");
return next;
});
}
useEffect(() => {
refreshModels();
@@ -31,43 +45,78 @@ 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<EngineDeployEvent>("engine://deploy", (e) => {
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, refreshEngine, refreshServer, refreshConversations]);
}, [
refreshModels,
refreshEngine,
refreshServer,
refreshConversations,
setDeployState,
setDeployProgress,
]);
return (
<div className="flex h-screen overflow-hidden">
<aside className="flex w-56 flex-col gap-1 border-r border-border bg-panel p-3">
<div className="mb-4 flex items-center gap-2 px-2">
<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">
<aside
className={`flex flex-col gap-1 border-r border-border bg-panel p-2 transition-all duration-200 ${
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 className="text-sm font-semibold"></div>
<div className="text-xs text-slate-400"></div>
</div>
{!collapsed ? (
<div className="min-w-0">
<div className="truncate text-sm font-semibold"></div>
<div className="truncate text-xs text-slate-400"></div>
</div>
) : null}
</div>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
title={item.label}
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
? "bg-panel-2 text-white"
: "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>
))}
<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>
<main className="flex-1 overflow-hidden">
<Routes>
@@ -82,9 +131,21 @@ export default function App() {
);
}
function EngineChip() {
function EngineChip({ collapsed }: { collapsed: boolean }) {
const engine = useStore((s) => s.engine);
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 (
<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">
+16
View File
@@ -108,6 +108,20 @@ export interface ChatErrorEvent {
message: string;
}
export interface EngineDeployEvent {
model_id: string;
file_name: string;
state: string;
message: string | null;
}
export interface EngineDeployProgressEvent {
model_id: string;
file_name: string;
percent: number;
stage: string;
}
export interface DownloadProgressEvent {
id: string;
url: string;
@@ -148,6 +162,8 @@ export const api = {
invoke<Message[]>("get_messages", { conversationId }),
engineStart: (modelId: string, params: ChatParams) =>
invoke<EngineStatus>("engine_start", { modelId, params }),
deployModel: (modelId: string, params: ChatParams) =>
invoke<void>("deploy_model", { modelId, params }),
engineStop: () => invoke<void>("engine_stop"),
engineStatus: () => invoke<EngineStatus>("engine_status"),
chatSend: (payload: ChatSendPayload) => invoke<string>("chat_send", { payload }),
+59
View File
@@ -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>
);
}
+150 -83
View File
@@ -10,6 +10,7 @@ import {
onEvent,
} from "../api";
import { useStore } from "../store";
import Icon from "../components/Icon";
interface LocalMessage {
id: string;
@@ -38,8 +39,19 @@ export default function ChatPage() {
const [error, setError] = useState<string | null>(null);
const [params, setParams] = useState<ChatParams>(defaultParams);
const [selectedModelId, setSelectedModelId] = useState<string>("");
const [rightOpen, setRightOpen] = useState(
() => localStorage.getItem("xianren-right-open") !== "0",
);
const bottomRef = useRef<HTMLDivElement>(null);
function toggleRight() {
setRightOpen((v) => {
const next = !v;
localStorage.setItem("xianren-right-open", next ? "1" : "0");
return next;
});
}
useEffect(() => {
if (models.length > 0 && !selectedModelId) {
setSelectedModelId(models[0].id);
@@ -168,6 +180,9 @@ 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],
@@ -206,45 +221,6 @@ export default function ChatPage() {
</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" : ""}
</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 className="flex flex-1 flex-col">
@@ -289,7 +265,121 @@ export default function ChatPage() {
</div>
<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 层数 ngl99=全部)"
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
label="temperature"
value={params.temperature}
@@ -314,47 +404,22 @@ export default function ChatPage() {
step={64}
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 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>
);
}
@@ -375,18 +440,20 @@ function ParamSlider({
onChange: (v: number) => void;
}) {
return (
<label className="flex items-center gap-2">
<span className="w-24">{label}</span>
<label className="mb-3 block">
<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
type="range"
className="w-28 accent-indigo-500"
className="w-full accent-indigo-500"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<span className="w-12 font-mono text-slate-300">{value}</span>
</label>
);
}
+109 -10
View File
@@ -11,6 +11,9 @@ 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 deployProgress = useStore((s) => s.deployProgress);
const [showRemoteForm, setShowRemoteForm] = useState(false);
const [scanning, setScanning] = useState(false);
const [remote, setRemote] = useState({
@@ -82,13 +85,36 @@ export default function ModelsPage() {
await api.openPath(info.models_dir);
}
async function handleDeploy(id: string, params?: Partial<import("../api").ChatParams>) {
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 (
<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>
<h1 className="text-xl font-semibold"></h1>
<p className="mt-1 text-sm text-slate-400">
GGUF 线 API
</p>
</div>
<div className="flex gap-2">
@@ -208,19 +234,34 @@ export default function ModelsPage() {
{m.kind === "remote" ? "-" : fmtSize(m.file_size)}
</td>
<td className="px-4 py-3">
{m.status === "ready" ? (
{m.kind === "remote" ? (
<span className="text-slate-400">-</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
className="text-xs text-slate-400 hover:text-red-400"
onClick={() => handleRemove(m.id, m.file_name)}
>
</button>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{m.kind === "local" ? (
<DeployButton
model={m}
engineRunning={engine?.running ?? false}
deployed={engine?.model === m.file_name}
deployState={deployStates[m.id]}
deployProgress={deployProgress[m.id]}
onDeploy={handleDeploy}
onStop={handleStopEngine}
/>
) : null}
<button
className="text-xs text-slate-400 hover:text-red-400"
onClick={() => handleRemove(m.id, m.file_name)}
>
</button>
</div>
</td>
</tr>
))}
@@ -232,3 +273,61 @@ export default function ModelsPage() {
);
}
function DeployButton({
model,
engineRunning,
deployed,
deployState,
deployProgress,
onDeploy,
onStop,
}: {
model: { id: string; file_name: string };
engineRunning: boolean;
deployed: boolean;
deployState?: string;
deployProgress?: { percent: number; stage: string };
onDeploy: (id: string) => void;
onStop: () => void;
}) {
if (deployed) {
return (
<div className="flex items-center gap-2">
<span className="rounded bg-emerald-500/20 px-2 py-1 text-xs text-emerald-300">
</span>
<button
className="text-xs text-slate-400 hover:text-red-400"
onClick={onStop}
disabled={!engineRunning}
>
</button>
</div>
);
}
if (deployState === "loading") {
const p = deployProgress ?? { percent: 8, stage: "启动引擎进程" };
return (
<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 (
<button
className="btn-secondary !px-3 !py-1 text-xs"
onClick={() => onDeploy(model.id)}
>
{deployState === "error" ? "重试部署" : "部署"}
</button>
);
}
+1 -2
View File
@@ -30,7 +30,7 @@ export default function ServerPage() {
return (
<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">
OpenAI SDK / HTTP
</p>
@@ -91,4 +91,3 @@ export default function ServerPage() {
</div>
);
}
+10 -1
View File
@@ -6,6 +6,10 @@ interface Store {
engine: EngineStatus | null;
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>;
@@ -17,10 +21,15 @@ export const useStore = create<Store>((set) => ({
engine: null,
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() }),
refreshConversations: async () =>
set({ conversations: await api.listConversations() }),
}));