create-interview-cockpit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/index.js +302 -0
- package/package.json +44 -0
- package/template/.env.example +14 -0
- package/template/client/index.html +12 -0
- package/template/client/package-lock.json +6012 -0
- package/template/client/package.json +34 -0
- package/template/client/postcss.config.cjs +6 -0
- package/template/client/src/App.tsx +120 -0
- package/template/client/src/api.ts +132 -0
- package/template/client/src/components/AnnotationDialog.tsx +307 -0
- package/template/client/src/components/ChatMessage.tsx +89 -0
- package/template/client/src/components/ChatView.tsx +763 -0
- package/template/client/src/components/CodeContextPanel.tsx +470 -0
- package/template/client/src/components/FileAttachments.tsx +107 -0
- package/template/client/src/components/FileViewerModal.tsx +470 -0
- package/template/client/src/components/MarkdownRenderer.tsx +333 -0
- package/template/client/src/components/MermaidDiagram.tsx +157 -0
- package/template/client/src/components/Sidebar.tsx +419 -0
- package/template/client/src/components/TextAnnotator.tsx +476 -0
- package/template/client/src/index.css +61 -0
- package/template/client/src/main.tsx +10 -0
- package/template/client/src/store.ts +321 -0
- package/template/client/src/types.ts +65 -0
- package/template/client/src/vite-env.d.ts +1 -0
- package/template/client/tailwind.config.cjs +8 -0
- package/template/client/tsconfig.json +16 -0
- package/template/client/tsconfig.tsbuildinfo +1 -0
- package/template/client/vite.config.ts +12 -0
- package/template/cockpit.json +3 -0
- package/template/data/context-files/.gitkeep +0 -0
- package/template/data/questions/.gitkeep +0 -0
- package/template/data/topics.json +1 -0
- package/template/package.json +14 -0
- package/template/server/package-lock.json +2266 -0
- package/template/server/package.json +31 -0
- package/template/server/src/index.ts +758 -0
- package/template/server/src/storage.ts +303 -0
- package/template/server/tsconfig.json +14 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
dotenv.config({ path: path.join(__dirname, "../../.env") });
|
|
7
|
+
|
|
8
|
+
import express from "express";
|
|
9
|
+
import cors from "cors";
|
|
10
|
+
import multer from "multer";
|
|
11
|
+
import mammoth from "mammoth";
|
|
12
|
+
import { PDFParse } from "pdf-parse";
|
|
13
|
+
import {
|
|
14
|
+
convertToModelMessages,
|
|
15
|
+
generateText,
|
|
16
|
+
streamText,
|
|
17
|
+
tool,
|
|
18
|
+
stepCountIs,
|
|
19
|
+
type LanguageModel,
|
|
20
|
+
} from "ai";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
23
|
+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
24
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
25
|
+
import { randomUUID } from "crypto";
|
|
26
|
+
import fs from "fs/promises";
|
|
27
|
+
import * as storage from "./storage.js";
|
|
28
|
+
|
|
29
|
+
const app = express();
|
|
30
|
+
app.use(cors());
|
|
31
|
+
app.use(express.json({ limit: "10mb" }));
|
|
32
|
+
|
|
33
|
+
const upload = multer({
|
|
34
|
+
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max
|
|
35
|
+
fileFilter: (_req, file, cb) => {
|
|
36
|
+
const allowed =
|
|
37
|
+
/\.(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;
|
|
38
|
+
if (allowed.test(file.originalname) || file.mimetype.startsWith("text/")) {
|
|
39
|
+
cb(null, true);
|
|
40
|
+
} else {
|
|
41
|
+
cb(new Error("Unsupported file type"));
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Extract text from uploaded files (docx, pdf, or plain text)
|
|
47
|
+
async function extractText(buffer: Buffer, filename: string): Promise<string> {
|
|
48
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
49
|
+
if (ext === "docx") {
|
|
50
|
+
const result = await mammoth.extractRawText({ buffer });
|
|
51
|
+
return result.value;
|
|
52
|
+
}
|
|
53
|
+
if (ext === "pdf") {
|
|
54
|
+
const parser = new PDFParse({ data: buffer });
|
|
55
|
+
const result = await parser.getText();
|
|
56
|
+
return result.text;
|
|
57
|
+
}
|
|
58
|
+
return buffer.toString("utf-8");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PORT = process.env.PORT || 3001;
|
|
62
|
+
const CODE_CONTEXT_DIR = process.env.CODE_CONTEXT_DIR || "";
|
|
63
|
+
|
|
64
|
+
// ─── AI Providers (Vercel AI SDK) ────────────────────────
|
|
65
|
+
// Set the provider + model in .env. Supports: openai, google, anthropic
|
|
66
|
+
|
|
67
|
+
function getModel(): LanguageModel {
|
|
68
|
+
const provider = (process.env.AI_PROVIDER || "openai").toLowerCase();
|
|
69
|
+
const model = process.env.AI_MODEL || "";
|
|
70
|
+
|
|
71
|
+
switch (provider) {
|
|
72
|
+
case "google":
|
|
73
|
+
case "gemini": {
|
|
74
|
+
const google = createGoogleGenerativeAI({
|
|
75
|
+
apiKey: process.env.GOOGLE_API_KEY || "",
|
|
76
|
+
});
|
|
77
|
+
return google(model || "gemini-2.5-flash");
|
|
78
|
+
}
|
|
79
|
+
case "anthropic":
|
|
80
|
+
case "claude": {
|
|
81
|
+
const anthropic = createAnthropic({
|
|
82
|
+
apiKey: process.env.ANTHROPIC_API_KEY || "",
|
|
83
|
+
});
|
|
84
|
+
return anthropic(model || "claude-sonnet-4-20250514");
|
|
85
|
+
}
|
|
86
|
+
case "openai":
|
|
87
|
+
default: {
|
|
88
|
+
const openai = createOpenAI({
|
|
89
|
+
apiKey: process.env.OPENAI_API_KEY || "",
|
|
90
|
+
});
|
|
91
|
+
return openai(model || "gpt-4o");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getTextFromUIMessage(message: any): string {
|
|
97
|
+
if (typeof message?.content === "string") {
|
|
98
|
+
return message.content;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (Array.isArray(message?.parts)) {
|
|
102
|
+
return message.parts
|
|
103
|
+
.filter((part: any) => part?.type === "text")
|
|
104
|
+
.map((part: any) => part.text || "")
|
|
105
|
+
.join("");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Topics ──────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
app.get("/api/topics", async (_req, res) => {
|
|
114
|
+
const topics = await storage.getTopics();
|
|
115
|
+
res.json(topics);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
app.post("/api/topics", async (req, res) => {
|
|
119
|
+
const topic: storage.Topic = {
|
|
120
|
+
id: randomUUID(),
|
|
121
|
+
name: req.body.name,
|
|
122
|
+
contextFiles: [],
|
|
123
|
+
createdAt: new Date().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
await storage.saveTopic(topic);
|
|
126
|
+
res.json(topic);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
app.delete("/api/topics/:id", async (req, res) => {
|
|
130
|
+
await storage.deleteTopic(req.params.id);
|
|
131
|
+
res.json({ ok: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
app.patch("/api/topics/:id", async (req, res) => {
|
|
135
|
+
const topics = await storage.getTopics();
|
|
136
|
+
const topic = topics.find((t) => t.id === req.params.id);
|
|
137
|
+
if (!topic) return res.status(404).json({ error: "Not found" });
|
|
138
|
+
if (typeof req.body.name === "string") topic.name = req.body.name;
|
|
139
|
+
await storage.saveTopic(topic);
|
|
140
|
+
res.json(topic);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ─── Topic Context Files ─────────────────────────────────
|
|
144
|
+
|
|
145
|
+
app.post(
|
|
146
|
+
"/api/topics/:topicId/context-files",
|
|
147
|
+
upload.array("files", 20),
|
|
148
|
+
async (req, res) => {
|
|
149
|
+
const files = req.files as Express.Multer.File[];
|
|
150
|
+
if (!files?.length) return res.status(400).json({ error: "No files" });
|
|
151
|
+
const results: storage.ContextFile[] = [];
|
|
152
|
+
for (const file of files) {
|
|
153
|
+
const text = await extractText(file.buffer, file.originalname);
|
|
154
|
+
const cf = await storage.saveContextFile(
|
|
155
|
+
req.params.topicId as string,
|
|
156
|
+
randomUUID(),
|
|
157
|
+
file.originalname,
|
|
158
|
+
Buffer.from(text, "utf-8"),
|
|
159
|
+
);
|
|
160
|
+
results.push(cf);
|
|
161
|
+
}
|
|
162
|
+
res.json(results);
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
app.delete("/api/topics/:topicId/context-files/:fileId", async (req, res) => {
|
|
167
|
+
await storage.deleteContextFile(req.params.topicId, req.params.fileId);
|
|
168
|
+
res.json({ ok: true });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ─── Questions ───────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
app.get("/api/topics/:topicId/questions", async (req, res) => {
|
|
174
|
+
const questions = await storage.getQuestionsByTopic(req.params.topicId);
|
|
175
|
+
res.json(questions);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
app.post("/api/topics/:topicId/questions", async (req, res) => {
|
|
179
|
+
const question: storage.Question = {
|
|
180
|
+
id: randomUUID(),
|
|
181
|
+
topicId: req.params.topicId,
|
|
182
|
+
parentQuestionId: req.body.parentQuestionId || undefined,
|
|
183
|
+
title: req.body.title,
|
|
184
|
+
systemContext: req.body.systemContext || "",
|
|
185
|
+
codeContextFiles: req.body.codeContextFiles || [],
|
|
186
|
+
contextFiles: [],
|
|
187
|
+
messages: [],
|
|
188
|
+
createdAt: new Date().toISOString(),
|
|
189
|
+
};
|
|
190
|
+
await storage.saveQuestion(question);
|
|
191
|
+
res.json(question);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
app.get("/api/questions/:id", async (req, res) => {
|
|
195
|
+
const q = await storage.getQuestion(req.params.id);
|
|
196
|
+
if (!q) return res.status(404).json({ error: "Not found" });
|
|
197
|
+
res.json(q);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
app.patch("/api/questions/:id", async (req, res) => {
|
|
201
|
+
const q = await storage.getQuestion(req.params.id);
|
|
202
|
+
if (!q) return res.status(404).json({ error: "Not found" });
|
|
203
|
+
if (req.body.codeContextFiles !== undefined)
|
|
204
|
+
q.codeContextFiles = req.body.codeContextFiles;
|
|
205
|
+
if (req.body.systemContext !== undefined)
|
|
206
|
+
q.systemContext = req.body.systemContext;
|
|
207
|
+
if (req.body.title !== undefined) q.title = req.body.title;
|
|
208
|
+
if (req.body.parentQuestionId !== undefined)
|
|
209
|
+
q.parentQuestionId = req.body.parentQuestionId;
|
|
210
|
+
if (req.body.messages !== undefined) q.messages = req.body.messages;
|
|
211
|
+
if (req.body.annotations !== undefined) q.annotations = req.body.annotations;
|
|
212
|
+
if (req.body.readingBookmark !== undefined)
|
|
213
|
+
q.readingBookmark = req.body.readingBookmark;
|
|
214
|
+
await storage.saveQuestion(q);
|
|
215
|
+
res.json(q);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
app.delete("/api/questions/:id", async (req, res) => {
|
|
219
|
+
const q = await storage.getQuestion(req.params.id);
|
|
220
|
+
if (q) {
|
|
221
|
+
// Cascade: delete children that point to this question
|
|
222
|
+
const siblings = await storage.getQuestionsByTopic(q.topicId);
|
|
223
|
+
const children = siblings.filter((c) => c.parentQuestionId === q.id);
|
|
224
|
+
await Promise.all(children.map((c) => storage.deleteQuestion(c.id)));
|
|
225
|
+
}
|
|
226
|
+
await storage.deleteQuestion(req.params.id);
|
|
227
|
+
res.json({ ok: true });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ─── Question Context Files ─────────────────────────────
|
|
231
|
+
|
|
232
|
+
app.post(
|
|
233
|
+
"/api/questions/:questionId/context-files",
|
|
234
|
+
upload.array("files", 20),
|
|
235
|
+
async (req, res) => {
|
|
236
|
+
const files = req.files as Express.Multer.File[];
|
|
237
|
+
if (!files?.length) return res.status(400).json({ error: "No files" });
|
|
238
|
+
const results: storage.ContextFile[] = [];
|
|
239
|
+
for (const file of files) {
|
|
240
|
+
const text = await extractText(file.buffer, file.originalname);
|
|
241
|
+
const cf = await storage.saveQuestionContextFile(
|
|
242
|
+
req.params.questionId as string,
|
|
243
|
+
randomUUID(),
|
|
244
|
+
file.originalname,
|
|
245
|
+
Buffer.from(text, "utf-8"),
|
|
246
|
+
);
|
|
247
|
+
results.push(cf);
|
|
248
|
+
}
|
|
249
|
+
res.json(results);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
app.delete(
|
|
254
|
+
"/api/questions/:questionId/context-files/:fileId",
|
|
255
|
+
async (req, res) => {
|
|
256
|
+
await storage.deleteQuestionContextFile(
|
|
257
|
+
req.params.questionId,
|
|
258
|
+
req.params.fileId,
|
|
259
|
+
);
|
|
260
|
+
res.json({ ok: true });
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// ─── Chat ────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
app.post("/api/chat", async (req, res) => {
|
|
267
|
+
const {
|
|
268
|
+
messages,
|
|
269
|
+
questionId,
|
|
270
|
+
topicId,
|
|
271
|
+
topicTitle,
|
|
272
|
+
questionTitle,
|
|
273
|
+
codeContextFiles,
|
|
274
|
+
codeSnippets,
|
|
275
|
+
systemContext,
|
|
276
|
+
responseLength,
|
|
277
|
+
} = req.body;
|
|
278
|
+
|
|
279
|
+
const responseProfiles: Record<
|
|
280
|
+
string,
|
|
281
|
+
{ maxOutputTokens: number; maxSteps: number }
|
|
282
|
+
> = {
|
|
283
|
+
concise: {
|
|
284
|
+
maxOutputTokens: 1000,
|
|
285
|
+
maxSteps: 3,
|
|
286
|
+
},
|
|
287
|
+
moderate: {
|
|
288
|
+
maxOutputTokens: 1000,
|
|
289
|
+
maxSteps: 5,
|
|
290
|
+
},
|
|
291
|
+
normal: {
|
|
292
|
+
maxOutputTokens: 3000,
|
|
293
|
+
maxSteps: 5,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
const selectedResponseProfile =
|
|
297
|
+
responseProfiles[responseLength] || responseProfiles.normal;
|
|
298
|
+
|
|
299
|
+
let system = `You are a senior engineering interview coach.
|
|
300
|
+
|
|
301
|
+
Highest priority: follow the user's explicit response preferences and current conversation context. If they conflict with your default teaching behavior, the user's preference wins.
|
|
302
|
+
Explain clearly, accurately, and practically.
|
|
303
|
+
Only include Mermaid diagrams, code blocks, or tables when the user explicitly asks for them or when they materially improve the answer.
|
|
304
|
+
If you show code, use a fenced code block with the correct language.
|
|
305
|
+
|
|
306
|
+
Mermaid syntax rules (follow strictly):
|
|
307
|
+
- Wrap node labels in quotes when they contain special characters: A["Microservice A (Producer)"]
|
|
308
|
+
- Edge labels use |text| syntax: A -->|sends message| B
|
|
309
|
+
- Never put parentheses or brackets inside [] without quoting the label
|
|
310
|
+
- Use simple node IDs (letters/numbers) and put descriptive text in the label`;
|
|
311
|
+
|
|
312
|
+
if (topicTitle || questionTitle) {
|
|
313
|
+
system += `\n\n--- Current Context ---`;
|
|
314
|
+
if (topicTitle) system += `\nTopic: ${topicTitle}`;
|
|
315
|
+
if (questionTitle) system += `\nQuestion: ${questionTitle}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (systemContext) {
|
|
319
|
+
system += `\n\n--- Additional Context ---\n${systemContext}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Build a file registry: id → { label, reader }
|
|
323
|
+
// The model sees the list of file names and can call readFile(id) for any of them.
|
|
324
|
+
const fileRegistry = new Map<
|
|
325
|
+
string,
|
|
326
|
+
{ label: string; reader: () => Promise<string> }
|
|
327
|
+
>();
|
|
328
|
+
|
|
329
|
+
// Topic-level uploaded files
|
|
330
|
+
if (topicId) {
|
|
331
|
+
const topics = await storage.getTopics();
|
|
332
|
+
const topic = topics.find((t) => t.id === topicId);
|
|
333
|
+
if (topic?.contextFiles?.length) {
|
|
334
|
+
for (const cf of topic.contextFiles) {
|
|
335
|
+
fileRegistry.set(cf.id, {
|
|
336
|
+
label: `[topic] ${cf.originalName}`,
|
|
337
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Question-level uploaded files
|
|
344
|
+
if (questionId) {
|
|
345
|
+
const question = await storage.getQuestion(questionId);
|
|
346
|
+
if (question?.contextFiles?.length) {
|
|
347
|
+
for (const cf of question.contextFiles) {
|
|
348
|
+
fileRegistry.set(cf.id, {
|
|
349
|
+
label: `[question] ${cf.originalName}`,
|
|
350
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Code-context files from the project directory
|
|
357
|
+
if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
|
|
358
|
+
for (const filePath of codeContextFiles as string[]) {
|
|
359
|
+
const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
|
|
360
|
+
const resolved = path.resolve(fullPath);
|
|
361
|
+
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
|
|
362
|
+
fileRegistry.set(`code:${filePath}`, {
|
|
363
|
+
label: `[code] ${filePath}`,
|
|
364
|
+
reader: () => fs.readFile(resolved, "utf-8"),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Tell the model what files are available
|
|
370
|
+
if (fileRegistry.size > 0) {
|
|
371
|
+
// collect just the code-context file paths for linking instructions
|
|
372
|
+
const codeFilePaths: string[] = [];
|
|
373
|
+
for (const [id, { label }] of fileRegistry) {
|
|
374
|
+
if (id.startsWith("code:")) codeFilePaths.push(id.slice("code:".length));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
system += `\n\n--- Available Reference Files ---
|
|
378
|
+
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.
|
|
379
|
+
|
|
380
|
+
`;
|
|
381
|
+
for (const [id, { label }] of fileRegistry) {
|
|
382
|
+
system += `• ${label} (id: "${id}")\n`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (codeFilePaths.length > 0) {
|
|
386
|
+
system += `
|
|
387
|
+
--- Linking Code Files in Your Response ---
|
|
388
|
+
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:
|
|
389
|
+
|
|
390
|
+
[DisplayText](coderef://relative/path/to/file)
|
|
391
|
+
|
|
392
|
+
Examples:
|
|
393
|
+
[EmployeesController](coderef://src/Controllers/EmployeesController.cs)
|
|
394
|
+
[SettleDeferredPaymentCaseRequest](coderef://src/Requests/SettleDeferredPaymentCaseRequest.cs)
|
|
395
|
+
|
|
396
|
+
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.
|
|
397
|
+
Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Code snippets hand-picked by the user from the file viewer
|
|
402
|
+
if (Array.isArray(codeSnippets) && codeSnippets.length > 0) {
|
|
403
|
+
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`;
|
|
404
|
+
for (const snippet of codeSnippets as Array<{
|
|
405
|
+
fileName: string;
|
|
406
|
+
filePath: string;
|
|
407
|
+
startLine: number;
|
|
408
|
+
endLine: number;
|
|
409
|
+
code: string;
|
|
410
|
+
}>) {
|
|
411
|
+
const lineLabel =
|
|
412
|
+
snippet.startLine === snippet.endLine
|
|
413
|
+
? `line ${snippet.startLine}`
|
|
414
|
+
: `lines ${snippet.startLine}–${snippet.endLine}`;
|
|
415
|
+
system += `**${snippet.fileName}** (${lineLabel}):\n\`\`\`\n${snippet.code}\n\`\`\`\n\n`;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!Array.isArray(messages)) {
|
|
420
|
+
return res.status(400).json({
|
|
421
|
+
error: "Invalid chat payload: messages must be an array",
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const modelMessages = await convertToModelMessages(messages as any[]);
|
|
427
|
+
|
|
428
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
429
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const result = streamText({
|
|
433
|
+
model: getModel(),
|
|
434
|
+
maxOutputTokens: selectedResponseProfile.maxOutputTokens,
|
|
435
|
+
...(isGoogle && {
|
|
436
|
+
providerOptions: {
|
|
437
|
+
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
system,
|
|
441
|
+
messages: modelMessages,
|
|
442
|
+
tools:
|
|
443
|
+
fileRegistry.size > 0
|
|
444
|
+
? {
|
|
445
|
+
readFile: tool({
|
|
446
|
+
description:
|
|
447
|
+
"Read the content of an available reference file. Use this to get file contents when they are relevant to the user's question.",
|
|
448
|
+
inputSchema: z.object({
|
|
449
|
+
fileId: z
|
|
450
|
+
.string()
|
|
451
|
+
.describe(
|
|
452
|
+
"The id of the file to read, from the available files list.",
|
|
453
|
+
),
|
|
454
|
+
}),
|
|
455
|
+
execute: async ({ fileId }) => {
|
|
456
|
+
const entry = fileRegistry.get(fileId);
|
|
457
|
+
if (!entry) return { error: "File not found" };
|
|
458
|
+
try {
|
|
459
|
+
const content = await entry.reader();
|
|
460
|
+
return { fileName: entry.label, content };
|
|
461
|
+
} catch {
|
|
462
|
+
return { error: "Could not read file" };
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
}),
|
|
466
|
+
}
|
|
467
|
+
: undefined,
|
|
468
|
+
stopWhen: stepCountIs(selectedResponseProfile.maxSteps),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
result.pipeUIMessageStreamToResponse(res, {
|
|
472
|
+
originalMessages: messages,
|
|
473
|
+
onFinish: async ({ messages: finalMessages, isAborted }) => {
|
|
474
|
+
if (questionId == null || isAborted) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const normalized = finalMessages.map((message: any) => ({
|
|
480
|
+
id: message.id || randomUUID(),
|
|
481
|
+
role: message.role,
|
|
482
|
+
content: getTextFromUIMessage(message),
|
|
483
|
+
}));
|
|
484
|
+
|
|
485
|
+
await storage.updateQuestionMessages(questionId, normalized);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
console.error("Failed to save messages:", e);
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
} catch (err: any) {
|
|
492
|
+
console.error("Chat error:", err?.message || err);
|
|
493
|
+
if (!res.headersSent) {
|
|
494
|
+
res
|
|
495
|
+
.status(500)
|
|
496
|
+
.json({ error: err?.message || "Failed to generate response" });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ─── Code Context Browser ────────────────────────────────
|
|
502
|
+
|
|
503
|
+
const IGNORE_DIRS = new Set([
|
|
504
|
+
"node_modules",
|
|
505
|
+
".git",
|
|
506
|
+
"dist",
|
|
507
|
+
"build",
|
|
508
|
+
".next",
|
|
509
|
+
".cache",
|
|
510
|
+
"__pycache__",
|
|
511
|
+
".vscode",
|
|
512
|
+
".idea",
|
|
513
|
+
"coverage",
|
|
514
|
+
".turbo",
|
|
515
|
+
// .NET build artifacts
|
|
516
|
+
"bin",
|
|
517
|
+
"obj",
|
|
518
|
+
".vs",
|
|
519
|
+
"packages",
|
|
520
|
+
"TestResults",
|
|
521
|
+
"publish",
|
|
522
|
+
// Python / misc
|
|
523
|
+
".mypy_cache",
|
|
524
|
+
".pytest_cache",
|
|
525
|
+
".ruff_cache",
|
|
526
|
+
"venv",
|
|
527
|
+
".venv",
|
|
528
|
+
// macOS / editor noise
|
|
529
|
+
".DS_Store",
|
|
530
|
+
".Spotlight-V100",
|
|
531
|
+
".Trashes",
|
|
532
|
+
".env",
|
|
533
|
+
".env.local",
|
|
534
|
+
".env.development",
|
|
535
|
+
".env.production",
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
// File extensions that are binary or auto-generated — never useful as context
|
|
539
|
+
const IGNORE_EXTENSIONS = new Set([
|
|
540
|
+
// .NET compiled outputs
|
|
541
|
+
".dll",
|
|
542
|
+
".exe",
|
|
543
|
+
".pdb",
|
|
544
|
+
".nupkg",
|
|
545
|
+
".snupkg",
|
|
546
|
+
".suo",
|
|
547
|
+
// Lock / generated
|
|
548
|
+
".lock",
|
|
549
|
+
".user",
|
|
550
|
+
// Images
|
|
551
|
+
".png",
|
|
552
|
+
".jpg",
|
|
553
|
+
".jpeg",
|
|
554
|
+
".gif",
|
|
555
|
+
".svg",
|
|
556
|
+
".ico",
|
|
557
|
+
".webp",
|
|
558
|
+
".bmp",
|
|
559
|
+
// Fonts
|
|
560
|
+
".woff",
|
|
561
|
+
".woff2",
|
|
562
|
+
".ttf",
|
|
563
|
+
".eot",
|
|
564
|
+
// Archives / binaries
|
|
565
|
+
".zip",
|
|
566
|
+
".tar",
|
|
567
|
+
".gz",
|
|
568
|
+
".rar",
|
|
569
|
+
".7z",
|
|
570
|
+
".bin",
|
|
571
|
+
".so",
|
|
572
|
+
".dylib",
|
|
573
|
+
".lib",
|
|
574
|
+
".obj",
|
|
575
|
+
// Misc
|
|
576
|
+
".min.js",
|
|
577
|
+
".map",
|
|
578
|
+
]);
|
|
579
|
+
|
|
580
|
+
async function walkDir(dir: string, prefix = ""): Promise<string[]> {
|
|
581
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
582
|
+
const files: string[] = [];
|
|
583
|
+
for (const entry of entries) {
|
|
584
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
585
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
586
|
+
if (entry.isDirectory()) {
|
|
587
|
+
files.push(...(await walkDir(path.join(dir, entry.name), rel)));
|
|
588
|
+
} else {
|
|
589
|
+
const lower = entry.name.toLowerCase();
|
|
590
|
+
if (IGNORE_EXTENSIONS.has(path.extname(lower))) continue;
|
|
591
|
+
// Also catch compound extensions like .min.js, .d.ts generated files
|
|
592
|
+
if (lower.endsWith(".min.js") || lower.endsWith(".d.ts")) continue;
|
|
593
|
+
files.push(rel);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return files;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
app.get("/api/code-context/tree", async (_req, res) => {
|
|
600
|
+
if (!CODE_CONTEXT_DIR) return res.json([]);
|
|
601
|
+
try {
|
|
602
|
+
const files = await walkDir(CODE_CONTEXT_DIR);
|
|
603
|
+
res.json(files);
|
|
604
|
+
} catch {
|
|
605
|
+
res.json([]);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
app.get("/api/code-context/file", async (req, res) => {
|
|
610
|
+
if (!CODE_CONTEXT_DIR)
|
|
611
|
+
return res.status(400).json({ error: "No code context directory" });
|
|
612
|
+
const filePath = req.query.path as string;
|
|
613
|
+
if (!filePath) return res.status(400).json({ error: "Path required" });
|
|
614
|
+
|
|
615
|
+
const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
|
|
616
|
+
const resolved = path.resolve(fullPath);
|
|
617
|
+
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) {
|
|
618
|
+
return res.status(403).json({ error: "Access denied" });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
const content = await fs.readFile(resolved, "utf-8");
|
|
623
|
+
res.json({ path: filePath, content });
|
|
624
|
+
} catch {
|
|
625
|
+
res.status(404).json({ error: "File not found" });
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// ─── Inline Ask ─────────────────────────────────────────
|
|
630
|
+
|
|
631
|
+
app.post("/api/inline-ask", async (req, res) => {
|
|
632
|
+
const {
|
|
633
|
+
selectedText,
|
|
634
|
+
prompt,
|
|
635
|
+
messageContent,
|
|
636
|
+
priorResponse,
|
|
637
|
+
followUps,
|
|
638
|
+
responseLength,
|
|
639
|
+
responseStyle,
|
|
640
|
+
responseAudience,
|
|
641
|
+
} = req.body;
|
|
642
|
+
if (typeof selectedText !== "string" || typeof prompt !== "string") {
|
|
643
|
+
return res
|
|
644
|
+
.status(400)
|
|
645
|
+
.json({ error: "selectedText and prompt are required" });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
try {
|
|
649
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
650
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// Build conversation thread context for follow-ups
|
|
654
|
+
let threadContext = "";
|
|
655
|
+
if (priorResponse) {
|
|
656
|
+
threadContext += `\n\nInitial response about "${selectedText}":\n${priorResponse}`;
|
|
657
|
+
}
|
|
658
|
+
if (Array.isArray(followUps) && followUps.length > 0) {
|
|
659
|
+
threadContext += `\n\nFollow-up thread so far:`;
|
|
660
|
+
for (const fu of followUps) {
|
|
661
|
+
threadContext += `\n\nUser asked: "${fu.prompt}"\nAnswer: ${fu.response}`;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const lengthHints: Record<string, string> = {
|
|
666
|
+
concise: "Be concise — aim for a short focused answer.",
|
|
667
|
+
moderate: "Keep the answer moderately detailed.",
|
|
668
|
+
normal: "Use a full explanation with enough context.",
|
|
669
|
+
};
|
|
670
|
+
const styleHints: Record<string, string> = {
|
|
671
|
+
prose: "Use natural prose; avoid bullet lists unless essential.",
|
|
672
|
+
bullets: "Use bullet points as the main format.",
|
|
673
|
+
structured: "Use sections/headings and numbered steps when helpful.",
|
|
674
|
+
};
|
|
675
|
+
const audienceHints: Record<string, string> = {
|
|
676
|
+
beginner:
|
|
677
|
+
"When using technical terms or abbreviations, immediately expand their meaning in square brackets right after the term — e.g. 'TCP [Transmission Control Protocol — a connection-oriented transport protocol]' or 'log lines [text entries written to a file or stream that record what happened at a given moment]'. Do this for every technical term throughout your response.",
|
|
678
|
+
};
|
|
679
|
+
const systemLines: string[] = [
|
|
680
|
+
"You are a senior engineering interview coach. Provide focused, helpful responses about the highlighted text.",
|
|
681
|
+
];
|
|
682
|
+
if (responseLength && lengthHints[responseLength])
|
|
683
|
+
systemLines.push(lengthHints[responseLength]);
|
|
684
|
+
if (responseStyle && styleHints[responseStyle])
|
|
685
|
+
systemLines.push(styleHints[responseStyle]);
|
|
686
|
+
if (responseAudience && audienceHints[responseAudience])
|
|
687
|
+
systemLines.push(audienceHints[responseAudience]);
|
|
688
|
+
|
|
689
|
+
const { text } = await generateText({
|
|
690
|
+
model: getModel(),
|
|
691
|
+
maxOutputTokens: 800,
|
|
692
|
+
...(isGoogle && {
|
|
693
|
+
providerOptions: {
|
|
694
|
+
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
695
|
+
},
|
|
696
|
+
}),
|
|
697
|
+
system: systemLines.join("\n"),
|
|
698
|
+
prompt: `The user is reading this coaching response:
|
|
699
|
+
---
|
|
700
|
+
${messageContent || ""}
|
|
701
|
+
---
|
|
702
|
+
|
|
703
|
+
They highlighted: "${selectedText}"
|
|
704
|
+
${threadContext}
|
|
705
|
+
|
|
706
|
+
Their question: "${prompt}"`,
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
res.json({ response: text });
|
|
710
|
+
} catch (err: any) {
|
|
711
|
+
console.error("inline-ask error:", err?.message || err);
|
|
712
|
+
res.status(500).json({ error: err?.message || "Failed to get response" });
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// ─── Fix Diagram ────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
app.post("/api/fix-diagram", async (req, res) => {
|
|
719
|
+
const { chart, error: renderError } = req.body;
|
|
720
|
+
if (typeof chart !== "string" || !chart.trim()) {
|
|
721
|
+
return res.status(400).json({ error: "chart is required" });
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
const isGoogle = ["google", "gemini"].includes(
|
|
726
|
+
(process.env.AI_PROVIDER || "openai").toLowerCase(),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
const { text } = await generateText({
|
|
730
|
+
model: getModel(),
|
|
731
|
+
maxOutputTokens: 800,
|
|
732
|
+
...(isGoogle && {
|
|
733
|
+
providerOptions: {
|
|
734
|
+
google: { thinkingConfig: { thinkingBudget: 0 } },
|
|
735
|
+
},
|
|
736
|
+
}),
|
|
737
|
+
prompt: `The following Mermaid diagram failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the syntax so it renders correctly. Common issues to check:\n- Parentheses inside edge labels |like (this)| must be quoted: |"like (this)"|\n- Parentheses inside node labels [like (this)] must be quoted: ["like (this)"]\n- Special characters in labels generally need quoting\n\nReturn ONLY the corrected Mermaid code with no explanation and no markdown fences — just the raw diagram definition.\n\nFailed diagram:\n${chart}`,
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// Strip any fences the model might have added anyway
|
|
741
|
+
const fixed = text
|
|
742
|
+
.replace(/^```mermaid\s*/i, "")
|
|
743
|
+
.replace(/^```\s*/i, "")
|
|
744
|
+
.replace(/```\s*$/, "")
|
|
745
|
+
.trim();
|
|
746
|
+
|
|
747
|
+
res.json({ chart: fixed });
|
|
748
|
+
} catch (err: any) {
|
|
749
|
+
console.error("fix-diagram error:", err?.message || err);
|
|
750
|
+
res.status(500).json({ error: err?.message || "Failed to fix diagram" });
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// ─── Start ───────────────────────────────────────────────
|
|
755
|
+
|
|
756
|
+
app.listen(PORT, () => {
|
|
757
|
+
console.log(`\n ✈ Interview Cockpit server → http://localhost:${PORT}\n`);
|
|
758
|
+
});
|