create-interview-cockpit 0.5.0 → 0.6.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 +321 -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 +419 -2
- package/template/client/src/components/CodeRunnerModal.tsx +1601 -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 +477 -0
- package/template/client/src/store.ts +219 -6
- package/template/client/src/types.ts +35 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/server/src/google-drive.ts +37 -3
- package/template/server/src/index.ts +693 -52
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +13 -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,23 @@ 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: "user" | "ai" | "sandbox";
|
|
693
|
+
origin: "user" | "ai" | "sandbox" | "infra" | "react" | "nextjs";
|
|
645
694
|
};
|
|
646
695
|
if (typeof code !== "string" || !code.trim()) {
|
|
647
696
|
return res.status(400).json({ error: "code is required" });
|
|
648
697
|
}
|
|
649
|
-
if (
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
698
|
+
if (
|
|
699
|
+
origin !== "user" &&
|
|
700
|
+
origin !== "ai" &&
|
|
701
|
+
origin !== "sandbox" &&
|
|
702
|
+
origin !== "infra" &&
|
|
703
|
+
origin !== "react" &&
|
|
704
|
+
origin !== "nextjs"
|
|
705
|
+
) {
|
|
706
|
+
return res.status(400).json({
|
|
707
|
+
error:
|
|
708
|
+
"origin must be 'user', 'ai', 'sandbox', 'infra', 'react', or 'nextjs'",
|
|
709
|
+
});
|
|
653
710
|
}
|
|
654
711
|
try {
|
|
655
712
|
const id = randomUUID();
|
|
@@ -659,7 +716,9 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
659
716
|
? "ts"
|
|
660
717
|
: language === "javascript"
|
|
661
718
|
? "js"
|
|
662
|
-
: "
|
|
719
|
+
: language === "infra"
|
|
720
|
+
? "infra.json"
|
|
721
|
+
: "txt";
|
|
663
722
|
const fileName = `${safeLabel}.${ext}`;
|
|
664
723
|
const cf = await storage.saveQuestionContextFile(
|
|
665
724
|
req.params.questionId,
|
|
@@ -784,6 +843,288 @@ app.get("/api/context-files/all", async (_req, res) => {
|
|
|
784
843
|
res.json(deduped);
|
|
785
844
|
});
|
|
786
845
|
|
|
846
|
+
app.post("/api/infra/run", async (req, res) => {
|
|
847
|
+
const { questionId, fileId, label, action, workspace } = req.body as {
|
|
848
|
+
questionId?: string;
|
|
849
|
+
fileId?: string;
|
|
850
|
+
label?: string;
|
|
851
|
+
action?: "validate" | "plan";
|
|
852
|
+
workspace?: unknown;
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
if (action !== "validate" && action !== "plan") {
|
|
856
|
+
return res
|
|
857
|
+
.status(400)
|
|
858
|
+
.json({ error: "action must be 'validate' or 'plan'" });
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
try {
|
|
862
|
+
const run = await runInfraAction({
|
|
863
|
+
questionId,
|
|
864
|
+
fileId,
|
|
865
|
+
label,
|
|
866
|
+
action,
|
|
867
|
+
workspace,
|
|
868
|
+
});
|
|
869
|
+
res.json(run);
|
|
870
|
+
} catch (err: any) {
|
|
871
|
+
res
|
|
872
|
+
.status(400)
|
|
873
|
+
.json({ error: err?.message || "Failed to run infra action" });
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// ─── Infra Lab AI Ask (streaming) ───────────────────────────────────────────
|
|
878
|
+
app.post("/api/infra/ask", async (req, res) => {
|
|
879
|
+
const { messages, workspace, questionId } = req.body as {
|
|
880
|
+
messages?: Array<{ role: "user" | "assistant"; content: string }>;
|
|
881
|
+
workspace?: Record<string, string>; // { "main.tf": "..." }
|
|
882
|
+
questionId?: string;
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
886
|
+
return res.status(400).json({ error: "messages is required" });
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
890
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
891
|
+
);
|
|
892
|
+
const aiSettings = await storage.getAiSettings();
|
|
893
|
+
|
|
894
|
+
// Build workspace context block
|
|
895
|
+
let workspaceBlock = "";
|
|
896
|
+
if (workspace && typeof workspace === "object") {
|
|
897
|
+
const entries = Object.entries(workspace).filter(
|
|
898
|
+
([k, v]) => typeof k === "string" && typeof v === "string",
|
|
899
|
+
);
|
|
900
|
+
if (entries.length > 0) {
|
|
901
|
+
workspaceBlock = "\n\n--- Workspace Files ---\n";
|
|
902
|
+
for (const [name, content] of entries) {
|
|
903
|
+
workspaceBlock += `\n### ${name}\n\`\`\`hcl\n${content}\n\`\`\`\n`;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const system =
|
|
909
|
+
`You are a senior infrastructure engineer and Terraform expert acting as a coding assistant inside an Infrastructure Lab.\n` +
|
|
910
|
+
`The user is editing a Terraform workspace. Answer questions about their code clearly and accurately.\n` +
|
|
911
|
+
`When suggesting edits, show the changed HCL as a fenced \`\`\`hcl code block.\n` +
|
|
912
|
+
`Be concise unless the user asks for a deeper explanation.` +
|
|
913
|
+
workspaceBlock;
|
|
914
|
+
|
|
915
|
+
try {
|
|
916
|
+
const result = streamText({
|
|
917
|
+
model: getModel(),
|
|
918
|
+
maxOutputTokens: 2000,
|
|
919
|
+
...(isGoogle && {
|
|
920
|
+
providerOptions: {
|
|
921
|
+
google: {
|
|
922
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
923
|
+
},
|
|
924
|
+
},
|
|
925
|
+
}),
|
|
926
|
+
system,
|
|
927
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
result.pipeUIMessageStreamToResponse(res);
|
|
931
|
+
} catch (err: any) {
|
|
932
|
+
console.error("infra/ask error:", err?.message || err);
|
|
933
|
+
if (!res.headersSent) {
|
|
934
|
+
res
|
|
935
|
+
.status(500)
|
|
936
|
+
.json({ error: err?.message || "Failed to generate response" });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
app.post("/api/notes/ask", async (req, res) => {
|
|
942
|
+
const { messages, noteContent, noteName } = req.body as {
|
|
943
|
+
messages?: Array<{ role: "user" | "assistant"; content: string }>;
|
|
944
|
+
noteContent?: string;
|
|
945
|
+
noteName?: string;
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
949
|
+
return res.status(400).json({ error: "messages is required" });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
953
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
954
|
+
);
|
|
955
|
+
const aiSettings = await storage.getAiSettings();
|
|
956
|
+
|
|
957
|
+
const noteBlock = noteContent?.trim()
|
|
958
|
+
? `\n\n--- Note: ${noteName || "Untitled"} ---\n${noteContent}\n---`
|
|
959
|
+
: "";
|
|
960
|
+
|
|
961
|
+
const system =
|
|
962
|
+
`You are a knowledgeable study assistant helping the user understand and improve their notes.\n` +
|
|
963
|
+
`Answer questions about the note content clearly. Help the user expand on ideas, clarify concepts, or drill deeper.\n` +
|
|
964
|
+
`Be concise unless the user asks for a deeper explanation.` +
|
|
965
|
+
noteBlock;
|
|
966
|
+
|
|
967
|
+
try {
|
|
968
|
+
const result = streamText({
|
|
969
|
+
model: getModel(),
|
|
970
|
+
maxOutputTokens: 2000,
|
|
971
|
+
...(isGoogle && {
|
|
972
|
+
providerOptions: {
|
|
973
|
+
google: {
|
|
974
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
975
|
+
},
|
|
976
|
+
},
|
|
977
|
+
}),
|
|
978
|
+
system,
|
|
979
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
result.pipeUIMessageStreamToResponse(res);
|
|
983
|
+
} catch (err: any) {
|
|
984
|
+
console.error("notes/ask error:", err?.message || err);
|
|
985
|
+
if (!res.headersSent) {
|
|
986
|
+
res
|
|
987
|
+
.status(500)
|
|
988
|
+
.json({ error: err?.message || "Failed to generate response" });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
app.post("/api/frontend-lab/ask", async (req, res) => {
|
|
994
|
+
const { messages, workspace, labType, questionId } = req.body as {
|
|
995
|
+
messages?: Array<{ role: "user" | "assistant"; content: string }>;
|
|
996
|
+
workspace?: Record<string, string>;
|
|
997
|
+
labType?: "react" | "nextjs";
|
|
998
|
+
questionId?: string;
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
1002
|
+
return res.status(400).json({ error: "messages is required" });
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
1006
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
1007
|
+
);
|
|
1008
|
+
const aiSettings = await storage.getAiSettings();
|
|
1009
|
+
|
|
1010
|
+
const typeLabel =
|
|
1011
|
+
labType === "nextjs" ? "Next.js App Router" : "React + TypeScript";
|
|
1012
|
+
|
|
1013
|
+
let workspaceBlock = "";
|
|
1014
|
+
if (workspace && typeof workspace === "object") {
|
|
1015
|
+
const entries = Object.entries(workspace).filter(
|
|
1016
|
+
([k, v]) => typeof k === "string" && typeof v === "string",
|
|
1017
|
+
);
|
|
1018
|
+
if (entries.length > 0) {
|
|
1019
|
+
workspaceBlock = "\n\n--- Workspace Files ---\n";
|
|
1020
|
+
for (const [name, content] of entries) {
|
|
1021
|
+
workspaceBlock += `\n### ${name}\n\`\`\`tsx\n${content}\n\`\`\`\n`;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const system =
|
|
1027
|
+
`You are an expert ${typeLabel} tutor acting as a coding assistant inside a hands-on lab.\n` +
|
|
1028
|
+
`The user is practising ${typeLabel} concepts. Help them understand their code, fix bugs, and learn best practices.\n` +
|
|
1029
|
+
`When suggesting code changes, use fenced TypeScript/TSX code blocks.\n` +
|
|
1030
|
+
`Be concise unless the user asks for deeper explanation.` +
|
|
1031
|
+
workspaceBlock;
|
|
1032
|
+
|
|
1033
|
+
try {
|
|
1034
|
+
const result = streamText({
|
|
1035
|
+
model: getModel(),
|
|
1036
|
+
maxOutputTokens: 2000,
|
|
1037
|
+
...(isGoogle && {
|
|
1038
|
+
providerOptions: {
|
|
1039
|
+
google: {
|
|
1040
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
}),
|
|
1044
|
+
system,
|
|
1045
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
result.pipeUIMessageStreamToResponse(res);
|
|
1049
|
+
} catch (err: any) {
|
|
1050
|
+
console.error("frontend-lab/ask error:", err?.message || err);
|
|
1051
|
+
if (!res.headersSent) {
|
|
1052
|
+
res
|
|
1053
|
+
.status(500)
|
|
1054
|
+
.json({ error: err?.message || "Failed to generate response" });
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
app.post("/api/infra/command-stream", async (req, res) => {
|
|
1060
|
+
const { questionId, fileId, label, command, workspace } = req.body as {
|
|
1061
|
+
questionId?: string;
|
|
1062
|
+
fileId?: string;
|
|
1063
|
+
label?: string;
|
|
1064
|
+
command?: string;
|
|
1065
|
+
workspace?: unknown;
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1069
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1070
|
+
res.setHeader("Connection", "keep-alive");
|
|
1071
|
+
res.flushHeaders();
|
|
1072
|
+
|
|
1073
|
+
const send = (payload: unknown) => {
|
|
1074
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
1078
|
+
send({ type: "error", error: "command is required" });
|
|
1079
|
+
res.end();
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
try {
|
|
1084
|
+
const run = await streamInfraCommand({
|
|
1085
|
+
questionId,
|
|
1086
|
+
fileId,
|
|
1087
|
+
label,
|
|
1088
|
+
command,
|
|
1089
|
+
workspace,
|
|
1090
|
+
onMessage: send,
|
|
1091
|
+
});
|
|
1092
|
+
send({ type: "complete", run });
|
|
1093
|
+
} catch (err: any) {
|
|
1094
|
+
send({
|
|
1095
|
+
type: "error",
|
|
1096
|
+
error: err?.message || "Failed to run infra command",
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
res.end();
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
app.get("/api/infra/runs", async (req, res) => {
|
|
1104
|
+
const fileId =
|
|
1105
|
+
typeof req.query.fileId === "string" ? req.query.fileId : undefined;
|
|
1106
|
+
const questionId =
|
|
1107
|
+
typeof req.query.questionId === "string" ? req.query.questionId : undefined;
|
|
1108
|
+
|
|
1109
|
+
try {
|
|
1110
|
+
const runs = await listInfraRuns({ fileId, questionId });
|
|
1111
|
+
res.json(runs);
|
|
1112
|
+
} catch (err: any) {
|
|
1113
|
+
res
|
|
1114
|
+
.status(500)
|
|
1115
|
+
.json({ error: err?.message || "Failed to list infra runs" });
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
app.get("/api/infra/runs/:runId", async (req, res) => {
|
|
1120
|
+
try {
|
|
1121
|
+
const run = await getInfraRun(req.params.runId);
|
|
1122
|
+
res.json(run);
|
|
1123
|
+
} catch {
|
|
1124
|
+
res.status(404).json({ error: "Infra run not found" });
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
787
1128
|
// Link an existing file to a topic without re-uploading
|
|
788
1129
|
app.post("/api/topics/:topicId/context-files/link", async (req, res) => {
|
|
789
1130
|
const { fileId, originalName } = req.body as {
|
|
@@ -900,6 +1241,10 @@ app.patch("/api/settings", async (req, res) => {
|
|
|
900
1241
|
typeof req.body.vizGuide === "string"
|
|
901
1242
|
? req.body.vizGuide
|
|
902
1243
|
: current.vizGuide,
|
|
1244
|
+
plotGuide:
|
|
1245
|
+
typeof req.body.plotGuide === "string"
|
|
1246
|
+
? req.body.plotGuide
|
|
1247
|
+
: current.plotGuide,
|
|
903
1248
|
promptGroups:
|
|
904
1249
|
req.body.promptGroups != null
|
|
905
1250
|
? req.body.promptGroups
|
|
@@ -934,10 +1279,11 @@ app.post("/api/chat", async (req, res) => {
|
|
|
934
1279
|
codeSnippets,
|
|
935
1280
|
systemContext,
|
|
936
1281
|
responseLength,
|
|
1282
|
+
linkedConversationIds,
|
|
937
1283
|
} = req.body;
|
|
938
1284
|
|
|
939
1285
|
const aiSettings = await storage.getAiSettings();
|
|
940
|
-
const { responseProfiles, vizGuide } = aiSettings;
|
|
1286
|
+
const { responseProfiles, vizGuide, plotGuide } = aiSettings;
|
|
941
1287
|
const selectedResponseProfile = responseProfiles[responseLength] ??
|
|
942
1288
|
responseProfiles["normal"] ?? { maxOutputTokens: 3000, maxSteps: 5 };
|
|
943
1289
|
|
|
@@ -952,6 +1298,34 @@ app.post("/api/chat", async (req, res) => {
|
|
|
952
1298
|
if (systemContext) {
|
|
953
1299
|
system += `\n\n--- Additional Context ---\n${systemContext}`;
|
|
954
1300
|
}
|
|
1301
|
+
if (
|
|
1302
|
+
Array.isArray(linkedConversationIds) &&
|
|
1303
|
+
linkedConversationIds.length > 0
|
|
1304
|
+
) {
|
|
1305
|
+
const linkedQuestions = await Promise.all(
|
|
1306
|
+
linkedConversationIds.map((id: string) => storage.getQuestion(id)),
|
|
1307
|
+
);
|
|
1308
|
+
const valid = linkedQuestions.filter(
|
|
1309
|
+
(q) => q && q.messages && q.messages.length > 0,
|
|
1310
|
+
) as storage.Question[];
|
|
1311
|
+
if (valid.length > 0) {
|
|
1312
|
+
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`;
|
|
1313
|
+
for (const lq of valid) {
|
|
1314
|
+
system += `\n### "${lq.title}"\n`;
|
|
1315
|
+
for (const msg of lq.messages) {
|
|
1316
|
+
const role = msg.role === "user" ? "User" : "Assistant";
|
|
1317
|
+
const text =
|
|
1318
|
+
typeof msg.content === "string"
|
|
1319
|
+
? msg.content
|
|
1320
|
+
: (msg.parts
|
|
1321
|
+
?.filter((p: any) => p.type === "text")
|
|
1322
|
+
.map((p: any) => p.text)
|
|
1323
|
+
.join("") ?? "");
|
|
1324
|
+
system += `**${role}:** ${text.slice(0, 2000)}${text.length > 2000 ? "…" : ""}\n\n`;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
955
1329
|
|
|
956
1330
|
// Build a file registry: id → { label, reader }
|
|
957
1331
|
// The model sees the list of file names and can call readFile(id) for any of them.
|
|
@@ -969,10 +1343,13 @@ app.post("/api/chat", async (req, res) => {
|
|
|
969
1343
|
});
|
|
970
1344
|
}
|
|
971
1345
|
|
|
972
|
-
// Topic-level uploaded files
|
|
1346
|
+
// Topic-level uploaded files + topic-wide system prompt
|
|
973
1347
|
if (topicId) {
|
|
974
1348
|
const topics = await storage.getTopics();
|
|
975
1349
|
const topic = topics.find((t) => t.id === topicId);
|
|
1350
|
+
if (topic?.systemContext?.trim()) {
|
|
1351
|
+
system += `\n\n--- Topic-Wide System Context ---\n${topic.systemContext.trim()}`;
|
|
1352
|
+
}
|
|
976
1353
|
if (topic?.contextFiles?.length) {
|
|
977
1354
|
for (const cf of topic.contextFiles) {
|
|
978
1355
|
fileRegistry.set(cf.id, {
|
|
@@ -1147,6 +1524,12 @@ Examples (illustrative only — use real ids and names from the list above):
|
|
|
1147
1524
|
inputSchema: z.object({}),
|
|
1148
1525
|
execute: async () => ({ guide: vizGuide }),
|
|
1149
1526
|
}),
|
|
1527
|
+
getPlotGuide: tool({
|
|
1528
|
+
description:
|
|
1529
|
+
"Get the full plotting spec reference. Call this before writing a ```plot block so you use the supported schema for graphs, curves, and charts.",
|
|
1530
|
+
inputSchema: z.object({}),
|
|
1531
|
+
execute: async () => ({ guide: plotGuide }),
|
|
1532
|
+
}),
|
|
1150
1533
|
...(fileRegistry.size > 0
|
|
1151
1534
|
? {
|
|
1152
1535
|
readFile: tool({
|
|
@@ -1620,13 +2003,13 @@ app.post("/api/fix-viz", async (req, res) => {
|
|
|
1620
2003
|
|
|
1621
2004
|
const { text } = await generateText({
|
|
1622
2005
|
model: getModel(),
|
|
1623
|
-
maxOutputTokens:
|
|
2006
|
+
maxOutputTokens: 8000,
|
|
1624
2007
|
...(isGoogle && {
|
|
1625
2008
|
providerOptions: {
|
|
1626
2009
|
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
1627
2010
|
},
|
|
1628
2011
|
}),
|
|
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
|
|
2012
|
+
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
2013
|
});
|
|
1631
2014
|
|
|
1632
2015
|
// Strip any fences the model might have added
|
|
@@ -1642,6 +2025,43 @@ app.post("/api/fix-viz", async (req, res) => {
|
|
|
1642
2025
|
}
|
|
1643
2026
|
});
|
|
1644
2027
|
|
|
2028
|
+
// ─── Fix Plot ───────────────────────────────────────────
|
|
2029
|
+
|
|
2030
|
+
app.post("/api/fix-plot", async (req, res) => {
|
|
2031
|
+
const { spec, error: renderError } = req.body;
|
|
2032
|
+
if (typeof spec !== "string" || !spec.trim()) {
|
|
2033
|
+
return res.status(400).json({ error: "spec is required" });
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
try {
|
|
2037
|
+
const { plotGuide } = await storage.getAiSettings();
|
|
2038
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
2039
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
2040
|
+
);
|
|
2041
|
+
|
|
2042
|
+
const { text } = await generateText({
|
|
2043
|
+
model: getModel(),
|
|
2044
|
+
maxOutputTokens: 4000,
|
|
2045
|
+
...(isGoogle && {
|
|
2046
|
+
providerOptions: {
|
|
2047
|
+
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
2048
|
+
},
|
|
2049
|
+
}),
|
|
2050
|
+
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}`,
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
const fixed = text
|
|
2054
|
+
.replace(/^```(?:plot|vega|vega-lite|json|yaml)?\s*/i, "")
|
|
2055
|
+
.replace(/```\s*$/, "")
|
|
2056
|
+
.trim();
|
|
2057
|
+
|
|
2058
|
+
res.json({ spec: fixed });
|
|
2059
|
+
} catch (err: any) {
|
|
2060
|
+
console.error("fix-plot error:", err?.message || err);
|
|
2061
|
+
res.status(500).json({ error: err?.message || "Failed to fix plot" });
|
|
2062
|
+
}
|
|
2063
|
+
});
|
|
2064
|
+
|
|
1645
2065
|
// ─── Refine Viz ─────────────────────────────────────────
|
|
1646
2066
|
|
|
1647
2067
|
app.post("/api/refine-viz", async (req, res) => {
|
|
@@ -1685,7 +2105,7 @@ Rules:
|
|
|
1685
2105
|
|
|
1686
2106
|
const { text } = await generateText({
|
|
1687
2107
|
model: getModel(),
|
|
1688
|
-
maxOutputTokens:
|
|
2108
|
+
maxOutputTokens: 8000,
|
|
1689
2109
|
...(isGoogle && {
|
|
1690
2110
|
providerOptions: {
|
|
1691
2111
|
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
@@ -1809,10 +2229,10 @@ app.post("/api/run-code", async (req, res) => {
|
|
|
1809
2229
|
}, RUN_TIMEOUT_MS);
|
|
1810
2230
|
|
|
1811
2231
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
1812
|
-
stdoutLines.push(chunk.toString());
|
|
2232
|
+
stdoutLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
|
|
1813
2233
|
});
|
|
1814
2234
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
1815
|
-
stderrLines.push(chunk.toString());
|
|
2235
|
+
stderrLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
|
|
1816
2236
|
});
|
|
1817
2237
|
child.on("close", () => {
|
|
1818
2238
|
clearTimeout(killTimer);
|
|
@@ -1843,6 +2263,223 @@ interface SandboxEntry {
|
|
|
1843
2263
|
const sandboxes = new Map<string, SandboxEntry>();
|
|
1844
2264
|
const SANDBOX_DIR = path.join(__dirname, "..", ".sandbox-tmp");
|
|
1845
2265
|
|
|
2266
|
+
// ── Next.js real-server sandboxes ────────────────────────────────────────────
|
|
2267
|
+
// We symlink node_modules from the pre-installed npx cache so there's
|
|
2268
|
+
// zero npm-install cost per sandbox.
|
|
2269
|
+
interface NextSandboxEntry {
|
|
2270
|
+
child: ReturnType<typeof spawn>;
|
|
2271
|
+
port: number;
|
|
2272
|
+
dir: string;
|
|
2273
|
+
logs: string[];
|
|
2274
|
+
ready: boolean;
|
|
2275
|
+
}
|
|
2276
|
+
const nextSandboxes = new Map<string, NextSandboxEntry>();
|
|
2277
|
+
const NEXT_NPX_DIR = path.join(os.homedir(), ".npm/_npx/8b377f6eec906bc4");
|
|
2278
|
+
const NEXT_MODULES_DIR = path.join(NEXT_NPX_DIR, "node_modules");
|
|
2279
|
+
// Sandboxes live INSIDE the npx cache dir so Next.js finds node_modules by
|
|
2280
|
+
// walking up the directory tree — no symlinks needed, no Turbopack restrictions.
|
|
2281
|
+
const NEXT_SANDBOX_BASE = path.join(NEXT_NPX_DIR, ".sandboxes");
|
|
2282
|
+
|
|
2283
|
+
/** Write all user files and necessary config into a sandbox directory. */
|
|
2284
|
+
async function writeNextSandboxFiles(
|
|
2285
|
+
dir: string,
|
|
2286
|
+
files: Record<string, string>,
|
|
2287
|
+
) {
|
|
2288
|
+
// package.json — needed so Next.js can resolve its own packages
|
|
2289
|
+
await fs.writeFile(
|
|
2290
|
+
path.join(dir, "package.json"),
|
|
2291
|
+
JSON.stringify(
|
|
2292
|
+
{
|
|
2293
|
+
name: "nextjs-lab",
|
|
2294
|
+
private: true,
|
|
2295
|
+
type: "module",
|
|
2296
|
+
dependencies: {
|
|
2297
|
+
next: "^16.2.4",
|
|
2298
|
+
react: "^19.0.0",
|
|
2299
|
+
"react-dom": "^19.0.0",
|
|
2300
|
+
},
|
|
2301
|
+
},
|
|
2302
|
+
null,
|
|
2303
|
+
2,
|
|
2304
|
+
),
|
|
2305
|
+
);
|
|
2306
|
+
// next.config.ts — set turbopack.root to the NPX cache dir (parent of both
|
|
2307
|
+
// node_modules and .sandboxes). This lets Turbopack find next/package.json
|
|
2308
|
+
// in node_modules while watching files within the sandbox subdirectory.
|
|
2309
|
+
// Setting it to the sandbox dir itself would block node_modules resolution
|
|
2310
|
+
// since node_modules sits above the sandbox root boundary.
|
|
2311
|
+
await fs.writeFile(
|
|
2312
|
+
path.join(dir, "next.config.ts"),
|
|
2313
|
+
`import type { NextConfig } from "next";
|
|
2314
|
+
const c: NextConfig = {
|
|
2315
|
+
turbopack: { root: ${JSON.stringify(NEXT_NPX_DIR)} },
|
|
2316
|
+
async headers() {
|
|
2317
|
+
return [{ source: "/(.*)", headers: [{ key: "X-Frame-Options", value: "ALLOWALL" }] }];
|
|
2318
|
+
},
|
|
2319
|
+
};
|
|
2320
|
+
export default c;\n`,
|
|
2321
|
+
);
|
|
2322
|
+
// tsconfig so Next.js TypeScript support works
|
|
2323
|
+
await fs.writeFile(
|
|
2324
|
+
path.join(dir, "tsconfig.json"),
|
|
2325
|
+
JSON.stringify(
|
|
2326
|
+
{
|
|
2327
|
+
compilerOptions: {
|
|
2328
|
+
lib: ["dom", "dom.iterable", "esnext"],
|
|
2329
|
+
allowJs: true,
|
|
2330
|
+
skipLibCheck: true,
|
|
2331
|
+
strict: false,
|
|
2332
|
+
noEmit: true,
|
|
2333
|
+
esModuleInterop: true,
|
|
2334
|
+
module: "esnext",
|
|
2335
|
+
moduleResolution: "bundler",
|
|
2336
|
+
resolveJsonModule: true,
|
|
2337
|
+
isolatedModules: true,
|
|
2338
|
+
jsx: "preserve",
|
|
2339
|
+
incremental: true,
|
|
2340
|
+
plugins: [{ name: "next" }],
|
|
2341
|
+
baseUrl: ".",
|
|
2342
|
+
},
|
|
2343
|
+
include: ["**/*.ts", "**/*.tsx"],
|
|
2344
|
+
exclude: ["node_modules"],
|
|
2345
|
+
},
|
|
2346
|
+
null,
|
|
2347
|
+
2,
|
|
2348
|
+
),
|
|
2349
|
+
);
|
|
2350
|
+
// User files
|
|
2351
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
2352
|
+
const fullPath = path.join(dir, filePath);
|
|
2353
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
2354
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
app.post("/api/nextjs/start", async (req, res) => {
|
|
2359
|
+
const { files } = req.body as { files: Record<string, string> };
|
|
2360
|
+
if (!files || typeof files !== "object") {
|
|
2361
|
+
return res.status(400).json({ error: "files is required" });
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// Use the actual file path (not the .bin symlink) so Node's __filename resolves
|
|
2365
|
+
// correctly and internal require("../server/require-hook") works.
|
|
2366
|
+
const nextBin = path.join(NEXT_MODULES_DIR, "next", "dist", "bin", "next");
|
|
2367
|
+
const nextExists = await fs
|
|
2368
|
+
.access(nextBin)
|
|
2369
|
+
.then(() => true)
|
|
2370
|
+
.catch(() => false);
|
|
2371
|
+
if (!nextExists) {
|
|
2372
|
+
return res.status(503).json({
|
|
2373
|
+
error:
|
|
2374
|
+
"Next.js is not available in the npx cache. Run `npx next --version` once to install it.",
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
const id = randomUUID();
|
|
2379
|
+
const dir = path.join(NEXT_SANDBOX_BASE, id);
|
|
2380
|
+
await fs.mkdir(dir, { recursive: true });
|
|
2381
|
+
|
|
2382
|
+
await writeNextSandboxFiles(dir, files);
|
|
2383
|
+
|
|
2384
|
+
const port = await getFreePort();
|
|
2385
|
+
const logs: string[] = [];
|
|
2386
|
+
// Run via `node <actual-bin>` — sandbox dir is inside the npx cache so
|
|
2387
|
+
// node_modules is resolved naturally two levels up; no symlinks required.
|
|
2388
|
+
const child = spawn(
|
|
2389
|
+
process.execPath,
|
|
2390
|
+
[nextBin, "dev", "--port", String(port)],
|
|
2391
|
+
{
|
|
2392
|
+
cwd: dir,
|
|
2393
|
+
env: {
|
|
2394
|
+
...process.env,
|
|
2395
|
+
NEXT_TELEMETRY_DISABLED: "1",
|
|
2396
|
+
NODE_NO_WARNINGS: "1",
|
|
2397
|
+
},
|
|
2398
|
+
},
|
|
2399
|
+
);
|
|
2400
|
+
|
|
2401
|
+
const entry: NextSandboxEntry = {
|
|
2402
|
+
child,
|
|
2403
|
+
port,
|
|
2404
|
+
dir,
|
|
2405
|
+
logs,
|
|
2406
|
+
ready: false,
|
|
2407
|
+
};
|
|
2408
|
+
nextSandboxes.set(id, entry);
|
|
2409
|
+
|
|
2410
|
+
const markReady = (text: string) => {
|
|
2411
|
+
if (!entry.ready && /ready|started server on|Local:/i.test(text)) {
|
|
2412
|
+
entry.ready = true;
|
|
2413
|
+
}
|
|
2414
|
+
};
|
|
2415
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2416
|
+
const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2417
|
+
logs.push(t);
|
|
2418
|
+
markReady(t);
|
|
2419
|
+
});
|
|
2420
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2421
|
+
const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2422
|
+
logs.push(t);
|
|
2423
|
+
markReady(t);
|
|
2424
|
+
});
|
|
2425
|
+
child.on("exit", () => {
|
|
2426
|
+
nextSandboxes.delete(id);
|
|
2427
|
+
fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
2428
|
+
});
|
|
2429
|
+
|
|
2430
|
+
// Wait up to 30 s for Next.js to be ready
|
|
2431
|
+
const deadline = Date.now() + 30_000;
|
|
2432
|
+
while (!entry.ready && Date.now() < deadline) {
|
|
2433
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
2434
|
+
if (!nextSandboxes.has(id)) {
|
|
2435
|
+
return res
|
|
2436
|
+
.status(500)
|
|
2437
|
+
.json({ error: logs.join("").trim() || "Next.js server exited" });
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
if (!entry.ready) {
|
|
2441
|
+
return res
|
|
2442
|
+
.status(504)
|
|
2443
|
+
.json({ error: "Next.js did not start in time", logs });
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
res.json({ id, port, url: `http://localhost:${port}` });
|
|
2447
|
+
});
|
|
2448
|
+
|
|
2449
|
+
app.post("/api/nextjs/:id/update-files", async (req, res) => {
|
|
2450
|
+
const sb = nextSandboxes.get(req.params.id);
|
|
2451
|
+
if (!sb) return res.status(404).json({ error: "Sandbox not found" });
|
|
2452
|
+
const { files } = req.body as { files: Record<string, string> };
|
|
2453
|
+
if (!files) return res.status(400).json({ error: "files is required" });
|
|
2454
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
2455
|
+
const fullPath = path.join(sb.dir, filePath);
|
|
2456
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
2457
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
2458
|
+
}
|
|
2459
|
+
res.json({ ok: true });
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
app.get("/api/nextjs/:id/status", (req, res) => {
|
|
2463
|
+
const sb = nextSandboxes.get(req.params.id);
|
|
2464
|
+
if (!sb) return res.json({ running: false });
|
|
2465
|
+
res.json({
|
|
2466
|
+
running: true,
|
|
2467
|
+
ready: sb.ready,
|
|
2468
|
+
port: sb.port,
|
|
2469
|
+
logs: sb.logs.slice(-30),
|
|
2470
|
+
});
|
|
2471
|
+
});
|
|
2472
|
+
|
|
2473
|
+
app.delete("/api/nextjs/:id", async (req, res) => {
|
|
2474
|
+
const sb = nextSandboxes.get(req.params.id);
|
|
2475
|
+
if (sb) {
|
|
2476
|
+
sb.child.kill("SIGTERM");
|
|
2477
|
+
nextSandboxes.delete(req.params.id);
|
|
2478
|
+
await fs.rm(sb.dir, { recursive: true, force: true }).catch(() => {});
|
|
2479
|
+
}
|
|
2480
|
+
res.json({ ok: true });
|
|
2481
|
+
});
|
|
2482
|
+
|
|
1846
2483
|
async function getFreePort(): Promise<number> {
|
|
1847
2484
|
return new Promise((resolve, reject) => {
|
|
1848
2485
|
const srv = net.createServer();
|
|
@@ -1893,8 +2530,12 @@ app.post("/api/sandbox/start", async (req, res) => {
|
|
|
1893
2530
|
const child = spawn(process.execPath, [tmpFile], {
|
|
1894
2531
|
env: { ...process.env, PORT: String(port), NODE_NO_WARNINGS: "1" },
|
|
1895
2532
|
});
|
|
1896
|
-
child.stdout.on("data", (chunk: Buffer) =>
|
|
1897
|
-
|
|
2533
|
+
child.stdout.on("data", (chunk: Buffer) =>
|
|
2534
|
+
logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
|
|
2535
|
+
);
|
|
2536
|
+
child.stderr.on("data", (chunk: Buffer) =>
|
|
2537
|
+
logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
|
|
2538
|
+
);
|
|
1898
2539
|
child.on("exit", () => {
|
|
1899
2540
|
sandboxes.delete(id);
|
|
1900
2541
|
fs.unlink(tmpFile).catch(() => {});
|