feat: collapsible nav sidebar and chat param panel, rename to service management
This commit is contained in:
+59
-16
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Route, Routes } from "react-router-dom";
|
||||
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,20 +10,31 @@ import ServerPage from "./pages/ServerPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/chat", label: "对话" },
|
||||
{ to: "/", label: "模型管理", end: true },
|
||||
{ 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();
|
||||
refreshEngine();
|
||||
@@ -53,33 +65,52 @@ export default function App() {
|
||||
|
||||
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>
|
||||
{!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>
|
||||
@@ -94,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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -275,8 +287,19 @@ export default function ChatPage() {
|
||||
</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"
|
||||
@@ -365,6 +388,20 @@ export default function ChatPage() {
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user