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
|
@@ -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,12 +54,21 @@ 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 {
|
|
57
68
|
id: string;
|
|
58
69
|
role: string;
|
|
59
70
|
content: string;
|
|
71
|
+
parts?: any[];
|
|
60
72
|
createdAt?: string;
|
|
61
73
|
}
|
|
62
74
|
|
|
@@ -82,6 +94,16 @@ export interface ReadingBookmark {
|
|
|
82
94
|
blockIndex: number;
|
|
83
95
|
}
|
|
84
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
|
+
|
|
85
107
|
export interface Question {
|
|
86
108
|
id: string;
|
|
87
109
|
topicId: string;
|
|
@@ -93,6 +115,8 @@ export interface Question {
|
|
|
93
115
|
messages: Message[];
|
|
94
116
|
annotations?: Annotation[];
|
|
95
117
|
readingBookmark?: ReadingBookmark;
|
|
118
|
+
/** Code-line annotations keyed by file path. */
|
|
119
|
+
codeAnnotations?: { [filePath: string]: StoredCodeAnnotation[] };
|
|
96
120
|
createdAt: string;
|
|
97
121
|
}
|
|
98
122
|
|
|
@@ -469,6 +493,11 @@ export async function deleteContextFile(
|
|
|
469
493
|
} catch {
|
|
470
494
|
/* already gone */
|
|
471
495
|
}
|
|
496
|
+
try {
|
|
497
|
+
await fs.unlink(path.join(contextFilesDirPath(), `${fileId}.orig`));
|
|
498
|
+
} catch {
|
|
499
|
+
/* already gone */
|
|
500
|
+
}
|
|
472
501
|
const topics = await getTopics();
|
|
473
502
|
const topic = topics.find((t) => t.id === topicId);
|
|
474
503
|
if (topic) {
|
|
@@ -483,7 +512,117 @@ export async function readContextFileContent(fileId: string): Promise<string> {
|
|
|
483
512
|
return fs.readFile(path.join(contextFilesDirPath(), fileId), "utf-8");
|
|
484
513
|
}
|
|
485
514
|
|
|
486
|
-
|
|
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
|
+
}
|
|
487
626
|
|
|
488
627
|
export async function getQuestion(id: string): Promise<Question | null> {
|
|
489
628
|
await ensureWorkspaceDirs();
|
|
@@ -525,6 +664,31 @@ export async function getQuestionsByTopic(
|
|
|
525
664
|
}
|
|
526
665
|
}
|
|
527
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
|
+
|
|
528
692
|
export async function saveQuestion(question: Question): Promise<Question> {
|
|
529
693
|
await ensureWorkspaceDirs();
|
|
530
694
|
await fs.writeFile(
|
|
@@ -558,14 +722,18 @@ export async function saveQuestionContextFile(
|
|
|
558
722
|
fileId: string,
|
|
559
723
|
originalName: string,
|
|
560
724
|
buffer: Buffer,
|
|
725
|
+
extra?: { origin?: ContextFile["origin"]; language?: string; label?: string },
|
|
561
726
|
): Promise<ContextFile> {
|
|
562
727
|
await ensureWorkspaceDirs();
|
|
563
728
|
await fs.writeFile(path.join(contextFilesDirPath(), fileId), buffer);
|
|
564
729
|
const cf: ContextFile = {
|
|
565
730
|
id: fileId,
|
|
566
|
-
name: originalName,
|
|
731
|
+
name: extra?.label ?? originalName,
|
|
567
732
|
originalName,
|
|
568
733
|
createdAt: new Date().toISOString(),
|
|
734
|
+
...(extra?.origin ? { origin: extra.origin } : {}),
|
|
735
|
+
...(extra?.language ? { language: extra.language } : {}),
|
|
736
|
+
...(extra?.label ? { label: extra.label } : {}),
|
|
569
737
|
};
|
|
570
738
|
const q = await getQuestion(questionId);
|
|
571
739
|
if (q) {
|
|
@@ -576,6 +744,32 @@ export async function saveQuestionContextFile(
|
|
|
576
744
|
return cf;
|
|
577
745
|
}
|
|
578
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
|
+
|
|
579
773
|
export async function deleteQuestionContextFile(
|
|
580
774
|
questionId: string,
|
|
581
775
|
fileId: string,
|
|
@@ -601,3 +795,166 @@ export async function updateQuestionMessages(
|
|
|
601
795
|
q.messages = messages;
|
|
602
796
|
await saveQuestion(q);
|
|
603
797
|
}
|
|
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
|
+
|
|
863
|
+
// ── AI Settings ───────────────────────────────────────────────
|
|
864
|
+
|
|
865
|
+
const AI_SETTINGS_FILE = path.join(DATA_DIR, "ai-settings.json");
|
|
866
|
+
|
|
867
|
+
export interface ResponseProfile {
|
|
868
|
+
maxOutputTokens: number;
|
|
869
|
+
maxSteps: number;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export interface PromptGroup {
|
|
873
|
+
label: string;
|
|
874
|
+
description?: string;
|
|
875
|
+
default: string;
|
|
876
|
+
options: Record<string, string>;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
export interface AiSettings {
|
|
880
|
+
/** Base system prompt sent to the AI on every chat turn. */
|
|
881
|
+
systemPrompt: string;
|
|
882
|
+
/** Per-length-preference token and step limits. */
|
|
883
|
+
responseProfiles: Record<string, ResponseProfile>;
|
|
884
|
+
/** Full viz diagram spec reference — returned by the getVizGuide tool. */
|
|
885
|
+
vizGuide: string;
|
|
886
|
+
/** All user-selectable prompt groups. Add new entries here to extend the UI. */
|
|
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;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const DEFAULT_AI_SETTINGS: AiSettings = {
|
|
895
|
+
systemPrompt:
|
|
896
|
+
"You are a senior engineering interview coach.\n\nExplain clearly, accurately, and practically.",
|
|
897
|
+
responseProfiles: {
|
|
898
|
+
concise: { maxOutputTokens: 1000, maxSteps: 3 },
|
|
899
|
+
moderate: { maxOutputTokens: 1000, maxSteps: 5 },
|
|
900
|
+
normal: { maxOutputTokens: 3000, maxSteps: 5 },
|
|
901
|
+
},
|
|
902
|
+
vizGuide: "No viz guide configured.",
|
|
903
|
+
promptGroups: {
|
|
904
|
+
length: {
|
|
905
|
+
label: "Response Length",
|
|
906
|
+
description:
|
|
907
|
+
"Appended to the user message when the selected length changes.",
|
|
908
|
+
default: "normal",
|
|
909
|
+
options: {
|
|
910
|
+
concise: "Keep the response concise.",
|
|
911
|
+
moderate: "Keep the response moderately detailed.",
|
|
912
|
+
normal:
|
|
913
|
+
"Use a fuller answer with enough context to explain the idea clearly.",
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
style: {
|
|
917
|
+
label: "Response Style",
|
|
918
|
+
description:
|
|
919
|
+
"Appended to the user message when the selected style changes.",
|
|
920
|
+
default: "prose",
|
|
921
|
+
options: {
|
|
922
|
+
prose: "Use natural prose with short paragraphs.",
|
|
923
|
+
bullets: "Use bullet points and short lists as the main format.",
|
|
924
|
+
structured:
|
|
925
|
+
"Use structured sections with headings and numbered steps when helpful.",
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
audience: {
|
|
929
|
+
label: "Response Audience",
|
|
930
|
+
description:
|
|
931
|
+
"Appended to the user message when the selected audience changes.",
|
|
932
|
+
default: "normal",
|
|
933
|
+
options: {
|
|
934
|
+
normal: "",
|
|
935
|
+
beginner:
|
|
936
|
+
"When using technical terms or abbreviations, immediately expand their meaning in square brackets.",
|
|
937
|
+
},
|
|
938
|
+
},
|
|
939
|
+
},
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
export async function getAiSettings(): Promise<AiSettings> {
|
|
943
|
+
try {
|
|
944
|
+
const raw = await fs.readFile(AI_SETTINGS_FILE, "utf-8");
|
|
945
|
+
return {
|
|
946
|
+
...DEFAULT_AI_SETTINGS,
|
|
947
|
+
...(JSON.parse(raw) as Partial<AiSettings>),
|
|
948
|
+
};
|
|
949
|
+
} catch {
|
|
950
|
+
return DEFAULT_AI_SETTINGS;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export async function saveAiSettings(settings: AiSettings): Promise<void> {
|
|
955
|
+
await fs.writeFile(
|
|
956
|
+
AI_SETTINGS_FILE,
|
|
957
|
+
JSON.stringify(settings, null, 2),
|
|
958
|
+
"utf-8",
|
|
959
|
+
);
|
|
960
|
+
}
|