pi-interview 0.5.4 → 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.
- package/README.md +5 -1
- package/form/script.js +230 -6
- package/form/styles.css +100 -0
- package/index.ts +273 -5
- package/package.json +1 -1
- package/server.ts +116 -32
- package/settings.ts +15 -5
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ Restart pi to load the extension.
|
|
|
38
38
|
- **Session Status Bar**: Shows project path, git branch, and session ID for identification
|
|
39
39
|
- **Image Support**: Drag & drop anywhere on question, file picker, paste image or path
|
|
40
40
|
- **Path Normalization**: Handles shell-escaped paths (`\ `) and macOS screenshot filenames (narrow no-break space before AM/PM)
|
|
41
|
+
- **Generate & Review Options**: Single/multi-select questions show "✦ Generate more" (appends new choices) and "↻ Review options" (validates and rewrites existing choices) buttons powered by an LLM
|
|
41
42
|
- **Themes**: Built-in default + optional light/dark + custom theme CSS
|
|
42
43
|
|
|
43
44
|
## How It Works
|
|
@@ -63,7 +64,7 @@ Restart pi to load the extension.
|
|
|
63
64
|
|
|
64
65
|
**Timeout behavior:** The countdown (visible in corner) resets on any activity - typing, clicking, or mouse movement. When it expires, an overlay appears giving the user a chance to continue. Progress is never lost thanks to localStorage auto-save.
|
|
65
66
|
|
|
66
|
-
**Multi-agent behavior:** When multiple agents run interviews simultaneously, only the first auto-opens the window. Subsequent interviews are queued and shown as a URL in the tool output, preventing focus stealing. Active interviews also surface a top-right toast with a dropdown to open queued sessions. A session status bar at the top of each form shows the project path, git branch, and session ID for easy identification.
|
|
67
|
+
**Multi-agent behavior:** When multiple agents run interviews simultaneously, only the first auto-opens the window. Subsequent interviews are queued and shown as a URL in the tool output, preventing focus stealing. When you submit the active interview, the window automatically redirects to the next queued interview. Active interviews also surface a top-right toast with a dropdown to open queued sessions. A session status bar at the top of each form shows the project path, git branch, and session ID for easy identification.
|
|
67
68
|
|
|
68
69
|
## Usage
|
|
69
70
|
|
|
@@ -287,6 +288,7 @@ Settings in `~/.pi/agent/settings.json`:
|
|
|
287
288
|
"port": 19847,
|
|
288
289
|
"snapshotDir": "~/.pi/interview-snapshots/",
|
|
289
290
|
"autoSaveOnSubmit": true,
|
|
291
|
+
"generateModel": "anthropic/claude-haiku-4-5",
|
|
290
292
|
"theme": {
|
|
291
293
|
"mode": "auto",
|
|
292
294
|
"name": "default",
|
|
@@ -306,6 +308,8 @@ Settings in `~/.pi/agent/settings.json`:
|
|
|
306
308
|
|
|
307
309
|
**Port setting**: Set a fixed `port` (e.g., `19847`) to use a consistent port across sessions.
|
|
308
310
|
|
|
311
|
+
**Generate model**: `generateModel` sets the model for the generate/review option actions (e.g., `"anthropic/claude-haiku-4-5"`). Defaults to the agent's current model, then falls back to a cheap available model. If an explicitly configured generate model fails at request time and the current session is using a different model, interview retries once with the current session model.
|
|
312
|
+
|
|
309
313
|
**Theme notes:**
|
|
310
314
|
- `mode`: `dark` (default), `light`, or `auto` (follows OS unless overridden)
|
|
311
315
|
- `name`: built-in themes are `default` and `tufte`
|
package/form/script.js
CHANGED
|
@@ -1232,7 +1232,8 @@
|
|
|
1232
1232
|
|
|
1233
1233
|
if (event.key === 'Tab') {
|
|
1234
1234
|
const inAttachArea = document.activeElement?.closest('.attach-inline');
|
|
1235
|
-
|
|
1235
|
+
const inGenerateArea = document.activeElement?.closest('.generate-more');
|
|
1236
|
+
if (inAttachArea || inGenerateArea) return;
|
|
1236
1237
|
|
|
1237
1238
|
event.preventDefault();
|
|
1238
1239
|
|
|
@@ -1287,6 +1288,9 @@
|
|
|
1287
1288
|
if (document.activeElement?.closest('.attach-inline')) {
|
|
1288
1289
|
return;
|
|
1289
1290
|
}
|
|
1291
|
+
if (document.activeElement?.closest('.generate-more')) {
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1290
1294
|
event.preventDefault();
|
|
1291
1295
|
const option = options[nav.optionIndex];
|
|
1292
1296
|
if (option) {
|
|
@@ -1388,6 +1392,210 @@
|
|
|
1388
1392
|
}
|
|
1389
1393
|
}
|
|
1390
1394
|
|
|
1395
|
+
function createGenerateMoreUI(question, list) {
|
|
1396
|
+
if (!data.canGenerate) return null;
|
|
1397
|
+
|
|
1398
|
+
const container = document.createElement("div");
|
|
1399
|
+
container.className = "generate-more";
|
|
1400
|
+
|
|
1401
|
+
const btnRow = document.createElement("div");
|
|
1402
|
+
btnRow.className = "generate-more-row";
|
|
1403
|
+
|
|
1404
|
+
const addBtn = document.createElement("button");
|
|
1405
|
+
addBtn.type = "button";
|
|
1406
|
+
addBtn.className = "generate-more-btn";
|
|
1407
|
+
addBtn.innerHTML = '<span class="generate-more-icon">✦</span> Generate more';
|
|
1408
|
+
|
|
1409
|
+
const reviewBtn = document.createElement("button");
|
|
1410
|
+
reviewBtn.type = "button";
|
|
1411
|
+
reviewBtn.className = "generate-more-btn";
|
|
1412
|
+
reviewBtn.innerHTML = '<span class="generate-more-icon">↻</span> Review options';
|
|
1413
|
+
|
|
1414
|
+
const status = document.createElement("div");
|
|
1415
|
+
status.className = "generate-more-status hidden";
|
|
1416
|
+
|
|
1417
|
+
btnRow.appendChild(addBtn);
|
|
1418
|
+
btnRow.appendChild(reviewBtn);
|
|
1419
|
+
container.appendChild(btnRow);
|
|
1420
|
+
container.appendChild(status);
|
|
1421
|
+
|
|
1422
|
+
let generating = false;
|
|
1423
|
+
let abortController = null;
|
|
1424
|
+
let statusTimer = null;
|
|
1425
|
+
|
|
1426
|
+
function clearStatus() {
|
|
1427
|
+
if (statusTimer !== null) {
|
|
1428
|
+
clearTimeout(statusTimer);
|
|
1429
|
+
statusTimer = null;
|
|
1430
|
+
}
|
|
1431
|
+
status.classList.add("hidden");
|
|
1432
|
+
status.classList.remove("error");
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function showStatus(message, timeoutMs, isError = false) {
|
|
1436
|
+
if (statusTimer !== null) {
|
|
1437
|
+
clearTimeout(statusTimer);
|
|
1438
|
+
statusTimer = null;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
status.textContent = message;
|
|
1442
|
+
status.classList.remove("hidden");
|
|
1443
|
+
status.classList.toggle("error", isError);
|
|
1444
|
+
|
|
1445
|
+
if (timeoutMs == null) {
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
statusTimer = setTimeout(() => {
|
|
1450
|
+
status.classList.add("hidden");
|
|
1451
|
+
statusTimer = null;
|
|
1452
|
+
}, timeoutMs);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function getExistingOptions() {
|
|
1456
|
+
const inputs = list.querySelectorAll(
|
|
1457
|
+
'input[name="' + escapeSelector(question.id) + '"]'
|
|
1458
|
+
);
|
|
1459
|
+
return Array.from(inputs)
|
|
1460
|
+
.map((input) => input.value)
|
|
1461
|
+
.filter((v) => v && v !== "__other__");
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function runGenerate(btn, mode) {
|
|
1465
|
+
if (generating) {
|
|
1466
|
+
if (abortController) abortController.abort();
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
generating = true;
|
|
1471
|
+
const icon = btn.querySelector(".generate-more-icon").textContent;
|
|
1472
|
+
btn.innerHTML = '<span class="generate-more-icon">' + icon + '</span> Cancel';
|
|
1473
|
+
btn.classList.add("loading");
|
|
1474
|
+
addBtn.disabled = true;
|
|
1475
|
+
reviewBtn.disabled = true;
|
|
1476
|
+
btn.disabled = false;
|
|
1477
|
+
clearStatus();
|
|
1478
|
+
|
|
1479
|
+
abortController = new AbortController();
|
|
1480
|
+
const existingOptions = getExistingOptions();
|
|
1481
|
+
|
|
1482
|
+
try {
|
|
1483
|
+
const response = await fetch("/generate", {
|
|
1484
|
+
method: "POST",
|
|
1485
|
+
headers: { "Content-Type": "application/json" },
|
|
1486
|
+
body: JSON.stringify({
|
|
1487
|
+
token: sessionToken,
|
|
1488
|
+
questionId: question.id,
|
|
1489
|
+
existingOptions,
|
|
1490
|
+
mode,
|
|
1491
|
+
}),
|
|
1492
|
+
signal: abortController.signal,
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
const result = await response.json();
|
|
1496
|
+
if (!result.ok) throw new Error(result.error || "Generation failed");
|
|
1497
|
+
if (!Array.isArray(result.options) || result.options.length === 0) {
|
|
1498
|
+
throw new Error("No options generated");
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if (mode === "review") {
|
|
1502
|
+
const seen = new Set();
|
|
1503
|
+
const revisedOptions = result.options.filter((option) => {
|
|
1504
|
+
const key = option.toLowerCase().trim();
|
|
1505
|
+
if (seen.has(key)) return false;
|
|
1506
|
+
seen.add(key);
|
|
1507
|
+
return true;
|
|
1508
|
+
});
|
|
1509
|
+
if (revisedOptions.length === 0) {
|
|
1510
|
+
throw new Error("No valid options returned for review");
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
list
|
|
1514
|
+
.querySelectorAll('.option-item:not(.option-other):not(.done-item)')
|
|
1515
|
+
.forEach((el) => el.remove());
|
|
1516
|
+
revisedOptions.forEach((optionText, i) => {
|
|
1517
|
+
const optionEl = createGeneratedOption(question, optionText, i);
|
|
1518
|
+
list.insertBefore(optionEl, container);
|
|
1519
|
+
});
|
|
1520
|
+
if (question.type === "multi") updateDoneState(question.id);
|
|
1521
|
+
debounceSave();
|
|
1522
|
+
showStatus(
|
|
1523
|
+
revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
|
|
1524
|
+
2500,
|
|
1525
|
+
);
|
|
1526
|
+
} else {
|
|
1527
|
+
const existingSet = new Set(existingOptions.map((o) => o.toLowerCase().trim()));
|
|
1528
|
+
const seen = new Set();
|
|
1529
|
+
const newOptions = result.options.filter((o) => {
|
|
1530
|
+
const key = o.toLowerCase().trim();
|
|
1531
|
+
if (existingSet.has(key) || seen.has(key)) return false;
|
|
1532
|
+
seen.add(key);
|
|
1533
|
+
return true;
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
if (newOptions.length === 0) {
|
|
1537
|
+
showStatus("All generated options already exist", 3000);
|
|
1538
|
+
} else {
|
|
1539
|
+
newOptions.forEach((optionText, i) => {
|
|
1540
|
+
const optionEl = createGeneratedOption(question, optionText, i);
|
|
1541
|
+
list.insertBefore(optionEl, container);
|
|
1542
|
+
});
|
|
1543
|
+
showStatus(
|
|
1544
|
+
newOptions.length + " option" + (newOptions.length > 1 ? "s" : "") + " added",
|
|
1545
|
+
2500,
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
refreshCountdown();
|
|
1550
|
+
} catch (err) {
|
|
1551
|
+
if (!(err instanceof Error && err.name === "AbortError")) {
|
|
1552
|
+
showStatus(err instanceof Error ? err.message : "Generation failed", null, true);
|
|
1553
|
+
}
|
|
1554
|
+
} finally {
|
|
1555
|
+
generating = false;
|
|
1556
|
+
addBtn.innerHTML = '<span class="generate-more-icon">✦</span> Generate more';
|
|
1557
|
+
reviewBtn.innerHTML = '<span class="generate-more-icon">↻</span> Review options';
|
|
1558
|
+
addBtn.classList.remove("loading");
|
|
1559
|
+
reviewBtn.classList.remove("loading");
|
|
1560
|
+
addBtn.disabled = false;
|
|
1561
|
+
reviewBtn.disabled = false;
|
|
1562
|
+
abortController = null;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
addBtn.addEventListener("click", () => runGenerate(addBtn, "add"));
|
|
1567
|
+
reviewBtn.addEventListener("click", () => runGenerate(reviewBtn, "review"));
|
|
1568
|
+
|
|
1569
|
+
return container;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function createGeneratedOption(question, optionText, animIndex) {
|
|
1573
|
+
const label = document.createElement("label");
|
|
1574
|
+
label.className = "option-item generated";
|
|
1575
|
+
label.style.animationDelay = (animIndex * 0.08) + "s";
|
|
1576
|
+
|
|
1577
|
+
const input = document.createElement("input");
|
|
1578
|
+
input.type = question.type === "single" ? "radio" : "checkbox";
|
|
1579
|
+
input.name = question.id;
|
|
1580
|
+
input.value = optionText;
|
|
1581
|
+
input.setAttribute("tabindex", "-1");
|
|
1582
|
+
|
|
1583
|
+
input.addEventListener("change", () => {
|
|
1584
|
+
debounceSave();
|
|
1585
|
+
if (question.type === "multi") {
|
|
1586
|
+
updateDoneState(question.id);
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
const text = document.createElement("span");
|
|
1591
|
+
text.textContent = optionText;
|
|
1592
|
+
|
|
1593
|
+
label.appendChild(input);
|
|
1594
|
+
label.appendChild(text);
|
|
1595
|
+
|
|
1596
|
+
return label;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1391
1599
|
function createQuestionCard(question, index, badgeNumber) {
|
|
1392
1600
|
const card = document.createElement("section");
|
|
1393
1601
|
card.className = "question-card";
|
|
@@ -1523,6 +1731,8 @@
|
|
|
1523
1731
|
list.appendChild(label);
|
|
1524
1732
|
});
|
|
1525
1733
|
|
|
1734
|
+
const generateMoreEl = createGenerateMoreUI(question, list);
|
|
1735
|
+
if (generateMoreEl) list.appendChild(generateMoreEl);
|
|
1526
1736
|
|
|
1527
1737
|
const otherLabel = document.createElement("label");
|
|
1528
1738
|
otherLabel.className = "option-item option-other";
|
|
@@ -2405,13 +2615,21 @@
|
|
|
2405
2615
|
body: JSON.stringify({ token: sessionToken, ...payload }),
|
|
2406
2616
|
});
|
|
2407
2617
|
|
|
2408
|
-
|
|
2618
|
+
let submitResult;
|
|
2619
|
+
try {
|
|
2620
|
+
submitResult = await response.json();
|
|
2621
|
+
} catch (err) {
|
|
2622
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2623
|
+
showGlobalError(`Invalid server response: ${message}`);
|
|
2624
|
+
submitBtn.disabled = false;
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2409
2627
|
|
|
2410
|
-
if (!response.ok || !
|
|
2411
|
-
if (
|
|
2412
|
-
setFieldError(
|
|
2628
|
+
if (!response.ok || !submitResult.ok) {
|
|
2629
|
+
if (submitResult.field) {
|
|
2630
|
+
setFieldError(submitResult.field, submitResult.error || "Invalid input");
|
|
2413
2631
|
} else {
|
|
2414
|
-
showGlobalError(
|
|
2632
|
+
showGlobalError(submitResult.error || "Submission failed.");
|
|
2415
2633
|
}
|
|
2416
2634
|
submitBtn.disabled = false;
|
|
2417
2635
|
return;
|
|
@@ -2427,6 +2645,12 @@
|
|
|
2427
2645
|
stopHeartbeat();
|
|
2428
2646
|
stopQueuePolling();
|
|
2429
2647
|
session.ended = true;
|
|
2648
|
+
|
|
2649
|
+
if (submitResult.nextUrl) {
|
|
2650
|
+
window.location.href = submitResult.nextUrl;
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2430
2654
|
successOverlay.classList.remove("hidden");
|
|
2431
2655
|
setTimeout(() => {
|
|
2432
2656
|
closeWindow();
|
package/form/styles.css
CHANGED
|
@@ -1710,6 +1710,106 @@ button {
|
|
|
1710
1710
|
background: color-mix(in srgb, var(--card-accent, var(--accent)) 4%, var(--bg-elevated));
|
|
1711
1711
|
}
|
|
1712
1712
|
|
|
1713
|
+
/* Generate more options */
|
|
1714
|
+
.generate-more {
|
|
1715
|
+
margin: 4px 0 2px;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
.generate-more-row {
|
|
1719
|
+
display: flex;
|
|
1720
|
+
gap: 6px;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
.generate-more-btn {
|
|
1724
|
+
flex: 1;
|
|
1725
|
+
display: flex;
|
|
1726
|
+
align-items: center;
|
|
1727
|
+
justify-content: center;
|
|
1728
|
+
gap: 6px;
|
|
1729
|
+
padding: 10px 14px;
|
|
1730
|
+
border: 1px dashed var(--border-muted);
|
|
1731
|
+
border-radius: 8px;
|
|
1732
|
+
background: transparent;
|
|
1733
|
+
color: var(--fg-dim);
|
|
1734
|
+
font-family: var(--font-body);
|
|
1735
|
+
font-size: var(--font-size-option);
|
|
1736
|
+
cursor: pointer;
|
|
1737
|
+
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
.generate-more-btn:hover {
|
|
1741
|
+
border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 50%, transparent);
|
|
1742
|
+
color: var(--card-accent, var(--accent));
|
|
1743
|
+
background: color-mix(in srgb, var(--card-accent, var(--accent)) 5%, transparent);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
.generate-more-btn:focus-visible {
|
|
1747
|
+
outline: none;
|
|
1748
|
+
box-shadow: 0 0 0 2px var(--focus-ring);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
.generate-more-icon {
|
|
1752
|
+
font-size: 14px;
|
|
1753
|
+
line-height: 1;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
@keyframes generate-shimmer {
|
|
1757
|
+
0% { background-position: 200% center; }
|
|
1758
|
+
100% { background-position: -200% center; }
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
@keyframes generate-spin {
|
|
1762
|
+
from { transform: rotate(0deg); }
|
|
1763
|
+
to { transform: rotate(360deg); }
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
.generate-more-btn.loading {
|
|
1767
|
+
border-style: solid;
|
|
1768
|
+
border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 30%, transparent);
|
|
1769
|
+
color: var(--card-accent, var(--accent));
|
|
1770
|
+
background: linear-gradient(
|
|
1771
|
+
90deg,
|
|
1772
|
+
transparent 0%,
|
|
1773
|
+
color-mix(in srgb, var(--card-accent, var(--accent)) 8%, transparent) 40%,
|
|
1774
|
+
color-mix(in srgb, var(--card-accent, var(--accent)) 15%, transparent) 50%,
|
|
1775
|
+
color-mix(in srgb, var(--card-accent, var(--accent)) 8%, transparent) 60%,
|
|
1776
|
+
transparent 100%
|
|
1777
|
+
);
|
|
1778
|
+
background-size: 200% 100%;
|
|
1779
|
+
animation: generate-shimmer 2s ease-in-out infinite;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
.generate-more-btn.loading .generate-more-icon {
|
|
1783
|
+
animation: generate-spin 1.5s linear infinite;
|
|
1784
|
+
display: inline-block;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
.generate-more-btn:disabled:not(.loading) {
|
|
1788
|
+
opacity: 0.4;
|
|
1789
|
+
pointer-events: none;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
.generate-more-status {
|
|
1793
|
+
margin-top: 8px;
|
|
1794
|
+
font-family: var(--font-mono);
|
|
1795
|
+
font-size: 11px;
|
|
1796
|
+
color: var(--fg-muted);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
.generate-more-status.error {
|
|
1800
|
+
color: var(--error);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/* Generated option entrance */
|
|
1804
|
+
@keyframes option-slide-in {
|
|
1805
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
1806
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
.option-item.generated {
|
|
1810
|
+
animation: option-slide-in 0.25s ease-out backwards;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1713
1813
|
/* Side-by-side layout */
|
|
1714
1814
|
.question-side-layout {
|
|
1715
1815
|
display: grid;
|
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
-
import { StringEnum } from "@mariozechner/pi-ai";
|
|
2
|
+
import { StringEnum, complete, type Api, type AssistantMessage, type Model } from "@mariozechner/pi-ai";
|
|
3
3
|
import { Text } from "@mariozechner/pi-tui";
|
|
4
4
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
5
5
|
import * as path from "node:path";
|
|
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { execSync, execFileSync } from "node:child_process";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
|
-
import { startInterviewServer, getActiveSessions, type ResponseItem } from "./server.js";
|
|
11
|
+
import { startInterviewServer, getActiveSessions, type ResponseItem, type InterviewServerCallbacks } from "./server.js";
|
|
12
12
|
import { validateQuestions, sanitizeLLMJSON, type QuestionsFile } from "./schema.js";
|
|
13
13
|
import { loadSettings, type InterviewThemeSettings } from "./settings.js";
|
|
14
14
|
|
|
@@ -232,6 +232,163 @@ function loadQuestions(questionsInput: string, cwd: string): SavedQuestionsFile
|
|
|
232
232
|
return validateQuestions(data);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
interface GenerateModelCandidate {
|
|
236
|
+
provider: string;
|
|
237
|
+
id: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const PREFERRED_GENERATE_MODELS = [
|
|
241
|
+
"anthropic/claude-haiku-4-5",
|
|
242
|
+
"google/gemini-2.5-flash",
|
|
243
|
+
"openai/gpt-4.1-mini",
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const GENERATE_OPTIONS_SYSTEM_PROMPT =
|
|
247
|
+
"You generate interview answer options. Return only a JSON array of strings. Do not include explanations or markdown.";
|
|
248
|
+
|
|
249
|
+
function formatModelRef(model: GenerateModelCandidate): string {
|
|
250
|
+
return `${model.provider}/${model.id}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function findModelByRef<T extends GenerateModelCandidate>(models: T[], modelRef: string): T | null {
|
|
254
|
+
for (const model of models) {
|
|
255
|
+
if (formatModelRef(model) === modelRef) {
|
|
256
|
+
return model;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function selectGenerateModels<T extends GenerateModelCandidate>(
|
|
263
|
+
configuredModel: T | null,
|
|
264
|
+
currentModel: T | null,
|
|
265
|
+
availableModels: T[],
|
|
266
|
+
): { primary: T | null; fallback: T | null } {
|
|
267
|
+
if (configuredModel) {
|
|
268
|
+
if (!currentModel || formatModelRef(currentModel) === formatModelRef(configuredModel)) {
|
|
269
|
+
return { primary: configuredModel, fallback: null };
|
|
270
|
+
}
|
|
271
|
+
return { primary: configuredModel, fallback: currentModel };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (currentModel) {
|
|
275
|
+
return { primary: currentModel, fallback: null };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const modelRef of PREFERRED_GENERATE_MODELS) {
|
|
279
|
+
const preferredModel = findModelByRef(availableModels, modelRef);
|
|
280
|
+
if (preferredModel) {
|
|
281
|
+
return { primary: preferredModel, fallback: null };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { primary: availableModels[0] ?? null, fallback: null };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function extractGenerateResponseText(
|
|
289
|
+
modelRef: string,
|
|
290
|
+
response: Pick<AssistantMessage, "content" | "stopReason" | "errorMessage">,
|
|
291
|
+
): string {
|
|
292
|
+
if (response.stopReason === "aborted") {
|
|
293
|
+
throw new Error("Aborted");
|
|
294
|
+
}
|
|
295
|
+
if (response.stopReason === "error") {
|
|
296
|
+
throw new Error(response.errorMessage ? `${modelRef}: ${response.errorMessage}` : `${modelRef} failed`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const text = response.content
|
|
300
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
301
|
+
.map((part) => part.text)
|
|
302
|
+
.join("")
|
|
303
|
+
.trim();
|
|
304
|
+
if (!text) {
|
|
305
|
+
throw new Error(`${modelRef} returned no text response`);
|
|
306
|
+
}
|
|
307
|
+
return text;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function extractJSONArray(text: string): string {
|
|
311
|
+
const start = text.indexOf("[");
|
|
312
|
+
if (start === -1) return text;
|
|
313
|
+
|
|
314
|
+
let depth = 0;
|
|
315
|
+
let inString = false;
|
|
316
|
+
let escaping = false;
|
|
317
|
+
|
|
318
|
+
for (let i = start; i < text.length; i++) {
|
|
319
|
+
const char = text[i];
|
|
320
|
+
|
|
321
|
+
if (inString) {
|
|
322
|
+
if (escaping) {
|
|
323
|
+
escaping = false;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (char === "\\") {
|
|
327
|
+
escaping = true;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (char === '"') {
|
|
331
|
+
inString = false;
|
|
332
|
+
}
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (char === '"') {
|
|
337
|
+
inString = true;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (char === "[") {
|
|
341
|
+
depth++;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (char !== "]") {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
depth--;
|
|
349
|
+
if (depth === 0) {
|
|
350
|
+
return text.slice(start, i + 1);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return text;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function createGenerateContext(prompt: string) {
|
|
358
|
+
return {
|
|
359
|
+
systemPrompt: GENERATE_OPTIONS_SYSTEM_PROMPT,
|
|
360
|
+
messages: [{
|
|
361
|
+
role: "user" as const,
|
|
362
|
+
content: [{ type: "text" as const, text: prompt }],
|
|
363
|
+
timestamp: Date.now(),
|
|
364
|
+
}],
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function parseGeneratedOptions(text: string): string[] {
|
|
369
|
+
let parsed: unknown;
|
|
370
|
+
try {
|
|
371
|
+
parsed = JSON.parse(extractJSONArray(text));
|
|
372
|
+
} catch (err) {
|
|
373
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
374
|
+
throw new Error(`Failed to parse generated options: ${detail}`);
|
|
375
|
+
}
|
|
376
|
+
if (!Array.isArray(parsed)) {
|
|
377
|
+
throw new Error("Expected array of options");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const options = parsed
|
|
381
|
+
.filter(
|
|
382
|
+
(item: unknown): item is string =>
|
|
383
|
+
typeof item === "string" && item.trim().length > 0,
|
|
384
|
+
)
|
|
385
|
+
.map((option: string) => option.trim());
|
|
386
|
+
if (options.length === 0) {
|
|
387
|
+
throw new Error("No valid options generated");
|
|
388
|
+
}
|
|
389
|
+
return options;
|
|
390
|
+
}
|
|
391
|
+
|
|
235
392
|
function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
|
|
236
393
|
// Extract JSON from <script id="pi-interview-data">
|
|
237
394
|
const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
|
|
@@ -242,8 +399,9 @@ function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile
|
|
|
242
399
|
let data: unknown;
|
|
243
400
|
try {
|
|
244
401
|
data = JSON.parse(match[1]);
|
|
245
|
-
} catch {
|
|
246
|
-
|
|
402
|
+
} catch (err) {
|
|
403
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
404
|
+
throw new Error(`Invalid saved interview: malformed JSON (${message})`);
|
|
247
405
|
}
|
|
248
406
|
|
|
249
407
|
const raw = data as Record<string, unknown>;
|
|
@@ -394,6 +552,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
394
552
|
const themeConfig = mergeThemeConfig(settings.theme, theme, ctx.cwd);
|
|
395
553
|
const questionsData = loadQuestions(questions, ctx.cwd);
|
|
396
554
|
|
|
555
|
+
let configuredGenerateModel: Model<Api> | null = null;
|
|
556
|
+
if (settings.generateModel) {
|
|
557
|
+
const slashIdx = settings.generateModel.indexOf("/");
|
|
558
|
+
if (slashIdx > 0) {
|
|
559
|
+
configuredGenerateModel = ctx.modelRegistry.find(
|
|
560
|
+
settings.generateModel.slice(0, slashIdx),
|
|
561
|
+
settings.generateModel.slice(slashIdx + 1),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let availableGenerateModels: Model<Api>[] = [];
|
|
567
|
+
if (!configuredGenerateModel && !ctx.model) {
|
|
568
|
+
try {
|
|
569
|
+
availableGenerateModels = ctx.modelRegistry.getAvailable();
|
|
570
|
+
} catch {
|
|
571
|
+
// Leave generation disabled when model discovery is unavailable.
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const { primary: generateModel, fallback: fallbackGenerateModel } = selectGenerateModels(
|
|
576
|
+
configuredGenerateModel,
|
|
577
|
+
ctx.model ?? null,
|
|
578
|
+
availableGenerateModels,
|
|
579
|
+
);
|
|
580
|
+
|
|
397
581
|
// Expand ~ in snapshotDir if present
|
|
398
582
|
const snapshotDir = settings.snapshotDir
|
|
399
583
|
? expandHome(settings.snapshotDir)
|
|
@@ -412,7 +596,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
412
596
|
let glimpseWin: GlimpseWindow | null = null;
|
|
413
597
|
let resolved = false;
|
|
414
598
|
let url = "";
|
|
415
|
-
|
|
416
599
|
const cleanup = () => {
|
|
417
600
|
if (server) {
|
|
418
601
|
server.close();
|
|
@@ -469,6 +652,89 @@ export default function (pi: ExtensionAPI) {
|
|
|
469
652
|
};
|
|
470
653
|
signal?.addEventListener("abort", handleAbort, { once: true });
|
|
471
654
|
|
|
655
|
+
let onGenerate: InterviewServerCallbacks["onGenerate"];
|
|
656
|
+
if (generateModel) {
|
|
657
|
+
const generateOptions = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
|
|
658
|
+
const modelRef = formatModelRef(model);
|
|
659
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
660
|
+
if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
|
|
661
|
+
if (!auth.apiKey) throw new Error(`No API key for ${modelRef}`);
|
|
662
|
+
|
|
663
|
+
const response = await complete(
|
|
664
|
+
model,
|
|
665
|
+
createGenerateContext(prompt),
|
|
666
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
return parseGeneratedOptions(extractGenerateResponseText(modelRef, response));
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
onGenerate = async (questionId, existingOptions, generateSignal, mode) => {
|
|
673
|
+
const question = questionsData.questions.find((q) => q.id === questionId);
|
|
674
|
+
if (!question) throw new Error(`Unknown question: ${questionId}`);
|
|
675
|
+
|
|
676
|
+
const existingList = existingOptions.length > 0
|
|
677
|
+
? existingOptions.map((option) => `- ${option}`).join("\n")
|
|
678
|
+
: "(none)";
|
|
679
|
+
|
|
680
|
+
let prompt: string;
|
|
681
|
+
if (mode === "review") {
|
|
682
|
+
let recommended = "";
|
|
683
|
+
if (question.recommended) {
|
|
684
|
+
const value = Array.isArray(question.recommended)
|
|
685
|
+
? question.recommended.join(", ")
|
|
686
|
+
: question.recommended;
|
|
687
|
+
recommended = `\nRecommended: ${value}`;
|
|
688
|
+
}
|
|
689
|
+
prompt = [
|
|
690
|
+
"Review these options for the question below. Fix any issues: incorrect options, missing obvious choices, poor wording, redundancy.",
|
|
691
|
+
"Return ONLY a JSON array of the corrected option strings. Keep good options as-is, fix bad ones, add missing ones, remove bad ones.",
|
|
692
|
+
"",
|
|
693
|
+
questionsData.title ? `Interview: ${questionsData.title}` : null,
|
|
694
|
+
`Question: ${question.question}`,
|
|
695
|
+
question.context ? `Context: ${question.context}` : null,
|
|
696
|
+
recommended || null,
|
|
697
|
+
"",
|
|
698
|
+
"Current options:",
|
|
699
|
+
existingList,
|
|
700
|
+
"",
|
|
701
|
+
'Format: ["Option A", "Option B", "Option C"]',
|
|
702
|
+
].filter((line) => line !== null).join("\n");
|
|
703
|
+
} else {
|
|
704
|
+
prompt = [
|
|
705
|
+
"Generate 3 new, distinct options for this question.",
|
|
706
|
+
"Return ONLY a JSON array of short option strings. No explanation, no markdown.",
|
|
707
|
+
"",
|
|
708
|
+
`Question: ${question.question}`,
|
|
709
|
+
question.context ? `Context: ${question.context}` : null,
|
|
710
|
+
"",
|
|
711
|
+
"Existing options (do NOT repeat):",
|
|
712
|
+
existingList,
|
|
713
|
+
"",
|
|
714
|
+
'Format: ["Option A", "Option B", "Option C"]',
|
|
715
|
+
].filter((line) => line !== null).join("\n");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let options: string[];
|
|
719
|
+
try {
|
|
720
|
+
options = await generateOptions(generateModel, prompt, generateSignal);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
if (!fallbackGenerateModel || generateSignal.aborted) {
|
|
723
|
+
throw err;
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
options = await generateOptions(fallbackGenerateModel, prompt, generateSignal);
|
|
727
|
+
} catch (fallbackErr) {
|
|
728
|
+
const primaryMessage = err instanceof Error ? err.message : String(err);
|
|
729
|
+
const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
730
|
+
throw new Error(`${primaryMessage}. Fallback failed: ${fallbackMessage}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return { options };
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
472
738
|
startInterviewServer(
|
|
473
739
|
{
|
|
474
740
|
questions: questionsData,
|
|
@@ -482,6 +748,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
482
748
|
snapshotDir,
|
|
483
749
|
autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
|
|
484
750
|
savedAnswers: questionsData.savedAnswers,
|
|
751
|
+
canGenerate: generateModel !== null,
|
|
485
752
|
},
|
|
486
753
|
{
|
|
487
754
|
onSubmit: (responses) => finish("completed", responses),
|
|
@@ -489,6 +756,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
489
756
|
reason === "timeout"
|
|
490
757
|
? finish("timeout", partialResponses ?? [])
|
|
491
758
|
: finish("cancelled", partialResponses ?? [], reason),
|
|
759
|
+
onGenerate,
|
|
492
760
|
}
|
|
493
761
|
)
|
|
494
762
|
.then(async (handle) => {
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -196,11 +196,18 @@ export interface InterviewServerOptions {
|
|
|
196
196
|
snapshotDir?: string;
|
|
197
197
|
autoSaveOnSubmit?: boolean;
|
|
198
198
|
savedAnswers?: ResponseItem[];
|
|
199
|
+
canGenerate?: boolean;
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
export interface InterviewServerCallbacks {
|
|
202
203
|
onSubmit: (responses: ResponseItem[]) => void;
|
|
203
204
|
onCancel: (reason?: "timeout" | "user" | "stale", partialResponses?: ResponseItem[]) => void;
|
|
205
|
+
onGenerate?: (
|
|
206
|
+
questionId: string,
|
|
207
|
+
existingOptions: string[],
|
|
208
|
+
signal: AbortSignal,
|
|
209
|
+
mode: "add" | "review",
|
|
210
|
+
) => Promise<{ options: string[] }>;
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
export interface InterviewServerHandle {
|
|
@@ -303,8 +310,9 @@ async function parseJSONBody(req: IncomingMessage): Promise<unknown> {
|
|
|
303
310
|
req.on("end", () => {
|
|
304
311
|
try {
|
|
305
312
|
resolve(JSON.parse(body));
|
|
306
|
-
} catch {
|
|
307
|
-
|
|
313
|
+
} catch (err) {
|
|
314
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
315
|
+
reject(new Error(`Invalid JSON: ${message}`));
|
|
308
316
|
}
|
|
309
317
|
});
|
|
310
318
|
|
|
@@ -807,6 +815,7 @@ export async function startInterviewServer(
|
|
|
807
815
|
let browserConnected = false;
|
|
808
816
|
let lastHeartbeatAt = Date.now();
|
|
809
817
|
let watchdog: NodeJS.Timeout | null = null;
|
|
818
|
+
let sessionKeepAlive: NodeJS.Timeout | null = null;
|
|
810
819
|
let completed = false;
|
|
811
820
|
|
|
812
821
|
const stopWatchdog = () => {
|
|
@@ -816,10 +825,18 @@ export async function startInterviewServer(
|
|
|
816
825
|
}
|
|
817
826
|
};
|
|
818
827
|
|
|
828
|
+
const stopSessionKeepAlive = () => {
|
|
829
|
+
if (sessionKeepAlive) {
|
|
830
|
+
clearInterval(sessionKeepAlive);
|
|
831
|
+
sessionKeepAlive = null;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
|
|
819
835
|
const markCompleted = () => {
|
|
820
836
|
if (completed) return false;
|
|
821
837
|
completed = true;
|
|
822
838
|
stopWatchdog();
|
|
839
|
+
stopSessionKeepAlive();
|
|
823
840
|
return true;
|
|
824
841
|
};
|
|
825
842
|
|
|
@@ -839,6 +856,20 @@ export async function startInterviewServer(
|
|
|
839
856
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
840
857
|
log(verbose, `${method} ${url.pathname}`);
|
|
841
858
|
|
|
859
|
+
const parseBodyOrRespond = async (): Promise<unknown | null> => {
|
|
860
|
+
try {
|
|
861
|
+
return await parseJSONBody(req);
|
|
862
|
+
} catch (err) {
|
|
863
|
+
if (err instanceof BodyTooLargeError) {
|
|
864
|
+
sendJson(res, err.statusCode, { ok: false, error: err.message });
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
868
|
+
sendJson(res, 400, { ok: false, error: message });
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
842
873
|
if (method === "GET" && url.pathname === "/") {
|
|
843
874
|
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
844
875
|
touchHeartbeat();
|
|
@@ -858,6 +889,7 @@ export async function startInterviewServer(
|
|
|
858
889
|
},
|
|
859
890
|
savedAnswers: options.savedAnswers,
|
|
860
891
|
autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
|
|
892
|
+
canGenerate: options.canGenerate ?? false,
|
|
861
893
|
});
|
|
862
894
|
const html = TEMPLATE
|
|
863
895
|
.replace("<!-- __CDN_SCRIPTS__ -->", cdnScripts)
|
|
@@ -978,11 +1010,8 @@ export async function startInterviewServer(
|
|
|
978
1010
|
}
|
|
979
1011
|
|
|
980
1012
|
if (method === "POST" && url.pathname === "/heartbeat") {
|
|
981
|
-
const body = await
|
|
982
|
-
if (!body)
|
|
983
|
-
sendJson(res, 400, { ok: false, error: "Invalid body" });
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
1013
|
+
const body = await parseBodyOrRespond();
|
|
1014
|
+
if (!body) return;
|
|
986
1015
|
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
987
1016
|
touchHeartbeat();
|
|
988
1017
|
sendJson(res, 200, { ok: true });
|
|
@@ -990,14 +1019,7 @@ export async function startInterviewServer(
|
|
|
990
1019
|
}
|
|
991
1020
|
|
|
992
1021
|
if (method === "POST" && url.pathname === "/cancel") {
|
|
993
|
-
const body = await
|
|
994
|
-
if (err instanceof BodyTooLargeError) {
|
|
995
|
-
sendJson(res, err.statusCode, { ok: false, error: err.message });
|
|
996
|
-
return null;
|
|
997
|
-
}
|
|
998
|
-
sendJson(res, 400, { ok: false, error: err.message });
|
|
999
|
-
return null;
|
|
1000
|
-
});
|
|
1022
|
+
const body = await parseBodyOrRespond();
|
|
1001
1023
|
if (!body) return;
|
|
1002
1024
|
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
1003
1025
|
if (completed) {
|
|
@@ -1020,14 +1042,7 @@ export async function startInterviewServer(
|
|
|
1020
1042
|
}
|
|
1021
1043
|
|
|
1022
1044
|
if (method === "POST" && url.pathname === "/submit") {
|
|
1023
|
-
const body = await
|
|
1024
|
-
if (err instanceof BodyTooLargeError) {
|
|
1025
|
-
sendJson(res, err.statusCode, { ok: false, error: err.message });
|
|
1026
|
-
return null;
|
|
1027
|
-
}
|
|
1028
|
-
sendJson(res, 400, { ok: false, error: err.message });
|
|
1029
|
-
return null;
|
|
1030
|
-
});
|
|
1045
|
+
const body = await parseBodyOrRespond();
|
|
1031
1046
|
if (!body) return;
|
|
1032
1047
|
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
1033
1048
|
if (completed) {
|
|
@@ -1143,20 +1158,22 @@ export async function startInterviewServer(
|
|
|
1143
1158
|
|
|
1144
1159
|
markCompleted();
|
|
1145
1160
|
unregisterSession(sessionId);
|
|
1146
|
-
|
|
1161
|
+
const nextSession = getActiveSessions()
|
|
1162
|
+
.filter((s) => s.id !== sessionId)
|
|
1163
|
+
.sort((a, b) => {
|
|
1164
|
+
if (a.startedAt !== b.startedAt) {
|
|
1165
|
+
return a.startedAt - b.startedAt;
|
|
1166
|
+
}
|
|
1167
|
+
return a.id.localeCompare(b.id);
|
|
1168
|
+
})[0];
|
|
1169
|
+
const nextUrl = nextSession ? nextSession.url : null;
|
|
1170
|
+
sendJson(res, 200, { ok: true, nextUrl });
|
|
1147
1171
|
setImmediate(() => callbacks.onSubmit(responses));
|
|
1148
1172
|
return;
|
|
1149
1173
|
}
|
|
1150
1174
|
|
|
1151
1175
|
if (method === "POST" && url.pathname === "/save") {
|
|
1152
|
-
const body = await
|
|
1153
|
-
if (err instanceof BodyTooLargeError) {
|
|
1154
|
-
sendJson(res, err.statusCode, { ok: false, error: err.message });
|
|
1155
|
-
return null;
|
|
1156
|
-
}
|
|
1157
|
-
sendJson(res, 400, { ok: false, error: err.message });
|
|
1158
|
-
return null;
|
|
1159
|
-
});
|
|
1176
|
+
const body = await parseBodyOrRespond();
|
|
1160
1177
|
if (!body) return;
|
|
1161
1178
|
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
1162
1179
|
// Note: don't check `completed` - allow save after submit
|
|
@@ -1274,6 +1291,68 @@ export async function startInterviewServer(
|
|
|
1274
1291
|
return;
|
|
1275
1292
|
}
|
|
1276
1293
|
|
|
1294
|
+
if (method === "POST" && url.pathname === "/generate") {
|
|
1295
|
+
const body = await parseBodyOrRespond();
|
|
1296
|
+
if (!body) return;
|
|
1297
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
1298
|
+
if (completed) {
|
|
1299
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (!callbacks.onGenerate) {
|
|
1304
|
+
sendJson(res, 501, { ok: false, error: "Generation not available" });
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const payload = body as {
|
|
1309
|
+
questionId?: string;
|
|
1310
|
+
existingOptions?: string[];
|
|
1311
|
+
mode?: string;
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
if (typeof payload.questionId !== "string") {
|
|
1315
|
+
sendJson(res, 400, { ok: false, error: "Missing questionId" });
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const question = questionById.get(payload.questionId);
|
|
1320
|
+
if (!question || (question.type !== "single" && question.type !== "multi")) {
|
|
1321
|
+
sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const existingOptions = Array.isArray(payload.existingOptions)
|
|
1326
|
+
? payload.existingOptions.filter((o): o is string => typeof o === "string")
|
|
1327
|
+
: [];
|
|
1328
|
+
|
|
1329
|
+
const mode = payload.mode === "review" ? "review" : "add";
|
|
1330
|
+
|
|
1331
|
+
const controller = new AbortController();
|
|
1332
|
+
res.on("close", () => {
|
|
1333
|
+
if (!res.writableEnded) controller.abort();
|
|
1334
|
+
});
|
|
1335
|
+
touchHeartbeat();
|
|
1336
|
+
|
|
1337
|
+
try {
|
|
1338
|
+
const result = await callbacks.onGenerate(
|
|
1339
|
+
payload.questionId,
|
|
1340
|
+
existingOptions,
|
|
1341
|
+
controller.signal,
|
|
1342
|
+
mode,
|
|
1343
|
+
);
|
|
1344
|
+
sendJson(res, 200, { ok: true, options: result.options });
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
if (controller.signal.aborted) {
|
|
1347
|
+
sendJson(res, 409, { ok: false, error: "Request cancelled" });
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
const message = err instanceof Error ? err.message : "Generation failed";
|
|
1351
|
+
sendJson(res, 500, { ok: false, error: message });
|
|
1352
|
+
}
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1277
1356
|
sendText(res, 404, "Not found");
|
|
1278
1357
|
} catch (err) {
|
|
1279
1358
|
const message = err instanceof Error ? err.message : "Server error";
|
|
@@ -1307,6 +1386,11 @@ export async function startInterviewServer(
|
|
|
1307
1386
|
lastSeen: now,
|
|
1308
1387
|
};
|
|
1309
1388
|
registerSession(sessionEntry);
|
|
1389
|
+
const keepAliveEntry = sessionEntry;
|
|
1390
|
+
sessionKeepAlive = setInterval(() => {
|
|
1391
|
+
if (completed) return;
|
|
1392
|
+
touchSession(keepAliveEntry);
|
|
1393
|
+
}, 10000);
|
|
1310
1394
|
if (!watchdog) {
|
|
1311
1395
|
watchdog = setInterval(() => {
|
|
1312
1396
|
if (completed || !browserConnected) return;
|
package/settings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
|
|
@@ -19,13 +19,23 @@ export interface InterviewSettings {
|
|
|
19
19
|
theme?: InterviewThemeSettings;
|
|
20
20
|
snapshotDir?: string; // Default: ~/.pi/interview-snapshots/
|
|
21
21
|
autoSaveOnSubmit?: boolean; // Default: true
|
|
22
|
+
generateModel?: string; // e.g., "anthropic/claude-haiku-4-5"
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function loadSettings(): InterviewSettings {
|
|
25
|
-
|
|
26
|
-
const data = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
27
|
-
return (data.interview as InterviewSettings) ?? {};
|
|
28
|
-
} catch {
|
|
26
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
29
27
|
return {};
|
|
30
28
|
}
|
|
29
|
+
|
|
30
|
+
const parsed = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
31
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const interview = (parsed as Record<string, unknown>).interview;
|
|
36
|
+
if (typeof interview !== "object" || interview === null) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return interview as InterviewSettings;
|
|
31
41
|
}
|