create-interview-cockpit 0.4.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 +753 -1
- package/template/client/package.json +4 -0
- package/template/client/src/App.tsx +20 -0
- package/template/client/src/api.ts +455 -3
- package/template/client/src/components/AiSettingsModal.tsx +855 -248
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +132 -27
- package/template/client/src/components/ChatView.tsx +365 -123
- package/template/client/src/components/CodeContextPanel.tsx +714 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
- package/template/client/src/components/DocRefModal.tsx +551 -0
- package/template/client/src/components/FileAttachments.tsx +128 -12
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- 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 +219 -2
- 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 +397 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +412 -25
- 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 +416 -2
- package/template/client/src/types.ts +41 -1
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +144 -2
- package/template/server/src/index.ts +1890 -188
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +274 -3
|
@@ -24,41 +24,88 @@ import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
|
24
24
|
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
25
25
|
import { randomUUID } from "crypto";
|
|
26
26
|
import fs from "fs/promises";
|
|
27
|
+
import os from "os";
|
|
28
|
+
import { spawn } from "child_process";
|
|
29
|
+
import net from "net";
|
|
30
|
+
import ts from "typescript";
|
|
27
31
|
import * as storage from "./storage.js";
|
|
28
32
|
import * as googleDrive from "./google-drive.js";
|
|
33
|
+
import {
|
|
34
|
+
getInfraRun,
|
|
35
|
+
listInfraRuns,
|
|
36
|
+
runInfraAction,
|
|
37
|
+
streamInfraCommand,
|
|
38
|
+
} from "./infra-runner.js";
|
|
29
39
|
|
|
30
40
|
const app = express();
|
|
31
41
|
app.use(cors());
|
|
32
42
|
app.use(express.json({ limit: "25mb" }));
|
|
33
43
|
|
|
34
44
|
const upload = multer({
|
|
35
|
-
limits: { fileSize:
|
|
45
|
+
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB max
|
|
36
46
|
fileFilter: (_req, file, cb) => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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}`));
|
|
41
52
|
} else {
|
|
42
|
-
|
|
53
|
+
// Accept everything else — extractText handles unknown extensions gracefully
|
|
54
|
+
cb(null, true);
|
|
43
55
|
}
|
|
44
56
|
},
|
|
45
57
|
});
|
|
46
58
|
|
|
47
|
-
// Extract text from uploaded files (docx, pdf,
|
|
59
|
+
// Extract text from uploaded files (docx, pdf, plain text, or images)
|
|
48
60
|
async function extractText(buffer: Buffer, filename: string): Promise<string> {
|
|
49
61
|
const ext = filename.split(".").pop()?.toLowerCase();
|
|
50
62
|
if (ext === "docx") {
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
try {
|
|
64
|
+
const result = await mammoth.extractRawText({ buffer });
|
|
65
|
+
return result.value;
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
return `[DOCX extraction failed: ${e?.message ?? "unknown error"}]`;
|
|
68
|
+
}
|
|
53
69
|
}
|
|
54
70
|
if (ext === "pdf") {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
try {
|
|
72
|
+
const parser = new PDFParse({ data: buffer });
|
|
73
|
+
const result = await parser.getText();
|
|
74
|
+
return result.text ?? "";
|
|
75
|
+
} catch (e: any) {
|
|
76
|
+
return `[PDF extraction failed: ${e?.message ?? "unknown error"}. The original file is stored and can be downloaded.]`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const imageExts = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
|
|
80
|
+
if (ext && imageExts.has(ext)) {
|
|
81
|
+
// Images can't be read as text; store original for download.
|
|
82
|
+
// Return a descriptor so the LLM knows the file exists.
|
|
83
|
+
return `[Image file: ${filename} — the original is stored and available for download, but cannot be read as text by the AI.]`;
|
|
58
84
|
}
|
|
59
85
|
return buffer.toString("utf-8");
|
|
60
86
|
}
|
|
61
87
|
|
|
88
|
+
function mimeForFilename(filename: string): string {
|
|
89
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
90
|
+
const map: Record<string, string> = {
|
|
91
|
+
pdf: "application/pdf",
|
|
92
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
93
|
+
doc: "application/msword",
|
|
94
|
+
json: "application/json",
|
|
95
|
+
csv: "text/csv",
|
|
96
|
+
html: "text/html; charset=utf-8",
|
|
97
|
+
xml: "application/xml",
|
|
98
|
+
zip: "application/zip",
|
|
99
|
+
png: "image/png",
|
|
100
|
+
jpg: "image/jpeg",
|
|
101
|
+
jpeg: "image/jpeg",
|
|
102
|
+
gif: "image/gif",
|
|
103
|
+
webp: "image/webp",
|
|
104
|
+
svg: "image/svg+xml",
|
|
105
|
+
};
|
|
106
|
+
return map[ext] ?? "application/octet-stream";
|
|
107
|
+
}
|
|
108
|
+
|
|
62
109
|
const PORT = process.env.PORT || 3001;
|
|
63
110
|
const CODE_CONTEXT_DIR = process.env.CODE_CONTEXT_DIR || "";
|
|
64
111
|
|
|
@@ -185,12 +232,24 @@ app.post("/api/workspaces/:id/drive-subfolders", async (req, res) => {
|
|
|
185
232
|
const { name } = req.body as { name?: string };
|
|
186
233
|
if (!name?.trim()) return res.status(400).json({ error: "name required" });
|
|
187
234
|
try {
|
|
235
|
+
if (!(await googleDrive.isExportAuthed())) {
|
|
236
|
+
return res.json({
|
|
237
|
+
needsAuth: true,
|
|
238
|
+
authUrl: googleDrive.getExportAuthUrl(),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
188
241
|
const folder = await googleDrive.createDriveSubfolder(
|
|
189
242
|
req.params.id,
|
|
190
243
|
name.trim(),
|
|
191
244
|
);
|
|
192
245
|
res.json(folder);
|
|
193
246
|
} catch (err: any) {
|
|
247
|
+
if (err?.needsReauth) {
|
|
248
|
+
return res.json({
|
|
249
|
+
needsAuth: true,
|
|
250
|
+
authUrl: googleDrive.getExportAuthUrl(),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
194
253
|
res.status(500).json({ error: err?.message || "Failed to create folder" });
|
|
195
254
|
}
|
|
196
255
|
});
|
|
@@ -322,30 +381,104 @@ app.patch("/api/topics/:id", async (req, res) => {
|
|
|
322
381
|
const topic = topics.find((t) => t.id === req.params.id);
|
|
323
382
|
if (!topic) return res.status(404).json({ error: "Not found" });
|
|
324
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;
|
|
325
386
|
await storage.saveTopic(topic);
|
|
326
387
|
res.json(topic);
|
|
327
388
|
});
|
|
328
389
|
|
|
329
|
-
// ───
|
|
390
|
+
// ─── Workspace Context Files ─────────────────────────────
|
|
330
391
|
|
|
331
|
-
app.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
392
|
+
app.get("/api/workspace/context-files", async (_req, res) => {
|
|
393
|
+
const files = await storage.getWorkspaceContextFiles();
|
|
394
|
+
res.json(files);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
app.post("/api/workspace/context-files", upload.any(), async (req, res) => {
|
|
398
|
+
try {
|
|
335
399
|
const files = req.files as Express.Multer.File[];
|
|
336
400
|
if (!files?.length) return res.status(400).json({ error: "No files" });
|
|
337
401
|
const results: storage.ContextFile[] = [];
|
|
338
402
|
for (const file of files) {
|
|
403
|
+
const id = randomUUID();
|
|
339
404
|
const text = await extractText(file.buffer, file.originalname);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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,
|
|
343
409
|
file.originalname,
|
|
344
410
|
Buffer.from(text, "utf-8"),
|
|
345
411
|
);
|
|
346
412
|
results.push(cf);
|
|
347
413
|
}
|
|
348
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
|
+
});
|
|
420
|
+
|
|
421
|
+
app.delete("/api/workspace/context-files/:fileId", async (req, res) => {
|
|
422
|
+
await storage.deleteWorkspaceContextFile(req.params.fileId);
|
|
423
|
+
res.json({ ok: true });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
app.get("/api/workspace/context-files/:fileId/download", async (req, res) => {
|
|
427
|
+
const files = await storage.getWorkspaceContextFiles();
|
|
428
|
+
const cf = files.find((f) => f.id === req.params.fileId);
|
|
429
|
+
if (!cf) return res.status(404).json({ error: "File not found" });
|
|
430
|
+
try {
|
|
431
|
+
const original = await storage.readOriginalBlob(req.params.fileId);
|
|
432
|
+
if (original) {
|
|
433
|
+
res.setHeader("Content-Type", mimeForFilename(cf.originalName));
|
|
434
|
+
res.setHeader(
|
|
435
|
+
"Content-Disposition",
|
|
436
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}"`,
|
|
437
|
+
);
|
|
438
|
+
return res.send(original);
|
|
439
|
+
}
|
|
440
|
+
// Fallback for files uploaded before original storage was added
|
|
441
|
+
const content = await storage.readWorkspaceContextFileContent(
|
|
442
|
+
req.params.fileId,
|
|
443
|
+
);
|
|
444
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
445
|
+
res.setHeader(
|
|
446
|
+
"Content-Disposition",
|
|
447
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}.txt"`,
|
|
448
|
+
);
|
|
449
|
+
res.send(content);
|
|
450
|
+
} catch {
|
|
451
|
+
res.status(404).json({ error: "File not found" });
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ─── Topic Context Files ─────────────────────────────────
|
|
456
|
+
|
|
457
|
+
app.post(
|
|
458
|
+
"/api/topics/:topicId/context-files",
|
|
459
|
+
upload.any(),
|
|
460
|
+
async (req, res) => {
|
|
461
|
+
try {
|
|
462
|
+
const files = req.files as Express.Multer.File[];
|
|
463
|
+
if (!files?.length) return res.status(400).json({ error: "No files" });
|
|
464
|
+
const results: storage.ContextFile[] = [];
|
|
465
|
+
for (const file of files) {
|
|
466
|
+
const id = randomUUID();
|
|
467
|
+
const text = await extractText(file.buffer, file.originalname);
|
|
468
|
+
await storage.writeOriginalBlob(id, file.buffer);
|
|
469
|
+
const cf = await storage.saveContextFile(
|
|
470
|
+
req.params.topicId as string,
|
|
471
|
+
id,
|
|
472
|
+
file.originalname,
|
|
473
|
+
Buffer.from(text, "utf-8"),
|
|
474
|
+
);
|
|
475
|
+
results.push(cf);
|
|
476
|
+
}
|
|
477
|
+
res.json(results);
|
|
478
|
+
} catch (err: any) {
|
|
479
|
+
console.error("topic upload error:", err?.message || err);
|
|
480
|
+
res.status(500).json({ error: err?.message || "Upload failed" });
|
|
481
|
+
}
|
|
349
482
|
},
|
|
350
483
|
);
|
|
351
484
|
|
|
@@ -354,6 +487,36 @@ app.delete("/api/topics/:topicId/context-files/:fileId", async (req, res) => {
|
|
|
354
487
|
res.json({ ok: true });
|
|
355
488
|
});
|
|
356
489
|
|
|
490
|
+
app.get(
|
|
491
|
+
"/api/topics/:topicId/context-files/:fileId/download",
|
|
492
|
+
async (req, res) => {
|
|
493
|
+
const topics = await storage.getTopics();
|
|
494
|
+
const topic = topics.find((t) => t.id === req.params.topicId);
|
|
495
|
+
const cf = topic?.contextFiles?.find((f) => f.id === req.params.fileId);
|
|
496
|
+
if (!cf) return res.status(404).json({ error: "File not found" });
|
|
497
|
+
try {
|
|
498
|
+
const original = await storage.readOriginalBlob(req.params.fileId);
|
|
499
|
+
if (original) {
|
|
500
|
+
res.setHeader("Content-Type", mimeForFilename(cf.originalName));
|
|
501
|
+
res.setHeader(
|
|
502
|
+
"Content-Disposition",
|
|
503
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}"`,
|
|
504
|
+
);
|
|
505
|
+
return res.send(original);
|
|
506
|
+
}
|
|
507
|
+
const content = await storage.readContextFileContent(req.params.fileId);
|
|
508
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
509
|
+
res.setHeader(
|
|
510
|
+
"Content-Disposition",
|
|
511
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}.txt"`,
|
|
512
|
+
);
|
|
513
|
+
res.send(content);
|
|
514
|
+
} catch {
|
|
515
|
+
res.status(404).json({ error: "File not found" });
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
|
|
357
520
|
// ─── Questions ───────────────────────────────────────────
|
|
358
521
|
|
|
359
522
|
app.get("/api/topics/:topicId/questions", async (req, res) => {
|
|
@@ -391,12 +554,45 @@ app.patch("/api/questions/:id", async (req, res) => {
|
|
|
391
554
|
if (req.body.systemContext !== undefined)
|
|
392
555
|
q.systemContext = req.body.systemContext;
|
|
393
556
|
if (req.body.title !== undefined) q.title = req.body.title;
|
|
394
|
-
if (req.body.parentQuestionId !== undefined)
|
|
395
|
-
|
|
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
|
+
}
|
|
396
590
|
if (req.body.messages !== undefined) q.messages = req.body.messages;
|
|
397
591
|
if (req.body.annotations !== undefined) q.annotations = req.body.annotations;
|
|
398
592
|
if (req.body.readingBookmark !== undefined)
|
|
399
593
|
q.readingBookmark = req.body.readingBookmark;
|
|
594
|
+
if (req.body.linkedConversationIds !== undefined)
|
|
595
|
+
q.linkedConversationIds = req.body.linkedConversationIds;
|
|
400
596
|
await storage.saveQuestion(q);
|
|
401
597
|
res.json(q);
|
|
402
598
|
});
|
|
@@ -404,35 +600,76 @@ app.patch("/api/questions/:id", async (req, res) => {
|
|
|
404
600
|
app.delete("/api/questions/:id", async (req, res) => {
|
|
405
601
|
const q = await storage.getQuestion(req.params.id);
|
|
406
602
|
if (q) {
|
|
407
|
-
// Cascade: delete
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
|
|
603
|
+
// Cascade: recursively delete all descendants (not just direct children)
|
|
604
|
+
const allInTopic = await storage.getQuestionsByTopic(q.topicId);
|
|
605
|
+
const collectDescendants = (parentId: string): string[] => {
|
|
606
|
+
const direct = allInTopic.filter((c) => c.parentQuestionId === parentId);
|
|
607
|
+
return direct.flatMap((c) => [c.id, ...collectDescendants(c.id)]);
|
|
608
|
+
};
|
|
609
|
+
const descendants = collectDescendants(q.id);
|
|
610
|
+
await Promise.all(descendants.map((id) => storage.deleteQuestion(id)));
|
|
411
611
|
}
|
|
412
612
|
await storage.deleteQuestion(req.params.id);
|
|
413
613
|
res.json({ ok: true });
|
|
414
614
|
});
|
|
415
615
|
|
|
616
|
+
// ─── Code-line Annotations ─────────────────────────────
|
|
617
|
+
|
|
618
|
+
app.get("/api/questions/:id/code-annotations", async (req, res) => {
|
|
619
|
+
const q = await storage.getQuestion(req.params.id);
|
|
620
|
+
if (!q) return res.status(404).json({ error: "Not found" });
|
|
621
|
+
const filePath = req.query.filePath as string;
|
|
622
|
+
if (!filePath) return res.json({ annotations: [] });
|
|
623
|
+
const annotations = q.codeAnnotations?.[filePath] ?? [];
|
|
624
|
+
res.json({ annotations });
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
app.patch("/api/questions/:id/code-annotations", async (req, res) => {
|
|
628
|
+
const { filePath, annotations } = req.body;
|
|
629
|
+
if (typeof filePath !== "string" || !filePath.trim()) {
|
|
630
|
+
return res.status(400).json({ error: "filePath is required" });
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
await storage.updateCodeAnnotationsForFile(
|
|
634
|
+
req.params.id,
|
|
635
|
+
filePath,
|
|
636
|
+
Array.isArray(annotations) ? annotations : [],
|
|
637
|
+
);
|
|
638
|
+
res.json({ ok: true });
|
|
639
|
+
} catch (err: any) {
|
|
640
|
+
res
|
|
641
|
+
.status(500)
|
|
642
|
+
.json({ error: err?.message || "Failed to save annotations" });
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
416
646
|
// ─── Question Context Files ─────────────────────────────
|
|
417
647
|
|
|
418
648
|
app.post(
|
|
419
649
|
"/api/questions/:questionId/context-files",
|
|
420
|
-
upload.
|
|
650
|
+
upload.any(),
|
|
421
651
|
async (req, res) => {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
652
|
+
try {
|
|
653
|
+
const files = req.files as Express.Multer.File[];
|
|
654
|
+
if (!files?.length) return res.status(400).json({ error: "No files" });
|
|
655
|
+
const results: storage.ContextFile[] = [];
|
|
656
|
+
for (const file of files) {
|
|
657
|
+
const id = randomUUID();
|
|
658
|
+
const text = await extractText(file.buffer, file.originalname);
|
|
659
|
+
await storage.writeOriginalBlob(id, file.buffer);
|
|
660
|
+
const cf = await storage.saveQuestionContextFile(
|
|
661
|
+
req.params.questionId as string,
|
|
662
|
+
id,
|
|
663
|
+
file.originalname,
|
|
664
|
+
Buffer.from(text, "utf-8"),
|
|
665
|
+
);
|
|
666
|
+
results.push(cf);
|
|
667
|
+
}
|
|
668
|
+
res.json(results);
|
|
669
|
+
} catch (err: any) {
|
|
670
|
+
console.error("question upload error:", err?.message || err);
|
|
671
|
+
res.status(500).json({ error: err?.message || "Upload failed" });
|
|
434
672
|
}
|
|
435
|
-
res.json(results);
|
|
436
673
|
},
|
|
437
674
|
);
|
|
438
675
|
|
|
@@ -447,150 +684,798 @@ app.delete(
|
|
|
447
684
|
},
|
|
448
685
|
);
|
|
449
686
|
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
app.patch("/api/settings", async (req, res) => {
|
|
458
|
-
const current = await storage.getAiSettings();
|
|
459
|
-
const updated: storage.AiSettings = {
|
|
460
|
-
systemPrompt:
|
|
461
|
-
typeof req.body.systemPrompt === "string"
|
|
462
|
-
? req.body.systemPrompt
|
|
463
|
-
: current.systemPrompt,
|
|
464
|
-
responseProfiles:
|
|
465
|
-
req.body.responseProfiles != null
|
|
466
|
-
? req.body.responseProfiles
|
|
467
|
-
: current.responseProfiles,
|
|
468
|
-
vizGuide:
|
|
469
|
-
typeof req.body.vizGuide === "string"
|
|
470
|
-
? req.body.vizGuide
|
|
471
|
-
: current.vizGuide,
|
|
472
|
-
promptGroups:
|
|
473
|
-
req.body.promptGroups != null
|
|
474
|
-
? req.body.promptGroups
|
|
475
|
-
: current.promptGroups,
|
|
687
|
+
// Save a code snippet (from Code Runner or AI response) as a question context file
|
|
688
|
+
app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
689
|
+
const { code, language, label, origin } = req.body as {
|
|
690
|
+
code: string;
|
|
691
|
+
language: string;
|
|
692
|
+
label: string;
|
|
693
|
+
origin: "user" | "ai" | "sandbox" | "infra" | "react" | "nextjs";
|
|
476
694
|
};
|
|
477
|
-
|
|
478
|
-
|
|
695
|
+
if (typeof code !== "string" || !code.trim()) {
|
|
696
|
+
return res.status(400).json({ error: "code is required" });
|
|
697
|
+
}
|
|
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
|
+
});
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
const id = randomUUID();
|
|
713
|
+
const safeLabel = (label || "snippet").replace(/[/\\:*?"<>|]/g, "-").trim();
|
|
714
|
+
const ext =
|
|
715
|
+
language === "typescript"
|
|
716
|
+
? "ts"
|
|
717
|
+
: language === "javascript"
|
|
718
|
+
? "js"
|
|
719
|
+
: language === "infra"
|
|
720
|
+
? "infra.json"
|
|
721
|
+
: "txt";
|
|
722
|
+
const fileName = `${safeLabel}.${ext}`;
|
|
723
|
+
const cf = await storage.saveQuestionContextFile(
|
|
724
|
+
req.params.questionId,
|
|
725
|
+
id,
|
|
726
|
+
fileName,
|
|
727
|
+
Buffer.from(code, "utf-8"),
|
|
728
|
+
{ origin, language, label: safeLabel },
|
|
729
|
+
);
|
|
730
|
+
res.json(cf);
|
|
731
|
+
} catch (err: any) {
|
|
732
|
+
res.status(500).json({ error: err?.message || "Failed to save snippet" });
|
|
733
|
+
}
|
|
479
734
|
});
|
|
480
735
|
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
let system = aiSettings.systemPrompt;
|
|
736
|
+
// Overwrite the content blob of an existing sandbox context file
|
|
737
|
+
app.put(
|
|
738
|
+
"/api/questions/:questionId/context-files/:fileId/content",
|
|
739
|
+
async (req, res) => {
|
|
740
|
+
const { code } = req.body as { code: string };
|
|
741
|
+
if (typeof code !== "string") {
|
|
742
|
+
return res.status(400).json({ error: "code is required" });
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
await storage.overwriteQuestionContextFileContent(
|
|
746
|
+
req.params.questionId,
|
|
747
|
+
req.params.fileId,
|
|
748
|
+
Buffer.from(code, "utf-8"),
|
|
749
|
+
);
|
|
750
|
+
res.json({ ok: true });
|
|
751
|
+
} catch (err: any) {
|
|
752
|
+
res.status(500).json({ error: err?.message || "Failed to overwrite" });
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
);
|
|
502
756
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
757
|
+
// Rename the label of an existing question context file
|
|
758
|
+
app.patch(
|
|
759
|
+
"/api/questions/:questionId/context-files/:fileId",
|
|
760
|
+
async (req, res) => {
|
|
761
|
+
const { label } = req.body as { label: string };
|
|
762
|
+
if (typeof label !== "string" || !label.trim()) {
|
|
763
|
+
return res.status(400).json({ error: "label is required" });
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const cf = await storage.renameQuestionContextFile(
|
|
767
|
+
req.params.questionId,
|
|
768
|
+
req.params.fileId,
|
|
769
|
+
label.trim(),
|
|
770
|
+
);
|
|
771
|
+
res.json(cf);
|
|
772
|
+
} catch (err: any) {
|
|
773
|
+
res.status(500).json({ error: err?.message || "Failed to rename" });
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
);
|
|
508
777
|
|
|
509
|
-
|
|
510
|
-
|
|
778
|
+
// Unified content-read endpoint — used by DocRefModal to show extracted text
|
|
779
|
+
// All context files (workspace / topic / question) share the same on-disk dir
|
|
780
|
+
// per workspace, so a single reader covers every scope.
|
|
781
|
+
app.get("/api/context-files/:fileId/content", async (req, res) => {
|
|
782
|
+
try {
|
|
783
|
+
const content = await storage.readContextFileContent(req.params.fileId);
|
|
784
|
+
res.json({ content });
|
|
785
|
+
} catch {
|
|
786
|
+
res.status(404).json({ error: "File not found" });
|
|
511
787
|
}
|
|
788
|
+
});
|
|
512
789
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
790
|
+
// List every uploaded file across workspace, topics, and questions.
|
|
791
|
+
// Used by the file picker so users can link an existing file instead of re-uploading.
|
|
792
|
+
app.get("/api/context-files/all", async (_req, res) => {
|
|
793
|
+
const entries: Array<{
|
|
794
|
+
fileId: string;
|
|
795
|
+
originalName: string;
|
|
796
|
+
source: "workspace" | "topic" | "question";
|
|
797
|
+
sourceName: string;
|
|
798
|
+
}> = [];
|
|
799
|
+
|
|
800
|
+
const wsFiles = await storage.getWorkspaceContextFiles();
|
|
801
|
+
for (const f of wsFiles) {
|
|
802
|
+
entries.push({
|
|
803
|
+
fileId: f.id,
|
|
804
|
+
originalName: f.originalName,
|
|
805
|
+
source: "workspace",
|
|
806
|
+
sourceName: "Workspace",
|
|
807
|
+
});
|
|
532
808
|
}
|
|
533
809
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
810
|
+
const topics = await storage.getTopics();
|
|
811
|
+
for (const t of topics) {
|
|
812
|
+
for (const f of t.contextFiles || []) {
|
|
813
|
+
entries.push({
|
|
814
|
+
fileId: f.id,
|
|
815
|
+
originalName: f.originalName,
|
|
816
|
+
source: "topic",
|
|
817
|
+
sourceName: t.name,
|
|
818
|
+
});
|
|
544
819
|
}
|
|
545
820
|
}
|
|
546
821
|
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
822
|
+
// Scan question files via the questions directory
|
|
823
|
+
const questions = await storage.getAllQuestions();
|
|
824
|
+
for (const q of questions) {
|
|
825
|
+
for (const f of q.contextFiles || []) {
|
|
826
|
+
entries.push({
|
|
827
|
+
fileId: f.id,
|
|
828
|
+
originalName: f.originalName,
|
|
829
|
+
source: "question",
|
|
830
|
+
sourceName: q.title,
|
|
556
831
|
});
|
|
557
832
|
}
|
|
558
833
|
}
|
|
559
834
|
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
835
|
+
// Deduplicate by fileId — keep the first occurrence (workspace > topic > question)
|
|
836
|
+
const seen = new Set<string>();
|
|
837
|
+
const deduped = entries.filter((e) => {
|
|
838
|
+
if (seen.has(e.fileId)) return false;
|
|
839
|
+
seen.add(e.fileId);
|
|
840
|
+
return true;
|
|
841
|
+
});
|
|
567
842
|
|
|
568
|
-
|
|
569
|
-
|
|
843
|
+
res.json(deduped);
|
|
844
|
+
});
|
|
570
845
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
+
};
|
|
575
854
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
855
|
+
if (action !== "validate" && action !== "plan") {
|
|
856
|
+
return res
|
|
857
|
+
.status(400)
|
|
858
|
+
.json({ error: "action must be 'validate' or 'plan'" });
|
|
859
|
+
}
|
|
580
860
|
|
|
581
|
-
|
|
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
|
+
});
|
|
582
876
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
+
};
|
|
586
884
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
}
|
|
885
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
886
|
+
return res.status(400).json({ error: "messages is required" });
|
|
590
887
|
}
|
|
591
888
|
|
|
592
|
-
|
|
593
|
-
|
|
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
|
+
|
|
1128
|
+
// Link an existing file to a topic without re-uploading
|
|
1129
|
+
app.post("/api/topics/:topicId/context-files/link", async (req, res) => {
|
|
1130
|
+
const { fileId, originalName } = req.body as {
|
|
1131
|
+
fileId?: string;
|
|
1132
|
+
originalName?: string;
|
|
1133
|
+
};
|
|
1134
|
+
if (!fileId || !originalName)
|
|
1135
|
+
return res.status(400).json({ error: "fileId and originalName required" });
|
|
1136
|
+
const cf = await storage.linkContextFileToTopic(
|
|
1137
|
+
req.params.topicId,
|
|
1138
|
+
fileId,
|
|
1139
|
+
originalName,
|
|
1140
|
+
);
|
|
1141
|
+
res.json(cf);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// Link an existing file to a question without re-uploading
|
|
1145
|
+
app.post("/api/questions/:questionId/context-files/link", async (req, res) => {
|
|
1146
|
+
const { fileId, originalName } = req.body as {
|
|
1147
|
+
fileId?: string;
|
|
1148
|
+
originalName?: string;
|
|
1149
|
+
};
|
|
1150
|
+
if (!fileId || !originalName)
|
|
1151
|
+
return res.status(400).json({ error: "fileId and originalName required" });
|
|
1152
|
+
const cf = await storage.linkContextFileToQuestion(
|
|
1153
|
+
req.params.questionId,
|
|
1154
|
+
fileId,
|
|
1155
|
+
originalName,
|
|
1156
|
+
);
|
|
1157
|
+
res.json(cf);
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
// Unified inline-download endpoint — serves the original binary with Content-Disposition: inline
|
|
1161
|
+
// so browsers can render PDFs/images directly inside an <iframe> or <img>.
|
|
1162
|
+
// Pass ?name=originalFilename.pdf so Content-Type and filename are set correctly.
|
|
1163
|
+
app.get("/api/context-files/:fileId/view", async (req, res) => {
|
|
1164
|
+
const name = typeof req.query.name === "string" ? req.query.name : "file";
|
|
1165
|
+
try {
|
|
1166
|
+
const original = await storage.readOriginalBlob(req.params.fileId);
|
|
1167
|
+
if (original) {
|
|
1168
|
+
res.setHeader("Content-Type", mimeForFilename(name));
|
|
1169
|
+
res.setHeader(
|
|
1170
|
+
"Content-Disposition",
|
|
1171
|
+
`inline; filename="${encodeURIComponent(name)}"`,
|
|
1172
|
+
);
|
|
1173
|
+
return res.send(original);
|
|
1174
|
+
}
|
|
1175
|
+
// Fallback: serve extracted text as plain text
|
|
1176
|
+
const content = await storage.readContextFileContent(req.params.fileId);
|
|
1177
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
1178
|
+
res.setHeader(
|
|
1179
|
+
"Content-Disposition",
|
|
1180
|
+
`inline; filename="${encodeURIComponent(name)}.txt"`,
|
|
1181
|
+
);
|
|
1182
|
+
res.send(content);
|
|
1183
|
+
} catch {
|
|
1184
|
+
res.status(404).json({ error: "File not found" });
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
app.get(
|
|
1189
|
+
"/api/questions/:questionId/context-files/:fileId/download",
|
|
1190
|
+
async (req, res) => {
|
|
1191
|
+
const question = await storage.getQuestion(req.params.questionId);
|
|
1192
|
+
const cf = question?.contextFiles?.find((f) => f.id === req.params.fileId);
|
|
1193
|
+
if (!cf) return res.status(404).json({ error: "File not found" });
|
|
1194
|
+
try {
|
|
1195
|
+
const original = await storage.readOriginalBlob(req.params.fileId);
|
|
1196
|
+
if (original) {
|
|
1197
|
+
res.setHeader("Content-Type", mimeForFilename(cf.originalName));
|
|
1198
|
+
res.setHeader(
|
|
1199
|
+
"Content-Disposition",
|
|
1200
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}"`,
|
|
1201
|
+
);
|
|
1202
|
+
return res.send(original);
|
|
1203
|
+
}
|
|
1204
|
+
const content = await storage.readContextFileContent(req.params.fileId);
|
|
1205
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
1206
|
+
res.setHeader(
|
|
1207
|
+
"Content-Disposition",
|
|
1208
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}.txt"`,
|
|
1209
|
+
);
|
|
1210
|
+
res.send(content);
|
|
1211
|
+
} catch {
|
|
1212
|
+
res.status(404).json({ error: "File not found" });
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
// ─── AI Settings ────────────────────────────────────────────
|
|
1218
|
+
|
|
1219
|
+
app.get("/api/settings", async (_req, res) => {
|
|
1220
|
+
const settings = await storage.getAiSettings();
|
|
1221
|
+
res.json({
|
|
1222
|
+
...settings,
|
|
1223
|
+
// Expose current provider/model from env so client can conditionally show thinking controls
|
|
1224
|
+
provider: (process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
1225
|
+
model: process.env.AI_MODEL || "",
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
app.patch("/api/settings", async (req, res) => {
|
|
1230
|
+
const current = await storage.getAiSettings();
|
|
1231
|
+
const updated: storage.AiSettings = {
|
|
1232
|
+
systemPrompt:
|
|
1233
|
+
typeof req.body.systemPrompt === "string"
|
|
1234
|
+
? req.body.systemPrompt
|
|
1235
|
+
: current.systemPrompt,
|
|
1236
|
+
responseProfiles:
|
|
1237
|
+
req.body.responseProfiles != null
|
|
1238
|
+
? req.body.responseProfiles
|
|
1239
|
+
: current.responseProfiles,
|
|
1240
|
+
vizGuide:
|
|
1241
|
+
typeof req.body.vizGuide === "string"
|
|
1242
|
+
? req.body.vizGuide
|
|
1243
|
+
: current.vizGuide,
|
|
1244
|
+
plotGuide:
|
|
1245
|
+
typeof req.body.plotGuide === "string"
|
|
1246
|
+
? req.body.plotGuide
|
|
1247
|
+
: current.plotGuide,
|
|
1248
|
+
promptGroups:
|
|
1249
|
+
req.body.promptGroups != null
|
|
1250
|
+
? req.body.promptGroups
|
|
1251
|
+
: current.promptGroups,
|
|
1252
|
+
thinkingBudget:
|
|
1253
|
+
typeof req.body.thinkingBudget === "number"
|
|
1254
|
+
? req.body.thinkingBudget
|
|
1255
|
+
: current.thinkingBudget,
|
|
1256
|
+
alwaysSendPrefsDefault:
|
|
1257
|
+
typeof req.body.alwaysSendPrefsDefault === "boolean"
|
|
1258
|
+
? req.body.alwaysSendPrefsDefault
|
|
1259
|
+
: current.alwaysSendPrefsDefault,
|
|
1260
|
+
};
|
|
1261
|
+
await storage.saveAiSettings(updated);
|
|
1262
|
+
res.json({
|
|
1263
|
+
...updated,
|
|
1264
|
+
provider: (process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
1265
|
+
model: process.env.AI_MODEL || "",
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
// ─── Chat ────────────────────────────────────────────────
|
|
1270
|
+
|
|
1271
|
+
app.post("/api/chat", async (req, res) => {
|
|
1272
|
+
const {
|
|
1273
|
+
messages,
|
|
1274
|
+
questionId,
|
|
1275
|
+
topicId,
|
|
1276
|
+
topicTitle,
|
|
1277
|
+
questionTitle,
|
|
1278
|
+
codeContextFiles,
|
|
1279
|
+
codeSnippets,
|
|
1280
|
+
systemContext,
|
|
1281
|
+
responseLength,
|
|
1282
|
+
linkedConversationIds,
|
|
1283
|
+
} = req.body;
|
|
1284
|
+
|
|
1285
|
+
const aiSettings = await storage.getAiSettings();
|
|
1286
|
+
const { responseProfiles, vizGuide, plotGuide } = aiSettings;
|
|
1287
|
+
const selectedResponseProfile = responseProfiles[responseLength] ??
|
|
1288
|
+
responseProfiles["normal"] ?? { maxOutputTokens: 3000, maxSteps: 5 };
|
|
1289
|
+
|
|
1290
|
+
let system = aiSettings.systemPrompt;
|
|
1291
|
+
|
|
1292
|
+
if (topicTitle || questionTitle) {
|
|
1293
|
+
system += `\n\n--- Current Context ---`;
|
|
1294
|
+
if (topicTitle) system += `\nTopic: ${topicTitle}`;
|
|
1295
|
+
if (questionTitle) system += `\nQuestion: ${questionTitle}`;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
if (systemContext) {
|
|
1299
|
+
system += `\n\n--- Additional Context ---\n${systemContext}`;
|
|
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
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Build a file registry: id → { label, reader }
|
|
1331
|
+
// The model sees the list of file names and can call readFile(id) for any of them.
|
|
1332
|
+
const fileRegistry = new Map<
|
|
1333
|
+
string,
|
|
1334
|
+
{ label: string; reader: () => Promise<string> }
|
|
1335
|
+
>();
|
|
1336
|
+
|
|
1337
|
+
// Workspace-level uploaded files (apply to all topics)
|
|
1338
|
+
const workspaceFiles = await storage.getWorkspaceContextFiles();
|
|
1339
|
+
for (const cf of workspaceFiles) {
|
|
1340
|
+
fileRegistry.set(cf.id, {
|
|
1341
|
+
label: `[workspace] ${cf.originalName}`,
|
|
1342
|
+
reader: () => storage.readWorkspaceContextFileContent(cf.id),
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Topic-level uploaded files + topic-wide system prompt
|
|
1347
|
+
if (topicId) {
|
|
1348
|
+
const topics = await storage.getTopics();
|
|
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
|
+
}
|
|
1353
|
+
if (topic?.contextFiles?.length) {
|
|
1354
|
+
for (const cf of topic.contextFiles) {
|
|
1355
|
+
fileRegistry.set(cf.id, {
|
|
1356
|
+
label: `[topic] ${cf.originalName}`,
|
|
1357
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Question-level uploaded files
|
|
1364
|
+
if (questionId) {
|
|
1365
|
+
const question = await storage.getQuestion(questionId);
|
|
1366
|
+
if (question?.contextFiles?.length) {
|
|
1367
|
+
for (const cf of question.contextFiles) {
|
|
1368
|
+
fileRegistry.set(cf.id, {
|
|
1369
|
+
label: `[question] ${cf.originalName}`,
|
|
1370
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Code-context files from the project directory
|
|
1377
|
+
if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
|
|
1378
|
+
for (const filePath of codeContextFiles as string[]) {
|
|
1379
|
+
const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
|
|
1380
|
+
const resolved = path.resolve(fullPath);
|
|
1381
|
+
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
|
|
1382
|
+
fileRegistry.set(`code:${filePath}`, {
|
|
1383
|
+
label: `[code] ${filePath}`,
|
|
1384
|
+
reader: () => fs.readFile(resolved, "utf-8"),
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Tell the model what files are available
|
|
1390
|
+
if (fileRegistry.size > 0) {
|
|
1391
|
+
// collect just the code-context file paths for linking instructions
|
|
1392
|
+
const codeFilePaths: string[] = [];
|
|
1393
|
+
for (const [id, { label }] of fileRegistry) {
|
|
1394
|
+
if (id.startsWith("code:")) codeFilePaths.push(id.slice("code:".length));
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
system += `\n\n--- Available Reference Files ---
|
|
1398
|
+
The following files are available to you. Use the readFile tool to retrieve a file's content when it would help answer the question. Only read files that are relevant — you do not need to read them all.
|
|
1399
|
+
|
|
1400
|
+
`;
|
|
1401
|
+
for (const [id, { label }] of fileRegistry) {
|
|
1402
|
+
system += `• ${label} (id: "${id}")\n`;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
if (codeFilePaths.length > 0) {
|
|
1406
|
+
system += `
|
|
1407
|
+
--- Linking Code Files in Your Response ---
|
|
1408
|
+
When you mention or reference one of the **[code]** files above in your response text, format it as a clickable link so the user can open it directly:
|
|
1409
|
+
|
|
1410
|
+
[DisplayText](coderef://relative/path/to/file)
|
|
1411
|
+
|
|
1412
|
+
Examples:
|
|
1413
|
+
[EmployeesController](coderef://src/Controllers/EmployeesController.cs)
|
|
1414
|
+
[SettleDeferredPaymentCaseRequest](coderef://src/Requests/SettleDeferredPaymentCaseRequest.cs)
|
|
1415
|
+
|
|
1416
|
+
Use this for class names, method names, or any mention of a specific file from the code context. The display text should be the class, file, or concept name — not the raw path.
|
|
1417
|
+
Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Always add the inline code block instruction
|
|
1421
|
+
system += `
|
|
1422
|
+
|
|
1423
|
+
--- Writing Code Blocks the User Can Explore ---
|
|
1424
|
+
When you write a fenced code block that the user might want to open in the interactive code viewer (to annotate it, ask questions line-by-line, or use it as context), add a reference label as the FIRST line of the block using a comment in the language's native syntax:
|
|
1425
|
+
|
|
1426
|
+
// @ref: some-unique-label ← JavaScript / TypeScript / Java / C# / Go etc.
|
|
1427
|
+
# @ref: some-unique-label ← Python / Ruby / Shell
|
|
1428
|
+
-- @ref: some-unique-label ← SQL / Lua
|
|
1429
|
+
|
|
1430
|
+
Then anywhere in your response text where you want a clickable link back to that block:
|
|
1431
|
+
|
|
1432
|
+
[Descriptive Label](inlineref://some-unique-label)
|
|
1433
|
+
|
|
1434
|
+
Example:
|
|
1435
|
+
\`\`\`typescript
|
|
1436
|
+
// @ref: user-service
|
|
1437
|
+
export class UserService {
|
|
1438
|
+
async getUser(id: string) { ... }
|
|
1439
|
+
}
|
|
1440
|
+
\`\`\`
|
|
1441
|
+
|
|
1442
|
+
The implementation is in [UserService](inlineref://user-service).
|
|
1443
|
+
|
|
1444
|
+
Rules:
|
|
1445
|
+
- The label must be a short unique slug within your response.
|
|
1446
|
+
- The @ref comment line is stripped from the displayed code — the user never sees it.
|
|
1447
|
+
- You only need @ref when you want to create an explicit link in prose. A plain code block without @ref still gets a pop-out button the user can click.
|
|
1448
|
+
- Never put @ref on any line other than the very first line of the block.`;
|
|
1449
|
+
|
|
1450
|
+
// Collect non-code document file IDs for docref linking instructions
|
|
1451
|
+
const docFileEntries: Array<{ id: string; label: string }> = [];
|
|
1452
|
+
for (const [id, { label }] of fileRegistry) {
|
|
1453
|
+
if (!id.startsWith("code:")) docFileEntries.push({ id, label });
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
if (docFileEntries.length > 0) {
|
|
1457
|
+
system += `
|
|
1458
|
+
|
|
1459
|
+
--- Quoting From Uploaded Documents ---
|
|
1460
|
+
When you quote or reference a specific passage from one of the **[workspace]**, **[topic]**, or **[question]** documents above, format the quoted text as a clickable link so the user can see it highlighted in the original document:
|
|
1461
|
+
|
|
1462
|
+
[the exact passage you are citing](docref://{id}?n={encodedFileName})
|
|
1463
|
+
|
|
1464
|
+
Rules:
|
|
1465
|
+
- The link text MUST be the verbatim passage you are quoting (a sentence, phrase, or term from the document).
|
|
1466
|
+
- {id} is the file id shown in the list above (e.g. "abc123").
|
|
1467
|
+
- {encodedFileName} is the file's original name, percent-encoded (spaces → %20, etc.).
|
|
1468
|
+
- Only use docref:// for uploaded document files. Do not use it for code files.
|
|
1469
|
+
|
|
1470
|
+
Examples (illustrative only — use real ids and names from the list above):
|
|
1471
|
+
[The system shall validate all inputs before processing](docref://abc123?n=Requirements.pdf)
|
|
1472
|
+
[O(n log n) average-case complexity](docref://def456?n=Algorithm%20Notes.docx)
|
|
1473
|
+
`;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Code snippets hand-picked by the user from the file viewer
|
|
1478
|
+
if (Array.isArray(codeSnippets) && codeSnippets.length > 0) {
|
|
594
1479
|
system += `\n\n--- Code Snippets (highlighted by user) ---\nThe user has selected these specific sections of code as focus areas for this conversation:\n\n`;
|
|
595
1480
|
for (const snippet of codeSnippets as Array<{
|
|
596
1481
|
fileName: string;
|
|
@@ -625,7 +1510,9 @@ Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
|
625
1510
|
maxOutputTokens: selectedResponseProfile.maxOutputTokens,
|
|
626
1511
|
...(isGoogle && {
|
|
627
1512
|
providerOptions: {
|
|
628
|
-
google: {
|
|
1513
|
+
google: {
|
|
1514
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1515
|
+
},
|
|
629
1516
|
},
|
|
630
1517
|
}),
|
|
631
1518
|
system,
|
|
@@ -637,6 +1524,12 @@ Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
|
637
1524
|
inputSchema: z.object({}),
|
|
638
1525
|
execute: async () => ({ guide: vizGuide }),
|
|
639
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
|
+
}),
|
|
640
1533
|
...(fileRegistry.size > 0
|
|
641
1534
|
? {
|
|
642
1535
|
readFile: tool({
|
|
@@ -795,33 +1688,204 @@ async function walkDir(dir: string, prefix = ""): Promise<string[]> {
|
|
|
795
1688
|
return files;
|
|
796
1689
|
}
|
|
797
1690
|
|
|
798
|
-
app.get("/api/code-context/tree", async (_req, res) => {
|
|
799
|
-
if (!CODE_CONTEXT_DIR) return res.json([]);
|
|
800
|
-
try {
|
|
801
|
-
const files = await walkDir(CODE_CONTEXT_DIR);
|
|
802
|
-
res.json(files);
|
|
803
|
-
} catch {
|
|
804
|
-
res.json([]);
|
|
805
|
-
}
|
|
806
|
-
});
|
|
1691
|
+
app.get("/api/code-context/tree", async (_req, res) => {
|
|
1692
|
+
if (!CODE_CONTEXT_DIR) return res.json([]);
|
|
1693
|
+
try {
|
|
1694
|
+
const files = await walkDir(CODE_CONTEXT_DIR);
|
|
1695
|
+
res.json(files);
|
|
1696
|
+
} catch {
|
|
1697
|
+
res.json([]);
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
app.get("/api/code-context/file", async (req, res) => {
|
|
1702
|
+
if (!CODE_CONTEXT_DIR)
|
|
1703
|
+
return res.status(400).json({ error: "No code context directory" });
|
|
1704
|
+
const filePath = req.query.path as string;
|
|
1705
|
+
if (!filePath) return res.status(400).json({ error: "Path required" });
|
|
1706
|
+
|
|
1707
|
+
const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
|
|
1708
|
+
const resolved = path.resolve(fullPath);
|
|
1709
|
+
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) {
|
|
1710
|
+
return res.status(403).json({ error: "Access denied" });
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
try {
|
|
1714
|
+
const content = await fs.readFile(resolved, "utf-8");
|
|
1715
|
+
res.json({ path: filePath, content });
|
|
1716
|
+
} catch {
|
|
1717
|
+
res.status(404).json({ error: "File not found" });
|
|
1718
|
+
}
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
// ─── Code Line Ask ──────────────────────────────────────
|
|
1722
|
+
|
|
1723
|
+
app.post("/api/code-line-ask", async (req, res) => {
|
|
1724
|
+
const {
|
|
1725
|
+
filePath,
|
|
1726
|
+
selectedCode,
|
|
1727
|
+
prompt,
|
|
1728
|
+
questionId,
|
|
1729
|
+
topicId,
|
|
1730
|
+
codeContextFiles,
|
|
1731
|
+
codeSnippets,
|
|
1732
|
+
preferenceSuffix,
|
|
1733
|
+
} = req.body;
|
|
1734
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
1735
|
+
return res.status(400).json({ error: "prompt is required" });
|
|
1736
|
+
}
|
|
1737
|
+
if (typeof selectedCode !== "string" || !selectedCode.trim()) {
|
|
1738
|
+
return res.status(400).json({ error: "selectedCode is required" });
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
try {
|
|
1742
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
1743
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
1744
|
+
);
|
|
1745
|
+
const aiSettings = await storage.getAiSettings();
|
|
1746
|
+
|
|
1747
|
+
// Build a file registry identical to /api/chat so the model has the same context
|
|
1748
|
+
const fileRegistry = new Map<
|
|
1749
|
+
string,
|
|
1750
|
+
{ label: string; reader: () => Promise<string> }
|
|
1751
|
+
>();
|
|
1752
|
+
|
|
1753
|
+
const workspaceFiles = await storage.getWorkspaceContextFiles();
|
|
1754
|
+
for (const cf of workspaceFiles) {
|
|
1755
|
+
fileRegistry.set(cf.id, {
|
|
1756
|
+
label: `[workspace] ${cf.originalName}`,
|
|
1757
|
+
reader: () => storage.readWorkspaceContextFileContent(cf.id),
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
if (topicId) {
|
|
1762
|
+
const topics = await storage.getTopics();
|
|
1763
|
+
const topic = topics.find((t: any) => t.id === topicId);
|
|
1764
|
+
if (topic?.contextFiles?.length) {
|
|
1765
|
+
for (const cf of topic.contextFiles) {
|
|
1766
|
+
fileRegistry.set(cf.id, {
|
|
1767
|
+
label: `[topic] ${cf.originalName}`,
|
|
1768
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
if (questionId) {
|
|
1775
|
+
const question = await storage.getQuestion(questionId);
|
|
1776
|
+
if (question?.contextFiles?.length) {
|
|
1777
|
+
for (const cf of question.contextFiles) {
|
|
1778
|
+
fileRegistry.set(cf.id, {
|
|
1779
|
+
label: `[question] ${cf.originalName}`,
|
|
1780
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
|
|
1787
|
+
for (const fp of codeContextFiles as string[]) {
|
|
1788
|
+
const fullPath = path.join(CODE_CONTEXT_DIR, fp);
|
|
1789
|
+
const resolved = path.resolve(fullPath);
|
|
1790
|
+
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
|
|
1791
|
+
fileRegistry.set(`code:${fp}`, {
|
|
1792
|
+
label: `[code] ${fp}`,
|
|
1793
|
+
reader: () => fs.readFile(resolved, "utf-8"),
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
let system =
|
|
1799
|
+
"You are a senior software engineer and interview coach. Answer questions about the highlighted code lines. Be concise, accurate, and practical. Use markdown formatting for code references.";
|
|
1800
|
+
|
|
1801
|
+
// Apply the user's active response preferences (LENGTH / STYLE / AUDIENCE / etc.)
|
|
1802
|
+
if (
|
|
1803
|
+
preferenceSuffix &&
|
|
1804
|
+
typeof preferenceSuffix === "string" &&
|
|
1805
|
+
preferenceSuffix.trim()
|
|
1806
|
+
) {
|
|
1807
|
+
system += `\n\n${preferenceSuffix.trim()}`;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
if (fileRegistry.size > 0) {
|
|
1811
|
+
const codeFilePaths: string[] = [];
|
|
1812
|
+
for (const [id, { label }] of fileRegistry) {
|
|
1813
|
+
if (id.startsWith("code:"))
|
|
1814
|
+
codeFilePaths.push(id.slice("code:".length));
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
system += `\n\n--- Available Reference Files ---\nThe following files are available to you. Use the readFile tool to retrieve a file's content when it would help answer the question. Only read files that are relevant.\n\n`;
|
|
1818
|
+
for (const [id, { label }] of fileRegistry) {
|
|
1819
|
+
system += `• ${label} (id: "${id}")\n`;
|
|
1820
|
+
}
|
|
807
1821
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (!filePath) return res.status(400).json({ error: "Path required" });
|
|
1822
|
+
if (codeFilePaths.length > 0) {
|
|
1823
|
+
system += `\n--- Linking Code Files in Your Response ---\nWhen you mention a [code] file, format it as a clickable link:\n [DisplayText](coderef://relative/path/to/file)\nOnly use coderef:// for [code] files.`;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
813
1826
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1827
|
+
if (Array.isArray(codeSnippets) && codeSnippets.length > 0) {
|
|
1828
|
+
system += `\n\n--- Code Snippets (pinned by user) ---\n`;
|
|
1829
|
+
for (const snippet of codeSnippets as Array<{
|
|
1830
|
+
fileName: string;
|
|
1831
|
+
filePath: string;
|
|
1832
|
+
startLine: number;
|
|
1833
|
+
endLine: number;
|
|
1834
|
+
code: string;
|
|
1835
|
+
}>) {
|
|
1836
|
+
const lineLabel =
|
|
1837
|
+
snippet.startLine === snippet.endLine
|
|
1838
|
+
? `line ${snippet.startLine}`
|
|
1839
|
+
: `lines ${snippet.startLine}–${snippet.endLine}`;
|
|
1840
|
+
system += `**${snippet.fileName}** (${lineLabel}):\n\`\`\`\n${snippet.code}\n\`\`\`\n\n`;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
819
1843
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1844
|
+
const { text } = await generateText({
|
|
1845
|
+
model: getModel(),
|
|
1846
|
+
maxOutputTokens: 1200,
|
|
1847
|
+
...(isGoogle && {
|
|
1848
|
+
providerOptions: {
|
|
1849
|
+
google: {
|
|
1850
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1851
|
+
},
|
|
1852
|
+
},
|
|
1853
|
+
}),
|
|
1854
|
+
system,
|
|
1855
|
+
prompt: `File: ${filePath || "unknown"}\n\nHighlighted code:\n\`\`\`\n${selectedCode}\n\`\`\`\n\nQuestion: ${prompt.trim()}`,
|
|
1856
|
+
tools:
|
|
1857
|
+
fileRegistry.size > 0
|
|
1858
|
+
? {
|
|
1859
|
+
readFile: tool({
|
|
1860
|
+
description:
|
|
1861
|
+
"Read the content of an available reference file. Use this to get file contents when relevant to the question.",
|
|
1862
|
+
inputSchema: z.object({
|
|
1863
|
+
fileId: z
|
|
1864
|
+
.string()
|
|
1865
|
+
.describe(
|
|
1866
|
+
"The id of the file to read, from the available files list.",
|
|
1867
|
+
),
|
|
1868
|
+
}),
|
|
1869
|
+
execute: async ({ fileId }) => {
|
|
1870
|
+
const entry = fileRegistry.get(fileId);
|
|
1871
|
+
if (!entry) return { error: "File not found" };
|
|
1872
|
+
try {
|
|
1873
|
+
const content = await entry.reader();
|
|
1874
|
+
return { fileName: entry.label, content };
|
|
1875
|
+
} catch {
|
|
1876
|
+
return { error: "Could not read file" };
|
|
1877
|
+
}
|
|
1878
|
+
},
|
|
1879
|
+
}),
|
|
1880
|
+
}
|
|
1881
|
+
: undefined,
|
|
1882
|
+
stopWhen: stepCountIs(4),
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
res.json({ response: text });
|
|
1886
|
+
} catch (err: any) {
|
|
1887
|
+
console.error("code-line-ask error:", err?.message || err);
|
|
1888
|
+
res.status(500).json({ error: err?.message || "Failed to get response" });
|
|
825
1889
|
}
|
|
826
1890
|
});
|
|
827
1891
|
|
|
@@ -837,6 +1901,7 @@ app.post("/api/inline-ask", async (req, res) => {
|
|
|
837
1901
|
responseLength,
|
|
838
1902
|
responseStyle,
|
|
839
1903
|
responseAudience,
|
|
1904
|
+
preferenceSuffix,
|
|
840
1905
|
} = req.body;
|
|
841
1906
|
if (typeof selectedText !== "string" || typeof prompt !== "string") {
|
|
842
1907
|
return res
|
|
@@ -849,6 +1914,8 @@ app.post("/api/inline-ask", async (req, res) => {
|
|
|
849
1914
|
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
850
1915
|
);
|
|
851
1916
|
|
|
1917
|
+
const aiSettings = await storage.getAiSettings();
|
|
1918
|
+
|
|
852
1919
|
// Build conversation thread context for follow-ups
|
|
853
1920
|
let threadContext = "";
|
|
854
1921
|
if (priorResponse) {
|
|
@@ -884,13 +1951,22 @@ app.post("/api/inline-ask", async (req, res) => {
|
|
|
884
1951
|
systemLines.push(styleHints[responseStyle]);
|
|
885
1952
|
if (responseAudience && audienceHints[responseAudience])
|
|
886
1953
|
systemLines.push(audienceHints[responseAudience]);
|
|
1954
|
+
// preferenceSuffix carries ALL current group selections as pre-built text
|
|
1955
|
+
if (
|
|
1956
|
+
preferenceSuffix &&
|
|
1957
|
+
typeof preferenceSuffix === "string" &&
|
|
1958
|
+
preferenceSuffix.trim()
|
|
1959
|
+
)
|
|
1960
|
+
systemLines.push(preferenceSuffix.trim());
|
|
887
1961
|
|
|
888
1962
|
const { text } = await generateText({
|
|
889
1963
|
model: getModel(),
|
|
890
1964
|
maxOutputTokens: 800,
|
|
891
1965
|
...(isGoogle && {
|
|
892
1966
|
providerOptions: {
|
|
893
|
-
google: {
|
|
1967
|
+
google: {
|
|
1968
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1969
|
+
},
|
|
894
1970
|
},
|
|
895
1971
|
}),
|
|
896
1972
|
system: systemLines.join("\n"),
|
|
@@ -927,13 +2003,13 @@ app.post("/api/fix-viz", async (req, res) => {
|
|
|
927
2003
|
|
|
928
2004
|
const { text } = await generateText({
|
|
929
2005
|
model: getModel(),
|
|
930
|
-
maxOutputTokens:
|
|
2006
|
+
maxOutputTokens: 8000,
|
|
931
2007
|
...(isGoogle && {
|
|
932
2008
|
providerOptions: {
|
|
933
2009
|
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
934
2010
|
},
|
|
935
2011
|
}),
|
|
936
|
-
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- 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-
|
|
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}`,
|
|
937
2013
|
});
|
|
938
2014
|
|
|
939
2015
|
// Strip any fences the model might have added
|
|
@@ -949,6 +2025,107 @@ app.post("/api/fix-viz", async (req, res) => {
|
|
|
949
2025
|
}
|
|
950
2026
|
});
|
|
951
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
|
+
|
|
2065
|
+
// ─── Refine Viz ─────────────────────────────────────────
|
|
2066
|
+
|
|
2067
|
+
app.post("/api/refine-viz", async (req, res) => {
|
|
2068
|
+
const { spec, prompt: userPrompt, history } = req.body;
|
|
2069
|
+
if (typeof spec !== "string" || !spec.trim()) {
|
|
2070
|
+
return res.status(400).json({ error: "spec is required" });
|
|
2071
|
+
}
|
|
2072
|
+
if (typeof userPrompt !== "string" || !userPrompt.trim()) {
|
|
2073
|
+
return res.status(400).json({ error: "prompt is required" });
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
try {
|
|
2077
|
+
const { vizGuide } = await storage.getAiSettings();
|
|
2078
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
2079
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
2080
|
+
);
|
|
2081
|
+
|
|
2082
|
+
const historyContext =
|
|
2083
|
+
Array.isArray(history) && history.length
|
|
2084
|
+
? history
|
|
2085
|
+
.map(
|
|
2086
|
+
(h: { prompt: string; spec: string }, i: number) =>
|
|
2087
|
+
`[Refinement ${i + 1}] User asked: "${h.prompt}"\nResulting spec:\n${h.spec}`,
|
|
2088
|
+
)
|
|
2089
|
+
.join("\n\n---\n\n") + "\n\n---\n\n"
|
|
2090
|
+
: "";
|
|
2091
|
+
|
|
2092
|
+
const prompt = `You are editing a VizCraft diagram spec.${vizGuide ? `\n\n${vizGuide}` : ""}${historyContext ? `\nPrior refinements applied to this diagram:\n\n${historyContext}` : ""}
|
|
2093
|
+
Current spec:
|
|
2094
|
+
${spec}
|
|
2095
|
+
|
|
2096
|
+
User instruction: ${userPrompt}
|
|
2097
|
+
|
|
2098
|
+
Apply the user's instruction to the spec. Return ONLY the updated YAML (or JSON) spec with no explanation and no markdown fences — just the raw spec.
|
|
2099
|
+
Rules:
|
|
2100
|
+
- Preserve all existing structure unless the user explicitly asks to remove something
|
|
2101
|
+
- If adding nodes, pick sensible x/y coordinates that fit the existing layout
|
|
2102
|
+
- YAML string quoting: wrap values with special characters (hyphens like --, colons, brackets) in double quotes, never single quotes
|
|
2103
|
+
- Node ids must be kebab-case with no spaces
|
|
2104
|
+
- Return the same format as the input (YAML or JSON)`;
|
|
2105
|
+
|
|
2106
|
+
const { text } = await generateText({
|
|
2107
|
+
model: getModel(),
|
|
2108
|
+
maxOutputTokens: 8000,
|
|
2109
|
+
...(isGoogle && {
|
|
2110
|
+
providerOptions: {
|
|
2111
|
+
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
2112
|
+
},
|
|
2113
|
+
}),
|
|
2114
|
+
prompt,
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
const refined = text
|
|
2118
|
+
.replace(/^```(?:yaml|json|viz)?\s*/i, "")
|
|
2119
|
+
.replace(/```\s*$/, "")
|
|
2120
|
+
.trim();
|
|
2121
|
+
|
|
2122
|
+
res.json({ spec: refined });
|
|
2123
|
+
} catch (err: any) {
|
|
2124
|
+
console.error("refine-viz error:", err?.message || err);
|
|
2125
|
+
res.status(500).json({ error: err?.message || "Failed to refine viz" });
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
|
|
952
2129
|
// ─── Fix Diagram ────────────────────────────────────────
|
|
953
2130
|
|
|
954
2131
|
app.post("/api/fix-diagram", async (req, res) => {
|
|
@@ -987,6 +2164,531 @@ app.post("/api/fix-diagram", async (req, res) => {
|
|
|
987
2164
|
}
|
|
988
2165
|
});
|
|
989
2166
|
|
|
2167
|
+
// ─── Code Runner ────────────────────────────────────────────
|
|
2168
|
+
|
|
2169
|
+
const RUN_TIMEOUT_MS = 10_000;
|
|
2170
|
+
|
|
2171
|
+
app.post("/api/run-code", async (req, res) => {
|
|
2172
|
+
const { code, language = "typescript" } = req.body as {
|
|
2173
|
+
code: string;
|
|
2174
|
+
language?: string;
|
|
2175
|
+
};
|
|
2176
|
+
|
|
2177
|
+
if (typeof code !== "string" || code.trim().length === 0) {
|
|
2178
|
+
return res.status(400).json({ error: "No code provided" });
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Transpile TS → JS if needed (ts.transpileModule is a zero-cost in-process call)
|
|
2182
|
+
let jsCode: string;
|
|
2183
|
+
try {
|
|
2184
|
+
if (language === "typescript") {
|
|
2185
|
+
const result = ts.transpileModule(code, {
|
|
2186
|
+
compilerOptions: {
|
|
2187
|
+
target: ts.ScriptTarget.ESNext,
|
|
2188
|
+
module: ts.ModuleKind.ESNext,
|
|
2189
|
+
strict: false,
|
|
2190
|
+
esModuleInterop: true,
|
|
2191
|
+
},
|
|
2192
|
+
});
|
|
2193
|
+
jsCode = result.outputText;
|
|
2194
|
+
} else {
|
|
2195
|
+
jsCode = code;
|
|
2196
|
+
}
|
|
2197
|
+
} catch (transpileErr: any) {
|
|
2198
|
+
return res.json({
|
|
2199
|
+
stdout: "",
|
|
2200
|
+
stderr: String(transpileErr?.message ?? transpileErr),
|
|
2201
|
+
durationMs: 0,
|
|
2202
|
+
timedOut: false,
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// Write to a temp .mjs file and run it as a child process
|
|
2207
|
+
const tmpFile = path.join(os.tmpdir(), `runner-${randomUUID()}.mjs`);
|
|
2208
|
+
try {
|
|
2209
|
+
await fs.writeFile(tmpFile, jsCode, "utf8");
|
|
2210
|
+
} catch {
|
|
2211
|
+
return res.status(500).json({ error: "Failed to write temp file" });
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
const stdoutLines: string[] = [];
|
|
2215
|
+
const stderrLines: string[] = [];
|
|
2216
|
+
let timedOut = false;
|
|
2217
|
+
const start = Date.now();
|
|
2218
|
+
|
|
2219
|
+
await new Promise<void>((resolve) => {
|
|
2220
|
+
const child = spawn(process.execPath, [tmpFile], {
|
|
2221
|
+
timeout: RUN_TIMEOUT_MS,
|
|
2222
|
+
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
const killTimer = setTimeout(() => {
|
|
2226
|
+
timedOut = true;
|
|
2227
|
+
child.kill("SIGKILL");
|
|
2228
|
+
resolve();
|
|
2229
|
+
}, RUN_TIMEOUT_MS);
|
|
2230
|
+
|
|
2231
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2232
|
+
stdoutLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
|
|
2233
|
+
});
|
|
2234
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2235
|
+
stderrLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
|
|
2236
|
+
});
|
|
2237
|
+
child.on("close", () => {
|
|
2238
|
+
clearTimeout(killTimer);
|
|
2239
|
+
resolve();
|
|
2240
|
+
});
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
// Clean up temp file (best-effort)
|
|
2244
|
+
fs.unlink(tmpFile).catch(() => {});
|
|
2245
|
+
|
|
2246
|
+
res.json({
|
|
2247
|
+
stdout: stdoutLines.join(""),
|
|
2248
|
+
stderr: stderrLines.join(""),
|
|
2249
|
+
durationMs: Date.now() - start,
|
|
2250
|
+
timedOut,
|
|
2251
|
+
});
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
// ─── Sandbox: persistent server + client runner ────────────
|
|
2255
|
+
|
|
2256
|
+
interface SandboxEntry {
|
|
2257
|
+
child: import("child_process").ChildProcess;
|
|
2258
|
+
port: number;
|
|
2259
|
+
tmpFile: string;
|
|
2260
|
+
logs: string[];
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
const sandboxes = new Map<string, SandboxEntry>();
|
|
2264
|
+
const SANDBOX_DIR = path.join(__dirname, "..", ".sandbox-tmp");
|
|
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
|
+
|
|
2483
|
+
async function getFreePort(): Promise<number> {
|
|
2484
|
+
return new Promise((resolve, reject) => {
|
|
2485
|
+
const srv = net.createServer();
|
|
2486
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
2487
|
+
const addr = srv.address() as net.AddressInfo;
|
|
2488
|
+
srv.close(() => resolve(addr.port));
|
|
2489
|
+
});
|
|
2490
|
+
srv.on("error", reject);
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
function transpileToJs(code: string, language: string): string {
|
|
2495
|
+
if (language === "typescript") {
|
|
2496
|
+
return ts.transpileModule(code, {
|
|
2497
|
+
compilerOptions: {
|
|
2498
|
+
target: ts.ScriptTarget.ESNext,
|
|
2499
|
+
module: ts.ModuleKind.ESNext,
|
|
2500
|
+
strict: false,
|
|
2501
|
+
esModuleInterop: true,
|
|
2502
|
+
},
|
|
2503
|
+
}).outputText;
|
|
2504
|
+
}
|
|
2505
|
+
return code;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
app.post("/api/sandbox/start", async (req, res) => {
|
|
2509
|
+
const { serverCode, language = "typescript" } = req.body as {
|
|
2510
|
+
serverCode: string;
|
|
2511
|
+
language?: string;
|
|
2512
|
+
};
|
|
2513
|
+
if (typeof serverCode !== "string" || !serverCode.trim()) {
|
|
2514
|
+
return res.status(400).json({ error: "serverCode is required" });
|
|
2515
|
+
}
|
|
2516
|
+
let jsCode: string;
|
|
2517
|
+
try {
|
|
2518
|
+
jsCode = transpileToJs(serverCode, language);
|
|
2519
|
+
} catch (err: any) {
|
|
2520
|
+
return res.status(400).json({ error: err?.message ?? String(err) });
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
const port = await getFreePort();
|
|
2524
|
+
const id = randomUUID();
|
|
2525
|
+
await fs.mkdir(SANDBOX_DIR, { recursive: true });
|
|
2526
|
+
const tmpFile = path.join(SANDBOX_DIR, `${id}.mjs`);
|
|
2527
|
+
await fs.writeFile(tmpFile, jsCode, "utf8");
|
|
2528
|
+
|
|
2529
|
+
const logs: string[] = [];
|
|
2530
|
+
const child = spawn(process.execPath, [tmpFile], {
|
|
2531
|
+
env: { ...process.env, PORT: String(port), NODE_NO_WARNINGS: "1" },
|
|
2532
|
+
});
|
|
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
|
+
);
|
|
2539
|
+
child.on("exit", () => {
|
|
2540
|
+
sandboxes.delete(id);
|
|
2541
|
+
fs.unlink(tmpFile).catch(() => {});
|
|
2542
|
+
});
|
|
2543
|
+
sandboxes.set(id, { child, port, tmpFile, logs });
|
|
2544
|
+
|
|
2545
|
+
// Give the server a moment to bind (or fail)
|
|
2546
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
2547
|
+
if (!sandboxes.has(id)) {
|
|
2548
|
+
return res.status(500).json({
|
|
2549
|
+
error: logs.join("").trim() || "Server exited immediately",
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
res.json({ sandboxId: id, port, sandboxUrl: `http://localhost:${port}` });
|
|
2553
|
+
});
|
|
2554
|
+
|
|
2555
|
+
app.get("/api/sandbox/:id/status", (req, res) => {
|
|
2556
|
+
const sb = sandboxes.get(req.params.id);
|
|
2557
|
+
if (!sb) return res.json({ running: false, logs: [] });
|
|
2558
|
+
res.json({ running: true, port: sb.port, logs: sb.logs });
|
|
2559
|
+
});
|
|
2560
|
+
|
|
2561
|
+
app.delete("/api/sandbox/:id", (req, res) => {
|
|
2562
|
+
const sb = sandboxes.get(req.params.id);
|
|
2563
|
+
if (sb) {
|
|
2564
|
+
sb.child.kill("SIGTERM");
|
|
2565
|
+
sandboxes.delete(req.params.id);
|
|
2566
|
+
fs.unlink(sb.tmpFile).catch(() => {});
|
|
2567
|
+
}
|
|
2568
|
+
res.json({ ok: true });
|
|
2569
|
+
});
|
|
2570
|
+
|
|
2571
|
+
app.post("/api/sandbox/run-client", async (req, res) => {
|
|
2572
|
+
const {
|
|
2573
|
+
code,
|
|
2574
|
+
language = "javascript",
|
|
2575
|
+
sandboxUrl,
|
|
2576
|
+
} = req.body as {
|
|
2577
|
+
code: string;
|
|
2578
|
+
language?: string;
|
|
2579
|
+
sandboxUrl: string;
|
|
2580
|
+
};
|
|
2581
|
+
if (typeof code !== "string" || !code.trim()) {
|
|
2582
|
+
return res.status(400).json({ error: "code is required" });
|
|
2583
|
+
}
|
|
2584
|
+
const eventSourcePolyfill = `
|
|
2585
|
+
if (typeof EventSource === 'undefined') {
|
|
2586
|
+
const { default: _http } = await import('http');
|
|
2587
|
+
const { default: _https } = await import('https');
|
|
2588
|
+
const { EventEmitter: _EE } = await import('events');
|
|
2589
|
+
globalThis.EventSource = class EventSource extends _EE {
|
|
2590
|
+
constructor(url) {
|
|
2591
|
+
super();
|
|
2592
|
+
// Resolve relative URLs against SANDBOX_URL (e.g. '/api/foo' → 'http://localhost:PORT/api/foo')
|
|
2593
|
+
const _resolved = (url.startsWith('http://') || url.startsWith('https://'))
|
|
2594
|
+
? url
|
|
2595
|
+
: SANDBOX_URL + (url.startsWith('/') ? url : '/' + url);
|
|
2596
|
+
this.url = _resolved; this.readyState = 0;
|
|
2597
|
+
const _mod = _resolved.startsWith('https') ? _https : _http;
|
|
2598
|
+
this._req = _mod.get(_resolved, { headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' } }, (res) => {
|
|
2599
|
+
this.readyState = 1;
|
|
2600
|
+
let _buf = '';
|
|
2601
|
+
res.on('data', (chunk) => {
|
|
2602
|
+
_buf += chunk.toString();
|
|
2603
|
+
const _msgs = _buf.split('\\n\\n');
|
|
2604
|
+
_buf = _msgs.pop() ?? '';
|
|
2605
|
+
for (const _b of _msgs) {
|
|
2606
|
+
if (!_b.trim()) continue;
|
|
2607
|
+
let _data = '', _type = 'message';
|
|
2608
|
+
for (const _l of _b.split('\\n')) {
|
|
2609
|
+
if (_l.startsWith('data:')) _data += (_data ? '\\n' : '') + _l.slice(5).trimStart();
|
|
2610
|
+
else if (_l.startsWith('event:')) _type = _l.slice(6).trimStart();
|
|
2611
|
+
}
|
|
2612
|
+
const _e = { type: _type, data: _data };
|
|
2613
|
+
if (_type === 'message' && this.onmessage) this.onmessage(_e);
|
|
2614
|
+
this.emit(_type, _e);
|
|
2615
|
+
}
|
|
2616
|
+
});
|
|
2617
|
+
res.on('end', () => { this.readyState = 2; });
|
|
2618
|
+
});
|
|
2619
|
+
this._req.on('error', () => { this.readyState = 2; });
|
|
2620
|
+
}
|
|
2621
|
+
close() { try { this._req?.destroy(); } catch {} this.readyState = 2; }
|
|
2622
|
+
addEventListener(t, fn) { this.on(t, fn); }
|
|
2623
|
+
removeEventListener(t, fn) { this.off(t, fn); }
|
|
2624
|
+
};
|
|
2625
|
+
}`;
|
|
2626
|
+
const wrapped = `const SANDBOX_URL = ${JSON.stringify(sandboxUrl ?? "")};
|
|
2627
|
+
${eventSourcePolyfill}
|
|
2628
|
+
${code}`;
|
|
2629
|
+
let jsCode: string;
|
|
2630
|
+
try {
|
|
2631
|
+
jsCode = transpileToJs(wrapped, language);
|
|
2632
|
+
} catch (err: any) {
|
|
2633
|
+
return res.json({
|
|
2634
|
+
stdout: "",
|
|
2635
|
+
stderr: String(err?.message ?? err),
|
|
2636
|
+
durationMs: 0,
|
|
2637
|
+
timedOut: false,
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
// Switch to SSE streaming so each log line reaches the browser immediately
|
|
2642
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
2643
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2644
|
+
res.setHeader("Connection", "keep-alive");
|
|
2645
|
+
res.flushHeaders();
|
|
2646
|
+
|
|
2647
|
+
const sendEvent = (kind: string, text: string) => {
|
|
2648
|
+
res.write(`data: ${JSON.stringify({ kind, text })}\n\n`);
|
|
2649
|
+
};
|
|
2650
|
+
|
|
2651
|
+
const tmpFile = path.join(os.tmpdir(), `sandbox-client-${randomUUID()}.mjs`);
|
|
2652
|
+
await fs.writeFile(tmpFile, jsCode, "utf8");
|
|
2653
|
+
|
|
2654
|
+
let timedOut = false;
|
|
2655
|
+
const start = Date.now();
|
|
2656
|
+
|
|
2657
|
+
await new Promise<void>((resolve) => {
|
|
2658
|
+
const child = spawn(process.execPath, [tmpFile], {
|
|
2659
|
+
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
|
2660
|
+
});
|
|
2661
|
+
const killTimer = setTimeout(() => {
|
|
2662
|
+
timedOut = true;
|
|
2663
|
+
child.kill("SIGKILL");
|
|
2664
|
+
resolve();
|
|
2665
|
+
}, RUN_TIMEOUT_MS);
|
|
2666
|
+
|
|
2667
|
+
// Stream each chunk as it arrives — split on newlines so each log line is its own event
|
|
2668
|
+
const pushChunks = (chunk: Buffer, kind: "stdout" | "stderr") => {
|
|
2669
|
+
chunk
|
|
2670
|
+
.toString()
|
|
2671
|
+
.split("\n")
|
|
2672
|
+
.forEach((line) => {
|
|
2673
|
+
if (line !== "") sendEvent(kind, line);
|
|
2674
|
+
});
|
|
2675
|
+
};
|
|
2676
|
+
child.stdout.on("data", (chunk: Buffer) => pushChunks(chunk, "stdout"));
|
|
2677
|
+
child.stderr.on("data", (chunk: Buffer) => pushChunks(chunk, "stderr"));
|
|
2678
|
+
child.on("close", (code) => {
|
|
2679
|
+
clearTimeout(killTimer);
|
|
2680
|
+
resolve();
|
|
2681
|
+
});
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
fs.unlink(tmpFile).catch(() => {});
|
|
2685
|
+
sendEvent(
|
|
2686
|
+
"done",
|
|
2687
|
+
JSON.stringify({ timedOut, durationMs: Date.now() - start }),
|
|
2688
|
+
);
|
|
2689
|
+
res.end();
|
|
2690
|
+
});
|
|
2691
|
+
|
|
990
2692
|
// ─── Start ───────────────────────────────────────────────
|
|
991
2693
|
|
|
992
2694
|
(async () => {
|