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.
Files changed (28) hide show
  1. package/README.md +23 -0
  2. package/package.json +1 -1
  3. package/template/client/package-lock.json +42 -0
  4. package/template/client/package.json +5 -0
  5. package/template/client/src/App.tsx +45 -12
  6. package/template/client/src/api.ts +174 -0
  7. package/template/client/src/components/AiSettingsModal.tsx +1041 -0
  8. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  9. package/template/client/src/components/ChatMessage.tsx +110 -27
  10. package/template/client/src/components/ChatView.tsx +239 -137
  11. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  12. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  13. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  14. package/template/client/src/components/DocRefModal.tsx +502 -0
  15. package/template/client/src/components/FileAttachments.tsx +109 -9
  16. package/template/client/src/components/FilePickerModal.tsx +181 -0
  17. package/template/client/src/components/FileViewerModal.tsx +406 -28
  18. package/template/client/src/components/MarkdownRenderer.tsx +210 -2
  19. package/template/client/src/components/Sidebar.tsx +213 -125
  20. package/template/client/src/components/TextAnnotator.tsx +8 -15
  21. package/template/client/src/components/VizCraftEmbed.tsx +645 -0
  22. package/template/client/src/store.ts +275 -0
  23. package/template/client/src/types.ts +9 -0
  24. package/template/cockpit.json +1 -1
  25. package/template/data/ai-settings.json +49 -0
  26. package/template/server/src/google-drive.ts +109 -1
  27. package/template/server/src/index.ts +1187 -76
  28. 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 { useEffect, useRef, useState, useMemo, useCallback } from "react";
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 ResponseLength = "normal" | "moderate" | "concise";
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
- responseLength: ResponseLength,
68
- responseStyle: ResponseStyle,
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
- if (cache.length !== responseLength) {
74
- updates.push(responseLengthPrompts[responseLength]);
75
- }
76
-
77
- if (cache.style !== responseStyle) {
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 [responseLength, setResponseLength] =
167
- useState<ResponseLength>("normal");
168
- const [responseStyle, setResponseStyle] = useState<ResponseStyle>("prose");
169
- const [responseAudience, setResponseAudience] =
170
- useState<ResponseAudience>("normal");
171
- const [alwaysSendPrefs, setAlwaysSendPrefs] = useState(false);
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
- responseStyle,
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
- responseStyle,
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.responseLength,
272
- requestOptionsRef.current.responseStyle,
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: [{ type: "text" as const, text: m.content }],
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
- responseLength={responseLength}
574
- responseStyle={responseStyle}
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-4 mt-2">
709
- <div className="flex items-center gap-1.5">
710
- <span className="text-[10px] text-slate-500 uppercase tracking-wider">
711
- Length
712
- </span>
713
- {(["normal", "moderate", "concise"] as const).map((opt) => (
714
- <button
715
- key={opt}
716
- onClick={() => setResponseLength(opt)}
717
- className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
718
- responseLength === opt
719
- ? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
720
- : "text-slate-500 hover:text-slate-300 border border-transparent"
721
- }`}
722
- >
723
- {opt.charAt(0).toUpperCase() + opt.slice(1)}
724
- </button>
725
- ))}
726
- </div>
727
- <div className="w-px h-4 bg-slate-700" />
728
- <div className="flex items-center gap-1.5">
729
- <span className="text-[10px] text-slate-500 uppercase tracking-wider">
730
- Style
731
- </span>
732
- {(
733
- [
734
- ["prose", "Prose"],
735
- ["bullets", "Bullets"],
736
- ["structured", "Structured"],
737
- ] as const
738
- ).map(([key, label]) => (
739
- <button
740
- key={key}
741
- onClick={() => setResponseStyle(key)}
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={