create-interview-cockpit 0.3.0 → 0.5.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 +42 -0
- package/template/client/package.json +5 -0
- package/template/client/src/App.tsx +45 -12
- package/template/client/src/api.ts +174 -0
- package/template/client/src/components/AiSettingsModal.tsx +1041 -0
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +110 -27
- package/template/client/src/components/ChatView.tsx +239 -137
- package/template/client/src/components/CodeContextPanel.tsx +297 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
- package/template/client/src/components/DocRefModal.tsx +502 -0
- package/template/client/src/components/FileAttachments.tsx +109 -9
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/MarkdownRenderer.tsx +210 -2
- package/template/client/src/components/Sidebar.tsx +213 -125
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +645 -0
- package/template/client/src/store.ts +275 -0
- package/template/client/src/types.ts +9 -0
- package/template/cockpit.json +1 -1
- package/template/data/ai-settings.json +49 -0
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1187 -76
- package/template/server/src/storage.ts +359 -2
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { useChat } from "@ai-sdk/react";
|
|
2
2
|
import { DefaultChatTransport } from "ai";
|
|
3
|
-
import type { FileUIPart } from "ai";
|
|
4
|
-
import {
|
|
3
|
+
import type { FileUIPart, UIMessage } from "ai";
|
|
4
|
+
import {
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useMemo,
|
|
9
|
+
useCallback,
|
|
10
|
+
Fragment,
|
|
11
|
+
} from "react";
|
|
5
12
|
import type { Question, Annotation, ReadingBookmark } from "../types";
|
|
6
13
|
import { useStore } from "../store";
|
|
7
14
|
import ChatMessage from "./ChatMessage";
|
|
@@ -13,44 +20,15 @@ import {
|
|
|
13
20
|
RotateCcw,
|
|
14
21
|
ImagePlus,
|
|
15
22
|
X,
|
|
23
|
+
AlertTriangle,
|
|
24
|
+
ChevronRight,
|
|
16
25
|
} from "lucide-react";
|
|
17
26
|
|
|
18
27
|
interface Props {
|
|
19
28
|
question: Question;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
|
-
type
|
|
23
|
-
type ResponseStyle = "prose" | "bullets" | "structured";
|
|
24
|
-
type ResponseAudience = "normal" | "beginner";
|
|
25
|
-
|
|
26
|
-
interface ResponsePreferenceCache {
|
|
27
|
-
length?: ResponseLength;
|
|
28
|
-
style?: ResponseStyle;
|
|
29
|
-
audience?: ResponseAudience;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const responseLengthPrompts: Record<ResponseLength, string> = {
|
|
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
|
-
const responseStylePrompts: Record<ResponseStyle, string> = {
|
|
42
|
-
prose:
|
|
43
|
-
"Use natural prose with short paragraphs. Avoid bullet lists and numbered lists unless I explicitly ask for them.",
|
|
44
|
-
bullets: "Use bullet points and short lists as the main format.",
|
|
45
|
-
structured:
|
|
46
|
-
"Use structured sections with headings and numbered steps when helpful.",
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const responseAudiencePrompts: Record<ResponseAudience, string> = {
|
|
50
|
-
normal: "",
|
|
51
|
-
beginner:
|
|
52
|
-
"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.",
|
|
53
|
-
};
|
|
31
|
+
type ResponsePreferenceCache = Record<string, string | undefined>;
|
|
54
32
|
|
|
55
33
|
function findLastUserMessageIndex(messages: any[]): number {
|
|
56
34
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
@@ -64,23 +42,16 @@ function findLastUserMessageIndex(messages: any[]): number {
|
|
|
64
42
|
|
|
65
43
|
function buildPreferenceSuffix(
|
|
66
44
|
cache: ResponsePreferenceCache,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
responseAudience: ResponseAudience,
|
|
45
|
+
selections: Record<string, string>,
|
|
46
|
+
promptGroups: Record<string, { options: Record<string, string> }>,
|
|
70
47
|
): string {
|
|
71
48
|
const updates: string[] = [];
|
|
72
49
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
updates.push(responseStylePrompts[responseStyle]);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (cache.audience !== responseAudience) {
|
|
82
|
-
const audiencePrompt = responseAudiencePrompts[responseAudience];
|
|
83
|
-
if (audiencePrompt) updates.push(audiencePrompt);
|
|
50
|
+
for (const [key, sel] of Object.entries(selections)) {
|
|
51
|
+
if (cache[key] !== sel) {
|
|
52
|
+
const prompt = promptGroups[key]?.options[sel];
|
|
53
|
+
if (prompt) updates.push(prompt);
|
|
54
|
+
}
|
|
84
55
|
}
|
|
85
56
|
|
|
86
57
|
if (updates.length === 0) {
|
|
@@ -152,23 +123,30 @@ export default function ChatView({ question }: Props) {
|
|
|
152
123
|
refreshCurrentQuestion,
|
|
153
124
|
uploadQuestionFiles,
|
|
154
125
|
removeQuestionFile,
|
|
126
|
+
linkFileToQuestion,
|
|
155
127
|
clearMessages,
|
|
156
128
|
updateQuestionSystemContext,
|
|
157
129
|
topics,
|
|
158
130
|
selectedTopicId,
|
|
159
131
|
codeSnippets,
|
|
132
|
+
aiSettings,
|
|
133
|
+
setLivePreferenceSuffix,
|
|
160
134
|
} = useStore();
|
|
161
135
|
const [showContext, setShowContext] = useState(false);
|
|
162
136
|
const [systemContext, setSystemContext] = useState(
|
|
163
137
|
question.systemContext || "",
|
|
164
138
|
);
|
|
165
139
|
const [input, setInput] = useState("");
|
|
166
|
-
const [
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
140
|
+
const [groupSelections, setGroupSelections] = useState<
|
|
141
|
+
Record<string, string>
|
|
142
|
+
>(() =>
|
|
143
|
+
Object.fromEntries(
|
|
144
|
+
Object.entries(aiSettings.promptGroups).map(([k, g]) => [k, g.default]),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
const [alwaysSendPrefs, setAlwaysSendPrefs] = useState(
|
|
148
|
+
() => aiSettings.alwaysSendPrefsDefault ?? false,
|
|
149
|
+
);
|
|
172
150
|
const [annotations, setAnnotations] = useState<Annotation[]>(
|
|
173
151
|
question.annotations ?? [],
|
|
174
152
|
);
|
|
@@ -183,6 +161,20 @@ export default function ChatView({ question }: Props) {
|
|
|
183
161
|
const responsePreferenceCacheRef = useRef<ResponsePreferenceCache>({});
|
|
184
162
|
const pendingResponsePreferenceCacheRef =
|
|
185
163
|
useRef<ResponsePreferenceCache | null>(null);
|
|
164
|
+
const aiSettingsRef = useRef(aiSettings);
|
|
165
|
+
const lastStreamActivityRef = useRef<number>(Date.now());
|
|
166
|
+
const [isStalled, setIsStalled] = useState(false);
|
|
167
|
+
|
|
168
|
+
// Sync groupSelections when new groups are added via settings
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
setGroupSelections((prev) => {
|
|
171
|
+
const additions: Record<string, string> = {};
|
|
172
|
+
for (const [k, g] of Object.entries(aiSettings.promptGroups)) {
|
|
173
|
+
if (!(k in prev)) additions[k] = g.default;
|
|
174
|
+
}
|
|
175
|
+
return Object.keys(additions).length ? { ...prev, ...additions } : prev;
|
|
176
|
+
});
|
|
177
|
+
}, [aiSettings.promptGroups]);
|
|
186
178
|
|
|
187
179
|
// ── Inline image attachments (per-message, ephemeral) ──────────────────────
|
|
188
180
|
const [attachedImages, setAttachedImages] = useState<
|
|
@@ -232,9 +224,8 @@ export default function ChatView({ question }: Props) {
|
|
|
232
224
|
questionTitle: question.title,
|
|
233
225
|
codeContextFiles: question.codeContextFiles,
|
|
234
226
|
systemContext,
|
|
235
|
-
responseLength,
|
|
236
|
-
|
|
237
|
-
responseAudience,
|
|
227
|
+
responseLength: "normal" as string,
|
|
228
|
+
groupSelections,
|
|
238
229
|
alwaysSendPrefs,
|
|
239
230
|
codeSnippets,
|
|
240
231
|
});
|
|
@@ -250,12 +241,12 @@ export default function ChatView({ question }: Props) {
|
|
|
250
241
|
questionTitle: question.title,
|
|
251
242
|
codeContextFiles: question.codeContextFiles,
|
|
252
243
|
systemContext,
|
|
253
|
-
responseLength,
|
|
254
|
-
|
|
255
|
-
responseAudience,
|
|
244
|
+
responseLength: groupSelections["length"] ?? "normal",
|
|
245
|
+
groupSelections,
|
|
256
246
|
alwaysSendPrefs,
|
|
257
247
|
codeSnippets,
|
|
258
248
|
};
|
|
249
|
+
aiSettingsRef.current = aiSettings;
|
|
259
250
|
|
|
260
251
|
const transport = useMemo(
|
|
261
252
|
() =>
|
|
@@ -268,9 +259,8 @@ export default function ChatView({ question }: Props) {
|
|
|
268
259
|
requestOptionsRef.current.alwaysSendPrefs
|
|
269
260
|
? {}
|
|
270
261
|
: responsePreferenceCacheRef.current,
|
|
271
|
-
requestOptionsRef.current.
|
|
272
|
-
|
|
273
|
-
requestOptionsRef.current.responseAudience,
|
|
262
|
+
requestOptionsRef.current.groupSelections,
|
|
263
|
+
aiSettingsRef.current.promptGroups,
|
|
274
264
|
)
|
|
275
265
|
: "";
|
|
276
266
|
|
|
@@ -280,11 +270,7 @@ export default function ChatView({ question }: Props) {
|
|
|
280
270
|
);
|
|
281
271
|
|
|
282
272
|
pendingResponsePreferenceCacheRef.current = preferenceSuffix
|
|
283
|
-
? {
|
|
284
|
-
length: requestOptionsRef.current.responseLength,
|
|
285
|
-
style: requestOptionsRef.current.responseStyle,
|
|
286
|
-
audience: requestOptionsRef.current.responseAudience,
|
|
287
|
-
}
|
|
273
|
+
? { ...requestOptionsRef.current.groupSelections }
|
|
288
274
|
: null;
|
|
289
275
|
|
|
290
276
|
return {
|
|
@@ -304,10 +290,12 @@ export default function ChatView({ question }: Props) {
|
|
|
304
290
|
// and triggering an update loop.
|
|
305
291
|
const initialMessages = useMemo(
|
|
306
292
|
() =>
|
|
307
|
-
question.messages.map((m) => ({
|
|
293
|
+
(question.messages ?? []).map((m) => ({
|
|
308
294
|
id: m.id,
|
|
309
295
|
role: m.role as "user" | "assistant",
|
|
310
|
-
parts:
|
|
296
|
+
parts: (m.parts ?? [
|
|
297
|
+
{ type: "text" as const, text: m.content },
|
|
298
|
+
]) as UIMessage["parts"],
|
|
311
299
|
})),
|
|
312
300
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
313
301
|
[question.id],
|
|
@@ -337,6 +325,60 @@ export default function ChatView({ question }: Props) {
|
|
|
337
325
|
|
|
338
326
|
const isLoading = status === "streaming" || status === "submitted";
|
|
339
327
|
|
|
328
|
+
// ── Stall detection ────────────────────────────────────────────────────────
|
|
329
|
+
// Track when streaming content last arrived
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (status === "streaming") {
|
|
332
|
+
lastStreamActivityRef.current = Date.now();
|
|
333
|
+
}
|
|
334
|
+
}, [messages, status]);
|
|
335
|
+
|
|
336
|
+
// Start an interval when streaming begins; fire stall if silent for 15s
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (status !== "streaming") {
|
|
339
|
+
setIsStalled(false);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
lastStreamActivityRef.current = Date.now();
|
|
343
|
+
const id = window.setInterval(() => {
|
|
344
|
+
if (Date.now() - lastStreamActivityRef.current > 15_000) {
|
|
345
|
+
setIsStalled(true);
|
|
346
|
+
}
|
|
347
|
+
}, 3_000);
|
|
348
|
+
return () => window.clearInterval(id);
|
|
349
|
+
}, [status]);
|
|
350
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
const handleContinue = useCallback(() => {
|
|
353
|
+
setIsStalled(false);
|
|
354
|
+
sendMessage({ text: "Please continue from where you left off." });
|
|
355
|
+
}, [sendMessage]);
|
|
356
|
+
|
|
357
|
+
// Only show the Continue button if the last assistant message looks truncated
|
|
358
|
+
// (doesn't end with sentence-terminating punctuation or a closing code fence).
|
|
359
|
+
const lastResponseLooksTruncated = useMemo(() => {
|
|
360
|
+
if (messages.length === 0) return false;
|
|
361
|
+
const last = messages[messages.length - 1];
|
|
362
|
+
if (last.role !== "assistant") return false;
|
|
363
|
+
// Find the last text part
|
|
364
|
+
const parts: any[] = (last as any).parts ?? [];
|
|
365
|
+
let text = "";
|
|
366
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
367
|
+
const p = parts[i];
|
|
368
|
+
if (p?.type === "text" && p.text) {
|
|
369
|
+
text = p.text;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const trimmed = text.trimEnd();
|
|
374
|
+
if (!trimmed) return false;
|
|
375
|
+
// Walk back to last non-empty line
|
|
376
|
+
const lines = trimmed.split("\n");
|
|
377
|
+
const lastLine = (lines.filter(Boolean).pop() ?? "").trimEnd();
|
|
378
|
+
// Complete if ends with sentence punctuation or structural close chars
|
|
379
|
+
return !/[.!?`\])>]$/.test(lastLine);
|
|
380
|
+
}, [messages]);
|
|
381
|
+
|
|
340
382
|
useEffect(() => {
|
|
341
383
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
342
384
|
}, [messages]);
|
|
@@ -420,6 +462,64 @@ export default function ChatView({ question }: Props) {
|
|
|
420
462
|
[question.id],
|
|
421
463
|
);
|
|
422
464
|
|
|
465
|
+
// Persist a refined viz spec back to the server so changes survive refresh
|
|
466
|
+
const handleSpecRefined = useCallback(
|
|
467
|
+
(messageId: string, originalSpec: string, newSpec: string) => {
|
|
468
|
+
setMessages((prev) =>
|
|
469
|
+
prev.map((m) => {
|
|
470
|
+
if (m.id !== messageId) return m;
|
|
471
|
+
const updatedParts = (m.parts ?? []).map((p) => {
|
|
472
|
+
if (p.type !== "text") return p;
|
|
473
|
+
const tp = p as { type: "text"; text: string };
|
|
474
|
+
return {
|
|
475
|
+
...tp,
|
|
476
|
+
text: tp.text.split(originalSpec).join(newSpec),
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
return { ...m, parts: updatedParts };
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
// Persist to server — rebuild server-side Message[] from updated UIMessages
|
|
483
|
+
setMessages((prev) => {
|
|
484
|
+
const serverMessages = prev.map((m) => ({
|
|
485
|
+
id: m.id,
|
|
486
|
+
role: m.role,
|
|
487
|
+
content: (m.parts ?? [])
|
|
488
|
+
.filter(
|
|
489
|
+
(p): p is { type: "text"; text: string } => p.type === "text",
|
|
490
|
+
)
|
|
491
|
+
.map((p) => p.text)
|
|
492
|
+
.join(""),
|
|
493
|
+
parts: m.parts,
|
|
494
|
+
}));
|
|
495
|
+
fetch(`/api/questions/${question.id}`, {
|
|
496
|
+
method: "PATCH",
|
|
497
|
+
headers: { "Content-Type": "application/json" },
|
|
498
|
+
body: JSON.stringify({ messages: serverMessages }),
|
|
499
|
+
}).catch((err) =>
|
|
500
|
+
console.error("Failed to persist refined spec:", err),
|
|
501
|
+
);
|
|
502
|
+
return prev; // no further state change
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
[question.id, setMessages],
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
// Build a preference text for annotation dialogs from ALL current group selections
|
|
509
|
+
const annotationPrefs = useMemo(() => {
|
|
510
|
+
const parts: string[] = [];
|
|
511
|
+
for (const [key, sel] of Object.entries(groupSelections)) {
|
|
512
|
+
const prompt = aiSettings.promptGroups[key]?.options[sel];
|
|
513
|
+
if (prompt) parts.push(prompt);
|
|
514
|
+
}
|
|
515
|
+
return parts.join(" ");
|
|
516
|
+
}, [groupSelections, aiSettings.promptGroups]);
|
|
517
|
+
|
|
518
|
+
// Keep the store in sync so FileViewerModal can read the current preference string
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
setLivePreferenceSuffix(annotationPrefs);
|
|
521
|
+
}, [annotationPrefs, setLivePreferenceSuffix]);
|
|
522
|
+
|
|
423
523
|
// Group annotations by message id so we don't run filter() inside render,
|
|
424
524
|
// which would produce a new array reference on every ChatView re-render and
|
|
425
525
|
// defeat React.memo on ChatMessage.
|
|
@@ -570,12 +670,40 @@ export default function ChatView({ question }: Props) {
|
|
|
570
670
|
: undefined
|
|
571
671
|
}
|
|
572
672
|
onSetBookmark={handleSetBookmark}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
responseAudience={responseAudience}
|
|
673
|
+
preferenceSuffix={annotationPrefs}
|
|
674
|
+
onSpecRefined={handleSpecRefined}
|
|
576
675
|
/>
|
|
577
676
|
</div>
|
|
578
677
|
))}
|
|
678
|
+
|
|
679
|
+
{/* Stall warning — shown when streaming has gone silent for 15s */}
|
|
680
|
+
{isStalled && status === "streaming" && (
|
|
681
|
+
<div className="flex items-center gap-3 bg-amber-500/10 border border-amber-500/20 rounded-lg px-4 py-3 text-sm">
|
|
682
|
+
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
|
|
683
|
+
<span className="text-amber-300 flex-1">
|
|
684
|
+
Response appears to have stalled.
|
|
685
|
+
</span>
|
|
686
|
+
<button
|
|
687
|
+
onClick={handleContinue}
|
|
688
|
+
className="flex items-center gap-1 text-xs text-amber-400 hover:text-amber-200 border border-amber-500/30 hover:border-amber-400/60 rounded-md px-2.5 py-1 transition-colors"
|
|
689
|
+
>
|
|
690
|
+
Continue <ChevronRight className="w-3 h-3" />
|
|
691
|
+
</button>
|
|
692
|
+
</div>
|
|
693
|
+
)}
|
|
694
|
+
|
|
695
|
+
{/* Continue button — only shown when the last response looks truncated */}
|
|
696
|
+
{status === "ready" && lastResponseLooksTruncated && (
|
|
697
|
+
<div className="flex justify-start pl-10">
|
|
698
|
+
<button
|
|
699
|
+
onClick={handleContinue}
|
|
700
|
+
className="flex items-center gap-1 text-[11px] text-slate-500 hover:text-cyan-400 border border-slate-700/50 hover:border-cyan-500/40 rounded-md px-2.5 py-1 transition-colors"
|
|
701
|
+
title="Ask the model to continue from where it left off"
|
|
702
|
+
>
|
|
703
|
+
<ChevronRight className="w-3 h-3" /> Continue
|
|
704
|
+
</button>
|
|
705
|
+
</div>
|
|
706
|
+
)}
|
|
579
707
|
{status === "submitted" && (
|
|
580
708
|
<div className="flex items-start gap-3 px-1">
|
|
581
709
|
<div className="w-7 h-7 rounded-lg bg-cyan-600/20 flex items-center justify-center shrink-0 mt-0.5">
|
|
@@ -617,6 +745,10 @@ export default function ChatView({ question }: Props) {
|
|
|
617
745
|
files={question.contextFiles || []}
|
|
618
746
|
onUpload={(files) => uploadQuestionFiles(question.id, files)}
|
|
619
747
|
onRemove={(fileId) => removeQuestionFile(question.id, fileId)}
|
|
748
|
+
onLink={(fileId, originalName) =>
|
|
749
|
+
linkFileToQuestion(question.id, fileId, originalName)
|
|
750
|
+
}
|
|
751
|
+
downloadBase={`/api/questions/${question.id}/context-files`}
|
|
620
752
|
label="question"
|
|
621
753
|
compact
|
|
622
754
|
/>
|
|
@@ -705,70 +837,40 @@ export default function ChatView({ question }: Props) {
|
|
|
705
837
|
</form>
|
|
706
838
|
|
|
707
839
|
{/* Response controls */}
|
|
708
|
-
<div className="flex items-center gap-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
|
|
743
|
-
responseStyle === key
|
|
744
|
-
? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
|
|
745
|
-
: "text-slate-500 hover:text-slate-300 border border-transparent"
|
|
746
|
-
}`}
|
|
747
|
-
>
|
|
748
|
-
{label}
|
|
749
|
-
</button>
|
|
750
|
-
))}
|
|
751
|
-
</div>
|
|
752
|
-
<div className="w-px h-4 bg-slate-700" />
|
|
753
|
-
<div className="flex items-center gap-1.5">
|
|
754
|
-
<span className="text-[10px] text-slate-500 uppercase tracking-wider">
|
|
755
|
-
Mode
|
|
756
|
-
</span>
|
|
757
|
-
{(["normal", "beginner"] as const).map((opt) => (
|
|
758
|
-
<button
|
|
759
|
-
key={opt}
|
|
760
|
-
onClick={() => setResponseAudience(opt)}
|
|
761
|
-
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
|
|
762
|
-
responseAudience === opt
|
|
763
|
-
? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
|
|
764
|
-
: "text-slate-500 hover:text-slate-300 border border-transparent"
|
|
765
|
-
}`}
|
|
766
|
-
>
|
|
767
|
-
{opt.charAt(0).toUpperCase() + opt.slice(1)}
|
|
768
|
-
</button>
|
|
769
|
-
))}
|
|
770
|
-
</div>
|
|
771
|
-
<div className="w-px h-4 bg-slate-700" />
|
|
840
|
+
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
|
841
|
+
{Object.entries(aiSettings.promptGroups).map(
|
|
842
|
+
([groupKey, group], idx) => (
|
|
843
|
+
<Fragment key={groupKey}>
|
|
844
|
+
{idx > 0 && <div className="w-px h-4 bg-slate-700 shrink-0" />}
|
|
845
|
+
<div className="flex items-center gap-1">
|
|
846
|
+
<span className="text-[10px] text-slate-500 uppercase tracking-wider shrink-0">
|
|
847
|
+
{group.label.replace(/^Response\s+/i, "")}
|
|
848
|
+
</span>
|
|
849
|
+
{Object.keys(group.options).map((optKey) => (
|
|
850
|
+
<button
|
|
851
|
+
key={optKey}
|
|
852
|
+
onClick={() =>
|
|
853
|
+
setGroupSelections((p) => ({
|
|
854
|
+
...p,
|
|
855
|
+
[groupKey]: optKey,
|
|
856
|
+
}))
|
|
857
|
+
}
|
|
858
|
+
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
|
|
859
|
+
groupSelections[groupKey] === optKey
|
|
860
|
+
? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
|
|
861
|
+
: "text-slate-500 hover:text-slate-300 border border-transparent"
|
|
862
|
+
}`}
|
|
863
|
+
>
|
|
864
|
+
{optKey.charAt(0).toUpperCase() + optKey.slice(1)}
|
|
865
|
+
</button>
|
|
866
|
+
))}
|
|
867
|
+
</div>
|
|
868
|
+
</Fragment>
|
|
869
|
+
),
|
|
870
|
+
)}
|
|
871
|
+
{Object.keys(aiSettings.promptGroups).length > 0 && (
|
|
872
|
+
<div className="w-px h-4 bg-slate-700 shrink-0" />
|
|
873
|
+
)}
|
|
772
874
|
<button
|
|
773
875
|
onClick={() => setAlwaysSendPrefs((p) => !p)}
|
|
774
876
|
title={
|