create-interview-cockpit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +753 -1
  3. package/template/client/package.json +4 -0
  4. package/template/client/src/App.tsx +20 -0
  5. package/template/client/src/api.ts +455 -3
  6. package/template/client/src/components/AiSettingsModal.tsx +855 -248
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +132 -27
  9. package/template/client/src/components/ChatView.tsx +365 -123
  10. package/template/client/src/components/CodeContextPanel.tsx +714 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
  13. package/template/client/src/components/DocRefModal.tsx +551 -0
  14. package/template/client/src/components/FileAttachments.tsx +128 -12
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  18. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  19. package/template/client/src/components/MarkdownRenderer.tsx +219 -2
  20. package/template/client/src/components/NotesModal.tsx +977 -0
  21. package/template/client/src/components/PlotEmbed.tsx +173 -0
  22. package/template/client/src/components/Sidebar.tsx +397 -127
  23. package/template/client/src/components/TextAnnotator.tsx +8 -15
  24. package/template/client/src/components/VizCraftEmbed.tsx +412 -25
  25. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  26. package/template/client/src/infraLab.ts +124 -0
  27. package/template/client/src/reactLab.ts +477 -0
  28. package/template/client/src/store.ts +416 -2
  29. package/template/client/src/types.ts +41 -1
  30. package/template/client/tsconfig.tsbuildinfo +1 -1
  31. package/template/cockpit.json +1 -1
  32. package/template/package.json +1 -1
  33. package/template/server/src/google-drive.ts +144 -2
  34. package/template/server/src/index.ts +1890 -188
  35. package/template/server/src/infra-runner.ts +1104 -0
  36. package/template/server/src/storage.ts +274 -3
@@ -24,41 +24,88 @@ import { createGoogleGenerativeAI } from "@ai-sdk/google";
24
24
  import { createAnthropic } from "@ai-sdk/anthropic";
25
25
  import { randomUUID } from "crypto";
26
26
  import fs from "fs/promises";
27
+ import os from "os";
28
+ import { spawn } from "child_process";
29
+ import net from "net";
30
+ import ts from "typescript";
27
31
  import * as storage from "./storage.js";
28
32
  import * as googleDrive from "./google-drive.js";
33
+ import {
34
+ getInfraRun,
35
+ listInfraRuns,
36
+ runInfraAction,
37
+ streamInfraCommand,
38
+ } from "./infra-runner.js";
29
39
 
30
40
  const app = express();
31
41
  app.use(cors());
32
42
  app.use(express.json({ limit: "25mb" }));
33
43
 
