create-interview-cockpit 0.4.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 (27) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +19 -0
  3. package/template/client/package.json +3 -0
  4. package/template/client/src/App.tsx +17 -0
  5. package/template/client/src/api.ts +135 -0
  6. package/template/client/src/components/AiSettingsModal.tsx +218 -4
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +110 -27
  9. package/template/client/src/components/ChatView.tsx +69 -4
  10. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  13. package/template/client/src/components/DocRefModal.tsx +502 -0
  14. package/template/client/src/components/FileAttachments.tsx +109 -9
  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/MarkdownRenderer.tsx +205 -2
  18. package/template/client/src/components/Sidebar.tsx +213 -127
  19. package/template/client/src/components/TextAnnotator.tsx +8 -15
  20. package/template/client/src/components/VizCraftEmbed.tsx +162 -19
  21. package/template/client/src/store.ts +201 -0
  22. package/template/client/src/types.ts +8 -0
  23. package/template/cockpit.json +1 -1
  24. package/template/package.json +1 -1
  25. package/template/server/src/google-drive.ts +109 -1
  26. package/template/server/src/index.ts +1107 -46
  27. package/template/server/src/storage.ts +263 -2
@@ -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 });
@@ -51,6 +54,14 @@ export interface ContextFile {
51
54
  originalName: string;
52
55
  driveFileId?: string;
53
56
  createdAt: string;
57
+ /** Distinguishes how this file was added. 'upload' = user-uploaded doc,
58
+ * 'user' = code saved from Code Runner, 'ai' = AI-generated code block,
59
+ * 'sandbox' = paired server+client sandbox saved as JSON. */
60
+ origin?: "user" | "ai" | "upload" | "sandbox";
61
+ /** Language hint for code snippets (e.g. 'typescript', 'javascript'). */
62
+ language?: string;
63
+ /** Short display label for code snippets. */
64
+ label?: string;
54
65
  }
55
66
 
