create-interview-cockpit 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/package-lock.json +19 -0
- package/template/client/package.json +3 -0
- package/template/client/src/App.tsx +17 -0
- package/template/client/src/api.ts +135 -0
- package/template/client/src/components/AiSettingsModal.tsx +218 -4
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +110 -27
- package/template/client/src/components/ChatView.tsx +69 -4
- package/template/client/src/components/CodeContextPanel.tsx +297 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
- package/template/client/src/components/DocRefModal.tsx +502 -0
- package/template/client/src/components/FileAttachments.tsx +109 -9
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/MarkdownRenderer.tsx +205 -2
- package/template/client/src/components/Sidebar.tsx +213 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +162 -19
- package/template/client/src/store.ts +201 -0
- package/template/client/src/types.ts +8 -0
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1107 -46
- package/template/server/src/storage.ts +263 -2
|
@@ -24,6 +24,10 @@ 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";
|
|
29
33
|
|
|
@@ -32,11 +36,13 @@ app.use(cors());
|
|
|
32
36
|
app.use(express.json({ limit: "25mb" }));
|
|
33
37
|
|
|
34
38
|
const upload = multer({
|
|
35
|
-
limits: { fileSize:
|
|
39
|
+
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB max
|
|
36
40
|
fileFilter: (_req, file, cb) => {
|
|
37
|
-
const
|
|
38
|
-
/\.(txt|md|ts|tsx|js|jsx|json|css|scss|html|xml|yaml|yml|csv|py|java|cs|go|rs|sql|sh|env|cfg|conf|toml|ini|log|pdf|docx)$/i;
|
|
39
|
-
|
|
41
|
+
const allowedExt =
|
|
42
|
+
/\.(txt|md|ts|tsx|js|jsx|json|css|scss|html|xml|yaml|yml|csv|py|java|cs|go|rs|sql|sh|env|cfg|conf|toml|ini|log|pdf|docx|png|jpg|jpeg|gif|webp|svg)$/i;
|
|
43
|
+
const allowedMime =
|
|
44
|
+
file.mimetype.startsWith("text/") || file.mimetype.startsWith("image/");
|
|
45
|
+
if (allowedExt.test(file.originalname) || allowedMime) {
|
|
40
46
|
cb(null, true);
|
|
41
47
|
} else {
|
|
42
48
|
cb(new Error("Unsupported file type"));
|
|
@@ -44,21 +50,56 @@ const upload = multer({
|
|
|
44
50
|
},
|
|
45
51
|
});
|
|
46
52
|
|
|
47
|
-
// Extract text from uploaded files (docx, pdf,
|
|
53
|
+
// Extract text from uploaded files (docx, pdf, plain text, or images)
|
|
48
54
|
async function extractText(buffer: Buffer, filename: string): Promise<string> {
|
|
49
55
|
const ext = filename.split(".").pop()?.toLowerCase();
|
|
50
56
|
if (ext === "docx") {
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
try {
|
|
58
|
+
const result = await mammoth.extractRawText({ buffer });
|
|
59
|
+
return result.value;
|
|
60
|
+
} catch (e: any) {
|
|
61
|
+
return `[DOCX extraction failed: ${e?.message ?? "unknown error"}]`;
|
|
62
|
+
}
|
|
53
63
|
}
|
|
54
64
|
if (ext === "pdf") {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
try {
|
|
66
|
+
const parser = new PDFParse({ data: buffer });
|
|
67
|
+
const result = await parser.getText();
|
|
68
|
+
return result.text ?? "";
|
|
69
|
+
} catch (e: any) {
|
|
70
|
+
return `[PDF extraction failed: ${e?.message ?? "unknown error"}. The original file is stored and can be downloaded.]`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const imageExts = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
|
|
74
|
+
if (ext && imageExts.has(ext)) {
|
|
75
|
+
// Images can't be read as text; store original for download.
|
|
76
|
+
// Return a descriptor so the LLM knows the file exists.
|
|
77
|
+
return `[Image file: ${filename} — the original is stored and available for download, but cannot be read as text by the AI.]`;
|
|
58
78
|
}
|
|
59
79
|
return buffer.toString("utf-8");
|
|
60
80
|
}
|
|
61
81
|
|
|
82
|
+
function mimeForFilename(filename: string): string {
|
|
83
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
84
|
+
const map: Record<string, string> = {
|
|
85
|
+
pdf: "application/pdf",
|
|
86
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
87
|
+
doc: "application/msword",
|
|
88
|
+
json: "application/json",
|
|
89
|
+
csv: "text/csv",
|
|
90
|
+
html: "text/html; charset=utf-8",
|
|
91
|
+
xml: "application/xml",
|
|
92
|
+
zip: "application/zip",
|
|
93
|
+
png: "image/png",
|
|
94
|
+
jpg: "image/jpeg",
|
|
95
|
+
jpeg: "image/jpeg",
|
|
96
|
+
gif: "image/gif",
|
|
97
|
+
webp: "image/webp",
|
|
98
|
+
svg: "image/svg+xml",
|
|
99
|
+
};
|
|
100
|
+
return map[ext] ?? "application/octet-stream";
|
|
101
|
+
}
|
|
102
|
+
|
|
62
103
|
const PORT = process.env.PORT || 3001;
|
|
63
104
|
const CODE_CONTEXT_DIR = process.env.CODE_CONTEXT_DIR || "";
|
|
64
105
|
|
|
@@ -326,26 +367,102 @@ app.patch("/api/topics/:id", async (req, res) => {
|
|
|
326
367
|
res.json(topic);
|
|
327
368
|
});
|
|
328
369
|
|
|
370
|
+
// ─── Workspace Context Files ─────────────────────────────
|
|
371
|
+
|
|
372
|
+
app.get("/api/workspace/context-files", async (_req, res) => {
|
|
373
|
+
const files = await storage.getWorkspaceContextFiles();
|
|
374
|
+
res.json(files);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
app.post(
|
|
378
|
+
"/api/workspace/context-files",
|
|
379
|
+
upload.array("files", 20),
|
|
380
|
+
async (req, res) => {
|
|
381
|
+
try {
|
|
382
|
+
const files = req.files as Express.Multer.File[];
|
|
383
|
+
if (!files?.length) return res.status(400).json({ error: "No files" });
|
|
384
|
+
const results: storage.ContextFile[] = [];
|
|
385
|
+
for (const file of files) {
|
|
386
|
+
const id = randomUUID();
|
|
387
|
+
const text = await extractText(file.buffer, file.originalname);
|
|
388
|
+
// Store original bytes for download; extracted text for the LLM
|
|
389
|
+
await storage.writeOriginalBlob(id, file.buffer);
|
|
390
|
+
const cf = await storage.saveWorkspaceContextFile(
|
|
391
|
+
id,
|
|
392
|
+
file.originalname,
|
|
393
|
+
Buffer.from(text, "utf-8"),
|
|
394
|
+
);
|
|
395
|
+
results.push(cf);
|
|
396
|
+
}
|
|
397
|
+
res.json(results);
|
|
398
|
+
} catch (err: any) {
|
|
399
|
+
console.error("workspace upload error:", err?.message || err);
|
|
400
|
+
res.status(500).json({ error: err?.message || "Upload failed" });
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
app.delete("/api/workspace/context-files/:fileId", async (req, res) => {
|
|
406
|
+
await storage.deleteWorkspaceContextFile(req.params.fileId);
|
|
407
|
+
res.json({ ok: true });
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
app.get("/api/workspace/context-files/:fileId/download", async (req, res) => {
|
|
411
|
+
const files = await storage.getWorkspaceContextFiles();
|
|
412
|
+
const cf = files.find((f) => f.id === req.params.fileId);
|
|
413
|
+
if (!cf) return res.status(404).json({ error: "File not found" });
|
|
414
|
+
try {
|
|
415
|
+
const original = await storage.readOriginalBlob(req.params.fileId);
|
|
416
|
+
if (original) {
|
|
417
|
+
res.setHeader("Content-Type", mimeForFilename(cf.originalName));
|
|
418
|
+
res.setHeader(
|
|
419
|
+
"Content-Disposition",
|
|
420
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}"`,
|
|
421
|
+
);
|
|
422
|
+
return res.send(original);
|
|
423
|
+
}
|
|
424
|
+
// Fallback for files uploaded before original storage was added
|
|
425
|
+
const content = await storage.readWorkspaceContextFileContent(
|
|
426
|
+
req.params.fileId,
|
|
427
|
+
);
|
|
428
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
429
|
+
res.setHeader(
|
|
430
|
+
"Content-Disposition",
|
|
431
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}.txt"`,
|
|
432
|
+
);
|
|
433
|
+
res.send(content);
|
|
434
|
+
} catch {
|
|
435
|
+
res.status(404).json({ error: "File not found" });
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
329
439
|
// ─── Topic Context Files ─────────────────────────────────
|
|
330
440
|
|
|
331
441
|
app.post(
|
|
332
442
|
"/api/topics/:topicId/context-files",
|
|
333
443
|
upload.array("files", 20),
|
|
334
444
|
async (req, res) => {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
445
|
+
try {
|
|
446
|
+
const files = req.files as Express.Multer.File[];
|
|
447
|
+
if (!files?.length) return res.status(400).json({ error: "No files" });
|
|
448
|
+
const results: storage.ContextFile[] = [];
|
|
449
|
+
for (const file of files) {
|
|
450
|
+
const id = randomUUID();
|
|
451
|
+
const text = await extractText(file.buffer, file.originalname);
|
|
452
|
+
await storage.writeOriginalBlob(id, file.buffer);
|
|
453
|
+
const cf = await storage.saveContextFile(
|
|
454
|
+
req.params.topicId as string,
|
|
455
|
+
id,
|
|
456
|
+
file.originalname,
|
|
457
|
+
Buffer.from(text, "utf-8"),
|
|
458
|
+
);
|
|
459
|
+
results.push(cf);
|
|
460
|
+
}
|
|
461
|
+
res.json(results);
|
|
462
|
+
} catch (err: any) {
|
|
463
|
+
console.error("topic upload error:", err?.message || err);
|
|
464
|
+
res.status(500).json({ error: err?.message || "Upload failed" });
|
|
347
465
|
}
|
|
348
|
-
res.json(results);
|
|
349
466
|
},
|
|
350
467
|
);
|
|
351
468
|
|
|
@@ -354,6 +471,36 @@ app.delete("/api/topics/:topicId/context-files/:fileId", async (req, res) => {
|
|
|
354
471
|
res.json({ ok: true });
|
|
355
472
|
});
|
|
356
473
|
|
|
474
|
+
app.get(
|
|
475
|
+
"/api/topics/:topicId/context-files/:fileId/download",
|
|
476
|
+
async (req, res) => {
|
|
477
|
+
const topics = await storage.getTopics();
|
|
478
|
+
const topic = topics.find((t) => t.id === req.params.topicId);
|
|
479
|
+
const cf = topic?.contextFiles?.find((f) => f.id === req.params.fileId);
|
|
480
|
+
if (!cf) return res.status(404).json({ error: "File not found" });
|
|
481
|
+
try {
|
|
482
|
+
const original = await storage.readOriginalBlob(req.params.fileId);
|
|
483
|
+
if (original) {
|
|
484
|
+
res.setHeader("Content-Type", mimeForFilename(cf.originalName));
|
|
485
|
+
res.setHeader(
|
|
486
|
+
"Content-Disposition",
|
|
487
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}"`,
|
|
488
|
+
);
|
|
489
|
+
return res.send(original);
|
|
490
|
+
}
|
|
491
|
+
const content = await storage.readContextFileContent(req.params.fileId);
|
|
492
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
493
|
+
res.setHeader(
|
|
494
|
+
"Content-Disposition",
|
|
495
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}.txt"`,
|
|
496
|
+
);
|
|
497
|
+
res.send(content);
|
|
498
|
+
} catch {
|
|
499
|
+
res.status(404).json({ error: "File not found" });
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
);
|
|
503
|
+
|
|
357
504
|
// ─── Questions ───────────────────────────────────────────
|
|
358
505
|
|
|
359
506
|
app.get("/api/topics/:topicId/questions", async (req, res) => {
|
|
@@ -404,35 +551,76 @@ app.patch("/api/questions/:id", async (req, res) => {
|
|
|
404
551
|
app.delete("/api/questions/:id", async (req, res) => {
|
|
405
552
|
const q = await storage.getQuestion(req.params.id);
|
|
406
553
|
if (q) {
|
|
407
|
-
// Cascade: delete
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
|
|
554
|
+
// Cascade: recursively delete all descendants (not just direct children)
|
|
555
|
+
const allInTopic = await storage.getQuestionsByTopic(q.topicId);
|
|
556
|
+
const collectDescendants = (parentId: string): string[] => {
|
|
557
|
+
const direct = allInTopic.filter((c) => c.parentQuestionId === parentId);
|
|
558
|
+
return direct.flatMap((c) => [c.id, ...collectDescendants(c.id)]);
|
|
559
|
+
};
|
|
560
|
+
const descendants = collectDescendants(q.id);
|
|
561
|
+
await Promise.all(descendants.map((id) => storage.deleteQuestion(id)));
|
|
411
562
|
}
|
|
412
563
|
await storage.deleteQuestion(req.params.id);
|
|
413
564
|
res.json({ ok: true });
|
|
414
565
|
});
|
|
415
566
|
|
|
567
|
+
// ─── Code-line Annotations ─────────────────────────────
|
|
568
|
+
|
|
569
|
+
app.get("/api/questions/:id/code-annotations", async (req, res) => {
|
|
570
|
+
const q = await storage.getQuestion(req.params.id);
|
|
571
|
+
if (!q) return res.status(404).json({ error: "Not found" });
|
|
572
|
+
const filePath = req.query.filePath as string;
|
|
573
|
+
if (!filePath) return res.json({ annotations: [] });
|
|
574
|
+
const annotations = q.codeAnnotations?.[filePath] ?? [];
|
|
575
|
+
res.json({ annotations });
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
app.patch("/api/questions/:id/code-annotations", async (req, res) => {
|
|
579
|
+
const { filePath, annotations } = req.body;
|
|
580
|
+
if (typeof filePath !== "string" || !filePath.trim()) {
|
|
581
|
+
return res.status(400).json({ error: "filePath is required" });
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
await storage.updateCodeAnnotationsForFile(
|
|
585
|
+
req.params.id,
|
|
586
|
+
filePath,
|
|
587
|
+
Array.isArray(annotations) ? annotations : [],
|
|
588
|
+
);
|
|
589
|
+
res.json({ ok: true });
|
|
590
|
+
} catch (err: any) {
|
|
591
|
+
res
|
|
592
|
+
.status(500)
|
|
593
|
+
.json({ error: err?.message || "Failed to save annotations" });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
416
597
|
// ─── Question Context Files ─────────────────────────────
|
|
417
598
|
|
|
418
599
|
app.post(
|
|
419
600
|
"/api/questions/:questionId/context-files",
|
|
420
601
|
upload.array("files", 20),
|
|
421
602
|
async (req, res) => {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
603
|
+
try {
|
|
604
|
+
const files = req.files as Express.Multer.File[];
|
|
605
|
+
if (!files?.length) return res.status(400).json({ error: "No files" });
|
|
606
|
+
const results: storage.ContextFile[] = [];
|
|
607
|
+
for (const file of files) {
|
|
608
|
+
const id = randomUUID();
|
|
609
|
+
const text = await extractText(file.buffer, file.originalname);
|
|
610
|
+
await storage.writeOriginalBlob(id, file.buffer);
|
|
611
|
+
const cf = await storage.saveQuestionContextFile(
|
|
612
|
+
req.params.questionId as string,
|
|
613
|
+
id,
|
|
614
|
+
file.originalname,
|
|
615
|
+
Buffer.from(text, "utf-8"),
|
|
616
|
+
);
|
|
617
|
+
results.push(cf);
|
|
618
|
+
}
|
|
619
|
+
res.json(results);
|
|
620
|
+
} catch (err: any) {
|
|
621
|
+
console.error("question upload error:", err?.message || err);
|
|
622
|
+
res.status(500).json({ error: err?.message || "Upload failed" });
|
|
434
623
|
}
|
|
435
|
-
res.json(results);
|
|
436
624
|
},
|
|
437
625
|
);
|
|
438
626
|
|
|
@@ -447,11 +635,254 @@ app.delete(
|
|
|
447
635
|
},
|
|
448
636
|
);
|
|
449
637
|
|
|
638
|
+
// Save a code snippet (from Code Runner or AI response) as a question context file
|
|
639
|
+
app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
640
|
+
const { code, language, label, origin } = req.body as {
|
|
641
|
+
code: string;
|
|
642
|
+
language: string;
|
|
643
|
+
label: string;
|
|
644
|
+
origin: "user" | "ai" | "sandbox";
|
|
645
|
+
};
|
|
646
|
+
if (typeof code !== "string" || !code.trim()) {
|
|
647
|
+
return res.status(400).json({ error: "code is required" });
|
|
648
|
+
}
|
|
649
|
+
if (origin !== "user" && origin !== "ai" && origin !== "sandbox") {
|
|
650
|
+
return res
|
|
651
|
+
.status(400)
|
|
652
|
+
.json({ error: "origin must be 'user', 'ai', or 'sandbox'" });
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
const id = randomUUID();
|
|
656
|
+
const safeLabel = (label || "snippet").replace(/[/\\:*?"<>|]/g, "-").trim();
|
|
657
|
+
const ext =
|
|
658
|
+
language === "typescript"
|
|
659
|
+
? "ts"
|
|
660
|
+
: language === "javascript"
|
|
661
|
+
? "js"
|
|
662
|
+
: "txt";
|
|
663
|
+
const fileName = `${safeLabel}.${ext}`;
|
|
664
|
+
const cf = await storage.saveQuestionContextFile(
|
|
665
|
+
req.params.questionId,
|
|
666
|
+
id,
|
|
667
|
+
fileName,
|
|
668
|
+
Buffer.from(code, "utf-8"),
|
|
669
|
+
{ origin, language, label: safeLabel },
|
|
670
|
+
);
|
|
671
|
+
res.json(cf);
|
|
672
|
+
} catch (err: any) {
|
|
673
|
+
res.status(500).json({ error: err?.message || "Failed to save snippet" });
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// Overwrite the content blob of an existing sandbox context file
|
|
678
|
+
app.put(
|
|
679
|
+
"/api/questions/:questionId/context-files/:fileId/content",
|
|
680
|
+
async (req, res) => {
|
|
681
|
+
const { code } = req.body as { code: string };
|
|
682
|
+
if (typeof code !== "string") {
|
|
683
|
+
return res.status(400).json({ error: "code is required" });
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
await storage.overwriteQuestionContextFileContent(
|
|
687
|
+
req.params.questionId,
|
|
688
|
+
req.params.fileId,
|
|
689
|
+
Buffer.from(code, "utf-8"),
|
|
690
|
+
);
|
|
691
|
+
res.json({ ok: true });
|
|
692
|
+
} catch (err: any) {
|
|
693
|
+
res.status(500).json({ error: err?.message || "Failed to overwrite" });
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
// Rename the label of an existing question context file
|
|
699
|
+
app.patch(
|
|
700
|
+
"/api/questions/:questionId/context-files/:fileId",
|
|
701
|
+
async (req, res) => {
|
|
702
|
+
const { label } = req.body as { label: string };
|
|
703
|
+
if (typeof label !== "string" || !label.trim()) {
|
|
704
|
+
return res.status(400).json({ error: "label is required" });
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
const cf = await storage.renameQuestionContextFile(
|
|
708
|
+
req.params.questionId,
|
|
709
|
+
req.params.fileId,
|
|
710
|
+
label.trim(),
|
|
711
|
+
);
|
|
712
|
+
res.json(cf);
|
|
713
|
+
} catch (err: any) {
|
|
714
|
+
res.status(500).json({ error: err?.message || "Failed to rename" });
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
// Unified content-read endpoint — used by DocRefModal to show extracted text
|
|
720
|
+
// All context files (workspace / topic / question) share the same on-disk dir
|
|
721
|
+
// per workspace, so a single reader covers every scope.
|
|
722
|
+
app.get("/api/context-files/:fileId/content", async (req, res) => {
|
|
723
|
+
try {
|
|
724
|
+
const content = await storage.readContextFileContent(req.params.fileId);
|
|
725
|
+
res.json({ content });
|
|
726
|
+
} catch {
|
|
727
|
+
res.status(404).json({ error: "File not found" });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// List every uploaded file across workspace, topics, and questions.
|
|
732
|
+
// Used by the file picker so users can link an existing file instead of re-uploading.
|
|
733
|
+
app.get("/api/context-files/all", async (_req, res) => {
|
|
734
|
+
const entries: Array<{
|
|
735
|
+
fileId: string;
|
|
736
|
+
originalName: string;
|
|
737
|
+
source: "workspace" | "topic" | "question";
|
|
738
|
+
sourceName: string;
|
|
739
|
+
}> = [];
|
|
740
|
+
|
|
741
|
+
const wsFiles = await storage.getWorkspaceContextFiles();
|
|
742
|
+
for (const f of wsFiles) {
|
|
743
|
+
entries.push({
|
|
744
|
+
fileId: f.id,
|
|
745
|
+
originalName: f.originalName,
|
|
746
|
+
source: "workspace",
|
|
747
|
+
sourceName: "Workspace",
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const topics = await storage.getTopics();
|
|
752
|
+
for (const t of topics) {
|
|
753
|
+
for (const f of t.contextFiles || []) {
|
|
754
|
+
entries.push({
|
|
755
|
+
fileId: f.id,
|
|
756
|
+
originalName: f.originalName,
|
|
757
|
+
source: "topic",
|
|
758
|
+
sourceName: t.name,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Scan question files via the questions directory
|
|
764
|
+
const questions = await storage.getAllQuestions();
|
|
765
|
+
for (const q of questions) {
|
|
766
|
+
for (const f of q.contextFiles || []) {
|
|
767
|
+
entries.push({
|
|
768
|
+
fileId: f.id,
|
|
769
|
+
originalName: f.originalName,
|
|
770
|
+
source: "question",
|
|
771
|
+
sourceName: q.title,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Deduplicate by fileId — keep the first occurrence (workspace > topic > question)
|
|
777
|
+
const seen = new Set<string>();
|
|
778
|
+
const deduped = entries.filter((e) => {
|
|
779
|
+
if (seen.has(e.fileId)) return false;
|
|
780
|
+
seen.add(e.fileId);
|
|
781
|
+
return true;
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
res.json(deduped);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Link an existing file to a topic without re-uploading
|
|
788
|
+
app.post("/api/topics/:topicId/context-files/link", async (req, res) => {
|
|
789
|
+
const { fileId, originalName } = req.body as {
|
|
790
|
+
fileId?: string;
|
|
791
|
+
originalName?: string;
|
|
792
|
+
};
|
|
793
|
+
if (!fileId || !originalName)
|
|
794
|
+
return res.status(400).json({ error: "fileId and originalName required" });
|
|
795
|
+
const cf = await storage.linkContextFileToTopic(
|
|
796
|
+
req.params.topicId,
|
|
797
|
+
fileId,
|
|
798
|
+
originalName,
|
|
799
|
+
);
|
|
800
|
+
res.json(cf);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// Link an existing file to a question without re-uploading
|
|
804
|
+
app.post("/api/questions/:questionId/context-files/link", async (req, res) => {
|
|
805
|
+
const { fileId, originalName } = req.body as {
|
|
806
|
+
fileId?: string;
|
|
807
|
+
originalName?: string;
|
|
808
|
+
};
|
|
809
|
+
if (!fileId || !originalName)
|
|
810
|
+
return res.status(400).json({ error: "fileId and originalName required" });
|
|
811
|
+
const cf = await storage.linkContextFileToQuestion(
|
|
812
|
+
req.params.questionId,
|
|
813
|
+
fileId,
|
|
814
|
+
originalName,
|
|
815
|
+
);
|
|
816
|
+
res.json(cf);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// Unified inline-download endpoint — serves the original binary with Content-Disposition: inline
|
|
820
|
+
// so browsers can render PDFs/images directly inside an <iframe> or <img>.
|
|
821
|
+
// Pass ?name=originalFilename.pdf so Content-Type and filename are set correctly.
|
|
822
|
+
app.get("/api/context-files/:fileId/view", async (req, res) => {
|
|
823
|
+
const name = typeof req.query.name === "string" ? req.query.name : "file";
|
|
824
|
+
try {
|
|
825
|
+
const original = await storage.readOriginalBlob(req.params.fileId);
|
|
826
|
+
if (original) {
|
|
827
|
+
res.setHeader("Content-Type", mimeForFilename(name));
|
|
828
|
+
res.setHeader(
|
|
829
|
+
"Content-Disposition",
|
|
830
|
+
`inline; filename="${encodeURIComponent(name)}"`,
|
|
831
|
+
);
|
|
832
|
+
return res.send(original);
|
|
833
|
+
}
|
|
834
|
+
// Fallback: serve extracted text as plain text
|
|
835
|
+
const content = await storage.readContextFileContent(req.params.fileId);
|
|
836
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
837
|
+
res.setHeader(
|
|
838
|
+
"Content-Disposition",
|
|
839
|
+
`inline; filename="${encodeURIComponent(name)}.txt"`,
|
|
840
|
+
);
|
|
841
|
+
res.send(content);
|
|
842
|
+
} catch {
|
|
843
|
+
res.status(404).json({ error: "File not found" });
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
app.get(
|
|
848
|
+
"/api/questions/:questionId/context-files/:fileId/download",
|
|
849
|
+
async (req, res) => {
|
|
850
|
+
const question = await storage.getQuestion(req.params.questionId);
|
|
851
|
+
const cf = question?.contextFiles?.find((f) => f.id === req.params.fileId);
|
|
852
|
+
if (!cf) return res.status(404).json({ error: "File not found" });
|
|
853
|
+
try {
|
|
854
|
+
const original = await storage.readOriginalBlob(req.params.fileId);
|
|
855
|
+
if (original) {
|
|
856
|
+
res.setHeader("Content-Type", mimeForFilename(cf.originalName));
|
|
857
|
+
res.setHeader(
|
|
858
|
+
"Content-Disposition",
|
|
859
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}"`,
|
|
860
|
+
);
|
|
861
|
+
return res.send(original);
|
|
862
|
+
}
|
|
863
|
+
const content = await storage.readContextFileContent(req.params.fileId);
|
|
864
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
865
|
+
res.setHeader(
|
|
866
|
+
"Content-Disposition",
|
|
867
|
+
`attachment; filename="${encodeURIComponent(cf.originalName)}.txt"`,
|
|
868
|
+
);
|
|
869
|
+
res.send(content);
|
|
870
|
+
} catch {
|
|
871
|
+
res.status(404).json({ error: "File not found" });
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
);
|
|
875
|
+
|
|
450
876
|
// ─── AI Settings ────────────────────────────────────────────
|
|
451
877
|
|
|
452
878
|
app.get("/api/settings", async (_req, res) => {
|
|
453
879
|
const settings = await storage.getAiSettings();
|
|
454
|
-
res.json(
|
|
880
|
+
res.json({
|
|
881
|
+
...settings,
|
|
882
|
+
// Expose current provider/model from env so client can conditionally show thinking controls
|
|
883
|
+
provider: (process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
884
|
+
model: process.env.AI_MODEL || "",
|
|
885
|
+
});
|
|
455
886
|
});
|
|
456
887
|
|
|
457
888
|
app.patch("/api/settings", async (req, res) => {
|
|
@@ -473,9 +904,21 @@ app.patch("/api/settings", async (req, res) => {
|
|
|
473
904
|
req.body.promptGroups != null
|
|
474
905
|
? req.body.promptGroups
|
|
475
906
|
: current.promptGroups,
|
|
907
|
+
thinkingBudget:
|
|
908
|
+
typeof req.body.thinkingBudget === "number"
|
|
909
|
+
? req.body.thinkingBudget
|
|
910
|
+
: current.thinkingBudget,
|
|
911
|
+
alwaysSendPrefsDefault:
|
|
912
|
+
typeof req.body.alwaysSendPrefsDefault === "boolean"
|
|
913
|
+
? req.body.alwaysSendPrefsDefault
|
|
914
|
+
: current.alwaysSendPrefsDefault,
|
|
476
915
|
};
|
|
477
916
|
await storage.saveAiSettings(updated);
|
|
478
|
-
res.json(
|
|
917
|
+
res.json({
|
|
918
|
+
...updated,
|
|
919
|
+
provider: (process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
920
|
+
model: process.env.AI_MODEL || "",
|
|
921
|
+
});
|
|
479
922
|
});
|
|
480
923
|
|
|
481
924
|
// ─── Chat ────────────────────────────────────────────────
|
|
@@ -517,6 +960,15 @@ app.post("/api/chat", async (req, res) => {
|
|
|
517
960
|
{ label: string; reader: () => Promise<string> }
|
|
518
961
|
>();
|
|
519
962
|
|
|
963
|
+
// Workspace-level uploaded files (apply to all topics)
|
|
964
|
+
const workspaceFiles = await storage.getWorkspaceContextFiles();
|
|
965
|
+
for (const cf of workspaceFiles) {
|
|
966
|
+
fileRegistry.set(cf.id, {
|
|
967
|
+
label: `[workspace] ${cf.originalName}`,
|
|
968
|
+
reader: () => storage.readWorkspaceContextFileContent(cf.id),
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
520
972
|
// Topic-level uploaded files
|
|
521
973
|
if (topicId) {
|
|
522
974
|
const topics = await storage.getTopics();
|
|
@@ -587,6 +1039,62 @@ Examples:
|
|
|
587
1039
|
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.
|
|
588
1040
|
Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
589
1041
|
}
|
|
1042
|
+
|
|
1043
|
+
// Always add the inline code block instruction
|
|
1044
|
+
system += `
|
|
1045
|
+
|
|
1046
|
+
--- Writing Code Blocks the User Can Explore ---
|
|
1047
|
+
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:
|
|
1048
|
+
|
|
1049
|
+
// @ref: some-unique-label ← JavaScript / TypeScript / Java / C# / Go etc.
|
|
1050
|
+
# @ref: some-unique-label ← Python / Ruby / Shell
|
|
1051
|
+
-- @ref: some-unique-label ← SQL / Lua
|
|
1052
|
+
|
|
1053
|
+
Then anywhere in your response text where you want a clickable link back to that block:
|
|
1054
|
+
|
|
1055
|
+
[Descriptive Label](inlineref://some-unique-label)
|
|
1056
|
+
|
|
1057
|
+
Example:
|
|
1058
|
+
\`\`\`typescript
|
|
1059
|
+
// @ref: user-service
|
|
1060
|
+
export class UserService {
|
|
1061
|
+
async getUser(id: string) { ... }
|
|
1062
|
+
}
|
|
1063
|
+
\`\`\`
|
|
1064
|
+
|
|
1065
|
+
The implementation is in [UserService](inlineref://user-service).
|
|
1066
|
+
|
|
1067
|
+
Rules:
|
|
1068
|
+
- The label must be a short unique slug within your response.
|
|
1069
|
+
- The @ref comment line is stripped from the displayed code — the user never sees it.
|
|
1070
|
+
- 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.
|
|
1071
|
+
- Never put @ref on any line other than the very first line of the block.`;
|
|
1072
|
+
|
|
1073
|
+
// Collect non-code document file IDs for docref linking instructions
|
|
1074
|
+
const docFileEntries: Array<{ id: string; label: string }> = [];
|
|
1075
|
+
for (const [id, { label }] of fileRegistry) {
|
|
1076
|
+
if (!id.startsWith("code:")) docFileEntries.push({ id, label });
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (docFileEntries.length > 0) {
|
|
1080
|
+
system += `
|
|
1081
|
+
|
|
1082
|
+
--- Quoting From Uploaded Documents ---
|
|
1083
|
+
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:
|
|
1084
|
+
|
|
1085
|
+
[the exact passage you are citing](docref://{id}?n={encodedFileName})
|
|
1086
|
+
|
|
1087
|
+
Rules:
|
|
1088
|
+
- The link text MUST be the verbatim passage you are quoting (a sentence, phrase, or term from the document).
|
|
1089
|
+
- {id} is the file id shown in the list above (e.g. "abc123").
|
|
1090
|
+
- {encodedFileName} is the file's original name, percent-encoded (spaces → %20, etc.).
|
|
1091
|
+
- Only use docref:// for uploaded document files. Do not use it for code files.
|
|
1092
|
+
|
|
1093
|
+
Examples (illustrative only — use real ids and names from the list above):
|
|
1094
|
+
[The system shall validate all inputs before processing](docref://abc123?n=Requirements.pdf)
|
|
1095
|
+
[O(n log n) average-case complexity](docref://def456?n=Algorithm%20Notes.docx)
|
|
1096
|
+
`;
|
|
1097
|
+
}
|
|
590
1098
|
}
|
|
591
1099
|
|
|
592
1100
|
// Code snippets hand-picked by the user from the file viewer
|
|
@@ -625,7 +1133,9 @@ Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
|
625
1133
|
maxOutputTokens: selectedResponseProfile.maxOutputTokens,
|
|
626
1134
|
...(isGoogle && {
|
|
627
1135
|
providerOptions: {
|
|
628
|
-
google: {
|
|
1136
|
+
google: {
|
|
1137
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1138
|
+
},
|
|
629
1139
|
},
|
|
630
1140
|
}),
|
|
631
1141
|
system,
|
|
@@ -825,6 +1335,177 @@ app.get("/api/code-context/file", async (req, res) => {
|
|
|
825
1335
|
}
|
|
826
1336
|
});
|
|
827
1337
|
|
|
1338
|
+
// ─── Code Line Ask ──────────────────────────────────────
|
|
1339
|
+
|
|
1340
|
+
app.post("/api/code-line-ask", async (req, res) => {
|
|
1341
|
+
const {
|
|
1342
|
+
filePath,
|
|
1343
|
+
selectedCode,
|
|
1344
|
+
prompt,
|
|
1345
|
+
questionId,
|
|
1346
|
+
topicId,
|
|
1347
|
+
codeContextFiles,
|
|
1348
|
+
codeSnippets,
|
|
1349
|
+
preferenceSuffix,
|
|
1350
|
+
} = req.body;
|
|
1351
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
1352
|
+
return res.status(400).json({ error: "prompt is required" });
|
|
1353
|
+
}
|
|
1354
|
+
if (typeof selectedCode !== "string" || !selectedCode.trim()) {
|
|
1355
|
+
return res.status(400).json({ error: "selectedCode is required" });
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
try {
|
|
1359
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
1360
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
1361
|
+
);
|
|
1362
|
+
const aiSettings = await storage.getAiSettings();
|
|
1363
|
+
|
|
1364
|
+
// Build a file registry identical to /api/chat so the model has the same context
|
|
1365
|
+
const fileRegistry = new Map<
|
|
1366
|
+
string,
|
|
1367
|
+
{ label: string; reader: () => Promise<string> }
|
|
1368
|
+
>();
|
|
1369
|
+
|
|
1370
|
+
const workspaceFiles = await storage.getWorkspaceContextFiles();
|
|
1371
|
+
for (const cf of workspaceFiles) {
|
|
1372
|
+
fileRegistry.set(cf.id, {
|
|
1373
|
+
label: `[workspace] ${cf.originalName}`,
|
|
1374
|
+
reader: () => storage.readWorkspaceContextFileContent(cf.id),
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
if (topicId) {
|
|
1379
|
+
const topics = await storage.getTopics();
|
|
1380
|
+
const topic = topics.find((t: any) => t.id === topicId);
|
|
1381
|
+
if (topic?.contextFiles?.length) {
|
|
1382
|
+
for (const cf of topic.contextFiles) {
|
|
1383
|
+
fileRegistry.set(cf.id, {
|
|
1384
|
+
label: `[topic] ${cf.originalName}`,
|
|
1385
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (questionId) {
|
|
1392
|
+
const question = await storage.getQuestion(questionId);
|
|
1393
|
+
if (question?.contextFiles?.length) {
|
|
1394
|
+
for (const cf of question.contextFiles) {
|
|
1395
|
+
fileRegistry.set(cf.id, {
|
|
1396
|
+
label: `[question] ${cf.originalName}`,
|
|
1397
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
|
|
1404
|
+
for (const fp of codeContextFiles as string[]) {
|
|
1405
|
+
const fullPath = path.join(CODE_CONTEXT_DIR, fp);
|
|
1406
|
+
const resolved = path.resolve(fullPath);
|
|
1407
|
+
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
|
|
1408
|
+
fileRegistry.set(`code:${fp}`, {
|
|
1409
|
+
label: `[code] ${fp}`,
|
|
1410
|
+
reader: () => fs.readFile(resolved, "utf-8"),
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
let system =
|
|
1416
|
+
"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.";
|
|
1417
|
+
|
|
1418
|
+
// Apply the user's active response preferences (LENGTH / STYLE / AUDIENCE / etc.)
|
|
1419
|
+
if (
|
|
1420
|
+
preferenceSuffix &&
|
|
1421
|
+
typeof preferenceSuffix === "string" &&
|
|
1422
|
+
preferenceSuffix.trim()
|
|
1423
|
+
) {
|
|
1424
|
+
system += `\n\n${preferenceSuffix.trim()}`;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (fileRegistry.size > 0) {
|
|
1428
|
+
const codeFilePaths: string[] = [];
|
|
1429
|
+
for (const [id, { label }] of fileRegistry) {
|
|
1430
|
+
if (id.startsWith("code:"))
|
|
1431
|
+
codeFilePaths.push(id.slice("code:".length));
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
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`;
|
|
1435
|
+
for (const [id, { label }] of fileRegistry) {
|
|
1436
|
+
system += `• ${label} (id: "${id}")\n`;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
if (codeFilePaths.length > 0) {
|
|
1440
|
+
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.`;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
if (Array.isArray(codeSnippets) && codeSnippets.length > 0) {
|
|
1445
|
+
system += `\n\n--- Code Snippets (pinned by user) ---\n`;
|
|
1446
|
+
for (const snippet of codeSnippets as Array<{
|
|
1447
|
+
fileName: string;
|
|
1448
|
+
filePath: string;
|
|
1449
|
+
startLine: number;
|
|
1450
|
+
endLine: number;
|
|
1451
|
+
code: string;
|
|
1452
|
+
}>) {
|
|
1453
|
+
const lineLabel =
|
|
1454
|
+
snippet.startLine === snippet.endLine
|
|
1455
|
+
? `line ${snippet.startLine}`
|
|
1456
|
+
: `lines ${snippet.startLine}–${snippet.endLine}`;
|
|
1457
|
+
system += `**${snippet.fileName}** (${lineLabel}):\n\`\`\`\n${snippet.code}\n\`\`\`\n\n`;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const { text } = await generateText({
|
|
1462
|
+
model: getModel(),
|
|
1463
|
+
maxOutputTokens: 1200,
|
|
1464
|
+
...(isGoogle && {
|
|
1465
|
+
providerOptions: {
|
|
1466
|
+
google: {
|
|
1467
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1468
|
+
},
|
|
1469
|
+
},
|
|
1470
|
+
}),
|
|
1471
|
+
system,
|
|
1472
|
+
prompt: `File: ${filePath || "unknown"}\n\nHighlighted code:\n\`\`\`\n${selectedCode}\n\`\`\`\n\nQuestion: ${prompt.trim()}`,
|
|
1473
|
+
tools:
|
|
1474
|
+
fileRegistry.size > 0
|
|
1475
|
+
? {
|
|
1476
|
+
readFile: tool({
|
|
1477
|
+
description:
|
|
1478
|
+
"Read the content of an available reference file. Use this to get file contents when relevant to the question.",
|
|
1479
|
+
inputSchema: z.object({
|
|
1480
|
+
fileId: z
|
|
1481
|
+
.string()
|
|
1482
|
+
.describe(
|
|
1483
|
+
"The id of the file to read, from the available files list.",
|
|
1484
|
+
),
|
|
1485
|
+
}),
|
|
1486
|
+
execute: async ({ fileId }) => {
|
|
1487
|
+
const entry = fileRegistry.get(fileId);
|
|
1488
|
+
if (!entry) return { error: "File not found" };
|
|
1489
|
+
try {
|
|
1490
|
+
const content = await entry.reader();
|
|
1491
|
+
return { fileName: entry.label, content };
|
|
1492
|
+
} catch {
|
|
1493
|
+
return { error: "Could not read file" };
|
|
1494
|
+
}
|
|
1495
|
+
},
|
|
1496
|
+
}),
|
|
1497
|
+
}
|
|
1498
|
+
: undefined,
|
|
1499
|
+
stopWhen: stepCountIs(4),
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
res.json({ response: text });
|
|
1503
|
+
} catch (err: any) {
|
|
1504
|
+
console.error("code-line-ask error:", err?.message || err);
|
|
1505
|
+
res.status(500).json({ error: err?.message || "Failed to get response" });
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
|
|
828
1509
|
// ─── Inline Ask ─────────────────────────────────────────
|
|
829
1510
|
|
|
830
1511
|
app.post("/api/inline-ask", async (req, res) => {
|
|
@@ -837,6 +1518,7 @@ app.post("/api/inline-ask", async (req, res) => {
|
|
|
837
1518
|
responseLength,
|
|
838
1519
|
responseStyle,
|
|
839
1520
|
responseAudience,
|
|
1521
|
+
preferenceSuffix,
|
|
840
1522
|
} = req.body;
|
|
841
1523
|
if (typeof selectedText !== "string" || typeof prompt !== "string") {
|
|
842
1524
|
return res
|
|
@@ -849,6 +1531,8 @@ app.post("/api/inline-ask", async (req, res) => {
|
|
|
849
1531
|
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
850
1532
|
);
|
|
851
1533
|
|
|
1534
|
+
const aiSettings = await storage.getAiSettings();
|
|
1535
|
+
|
|
852
1536
|
// Build conversation thread context for follow-ups
|
|
853
1537
|
let threadContext = "";
|
|
854
1538
|
if (priorResponse) {
|
|
@@ -884,13 +1568,22 @@ app.post("/api/inline-ask", async (req, res) => {
|
|
|
884
1568
|
systemLines.push(styleHints[responseStyle]);
|
|
885
1569
|
if (responseAudience && audienceHints[responseAudience])
|
|
886
1570
|
systemLines.push(audienceHints[responseAudience]);
|
|
1571
|
+
// preferenceSuffix carries ALL current group selections as pre-built text
|
|
1572
|
+
if (
|
|
1573
|
+
preferenceSuffix &&
|
|
1574
|
+
typeof preferenceSuffix === "string" &&
|
|
1575
|
+
preferenceSuffix.trim()
|
|
1576
|
+
)
|
|
1577
|
+
systemLines.push(preferenceSuffix.trim());
|
|
887
1578
|
|
|
888
1579
|
const { text } = await generateText({
|
|
889
1580
|
model: getModel(),
|
|
890
1581
|
maxOutputTokens: 800,
|
|
891
1582
|
...(isGoogle && {
|
|
892
1583
|
providerOptions: {
|
|
893
|
-
google: {
|
|
1584
|
+
google: {
|
|
1585
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1586
|
+
},
|
|
894
1587
|
},
|
|
895
1588
|
}),
|
|
896
1589
|
system: systemLines.join("\n"),
|
|
@@ -927,13 +1620,13 @@ app.post("/api/fix-viz", async (req, res) => {
|
|
|
927
1620
|
|
|
928
1621
|
const { text } = await generateText({
|
|
929
1622
|
model: getModel(),
|
|
930
|
-
maxOutputTokens:
|
|
1623
|
+
maxOutputTokens: 2500,
|
|
931
1624
|
...(isGoogle && {
|
|
932
1625
|
providerOptions: {
|
|
933
1626
|
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
934
1627
|
},
|
|
935
1628
|
}),
|
|
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- YAML string quoting: if a label or value contains special characters (hyphens like --, colons, brackets, slashes, or leading/trailing spaces), wrap the ENTIRE value in double quotes. Do NOT use single quotes — single-quoted YAML strings must be self-contained; trailing words after the closing quote cause parse errors. Example of wrong: label: 'update-index --skip-worktree' changes file metadata Example of correct: label: "update-index --skip-worktree changes file metadata"\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}`,
|
|
1629
|
+
prompt: `The following viz diagram spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the spec so it renders correctly. Common issues to check:\n- x/y coordinates must be numbers, not strings\n- node ids must be kebab-case with no spaces\n- Truncated spec: if the spec looks cut off mid-label or mid-step, complete all missing steps before returning — output the full corrected spec.\n- Signal chain direction: a chain [A, B] animates along the edge FROM A TO B. That directed edge (from: A, to: B) MUST exist in the edges section. If you want to show data flowing from B to A but only have the edge A→B, either flip the chain to [A, B] (showing the reverse path) or add a new reverse edge. Never use a chain direction that has no matching directed edge.\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. These typographic Unicode characters cause the YAML parser to fail with "Missing closing quote" errors.\n- YAML string quoting: if a label or value contains special characters (hyphens like --, colons, brackets, slashes, or leading/trailing spaces), wrap the ENTIRE value in double quotes. Do NOT use single quotes — single-quoted YAML strings must be self-contained; trailing words after the closing quote cause parse errors. Example of wrong: label: 'update-index --skip-worktree' changes file metadata Example of correct: label: "update-index --skip-worktree changes file metadata"\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
1630
|
});
|
|
938
1631
|
|
|
939
1632
|
// Strip any fences the model might have added
|
|
@@ -949,6 +1642,70 @@ app.post("/api/fix-viz", async (req, res) => {
|
|
|
949
1642
|
}
|
|
950
1643
|
});
|
|
951
1644
|
|
|
1645
|
+
// ─── Refine Viz ─────────────────────────────────────────
|
|
1646
|
+
|
|
1647
|
+
app.post("/api/refine-viz", async (req, res) => {
|
|
1648
|
+
const { spec, prompt: userPrompt, history } = req.body;
|
|
1649
|
+
if (typeof spec !== "string" || !spec.trim()) {
|
|
1650
|
+
return res.status(400).json({ error: "spec is required" });
|
|
1651
|
+
}
|
|
1652
|
+
if (typeof userPrompt !== "string" || !userPrompt.trim()) {
|
|
1653
|
+
return res.status(400).json({ error: "prompt is required" });
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
try {
|
|
1657
|
+
const { vizGuide } = await storage.getAiSettings();
|
|
1658
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
1659
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
1660
|
+
);
|
|
1661
|
+
|
|
1662
|
+
const historyContext =
|
|
1663
|
+
Array.isArray(history) && history.length
|
|
1664
|
+
? history
|
|
1665
|
+
.map(
|
|
1666
|
+
(h: { prompt: string; spec: string }, i: number) =>
|
|
1667
|
+
`[Refinement ${i + 1}] User asked: "${h.prompt}"\nResulting spec:\n${h.spec}`,
|
|
1668
|
+
)
|
|
1669
|
+
.join("\n\n---\n\n") + "\n\n---\n\n"
|
|
1670
|
+
: "";
|
|
1671
|
+
|
|
1672
|
+
const prompt = `You are editing a VizCraft diagram spec.${vizGuide ? `\n\n${vizGuide}` : ""}${historyContext ? `\nPrior refinements applied to this diagram:\n\n${historyContext}` : ""}
|
|
1673
|
+
Current spec:
|
|
1674
|
+
${spec}
|
|
1675
|
+
|
|
1676
|
+
User instruction: ${userPrompt}
|
|
1677
|
+
|
|
1678
|
+
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.
|
|
1679
|
+
Rules:
|
|
1680
|
+
- Preserve all existing structure unless the user explicitly asks to remove something
|
|
1681
|
+
- If adding nodes, pick sensible x/y coordinates that fit the existing layout
|
|
1682
|
+
- YAML string quoting: wrap values with special characters (hyphens like --, colons, brackets) in double quotes, never single quotes
|
|
1683
|
+
- Node ids must be kebab-case with no spaces
|
|
1684
|
+
- Return the same format as the input (YAML or JSON)`;
|
|
1685
|
+
|
|
1686
|
+
const { text } = await generateText({
|
|
1687
|
+
model: getModel(),
|
|
1688
|
+
maxOutputTokens: 1600,
|
|
1689
|
+
...(isGoogle && {
|
|
1690
|
+
providerOptions: {
|
|
1691
|
+
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
1692
|
+
},
|
|
1693
|
+
}),
|
|
1694
|
+
prompt,
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
const refined = text
|
|
1698
|
+
.replace(/^```(?:yaml|json|viz)?\s*/i, "")
|
|
1699
|
+
.replace(/```\s*$/, "")
|
|
1700
|
+
.trim();
|
|
1701
|
+
|
|
1702
|
+
res.json({ spec: refined });
|
|
1703
|
+
} catch (err: any) {
|
|
1704
|
+
console.error("refine-viz error:", err?.message || err);
|
|
1705
|
+
res.status(500).json({ error: err?.message || "Failed to refine viz" });
|
|
1706
|
+
}
|
|
1707
|
+
});
|
|
1708
|
+
|
|
952
1709
|
// ─── Fix Diagram ────────────────────────────────────────
|
|
953
1710
|
|
|
954
1711
|
app.post("/api/fix-diagram", async (req, res) => {
|
|
@@ -987,6 +1744,310 @@ app.post("/api/fix-diagram", async (req, res) => {
|
|
|
987
1744
|
}
|
|
988
1745
|
});
|
|
989
1746
|
|
|
1747
|
+
// ─── Code Runner ────────────────────────────────────────────
|
|
1748
|
+
|
|
1749
|
+
const RUN_TIMEOUT_MS = 10_000;
|
|
1750
|
+
|
|
1751
|
+
app.post("/api/run-code", async (req, res) => {
|
|
1752
|
+
const { code, language = "typescript" } = req.body as {
|
|
1753
|
+
code: string;
|
|
1754
|
+
language?: string;
|
|
1755
|
+
};
|
|
1756
|
+
|
|
1757
|
+
if (typeof code !== "string" || code.trim().length === 0) {
|
|
1758
|
+
return res.status(400).json({ error: "No code provided" });
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// Transpile TS → JS if needed (ts.transpileModule is a zero-cost in-process call)
|
|
1762
|
+
let jsCode: string;
|
|
1763
|
+
try {
|
|
1764
|
+
if (language === "typescript") {
|
|
1765
|
+
const result = ts.transpileModule(code, {
|
|
1766
|
+
compilerOptions: {
|
|
1767
|
+
target: ts.ScriptTarget.ESNext,
|
|
1768
|
+
module: ts.ModuleKind.ESNext,
|
|
1769
|
+
strict: false,
|
|
1770
|
+
esModuleInterop: true,
|
|
1771
|
+
},
|
|
1772
|
+
});
|
|
1773
|
+
jsCode = result.outputText;
|
|
1774
|
+
} else {
|
|
1775
|
+
jsCode = code;
|
|
1776
|
+
}
|
|
1777
|
+
} catch (transpileErr: any) {
|
|
1778
|
+
return res.json({
|
|
1779
|
+
stdout: "",
|
|
1780
|
+
stderr: String(transpileErr?.message ?? transpileErr),
|
|
1781
|
+
durationMs: 0,
|
|
1782
|
+
timedOut: false,
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// Write to a temp .mjs file and run it as a child process
|
|
1787
|
+
const tmpFile = path.join(os.tmpdir(), `runner-${randomUUID()}.mjs`);
|
|
1788
|
+
try {
|
|
1789
|
+
await fs.writeFile(tmpFile, jsCode, "utf8");
|
|
1790
|
+
} catch {
|
|
1791
|
+
return res.status(500).json({ error: "Failed to write temp file" });
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
const stdoutLines: string[] = [];
|
|
1795
|
+
const stderrLines: string[] = [];
|
|
1796
|
+
let timedOut = false;
|
|
1797
|
+
const start = Date.now();
|
|
1798
|
+
|
|
1799
|
+
await new Promise<void>((resolve) => {
|
|
1800
|
+
const child = spawn(process.execPath, [tmpFile], {
|
|
1801
|
+
timeout: RUN_TIMEOUT_MS,
|
|
1802
|
+
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
const killTimer = setTimeout(() => {
|
|
1806
|
+
timedOut = true;
|
|
1807
|
+
child.kill("SIGKILL");
|
|
1808
|
+
resolve();
|
|
1809
|
+
}, RUN_TIMEOUT_MS);
|
|
1810
|
+
|
|
1811
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
1812
|
+
stdoutLines.push(chunk.toString());
|
|
1813
|
+
});
|
|
1814
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
1815
|
+
stderrLines.push(chunk.toString());
|
|
1816
|
+
});
|
|
1817
|
+
child.on("close", () => {
|
|
1818
|
+
clearTimeout(killTimer);
|
|
1819
|
+
resolve();
|
|
1820
|
+
});
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
// Clean up temp file (best-effort)
|
|
1824
|
+
fs.unlink(tmpFile).catch(() => {});
|
|
1825
|
+
|
|
1826
|
+
res.json({
|
|
1827
|
+
stdout: stdoutLines.join(""),
|
|
1828
|
+
stderr: stderrLines.join(""),
|
|
1829
|
+
durationMs: Date.now() - start,
|
|
1830
|
+
timedOut,
|
|
1831
|
+
});
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
// ─── Sandbox: persistent server + client runner ────────────
|
|
1835
|
+
|
|
1836
|
+
interface SandboxEntry {
|
|
1837
|
+
child: import("child_process").ChildProcess;
|
|
1838
|
+
port: number;
|
|
1839
|
+
tmpFile: string;
|
|
1840
|
+
logs: string[];
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const sandboxes = new Map<string, SandboxEntry>();
|
|
1844
|
+
const SANDBOX_DIR = path.join(__dirname, "..", ".sandbox-tmp");
|
|
1845
|
+
|
|
1846
|
+
async function getFreePort(): Promise<number> {
|
|
1847
|
+
return new Promise((resolve, reject) => {
|
|
1848
|
+
const srv = net.createServer();
|
|
1849
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
1850
|
+
const addr = srv.address() as net.AddressInfo;
|
|
1851
|
+
srv.close(() => resolve(addr.port));
|
|
1852
|
+
});
|
|
1853
|
+
srv.on("error", reject);
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function transpileToJs(code: string, language: string): string {
|
|
1858
|
+
if (language === "typescript") {
|
|
1859
|
+
return ts.transpileModule(code, {
|
|
1860
|
+
compilerOptions: {
|
|
1861
|
+
target: ts.ScriptTarget.ESNext,
|
|
1862
|
+
module: ts.ModuleKind.ESNext,
|
|
1863
|
+
strict: false,
|
|
1864
|
+
esModuleInterop: true,
|
|
1865
|
+
},
|
|
1866
|
+
}).outputText;
|
|
1867
|
+
}
|
|
1868
|
+
return code;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
app.post("/api/sandbox/start", async (req, res) => {
|
|
1872
|
+
const { serverCode, language = "typescript" } = req.body as {
|
|
1873
|
+
serverCode: string;
|
|
1874
|
+
language?: string;
|
|
1875
|
+
};
|
|
1876
|
+
if (typeof serverCode !== "string" || !serverCode.trim()) {
|
|
1877
|
+
return res.status(400).json({ error: "serverCode is required" });
|
|
1878
|
+
}
|
|
1879
|
+
let jsCode: string;
|
|
1880
|
+
try {
|
|
1881
|
+
jsCode = transpileToJs(serverCode, language);
|
|
1882
|
+
} catch (err: any) {
|
|
1883
|
+
return res.status(400).json({ error: err?.message ?? String(err) });
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
const port = await getFreePort();
|
|
1887
|
+
const id = randomUUID();
|
|
1888
|
+
await fs.mkdir(SANDBOX_DIR, { recursive: true });
|
|
1889
|
+
const tmpFile = path.join(SANDBOX_DIR, `${id}.mjs`);
|
|
1890
|
+
await fs.writeFile(tmpFile, jsCode, "utf8");
|
|
1891
|
+
|
|
1892
|
+
const logs: string[] = [];
|
|
1893
|
+
const child = spawn(process.execPath, [tmpFile], {
|
|
1894
|
+
env: { ...process.env, PORT: String(port), NODE_NO_WARNINGS: "1" },
|
|
1895
|
+
});
|
|
1896
|
+
child.stdout.on("data", (chunk: Buffer) => logs.push(chunk.toString()));
|
|
1897
|
+
child.stderr.on("data", (chunk: Buffer) => logs.push(chunk.toString()));
|
|
1898
|
+
child.on("exit", () => {
|
|
1899
|
+
sandboxes.delete(id);
|
|
1900
|
+
fs.unlink(tmpFile).catch(() => {});
|
|
1901
|
+
});
|
|
1902
|
+
sandboxes.set(id, { child, port, tmpFile, logs });
|
|
1903
|
+
|
|
1904
|
+
// Give the server a moment to bind (or fail)
|
|
1905
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
1906
|
+
if (!sandboxes.has(id)) {
|
|
1907
|
+
return res.status(500).json({
|
|
1908
|
+
error: logs.join("").trim() || "Server exited immediately",
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
res.json({ sandboxId: id, port, sandboxUrl: `http://localhost:${port}` });
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
app.get("/api/sandbox/:id/status", (req, res) => {
|
|
1915
|
+
const sb = sandboxes.get(req.params.id);
|
|
1916
|
+
if (!sb) return res.json({ running: false, logs: [] });
|
|
1917
|
+
res.json({ running: true, port: sb.port, logs: sb.logs });
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
app.delete("/api/sandbox/:id", (req, res) => {
|
|
1921
|
+
const sb = sandboxes.get(req.params.id);
|
|
1922
|
+
if (sb) {
|
|
1923
|
+
sb.child.kill("SIGTERM");
|
|
1924
|
+
sandboxes.delete(req.params.id);
|
|
1925
|
+
fs.unlink(sb.tmpFile).catch(() => {});
|
|
1926
|
+
}
|
|
1927
|
+
res.json({ ok: true });
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
app.post("/api/sandbox/run-client", async (req, res) => {
|
|
1931
|
+
const {
|
|
1932
|
+
code,
|
|
1933
|
+
language = "javascript",
|
|
1934
|
+
sandboxUrl,
|
|
1935
|
+
} = req.body as {
|
|
1936
|
+
code: string;
|
|
1937
|
+
language?: string;
|
|
1938
|
+
sandboxUrl: string;
|
|
1939
|
+
};
|
|
1940
|
+
if (typeof code !== "string" || !code.trim()) {
|
|
1941
|
+
return res.status(400).json({ error: "code is required" });
|
|
1942
|
+
}
|
|
1943
|
+
const eventSourcePolyfill = `
|
|
1944
|
+
if (typeof EventSource === 'undefined') {
|
|
1945
|
+
const { default: _http } = await import('http');
|
|
1946
|
+
const { default: _https } = await import('https');
|
|
1947
|
+
const { EventEmitter: _EE } = await import('events');
|
|
1948
|
+
globalThis.EventSource = class EventSource extends _EE {
|
|
1949
|
+
constructor(url) {
|
|
1950
|
+
super();
|
|
1951
|
+
// Resolve relative URLs against SANDBOX_URL (e.g. '/api/foo' → 'http://localhost:PORT/api/foo')
|
|
1952
|
+
const _resolved = (url.startsWith('http://') || url.startsWith('https://'))
|
|
1953
|
+
? url
|
|
1954
|
+
: SANDBOX_URL + (url.startsWith('/') ? url : '/' + url);
|
|
1955
|
+
this.url = _resolved; this.readyState = 0;
|
|
1956
|
+
const _mod = _resolved.startsWith('https') ? _https : _http;
|
|
1957
|
+
this._req = _mod.get(_resolved, { headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' } }, (res) => {
|
|
1958
|
+
this.readyState = 1;
|
|
1959
|
+
let _buf = '';
|
|
1960
|
+
res.on('data', (chunk) => {
|
|
1961
|
+
_buf += chunk.toString();
|
|
1962
|
+
const _msgs = _buf.split('\\n\\n');
|
|
1963
|
+
_buf = _msgs.pop() ?? '';
|
|
1964
|
+
for (const _b of _msgs) {
|
|
1965
|
+
if (!_b.trim()) continue;
|
|
1966
|
+
let _data = '', _type = 'message';
|
|
1967
|
+
for (const _l of _b.split('\\n')) {
|
|
1968
|
+
if (_l.startsWith('data:')) _data += (_data ? '\\n' : '') + _l.slice(5).trimStart();
|
|
1969
|
+
else if (_l.startsWith('event:')) _type = _l.slice(6).trimStart();
|
|
1970
|
+
}
|
|
1971
|
+
const _e = { type: _type, data: _data };
|
|
1972
|
+
if (_type === 'message' && this.onmessage) this.onmessage(_e);
|
|
1973
|
+
this.emit(_type, _e);
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1976
|
+
res.on('end', () => { this.readyState = 2; });
|
|
1977
|
+
});
|
|
1978
|
+
this._req.on('error', () => { this.readyState = 2; });
|
|
1979
|
+
}
|
|
1980
|
+
close() { try { this._req?.destroy(); } catch {} this.readyState = 2; }
|
|
1981
|
+
addEventListener(t, fn) { this.on(t, fn); }
|
|
1982
|
+
removeEventListener(t, fn) { this.off(t, fn); }
|
|
1983
|
+
};
|
|
1984
|
+
}`;
|
|
1985
|
+
const wrapped = `const SANDBOX_URL = ${JSON.stringify(sandboxUrl ?? "")};
|
|
1986
|
+
${eventSourcePolyfill}
|
|
1987
|
+
${code}`;
|
|
1988
|
+
let jsCode: string;
|
|
1989
|
+
try {
|
|
1990
|
+
jsCode = transpileToJs(wrapped, language);
|
|
1991
|
+
} catch (err: any) {
|
|
1992
|
+
return res.json({
|
|
1993
|
+
stdout: "",
|
|
1994
|
+
stderr: String(err?.message ?? err),
|
|
1995
|
+
durationMs: 0,
|
|
1996
|
+
timedOut: false,
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// Switch to SSE streaming so each log line reaches the browser immediately
|
|
2001
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
2002
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2003
|
+
res.setHeader("Connection", "keep-alive");
|
|
2004
|
+
res.flushHeaders();
|
|
2005
|
+
|
|
2006
|
+
const sendEvent = (kind: string, text: string) => {
|
|
2007
|
+
res.write(`data: ${JSON.stringify({ kind, text })}\n\n`);
|
|
2008
|
+
};
|
|
2009
|
+
|
|
2010
|
+
const tmpFile = path.join(os.tmpdir(), `sandbox-client-${randomUUID()}.mjs`);
|
|
2011
|
+
await fs.writeFile(tmpFile, jsCode, "utf8");
|
|
2012
|
+
|
|
2013
|
+
let timedOut = false;
|
|
2014
|
+
const start = Date.now();
|
|
2015
|
+
|
|
2016
|
+
await new Promise<void>((resolve) => {
|
|
2017
|
+
const child = spawn(process.execPath, [tmpFile], {
|
|
2018
|
+
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
|
2019
|
+
});
|
|
2020
|
+
const killTimer = setTimeout(() => {
|
|
2021
|
+
timedOut = true;
|
|
2022
|
+
child.kill("SIGKILL");
|
|
2023
|
+
resolve();
|
|
2024
|
+
}, RUN_TIMEOUT_MS);
|
|
2025
|
+
|
|
2026
|
+
// Stream each chunk as it arrives — split on newlines so each log line is its own event
|
|
2027
|
+
const pushChunks = (chunk: Buffer, kind: "stdout" | "stderr") => {
|
|
2028
|
+
chunk
|
|
2029
|
+
.toString()
|
|
2030
|
+
.split("\n")
|
|
2031
|
+
.forEach((line) => {
|
|
2032
|
+
if (line !== "") sendEvent(kind, line);
|
|
2033
|
+
});
|
|
2034
|
+
};
|
|
2035
|
+
child.stdout.on("data", (chunk: Buffer) => pushChunks(chunk, "stdout"));
|
|
2036
|
+
child.stderr.on("data", (chunk: Buffer) => pushChunks(chunk, "stderr"));
|
|
2037
|
+
child.on("close", (code) => {
|
|
2038
|
+
clearTimeout(killTimer);
|
|
2039
|
+
resolve();
|
|
2040
|
+
});
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
fs.unlink(tmpFile).catch(() => {});
|
|
2044
|
+
sendEvent(
|
|
2045
|
+
"done",
|
|
2046
|
+
JSON.stringify({ timedOut, durationMs: Date.now() - start }),
|
|
2047
|
+
);
|
|
2048
|
+
res.end();
|
|
2049
|
+
});
|
|
2050
|
+
|
|
990
2051
|
// ─── Start ───────────────────────────────────────────────
|
|
991
2052
|
|
|
992
2053
|
(async () => {
|