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
|
@@ -1,7 +1,60 @@
|
|
|
1
1
|
import { create } from "zustand";
|
|
2
2
|
import type { Topic, Question, CodeSnippet, WorkspaceMeta } from "./types";
|
|
3
|
+
import type { AiSettings } from "./api";
|
|
3
4
|
import * as api from "./api";
|
|
4
5
|
|
|
6
|
+
const DEFAULT_AI_SETTINGS: AiSettings = {
|
|
7
|
+
systemPrompt: "",
|
|
8
|
+
responseProfiles: {
|
|
9
|
+
concise: { maxOutputTokens: 1000, maxSteps: 3 },
|
|
10
|
+
moderate: { maxOutputTokens: 1000, maxSteps: 5 },
|
|
11
|
+
normal: { maxOutputTokens: 3000, maxSteps: 5 },
|
|
12
|
+
},
|
|
13
|
+
vizGuide: "",
|
|
14
|
+
alwaysSendPrefsDefault: false,
|
|
15
|
+
thinkingBudget: 0,
|
|
16
|
+
promptGroups: {
|
|
17
|
+
length: {
|
|
18
|
+
label: "Response Length",
|
|
19
|
+
description:
|
|
20
|
+
"Appended to the user message when the selected length changes.",
|
|
21
|
+
default: "normal",
|
|
22
|
+
options: {
|
|
23
|
+
concise:
|
|
24
|
+
"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.",
|
|
25
|
+
moderate:
|
|
26
|
+
"Keep the response moderately detailed. Aim for roughly 550 characters of text when possible.",
|
|
27
|
+
normal:
|
|
28
|
+
"Use a fuller answer with enough context to explain the idea clearly.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
style: {
|
|
32
|
+
label: "Response Style",
|
|
33
|
+
description:
|
|
34
|
+
"Appended to the user message when the selected style changes.",
|
|
35
|
+
default: "prose",
|
|
36
|
+
options: {
|
|
37
|
+
prose:
|
|
38
|
+
"Use natural prose with short paragraphs. Avoid bullet lists and numbered lists unless I explicitly ask for them.",
|
|
39
|
+
bullets: "Use bullet points and short lists as the main format.",
|
|
40
|
+
structured:
|
|
41
|
+
"Use structured sections with headings and numbered steps when helpful.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
audience: {
|
|
45
|
+
label: "Response Audience",
|
|
46
|
+
description:
|
|
47
|
+
"Appended to the user message when the selected audience changes.",
|
|
48
|
+
default: "normal",
|
|
49
|
+
options: {
|
|
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
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
5
58
|
interface Store {
|
|
6
59
|
topics: Topic[];
|
|
7
60
|
questionsByTopic: Record<string, Question[]>;
|
|
@@ -13,6 +66,7 @@ interface Store {
|
|
|
13
66
|
showCodePanel: boolean;
|
|
14
67
|
showSidebar: boolean;
|
|
15
68
|
viewingFile: string | null;
|
|
69
|
+
viewingDoc: { fileId: string; quote: string; fileName: string } | null;
|
|
16
70
|
|
|
17
71
|
// ── Workspaces ───────────────────────────────────────────────
|
|
18
72
|
workspaces: WorkspaceMeta[];
|
|
@@ -85,26 +139,107 @@ interface Store {
|
|
|
85
139
|
files: FileList | File[],
|
|
86
140
|
) => Promise<void>;
|
|
87
141
|
removeTopicFile: (topicId: string, fileId: string) => Promise<void>;
|
|
142
|
+
linkFileToTopic: (
|
|
143
|
+
topicId: string,
|
|
144
|
+
fileId: string,
|
|
145
|
+
originalName: string,
|
|
146
|
+
) => Promise<void>;
|
|
88
147
|
uploadQuestionFiles: (
|
|
89
148
|
questionId: string,
|
|
90
149
|
files: FileList | File[],
|
|
91
150
|
) => Promise<void>;
|
|
92
151
|
removeQuestionFile: (questionId: string, fileId: string) => Promise<void>;
|
|
152
|
+
linkFileToQuestion: (
|
|
153
|
+
questionId: string,
|
|
154
|
+
fileId: string,
|
|
155
|
+
originalName: string,
|
|
156
|
+
) => Promise<void>;
|
|
157
|
+
saveCodeSnippetToQuestion: (
|
|
158
|
+
questionId: string,
|
|
159
|
+
code: string,
|
|
160
|
+
language: string,
|
|
161
|
+
label: string,
|
|
162
|
+
origin: "user" | "ai" | "sandbox",
|
|
163
|
+
) => Promise<import("./types").ContextFile>;
|
|
93
164
|
clearMessages: (questionId: string) => Promise<void>;
|
|
165
|
+
|
|
166
|
+
// ── Workspace Context Files ──────────────────────────────────
|
|
167
|
+
workspaceFiles: import("./types").ContextFile[];
|
|
168
|
+
fetchWorkspaceFiles: () => Promise<void>;
|
|
169
|
+
uploadWorkspaceFiles: (files: FileList | File[]) => Promise<void>;
|
|
170
|
+
removeWorkspaceFile: (fileId: string) => Promise<void>;
|
|
171
|
+
|
|
94
172
|
updateQuestionSystemContext: (
|
|
95
173
|
questionId: string,
|
|
96
174
|
systemContext: string,
|
|
97
175
|
) => Promise<void>;
|
|
98
176
|
openFileViewer: (path: string) => void;
|
|
99
177
|
closeFileViewer: () => void;
|
|
178
|
+
openDocViewer: (fileId: string, quote: string, fileName: string) => void;
|
|
179
|
+
closeDocViewer: () => void;
|
|
180
|
+
/** Inline code blocks written by the AI, keyed by `inline:<id>` */
|
|
181
|
+
inlineCodeSnippets: Record<
|
|
182
|
+
string,
|
|
183
|
+
{ content: string; language: string; label: string }
|
|
184
|
+
>;
|
|
185
|
+
registerInlineCode: (
|
|
186
|
+
id: string,
|
|
187
|
+
entry: { content: string; language: string; label: string },
|
|
188
|
+
) => void;
|
|
100
189
|
codeSnippets: CodeSnippet[];
|
|
101
190
|
addSnippet: (snippet: CodeSnippet) => void;
|
|
102
191
|
removeSnippet: (id: string) => void;
|
|
103
192
|
clearSnippets: () => void;
|
|
193
|
+
|
|
194
|
+
// ── AI Settings ──────────────────────────────────────────────
|
|
195
|
+
aiSettings: AiSettings;
|
|
196
|
+
fetchAiSettings: () => Promise<void>;
|
|
197
|
+
saveAiSettings: (patch: Partial<AiSettings>) => Promise<void>;
|
|
198
|
+
/** The currently active preference suffix (LENGTH/STYLE/AUDIENCE/etc) built by ChatView. */
|
|
199
|
+
livePreferenceSuffix: string;
|
|
200
|
+
setLivePreferenceSuffix: (suffix: string) => void;
|
|
201
|
+
showSettings: boolean;
|
|
202
|
+
openSettings: () => void;
|
|
203
|
+
closeSettings: () => void;
|
|
204
|
+
|
|
205
|
+
// ── Code Runner ──────────────────────────────────────────────
|
|
206
|
+
showCodeRunner: boolean;
|
|
207
|
+
/** Code pre-filled into the runner when opened */
|
|
208
|
+
runnerInitialCode: string;
|
|
209
|
+
/** Language hint — 'typescript' or 'javascript' */
|
|
210
|
+
runnerInitialLanguage: string;
|
|
211
|
+
/** When set, opens the runner in sandbox mode with these values pre-filled */
|
|
212
|
+
runnerInitialSandbox: {
|
|
213
|
+
serverCode: string;
|
|
214
|
+
serverLang: string;
|
|
215
|
+
clientCode: string;
|
|
216
|
+
clientLang: string;
|
|
217
|
+
fileId?: string;
|
|
218
|
+
} | null;
|
|
219
|
+
openCodeRunner: (code?: string, language?: string) => void;
|
|
220
|
+
openSandbox: (
|
|
221
|
+
serverCode: string,
|
|
222
|
+
serverLang: string,
|
|
223
|
+
clientCode: string,
|
|
224
|
+
clientLang: string,
|
|
225
|
+
fileId?: string,
|
|
226
|
+
) => void;
|
|
227
|
+
overwriteContextFileContent: (
|
|
228
|
+
questionId: string,
|
|
229
|
+
fileId: string,
|
|
230
|
+
content: string,
|
|
231
|
+
) => Promise<void>;
|
|
232
|
+
renameContextFile: (
|
|
233
|
+
questionId: string,
|
|
234
|
+
fileId: string,
|
|
235
|
+
label: string,
|
|
236
|
+
) => Promise<void>;
|
|
237
|
+
closeCodeRunner: () => void;
|
|
104
238
|
}
|
|
105
239
|
|
|
106
240
|
export const useStore = create<Store>((set, get) => ({
|
|
107
241
|
topics: [],
|
|
242
|
+
workspaceFiles: [],
|
|
108
243
|
questionsByTopic: {},
|
|
109
244
|
selectedTopicId: null,
|
|
110
245
|
selectedQuestionId: null,
|
|
@@ -114,7 +249,16 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
114
249
|
showCodePanel: false,
|
|
115
250
|
showSidebar: true,
|
|
116
251
|
viewingFile: null,
|
|
252
|
+
viewingDoc: null,
|
|
253
|
+
inlineCodeSnippets: {},
|
|
117
254
|
codeSnippets: [],
|
|
255
|
+
aiSettings: DEFAULT_AI_SETTINGS,
|
|
256
|
+
livePreferenceSuffix: "",
|
|
257
|
+
showSettings: false,
|
|
258
|
+
showCodeRunner: false,
|
|
259
|
+
runnerInitialCode: "",
|
|
260
|
+
runnerInitialLanguage: "typescript",
|
|
261
|
+
runnerInitialSandbox: null,
|
|
118
262
|
|
|
119
263
|
// ── Workspaces ───────────────────────────────────────────────
|
|
120
264
|
workspaces: [],
|
|
@@ -154,6 +298,8 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
154
298
|
});
|
|
155
299
|
const topics = await api.fetchTopics();
|
|
156
300
|
set({ topics });
|
|
301
|
+
const workspaceFiles = await api.fetchWorkspaceFiles();
|
|
302
|
+
set({ workspaceFiles });
|
|
157
303
|
},
|
|
158
304
|
|
|
159
305
|
deleteWorkspace: async (id) => {
|
|
@@ -445,6 +591,17 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
445
591
|
}));
|
|
446
592
|
},
|
|
447
593
|
|
|
594
|
+
linkFileToTopic: async (topicId, fileId, originalName) => {
|
|
595
|
+
const cf = await api.linkFileToTopic(topicId, fileId, originalName);
|
|
596
|
+
set((s) => ({
|
|
597
|
+
topics: s.topics.map((t) =>
|
|
598
|
+
t.id === topicId
|
|
599
|
+
? { ...t, contextFiles: [...(t.contextFiles || []), cf] }
|
|
600
|
+
: t,
|
|
601
|
+
),
|
|
602
|
+
}));
|
|
603
|
+
},
|
|
604
|
+
|
|
448
605
|
uploadQuestionFiles: async (questionId, files) => {
|
|
449
606
|
const uploaded = await api.uploadQuestionFiles(questionId, files);
|
|
450
607
|
set((s) => ({
|
|
@@ -476,6 +633,45 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
476
633
|
}));
|
|
477
634
|
},
|
|
478
635
|
|
|
636
|
+
linkFileToQuestion: async (questionId, fileId, originalName) => {
|
|
637
|
+
const cf = await api.linkFileToQuestion(questionId, fileId, originalName);
|
|
638
|
+
set((s) => ({
|
|
639
|
+
currentQuestion:
|
|
640
|
+
s.currentQuestion?.id === questionId
|
|
641
|
+
? {
|
|
642
|
+
...s.currentQuestion,
|
|
643
|
+
contextFiles: [...(s.currentQuestion.contextFiles || []), cf],
|
|
644
|
+
}
|
|
645
|
+
: s.currentQuestion,
|
|
646
|
+
}));
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
saveCodeSnippetToQuestion: async (
|
|
650
|
+
questionId,
|
|
651
|
+
code,
|
|
652
|
+
language,
|
|
653
|
+
label,
|
|
654
|
+
origin,
|
|
655
|
+
) => {
|
|
656
|
+
const cf = await api.saveCodeSnippet(
|
|
657
|
+
questionId,
|
|
658
|
+
code,
|
|
659
|
+
language,
|
|
660
|
+
label,
|
|
661
|
+
origin,
|
|
662
|
+
);
|
|
663
|
+
set((s) => ({
|
|
664
|
+
currentQuestion:
|
|
665
|
+
s.currentQuestion?.id === questionId
|
|
666
|
+
? {
|
|
667
|
+
...s.currentQuestion,
|
|
668
|
+
contextFiles: [...(s.currentQuestion.contextFiles || []), cf],
|
|
669
|
+
}
|
|
670
|
+
: s.currentQuestion,
|
|
671
|
+
}));
|
|
672
|
+
return cf;
|
|
673
|
+
},
|
|
674
|
+
|
|
479
675
|
clearMessages: async (questionId) => {
|
|
480
676
|
await api.updateQuestion(questionId, { messages: [] });
|
|
481
677
|
set((s) => ({
|
|
@@ -486,6 +682,23 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
486
682
|
}));
|
|
487
683
|
},
|
|
488
684
|
|
|
685
|
+
fetchWorkspaceFiles: async () => {
|
|
686
|
+
const files = await api.fetchWorkspaceFiles();
|
|
687
|
+
set({ workspaceFiles: files });
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
uploadWorkspaceFiles: async (files) => {
|
|
691
|
+
const uploaded = await api.uploadWorkspaceFiles(files);
|
|
692
|
+
set((s) => ({ workspaceFiles: [...s.workspaceFiles, ...uploaded] }));
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
removeWorkspaceFile: async (fileId) => {
|
|
696
|
+
await api.deleteWorkspaceFile(fileId);
|
|
697
|
+
set((s) => ({
|
|
698
|
+
workspaceFiles: s.workspaceFiles.filter((f) => f.id !== fileId),
|
|
699
|
+
}));
|
|
700
|
+
},
|
|
701
|
+
|
|
489
702
|
updateQuestionSystemContext: async (questionId, systemContext) => {
|
|
490
703
|
const updated = await api.updateQuestion(questionId, { systemContext });
|
|
491
704
|
set((s) => ({
|
|
@@ -507,4 +720,66 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
507
720
|
|
|
508
721
|
openFileViewer: (path) => set({ viewingFile: path }),
|
|
509
722
|
closeFileViewer: () => set({ viewingFile: null }),
|
|
723
|
+
registerInlineCode: (id, entry) =>
|
|
724
|
+
set((s) => ({
|
|
725
|
+
inlineCodeSnippets: { ...s.inlineCodeSnippets, [id]: entry },
|
|
726
|
+
})),
|
|
727
|
+
openDocViewer: (fileId, quote, fileName) =>
|
|
728
|
+
set({ viewingDoc: { fileId, quote, fileName } }),
|
|
729
|
+
closeDocViewer: () => set({ viewingDoc: null }),
|
|
730
|
+
openCodeRunner: (code = "", language = "typescript") =>
|
|
731
|
+
set({
|
|
732
|
+
showCodeRunner: true,
|
|
733
|
+
runnerInitialCode: code,
|
|
734
|
+
runnerInitialLanguage: language,
|
|
735
|
+
runnerInitialSandbox: null,
|
|
736
|
+
}),
|
|
737
|
+
openSandbox: (serverCode, serverLang, clientCode, clientLang, fileId?) =>
|
|
738
|
+
set({
|
|
739
|
+
showCodeRunner: true,
|
|
740
|
+
runnerInitialCode: "",
|
|
741
|
+
runnerInitialLanguage: "typescript",
|
|
742
|
+
runnerInitialSandbox: {
|
|
743
|
+
serverCode,
|
|
744
|
+
serverLang,
|
|
745
|
+
clientCode,
|
|
746
|
+
clientLang,
|
|
747
|
+
fileId,
|
|
748
|
+
},
|
|
749
|
+
}),
|
|
750
|
+
|
|
751
|
+
overwriteContextFileContent: async (questionId, fileId, content) => {
|
|
752
|
+
await api.overwriteContextFileContent(questionId, fileId, content);
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
renameContextFile: async (questionId, fileId, label) => {
|
|
756
|
+
const cf = await api.renameContextFile(questionId, fileId, label);
|
|
757
|
+
set((s) => ({
|
|
758
|
+
currentQuestion:
|
|
759
|
+
s.currentQuestion?.id === questionId
|
|
760
|
+
? {
|
|
761
|
+
...s.currentQuestion,
|
|
762
|
+
contextFiles: (s.currentQuestion.contextFiles || []).map((f) =>
|
|
763
|
+
f.id === fileId ? { ...f, label: cf.label, name: cf.name } : f,
|
|
764
|
+
),
|
|
765
|
+
}
|
|
766
|
+
: s.currentQuestion,
|
|
767
|
+
}));
|
|
768
|
+
},
|
|
769
|
+
closeCodeRunner: () => set({ showCodeRunner: false }),
|
|
770
|
+
|
|
771
|
+
fetchAiSettings: async () => {
|
|
772
|
+
const settings = await api.fetchAiSettings();
|
|
773
|
+
set({ aiSettings: settings });
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
saveAiSettings: async (patch) => {
|
|
777
|
+
const updated = await api.saveAiSettings(patch);
|
|
778
|
+
set({ aiSettings: updated });
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
setLivePreferenceSuffix: (suffix) => set({ livePreferenceSuffix: suffix }),
|
|
782
|
+
|
|
783
|
+
openSettings: () => set({ showSettings: true }),
|
|
784
|
+
closeSettings: () => set({ showSettings: false }),
|
|
510
785
|
}));
|
|
@@ -4,6 +4,14 @@ export interface ContextFile {
|
|
|
4
4
|
originalName: string;
|
|
5
5
|
driveFileId?: string;
|
|
6
6
|
createdAt: string;
|
|
7
|
+
/** Distinguishes how this file was added. 'upload' = user-uploaded doc,
|
|
8
|
+
* 'user' = code saved from Code Runner, 'ai' = AI-generated code block,
|
|
9
|
+
* 'sandbox' = paired server+client sandbox saved as JSON. */
|
|
10
|
+
origin?: "user" | "ai" | "upload" | "sandbox";
|
|
11
|
+
/** Language hint for code snippets (e.g. 'typescript', 'javascript'). */
|
|
12
|
+
language?: string;
|
|
13
|
+
/** Short display label for code snippets. */
|
|
14
|
+
label?: string;
|
|
7
15
|
}
|
|
8
16
|
|
|
9
17
|
export interface WorkspaceMeta {
|
|
@@ -36,6 +44,7 @@ export interface Message {
|
|
|
36
44
|
id: string;
|
|
37
45
|
role: "user" | "assistant";
|
|
38
46
|
content: string;
|
|
47
|
+
parts?: { type: string; [key: string]: any }[];
|
|
39
48
|
createdAt?: string;
|
|
40
49
|
}
|
|
41
50
|
|
package/template/cockpit.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"systemPrompt": "You are a senior engineering interview coach.\n\nHighest priority: follow the user's explicit response preferences and current conversation context. If they conflict with your default teaching behavior, the user's preference wins.\nExplain clearly, accurately, and practically.\nOnly include Mermaid diagrams, code blocks, or tables when the user explicitly asks for them or when they materially improve the answer.\nIf you show code, use a fenced code block with the correct language.\n\nMermaid syntax rules (follow strictly):\n- Wrap node labels in quotes when they contain special characters: A[\"Microservice A (Producer)\"]\n- Edge labels use |text| syntax: A -->|sends message| B\n- Never put parentheses or brackets inside [] without quoting the label\n- Use simple node IDs (letters/numbers) and put descriptive text in the label\n\nYou can produce animated diagrams using ```viz blocks for flows, step-through walkthroughs, or any explanation that benefits from animation. Call getVizGuide() first to get the full spec reference before writing a viz block.",
|
|
3
|
+
"responseProfiles": {
|
|
4
|
+
"concise": {
|
|
5
|
+
"maxOutputTokens": 1000,
|
|
6
|
+
"maxSteps": 3
|
|
7
|
+
},
|
|
8
|
+
"moderate": {
|
|
9
|
+
"maxOutputTokens": 1000,
|
|
10
|
+
"maxSteps": 5
|
|
11
|
+
},
|
|
12
|
+
"normal": {
|
|
13
|
+
"maxOutputTokens": 3000,
|
|
14
|
+
"maxSteps": 5
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"vizGuide": "Animated viz diagrams — full spec reference\n\nThe content of a ```viz block is a YAML (preferred) or JSON object:\n\n view: { width: 900, height: 360 } # required — scene canvas size\n nodes: # required — list of nodes\n - id: client # required, kebab-case\n label: Client # display text\n x: 80 # required — absolute X centre in view\n y: 180 # required — absolute Y centre in view\n shape: rect # rect (default) | circle | cylinder | diamond | hexagon | ellipse | cloud | document | note\n width: 120 # optional, default 120\n height: 40 # optional, default 40\n fill: '#0e7490' # optional hex colour\n stroke: '#164e63' # optional\n edges: # optional\n - from: client\n to: lb\n label: HTTP Request # optional\n animate: flow # optional — adds marching-ants stroke animation\n style: straight # straight (default) | curved | orthogonal\n autoSignals: # optional — self-animating signal dots, no code needed\n - id: req-flow # unique key\n chain: [client, lb, server] # ordered node ids — dot travels this path\n loop: true # restart after reaching the end\n durationPerHop: 700 # ms per hop, default 800\n loopDelay: 0 # ms pause before restart\n color: '#7dd3fc' # optional dot colour\n steps: # optional — replaces autoSignals; adds step-through UI\n - label: Client sends a request # shown in step indicator\n highlight: [client, lb] # node ids to highlight on this step\n signals: # one-shot signals that play on this step\n - id: s1\n chain: [client, lb]\n durationPerHop: 800\n autoAdvance: false # true = auto-proceeds after signals complete\n\nRules:\n- x/y are ABSOLUTE coordinates within the view. Plan layout so nodes don't overlap (typical node is 120×40).\n- Use autoSignals (loop: true) for animated overview diagrams. Use steps for walkthroughs where you explain each phase.\n- Do NOT mix autoSignals and steps in the same spec.\n- Keep node ids simple, lowercase, no spaces (use hyphens).\n- Prefer YAML — it is more readable. Only use JSON if the structure is trivial.\n\nMinimal animated example (autoSignal loop):\n```viz\nview: { width: 860, height: 260 }\nnodes:\n - { id: client, label: Client, x: 80, y: 130, width: 120, height: 40, fill: '#0e7490' }\n - { id: lb, label: Load Balancer, x: 430, y: 130, width: 160, height: 40, fill: '#7c3aed' }\n - { id: srv, label: Server, x: 770, y: 130, width: 120, height: 40, fill: '#065f46' }\nedges:\n - { from: client, to: lb }\n - { from: lb, to: srv }\nautoSignals:\n - { id: flow, chain: [client, lb, srv], loop: true, durationPerHop: 700 }\n```\n\nMinimal step-through example:\n```viz\nview: { width: 860, height: 260 }\nnodes:\n - { id: client, label: Client, x: 80, y: 130, width: 120, height: 40, fill: '#0e7490' }\n - { id: cache, label: Cache, x: 430, y: 130, width: 120, height: 40, fill: '#7c3aed' }\n - { id: db, label: Database, x: 770, y: 130, width: 120, height: 40, fill: '#065f46' }\nedges:\n - { from: client, to: cache }\n - { from: cache, to: db }\nsteps:\n - label: Client queries the cache\n highlight: [client, cache]\n signals: [{ id: s1, chain: [client, cache], durationPerHop: 800 }]\n - label: Cache miss — forward to DB\n highlight: [cache, db]\n signals: [{ id: s2, chain: [cache, db], durationPerHop: 800 }]\n - label: DB responds, cache is populated\n highlight: [db, cache, client]\n signals:\n - { id: s3a, chain: [db, cache], durationPerHop: 600 }\n - { id: s3b, chain: [cache, client], durationPerHop: 600 }\n```",
|
|
18
|
+
"promptGroups": {
|
|
19
|
+
"length": {
|
|
20
|
+
"label": "Response Length",
|
|
21
|
+
"description": "Appended to the user message when the selected length changes.",
|
|
22
|
+
"default": "normal",
|
|
23
|
+
"options": {
|
|
24
|
+
"concise": "Keep the response very concise",
|
|
25
|
+
"moderate": "Keep the response moderately detailed.",
|
|
26
|
+
"normal": "Use a fuller answer with enough context to explain the idea clearly."
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"style": {
|
|
30
|
+
"label": "Response Style",
|
|
31
|
+
"description": "Appended to the user message when the selected style changes.",
|
|
32
|
+
"default": "structured",
|
|
33
|
+
"options": {
|
|
34
|
+
"prose": "Use natural prose with short paragraphs.",
|
|
35
|
+
"bullets": "Use bullet points and short lists as the main format.",
|
|
36
|
+
"structured": "Use structured sections with headings and numbered steps when helpful."
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"audience": {
|
|
40
|
+
"label": "Response Audience",
|
|
41
|
+
"description": "Appended to the user message when the selected audience changes.",
|
|
42
|
+
"default": "normal",
|
|
43
|
+
"options": {
|
|
44
|
+
"normal": "",
|
|
45
|
+
"beginner": "When using technical terms or abbreviations, immediately expand their meaning in square brackets."
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -313,6 +313,14 @@ export async function syncWorkspace(
|
|
|
313
313
|
const questions = pending.filter((p) => !p.isContextFile);
|
|
314
314
|
const contextFiles = pending.filter((p) => p.isContextFile);
|
|
315
315
|
|
|
316
|
+
// Collect parent-title links to re-apply after all questions are saved
|
|
317
|
+
// (IDs are regenerated on import, so we must link by title within a topic)
|
|
318
|
+
const parentLinks: Array<{
|
|
319
|
+
questionId: string;
|
|
320
|
+
topicId: string;
|
|
321
|
+
parentTitle: string;
|
|
322
|
+
}> = [];
|
|
323
|
+
|
|
316
324
|
await Promise.all(
|
|
317
325
|
questions.map(async ({ topicId, filename, buffer }) => {
|
|
318
326
|
try {
|
|
@@ -320,6 +328,9 @@ export async function syncWorkspace(
|
|
|
320
328
|
let systemContext = "";
|
|
321
329
|
let messages: storage.Question["messages"] = [];
|
|
322
330
|
let codeContextFiles: string[] = [];
|
|
331
|
+
let codeAnnotations: storage.Question["codeAnnotations"] = {};
|
|
332
|
+
let parentTitle: string | null = null;
|
|
333
|
+
const restoredContextFiles: storage.ContextFile[] = [];
|
|
323
334
|
|
|
324
335
|
if (filename.endsWith(".json")) {
|
|
325
336
|
// Our own exported JSON — restore fields directly, no text extraction
|
|
@@ -329,6 +340,42 @@ export async function syncWorkspace(
|
|
|
329
340
|
systemContext = parsed.systemContext || "";
|
|
330
341
|
messages = parsed.messages || [];
|
|
331
342
|
codeContextFiles = parsed.codeContextFiles || [];
|
|
343
|
+
codeAnnotations = parsed.codeAnnotations || {};
|
|
344
|
+
parentTitle = parsed.parentTitle || null;
|
|
345
|
+
|
|
346
|
+
// Restore code snippet context files (user/ai origin)
|
|
347
|
+
if (
|
|
348
|
+
Array.isArray(parsed.codeSnippets) &&
|
|
349
|
+
parsed.codeSnippets.length > 0
|
|
350
|
+
) {
|
|
351
|
+
const ctxDir =
|
|
352
|
+
storage.getContextFilesDirForWorkspace(workspaceId);
|
|
353
|
+
await Promise.all(
|
|
354
|
+
parsed.codeSnippets.map(
|
|
355
|
+
async (cs: storage.ContextFile & { content?: string }) => {
|
|
356
|
+
if (
|
|
357
|
+
cs.content &&
|
|
358
|
+
(cs.origin === "user" ||
|
|
359
|
+
cs.origin === "ai" ||
|
|
360
|
+
cs.origin === "sandbox")
|
|
361
|
+
) {
|
|
362
|
+
try {
|
|
363
|
+
await fs.mkdir(ctxDir, { recursive: true });
|
|
364
|
+
await fs.writeFile(
|
|
365
|
+
path.join(ctxDir, cs.id),
|
|
366
|
+
Buffer.from(cs.content, "utf-8"),
|
|
367
|
+
);
|
|
368
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
369
|
+
const { content: _content, ...cfMeta } = cs;
|
|
370
|
+
restoredContextFiles.push(cfMeta);
|
|
371
|
+
} catch {
|
|
372
|
+
/* skip bad blobs */
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
332
379
|
} catch {
|
|
333
380
|
// Malformed JSON — fall through to raw text
|
|
334
381
|
systemContext = buffer.toString("utf-8");
|
|
@@ -344,18 +391,47 @@ export async function syncWorkspace(
|
|
|
344
391
|
title,
|
|
345
392
|
systemContext,
|
|
346
393
|
codeContextFiles,
|
|
347
|
-
contextFiles:
|
|
394
|
+
contextFiles: restoredContextFiles,
|
|
348
395
|
messages,
|
|
396
|
+
codeAnnotations,
|
|
349
397
|
createdAt: new Date().toISOString(),
|
|
350
398
|
};
|
|
351
399
|
await storage.saveQuestion(q);
|
|
352
400
|
result.filesImported++;
|
|
401
|
+
if (parentTitle) {
|
|
402
|
+
parentLinks.push({ questionId: q.id, topicId, parentTitle });
|
|
403
|
+
}
|
|
353
404
|
} catch (err: any) {
|
|
354
405
|
result.errors.push(`${filename}: ${err?.message ?? "failed"}`);
|
|
355
406
|
}
|
|
356
407
|
}),
|
|
357
408
|
);
|
|
358
409
|
|
|
410
|
+
// ── Phase 4b: re-link parent–child relationships by title ───────────────────
|
|
411
|
+
// IDs are regenerated on import, so we match parent by title within the same topic.
|
|
412
|
+
if (parentLinks.length > 0) {
|
|
413
|
+
// Group by topicId to avoid redundant getQuestionsByTopic calls
|
|
414
|
+
const byTopic = new Map<string, typeof parentLinks>();
|
|
415
|
+
for (const link of parentLinks) {
|
|
416
|
+
const arr = byTopic.get(link.topicId) ?? [];
|
|
417
|
+
arr.push(link);
|
|
418
|
+
byTopic.set(link.topicId, arr);
|
|
419
|
+
}
|
|
420
|
+
for (const [topicId, links] of byTopic) {
|
|
421
|
+
const allInTopic = await storage.getQuestionsByTopic(topicId);
|
|
422
|
+
for (const { questionId, parentTitle } of links) {
|
|
423
|
+
const parent = allInTopic.find(
|
|
424
|
+
(q) => q.title === parentTitle && q.id !== questionId,
|
|
425
|
+
);
|
|
426
|
+
const child = allInTopic.find((q) => q.id === questionId);
|
|
427
|
+
if (parent && child) {
|
|
428
|
+
child.parentQuestionId = parent.id;
|
|
429
|
+
await storage.saveQuestion(child);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
359
435
|
// ── Phase 5: save context file blobs in parallel, then update topics.json once ─
|
|
360
436
|
if (contextFiles.length > 0) {
|
|
361
437
|
// Write binary files in parallel
|
|
@@ -645,13 +721,45 @@ export async function exportWorkspace(
|
|
|
645
721
|
try {
|
|
646
722
|
const safeName =
|
|
647
723
|
q.title.replace(/[/\\:*?"<>|]/g, "-").trim() || q.id;
|
|
724
|
+
// Look up parent title so the relationship can be restored on import
|
|
725
|
+
const parentTitle = q.parentQuestionId
|
|
726
|
+
? questions.find((p) => p.id === q.parentQuestionId)?.title
|
|
727
|
+
: undefined;
|
|
728
|
+
|
|
729
|
+
// Read code snippet blobs so they survive drive sync
|
|
730
|
+
const ctxDir =
|
|
731
|
+
storage.getContextFilesDirForWorkspace(workspaceId);
|
|
732
|
+
const contextFilesWithContent: Array<
|
|
733
|
+
storage.ContextFile & { content?: string }
|
|
734
|
+
> = [];
|
|
735
|
+
for (const cf of q.contextFiles || []) {
|
|
736
|
+
if (
|
|
737
|
+
cf.origin === "user" ||
|
|
738
|
+
cf.origin === "ai" ||
|
|
739
|
+
cf.origin === "sandbox"
|
|
740
|
+
) {
|
|
741
|
+
try {
|
|
742
|
+
const content = await fs.readFile(
|
|
743
|
+
path.join(ctxDir, cf.id),
|
|
744
|
+
"utf-8",
|
|
745
|
+
);
|
|
746
|
+
contextFilesWithContent.push({ ...cf, content });
|
|
747
|
+
} catch {
|
|
748
|
+
contextFilesWithContent.push(cf);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
648
753
|
// Export full question as JSON so nothing is lost on sync-back
|
|
649
754
|
const payload = JSON.stringify(
|
|
650
755
|
{
|
|
651
756
|
title: q.title,
|
|
757
|
+
parentTitle: parentTitle ?? null,
|
|
652
758
|
systemContext: q.systemContext || "",
|
|
653
759
|
messages: q.messages,
|
|
654
760
|
codeContextFiles: q.codeContextFiles,
|
|
761
|
+
codeAnnotations: q.codeAnnotations ?? {},
|
|
762
|
+
codeSnippets: contextFilesWithContent,
|
|
655
763
|
},
|
|
656
764
|
null,
|
|
657
765
|
2,
|