34
44
  const upload = multer({
35
- limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max
45
+ limits: { fileSize: 20 * 1024 * 1024 }, // 20MB max
36
46
  fileFilter: (_req, file, cb) => {
37
- const allowed =
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
- if (allowed.test(file.originalname) || file.mimetype.startsWith("text/")) {
40
- cb(null, true);
47
+ // Block only clearly non-text binary formats (executables, archives, etc.)
48
+ const blockedExt =
49
+ /\.(exe|dll|so|dylib|bin|zip|tar|gz|bz2|rar|7z|dmg|iso|mp3|mp4|avi|mov|wmv|flac|wav|class|pyc|wasm)$/i;
50
+ if (blockedExt.test(file.originalname)) {
51
+ cb(new Error(`Unsupported file type: ${file.originalname}`));
41
52
  } else {
42
- cb(new Error("Unsupported file type"));
53
+ // Accept everything else — extractText handles unknown extensions gracefully
54
+ cb(null, true);
43
55
  }
44
56
  },
45
57
  });
46
58
 
47
- // Extract text from uploaded files (docx, pdf, or plain text)
59
+ // Extract text from uploaded files (docx, pdf, plain text, or images)
48
60
  async function extractText(buffer: Buffer, filename: string): Promise<string> {
49
61
  const ext = filename.split(".").pop()?.toLowerCase();
50
62
  if (ext === "docx") {
51
- const result = await mammoth.extractRawText({ buffer });
52
- return result.value;
63
+ try {
64
+ const result = await mammoth.extractRawText({ buffer });
65
+ return result.value;
66
+ } catch (e: any) {
67
+ return `[DOCX extraction failed: ${e?.message ?? "unknown error"}]`;
68
+ }
53
69
  }
54
70
  if (ext === "pdf") {
55
- const parser = new PDFParse({ data: buffer });
56
- const result = await parser.getText();
57
- return result.text;
71
+ try {
72
+ const parser = new PDFParse({ data: buffer });
73
+ const result = await parser.getText();
74
+ return result.text ?? "";
75
+ } catch (e: any) {
76
+ return `[PDF extraction failed: ${e?.message ?? "unknown error"}. The original file is stored and can be downloaded.]`;
77
+ }
78
+ }
79
+ const imageExts = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
80
+ if (ext && imageExts.has(ext)) {
81
+ // Images can't be read as text; store original for download.
82
+ // Return a descriptor so the LLM knows the file exists.
83
+ return `[Image file: ${filename} — the original is stored and available for download, but cannot be read as text by the AI.]`;
58
84
  }
59
85
  return buffer.toString("utf-8");
60
86
  }
61
87
 
88
+ function mimeForFilename(filename: string): string {
89
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
90
+ const map: Record<string, string> = {
91
+ pdf: "application/pdf",
92
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
93
+ doc: "application/msword",
94
+ json: "application/json",
95
+ csv: "text/csv",
96
+ html: "text/html; charset=utf-8",
97
+ xml: "application/xml",
98
+ zip: "application/zip",
99
+ png: "image/png",
100
+ jpg: "image/jpeg",
101
+ jpeg: "image/jpeg",
102
+ gif: "image/gif",
103
+ webp: "image/webp",
104
+ svg: "image/svg+xml",
105
+ };
106
+ return map[ext] ?? "application/octet-stream";
107
+ }
108
+
62
109
  const PORT = process.env.PORT || 3001;
63
110
  const CODE_CONTEXT_DIR = process.env.CODE_CONTEXT_DIR || "";
64
111
 
@@ -185,12 +232,24 @@ app.post("/api/workspaces/:id/drive-subfolders", async (req, res) => {
185
232
  const { name } = req.body as { name?: string };
186
233
  if (!name?.trim()) return res.status(400).json({ error: "name required" });
187
234
  try {
235
+ if (!(await googleDrive.isExportAuthed())) {
236
+ return res.json({
237
+ needsAuth: true,
238
+ authUrl: googleDrive.getExportAuthUrl(),
239
+ });
240
+ }
188
241
  const folder = await googleDrive.createDriveSubfolder(
189
242
  req.params.id,
190
243
  name.trim(),
191
244
  );
192
245
  res.json(folder);
193
246
  } catch (err: any) {
247
+ if (err?.needsReauth) {
248
+ return res.json({
249
+ needsAuth: true,
250
+ authUrl: googleDrive.getExportAuthUrl(),
251
+ });
252
+ }
194
253
  res.status(500).json({ error: err?.message || "Failed to create folder" });
195
254
  }
196
255
  });
@@ -322,30 +381,104 @@ app.patch("/api/topics/:id", async (req, res) => {
322
381
  const topic = topics.find((t) => t.id === req.params.id);
323
382
  if (!topic) return res.status(404).json({ error: "Not found" });
324
383
  if (typeof req.body.name === "string") topic.name = req.body.name;
384
+ if (typeof req.body.systemContext === "string")
385
+ topic.systemContext = req.body.systemContext;
325
386
  await storage.saveTopic(topic);
326
387
  res.json(topic);
327
388
  });
328
389
 
329
- // ─── Topic Context Files ─────────────────────────────────
390
+ // ─── Workspace Context Files ─────────────────────────────
330
391
 
331
- app.post(
332
- "/api/topics/:topicId/context-files",
333
- upload.array("files", 20),
334
- async (req, res) => {
392
+ app.get("/api/workspace/context-files", async (_req, res) => {
393
+ const files = await storage.getWorkspaceContextFiles();
394
+ res.json(files);
395
+ });
396
+
397
+ app.post("/api/workspace/context-files", upload.any(), async (req, res) => {
398
+ try {
335
399
  const files = req.files as Express.Multer.File[];
336
400
  if (!files?.length) return res.status(400).json({ error: "No files" });
337
401
  const results: storage.ContextFile[] = [];
338
402
  for (const file of files) {
403
+ const id = randomUUID();
339
404
  const text = await extractText(file.buffer, file.originalname);
340
- const cf = await storage.saveContextFile(
341
- req.params.topicId as string,
342
- randomUUID(),
405
+ // Store original bytes for download; extracted text for the LLM
406
+ await storage.writeOriginalBlob(id, file.buffer);
407
+ const cf = await storage.saveWorkspaceContextFile(
408
+ id,
343
409
  file.originalname,
344
410
  Buffer.from(text, "utf-8"),
345
411
  );
346
412
  results.push(cf);
347
413
  }
348
414
  res.json(results);
415
+ } catch (err: any) {
416
+ console.error("workspace upload error:", err?.message || err);
417
+ res.status(500).json({ error: err?.message || "Upload failed" });
418
+ }
419
+ });
420
+
421
+ app.delete("/api/workspace/context-files/:fileId", async (req, res) => {
422
+ await storage.deleteWorkspaceContextFile(req.params.fileId);
423
+ res.json({ ok: true });
424
+ });
425
+
426
+ app.get("/api/workspace/context-files/:fileId/download", async (req, res) => {
427
+ const files = await storage.getWorkspaceContextFiles();
428
+ const cf = files.find((f) => f.id === req.params.fileId);
429
+ if (!cf) return res.status(404).json({ error: "File not found" });
430
+ try {
431
+ const original = await storage.readOriginalBlob(req.params.fileId);
432
+ if (original) {
433
+ res.setHeader("Content-Type", mimeForFilename(cf.originalName));
434
+ res.setHeader(
435
+ "Content-Disposition",
436
+ `attachment; filename="${encodeURIComponent(cf.originalName)}"`,
437
+ );
438
+ return res.send(original);
439
+ }
440
+ // Fallback for files uploaded before original storage was added
441
+ const content = await storage.readWorkspaceContextFileContent(
442
+ req.params.fileId,
443
+ );
444
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
445
+ res.setHeader(
446
+ "Content-Disposition",
447
+ `attachment; filename="${encodeURIComponent(cf.originalName)}.txt"`,
448
+ );
449
+ res.send(content);
450
+ } catch {
451
+ res.status(404).json({ error: "File not found" });
452
+ }
453
+ });
454
+
455
+ // ─── Topic Context Files ─────────────────────────────────
456
+
457
+ app.post(
458
+ "/api/topics/:topicId/context-files",
459
+ upload.any(),
460
+ async (req, res) => {
461
+ try {
462
+ const files = req.files as Express.Multer.File[];
463
+ if (!files?.length) return res.status(400).json({ error: "No files" });
464
+ const results: storage.ContextFile[] = [];
465
+ for (const file of files) {
466
+ const id = randomUUID();
467
+ const text = await extractText(file.buffer, file.originalname);
468
+ await storage.writeOriginalBlob(id, file.buffer);
469
+ const cf = await storage.saveContextFile(
470
+ req.params.topicId as string,
471
+ id,
472
+ file.originalname,
473
+ Buffer.from(text, "utf-8"),
474
+ );
475
+ results.push(cf);
476
+ }
477
+ res.json(results);
478
+ } catch (err: any) {
479
+ console.error("topic upload error:", err?.message || err);
480
+ res.status(500).json({ error: err?.message || "Upload failed" });
481
+ }
349
482
  },
350
483
  );
351
484
 
@@ -354,6 +487,36 @@ app.delete("/api/topics/:topicId/context-files/:fileId", async (req, res) => {
354
487
  res.json({ ok: true });
355
488
  });
356
489
 
490
+ app.get(
491
+ "/api/topics/:topicId/context-files/:fileId/download",
492
+ async (req, res) => {
493
+ const topics = await storage.getTopics();
494
+ const topic = topics.find((t) => t.id === req.params.topicId);
495
+ const cf = topic?.contextFiles?.find((f) => f.id === req.params.fileId);
496
+ if (!cf) return res.status(404).json({ error: "File not found" });
497
+ try {
498
+ const original = await storage.readOriginalBlob(req.params.fileId);
499
+ if (original) {
500
+ res.setHeader("Content-Type", mimeForFilename(cf.originalName));
501
+ res.setHeader(
502
+ "Content-Disposition",
503
+ `attachment; filename="${encodeURIComponent(cf.originalName)}"`,
504
+ );
505
+ return res.send(original);
506
+ }
507
+ const content = await storage.readContextFileContent(req.params.fileId);
508
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
509
+ res.setHeader(
510
+ "Content-Disposition",
511
+ `attachment; filename="${encodeURIComponent(cf.originalName)}.txt"`,
512
+ );
513
+ res.send(content);
514
+ } catch {
515
+ res.status(404).json({ error: "File not found" });
516
+ }
517
+ },
518
+ );
519
+
357
520
  // ─── Questions ───────────────────────────────────────────
358
521
 
359
522
  app.get("/api/topics/:topicId/questions", async (req, res) => {
@@ -391,12 +554,45 @@ app.patch("/api/questions/:id", async (req, res) => {
391
554
  if (req.body.systemContext !== undefined)
392
555
  q.systemContext = req.body.systemContext;
393
556
  if (req.body.title !== undefined) q.title = req.body.title;
394
- if (req.body.parentQuestionId !== undefined)
395
- q.parentQuestionId = req.body.parentQuestionId;
557
+ if (req.body.parentQuestionId !== undefined) {
558
+ const nextParentId = req.body.parentQuestionId as string | null;
559
+ if (nextParentId === q.id) {
560
+ return res.status(400).json({ error: "A question cannot parent itself" });
561
+ }
562
+ if (nextParentId != null) {
563
+ const allInTopic = await storage.getQuestionsByTopic(q.topicId);
564
+ const parentExists = allInTopic.some(
565
+ (candidate) => candidate.id === nextParentId,
566
+ );
567
+ if (!parentExists) {
568
+ return res
569
+ .status(400)
570
+ .json({ error: "Selected parent question was not found" });
571
+ }
572
+ const descendantIds = new Set<string>();
573
+ const visit = (parentId: string) => {
574
+ for (const candidate of allInTopic) {
575
+ if (candidate.parentQuestionId !== parentId) continue;
576
+ if (descendantIds.has(candidate.id)) continue;
577
+ descendantIds.add(candidate.id);
578
+ visit(candidate.id);
579
+ }
580
+ };
581
+ visit(q.id);
582
+ if (descendantIds.has(nextParentId)) {
583
+ return res.status(400).json({
584
+ error: "A question cannot be moved under one of its descendants",
585
+ });
586
+ }
587
+ }
588
+ q.parentQuestionId = nextParentId ?? undefined;
589
+ }
396
590
  if (req.body.messages !== undefined) q.messages = req.body.messages;
397
591
  if (req.body.annotations !== undefined) q.annotations = req.body.annotations;
398
592
  if (req.body.readingBookmark !== undefined)
399
593
  q.readingBookmark = req.body.readingBookmark;
594
+ if (req.body.linkedConversationIds !== undefined)
595
+ q.linkedConversationIds = req.body.linkedConversationIds;
400
596
  await storage.saveQuestion(q);
401
597
  res.json(q);
402
598
  });
@@ -404,35 +600,76 @@ app.patch("/api/questions/:id", async (req, res) => {
404
600
  app.delete("/api/questions/:id", async (req, res) => {
405
601
  const q = await storage.getQuestion(req.params.id);
406
602
  if (q) {
407
- // Cascade: delete children that point to this question
408
- const siblings = await storage.getQuestionsByTopic(q.topicId);
409
- const children = siblings.filter((c) => c.parentQuestionId === q.id);
410
- await Promise.all(children.map((c) => storage.deleteQuestion(c.id)));
603
+ // Cascade: recursively delete all descendants (not just direct children)
604
+ const allInTopic = await storage.getQuestionsByTopic(q.topicId);
605
+ const collectDescendants = (parentId: string): string[] => {
606
+ const direct = allInTopic.filter((c) => c.parentQuestionId === parentId);
607
+ return direct.flatMap((c) => [c.id, ...collectDescendants(c.id)]);
608
+ };
609
+ const descendants = collectDescendants(q.id);
610
+ await Promise.all(descendants.map((id) => storage.deleteQuestion(id)));
411
611
  }
412
612
  await storage.deleteQuestion(req.params.id);
413
613
  res.json({ ok: true });
414
614
  });
415
615
 
616
+ // ─── Code-line Annotations ─────────────────────────────
617
+
618
+ app.get("/api/questions/:id/code-annotations", async (req, res) => {
619
+ const q = await storage.getQuestion(req.params.id);
620
+ if (!q) return res.status(404).json({ error: "Not found" });
621
+ const filePath = req.query.filePath as string;
622
+ if (!filePath) return res.json({ annotations: [] });
623
+ const annotations = q.codeAnnotations?.[filePath] ?? [];
624
+ res.json({ annotations });
625
+ });
626
+
627
+ app.patch("/api/questions/:id/code-annotations", async (req, res) => {
628
+ const { filePath, annotations } = req.body;
629
+ if (typeof filePath !== "string" || !filePath.trim()) {
630
+ return res.status(400).json({ error: "filePath is required" });
631
+ }
632
+ try {
633
+ await storage.updateCodeAnnotationsForFile(
634
+ req.params.id,
635
+ filePath,
636
+ Array.isArray(annotations) ? annotations : [],
637
+ );
638
+ res.json({ ok: true });
639
+ } catch (err: any) {
640
+ res
641
+ .status(500)
642
+ .json({ error: err?.message || "Failed to save annotations" });
643
+ }
644
+ });
645
+
416
646
  // ─── Question Context Files ─────────────────────────────
417
647
 
418
648
  app.post(
419
649
  "/api/questions/:questionId/context-files",
420
- upload.array("files", 20),
650
+ upload.any(),
421
651
  async (req, res) => {
422
- const files = req.files as Express.Multer.File[];
423
- if (!files?.length) return res.status(400).json({ error: "No files" });
424
- const results: storage.ContextFile[] = [];
425
- for (const file of files) {
426
- const text = await extractText(file.buffer, file.originalname);
427
- const cf = await storage.saveQuestionContextFile(
428
- req.params.questionId as string,
429
- randomUUID(),
430
- file.originalname,
431
- Buffer.from(text, "utf-8"),
432
- );
433
- results.push(cf);
652
+ try {
653
+ const files = req.files as Express.Multer.File[];
654
+ if (!files?.length) return res.status(400).json({ error: "No files" });
655
+ const results: storage.ContextFile[] = [];
656
+ for (const file of files) {
657
+ const id = randomUUID();
658
+ const text = await extractText(file.buffer, file.originalname);
659
+ await storage.writeOriginalBlob(id, file.buffer);
660
+ const cf = await storage.saveQuestionContextFile(
661
+ req.params.questionId as string,
662
+ id,
663
+ file.originalname,
664
+ Buffer.from(text, "utf-8"),
665
+ );
666
+ results.push(cf);
667
+ }
668
+ res.json(results);
669
+ } catch (err: any) {
670
+ console.error("question upload error:", err?.message || err);
671
+ res.status(500).json({ error: err?.message || "Upload failed" });
434
672
  }
435
- res.json(results);
436
673
  },
437
674
  );
438
675
 
@@ -447,150 +684,798 @@ app.delete(
447
684
  },
448
685
  );
449
686
 
450
- // ─── AI Settings ────────────────────────────────────────────
451
-
452
- app.get("/api/settings", async (_req, res) => {
453
- const settings = await storage.getAiSettings();
454
- res.json(settings);
455
- });
456
-
457
- app.patch("/api/settings", async (req, res) => {
458
- const current = await storage.getAiSettings();
459
- const updated: storage.AiSettings = {
460
- systemPrompt:
461
- typeof req.body.systemPrompt === "string"
462
- ? req.body.systemPrompt
463
- : current.systemPrompt,
464
- responseProfiles:
465
- req.body.responseProfiles != null
466
- ? req.body.responseProfiles
467
- : current.responseProfiles,
468
- vizGuide:
469
- typeof req.body.vizGuide === "string"
470
- ? req.body.vizGuide
471
- : current.vizGuide,
472
- promptGroups:
473
- req.body.promptGroups != null
474
- ? req.body.promptGroups
475
- : current.promptGroups,
687
+ // Save a code snippet (from Code Runner or AI response) as a question context file
688
+ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
689
+ const { code, language, label, origin } = req.body as {
690
+ code: string;
691
+ language: string;
692
+ label: string;
693
+ origin: "user" | "ai" | "sandbox" | "infra" | "react" | "nextjs";
476
694
  };
477
- await storage.saveAiSettings(updated);
478
- res.json(updated);
695
+ if (typeof code !== "string" || !code.trim()) {
696
+ return res.status(400).json({ error: "code is required" });
697
+ }
698
+ if (
699
+ origin !== "user" &&
700
+ origin !== "ai" &&
701
+ origin !== "sandbox" &&
702
+ origin !== "infra" &&
703
+ origin !== "react" &&
704
+ origin !== "nextjs"
705
+ ) {
706
+ return res.status(400).json({
707
+ error:
708
+ "origin must be 'user', 'ai', 'sandbox', 'infra', 'react', or 'nextjs'",
709
+ });
710
+ }
711
+ try {
712
+ const id = randomUUID();
713
+ const safeLabel = (label || "snippet").replace(/[/\\:*?"<>|]/g, "-").trim();
714
+ const ext =
715
+ language === "typescript"
716
+ ? "ts"
717
+ : language === "javascript"
718
+ ? "js"
719
+ : language === "infra"
720
+ ? "infra.json"
721
+ : "txt";
722
+ const fileName = `${safeLabel}.${ext}`;
723
+ const cf = await storage.saveQuestionContextFile(
724
+ req.params.questionId,
725
+ id,
726
+ fileName,
727
+ Buffer.from(code, "utf-8"),
728
+ { origin, language, label: safeLabel },
729
+ );
730
+ res.json(cf);
731
+ } catch (err: any) {
732
+ res.status(500).json({ error: err?.message || "Failed to save snippet" });
733
+ }
479
734
  });
480
735
 
481
- // ─── Chat ────────────────────────────────────────────────
482
-
483
- app.post("/api/chat", async (req, res) => {
484
- const {
485
- messages,
486
- questionId,
487
- topicId,
488
- topicTitle,
489
- questionTitle,
490
- codeContextFiles,
491
- codeSnippets,
492
- systemContext,
493
- responseLength,
494
- } = req.body;
495
-
496
- const aiSettings = await storage.getAiSettings();
497
- const { responseProfiles, vizGuide } = aiSettings;
498
- const selectedResponseProfile = responseProfiles[responseLength] ??
499
- responseProfiles["normal"] ?? { maxOutputTokens: 3000, maxSteps: 5 };
500
-
501
- let system = aiSettings.systemPrompt;
736
+ // Overwrite the content blob of an existing sandbox context file
737
+ app.put(
738
+ "/api/questions/:questionId/context-files/:fileId/content",
739
+ async (req, res) => {
740
+ const { code } = req.body as { code: string };
741
+ if (typeof code !== "string") {
742
+ return res.status(400).json({ error: "code is required" });
743
+ }
744
+ try {
745
+ await storage.overwriteQuestionContextFileContent(
746
+ req.params.questionId,
747
+ req.params.fileId,
748
+ Buffer.from(code, "utf-8"),
749
+ );
750
+ res.json({ ok: true });
751
+ } catch (err: any) {
752
+ res.status(500).json({ error: err?.message || "Failed to overwrite" });
753
+ }
754
+ },
755
+ );
502
756
 
503
- if (topicTitle || questionTitle) {
504
- system += `\n\n--- Current Context ---`;
505
- if (topicTitle) system += `\nTopic: ${topicTitle}`;
506
- if (questionTitle) system += `\nQuestion: ${questionTitle}`;
507
- }
757
+ // Rename the label of an existing question context file
758
+ app.patch(
759
+ "/api/questions/:questionId/context-files/:fileId",
760
+ async (req, res) => {
761
+ const { label } = req.body as { label: string };
762
+ if (typeof label !== "string" || !label.trim()) {
763
+ return res.status(400).json({ error: "label is required" });
764
+ }
765
+ try {
766
+ const cf = await storage.renameQuestionContextFile(
767
+ req.params.questionId,
768
+ req.params.fileId,
769
+ label.trim(),
770
+ );
771
+ res.json(cf);
772
+ } catch (err: any) {
773
+ res.status(500).json({ error: err?.message || "Failed to rename" });
774
+ }
775
+ },
776
+ );
508
777
 
509
- if (systemContext) {
510
- system += `\n\n--- Additional Context ---\n${systemContext}`;
778
+ // Unified content-read endpoint — used by DocRefModal to show extracted text
779
+ // All context files (workspace / topic / question) share the same on-disk dir
780
+ // per workspace, so a single reader covers every scope.
781
+ app.get("/api/context-files/:fileId/content", async (req, res) => {
782
+ try {
783
+ const content = await storage.readContextFileContent(req.params.fileId);
784
+ res.json({ content });
785
+ } catch {
786
+ res.status(404).json({ error: "File not found" });
511
787
  }
788
+ });
512
789
 
513
- // Build a file registry: id → { label, reader }
514
- // The model sees the list of file names and can call readFile(id) for any of them.
515
- const fileRegistry = new Map<
516
- string,
517
- { label: string; reader: () => Promise<string> }
518
- >();
519
-
520
- // Topic-level uploaded files
521
- if (topicId) {
522
- const topics = await storage.getTopics();
523
- const topic = topics.find((t) => t.id === topicId);
524
- if (topic?.contextFiles?.length) {
525
- for (const cf of topic.contextFiles) {
526
- fileRegistry.set(cf.id, {
527
- label: `[topic] ${cf.originalName}`,
528
- reader: () => storage.readContextFileContent(cf.id),
529
- });
530
- }
531
- }
790
+ // List every uploaded file across workspace, topics, and questions.
791
+ // Used by the file picker so users can link an existing file instead of re-uploading.
792
+ app.get("/api/context-files/all", async (_req, res) => {
793
+ const entries: Array<{
794
+ fileId: string;
795
+ originalName: string;
796
+ source: "workspace" | "topic" | "question";
797
+ sourceName: string;
798
+ }> = [];
799
+
800
+ const wsFiles = await storage.getWorkspaceContextFiles();
801
+ for (const f of wsFiles) {
802
+ entries.push({
803
+ fileId: f.id,
804
+ originalName: f.originalName,
805
+ source: "workspace",
806
+ sourceName: "Workspace",
807
+ });
532
808
  }
533
809
 
534
- // Question-level uploaded files
535
- if (questionId) {
536
- const question = await storage.getQuestion(questionId);
537
- if (question?.contextFiles?.length) {
538
- for (const cf of question.contextFiles) {
539
- fileRegistry.set(cf.id, {
540
- label: `[question] ${cf.originalName}`,
541
- reader: () => storage.readContextFileContent(cf.id),
542
- });
543
- }
810
+ const topics = await storage.getTopics();
811
+ for (const t of topics) {
812
+ for (const f of t.contextFiles || []) {
813
+ entries.push({
814
+ fileId: f.id,
815
+ originalName: f.originalName,
816
+ source: "topic",
817
+ sourceName: t.name,
818
+ });
544
819
  }
545
820
  }
546
821
 
547
- // Code-context files from the project directory
548
- if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
549
- for (const filePath of codeContextFiles as string[]) {
550
- const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
551
- const resolved = path.resolve(fullPath);
552
- if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
553
- fileRegistry.set(`code:${filePath}`, {
554
- label: `[code] ${filePath}`,
555
- reader: () => fs.readFile(resolved, "utf-8"),
822
+ // Scan question files via the questions directory
823
+ const questions = await storage.getAllQuestions();
824
+ for (const q of questions) {
825
+ for (const f of q.contextFiles || []) {
826
+ entries.push({
827
+ fileId: f.id,
828
+ originalName: f.originalName,
829
+ source: "question",
830
+ sourceName: q.title,
556
831
  });
557
832
  }
558
833
  }
559
834
 
560
- // Tell the model what files are available
561
- if (fileRegistry.size > 0) {
562
- // collect just the code-context file paths for linking instructions
563
- const codeFilePaths: string[] = [];
564
- for (const [id, { label }] of fileRegistry) {
565
- if (id.startsWith("code:")) codeFilePaths.push(id.slice("code:".length));
566
- }
835
+ // Deduplicate by fileId — keep the first occurrence (workspace > topic > question)
836
+ const seen = new Set<string>();
837
+ const deduped = entries.filter((e) => {
838
+ if (seen.has(e.fileId)) return false;
839
+ seen.add(e.fileId);
840
+ return true;
841
+ });
567
842
 
568
- system += `\n\n--- Available Reference Files ---
569
- The following files are available to you. Use the readFile tool to retrieve a file's content when it would help answer the question. Only read files that are relevant — you do not need to read them all.
843
+ res.json(deduped);
844
+ });
570
845
 
571
- `;
572
- for (const [id, { label }] of fileRegistry) {
573
- system += `• ${label} (id: "${id}")\n`;
574
- }
846
+ app.post("/api/infra/run", async (req, res) => {
847
+ const { questionId, fileId, label, action, workspace } = req.body as {
848
+ questionId?: string;
849
+ fileId?: string;
850
+ label?: string;
851
+ action?: "validate" | "plan";
852
+ workspace?: unknown;
853
+ };
575
854
 
576
- if (codeFilePaths.length > 0) {
577
- system += `
578
- --- Linking Code Files in Your Response ---
579
- When you mention or reference one of the **[code]** files above in your response text, format it as a clickable link so the user can open it directly:
855
+ if (action !== "validate" && action !== "plan") {
856
+ return res
857
+ .status(400)
858
+ .json({ error: "action must be 'validate' or 'plan'" });
859
+ }
580
860
 
581
- [DisplayText](coderef://relative/path/to/file)
861
+ try {
862
+ const run = await runInfraAction({
863
+ questionId,
864
+ fileId,
865
+ label,
866
+ action,
867
+ workspace,
868
+ });
869
+ res.json(run);
870
+ } catch (err: any) {
871
+ res
872
+ .status(400)
873
+ .json({ error: err?.message || "Failed to run infra action" });
874
+ }
875
+ });
582
876
 
583
- Examples:
584
- [EmployeesController](coderef://src/Controllers/EmployeesController.cs)
585
- [SettleDeferredPaymentCaseRequest](coderef://src/Requests/SettleDeferredPaymentCaseRequest.cs)
877
+ // ─── Infra Lab AI Ask (streaming) ───────────────────────────────────────────
878
+ app.post("/api/infra/ask", async (req, res) => {
879
+ const { messages, workspace, questionId } = req.body as {
880
+ messages?: Array<{ role: "user" | "assistant"; content: string }>;
881
+ workspace?: Record<string, string>; // { "main.tf": "..." }
882
+ questionId?: string;
883
+ };
586
884
 
587
- 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
- Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
589
- }
885
+ if (!Array.isArray(messages) || messages.length === 0) {
886
+ return res.status(400).json({ error: "messages is required" });
590
887
  }
591
888
 
592
- // Code snippets hand-picked by the user from the file viewer
593
- if (Array.isArray(codeSnippets) && codeSnippets.length > 0) {
889
+ const isGoogle = ["google", "gemini"].includes(
890
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
891
+ );
892
+ const aiSettings = await storage.getAiSettings();
893
+
894
+ // Build workspace context block
895
+ let workspaceBlock = "";
896
+ if (workspace && typeof workspace === "object") {
897
+ const entries = Object.entries(workspace).filter(
898
+ ([k, v]) => typeof k === "string" && typeof v === "string",
899
+ );
900
+ if (entries.length > 0) {
901
+ workspaceBlock = "\n\n--- Workspace Files ---\n";
902
+ for (const [name, content] of entries) {
903
+ workspaceBlock += `\n### ${name}\n\`\`\`hcl\n${content}\n\`\`\`\n`;
904
+ }
905
+ }
906
+ }
907
+
908
+ const system =
909
+ `You are a senior infrastructure engineer and Terraform expert acting as a coding assistant inside an Infrastructure Lab.\n` +
910
+ `The user is editing a Terraform workspace. Answer questions about their code clearly and accurately.\n` +
911
+ `When suggesting edits, show the changed HCL as a fenced \`\`\`hcl code block.\n` +
912
+ `Be concise unless the user asks for a deeper explanation.` +
913
+ workspaceBlock;
914
+
915
+ try {
916
+ const result = streamText({
917
+ model: getModel(),
918
+ maxOutputTokens: 2000,
919
+ ...(isGoogle && {
920
+ providerOptions: {
921
+ google: {
922
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
923
+ },
924
+ },
925
+ }),
926
+ system,
927
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
928
+ });
929
+
930
+ result.pipeUIMessageStreamToResponse(res);
931
+ } catch (err: any) {
932
+ console.error("infra/ask error:", err?.message || err);
933
+ if (!res.headersSent) {
934
+ res
935
+ .status(500)
936
+ .json({ error: err?.message || "Failed to generate response" });
937
+ }
938
+ }
939
+ });
940
+
941
+ app.post("/api/notes/ask", async (req, res) => {
942
+ const { messages, noteContent, noteName } = req.body as {
943
+ messages?: Array<{ role: "user" | "assistant"; content: string }>;
944
+ noteContent?: string;
945
+ noteName?: string;
946
+ };
947
+
948
+ if (!Array.isArray(messages) || messages.length === 0) {
949
+ return res.status(400).json({ error: "messages is required" });
950
+ }
951
+
952
+ const isGoogle = ["google", "gemini"].includes(
953
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
954
+ );
955
+ const aiSettings = await storage.getAiSettings();
956
+
957
+ const noteBlock = noteContent?.trim()
958
+ ? `\n\n--- Note: ${noteName || "Untitled"} ---\n${noteContent}\n---`
959
+ : "";
960
+
961
+ const system =
962
+ `You are a knowledgeable study assistant helping the user understand and improve their notes.\n` +
963
+ `Answer questions about the note content clearly. Help the user expand on ideas, clarify concepts, or drill deeper.\n` +
964
+ `Be concise unless the user asks for a deeper explanation.` +
965
+ noteBlock;
966
+
967
+ try {
968
+ const result = streamText({
969
+ model: getModel(),
970
+ maxOutputTokens: 2000,
971
+ ...(isGoogle && {
972
+ providerOptions: {
973
+ google: {
974
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
975
+ },
976
+ },
977
+ }),
978
+ system,
979
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
980
+ });
981
+
982
+ result.pipeUIMessageStreamToResponse(res);
983
+ } catch (err: any) {
984
+ console.error("notes/ask error:", err?.message || err);
985
+ if (!res.headersSent) {
986
+ res
987
+ .status(500)
988
+ .json({ error: err?.message || "Failed to generate response" });
989
+ }
990
+ }
991
+ });
992
+
993
+ app.post("/api/frontend-lab/ask", async (req, res) => {
994
+ const { messages, workspace, labType, questionId } = req.body as {
995
+ messages?: Array<{ role: "user" | "assistant"; content: string }>;
996
+ workspace?: Record<string, string>;
997
+ labType?: "react" | "nextjs";
998
+ questionId?: string;
999
+ };
1000
+
1001
+ if (!Array.isArray(messages) || messages.length === 0) {
1002
+ return res.status(400).json({ error: "messages is required" });
1003
+ }
1004
+
1005
+ const isGoogle = ["google", "gemini"].includes(
1006
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
1007
+ );
1008
+ const aiSettings = await storage.getAiSettings();
1009
+
1010
+ const typeLabel =
1011
+ labType === "nextjs" ? "Next.js App Router" : "React + TypeScript";
1012
+
1013
+ let workspaceBlock = "";
1014
+ if (workspace && typeof workspace === "object") {
1015
+ const entries = Object.entries(workspace).filter(
1016
+ ([k, v]) => typeof k === "string" && typeof v === "string",
1017
+ );
1018
+ if (entries.length > 0) {
1019
+ workspaceBlock = "\n\n--- Workspace Files ---\n";
1020
+ for (const [name, content] of entries) {
1021
+ workspaceBlock += `\n### ${name}\n\`\`\`tsx\n${content}\n\`\`\`\n`;
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ const system =
1027
+ `You are an expert ${typeLabel} tutor acting as a coding assistant inside a hands-on lab.\n` +
1028
+ `The user is practising ${typeLabel} concepts. Help them understand their code, fix bugs, and learn best practices.\n` +
1029
+ `When suggesting code changes, use fenced TypeScript/TSX code blocks.\n` +
1030
+ `Be concise unless the user asks for deeper explanation.` +
1031
+ workspaceBlock;
1032
+
1033
+ try {
1034
+ const result = streamText({
1035
+ model: getModel(),
1036
+ maxOutputTokens: 2000,
1037
+ ...(isGoogle && {
1038
+ providerOptions: {
1039
+ google: {
1040
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
1041
+ },
1042
+ },
1043
+ }),
1044
+ system,
1045
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
1046
+ });
1047
+
1048
+ result.pipeUIMessageStreamToResponse(res);
1049
+ } catch (err: any) {
1050
+ console.error("frontend-lab/ask error:", err?.message || err);
1051
+ if (!res.headersSent) {
1052
+ res
1053
+ .status(500)
1054
+ .json({ error: err?.message || "Failed to generate response" });
1055
+ }
1056
+ }
1057
+ });
1058
+
1059
+ app.post("/api/infra/command-stream", async (req, res) => {
1060
+ const { questionId, fileId, label, command, workspace } = req.body as {
1061
+ questionId?: string;
1062
+ fileId?: string;
1063
+ label?: string;
1064
+ command?: string;
1065
+ workspace?: unknown;
1066
+ };
1067
+
1068
+ res.setHeader("Content-Type", "text/event-stream");
1069
+ res.setHeader("Cache-Control", "no-cache");
1070
+ res.setHeader("Connection", "keep-alive");
1071
+ res.flushHeaders();
1072
+
1073
+ const send = (payload: unknown) => {
1074
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
1075
+ };
1076
+
1077
+ if (typeof command !== "string" || !command.trim()) {
1078
+ send({ type: "error", error: "command is required" });
1079
+ res.end();
1080
+ return;
1081
+ }
1082
+
1083
+ try {
1084
+ const run = await streamInfraCommand({
1085
+ questionId,
1086
+ fileId,
1087
+ label,
1088
+ command,
1089
+ workspace,
1090
+ onMessage: send,
1091
+ });
1092
+ send({ type: "complete", run });
1093
+ } catch (err: any) {
1094
+ send({
1095
+ type: "error",
1096
+ error: err?.message || "Failed to run infra command",
1097
+ });
1098
+ }
1099
+
1100
+ res.end();
1101
+ });
1102
+
1103
+ app.get("/api/infra/runs", async (req, res) => {
1104
+ const fileId =
1105
+ typeof req.query.fileId === "string" ? req.query.fileId : undefined;
1106
+ const questionId =
1107
+ typeof req.query.questionId === "string" ? req.query.questionId : undefined;
1108
+
1109
+ try {
1110
+ const runs = await listInfraRuns({ fileId, questionId });
1111
+ res.json(runs);
1112
+ } catch (err: any) {
1113
+ res
1114
+ .status(500)
1115
+ .json({ error: err?.message || "Failed to list infra runs" });
1116
+ }
1117
+ });
1118
+
1119
+ app.get("/api/infra/runs/:runId", async (req, res) => {
1120
+ try {
1121
+ const run = await getInfraRun(req.params.runId);
1122
+ res.json(run);
1123
+ } catch {
1124
+ res.status(404).json({ error: "Infra run not found" });
1125
+ }
1126
+ });
1127
+
1128
+ // Link an existing file to a topic without re-uploading
1129
+ app.post("/api/topics/:topicId/context-files/link", async (req, res) => {
1130
+ const { fileId, originalName } = req.body as {
1131
+ fileId?: string;
1132
+ originalName?: string;
1133
+ };
1134
+ if (!fileId || !originalName)
1135
+ return res.status(400).json({ error: "fileId and originalName required" });
1136
+ const cf = await storage.linkContextFileToTopic(
1137
+ req.params.topicId,
1138
+ fileId,
1139
+ originalName,
1140
+ );
1141
+ res.json(cf);
1142
+ });
1143
+
1144
+ // Link an existing file to a question without re-uploading
1145
+ app.post("/api/questions/:questionId/context-files/link", async (req, res) => {
1146
+ const { fileId, originalName } = req.body as {
1147
+ fileId?: string;
1148
+ originalName?: string;
1149
+ };
1150
+ if (!fileId || !originalName)
1151
+ return res.status(400).json({ error: "fileId and originalName required" });
1152
+ const cf = await storage.linkContextFileToQuestion(
1153
+ req.params.questionId,
1154
+ fileId,
1155
+ originalName,
1156
+ );
1157
+ res.json(cf);
1158
+ });
1159
+
1160
+ // Unified inline-download endpoint — serves the original binary with Content-Disposition: inline
1161
+ // so browsers can render PDFs/images directly inside an <iframe> or <img>.
1162
+ // Pass ?name=originalFilename.pdf so Content-Type and filename are set correctly.
1163
+ app.get("/api/context-files/:fileId/view", async (req, res) => {
1164
+ const name = typeof req.query.name === "string" ? req.query.name : "file";
1165
+ try {
1166
+ const original = await storage.readOriginalBlob(req.params.fileId);
1167
+ if (original) {
1168
+ res.setHeader("Content-Type", mimeForFilename(name));
1169
+ res.setHeader(
1170
+ "Content-Disposition",
1171
+ `inline; filename="${encodeURIComponent(name)}"`,
1172
+ );
1173
+ return res.send(original);
1174
+ }
1175
+ // Fallback: serve extracted text as plain text
1176
+ const content = await storage.readContextFileContent(req.params.fileId);
1177
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
1178
+ res.setHeader(
1179
+ "Content-Disposition",
1180
+ `inline; filename="${encodeURIComponent(name)}.txt"`,
1181
+ );
1182
+ res.send(content);
1183
+ } catch {
1184
+ res.status(404).json({ error: "File not found" });
1185
+ }
1186
+ });
1187
+
1188
+ app.get(
1189
+ "/api/questions/:questionId/context-files/:fileId/download",
1190
+ async (req, res) => {
1191
+ const question = await storage.getQuestion(req.params.questionId);
1192
+ const cf = question?.contextFiles?.find((f) => f.id === req.params.fileId);
1193
+ if (!cf) return res.status(404).json({ error: "File not found" });
1194
+ try {
1195
+ const original = await storage.readOriginalBlob(req.params.fileId);
1196
+ if (original) {
1197
+ res.setHeader("Content-Type", mimeForFilename(cf.originalName));
1198
+ res.setHeader(
1199
+ "Content-Disposition",
1200
+ `attachment; filename="${encodeURIComponent(cf.originalName)}"`,
1201
+ );
1202
+ return res.send(original);
1203
+ }
1204
+ const content = await storage.readContextFileContent(req.params.fileId);
1205
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
1206
+ res.setHeader(
1207
+ "Content-Disposition",
1208
+ `attachment; filename="${encodeURIComponent(cf.originalName)}.txt"`,
1209
+ );
1210
+ res.send(content);
1211
+ } catch {
1212
+ res.status(404).json({ error: "File not found" });
1213
+ }
1214
+ },
1215
+ );
1216
+
1217
+ // ─── AI Settings ────────────────────────────────────────────
1218
+
1219
+ app.get("/api/settings", async (_req, res) => {
1220
+ const settings = await storage.getAiSettings();
1221
+ res.json({
1222
+ ...settings,
1223
+ // Expose current provider/model from env so client can conditionally show thinking controls
1224
+ provider: (process.env.AI_PROVIDER || "openai").toLowerCase(),
1225
+ model: process.env.AI_MODEL || "",
1226
+ });
1227
+ });
1228
+
1229
+ app.patch("/api/settings", async (req, res) => {
1230
+ const current = await storage.getAiSettings();
1231
+ const updated: storage.AiSettings = {
1232
+ systemPrompt:
1233
+ typeof req.body.systemPrompt === "string"
1234
+ ? req.body.systemPrompt
1235
+ : current.systemPrompt,
1236
+ responseProfiles:
1237
+ req.body.responseProfiles != null
1238
+ ? req.body.responseProfiles
1239
+ : current.responseProfiles,
1240
+ vizGuide:
1241
+ typeof req.body.vizGuide === "string"
1242
+ ? req.body.vizGuide
1243
+ : current.vizGuide,
1244
+ plotGuide:
1245
+ typeof req.body.plotGuide === "string"
1246
+ ? req.body.plotGuide
1247
+ : current.plotGuide,
1248
+ promptGroups:
1249
+ req.body.promptGroups != null
1250
+ ? req.body.promptGroups
1251
+ : current.promptGroups,
1252
+ thinkingBudget:
1253
+ typeof req.body.thinkingBudget === "number"
1254
+ ? req.body.thinkingBudget
1255
+ : current.thinkingBudget,
1256
+ alwaysSendPrefsDefault:
1257
+ typeof req.body.alwaysSendPrefsDefault === "boolean"
1258
+ ? req.body.alwaysSendPrefsDefault
1259
+ : current.alwaysSendPrefsDefault,
1260
+ };
1261
+ await storage.saveAiSettings(updated);
1262
+ res.json({
1263
+ ...updated,
1264
+ provider: (process.env.AI_PROVIDER || "openai").toLowerCase(),
1265
+ model: process.env.AI_MODEL || "",
1266
+ });
1267
+ });
1268
+
1269
+ // ─── Chat ────────────────────────────────────────────────
1270
+
1271
+ app.post("/api/chat", async (req, res) => {
1272
+ const {
1273
+ messages,
1274
+ questionId,
1275
+ topicId,
1276
+ topicTitle,
1277
+ questionTitle,
1278
+ codeContextFiles,
1279
+ codeSnippets,
1280
+ systemContext,
1281
+ responseLength,
1282
+ linkedConversationIds,
1283
+ } = req.body;
1284
+
1285
+ const aiSettings = await storage.getAiSettings();
1286
+ const { responseProfiles, vizGuide, plotGuide } = aiSettings;
1287
+ const selectedResponseProfile = responseProfiles[responseLength] ??
1288
+ responseProfiles["normal"] ?? { maxOutputTokens: 3000, maxSteps: 5 };
1289
+
1290
+ let system = aiSettings.systemPrompt;
1291
+
1292
+ if (topicTitle || questionTitle) {
1293
+ system += `\n\n--- Current Context ---`;
1294
+ if (topicTitle) system += `\nTopic: ${topicTitle}`;
1295
+ if (questionTitle) system += `\nQuestion: ${questionTitle}`;
1296
+ }
1297
+
1298
+ if (systemContext) {
1299
+ system += `\n\n--- Additional Context ---\n${systemContext}`;
1300
+ }
1301
+ if (
1302
+ Array.isArray(linkedConversationIds) &&
1303
+ linkedConversationIds.length > 0
1304
+ ) {
1305
+ const linkedQuestions = await Promise.all(
1306
+ linkedConversationIds.map((id: string) => storage.getQuestion(id)),
1307
+ );
1308
+ const valid = linkedQuestions.filter(
1309
+ (q) => q && q.messages && q.messages.length > 0,
1310
+ ) as storage.Question[];
1311
+ if (valid.length > 0) {
1312
+ system += `\n\n--- Linked Conversation${valid.length > 1 ? "s" : ""} (read-only reference) ---\nThe user has linked the following conversation${valid.length > 1 ? "s" : ""} from this topic as background context. Do not treat them as the current conversation — they are reference material only.\n`;
1313
+ for (const lq of valid) {
1314
+ system += `\n### "${lq.title}"\n`;
1315
+ for (const msg of lq.messages) {
1316
+ const role = msg.role === "user" ? "User" : "Assistant";
1317
+ const text =
1318
+ typeof msg.content === "string"
1319
+ ? msg.content
1320
+ : (msg.parts
1321
+ ?.filter((p: any) => p.type === "text")
1322
+ .map((p: any) => p.text)
1323
+ .join("") ?? "");
1324
+ system += `**${role}:** ${text.slice(0, 2000)}${text.length > 2000 ? "…" : ""}\n\n`;
1325
+ }
1326
+ }
1327
+ }
1328
+ }
1329
+
1330
+ // Build a file registry: id → { label, reader }
1331
+ // The model sees the list of file names and can call readFile(id) for any of them.
1332
+ const fileRegistry = new Map<
1333
+ string,
1334
+ { label: string; reader: () => Promise<string> }
1335
+ >();
1336
+
1337
+ // Workspace-level uploaded files (apply to all topics)
1338
+ const workspaceFiles = await storage.getWorkspaceContextFiles();
1339
+ for (const cf of workspaceFiles) {
1340
+ fileRegistry.set(cf.id, {
1341
+ label: `[workspace] ${cf.originalName}`,
1342
+ reader: () => storage.readWorkspaceContextFileContent(cf.id),
1343
+ });
1344
+ }
1345
+
1346
+ // Topic-level uploaded files + topic-wide system prompt
1347
+ if (topicId) {
1348
+ const topics = await storage.getTopics();
1349
+ const topic = topics.find((t) => t.id === topicId);
1350
+ if (topic?.systemContext?.trim()) {
1351
+ system += `\n\n--- Topic-Wide System Context ---\n${topic.systemContext.trim()}`;
1352
+ }
1353
+ if (topic?.contextFiles?.length) {
1354
+ for (const cf of topic.contextFiles) {
1355
+ fileRegistry.set(cf.id, {
1356
+ label: `[topic] ${cf.originalName}`,
1357
+ reader: () => storage.readContextFileContent(cf.id),
1358
+ });
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ // Question-level uploaded files
1364
+ if (questionId) {
1365
+ const question = await storage.getQuestion(questionId);
1366
+ if (question?.contextFiles?.length) {
1367
+ for (const cf of question.contextFiles) {
1368
+ fileRegistry.set(cf.id, {
1369
+ label: `[question] ${cf.originalName}`,
1370
+ reader: () => storage.readContextFileContent(cf.id),
1371
+ });
1372
+ }
1373
+ }
1374
+ }
1375
+
1376
+ // Code-context files from the project directory
1377
+ if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
1378
+ for (const filePath of codeContextFiles as string[]) {
1379
+ const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
1380
+ const resolved = path.resolve(fullPath);
1381
+ if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
1382
+ fileRegistry.set(`code:${filePath}`, {
1383
+ label: `[code] ${filePath}`,
1384
+ reader: () => fs.readFile(resolved, "utf-8"),
1385
+ });
1386
+ }
1387
+ }
1388
+
1389
+ // Tell the model what files are available
1390
+ if (fileRegistry.size > 0) {
1391
+ // collect just the code-context file paths for linking instructions
1392
+ const codeFilePaths: string[] = [];
1393
+ for (const [id, { label }] of fileRegistry) {
1394
+ if (id.startsWith("code:")) codeFilePaths.push(id.slice("code:".length));
1395
+ }
1396
+
1397
+ system += `\n\n--- Available Reference Files ---
1398
+ The following files are available to you. Use the readFile tool to retrieve a file's content when it would help answer the question. Only read files that are relevant — you do not need to read them all.
1399
+
1400
+ `;
1401
+ for (const [id, { label }] of fileRegistry) {
1402
+ system += `• ${label} (id: "${id}")\n`;
1403
+ }
1404
+
1405
+ if (codeFilePaths.length > 0) {
1406
+ system += `
1407
+ --- Linking Code Files in Your Response ---
1408
+ When you mention or reference one of the **[code]** files above in your response text, format it as a clickable link so the user can open it directly:
1409
+
1410
+ [DisplayText](coderef://relative/path/to/file)
1411
+
1412
+ Examples:
1413
+ [EmployeesController](coderef://src/Controllers/EmployeesController.cs)
1414
+ [SettleDeferredPaymentCaseRequest](coderef://src/Requests/SettleDeferredPaymentCaseRequest.cs)
1415
+
1416
+ Use this for class names, method names, or any mention of a specific file from the code context. The display text should be the class, file, or concept name — not the raw path.
1417
+ Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
1418
+ }
1419
+
1420
+ // Always add the inline code block instruction
1421
+ system += `
1422
+
1423
+ --- Writing Code Blocks the User Can Explore ---
1424
+ When you write a fenced code block that the user might want to open in the interactive code viewer (to annotate it, ask questions line-by-line, or use it as context), add a reference label as the FIRST line of the block using a comment in the language's native syntax:
1425
+
1426
+ // @ref: some-unique-label ← JavaScript / TypeScript / Java / C# / Go etc.
1427
+ # @ref: some-unique-label ← Python / Ruby / Shell
1428
+ -- @ref: some-unique-label ← SQL / Lua
1429
+
1430
+ Then anywhere in your response text where you want a clickable link back to that block:
1431
+
1432
+ [Descriptive Label](inlineref://some-unique-label)
1433
+
1434
+ Example:
1435
+ \`\`\`typescript
1436
+ // @ref: user-service
1437
+ export class UserService {
1438
+ async getUser(id: string) { ... }
1439
+ }
1440
+ \`\`\`
1441
+
1442
+ The implementation is in [UserService](inlineref://user-service).
1443
+
1444
+ Rules:
1445
+ - The label must be a short unique slug within your response.
1446
+ - The @ref comment line is stripped from the displayed code — the user never sees it.
1447
+ - You only need @ref when you want to create an explicit link in prose. A plain code block without @ref still gets a pop-out button the user can click.
1448
+ - Never put @ref on any line other than the very first line of the block.`;
1449
+
1450
+ // Collect non-code document file IDs for docref linking instructions
1451
+ const docFileEntries: Array<{ id: string; label: string }> = [];
1452
+ for (const [id, { label }] of fileRegistry) {
1453
+ if (!id.startsWith("code:")) docFileEntries.push({ id, label });
1454
+ }
1455
+
1456
+ if (docFileEntries.length > 0) {
1457
+ system += `
1458
+
1459
+ --- Quoting From Uploaded Documents ---
1460
+ When you quote or reference a specific passage from one of the **[workspace]**, **[topic]**, or **[question]** documents above, format the quoted text as a clickable link so the user can see it highlighted in the original document:
1461
+
1462
+ [the exact passage you are citing](docref://{id}?n={encodedFileName})
1463
+
1464
+ Rules:
1465
+ - The link text MUST be the verbatim passage you are quoting (a sentence, phrase, or term from the document).
1466
+ - {id} is the file id shown in the list above (e.g. "abc123").
1467
+ - {encodedFileName} is the file's original name, percent-encoded (spaces → %20, etc.).
1468
+ - Only use docref:// for uploaded document files. Do not use it for code files.
1469
+
1470
+ Examples (illustrative only — use real ids and names from the list above):
1471
+ [The system shall validate all inputs before processing](docref://abc123?n=Requirements.pdf)
1472
+ [O(n log n) average-case complexity](docref://def456?n=Algorithm%20Notes.docx)
1473
+ `;
1474
+ }
1475
+ }
1476
+
1477
+ // Code snippets hand-picked by the user from the file viewer
1478
+ if (Array.isArray(codeSnippets) && codeSnippets.length > 0) {
594
1479
  system += `\n\n--- Code Snippets (highlighted by user) ---\nThe user has selected these specific sections of code as focus areas for this conversation:\n\n`;
595
1480
  for (const snippet of codeSnippets as Array<{
596
1481
  fileName: string;
@@ -625,7 +1510,9 @@ Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
625
1510
  maxOutputTokens: selectedResponseProfile.maxOutputTokens,
626
1511
  ...(isGoogle && {
627
1512
  providerOptions: {
628
- google: { thinkingConfig: { thinkingBudget: 0 } },
1513
+ google: {
1514
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
1515
+ },
629
1516
  },
630
1517
  }),
631
1518
  system,
@@ -637,6 +1524,12 @@ Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
637
1524
  inputSchema: z.object({}),
638
1525
  execute: async () => ({ guide: vizGuide }),
639
1526
  }),
1527
+ getPlotGuide: tool({
1528
+ description:
1529
+ "Get the full plotting spec reference. Call this before writing a ```plot block so you use the supported schema for graphs, curves, and charts.",
1530
+ inputSchema: z.object({}),
1531
+ execute: async () => ({ guide: plotGuide }),
1532
+ }),
640
1533
  ...(fileRegistry.size > 0
641
1534
  ? {
642
1535
  readFile: tool({
@@ -795,33 +1688,204 @@ async function walkDir(dir: string, prefix = ""): Promise<string[]> {
795
1688
  return files;
796
1689
  }
797
1690
 
798
- app.get("/api/code-context/tree", async (_req, res) => {
799
- if (!CODE_CONTEXT_DIR) return res.json([]);
800
- try {
801
- const files = await walkDir(CODE_CONTEXT_DIR);
802
- res.json(files);
803
- } catch {
804
- res.json([]);
805
- }
806
- });
1691
+ app.get("/api/code-context/tree", async (_req, res) => {
1692
+ if (!CODE_CONTEXT_DIR) return res.json([]);
1693
+ try {
1694
+ const files = await walkDir(CODE_CONTEXT_DIR);
1695
+ res.json(files);
1696
+ } catch {
1697
+ res.json([]);
1698
+ }
1699
+ });
1700
+
1701
+ app.get("/api/code-context/file", async (req, res) => {
1702
+ if (!CODE_CONTEXT_DIR)
1703
+ return res.status(400).json({ error: "No code context directory" });
1704
+ const filePath = req.query.path as string;
1705
+ if (!filePath) return res.status(400).json({ error: "Path required" });
1706
+
1707
+ const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
1708
+ const resolved = path.resolve(fullPath);
1709
+ if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) {
1710
+ return res.status(403).json({ error: "Access denied" });
1711
+ }
1712
+
1713
+ try {
1714
+ const content = await fs.readFile(resolved, "utf-8");
1715
+ res.json({ path: filePath, content });
1716
+ } catch {
1717
+ res.status(404).json({ error: "File not found" });
1718
+ }
1719
+ });
1720
+
1721
+ // ─── Code Line Ask ──────────────────────────────────────
1722
+
1723
+ app.post("/api/code-line-ask", async (req, res) => {
1724
+ const {
1725
+ filePath,
1726
+ selectedCode,
1727
+ prompt,
1728
+ questionId,
1729
+ topicId,
1730
+ codeContextFiles,
1731
+ codeSnippets,
1732
+ preferenceSuffix,
1733
+ } = req.body;
1734
+ if (typeof prompt !== "string" || !prompt.trim()) {
1735
+ return res.status(400).json({ error: "prompt is required" });
1736
+ }
1737
+ if (typeof selectedCode !== "string" || !selectedCode.trim()) {
1738
+ return res.status(400).json({ error: "selectedCode is required" });
1739
+ }
1740
+
1741
+ try {
1742
+ const isGoogle = ["google", "gemini"].includes(
1743
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
1744
+ );
1745
+ const aiSettings = await storage.getAiSettings();
1746
+
1747
+ // Build a file registry identical to /api/chat so the model has the same context
1748
+ const fileRegistry = new Map<
1749
+ string,
1750
+ { label: string; reader: () => Promise<string> }
1751
+ >();
1752
+
1753
+ const workspaceFiles = await storage.getWorkspaceContextFiles();
1754
+ for (const cf of workspaceFiles) {
1755
+ fileRegistry.set(cf.id, {
1756
+ label: `[workspace] ${cf.originalName}`,
1757
+ reader: () => storage.readWorkspaceContextFileContent(cf.id),
1758
+ });
1759
+ }
1760
+
1761
+ if (topicId) {
1762
+ const topics = await storage.getTopics();
1763
+ const topic = topics.find((t: any) => t.id === topicId);
1764
+ if (topic?.contextFiles?.length) {
1765
+ for (const cf of topic.contextFiles) {
1766
+ fileRegistry.set(cf.id, {
1767
+ label: `[topic] ${cf.originalName}`,
1768
+ reader: () => storage.readContextFileContent(cf.id),
1769
+ });
1770
+ }
1771
+ }
1772
+ }
1773
+
1774
+ if (questionId) {
1775
+ const question = await storage.getQuestion(questionId);
1776
+ if (question?.contextFiles?.length) {
1777
+ for (const cf of question.contextFiles) {
1778
+ fileRegistry.set(cf.id, {
1779
+ label: `[question] ${cf.originalName}`,
1780
+ reader: () => storage.readContextFileContent(cf.id),
1781
+ });
1782
+ }
1783
+ }
1784
+ }
1785
+
1786
+ if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
1787
+ for (const fp of codeContextFiles as string[]) {
1788
+ const fullPath = path.join(CODE_CONTEXT_DIR, fp);
1789
+ const resolved = path.resolve(fullPath);
1790
+ if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
1791
+ fileRegistry.set(`code:${fp}`, {
1792
+ label: `[code] ${fp}`,
1793
+ reader: () => fs.readFile(resolved, "utf-8"),
1794
+ });
1795
+ }
1796
+ }
1797
+
1798
+ let system =
1799
+ "You are a senior software engineer and interview coach. Answer questions about the highlighted code lines. Be concise, accurate, and practical. Use markdown formatting for code references.";
1800
+
1801
+ // Apply the user's active response preferences (LENGTH / STYLE / AUDIENCE / etc.)
1802
+ if (
1803
+ preferenceSuffix &&
1804
+ typeof preferenceSuffix === "string" &&
1805
+ preferenceSuffix.trim()
1806
+ ) {
1807
+ system += `\n\n${preferenceSuffix.trim()}`;
1808
+ }
1809
+
1810
+ if (fileRegistry.size > 0) {
1811
+ const codeFilePaths: string[] = [];
1812
+ for (const [id, { label }] of fileRegistry) {
1813
+ if (id.startsWith("code:"))
1814
+ codeFilePaths.push(id.slice("code:".length));
1815
+ }
1816
+
1817
+ system += `\n\n--- Available Reference Files ---\nThe following files are available to you. Use the readFile tool to retrieve a file's content when it would help answer the question. Only read files that are relevant.\n\n`;
1818
+ for (const [id, { label }] of fileRegistry) {
1819
+ system += `• ${label} (id: "${id}")\n`;
1820
+ }
807
1821
 
808
- app.get("/api/code-context/file", async (req, res) => {
809
- if (!CODE_CONTEXT_DIR)
810
- return res.status(400).json({ error: "No code context directory" });
811
- const filePath = req.query.path as string;
812
- if (!filePath) return res.status(400).json({ error: "Path required" });
1822
+ if (codeFilePaths.length > 0) {
1823
+ system += `\n--- Linking Code Files in Your Response ---\nWhen you mention a [code] file, format it as a clickable link:\n [DisplayText](coderef://relative/path/to/file)\nOnly use coderef:// for [code] files.`;
1824
+ }
1825
+ }
813
1826
 
814
- const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
815
- const resolved = path.resolve(fullPath);
816
- if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) {
817
- return res.status(403).json({ error: "Access denied" });
818
- }
1827
+ if (Array.isArray(codeSnippets) && codeSnippets.length > 0) {
1828
+ system += `\n\n--- Code Snippets (pinned by user) ---\n`;
1829
+ for (const snippet of codeSnippets as Array<{
1830
+ fileName: string;
1831
+ filePath: string;
1832
+ startLine: number;
1833
+ endLine: number;
1834
+ code: string;
1835
+ }>) {
1836
+ const lineLabel =
1837
+ snippet.startLine === snippet.endLine
1838
+ ? `line ${snippet.startLine}`
1839
+ : `lines ${snippet.startLine}–${snippet.endLine}`;
1840
+ system += `**${snippet.fileName}** (${lineLabel}):\n\`\`\`\n${snippet.code}\n\`\`\`\n\n`;
1841
+ }
1842
+ }
819
1843
 
820
- try {
821
- const content = await fs.readFile(resolved, "utf-8");
822
- res.json({ path: filePath, content });
823
- } catch {
824
- res.status(404).json({ error: "File not found" });
1844
+ const { text } = await generateText({
1845
+ model: getModel(),
1846
+ maxOutputTokens: 1200,
1847
+ ...(isGoogle && {
1848
+ providerOptions: {
1849
+ google: {
1850
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
1851
+ },
1852
+ },
1853
+ }),
1854
+ system,
1855
+ prompt: `File: ${filePath || "unknown"}\n\nHighlighted code:\n\`\`\`\n${selectedCode}\n\`\`\`\n\nQuestion: ${prompt.trim()}`,
1856
+ tools:
1857
+ fileRegistry.size > 0
1858
+ ? {
1859
+ readFile: tool({
1860
+ description:
1861
+ "Read the content of an available reference file. Use this to get file contents when relevant to the question.",
1862
+ inputSchema: z.object({
1863
+ fileId: z
1864
+ .string()
1865
+ .describe(
1866
+ "The id of the file to read, from the available files list.",
1867
+ ),
1868
+ }),
1869
+ execute: async ({ fileId }) => {
1870
+ const entry = fileRegistry.get(fileId);
1871
+ if (!entry) return { error: "File not found" };
1872
+ try {
1873
+ const content = await entry.reader();
1874
+ return { fileName: entry.label, content };
1875
+ } catch {
1876
+ return { error: "Could not read file" };
1877
+ }
1878
+ },
1879
+ }),
1880
+ }
1881
+ : undefined,
1882
+ stopWhen: stepCountIs(4),
1883
+ });
1884
+
1885
+ res.json({ response: text });
1886
+ } catch (err: any) {
1887
+ console.error("code-line-ask error:", err?.message || err);
1888
+ res.status(500).json({ error: err?.message || "Failed to get response" });
825
1889
  }
826
1890
  });
827
1891
 
@@ -837,6 +1901,7 @@ app.post("/api/inline-ask", async (req, res) => {
837
1901
  responseLength,
838
1902
  responseStyle,
839
1903
  responseAudience,
1904
+ preferenceSuffix,
840
1905
  } = req.body;
841
1906
  if (typeof selectedText !== "string" || typeof prompt !== "string") {
842
1907
  return res
@@ -849,6 +1914,8 @@ app.post("/api/inline-ask", async (req, res) => {
849
1914
  (process.env.AI_PROVIDER || "openai").toLowerCase(),
850
1915
  );
851
1916
 
1917
+ const aiSettings = await storage.getAiSettings();
1918
+
852
1919
  // Build conversation thread context for follow-ups
853
1920
  let threadContext = "";
854
1921
  if (priorResponse) {
@@ -884,13 +1951,22 @@ app.post("/api/inline-ask", async (req, res) => {
884
1951
  systemLines.push(styleHints[responseStyle]);
885
1952
  if (responseAudience && audienceHints[responseAudience])
886
1953
  systemLines.push(audienceHints[responseAudience]);
1954
+ // preferenceSuffix carries ALL current group selections as pre-built text
1955
+ if (
1956
+ preferenceSuffix &&
1957
+ typeof preferenceSuffix === "string" &&
1958
+ preferenceSuffix.trim()
1959
+ )
1960
+ systemLines.push(preferenceSuffix.trim());
887
1961
 
888
1962
  const { text } = await generateText({
889
1963
  model: getModel(),
890
1964
  maxOutputTokens: 800,
891
1965
  ...(isGoogle && {
892
1966
  providerOptions: {
893
- google: { thinkingConfig: { thinkingBudget: 0 } },
1967
+ google: {
1968
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
1969
+ },
894
1970
  },
895
1971
  }),
896
1972
  system: systemLines.join("\n"),
@@ -927,13 +2003,13 @@ app.post("/api/fix-viz", async (req, res) => {
927
2003
 
928
2004
  const { text } = await generateText({
929
2005
  model: getModel(),
930
- maxOutputTokens: 1200,
2006
+ maxOutputTokens: 8000,
931
2007
  ...(isGoogle && {
932
2008
  providerOptions: {
933
2009
  google: { thinkingConfig: { thinkingBudget: 0 } },
934
2010
  },
935
2011
  }),
936
- prompt: `The following viz diagram spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the spec so it renders correctly. Common issues to check:\n- x/y coordinates must be numbers, not strings\n- node ids must be kebab-case with no spaces\n- chain arrays in autoSignals/signals must reference valid node ids\n- YAML flow sequences ([a, b]) must be correctly indented and closed\n- Do NOT mix autoSignals and steps in the same spec\n- 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}`,
2012
+ prompt: `The following viz diagram spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the spec so it renders correctly. Common issues to check:\n- x/y coordinates must be numbers, not strings\n- node ids must be kebab-case with no spaces\n- Truncated spec: if the spec looks cut off mid-label or mid-step, complete all missing steps before returning — output the full corrected spec.\n- Signal chain direction: a chain [A, B] animates along the edge FROM A TO B. That directed edge (from: A, to: B) MUST exist in the edges section.\n- chain arrays in autoSignals/signals must reference valid node ids\n- YAML flow sequences ([a, b]) must be correctly indented and closed\n- Do NOT mix autoSignals and steps in the same spec\n- Typography: replace en-dashes (–, U+2013) and em-dashes (—, U+2014) with a plain ASCII hyphen (-). Replace curly/smart quotes with straight ASCII quotes.\n- YAML string quoting: ALWAYS use double quotes for ALL string values — NEVER single quotes. Single-quoted YAML values (e.g. fill: '#fff') cause "Flow map must end with a }" parse errors in yaml v2. Every string in the spec must use double quotes. Wrong: fill: '#1e40af' Correct: fill: "#1e40af"\n- Inline flow maps must be single-line: any node written as { id: foo, x: 1, ... } MUST have its opening { and closing } on the SAME line with no line breaks inside. The yaml v2 parser throws "Flow map must end with a }" when a flow map spans multiple lines. If a flow map is too long to fit on one line, convert it to block style (indented key: value pairs), but NEVER split a single { ... } across lines.\n- Never split a label across multiple YAML lines unless using a literal block scalar (| or >)\n\nReturn ONLY the corrected YAML (or JSON) spec with no explanation and no markdown fences — just the raw spec.\n\nFailed spec:\n${spec}`,
937
2013
  });
938
2014
 
939
2015
  // Strip any fences the model might have added
@@ -949,6 +2025,107 @@ app.post("/api/fix-viz", async (req, res) => {
949
2025
  }
950
2026
  });
951
2027
 
2028
+ // ─── Fix Plot ───────────────────────────────────────────
2029
+
2030
+ app.post("/api/fix-plot", async (req, res) => {
2031
+ const { spec, error: renderError } = req.body;
2032
+ if (typeof spec !== "string" || !spec.trim()) {
2033
+ return res.status(400).json({ error: "spec is required" });
2034
+ }
2035
+
2036
+ try {
2037
+ const { plotGuide } = await storage.getAiSettings();
2038
+ const isGoogle = ["google", "gemini"].includes(
2039
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
2040
+ );
2041
+
2042
+ const { text } = await generateText({
2043
+ model: getModel(),
2044
+ maxOutputTokens: 4000,
2045
+ ...(isGoogle && {
2046
+ providerOptions: {
2047
+ google: { thinkingConfig: { thinkingBudget: 0 } },
2048
+ },
2049
+ }),
2050
+ prompt: `The following plot spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}${plotGuide ? `\n\nSupported plotting guide:\n${plotGuide}` : ""}\n\nFix the spec so it renders correctly as a Vega-Lite plot.\nRules:\n- Return ONLY the corrected JSON or YAML spec with no explanation and no markdown fences\n- The spec must describe a valid Vega-Lite chart\n- Prefer simple, valid encodings over clever transforms\n- Ensure x and y encodings include valid field/type definitions when needed\n- If the chart represents a mathematical curve, provide concrete sampled data points instead of symbolic expressions\n- Keep the dark-theme friendly defaults implicit; do not add custom JS callbacks or unsupported runtime code\n\nFailed spec:\n${spec}`,
2051
+ });
2052
+
2053
+ const fixed = text
2054
+ .replace(/^```(?:plot|vega|vega-lite|json|yaml)?\s*/i, "")
2055
+ .replace(/```\s*$/, "")
2056
+ .trim();
2057
+
2058
+ res.json({ spec: fixed });
2059
+ } catch (err: any) {
2060
+ console.error("fix-plot error:", err?.message || err);
2061
+ res.status(500).json({ error: err?.message || "Failed to fix plot" });
2062
+ }
2063
+ });
2064
+
2065
+ // ─── Refine Viz ─────────────────────────────────────────
2066
+
2067
+ app.post("/api/refine-viz", async (req, res) => {
2068
+ const { spec, prompt: userPrompt, history } = req.body;
2069
+ if (typeof spec !== "string" || !spec.trim()) {
2070
+ return res.status(400).json({ error: "spec is required" });
2071
+ }
2072
+ if (typeof userPrompt !== "string" || !userPrompt.trim()) {
2073
+ return res.status(400).json({ error: "prompt is required" });
2074
+ }
2075
+
2076
+ try {
2077
+ const { vizGuide } = await storage.getAiSettings();
2078
+ const isGoogle = ["google", "gemini"].includes(
2079
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
2080
+ );
2081
+
2082
+ const historyContext =
2083
+ Array.isArray(history) && history.length
2084
+ ? history
2085
+ .map(
2086
+ (h: { prompt: string; spec: string }, i: number) =>
2087
+ `[Refinement ${i + 1}] User asked: "${h.prompt}"\nResulting spec:\n${h.spec}`,
2088
+ )
2089
+ .join("\n\n---\n\n") + "\n\n---\n\n"
2090
+ : "";
2091
+
2092
+ const prompt = `You are editing a VizCraft diagram spec.${vizGuide ? `\n\n${vizGuide}` : ""}${historyContext ? `\nPrior refinements applied to this diagram:\n\n${historyContext}` : ""}
2093
+ Current spec:
2094
+ ${spec}
2095
+
2096
+ User instruction: ${userPrompt}
2097
+
2098
+ Apply the user's instruction to the spec. Return ONLY the updated YAML (or JSON) spec with no explanation and no markdown fences — just the raw spec.
2099
+ Rules:
2100
+ - Preserve all existing structure unless the user explicitly asks to remove something
2101
+ - If adding nodes, pick sensible x/y coordinates that fit the existing layout
2102
+ - YAML string quoting: wrap values with special characters (hyphens like --, colons, brackets) in double quotes, never single quotes
2103
+ - Node ids must be kebab-case with no spaces
2104
+ - Return the same format as the input (YAML or JSON)`;
2105
+
2106
+ const { text } = await generateText({
2107
+ model: getModel(),
2108
+ maxOutputTokens: 8000,
2109
+ ...(isGoogle && {
2110
+ providerOptions: {
2111
+ google: { thinkingConfig: { thinkingBudget: 0 } },
2112
+ },
2113
+ }),
2114
+ prompt,
2115
+ });
2116
+
2117
+ const refined = text
2118
+ .replace(/^```(?:yaml|json|viz)?\s*/i, "")
2119
+ .replace(/```\s*$/, "")
2120
+ .trim();
2121
+
2122
+ res.json({ spec: refined });
2123
+ } catch (err: any) {
2124
+ console.error("refine-viz error:", err?.message || err);
2125
+ res.status(500).json({ error: err?.message || "Failed to refine viz" });
2126
+ }
2127
+ });
2128
+
952
2129
  // ─── Fix Diagram ────────────────────────────────────────
953
2130
 
954
2131
  app.post("/api/fix-diagram", async (req, res) => {
@@ -987,6 +2164,531 @@ app.post("/api/fix-diagram", async (req, res) => {
987
2164
  }
988
2165
  });
989
2166
 
2167
+ // ─── Code Runner ────────────────────────────────────────────
2168
+
2169
+ const RUN_TIMEOUT_MS = 10_000;
2170
+
2171
+ app.post("/api/run-code", async (req, res) => {
2172
+ const { code, language = "typescript" } = req.body as {
2173
+ code: string;
2174
+ language?: string;
2175
+ };
2176
+
2177
+ if (typeof code !== "string" || code.trim().length === 0) {
2178
+ return res.status(400).json({ error: "No code provided" });
2179
+ }
2180
+
2181
+ // Transpile TS → JS if needed (ts.transpileModule is a zero-cost in-process call)
2182
+ let jsCode: string;
2183
+ try {
2184
+ if (language === "typescript") {
2185
+ const result = ts.transpileModule(code, {
2186
+ compilerOptions: {
2187
+ target: ts.ScriptTarget.ESNext,
2188
+ module: ts.ModuleKind.ESNext,
2189
+ strict: false,
2190
+ esModuleInterop: true,
2191
+ },
2192
+ });
2193
+ jsCode = result.outputText;
2194
+ } else {
2195
+ jsCode = code;
2196
+ }
2197
+ } catch (transpileErr: any) {
2198
+ return res.json({
2199
+ stdout: "",
2200
+ stderr: String(transpileErr?.message ?? transpileErr),
2201
+ durationMs: 0,
2202
+ timedOut: false,
2203
+ });
2204
+ }
2205
+
2206
+ // Write to a temp .mjs file and run it as a child process
2207
+ const tmpFile = path.join(os.tmpdir(), `runner-${randomUUID()}.mjs`);
2208
+ try {
2209
+ await fs.writeFile(tmpFile, jsCode, "utf8");
2210
+ } catch {
2211
+ return res.status(500).json({ error: "Failed to write temp file" });
2212
+ }
2213
+
2214
+ const stdoutLines: string[] = [];
2215
+ const stderrLines: string[] = [];
2216
+ let timedOut = false;
2217
+ const start = Date.now();
2218
+
2219
+ await new Promise<void>((resolve) => {
2220
+ const child = spawn(process.execPath, [tmpFile], {
2221
+ timeout: RUN_TIMEOUT_MS,
2222
+ env: { ...process.env, NODE_NO_WARNINGS: "1" },
2223
+ });
2224
+
2225
+ const killTimer = setTimeout(() => {
2226
+ timedOut = true;
2227
+ child.kill("SIGKILL");
2228
+ resolve();
2229
+ }, RUN_TIMEOUT_MS);
2230
+
2231
+ child.stdout.on("data", (chunk: Buffer) => {
2232
+ stdoutLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
2233
+ });
2234
+ child.stderr.on("data", (chunk: Buffer) => {
2235
+ stderrLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
2236
+ });
2237
+ child.on("close", () => {
2238
+ clearTimeout(killTimer);
2239
+ resolve();
2240
+ });
2241
+ });
2242
+
2243
+ // Clean up temp file (best-effort)
2244
+ fs.unlink(tmpFile).catch(() => {});
2245
+
2246
+ res.json({
2247
+ stdout: stdoutLines.join(""),
2248
+ stderr: stderrLines.join(""),
2249
+ durationMs: Date.now() - start,
2250
+ timedOut,
2251
+ });
2252
+ });
2253
+
2254
+ // ─── Sandbox: persistent server + client runner ────────────
2255
+
2256
+ interface SandboxEntry {
2257
+ child: import("child_process").ChildProcess;
2258
+ port: number;
2259
+ tmpFile: string;
2260
+ logs: string[];
2261
+ }
2262
+
2263
+ const sandboxes = new Map<string, SandboxEntry>();
2264
+ const SANDBOX_DIR = path.join(__dirname, "..", ".sandbox-tmp");
2265
+
2266
+ // ── Next.js real-server sandboxes ────────────────────────────────────────────
2267
+ // We symlink node_modules from the pre-installed npx cache so there's
2268
+ // zero npm-install cost per sandbox.
2269
+ interface NextSandboxEntry {
2270
+ child: ReturnType<typeof spawn>;
2271
+ port: number;
2272
+ dir: string;
2273
+ logs: string[];
2274
+ ready: boolean;
2275
+ }
2276
+ const nextSandboxes = new Map<string, NextSandboxEntry>();
2277
+ const NEXT_NPX_DIR = path.join(os.homedir(), ".npm/_npx/8b377f6eec906bc4");
2278
+ const NEXT_MODULES_DIR = path.join(NEXT_NPX_DIR, "node_modules");
2279
+ // Sandboxes live INSIDE the npx cache dir so Next.js finds node_modules by
2280
+ // walking up the directory tree — no symlinks needed, no Turbopack restrictions.
2281
+ const NEXT_SANDBOX_BASE = path.join(NEXT_NPX_DIR, ".sandboxes");
2282
+
2283
+ /** Write all user files and necessary config into a sandbox directory. */
2284
+ async function writeNextSandboxFiles(
2285
+ dir: string,
2286
+ files: Record<string, string>,
2287
+ ) {
2288
+ // package.json — needed so Next.js can resolve its own packages
2289
+ await fs.writeFile(
2290
+ path.join(dir, "package.json"),
2291
+ JSON.stringify(
2292
+ {
2293
+ name: "nextjs-lab",
2294
+ private: true,
2295
+ type: "module",
2296
+ dependencies: {
2297
+ next: "^16.2.4",
2298
+ react: "^19.0.0",
2299
+ "react-dom": "^19.0.0",
2300
+ },
2301
+ },
2302
+ null,
2303
+ 2,
2304
+ ),
2305
+ );
2306
+ // next.config.ts — set turbopack.root to the NPX cache dir (parent of both
2307
+ // node_modules and .sandboxes). This lets Turbopack find next/package.json
2308
+ // in node_modules while watching files within the sandbox subdirectory.
2309
+ // Setting it to the sandbox dir itself would block node_modules resolution
2310
+ // since node_modules sits above the sandbox root boundary.
2311
+ await fs.writeFile(
2312
+ path.join(dir, "next.config.ts"),
2313
+ `import type { NextConfig } from "next";
2314
+ const c: NextConfig = {
2315
+ turbopack: { root: ${JSON.stringify(NEXT_NPX_DIR)} },
2316
+ async headers() {
2317
+ return [{ source: "/(.*)", headers: [{ key: "X-Frame-Options", value: "ALLOWALL" }] }];
2318
+ },
2319
+ };
2320
+ export default c;\n`,
2321
+ );
2322
+ // tsconfig so Next.js TypeScript support works
2323
+ await fs.writeFile(
2324
+ path.join(dir, "tsconfig.json"),
2325
+ JSON.stringify(
2326
+ {
2327
+ compilerOptions: {
2328
+ lib: ["dom", "dom.iterable", "esnext"],
2329
+ allowJs: true,
2330
+ skipLibCheck: true,
2331
+ strict: false,
2332
+ noEmit: true,
2333
+ esModuleInterop: true,
2334
+ module: "esnext",
2335
+ moduleResolution: "bundler",
2336
+ resolveJsonModule: true,
2337
+ isolatedModules: true,
2338
+ jsx: "preserve",
2339
+ incremental: true,
2340
+ plugins: [{ name: "next" }],
2341
+ baseUrl: ".",
2342
+ },
2343
+ include: ["**/*.ts", "**/*.tsx"],
2344
+ exclude: ["node_modules"],
2345
+ },
2346
+ null,
2347
+ 2,
2348
+ ),
2349
+ );
2350
+ // User files
2351
+ for (const [filePath, content] of Object.entries(files)) {
2352
+ const fullPath = path.join(dir, filePath);
2353
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
2354
+ await fs.writeFile(fullPath, content, "utf8");
2355
+ }
2356
+ }
2357
+
2358
+ app.post("/api/nextjs/start", async (req, res) => {
2359
+ const { files } = req.body as { files: Record<string, string> };
2360
+ if (!files || typeof files !== "object") {
2361
+ return res.status(400).json({ error: "files is required" });
2362
+ }
2363
+
2364
+ // Use the actual file path (not the .bin symlink) so Node's __filename resolves
2365
+ // correctly and internal require("../server/require-hook") works.
2366
+ const nextBin = path.join(NEXT_MODULES_DIR, "next", "dist", "bin", "next");
2367
+ const nextExists = await fs
2368
+ .access(nextBin)
2369
+ .then(() => true)
2370
+ .catch(() => false);
2371
+ if (!nextExists) {
2372
+ return res.status(503).json({
2373
+ error:
2374
+ "Next.js is not available in the npx cache. Run `npx next --version` once to install it.",
2375
+ });
2376
+ }
2377
+
2378
+ const id = randomUUID();
2379
+ const dir = path.join(NEXT_SANDBOX_BASE, id);
2380
+ await fs.mkdir(dir, { recursive: true });
2381
+
2382
+ await writeNextSandboxFiles(dir, files);
2383
+
2384
+ const port = await getFreePort();
2385
+ const logs: string[] = [];
2386
+ // Run via `node <actual-bin>` — sandbox dir is inside the npx cache so
2387
+ // node_modules is resolved naturally two levels up; no symlinks required.
2388
+ const child = spawn(
2389
+ process.execPath,
2390
+ [nextBin, "dev", "--port", String(port)],
2391
+ {
2392
+ cwd: dir,
2393
+ env: {
2394
+ ...process.env,
2395
+ NEXT_TELEMETRY_DISABLED: "1",
2396
+ NODE_NO_WARNINGS: "1",
2397
+ },
2398
+ },
2399
+ );
2400
+
2401
+ const entry: NextSandboxEntry = {
2402
+ child,
2403
+ port,
2404
+ dir,
2405
+ logs,
2406
+ ready: false,
2407
+ };
2408
+ nextSandboxes.set(id, entry);
2409
+
2410
+ const markReady = (text: string) => {
2411
+ if (!entry.ready && /ready|started server on|Local:/i.test(text)) {
2412
+ entry.ready = true;
2413
+ }
2414
+ };
2415
+ child.stdout.on("data", (chunk: Buffer) => {
2416
+ const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
2417
+ logs.push(t);
2418
+ markReady(t);
2419
+ });
2420
+ child.stderr.on("data", (chunk: Buffer) => {
2421
+ const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
2422
+ logs.push(t);
2423
+ markReady(t);
2424
+ });
2425
+ child.on("exit", () => {
2426
+ nextSandboxes.delete(id);
2427
+ fs.rm(dir, { recursive: true, force: true }).catch(() => {});
2428
+ });
2429
+
2430
+ // Wait up to 30 s for Next.js to be ready
2431
+ const deadline = Date.now() + 30_000;
2432
+ while (!entry.ready && Date.now() < deadline) {
2433
+ await new Promise((r) => setTimeout(r, 400));
2434
+ if (!nextSandboxes.has(id)) {
2435
+ return res
2436
+ .status(500)
2437
+ .json({ error: logs.join("").trim() || "Next.js server exited" });
2438
+ }
2439
+ }
2440
+ if (!entry.ready) {
2441
+ return res
2442
+ .status(504)
2443
+ .json({ error: "Next.js did not start in time", logs });
2444
+ }
2445
+
2446
+ res.json({ id, port, url: `http://localhost:${port}` });
2447
+ });
2448
+
2449
+ app.post("/api/nextjs/:id/update-files", async (req, res) => {
2450
+ const sb = nextSandboxes.get(req.params.id);
2451
+ if (!sb) return res.status(404).json({ error: "Sandbox not found" });
2452
+ const { files } = req.body as { files: Record<string, string> };
2453
+ if (!files) return res.status(400).json({ error: "files is required" });
2454
+ for (const [filePath, content] of Object.entries(files)) {
2455
+ const fullPath = path.join(sb.dir, filePath);
2456
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
2457
+ await fs.writeFile(fullPath, content, "utf8");
2458
+ }
2459
+ res.json({ ok: true });
2460
+ });
2461
+
2462
+ app.get("/api/nextjs/:id/status", (req, res) => {
2463
+ const sb = nextSandboxes.get(req.params.id);
2464
+ if (!sb) return res.json({ running: false });
2465
+ res.json({
2466
+ running: true,
2467
+ ready: sb.ready,
2468
+ port: sb.port,
2469
+ logs: sb.logs.slice(-30),
2470
+ });
2471
+ });
2472
+
2473
+ app.delete("/api/nextjs/:id", async (req, res) => {
2474
+ const sb = nextSandboxes.get(req.params.id);
2475
+ if (sb) {
2476
+ sb.child.kill("SIGTERM");
2477
+ nextSandboxes.delete(req.params.id);
2478
+ await fs.rm(sb.dir, { recursive: true, force: true }).catch(() => {});
2479
+ }
2480
+ res.json({ ok: true });
2481
+ });
2482
+
2483
+ async function getFreePort(): Promise<number> {
2484
+ return new Promise((resolve, reject) => {
2485
+ const srv = net.createServer();
2486
+ srv.listen(0, "127.0.0.1", () => {
2487
+ const addr = srv.address() as net.AddressInfo;
2488
+ srv.close(() => resolve(addr.port));
2489
+ });
2490
+ srv.on("error", reject);
2491
+ });
2492
+ }
2493
+
2494
+ function transpileToJs(code: string, language: string): string {
2495
+ if (language === "typescript") {
2496
+ return ts.transpileModule(code, {
2497
+ compilerOptions: {
2498
+ target: ts.ScriptTarget.ESNext,
2499
+ module: ts.ModuleKind.ESNext,
2500
+ strict: false,
2501
+ esModuleInterop: true,
2502
+ },
2503
+ }).outputText;
2504
+ }
2505
+ return code;
2506
+ }
2507
+
2508
+ app.post("/api/sandbox/start", async (req, res) => {
2509
+ const { serverCode, language = "typescript" } = req.body as {
2510
+ serverCode: string;
2511
+ language?: string;
2512
+ };
2513
+ if (typeof serverCode !== "string" || !serverCode.trim()) {
2514
+ return res.status(400).json({ error: "serverCode is required" });
2515
+ }
2516
+ let jsCode: string;
2517
+ try {
2518
+ jsCode = transpileToJs(serverCode, language);
2519
+ } catch (err: any) {
2520
+ return res.status(400).json({ error: err?.message ?? String(err) });
2521
+ }
2522
+
2523
+ const port = await getFreePort();
2524
+ const id = randomUUID();
2525
+ await fs.mkdir(SANDBOX_DIR, { recursive: true });
2526
+ const tmpFile = path.join(SANDBOX_DIR, `${id}.mjs`);
2527
+ await fs.writeFile(tmpFile, jsCode, "utf8");
2528
+
2529
+ const logs: string[] = [];
2530
+ const child = spawn(process.execPath, [tmpFile], {
2531
+ env: { ...process.env, PORT: String(port), NODE_NO_WARNINGS: "1" },
2532
+ });
2533
+ child.stdout.on("data", (chunk: Buffer) =>
2534
+ logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
2535
+ );
2536
+ child.stderr.on("data", (chunk: Buffer) =>
2537
+ logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
2538
+ );
2539
+ child.on("exit", () => {
2540
+ sandboxes.delete(id);
2541
+ fs.unlink(tmpFile).catch(() => {});
2542
+ });
2543
+ sandboxes.set(id, { child, port, tmpFile, logs });
2544
+
2545
+ // Give the server a moment to bind (or fail)
2546
+ await new Promise((r) => setTimeout(r, 900));
2547
+ if (!sandboxes.has(id)) {
2548
+ return res.status(500).json({
2549
+ error: logs.join("").trim() || "Server exited immediately",
2550
+ });
2551
+ }
2552
+ res.json({ sandboxId: id, port, sandboxUrl: `http://localhost:${port}` });
2553
+ });
2554
+
2555
+ app.get("/api/sandbox/:id/status", (req, res) => {
2556
+ const sb = sandboxes.get(req.params.id);
2557
+ if (!sb) return res.json({ running: false, logs: [] });
2558
+ res.json({ running: true, port: sb.port, logs: sb.logs });
2559
+ });
2560
+
2561
+ app.delete("/api/sandbox/:id", (req, res) => {
2562
+ const sb = sandboxes.get(req.params.id);
2563
+ if (sb) {
2564
+ sb.child.kill("SIGTERM");
2565
+ sandboxes.delete(req.params.id);
2566
+ fs.unlink(sb.tmpFile).catch(() => {});
2567
+ }
2568
+ res.json({ ok: true });
2569
+ });
2570
+
2571
+ app.post("/api/sandbox/run-client", async (req, res) => {
2572
+ const {
2573
+ code,
2574
+ language = "javascript",
2575
+ sandboxUrl,
2576
+ } = req.body as {
2577
+ code: string;
2578
+ language?: string;
2579
+ sandboxUrl: string;
2580
+ };
2581
+ if (typeof code !== "string" || !code.trim()) {
2582
+ return res.status(400).json({ error: "code is required" });
2583
+ }
2584
+ const eventSourcePolyfill = `
2585
+ if (typeof EventSource === 'undefined') {
2586
+ const { default: _http } = await import('http');
2587
+ const { default: _https } = await import('https');
2588
+ const { EventEmitter: _EE } = await import('events');
2589
+ globalThis.EventSource = class EventSource extends _EE {
2590
+ constructor(url) {
2591
+ super();
2592
+ // Resolve relative URLs against SANDBOX_URL (e.g. '/api/foo' → 'http://localhost:PORT/api/foo')
2593
+ const _resolved = (url.startsWith('http://') || url.startsWith('https://'))
2594
+ ? url
2595
+ : SANDBOX_URL + (url.startsWith('/') ? url : '/' + url);
2596
+ this.url = _resolved; this.readyState = 0;
2597
+ const _mod = _resolved.startsWith('https') ? _https : _http;
2598
+ this._req = _mod.get(_resolved, { headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' } }, (res) => {
2599
+ this.readyState = 1;
2600
+ let _buf = '';
2601
+ res.on('data', (chunk) => {
2602
+ _buf += chunk.toString();
2603
+ const _msgs = _buf.split('\\n\\n');
2604
+ _buf = _msgs.pop() ?? '';
2605
+ for (const _b of _msgs) {
2606
+ if (!_b.trim()) continue;
2607
+ let _data = '', _type = 'message';
2608
+ for (const _l of _b.split('\\n')) {
2609
+ if (_l.startsWith('data:')) _data += (_data ? '\\n' : '') + _l.slice(5).trimStart();
2610
+ else if (_l.startsWith('event:')) _type = _l.slice(6).trimStart();
2611
+ }
2612
+ const _e = { type: _type, data: _data };
2613
+ if (_type === 'message' && this.onmessage) this.onmessage(_e);
2614
+ this.emit(_type, _e);
2615
+ }
2616
+ });
2617
+ res.on('end', () => { this.readyState = 2; });
2618
+ });
2619
+ this._req.on('error', () => { this.readyState = 2; });
2620
+ }
2621
+ close() { try { this._req?.destroy(); } catch {} this.readyState = 2; }
2622
+ addEventListener(t, fn) { this.on(t, fn); }
2623
+ removeEventListener(t, fn) { this.off(t, fn); }
2624
+ };
2625
+ }`;
2626
+ const wrapped = `const SANDBOX_URL = ${JSON.stringify(sandboxUrl ?? "")};
2627
+ ${eventSourcePolyfill}
2628
+ ${code}`;
2629
+ let jsCode: string;
2630
+ try {
2631
+ jsCode = transpileToJs(wrapped, language);
2632
+ } catch (err: any) {
2633
+ return res.json({
2634
+ stdout: "",
2635
+ stderr: String(err?.message ?? err),
2636
+ durationMs: 0,
2637
+ timedOut: false,
2638
+ });
2639
+ }
2640
+
2641
+ // Switch to SSE streaming so each log line reaches the browser immediately
2642
+ res.setHeader("Content-Type", "text/event-stream");
2643
+ res.setHeader("Cache-Control", "no-cache");
2644
+ res.setHeader("Connection", "keep-alive");
2645
+ res.flushHeaders();
2646
+
2647
+ const sendEvent = (kind: string, text: string) => {
2648
+ res.write(`data: ${JSON.stringify({ kind, text })}\n\n`);
2649
+ };
2650
+
2651
+ const tmpFile = path.join(os.tmpdir(), `sandbox-client-${randomUUID()}.mjs`);
2652
+ await fs.writeFile(tmpFile, jsCode, "utf8");
2653
+
2654
+ let timedOut = false;
2655
+ const start = Date.now();
2656
+
2657
+ await new Promise<void>((resolve) => {
2658
+ const child = spawn(process.execPath, [tmpFile], {
2659
+ env: { ...process.env, NODE_NO_WARNINGS: "1" },
2660
+ });
2661
+ const killTimer = setTimeout(() => {
2662
+ timedOut = true;
2663
+ child.kill("SIGKILL");
2664
+ resolve();
2665
+ }, RUN_TIMEOUT_MS);
2666
+
2667
+ // Stream each chunk as it arrives — split on newlines so each log line is its own event
2668
+ const pushChunks = (chunk: Buffer, kind: "stdout" | "stderr") => {
2669
+ chunk
2670
+ .toString()
2671
+ .split("\n")
2672
+ .forEach((line) => {
2673
+ if (line !== "") sendEvent(kind, line);
2674
+ });
2675
+ };
2676
+ child.stdout.on("data", (chunk: Buffer) => pushChunks(chunk, "stdout"));
2677
+ child.stderr.on("data", (chunk: Buffer) => pushChunks(chunk, "stderr"));
2678
+ child.on("close", (code) => {
2679
+ clearTimeout(killTimer);
2680
+ resolve();
2681
+ });
2682
+ });
2683
+
2684
+ fs.unlink(tmpFile).catch(() => {});
2685
+ sendEvent(
2686
+ "done",
2687
+ JSON.stringify({ timedOut, durationMs: Date.now() - start }),
2688
+ );
2689
+ res.end();
2690
+ });
2691
+
990
2692
  // ─── Start ───────────────────────────────────────────────
991
2693
 
992
2694
  (async () => {