pi-interview 0.5.5 → 0.6.1
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 -0
- package/form/script.js +258 -3
- package/form/styles.css +105 -3
- package/index.ts +369 -20
- package/package.json +1 -1
- package/server.ts +132 -3
- package/settings.ts +1 -0
package/README.md
CHANGED
|
@@ -38,6 +38,8 @@ 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" (reviews options and rewrites the question for clarity) buttons powered by an LLM
|
|
42
|
+
- **Tool Discoverability (pi v0.59+)**: Registers a `promptSnippet` so `interview` remains eligible for inclusion in pi's default `Available tools` prompt section
|
|
41
43
|
- **Themes**: Built-in default + optional light/dark + custom theme CSS
|
|
42
44
|
|
|
43
45
|
## How It Works
|
|
@@ -287,6 +289,7 @@ Settings in `~/.pi/agent/settings.json`:
|
|
|
287
289
|
"port": 19847,
|
|
288
290
|
"snapshotDir": "~/.pi/interview-snapshots/",
|
|
289
291
|
"autoSaveOnSubmit": true,
|
|
292
|
+
"generateModel": "anthropic/claude-haiku-4-5",
|
|
290
293
|
"theme": {
|
|
291
294
|
"mode": "auto",
|
|
292
295
|
"name": "default",
|
|
@@ -306,6 +309,8 @@ Settings in `~/.pi/agent/settings.json`:
|
|
|
306
309
|
|
|
307
310
|
**Port setting**: Set a fixed `port` (e.g., `19847`) to use a consistent port across sessions.
|
|
308
311
|
|
|
312
|
+
**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.
|
|
313
|
+
|
|
309
314
|
**Theme notes:**
|
|
310
315
|
- `mode`: `dark` (default), `light`, or `auto` (follows OS unless overridden)
|
|
311
316
|
- `name`: built-in themes are `default` and `tufte`
|
package/form/script.js
CHANGED
|
@@ -401,6 +401,35 @@
|
|
|
401
401
|
return typeof option === "object" && option !== null && "label" in option;
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
+
function syncRecommendations(question, options) {
|
|
405
|
+
if (!question.recommended) return;
|
|
406
|
+
|
|
407
|
+
if (question.type === "single") {
|
|
408
|
+
if (typeof question.recommended === "string" && options.includes(question.recommended)) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
delete question.recommended;
|
|
412
|
+
delete question.conviction;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (question.type !== "multi") {
|
|
417
|
+
delete question.recommended;
|
|
418
|
+
delete question.conviction;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const nextRecommended = (Array.isArray(question.recommended)
|
|
423
|
+
? question.recommended
|
|
424
|
+
: [question.recommended]).filter((option) => options.includes(option));
|
|
425
|
+
if (nextRecommended.length === 0) {
|
|
426
|
+
delete question.recommended;
|
|
427
|
+
delete question.conviction;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
question.recommended = nextRecommended;
|
|
431
|
+
}
|
|
432
|
+
|
|
404
433
|
function renderCodeBlock(block) {
|
|
405
434
|
if (!block || !block.code) return null;
|
|
406
435
|
|
|
@@ -1232,7 +1261,8 @@
|
|
|
1232
1261
|
|
|
1233
1262
|
if (event.key === 'Tab') {
|
|
1234
1263
|
const inAttachArea = document.activeElement?.closest('.attach-inline');
|
|
1235
|
-
|
|
1264
|
+
const inGenerateArea = document.activeElement?.closest('.generate-more');
|
|
1265
|
+
if (inAttachArea || inGenerateArea) return;
|
|
1236
1266
|
|
|
1237
1267
|
event.preventDefault();
|
|
1238
1268
|
|
|
@@ -1287,6 +1317,9 @@
|
|
|
1287
1317
|
if (document.activeElement?.closest('.attach-inline')) {
|
|
1288
1318
|
return;
|
|
1289
1319
|
}
|
|
1320
|
+
if (document.activeElement?.closest('.generate-more')) {
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1290
1323
|
event.preventDefault();
|
|
1291
1324
|
const option = options[nav.optionIndex];
|
|
1292
1325
|
if (option) {
|
|
@@ -1388,6 +1421,224 @@
|
|
|
1388
1421
|
}
|
|
1389
1422
|
}
|
|
1390
1423
|
|
|
1424
|
+
function createGenerateMoreUI(question, list) {
|
|
1425
|
+
if (!data.canGenerate) return null;
|
|
1426
|
+
if (question.options.some(isRichOption)) return null;
|
|
1427
|
+
|
|
1428
|
+
const container = document.createElement("div");
|
|
1429
|
+
container.className = "generate-more";
|
|
1430
|
+
|
|
1431
|
+
const btnRow = document.createElement("div");
|
|
1432
|
+
btnRow.className = "generate-more-row";
|
|
1433
|
+
|
|
1434
|
+
const addBtn = document.createElement("button");
|
|
1435
|
+
addBtn.type = "button";
|
|
1436
|
+
addBtn.className = "generate-more-btn";
|
|
1437
|
+
addBtn.innerHTML = '<span class="generate-more-icon">✦</span> Generate more';
|
|
1438
|
+
|
|
1439
|
+
const reviewBtn = document.createElement("button");
|
|
1440
|
+
reviewBtn.type = "button";
|
|
1441
|
+
reviewBtn.className = "generate-more-btn";
|
|
1442
|
+
reviewBtn.innerHTML = '<span class="generate-more-icon">↻</span> Review options';
|
|
1443
|
+
|
|
1444
|
+
const status = document.createElement("div");
|
|
1445
|
+
status.className = "generate-more-status hidden";
|
|
1446
|
+
|
|
1447
|
+
btnRow.appendChild(addBtn);
|
|
1448
|
+
btnRow.appendChild(reviewBtn);
|
|
1449
|
+
container.appendChild(btnRow);
|
|
1450
|
+
container.appendChild(status);
|
|
1451
|
+
|
|
1452
|
+
let generating = false;
|
|
1453
|
+
let abortController = null;
|
|
1454
|
+
let statusTimer = null;
|
|
1455
|
+
|
|
1456
|
+
function clearStatus() {
|
|
1457
|
+
if (statusTimer !== null) {
|
|
1458
|
+
clearTimeout(statusTimer);
|
|
1459
|
+
statusTimer = null;
|
|
1460
|
+
}
|
|
1461
|
+
status.classList.add("hidden");
|
|
1462
|
+
status.classList.remove("error");
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function showStatus(message, timeoutMs, isError = false) {
|
|
1466
|
+
if (statusTimer !== null) {
|
|
1467
|
+
clearTimeout(statusTimer);
|
|
1468
|
+
statusTimer = null;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
status.textContent = message;
|
|
1472
|
+
status.classList.remove("hidden");
|
|
1473
|
+
status.classList.toggle("error", isError);
|
|
1474
|
+
|
|
1475
|
+
if (timeoutMs == null) {
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
statusTimer = setTimeout(() => {
|
|
1480
|
+
status.classList.add("hidden");
|
|
1481
|
+
statusTimer = null;
|
|
1482
|
+
}, timeoutMs);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function getExistingOptions() {
|
|
1486
|
+
const inputs = list.querySelectorAll(
|
|
1487
|
+
'input[name="' + escapeSelector(question.id) + '"]'
|
|
1488
|
+
);
|
|
1489
|
+
return Array.from(inputs)
|
|
1490
|
+
.map((input) => input.value)
|
|
1491
|
+
.filter((v) => v && v !== "__other__");
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async function runGenerate(btn, mode) {
|
|
1495
|
+
if (generating) {
|
|
1496
|
+
if (abortController) abortController.abort();
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
generating = true;
|
|
1501
|
+
const icon = btn.querySelector(".generate-more-icon").textContent;
|
|
1502
|
+
btn.innerHTML = '<span class="generate-more-icon">' + icon + '</span> Cancel';
|
|
1503
|
+
btn.classList.add("loading");
|
|
1504
|
+
addBtn.disabled = true;
|
|
1505
|
+
reviewBtn.disabled = true;
|
|
1506
|
+
btn.disabled = false;
|
|
1507
|
+
clearStatus();
|
|
1508
|
+
|
|
1509
|
+
abortController = new AbortController();
|
|
1510
|
+
const existingOptions = getExistingOptions();
|
|
1511
|
+
|
|
1512
|
+
try {
|
|
1513
|
+
const response = await fetch("/generate", {
|
|
1514
|
+
method: "POST",
|
|
1515
|
+
headers: { "Content-Type": "application/json" },
|
|
1516
|
+
body: JSON.stringify({
|
|
1517
|
+
token: sessionToken,
|
|
1518
|
+
questionId: question.id,
|
|
1519
|
+
existingOptions,
|
|
1520
|
+
mode,
|
|
1521
|
+
}),
|
|
1522
|
+
signal: abortController.signal,
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
const result = await response.json();
|
|
1526
|
+
if (!result.ok) throw new Error(result.error || "Generation failed");
|
|
1527
|
+
if (!Array.isArray(result.options) || result.options.length === 0) {
|
|
1528
|
+
throw new Error("No options generated");
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (mode === "review") {
|
|
1532
|
+
if (typeof result.question !== "string" || !result.question.trim()) {
|
|
1533
|
+
throw new Error("No revised question returned");
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const seen = new Set();
|
|
1537
|
+
const revisedOptions = result.options.filter((option) => {
|
|
1538
|
+
const key = option.toLowerCase().trim();
|
|
1539
|
+
if (seen.has(key)) return false;
|
|
1540
|
+
seen.add(key);
|
|
1541
|
+
return true;
|
|
1542
|
+
});
|
|
1543
|
+
if (revisedOptions.length === 0) {
|
|
1544
|
+
throw new Error("No valid options returned for review");
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
question.question = result.question.trim();
|
|
1548
|
+
question.options = revisedOptions;
|
|
1549
|
+
syncRecommendations(question, revisedOptions);
|
|
1550
|
+
const title = list.closest('.question-card')?.querySelector('.question-title');
|
|
1551
|
+
if (title) {
|
|
1552
|
+
title.innerHTML = renderLightMarkdown(question.question);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
list
|
|
1556
|
+
.querySelectorAll('.option-item:not(.option-other):not(.done-item)')
|
|
1557
|
+
.forEach((el) => el.remove());
|
|
1558
|
+
revisedOptions.forEach((optionText, i) => {
|
|
1559
|
+
const optionEl = createGeneratedOption(question, optionText, i);
|
|
1560
|
+
list.insertBefore(optionEl, container);
|
|
1561
|
+
});
|
|
1562
|
+
if (question.type === "multi") updateDoneState(question.id);
|
|
1563
|
+
debounceSave();
|
|
1564
|
+
showStatus(
|
|
1565
|
+
"Question updated and " + revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
|
|
1566
|
+
2500,
|
|
1567
|
+
);
|
|
1568
|
+
} else {
|
|
1569
|
+
const existingSet = new Set(existingOptions.map((o) => o.toLowerCase().trim()));
|
|
1570
|
+
const seen = new Set();
|
|
1571
|
+
const newOptions = result.options.filter((o) => {
|
|
1572
|
+
const key = o.toLowerCase().trim();
|
|
1573
|
+
if (existingSet.has(key) || seen.has(key)) return false;
|
|
1574
|
+
seen.add(key);
|
|
1575
|
+
return true;
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
if (newOptions.length === 0) {
|
|
1579
|
+
showStatus("All generated options already exist", 3000);
|
|
1580
|
+
} else {
|
|
1581
|
+
question.options = question.options.concat(newOptions);
|
|
1582
|
+
newOptions.forEach((optionText, i) => {
|
|
1583
|
+
const optionEl = createGeneratedOption(question, optionText, i);
|
|
1584
|
+
list.insertBefore(optionEl, container);
|
|
1585
|
+
});
|
|
1586
|
+
showStatus(
|
|
1587
|
+
newOptions.length + " option" + (newOptions.length > 1 ? "s" : "") + " added",
|
|
1588
|
+
2500,
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
refreshCountdown();
|
|
1593
|
+
} catch (err) {
|
|
1594
|
+
if (!(err instanceof Error && err.name === "AbortError")) {
|
|
1595
|
+
showStatus(err instanceof Error ? err.message : "Generation failed", null, true);
|
|
1596
|
+
}
|
|
1597
|
+
} finally {
|
|
1598
|
+
generating = false;
|
|
1599
|
+
addBtn.innerHTML = '<span class="generate-more-icon">✦</span> Generate more';
|
|
1600
|
+
reviewBtn.innerHTML = '<span class="generate-more-icon">↻</span> Review options';
|
|
1601
|
+
addBtn.classList.remove("loading");
|
|
1602
|
+
reviewBtn.classList.remove("loading");
|
|
1603
|
+
addBtn.disabled = false;
|
|
1604
|
+
reviewBtn.disabled = false;
|
|
1605
|
+
abortController = null;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
addBtn.addEventListener("click", () => runGenerate(addBtn, "add"));
|
|
1610
|
+
reviewBtn.addEventListener("click", () => runGenerate(reviewBtn, "review"));
|
|
1611
|
+
|
|
1612
|
+
return container;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function createGeneratedOption(question, optionText, animIndex) {
|
|
1616
|
+
const label = document.createElement("label");
|
|
1617
|
+
label.className = "option-item generated";
|
|
1618
|
+
label.style.animationDelay = (animIndex * 0.08) + "s";
|
|
1619
|
+
|
|
1620
|
+
const input = document.createElement("input");
|
|
1621
|
+
input.type = question.type === "single" ? "radio" : "checkbox";
|
|
1622
|
+
input.name = question.id;
|
|
1623
|
+
input.value = optionText;
|
|
1624
|
+
input.setAttribute("tabindex", "-1");
|
|
1625
|
+
|
|
1626
|
+
input.addEventListener("change", () => {
|
|
1627
|
+
debounceSave();
|
|
1628
|
+
if (question.type === "multi") {
|
|
1629
|
+
updateDoneState(question.id);
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
const text = document.createElement("span");
|
|
1634
|
+
text.textContent = optionText;
|
|
1635
|
+
|
|
1636
|
+
label.appendChild(input);
|
|
1637
|
+
label.appendChild(text);
|
|
1638
|
+
|
|
1639
|
+
return label;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1391
1642
|
function createQuestionCard(question, index, badgeNumber) {
|
|
1392
1643
|
const card = document.createElement("section");
|
|
1393
1644
|
card.className = "question-card";
|
|
@@ -1523,6 +1774,8 @@
|
|
|
1523
1774
|
list.appendChild(label);
|
|
1524
1775
|
});
|
|
1525
1776
|
|
|
1777
|
+
const generateMoreEl = createGenerateMoreUI(question, list);
|
|
1778
|
+
if (generateMoreEl) list.appendChild(generateMoreEl);
|
|
1526
1779
|
|
|
1527
1780
|
const otherLabel = document.createElement("label");
|
|
1528
1781
|
otherLabel.className = "option-item option-other";
|
|
@@ -2368,7 +2621,8 @@
|
|
|
2368
2621
|
}
|
|
2369
2622
|
} catch (err) {
|
|
2370
2623
|
if (!submitted) {
|
|
2371
|
-
|
|
2624
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2625
|
+
showSaveError(`Failed to save interview: ${message}`);
|
|
2372
2626
|
}
|
|
2373
2627
|
return false;
|
|
2374
2628
|
}
|
|
@@ -2449,7 +2703,8 @@
|
|
|
2449
2703
|
if (isNetworkError(err)) {
|
|
2450
2704
|
showSessionExpired();
|
|
2451
2705
|
} else {
|
|
2452
|
-
|
|
2706
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2707
|
+
showGlobalError(`Failed to submit responses: ${message}`);
|
|
2453
2708
|
submitBtn.disabled = false;
|
|
2454
2709
|
}
|
|
2455
2710
|
}
|
package/form/styles.css
CHANGED
|
@@ -1391,7 +1391,7 @@ button {
|
|
|
1391
1391
|
.code-block pre {
|
|
1392
1392
|
margin: 0;
|
|
1393
1393
|
padding: 0.75rem;
|
|
1394
|
-
overflow-x:
|
|
1394
|
+
overflow-x: hidden;
|
|
1395
1395
|
line-height: 1.5;
|
|
1396
1396
|
}
|
|
1397
1397
|
|
|
@@ -1404,7 +1404,8 @@ button {
|
|
|
1404
1404
|
|
|
1405
1405
|
.code-block-lines-container {
|
|
1406
1406
|
display: table;
|
|
1407
|
-
|
|
1407
|
+
width: 100%;
|
|
1408
|
+
table-layout: fixed;
|
|
1408
1409
|
}
|
|
1409
1410
|
|
|
1410
1411
|
.code-block-line {
|
|
@@ -1425,7 +1426,8 @@ button {
|
|
|
1425
1426
|
|
|
1426
1427
|
.code-block-line-content {
|
|
1427
1428
|
display: table-cell;
|
|
1428
|
-
white-space: pre;
|
|
1429
|
+
white-space: pre-wrap;
|
|
1430
|
+
overflow-wrap: anywhere;
|
|
1429
1431
|
padding-right: 0.75rem;
|
|
1430
1432
|
}
|
|
1431
1433
|
|
|
@@ -1710,6 +1712,106 @@ button {
|
|
|
1710
1712
|
background: color-mix(in srgb, var(--card-accent, var(--accent)) 4%, var(--bg-elevated));
|
|
1711
1713
|
}
|
|
1712
1714
|
|
|
1715
|
+
/* Generate more options */
|
|
1716
|
+
.generate-more {
|
|
1717
|
+
margin: 4px 0 2px;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
.generate-more-row {
|
|
1721
|
+
display: flex;
|
|
1722
|
+
gap: 6px;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
.generate-more-btn {
|
|
1726
|
+
flex: 1;
|
|
1727
|
+
display: flex;
|
|
1728
|
+
align-items: center;
|
|
1729
|
+
justify-content: center;
|
|
1730
|
+
gap: 6px;
|
|
1731
|
+
padding: 10px 14px;
|
|
1732
|
+
border: 1px dashed var(--border-muted);
|
|
1733
|
+
border-radius: 8px;
|
|
1734
|
+
background: transparent;
|
|
1735
|
+
color: var(--fg-dim);
|
|
1736
|
+
font-family: var(--font-body);
|
|
1737
|
+
font-size: var(--font-size-option);
|
|
1738
|
+
cursor: pointer;
|
|
1739
|
+
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
.generate-more-btn:hover {
|
|
1743
|
+
border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 50%, transparent);
|
|
1744
|
+
color: var(--card-accent, var(--accent));
|
|
1745
|
+
background: color-mix(in srgb, var(--card-accent, var(--accent)) 5%, transparent);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
.generate-more-btn:focus-visible {
|
|
1749
|
+
outline: none;
|
|
1750
|
+
box-shadow: 0 0 0 2px var(--focus-ring);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
.generate-more-icon {
|
|
1754
|
+
font-size: 14px;
|
|
1755
|
+
line-height: 1;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
@keyframes generate-shimmer {
|
|
1759
|
+
0% { background-position: 200% center; }
|
|
1760
|
+
100% { background-position: -200% center; }
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
@keyframes generate-spin {
|
|
1764
|
+
from { transform: rotate(0deg); }
|
|
1765
|
+
to { transform: rotate(360deg); }
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
.generate-more-btn.loading {
|
|
1769
|
+
border-style: solid;
|
|
1770
|
+
border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 30%, transparent);
|
|
1771
|
+
color: var(--card-accent, var(--accent));
|
|
1772
|
+
background: linear-gradient(
|
|
1773
|
+
90deg,
|
|
1774
|
+
transparent 0%,
|
|
1775
|
+
color-mix(in srgb, var(--card-accent, var(--accent)) 8%, transparent) 40%,
|
|
1776
|
+
color-mix(in srgb, var(--card-accent, var(--accent)) 15%, transparent) 50%,
|
|
1777
|
+
color-mix(in srgb, var(--card-accent, var(--accent)) 8%, transparent) 60%,
|
|
1778
|
+
transparent 100%
|
|
1779
|
+
);
|
|
1780
|
+
background-size: 200% 100%;
|
|
1781
|
+
animation: generate-shimmer 2s ease-in-out infinite;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
.generate-more-btn.loading .generate-more-icon {
|
|
1785
|
+
animation: generate-spin 1.5s linear infinite;
|
|
1786
|
+
display: inline-block;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
.generate-more-btn:disabled:not(.loading) {
|
|
1790
|
+
opacity: 0.4;
|
|
1791
|
+
pointer-events: none;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
.generate-more-status {
|
|
1795
|
+
margin-top: 8px;
|
|
1796
|
+
font-family: var(--font-mono);
|
|
1797
|
+
font-size: 11px;
|
|
1798
|
+
color: var(--fg-muted);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
.generate-more-status.error {
|
|
1802
|
+
color: var(--error);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/* Generated option entrance */
|
|
1806
|
+
@keyframes option-slide-in {
|
|
1807
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
1808
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
.option-item.generated {
|
|
1812
|
+
animation: option-slide-in 0.25s ease-out backwards;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1713
1815
|
/* Side-by-side layout */
|
|
1714
1816
|
.question-side-layout {
|
|
1715
1817
|
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,7 +232,202 @@ function loadQuestions(questionsInput: string, cwd: string): SavedQuestionsFile
|
|
|
232
232
|
return validateQuestions(data);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
|
|
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
|
+
const REVIEW_QUESTION_SYSTEM_PROMPT =
|
|
250
|
+
"You review interview questions and answer options. Preserve intent. Return only JSON with a rewritten question string and an options array.";
|
|
251
|
+
|
|
252
|
+
function formatModelRef(model: GenerateModelCandidate): string {
|
|
253
|
+
return `${model.provider}/${model.id}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function findModelByRef<T extends GenerateModelCandidate>(models: T[], modelRef: string): T | null {
|
|
257
|
+
for (const model of models) {
|
|
258
|
+
if (formatModelRef(model) === modelRef) {
|
|
259
|
+
return model;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function selectGenerateModels<T extends GenerateModelCandidate>(
|
|
266
|
+
configuredModel: T | null,
|
|
267
|
+
currentModel: T | null,
|
|
268
|
+
availableModels: T[],
|
|
269
|
+
): { primary: T | null; fallback: T | null } {
|
|
270
|
+
if (configuredModel) {
|
|
271
|
+
if (!currentModel || formatModelRef(currentModel) === formatModelRef(configuredModel)) {
|
|
272
|
+
return { primary: configuredModel, fallback: null };
|
|
273
|
+
}
|
|
274
|
+
return { primary: configuredModel, fallback: currentModel };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (currentModel) {
|
|
278
|
+
return { primary: currentModel, fallback: null };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const modelRef of PREFERRED_GENERATE_MODELS) {
|
|
282
|
+
const preferredModel = findModelByRef(availableModels, modelRef);
|
|
283
|
+
if (preferredModel) {
|
|
284
|
+
return { primary: preferredModel, fallback: null };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { primary: availableModels[0] ?? null, fallback: null };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function extractGenerateResponseText(
|
|
292
|
+
modelRef: string,
|
|
293
|
+
response: Pick<AssistantMessage, "content" | "stopReason" | "errorMessage">,
|
|
294
|
+
): string {
|
|
295
|
+
if (response.stopReason === "aborted") {
|
|
296
|
+
throw new Error("Aborted");
|
|
297
|
+
}
|
|
298
|
+
if (response.stopReason === "error") {
|
|
299
|
+
throw new Error(response.errorMessage ? `${modelRef}: ${response.errorMessage}` : `${modelRef} failed`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const text = response.content
|
|
303
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
304
|
+
.map((part) => part.text)
|
|
305
|
+
.join("")
|
|
306
|
+
.trim();
|
|
307
|
+
if (!text) {
|
|
308
|
+
throw new Error(`${modelRef} returned no text response`);
|
|
309
|
+
}
|
|
310
|
+
return text;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function extractJSONBlock(text: string, openChar: "[" | "{", closeChar: "]" | "}"): string {
|
|
314
|
+
const start = text.indexOf(openChar);
|
|
315
|
+
if (start === -1) return text;
|
|
316
|
+
|
|
317
|
+
let depth = 0;
|
|
318
|
+
let inString = false;
|
|
319
|
+
let escaping = false;
|
|
320
|
+
|
|
321
|
+
for (let i = start; i < text.length; i++) {
|
|
322
|
+
const char = text[i];
|
|
323
|
+
|
|
324
|
+
if (inString) {
|
|
325
|
+
if (escaping) {
|
|
326
|
+
escaping = false;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (char === "\\") {
|
|
330
|
+
escaping = true;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (char === '"') {
|
|
334
|
+
inString = false;
|
|
335
|
+
}
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (char === '"') {
|
|
340
|
+
inString = true;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (char === openChar) {
|
|
344
|
+
depth++;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (char !== closeChar) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
depth--;
|
|
352
|
+
if (depth === 0) {
|
|
353
|
+
return text.slice(start, i + 1);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return text;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function extractJSONArray(text: string): string {
|
|
361
|
+
return extractJSONBlock(text, "[", "]");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function extractJSONObject(text: string): string {
|
|
365
|
+
return extractJSONBlock(text, "{", "}");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function createGenerateContext(prompt: string, systemPrompt = GENERATE_OPTIONS_SYSTEM_PROMPT) {
|
|
369
|
+
return {
|
|
370
|
+
systemPrompt,
|
|
371
|
+
messages: [{
|
|
372
|
+
role: "user" as const,
|
|
373
|
+
content: [{ type: "text" as const, text: prompt }],
|
|
374
|
+
timestamp: Date.now(),
|
|
375
|
+
}],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function normalizeGeneratedOptions(parsed: unknown): string[] {
|
|
380
|
+
if (!Array.isArray(parsed)) {
|
|
381
|
+
throw new Error("Expected array of options");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const options = parsed
|
|
385
|
+
.filter(
|
|
386
|
+
(item: unknown): item is string =>
|
|
387
|
+
typeof item === "string" && item.trim().length > 0,
|
|
388
|
+
)
|
|
389
|
+
.map((option: string) => option.trim());
|
|
390
|
+
if (options.length === 0) {
|
|
391
|
+
throw new Error("No valid options generated");
|
|
392
|
+
}
|
|
393
|
+
return options;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function parseGeneratedOptions(text: string): string[] {
|
|
397
|
+
let parsed: unknown;
|
|
398
|
+
try {
|
|
399
|
+
parsed = JSON.parse(extractJSONArray(text));
|
|
400
|
+
} catch (err) {
|
|
401
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
402
|
+
throw new Error(`Failed to parse generated options: ${detail}`);
|
|
403
|
+
}
|
|
404
|
+
return normalizeGeneratedOptions(parsed);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function parseReviewedQuestion(text: string): { question: string; options: string[] } {
|
|
408
|
+
let parsed: unknown;
|
|
409
|
+
try {
|
|
410
|
+
parsed = JSON.parse(extractJSONObject(text));
|
|
411
|
+
} catch (err) {
|
|
412
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
413
|
+
throw new Error(`Failed to parse reviewed question: ${detail}`);
|
|
414
|
+
}
|
|
415
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
416
|
+
throw new Error("Expected reviewed question object");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const review = parsed as Record<string, unknown>;
|
|
420
|
+
if (typeof review.question !== "string" || !review.question.trim()) {
|
|
421
|
+
throw new Error("Reviewed question must include a non-empty question string");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
question: review.question.trim(),
|
|
426
|
+
options: normalizeGeneratedOptions(review.options),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
|
|
236
431
|
// Extract JSON from <script id="pi-interview-data">
|
|
237
432
|
const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
|
|
238
433
|
if (!match) {
|
|
@@ -249,11 +444,13 @@ function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile
|
|
|
249
444
|
|
|
250
445
|
const raw = data as Record<string, unknown>;
|
|
251
446
|
const validated = validateQuestions(data);
|
|
447
|
+
const questionTypeById = new Map(validated.questions.map((question) => [question.id, question.type]));
|
|
252
448
|
|
|
253
|
-
// Resolve relative image paths to absolute based on HTML file location
|
|
449
|
+
// Resolve relative image paths to absolute based on HTML file location.
|
|
450
|
+
// Only image-question values are treated as paths; text/single/multi values must stay literal.
|
|
254
451
|
const snapshotDir = path.dirname(filePath);
|
|
255
452
|
const savedAnswers = Array.isArray(raw.savedAnswers)
|
|
256
|
-
? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir)
|
|
453
|
+
? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir, questionTypeById)
|
|
257
454
|
: undefined;
|
|
258
455
|
|
|
259
456
|
// Validate savedFrom if present
|
|
@@ -279,26 +476,28 @@ function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile
|
|
|
279
476
|
};
|
|
280
477
|
}
|
|
281
478
|
|
|
282
|
-
function resolveAnswerPaths(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
479
|
+
function resolveAnswerPaths(
|
|
480
|
+
answers: ResponseItem[],
|
|
481
|
+
baseDir: string,
|
|
482
|
+
questionTypeById: Map<string, "single" | "multi" | "text" | "image" | "info">,
|
|
483
|
+
): ResponseItem[] {
|
|
484
|
+
return answers.map((ans) => {
|
|
485
|
+
const questionType = questionTypeById.get(ans.id);
|
|
486
|
+
return {
|
|
487
|
+
...ans,
|
|
488
|
+
value: questionType === "image" ? resolvePathValue(ans.value, baseDir) : ans.value,
|
|
489
|
+
attachments: ans.attachments?.map((attachmentPath) => resolveImagePath(attachmentPath, baseDir)),
|
|
490
|
+
};
|
|
491
|
+
});
|
|
288
492
|
}
|
|
289
493
|
|
|
290
494
|
function resolveImagePath(p: string, baseDir: string): string {
|
|
291
495
|
if (!p) return p;
|
|
292
|
-
// Skip URLs
|
|
293
|
-
if (p.includes("://")) return p;
|
|
294
|
-
// Expand ~ first
|
|
496
|
+
// Skip URLs and data/file URIs
|
|
497
|
+
if (p.includes("://") || p.startsWith("data:") || p.startsWith("file:")) return p;
|
|
295
498
|
const expanded = expandHome(p);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
return expanded;
|
|
299
|
-
}
|
|
300
|
-
// Resolve relative path against snapshot directory
|
|
301
|
-
return path.join(baseDir, p);
|
|
499
|
+
if (path.isAbsolute(expanded)) return expanded;
|
|
500
|
+
return path.join(baseDir, expanded);
|
|
302
501
|
}
|
|
303
502
|
|
|
304
503
|
function resolvePathValue(value: string | string[], baseDir: string): string | string[] {
|
|
@@ -366,6 +565,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
366
565
|
"Questions can have a codeBlock field to display code above options. Types: single (radio), multi (checkbox), text (textarea), image (file upload), info (non-interactive). " +
|
|
367
566
|
'Media blocks: { type: "image", src, alt, caption }, { type: "table", table: { headers, rows, highlights }, caption }, { type: "chart", chart: { type, data, options }, caption }, { type: "mermaid", mermaid: "graph LR\\n..." }, { type: "html", html }. ' +
|
|
368
567
|
"Info type is a non-interactive content panel for displaying context with media. Media position: above (default), below, side (two-column).",
|
|
568
|
+
promptSnippet:
|
|
569
|
+
"Gather structured user input through an interactive form for requirements, tradeoffs, or multi-dimensional decisions.",
|
|
369
570
|
parameters: InterviewParams,
|
|
370
571
|
|
|
371
572
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -395,6 +596,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
395
596
|
const themeConfig = mergeThemeConfig(settings.theme, theme, ctx.cwd);
|
|
396
597
|
const questionsData = loadQuestions(questions, ctx.cwd);
|
|
397
598
|
|
|
599
|
+
let configuredGenerateModel: Model<Api> | null = null;
|
|
600
|
+
if (settings.generateModel) {
|
|
601
|
+
const slashIdx = settings.generateModel.indexOf("/");
|
|
602
|
+
if (slashIdx > 0) {
|
|
603
|
+
configuredGenerateModel = ctx.modelRegistry.find(
|
|
604
|
+
settings.generateModel.slice(0, slashIdx),
|
|
605
|
+
settings.generateModel.slice(slashIdx + 1),
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
let availableGenerateModels: Model<Api>[] = [];
|
|
611
|
+
if (!configuredGenerateModel && !ctx.model) {
|
|
612
|
+
try {
|
|
613
|
+
availableGenerateModels = ctx.modelRegistry.getAvailable();
|
|
614
|
+
} catch {
|
|
615
|
+
// Leave generation disabled when model discovery is unavailable.
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const { primary: generateModel, fallback: fallbackGenerateModel } = selectGenerateModels(
|
|
620
|
+
configuredGenerateModel,
|
|
621
|
+
ctx.model ?? null,
|
|
622
|
+
availableGenerateModels,
|
|
623
|
+
);
|
|
624
|
+
|
|
398
625
|
// Expand ~ in snapshotDir if present
|
|
399
626
|
const snapshotDir = settings.snapshotDir
|
|
400
627
|
? expandHome(settings.snapshotDir)
|
|
@@ -469,6 +696,126 @@ export default function (pi: ExtensionAPI) {
|
|
|
469
696
|
};
|
|
470
697
|
signal?.addEventListener("abort", handleAbort, { once: true });
|
|
471
698
|
|
|
699
|
+
let onGenerate: InterviewServerCallbacks["onGenerate"];
|
|
700
|
+
if (generateModel) {
|
|
701
|
+
const generateOptions = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
|
|
702
|
+
const modelRef = formatModelRef(model);
|
|
703
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
704
|
+
if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
|
|
705
|
+
if (!auth.apiKey) throw new Error(`No API key for ${modelRef}`);
|
|
706
|
+
|
|
707
|
+
const response = await complete(
|
|
708
|
+
model,
|
|
709
|
+
createGenerateContext(prompt),
|
|
710
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
return parseGeneratedOptions(extractGenerateResponseText(modelRef, response));
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const reviewQuestion = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
|
|
717
|
+
const modelRef = formatModelRef(model);
|
|
718
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
719
|
+
if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
|
|
720
|
+
if (!auth.apiKey) throw new Error(`No API key for ${modelRef}`);
|
|
721
|
+
|
|
722
|
+
const response = await complete(
|
|
723
|
+
model,
|
|
724
|
+
createGenerateContext(prompt, REVIEW_QUESTION_SYSTEM_PROMPT),
|
|
725
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
return parseReviewedQuestion(extractGenerateResponseText(modelRef, response));
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
onGenerate = async (questionId, existingOptions, generateSignal, mode) => {
|
|
732
|
+
const question = questionsData.questions.find((q) => q.id === questionId);
|
|
733
|
+
if (!question) throw new Error(`Unknown question: ${questionId}`);
|
|
734
|
+
|
|
735
|
+
const existingList = existingOptions.length > 0
|
|
736
|
+
? existingOptions.map((option) => `- ${option}`).join("\n")
|
|
737
|
+
: "(none)";
|
|
738
|
+
|
|
739
|
+
let prompt: string;
|
|
740
|
+
if (mode === "review") {
|
|
741
|
+
let recommended = "";
|
|
742
|
+
if (question.recommended) {
|
|
743
|
+
const value = Array.isArray(question.recommended)
|
|
744
|
+
? question.recommended.join(", ")
|
|
745
|
+
: question.recommended;
|
|
746
|
+
recommended = `\nRecommended: ${value}`;
|
|
747
|
+
}
|
|
748
|
+
prompt = [
|
|
749
|
+
"Review this interview question and its options.",
|
|
750
|
+
"Rewrite the question so it is easier to understand while preserving the original intent.",
|
|
751
|
+
"Review the options the same way you already would: keep good ones as-is, fix bad ones, add missing ones, and remove bad ones.",
|
|
752
|
+
"Return ONLY JSON in this format:",
|
|
753
|
+
'{"question":"Clearer question text","options":["Option A","Option B","Option C"]}',
|
|
754
|
+
"",
|
|
755
|
+
questionsData.title ? `Interview: ${questionsData.title}` : null,
|
|
756
|
+
questionsData.description ? `Interview context: ${questionsData.description}` : null,
|
|
757
|
+
`Question: ${question.question}`,
|
|
758
|
+
question.context ? `Question context: ${question.context}` : null,
|
|
759
|
+
recommended || null,
|
|
760
|
+
"",
|
|
761
|
+
"Current options:",
|
|
762
|
+
existingList,
|
|
763
|
+
].filter((line) => line !== null).join("\n");
|
|
764
|
+
} else {
|
|
765
|
+
prompt = [
|
|
766
|
+
"Generate 3 new, distinct options for this question.",
|
|
767
|
+
"Return ONLY a JSON array of short option strings. No explanation, no markdown.",
|
|
768
|
+
"",
|
|
769
|
+
`Question: ${question.question}`,
|
|
770
|
+
question.context ? `Context: ${question.context}` : null,
|
|
771
|
+
"",
|
|
772
|
+
"Existing options (do NOT repeat):",
|
|
773
|
+
existingList,
|
|
774
|
+
"",
|
|
775
|
+
'Format: ["Option A", "Option B", "Option C"]',
|
|
776
|
+
].filter((line) => line !== null).join("\n");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (mode === "review") {
|
|
780
|
+
let result: { question: string; options: string[] };
|
|
781
|
+
try {
|
|
782
|
+
result = await reviewQuestion(generateModel, prompt, generateSignal);
|
|
783
|
+
} catch (err) {
|
|
784
|
+
if (!fallbackGenerateModel || generateSignal.aborted) {
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
result = await reviewQuestion(fallbackGenerateModel, prompt, generateSignal);
|
|
789
|
+
} catch (fallbackErr) {
|
|
790
|
+
const primaryMessage = err instanceof Error ? err.message : String(err);
|
|
791
|
+
const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
792
|
+
throw new Error(`${primaryMessage}. Fallback failed: ${fallbackMessage}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return result;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
let options: string[];
|
|
800
|
+
try {
|
|
801
|
+
options = await generateOptions(generateModel, prompt, generateSignal);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
if (!fallbackGenerateModel || generateSignal.aborted) {
|
|
804
|
+
throw err;
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
options = await generateOptions(fallbackGenerateModel, prompt, generateSignal);
|
|
808
|
+
} catch (fallbackErr) {
|
|
809
|
+
const primaryMessage = err instanceof Error ? err.message : String(err);
|
|
810
|
+
const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
811
|
+
throw new Error(`${primaryMessage}. Fallback failed: ${fallbackMessage}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return { options };
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
472
819
|
startInterviewServer(
|
|
473
820
|
{
|
|
474
821
|
questions: questionsData,
|
|
@@ -482,6 +829,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
482
829
|
snapshotDir,
|
|
483
830
|
autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
|
|
484
831
|
savedAnswers: questionsData.savedAnswers,
|
|
832
|
+
canGenerate: generateModel !== null,
|
|
485
833
|
},
|
|
486
834
|
{
|
|
487
835
|
onSubmit: (responses) => finish("completed", responses),
|
|
@@ -489,6 +837,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
489
837
|
reason === "timeout"
|
|
490
838
|
? finish("timeout", partialResponses ?? [])
|
|
491
839
|
: finish("cancelled", partialResponses ?? [], reason),
|
|
840
|
+
onGenerate,
|
|
492
841
|
}
|
|
493
842
|
)
|
|
494
843
|
.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[]; question?: string }>;
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
export interface InterviewServerHandle {
|
|
@@ -380,6 +387,35 @@ function ensureQuestionId(
|
|
|
380
387
|
return { ok: true, question };
|
|
381
388
|
}
|
|
382
389
|
|
|
390
|
+
function syncRecommendations(question: Question, options: string[]): void {
|
|
391
|
+
if (!question.recommended) return;
|
|
392
|
+
|
|
393
|
+
if (question.type === "single") {
|
|
394
|
+
if (typeof question.recommended === "string" && options.includes(question.recommended)) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
delete question.recommended;
|
|
398
|
+
delete question.conviction;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (question.type !== "multi") {
|
|
403
|
+
delete question.recommended;
|
|
404
|
+
delete question.conviction;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const nextRecommended = (Array.isArray(question.recommended)
|
|
409
|
+
? question.recommended
|
|
410
|
+
: [question.recommended]).filter((option) => options.includes(option));
|
|
411
|
+
if (nextRecommended.length === 0) {
|
|
412
|
+
delete question.recommended;
|
|
413
|
+
delete question.conviction;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
question.recommended = nextRecommended;
|
|
417
|
+
}
|
|
418
|
+
|
|
383
419
|
// HTML generation for saved interviews
|
|
384
420
|
interface SavedFromMeta {
|
|
385
421
|
cwd: string;
|
|
@@ -882,6 +918,7 @@ export async function startInterviewServer(
|
|
|
882
918
|
},
|
|
883
919
|
savedAnswers: options.savedAnswers,
|
|
884
920
|
autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
|
|
921
|
+
canGenerate: options.canGenerate ?? false,
|
|
885
922
|
});
|
|
886
923
|
const html = TEMPLATE
|
|
887
924
|
.replace("<!-- __CDN_SCRIPTS__ -->", cdnScripts)
|
|
@@ -1250,9 +1287,7 @@ export async function startInterviewServer(
|
|
|
1250
1287
|
}
|
|
1251
1288
|
|
|
1252
1289
|
// Copy local media images to snapshot and rewrite paths
|
|
1253
|
-
const rewrittenQuestions = await copyMediaImages(
|
|
1254
|
-
questions.questions, imagesPath, cwd
|
|
1255
|
-
);
|
|
1290
|
+
const rewrittenQuestions = await copyMediaImages(questions.questions, imagesPath, cwd);
|
|
1256
1291
|
const snapshotQuestions: QuestionsFile = {
|
|
1257
1292
|
...questions,
|
|
1258
1293
|
questions: rewrittenQuestions,
|
|
@@ -1283,6 +1318,100 @@ export async function startInterviewServer(
|
|
|
1283
1318
|
return;
|
|
1284
1319
|
}
|
|
1285
1320
|
|
|
1321
|
+
if (method === "POST" && url.pathname === "/generate") {
|
|
1322
|
+
const body = await parseBodyOrRespond();
|
|
1323
|
+
if (!body) return;
|
|
1324
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
1325
|
+
if (completed) {
|
|
1326
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (!callbacks.onGenerate) {
|
|
1331
|
+
sendJson(res, 501, { ok: false, error: "Generation not available" });
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const payload = body as {
|
|
1336
|
+
questionId?: string;
|
|
1337
|
+
existingOptions?: string[];
|
|
1338
|
+
mode?: string;
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
if (typeof payload.questionId !== "string") {
|
|
1342
|
+
sendJson(res, 400, { ok: false, error: "Missing questionId" });
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const question = questionById.get(payload.questionId);
|
|
1347
|
+
if (!question || (question.type !== "single" && question.type !== "multi")) {
|
|
1348
|
+
sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
if (question.options.some((option) => typeof option !== "string")) {
|
|
1352
|
+
sendJson(res, 400, { ok: false, error: "Generation is not available for rich options" });
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const existingOptions = Array.isArray(payload.existingOptions)
|
|
1357
|
+
? payload.existingOptions.filter((o): o is string => typeof o === "string")
|
|
1358
|
+
: [];
|
|
1359
|
+
|
|
1360
|
+
const mode = payload.mode === "review" ? "review" : "add";
|
|
1361
|
+
|
|
1362
|
+
const controller = new AbortController();
|
|
1363
|
+
res.on("close", () => {
|
|
1364
|
+
if (!res.writableEnded) controller.abort();
|
|
1365
|
+
});
|
|
1366
|
+
touchHeartbeat();
|
|
1367
|
+
|
|
1368
|
+
try {
|
|
1369
|
+
const result = await callbacks.onGenerate(
|
|
1370
|
+
payload.questionId,
|
|
1371
|
+
existingOptions,
|
|
1372
|
+
controller.signal,
|
|
1373
|
+
mode,
|
|
1374
|
+
);
|
|
1375
|
+
|
|
1376
|
+
const uniqueOptions: string[] = [];
|
|
1377
|
+
const seenOptions = new Set<string>();
|
|
1378
|
+
for (const option of result.options) {
|
|
1379
|
+
const trimmed = option.trim();
|
|
1380
|
+
if (!trimmed) continue;
|
|
1381
|
+
const key = trimmed.toLowerCase();
|
|
1382
|
+
if (seenOptions.has(key)) continue;
|
|
1383
|
+
seenOptions.add(key);
|
|
1384
|
+
uniqueOptions.push(trimmed);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const reviewedQuestion = typeof result.question === "string" ? result.question.trim() : undefined;
|
|
1388
|
+
const storedQuestion = questions.questions.find((q) => q.id === payload.questionId);
|
|
1389
|
+
if (storedQuestion) {
|
|
1390
|
+
if (mode === "review" && reviewedQuestion && uniqueOptions.length > 0) {
|
|
1391
|
+
storedQuestion.question = reviewedQuestion;
|
|
1392
|
+
storedQuestion.options = uniqueOptions;
|
|
1393
|
+
syncRecommendations(storedQuestion, uniqueOptions);
|
|
1394
|
+
} else if (mode === "add") {
|
|
1395
|
+
const existingKeys = new Set(existingOptions.map((option) => option.trim().toLowerCase()));
|
|
1396
|
+
const newOptions = uniqueOptions.filter((option) => !existingKeys.has(option.toLowerCase()));
|
|
1397
|
+
if (newOptions.length > 0) {
|
|
1398
|
+
storedQuestion.options = storedQuestion.options.concat(newOptions);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
sendJson(res, 200, { ok: true, options: uniqueOptions, question: reviewedQuestion });
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
if (controller.signal.aborted) {
|
|
1406
|
+
sendJson(res, 409, { ok: false, error: "Request cancelled" });
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const message = err instanceof Error ? err.message : "Generation failed";
|
|
1410
|
+
sendJson(res, 500, { ok: false, error: message });
|
|
1411
|
+
}
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1286
1415
|
sendText(res, 404, "Not found");
|
|
1287
1416
|
} catch (err) {
|
|
1288
1417
|
const message = err instanceof Error ? err.message : "Server error";
|
package/settings.ts
CHANGED
|
@@ -19,6 +19,7 @@ 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 {
|