SPB Git forge

spb/llm-api

Public
0commits 0branches 0releases
0 Bsize
maindefault branch
—last push
7.3 KB · 158 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { usePathname, useRouter } from "next/navigation";5import { useEffect, useState } from "react";6import { api } from "@/lib/api";7import { LiveProvider, useLive } from "@/lib/events";8import { fmtGB } from "@/lib/format";9import { Dot } from "./ui";1011const NAV = [12  { href: "/", label: "Dashboard", icon: "◫" },13  { href: "/models", label: "Models", icon: "▤" },14  { href: "/playground", label: "Playground", icon: "▷" },15  { href: "/downloads", label: "Downloads", icon: "⇩" },16  { href: "/harvester", label: "Harvester", icon: "✦" },17  { href: "/keys", label: "API Keys", icon: "⚿" },18  { href: "/system", label: "System", icon: "◉" },19  { href: "/settings", label: "Settings", icon: "⚙" },20  { href: "/docs", label: "API Docs", icon: "❯" },21];2223type AuthState = { checked: boolean; authenticated: boolean; needsSetup: boolean; email?: string };2425export function Shell({ children }: { children: React.ReactNode }) {26  const path = usePathname();27  const router = useRouter();28  const [auth, setAuth] = useState<AuthState>({ checked: false, authenticated: false, needsSetup: false });29  const isLogin = path === "/login";3031  useEffect(() => {32    let alive = true;33    api.get<{ needs_setup: boolean; authenticated: boolean; principal: { name: string } | null }>("/api/auth/status")34      .then((s) => {35        if (!alive) return;36        setAuth({ checked: true, authenticated: s.authenticated, needsSetup: s.needs_setup, email: s.principal?.name });37        if (!s.authenticated && !isLogin) router.replace(`/login?next=${encodeURIComponent(path)}`);38        if (s.authenticated && isLogin) router.replace("/");39      })40      .catch(() => alive && setAuth({ checked: true, authenticated: false, needsSetup: false }));41    return () => { alive = false; };42    // eslint-disable-next-line react-hooks/exhaustive-deps43  }, [path]);4445  if (isLogin) return <LiveProvider enabled={false}>{children}</LiveProvider>;46  if (!auth.checked || !auth.authenticated) {47    return <div className="min-h-screen grid place-items-center text-ink-3 text-sm">Loading…</div>;48  }49  return (50    <LiveProvider enabled>51      <div className="min-h-screen md:grid md:grid-cols-[232px_1fr]">52        <Sidebar path={path} email={auth.email} />53        <main className="min-w-0 px-4 py-5 md:px-8 md:py-7 pb-24 md:pb-8 max-w-[1400px]">{children}</main>54        <MobileNav path={path} />55      </div>56    </LiveProvider>57  );58}5960function Sidebar({ path, email }: { path: string; email?: string }) {61  const live = useLive();62  const router = useRouter();63  const cur = live.manager?.loaded.find((w) => w.status === "ready");64  const loading = live.manager && Object.values(live.manager.progress).length > 0;65  return (66    <aside className="hidden md:flex flex-col border-r border-border bg-surface/60 sticky top-0 h-screen">67      <div className="px-5 pt-5 pb-4 flex items-center gap-2.5">68        <div className="h-7 w-7 rounded-lg bg-accent grid place-items-center text-white font-bold text-sm">λ</div>69        <div>70          <div className="font-semibold tracking-tight leading-none">LLM API</div>71          <div className="text-[11px] text-ink-3 mt-0.5">private inference</div>72        </div>73      </div>74      <nav className="px-3 flex flex-col gap-0.5">75        {NAV.map((n) => {76          const active = n.href === "/" ? path === "/" : path.startsWith(n.href);77          return (78            <Link key={n.href} href={n.href}79              className={`flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg text-[13px] transition-colors ${active ? "bg-surface-3 text-ink" : "text-ink-2 hover:bg-surface-2 hover:text-ink"}`}>80              <span className="w-4 text-center text-ink-3">{n.icon}</span>{n.label}81            </Link>82          );83        })}84      </nav>85      <div className="mt-auto px-4 pb-4 flex flex-col gap-3">86        <div className="card p-3 text-xs">87          <div className="flex items-center justify-between mb-1.5">88            <span className="label">Current model</span>89            <span className={`flex items-center gap-1 text-[11px] ${live.connected ? "text-good" : "text-ink-3"}`}><Dot className={live.connected ? "bg-good" : "bg-ink-3"} />{live.connected ? "live" : "offline"}</span>90          </div>91          {cur ? (92            <>93              <div className="font-medium truncate" title={cur.model_id}>{cur.model_id}</div>94              <div className="text-ink-3 mt-0.5 num">{fmtGB(cur.measured_gb || cur.estimate_gb)} · {cur.runtime} · ctx {Math.round(cur.context / 1024)}K</div>95            </>96          ) : loading ? (97            <div className="text-accent pulse">Loading model…</div>98          ) : (99            <div className="text-ink-3">No model loaded</div>100          )}101          {live.metrics && (102            <div className="mt-2 text-ink-3 num">RAM {live.metrics.mem_used_gb.toFixed(1)} / {live.metrics.mem_total_gb.toFixed(0)} GB · GPU {live.metrics.gpu_percent ?? "—"}%</div>103          )}104        </div>105        <div className="flex items-center justify-between text-xs text-ink-3 px-1">106          <span className="truncate" title={email}>{email}</span>107          <button className="btn btn-ghost btn-sm" onClick={async () => { await api.post("/api/auth/logout"); router.replace("/login"); }}>Sign out</button>108        </div>109      </div>110    </aside>111  );112}113114function MobileNav({ path }: { path: string }) {115  const items = NAV.slice(0, 5);116  return (117    <nav className="md:hidden fixed bottom-0 inset-x-0 z-40 bg-surface/95 backdrop-blur border-t border-border grid grid-cols-6 pb-[env(safe-area-inset-bottom)]">118      {items.map((n) => {119        const active = n.href === "/" ? path === "/" : path.startsWith(n.href);120        return (121          <Link key={n.href} href={n.href} className={`flex flex-col items-center gap-0.5 py-2 text-[10px] ${active ? "text-ink" : "text-ink-3"}`}>122            <span className="text-base leading-none">{n.icon}</span>{n.label}123          </Link>124        );125      })}126      <MoreMenu path={path} />127    </nav>128  );129}130131function MoreMenu({ path }: { path: string }) {132  const [open, setOpen] = useState(false);133  const rest = NAV.slice(5);134  const active = rest.some((n) => path.startsWith(n.href));135  return (136    <>137      <button onClick={() => setOpen((o) => !o)} className={`flex flex-col items-center gap-0.5 py-2 text-[10px] ${active ? "text-ink" : "text-ink-3"}`}>138        <span className="text-base leading-none">⋯</span>More139      </button>140      {open && (141        <div className="fixed inset-0 z-50 bg-black/50" onClick={() => setOpen(false)}>142          <div className="absolute bottom-0 inset-x-0 card rounded-b-none p-2 pb-[calc(env(safe-area-inset-bottom)+8px)]" onClick={(e) => e.stopPropagation()}>143            {rest.map((n) => (144              <Link key={n.href} href={n.href} onClick={() => setOpen(false)} className="flex items-center gap-3 px-3 py-3 text-sm rounded-lg hover:bg-surface-2">145                <span className="w-5 text-center text-ink-3">{n.icon}</span>{n.label}146              </Link>147            ))}148            <button className="flex items-center gap-3 px-3 py-3 text-sm rounded-lg hover:bg-surface-2 w-full text-left text-ink-2"149              onClick={async () => { await api.post("/api/auth/logout"); location.href = "/login"; }}>150              <span className="w-5 text-center text-ink-3">⏻</span>Sign out151            </button>152          </div>153        </div>154      )}155    </>156  );157}158