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
|
@@ -12,9 +12,8 @@ interface Props {
|
|
|
12
12
|
onAnnotationUpdate: (updated: Annotation) => void;
|
|
13
13
|
bookmarkedBlockIndex?: number;
|
|
14
14
|
onBookmarkBlock?: (blockIndex: number) => void;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
responseAudience?: string;
|
|
15
|
+
preferenceSuffix?: string;
|
|
16
|
+
onSpecRefined?: (originalSpec: string, newSpec: string) => void;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
type Phase = "idle" | "button" | "input" | "loading";
|
|
@@ -119,9 +118,8 @@ export default function TextAnnotator({
|
|
|
119
118
|
onAnnotationUpdate,
|
|
120
119
|
bookmarkedBlockIndex,
|
|
121
120
|
onBookmarkBlock,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
responseAudience,
|
|
121
|
+
preferenceSuffix,
|
|
122
|
+
onSpecRefined,
|
|
125
123
|
}: Props) {
|
|
126
124
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
127
125
|
const annotationsRef = useRef(annotations);
|
|
@@ -207,9 +205,7 @@ export default function TextAnnotator({
|
|
|
207
205
|
selectedText,
|
|
208
206
|
prompt: inputValue.trim(),
|
|
209
207
|
messageContent: content,
|
|
210
|
-
|
|
211
|
-
responseStyle,
|
|
212
|
-
responseAudience,
|
|
208
|
+
preferenceSuffix,
|
|
213
209
|
}),
|
|
214
210
|
});
|
|
215
211
|
const data = await res.json();
|
|
@@ -248,9 +244,7 @@ export default function TextAnnotator({
|
|
|
248
244
|
messageContent: content,
|
|
249
245
|
priorResponse: annotation.response,
|
|
250
246
|
followUps: annotation.followUps ?? [],
|
|
251
|
-
|
|
252
|
-
responseStyle,
|
|
253
|
-
responseAudience,
|
|
247
|
+
preferenceSuffix,
|
|
254
248
|
}),
|
|
255
249
|
});
|
|
256
250
|
const data = await res.json();
|
|
@@ -288,6 +282,7 @@ export default function TextAnnotator({
|
|
|
288
282
|
onAnnotationClick={handleAnnotationClick}
|
|
289
283
|
bookmarkedBlockIndex={bookmarkedBlockIndex}
|
|
290
284
|
onBookmarkBlock={onBookmarkBlock}
|
|
285
|
+
onSpecRefined={onSpecRefined}
|
|
291
286
|
/>
|
|
292
287
|
|
|
293
288
|
{/* Annotation dialog — opened by clicking an underlined annotation link */}
|
|
@@ -298,9 +293,7 @@ export default function TextAnnotator({
|
|
|
298
293
|
onUpdate={onAnnotationUpdate}
|
|
299
294
|
messageContent={content}
|
|
300
295
|
initialPos={dialogPos}
|
|
301
|
-
|
|
302
|
-
responseStyle={responseStyle}
|
|
303
|
-
responseAudience={responseAudience}
|
|
296
|
+
preferenceSuffix={preferenceSuffix}
|
|
304
297
|
/>
|
|
305
298
|
)}
|
|
306
299
|
|
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
Loader2,
|
|
9
9
|
ZoomIn,
|
|
10
10
|
ZoomOut,
|
|
11
|
+
Send,
|
|
12
|
+
MessageSquare,
|
|
13
|
+
Undo2,
|
|
11
14
|
} from "lucide-react";
|
|
12
15
|
import { parse as parseYaml } from "yaml";
|
|
13
16
|
import {
|
|
@@ -86,8 +89,24 @@ function ensureVizCss() {
|
|
|
86
89
|
document.head.appendChild(style);
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Replace typographic characters that the YAML parser mishandles:
|
|
94
|
+
* - en-dash (U+2013) and em-dash (U+2014) → plain hyphen
|
|
95
|
+
* - left/right curly double-quotes → straight double-quote
|
|
96
|
+
* - left/right curly single-quotes / apostrophes → straight single-quote
|
|
97
|
+
*
|
|
98
|
+
* These are commonly injected by LLMs or copy-paste from rich text.
|
|
99
|
+
*/
|
|
100
|
+
function sanitizeSpecText(raw: string): string {
|
|
101
|
+
return raw
|
|
102
|
+
.replace(/\u2013/g, "-") // en-dash –
|
|
103
|
+
.replace(/\u2014/g, "-") // em-dash —
|
|
104
|
+
.replace(/[\u201C\u201D]/g, '"') // curly double quotes " "
|
|
105
|
+
.replace(/[\u2018\u2019\u02BC]/g, "'"); // curly single quotes ' '
|
|
106
|
+
}
|
|
107
|
+
|
|
89
108
|
function parseSpec(raw: string): VizSpec {
|
|
90
|
-
const trimmed = raw.trim();
|
|
109
|
+
const trimmed = sanitizeSpecText(raw.trim());
|
|
91
110
|
// Try JSON first, then YAML
|
|
92
111
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
93
112
|
return JSON.parse(trimmed) as VizSpec;
|
|
@@ -139,9 +158,11 @@ interface StepState {
|
|
|
139
158
|
|
|
140
159
|
interface Props {
|
|
141
160
|
spec: string;
|
|
161
|
+
/** Called after the user successfully refines the spec so the parent can persist it. */
|
|
162
|
+
onSpecRefined?: (originalSpec: string, newSpec: string) => void;
|
|
142
163
|
}
|
|
143
164
|
|
|
144
|
-
export default memo(function VizCraftEmbed({ spec }: Props) {
|
|
165
|
+
export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
|
|
145
166
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
146
167
|
const controllerRef = useRef<MountController | StepController | null>(null);
|
|
147
168
|
// Tracks the active step's MountController (set in custom step mode) so panZoom is accessible
|
|
@@ -150,10 +171,16 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
|
|
|
150
171
|
const [error, setError] = useState<string | null>(null);
|
|
151
172
|
const [fixing, setFixing] = useState(false);
|
|
152
173
|
const [stepState, setStepState] = useState<StepState | null>(null);
|
|
174
|
+
const [refineInput, setRefineInput] = useState("");
|
|
175
|
+
const [refining, setRefining] = useState(false);
|
|
176
|
+
const [refineHistory, setRefineHistory] = useState<
|
|
177
|
+
Array<{ prompt: string; spec: string }>
|
|
178
|
+
>([]);
|
|
153
179
|
|
|
154
180
|
// Keep activeSpec in sync when the prop changes (streaming / message reload)
|
|
155
181
|
useEffect(() => {
|
|
156
182
|
setActiveSpec(spec);
|
|
183
|
+
setRefineHistory([]);
|
|
157
184
|
}, [spec]);
|
|
158
185
|
|
|
159
186
|
const handleFix = async () => {
|
|
@@ -174,6 +201,41 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
|
|
|
174
201
|
}
|
|
175
202
|
};
|
|
176
203
|
|
|
204
|
+
const handleRefine = async (e: React.FormEvent) => {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
const prompt = refineInput.trim();
|
|
207
|
+
if (!prompt || refining) return;
|
|
208
|
+
setRefining(true);
|
|
209
|
+
try {
|
|
210
|
+
const res = await fetch("/api/refine-viz", {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { "Content-Type": "application/json" },
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
spec: activeSpec,
|
|
215
|
+
prompt,
|
|
216
|
+
history: refineHistory,
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
if (!res.ok) throw new Error("Refine request failed");
|
|
220
|
+
const { spec: refined } = (await res.json()) as { spec: string };
|
|
221
|
+
setRefineHistory((prev) => [...prev, { prompt, spec: activeSpec }]);
|
|
222
|
+
onSpecRefined?.(spec, refined);
|
|
223
|
+
setActiveSpec(refined);
|
|
224
|
+
setRefineInput("");
|
|
225
|
+
} catch (err) {
|
|
226
|
+
console.error("Refine viz error:", err);
|
|
227
|
+
} finally {
|
|
228
|
+
setRefining(false);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const handleUndoRefine = () => {
|
|
233
|
+
if (refineHistory.length === 0) return;
|
|
234
|
+
const prev = refineHistory[refineHistory.length - 1];
|
|
235
|
+
setActiveSpec(prev.spec);
|
|
236
|
+
setRefineHistory((h) => h.slice(0, -1));
|
|
237
|
+
};
|
|
238
|
+
|
|
177
239
|
// Prevent the parent chat scroll container from scrolling when the user
|
|
178
240
|
// wheels over the viz. We need a non-passive listener so preventDefault works.
|
|
179
241
|
// (React's synthetic onWheel is passive in some environments and can't prevent scroll.)
|
|
@@ -245,24 +307,43 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
|
|
|
245
307
|
};
|
|
246
308
|
};
|
|
247
309
|
|
|
310
|
+
// Timer to unblock "Next" if signals never complete (e.g. invalid edge direction)
|
|
311
|
+
let signalReadyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
312
|
+
const clearSignalTimer = () => {
|
|
313
|
+
if (signalReadyTimer !== null) {
|
|
314
|
+
clearTimeout(signalReadyTimer);
|
|
315
|
+
signalReadyTimer = null;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
248
319
|
const goTo = (index: number) => {
|
|
249
320
|
if (cancelled || index < 0 || index >= total) return;
|
|
321
|
+
clearSignalTimer();
|
|
250
322
|
// Preserve the current viewport so navigating steps doesn't reset zoom/pan.
|
|
251
323
|
// getState() is only available after the first mount (prevState is undefined on step 0).
|
|
252
324
|
const prevState = mountControllerRef.current?.panZoom?.getState();
|
|
253
325
|
currentMount?.destroy();
|
|
254
326
|
currentMount = null;
|
|
255
327
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
328
|
+
// Wrap VizCraft mount in try-catch — runtime errors (e.g. unknown node in chain)
|
|
329
|
+
// are displayed in the error panel rather than silently freezing the component.
|
|
330
|
+
try {
|
|
331
|
+
const builder = fromSpec(buildStepSpec(index));
|
|
332
|
+
injectLabelMaxWidth(builder);
|
|
333
|
+
currentMount = builder.mount(container, {
|
|
334
|
+
panZoom: true,
|
|
335
|
+
// Restore previous zoom if the user had already panned/zoomed; otherwise fit.
|
|
336
|
+
initialZoom: prevState?.zoom ?? "fit",
|
|
337
|
+
initialPan: prevState?.pan,
|
|
338
|
+
minZoom: 0.1,
|
|
339
|
+
maxZoom: 8,
|
|
340
|
+
});
|
|
341
|
+
} catch (e) {
|
|
342
|
+
setError(
|
|
343
|
+
e instanceof Error ? e.message : "Failed to render step",
|
|
344
|
+
);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
266
347
|
mountControllerRef.current = currentMount;
|
|
267
348
|
currentIndex = index;
|
|
268
349
|
|
|
@@ -281,15 +362,23 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
|
|
|
281
362
|
if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
|
|
282
363
|
} else {
|
|
283
364
|
let done = 0;
|
|
365
|
+
const onComplete = () => {
|
|
366
|
+
done++;
|
|
367
|
+
if (done >= signals.length) {
|
|
368
|
+
clearSignalTimer();
|
|
369
|
+
setStepState((prev) => prev && { ...prev, isReady: true });
|
|
370
|
+
if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
284
373
|
signals.forEach((s) => {
|
|
285
|
-
currentMount!.onSignalComplete(s.id,
|
|
286
|
-
done++;
|
|
287
|
-
if (done >= signals.length) {
|
|
288
|
-
setStepState((prev) => prev && { ...prev, isReady: true });
|
|
289
|
-
if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
|
|
290
|
-
}
|
|
291
|
-
});
|
|
374
|
+
currentMount!.onSignalComplete(s.id, onComplete);
|
|
292
375
|
});
|
|
376
|
+
// Fallback: if signals never fire (e.g. signal chain travels against edge direction),
|
|
377
|
+
// unblock the Next button after 6 s so the user isn't permanently stuck.
|
|
378
|
+
signalReadyTimer = setTimeout(() => {
|
|
379
|
+
signalReadyTimer = null;
|
|
380
|
+
setStepState((prev) => prev && { ...prev, isReady: true });
|
|
381
|
+
}, 6000);
|
|
293
382
|
}
|
|
294
383
|
};
|
|
295
384
|
|
|
@@ -302,6 +391,7 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
|
|
|
302
391
|
reset: () => goTo(0),
|
|
303
392
|
destroy: () => {
|
|
304
393
|
cancelled = true;
|
|
394
|
+
clearSignalTimer();
|
|
305
395
|
currentMount?.destroy();
|
|
306
396
|
currentMount = null;
|
|
307
397
|
mountControllerRef.current = null;
|
|
@@ -497,6 +587,59 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
|
|
|
497
587
|
)}
|
|
498
588
|
</div>
|
|
499
589
|
)}
|
|
590
|
+
|
|
591
|
+
{/* Refine panel */}
|
|
592
|
+
<div className="border-t border-slate-700/50">
|
|
593
|
+
{/* History chips */}
|
|
594
|
+
{refineHistory.length > 0 && (
|
|
595
|
+
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-2">
|
|
596
|
+
{refineHistory.map((h, i) => (
|
|
597
|
+
<span
|
|
598
|
+
key={i}
|
|
599
|
+
className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-slate-800 text-slate-400 border border-slate-700 max-w-[180px]"
|
|
600
|
+
title={h.prompt}
|
|
601
|
+
>
|
|
602
|
+
<MessageSquare className="w-2.5 h-2.5 flex-shrink-0" />
|
|
603
|
+
<span className="truncate">{h.prompt}</span>
|
|
604
|
+
</span>
|
|
605
|
+
))}
|
|
606
|
+
<button
|
|
607
|
+
onClick={handleUndoRefine}
|
|
608
|
+
className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-slate-800 text-slate-400 hover:text-amber-300 hover:border-amber-500/40 border border-slate-700 transition-colors"
|
|
609
|
+
title="Undo last refinement"
|
|
610
|
+
>
|
|
611
|
+
<Undo2 className="w-2.5 h-2.5" />
|
|
612
|
+
Undo
|
|
613
|
+
</button>
|
|
614
|
+
</div>
|
|
615
|
+
)}
|
|
616
|
+
{/* Prompt input row */}
|
|
617
|
+
<form
|
|
618
|
+
onSubmit={handleRefine}
|
|
619
|
+
className="flex items-center gap-2 px-3 py-2"
|
|
620
|
+
>
|
|
621
|
+
<input
|
|
622
|
+
type="text"
|
|
623
|
+
value={refineInput}
|
|
624
|
+
onChange={(e) => setRefineInput(e.target.value)}
|
|
625
|
+
placeholder="Describe a change… e.g. add a database node"
|
|
626
|
+
className="flex-1 bg-slate-800 border border-slate-700 rounded px-2.5 py-1.5 text-xs text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-600 transition-colors"
|
|
627
|
+
disabled={refining}
|
|
628
|
+
/>
|
|
629
|
+
<button
|
|
630
|
+
type="submit"
|
|
631
|
+
disabled={refining || !refineInput.trim()}
|
|
632
|
+
className="flex items-center justify-center w-7 h-7 rounded bg-cyan-700 text-white hover:bg-cyan-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"
|
|
633
|
+
title="Refine diagram"
|
|
634
|
+
>
|
|
635
|
+
{refining ? (
|
|
636
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
637
|
+
) : (
|
|
638
|
+
<Send className="w-3.5 h-3.5" />
|
|
639
|
+
)}
|
|
640
|
+
</button>
|
|
641
|
+
</form>
|
|
642
|
+
</div>
|
|
500
643
|
</div>
|
|
501
644
|
);
|
|
502
645
|
});
|
|
@@ -11,6 +11,8 @@ const DEFAULT_AI_SETTINGS: AiSettings = {
|
|
|
11
11
|
normal: { maxOutputTokens: 3000, maxSteps: 5 },
|
|
12
12
|
},
|
|
13
13
|
vizGuide: "",
|
|
14
|
+
alwaysSendPrefsDefault: false,
|
|
15
|
+
thinkingBudget: 0,
|
|
14
16
|
promptGroups: {
|
|
15
17
|
length: {
|
|
16
18
|
label: "Response Length",
|
|
@@ -64,6 +66,7 @@ interface Store {
|
|
|
64
66
|
showCodePanel: boolean;
|
|
65
67
|
showSidebar: boolean;
|
|
66
68
|
viewingFile: string | null;
|
|
69
|
+
viewingDoc: { fileId: string; quote: string; fileName: string } | null;
|
|
67
70
|
|
|
68
71
|
// ── Workspaces ───────────────────────────────────────────────
|
|
69
72
|
workspaces: WorkspaceMeta[];
|
|
@@ -136,18 +139,53 @@ interface Store {
|
|
|
136
139
|
files: FileList | File[],
|
|
137
140
|
) => Promise<void>;
|
|
138
141
|
removeTopicFile: (topicId: string, fileId: string) => Promise<void>;
|
|
142
|
+
linkFileToTopic: (
|
|
143
|
+
topicId: string,
|
|
144
|
+
fileId: string,
|
|
145
|
+
originalName: string,
|
|
146
|
+
) => Promise<void>;
|
|
139
147
|
uploadQuestionFiles: (
|
|
140
148
|
questionId: string,
|
|
141
149
|
files: FileList | File[],
|
|
142
150
|
) => Promise<void>;
|
|
143
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>;
|
|
144
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
|
+
|
|
145
172
|
updateQuestionSystemContext: (
|
|
146
173
|
questionId: string,
|
|
147
174
|
systemContext: string,
|
|
148
175
|
) => Promise<void>;
|
|
149
176
|
openFileViewer: (path: string) => void;
|
|
150
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;
|
|
151
189
|
codeSnippets: CodeSnippet[];
|
|
152
190
|
addSnippet: (snippet: CodeSnippet) => void;
|
|
153
191
|
removeSnippet: (id: string) => void;
|
|
@@ -157,13 +195,51 @@ interface Store {
|
|
|
157
195
|
aiSettings: AiSettings;
|
|
158
196
|
fetchAiSettings: () => Promise<void>;
|
|
159
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;
|
|
160
201
|
showSettings: boolean;
|
|
161
202
|
openSettings: () => void;
|
|
162
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;
|
|
163
238
|
}
|
|
164
239
|
|
|
165
240
|
export const useStore = create<Store>((set, get) => ({
|
|
166
241
|
topics: [],
|
|
242
|
+
workspaceFiles: [],
|
|
167
243
|
questionsByTopic: {},
|
|
168
244
|
selectedTopicId: null,
|
|
169
245
|
selectedQuestionId: null,
|
|
@@ -173,9 +249,16 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
173
249
|
showCodePanel: false,
|
|
174
250
|
showSidebar: true,
|
|
175
251
|
viewingFile: null,
|
|
252
|
+
viewingDoc: null,
|
|
253
|
+
inlineCodeSnippets: {},
|
|
176
254
|
codeSnippets: [],
|
|
177
255
|
aiSettings: DEFAULT_AI_SETTINGS,
|
|
256
|
+
livePreferenceSuffix: "",
|
|
178
257
|
showSettings: false,
|
|
258
|
+
showCodeRunner: false,
|
|
259
|
+
runnerInitialCode: "",
|
|
260
|
+
runnerInitialLanguage: "typescript",
|
|
261
|
+
runnerInitialSandbox: null,
|
|
179
262
|
|
|
180
263
|
// ── Workspaces ───────────────────────────────────────────────
|
|
181
264
|
workspaces: [],
|
|
@@ -215,6 +298,8 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
215
298
|
});
|
|
216
299
|
const topics = await api.fetchTopics();
|
|
217
300
|
set({ topics });
|
|
301
|
+
const workspaceFiles = await api.fetchWorkspaceFiles();
|
|
302
|
+
set({ workspaceFiles });
|
|
218
303
|
},
|
|
219
304
|
|
|
220
305
|
deleteWorkspace: async (id) => {
|
|
@@ -506,6 +591,17 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
506
591
|
}));
|
|
507
592
|
},
|
|
508
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
|
+
|
|
509
605
|
uploadQuestionFiles: async (questionId, files) => {
|
|
510
606
|
const uploaded = await api.uploadQuestionFiles(questionId, files);
|
|
511
607
|
set((s) => ({
|
|
@@ -537,6 +633,45 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
537
633
|
}));
|
|
538
634
|
},
|
|
539
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
|
+
|
|
540
675
|
clearMessages: async (questionId) => {
|
|
541
676
|
await api.updateQuestion(questionId, { messages: [] });
|
|
542
677
|
set((s) => ({
|
|
@@ -547,6 +682,23 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
547
682
|
}));
|
|
548
683
|
},
|
|
549
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
|
+
|
|
550
702
|
updateQuestionSystemContext: async (questionId, systemContext) => {
|
|
551
703
|
const updated = await api.updateQuestion(questionId, { systemContext });
|
|
552
704
|
set((s) => ({
|
|
@@ -568,6 +720,53 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
568
720
|
|
|
569
721
|
openFileViewer: (path) => set({ viewingFile: path }),
|
|
570
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 }),
|
|
571
770
|
|
|
572
771
|
fetchAiSettings: async () => {
|
|
573
772
|
const settings = await api.fetchAiSettings();
|
|
@@ -579,6 +778,8 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
579
778
|
set({ aiSettings: updated });
|
|
580
779
|
},
|
|
581
780
|
|
|
781
|
+
setLivePreferenceSuffix: (suffix) => set({ livePreferenceSuffix: suffix }),
|
|
782
|
+
|
|
582
783
|
openSettings: () => set({ showSettings: true }),
|
|
583
784
|
closeSettings: () => set({ showSettings: false }),
|
|
584
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 {
|
package/template/cockpit.json
CHANGED
package/template/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"dev": "concurrently -n server,client -c blue,green \"npm run dev --prefix server\" \"npm run dev --prefix client\"",
|
|
7
7
|
"build": "npm run build --prefix client",
|
|
8
8
|
"start": "npm run start --prefix server",
|
|
9
|
-
"sync:template": "
|
|
9
|
+
"sync:template": "node scripts/sync-template.js"
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"concurrently": "^9.1.0"
|