pi-interview 0.3.1 → 0.4.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 +46 -0
- package/form/index.html +4 -0
- package/form/script.js +140 -8
- package/form/styles.css +59 -0
- package/index.ts +154 -12
- package/package.json +1 -1
- package/server.ts +352 -9
- package/settings.ts +2 -0
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@ The tool is automatically discovered on next pi session. No build step required.
|
|
|
32
32
|
- **Multi-Agent Support**: Queue detection prevents focus stealing when multiple agents run interviews
|
|
33
33
|
- **Queue Toast Switcher**: Active interviews show a top-right toast with a dropdown to open queued sessions
|
|
34
34
|
- **Session Recovery**: Abandoned/timed-out interviews save questions for later retry
|
|
35
|
+
- **Save Snapshots**: Save interview state to HTML for later review or revival
|
|
35
36
|
- **Session Status Bar**: Shows project path, git branch, and session ID for identification
|
|
36
37
|
- **Image Support**: Drag & drop anywhere on question, file picker, paste image or path
|
|
37
38
|
- **Path Normalization**: Handles shell-escaped paths (`\ `) and macOS screenshot filenames (narrow no-break space before AM/PM)
|
|
@@ -205,6 +206,8 @@ Settings in `~/.pi/agent/settings.json`:
|
|
|
205
206
|
"interview": {
|
|
206
207
|
"timeout": 600,
|
|
207
208
|
"port": 19847,
|
|
209
|
+
"snapshotDir": "~/.pi/interview-snapshots/",
|
|
210
|
+
"autoSaveOnSubmit": true,
|
|
208
211
|
"theme": {
|
|
209
212
|
"mode": "auto",
|
|
210
213
|
"name": "default",
|
|
@@ -218,6 +221,10 @@ Settings in `~/.pi/agent/settings.json`:
|
|
|
218
221
|
|
|
219
222
|
**Timeout precedence**: params > settings > default (600s)
|
|
220
223
|
|
|
224
|
+
**Snapshot settings:**
|
|
225
|
+
- `snapshotDir`: Directory for saved interview snapshots (default: `~/.pi/interview-snapshots/`)
|
|
226
|
+
- `autoSaveOnSubmit`: Automatically save snapshot on successful submit (default: `true`)
|
|
227
|
+
|
|
221
228
|
**Port setting**: Set a fixed `port` (e.g., `19847`) to use a consistent port across sessions.
|
|
222
229
|
|
|
223
230
|
**Theme notes:**
|
|
@@ -338,6 +345,45 @@ If an interview times out or is abandoned (tab closed, lost connection), the que
|
|
|
338
345
|
interview({ questions: "~/.pi/interview-recovery/2026-01-02_093000_myproject_main_65bec3f4.json" })
|
|
339
346
|
```
|
|
340
347
|
|
|
348
|
+
## Saving Interviews
|
|
349
|
+
|
|
350
|
+
Save a snapshot of your interview at any time for later review or to resume.
|
|
351
|
+
|
|
352
|
+
**Manual Save:**
|
|
353
|
+
- Click the Save button (header or footer)
|
|
354
|
+
- Saves to `~/.pi/interview-snapshots/` by default
|
|
355
|
+
- Creates folder with `index.html` + `images/` subfolder
|
|
356
|
+
|
|
357
|
+
**Auto-save on Submit:**
|
|
358
|
+
- Enabled by default (`autoSaveOnSubmit: true` in settings)
|
|
359
|
+
- Automatically saves after successful submission
|
|
360
|
+
- Folder name includes `-submitted` suffix
|
|
361
|
+
|
|
362
|
+
**Reviving a Saved Interview:**
|
|
363
|
+
```javascript
|
|
364
|
+
interview({ questions: "~/.pi/interview-snapshots/project-setup-myapp-main-2026-01-20-141523/index.html" })
|
|
365
|
+
```
|
|
366
|
+
The form opens with answers pre-populated. Edit and submit as normal.
|
|
367
|
+
|
|
368
|
+
**Configuration:**
|
|
369
|
+
```json
|
|
370
|
+
{
|
|
371
|
+
"interview": {
|
|
372
|
+
"snapshotDir": "~/.pi/interview-snapshots/",
|
|
373
|
+
"autoSaveOnSubmit": true
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**Snapshot Structure:**
|
|
379
|
+
```
|
|
380
|
+
~/.pi/interview-snapshots/
|
|
381
|
+
{title}-{project}-{branch}-{timestamp}[-submitted]/
|
|
382
|
+
index.html # Human-readable + embedded JSON for revival
|
|
383
|
+
images/
|
|
384
|
+
mockup.png # Uploaded images (relative paths in HTML)
|
|
385
|
+
```
|
|
386
|
+
|
|
341
387
|
## Limits
|
|
342
388
|
|
|
343
389
|
- Max 12 images total per submission
|
package/form/index.html
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
<header class="interview-header">
|
|
18
18
|
<div class="header-row">
|
|
19
19
|
<h1 id="form-title"></h1>
|
|
20
|
+
<button type="button" class="save-btn-header" id="save-btn-header" title="Save snapshot">Save</button>
|
|
20
21
|
</div>
|
|
21
22
|
<p id="form-description"></p>
|
|
22
23
|
</header>
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
<div id="error-container" aria-live="polite" class="error-message hidden"></div>
|
|
28
29
|
|
|
29
30
|
<footer class="form-footer">
|
|
31
|
+
<button type="button" id="save-btn-footer" class="btn-secondary">Save</button>
|
|
30
32
|
<button type="submit" id="submit-btn" class="btn-primary">Submit</button>
|
|
31
33
|
</footer>
|
|
32
34
|
</form>
|
|
@@ -109,6 +111,8 @@
|
|
|
109
111
|
</div>
|
|
110
112
|
</div>
|
|
111
113
|
</div>
|
|
114
|
+
|
|
115
|
+
<div id="save-toast" class="save-toast hidden" aria-live="polite"></div>
|
|
112
116
|
</main>
|
|
113
117
|
|
|
114
118
|
<script>
|
package/form/script.js
CHANGED
|
@@ -323,7 +323,8 @@
|
|
|
323
323
|
function sendCancelBeacon(reason) {
|
|
324
324
|
if (session.cancelSent || session.ended) return;
|
|
325
325
|
session.cancelSent = true;
|
|
326
|
-
const
|
|
326
|
+
const responses = collectResponses();
|
|
327
|
+
const payload = JSON.stringify({ token: sessionToken, reason, responses });
|
|
327
328
|
if (navigator.sendBeacon) {
|
|
328
329
|
const blob = new Blob([payload], { type: "application/json" });
|
|
329
330
|
navigator.sendBeacon("/cancel", blob);
|
|
@@ -343,11 +344,12 @@
|
|
|
343
344
|
session.cancelSent = true;
|
|
344
345
|
stopHeartbeat();
|
|
345
346
|
stopQueuePolling();
|
|
347
|
+
const responses = collectResponses();
|
|
346
348
|
try {
|
|
347
349
|
await fetch("/cancel", {
|
|
348
350
|
method: "POST",
|
|
349
351
|
headers: { "Content-Type": "application/json" },
|
|
350
|
-
body: JSON.stringify({ token: sessionToken, reason }),
|
|
352
|
+
body: JSON.stringify({ token: sessionToken, reason, responses }),
|
|
351
353
|
});
|
|
352
354
|
} catch (_err) {}
|
|
353
355
|
}
|
|
@@ -1968,6 +1970,65 @@
|
|
|
1968
1970
|
}
|
|
1969
1971
|
}
|
|
1970
1972
|
|
|
1973
|
+
// Set storage key without loading (for revival from saved interview)
|
|
1974
|
+
async function initStorageKeyOnly() {
|
|
1975
|
+
try {
|
|
1976
|
+
const hash = await hashQuestions();
|
|
1977
|
+
session.storageKey = `pi-interview-${hash}`;
|
|
1978
|
+
} catch (_err) {
|
|
1979
|
+
session.storageKey = null;
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Pre-populate form from saved interview answers
|
|
1984
|
+
function populateFromSavedAnswers(savedAnswers) {
|
|
1985
|
+
// Convert ResponseItem[] to Record for existing populateForm()
|
|
1986
|
+
const valueMap = {};
|
|
1987
|
+
savedAnswers.forEach((ans) => {
|
|
1988
|
+
const question = questions.find((q) => q.id === ans.id);
|
|
1989
|
+
if (question?.type !== "image") {
|
|
1990
|
+
valueMap[ans.id] = ans.value;
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
populateForm(valueMap);
|
|
1994
|
+
|
|
1995
|
+
// Restore attachments to attachPathState
|
|
1996
|
+
savedAnswers.forEach((ans) => {
|
|
1997
|
+
if (ans.attachments && ans.attachments.length > 0) {
|
|
1998
|
+
attachPathState.set(ans.id, [...ans.attachments]);
|
|
1999
|
+
attachments.render(ans.id);
|
|
2000
|
+
const panel = document.querySelector(
|
|
2001
|
+
`[data-attach-inline-for="${escapeSelector(ans.id)}"]`
|
|
2002
|
+
);
|
|
2003
|
+
if (panel) panel.classList.remove("hidden");
|
|
2004
|
+
const btn = document.querySelector(
|
|
2005
|
+
`.attach-btn[data-question-id="${escapeSelector(ans.id)}"]`
|
|
2006
|
+
);
|
|
2007
|
+
if (btn) btn.classList.add("has-attachment");
|
|
2008
|
+
}
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
// Restore image paths for image-type questions
|
|
2012
|
+
savedAnswers.forEach((ans) => {
|
|
2013
|
+
const question = questions.find((q) => q.id === ans.id);
|
|
2014
|
+
if (question?.type === "image" && ans.value) {
|
|
2015
|
+
const paths = Array.isArray(ans.value) ? ans.value : [ans.value];
|
|
2016
|
+
const validPaths = paths.filter((p) => typeof p === "string" && p);
|
|
2017
|
+
if (validPaths.length > 0) {
|
|
2018
|
+
imagePathState.set(ans.id, validPaths);
|
|
2019
|
+
questionImages.render(ans.id);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
// Update done states for multi-select
|
|
2025
|
+
questions.forEach((q) => {
|
|
2026
|
+
if (q.type === "multi") {
|
|
2027
|
+
updateDoneState(q.id);
|
|
2028
|
+
}
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1971
2032
|
function readFileBase64(file) {
|
|
1972
2033
|
return new Promise((resolve, reject) => {
|
|
1973
2034
|
const reader = new FileReader();
|
|
@@ -2020,6 +2081,54 @@
|
|
|
2020
2081
|
return { responses, images };
|
|
2021
2082
|
}
|
|
2022
2083
|
|
|
2084
|
+
// Save interview snapshot
|
|
2085
|
+
async function saveInterview(options = {}) {
|
|
2086
|
+
const { submitted = false } = options;
|
|
2087
|
+
|
|
2088
|
+
try {
|
|
2089
|
+
const payload = await buildPayload();
|
|
2090
|
+
const response = await fetch("/save", {
|
|
2091
|
+
method: "POST",
|
|
2092
|
+
headers: { "Content-Type": "application/json" },
|
|
2093
|
+
body: JSON.stringify({
|
|
2094
|
+
token: sessionToken,
|
|
2095
|
+
responses: payload.responses,
|
|
2096
|
+
images: payload.images,
|
|
2097
|
+
submitted,
|
|
2098
|
+
}),
|
|
2099
|
+
});
|
|
2100
|
+
const result = await response.json();
|
|
2101
|
+
if (result.ok) {
|
|
2102
|
+
showSaveSuccess(result.relativePath);
|
|
2103
|
+
return true;
|
|
2104
|
+
} else {
|
|
2105
|
+
if (!submitted) showSaveError(result.error);
|
|
2106
|
+
return false;
|
|
2107
|
+
}
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
if (!submitted) {
|
|
2110
|
+
showSaveError("Failed to save interview");
|
|
2111
|
+
}
|
|
2112
|
+
return false;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
function showSaveSuccess(savePath) {
|
|
2117
|
+
const toast = document.getElementById("save-toast");
|
|
2118
|
+
if (!toast) return;
|
|
2119
|
+
toast.textContent = `Saved to ${savePath}`;
|
|
2120
|
+
toast.className = "save-toast success";
|
|
2121
|
+
setTimeout(() => toast.classList.add("hidden"), 3000);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function showSaveError(message) {
|
|
2125
|
+
const toast = document.getElementById("save-toast");
|
|
2126
|
+
if (!toast) return;
|
|
2127
|
+
toast.textContent = message || "Save failed";
|
|
2128
|
+
toast.className = "save-toast error";
|
|
2129
|
+
setTimeout(() => toast.classList.add("hidden"), 3000);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2023
2132
|
async function submitForm(event) {
|
|
2024
2133
|
event.preventDefault();
|
|
2025
2134
|
clearGlobalError();
|
|
@@ -2035,18 +2144,24 @@
|
|
|
2035
2144
|
body: JSON.stringify({ token: sessionToken, ...payload }),
|
|
2036
2145
|
});
|
|
2037
2146
|
|
|
2038
|
-
const
|
|
2147
|
+
const result = await response.json().catch(() => ({ ok: false, error: "Invalid server response" }));
|
|
2039
2148
|
|
|
2040
|
-
if (!response.ok || !
|
|
2041
|
-
if (
|
|
2042
|
-
setFieldError(
|
|
2149
|
+
if (!response.ok || !result.ok) {
|
|
2150
|
+
if (result.field) {
|
|
2151
|
+
setFieldError(result.field, result.error || "Invalid input");
|
|
2043
2152
|
} else {
|
|
2044
|
-
showGlobalError(
|
|
2153
|
+
showGlobalError(result.error || "Submission failed.");
|
|
2045
2154
|
}
|
|
2046
2155
|
submitBtn.disabled = false;
|
|
2047
2156
|
return;
|
|
2048
2157
|
}
|
|
2049
2158
|
|
|
2159
|
+
// Auto-save on successful submit (fire-and-forget)
|
|
2160
|
+
// Note: data is window.__INTERVIEW_DATA__, result is server response
|
|
2161
|
+
if (data.autoSaveOnSubmit !== false) {
|
|
2162
|
+
saveInterview({ submitted: true });
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2050
2165
|
clearProgress();
|
|
2051
2166
|
stopHeartbeat();
|
|
2052
2167
|
stopQueuePolling();
|
|
@@ -2096,11 +2211,28 @@
|
|
|
2096
2211
|
containerEl.appendChild(createQuestionCard(question, index));
|
|
2097
2212
|
});
|
|
2098
2213
|
|
|
2099
|
-
|
|
2214
|
+
// Pre-populate: savedAnswers takes precedence over localStorage
|
|
2215
|
+
if (data.savedAnswers && Array.isArray(data.savedAnswers)) {
|
|
2216
|
+
populateFromSavedAnswers(data.savedAnswers);
|
|
2217
|
+
initStorageKeyOnly();
|
|
2218
|
+
} else {
|
|
2219
|
+
initStorage();
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2100
2222
|
startHeartbeat();
|
|
2101
2223
|
startQueuePolling();
|
|
2102
2224
|
|
|
2103
2225
|
formEl.addEventListener("submit", submitForm);
|
|
2226
|
+
|
|
2227
|
+
// Wire up save buttons
|
|
2228
|
+
const saveBtnHeader = document.getElementById("save-btn-header");
|
|
2229
|
+
const saveBtnFooter = document.getElementById("save-btn-footer");
|
|
2230
|
+
if (saveBtnHeader) {
|
|
2231
|
+
saveBtnHeader.addEventListener("click", () => saveInterview());
|
|
2232
|
+
}
|
|
2233
|
+
if (saveBtnFooter) {
|
|
2234
|
+
saveBtnFooter.addEventListener("click", () => saveInterview());
|
|
2235
|
+
}
|
|
2104
2236
|
if (queueToastClose) {
|
|
2105
2237
|
queueToastClose.addEventListener("click", () => {
|
|
2106
2238
|
queueState.dismissed = true;
|
package/form/styles.css
CHANGED
|
@@ -1409,3 +1409,62 @@ button {
|
|
|
1409
1409
|
margin-bottom: 1rem;
|
|
1410
1410
|
}
|
|
1411
1411
|
|
|
1412
|
+
/* Save button in header */
|
|
1413
|
+
.save-btn-header {
|
|
1414
|
+
background: transparent;
|
|
1415
|
+
border: 1px solid var(--border-muted);
|
|
1416
|
+
color: var(--fg-muted);
|
|
1417
|
+
padding: 4px 8px;
|
|
1418
|
+
font-size: 11px;
|
|
1419
|
+
border-radius: var(--radius);
|
|
1420
|
+
cursor: pointer;
|
|
1421
|
+
transition: border-color 150ms ease, color 150ms ease;
|
|
1422
|
+
font-family: var(--font-ui);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
.save-btn-header:hover {
|
|
1426
|
+
border-color: var(--fg-dim);
|
|
1427
|
+
color: var(--fg);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
.save-btn-header:focus {
|
|
1431
|
+
outline: none;
|
|
1432
|
+
border-color: var(--border-focus);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/* Save toast notification */
|
|
1436
|
+
.save-toast {
|
|
1437
|
+
position: fixed;
|
|
1438
|
+
bottom: 80px;
|
|
1439
|
+
left: 50%;
|
|
1440
|
+
transform: translateX(-50%);
|
|
1441
|
+
background: var(--bg-elevated);
|
|
1442
|
+
border: 1px solid var(--border-muted);
|
|
1443
|
+
border-radius: var(--radius);
|
|
1444
|
+
padding: 10px 16px;
|
|
1445
|
+
font-size: 12px;
|
|
1446
|
+
z-index: 150;
|
|
1447
|
+
animation: toast-in 200ms ease-out;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
.save-toast.success {
|
|
1451
|
+
border-color: var(--success);
|
|
1452
|
+
color: var(--success);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
.save-toast.error {
|
|
1456
|
+
border-color: var(--error);
|
|
1457
|
+
color: var(--error);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
@keyframes toast-in {
|
|
1461
|
+
from {
|
|
1462
|
+
opacity: 0;
|
|
1463
|
+
transform: translateX(-50%) translateY(10px);
|
|
1464
|
+
}
|
|
1465
|
+
to {
|
|
1466
|
+
opacity: 1;
|
|
1467
|
+
transform: translateX(-50%) translateY(0);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
package/index.ts
CHANGED
|
@@ -54,8 +54,22 @@ interface InterviewDetails {
|
|
|
54
54
|
queuedMessage?: string;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Types for saved interviews
|
|
58
|
+
interface SavedFromMeta {
|
|
59
|
+
cwd: string;
|
|
60
|
+
branch: string | null;
|
|
61
|
+
sessionId: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface SavedQuestionsFile extends QuestionsFile {
|
|
65
|
+
savedAnswers?: ResponseItem[];
|
|
66
|
+
savedAt?: string;
|
|
67
|
+
wasSubmitted?: boolean;
|
|
68
|
+
savedFrom?: SavedFromMeta;
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
const InterviewParams = Type.Object({
|
|
58
|
-
questions: Type.String({ description: "Path to questions JSON file" }),
|
|
72
|
+
questions: Type.String({ description: "Path to questions JSON or saved interview HTML file" }),
|
|
59
73
|
timeout: Type.Optional(
|
|
60
74
|
Type.Number({ description: "Seconds before auto-timeout", default: 600 })
|
|
61
75
|
),
|
|
@@ -63,7 +77,7 @@ const InterviewParams = Type.Object({
|
|
|
63
77
|
theme: Type.Optional(
|
|
64
78
|
Type.Object(
|
|
65
79
|
{
|
|
66
|
-
mode: Type.Optional(Type.
|
|
80
|
+
mode: Type.Optional(Type.String({ description: "Theme mode: 'auto', 'light', or 'dark'" })),
|
|
67
81
|
name: Type.Optional(Type.String()),
|
|
68
82
|
lightPath: Type.Optional(Type.String()),
|
|
69
83
|
darkPath: Type.Optional(Type.String()),
|
|
@@ -75,7 +89,11 @@ const InterviewParams = Type.Object({
|
|
|
75
89
|
});
|
|
76
90
|
|
|
77
91
|
function expandHome(value: string): string {
|
|
78
|
-
if (value
|
|
92
|
+
if (value === "~") {
|
|
93
|
+
return os.homedir();
|
|
94
|
+
}
|
|
95
|
+
// Handle both Unix (/) and Windows (\) separators for user convenience
|
|
96
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
79
97
|
return path.join(os.homedir(), value.slice(2));
|
|
80
98
|
}
|
|
81
99
|
return value;
|
|
@@ -103,18 +121,27 @@ function mergeThemeConfig(
|
|
|
103
121
|
};
|
|
104
122
|
}
|
|
105
123
|
|
|
106
|
-
function loadQuestions(questionsPath: string, cwd: string):
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
function loadQuestions(questionsPath: string, cwd: string): SavedQuestionsFile {
|
|
125
|
+
// Expand ~ first, then check if absolute
|
|
126
|
+
const expanded = expandHome(questionsPath);
|
|
127
|
+
const absolutePath = path.isAbsolute(expanded)
|
|
128
|
+
? expanded
|
|
129
|
+
: path.join(cwd, questionsPath); // Use original if relative (no ~)
|
|
110
130
|
|
|
111
131
|
if (!fs.existsSync(absolutePath)) {
|
|
112
132
|
throw new Error(`Questions file not found: ${absolutePath}`);
|
|
113
133
|
}
|
|
114
134
|
|
|
135
|
+
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
136
|
+
|
|
137
|
+
// Handle HTML files (saved interviews)
|
|
138
|
+
if (absolutePath.endsWith(".html") || absolutePath.endsWith(".htm")) {
|
|
139
|
+
return loadSavedInterview(content, absolutePath);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Original JSON handling
|
|
115
143
|
let data: unknown;
|
|
116
144
|
try {
|
|
117
|
-
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
118
145
|
data = JSON.parse(content);
|
|
119
146
|
} catch (err) {
|
|
120
147
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -124,6 +151,81 @@ function loadQuestions(questionsPath: string, cwd: string): QuestionsFile {
|
|
|
124
151
|
return validateQuestions(data);
|
|
125
152
|
}
|
|
126
153
|
|
|
154
|
+
function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
|
|
155
|
+
// Extract JSON from <script id="pi-interview-data">
|
|
156
|
+
const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
|
|
157
|
+
if (!match) {
|
|
158
|
+
throw new Error("Invalid saved interview: missing embedded data");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let data: unknown;
|
|
162
|
+
try {
|
|
163
|
+
data = JSON.parse(match[1]);
|
|
164
|
+
} catch {
|
|
165
|
+
throw new Error("Invalid saved interview: malformed JSON");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const raw = data as Record<string, unknown>;
|
|
169
|
+
const validated = validateQuestions(data);
|
|
170
|
+
|
|
171
|
+
// Resolve relative image paths to absolute based on HTML file location
|
|
172
|
+
const snapshotDir = path.dirname(filePath);
|
|
173
|
+
const savedAnswers = Array.isArray(raw.savedAnswers)
|
|
174
|
+
? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir)
|
|
175
|
+
: undefined;
|
|
176
|
+
|
|
177
|
+
// Validate savedFrom if present
|
|
178
|
+
let savedFrom: SavedFromMeta | undefined;
|
|
179
|
+
if (raw.savedFrom && typeof raw.savedFrom === "object") {
|
|
180
|
+
const sf = raw.savedFrom as Record<string, unknown>;
|
|
181
|
+
if (typeof sf.cwd === "string" && typeof sf.sessionId === "string") {
|
|
182
|
+
savedFrom = {
|
|
183
|
+
cwd: sf.cwd,
|
|
184
|
+
branch: typeof sf.branch === "string" ? sf.branch : null,
|
|
185
|
+
sessionId: sf.sessionId,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Return validated questions plus saved interview metadata
|
|
191
|
+
return {
|
|
192
|
+
...validated,
|
|
193
|
+
savedAnswers,
|
|
194
|
+
savedAt: typeof raw.savedAt === "string" ? raw.savedAt : undefined,
|
|
195
|
+
wasSubmitted: typeof raw.wasSubmitted === "boolean" ? raw.wasSubmitted : undefined,
|
|
196
|
+
savedFrom,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveAnswerPaths(answers: ResponseItem[], baseDir: string): ResponseItem[] {
|
|
201
|
+
return answers.map((ans) => ({
|
|
202
|
+
...ans,
|
|
203
|
+
value: resolvePathValue(ans.value, baseDir),
|
|
204
|
+
attachments: ans.attachments?.map((p) => resolveImagePath(p, baseDir)),
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveImagePath(p: string, baseDir: string): string {
|
|
209
|
+
if (!p) return p;
|
|
210
|
+
// Skip URLs
|
|
211
|
+
if (p.includes("://")) return p;
|
|
212
|
+
// Expand ~ first
|
|
213
|
+
const expanded = expandHome(p);
|
|
214
|
+
// Don't resolve if already absolute (cross-platform check)
|
|
215
|
+
if (path.isAbsolute(expanded)) {
|
|
216
|
+
return expanded;
|
|
217
|
+
}
|
|
218
|
+
// Resolve relative path against snapshot directory
|
|
219
|
+
return path.join(baseDir, p);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolvePathValue(value: string | string[], baseDir: string): string | string[] {
|
|
223
|
+
if (Array.isArray(value)) {
|
|
224
|
+
return value.map((v) => resolveImagePath(v, baseDir));
|
|
225
|
+
}
|
|
226
|
+
return typeof value === "string" && value ? resolveImagePath(value, baseDir) : value;
|
|
227
|
+
}
|
|
228
|
+
|
|
127
229
|
function formatResponses(responses: ResponseItem[]): string {
|
|
128
230
|
if (responses.length === 0) return "(none)";
|
|
129
231
|
return responses
|
|
@@ -138,6 +240,28 @@ function formatResponses(responses: ResponseItem[]): string {
|
|
|
138
240
|
.join("\n");
|
|
139
241
|
}
|
|
140
242
|
|
|
243
|
+
function hasAnyAnswers(responses: ResponseItem[]): boolean {
|
|
244
|
+
if (!responses || responses.length === 0) return false;
|
|
245
|
+
return responses.some((resp) => {
|
|
246
|
+
if (!resp || resp.value == null) return false;
|
|
247
|
+
if (Array.isArray(resp.value)) {
|
|
248
|
+
return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
|
|
249
|
+
}
|
|
250
|
+
return typeof resp.value === "string" && resp.value.trim() !== "";
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function filterAnsweredResponses(responses: ResponseItem[]): ResponseItem[] {
|
|
255
|
+
if (!responses) return [];
|
|
256
|
+
return responses.filter((resp) => {
|
|
257
|
+
if (!resp || resp.value == null) return false;
|
|
258
|
+
if (Array.isArray(resp.value)) {
|
|
259
|
+
return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
|
|
260
|
+
}
|
|
261
|
+
return typeof resp.value === "string" && resp.value.trim() !== "";
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
141
265
|
export default function (pi: ExtensionAPI) {
|
|
142
266
|
pi.registerTool({
|
|
143
267
|
name: "interview",
|
|
@@ -180,6 +304,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
180
304
|
const themeConfig = mergeThemeConfig(settings.theme, theme, ctx.cwd);
|
|
181
305
|
const questionsData = loadQuestions(questions, ctx.cwd);
|
|
182
306
|
|
|
307
|
+
// Expand ~ in snapshotDir if present
|
|
308
|
+
const snapshotDir = settings.snapshotDir
|
|
309
|
+
? expandHome(settings.snapshotDir)
|
|
310
|
+
: undefined; // Server will use default
|
|
311
|
+
|
|
183
312
|
if (signal?.aborted) {
|
|
184
313
|
return {
|
|
185
314
|
content: [{ type: "text", text: "Interview was aborted." }],
|
|
@@ -217,11 +346,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
217
346
|
if (cancelReason === "stale") {
|
|
218
347
|
text =
|
|
219
348
|
"Interview session ended due to lost heartbeat.\n\nQuestions saved to: ~/.pi/interview-recovery/";
|
|
349
|
+
} else if (hasAnyAnswers(responses)) {
|
|
350
|
+
const answered = filterAnsweredResponses(responses);
|
|
351
|
+
text = `User cancelled the interview with partial responses:\n${formatResponses(answered)}\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
|
|
220
352
|
} else {
|
|
221
|
-
text = "User
|
|
353
|
+
text = "User skipped the interview without providing answers. Proceed with your best judgment - use recommended options where specified, make reasonable choices elsewhere. Don't ask for clarification unless absolutely necessary.";
|
|
222
354
|
}
|
|
223
355
|
} else if (status === "timeout") {
|
|
224
|
-
|
|
356
|
+
if (hasAnyAnswers(responses)) {
|
|
357
|
+
const answered = filterAnsweredResponses(responses);
|
|
358
|
+
text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nPartial responses before timeout:\n${formatResponses(answered)}\n\nQuestions saved to: ~/.pi/interview-recovery/\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
|
|
359
|
+
} else {
|
|
360
|
+
text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nQuestions saved to: ~/.pi/interview-recovery/`;
|
|
361
|
+
}
|
|
225
362
|
} else {
|
|
226
363
|
text = "Interview was aborted.";
|
|
227
364
|
}
|
|
@@ -245,11 +382,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
245
382
|
port: settings.port,
|
|
246
383
|
verbose,
|
|
247
384
|
theme: themeConfig,
|
|
385
|
+
snapshotDir,
|
|
386
|
+
autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
|
|
387
|
+
savedAnswers: questionsData.savedAnswers,
|
|
248
388
|
},
|
|
249
389
|
{
|
|
250
390
|
onSubmit: (responses) => finish("completed", responses),
|
|
251
|
-
onCancel: (reason) =>
|
|
252
|
-
reason === "timeout"
|
|
391
|
+
onCancel: (reason, partialResponses) =>
|
|
392
|
+
reason === "timeout"
|
|
393
|
+
? finish("timeout", partialResponses ?? [])
|
|
394
|
+
: finish("cancelled", partialResponses ?? [], reason),
|
|
253
395
|
}
|
|
254
396
|
)
|
|
255
397
|
.then(async (handle) => {
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -46,6 +46,7 @@ interface SessionsFile {
|
|
|
46
46
|
|
|
47
47
|
const SESSIONS_FILE = join(homedir(), ".pi", "interview-sessions.json");
|
|
48
48
|
const RECOVERY_DIR = join(homedir(), ".pi", "interview-recovery");
|
|
49
|
+
const SNAPSHOTS_DIR = join(homedir(), ".pi", "interview-snapshots");
|
|
49
50
|
const STALE_THRESHOLD_MS = 30000;
|
|
50
51
|
const STALE_PRUNE_MS = 60000;
|
|
51
52
|
const RECOVERY_MAX_AGE_DAYS = 7;
|
|
@@ -192,11 +193,14 @@ export interface InterviewServerOptions {
|
|
|
192
193
|
port?: number;
|
|
193
194
|
verbose?: boolean;
|
|
194
195
|
theme?: InterviewThemeConfig;
|
|
196
|
+
snapshotDir?: string;
|
|
197
|
+
autoSaveOnSubmit?: boolean;
|
|
198
|
+
savedAnswers?: ResponseItem[];
|
|
195
199
|
}
|
|
196
200
|
|
|
197
201
|
export interface InterviewServerCallbacks {
|
|
198
202
|
onSubmit: (responses: ResponseItem[]) => void;
|
|
199
|
-
onCancel: (reason?: "timeout" | "user" | "stale") => void;
|
|
203
|
+
onCancel: (reason?: "timeout" | "user" | "stale", partialResponses?: ResponseItem[]) => void;
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
export interface InterviewServerHandle {
|
|
@@ -310,7 +314,8 @@ async function parseJSONBody(req: IncomingMessage): Promise<unknown> {
|
|
|
310
314
|
|
|
311
315
|
async function handleImageUpload(
|
|
312
316
|
image: { id: string; filename: string; mimeType: string; data: string },
|
|
313
|
-
sessionId: string
|
|
317
|
+
sessionId: string,
|
|
318
|
+
targetDir?: string
|
|
314
319
|
): Promise<string> {
|
|
315
320
|
if (!ALLOWED_TYPES.includes(image.mimeType)) {
|
|
316
321
|
throw new Error(`Invalid image type: ${image.mimeType}`);
|
|
@@ -322,7 +327,7 @@ async function handleImageUpload(
|
|
|
322
327
|
}
|
|
323
328
|
|
|
324
329
|
const sanitized = image.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
325
|
-
const
|
|
330
|
+
const fileBasename = sanitized.split(/[/\\]/).pop() || `image_${randomUUID()}`;
|
|
326
331
|
const extMap: Record<string, string> = {
|
|
327
332
|
"image/png": ".png",
|
|
328
333
|
"image/jpeg": ".jpg",
|
|
@@ -330,12 +335,12 @@ async function handleImageUpload(
|
|
|
330
335
|
"image/webp": ".webp",
|
|
331
336
|
};
|
|
332
337
|
const ext = extMap[image.mimeType] ?? "";
|
|
333
|
-
const filename =
|
|
338
|
+
const filename = fileBasename.includes(".") ? fileBasename : `${fileBasename}${ext}`;
|
|
334
339
|
|
|
335
|
-
const
|
|
336
|
-
await mkdir(
|
|
340
|
+
const dir = targetDir ?? join(tmpdir(), `pi-interview-${sessionId}`);
|
|
341
|
+
await mkdir(dir, { recursive: true });
|
|
337
342
|
|
|
338
|
-
const filepath = join(
|
|
343
|
+
const filepath = join(dir, filename);
|
|
339
344
|
await writeFile(filepath, buffer);
|
|
340
345
|
|
|
341
346
|
return filepath;
|
|
@@ -374,6 +379,223 @@ function ensureQuestionId(
|
|
|
374
379
|
return { ok: true, question };
|
|
375
380
|
}
|
|
376
381
|
|
|
382
|
+
// HTML generation for saved interviews
|
|
383
|
+
interface SavedFromMeta {
|
|
384
|
+
cwd: string;
|
|
385
|
+
branch: string | null;
|
|
386
|
+
sessionId: string;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
interface SavedInterviewMeta {
|
|
390
|
+
savedAt: string;
|
|
391
|
+
wasSubmitted: boolean;
|
|
392
|
+
savedFrom: SavedFromMeta;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function escapeHtml(str: string): string {
|
|
396
|
+
return str
|
|
397
|
+
.replace(/&/g, "&")
|
|
398
|
+
.replace(/</g, "<")
|
|
399
|
+
.replace(/>/g, ">")
|
|
400
|
+
.replace(/"/g, """);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[]): string {
|
|
404
|
+
const answerMap = new Map(answers.map((a) => [a.id, a]));
|
|
405
|
+
return questionsList
|
|
406
|
+
.map((q, i) => {
|
|
407
|
+
const ans = answerMap.get(q.id);
|
|
408
|
+
const value = ans?.value;
|
|
409
|
+
const attachments = ans?.attachments ?? [];
|
|
410
|
+
|
|
411
|
+
// Format answer based on question type
|
|
412
|
+
let answerHtml: string;
|
|
413
|
+
if (!value || (Array.isArray(value) && value.length === 0)) {
|
|
414
|
+
answerHtml = '<div class="saved-answer empty">(no answer)</div>';
|
|
415
|
+
} else if (q.type === "image") {
|
|
416
|
+
const paths = Array.isArray(value) ? value : [value];
|
|
417
|
+
answerHtml = `<div class="saved-images">${paths
|
|
418
|
+
.map((p) => `<img src="${escapeHtml(p)}" alt="uploaded image">`)
|
|
419
|
+
.join("")}</div>`;
|
|
420
|
+
} else if (q.type === "multi") {
|
|
421
|
+
const items = Array.isArray(value) ? value : [value];
|
|
422
|
+
answerHtml = `<div class="saved-answer"><ul>${items
|
|
423
|
+
.map((v) => `<li>${escapeHtml(String(v))}</li>`)
|
|
424
|
+
.join("")}</ul></div>`;
|
|
425
|
+
} else {
|
|
426
|
+
answerHtml = `<div class="saved-answer">${escapeHtml(String(value))}</div>`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Render code block if present
|
|
430
|
+
const codeHtml = q.codeBlock
|
|
431
|
+
? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
|
|
432
|
+
: "";
|
|
433
|
+
|
|
434
|
+
// Render attachments if any
|
|
435
|
+
const attachHtml =
|
|
436
|
+
attachments.length > 0
|
|
437
|
+
? `<div class="saved-attachments">${attachments
|
|
438
|
+
.map((p) => `<img src="${escapeHtml(p)}" alt="attachment">`)
|
|
439
|
+
.join("")}</div>`
|
|
440
|
+
: "";
|
|
441
|
+
|
|
442
|
+
return `
|
|
443
|
+
<div class="saved-question">
|
|
444
|
+
<h2>${i + 1}. ${escapeHtml(q.question)}</h2>
|
|
445
|
+
${codeHtml}
|
|
446
|
+
${answerHtml}
|
|
447
|
+
${attachHtml}
|
|
448
|
+
</div>
|
|
449
|
+
`;
|
|
450
|
+
})
|
|
451
|
+
.join("\n");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const SAVED_VIEW_STYLES = `
|
|
455
|
+
.saved-interview {
|
|
456
|
+
max-width: 680px;
|
|
457
|
+
margin: 0 auto;
|
|
458
|
+
padding: var(--spacing);
|
|
459
|
+
}
|
|
460
|
+
.saved-header {
|
|
461
|
+
margin-bottom: 24px;
|
|
462
|
+
padding-bottom: 16px;
|
|
463
|
+
border-bottom: 1px solid var(--border-muted);
|
|
464
|
+
}
|
|
465
|
+
.saved-header h1 {
|
|
466
|
+
margin: 0 0 8px;
|
|
467
|
+
font-size: 20px;
|
|
468
|
+
}
|
|
469
|
+
.saved-meta {
|
|
470
|
+
display: flex;
|
|
471
|
+
flex-wrap: wrap;
|
|
472
|
+
gap: 16px;
|
|
473
|
+
font-size: 12px;
|
|
474
|
+
color: var(--fg-muted);
|
|
475
|
+
}
|
|
476
|
+
.saved-status {
|
|
477
|
+
padding: 2px 8px;
|
|
478
|
+
border-radius: var(--radius);
|
|
479
|
+
background: var(--bg-elevated);
|
|
480
|
+
}
|
|
481
|
+
.saved-status.submitted {
|
|
482
|
+
color: var(--success);
|
|
483
|
+
border: 1px solid var(--success);
|
|
484
|
+
}
|
|
485
|
+
.saved-status.draft {
|
|
486
|
+
color: var(--warning);
|
|
487
|
+
border: 1px solid var(--warning);
|
|
488
|
+
}
|
|
489
|
+
.saved-question {
|
|
490
|
+
margin-bottom: 20px;
|
|
491
|
+
padding: 16px;
|
|
492
|
+
background: var(--bg-elevated);
|
|
493
|
+
border: 1px solid var(--border-muted);
|
|
494
|
+
border-radius: var(--radius);
|
|
495
|
+
}
|
|
496
|
+
.saved-question h2 {
|
|
497
|
+
margin: 0 0 12px;
|
|
498
|
+
font-size: 14px;
|
|
499
|
+
font-weight: 500;
|
|
500
|
+
}
|
|
501
|
+
.saved-code {
|
|
502
|
+
margin: 12px 0;
|
|
503
|
+
padding: 12px;
|
|
504
|
+
background: var(--bg-body);
|
|
505
|
+
border-radius: var(--radius);
|
|
506
|
+
overflow-x: auto;
|
|
507
|
+
font-size: 13px;
|
|
508
|
+
}
|
|
509
|
+
.saved-answer {
|
|
510
|
+
color: var(--fg);
|
|
511
|
+
padding: 8px 12px;
|
|
512
|
+
background: var(--bg-body);
|
|
513
|
+
border-radius: var(--radius);
|
|
514
|
+
white-space: pre-wrap;
|
|
515
|
+
}
|
|
516
|
+
.saved-answer.empty {
|
|
517
|
+
color: var(--fg-dim);
|
|
518
|
+
font-style: italic;
|
|
519
|
+
}
|
|
520
|
+
.saved-answer ul {
|
|
521
|
+
margin: 0;
|
|
522
|
+
padding-left: 20px;
|
|
523
|
+
}
|
|
524
|
+
.saved-images, .saved-attachments {
|
|
525
|
+
margin-top: 12px;
|
|
526
|
+
display: flex;
|
|
527
|
+
flex-wrap: wrap;
|
|
528
|
+
gap: 8px;
|
|
529
|
+
}
|
|
530
|
+
.saved-images img, .saved-attachments img {
|
|
531
|
+
max-width: 200px;
|
|
532
|
+
max-height: 150px;
|
|
533
|
+
border-radius: var(--radius);
|
|
534
|
+
border: 1px solid var(--border-muted);
|
|
535
|
+
}
|
|
536
|
+
`;
|
|
537
|
+
|
|
538
|
+
function generateSavedHtml(options: {
|
|
539
|
+
questions: QuestionsFile;
|
|
540
|
+
answers: ResponseItem[];
|
|
541
|
+
meta: SavedInterviewMeta;
|
|
542
|
+
baseStyles: string;
|
|
543
|
+
themeCss: string;
|
|
544
|
+
}): string {
|
|
545
|
+
const { questions: questionsData, answers, meta, baseStyles, themeCss } = options;
|
|
546
|
+
const title = questionsData.title || "Interview";
|
|
547
|
+
|
|
548
|
+
// Build the data object for embedding
|
|
549
|
+
const dataForEmbedding = {
|
|
550
|
+
title: questionsData.title,
|
|
551
|
+
description: questionsData.description,
|
|
552
|
+
questions: questionsData.questions,
|
|
553
|
+
savedAnswers: answers,
|
|
554
|
+
savedAt: meta.savedAt,
|
|
555
|
+
wasSubmitted: meta.wasSubmitted,
|
|
556
|
+
savedFrom: meta.savedFrom,
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const embeddedJson = safeInlineJSON(dataForEmbedding);
|
|
560
|
+
const questionsHtml = renderQuestionsHtml(questionsData.questions, answers);
|
|
561
|
+
const savedDate = new Date(meta.savedAt).toLocaleString();
|
|
562
|
+
const statusClass = meta.wasSubmitted ? "submitted" : "draft";
|
|
563
|
+
const statusText = meta.wasSubmitted ? "Submitted" : "Draft";
|
|
564
|
+
|
|
565
|
+
return `<!DOCTYPE html>
|
|
566
|
+
<html lang="en">
|
|
567
|
+
<head>
|
|
568
|
+
<meta charset="UTF-8">
|
|
569
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
570
|
+
<title>${escapeHtml(title)} - Saved Interview</title>
|
|
571
|
+
<style>
|
|
572
|
+
${baseStyles}
|
|
573
|
+
${themeCss}
|
|
574
|
+
${SAVED_VIEW_STYLES}
|
|
575
|
+
</style>
|
|
576
|
+
</head>
|
|
577
|
+
<body>
|
|
578
|
+
<main class="saved-interview">
|
|
579
|
+
<header class="saved-header">
|
|
580
|
+
<h1>${escapeHtml(title)}</h1>
|
|
581
|
+
<div class="saved-meta">
|
|
582
|
+
<span>Saved: ${escapeHtml(savedDate)}</span>
|
|
583
|
+
<span>Project: ${escapeHtml(meta.savedFrom.cwd)}</span>
|
|
584
|
+
${meta.savedFrom.branch ? `<span>Branch: ${escapeHtml(meta.savedFrom.branch)}</span>` : ""}
|
|
585
|
+
<span class="saved-status ${statusClass}">${statusText}</span>
|
|
586
|
+
</div>
|
|
587
|
+
</header>
|
|
588
|
+
<div class="saved-questions">
|
|
589
|
+
${questionsHtml}
|
|
590
|
+
</div>
|
|
591
|
+
</main>
|
|
592
|
+
<script type="application/json" id="pi-interview-data">
|
|
593
|
+
${embeddedJson}
|
|
594
|
+
</script>
|
|
595
|
+
</body>
|
|
596
|
+
</html>`;
|
|
597
|
+
}
|
|
598
|
+
|
|
377
599
|
export async function startInterviewServer(
|
|
378
600
|
options: InterviewServerOptions,
|
|
379
601
|
callbacks: InterviewServerCallbacks
|
|
@@ -468,6 +690,8 @@ export async function startInterviewServer(
|
|
|
468
690
|
mode: themeMode,
|
|
469
691
|
toggleHotkey: themeConfig.toggleHotkey,
|
|
470
692
|
},
|
|
693
|
+
savedAnswers: options.savedAnswers,
|
|
694
|
+
autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
|
|
471
695
|
});
|
|
472
696
|
const html = TEMPLATE
|
|
473
697
|
.replace("/* __INTERVIEW_DATA_PLACEHOLDER__ */", inlineData)
|
|
@@ -563,7 +787,8 @@ export async function startInterviewServer(
|
|
|
563
787
|
sendJson(res, 200, { ok: true });
|
|
564
788
|
return;
|
|
565
789
|
}
|
|
566
|
-
const
|
|
790
|
+
const payload = body as { reason?: string; responses?: ResponseItem[] };
|
|
791
|
+
const reason = payload.reason;
|
|
567
792
|
if (reason === "timeout" || reason === "stale") {
|
|
568
793
|
const recoveryPath = saveToRecovery(questions, cwd, gitBranch, sessionId);
|
|
569
794
|
const label = reason === "timeout" ? "timed out" : "stale";
|
|
@@ -572,7 +797,8 @@ export async function startInterviewServer(
|
|
|
572
797
|
markCompleted();
|
|
573
798
|
unregisterSession(sessionId);
|
|
574
799
|
sendJson(res, 200, { ok: true });
|
|
575
|
-
|
|
800
|
+
const partialResponses = Array.isArray(payload.responses) ? payload.responses : undefined;
|
|
801
|
+
setImmediate(() => callbacks.onCancel(reason as "timeout" | "user" | "stale" | undefined, partialResponses));
|
|
576
802
|
return;
|
|
577
803
|
}
|
|
578
804
|
|
|
@@ -705,6 +931,123 @@ export async function startInterviewServer(
|
|
|
705
931
|
return;
|
|
706
932
|
}
|
|
707
933
|
|
|
934
|
+
if (method === "POST" && url.pathname === "/save") {
|
|
935
|
+
const body = await parseJSONBody(req).catch((err) => {
|
|
936
|
+
if (err instanceof BodyTooLargeError) {
|
|
937
|
+
sendJson(res, err.statusCode, { ok: false, error: err.message });
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
sendJson(res, 400, { ok: false, error: err.message });
|
|
941
|
+
return null;
|
|
942
|
+
});
|
|
943
|
+
if (!body) return;
|
|
944
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
945
|
+
// Note: don't check `completed` - allow save after submit
|
|
946
|
+
|
|
947
|
+
const payload = body as {
|
|
948
|
+
responses?: ResponseItem[];
|
|
949
|
+
images?: Array<{
|
|
950
|
+
id: string;
|
|
951
|
+
filename: string;
|
|
952
|
+
mimeType: string;
|
|
953
|
+
data: string;
|
|
954
|
+
isAttachment?: boolean;
|
|
955
|
+
}>;
|
|
956
|
+
submitted?: boolean;
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
const responsesInput = Array.isArray(payload.responses) ? payload.responses : [];
|
|
960
|
+
const imagesInput = Array.isArray(payload.images) ? payload.images : [];
|
|
961
|
+
const submitted = payload.submitted === true;
|
|
962
|
+
|
|
963
|
+
const snapshotBaseDir = options.snapshotDir ?? SNAPSHOTS_DIR;
|
|
964
|
+
|
|
965
|
+
// Build folder name: {title}-{project}-{branch}-{timestamp}[-submitted]
|
|
966
|
+
const now = new Date();
|
|
967
|
+
const date = now.toISOString().slice(0, 10);
|
|
968
|
+
const time = now.toTimeString().slice(0, 8).replace(/:/g, "");
|
|
969
|
+
const timestamp = `${date}-${time}`;
|
|
970
|
+
const project = sanitizeForFilename(basename(cwd) || "unknown");
|
|
971
|
+
const branch = sanitizeForFilename(gitBranch || "nogit");
|
|
972
|
+
const titleSlug = sanitizeForFilename(questions.title || "interview");
|
|
973
|
+
const suffix = submitted ? "-submitted" : "";
|
|
974
|
+
const folderName = `${titleSlug}-${project}-${branch}-${timestamp}${suffix}`;
|
|
975
|
+
const snapshotPath = join(snapshotBaseDir, folderName);
|
|
976
|
+
const imagesPath = join(snapshotPath, "images");
|
|
977
|
+
|
|
978
|
+
await mkdir(snapshotPath, { recursive: true });
|
|
979
|
+
|
|
980
|
+
// Process responses - make a deep copy to avoid mutating input
|
|
981
|
+
const savedResponses: ResponseItem[] = responsesInput.map((r) => ({
|
|
982
|
+
...r,
|
|
983
|
+
value: Array.isArray(r.value) ? [...r.value] : r.value,
|
|
984
|
+
attachments: r.attachments ? [...r.attachments] : undefined,
|
|
985
|
+
}));
|
|
986
|
+
|
|
987
|
+
// Process uploaded images - save to images/ subfolder
|
|
988
|
+
if (imagesInput.length > 0) {
|
|
989
|
+
await mkdir(imagesPath, { recursive: true });
|
|
990
|
+
for (const image of imagesInput) {
|
|
991
|
+
if (!image || typeof image.id !== "string") continue;
|
|
992
|
+
|
|
993
|
+
try {
|
|
994
|
+
const absPath = await handleImageUpload(image, sessionId, imagesPath);
|
|
995
|
+
const relPath = "images/" + basename(absPath);
|
|
996
|
+
|
|
997
|
+
const existing = savedResponses.find((r) => r.id === image.id);
|
|
998
|
+
if (image.isAttachment) {
|
|
999
|
+
if (existing) {
|
|
1000
|
+
existing.attachments = existing.attachments || [];
|
|
1001
|
+
existing.attachments.push(relPath);
|
|
1002
|
+
} else {
|
|
1003
|
+
savedResponses.push({ id: image.id, value: "", attachments: [relPath] });
|
|
1004
|
+
}
|
|
1005
|
+
} else {
|
|
1006
|
+
if (existing) {
|
|
1007
|
+
if (Array.isArray(existing.value)) {
|
|
1008
|
+
existing.value.push(relPath);
|
|
1009
|
+
} else if (existing.value === "") {
|
|
1010
|
+
existing.value = relPath;
|
|
1011
|
+
} else {
|
|
1012
|
+
existing.value = [existing.value, relPath];
|
|
1013
|
+
}
|
|
1014
|
+
} else {
|
|
1015
|
+
savedResponses.push({ id: image.id, value: relPath });
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
const message = err instanceof Error ? err.message : "Image upload failed";
|
|
1020
|
+
sendJson(res, 400, { ok: false, error: message, field: image.id });
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Generate HTML with embedded data
|
|
1027
|
+
const meta: SavedInterviewMeta = {
|
|
1028
|
+
savedAt: new Date().toISOString(),
|
|
1029
|
+
wasSubmitted: submitted,
|
|
1030
|
+
savedFrom: { cwd: normalizedCwd, branch: gitBranch, sessionId },
|
|
1031
|
+
};
|
|
1032
|
+
const themeCss = themeMode === "light" ? themeLightCss : themeDarkCss;
|
|
1033
|
+
const html = generateSavedHtml({
|
|
1034
|
+
questions,
|
|
1035
|
+
answers: savedResponses,
|
|
1036
|
+
meta,
|
|
1037
|
+
baseStyles: STYLES,
|
|
1038
|
+
themeCss,
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
await writeFile(join(snapshotPath, "index.html"), html);
|
|
1042
|
+
|
|
1043
|
+
sendJson(res, 200, {
|
|
1044
|
+
ok: true,
|
|
1045
|
+
path: snapshotPath,
|
|
1046
|
+
relativePath: normalizePath(snapshotPath),
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
708
1051
|
sendText(res, 404, "Not found");
|
|
709
1052
|
} catch (err) {
|
|
710
1053
|
const message = err instanceof Error ? err.message : "Server error";
|
package/settings.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface InterviewSettings {
|
|
|
17
17
|
timeout?: number;
|
|
18
18
|
port?: number;
|
|
19
19
|
theme?: InterviewThemeSettings;
|
|
20
|
+
snapshotDir?: string; // Default: ~/.pi/interview-snapshots/
|
|
21
|
+
autoSaveOnSubmit?: boolean; // Default: true
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function loadSettings(): InterviewSettings {
|