create-interview-cockpit 0.3.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/README.md +23 -0
- package/package.json +1 -1
- package/template/client/package-lock.json +42 -0
- package/template/client/package.json +5 -0
- package/template/client/src/App.tsx +45 -12
- package/template/client/src/api.ts +174 -0
- package/template/client/src/components/AiSettingsModal.tsx +1041 -0
- 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 +239 -137
- 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 +210 -2
- package/template/client/src/components/Sidebar.tsx +213 -125
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +645 -0
- package/template/client/src/store.ts +275 -0
- package/template/client/src/types.ts +9 -0
- package/template/cockpit.json +1 -1
- package/template/data/ai-settings.json +49 -0
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1187 -76
- package/template/server/src/storage.ts +359 -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,6 +635,292 @@ 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
|
+
|
|
876
|
+
// ─── AI Settings ────────────────────────────────────────────
|
|
877
|
+
|
|
878
|
+
app.get("/api/settings", async (_req, res) => {
|
|
879
|
+
const settings = await storage.getAiSettings();
|
|
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
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
app.patch("/api/settings", async (req, res) => {
|
|
889
|
+
const current = await storage.getAiSettings();
|
|
890
|
+
const updated: storage.AiSettings = {
|
|
891
|
+
systemPrompt:
|
|
892
|
+
typeof req.body.systemPrompt === "string"
|
|
893
|
+
? req.body.systemPrompt
|
|
894
|
+
: current.systemPrompt,
|
|
895
|
+
responseProfiles:
|
|
896
|
+
req.body.responseProfiles != null
|
|
897
|
+
? req.body.responseProfiles
|
|
898
|
+
: current.responseProfiles,
|
|
899
|
+
vizGuide:
|
|
900
|
+
typeof req.body.vizGuide === "string"
|
|
901
|
+
? req.body.vizGuide
|
|
902
|
+
: current.vizGuide,
|
|
903
|
+
promptGroups:
|
|
904
|
+
req.body.promptGroups != null
|
|
905
|
+
? req.body.promptGroups
|
|
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,
|
|
915
|
+
};
|
|
916
|
+
await storage.saveAiSettings(updated);
|
|
917
|
+
res.json({
|
|
918
|
+
...updated,
|
|
919
|
+
provider: (process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
920
|
+
model: process.env.AI_MODEL || "",
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
|
|
450
924
|
// ─── Chat ────────────────────────────────────────────────
|
|
451
925
|
|
|
452
926
|
app.post("/api/chat", async (req, res) => {
|
|
@@ -462,38 +936,12 @@ app.post("/api/chat", async (req, res) => {
|
|
|
462
936
|
responseLength,
|
|
463
937
|
} = req.body;
|
|
464
938
|
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
concise: {
|
|
470
|
-
maxOutputTokens: 1000,
|
|
471
|
-
maxSteps: 3,
|
|
472
|
-
},
|
|
473
|
-
moderate: {
|
|
474
|
-
maxOutputTokens: 1000,
|
|
475
|
-
maxSteps: 5,
|
|
476
|
-
},
|
|
477
|
-
normal: {
|
|
478
|
-
maxOutputTokens: 3000,
|
|
479
|
-
maxSteps: 5,
|
|
480
|
-
},
|
|
481
|
-
};
|
|
482
|
-
const selectedResponseProfile =
|
|
483
|
-
responseProfiles[responseLength] || responseProfiles.normal;
|
|
484
|
-
|
|
485
|
-
let system = `You are a senior engineering interview coach.
|
|
486
|
-
|
|
487
|
-
Highest priority: follow the user's explicit response preferences and current conversation context. If they conflict with your default teaching behavior, the user's preference wins.
|
|
488
|
-
Explain clearly, accurately, and practically.
|
|
489
|
-
Only include Mermaid diagrams, code blocks, or tables when the user explicitly asks for them or when they materially improve the answer.
|
|
490
|
-
If you show code, use a fenced code block with the correct language.
|
|
939
|
+
const aiSettings = await storage.getAiSettings();
|
|
940
|
+
const { responseProfiles, vizGuide } = aiSettings;
|
|
941
|
+
const selectedResponseProfile = responseProfiles[responseLength] ??
|
|
942
|
+
responseProfiles["normal"] ?? { maxOutputTokens: 3000, maxSteps: 5 };
|
|
491
943
|
|
|
492
|
-
|
|
493
|
-
- Wrap node labels in quotes when they contain special characters: A["Microservice A (Producer)"]
|
|
494
|
-
- Edge labels use |text| syntax: A -->|sends message| B
|
|
495
|
-
- Never put parentheses or brackets inside [] without quoting the label
|
|
496
|
-
- Use simple node IDs (letters/numbers) and put descriptive text in the label`;
|
|
944
|
+
let system = aiSettings.systemPrompt;
|
|
497
945
|
|
|
498
946
|
if (topicTitle || questionTitle) {
|
|
499
947
|
system += `\n\n--- Current Context ---`;
|
|
@@ -512,6 +960,15 @@ Mermaid syntax rules (follow strictly):
|
|
|
512
960
|
{ label: string; reader: () => Promise<string> }
|
|
513
961
|
>();
|
|
514
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
|
+
|
|
515
972
|
// Topic-level uploaded files
|
|
516
973
|
if (topicId) {
|
|
517
974
|
const topics = await storage.getTopics();
|
|
@@ -582,6 +1039,62 @@ Examples:
|
|
|
582
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.
|
|
583
1040
|
Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
584
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
|
+
}
|
|
585
1098
|
}
|
|
586
1099
|
|
|
587
1100
|
// Code snippets hand-picked by the user from the file viewer
|
|
@@ -620,13 +1133,21 @@ Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
|
620
1133
|
maxOutputTokens: selectedResponseProfile.maxOutputTokens,
|
|
621
1134
|
...(isGoogle && {
|
|
622
1135
|
providerOptions: {
|
|
623
|
-
google: {
|
|
1136
|
+
google: {
|
|
1137
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1138
|
+
},
|
|
624
1139
|
},
|
|
625
1140
|
}),
|
|
626
1141
|
system,
|
|
627
1142
|
messages: modelMessages,
|
|
628
|
-
tools:
|
|
629
|
-
|
|
1143
|
+
tools: {
|
|
1144
|
+
getVizGuide: tool({
|
|
1145
|
+
description:
|
|
1146
|
+
"Get the full viz diagram spec reference. Call this before writing a ```viz block so you have the correct schema, rules, and examples.",
|
|
1147
|
+
inputSchema: z.object({}),
|
|
1148
|
+
execute: async () => ({ guide: vizGuide }),
|
|
1149
|
+
}),
|
|
1150
|
+
...(fileRegistry.size > 0
|
|
630
1151
|
? {
|
|
631
1152
|
readFile: tool({
|
|
632
1153
|
description:
|
|
@@ -650,7 +1171,8 @@ Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
|
650
1171
|
},
|
|
651
1172
|
}),
|
|
652
1173
|
}
|
|
653
|
-
:
|
|
1174
|
+
: {}),
|
|
1175
|
+
},
|
|
654
1176
|
stopWhen: stepCountIs(selectedResponseProfile.maxSteps),
|
|
655
1177
|
});
|
|
656
1178
|
|
|
@@ -666,6 +1188,7 @@ Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
|
666
1188
|
id: message.id || randomUUID(),
|
|
667
1189
|
role: message.role,
|
|
668
1190
|
content: getTextFromUIMessage(message),
|
|
1191
|
+
...(Array.isArray(message.parts) ? { parts: message.parts } : {}),
|
|
669
1192
|
}));
|
|
670
1193
|
|
|
671
1194
|
await storage.updateQuestionMessages(questionId, normalized);
|
|
@@ -812,6 +1335,177 @@ app.get("/api/code-context/file", async (req, res) => {
|
|
|
812
1335
|
}
|
|
813
1336
|
});
|
|
814
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
|
+
|
|
815
1509
|
// ─── Inline Ask ─────────────────────────────────────────
|
|
816
1510
|
|
|
817
1511
|
app.post("/api/inline-ask", async (req, res) => {
|
|
@@ -824,6 +1518,7 @@ app.post("/api/inline-ask", async (req, res) => {
|
|
|
824
1518
|
responseLength,
|
|
825
1519
|
responseStyle,
|
|
826
1520
|
responseAudience,
|
|
1521
|
+
preferenceSuffix,
|
|
827
1522
|
} = req.body;
|
|
828
1523
|
if (typeof selectedText !== "string" || typeof prompt !== "string") {
|
|
829
1524
|
return res
|
|
@@ -836,6 +1531,8 @@ app.post("/api/inline-ask", async (req, res) => {
|
|
|
836
1531
|
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
837
1532
|
);
|
|
838
1533
|
|
|
1534
|
+
const aiSettings = await storage.getAiSettings();
|
|
1535
|
+
|
|
839
1536
|
// Build conversation thread context for follow-ups
|
|
840
1537
|
let threadContext = "";
|
|
841
1538
|
if (priorResponse) {
|
|
@@ -871,13 +1568,22 @@ app.post("/api/inline-ask", async (req, res) => {
|
|
|
871
1568
|
systemLines.push(styleHints[responseStyle]);
|
|
872
1569
|
if (responseAudience && audienceHints[responseAudience])
|
|
873
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());
|
|
874
1578
|
|
|
875
1579
|
const { text } = await generateText({
|
|
876
1580
|
model: getModel(),
|
|
877
1581
|
maxOutputTokens: 800,
|
|
878
1582
|
...(isGoogle && {
|
|
879
1583
|
providerOptions: {
|
|
880
|
-
google: {
|
|
1584
|
+
google: {
|
|
1585
|
+
thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
|
|
1586
|
+
},
|
|
881
1587
|
},
|
|
882
1588
|
}),
|
|
883
1589
|
system: systemLines.join("\n"),
|
|
@@ -899,6 +1605,107 @@ Their question: "${prompt}"`,
|
|
|
899
1605
|
}
|
|
900
1606
|
});
|
|
901
1607
|
|
|
1608
|
+
// ─── Fix Viz ────────────────────────────────────────────
|
|
1609
|
+
|
|
1610
|
+
app.post("/api/fix-viz", async (req, res) => {
|
|
1611
|
+
const { spec, error: renderError } = req.body;
|
|
1612
|
+
if (typeof spec !== "string" || !spec.trim()) {
|
|
1613
|
+
return res.status(400).json({ error: "spec is required" });
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
try {
|
|
1617
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
1618
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
1619
|
+
);
|
|
1620
|
+
|
|
1621
|
+
const { text } = await generateText({
|
|
1622
|
+
model: getModel(),
|
|
1623
|
+
maxOutputTokens: 2500,
|
|
1624
|
+
...(isGoogle && {
|
|
1625
|
+
providerOptions: {
|
|
1626
|
+
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
1627
|
+
},
|
|
1628
|
+
}),
|
|
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}`,
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
// Strip any fences the model might have added
|
|
1633
|
+
const fixed = text
|
|
1634
|
+
.replace(/^```(?:yaml|json|viz)?\s*/i, "")
|
|
1635
|
+
.replace(/```\s*$/, "")
|
|
1636
|
+
.trim();
|
|
1637
|
+
|
|
1638
|
+
res.json({ spec: fixed });
|
|
1639
|
+
} catch (err: any) {
|
|
1640
|
+
console.error("fix-viz error:", err?.message || err);
|
|
1641
|
+
res.status(500).json({ error: err?.message || "Failed to fix viz" });
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
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
|
+
|
|
902
1709
|
// ─── Fix Diagram ────────────────────────────────────────
|
|
903
1710
|
|
|
904
1711
|
app.post("/api/fix-diagram", async (req, res) => {
|
|
@@ -937,6 +1744,310 @@ app.post("/api/fix-diagram", async (req, res) => {
|
|
|
937
1744
|
}
|
|
938
1745
|
});
|
|
939
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
|
+
|
|
940
2051
|
// ─── Start ───────────────────────────────────────────────
|
|
941
2052
|
|
|
942
2053
|
(async () => {
|