create-interview-cockpit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +321 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +419 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1601 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +477 -0
  23. package/template/client/src/store.ts +219 -6
  24. package/template/client/src/types.ts +35 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/server/src/google-drive.ts +37 -3
  27. package/template/server/src/index.ts +693 -52
  28. package/template/server/src/infra-runner.ts +1104 -0
  29. package/template/server/src/storage.ts +13 -3
@@ -30,6 +30,12 @@ import net from "net";
30
30
  import ts from "typescript";
31
31
  import * as storage from "./storage.js";
32
32
  import * as googleDrive from "./google-drive.js";
33
+ import {
34
+ getInfraRun,
35
+ listInfraRuns,
36
+ runInfraAction,
37
+ streamInfraCommand,
38
+ } from "./infra-runner.js";
33
39
 
34
40
  const app = express();
35
41
  app.use(cors());
@@ -38,14 +44,14 @@ app.use(express.json({ limit: "25mb" }));
38
44
  const upload = multer({
39
45
  limits: { fileSize: 20 * 1024 * 1024 }, // 20MB max
40
46
  fileFilter: (_req, file, cb) => {
41
- 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) {
46
- cb(null, true);
47
+ // Block only clearly non-text binary formats (executables, archives, etc.)
48
+ const blockedExt =
49
+ /\.(exe|dll|so|dylib|bin|zip|tar|gz|bz2|rar|7z|dmg|iso|mp3|mp4|avi|mov|wmv|flac|wav|class|pyc|wasm)$/i;
50
+ if (blockedExt.test(file.originalname)) {
51
+ cb(new Error(`Unsupported file type: ${file.originalname}`));
47
52
  } else {
48
- cb(new Error("Unsupported file type"));
53
+ // Accept everything else — extractText handles unknown extensions gracefully
54
+ cb(null, true);
49
55
  }
50
56
  },
51
57
  });
@@ -226,12 +232,24 @@ app.post("/api/workspaces/:id/drive-subfolders", async (req, res) => {
226
232
  const { name } = req.body as { name?: string };
227
233
  if (!name?.trim()) return res.status(400).json({ error: "name required" });
228
234
  try {
235
+ if (!(await googleDrive.isExportAuthed())) {
236
+ return res.json({
237
+ needsAuth: true,
238
+ authUrl: googleDrive.getExportAuthUrl(),
239
+ });
240
+ }
229
241
  const folder = await googleDrive.createDriveSubfolder(
230
242
  req.params.id,
231
243
  name.trim(),
232
244
  );
233
245
  res.json(folder);
234
246
  } catch (err: any) {
247
+ if (err?.needsReauth) {
248
+ return res.json({
249
+ needsAuth: true,
250
+ authUrl: googleDrive.getExportAuthUrl(),
251
+ });
252
+ }
235
253
  res.status(500).json({ error: err?.message || "Failed to create folder" });
236
254
  }
237
255
  });
@@ -363,6 +381,8 @@ app.patch("/api/topics/:id", async (req, res) => {
363
381
  const topic = topics.find((t) => t.id === req.params.id);
364
382
  if (!topic) return res.status(404).json({ error: "Not found" });
365
383
  if (typeof req.body.name === "string") topic.name = req.body.name;
384
+ if (typeof req.body.systemContext === "string")
385
+ topic.systemContext = req.body.systemContext;
366
386
  await storage.saveTopic(topic);
367
387
  res.json(topic);
368
388
  });
@@ -374,33 +394,29 @@ app.get("/api/workspace/context-files", async (_req, res) => {
374
394
  res.json(files);
375
395
  });
376
396
 
377
- app.post(
378
- "/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" });
397
+ app.post("/api/workspace/context-files", upload.any(), async (req, res) => {
398
+ try {
399
+ const files = req.files as Express.Multer.File[];
400
+ if (!files?.length) return res.status(400).json({ error: "No files" });
401
+ const results: storage.ContextFile[] = [];
402
+ for (const file of files) {
403
+ const id = randomUUID();
404
+ const text = await extractText(file.buffer, file.originalname);
405
+ // Store original bytes for download; extracted text for the LLM
406
+ await storage.writeOriginalBlob(id, file.buffer);
407
+ const cf = await storage.saveWorkspaceContextFile(
408
+ id,
409
+ file.originalname,
410
+ Buffer.from(text, "utf-8"),
411
+ );
412
+ results.push(cf);
401
413
  }
402
- },
403
- );
414
+ res.json(results);
415
+ } catch (err: any) {
416
+ console.error("workspace upload error:", err?.message || err);
417
+ res.status(500).json({ error: err?.message || "Upload failed" });
418
+ }
419
+ });
404
420
 
