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.
- package/package.json +1 -1
- package/template/client/package-lock.json +19 -0
- package/template/client/package.json +3 -0
- package/template/client/src/App.tsx +17 -0
- package/template/client/src/api.ts +135 -0
- package/template/client/src/components/AiSettingsModal.tsx +218 -4
- 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 +69 -4
- 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 +205 -2
- package/template/client/src/components/Sidebar.tsx +213 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +162 -19
- package/template/client/src/store.ts +201 -0
- package/template/client/src/types.ts +8 -0
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1107 -46
- 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
|
-
|
|
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 = {
|