1"use client";
2
3import { PlusIcon, XIcon } from "lucide-react";
4import { useMemo, useState } from "react";
5import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts";
6import type { CustomModelDefinition } from "@t3tools/shared/model";
7
8import { cn } from "../../lib/utils";
9import { Button } from "../ui/button";
10import { Input } from "../ui/input";
11import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
12import { Switch } from "../ui/switch";
13import {
14 DESCRIPTOR_PRESETS_BY_KIND,
15 type CustomModelDraft,
16 type EditorChoice,
17 type EditorDescriptor,
18 choiceFromPreset,
19 definitionFromDraft,
20 descriptorFromPreset,
21 descriptorsFromCapabilities,
22 draftFromDefinition,
23 emptyEditorChoice,
24 emptyEditorDescriptor,
25 validateDraft,
26} from "./customModelEditor.logic";
27
28const CUSTOM_ID_VALUE = "__custom__";
29const START_FROM_NONE = "__none__";
30
31interface CustomModelEditorProps {
32 readonly instanceId: string;
33 readonly driverKind: ProviderDriverKind | null;
34 readonly entry: CustomModelDefinition;
35 /** Built-in models whose descriptors can be copied as a starting point. */
36 readonly builtInModels: ReadonlyArray<ServerProviderModel>;
37 readonly onSave: (next: CustomModelDefinition) => void;
38 readonly onCancel: () => void;
39}
40
41/**
42 * Inline editor for one custom model: display name plus the option
43 * descriptors the composer should offer for it (Reasoning effort, Fast
44 * mode, ...). Draft state is local; nothing is persisted until Save.
45 */
46export function CustomModelEditor({
47 instanceId,
48 driverKind,
49 entry,
50 builtInModels,
51 onSave,
52 onCancel,
53}: CustomModelEditorProps) {
54 const [draft, setDraft] = useState<CustomModelDraft>(() => draftFromDefinition(entry));
55 const [error, setError] = useState<string | null>(null);
56 const presets = useMemo(
57 () => (driverKind ? (DESCRIPTOR_PRESETS_BY_KIND[driverKind] ?? []) : []),
58 [driverKind],
59 );
60 const startFromCandidates = useMemo(
61 () => builtInModels.filter((model) => (model.capabilities?.optionDescriptors?.length ?? 0) > 0),
62 [builtInModels],
63 );
64 const domId = (suffix: string) => `provider-instance-${instanceId}-custom-model-${suffix}`;
65
66 const updateDescriptor = (key: string, patch: Partial<EditorDescriptor>) => {
67 setError(null);
68 setDraft((current) => ({
69 ...current,
70 descriptors: current.descriptors.map((descriptor) =>
71 descriptor.key === key ? { ...descriptor, ...patch } : descriptor,
72 ),
73 }));
74 };
75
76 const updateChoice = (descriptorKey: string, choiceKey: string, patch: Partial<EditorChoice>) => {
77 setError(null);
78 setDraft((current) => ({
79 ...current,
80 descriptors: current.descriptors.map((descriptor) => {
81 if (descriptor.key !== descriptorKey) return descriptor;
82 return {
83 ...descriptor,
84 choices: descriptor.choices.map((choice) => {
85 if (choice.key === choiceKey) return { ...choice, ...patch };
86 // Only one choice can be the default.
87 return patch.isDefault ? { ...choice, isDefault: false } : choice;
88 }),
89 };
90 }),
91 }));
92 };
93
94 const removeDescriptor = (key: string) => {
95 setError(null);
96 setDraft((current) => ({
97 ...current,
98 descriptors: current.descriptors.filter((descriptor) => descriptor.key !== key),
99 }));
100 };
101
102 const addDescriptor = (descriptor: EditorDescriptor) => {
103 setError(null);
104 setDraft((current) => ({ ...current, descriptors: [...current.descriptors, descriptor] }));
105 };
106
107 // Selecting a preset id replaces the descriptor's label/type/choices so the
108 // usual values are one click away; "Custom…" leaves the row blank to type into.
109 const applyPresetId = (descriptor: EditorDescriptor, value: string | null) => {
110 if (value === null) return;
111 if (value === CUSTOM_ID_VALUE) {
112 updateDescriptor(descriptor.key, { id: "" });
113 return;
114 }
115 const preset = presets.find((candidate) => candidate.id === value);
116 if (!preset) return;
117 updateDescriptor(descriptor.key, {
118 id: preset.id,
119 label: preset.label,
120 type: preset.type,
121 choices: (preset.choices ?? []).map(choiceFromPreset),
122 currentBooleanValue: undefined,
123 description: undefined,
124 });
125 };
126
127 const handleStartFrom = (slug: string | null) => {
128 if (slug === null || slug === START_FROM_NONE) return;
129 const model = startFromCandidates.find((candidate) => candidate.slug === slug);
130 if (!model) return;
131 setError(null);
132 setDraft((current) => ({
133 ...current,
134 descriptors: descriptorsFromCapabilities(model.capabilities, driverKind),
135 }));
136 };
137
138 const handleSave = () => {
139 const problem = validateDraft(draft);
140 if (problem) {
141 setError(problem);
142 return;
143 }
144 onSave(definitionFromDraft(draft));
145 };
146
147 const idSelectValue = (descriptor: EditorDescriptor) =>
148 presets.some((preset) => preset.id === descriptor.id) ? descriptor.id : CUSTOM_ID_VALUE;
149
150 const renderChoice = (descriptor: EditorDescriptor, choice: EditorChoice) => (
151 <div key={choice.key} className="flex items-center gap-2">
152 <Input
153 size="compact"
154 value={choice.id}
155 onChange={(event) => updateChoice(descriptor.key, choice.key, { id: event.target.value })}
156 placeholder="value"
157 font="mono"
158 className="w-28"
159 spellCheck={false}
160 aria-label="Choice value"
161 />
162 <Input
163 size="compact"
164 value={choice.label}
165 onChange={(event) =>
166 updateChoice(descriptor.key, choice.key, { label: event.target.value })
167 }
168 placeholder="Label"
169 className="min-w-0 flex-1"
170 aria-label="Choice label"
171 />
172 <label className="flex shrink-0 items-center gap-1.5 text-2xs text-muted-foreground">
173 <Switch
174 size="sm"
175 checked={choice.isDefault}
176 onCheckedChange={(checked) =>
177 updateChoice(descriptor.key, choice.key, { isDefault: checked })
178 }
179 aria-label="Default choice"
180 />
181 Default
182 </label>
183 <Button
184 size="icon-micro"
185 variant="ghost-muted"
186 aria-label="Remove choice"
187 onClick={() =>
188 updateDescriptor(descriptor.key, {
189 choices: descriptor.choices.filter((candidate) => candidate.key !== choice.key),
190 })
191 }
192 >
193 <XIcon className="size-3" />
194 </Button>
195 </div>
196 );
197
198 const renderDescriptor = (descriptor: EditorDescriptor, index: number) => (
199 <div
200 key={descriptor.key}
201 className="flex flex-col gap-2 rounded-md border border-border/60 bg-background/40 p-2.5"
202 >
203 <div className="flex flex-wrap items-center gap-2">
204 <span className="w-14 shrink-0 text-2xs text-muted-foreground">Option {index + 1}</span>
205 {presets.length > 0 ? (
206 <Select
207 value={idSelectValue(descriptor)}
208 onValueChange={(value) => applyPresetId(descriptor, value)}
209 >
210 <SelectTrigger size="compact" className="w-40" aria-label="Option id">
211 <SelectValue>
212 {idSelectValue(descriptor) === CUSTOM_ID_VALUE ? "Custom…" : descriptor.id}
213 </SelectValue>
214 </SelectTrigger>
215 <SelectPopup align="start" alignItemWithTrigger={false}>
216 {presets.map((preset) => (
217 <SelectItem key={preset.id} value={preset.id}>
218 <span className="flex items-baseline gap-2">
219 <code className="font-mono text-xs">{preset.id}</code>
220 <span className="text-muted-foreground">{preset.label}</span>
221 </span>
222 </SelectItem>
223 ))}
224 <SelectItem value={CUSTOM_ID_VALUE}>Custom…</SelectItem>
225 </SelectPopup>
226 </Select>
227 ) : null}
228 {idSelectValue(descriptor) === CUSTOM_ID_VALUE ? (
229 <Input
230 size="compact"
231 value={descriptor.id}
232 onChange={(event) => updateDescriptor(descriptor.key, { id: event.target.value })}
233 placeholder="optionId"
234 font="mono"
235 className="w-36"
236 spellCheck={false}
237 aria-label="Option id"
238 />
239 ) : null}
240 <Input
241 size="compact"
242 value={descriptor.label}
243 onChange={(event) => updateDescriptor(descriptor.key, { label: event.target.value })}
244 placeholder="Label"
245 className="min-w-0 flex-1"
246 aria-label="Option label"
247 />
248 <Select
249 value={descriptor.type}
250 onValueChange={(value) =>
251 updateDescriptor(descriptor.key, { type: value === "boolean" ? "boolean" : "select" })
252 }
253 >
254 <SelectTrigger size="compact" className="w-24" aria-label="Option type">
255 <SelectValue>{descriptor.type === "boolean" ? "Toggle" : "Choices"}</SelectValue>
256 </SelectTrigger>
257 <SelectPopup align="end" alignItemWithTrigger={false}>
258 <SelectItem value="select">Choices</SelectItem>
259 <SelectItem value="boolean">Toggle</SelectItem>
260 </SelectPopup>
261 </Select>
262 <Button
263 size="icon-micro"
264 variant="ghost-muted"
265 aria-label={`Remove option ${index + 1}`}
266 onClick={() => removeDescriptor(descriptor.key)}
267 >
268 <XIcon className="size-3" />
269 </Button>
270 </div>
271 {descriptor.type === "select" ? (
272 <div className="flex flex-col gap-1.5 pl-16">
273 {descriptor.choices.map((choice) => renderChoice(descriptor, choice))}
274 <Button
275 type="button"
276 size="xs"
277 variant="ghost-muted"
278 className="-ml-2 self-start"
279 onClick={() =>
280 updateDescriptor(descriptor.key, {
281 choices: [...descriptor.choices, emptyEditorChoice()],
282 })
283 }
284 >
285 <PlusIcon className="size-3" />
286 Add choice
287 </Button>
288 </div>
289 ) : null}
290 </div>
291 );
292
293 return (
294 <div
295 className="mx-2 mt-1 mb-2 flex flex-col gap-3 rounded-md border border-border bg-muted/20 p-3"
296 onKeyDown={(event) => {
297 if (event.key === "Escape") {
298 event.preventDefault();
299 onCancel();
300 }
301 }}
302 >
303 <div className="flex flex-col gap-1">
304 <label htmlFor={domId("name")} className="text-xs text-muted-foreground">
305 Display name
306 </label>
307 <Input
308 id={domId("name")}
309 size="sm"
310 autoFocus
311 value={draft.name}
312 onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
313 placeholder={draft.slug}
314 className="sm:w-72"
315 spellCheck={false}
316 />
317 </div>
318
319 <div className="flex flex-col gap-2">
320 <div className="flex flex-wrap items-center justify-between gap-2">
321 <span className="text-xs text-muted-foreground">Options shown in the composer</span>
322 {startFromCandidates.length > 0 ? (
323 <Select value={START_FROM_NONE} onValueChange={handleStartFrom}>
324 <SelectTrigger
325 size="compact"
326 className="w-44"
327 aria-label="Copy options from a built-in model"
328 >
329 <SelectValue>Copy from…</SelectValue>
330 </SelectTrigger>
331 <SelectPopup align="end" alignItemWithTrigger={false}>
332 {startFromCandidates.map((model) => (
333 <SelectItem key={model.slug} value={model.slug}>
334 {model.name}
335 </SelectItem>
336 ))}
337 </SelectPopup>
338 </Select>
339 ) : null}
340 </div>
341 {draft.descriptors.length === 0 ? (
342 <p className="text-xs text-muted-foreground/70">
343 No custom options. The composer uses the provider's default options.
344 </p>
345 ) : null}
346 {draft.descriptors.map(renderDescriptor)}
347 <div className="flex flex-wrap gap-1">
348 {presets
349 .filter(
350 (preset) => !draft.descriptors.some((descriptor) => descriptor.id === preset.id),
351 )
352 .map((preset) => (
353 <Button
354 key={preset.id}
355 type="button"
356 size="xs"
357 variant="ghost-muted"
358 className={cn("-ml-2 first:ml-0")}
359 onClick={() => addDescriptor(descriptorFromPreset(preset))}
360 >
361 <PlusIcon className="size-3" />
362 {preset.label}
363 </Button>
364 ))}
365 <Button
366 type="button"
367 size="xs"
368 variant="ghost-muted"
369 onClick={() => addDescriptor(emptyEditorDescriptor())}
370 >
371 <PlusIcon className="size-3" />
372 Custom option
373 </Button>
374 </div>
375 </div>
376
377 {error ? <p className="text-xs text-destructive">{error}</p> : null}
378
379 <div className="flex gap-2">
380 <Button size="sm" variant="outline" onClick={handleSave}>
381 Save
382 </Button>
383 <Button size="sm" variant="ghost" onClick={onCancel}>
384 Cancel
385 </Button>
386 </div>
387 </div>
388 );
389}