create-interview-cockpit 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/package.json +1 -1
- package/template/client/package-lock.json +23 -0
- package/template/client/package.json +2 -0
- package/template/client/src/App.tsx +28 -12
- package/template/client/src/api.ts +39 -0
- package/template/client/src/components/AiSettingsModal.tsx +827 -0
- package/template/client/src/components/ChatView.tsx +173 -136
- package/template/client/src/components/MarkdownRenderer.tsx +5 -0
- package/template/client/src/components/Sidebar.tsx +3 -1
- package/template/client/src/components/VizCraftEmbed.tsx +502 -0
- package/template/client/src/store.ts +74 -0
- package/template/client/src/types.ts +1 -0
- package/template/cockpit.json +1 -1
- package/template/data/ai-settings.json +49 -0
- package/template/package.json +1 -1
- package/template/server/src/index.ts +84 -34
- package/template/server/src/storage.ts +96 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
X,
|
|
4
|
+
RotateCcw,
|
|
5
|
+
Save,
|
|
6
|
+
ChevronDown,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
Check,
|
|
9
|
+
Loader2,
|
|
10
|
+
Plus,
|
|
11
|
+
Trash2,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { useStore } from "../store";
|
|
14
|
+
import type { AiSettings, PromptGroup } from "../api";
|
|
15
|
+
|
|
16
|
+
// ── Committed baseline — mirrors data/ai-settings.json ───────────────────────
|
|
17
|
+
const BASELINE: AiSettings = {
|
|
18
|
+
systemPrompt:
|
|
19
|
+
"You are a senior engineering interview coach.\n\nHighest priority: follow the user's explicit response preferences and current conversation context. If they conflict with your default teaching behavior, the user's preference wins.\nExplain clearly, accurately, and practically.\nOnly include Mermaid diagrams, code blocks, or tables when the user explicitly asks for them or when they materially improve the answer.\nIf you show code, use a fenced code block with the correct language.\n\nMermaid syntax rules (follow strictly):\n- Wrap node labels in quotes when they contain special characters: A[\"Microservice A (Producer)\"]\n- Edge labels use |text| syntax: A -->|sends message| B\n- Never put parentheses or brackets inside [] without quoting the label\n- Use simple node IDs (letters/numbers) and put descriptive text in the label\n\nYou can produce animated diagrams using ```viz blocks for flows, step-through walkthroughs, or any explanation that benefits from animation. Call getVizGuide() first to get the full spec reference before writing a viz block.",
|
|
20
|
+
responseProfiles: {
|
|
21
|
+
concise: { maxOutputTokens: 1000, maxSteps: 3 },
|
|
22
|
+
moderate: { maxOutputTokens: 1000, maxSteps: 5 },
|
|
23
|
+
normal: { maxOutputTokens: 3000, maxSteps: 5 },
|
|
24
|
+
},
|
|
25
|
+
vizGuide: "",
|
|
26
|
+
promptGroups: {
|
|
27
|
+
length: {
|
|
28
|
+
label: "Response Length",
|
|
29
|
+
description:
|
|
30
|
+
"Appended to the user message when the selected length changes.",
|
|
31
|
+
default: "normal",
|
|
32
|
+
options: {
|
|
33
|
+
concise:
|
|
34
|
+
"Keep the response concise. Aim for roughly 300 characters of text when possible. These limits do not apply to mermaid diagrams. You can generate as many as you want to explain the solution effectively. Prioritize diagrams over text.",
|
|
35
|
+
moderate:
|
|
36
|
+
"Keep the response moderately detailed. Aim for roughly 550 characters of text when possible.",
|
|
37
|
+
normal:
|
|
38
|
+
"Use a fuller answer with enough context to explain the idea clearly.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
style: {
|
|
42
|
+
label: "Response Style",
|
|
43
|
+
description:
|
|
44
|
+
"Appended to the user message when the selected style changes.",
|
|
45
|
+
default: "prose",
|
|
46
|
+
options: {
|
|
47
|
+
prose:
|
|
48
|
+
"Use natural prose with short paragraphs. Avoid bullet lists and numbered lists unless I explicitly ask for them.",
|
|
49
|
+
bullets: "Use bullet points and short lists as the main format.",
|
|
50
|
+
structured:
|
|
51
|
+
"Use structured sections with headings and numbered steps when helpful.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
audience: {
|
|
55
|
+
label: "Response Audience",
|
|
56
|
+
description:
|
|
57
|
+
"Appended to the user message when the selected audience changes.",
|
|
58
|
+
default: "normal",
|
|
59
|
+
options: {
|
|
60
|
+
normal: "",
|
|
61
|
+
beginner:
|
|
62
|
+
"When using technical terms or abbreviations, immediately expand their meaning in square brackets right after the term — e.g. 'TCP [Transmission Control Protocol — a connection-oriented transport protocol]' or 'idempotent [an operation that produces the same result no matter how many times it is applied]'. Do this throughout your response so I never need to look anything up.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function Section({
|
|
71
|
+
title,
|
|
72
|
+
children,
|
|
73
|
+
defaultOpen = true,
|
|
74
|
+
trailingAction,
|
|
75
|
+
}: {
|
|
76
|
+
title: string;
|
|
77
|
+
children: React.ReactNode;
|
|
78
|
+
defaultOpen?: boolean;
|
|
79
|
+
trailingAction?: React.ReactNode;
|
|
80
|
+
}) {
|
|
81
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
82
|
+
return (
|
|
83
|
+
<div className="border border-slate-700 rounded-lg overflow-hidden">
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => setOpen((v) => !v)}
|
|
87
|
+
className="w-full flex items-center justify-between px-4 py-3 bg-slate-800/60 hover:bg-slate-800 text-left transition-colors"
|
|
88
|
+
>
|
|
89
|
+
<span className="text-sm font-medium text-slate-200">{title}</span>
|
|
90
|
+
<div className="flex items-center gap-2">
|
|
91
|
+
{trailingAction && (
|
|
92
|
+
<span
|
|
93
|
+
onClick={(e) => e.stopPropagation()}
|
|
94
|
+
className="flex items-center"
|
|
95
|
+
>
|
|
96
|
+
{trailingAction}
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
{open ? (
|
|
100
|
+
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0" />
|
|
101
|
+
) : (
|
|
102
|
+
<ChevronRight className="w-4 h-4 text-slate-400 shrink-0" />
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
</button>
|
|
106
|
+
{open && <div className="p-4 space-y-4 bg-slate-900/40">{children}</div>}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function Label({ children }: { children: React.ReactNode }) {
|
|
112
|
+
return (
|
|
113
|
+
<label className="block text-xs font-medium text-slate-400 mb-1 uppercase tracking-wide">
|
|
114
|
+
{children}
|
|
115
|
+
</label>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function Textarea({
|
|
120
|
+
value,
|
|
121
|
+
onChange,
|
|
122
|
+
rows = 4,
|
|
123
|
+
mono = false,
|
|
124
|
+
}: {
|
|
125
|
+
value: string;
|
|
126
|
+
onChange: (v: string) => void;
|
|
127
|
+
rows?: number;
|
|
128
|
+
mono?: boolean;
|
|
129
|
+
}) {
|
|
130
|
+
return (
|
|
131
|
+
<textarea
|
|
132
|
+
rows={rows}
|
|
133
|
+
value={value}
|
|
134
|
+
onChange={(e) => onChange(e.target.value)}
|
|
135
|
+
className={`w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-500 resize-y ${mono ? "font-mono" : ""}`}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function NumberInput({
|
|
141
|
+
value,
|
|
142
|
+
onChange,
|
|
143
|
+
min,
|
|
144
|
+
max,
|
|
145
|
+
step = 1,
|
|
146
|
+
}: {
|
|
147
|
+
value: number;
|
|
148
|
+
onChange: (v: number) => void;
|
|
149
|
+
min?: number;
|
|
150
|
+
max?: number;
|
|
151
|
+
step?: number;
|
|
152
|
+
}) {
|
|
153
|
+
return (
|
|
154
|
+
<input
|
|
155
|
+
type="number"
|
|
156
|
+
value={value}
|
|
157
|
+
min={min}
|
|
158
|
+
max={max}
|
|
159
|
+
step={step}
|
|
160
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
161
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Generic prompt-group section ───────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
function PromptGroupSection({
|
|
169
|
+
groupKey,
|
|
170
|
+
group,
|
|
171
|
+
onOptionChange,
|
|
172
|
+
onDefaultChange,
|
|
173
|
+
onAddOption,
|
|
174
|
+
onRemoveOption,
|
|
175
|
+
onRemoveGroup,
|
|
176
|
+
onMetaChange,
|
|
177
|
+
}: {
|
|
178
|
+
groupKey: string;
|
|
179
|
+
group: PromptGroup;
|
|
180
|
+
onOptionChange: (optKey: string, value: string) => void;
|
|
181
|
+
onDefaultChange: (optKey: string) => void;
|
|
182
|
+
onAddOption: (optKey: string, prompt: string) => void;
|
|
183
|
+
onRemoveOption: (optKey: string) => void;
|
|
184
|
+
onRemoveGroup: () => void;
|
|
185
|
+
onMetaChange: (field: "label" | "description", value: string) => void;
|
|
186
|
+
}) {
|
|
187
|
+
const [newOptKey, setNewOptKey] = useState("");
|
|
188
|
+
const [newOptPrompt, setNewOptPrompt] = useState("");
|
|
189
|
+
const [showAddOpt, setShowAddOpt] = useState(false);
|
|
190
|
+
const optionCount = Object.keys(group.options).length;
|
|
191
|
+
|
|
192
|
+
const handleAddOption = () => {
|
|
193
|
+
const key = newOptKey.trim();
|
|
194
|
+
if (!key || key in group.options) return;
|
|
195
|
+
onAddOption(key, newOptPrompt);
|
|
196
|
+
setNewOptKey("");
|
|
197
|
+
setNewOptPrompt("");
|
|
198
|
+
setShowAddOpt(false);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<Section
|
|
203
|
+
title={group.label || groupKey}
|
|
204
|
+
trailingAction={
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={onRemoveGroup}
|
|
208
|
+
title="Delete this group"
|
|
209
|
+
className="p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
210
|
+
>
|
|
211
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
212
|
+
</button>
|
|
213
|
+
}
|
|
214
|
+
>
|
|
215
|
+
{/* Meta fields */}
|
|
216
|
+
<div className="grid grid-cols-2 gap-3">
|
|
217
|
+
<div>
|
|
218
|
+
<Label>Label (shown in bottom bar)</Label>
|
|
219
|
+
<input
|
|
220
|
+
type="text"
|
|
221
|
+
value={group.label}
|
|
222
|
+
onChange={(e) => onMetaChange("label", e.target.value)}
|
|
223
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
<div>
|
|
227
|
+
<Label>Description</Label>
|
|
228
|
+
<input
|
|
229
|
+
type="text"
|
|
230
|
+
value={group.description ?? ""}
|
|
231
|
+
onChange={(e) => onMetaChange("description", e.target.value)}
|
|
232
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Options */}
|
|
238
|
+
{Object.entries(group.options).map(([optKey, optValue]) => (
|
|
239
|
+
<div key={optKey} className="flex gap-3 items-start">
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
title="Set as default"
|
|
243
|
+
onClick={() => onDefaultChange(optKey)}
|
|
244
|
+
className={`mt-6 shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
|
|
245
|
+
group.default === optKey
|
|
246
|
+
? "border-cyan-400 bg-cyan-400"
|
|
247
|
+
: "border-slate-600 hover:border-slate-400"
|
|
248
|
+
}`}
|
|
249
|
+
>
|
|
250
|
+
{group.default === optKey && (
|
|
251
|
+
<span className="w-1.5 h-1.5 rounded-full bg-slate-900" />
|
|
252
|
+
)}
|
|
253
|
+
</button>
|
|
254
|
+
<div className="flex-1">
|
|
255
|
+
<Label>
|
|
256
|
+
{optKey}
|
|
257
|
+
{group.default === optKey && (
|
|
258
|
+
<span className="ml-2 text-cyan-400 normal-case font-normal">
|
|
259
|
+
(default)
|
|
260
|
+
</span>
|
|
261
|
+
)}
|
|
262
|
+
</Label>
|
|
263
|
+
<Textarea
|
|
264
|
+
value={optValue}
|
|
265
|
+
onChange={(v) => onOptionChange(optKey, v)}
|
|
266
|
+
rows={optValue.length > 120 ? 3 : 2}
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
{optionCount > 1 && (
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
title="Remove option"
|
|
273
|
+
onClick={() => onRemoveOption(optKey)}
|
|
274
|
+
className="mt-6 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
|
275
|
+
>
|
|
276
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
277
|
+
</button>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
))}
|
|
281
|
+
|
|
282
|
+
{/* Add option */}
|
|
283
|
+
{showAddOpt ? (
|
|
284
|
+
<div className="border border-slate-700 rounded-lg p-3 space-y-2 bg-slate-800/40">
|
|
285
|
+
<p className="text-xs font-medium text-slate-400">New option</p>
|
|
286
|
+
<div className="flex gap-2">
|
|
287
|
+
<div className="w-36">
|
|
288
|
+
<Label>Key (e.g. "verbose")</Label>
|
|
289
|
+
<input
|
|
290
|
+
type="text"
|
|
291
|
+
value={newOptKey}
|
|
292
|
+
onChange={(e) =>
|
|
293
|
+
setNewOptKey(
|
|
294
|
+
e.target.value.toLowerCase().replace(/\s+/g, "-"),
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
placeholder="key"
|
|
298
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
<div className="flex-1">
|
|
302
|
+
<Label>Prompt text</Label>
|
|
303
|
+
<input
|
|
304
|
+
type="text"
|
|
305
|
+
value={newOptPrompt}
|
|
306
|
+
onChange={(e) => setNewOptPrompt(e.target.value)}
|
|
307
|
+
placeholder="Appended to user message when selected"
|
|
308
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
<div className="flex gap-2">
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
onClick={handleAddOption}
|
|
316
|
+
disabled={!newOptKey.trim() || newOptKey in group.options}
|
|
317
|
+
className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
|
|
318
|
+
>
|
|
319
|
+
Add
|
|
320
|
+
</button>
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
onClick={() => {
|
|
324
|
+
setShowAddOpt(false);
|
|
325
|
+
setNewOptKey("");
|
|
326
|
+
setNewOptPrompt("");
|
|
327
|
+
}}
|
|
328
|
+
className="px-3 py-1 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
329
|
+
>
|
|
330
|
+
Cancel
|
|
331
|
+
</button>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
) : (
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
onClick={() => setShowAddOpt(true)}
|
|
338
|
+
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
|
|
339
|
+
>
|
|
340
|
+
<Plus className="w-3.5 h-3.5" /> Add option
|
|
341
|
+
</button>
|
|
342
|
+
)}
|
|
343
|
+
</Section>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Main modal ───────────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
export default function AiSettingsModal() {
|
|
350
|
+
const { aiSettings, saveAiSettings, closeSettings } = useStore();
|
|
351
|
+
const [draft, setDraft] = useState<AiSettings>(() =>
|
|
352
|
+
JSON.parse(JSON.stringify(aiSettings)),
|
|
353
|
+
);
|
|
354
|
+
const [saving, setSaving] = useState(false);
|
|
355
|
+
const [saved, setSaved] = useState(false);
|
|
356
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
357
|
+
|
|
358
|
+
// ── New group form state ─────────────────────
|
|
359
|
+
const [showNewGroupForm, setShowNewGroupForm] = useState(false);
|
|
360
|
+
const [newGroupKey, setNewGroupKey] = useState("");
|
|
361
|
+
const [newGroupLabel, setNewGroupLabel] = useState("");
|
|
362
|
+
const [newGroupDescription, setNewGroupDescription] = useState("");
|
|
363
|
+
const [newGroupOptions, setNewGroupOptions] = useState([
|
|
364
|
+
{ key: "", prompt: "" },
|
|
365
|
+
]);
|
|
366
|
+
|
|
367
|
+
function resetNewGroupForm() {
|
|
368
|
+
setShowNewGroupForm(false);
|
|
369
|
+
setNewGroupKey("");
|
|
370
|
+
setNewGroupLabel("");
|
|
371
|
+
setNewGroupDescription("");
|
|
372
|
+
setNewGroupOptions([{ key: "", prompt: "" }]);
|
|
373
|
+
}
|
|
374
|
+
// ───────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
// Sync draft if settings change externally (e.g. first fetch)
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
setDraft(JSON.parse(JSON.stringify(aiSettings)));
|
|
379
|
+
}, [aiSettings]);
|
|
380
|
+
|
|
381
|
+
// Close on Escape
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
const handler = (e: KeyboardEvent) => {
|
|
384
|
+
if (e.key === "Escape") closeSettings();
|
|
385
|
+
};
|
|
386
|
+
window.addEventListener("keydown", handler);
|
|
387
|
+
return () => window.removeEventListener("keydown", handler);
|
|
388
|
+
}, [closeSettings]);
|
|
389
|
+
|
|
390
|
+
// ── Patch helpers ─────────────────────────────────────────────
|
|
391
|
+
function patchTop<K extends keyof AiSettings>(key: K, value: AiSettings[K]) {
|
|
392
|
+
setDraft((d) => ({ ...d, [key]: value }));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function patchProfileField(
|
|
396
|
+
profile: string,
|
|
397
|
+
field: "maxOutputTokens" | "maxSteps",
|
|
398
|
+
value: number,
|
|
399
|
+
) {
|
|
400
|
+
setDraft((d) => ({
|
|
401
|
+
...d,
|
|
402
|
+
responseProfiles: {
|
|
403
|
+
...d.responseProfiles,
|
|
404
|
+
[profile]: { ...d.responseProfiles[profile], [field]: value },
|
|
405
|
+
},
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function patchGroupOption(groupKey: string, optKey: string, value: string) {
|
|
410
|
+
setDraft((d) => ({
|
|
411
|
+
...d,
|
|
412
|
+
promptGroups: {
|
|
413
|
+
...d.promptGroups,
|
|
414
|
+
[groupKey]: {
|
|
415
|
+
...d.promptGroups[groupKey],
|
|
416
|
+
options: { ...d.promptGroups[groupKey].options, [optKey]: value },
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function patchGroupDefault(groupKey: string, optKey: string) {
|
|
423
|
+
setDraft((d) => ({
|
|
424
|
+
...d,
|
|
425
|
+
promptGroups: {
|
|
426
|
+
...d.promptGroups,
|
|
427
|
+
[groupKey]: { ...d.promptGroups[groupKey], default: optKey },
|
|
428
|
+
},
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function patchGroupMeta(
|
|
433
|
+
groupKey: string,
|
|
434
|
+
field: "label" | "description",
|
|
435
|
+
value: string,
|
|
436
|
+
) {
|
|
437
|
+
setDraft((d) => ({
|
|
438
|
+
...d,
|
|
439
|
+
promptGroups: {
|
|
440
|
+
...d.promptGroups,
|
|
441
|
+
[groupKey]: { ...d.promptGroups[groupKey], [field]: value },
|
|
442
|
+
},
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function addGroupOption(groupKey: string, optKey: string, prompt: string) {
|
|
447
|
+
setDraft((d) => {
|
|
448
|
+
const g = d.promptGroups[groupKey];
|
|
449
|
+
return {
|
|
450
|
+
...d,
|
|
451
|
+
promptGroups: {
|
|
452
|
+
...d.promptGroups,
|
|
453
|
+
[groupKey]: {
|
|
454
|
+
...g,
|
|
455
|
+
options: { ...g.options, [optKey]: prompt },
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function removeGroupOption(groupKey: string, optKey: string) {
|
|
463
|
+
setDraft((d) => {
|
|
464
|
+
const g = d.promptGroups[groupKey];
|
|
465
|
+
const newOptions = { ...g.options };
|
|
466
|
+
delete newOptions[optKey];
|
|
467
|
+
const firstKey = Object.keys(newOptions)[0] ?? "";
|
|
468
|
+
return {
|
|
469
|
+
...d,
|
|
470
|
+
promptGroups: {
|
|
471
|
+
...d.promptGroups,
|
|
472
|
+
[groupKey]: {
|
|
473
|
+
...g,
|
|
474
|
+
options: newOptions,
|
|
475
|
+
default: g.default === optKey ? firstKey : g.default,
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function removeGroup(groupKey: string) {
|
|
483
|
+
if (
|
|
484
|
+
!window.confirm(
|
|
485
|
+
`Delete the “${draft.promptGroups[groupKey]?.label || groupKey}” group? This will also remove it from the bottom bar.`,
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
return;
|
|
489
|
+
setDraft((d) => {
|
|
490
|
+
const newGroups = { ...d.promptGroups };
|
|
491
|
+
delete newGroups[groupKey];
|
|
492
|
+
return { ...d, promptGroups: newGroups };
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function addNewGroup() {
|
|
497
|
+
const key = newGroupKey.trim().toLowerCase().replace(/\s+/g, "-");
|
|
498
|
+
if (!key || key in draft.promptGroups) return;
|
|
499
|
+
if (newGroupOptions.some((o) => !o.key.trim())) return;
|
|
500
|
+
const options = Object.fromEntries(
|
|
501
|
+
newGroupOptions.map((o) => [o.key.trim(), o.prompt]),
|
|
502
|
+
);
|
|
503
|
+
const firstKey = Object.keys(options)[0] ?? "";
|
|
504
|
+
setDraft((d) => ({
|
|
505
|
+
...d,
|
|
506
|
+
promptGroups: {
|
|
507
|
+
...d.promptGroups,
|
|
508
|
+
[key]: {
|
|
509
|
+
label: newGroupLabel.trim() || key,
|
|
510
|
+
description: newGroupDescription.trim() || undefined,
|
|
511
|
+
default: firstKey,
|
|
512
|
+
options,
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
}));
|
|
516
|
+
resetNewGroupForm();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── Actions ───────────────────────────────────────────────────
|
|
520
|
+
async function handleSave() {
|
|
521
|
+
if (saving) return;
|
|
522
|
+
setSaving(true);
|
|
523
|
+
try {
|
|
524
|
+
await saveAiSettings(draft);
|
|
525
|
+
setSaved(true);
|
|
526
|
+
setTimeout(() => setSaved(false), 2000);
|
|
527
|
+
} finally {
|
|
528
|
+
setSaving(false);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function handleReset() {
|
|
533
|
+
if (!window.confirm("Reset all settings to the committed baseline?"))
|
|
534
|
+
return;
|
|
535
|
+
setDraft(JSON.parse(JSON.stringify(BASELINE)));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ── Render ────────────────────────────────────────────────────
|
|
539
|
+
const profileKeys = Object.keys(draft.responseProfiles);
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<div
|
|
543
|
+
ref={overlayRef}
|
|
544
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
|
545
|
+
onMouseDown={(e) => {
|
|
546
|
+
if (e.target === overlayRef.current) closeSettings();
|
|
547
|
+
}}
|
|
548
|
+
>
|
|
549
|
+
<div className="relative w-full max-w-3xl max-h-[90vh] flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl">
|
|
550
|
+
{/* Header */}
|
|
551
|
+
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-700 shrink-0">
|
|
552
|
+
<h2 className="text-base font-semibold text-slate-100">
|
|
553
|
+
AI Settings
|
|
554
|
+
</h2>
|
|
555
|
+
<div className="flex items-center gap-2">
|
|
556
|
+
<button
|
|
557
|
+
type="button"
|
|
558
|
+
onClick={handleReset}
|
|
559
|
+
title="Reset to baseline defaults"
|
|
560
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
561
|
+
>
|
|
562
|
+
<RotateCcw className="w-3.5 h-3.5" />
|
|
563
|
+
Reset to defaults
|
|
564
|
+
</button>
|
|
565
|
+
<button
|
|
566
|
+
type="button"
|
|
567
|
+
onClick={closeSettings}
|
|
568
|
+
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
569
|
+
>
|
|
570
|
+
<X className="w-4 h-4" />
|
|
571
|
+
</button>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
|
|
575
|
+
{/* Scrollable body */}
|
|
576
|
+
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
|
|
577
|
+
{/* ── System Prompt ─────────────────────────────────── */}
|
|
578
|
+
<Section title="System Prompt">
|
|
579
|
+
<Textarea
|
|
580
|
+
value={draft.systemPrompt}
|
|
581
|
+
onChange={(v) => patchTop("systemPrompt", v)}
|
|
582
|
+
rows={8}
|
|
583
|
+
/>
|
|
584
|
+
</Section>
|
|
585
|
+
|
|
586
|
+
{/* ── Response Profiles ─────────────────────────────── */}
|
|
587
|
+
<Section title="Response Profiles (token limits per length setting)">
|
|
588
|
+
<div className="grid grid-cols-3 gap-4">
|
|
589
|
+
{profileKeys.map((key) => (
|
|
590
|
+
<div key={key} className="space-y-3">
|
|
591
|
+
<p className="text-xs font-semibold text-cyan-400 capitalize">
|
|
592
|
+
{key}
|
|
593
|
+
</p>
|
|
594
|
+
<div>
|
|
595
|
+
<Label>Max Output Tokens</Label>
|
|
596
|
+
<NumberInput
|
|
597
|
+
value={draft.responseProfiles[key].maxOutputTokens}
|
|
598
|
+
onChange={(v) =>
|
|
599
|
+
patchProfileField(key, "maxOutputTokens", v)
|
|
600
|
+
}
|
|
601
|
+
min={100}
|
|
602
|
+
max={32000}
|
|
603
|
+
step={100}
|
|
604
|
+
/>
|
|
605
|
+
</div>
|
|
606
|
+
<div>
|
|
607
|
+
<Label>Max Steps</Label>
|
|
608
|
+
<NumberInput
|
|
609
|
+
value={draft.responseProfiles[key].maxSteps}
|
|
610
|
+
onChange={(v) => patchProfileField(key, "maxSteps", v)}
|
|
611
|
+
min={1}
|
|
612
|
+
max={20}
|
|
613
|
+
/>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
))}
|
|
617
|
+
</div>
|
|
618
|
+
</Section>
|
|
619
|
+
|
|
620
|
+
{/* ── Prompt Groups ─────────────────────────────────── */}
|
|
621
|
+
{Object.entries(draft.promptGroups).map(([groupKey, group]) => (
|
|
622
|
+
<PromptGroupSection
|
|
623
|
+
key={groupKey}
|
|
624
|
+
groupKey={groupKey}
|
|
625
|
+
group={group}
|
|
626
|
+
onOptionChange={(optKey, value) =>
|
|
627
|
+
patchGroupOption(groupKey, optKey, value)
|
|
628
|
+
}
|
|
629
|
+
onDefaultChange={(optKey) => patchGroupDefault(groupKey, optKey)}
|
|
630
|
+
onAddOption={(optKey, prompt) =>
|
|
631
|
+
addGroupOption(groupKey, optKey, prompt)
|
|
632
|
+
}
|
|
633
|
+
onRemoveOption={(optKey) => removeGroupOption(groupKey, optKey)}
|
|
634
|
+
onRemoveGroup={() => removeGroup(groupKey)}
|
|
635
|
+
onMetaChange={(field, value) =>
|
|
636
|
+
patchGroupMeta(groupKey, field, value)
|
|
637
|
+
}
|
|
638
|
+
/>
|
|
639
|
+
))}
|
|
640
|
+
|
|
641
|
+
{/* ── Add group form ─────────────────────────────── */}
|
|
642
|
+
{showNewGroupForm ? (
|
|
643
|
+
<div className="border border-cyan-600/30 rounded-lg p-4 space-y-4 bg-slate-900/60">
|
|
644
|
+
<p className="text-sm font-medium text-cyan-300">
|
|
645
|
+
New setting group
|
|
646
|
+
</p>
|
|
647
|
+
<div className="grid grid-cols-3 gap-3">
|
|
648
|
+
<div>
|
|
649
|
+
<Label>Key (internal ID)</Label>
|
|
650
|
+
<input
|
|
651
|
+
type="text"
|
|
652
|
+
value={newGroupKey}
|
|
653
|
+
onChange={(e) =>
|
|
654
|
+
setNewGroupKey(
|
|
655
|
+
e.target.value.toLowerCase().replace(/\s+/g, "-"),
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
placeholder="e.g. tone"
|
|
659
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
660
|
+
/>
|
|
661
|
+
{newGroupKey && newGroupKey in draft.promptGroups && (
|
|
662
|
+
<p className="text-xs text-red-400 mt-1">
|
|
663
|
+
Key already exists.
|
|
664
|
+
</p>
|
|
665
|
+
)}
|
|
666
|
+
</div>
|
|
667
|
+
<div>
|
|
668
|
+
<Label>Label (shown in bottom bar)</Label>
|
|
669
|
+
<input
|
|
670
|
+
type="text"
|
|
671
|
+
value={newGroupLabel}
|
|
672
|
+
onChange={(e) => setNewGroupLabel(e.target.value)}
|
|
673
|
+
placeholder="e.g. Response Tone"
|
|
674
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
675
|
+
/>
|
|
676
|
+
</div>
|
|
677
|
+
<div>
|
|
678
|
+
<Label>Description (optional)</Label>
|
|
679
|
+
<input
|
|
680
|
+
type="text"
|
|
681
|
+
value={newGroupDescription}
|
|
682
|
+
onChange={(e) => setNewGroupDescription(e.target.value)}
|
|
683
|
+
placeholder="Shown in the settings panel"
|
|
684
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
685
|
+
/>
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
|
|
689
|
+
<div className="space-y-2">
|
|
690
|
+
<Label>Options (first will be the default)</Label>
|
|
691
|
+
{newGroupOptions.map((opt, i) => (
|
|
692
|
+
<div key={i} className="flex gap-2 items-start">
|
|
693
|
+
<input
|
|
694
|
+
type="text"
|
|
695
|
+
value={opt.key}
|
|
696
|
+
onChange={(e) => {
|
|
697
|
+
const upd = [...newGroupOptions];
|
|
698
|
+
upd[i] = {
|
|
699
|
+
...upd[i],
|
|
700
|
+
key: e.target.value
|
|
701
|
+
.toLowerCase()
|
|
702
|
+
.replace(/\s+/g, "-"),
|
|
703
|
+
};
|
|
704
|
+
setNewGroupOptions(upd);
|
|
705
|
+
}}
|
|
706
|
+
placeholder="key"
|
|
707
|
+
className="w-28 shrink-0 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
708
|
+
/>
|
|
709
|
+
<input
|
|
710
|
+
type="text"
|
|
711
|
+
value={opt.prompt}
|
|
712
|
+
onChange={(e) => {
|
|
713
|
+
const upd = [...newGroupOptions];
|
|
714
|
+
upd[i] = { ...upd[i], prompt: e.target.value };
|
|
715
|
+
setNewGroupOptions(upd);
|
|
716
|
+
}}
|
|
717
|
+
placeholder="Prompt text appended to message when selected (can be empty)"
|
|
718
|
+
className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
719
|
+
/>
|
|
720
|
+
{newGroupOptions.length > 1 && (
|
|
721
|
+
<button
|
|
722
|
+
type="button"
|
|
723
|
+
onClick={() =>
|
|
724
|
+
setNewGroupOptions((prev) =>
|
|
725
|
+
prev.filter((_, j) => j !== i),
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
className="mt-1 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
729
|
+
>
|
|
730
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
731
|
+
</button>
|
|
732
|
+
)}
|
|
733
|
+
</div>
|
|
734
|
+
))}
|
|
735
|
+
<button
|
|
736
|
+
type="button"
|
|
737
|
+
onClick={() =>
|
|
738
|
+
setNewGroupOptions((prev) => [
|
|
739
|
+
...prev,
|
|
740
|
+
{ key: "", prompt: "" },
|
|
741
|
+
])
|
|
742
|
+
}
|
|
743
|
+
className="flex items-center gap-1 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
|
|
744
|
+
>
|
|
745
|
+
<Plus className="w-3.5 h-3.5" /> Add option row
|
|
746
|
+
</button>
|
|
747
|
+
</div>
|
|
748
|
+
|
|
749
|
+
<div className="flex gap-2 pt-1">
|
|
750
|
+
<button
|
|
751
|
+
type="button"
|
|
752
|
+
onClick={addNewGroup}
|
|
753
|
+
disabled={
|
|
754
|
+
!newGroupKey.trim() ||
|
|
755
|
+
newGroupKey in draft.promptGroups ||
|
|
756
|
+
newGroupOptions.every((o) => !o.key.trim())
|
|
757
|
+
}
|
|
758
|
+
className="px-4 py-1.5 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
|
|
759
|
+
>
|
|
760
|
+
Create group
|
|
761
|
+
</button>
|
|
762
|
+
<button
|
|
763
|
+
type="button"
|
|
764
|
+
onClick={resetNewGroupForm}
|
|
765
|
+
className="px-4 py-1.5 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
766
|
+
>
|
|
767
|
+
Cancel
|
|
768
|
+
</button>
|
|
769
|
+
</div>
|
|
770
|
+
</div>
|
|
771
|
+
) : (
|
|
772
|
+
<button
|
|
773
|
+
type="button"
|
|
774
|
+
onClick={() => setShowNewGroupForm(true)}
|
|
775
|
+
className="flex items-center gap-2 w-full justify-center py-2.5 border border-dashed border-slate-700 hover:border-cyan-600/50 rounded-lg text-sm text-slate-500 hover:text-cyan-400 transition-colors"
|
|
776
|
+
>
|
|
777
|
+
<Plus className="w-4 h-4" /> Add setting group
|
|
778
|
+
</button>
|
|
779
|
+
)}
|
|
780
|
+
|
|
781
|
+
{/* ── Viz Guide ─────────────────────────────────────── */}
|
|
782
|
+
<Section
|
|
783
|
+
title="Viz Guide (returned by getVizGuide tool)"
|
|
784
|
+
defaultOpen={false}
|
|
785
|
+
>
|
|
786
|
+
<p className="text-xs text-slate-500 -mt-1">
|
|
787
|
+
The full VizCraft spec reference the AI receives when it calls{" "}
|
|
788
|
+
<code className="text-cyan-400">getVizGuide()</code>.
|
|
789
|
+
</p>
|
|
790
|
+
<Textarea
|
|
791
|
+
value={draft.vizGuide}
|
|
792
|
+
onChange={(v) => patchTop("vizGuide", v)}
|
|
793
|
+
rows={16}
|
|
794
|
+
mono
|
|
795
|
+
/>
|
|
796
|
+
</Section>
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
{/* Footer */}
|
|
800
|
+
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-700 shrink-0">
|
|
801
|
+
<button
|
|
802
|
+
type="button"
|
|
803
|
+
onClick={closeSettings}
|
|
804
|
+
className="px-4 py-2 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
805
|
+
>
|
|
806
|
+
Cancel
|
|
807
|
+
</button>
|
|
808
|
+
<button
|
|
809
|
+
type="button"
|
|
810
|
+
onClick={handleSave}
|
|
811
|
+
disabled={saving}
|
|
812
|
+
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-60 text-white transition-colors"
|
|
813
|
+
>
|
|
814
|
+
{saving ? (
|
|
815
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
816
|
+
) : saved ? (
|
|
817
|
+
<Check className="w-4 h-4" />
|
|
818
|
+
) : (
|
|
819
|
+
<Save className="w-4 h-4" />
|
|
820
|
+
)}
|
|
821
|
+
{saved ? "Saved!" : "Save changes"}
|
|
822
|
+
</button>
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
</div>
|
|
826
|
+
);
|
|
827
|
+
}
|