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