SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
20.5 KB · 356 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Eye, EyeOff, Plus, Trash2, Activity, ClipboardPaste, AlertTriangle, CheckCircle2, XCircle, Info } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Input, Field } from "@/components/ui/input";7import { Switch } from "@/components/ui/switch";8import { Badge } from "@/components/ui/badge";9import { toast } from "@/components/ui/toast";10import { api } from "@/lib/client/api";11import { errorMessage } from "@/lib/client/humanize";12import { formatMs, cn } from "@/lib/utils";13import type { PublicEndpoint, ManualModel } from "@/lib/client/types";14import { ENDPOINT_PRESETS } from "./presets";1516interface HeaderRow {17  key: string;18  value: string;19}2021interface FormState {22  name: string;23  baseUrl: string;24  apiKey: string;25  clearKey: boolean;26  headers: HeaderRow[];27  discover: boolean;28  modelsPath: string;29  manualModels: ManualModel[];30}3132const EMPTY: FormState = { name: "", baseUrl: "", apiKey: "", clearKey: false, headers: [], discover: true, modelsPath: "/models", manualModels: [] };3334function fromEndpoint(e: PublicEndpoint): FormState {35  return { name: e.name, baseUrl: e.baseUrl, apiKey: "", clearKey: false, headers: e.headerNames.map((k) => ({ key: k, value: "" })), discover: Boolean(e.modelsPath), modelsPath: e.modelsPath || "/models", manualModels: e.manualModels.map((m) => ({ ...m })) };36}3738/** Client-side hint only — the server runs the authoritative SSRF check. */39export function looksPrivate(raw: string): boolean {40  try {41    const u = new URL(raw);42    const h = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();43    return h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal") || h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || /^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h) || !h.includes(".");44  } catch {45    return false;46  }47}4849export interface TestOutcome {50  ok: boolean;51  latencyMs: number;52  modelsAvailable: number;53  error?: string;54  models?: { id: string; displayName: string }[];55}5657export function EndpointSheet({ open, onOpenChange, endpoint, allowPrivate, onSaved }: { open: boolean; onOpenChange: (v: boolean) => void; endpoint: PublicEndpoint | null; allowPrivate: boolean; onSaved: (e: PublicEndpoint, test?: TestOutcome) => void }) {58  const editing = Boolean(endpoint);59  const [form, setForm] = React.useState<FormState>(EMPTY);60  const [preset, setPreset] = React.useState<string>("other");61  const [showKey, setShowKey] = React.useState(false);62  const [busy, setBusy] = React.useState<"save" | "test" | null>(null);63  const [error, setError] = React.useState<string | null>(null);64  const [test, setTest] = React.useState<TestOutcome | null>(null);65  const [canPaste, setCanPaste] = React.useState(false);6667  React.useEffect(() => {68    if (!open) return;69    // eslint-disable-next-line react-hooks/set-state-in-effect70    setForm(endpoint ? fromEndpoint(endpoint) : EMPTY);71    setPreset(endpoint ? ENDPOINT_PRESETS.find((p) => endpoint.baseUrl.startsWith(p.baseUrl.replace(/\/v1$/, "")))?.id ?? "other" : "other");72    setError(null);73    setTest(null);74    setShowKey(false);75    setCanPaste(typeof navigator !== "undefined" && Boolean(navigator.clipboard?.readText));76  }, [open, endpoint]);7778  const patch = (p: Partial<FormState>) => {79    setForm((f) => ({ ...f, ...p }));80    setError(null);81  };8283  const applyPreset = (id: string) => {84    setPreset(id);85    const p = ENDPOINT_PRESETS.find((x) => x.id === id);86    if (!p || id === "other") return;87    patch({ name: form.name || p.name, baseUrl: p.baseUrl, modelsPath: p.modelsPath, discover: true });88  };8990  const privateHost = looksPrivate(form.baseUrl);91  const urlOk = /^https?:\/\/\S+/i.test(form.baseUrl.trim());92  const canSave = form.name.trim().length > 0 && urlOk && (form.discover ? form.modelsPath.trim().length > 0 : form.manualModels.some((m) => m.id.trim()));9394  const payload = () => {95    const headers: Record<string, string> = {};96    for (const h of form.headers) if (h.key.trim() && h.value.trim()) headers[h.key.trim()] = h.value.trim();97    const keepExistingHeaders = editing && form.headers.length > 0 && form.headers.every((h) => !h.value.trim()) && form.headers.every((h) => endpoint!.headerNames.includes(h.key.trim()));98    return {99      name: form.name.trim(),100      baseUrl: form.baseUrl.trim().replace(/\/+$/, ""),101      ...(form.clearKey ? { apiKey: null } : form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),102      ...(keepExistingHeaders ? {} : { headers: Object.keys(headers).length ? headers : null }),103      modelsPath: form.discover ? form.modelsPath.trim() : "",104      manualModels: form.manualModels.filter((m) => m.id.trim()).map((m) => ({ ...m, id: m.id.trim(), displayName: m.displayName?.trim() || undefined, contextTokens: m.contextTokens || undefined })),105    };106  };107108  const save = async (andTest: boolean) => {109    if (!canSave) return;110    setBusy(andTest ? "test" : "save");111    setError(null);112    try {113      if (editing) {114        const { endpoint: saved } = await api<{ endpoint: PublicEndpoint }>(`/api/endpoints/${endpoint!.id}`, { method: "PATCH", json: payload() });115        let outcome: TestOutcome | undefined;116        if (andTest) {117          const r = await api<{ ok: boolean; latencyMs: number; models: { id: string; displayName: string }[]; error?: string; endpoint: PublicEndpoint }>(`/api/endpoints/${endpoint!.id}/sync`, { method: "POST" });118          outcome = { ok: r.ok, latencyMs: r.latencyMs, modelsAvailable: r.models.length, error: r.error, models: r.models };119          setTest(outcome);120          onSaved(r.endpoint, outcome);121          if (r.ok) toast.success("Endpoint reachable", `${r.models.length} models · ${formatMs(r.latencyMs)}`);122          else toast.error("Endpoint test failed", r.error);123          return;124        }125        onSaved(saved);126        toast.success("Endpoint saved");127        onOpenChange(false);128      } else {129        const r = await api<{ endpoint: PublicEndpoint; test?: { ok: boolean; latencyMs: number; modelsAvailable: number; error?: string } }>("/api/endpoints", { method: "POST", json: { ...payload(), validate: andTest } });130        if (r.test) {131          const outcome: TestOutcome = { ...r.test, models: r.endpoint.discoveredModels.map((m) => ({ id: m.id, displayName: m.id })) };132          setTest(outcome);133          onSaved(r.endpoint, outcome);134          if (r.test.ok) toast.success(`${r.endpoint.name} connected`, `${r.test.modelsAvailable} models · ${formatMs(r.test.latencyMs)}`);135          else toast.warning("Saved, but the test failed", r.test.error);136          if (r.test.ok) onOpenChange(false);137        } else {138          onSaved(r.endpoint);139          toast.success("Endpoint saved", "Not tested yet.");140          onOpenChange(false);141        }142      }143    } catch (e) {144      setError(errorMessage(e, "Could not save the endpoint."));145    } finally {146      setBusy(null);147    }148  };149150  const pasteKey = async () => {151    try {152      const t = (await navigator.clipboard.readText()).trim();153      if (t) patch({ apiKey: t, clearKey: false });154    } catch {155      toast.error("Could not read the clipboard");156    }157  };158159  const presetMeta = ENDPOINT_PRESETS.find((p) => p.id === preset);160161  return (162    <ResponsiveDialog163      open={open}164      onOpenChange={(v) => !busy && onOpenChange(v)}165      size="lg"166      snap="full"167      title={editing ? `Edit ${endpoint!.name}` : "Add custom endpoint"}168      description="Any server that speaks the OpenAI Chat Completions API."169      footer={170        <div className="flex flex-wrap gap-2">171          <Button variant="ghost" className="md:mr-auto" onClick={() => onOpenChange(false)} disabled={Boolean(busy)}>172            Cancel173          </Button>174          <Button variant="outline" className="flex-1 md:flex-none" disabled={!canSave} loading={busy === "test"} onClick={() => save(true)}>175            <Activity /> {editing ? "Save & test" : "Save & test connection"}176          </Button>177          <Button variant="accent" className="flex-1 md:flex-none" disabled={!canSave} loading={busy === "save"} onClick={() => save(false)}>178            {editing ? "Save" : "Save without testing"}179          </Button>180        </div>181      }182    >183      <div className="space-y-5 pt-1">184        {!editing ? (185          <div>186            <p className="mb-1.5 text-[12px] font-medium text-fg-muted">Start from a preset</p>187            <div className="-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 scrollbar-none md:mx-0 md:flex-wrap md:px-0">188              {ENDPOINT_PRESETS.map((p) => (189                <button key={p.id} type="button" onClick={() => applyPreset(p.id)} aria-pressed={preset === p.id} className={cn("inline-flex h-9 shrink-0 items-center rounded-full border px-3.5 text-[13px] font-medium transition-colors", preset === p.id ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted hover:border-border-strong hover:text-fg")}>190                  {p.name}191                </button>192              ))}193            </div>194            {presetMeta ? <p className="mt-1.5 text-[12px] leading-4 text-fg-subtle">{presetMeta.hint}</p> : null}195          </div>196        ) : null}197198        <div className="grid gap-4 md:grid-cols-2">199          <Field label="Name" htmlFor="ep-name">200            <Input id="ep-name" value={form.name} onChange={(e) => patch({ name: e.target.value })} placeholder="My Ollama" maxLength={80} className="h-11 text-[16px] md:h-9 md:text-sm" />201          </Field>202          <Field label="Base URL" htmlFor="ep-url" hint="Up to and including /v1." error={form.baseUrl && !urlOk ? "Enter a full http(s) URL." : null}>203            <Input id="ep-url" value={form.baseUrl} onChange={(e) => patch({ baseUrl: e.target.value })} placeholder="http://localhost:11434/v1" inputMode="url" autoCapitalize="off" autoCorrect="off" spellCheck={false} className="h-11 font-mono text-[16px] md:h-9 md:text-sm" />204          </Field>205        </div>206207        {privateHost ? (208          <div className={cn("flex gap-2 rounded-lg px-3 py-2.5 text-xs leading-5", allowPrivate ? "bg-info-soft text-info" : "bg-warning-soft text-warning")}>209            {allowPrivate ? <Info className="mt-0.5 size-3.5 shrink-0" /> : <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />}210            <p>211              {allowPrivate ? (212                <>This server allows private addresses: the URL must be reachable from the machine running PolyLLM, not from your browser.</>213              ) : (214                <>215                  <span className="font-medium">Requests are made by the PolyLLM server, not your browser.</span> On www.polyllm.io a localhost or LAN address is unreachable and will be rejected. Expose the server through a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) and use that public URL, or run PolyLLM on your own machine with <code className="font-mono">ALLOW_PRIVATE_ENDPOINTS=1</code>.216                </>217              )}218            </p>219          </div>220        ) : null}221222        <Field label="API key (optional)" htmlFor="ep-key" hint={editing && endpoint!.hasKey && !form.clearKey && !form.apiKey ? `Current key ${endpoint!.keyHint} is kept unless you enter a new one.` : "Sent as Authorization: Bearer. Encrypted at rest, never shown again."}>223          <div className="flex gap-2">224            <div className="relative min-w-0 flex-1">225              <Input id="ep-key" type={showKey ? "text" : "password"} value={form.apiKey} disabled={form.clearKey} onChange={(e) => patch({ apiKey: e.target.value })} placeholder={editing && endpoint!.hasKey ? "Enter a new key to replace" : "Leave empty if the server needs none"} autoComplete="off" autoCapitalize="off" autoCorrect="off" spellCheck={false} className="h-11 pr-11 font-mono text-[16px] md:h-9 md:text-sm" />226              <button type="button" onClick={() => setShowKey((s) => !s)} className="tap absolute right-1.5 top-1/2 -translate-y-1/2 rounded-sm p-1.5 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label={showKey ? "Hide key" : "Show key"}>227                {showKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}228              </button>229            </div>230            {canPaste ? (231              <Button type="button" variant="outline" className="h-11 shrink-0 px-3 md:h-9" onClick={pasteKey} aria-label="Paste key">232                <ClipboardPaste />233              </Button>234            ) : null}235          </div>236          {editing && endpoint!.hasKey ? (237            <label className="mt-2 flex items-center gap-2 text-xs text-fg-muted">238              <Switch size="sm" checked={form.clearKey} onCheckedChange={(v) => patch({ clearKey: v, apiKey: v ? "" : form.apiKey })} aria-label="Remove the stored key" />239              Remove the stored key240            </label>241          ) : null}242        </Field>243244        <div>245          <div className="mb-1.5 flex items-center justify-between">246            <p className="text-[12px] font-medium text-fg-muted">Custom headers</p>247            <Button type="button" size="xs" variant="ghost" onClick={() => patch({ headers: [...form.headers, { key: "", value: "" }] })} disabled={form.headers.length >= 12}>248              <Plus /> Add header249            </Button>250          </div>251          {form.headers.length === 0 ? (252            <p className="text-[12px] text-fg-subtle">None. Useful for gateways (e.g. <code className="font-mono">X-Api-Key</code>, <code className="font-mono">CF-Access-Client-Id</code>).</p>253          ) : (254            <ul className="space-y-2">255              {form.headers.map((h, i) => (256                <li key={i} className="flex gap-2">257                  <Input value={h.key} onChange={(e) => patch({ headers: form.headers.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)) })} placeholder="Header" aria-label={`Header ${i + 1} name`} autoCapitalize="off" spellCheck={false} className="h-11 w-2/5 font-mono text-[16px] md:h-9 md:text-sm" />258                  <Input value={h.value} onChange={(e) => patch({ headers: form.headers.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)) })} placeholder={editing && endpoint!.headerNames.includes(h.key) && !h.value ? "•••••• (stored)" : "Value"} aria-label={`Header ${i + 1} value`} autoCapitalize="off" spellCheck={false} className="h-11 min-w-0 flex-1 font-mono text-[16px] md:h-9 md:text-sm" />259                  <Button type="button" variant="ghost" size="icon-lg" className="h-11 md:size-9" aria-label="Remove header" onClick={() => patch({ headers: form.headers.filter((_, j) => j !== i) })}>260                    <Trash2 />261                  </Button>262                </li>263              ))}264            </ul>265          )}266          {editing && form.headers.some((h) => endpoint!.headerNames.includes(h.key) && !h.value) ? <p className="mt-1.5 text-[11px] text-fg-subtle">Stored header values are never shown. Leave them blank to keep them, or type a new value to replace; editing any value re-saves all headers.</p> : null}267        </div>268269        <div className="panel divide-y divide-hairline">270          <div className="flex min-h-[52px] items-center justify-between gap-4 px-4 py-3">271            <div>272              <p className="text-[14px] font-medium">Discover models automatically</p>273              <p className="mt-0.5 text-[12px] text-fg-muted">GET {form.baseUrl.trim().replace(/\/+$/, "") || "{baseUrl}"}{form.discover ? form.modelsPath || "/models" : "…"}</p>274            </div>275            <Switch checked={form.discover} onCheckedChange={(v) => patch({ discover: v })} aria-label="Discover models automatically" />276          </div>277          {form.discover ? (278            <div className="px-4 py-3">279              <Field label="Models path" htmlFor="ep-models-path" hint="Relative to the base URL. OpenAI shape {data:[{id}]}, bare arrays and Ollama's {models:[{name}]} are understood.">280                <Input id="ep-models-path" value={form.modelsPath} onChange={(e) => patch({ modelsPath: e.target.value })} placeholder="/models" autoCapitalize="off" spellCheck={false} className="h-11 font-mono text-[16px] md:h-9 md:text-sm" />281              </Field>282            </div>283          ) : null}284        </div>285286        <div>287          <div className="mb-1.5 flex items-center justify-between">288            <div>289              <p className="text-[12px] font-medium text-fg-muted">Manual models {form.discover ? "(optional overrides)" : ""}</p>290              <p className="text-[11px] text-fg-subtle">{form.discover ? "Declare capabilities for discovered ids (vision, tools, reasoning, context) or add ids the listing misses." : "Discovery is off: list every model id the server accepts."}</p>291            </div>292            <Button type="button" size="xs" variant="ghost" onClick={() => patch({ manualModels: [...form.manualModels, { id: "" }] })} disabled={form.manualModels.length >= 200}>293              <Plus /> Add model294            </Button>295          </div>296          {form.manualModels.length ? (297            <ul className="space-y-2">298              {form.manualModels.map((m, i) => {299                const upd = (p: Partial<ManualModel>) => patch({ manualModels: form.manualModels.map((x, j) => (j === i ? { ...x, ...p } : x)) });300                return (301                  <li key={i} className="panel space-y-2 p-3">302                    <div className="flex gap-2">303                      <Input value={m.id} onChange={(e) => upd({ id: e.target.value })} placeholder="model id (e.g. llama3.1:8b)" aria-label={`Model ${i + 1} id`} autoCapitalize="off" spellCheck={false} className="h-11 min-w-0 flex-1 font-mono text-[16px] md:h-9 md:text-sm" />304                      <Button type="button" variant="ghost" size="icon-lg" className="h-11 md:size-9" aria-label="Remove model" onClick={() => patch({ manualModels: form.manualModels.filter((_, j) => j !== i) })}>305                        <Trash2 />306                      </Button>307                    </div>308                    <div className="grid gap-2 sm:grid-cols-2">309                      <Input value={m.displayName ?? ""} onChange={(e) => upd({ displayName: e.target.value })} placeholder="Display name (optional)" aria-label={`Model ${i + 1} display name`} className="h-11 text-[16px] md:h-9 md:text-sm" />310                      <Input type="number" inputMode="numeric" min={1} value={m.contextTokens ?? ""} onChange={(e) => upd({ contextTokens: e.target.value ? Number(e.target.value) : undefined })} placeholder="Context tokens (optional)" aria-label={`Model ${i + 1} context tokens`} className="h-11 text-[16px] md:h-9 md:text-sm" />311                    </div>312                    <div className="flex flex-wrap gap-x-5 gap-y-2 text-[13px]">313                      {(["vision", "tools", "reasoning"] as const).map((cap) => (314                        <label key={cap} className="flex min-h-[32px] items-center gap-2 capitalize">315                          <Switch size="sm" checked={Boolean(m[cap])} onCheckedChange={(v) => upd({ [cap]: v })} aria-label={`${cap} supported`} />316                          {cap}317                        </label>318                      ))}319                    </div>320                  </li>321                );322              })}323            </ul>324          ) : (325            <p className="text-[12px] text-fg-subtle">Discovered models start as text-only. Add an entry to unlock vision, tools or reasoning controls for a specific id.</p>326          )}327        </div>328329        {test ? (330          <div className={cn("rounded-lg px-3 py-2.5 text-xs leading-5", test.ok ? "bg-success-soft text-success" : "bg-danger-soft text-danger")}>331            <p className="flex items-center gap-1.5 font-medium">332              {test.ok ? <CheckCircle2 className="size-3.5" /> : <XCircle className="size-3.5" />}333              {test.ok ? `Reachable in ${formatMs(test.latencyMs)} · ${test.modelsAvailable} model${test.modelsAvailable === 1 ? "" : "s"}` : `Failed after ${formatMs(test.latencyMs)}`}334            </p>335            {!test.ok && test.error ? <p className="mt-0.5 text-fg-muted">{test.error}</p> : null}336            {test.ok && test.models?.length ? (337              <ul className="mt-2 flex flex-wrap gap-1.5">338                {test.models.slice(0, 30).map((m) => (339                  <li key={m.id}>340                    <Badge variant="outline" className="font-mono text-[10.5px] text-fg-muted">341                      {m.id}342                    </Badge>343                  </li>344                ))}345                {test.models.length > 30 ? <li className="text-fg-muted">+{test.models.length - 30} more</li> : null}346              </ul>347            ) : null}348          </div>349        ) : null}350351        {error ? <p className="rounded-lg bg-danger-soft px-3 py-2 text-xs text-danger">{error}</p> : null}352      </div>353    </ResponsiveDialog>354  );355}356