Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f0edbd0a1 | ||
|
|
f6a0b4b89b | ||
|
|
ebe3591445 |
@@ -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
|
||||
|
||||
@@ -62,6 +62,14 @@ pub struct ChatErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct EngineDeployEvent {
|
||||
pub model_id: String,
|
||||
pub file_name: String,
|
||||
pub state: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct DownloadPayload {
|
||||
pub url: String,
|
||||
@@ -196,6 +204,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 +246,16 @@ pub async fn engine_start(
|
||||
};
|
||||
engine.start(cfg).await.map_err(|e| e.to_string())?;
|
||||
*base.write().await = engine.base_url().await;
|
||||
Ok(engine.status().await)
|
||||
let status = engine.status().await;
|
||||
let _ = app.emit("engine://status", status.clone());
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn engine_stop(state: State<'_, App>) -> Result<(), String> {
|
||||
pub async fn engine_stop(app: AppHandle, state: State<'_, App>) -> Result<(), String> {
|
||||
state.engine.stop().await.map_err(|e| e.to_string())?;
|
||||
*state.engine_base.write().await = None;
|
||||
let _ = app.emit("engine://status", state.engine.status().await);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -254,6 +266,117 @@ pub async fn engine_status(
|
||||
Ok(state.engine.status().await)
|
||||
}
|
||||
|
||||
/// 后台部署(加载)本地模型:启动 llama-server 并等待就绪,通过事件广播进度。
|
||||
#[tauri::command]
|
||||
pub async fn deploy_model(
|
||||
app: AppHandle,
|
||||
state: State<'_, App>,
|
||||
model_id: String,
|
||||
params: ChatParams,
|
||||
) -> Result<(), String> {
|
||||
let core = state.core.clone();
|
||||
let engine = state.engine.clone();
|
||||
let base = state.engine_base.clone();
|
||||
|
||||
let (model, engine_bin) = {
|
||||
let db = core.db.lock().unwrap();
|
||||
let model = models_db::get(&db, &model_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "model not found".to_string())?;
|
||||
if model.kind != "local" {
|
||||
return Err("在线 API 模型无需部署".into());
|
||||
}
|
||||
let bin = settings_db::get(&db, "engine_bin")
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or_default();
|
||||
(model, bin)
|
||||
};
|
||||
|
||||
let file_name = model.file_name.clone();
|
||||
let emit_deploy = |state_name: &str, message: Option<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)),
|
||||
};
|
||||
match engine2.start(cfg).await {
|
||||
Ok(()) => {
|
||||
*base2.write().await = engine2.base_url().await;
|
||||
let _ = app2.emit(
|
||||
"engine://deploy",
|
||||
EngineDeployEvent {
|
||||
model_id: model_id2,
|
||||
file_name: file_name2,
|
||||
state: "ready".into(),
|
||||
message: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app2.emit(
|
||||
"engine://deploy",
|
||||
EngineDeployEvent {
|
||||
model_id: model_id2,
|
||||
file_name: file_name2,
|
||||
state: "error".into(),
|
||||
message: Some(e.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = app2.emit("engine://status", engine2.status().await);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_send(
|
||||
app: AppHandle,
|
||||
|
||||
@@ -81,6 +81,7 @@ pub fn run() {
|
||||
commands::engine_start,
|
||||
commands::engine_stop,
|
||||
commands::engine_status,
|
||||
commands::deploy_model,
|
||||
commands::chat_send,
|
||||
commands::download_enqueue,
|
||||
commands::server_start,
|
||||
|
||||
+74
-19
@@ -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, 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,30 @@ 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);
|
||||
|
||||
function toggleCollapsed() {
|
||||
setCollapsed((v) => {
|
||||
const next = !v;
|
||||
localStorage.setItem("xianren-nav-collapsed", next ? "1" : "0");
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshModels();
|
||||
@@ -31,43 +44,73 @@ 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();
|
||||
});
|
||||
return () => {
|
||||
un1.then((f) => f());
|
||||
un2.then((f) => f());
|
||||
un3.then((f) => f());
|
||||
un4.then((f) => f());
|
||||
un5.then((f) => f());
|
||||
};
|
||||
}, [refreshModels, refreshEngine, refreshServer, refreshConversations]);
|
||||
}, [
|
||||
refreshModels,
|
||||
refreshEngine,
|
||||
refreshServer,
|
||||
refreshConversations,
|
||||
setDeployState,
|
||||
]);
|
||||
|
||||
return (
|
||||
<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 +125,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">
|
||||
|
||||
@@ -108,6 +108,13 @@ export interface ChatErrorEvent {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface EngineDeployEvent {
|
||||
model_id: string;
|
||||
file_name: string;
|
||||
state: string;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface DownloadProgressEvent {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -148,6 +155,8 @@ export const api = {
|
||||
invoke<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 }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+132
-83
@@ -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,7 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
const engineRunning = useStore((s) => s.engine?.running ?? false);
|
||||
const engineModel = useStore((s) => s.engine?.model ?? null);
|
||||
const selectedIsRemote = useMemo(
|
||||
() => models.find((m) => m.id === selectedModelId)?.kind === "remote",
|
||||
[models, selectedModelId],
|
||||
@@ -206,45 +219,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 +263,105 @@ 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>
|
||||
)}
|
||||
<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
|
||||
label="temperature"
|
||||
value={params.temperature}
|
||||
@@ -314,47 +386,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 +422,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>
|
||||
);
|
||||
}
|
||||
+96
-10
@@ -11,6 +11,8 @@ function fmtSize(bytes: number) {
|
||||
export default function ModelsPage() {
|
||||
const models = useStore((s) => s.models);
|
||||
const refreshModels = useStore((s) => s.refreshModels);
|
||||
const engine = useStore((s) => s.engine);
|
||||
const deployStates = useStore((s) => s.deployStates);
|
||||
const [showRemoteForm, setShowRemoteForm] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [remote, setRemote] = useState({
|
||||
@@ -82,13 +84,36 @@ export default function ModelsPage() {
|
||||
await api.openPath(info.models_dir);
|
||||
}
|
||||
|
||||
async function handleDeploy(id: string, params?: Partial<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 +233,33 @@ 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]}
|
||||
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 +271,50 @@ export default function ModelsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function DeployButton({
|
||||
model,
|
||||
engineRunning,
|
||||
deployed,
|
||||
deployState,
|
||||
onDeploy,
|
||||
onStop,
|
||||
}: {
|
||||
model: { id: string; file_name: string };
|
||||
engineRunning: boolean;
|
||||
deployed: boolean;
|
||||
deployState?: string;
|
||||
onDeploy: (id: string) => void;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
if (deployed) {
|
||||
return (
|
||||
<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") {
|
||||
return (
|
||||
<button className="btn-secondary !px-3 !py-1 text-xs" disabled>
|
||||
部署中…
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="btn-secondary !px-3 !py-1 text-xs"
|
||||
onClick={() => onDeploy(model.id)}
|
||||
>
|
||||
{deployState === "error" ? "重试部署" : "部署"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-1
@@ -6,6 +6,8 @@ interface Store {
|
||||
engine: EngineStatus | null;
|
||||
server: ServerStatus | null;
|
||||
conversations: Conversation[];
|
||||
deployStates: Record<string, string>;
|
||||
setDeployState: (modelId: string, state: string) => void;
|
||||
refreshModels: () => Promise<void>;
|
||||
refreshEngine: () => Promise<void>;
|
||||
refreshServer: () => Promise<void>;
|
||||
@@ -17,10 +19,12 @@ export const useStore = create<Store>((set) => ({
|
||||
engine: null,
|
||||
server: null,
|
||||
conversations: [],
|
||||
deployStates: {},
|
||||
setDeployState: (modelId, state) =>
|
||||
set((s) => ({ deployStates: { ...s.deployStates, [modelId]: state } })),
|
||||
refreshModels: async () => set({ models: await api.listModels() }),
|
||||
refreshEngine: async () => set({ engine: await api.engineStatus() }),
|
||||
refreshServer: async () => set({ server: await api.serverStatus() }),
|
||||
refreshConversations: async () =>
|
||||
set({ conversations: await api.listConversations() }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user