405
421
  app.delete("/api/workspace/context-files/:fileId", async (req, res) => {
406
422
  await storage.deleteWorkspaceContextFile(req.params.fileId);
@@ -440,7 +456,7 @@ app.get("/api/workspace/context-files/:fileId/download", async (req, res) => {
440
456
 
441
457
  app.post(
442
458
  "/api/topics/:topicId/context-files",
443
- upload.array("files", 20),
459
+ upload.any(),
444
460
  async (req, res) => {
445
461
  try {
446
462
  const files = req.files as Express.Multer.File[];
@@ -538,12 +554,45 @@ app.patch("/api/questions/:id", async (req, res) => {
538
554
  if (req.body.systemContext !== undefined)
539
555
  q.systemContext = req.body.systemContext;
540
556
  if (req.body.title !== undefined) q.title = req.body.title;
541
- if (req.body.parentQuestionId !== undefined)
542
- 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
+ }
543
590
  if (req.body.messages !== undefined) q.messages = req.body.messages;
544
591
  if (req.body.annotations !== undefined) q.annotations = req.body.annotations;
545
592
  if (req.body.readingBookmark !== undefined)
546
593
  q.readingBookmark = req.body.readingBookmark;
594
+ if (req.body.linkedConversationIds !== undefined)
595
+ q.linkedConversationIds = req.body.linkedConversationIds;
547
596
  await storage.saveQuestion(q);
548
597
  res.json(q);
549
598
  });
@@ -598,7 +647,7 @@ app.patch("/api/questions/:id/code-annotations", async (req, res) => {
598
647
 
599
648
  app.post(
600
649
  "/api/questions/:questionId/context-files",
601
- upload.array("files", 20),
650
+ upload.any(),
602
651
  async (req, res) => {
603
652
  try {
604
653
  const files = req.files as Express.Multer.File[];
@@ -641,15 +690,23 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
641
690
  code: string;
642
691
  language: string;
643
692
  label: string;
644
- origin: "user" | "ai" | "sandbox";
693
+ origin: "user" | "ai" | "sandbox" | "infra" | "react" | "nextjs";
645
694
  };
646
695
  if (typeof code !== "string" || !code.trim()) {
647
696
  return res.status(400).json({ error: "code is required" });
648
697
  }
649
- if (origin !== "user" && origin !== "ai" && origin !== "sandbox") {
650
- return res
651
- .status(400)
652
- .json({ error: "origin must be 'user', 'ai', or 'sandbox'" });
698
+ if (
699
+ origin !== "user" &&
700
+ origin !== "ai" &&
701
+ origin !== "sandbox" &&
702
+ origin !== "infra" &&
703
+ origin !== "react" &&
704
+ origin !== "nextjs"
705
+ ) {
706
+ return res.status(400).json({
707
+ error:
708
+ "origin must be 'user', 'ai', 'sandbox', 'infra', 'react', or 'nextjs'",
709
+ });
653
710
  }
654
711
  try {
655
712
  const id = randomUUID();
@@ -659,7 +716,9 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
659
716
  ? "ts"
660
717
  : language === "javascript"
661
718
  ? "js"
662
- : "txt";
719
+ : language === "infra"
720
+ ? "infra.json"
721
+ : "txt";
663
722
  const fileName = `${safeLabel}.${ext}`;
664
723
  const cf = await storage.saveQuestionContextFile(
665
724
  req.params.questionId,
@@ -784,6 +843,288 @@ app.get("/api/context-files/all", async (_req, res) => {
784
843
  res.json(deduped);
785
844
  });
786
845
 
846
+ app.post("/api/infra/run", async (req, res) => {
847
+ const { questionId, fileId, label, action, workspace } = req.body as {
848
+ questionId?: string;
849
+ fileId?: string;
850
+ label?: string;
851
+ action?: "validate" | "plan";
852
+ workspace?: unknown;
853
+ };
854
+
855
+ if (action !== "validate" && action !== "plan") {
856
+ return res
857
+ .status(400)
858
+ .json({ error: "action must be 'validate' or 'plan'" });
859
+ }
860
+
861
+ try {
862
+ const run = await runInfraAction({
863
+ questionId,
864
+ fileId,
865
+ label,
866
+ action,
867
+ workspace,
868
+ });
869
+ res.json(run);
870
+ } catch (err: any) {
871
+ res
872
+ .status(400)
873
+ .json({ error: err?.message || "Failed to run infra action" });
874
+ }
875
+ });
876
+
877
+ // ─── Infra Lab AI Ask (streaming) ───────────────────────────────────────────
878
+ app.post("/api/infra/ask", async (req, res) => {
879
+ const { messages, workspace, questionId } = req.body as {
880
+ messages?: Array<{ role: "user" | "assistant"; content: string }>;
881
+ workspace?: Record<string, string>; // { "main.tf": "..." }
882
+ questionId?: string;
883
+ };
884
+
885
+ if (!Array.isArray(messages) || messages.length === 0) {
886
+ return res.status(400).json({ error: "messages is required" });
887
+ }
888
+
889
+ const isGoogle = ["google", "gemini"].includes(
890
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
891
+ );
892
+ const aiSettings = await storage.getAiSettings();
893
+
894
+ // Build workspace context block
895
+ let workspaceBlock = "";
896
+ if (workspace && typeof workspace === "object") {
897
+ const entries = Object.entries(workspace).filter(
898
+ ([k, v]) => typeof k === "string" && typeof v === "string",
899
+ );
900
+ if (entries.length > 0) {
901
+ workspaceBlock = "\n\n--- Workspace Files ---\n";
902
+ for (const [name, content] of entries) {
903
+ workspaceBlock += `\n### ${name}\n\`\`\`hcl\n${content}\n\`\`\`\n`;
904
+ }
905
+ }
906
+ }
907
+
908
+ const system =
909
+ `You are a senior infrastructure engineer and Terraform expert acting as a coding assistant inside an Infrastructure Lab.\n` +
910
+ `The user is editing a Terraform workspace. Answer questions about their code clearly and accurately.\n` +
911
+ `When suggesting edits, show the changed HCL as a fenced \`\`\`hcl code block.\n` +
912
+ `Be concise unless the user asks for a deeper explanation.` +
913
+ workspaceBlock;
914
+
915
+ try {
916
+ const result = streamText({
917
+ model: getModel(),
918
+ maxOutputTokens: 2000,
919
+ ...(isGoogle && {
920
+ providerOptions: {
921
+ google: {
922
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
923
+ },
924
+ },
925
+ }),
926
+ system,
927
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
928
+ });
929
+
930
+ result.pipeUIMessageStreamToResponse(res);
931
+ } catch (err: any) {
932
+ console.error("infra/ask error:", err?.message || err);
933
+ if (!res.headersSent) {
934
+ res
935
+ .status(500)
936
+ .json({ error: err?.message || "Failed to generate response" });
937
+ }
938
+ }
939
+ });
940
+
941
+ app.post("/api/notes/ask", async (req, res) => {
942
+ const { messages, noteContent, noteName } = req.body as {
943
+ messages?: Array<{ role: "user" | "assistant"; content: string }>;
944
+ noteContent?: string;
945
+ noteName?: string;
946
+ };
947
+
948
+ if (!Array.isArray(messages) || messages.length === 0) {
949
+ return res.status(400).json({ error: "messages is required" });
950
+ }
951
+
952
+ const isGoogle = ["google", "gemini"].includes(
953
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
954
+ );
955
+ const aiSettings = await storage.getAiSettings();
956
+
957
+ const noteBlock = noteContent?.trim()
958
+ ? `\n\n--- Note: ${noteName || "Untitled"} ---\n${noteContent}\n---`
959
+ : "";
960
+
961
+ const system =
962
+ `You are a knowledgeable study assistant helping the user understand and improve their notes.\n` +
963
+ `Answer questions about the note content clearly. Help the user expand on ideas, clarify concepts, or drill deeper.\n` +
964
+ `Be concise unless the user asks for a deeper explanation.` +
965
+ noteBlock;
966
+
967
+ try {
968
+ const result = streamText({
969
+ model: getModel(),
970
+ maxOutputTokens: 2000,
971
+ ...(isGoogle && {
972
+ providerOptions: {
973
+ google: {
974
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
975
+ },
976
+ },
977
+ }),
978
+ system,
979
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
980
+ });
981
+
982
+ result.pipeUIMessageStreamToResponse(res);
983
+ } catch (err: any) {
984
+ console.error("notes/ask error:", err?.message || err);
985
+ if (!res.headersSent) {
986
+ res
987
+ .status(500)
988
+ .json({ error: err?.message || "Failed to generate response" });
989
+ }
990
+ }
991
+ });
992
+
993
+ app.post("/api/frontend-lab/ask", async (req, res) => {
994
+ const { messages, workspace, labType, questionId } = req.body as {
995
+ messages?: Array<{ role: "user" | "assistant"; content: string }>;
996
+ workspace?: Record<string, string>;
997
+ labType?: "react" | "nextjs";
998
+ questionId?: string;
999
+ };
1000
+
1001
+ if (!Array.isArray(messages) || messages.length === 0) {
1002
+ return res.status(400).json({ error: "messages is required" });
1003
+ }
1004
+
1005
+ const isGoogle = ["google", "gemini"].includes(
1006
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
1007
+ );
1008
+ const aiSettings = await storage.getAiSettings();
1009
+
1010
+ const typeLabel =
1011
+ labType === "nextjs" ? "Next.js App Router" : "React + TypeScript";
1012
+
1013
+ let workspaceBlock = "";
1014
+ if (workspace && typeof workspace === "object") {
1015
+ const entries = Object.entries(workspace).filter(
1016
+ ([k, v]) => typeof k === "string" && typeof v === "string",
1017
+ );
1018
+ if (entries.length > 0) {
1019
+ workspaceBlock = "\n\n--- Workspace Files ---\n";
1020
+ for (const [name, content] of entries) {
1021
+ workspaceBlock += `\n### ${name}\n\`\`\`tsx\n${content}\n\`\`\`\n`;
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ const system =
1027
+ `You are an expert ${typeLabel} tutor acting as a coding assistant inside a hands-on lab.\n` +
1028
+ `The user is practising ${typeLabel} concepts. Help them understand their code, fix bugs, and learn best practices.\n` +
1029
+ `When suggesting code changes, use fenced TypeScript/TSX code blocks.\n` +
1030
+ `Be concise unless the user asks for deeper explanation.` +
1031
+ workspaceBlock;
1032
+
1033
+ try {
1034
+ const result = streamText({
1035
+ model: getModel(),
1036
+ maxOutputTokens: 2000,
1037
+ ...(isGoogle && {
1038
+ providerOptions: {
1039
+ google: {
1040
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
1041
+ },
1042
+ },
1043
+ }),
1044
+ system,
1045
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
1046
+ });
1047
+
1048
+ result.pipeUIMessageStreamToResponse(res);
1049
+ } catch (err: any) {
1050
+ console.error("frontend-lab/ask error:", err?.message || err);
1051
+ if (!res.headersSent) {
1052
+ res
1053
+ .status(500)
1054
+ .json({ error: err?.message || "Failed to generate response" });
1055
+ }
1056
+ }
1057
+ });
1058
+
1059
+ app.post("/api/infra/command-stream", async (req, res) => {
1060
+ const { questionId, fileId, label, command, workspace } = req.body as {
1061
+ questionId?: string;
1062
+ fileId?: string;
1063
+ label?: string;
1064
+ command?: string;
1065
+ workspace?: unknown;
1066
+ };
1067
+
1068
+ res.setHeader("Content-Type", "text/event-stream");
1069
+ res.setHeader("Cache-Control", "no-cache");
1070
+ res.setHeader("Connection", "keep-alive");
1071
+ res.flushHeaders();
1072
+
1073
+ const send = (payload: unknown) => {
1074
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
1075
+ };
1076
+
1077
+ if (typeof command !== "string" || !command.trim()) {
1078
+ send({ type: "error", error: "command is required" });
1079
+ res.end();
1080
+ return;
1081
+ }
1082
+
1083
+ try {
1084
+ const run = await streamInfraCommand({
1085
+ questionId,
1086
+ fileId,
1087
+ label,
1088
+ command,
1089
+ workspace,
1090
+ onMessage: send,
1091
+ });
1092
+ send({ type: "complete", run });
1093
+ } catch (err: any) {
1094
+ send({
1095
+ type: "error",
1096
+ error: err?.message || "Failed to run infra command",
1097
+ });
1098
+ }
1099
+
1100
+ res.end();
1101
+ });
1102
+
1103
+ app.get("/api/infra/runs", async (req, res) => {
1104
+ const fileId =
1105
+ typeof req.query.fileId === "string" ? req.query.fileId : undefined;
1106
+ const questionId =
1107
+ typeof req.query.questionId === "string" ? req.query.questionId : undefined;
1108
+
1109
+ try {
1110
+ const runs = await listInfraRuns({ fileId, questionId });
1111
+ res.json(runs);
1112
+ } catch (err: any) {
1113
+ res
1114
+ .status(500)
1115
+ .json({ error: err?.message || "Failed to list infra runs" });
1116
+ }
1117
+ });
1118
+
1119
+ app.get("/api/infra/runs/:runId", async (req, res) => {
1120
+ try {
1121
+ const run = await getInfraRun(req.params.runId);
1122
+ res.json(run);
1123
+ } catch {
1124
+ res.status(404).json({ error: "Infra run not found" });
1125
+ }
1126
+ });
1127
+
787
1128
  // Link an existing file to a topic without re-uploading
788
1129
  app.post("/api/topics/:topicId/context-files/link", async (req, res) => {
789
1130
  const { fileId, originalName } = req.body as {
@@ -900,6 +1241,10 @@ app.patch("/api/settings", async (req, res) => {
900
1241
  typeof req.body.vizGuide === "string"
901
1242
  ? req.body.vizGuide
902
1243
  : current.vizGuide,
1244
+ plotGuide:
1245
+ typeof req.body.plotGuide === "string"
1246
+ ? req.body.plotGuide
1247
+ : current.plotGuide,
903
1248
  promptGroups:
904
1249
  req.body.promptGroups != null
905
1250
  ? req.body.promptGroups
@@ -934,10 +1279,11 @@ app.post("/api/chat", async (req, res) => {
934
1279
  codeSnippets,
935
1280
  systemContext,
936
1281
  responseLength,
1282
+ linkedConversationIds,
937
1283
  } = req.body;
938
1284
 
939
1285
  const aiSettings = await storage.getAiSettings();
940
- const { responseProfiles, vizGuide } = aiSettings;
1286
+ const { responseProfiles, vizGuide, plotGuide } = aiSettings;
941
1287
  const selectedResponseProfile = responseProfiles[responseLength] ??
942
1288
  responseProfiles["normal"] ?? { maxOutputTokens: 3000, maxSteps: 5 };
943
1289
 
@@ -952,6 +1298,34 @@ app.post("/api/chat", async (req, res) => {
952
1298
  if (systemContext) {
953
1299
  system += `\n\n--- Additional Context ---\n${systemContext}`;
954
1300
  }
1301
+ if (
1302
+ Array.isArray(linkedConversationIds) &&
1303
+ linkedConversationIds.length > 0
1304
+ ) {
1305
+ const linkedQuestions = await Promise.all(
1306
+ linkedConversationIds.map((id: string) => storage.getQuestion(id)),
1307
+ );
1308
+ const valid = linkedQuestions.filter(
1309
+ (q) => q && q.messages && q.messages.length > 0,
1310
+ ) as storage.Question[];
1311
+ if (valid.length > 0) {
1312
+ system += `\n\n--- Linked Conversation${valid.length > 1 ? "s" : ""} (read-only reference) ---\nThe user has linked the following conversation${valid.length > 1 ? "s" : ""} from this topic as background context. Do not treat them as the current conversation — they are reference material only.\n`;
1313
+ for (const lq of valid) {
1314
+ system += `\n### "${lq.title}"\n`;
1315
+ for (const msg of lq.messages) {
1316
+ const role = msg.role === "user" ? "User" : "Assistant";
1317
+ const text =
1318
+ typeof msg.content === "string"
1319
+ ? msg.content
1320
+ : (msg.parts
1321
+ ?.filter((p: any) => p.type === "text")
1322
+ .map((p: any) => p.text)
1323
+ .join("") ?? "");
1324
+ system += `**${role}:** ${text.slice(0, 2000)}${text.length > 2000 ? "…" : ""}\n\n`;
1325
+ }
1326
+ }
1327
+ }
1328
+ }
955
1329
 
956
1330
  // Build a file registry: id → { label, reader }
957
1331
  // The model sees the list of file names and can call readFile(id) for any of them.
@@ -969,10 +1343,13 @@ app.post("/api/chat", async (req, res) => {
969
1343
  });
970
1344
  }
971
1345
 
972
- // Topic-level uploaded files
1346
+ // Topic-level uploaded files + topic-wide system prompt
973
1347
  if (topicId) {
974
1348
  const topics = await storage.getTopics();
975
1349
  const topic = topics.find((t) => t.id === topicId);
1350
+ if (topic?.systemContext?.trim()) {
1351
+ system += `\n\n--- Topic-Wide System Context ---\n${topic.systemContext.trim()}`;
1352
+ }
976
1353
  if (topic?.contextFiles?.length) {
977
1354
  for (const cf of topic.contextFiles) {
978
1355
  fileRegistry.set(cf.id, {
@@ -1147,6 +1524,12 @@ Examples (illustrative only — use real ids and names from the list above):
1147
1524
  inputSchema: z.object({}),
1148
1525
  execute: async () => ({ guide: vizGuide }),
1149
1526
  }),
1527
+ getPlotGuide: tool({
1528
+ description:
1529
+ "Get the full plotting spec reference. Call this before writing a ```plot block so you use the supported schema for graphs, curves, and charts.",
1530
+ inputSchema: z.object({}),
1531
+ execute: async () => ({ guide: plotGuide }),
1532
+ }),
1150
1533
  ...(fileRegistry.size > 0
1151
1534
  ? {
1152
1535
  readFile: tool({
@@ -1620,13 +2003,13 @@ app.post("/api/fix-viz", async (req, res) => {
1620
2003
 
1621
2004
  const { text } = await generateText({
1622
2005
  model: getModel(),
1623
- maxOutputTokens: 2500,
2006
+ maxOutputTokens: 8000,
1624
2007
  ...(isGoogle && {
1625
2008
  providerOptions: {
1626
2009
  google: { thinkingConfig: { thinkingBudget: 0 } },
1627
2010
  },
1628
2011
  }),
1629
- prompt: `The following viz diagram spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the spec so it renders correctly. Common issues to check:\n- x/y coordinates must be numbers, not strings\n- node ids must be kebab-case with no spaces\n- Truncated spec: if the spec looks cut off mid-label or mid-step, complete all missing steps before returning — output the full corrected spec.\n- Signal chain direction: a chain [A, B] animates along the edge FROM A TO B. That directed edge (from: A, to: B) MUST exist in the edges section. 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}`,
2012
+ prompt: `The following viz diagram spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the spec so it renders correctly. Common issues to check:\n- x/y coordinates must be numbers, not strings\n- node ids must be kebab-case with no spaces\n- Truncated spec: if the spec looks cut off mid-label or mid-step, complete all missing steps before returning — output the full corrected spec.\n- Signal chain direction: a chain [A, B] animates along the edge FROM A TO B. That directed edge (from: A, to: B) MUST exist in the edges section.\n- chain arrays in autoSignals/signals must reference valid node ids\n- YAML flow sequences ([a, b]) must be correctly indented and closed\n- Do NOT mix autoSignals and steps in the same spec\n- Typography: replace en-dashes (–, U+2013) and em-dashes (—, U+2014) with a plain ASCII hyphen (-). Replace curly/smart quotes with straight ASCII quotes.\n- YAML string quoting: ALWAYS use double quotes for ALL string values — NEVER single quotes. Single-quoted YAML values (e.g. fill: '#fff') cause "Flow map must end with a }" parse errors in yaml v2. Every string in the spec must use double quotes. Wrong: fill: '#1e40af' Correct: fill: "#1e40af"\n- Inline flow maps must be single-line: any node written as { id: foo, x: 1, ... } MUST have its opening { and closing } on the SAME line with no line breaks inside. The yaml v2 parser throws "Flow map must end with a }" when a flow map spans multiple lines. If a flow map is too long to fit on one line, convert it to block style (indented key: value pairs), but NEVER split a single { ... } across lines.\n- Never split a label across multiple YAML lines unless using a literal block scalar (| or >)\n\nReturn ONLY the corrected YAML (or JSON) spec with no explanation and no markdown fences — just the raw spec.\n\nFailed spec:\n${spec}`,
1630
2013
  });
1631
2014
 
1632
2015
  // Strip any fences the model might have added
@@ -1642,6 +2025,43 @@ app.post("/api/fix-viz", async (req, res) => {
1642
2025
  }
1643
2026
  });
1644
2027
 
2028
+ // ─── Fix Plot ───────────────────────────────────────────
2029
+
2030
+ app.post("/api/fix-plot", async (req, res) => {
2031
+ const { spec, error: renderError } = req.body;
2032
+ if (typeof spec !== "string" || !spec.trim()) {
2033
+ return res.status(400).json({ error: "spec is required" });
2034
+ }
2035
+
2036
+ try {
2037
+ const { plotGuide } = await storage.getAiSettings();
2038
+ const isGoogle = ["google", "gemini"].includes(
2039
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
2040
+ );
2041
+
2042
+ const { text } = await generateText({
2043
+ model: getModel(),
2044
+ maxOutputTokens: 4000,
2045
+ ...(isGoogle && {
2046
+ providerOptions: {
2047
+ google: { thinkingConfig: { thinkingBudget: 0 } },
2048
+ },
2049
+ }),
2050
+ prompt: `The following plot spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}${plotGuide ? `\n\nSupported plotting guide:\n${plotGuide}` : ""}\n\nFix the spec so it renders correctly as a Vega-Lite plot.\nRules:\n- Return ONLY the corrected JSON or YAML spec with no explanation and no markdown fences\n- The spec must describe a valid Vega-Lite chart\n- Prefer simple, valid encodings over clever transforms\n- Ensure x and y encodings include valid field/type definitions when needed\n- If the chart represents a mathematical curve, provide concrete sampled data points instead of symbolic expressions\n- Keep the dark-theme friendly defaults implicit; do not add custom JS callbacks or unsupported runtime code\n\nFailed spec:\n${spec}`,
2051
+ });
2052
+
2053
+ const fixed = text
2054
+ .replace(/^```(?:plot|vega|vega-lite|json|yaml)?\s*/i, "")
2055
+ .replace(/```\s*$/, "")
2056
+ .trim();
2057
+
2058
+ res.json({ spec: fixed });
2059
+ } catch (err: any) {
2060
+ console.error("fix-plot error:", err?.message || err);
2061
+ res.status(500).json({ error: err?.message || "Failed to fix plot" });
2062
+ }
2063
+ });
2064
+
1645
2065
  // ─── Refine Viz ─────────────────────────────────────────
1646
2066
 
1647
2067
  app.post("/api/refine-viz", async (req, res) => {
@@ -1685,7 +2105,7 @@ Rules:
1685
2105
 
1686
2106
  const { text } = await generateText({
1687
2107
  model: getModel(),
1688
- maxOutputTokens: 1600,
2108
+ maxOutputTokens: 8000,
1689
2109
  ...(isGoogle && {
1690
2110
  providerOptions: {
1691
2111
  google: { thinkingConfig: { thinkingBudget: 0 } },
@@ -1809,10 +2229,10 @@ app.post("/api/run-code", async (req, res) => {
1809
2229
  }, RUN_TIMEOUT_MS);
1810
2230
 
1811
2231
  child.stdout.on("data", (chunk: Buffer) => {
1812
- stdoutLines.push(chunk.toString());
2232
+ stdoutLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
1813
2233
  });
1814
2234
  child.stderr.on("data", (chunk: Buffer) => {
1815
- stderrLines.push(chunk.toString());
2235
+ stderrLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
1816
2236
  });
1817
2237
  child.on("close", () => {
1818
2238
  clearTimeout(killTimer);
@@ -1843,6 +2263,223 @@ interface SandboxEntry {
1843
2263
  const sandboxes = new Map<string, SandboxEntry>();
1844
2264
  const SANDBOX_DIR = path.join(__dirname, "..", ".sandbox-tmp");
1845
2265
 
2266
+ // ── Next.js real-server sandboxes ────────────────────────────────────────────
2267
+ // We symlink node_modules from the pre-installed npx cache so there's
2268
+ // zero npm-install cost per sandbox.
2269
+ interface NextSandboxEntry {
2270
+ child: ReturnType<typeof spawn>;
2271
+ port: number;
2272
+ dir: string;
2273
+ logs: string[];
2274
+ ready: boolean;
2275
+ }
2276
+ const nextSandboxes = new Map<string, NextSandboxEntry>();
2277
+ const NEXT_NPX_DIR = path.join(os.homedir(), ".npm/_npx/8b377f6eec906bc4");
2278
+ const NEXT_MODULES_DIR = path.join(NEXT_NPX_DIR, "node_modules");
2279
+ // Sandboxes live INSIDE the npx cache dir so Next.js finds node_modules by
2280
+ // walking up the directory tree — no symlinks needed, no Turbopack restrictions.
2281
+ const NEXT_SANDBOX_BASE = path.join(NEXT_NPX_DIR, ".sandboxes");
2282
+
2283
+ /** Write all user files and necessary config into a sandbox directory. */
2284
+ async function writeNextSandboxFiles(
2285
+ dir: string,
2286
+ files: Record<string, string>,
2287
+ ) {
2288
+ // package.json — needed so Next.js can resolve its own packages
2289
+ await fs.writeFile(
2290
+ path.join(dir, "package.json"),
2291
+ JSON.stringify(
2292
+ {
2293
+ name: "nextjs-lab",
2294
+ private: true,
2295
+ type: "module",
2296
+ dependencies: {
2297
+ next: "^16.2.4",
2298
+ react: "^19.0.0",
2299
+ "react-dom": "^19.0.0",
2300
+ },
2301
+ },
2302
+ null,
2303
+ 2,
2304
+ ),
2305
+ );
2306
+ // next.config.ts — set turbopack.root to the NPX cache dir (parent of both
2307
+ // node_modules and .sandboxes). This lets Turbopack find next/package.json
2308
+ // in node_modules while watching files within the sandbox subdirectory.
2309
+ // Setting it to the sandbox dir itself would block node_modules resolution
2310
+ // since node_modules sits above the sandbox root boundary.
2311
+ await fs.writeFile(
2312
+ path.join(dir, "next.config.ts"),
2313
+ `import type { NextConfig } from "next";
2314
+ const c: NextConfig = {
2315
+ turbopack: { root: ${JSON.stringify(NEXT_NPX_DIR)} },
2316
+ async headers() {
2317
+ return [{ source: "/(.*)", headers: [{ key: "X-Frame-Options", value: "ALLOWALL" }] }];
2318
+ },
2319
+ };
2320
+ export default c;\n`,
2321
+ );
2322
+ // tsconfig so Next.js TypeScript support works
2323
+ await fs.writeFile(
2324
+ path.join(dir, "tsconfig.json"),
2325
+ JSON.stringify(
2326
+ {
2327
+ compilerOptions: {
2328
+ lib: ["dom", "dom.iterable", "esnext"],
2329
+ allowJs: true,
2330
+ skipLibCheck: true,
2331
+ strict: false,
2332
+ noEmit: true,
2333
+ esModuleInterop: true,
2334
+ module: "esnext",
2335
+ moduleResolution: "bundler",
2336
+ resolveJsonModule: true,
2337
+ isolatedModules: true,
2338
+ jsx: "preserve",
2339
+ incremental: true,
2340
+ plugins: [{ name: "next" }],
2341
+ baseUrl: ".",
2342
+ },
2343
+ include: ["**/*.ts", "**/*.tsx"],
2344
+ exclude: ["node_modules"],
2345
+ },
2346
+ null,
2347
+ 2,
2348
+ ),
2349
+ );
2350
+ // User files
2351
+ for (const [filePath, content] of Object.entries(files)) {
2352
+ const fullPath = path.join(dir, filePath);
2353
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
2354
+ await fs.writeFile(fullPath, content, "utf8");
2355
+ }
2356
+ }
2357
+
2358
+ app.post("/api/nextjs/start", async (req, res) => {
2359
+ const { files } = req.body as { files: Record<string, string> };
2360
+ if (!files || typeof files !== "object") {
2361
+ return res.status(400).json({ error: "files is required" });
2362
+ }
2363
+
2364
+ // Use the actual file path (not the .bin symlink) so Node's __filename resolves
2365
+ // correctly and internal require("../server/require-hook") works.
2366
+ const nextBin = path.join(NEXT_MODULES_DIR, "next", "dist", "bin", "next");
2367
+ const nextExists = await fs
2368
+ .access(nextBin)
2369
+ .then(() => true)
2370
+ .catch(() => false);
2371
+ if (!nextExists) {
2372
+ return res.status(503).json({
2373
+ error:
2374
+ "Next.js is not available in the npx cache. Run `npx next --version` once to install it.",
2375
+ });
2376
+ }
2377
+
2378
+ const id = randomUUID();
2379
+ const dir = path.join(NEXT_SANDBOX_BASE, id);
2380
+ await fs.mkdir(dir, { recursive: true });
2381
+
2382
+ await writeNextSandboxFiles(dir, files);
2383
+
2384
+ const port = await getFreePort();
2385
+ const logs: string[] = [];
2386
+ // Run via `node <actual-bin>` — sandbox dir is inside the npx cache so
2387
+ // node_modules is resolved naturally two levels up; no symlinks required.
2388
+ const child = spawn(
2389
+ process.execPath,
2390
+ [nextBin, "dev", "--port", String(port)],
2391
+ {
2392
+ cwd: dir,
2393
+ env: {
2394
+ ...process.env,
2395
+ NEXT_TELEMETRY_DISABLED: "1",
2396
+ NODE_NO_WARNINGS: "1",
2397
+ },
2398
+ },
2399
+ );
2400
+
2401
+ const entry: NextSandboxEntry = {
2402
+ child,
2403
+ port,
2404
+ dir,
2405
+ logs,
2406
+ ready: false,
2407
+ };
2408
+ nextSandboxes.set(id, entry);
2409
+
2410
+ const markReady = (text: string) => {
2411
+ if (!entry.ready && /ready|started server on|Local:/i.test(text)) {
2412
+ entry.ready = true;
2413
+ }
2414
+ };
2415
+ child.stdout.on("data", (chunk: Buffer) => {
2416
+ const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
2417
+ logs.push(t);
2418
+ markReady(t);
2419
+ });
2420
+ child.stderr.on("data", (chunk: Buffer) => {
2421
+ const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
2422
+ logs.push(t);
2423
+ markReady(t);
2424
+ });
2425
+ child.on("exit", () => {
2426
+ nextSandboxes.delete(id);
2427
+ fs.rm(dir, { recursive: true, force: true }).catch(() => {});
2428
+ });
2429
+
2430
+ // Wait up to 30 s for Next.js to be ready
2431
+ const deadline = Date.now() + 30_000;
2432
+ while (!entry.ready && Date.now() < deadline) {
2433
+ await new Promise((r) => setTimeout(r, 400));
2434
+ if (!nextSandboxes.has(id)) {
2435
+ return res
2436
+ .status(500)
2437
+ .json({ error: logs.join("").trim() || "Next.js server exited" });
2438
+ }
2439
+ }
2440
+ if (!entry.ready) {
2441
+ return res
2442
+ .status(504)
2443
+ .json({ error: "Next.js did not start in time", logs });
2444
+ }
2445
+
2446
+ res.json({ id, port, url: `http://localhost:${port}` });
2447
+ });
2448
+
2449
+ app.post("/api/nextjs/:id/update-files", async (req, res) => {
2450
+ const sb = nextSandboxes.get(req.params.id);
2451
+ if (!sb) return res.status(404).json({ error: "Sandbox not found" });
2452
+ const { files } = req.body as { files: Record<string, string> };
2453
+ if (!files) return res.status(400).json({ error: "files is required" });
2454
+ for (const [filePath, content] of Object.entries(files)) {
2455
+ const fullPath = path.join(sb.dir, filePath);
2456
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
2457
+ await fs.writeFile(fullPath, content, "utf8");
2458
+ }
2459
+ res.json({ ok: true });
2460
+ });
2461
+
2462
+ app.get("/api/nextjs/:id/status", (req, res) => {
2463
+ const sb = nextSandboxes.get(req.params.id);
2464
+ if (!sb) return res.json({ running: false });
2465
+ res.json({
2466
+ running: true,
2467
+ ready: sb.ready,
2468
+ port: sb.port,
2469
+ logs: sb.logs.slice(-30),
2470
+ });
2471
+ });
2472
+
2473
+ app.delete("/api/nextjs/:id", async (req, res) => {
2474
+ const sb = nextSandboxes.get(req.params.id);
2475
+ if (sb) {
2476
+ sb.child.kill("SIGTERM");
2477
+ nextSandboxes.delete(req.params.id);
2478
+ await fs.rm(sb.dir, { recursive: true, force: true }).catch(() => {});
2479
+ }
2480
+ res.json({ ok: true });
2481
+ });
2482
+
1846
2483
  async function getFreePort(): Promise<number> {
1847
2484
  return new Promise((resolve, reject) => {
1848
2485
  const srv = net.createServer();
@@ -1893,8 +2530,12 @@ app.post("/api/sandbox/start", async (req, res) => {
1893
2530
  const child = spawn(process.execPath, [tmpFile], {
1894
2531
  env: { ...process.env, PORT: String(port), NODE_NO_WARNINGS: "1" },
1895
2532
  });
1896
- child.stdout.on("data", (chunk: Buffer) => logs.push(chunk.toString()));
1897
- child.stderr.on("data", (chunk: Buffer) => logs.push(chunk.toString()));
2533
+ child.stdout.on("data", (chunk: Buffer) =>
2534
+ logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
2535
+ );
2536
+ child.stderr.on("data", (chunk: Buffer) =>
2537
+ logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
2538
+ );
1898
2539
  child.on("exit", () => {
1899
2540
  sandboxes.delete(id);
1900
2541
  fs.unlink(tmpFile).catch(() => {});