create-interview-cockpit 0.5.0 → 0.7.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 +734 -1
- package/template/client/package.json +1 -0
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +384 -4
- package/template/client/src/components/AiSettingsModal.tsx +818 -425
- package/template/client/src/components/ChatMessage.tsx +34 -12
- package/template/client/src/components/ChatView.tsx +298 -121
- package/template/client/src/components/CodeContextPanel.tsx +530 -2
- package/template/client/src/components/CodeRunnerModal.tsx +1895 -120
- package/template/client/src/components/DocRefModal.tsx +55 -6
- package/template/client/src/components/FileAttachments.tsx +20 -4
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +22 -8
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +184 -0
- package/template/client/src/components/VizCraftEmbed.tsx +257 -13
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +960 -0
- package/template/client/src/store.ts +250 -6
- package/template/client/src/types.ts +36 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +39 -3
- package/template/server/src/index.ts +954 -52
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +22 -3
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { memo, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Loader2, RefreshCw } from "lucide-react";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
spec: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function sanitizePlotSpec(raw: string): string {
|
|
10
|
+
return raw
|
|
11
|
+
.trim()
|
|
12
|
+
.replace(/^```(?:plot|vega|vega-lite|json|yaml)?\s*/i, "")
|
|
13
|
+
.replace(/```\s*$/i, "")
|
|
14
|
+
.replace(/[\u201C\u201D]/g, '"')
|
|
15
|
+
.replace(/[\u2018\u2019\u02BC]/g, "'")
|
|
16
|
+
.replace(/\u2013|\u2014/g, "-");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parsePlotSpec(raw: string): unknown {
|
|
20
|
+
const trimmed = sanitizePlotSpec(raw);
|
|
21
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
22
|
+
return JSON.parse(trimmed);
|
|
23
|
+
}
|
|
24
|
+
return parseYaml(trimmed);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const EMBED_OPTIONS = {
|
|
28
|
+
actions: false,
|
|
29
|
+
renderer: "svg" as const,
|
|
30
|
+
theme: "dark" as const,
|
|
31
|
+
config: {
|
|
32
|
+
background: "transparent",
|
|
33
|
+
title: {
|
|
34
|
+
color: "#e2e8f0",
|
|
35
|
+
subtitleColor: "#94a3b8",
|
|
36
|
+
},
|
|
37
|
+
axis: {
|
|
38
|
+
domainColor: "#475569",
|
|
39
|
+
gridColor: "rgba(71, 85, 105, 0.35)",
|
|
40
|
+
tickColor: "#64748b",
|
|
41
|
+
labelColor: "#cbd5e1",
|
|
42
|
+
titleColor: "#e2e8f0",
|
|
43
|
+
},
|
|
44
|
+
legend: {
|
|
45
|
+
labelColor: "#cbd5e1",
|
|
46
|
+
titleColor: "#e2e8f0",
|
|
47
|
+
},
|
|
48
|
+
view: {
|
|
49
|
+
stroke: "transparent",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default memo(function PlotEmbed({ spec }: Props) {
|
|
55
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
56
|
+
const finalizeRef = useRef<null | (() => void)>(null);
|
|
57
|
+
const [activeSpec, setActiveSpec] = useState(spec);
|
|
58
|
+
const [error, setError] = useState<string | null>(null);
|
|
59
|
+
const [fixing, setFixing] = useState(false);
|
|
60
|
+
const [rendering, setRendering] = useState(true);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setActiveSpec(spec);
|
|
64
|
+
}, [spec]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
let cancelled = false;
|
|
68
|
+
|
|
69
|
+
const renderPlot = async () => {
|
|
70
|
+
setRendering(true);
|
|
71
|
+
setError(null);
|
|
72
|
+
finalizeRef.current?.();
|
|
73
|
+
finalizeRef.current = null;
|
|
74
|
+
if (hostRef.current) {
|
|
75
|
+
hostRef.current.innerHTML = "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const parsed = parsePlotSpec(activeSpec);
|
|
80
|
+
const { default: vegaEmbed } = await import("vega-embed");
|
|
81
|
+
if (cancelled || !hostRef.current) return;
|
|
82
|
+
const result = await vegaEmbed(
|
|
83
|
+
hostRef.current,
|
|
84
|
+
parsed as any,
|
|
85
|
+
EMBED_OPTIONS,
|
|
86
|
+
);
|
|
87
|
+
finalizeRef.current = () => result.finalize();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (!cancelled) {
|
|
90
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
if (!cancelled) {
|
|
94
|
+
setRendering(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
void renderPlot();
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
cancelled = true;
|
|
103
|
+
finalizeRef.current?.();
|
|
104
|
+
finalizeRef.current = null;
|
|
105
|
+
};
|
|
106
|
+
}, [activeSpec]);
|
|
107
|
+
|
|
108
|
+
const handleFix = async () => {
|
|
109
|
+
setFixing(true);
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch("/api/fix-plot", {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ spec: activeSpec, error }),
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok) throw new Error("Fix request failed");
|
|
117
|
+
const { spec: fixed } = (await res.json()) as { spec: string };
|
|
118
|
+
setActiveSpec(fixed);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error("Fix plot error:", err);
|
|
121
|
+
} finally {
|
|
122
|
+
setFixing(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (error) {
|
|
127
|
+
return (
|
|
128
|
+
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 my-2">
|
|
129
|
+
<div className="flex items-center justify-between mb-1 gap-3">
|
|
130
|
+
<div>
|
|
131
|
+
<p className="text-xs text-red-400">Plot error:</p>
|
|
132
|
+
<p className="text-[11px] text-slate-500 mt-1 whitespace-pre-wrap">
|
|
133
|
+
{error}
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
<button
|
|
137
|
+
onClick={handleFix}
|
|
138
|
+
disabled={fixing}
|
|
139
|
+
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded bg-slate-700 text-slate-300 hover:bg-slate-600 disabled:opacity-50 transition-colors shrink-0"
|
|
140
|
+
>
|
|
141
|
+
{fixing ? (
|
|
142
|
+
<>
|
|
143
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
144
|
+
Fixing…
|
|
145
|
+
</>
|
|
146
|
+
) : (
|
|
147
|
+
<>
|
|
148
|
+
<RefreshCw className="w-3 h-3" />
|
|
149
|
+
Fix plot
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
<pre className="text-xs text-slate-400 whitespace-pre-wrap overflow-x-auto">
|
|
155
|
+
{activeSpec}
|
|
156
|
+
</pre>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="my-3 bg-slate-800/30 rounded-lg p-4 overflow-x-auto">
|
|
163
|
+
{rendering && (
|
|
164
|
+
<div className="flex justify-center pb-4">
|
|
165
|
+
<span className="text-xs text-slate-500 animate-pulse">
|
|
166
|
+
Rendering plot…
|
|
167
|
+
</span>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
<div ref={hostRef} className="plot-embed min-w-[280px]" />
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
});
|
|
@@ -17,8 +17,12 @@ import {
|
|
|
17
17
|
ArrowLeft,
|
|
18
18
|
RefreshCw,
|
|
19
19
|
Globe,
|
|
20
|
+
SlidersHorizontal,
|
|
21
|
+
ArrowRightLeft,
|
|
20
22
|
} from "lucide-react";
|
|
21
23
|
|
|
24
|
+
const ROOT_PARENT_VALUE = "__root__";
|
|
25
|
+
|
|
22
26
|
export default function Sidebar() {
|
|
23
27
|
const {
|
|
24
28
|
topics,
|
|
@@ -31,6 +35,7 @@ export default function Sidebar() {
|
|
|
31
35
|
renameTopic,
|
|
32
36
|
addQuestion,
|
|
33
37
|
addChildQuestion,
|
|
38
|
+
moveQuestion,
|
|
34
39
|
removeQuestion,
|
|
35
40
|
renameQuestion,
|
|
36
41
|
selectQuestion,
|
|
@@ -49,6 +54,7 @@ export default function Sidebar() {
|
|
|
49
54
|
workspaceFiles,
|
|
50
55
|
uploadWorkspaceFiles,
|
|
51
56
|
removeWorkspaceFile,
|
|
57
|
+
updateTopicSystemContext,
|
|
52
58
|
} = useStore();
|
|
53
59
|
|
|
54
60
|
const [newTopicName, setNewTopicName] = useState("");
|
|
@@ -63,9 +69,34 @@ export default function Sidebar() {
|
|
|
63
69
|
null,
|
|
64
70
|
);
|
|
65
71
|
const [editingQuestionTitle, setEditingQuestionTitle] = useState("");
|
|
72
|
+
const [movingQuestionId, setMovingQuestionId] = useState<string | null>(null);
|
|
73
|
+
const [moveTargetParentId, setMoveTargetParentId] =
|
|
74
|
+
useState(ROOT_PARENT_VALUE);
|
|
66
75
|
const [collapsedQuestions, setCollapsedQuestions] = useState<Set<string>>(
|
|
67
76
|
new Set(),
|
|
68
77
|
);
|
|
78
|
+
const [openTopicPrompts, setOpenTopicPrompts] = useState<Set<string>>(
|
|
79
|
+
new Set(),
|
|
80
|
+
);
|
|
81
|
+
const topicPromptTimers = useRef<
|
|
82
|
+
Record<string, ReturnType<typeof setTimeout>>
|
|
83
|
+
>({});
|
|
84
|
+
|
|
85
|
+
const toggleTopicPrompt = (topicId: string) => {
|
|
86
|
+
setOpenTopicPrompts((prev) => {
|
|
87
|
+
const next = new Set(prev);
|
|
88
|
+
if (next.has(topicId)) next.delete(topicId);
|
|
89
|
+
else next.add(topicId);
|
|
90
|
+
return next;
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleTopicPromptChange = (topicId: string, value: string) => {
|
|
95
|
+
clearTimeout(topicPromptTimers.current[topicId]);
|
|
96
|
+
topicPromptTimers.current[topicId] = setTimeout(() => {
|
|
97
|
+
updateTopicSystemContext(topicId, value);
|
|
98
|
+
}, 600);
|
|
99
|
+
};
|
|
69
100
|
|
|
70
101
|
const toggleQuestionCollapse = (questionId: string) => {
|
|
71
102
|
setCollapsedQuestions((prev) => {
|
|
@@ -174,6 +205,111 @@ export default function Sidebar() {
|
|
|
174
205
|
setAddingChildTo(null);
|
|
175
206
|
};
|
|
176
207
|
|
|
208
|
+
const getDescendantIds = (questions: Question[], questionId: string) => {
|
|
209
|
+
const descendants = new Set<string>();
|
|
210
|
+
const visit = (parentId: string) => {
|
|
211
|
+
for (const candidate of questions) {
|
|
212
|
+
if (candidate.parentQuestionId !== parentId) continue;
|
|
213
|
+
if (descendants.has(candidate.id)) continue;
|
|
214
|
+
descendants.add(candidate.id);
|
|
215
|
+
visit(candidate.id);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
visit(questionId);
|
|
219
|
+
return descendants;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const buildMoveParentOptions = (
|
|
223
|
+
questions: Question[],
|
|
224
|
+
excludedIds: Set<string>,
|
|
225
|
+
parentId: string | null,
|
|
226
|
+
depth: number,
|
|
227
|
+
): Array<{ id: string; title: string }> => {
|
|
228
|
+
const siblings = questions.filter(
|
|
229
|
+
(q) => (q.parentQuestionId ?? null) === parentId,
|
|
230
|
+
);
|
|
231
|
+
return siblings.flatMap((q) => {
|
|
232
|
+
if (excludedIds.has(q.id)) return [];
|
|
233
|
+
return [
|
|
234
|
+
{
|
|
235
|
+
id: q.id,
|
|
236
|
+
title: `${"— ".repeat(depth)}${q.title}`,
|
|
237
|
+
},
|
|
238
|
+
...buildMoveParentOptions(questions, excludedIds, q.id, depth + 1),
|
|
239
|
+
];
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const handleMoveQuestion = async (
|
|
244
|
+
topicId: string,
|
|
245
|
+
questionId: string,
|
|
246
|
+
targetParentId: string | null,
|
|
247
|
+
) => {
|
|
248
|
+
await moveQuestion(questionId, topicId, targetParentId);
|
|
249
|
+
setMovingQuestionId(null);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const renderMoveQuestionPicker = (
|
|
253
|
+
questions: Question[],
|
|
254
|
+
q: Question,
|
|
255
|
+
topicId: string,
|
|
256
|
+
depth: number,
|
|
257
|
+
) => {
|
|
258
|
+
if (movingQuestionId !== q.id) return null;
|
|
259
|
+
const excludedIds = getDescendantIds(questions, q.id);
|
|
260
|
+
excludedIds.add(q.id);
|
|
261
|
+
const parentOptions = buildMoveParentOptions(
|
|
262
|
+
questions,
|
|
263
|
+
excludedIds,
|
|
264
|
+
null,
|
|
265
|
+
0,
|
|
266
|
+
);
|
|
267
|
+
const targetParentId =
|
|
268
|
+
moveTargetParentId === ROOT_PARENT_VALUE ? null : moveTargetParentId;
|
|
269
|
+
const unchanged = (q.parentQuestionId ?? null) === targetParentId;
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div
|
|
273
|
+
className="pr-2 py-1.5 animate-fadeIn"
|
|
274
|
+
style={{ paddingLeft: 12 + (depth + 1) * 16 }}
|
|
275
|
+
>
|
|
276
|
+
<div className="rounded border border-slate-700 bg-slate-800/70 p-2 space-y-2">
|
|
277
|
+
<div className="text-[11px] text-slate-500">Move under</div>
|
|
278
|
+
<select
|
|
279
|
+
autoFocus
|
|
280
|
+
value={moveTargetParentId}
|
|
281
|
+
onChange={(e) => setMoveTargetParentId(e.target.value)}
|
|
282
|
+
className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-cyan-500"
|
|
283
|
+
>
|
|
284
|
+
<option value={ROOT_PARENT_VALUE}>No parent (top level)</option>
|
|
285
|
+
{parentOptions.map((option) => (
|
|
286
|
+
<option key={option.id} value={option.id}>
|
|
287
|
+
{option.title}
|
|
288
|
+
</option>
|
|
289
|
+
))}
|
|
290
|
+
</select>
|
|
291
|
+
<div className="flex items-center justify-end gap-2">
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
onClick={() => setMovingQuestionId(null)}
|
|
295
|
+
className="px-2 py-1 rounded text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
296
|
+
>
|
|
297
|
+
Cancel
|
|
298
|
+
</button>
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
onClick={() => handleMoveQuestion(topicId, q.id, targetParentId)}
|
|
302
|
+
disabled={unchanged}
|
|
303
|
+
className="px-2 py-1 rounded text-[11px] bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
|
|
304
|
+
>
|
|
305
|
+
Move
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
};
|
|
312
|
+
|
|
177
313
|
const renderQuestionRow = (
|
|
178
314
|
q: Question,
|
|
179
315
|
topicId: string,
|
|
@@ -274,6 +410,23 @@ export default function Sidebar() {
|
|
|
274
410
|
<Pencil className="w-2.5 h-2.5" />
|
|
275
411
|
</button>
|
|
276
412
|
)}
|
|
413
|
+
{editingQuestionId !== q.id && (
|
|
414
|
+
<button
|
|
415
|
+
onClick={(e) => {
|
|
416
|
+
e.stopPropagation();
|
|
417
|
+
setMovingQuestionId((prev) => (prev === q.id ? null : q.id));
|
|
418
|
+
setMoveTargetParentId(q.parentQuestionId ?? ROOT_PARENT_VALUE);
|
|
419
|
+
}}
|
|
420
|
+
className={`p-0.5 rounded opacity-0 group-hover:opacity-100 transition-all ${
|
|
421
|
+
movingQuestionId === q.id
|
|
422
|
+
? "opacity-100 text-cyan-400"
|
|
423
|
+
: "text-slate-600 hover:text-cyan-400"
|
|
424
|
+
}`}
|
|
425
|
+
title="Move to a different parent"
|
|
426
|
+
>
|
|
427
|
+
<ArrowRightLeft className="w-2.5 h-2.5" />
|
|
428
|
+
</button>
|
|
429
|
+
)}
|
|
277
430
|
<button
|
|
278
431
|
onClick={(e) => {
|
|
279
432
|
e.stopPropagation();
|
|
@@ -332,6 +485,7 @@ export default function Sidebar() {
|
|
|
332
485
|
/>
|
|
333
486
|
</div>
|
|
334
487
|
)}
|
|
488
|
+
{renderMoveQuestionPicker(questions, q, topicId, depth)}
|
|
335
489
|
{/* Recurse into children — hidden when collapsed */}
|
|
336
490
|
{!isCollapsed &&
|
|
337
491
|
renderQuestionSubtree(questions, topicId, q.id, depth + 1)}
|
|
@@ -617,6 +771,36 @@ export default function Sidebar() {
|
|
|
617
771
|
/>
|
|
618
772
|
</div>
|
|
619
773
|
|
|
774
|
+
{/* Topic-wide system prompt */}
|
|
775
|
+
<div className="pl-3 pr-2 py-1 border-b border-slate-800/50">
|
|
776
|
+
<button
|
|
777
|
+
onClick={() => toggleTopicPrompt(topic.id)}
|
|
778
|
+
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-cyan-400 transition-colors w-full"
|
|
779
|
+
>
|
|
780
|
+
<SlidersHorizontal className="w-3 h-3" />
|
|
781
|
+
<span>Topic prompt</span>
|
|
782
|
+
<span className="ml-auto flex items-center gap-1">
|
|
783
|
+
{topic.systemContext && (
|
|
784
|
+
<span className="w-1.5 h-1.5 rounded-full bg-cyan-500" />
|
|
785
|
+
)}
|
|
786
|
+
<ChevronDown
|
|
787
|
+
className={`w-3 h-3 transition-transform ${openTopicPrompts.has(topic.id) ? "" : "-rotate-90"}`}
|
|
788
|
+
/>
|
|
789
|
+
</span>
|
|
790
|
+
</button>
|
|
791
|
+
{openTopicPrompts.has(topic.id) && (
|
|
792
|
+
<textarea
|
|
793
|
+
defaultValue={topic.systemContext ?? ""}
|
|
794
|
+
onChange={(e) =>
|
|
795
|
+
handleTopicPromptChange(topic.id, e.target.value)
|
|
796
|
+
}
|
|
797
|
+
placeholder="System context added to every question in this topic…"
|
|
798
|
+
rows={4}
|
|
799
|
+
className="mt-1.5 w-full bg-slate-800/60 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500 resize-y leading-relaxed"
|
|
800
|
+
/>
|
|
801
|
+
)}
|
|
802
|
+
</div>
|
|
803
|
+
|
|
620
804
|
{/* Questions — recursive tree (unlimited depth) */}
|
|
621
805
|
{renderQuestionSubtree(questions, topic.id, null, 0)}
|
|
622
806
|
|