TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
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