SPB Git
15.0 KB · 141 lines tsx
1"use client";2import { useEffect, useState, type FormEvent, type ReactNode } from "react";3import useSWR from "swr";4import { Monitor, Moon, Sun, LogOut, ShieldCheck, Smartphone, Download } from "lucide-react";5import { api, fetcher, relTime } from "@/lib/client/fetcher";6import { usePrefs, setPrefs, type Prefs } from "@/lib/client/prefs";7import { useModels } from "@/lib/client/use-models";8import { Badge, Button, Input, Toggle, Spinner, cn, toast } from "../ui";910export function Section({ id, title, hint, children }: { id: string; title: string; hint?: string; children: ReactNode }) {11  return <section id={id} className="scroll-mt-6"><h2 className="text-[15px] font-semibold">{title}</h2>{hint && <p className="mb-3 mt-0.5 text-[13px] text-muted">{hint}</p>}<div className={cn("card p-4 space-y-3", !hint && "mt-3")}>{children}</div></section>;12}1314export function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {15  return <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border py-2.5 last:border-0 last:pb-0 first:pt-0"><span className="min-w-0"><span className="block text-[13px] font-medium">{label}</span>{hint && <span className="block text-[12px] text-muted">{hint}</span>}</span><span className="shrink-0">{children}</span></div>;16}1718// ---------------------------------------------------------------- Apparence19export function AppearanceSection() {20  const prefs = usePrefs();21  const themes: { v: Prefs["theme"]; l: string; I: typeof Sun }[] = [{ v: "dark", l: "Sombre", I: Moon }, { v: "light", l: "Clair", I: Sun }, { v: "system", l: "Système", I: Monitor }];22  return (23    <Section id="apparence" title="Apparence" hint="Préférences d'affichage propres à cet appareil.">24      <Row label="Thème" hint="Sombre par défaut ; « Système » suit l'apparence du système."><div className="inline-flex rounded-lg border border-border bg-surface p-0.5">{themes.map(t => <button key={t.v} onClick={() => setPrefs({ theme: t.v })} className={cn("inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px]", prefs.theme === t.v ? "bg-surface-2 text-fg" : "text-muted hover:text-fg")}><t.I className="h-3.5 w-3.5" />{t.l}</button>)}</div></Row>25      <Row label="Taille du texte"><select value={prefs.fontSize} onChange={e => setPrefs({ fontSize: e.target.value as Prefs["fontSize"] })} className="input !w-auto !py-1 text-[13px]"><option value="sm">Petite</option><option value="md">Normale</option><option value="lg">Grande</option></select></Row>26      <Row label="Entrée envoie le message" hint="Sinon ⌘/Ctrl + Entrée ; Maj + Entrée insère toujours un saut de ligne."><Toggle checked={prefs.sendWithEnter} onChange={v => setPrefs({ sendWithEnter: v })} /></Row>27      <Row label="Afficher le raisonnement" hint="Résumé de la réflexion des modèles qui le fournissent."><Toggle checked={prefs.showReasoning} onChange={v => setPrefs({ showReasoning: v })} /></Row>28    </Section>29  );30}3132// ---------------------------------------------------------------- Modèles & routage33export function ModelsSection({ routing, onSave }: { routing: { defaultMode?: string }; onSave: (key: string, value: unknown) => Promise<void> }) {34  const prefs = usePrefs(); const { chat, defaultModel, mutate } = useModels();35  const [saving, setSaving] = useState(false);36  async function setDefault(id: string) { setSaving(true); try { await api("/api/models", { method: "PATCH", json: { id, default: true } }); await mutate(); toast("Modèle par défaut enregistré", "ok"); } finally { setSaving(false); } }37  const modes = [{ v: "manual", l: "Manuel" }, { v: "auto", l: "Automatique" }, { v: "fast", l: "Rapide" }, { v: "quality", l: "Qualité" }, { v: "economy", l: "Économie" }];38  return (39    <Section id="modeles" title="Modèles" hint="Le modèle par défaut est utilisé pour toute nouvelle conversation sans choix explicite.">40      <Row label="Modèle par défaut" hint="Seuls les modèles vérifiés par requête réelle sont proposés.">41        <span className="inline-flex items-center gap-2">{saving && <Spinner />}<select value={defaultModel ?? ""} onChange={e => e.target.value && setDefault(e.target.value)} className="input !w-auto max-w-[60vw] !py-1 text-[13px]"><option value="">— aucun —</option>{chat.map(m => <option key={m.id} value={m.id}>{m.displayName} ({m.provider})</option>)}</select></span>42      </Row>43      <Row label="Mode de routage par défaut" hint="Manuel = tu choisis ; les autres modes sélectionnent selon le benchmark, le prix et la latence.">44        <select value={routing.defaultMode ?? prefs.routingMode} onChange={async e => { const v = e.target.value as Prefs["routingMode"]; setPrefs({ routingMode: v }); await onSave("routing", { ...routing, defaultMode: v }); }} className="input !w-auto !py-1 text-[13px]">{modes.map(m => <option key={m.v} value={m.v}>{m.l}</option>)}</select>45      </Row>46      <div className="text-[12px] text-muted">Détails, favoris, masquage et résultats de benchmark : <a href="/models" className="text-accent hover:underline">Laboratoire de modèles</a>.</div>47    </Section>48  );49}5051// ---------------------------------------------------------------- Dépenses52export interface Spending { dailyLimitUsd: number; monthlyLimitUsd: number; alertAtPercent: number }53export function SpendingSection({ value, onSave }: { value: Spending; onSave: (key: string, value: unknown) => Promise<void> }) {54  const [v, setV] = useState<Spending>(value); const [busy, setBusy] = useState(false);55  useEffect(() => setV(value), [value]);56  const num = (k: keyof Spending) => (e: React.ChangeEvent<HTMLInputElement>) => setV(x => ({ ...x, [k]: Number(e.target.value) }));57  return (58    <Section id="depenses" title="Dépenses" hint="Plafonds locaux calculés à partir des jetons mesurés × tarifs vérifiés. Une fois atteints, l'application refuse de lancer de nouvelles requêtes.">59      <div className="grid gap-3 sm:grid-cols-3">60        <label className="block"><span className="mb-1 block text-xs font-medium text-fg-2">Limite quotidienne ($)</span><Input type="number" min={0} step={1} value={v.dailyLimitUsd} onChange={num("dailyLimitUsd")} /></label>61        <label className="block"><span className="mb-1 block text-xs font-medium text-fg-2">Limite mensuelle ($)</span><Input type="number" min={0} step={5} value={v.monthlyLimitUsd} onChange={num("monthlyLimitUsd")} /></label>62        <label className="block"><span className="mb-1 block text-xs font-medium text-fg-2">Alerte à (%)</span><Input type="number" min={1} max={100} value={v.alertAtPercent} onChange={num("alertAtPercent")} /></label>63      </div>64      <p className="text-[12px] text-muted">Mettre 0 désactive la limite. Ce suivi est une estimation locale : la facturation des fournisseurs peut inclure d'autres usages (autres applications, Claude Code, etc.). Détails dans <a href="/usage" className="text-accent hover:underline">Coûts & usage</a>.</p>65      <div className="flex justify-end"><Button variant="primary" loading={busy} onClick={async () => { setBusy(true); try { await onSave("spending", v); } finally { setBusy(false); } }}>Enregistrer</Button></div>66    </Section>67  );68}6970// ---------------------------------------------------------------- Outils & intégrations71export interface ToolInfo { name: string; description: string; permission: string; category: string }72export function ToolsSection({ tools }: { tools: ToolInfo[] }) {73  return (74    <Section id="outils" title="Outils" hint="Outils que les modèles peuvent appeler pendant une conversation. Chacun a un schéma strict et des limites d'exécution.">75      <div className="space-y-2">{tools.map(t => <div key={t.name} className="flex items-start gap-3 border-b border-border pb-2.5 last:border-0 last:pb-0"><code className="mt-0.5 shrink-0 rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[12px]">{t.name}</code><span className="min-w-0 flex-1 text-[12.5px] text-fg-2">{t.description}</span><Badge tone={t.permission === "auto" ? "neutral" : "warn"}>{t.permission === "auto" ? "auto" : "approbation"}</Badge></div>)}</div>76      <p className="text-[12px] text-muted">L'outil Python s'exécute dans un bac à sable (sandbox-exec, sans réseau, 20 s, répertoire temporaire isolé). Aucun outil n'a d'accès shell au cluster. Les permissions par projet se règlent dans chaque projet.</p>77    </Section>78  );79}8081export function IntegrationsSection({ integrations }: { integrations: { openai: boolean; anthropic: boolean; tavily: boolean } }) {82  const items = [{ k: "openai", l: "OpenAI", d: "Modèles GPT / o-series, images, voix, embeddings" }, { k: "anthropic", l: "Anthropic", d: "Modèles Claude" }, { k: "tavily", l: "Tavily", d: "Recherche web et extraction de pages" }] as const;83  return (84    <Section id="integrations" title="Intégrations" hint="Les clés API vivent uniquement dans l'environnement du serveur ; elles ne sont jamais affichées ni transmises au navigateur.">85      {items.map(i => <Row key={i.k} label={i.l} hint={i.d}><span className="inline-flex items-center gap-2 text-[13px]"><span className={cn("h-2.5 w-2.5 rounded-full", integrations[i.k] ? "bg-ok" : "bg-muted/50")} />{integrations[i.k] ? "configurée" : "absente"}</span></Row>)}86      <Row label="MacLustr local" hint="Inférence locale sur le cluster — intégration future"><span className="inline-flex items-center gap-2 text-[13px] text-muted"><span className="h-2.5 w-2.5 rounded-full bg-muted/50" />à venir</span></Row>87    </Section>88  );89}9091// ---------------------------------------------------------------- Sécurité92interface SessionInfo { id: string; createdAt: string; expiresAt: string; lastSeen: string; userAgent: string | null; ip: string | null; current: boolean }93export function SecuritySection({ owner }: { owner: { username: string; createdAt: string; totp: boolean } | null }) {94  const { data, mutate } = useSWR<{ sessions: SessionInfo[] }>("/api/auth/sessions", fetcher);95  const [cur, setCur] = useState(""); const [next, setNext] = useState(""); const [confirm2, setConfirm2] = useState(""); const [busy, setBusy] = useState(false);96  async function changePw(e: FormEvent) {97    e.preventDefault(); if (next !== confirm2) { toast("Les mots de passe ne correspondent pas", "error"); return; }98    setBusy(true);99    try { const r = await api<{ note?: string }>("/api/settings", { method: "PATCH", json: { password: { current: cur, next } } }); toast(r.note ?? "Mot de passe changé", "ok"); setCur(""); setNext(""); setConfirm2(""); void mutate(); }100    catch (err) { toast((err as Error).message, "error"); } finally { setBusy(false); }101  }102  async function revoke(id?: string) { await api("/api/auth/sessions", { method: "DELETE", json: id ? { id } : { all: true } }); toast(id ? "Session révoquée" : "Autres sessions révoquées", "ok"); void mutate(); }103  async function logout() { await api("/api/auth/logout", { method: "POST" }); navigator.serviceWorker?.controller?.postMessage("logout"); location.href = "/login"; }104  const ua = (s: string | null) => { if (!s) return "appareil inconnu"; const m = /(iPhone|iPad|Android|Macintosh|Windows|Linux)/.exec(s)?.[1] ?? "navigateur"; const b = /(Safari|Chrome|Firefox|Edg)/.exec(s)?.[1] ?? ""; return `${m}${b ? ` · ${b}` : ""}`; };105  return (106    <Section id="securite" title="Sécurité" hint="Compte unique du propriétaire. Mot de passe haché en Argon2id ; tentatives de connexion limitées ; cookies de session HttpOnly et SameSite=Strict.">107      <Row label="Propriétaire" hint={owner ? `créé ${relTime(owner.createdAt)}` : undefined}><span className="text-[13px] font-medium">{owner?.username ?? "—"}</span></Row>108      <Row label="Authentification à deux facteurs" hint="Optionnelle ; non configurée pour l'instant."><Badge tone={owner?.totp ? "ok" : "neutral"}>{owner?.totp ? "activée" : "désactivée"}</Badge></Row>109      <form onSubmit={changePw} className="space-y-2 border-t border-border pt-3">110        <div className="text-[13px] font-medium">Changer le mot de passe</div>111        <div className="grid gap-2 sm:grid-cols-3">112          <Input type="password" autoComplete="current-password" placeholder="Mot de passe actuel" value={cur} onChange={e => setCur(e.target.value)} required />113          <Input type="password" autoComplete="new-password" placeholder="Nouveau (min. 12 caractères)" minLength={12} value={next} onChange={e => setNext(e.target.value)} required />114          <Input type="password" autoComplete="new-password" placeholder="Confirmer" minLength={12} value={confirm2} onChange={e => setConfirm2(e.target.value)} required />115        </div>116        <div className="flex items-center justify-between gap-3"><span className="text-[12px] text-muted">Toutes les autres sessions seront révoquées.</span><Button type="submit" variant="primary" loading={busy} disabled={next.length < 12}><ShieldCheck className="h-4 w-4" /> Changer</Button></div>117      </form>118      <div className="border-t border-border pt-3">119        <div className="mb-2 flex items-center justify-between"><span className="text-[13px] font-medium">Sessions actives</span>{(data?.sessions.length ?? 0) > 1 && <Button size="sm" variant="outline" onClick={() => revoke()}>Révoquer les autres</Button>}</div>120        {!data && <Spinner />}121        <div className="space-y-1.5">{data?.sessions.map(s => (122          <div key={s.id} className="flex items-center gap-3 rounded-lg border border-border bg-surface px-3 py-2 text-[13px]">123            <Smartphone className="h-4 w-4 shrink-0 text-muted" />124            <span className="min-w-0 flex-1"><span className="block truncate">{ua(s.userAgent)}{s.current && <Badge tone="accent" className="ml-2">cette session</Badge>}</span><span className="block text-[12px] text-muted">{s.ip ?? "IP inconnue"} · vue {relTime(s.lastSeen)} · expire {new Date(s.expiresAt).toLocaleDateString("fr-CA")}</span></span>125            {!s.current && <Button size="sm" variant="ghost" onClick={() => revoke(s.id)}>Révoquer</Button>}126          </div>))}</div>127      </div>128      <div className="flex justify-end border-t border-border pt-3"><Button variant="danger" onClick={logout}><LogOut className="h-4 w-4" /> Se déconnecter</Button></div>129    </Section>130  );131}132133// ---------------------------------------------------------------- PWA134interface BeforeInstallPromptEvent extends Event { prompt(): Promise<void>; userChoice: Promise<{ outcome: "accepted" | "dismissed" }> }135export function InstallHint() {136  const [evt, setEvt] = useState<BeforeInstallPromptEvent | null>(null);137  useEffect(() => { const h = (e: Event) => { e.preventDefault(); setEvt(e as BeforeInstallPromptEvent); }; window.addEventListener("beforeinstallprompt", h); return () => window.removeEventListener("beforeinstallprompt", h); }, []);138  if (!evt) return null;139  return <div className="flex flex-wrap items-center gap-3 rounded-xl border border-accent/30 bg-accent/10 px-4 py-3 text-[13px]"><Download className="h-4 w-4 text-accent" /><span className="flex-1">Installe Chat SPBoucher AI comme application (écran d'accueil, plein écran, démarrage rapide).</span><Button size="sm" variant="primary" onClick={async () => { await evt.prompt(); const c = await evt.userChoice; if (c.outcome === "accepted") setEvt(null); }}>Installer</Button></div>;140}141
Files · main
Menu

Theme