SPB Git forge

spb/llm-api

Public
0commits 0branches 0releases
0 Bsize
maindefault branch
—last push
9.0 KB · 135 lines tsx
Raw Blame History
1"use client";23import { useEffect, useState } from "react";4import { api, ApiError } from "@/lib/api";5import { useLive } from "@/lib/events";6import type { Model } from "@/lib/types";7import { PageHeader, Toggle, useToast } from "@/components/ui";89type S = Record<string, string | number | boolean | null>;1011export default function SettingsPage() {12  const live = useLive();13  const toast = useToast();14  const [s, setS] = useState<S | null>(null);15  const [defaults, setDefaults] = useState<S>({});16  const [paths, setPaths] = useState<Record<string, string>>({});17  const [hfToken, setHfToken] = useState(false);18  const [models, setModels] = useState<Model[]>([]);19  const [aliases, setAliases] = useState<Record<string, string>>({});20  const [alias, setAlias] = useState("");21  const [aliasModel, setAliasModel] = useState("");22  const [pw, setPw] = useState({ current: "", next: "", confirm: "" });23  const [dirty, setDirty] = useState<S>({});2425  const load = () => {26    api.get<{ settings: S; defaults: S; paths: Record<string, string>; hf_token_set: boolean }>("/api/settings").then((r) => { setS(r.settings); setDefaults(r.defaults); setPaths(r.paths); setHfToken(r.hf_token_set); });27    api.get<{ models: Model[]; aliases: Record<string, string> }>("/api/models").then((r) => { setModels(r.models); setAliases(r.aliases); });28  };29  useEffect(() => { load(); }, [live.version]);3031  const set = (k: string, v: string | number | boolean | null) => { setS((o) => ({ ...(o || {}), [k]: v })); setDirty((d) => ({ ...d, [k]: v })); };32  const save = async () => {33    try {34      await api.patch("/api/settings", dirty);35      toast.push("Settings saved", "good");36      setDirty({});37      load();38    } catch (e) {39      toast.push(e instanceof ApiError ? e.message : "Failed", "bad");40    }41  };42  const addAlias = async () => {43    try {44      await api.put("/api/aliases", { alias: alias.trim(), model_id: aliasModel });45      setAlias("");46      load();47    } catch (e) {48      toast.push(e instanceof ApiError ? e.message : "Failed", "bad");49    }50  };51  const changePw = async (e: React.FormEvent) => {52    e.preventDefault();53    if (pw.next !== pw.confirm) return toast.push("Passwords do not match", "bad");54    try {55      await api.post("/api/auth/password", { current_password: pw.current, new_password: pw.next });56      toast.push("Password changed", "good");57      setPw({ current: "", next: "", confirm: "" });58    } catch (err) {59      toast.push(err instanceof ApiError ? err.message : "Failed", "bad");60    }61  };62  if (!s) return <div className="text-ink-3 text-sm">Loading…</div>;63  const textModels = models.filter((m) => m.installed && !m.embedding && !m.reranker);64  const Num = ({ k, label, hint, step = 1 }: { k: string; label: string; hint?: string; step?: number }) => (65    <label className="flex flex-col gap-1 text-sm"><span className="label">{label}</span>66      <input className="input" type="number" step={step} value={s[k] as number ?? ""} onChange={(e) => set(k, e.target.value === "" ? null : Number(e.target.value))} />67      {hint && <span className="text-xs text-ink-3">{hint} · default {String(defaults[k])}</span>}</label>68  );69  const Sel = ({ k, label, hint }: { k: string; label: string; hint?: string }) => (70    <label className="flex flex-col gap-1 text-sm"><span className="label">{label}</span>71      <select className="input" value={(s[k] as string) || ""} onChange={(e) => set(k, e.target.value || null)}>72        <option value="">none</option>{textModels.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}</select>73      {hint && <span className="text-xs text-ink-3">{hint}</span>}</label>74  );7576  return (77    <div>78      {toast.view}79      <PageHeader title="Settings" actions={<button className="btn btn-primary" disabled={!Object.keys(dirty).length} onClick={save}>Save changes</button>} />80      <div className="grid lg:grid-cols-2 gap-4">81        <div className="card p-4 flex flex-col gap-4">82          <div className="font-medium text-sm">Memory policy</div>83          <Num k="max_model_memory_gb" label="Max model memory (GB)" hint="Safe budget for weights + KV cache + runtime. Models above it are refused." step={0.5} />84          <Num k="absolute_max_memory_gb" label="Absolute limit (GB)" hint="Hard ceiling even with force-load." step={0.5} />85          <Num k="max_simultaneous_models" label="Simultaneous large models" hint="Small embedding/reranker models can stay resident alongside." />86          <Num k="model_idle_timeout_minutes" label="Idle unload (minutes)" hint="0 disables. Pinned models are never idle-unloaded." />87          <Num k="default_context" label="Default context (tokens)" hint="Used when a model has no recommended context." />88          <Num k="default_max_tokens" label="Default max_tokens" hint="When a request does not specify one." />89        </div>90        <div className="card p-4 flex flex-col gap-4">91          <div className="font-medium text-sm">Models & runtimes</div>92          <Sel k="default_model" label="Default model" hint="Used when a request omits `model`." />93          <Sel k="preload_model" label="Preload at startup" hint="Default none — conserve RAM." />94          <div className="flex flex-col gap-3 pt-1">95            <Toggle checked={!!s.allow_mlx} onChange={(v) => set("allow_mlx", v)} label="Allow MLX models" />96            <Toggle checked={!!s.allow_gguf} onChange={(v) => set("allow_gguf", v)} label="Allow GGUF (llama.cpp) models" />97            <Toggle checked={!!s.allow_downloads} onChange={(v) => set("allow_downloads", v)} label="Allow downloads from Hugging Face" />98            <Toggle checked={!!s.log_prompts} onChange={(v) => set("log_prompts", v)} label="Log prompts and completions (privacy: off by default)" />99          </div>100          <Num k="min_free_disk_gb" label="Minimum free disk (GB)" hint="Downloads that would go below this are refused." />101          <div className="text-xs text-ink-3">Hugging Face token: {hfToken ? <span className="text-good">configured (HF_TOKEN)</span> : <span>not set — gated repos will fail. Set HF_TOKEN in .env.</span>}</div>102        </div>103        <div className="card p-4">104          <div className="font-medium text-sm mb-3">Model aliases</div>105          <p className="text-xs text-ink-3 mb-3">Clients can request <code className="mono">fast</code>, <code className="mono">coder</code>, <code className="mono">reasoning</code>, <code className="mono">vision</code>, <code className="mono">embedding</code>, <code className="mono">default</code>… <code className="mono">auto</code> picks a model from the prompt (code, images, length) using these aliases.</p>106          <div className="flex flex-col gap-1.5 mb-3">107            {Object.entries(aliases).map(([a, mid]) => (108              <div key={a} className="flex items-center gap-2 text-sm"><span className="mono text-xs bg-surface-2 px-1.5 py-0.5 rounded text-accent w-28 truncate">{a}</span><span className="text-ink-3">→</span><span className="truncate flex-1">{mid}</span><button className="btn btn-ghost btn-sm" onClick={() => api.del(`/api/aliases/${a}`).then(load)}>✕</button></div>109            ))}110            {!Object.keys(aliases).length && <div className="text-xs text-ink-3">No aliases yet.</div>}111          </div>112          <div className="flex gap-2">113            <input className="input w-32" placeholder="alias" value={alias} onChange={(e) => setAlias(e.target.value)} list="alias-suggest" />114            <datalist id="alias-suggest">{["default", "fast", "coder", "reasoning", "vision", "embedding", "reranker"].map((a) => <option key={a} value={a} />)}</datalist>115            <select className="input flex-1" value={aliasModel} onChange={(e) => setAliasModel(e.target.value)}><option value="">model…</option>{models.filter((m) => m.installed).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}</select>116            <button className="btn" disabled={!alias.trim() || !aliasModel} onClick={addAlias}>Add</button>117          </div>118        </div>119        <div className="card p-4 flex flex-col gap-4">120          <div className="font-medium text-sm">Account & paths</div>121          <form onSubmit={changePw} className="flex flex-col gap-2">122            <input className="input" type="password" placeholder="Current password" autoComplete="current-password" value={pw.current} onChange={(e) => setPw({ ...pw, current: e.target.value })} required />123            <input className="input" type="password" placeholder="New password (min 10)" autoComplete="new-password" value={pw.next} onChange={(e) => setPw({ ...pw, next: e.target.value })} required minLength={10} />124            <input className="input" type="password" placeholder="Confirm new password" autoComplete="new-password" value={pw.confirm} onChange={(e) => setPw({ ...pw, confirm: e.target.value })} required />125            <button className="btn self-start">Change password</button>126          </form>127          <div className="text-xs text-ink-3 flex flex-col gap-1 mono">128            {Object.entries(paths).map(([k, v]) => <div key={k}><span className="text-ink-2">{k}:</span> {v}</div>)}129          </div>130        </div>131      </div>132    </div>133  );134}135