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%
8.0 KB · 148 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { GripVertical, RotateCcw, X } from "lucide-react";4import { useApp, invalidateProjects } from "@/components/app/store";5import { api } from "@/lib/client/api";6import { errorMessage } from "@/lib/client/humanize";7import { Button } from "@/components/ui/button";8import { Textarea, Field, Label } from "@/components/ui/input";9import { Badge } from "@/components/ui/badge";10import { toast } from "@/components/ui/toast";11import { ProviderIcon } from "@/components/brand/provider-icon";12import { ModelSelector } from "@/components/chat/model-selector";13import { ParametersForm, parameterChips, type Params } from "@/components/presets/parameters-form";14import { estimateTextTokens } from "@/lib/client/tokens";15import type { PublicProject } from "@/lib/client/types";16import { formatTokens, cn } from "@/lib/utils";1718interface Draft {19  instructions: string;20  preferredModelKeys: string[];21  defaultSettings: Params;22}2324const fromProject = (p: PublicProject): Draft => ({ instructions: p.instructions ?? "", preferredModelKeys: p.preferredModelKeys ?? [], defaultSettings: (p.defaultSettings ?? {}) as Params });25const same = (a: Draft, b: Draft) => a.instructions === b.instructions && JSON.stringify(a.preferredModelKeys) === JSON.stringify(b.preferredModelKeys) && JSON.stringify(a.defaultSettings) === JSON.stringify(b.defaultSettings);2627/** "Instructions & models" tab: system instructions, preferred models (first = default), default generation settings. */28export function ProjectSetup({ project, onSaved }: { project: PublicProject; onSaved: () => Promise<unknown> | void }) {29  const { modelsByKey, connectedProviders } = useApp();30  const [draft, setDraft] = React.useState<Draft>(() => fromProject(project));31  const [saving, setSaving] = React.useState(false);32  const base = React.useMemo(() => fromProject(project), [project]);33  const dirty = !same(draft, base);3435  // Re-sync when the project changes underneath (another tab saved, SWR refresh) and nothing is being edited.36  const lastId = React.useRef(project.updatedAt);37  React.useEffect(() => {38    if (lastId.current !== project.updatedAt) {39      lastId.current = project.updatedAt;40      setDraft(fromProject(project));41    }42  }, [project]);4344  const selected = React.useMemo(() => new Set(draft.preferredModelKeys), [draft.preferredModelKeys]);45  const toggle = (key: string) =>46    setDraft((d) => {47      const has = d.preferredModelKeys.includes(key);48      if (!has && d.preferredModelKeys.length >= 8) {49        toast.warning("Up to 8 preferred models per project");50        return d;51      }52      return { ...d, preferredModelKeys: has ? d.preferredModelKeys.filter((k) => k !== key) : [...d.preferredModelKeys, key] };53    });54  const moveFirst = (key: string) => setDraft((d) => ({ ...d, preferredModelKeys: [key, ...d.preferredModelKeys.filter((k) => k !== key)] }));55  const firstModel = draft.preferredModelKeys[0] ? modelsByKey.get(draft.preferredModelKeys[0]) : null;56  const chips = parameterChips(draft.defaultSettings);5758  const save = async () => {59    setSaving(true);60    try {61      await api(`/api/projects/${project.id}`, { method: "PATCH", json: { instructions: draft.instructions.trim() ? draft.instructions : null, preferredModelKeys: draft.preferredModelKeys, defaultSettings: draft.defaultSettings } });62      await Promise.all([onSaved(), invalidateProjects()]);63      toast.success("Project setup saved", "New chats in this project will use it.");64    } catch (e) {65      toast.error("Could not save", errorMessage(e));66    } finally {67      setSaving(false);68    }69  };7071  return (72    <div className="space-y-6">73      <Field label="Instructions" htmlFor="project-instructions" hint={`Prepended as the system prompt of every new chat in this project · ~${formatTokens(estimateTextTokens(draft.instructions))} tokens`}>74        <Textarea id="project-instructions" value={draft.instructions} onChange={(e) => setDraft((d) => ({ ...d, instructions: e.target.value }))} rows={8} maxLength={50_000} className="min-h-[160px] font-mono text-[13px] leading-5" placeholder={"You are helping with the “Acme redesign” project. Always answer in French, prefer concise bullet points, and cite the file you used…"} spellCheck={false} />75      </Field>7677      <div className="space-y-2">78        <div className="flex items-center justify-between gap-2">79          <Label>Preferred models</Label>80          <ModelSelector value={null} multiple selected={selected} onToggle={toggle} buttonLabel={draft.preferredModelKeys.length ? "Add or remove models" : "Choose models"} size="sm" />81        </div>82        {draft.preferredModelKeys.length === 0 ? (83          <p className="rounded-lg border border-dashed border-border px-3 py-3 text-[13px] text-fg-subtle">No preferred models — new chats use your current selection. The first model in this list becomes the default for the project.</p>84        ) : (85          <ul className="divide-y divide-hairline rounded-xl border border-border bg-bg-elevated">86            {draft.preferredModelKeys.map((key, i) => {87              const m = modelsByKey.get(key);88              const usable = m ? connectedProviders.has(m.provider) : false;89              return (90                <li key={key} className="flex min-h-[48px] items-center gap-2 px-2.5 py-1.5">91                  <GripVertical className="size-4 shrink-0 text-fg-subtle/60" aria-hidden />92                  {m ? <ProviderIcon provider={m.provider} size={15} /> : null}93                  <span className="min-w-0 flex-1 truncate text-[14px]">94                    {m?.displayName ?? key}95                    {i === 0 ? <span className="ml-2 rounded-full bg-accent-soft px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent">Default</span> : null}96                    {m && !usable ? <span className="ml-2 text-[11px] text-warning">provider not connected</span> : null}97                  </span>98                  {i > 0 ? (99                    <Button variant="ghost" size="xs" onClick={() => moveFirst(key)} className="hidden sm:inline-flex">100                      Make default101                    </Button>102                  ) : null}103                  <Button variant="ghost" size="icon-sm" className="tap" onClick={() => toggle(key)} aria-label={`Remove ${m?.displayName ?? key}`}>104                    <X />105                  </Button>106                </li>107              );108            })}109          </ul>110        )}111      </div>112113      <div className="space-y-2">114        <div className="flex items-center justify-between gap-2">115          <Label>Default settings</Label>116          {chips.length ? (117            <Button variant="ghost" size="xs" onClick={() => setDraft((d) => ({ ...d, defaultSettings: {} }))}>118              <RotateCcw /> Reset119            </Button>120          ) : null}121        </div>122        <p className="text-[12px] text-fg-subtle">{firstModel ? `Controls follow what ${firstModel.displayName} supports.` : "Generic controls — pick a default model above to see its exact parameter sheet."}</p>123        <div className="rounded-xl border border-border bg-bg-elevated p-3 sm:p-4">124          <ParametersForm compact value={draft.defaultSettings} onChange={(p) => setDraft((d) => ({ ...d, defaultSettings: p }))} model={firstModel ?? null} />125        </div>126        {chips.length ? (127          <div className="flex flex-wrap gap-1.5">128            {chips.map((c) => (129              <Badge key={c}>{c}</Badge>130            ))}131          </div>132        ) : null}133      </div>134135      <div className={cn("sticky bottom-0 -mx-4 flex items-center gap-2 border-t border-border bg-bg/90 px-4 py-3 backdrop-blur sm:mx-0 sm:rounded-xl sm:border sm:px-3", !dirty && "opacity-0 pointer-events-none")} aria-hidden={!dirty}>136        <span className="text-[13px] text-fg-muted">Unsaved changes</span>137        <div className="flex-1" />138        <Button variant="ghost" onClick={() => setDraft(base)} disabled={saving}>139          Discard140        </Button>141        <Button loading={saving} onClick={save}>142          Save setup143        </Button>144      </div>145    </div>146  );147}148