56
67
  export interface Message {
@@ -83,6 +94,16 @@ export interface ReadingBookmark {
83
94
  blockIndex: number;
84
95
  }
85
96
 
97
+ export interface StoredCodeAnnotation {
98
+ id: string;
99
+ lineNumber: number;
100
+ lineContent: string;
101
+ prompt: string;
102
+ response: string;
103
+ filePath: string;
104
+ createdAt: string;
105
+ }
106
+
86
107
  export interface Question {
87
108
  id: string;
88
109
  topicId: string;
@@ -94,6 +115,8 @@ export interface Question {
94
115
  messages: Message[];
95
116
  annotations?: Annotation[];
96
117
  readingBookmark?: ReadingBookmark;
118
+ /** Code-line annotations keyed by file path. */
119
+ codeAnnotations?: { [filePath: string]: StoredCodeAnnotation[] };
97
120
  createdAt: string;
98
121
  }
99
122
 
@@ -470,6 +493,11 @@ export async function deleteContextFile(
470
493
  } catch {
471
494
  /* already gone */
472
495
  }
496
+ try {
497
+ await fs.unlink(path.join(contextFilesDirPath(), `${fileId}.orig`));
498
+ } catch {
499
+ /* already gone */
500
+ }
473
501
  const topics = await getTopics();
474
502
  const topic = topics.find((t) => t.id === topicId);
475
503
  if (topic) {
@@ -484,7 +512,117 @@ export async function readContextFileContent(fileId: string): Promise<string> {
484
512
  return fs.readFile(path.join(contextFilesDirPath(), fileId), "utf-8");
485
513
  }
486
514
 
487
- // --- Questions ---
515
+ /**
516
+ * Store the raw original file bytes at {fileId}.orig so downloads can serve the
517
+ * real file rather than the extracted-text version used by the LLM.
518
+ */
519
+ export async function writeOriginalBlob(
520
+ fileId: string,
521
+ buffer: Buffer,
522
+ workspaceId = _activeWorkspaceId,
523
+ ): Promise<void> {
524
+ await ensureWorkspaceDirs(workspaceId);
525
+ await fs.writeFile(
526
+ path.join(contextFilesDirPath(workspaceId), `${fileId}.orig`),
527
+ buffer,
528
+ );
529
+ }
530
+
531
+ /**
532
+ * Read the original file bytes. Returns null if the file was uploaded before
533
+ * original storage was added (graceful fallback).
534
+ */
535
+ export async function readOriginalBlob(
536
+ fileId: string,
537
+ workspaceId = _activeWorkspaceId,
538
+ ): Promise<Buffer | null> {
539
+ try {
540
+ return await fs.readFile(
541
+ path.join(contextFilesDirPath(workspaceId), `${fileId}.orig`),
542
+ );
543
+ } catch {
544
+ return null;
545
+ }
546
+ }
547
+ // --- Workspace-level Context Files ---
548
+ // Stored in data/workspaces/{id}/workspace-files.json
549
+ // Blobs share the same context-files/ dir as topic/question files.
550
+
551
+ export async function getWorkspaceContextFiles(
552
+ workspaceId = _activeWorkspaceId,
553
+ ): Promise<ContextFile[]> {
554
+ try {
555
+ const data = await fs.readFile(
556
+ workspaceFilesFilePath(workspaceId),
557
+ "utf-8",
558
+ );
559
+ return JSON.parse(data) as ContextFile[];
560
+ } catch {
561
+ return [];
562
+ }
563
+ }
564
+
565
+ export async function saveWorkspaceContextFile(
566
+ fileId: string,
567
+ originalName: string,
568
+ buffer: Buffer,
569
+ workspaceId = _activeWorkspaceId,
570
+ ): Promise<ContextFile> {
571
+ await ensureWorkspaceDirs(workspaceId);
572
+ await fs.writeFile(
573
+ path.join(contextFilesDirPath(workspaceId), fileId),
574
+ buffer,
575
+ );
576
+ const cf: ContextFile = {
577
+ id: fileId,
578
+ name: originalName,
579
+ originalName,
580
+ createdAt: new Date().toISOString(),
581
+ };
582
+ const existing = await getWorkspaceContextFiles(workspaceId);
583
+ existing.push(cf);
584
+ await fs.writeFile(
585
+ workspaceFilesFilePath(workspaceId),
586
+ JSON.stringify(existing, null, 2),
587
+ );
588
+ return cf;
589
+ }
590
+
591
+ export async function deleteWorkspaceContextFile(
592
+ fileId: string,
593
+ workspaceId = _activeWorkspaceId,
594
+ ): Promise<void> {
595
+ try {
596
+ await fs.unlink(path.join(contextFilesDirPath(workspaceId), fileId));
597
+ } catch {
598
+ /* already gone */
599
+ }
600
+ try {
601
+ await fs.unlink(
602
+ path.join(contextFilesDirPath(workspaceId), `${fileId}.orig`),
603
+ );
604
+ } catch {
605
+ /* already gone */
606
+ }
607
+ const existing = await getWorkspaceContextFiles(workspaceId);
608
+ const updated = existing.filter((f) => f.id !== fileId);
609
+ await fs.writeFile(
610
+ workspaceFilesFilePath(workspaceId),
611
+ JSON.stringify(updated, null, 2),
612
+ );
613
+ }
614
+
615
+ // --- Workspace-level Context Files ---
616
+
617
+ export async function readWorkspaceContextFileContent(
618
+ fileId: string,
619
+ workspaceId = _activeWorkspaceId,
620
+ ): Promise<string> {
621
+ return fs.readFile(
622
+ path.join(contextFilesDirPath(workspaceId), fileId),
623
+ "utf-8",
624
+ );
625
+ }
488
626
 
489
627
  export async function getQuestion(id: string): Promise<Question | null> {
490
628
  await ensureWorkspaceDirs();
@@ -526,6 +664,31 @@ export async function getQuestionsByTopic(
526
664
  }
527
665
  }
528
666
 
667
+ export async function getAllQuestions(): Promise<Question[]> {
668
+ await ensureWorkspaceDirs();
669
+ try {
670
+ const files = await fs.readdir(questionsDirPath());
671
+ const questions: Question[] = [];
672
+ for (const file of files) {
673
+ if (!file.endsWith(".json")) continue;
674
+ try {
675
+ const data = await fs.readFile(
676
+ path.join(questionsDirPath(), file),
677
+ "utf-8",
678
+ );
679
+ const q: Question = JSON.parse(data);
680
+ if (!q.contextFiles) q.contextFiles = [];
681
+ questions.push(q);
682
+ } catch {
683
+ /* skip malformed */
684
+ }
685
+ }
686
+ return questions;
687
+ } catch {
688
+ return [];
689
+ }
690
+ }
691
+
529
692
  export async function saveQuestion(question: Question): Promise<Question> {
530
693
  await ensureWorkspaceDirs();
531
694
  await fs.writeFile(
@@ -559,14 +722,18 @@ export async function saveQuestionContextFile(
559
722
  fileId: string,
560
723
  originalName: string,
561
724
  buffer: Buffer,
725
+ extra?: { origin?: ContextFile["origin"]; language?: string; label?: string },
562
726
  ): Promise<ContextFile> {
563
727
  await ensureWorkspaceDirs();
564
728
  await fs.writeFile(path.join(contextFilesDirPath(), fileId), buffer);
565
729
  const cf: ContextFile = {
566
730
  id: fileId,
567
- name: originalName,
731
+ name: extra?.label ?? originalName,
568
732
  originalName,
569
733
  createdAt: new Date().toISOString(),
734
+ ...(extra?.origin ? { origin: extra.origin } : {}),
735
+ ...(extra?.language ? { language: extra.language } : {}),
736
+ ...(extra?.label ? { label: extra.label } : {}),
570
737
  };
571
738
  const q = await getQuestion(questionId);
572
739
  if (q) {
@@ -577,6 +744,32 @@ export async function saveQuestionContextFile(
577
744
  return cf;
578
745
  }
579
746
 
747
+ export async function overwriteQuestionContextFileContent(
748
+ questionId: string,
749
+ fileId: string,
750
+ buffer: Buffer,
751
+ ): Promise<void> {
752
+ await ensureWorkspaceDirs();
753
+ await fs.writeFile(path.join(contextFilesDirPath(), fileId), buffer);
754
+ }
755
+
756
+ export async function renameQuestionContextFile(
757
+ questionId: string,
758
+ fileId: string,
759
+ label: string,
760
+ ): Promise<ContextFile> {
761
+ const q = await getQuestion(questionId);
762
+ if (!q) throw new Error("Question not found");
763
+ const cf = (q.contextFiles || []).find((f) => f.id === fileId);
764
+ if (!cf) throw new Error("Context file not found");
765
+ const safe =
766
+ label.replace(/[/\\:*?"<>|]/g, "-").trim() || cf.label || cf.originalName;
767
+ cf.label = safe;
768
+ cf.name = safe;
769
+ await saveQuestion(q);
770
+ return cf;
771
+ }
772
+
580
773
  export async function deleteQuestionContextFile(
581
774
  questionId: string,
582
775
  fileId: string,
@@ -603,6 +796,70 @@ export async function updateQuestionMessages(
603
796
  await saveQuestion(q);
604
797
  }
605
798
 
799
+ export async function updateCodeAnnotationsForFile(
800
+ questionId: string,
801
+ filePath: string,
802
+ annotations: StoredCodeAnnotation[],
803
+ ): Promise<void> {
804
+ const q = await getQuestion(questionId);
805
+ if (!q) throw new Error("Question not found");
806
+ if (!q.codeAnnotations) q.codeAnnotations = {};
807
+ q.codeAnnotations[filePath] = annotations;
808
+ await saveQuestion(q);
809
+ }
810
+
811
+ /**
812
+ * Register an already-uploaded file in a topic's contextFiles without copying
813
+ * any bytes — the file content on disk (identified by fileId) is shared.
814
+ */
815
+ export async function linkContextFileToTopic(
816
+ topicId: string,
817
+ fileId: string,
818
+ originalName: string,
819
+ ): Promise<ContextFile> {
820
+ const cf: ContextFile = {
821
+ id: fileId,
822
+ name: originalName,
823
+ originalName,
824
+ createdAt: new Date().toISOString(),
825
+ };
826
+ const topics = await getTopics();
827
+ const topic = topics.find((t) => t.id === topicId);
828
+ if (topic) {
829
+ if (!topic.contextFiles) topic.contextFiles = [];
830
+ if (!topic.contextFiles.find((f) => f.id === fileId)) {
831
+ topic.contextFiles.push(cf);
832
+ await fs.writeFile(topicsFilePath(), JSON.stringify(topics, null, 2));
833
+ }
834
+ }
835
+ return cf;
836
+ }
837
+
838
+ /**
839
+ * Register an already-uploaded file in a question's contextFiles without copying bytes.
840
+ */
841
+ export async function linkContextFileToQuestion(
842
+ questionId: string,
843
+ fileId: string,
844
+ originalName: string,
845
+ ): Promise<ContextFile> {
846
+ const cf: ContextFile = {
847
+ id: fileId,
848
+ name: originalName,
849
+ originalName,
850
+ createdAt: new Date().toISOString(),
851
+ };
852
+ const q = await getQuestion(questionId);
853
+ if (q) {
854
+ if (!q.contextFiles) q.contextFiles = [];
855
+ if (!q.contextFiles.find((f) => f.id === fileId)) {
856
+ q.contextFiles.push(cf);
857
+ await saveQuestion(q);
858
+ }
859
+ }
860
+ return cf;
861
+ }
862
+
606
863
  // ── AI Settings ───────────────────────────────────────────────
607
864
 
608
865
  const AI_SETTINGS_FILE = path.join(DATA_DIR, "ai-settings.json");
@@ -628,6 +885,10 @@ export interface AiSettings {
628
885
  vizGuide: string;
629
886
  /** All user-selectable prompt groups. Add new entries here to extend the UI. */
630
887
  promptGroups: Record<string, PromptGroup>;
888
+ /** Gemini thinking budget in tokens. 0 = disabled. Only applies to Google models. */
889
+ thinkingBudget?: number;
890
+ /** When true, preference prompt texts are appended to every message (not just on change). */
891
+ alwaysSendPrefsDefault?: boolean;
631
892
  }
632
893
 
633
894
  const DEFAULT_AI_SETTINGS: AiSettings = {