create-interview-cockpit 0.4.0 → 0.6.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 (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +753 -1
  3. package/template/client/package.json +4 -0
  4. package/template/client/src/App.tsx +20 -0
  5. package/template/client/src/api.ts +455 -3
  6. package/template/client/src/components/AiSettingsModal.tsx +855 -248
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +132 -27
  9. package/template/client/src/components/ChatView.tsx +365 -123
  10. package/template/client/src/components/CodeContextPanel.tsx +714 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
  13. package/template/client/src/components/DocRefModal.tsx +551 -0
  14. package/template/client/src/components/FileAttachments.tsx +128 -12
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  18. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  19. package/template/client/src/components/MarkdownRenderer.tsx +219 -2
  20. package/template/client/src/components/NotesModal.tsx +977 -0
  21. package/template/client/src/components/PlotEmbed.tsx +173 -0
  22. package/template/client/src/components/Sidebar.tsx +397 -127
  23. package/template/client/src/components/TextAnnotator.tsx +8 -15
  24. package/template/client/src/components/VizCraftEmbed.tsx +412 -25
  25. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  26. package/template/client/src/infraLab.ts +124 -0
  27. package/template/client/src/reactLab.ts +477 -0
  28. package/template/client/src/store.ts +416 -2
  29. package/template/client/src/types.ts +41 -1
  30. package/template/client/tsconfig.tsbuildinfo +1 -1
  31. package/template/cockpit.json +1 -1
  32. package/template/package.json +1 -1
  33. package/template/server/src/google-drive.ts +144 -2
  34. package/template/server/src/index.ts +1890 -188
  35. package/template/server/src/infra-runner.ts +1104 -0
  36. package/template/server/src/storage.ts +274 -3
@@ -32,6 +32,9 @@ function questionsDirPath(id = _activeWorkspaceId) {
32
32
  function contextFilesDirPath(id = _activeWorkspaceId) {
33
33
  return path.join(workspaceDir(id), "context-files");
34
34
  }
35
+ function workspaceFilesFilePath(id = _activeWorkspaceId) {
36
+ return path.join(workspaceDir(id), "workspace-files.json");
37
+ }
35
38
  async function ensureWorkspaceDirs(id = _activeWorkspaceId) {
36
39
  await fs.mkdir(workspaceDir(id), { recursive: true });
37
40
  await fs.mkdir(questionsDirPath(id), { recursive: true });
@@ -42,6 +45,8 @@ export interface Topic {
42
45
  id: string;
43
46
  name: string;
44
47
  contextFiles: ContextFile[];
48
+ /** Topic-wide system prompt prepended to every question's context in this topic. */
49
+ systemContext?: string;
45
50
  createdAt: string;
46
51
  }
47
52
 
@@ -51,6 +56,17 @@ export interface ContextFile {
51
56
  originalName: string;
52
57
  driveFileId?: string;
53
58
  createdAt: string;
59
+ /** Distinguishes how this file was added. 'upload' = user-uploaded doc,
60
+ * 'user' = code saved from Code Runner, 'ai' = AI-generated code block,
61
+ * 'sandbox' = paired server+client sandbox saved as JSON,
62
+ * 'infra' = Terraform-style infra lab workspace saved as JSON,
63
+ * 'react' = React + TypeScript lab workspace,
64
+ * 'nextjs' = Next.js App Router lab workspace. */
65
+ origin?: "user" | "ai" | "upload" | "sandbox" | "infra" | "react" | "nextjs";
66
+ /** Language hint for code snippets (e.g. 'typescript', 'javascript'). */
67
+ language?: string;
68
+ /** Short display label for code snippets. */
69
+ label?: string;
54
70
  }
55
71
 
56
72
  export interface Message {
@@ -83,10 +99,20 @@ export interface ReadingBookmark {
83
99
  blockIndex: number;
84
100
  }
85
101
 
102
+ export interface StoredCodeAnnotation {
103
+ id: string;
104
+ lineNumber: number;
105
+ lineContent: string;
106
+ prompt: string;
107
+ response: string;
108
+ filePath: string;
109
+ createdAt: string;
110
+ }
111
+
86
112
  export interface Question {
87
113
  id: string;
88
114
  topicId: string;
89
- parentQuestionId?: string;
115
+ parentQuestionId?: string | null;
90
116
  title: string;
91
117
  systemContext: string;
92
118
  codeContextFiles: string[];
@@ -94,6 +120,10 @@ export interface Question {
94
120
  messages: Message[];
95
121
  annotations?: Annotation[];
96
122
  readingBookmark?: ReadingBookmark;
123
+ /** Code-line annotations keyed by file path. */
124
+ codeAnnotations?: { [filePath: string]: StoredCodeAnnotation[] };
125
+ /** IDs of sibling questions whose conversation history is injected as context. */
126
+ linkedConversationIds?: string[];
97
127
  createdAt: string;
98
128
  }
99
129
 
@@ -470,6 +500,11 @@ export async function deleteContextFile(
470
500
  } catch {
471
501
  /* already gone */
472
502
  }
503
+ try {
504
+ await fs.unlink(path.join(contextFilesDirPath(), `${fileId}.orig`));
505
+ } catch {
506
+ /* already gone */
507
+ }
473
508
  const topics = await getTopics();
474
509
  const topic = topics.find((t) => t.id === topicId);
475
510
  if (topic) {
@@ -484,7 +519,117 @@ export async function readContextFileContent(fileId: string): Promise<string> {
484
519
  return fs.readFile(path.join(contextFilesDirPath(), fileId), "utf-8");
485
520
  }
486
521
 
487
- // --- Questions ---
522
+ /**
523
+ * Store the raw original file bytes at {fileId}.orig so downloads can serve the
524
+ * real file rather than the extracted-text version used by the LLM.
525
+ */
526
+ export async function writeOriginalBlob(
527
+ fileId: string,
528
+ buffer: Buffer,
529
+ workspaceId = _activeWorkspaceId,
530
+ ): Promise<void> {
531
+ await ensureWorkspaceDirs(workspaceId);
532
+ await fs.writeFile(
533
+ path.join(contextFilesDirPath(workspaceId), `${fileId}.orig`),
534
+ buffer,
535
+ );
536
+ }
537
+
538
+ /**
539
+ * Read the original file bytes. Returns null if the file was uploaded before
540
+ * original storage was added (graceful fallback).
541
+ */
542
+ export async function readOriginalBlob(
543
+ fileId: string,
544
+ workspaceId = _activeWorkspaceId,
545
+ ): Promise<Buffer | null> {
546
+ try {
547
+ return await fs.readFile(
548
+ path.join(contextFilesDirPath(workspaceId), `${fileId}.orig`),
549
+ );
550
+ } catch {
551
+ return null;
552
+ }
553
+ }
554
+ // --- Workspace-level Context Files ---
555
+ // Stored in data/workspaces/{id}/workspace-files.json
556
+ // Blobs share the same context-files/ dir as topic/question files.
557
+
558
+ export async function getWorkspaceContextFiles(
559
+ workspaceId = _activeWorkspaceId,
560
+ ): Promise<ContextFile[]> {
561
+ try {
562
+ const data = await fs.readFile(
563
+ workspaceFilesFilePath(workspaceId),
564
+ "utf-8",
565
+ );
566
+ return JSON.parse(data) as ContextFile[];
567
+ } catch {
568
+ return [];
569
+ }
570
+ }
571
+
572
+ export async function saveWorkspaceContextFile(
573
+ fileId: string,
574
+ originalName: string,
575
+ buffer: Buffer,
576
+ workspaceId = _activeWorkspaceId,
577
+ ): Promise<ContextFile> {
578
+ await ensureWorkspaceDirs(workspaceId);
579
+ await fs.writeFile(
580
+ path.join(contextFilesDirPath(workspaceId), fileId),
581
+ buffer,
582
+ );
583
+ const cf: ContextFile = {
584
+ id: fileId,
585
+ name: originalName,
586
+ originalName,
587
+ createdAt: new Date().toISOString(),
588
+ };
589
+ const existing = await getWorkspaceContextFiles(workspaceId);
590
+ existing.push(cf);
591
+ await fs.writeFile(
592
+ workspaceFilesFilePath(workspaceId),
593
+ JSON.stringify(existing, null, 2),
594
+ );
595
+ return cf;
596
+ }
597
+
598
+ export async function deleteWorkspaceContextFile(
599
+ fileId: string,
600
+ workspaceId = _activeWorkspaceId,
601
+ ): Promise<void> {
602
+ try {
603
+ await fs.unlink(path.join(contextFilesDirPath(workspaceId), fileId));
604
+ } catch {
605
+ /* already gone */
606
+ }
607
+ try {
608
+ await fs.unlink(
609
+ path.join(contextFilesDirPath(workspaceId), `${fileId}.orig`),
610
+ );
611
+ } catch {
612
+ /* already gone */
613
+ }
614
+ const existing = await getWorkspaceContextFiles(workspaceId);
615
+ const updated = existing.filter((f) => f.id !== fileId);
616
+ await fs.writeFile(
617
+ workspaceFilesFilePath(workspaceId),
618
+ JSON.stringify(updated, null, 2),
619
+ );
620
+ }
621
+
622
+ // --- Workspace-level Context Files ---
623
+
624
+ export async function readWorkspaceContextFileContent(
625
+ fileId: string,
626
+ workspaceId = _activeWorkspaceId,
627
+ ): Promise<string> {
628
+ return fs.readFile(
629
+ path.join(contextFilesDirPath(workspaceId), fileId),
630
+ "utf-8",
631
+ );
632
+ }
488
633
 
489
634
  export async function getQuestion(id: string): Promise<Question | null> {
490
635
  await ensureWorkspaceDirs();
@@ -526,6 +671,31 @@ export async function getQuestionsByTopic(
526
671
  }
527
672
  }
528
673
 
674
+ export async function getAllQuestions(): Promise<Question[]> {
675
+ await ensureWorkspaceDirs();
676
+ try {
677
+ const files = await fs.readdir(questionsDirPath());
678
+ const questions: Question[] = [];
679
+ for (const file of files) {
680
+ if (!file.endsWith(".json")) continue;
681
+ try {
682
+ const data = await fs.readFile(
683
+ path.join(questionsDirPath(), file),
684
+ "utf-8",
685
+ );
686
+ const q: Question = JSON.parse(data);
687
+ if (!q.contextFiles) q.contextFiles = [];
688
+ questions.push(q);
689
+ } catch {
690
+ /* skip malformed */
691
+ }
692
+ }
693
+ return questions;
694
+ } catch {
695
+ return [];
696
+ }
697
+ }
698
+
529
699
  export async function saveQuestion(question: Question): Promise<Question> {
530
700
  await ensureWorkspaceDirs();
531
701
  await fs.writeFile(
@@ -559,14 +729,18 @@ export async function saveQuestionContextFile(
559
729
  fileId: string,
560
730
  originalName: string,
561
731
  buffer: Buffer,
732
+ extra?: { origin?: ContextFile["origin"]; language?: string; label?: string },
562
733
  ): Promise<ContextFile> {
563
734
  await ensureWorkspaceDirs();
564
735
  await fs.writeFile(path.join(contextFilesDirPath(), fileId), buffer);
565
736
  const cf: ContextFile = {
566
737
  id: fileId,
567
- name: originalName,
738
+ name: extra?.label ?? originalName,
568
739
  originalName,
569
740
  createdAt: new Date().toISOString(),
741
+ ...(extra?.origin ? { origin: extra.origin } : {}),
742
+ ...(extra?.language ? { language: extra.language } : {}),
743
+ ...(extra?.label ? { label: extra.label } : {}),
570
744
  };
571
745
  const q = await getQuestion(questionId);
572
746
  if (q) {
@@ -577,6 +751,32 @@ export async function saveQuestionContextFile(
577
751
  return cf;
578
752
  }
579
753
 
754
+ export async function overwriteQuestionContextFileContent(
755
+ questionId: string,
756
+ fileId: string,
757
+ buffer: Buffer,
758
+ ): Promise<void> {
759
+ await ensureWorkspaceDirs();
760
+ await fs.writeFile(path.join(contextFilesDirPath(), fileId), buffer);
761
+ }
762
+
763
+ export async function renameQuestionContextFile(
764
+ questionId: string,
765
+ fileId: string,
766
+ label: string,
767
+ ): Promise<ContextFile> {
768
+ const q = await getQuestion(questionId);
769
+ if (!q) throw new Error("Question not found");
770
+ const cf = (q.contextFiles || []).find((f) => f.id === fileId);
771
+ if (!cf) throw new Error("Context file not found");
772
+ const safe =
773
+ label.replace(/[/\\:*?"<>|]/g, "-").trim() || cf.label || cf.originalName;
774
+ cf.label = safe;
775
+ cf.name = safe;
776
+ await saveQuestion(q);
777
+ return cf;
778
+ }
779
+
580
780
  export async function deleteQuestionContextFile(
581
781
  questionId: string,
582
782
  fileId: string,
@@ -603,6 +803,70 @@ export async function updateQuestionMessages(
603
803
  await saveQuestion(q);
604
804
  }
605
805
 
806
+ export async function updateCodeAnnotationsForFile(
807
+ questionId: string,
808
+ filePath: string,
809
+ annotations: StoredCodeAnnotation[],
810
+ ): Promise<void> {
811
+ const q = await getQuestion(questionId);
812
+ if (!q) throw new Error("Question not found");
813
+ if (!q.codeAnnotations) q.codeAnnotations = {};
814
+ q.codeAnnotations[filePath] = annotations;
815
+ await saveQuestion(q);
816
+ }
817
+
818
+ /**
819
+ * Register an already-uploaded file in a topic's contextFiles without copying
820
+ * any bytes — the file content on disk (identified by fileId) is shared.
821
+ */
822
+ export async function linkContextFileToTopic(
823
+ topicId: string,
824
+ fileId: string,
825
+ originalName: string,
826
+ ): Promise<ContextFile> {
827
+ const cf: ContextFile = {
828
+ id: fileId,
829
+ name: originalName,
830
+ originalName,
831
+ createdAt: new Date().toISOString(),
832
+ };
833
+ const topics = await getTopics();
834
+ const topic = topics.find((t) => t.id === topicId);
835
+ if (topic) {
836
+ if (!topic.contextFiles) topic.contextFiles = [];
837
+ if (!topic.contextFiles.find((f) => f.id === fileId)) {
838
+ topic.contextFiles.push(cf);
839
+ await fs.writeFile(topicsFilePath(), JSON.stringify(topics, null, 2));
840
+ }
841
+ }
842
+ return cf;
843
+ }
844
+
845
+ /**
846
+ * Register an already-uploaded file in a question's contextFiles without copying bytes.
847
+ */
848
+ export async function linkContextFileToQuestion(
849
+ questionId: string,
850
+ fileId: string,
851
+ originalName: string,
852
+ ): Promise<ContextFile> {
853
+ const cf: ContextFile = {
854
+ id: fileId,
855
+ name: originalName,
856
+ originalName,
857
+ createdAt: new Date().toISOString(),
858
+ };
859
+ const q = await getQuestion(questionId);
860
+ if (q) {
861
+ if (!q.contextFiles) q.contextFiles = [];
862
+ if (!q.contextFiles.find((f) => f.id === fileId)) {
863
+ q.contextFiles.push(cf);
864
+ await saveQuestion(q);
865
+ }
866
+ }
867
+ return cf;
868
+ }
869
+
606
870
  // ── AI Settings ───────────────────────────────────────────────
607
871
 
608
872
  const AI_SETTINGS_FILE = path.join(DATA_DIR, "ai-settings.json");
@@ -626,8 +890,14 @@ export interface AiSettings {
626
890
  responseProfiles: Record<string, ResponseProfile>;
627
891
  /** Full viz diagram spec reference — returned by the getVizGuide tool. */
628
892
  vizGuide: string;
893
+ /** Full plotting spec reference — returned by the getPlotGuide tool. */
894
+ plotGuide: string;
629
895
  /** All user-selectable prompt groups. Add new entries here to extend the UI. */
630
896
  promptGroups: Record<string, PromptGroup>;
897
+ /** Gemini thinking budget in tokens. 0 = disabled. Only applies to Google models. */
898
+ thinkingBudget?: number;
899
+ /** When true, preference prompt texts are appended to every message (not just on change). */
900
+ alwaysSendPrefsDefault?: boolean;
631
901
  }
632
902
 
633
903
  const DEFAULT_AI_SETTINGS: AiSettings = {
@@ -639,6 +909,7 @@ const DEFAULT_AI_SETTINGS: AiSettings = {
639
909
  normal: { maxOutputTokens: 3000, maxSteps: 5 },
640
910
  },
641
911
  vizGuide: "No viz guide configured.",
912
+ plotGuide: "No plot guide configured.",
642
913
  promptGroups: {
643
914
  length: {
644
915
  label: "Response Length",