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
|
@@ -30,6 +30,12 @@ import net from "net";
|
|
|
30
30
|
import ts from "typescript";
|
|
31
31
|
import * as storage from "./storage.js";
|
|
32
32
|
import * as googleDrive from "./google-drive.js";
|
|
33
|
+
import {
|
|
34
|
+
getInfraRun,
|
|
35
|
+
listInfraRuns,
|
|
36
|
+
runInfraAction,
|
|
37
|
+
streamInfraCommand,
|
|
38
|
+
} from "./infra-runner.js";
|
|
33
39
|
|
|
34
40
|
const app = express();
|
|
35
41
|
app.use(cors());
|
|
@@ -38,14 +44,14 @@ app.use(express.json({ limit: "25mb" }));
|
|
|
38
44
|
const upload = multer({
|
|
39
45
|
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB max
|
|
40
46
|
fileFilter: (_req, file, cb) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
cb(null, true);
|
|
47
|
+
// Block only clearly non-text binary formats (executables, archives, etc.)
|
|
48
|
+
const blockedExt =
|
|
49
|
+
/\.(exe|dll|so|dylib|bin|zip|tar|gz|bz2|rar|7z|dmg|iso|mp3|mp4|avi|mov|wmv|flac|wav|class|pyc|wasm)$/i;
|
|
50
|
+
if (blockedExt.test(file.originalname)) {
|
|
51
|
+
cb(new Error(`Unsupported file type: ${file.originalname}`));
|
|
47
52
|
} else {
|
|
48
|
-
|
|
53
|
+
// Accept everything else — extractText handles unknown extensions gracefully
|
|
54
|
+
cb(null, true);
|
|
49
55
|
}
|
|
50
56
|
},
|
|
51
57
|
});
|
|
@@ -226,12 +232,24 @@ app.post("/api/workspaces/:id/drive-subfolders", async (req, res) => {
|
|
|
226
232
|
const { name } = req.body as { name?: string };
|
|
227
233
|
if (!name?.trim()) return res.status(400).json({ error: "name required" });
|
|
228
234
|
try {
|
|
235
|
+
if (!(await googleDrive.isExportAuthed())) {
|
|
236
|
+
return res.json({
|
|
237
|
+
needsAuth: true,
|
|
238
|
+
authUrl: googleDrive.getExportAuthUrl(),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
229
241
|
const folder = await googleDrive.createDriveSubfolder(
|
|
230
242
|
req.params.id,
|
|
231
243
|
name.trim(),
|
|
232
244
|
);
|
|
233
245
|
res.json(folder);
|
|
234
246
|
} catch (err: any) {
|
|
247
|
+
if (err?.needsReauth) {
|
|
248
|
+
return res.json({
|
|
249
|
+
needsAuth: true,
|
|
250
|
+
authUrl: googleDrive.getExportAuthUrl(),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
235
253
|
res.status(500).json({ error: err?.message || "Failed to create folder" });
|
|
236
254
|
}
|
|
237
255
|
});
|
|
@@ -363,6 +381,8 @@ app.patch("/api/topics/:id", async (req, res) => {
|
|
|
363
381
|
const topic = topics.find((t) => t.id === req.params.id);
|
|
364
382
|
if (!topic) return res.status(404).json({ error: "Not found" });
|
|
365
383
|
if (typeof req.body.name === "string") topic.name = req.body.name;
|
|
384
|
+
if (typeof req.body.systemContext === "string")
|
|
385
|
+
topic.systemContext = req.body.systemContext;
|
|
366
386
|
await storage.saveTopic(topic);
|
|
367
387
|
res.json(topic);
|
|
368
388
|
});
|
|
@@ -374,33 +394,29 @@ app.get("/api/workspace/context-files", async (_req, res) => {
|
|
|
374
394
|
res.json(files);
|
|
375
395
|
});
|
|
376
396
|
|
|
377
|
-
app.post(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
for
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
Buffer.from(text, "utf-8"),
|
|
394
|
-
);
|
|
395
|
-
results.push(cf);
|
|
396
|
-
}
|
|
397
|
-
res.json(results);
|
|
398
|
-
} catch (err: any) {
|
|
399
|
-
console.error("workspace upload error:", err?.message || err);
|
|
400
|
-
res.status(500).json({ error: err?.message || "Upload failed" });
|
|
397
|
+
app.post("/api/workspace/context-files", upload.any(), async (req, res) => {
|
|
398
|
+
try {
|
|
399
|
+
const files = req.files as Express.Multer.File[];
|
|
400
|
+
if (!files?.length) return res.status(400).json({ error: "No files" });
|
|
401
|
+
const results: storage.ContextFile[] = [];
|
|
402
|
+
for (const file of files) {
|
|
403
|
+
const id = randomUUID();
|
|
404
|
+
const text = await extractText(file.buffer, file.originalname);
|
|
405
|
+
// Store original bytes for download; extracted text for the LLM
|
|
406
|
+
await storage.writeOriginalBlob(id, file.buffer);
|
|
407
|
+
const cf = await storage.saveWorkspaceContextFile(
|
|
408
|
+
id,
|
|
409
|
+
file.originalname,
|
|
410
|
+
Buffer.from(text, "utf-8"),
|
|
411
|
+
);
|
|
412
|
+
results.push(cf);
|
|
401
413
|
}
|
|
402
|
-
|
|
403
|
-
)
|
|
414
|
+
res.json(results);
|
|
415
|
+
} catch (err: any) {
|
|
416
|
+
console.error("workspace upload error:", err?.message || err);
|
|
417
|
+
res.status(500).json({ error: err?.message || "Upload failed" });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
404
420
|
|
|
405
421
|
app.delete("/api/workspace/context-files/:fileId", async (req, res) => {
|
|
406
422
|
await storage.deleteWorkspaceContextFile(req.params.fileId);
|
|
@@ -440,7 +456,7 @@ app.get("/api/workspace/context-files/:fileId/download", async (req, res) => {
|
|
|
440
456
|
|
|
441
457
|
app.post(
|
|
442
458
|
"/api/topics/:topicId/context-files",
|
|
443
|
-
upload.
|
|
459
|
+
upload.any(),
|
|
444
460
|
async (req, res) => {
|
|
445
461
|
try {
|
|
446
462
|
const files = req.files as Express.Multer.File[];
|
|
@@ -538,12 +554,45 @@ app.patch("/api/questions/:id", async (req, res) => {
|
|
|
538
554
|
if (req.body.systemContext !== undefined)
|
|
539
555
|
q.systemContext = req.body.systemContext;
|
|
540
556
|
if (req.body.title !== undefined) q.title = req.body.title;
|
|
541
|
-
if (req.body.parentQuestionId !== undefined)
|
|
542
|
-
|
|
557
|
+
if (req.body.parentQuestionId !== undefined) {
|
|
558
|
+
const nextParentId = req.body.parentQuestionId as string | null;
|
|
559
|
+
if (nextParentId === q.id) {
|
|
560
|
+
return res.status(400).json({ error: "A question cannot parent itself" });
|
|
561
|
+
}
|
|
562
|
+
if (nextParentId != null) {
|
|
563
|
+
const allInTopic = await storage.getQuestionsByTopic(q.topicId);
|
|
564
|
+
const parentExists = allInTopic.some(
|
|
565
|
+
(candidate) => candidate.id === nextParentId,
|
|
566
|
+
);
|
|
567
|
+
if (!parentExists) {
|
|
568
|
+
return res
|
|
569
|
+
.status(400)
|
|
570
|
+
.json({ error: "Selected parent question was not found" });
|
|
571
|
+
}
|
|
572
|
+
const descendantIds = new Set<string>();
|
|
573
|
+
const visit = (parentId: string) => {
|
|
574
|
+
for (const candidate of allInTopic) {
|
|
575
|
+
if (candidate.parentQuestionId !== parentId) continue;
|
|
576
|
+
if (descendantIds.has(candidate.id)) continue;
|
|
577
|
+
descendantIds.add(candidate.id);
|
|
578
|
+
visit(candidate.id);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
visit(q.id);
|
|
582
|
+
if (descendantIds.has(nextParentId)) {
|
|
583
|
+
return res.status(400).json({
|
|
584
|
+
error: "A question cannot be moved under one of its descendants",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
q.parentQuestionId = nextParentId ?? undefined;
|
|
589
|
+
}
|
|
543
590
|
if (req.body.messages !== undefined) q.messages = req.body.messages;
|
|
544
591
|
if (req.body.annotations !== undefined) q.annotations = req.body.annotations;
|
|
545
592
|
if (req.body.readingBookmark !== undefined)
|
|
546
593
|
q.readingBookmark = req.body.readingBookmark;
|
|
594
|
+
if (req.body.linkedConversationIds !== undefined)
|
|
595
|
+
q.linkedConversationIds = req.body.linkedConversationIds;
|
|
547
596
|
await storage.saveQuestion(q);
|
|
548
597
|
res.json(q);
|
|
549
598
|
});
|
|
@@ -598,7 +647,7 @@ app.patch("/api/questions/:id/code-annotations", async (req, res) => {
|
|
|
598
647
|
|
|
599
648
|
app.post(
|
|
600
649
|
"/api/questions/:questionId/context-files",
|
|
601
|
-
upload.
|
|
650
|
+
upload.any(),
|
|
602
651
|
async (req, res) => {
|
|
603
652
|
try {
|
|
604
653
|
const files = req.files as Express.Multer.File[];
|
|
@@ -641,15 +690,31 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
641
690
|
code: string;
|
|
642
691
|
language: string;
|
|
643
692
|
label: string;
|
|
644
|
-
origin:
|
|
693
|
+
origin:
|
|
694
|
+
| "user"
|
|
695
|
+
| "ai"
|
|
696
|
+
| "sandbox"
|
|
697
|
+
| "infra"
|
|
698
|
+
| "react"
|
|
699
|
+
| "nextjs"
|
|
700
|
+
| "module-federation";
|
|
645
701
|
};
|
|
646
702
|
if (typeof code !== "string" || !code.trim()) {
|
|
647
703
|
return res.status(400).json({ error: "code is required" });
|
|
648
704
|
}
|
|
649
|
-
if (
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
705
|
+
if (
|
|
706
|
+
origin !== "user" &&
|
|
707
|
+
origin !== "ai" &&
|
|
708
|
+
origin !== "sandbox" &&
|
|
709
|
+
origin !== "infra" &&
|
|
710
|
+
origin !== "react" &&
|
|
711
|
+
origin !== "nextjs" &&
|
|
712
|
+
origin !== "module-federation"
|
|
713
|
+
) {
|
|
714
|
+
return res.status(400).json({
|
|
715
|
+
error:
|
|
716
|
+
"origin must be 'user', 'ai', 'sandbox', 'infra', 'react', 'nextjs', or 'module-federation'",
|
|
717
|
+
});
|
|
653
718
|
}
|
|
654
719
|
try {
|
|
655
720
|
const id = randomUUID();
|
|
@@ -659,7 +724,9 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
659
724
|
? "ts"
|
|
660
725
|
: language === "javascript"
|
|
661
726
|
? "js"
|
|
662
|
-
: "
|
|
727
|
+
: language === "infra"
|
|
728
|
+
? "infra.json"
|
|
729
|
+
: "txt";
|
|
663
730
|
const fileName = `${safeLabel}.${ext}`;
|
|
664
731
|
const cf = await storage.saveQuestionContextFile(
|
|
665
732
|
req.params.questionId,
|
|
@@ -784,6 +851,292 @@ app.get("/api/context-files/all", async (_req, res) => {
|
|
|
784
851
|
res.json(deduped);
|
|
785
852
|
});
|
|
786
853
|
|
|
854
|
+
app.post("/api/infra/run", async (req, res) => {
|
|
855
|
+
const { questionId, fileId, label, action, workspace } = req.body as {
|
|
856
|
+
questionId?: string;
|
|
857
|
+
fileId?: string;
|
|
858
|
+
label?: string;
|
|
859
|
+
action?: "validate" | "plan";
|
|
860
|
+
workspace?: unknown;
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
if (action !== "validate" && action !== "plan") {
|
|
864
|
+
return res
|
|
865
|
+
.status(400)
|
|
866
|
+
.json({ error: "action must be 'validate' or 'plan'" });
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
const run = await runInfraAction({
|
|
871
|
+
questionId,
|
|
872
|
+
fileId,
|
|
873
|
+
label,
|
|
874
|
+
action,
|
|
875
|
+
workspace,
|
|
876
|
+
});
|
|
877
|
+
res.json(run);
|
|
878
|
+
} catch (err: any) {
|
|
879
|
+
res
|
|
880
|
+
.status(400)
|
|
881
|
+
.json({ error: err?.message || "Failed to run infra action" });
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// ─── Infra Lab AI Ask (streaming) ───────────────────────────────────────────
|
|
886
|
+
app.post("/api/infra/ask", async (req, res) => {
|
|
887
|
+
const { messages, workspace, questionId } = req.body as {
|
|
888
|
+
messages?: Array<{ role: "user" | "assistant"; content: string }>;
|
|
889
|
+
workspace?: Record<string, string>; // { "main.tf": "..." }
|
|
890
|
+
questionId?: string;
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
894
|
+
return res.status(400).json({ error: "messages is required" });
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
898
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
899
|
+
);
|
|
900
|
+
const aiSettings = await storage.getAiSettings();
|
|
901
|
+
|
|
902
|
+
// Build workspace context block
|
|
903
|
+
let workspaceBlock = "";
|
|
904
|
+
if (workspace && typeof workspace === "object") {
|
|
905
|
+
const entries = Object.entries(workspace).filter(
|
|
906
|
+
([k, v]) => typeof k === "string" && typeof v === "string",
|
|
907
|
+
);
|
|
908
|
+
if (entries.length > 0) {
|
|
909
|
+
workspaceBlock = "\n\n--- Workspace Files ---\n";
|
|
910
|
+
for (const [name, content] of entries) {
|
|
911
|
+
workspaceBlock += `\n### ${name}\n\`\`\`hcl\n${content}\n\`\`\`\n`;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const system =
|
|
917
|
+
`You are a senior infrastructure engineer and Terraform expert acting as a coding assistant inside an Infrastructure Lab.\n` +
|
|
918
|
+
`The user is editing a Terraform workspace. Answer questions about their code clearly and accurately.\n` +
|
|
919
|
+
`When suggesting edits, show the changed HCL as a fenced \`\`\`hcl code block.\n` +
|
|
920
|
+
`Be concise unless the user asks for a deeper explanation.` +
|
|
921
|
+
workspaceBlock;
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
const result = streamText({
|
|
925
|
+
model: getModel(),
|
|
926
|
+
maxOutputTokens: 2000,
|
|
927
|
+
...(isGoogle && {
|
|
928
|
+
providerOptions: {
|
|
929
|
+
google: {
|
|
930
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
}),
|
|
934
|
+
system,
|
|
935
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
result.pipeUIMessageStreamToResponse(res);
|
|
939
|
+
} catch (err: any) {
|
|
940
|
+
console.error("infra/ask error:", err?.message || err);
|
|
941
|
+
if (!res.headersSent) {
|
|
942
|
+
res
|
|
943
|
+
.status(500)
|
|
944
|
+
.json({ error: err?.message || "Failed to generate response" });
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
app.post("/api/notes/ask", async (req, res) => {
|
|
950
|
+
const { messages, noteContent, noteName } = req.body as {
|
|
951
|
+
messages?: Array<{ role: "user" | "assistant"; content: string }>;
|
|
952
|
+
noteContent?: string;
|
|
953
|
+
noteName?: string;
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
957
|
+
return res.status(400).json({ error: "messages is required" });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
961
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
962
|
+
);
|
|
963
|
+
const aiSettings = await storage.getAiSettings();
|
|
964
|
+
|
|
965
|
+
const noteBlock = noteContent?.trim()
|
|
966
|
+
? `\n\n--- Note: ${noteName || "Untitled"} ---\n${noteContent}\n---`
|
|
967
|
+
: "";
|
|
968
|
+
|
|
969
|
+
const system =
|
|
970
|
+
`You are a knowledgeable study assistant helping the user understand and improve their notes.\n` +
|
|
971
|
+
`Answer questions about the note content clearly. Help the user expand on ideas, clarify concepts, or drill deeper.\n` +
|
|
972
|
+
`Be concise unless the user asks for a deeper explanation.` +
|
|
973
|
+
noteBlock;
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
const result = streamText({
|
|
977
|
+
model: getModel(),
|
|
978
|
+
maxOutputTokens: 2000,
|
|
979
|
+
...(isGoogle && {
|
|
980
|
+
providerOptions: {
|
|
981
|
+
google: {
|
|
982
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
}),
|
|
986
|
+
system,
|
|
987
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
result.pipeUIMessageStreamToResponse(res);
|
|
991
|
+
} catch (err: any) {
|
|
992
|
+
console.error("notes/ask error:", err?.message || err);
|
|
993
|
+
if (!res.headersSent) {
|
|
994
|
+
res
|
|
995
|
+
.status(500)
|
|
996
|
+
.json({ error: err?.message || "Failed to generate response" });
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
app.post("/api/frontend-lab/ask", async (req, res) => {
|
|
1002
|
+
const { messages, workspace, labType, questionId } = req.body as {
|
|
1003
|
+
messages?: Array<{ role: "user" | "assistant"; content: string }>;
|
|
1004
|
+
workspace?: Record<string, string>;
|
|
1005
|
+
labType?: "react" | "nextjs" | "module-federation";
|
|
1006
|
+
questionId?: string;
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
1010
|
+
return res.status(400).json({ error: "messages is required" });
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
1014
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
1015
|
+
);
|
|
1016
|
+
const aiSettings = await storage.getAiSettings();
|
|
1017
|
+
|
|
1018
|
+
const typeLabel =
|
|
1019
|
+
labType === "nextjs"
|
|
1020
|
+
? "Next.js App Router"
|
|
1021
|
+
: labType === "module-federation"
|
|
1022
|
+
? "Webpack Module Federation"
|
|
1023
|
+
: "React + TypeScript";
|
|
1024
|
+
|
|
1025
|
+
let workspaceBlock = "";
|
|
1026
|
+
if (workspace && typeof workspace === "object") {
|
|
1027
|
+
const entries = Object.entries(workspace).filter(
|
|
1028
|
+
([k, v]) => typeof k === "string" && typeof v === "string",
|
|
1029
|
+
);
|
|
1030
|
+
if (entries.length > 0) {
|
|
1031
|
+
workspaceBlock = "\n\n--- Workspace Files ---\n";
|
|
1032
|
+
for (const [name, content] of entries) {
|
|
1033
|
+
workspaceBlock += `\n### ${name}\n\`\`\`tsx\n${content}\n\`\`\`\n`;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const system =
|
|
1039
|
+
`You are an expert ${typeLabel} tutor acting as a coding assistant inside a hands-on lab.\n` +
|
|
1040
|
+
`The user is practising ${typeLabel} concepts. Help them understand their code, fix bugs, and learn best practices.\n` +
|
|
1041
|
+
`When suggesting code changes, use fenced TypeScript/TSX code blocks.\n` +
|
|
1042
|
+
`Be concise unless the user asks for deeper explanation.` +
|
|
1043
|
+
workspaceBlock;
|
|
1044
|
+
|
|
1045
|
+
try {
|
|
1046
|
+
const result = streamText({
|
|
1047
|
+
model: getModel(),
|
|
1048
|
+
maxOutputTokens: 2000,
|
|
1049
|
+
...(isGoogle && {
|
|
1050
|
+
providerOptions: {
|
|
1051
|
+
google: {
|
|
1052
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
}),
|
|
1056
|
+
system,
|
|
1057
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
result.pipeUIMessageStreamToResponse(res);
|
|
1061
|
+
} catch (err: any) {
|
|
1062
|
+
console.error("frontend-lab/ask error:", err?.message || err);
|
|
1063
|
+
if (!res.headersSent) {
|
|
1064
|
+
res
|
|
1065
|
+
.status(500)
|
|
1066
|
+
.json({ error: err?.message || "Failed to generate response" });
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
app.post("/api/infra/command-stream", async (req, res) => {
|
|
1072
|
+
const { questionId, fileId, label, command, workspace } = req.body as {
|
|
1073
|
+
questionId?: string;
|
|
1074
|
+
fileId?: string;
|
|
1075
|
+
label?: string;
|
|
1076
|
+
command?: string;
|
|
1077
|
+
workspace?: unknown;
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1081
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1082
|
+
res.setHeader("Connection", "keep-alive");
|
|
1083
|
+
res.flushHeaders();
|
|
1084
|
+
|
|
1085
|
+
const send = (payload: unknown) => {
|
|
1086
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
1090
|
+
send({ type: "error", error: "command is required" });
|
|
1091
|
+
res.end();
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
try {
|
|
1096
|
+
const run = await streamInfraCommand({
|
|
1097
|
+
questionId,
|
|
1098
|
+
fileId,
|
|
1099
|
+
label,
|
|
1100
|
+
command,
|
|
1101
|
+
workspace,
|
|
1102
|
+
onMessage: send,
|
|
1103
|
+
});
|
|
1104
|
+
send({ type: "complete", run });
|
|
1105
|
+
} catch (err: any) {
|
|
1106
|
+
send({
|
|
1107
|
+
type: "error",
|
|
1108
|
+
error: err?.message || "Failed to run infra command",
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
res.end();
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
app.get("/api/infra/runs", async (req, res) => {
|
|
1116
|
+
const fileId =
|
|
1117
|
+
typeof req.query.fileId === "string" ? req.query.fileId : undefined;
|
|
1118
|
+
const questionId =
|
|
1119
|
+
typeof req.query.questionId === "string" ? req.query.questionId : undefined;
|
|
1120
|
+
|
|
1121
|
+
try {
|
|
1122
|
+
const runs = await listInfraRuns({ fileId, questionId });
|
|
1123
|
+
res.json(runs);
|
|
1124
|
+
} catch (err: any) {
|
|
1125
|
+
res
|
|
1126
|
+
.status(500)
|
|
1127
|
+
.json({ error: err?.message || "Failed to list infra runs" });
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
app.get("/api/infra/runs/:runId", async (req, res) => {
|
|
1132
|
+
try {
|
|
1133
|
+
const run = await getInfraRun(req.params.runId);
|
|
1134
|
+
res.json(run);
|
|
1135
|
+
} catch {
|
|
1136
|
+
res.status(404).json({ error: "Infra run not found" });
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
|
|
787
1140
|
// Link an existing file to a topic without re-uploading
|
|
788
1141
|
app.post("/api/topics/:topicId/context-files/link", async (req, res) => {
|
|
789
1142
|
const { fileId, originalName } = req.body as {
|
|
@@ -900,6 +1253,10 @@ app.patch("/api/settings", async (req, res) => {
|
|
|
900
1253
|
typeof req.body.vizGuide === "string"
|
|
901
1254
|
? req.body.vizGuide
|
|
902
1255
|
: current.vizGuide,
|
|
1256
|
+
plotGuide:
|
|
1257
|
+
typeof req.body.plotGuide === "string"
|
|
1258
|
+
? req.body.plotGuide
|
|
1259
|
+
: current.plotGuide,
|
|
903
1260
|
promptGroups:
|
|
904
1261
|
req.body.promptGroups != null
|
|
905
1262
|
? req.body.promptGroups
|
|
@@ -934,10 +1291,11 @@ app.post("/api/chat", async (req, res) => {
|
|
|
934
1291
|
codeSnippets,
|
|
935
1292
|
systemContext,
|
|
936
1293
|
responseLength,
|
|
1294
|
+
linkedConversationIds,
|
|
937
1295
|
} = req.body;
|
|
938
1296
|
|
|
939
1297
|
const aiSettings = await storage.getAiSettings();
|
|
940
|
-
const { responseProfiles, vizGuide } = aiSettings;
|
|
1298
|
+
const { responseProfiles, vizGuide, plotGuide } = aiSettings;
|
|
941
1299
|
const selectedResponseProfile = responseProfiles[responseLength] ??
|
|
942
1300
|
responseProfiles["normal"] ?? { maxOutputTokens: 3000, maxSteps: 5 };
|
|
943
1301
|
|
|
@@ -952,6 +1310,34 @@ app.post("/api/chat", async (req, res) => {
|
|
|
952
1310
|
if (systemContext) {
|
|
953
1311
|
system += `\n\n--- Additional Context ---\n${systemContext}`;
|
|
954
1312
|
}
|
|
1313
|
+
if (
|
|
1314
|
+
Array.isArray(linkedConversationIds) &&
|
|
1315
|
+
linkedConversationIds.length > 0
|
|
1316
|
+
) {
|
|
1317
|
+
const linkedQuestions = await Promise.all(
|
|
1318
|
+
linkedConversationIds.map((id: string) => storage.getQuestion(id)),
|
|
1319
|
+
);
|
|
1320
|
+
const valid = linkedQuestions.filter(
|
|
1321
|
+
(q) => q && q.messages && q.messages.length > 0,
|
|
1322
|
+
) as storage.Question[];
|
|
1323
|
+
if (valid.length > 0) {
|
|
1324
|
+
system += `\n\n--- Linked Conversation${valid.length > 1 ? "s" : ""} (read-only reference) ---\nThe user has linked the following conversation${valid.length > 1 ? "s" : ""} from this topic as background context. Do not treat them as the current conversation — they are reference material only.\n`;
|
|
1325
|
+
for (const lq of valid) {
|
|
1326
|
+
system += `\n### "${lq.title}"\n`;
|
|
1327
|
+
for (const msg of lq.messages) {
|
|
1328
|
+
const role = msg.role === "user" ? "User" : "Assistant";
|
|
1329
|
+
const text =
|
|
1330
|
+
typeof msg.content === "string"
|
|
1331
|
+
? msg.content
|
|
1332
|
+
: (msg.parts
|
|
1333
|
+
?.filter((p: any) => p.type === "text")
|
|
1334
|
+
.map((p: any) => p.text)
|
|
1335
|
+
.join("") ?? "");
|
|
1336
|
+
system += `**${role}:** ${text.slice(0, 2000)}${text.length > 2000 ? "…" : ""}\n\n`;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
955
1341
|
|
|
956
1342
|
// Build a file registry: id → { label, reader }
|
|
957
1343
|
// The model sees the list of file names and can call readFile(id) for any of them.
|
|
@@ -969,10 +1355,13 @@ app.post("/api/chat", async (req, res) => {
|
|
|
969
1355
|
});
|
|
970
1356
|
}
|
|
971
1357
|
|
|
972
|
-
// Topic-level uploaded files
|
|
1358
|
+
// Topic-level uploaded files + topic-wide system prompt
|
|
973
1359
|
if (topicId) {
|
|
974
1360
|
const topics = await storage.getTopics();
|
|
975
1361
|
const topic = topics.find((t) => t.id === topicId);
|
|
1362
|
+
if (topic?.systemContext?.trim()) {
|
|
1363
|
+
system += `\n\n--- Topic-Wide System Context ---\n${topic.systemContext.trim()}`;
|
|
1364
|
+
}
|
|
976
1365
|
if (topic?.contextFiles?.length) {
|
|
977
1366
|
for (const cf of topic.contextFiles) {
|
|
978
1367
|
fileRegistry.set(cf.id, {
|
|
@@ -1147,6 +1536,12 @@ Examples (illustrative only — use real ids and names from the list above):
|
|
|
1147
1536
|
inputSchema: z.object({}),
|
|
1148
1537
|
execute: async () => ({ guide: vizGuide }),
|
|
1149
1538
|
}),
|
|
1539
|
+
getPlotGuide: tool({
|
|
1540
|
+
description:
|
|
1541
|
+
"Get the full plotting spec reference. Call this before writing a ```plot block so you use the supported schema for graphs, curves, and charts.",
|
|
1542
|
+
inputSchema: z.object({}),
|
|
1543
|
+
execute: async () => ({ guide: plotGuide }),
|
|
1544
|
+
}),
|
|
1150
1545
|
...(fileRegistry.size > 0
|
|
1151
1546
|
? {
|
|
1152
1547
|
readFile: tool({
|
|
@@ -1620,13 +2015,13 @@ app.post("/api/fix-viz", async (req, res) => {
|
|
|
1620
2015
|
|
|
1621
2016
|
const { text } = await generateText({
|
|
1622
2017
|
model: getModel(),
|
|
1623
|
-
maxOutputTokens:
|
|
2018
|
+
maxOutputTokens: 8000,
|
|
1624
2019
|
...(isGoogle && {
|
|
1625
2020
|
providerOptions: {
|
|
1626
2021
|
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
1627
2022
|
},
|
|
1628
2023
|
}),
|
|
1629
|
-
prompt: `The following viz diagram spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the spec so it renders correctly. Common issues to check:\n- x/y coordinates must be numbers, not strings\n- node ids must be kebab-case with no spaces\n- Truncated spec: if the spec looks cut off mid-label or mid-step, complete all missing steps before returning — output the full corrected spec.\n- Signal chain direction: a chain [A, B] animates along the edge FROM A TO B. That directed edge (from: A, to: B) MUST exist in the edges section
|
|
2024
|
+
prompt: `The following viz diagram spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the spec so it renders correctly. Common issues to check:\n- x/y coordinates must be numbers, not strings\n- node ids must be kebab-case with no spaces\n- Truncated spec: if the spec looks cut off mid-label or mid-step, complete all missing steps before returning — output the full corrected spec.\n- Signal chain direction: a chain [A, B] animates along the edge FROM A TO B. That directed edge (from: A, to: B) MUST exist in the edges section.\n- chain arrays in autoSignals/signals must reference valid node ids\n- YAML flow sequences ([a, b]) must be correctly indented and closed\n- Do NOT mix autoSignals and steps in the same spec\n- Typography: replace en-dashes (–, U+2013) and em-dashes (—, U+2014) with a plain ASCII hyphen (-). Replace curly/smart quotes with straight ASCII quotes.\n- YAML string quoting: ALWAYS use double quotes for ALL string values — NEVER single quotes. Single-quoted YAML values (e.g. fill: '#fff') cause "Flow map must end with a }" parse errors in yaml v2. Every string in the spec must use double quotes. Wrong: fill: '#1e40af' Correct: fill: "#1e40af"\n- Inline flow maps must be single-line: any node written as { id: foo, x: 1, ... } MUST have its opening { and closing } on the SAME line with no line breaks inside. The yaml v2 parser throws "Flow map must end with a }" when a flow map spans multiple lines. If a flow map is too long to fit on one line, convert it to block style (indented key: value pairs), but NEVER split a single { ... } across lines.\n- Never split a label across multiple YAML lines unless using a literal block scalar (| or >)\n\nReturn ONLY the corrected YAML (or JSON) spec with no explanation and no markdown fences — just the raw spec.\n\nFailed spec:\n${spec}`,
|
|
1630
2025
|
});
|
|
1631
2026
|
|
|
1632
2027
|
// Strip any fences the model might have added
|
|
@@ -1642,6 +2037,43 @@ app.post("/api/fix-viz", async (req, res) => {
|
|
|
1642
2037
|
}
|
|
1643
2038
|
});
|
|
1644
2039
|
|
|
2040
|
+
// ─── Fix Plot ───────────────────────────────────────────
|
|
2041
|
+
|
|
2042
|
+
app.post("/api/fix-plot", async (req, res) => {
|
|
2043
|
+
const { spec, error: renderError } = req.body;
|
|
2044
|
+
if (typeof spec !== "string" || !spec.trim()) {
|
|
2045
|
+
return res.status(400).json({ error: "spec is required" });
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
try {
|
|
2049
|
+
const { plotGuide } = await storage.getAiSettings();
|
|
2050
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
2051
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
2052
|
+
);
|
|
2053
|
+
|
|
2054
|
+
const { text } = await generateText({
|
|
2055
|
+
model: getModel(),
|
|
2056
|
+
maxOutputTokens: 4000,
|
|
2057
|
+
...(isGoogle && {
|
|
2058
|
+
providerOptions: {
|
|
2059
|
+
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
2060
|
+
},
|
|
2061
|
+
}),
|
|
2062
|
+
prompt: `The following plot spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}${plotGuide ? `\n\nSupported plotting guide:\n${plotGuide}` : ""}\n\nFix the spec so it renders correctly as a Vega-Lite plot.\nRules:\n- Return ONLY the corrected JSON or YAML spec with no explanation and no markdown fences\n- The spec must describe a valid Vega-Lite chart\n- Prefer simple, valid encodings over clever transforms\n- Ensure x and y encodings include valid field/type definitions when needed\n- If the chart represents a mathematical curve, provide concrete sampled data points instead of symbolic expressions\n- Keep the dark-theme friendly defaults implicit; do not add custom JS callbacks or unsupported runtime code\n\nFailed spec:\n${spec}`,
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
const fixed = text
|
|
2066
|
+
.replace(/^```(?:plot|vega|vega-lite|json|yaml)?\s*/i, "")
|
|
2067
|
+
.replace(/```\s*$/, "")
|
|
2068
|
+
.trim();
|
|
2069
|
+
|
|
2070
|
+
res.json({ spec: fixed });
|
|
2071
|
+
} catch (err: any) {
|
|
2072
|
+
console.error("fix-plot error:", err?.message || err);
|
|
2073
|
+
res.status(500).json({ error: err?.message || "Failed to fix plot" });
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
|
|
1645
2077
|
// ─── Refine Viz ─────────────────────────────────────────
|
|
1646
2078
|
|
|
1647
2079
|
app.post("/api/refine-viz", async (req, res) => {
|
|
@@ -1685,7 +2117,7 @@ Rules:
|
|
|
1685
2117
|
|
|
1686
2118
|
const { text } = await generateText({
|
|
1687
2119
|
model: getModel(),
|
|
1688
|
-
maxOutputTokens:
|
|
2120
|
+
maxOutputTokens: 8000,
|
|
1689
2121
|
...(isGoogle && {
|
|
1690
2122
|
providerOptions: {
|
|
1691
2123
|
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
@@ -1809,10 +2241,10 @@ app.post("/api/run-code", async (req, res) => {
|
|
|
1809
2241
|
}, RUN_TIMEOUT_MS);
|
|
1810
2242
|
|
|
1811
2243
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
1812
|
-
stdoutLines.push(chunk.toString());
|
|
2244
|
+
stdoutLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
|
|
1813
2245
|
});
|
|
1814
2246
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
1815
|
-
stderrLines.push(chunk.toString());
|
|
2247
|
+
stderrLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
|
|
1816
2248
|
});
|
|
1817
2249
|
child.on("close", () => {
|
|
1818
2250
|
clearTimeout(killTimer);
|
|
@@ -1843,6 +2275,472 @@ interface SandboxEntry {
|
|
|
1843
2275
|
const sandboxes = new Map<string, SandboxEntry>();
|
|
1844
2276
|
const SANDBOX_DIR = path.join(__dirname, "..", ".sandbox-tmp");
|
|
1845
2277
|
|
|
2278
|
+
// ── Next.js real-server sandboxes ────────────────────────────────────────────
|
|
2279
|
+
// We symlink node_modules from the pre-installed npx cache so there's
|
|
2280
|
+
// zero npm-install cost per sandbox.
|
|
2281
|
+
interface NextSandboxEntry {
|
|
2282
|
+
child: ReturnType<typeof spawn>;
|
|
2283
|
+
port: number;
|
|
2284
|
+
dir: string;
|
|
2285
|
+
logs: string[];
|
|
2286
|
+
ready: boolean;
|
|
2287
|
+
}
|
|
2288
|
+
const nextSandboxes = new Map<string, NextSandboxEntry>();
|
|
2289
|
+
const NEXT_NPX_DIR = path.join(os.homedir(), ".npm/_npx/8b377f6eec906bc4");
|
|
2290
|
+
const NEXT_MODULES_DIR = path.join(NEXT_NPX_DIR, "node_modules");
|
|
2291
|
+
// Sandboxes live INSIDE the npx cache dir so Next.js finds node_modules by
|
|
2292
|
+
// walking up the directory tree — no symlinks needed, no Turbopack restrictions.
|
|
2293
|
+
const NEXT_SANDBOX_BASE = path.join(NEXT_NPX_DIR, ".sandboxes");
|
|
2294
|
+
|
|
2295
|
+
interface ModuleFederationSandboxEntry {
|
|
2296
|
+
child: ReturnType<typeof spawn>;
|
|
2297
|
+
dir: string;
|
|
2298
|
+
hostUrl: string;
|
|
2299
|
+
appUrls: Record<string, string>;
|
|
2300
|
+
workspaceFiles: Set<string>;
|
|
2301
|
+
logs: string[];
|
|
2302
|
+
ready: boolean;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
const moduleFederationSandboxes = new Map<
|
|
2306
|
+
string,
|
|
2307
|
+
ModuleFederationSandboxEntry
|
|
2308
|
+
>();
|
|
2309
|
+
const MODULE_FEDERATION_SANDBOX_BASE = path.join(
|
|
2310
|
+
os.tmpdir(),
|
|
2311
|
+
"interview-cockpit-module-federation",
|
|
2312
|
+
);
|
|
2313
|
+
|
|
2314
|
+
function appendSandboxLog(logs: string[], text: string): string {
|
|
2315
|
+
const clean = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2316
|
+
logs.push(clean);
|
|
2317
|
+
if (logs.length > 400) {
|
|
2318
|
+
logs.splice(0, logs.length - 400);
|
|
2319
|
+
}
|
|
2320
|
+
return clean;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function npmCommand(): string {
|
|
2324
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
async function writeModuleFederationSandboxFiles(
|
|
2328
|
+
dir: string,
|
|
2329
|
+
files: Record<string, string>,
|
|
2330
|
+
) {
|
|
2331
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
2332
|
+
const fullPath = path.join(dir, filePath);
|
|
2333
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
2334
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
async function runLoggedCommand(
|
|
2339
|
+
command: string,
|
|
2340
|
+
args: string[],
|
|
2341
|
+
options: { cwd: string; env?: NodeJS.ProcessEnv },
|
|
2342
|
+
logs: string[],
|
|
2343
|
+
): Promise<void> {
|
|
2344
|
+
await new Promise<void>((resolve, reject) => {
|
|
2345
|
+
const child = spawn(command, args, {
|
|
2346
|
+
cwd: options.cwd,
|
|
2347
|
+
env: options.env,
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2351
|
+
appendSandboxLog(logs, chunk.toString());
|
|
2352
|
+
});
|
|
2353
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2354
|
+
appendSandboxLog(logs, chunk.toString());
|
|
2355
|
+
});
|
|
2356
|
+
child.on("error", reject);
|
|
2357
|
+
child.on("exit", (code) => {
|
|
2358
|
+
if (code === 0) {
|
|
2359
|
+
resolve();
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
reject(
|
|
2363
|
+
new Error(
|
|
2364
|
+
logs.join("").trim() ||
|
|
2365
|
+
`${command} ${args.join(" ")} exited with code ${String(code)}`,
|
|
2366
|
+
),
|
|
2367
|
+
);
|
|
2368
|
+
});
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
async function getDistinctPorts(count: number): Promise<number[]> {
|
|
2373
|
+
const ports = new Set<number>();
|
|
2374
|
+
while (ports.size < count) {
|
|
2375
|
+
ports.add(await getFreePort());
|
|
2376
|
+
}
|
|
2377
|
+
return Array.from(ports);
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/** Write all user files and necessary config into a sandbox directory. */
|
|
2381
|
+
async function writeNextSandboxFiles(
|
|
2382
|
+
dir: string,
|
|
2383
|
+
files: Record<string, string>,
|
|
2384
|
+
) {
|
|
2385
|
+
// package.json — needed so Next.js can resolve its own packages
|
|
2386
|
+
await fs.writeFile(
|
|
2387
|
+
path.join(dir, "package.json"),
|
|
2388
|
+
JSON.stringify(
|
|
2389
|
+
{
|
|
2390
|
+
name: "nextjs-lab",
|
|
2391
|
+
private: true,
|
|
2392
|
+
type: "module",
|
|
2393
|
+
dependencies: {
|
|
2394
|
+
next: "^16.2.4",
|
|
2395
|
+
react: "^19.0.0",
|
|
2396
|
+
"react-dom": "^19.0.0",
|
|
2397
|
+
},
|
|
2398
|
+
},
|
|
2399
|
+
null,
|
|
2400
|
+
2,
|
|
2401
|
+
),
|
|
2402
|
+
);
|
|
2403
|
+
// next.config.ts — set turbopack.root to the NPX cache dir (parent of both
|
|
2404
|
+
// node_modules and .sandboxes). This lets Turbopack find next/package.json
|
|
2405
|
+
// in node_modules while watching files within the sandbox subdirectory.
|
|
2406
|
+
// Setting it to the sandbox dir itself would block node_modules resolution
|
|
2407
|
+
// since node_modules sits above the sandbox root boundary.
|
|
2408
|
+
await fs.writeFile(
|
|
2409
|
+
path.join(dir, "next.config.ts"),
|
|
2410
|
+
`import type { NextConfig } from "next";
|
|
2411
|
+
const c: NextConfig = {
|
|
2412
|
+
turbopack: { root: ${JSON.stringify(NEXT_NPX_DIR)} },
|
|
2413
|
+
async headers() {
|
|
2414
|
+
return [{ source: "/(.*)", headers: [{ key: "X-Frame-Options", value: "ALLOWALL" }] }];
|
|
2415
|
+
},
|
|
2416
|
+
};
|
|
2417
|
+
export default c;\n`,
|
|
2418
|
+
);
|
|
2419
|
+
// tsconfig so Next.js TypeScript support works
|
|
2420
|
+
await fs.writeFile(
|
|
2421
|
+
path.join(dir, "tsconfig.json"),
|
|
2422
|
+
JSON.stringify(
|
|
2423
|
+
{
|
|
2424
|
+
compilerOptions: {
|
|
2425
|
+
lib: ["dom", "dom.iterable", "esnext"],
|
|
2426
|
+
allowJs: true,
|
|
2427
|
+
skipLibCheck: true,
|
|
2428
|
+
strict: false,
|
|
2429
|
+
noEmit: true,
|
|
2430
|
+
esModuleInterop: true,
|
|
2431
|
+
module: "esnext",
|
|
2432
|
+
moduleResolution: "bundler",
|
|
2433
|
+
resolveJsonModule: true,
|
|
2434
|
+
isolatedModules: true,
|
|
2435
|
+
jsx: "preserve",
|
|
2436
|
+
incremental: true,
|
|
2437
|
+
plugins: [{ name: "next" }],
|
|
2438
|
+
baseUrl: ".",
|
|
2439
|
+
},
|
|
2440
|
+
include: ["**/*.ts", "**/*.tsx"],
|
|
2441
|
+
exclude: ["node_modules"],
|
|
2442
|
+
},
|
|
2443
|
+
null,
|
|
2444
|
+
2,
|
|
2445
|
+
),
|
|
2446
|
+
);
|
|
2447
|
+
// User files
|
|
2448
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
2449
|
+
const fullPath = path.join(dir, filePath);
|
|
2450
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
2451
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
app.post("/api/nextjs/start", async (req, res) => {
|
|
2456
|
+
const { files } = req.body as { files: Record<string, string> };
|
|
2457
|
+
if (!files || typeof files !== "object") {
|
|
2458
|
+
return res.status(400).json({ error: "files is required" });
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// Use the actual file path (not the .bin symlink) so Node's __filename resolves
|
|
2462
|
+
// correctly and internal require("../server/require-hook") works.
|
|
2463
|
+
const nextBin = path.join(NEXT_MODULES_DIR, "next", "dist", "bin", "next");
|
|
2464
|
+
const nextExists = await fs
|
|
2465
|
+
.access(nextBin)
|
|
2466
|
+
.then(() => true)
|
|
2467
|
+
.catch(() => false);
|
|
2468
|
+
if (!nextExists) {
|
|
2469
|
+
return res.status(503).json({
|
|
2470
|
+
error:
|
|
2471
|
+
"Next.js is not available in the npx cache. Run `npx next --version` once to install it.",
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
const id = randomUUID();
|
|
2476
|
+
const dir = path.join(NEXT_SANDBOX_BASE, id);
|
|
2477
|
+
await fs.mkdir(dir, { recursive: true });
|
|
2478
|
+
|
|
2479
|
+
await writeNextSandboxFiles(dir, files);
|
|
2480
|
+
|
|
2481
|
+
const port = await getFreePort();
|
|
2482
|
+
const logs: string[] = [];
|
|
2483
|
+
// Run via `node <actual-bin>` — sandbox dir is inside the npx cache so
|
|
2484
|
+
// node_modules is resolved naturally two levels up; no symlinks required.
|
|
2485
|
+
const child = spawn(
|
|
2486
|
+
process.execPath,
|
|
2487
|
+
[nextBin, "dev", "--port", String(port)],
|
|
2488
|
+
{
|
|
2489
|
+
cwd: dir,
|
|
2490
|
+
env: {
|
|
2491
|
+
...process.env,
|
|
2492
|
+
NEXT_TELEMETRY_DISABLED: "1",
|
|
2493
|
+
NODE_NO_WARNINGS: "1",
|
|
2494
|
+
},
|
|
2495
|
+
},
|
|
2496
|
+
);
|
|
2497
|
+
|
|
2498
|
+
const entry: NextSandboxEntry = {
|
|
2499
|
+
child,
|
|
2500
|
+
port,
|
|
2501
|
+
dir,
|
|
2502
|
+
logs,
|
|
2503
|
+
ready: false,
|
|
2504
|
+
};
|
|
2505
|
+
nextSandboxes.set(id, entry);
|
|
2506
|
+
|
|
2507
|
+
const markReady = (text: string) => {
|
|
2508
|
+
if (!entry.ready && /ready|started server on|Local:/i.test(text)) {
|
|
2509
|
+
entry.ready = true;
|
|
2510
|
+
}
|
|
2511
|
+
};
|
|
2512
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2513
|
+
const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2514
|
+
logs.push(t);
|
|
2515
|
+
markReady(t);
|
|
2516
|
+
});
|
|
2517
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2518
|
+
const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2519
|
+
logs.push(t);
|
|
2520
|
+
markReady(t);
|
|
2521
|
+
});
|
|
2522
|
+
child.on("exit", () => {
|
|
2523
|
+
nextSandboxes.delete(id);
|
|
2524
|
+
fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
2525
|
+
});
|
|
2526
|
+
|
|
2527
|
+
// Wait up to 30 s for Next.js to be ready
|
|
2528
|
+
const deadline = Date.now() + 30_000;
|
|
2529
|
+
while (!entry.ready && Date.now() < deadline) {
|
|
2530
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
2531
|
+
if (!nextSandboxes.has(id)) {
|
|
2532
|
+
return res
|
|
2533
|
+
.status(500)
|
|
2534
|
+
.json({ error: logs.join("").trim() || "Next.js server exited" });
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
if (!entry.ready) {
|
|
2538
|
+
return res
|
|
2539
|
+
.status(504)
|
|
2540
|
+
.json({ error: "Next.js did not start in time", logs });
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
res.json({ id, port, url: `http://localhost:${port}` });
|
|
2544
|
+
});
|
|
2545
|
+
|
|
2546
|
+
app.post("/api/nextjs/:id/update-files", async (req, res) => {
|
|
2547
|
+
const sb = nextSandboxes.get(req.params.id);
|
|
2548
|
+
if (!sb) return res.status(404).json({ error: "Sandbox not found" });
|
|
2549
|
+
const { files } = req.body as { files: Record<string, string> };
|
|
2550
|
+
if (!files) return res.status(400).json({ error: "files is required" });
|
|
2551
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
2552
|
+
const fullPath = path.join(sb.dir, filePath);
|
|
2553
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
2554
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
2555
|
+
}
|
|
2556
|
+
res.json({ ok: true });
|
|
2557
|
+
});
|
|
2558
|
+
|
|
2559
|
+
app.get("/api/nextjs/:id/status", (req, res) => {
|
|
2560
|
+
const sb = nextSandboxes.get(req.params.id);
|
|
2561
|
+
if (!sb) return res.json({ running: false });
|
|
2562
|
+
res.json({
|
|
2563
|
+
running: true,
|
|
2564
|
+
ready: sb.ready,
|
|
2565
|
+
port: sb.port,
|
|
2566
|
+
logs: sb.logs.slice(-30),
|
|
2567
|
+
});
|
|
2568
|
+
});
|
|
2569
|
+
|
|
2570
|
+
app.delete("/api/nextjs/:id", async (req, res) => {
|
|
2571
|
+
const sb = nextSandboxes.get(req.params.id);
|
|
2572
|
+
if (sb) {
|
|
2573
|
+
sb.child.kill("SIGTERM");
|
|
2574
|
+
nextSandboxes.delete(req.params.id);
|
|
2575
|
+
await fs.rm(sb.dir, { recursive: true, force: true }).catch(() => {});
|
|
2576
|
+
}
|
|
2577
|
+
res.json({ ok: true });
|
|
2578
|
+
});
|
|
2579
|
+
|
|
2580
|
+
app.post("/api/module-federation/start", async (req, res) => {
|
|
2581
|
+
const { files } = req.body as { files?: Record<string, string> };
|
|
2582
|
+
if (!files || typeof files !== "object") {
|
|
2583
|
+
return res.status(400).json({ error: "files is required" });
|
|
2584
|
+
}
|
|
2585
|
+
if (typeof files["package.json"] !== "string") {
|
|
2586
|
+
return res.status(400).json({ error: "package.json is required" });
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
const id = randomUUID();
|
|
2590
|
+
const dir = path.join(MODULE_FEDERATION_SANDBOX_BASE, id);
|
|
2591
|
+
const logs: string[] = [];
|
|
2592
|
+
|
|
2593
|
+
try {
|
|
2594
|
+
await fs.mkdir(dir, { recursive: true });
|
|
2595
|
+
await writeModuleFederationSandboxFiles(dir, files);
|
|
2596
|
+
|
|
2597
|
+
await runLoggedCommand(
|
|
2598
|
+
npmCommand(),
|
|
2599
|
+
["install", "--no-audit", "--no-fund", "--prefer-offline"],
|
|
2600
|
+
{
|
|
2601
|
+
cwd: dir,
|
|
2602
|
+
env: {
|
|
2603
|
+
...process.env,
|
|
2604
|
+
npm_config_update_notifier: "false",
|
|
2605
|
+
},
|
|
2606
|
+
},
|
|
2607
|
+
logs,
|
|
2608
|
+
);
|
|
2609
|
+
|
|
2610
|
+
const [hostPort, profilePort, checkoutPort] = await getDistinctPorts(3);
|
|
2611
|
+
const appUrls = {
|
|
2612
|
+
host: `http://localhost:${hostPort}`,
|
|
2613
|
+
profile: `http://localhost:${profilePort}`,
|
|
2614
|
+
checkout: `http://localhost:${checkoutPort}`,
|
|
2615
|
+
};
|
|
2616
|
+
const readyPorts = new Set<string>();
|
|
2617
|
+
|
|
2618
|
+
const child = spawn(npmCommand(), ["run", "dev"], {
|
|
2619
|
+
cwd: dir,
|
|
2620
|
+
env: {
|
|
2621
|
+
...process.env,
|
|
2622
|
+
HOST_PORT: String(hostPort),
|
|
2623
|
+
PROFILE_PORT: String(profilePort),
|
|
2624
|
+
CHECKOUT_PORT: String(checkoutPort),
|
|
2625
|
+
npm_config_update_notifier: "false",
|
|
2626
|
+
},
|
|
2627
|
+
});
|
|
2628
|
+
|
|
2629
|
+
const entry: ModuleFederationSandboxEntry = {
|
|
2630
|
+
child,
|
|
2631
|
+
dir,
|
|
2632
|
+
hostUrl: appUrls.host,
|
|
2633
|
+
appUrls,
|
|
2634
|
+
workspaceFiles: new Set(Object.keys(files)),
|
|
2635
|
+
logs,
|
|
2636
|
+
ready: false,
|
|
2637
|
+
};
|
|
2638
|
+
|
|
2639
|
+
const markReady = (text: string) => {
|
|
2640
|
+
if (text.includes(`localhost:${hostPort}`)) readyPorts.add("host");
|
|
2641
|
+
if (text.includes(`localhost:${profilePort}`)) readyPorts.add("profile");
|
|
2642
|
+
if (text.includes(`localhost:${checkoutPort}`))
|
|
2643
|
+
readyPorts.add("checkout");
|
|
2644
|
+
if (readyPorts.size === 3) {
|
|
2645
|
+
entry.ready = true;
|
|
2646
|
+
}
|
|
2647
|
+
};
|
|
2648
|
+
|
|
2649
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2650
|
+
markReady(appendSandboxLog(logs, chunk.toString()));
|
|
2651
|
+
});
|
|
2652
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2653
|
+
markReady(appendSandboxLog(logs, chunk.toString()));
|
|
2654
|
+
});
|
|
2655
|
+
child.on("exit", () => {
|
|
2656
|
+
moduleFederationSandboxes.delete(id);
|
|
2657
|
+
fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
moduleFederationSandboxes.set(id, entry);
|
|
2661
|
+
|
|
2662
|
+
const deadline = Date.now() + 90_000;
|
|
2663
|
+
while (!entry.ready && Date.now() < deadline) {
|
|
2664
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
2665
|
+
if (!moduleFederationSandboxes.has(id)) {
|
|
2666
|
+
return res.status(500).json({
|
|
2667
|
+
error: logs.join("").trim() || "Webpack module federation lab exited",
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
if (!entry.ready) {
|
|
2673
|
+
return res.status(504).json({
|
|
2674
|
+
error: "Webpack module federation lab did not start in time",
|
|
2675
|
+
logs,
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
res.json({
|
|
2680
|
+
id,
|
|
2681
|
+
hostUrl: entry.hostUrl,
|
|
2682
|
+
appUrls: entry.appUrls,
|
|
2683
|
+
});
|
|
2684
|
+
} catch (error: any) {
|
|
2685
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
2686
|
+
res.status(500).json({
|
|
2687
|
+
error:
|
|
2688
|
+
logs.join("").trim() ||
|
|
2689
|
+
error?.message ||
|
|
2690
|
+
"Failed to start webpack module federation lab",
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
});
|
|
2694
|
+
|
|
2695
|
+
app.post("/api/module-federation/:id/update-files", async (req, res) => {
|
|
2696
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
2697
|
+
if (!sandbox) return res.status(404).json({ error: "Sandbox not found" });
|
|
2698
|
+
|
|
2699
|
+
const { files } = req.body as { files?: Record<string, string> };
|
|
2700
|
+
if (!files || typeof files !== "object") {
|
|
2701
|
+
return res.status(400).json({ error: "files is required" });
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
const nextFiles = new Set(Object.keys(files));
|
|
2705
|
+
await Promise.all(
|
|
2706
|
+
Array.from(sandbox.workspaceFiles)
|
|
2707
|
+
.filter((filePath) => !nextFiles.has(filePath))
|
|
2708
|
+
.map((filePath) =>
|
|
2709
|
+
fs
|
|
2710
|
+
.rm(path.join(sandbox.dir, filePath), { force: true })
|
|
2711
|
+
.catch(() => {}),
|
|
2712
|
+
),
|
|
2713
|
+
);
|
|
2714
|
+
await writeModuleFederationSandboxFiles(sandbox.dir, files);
|
|
2715
|
+
sandbox.workspaceFiles = nextFiles;
|
|
2716
|
+
res.json({ ok: true });
|
|
2717
|
+
});
|
|
2718
|
+
|
|
2719
|
+
app.get("/api/module-federation/:id/status", (req, res) => {
|
|
2720
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
2721
|
+
if (!sandbox) {
|
|
2722
|
+
return res.json({ running: false });
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
res.json({
|
|
2726
|
+
running: true,
|
|
2727
|
+
ready: sandbox.ready,
|
|
2728
|
+
hostUrl: sandbox.hostUrl,
|
|
2729
|
+
appUrls: sandbox.appUrls,
|
|
2730
|
+
logs: sandbox.logs.slice(-80),
|
|
2731
|
+
});
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
app.delete("/api/module-federation/:id", async (req, res) => {
|
|
2735
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
2736
|
+
if (sandbox) {
|
|
2737
|
+
sandbox.child.kill("SIGTERM");
|
|
2738
|
+
moduleFederationSandboxes.delete(req.params.id);
|
|
2739
|
+
await fs.rm(sandbox.dir, { recursive: true, force: true }).catch(() => {});
|
|
2740
|
+
}
|
|
2741
|
+
res.json({ ok: true });
|
|
2742
|
+
});
|
|
2743
|
+
|
|
1846
2744
|
async function getFreePort(): Promise<number> {
|
|
1847
2745
|
return new Promise((resolve, reject) => {
|
|
1848
2746
|
const srv = net.createServer();
|
|
@@ -1893,8 +2791,12 @@ app.post("/api/sandbox/start", async (req, res) => {
|
|
|
1893
2791
|
const child = spawn(process.execPath, [tmpFile], {
|
|
1894
2792
|
env: { ...process.env, PORT: String(port), NODE_NO_WARNINGS: "1" },
|
|
1895
2793
|
});
|
|
1896
|
-
child.stdout.on("data", (chunk: Buffer) =>
|
|
1897
|
-
|
|
2794
|
+
child.stdout.on("data", (chunk: Buffer) =>
|
|
2795
|
+
logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
|
|
2796
|
+
);
|
|
2797
|
+
child.stderr.on("data", (chunk: Buffer) =>
|
|
2798
|
+
logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
|
|
2799
|
+
);
|
|
1898
2800
|
child.on("exit", () => {
|
|
1899
2801
|
sandboxes.delete(id);
|
|
1900
2802
|
fs.unlink(tmpFile).catch(() => {});
|