feat: collapsible nav sidebar and chat param panel, rename to service management

This commit is contained in:
Xianren Studio
2026-08-13 16:37:22 +08:00
parent f6a0b4b89b
commit 7f0edbd0a1
4 changed files with 240 additions and 102 deletions
+60 -17
View File
@@ -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, 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,20 +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);
function toggleCollapsed() {
setCollapsed((v) => {
const next = !v;
localStorage.setItem("xianren-nav-collapsed", next ? "1" : "0");
return next;
});
}
useEffect(() => { useEffect(() => {
refreshModels(); refreshModels();
refreshEngine(); refreshEngine();
@@ -53,33 +65,52 @@ export default function App() {
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 +125,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">
+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>
);
}
+120 -83
View File
@@ -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);
@@ -275,96 +287,121 @@ export default function ChatPage() {
</div> </div>
</div> </div>
{/* 右侧边栏:当前模型 + 部署/请求参数 */} {/* 右侧边栏:当前模型 + 部署/请求参数(可收起) */}
<div className="flex w-72 flex-col overflow-y-auto border-l border-border bg-panel p-3"> {rightOpen ? (
<div className="mb-1 text-xs text-slate-400"></div> <div className="flex w-72 flex-col overflow-y-auto border-l border-border bg-panel p-3">
<select <div className="mb-2 flex items-center justify-between">
className="input w-full" <span className="text-xs uppercase text-slate-400"></span>
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 <button
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60" className="rounded p-1 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
onClick={() => onClick={toggleRight}
api.engineStart(selectedModelId, params).catch((e) => alert(String(e))) title="收起参数面板"
}
disabled={engineRunning}
> >
<Icon name="chevron-right" />
</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> </button>
</div> </div>
)} <div className="mb-1 text-xs text-slate-400"></div>
<div className="mt-1 truncate text-xs text-slate-500">{engineLabel}</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="mt-4 border-t border-border pt-3">
<div className="mb-3 text-xs uppercase text-slate-400"></div> <div className="mb-3 text-xs uppercase text-slate-400"></div>
<ParamSlider <ParamSlider
label="上下文长度 ctx_size" label="上下文长度 ctx_size"
value={params.ctx_size} value={params.ctx_size}
min={512} min={512}
max={32768} max={32768}
step={512} step={512}
onChange={(v) => setParams({ ...params, ctx_size: v })} onChange={(v) => setParams({ ...params, ctx_size: v })}
/> />
<ParamSlider <ParamSlider
label="GPU 层数 ngl99=全部)" label="GPU 层数 ngl99=全部)"
value={params.ngl} value={params.ngl}
min={0} min={0}
max={99} max={99}
step={1} step={1}
onChange={(v) => setParams({ ...params, ngl: v })} onChange={(v) => setParams({ ...params, ngl: v })}
/> />
</div> </div>
<div className="mt-4 border-t border-border pt-3"> <div className="mt-4 border-t border-border pt-3">
<div className="mb-3 text-xs uppercase text-slate-400"></div> <div className="mb-3 text-xs uppercase text-slate-400"></div>
<ParamSlider <ParamSlider
label="temperature" label="temperature"
value={params.temperature} value={params.temperature}
min={0} min={0}
max={2} max={2}
step={0.05} step={0.05}
onChange={(v) => setParams({ ...params, temperature: v })} onChange={(v) => setParams({ ...params, temperature: v })}
/> />
<ParamSlider <ParamSlider
label="top_p" label="top_p"
value={params.top_p} value={params.top_p}
min={0} min={0}
max={1} max={1}
step={0.05} step={0.05}
onChange={(v) => setParams({ ...params, top_p: v })} onChange={(v) => setParams({ ...params, top_p: v })}
/> />
<ParamSlider <ParamSlider
label="max_tokens" label="max_tokens"
value={params.max_tokens} value={params.max_tokens}
min={64} min={64}
max={8192} max={8192}
step={64} step={64}
onChange={(v) => setParams({ ...params, max_tokens: v })} onChange={(v) => setParams({ ...params, max_tokens: v })}
/> />
</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>
); );
} }
+1 -2
View File
@@ -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>
); );
} }