SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
14.7 KB · 312 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { X, RotateCcw } from "lucide-react";4import { Slider } from "@/components/ui/slider";5import { Input, Label } from "@/components/ui/input";6import { Switch } from "@/components/ui/switch";7import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";8import { Button } from "@/components/ui/button";9import { Badge } from "@/components/ui/badge";10import { cn } from "@/lib/utils";11import type { GenerationSettingsInput, PolyModel, ReasoningEffort } from "@/lib/client/types";1213export type Params = GenerationSettingsInput;1415const UNSET = "__unset__";16const EFFORTS: ReasoningEffort[] = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];1718export const BUILTIN_TOOLS: { id: string; label: string; description: string }[] = [19  { id: "calculator", label: "Calculator", description: "Exact arithmetic and unit conversion." },20  { id: "clock", label: "Clock", description: "Current date and time in any timezone." },21  { id: "random", label: "Random", description: "Random numbers, UUIDs and picks." },22];2324/** Human chips for a stored parameter object — used on preset cards. */25export function parameterChips(p: Record<string, unknown> | null | undefined): string[] {26  if (!p) return [];27  const out: string[] = [];28  if (typeof p.temperature === "number") out.push(`temp ${p.temperature}`);29  if (typeof p.topP === "number") out.push(`top-p ${p.topP}`);30  if (typeof p.topK === "number") out.push(`top-k ${p.topK}`);31  if (typeof p.maxTokens === "number") out.push(`${p.maxTokens.toLocaleString()} max`);32  if (typeof p.reasoningEffort === "string") out.push(`effort ${p.reasoningEffort}`);33  if (typeof p.thinkingBudget === "number") out.push(`budget ${p.thinkingBudget.toLocaleString()}`);34  if (typeof p.verbosity === "string") out.push(`verbosity ${p.verbosity}`);35  if (typeof p.frequencyPenalty === "number") out.push(`freq ${p.frequencyPenalty}`);36  if (typeof p.presencePenalty === "number") out.push(`pres ${p.presencePenalty}`);37  if (typeof p.seed === "number") out.push(`seed ${p.seed}`);38  if (Array.isArray(p.stop) && p.stop.length) out.push(`${p.stop.length} stop`);39  if (p.webSearch) out.push("web search");40  if (Array.isArray(p.tools) && p.tools.length) out.push(`tools: ${(p.tools as string[]).join(", ")}`);41  if (p.responseFormat && typeof p.responseFormat === "object" && (p.responseFormat as { type?: string }).type !== "text") out.push("JSON output");42  return out;43}4445interface Props {46  value: Params;47  onChange: (next: Params) => void;48  /**49   * When provided, only controls the model supports are shown (its `parameters` sheet + capabilities).50   * When omitted (prompt presets), a compact generic subset is shown.51   */52  model?: PolyModel | null;53  compact?: boolean;54  className?: string;55}5657/** Parameters editor driven by a model's capability sheet. Never shows a control the model does not support. */58export function ParametersForm({ value, onChange, model, compact, className }: Props) {59  const sheet = model?.parameters;60  const caps = model?.capabilities;61  const generic = !model;6263  const set = <K extends keyof Params>(k: K, v: Params[K] | undefined) => {64    const next = { ...value };65    if (v === undefined) delete next[k];66    else next[k] = v;67    onChange(next);68  };6970  const show = {71    temperature: generic || Boolean(sheet?.temperature),72    topP: !compact && Boolean(sheet?.topP),73    topK: !compact && Boolean(sheet?.topK),74    maxTokens: generic || Boolean(sheet?.maxTokens),75    reasoningEffort: generic || Boolean(sheet?.reasoningEffort),76    thinkingBudget: !compact && Boolean(sheet?.thinkingBudget),77    verbosity: !compact && Boolean(sheet?.verbosity),78    frequencyPenalty: !compact && Boolean(sheet?.frequencyPenalty),79    presencePenalty: !compact && Boolean(sheet?.presencePenalty),80    seed: !compact && Boolean(sheet?.seed),81    stop: !compact && Boolean(sheet?.stop),82    tools: !compact && (generic || Boolean(caps?.tools)),83    webSearch: !compact && Boolean(caps?.webSearch),84    structured: !compact && Boolean(caps?.structuredOutput),85  };8687  const tempRange = sheet?.temperatureRange ?? { min: 0, max: 2 };88  const effortLevels = (sheet?.reasoningEffortLevels?.length ? sheet.reasoningEffortLevels : EFFORTS) as string[];89  const budgetRange = sheet?.thinkingBudgetRange ?? { min: 0, max: 200_000 };90  const maxOut = model?.limits?.maxOutputTokens;91  const anySet = Object.keys(value).length > 0;9293  const nothing = !Object.values(show).some(Boolean);9495  return (96    <div className={cn("space-y-4", className)}>97      <div className="flex items-center justify-between">98        <p className="text-[13px] font-medium text-fg-muted">Parameters</p>99        {anySet ? (100          <Button type="button" variant="ghost" size="xs" onClick={() => onChange({})}>101            <RotateCcw /> Reset to model defaults102          </Button>103        ) : (104          <span className="text-[11px] text-fg-subtle">Unset = provider default</span>105        )}106      </div>107108      {nothing ? <p className="rounded-lg border border-dashed border-border px-3 py-4 text-center text-xs text-fg-subtle">This model exposes no tunable parameters.</p> : null}109110      {show.temperature ? (111        <SliderField label="Temperature" hint="Lower is more deterministic." value={value.temperature} min={tempRange.min} max={tempRange.max} step={0.05} onChange={(v) => set("temperature", v)} />112      ) : null}113      {show.topP ? <SliderField label="Top-p" hint="Nucleus sampling cutoff." value={value.topP} min={0} max={1} step={0.01} onChange={(v) => set("topP", v)} /> : null}114      {show.topK ? <NumberField label="Top-k" value={value.topK} min={1} max={500} step={1} onChange={(v) => set("topK", v)} /> : null}115      {show.maxTokens ? <NumberField label="Max output tokens" hint={maxOut ? `Model limit ${maxOut.toLocaleString()}` : undefined} value={value.maxTokens} min={1} max={maxOut ?? 400_000} step={1} onChange={(v) => set("maxTokens", v)} /> : null}116117      {show.reasoningEffort ? (118        <SelectField label="Reasoning effort" value={value.reasoningEffort} options={effortLevels.map((l) => ({ value: l, label: l }))} onChange={(v) => set("reasoningEffort", v as ReasoningEffort | undefined)} />119      ) : null}120      {show.thinkingBudget ? <NumberField label="Thinking budget (tokens)" hint={`${budgetRange.min.toLocaleString()} – ${budgetRange.max.toLocaleString()}`} value={value.thinkingBudget} min={budgetRange.min} max={budgetRange.max} step={256} onChange={(v) => set("thinkingBudget", v)} /> : null}121      {show.verbosity ? <SelectField label="Verbosity" value={value.verbosity} options={["low", "medium", "high"].map((l) => ({ value: l, label: l }))} onChange={(v) => set("verbosity", v as Params["verbosity"])} /> : null}122123      {show.frequencyPenalty || show.presencePenalty ? (124        <div className="grid gap-4 sm:grid-cols-2">125          {show.frequencyPenalty ? <SliderField label="Frequency penalty" value={value.frequencyPenalty} min={-2} max={2} step={0.1} onChange={(v) => set("frequencyPenalty", v)} /> : null}126          {show.presencePenalty ? <SliderField label="Presence penalty" value={value.presencePenalty} min={-2} max={2} step={0.1} onChange={(v) => set("presencePenalty", v)} /> : null}127        </div>128      ) : null}129130      {show.seed ? <NumberField label="Seed" hint="Best-effort reproducibility." value={value.seed} min={0} max={2_147_483_647} step={1} onChange={(v) => set("seed", v)} /> : null}131      {show.stop ? <StopField value={value.stop ?? []} onChange={(v) => set("stop", v.length ? v : undefined)} /> : null}132133      {show.structured ? (134        <ToggleField label="Strict JSON output" hint="Ask the model to return a JSON object." checked={value.responseFormat?.type === "json"} onChange={(v) => set("responseFormat", v ? { type: "json" } : undefined)} />135      ) : null}136      {show.webSearch ? <ToggleField label="Web search" hint="Let the provider search the live web and cite sources." checked={Boolean(value.webSearch)} onChange={(v) => set("webSearch", v || undefined)} /> : null}137138      {show.tools ? (139        <div className="space-y-2">140          <Label>Built-in tools</Label>141          <div className="grid gap-2 sm:grid-cols-3">142            {BUILTIN_TOOLS.map((t) => {143              const on = value.tools?.includes(t.id) ?? false;144              return (145                <label key={t.id} className={cn("flex cursor-pointer items-start gap-2.5 rounded-lg border p-2.5 transition-colors", on ? "border-accent/50 bg-accent-soft/40" : "border-border hover:border-border-strong")}>146                  <Switch147                    size="sm"148                    checked={on}149                    onCheckedChange={(v) => {150                      const cur = new Set(value.tools ?? []);151                      if (v) cur.add(t.id);152                      else cur.delete(t.id);153                      set("tools", cur.size ? [...cur] : undefined);154                    }}155                    className="mt-0.5"156                  />157                  <span className="min-w-0">158                    <span className="block text-[13px] font-medium leading-4">{t.label}</span>159                    <span className="block text-[11px] leading-4 text-fg-subtle">{t.description}</span>160                  </span>161                </label>162              );163            })}164          </div>165        </div>166      ) : null}167    </div>168  );169}170171function SliderField({ label, hint, value, min, max, step, onChange }: { label: string; hint?: string; value: number | undefined; min: number; max: number; step: number; onChange: (v: number | undefined) => void }) {172  const id = React.useId();173  const isSet = value !== undefined;174  const shown = value ?? (min + max) / 2;175  return (176    <div className="space-y-1.5">177      <div className="flex items-center justify-between gap-2">178        <Label htmlFor={id}>{label}</Label>179        <span className="flex items-center gap-1.5">180          {isSet ? (181            <>182              <span className="font-mono text-xs tabular-nums text-fg">{value}</span>183              <button type="button" onClick={() => onChange(undefined)} className="rounded p-0.5 text-fg-subtle hover:text-fg" aria-label={`Unset ${label}`}>184                <X className="size-3" />185              </button>186            </>187          ) : (188            <Badge>default</Badge>189          )}190        </span>191      </div>192      <Slider id={id} min={min} max={max} step={step} value={[shown]} onValueChange={([v]) => onChange(Number(v.toFixed(2)))} className={cn(!isSet && "opacity-60")} aria-label={label} />193      <div className="flex justify-between text-[10.5px] text-fg-subtle">194        <span>{min}</span>195        {hint ? <span>{hint}</span> : null}196        <span>{max}</span>197      </div>198    </div>199  );200}201202function NumberField({ label, hint, value, min, max, step, onChange }: { label: string; hint?: string; value: number | undefined; min: number; max: number; step: number; onChange: (v: number | undefined) => void }) {203  const id = React.useId();204  return (205    <div className="space-y-1.5">206      <div className="flex items-center justify-between">207        <Label htmlFor={id}>{label}</Label>208        {hint ? <span className="text-[11px] text-fg-subtle">{hint}</span> : null}209      </div>210      <Input211        id={id}212        type="number"213        inputMode="numeric"214        min={min}215        max={max}216        step={step}217        value={value ?? ""}218        placeholder="default"219        onChange={(e) => {220          const raw = e.target.value;221          if (raw === "") return onChange(undefined);222          const n = Number(raw);223          if (Number.isNaN(n)) return;224          onChange(Math.min(max, Math.max(min, step >= 1 ? Math.round(n) : n)));225        }}226        className="font-mono"227      />228    </div>229  );230}231232function SelectField({ label, value, options, onChange }: { label: string; value: string | undefined; options: { value: string; label: string }[]; onChange: (v: string | undefined) => void }) {233  const id = React.useId();234  return (235    <div className="space-y-1.5">236      <Label htmlFor={id}>{label}</Label>237      <Select value={value ?? UNSET} onValueChange={(v) => onChange(v === UNSET ? undefined : v)}>238        <SelectTrigger id={id}>239          <SelectValue />240        </SelectTrigger>241        <SelectContent>242          <SelectItem value={UNSET}>Provider default</SelectItem>243          {options.map((o) => (244            <SelectItem key={o.value} value={o.value} className="capitalize">245              {o.label}246            </SelectItem>247          ))}248        </SelectContent>249      </Select>250    </div>251  );252}253254function ToggleField({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {255  const id = React.useId();256  return (257    <div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5">258      <div className="min-w-0">259        <label htmlFor={id} className="text-[13px] font-medium">260          {label}261        </label>262        {hint ? <p className="text-[11px] text-fg-subtle">{hint}</p> : null}263      </div>264      <Switch id={id} size="sm" checked={checked} onCheckedChange={onChange} />265    </div>266  );267}268269function StopField({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {270  const [draft, setDraft] = React.useState("");271  const id = React.useId();272  const add = () => {273    const s = draft.trim();274    if (!s || value.includes(s) || value.length >= 4) return;275    onChange([...value, s.slice(0, 64)]);276    setDraft("");277  };278  return (279    <div className="space-y-1.5">280      <div className="flex items-center justify-between">281        <Label htmlFor={id}>Stop sequences</Label>282        <span className="text-[11px] text-fg-subtle">{value.length}/4</span>283      </div>284      <div className="flex flex-wrap items-center gap-1.5 rounded-md border border-border bg-bg-elevated px-2 py-1.5 focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/25">285        {value.map((s) => (286          <span key={s} className="inline-flex items-center gap-1 rounded bg-bg-muted px-1.5 py-0.5 font-mono text-xs">287            {JSON.stringify(s).slice(1, -1)}288            <button type="button" onClick={() => onChange(value.filter((x) => x !== s))} className="text-fg-subtle hover:text-fg" aria-label={`Remove stop sequence ${s}`}>289              <X className="size-3" />290            </button>291          </span>292        ))}293        <input294          id={id}295          value={draft}296          onChange={(e) => setDraft(e.target.value)}297          onKeyDown={(e) => {298            if (e.key === "Enter" || e.key === ",") {299              e.preventDefault();300              add();301            }302          }}303          onBlur={add}304          placeholder={value.length ? "" : "Type and press Enter"}305          disabled={value.length >= 4}306          className="min-w-[8ch] flex-1 bg-transparent font-mono text-[15px] outline-none placeholder:text-fg-subtle sm:text-xs"307        />308      </div>309    </div>310  );311}312