pi-design-deck 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +329 -0
- package/banner.png +0 -0
- package/deck-schema.ts +262 -0
- package/deck-server.ts +675 -0
- package/form/css/controls.css +340 -0
- package/form/css/layout.css +338 -0
- package/form/css/preview.css +357 -0
- package/form/css/variables.css +54 -0
- package/form/deck.html +83 -0
- package/form/js/deck-core.js +199 -0
- package/form/js/deck-interact.js +400 -0
- package/form/js/deck-render.js +411 -0
- package/form/js/deck-session.js +582 -0
- package/generate-prompts.ts +87 -0
- package/index.ts +671 -0
- package/package.json +37 -0
- package/prompts/deck-discover.md +12 -0
- package/prompts/deck-plan.md +14 -0
- package/prompts/deck.md +16 -0
- package/server-utils.ts +197 -0
- package/settings.ts +65 -0
- package/skills/design-deck/SKILL.md +292 -0
package/deck-server.ts
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import {
|
|
8
|
+
getGitBranch,
|
|
9
|
+
MAX_BODY_SIZE,
|
|
10
|
+
normalizePath,
|
|
11
|
+
registerSession,
|
|
12
|
+
safeInlineJSON,
|
|
13
|
+
safeParseBody,
|
|
14
|
+
sendJson,
|
|
15
|
+
sendText,
|
|
16
|
+
touchSession,
|
|
17
|
+
unregisterSession,
|
|
18
|
+
validateTokenBody,
|
|
19
|
+
validateTokenQuery,
|
|
20
|
+
type SessionEntry,
|
|
21
|
+
} from "./server-utils.js";
|
|
22
|
+
import { isDeckOption, type DeckConfig, type DeckOption, type PreviewBlock } from "./deck-schema.js";
|
|
23
|
+
import { saveGenerateModel } from "./settings.js";
|
|
24
|
+
|
|
25
|
+
export interface ModelInfo {
|
|
26
|
+
provider: string;
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
reasoning: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ModelsPayload {
|
|
33
|
+
current: string | null;
|
|
34
|
+
available: ModelInfo[];
|
|
35
|
+
defaultModel: string | null;
|
|
36
|
+
currentThinking: string;
|
|
37
|
+
currentModelReasoning: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FORM_DIR = join(dirname(fileURLToPath(import.meta.url)), "form");
|
|
41
|
+
const DECK_TEMPLATE = readFileSync(join(FORM_DIR, "deck.html"), "utf-8");
|
|
42
|
+
|
|
43
|
+
// CSS modules - concatenated in order
|
|
44
|
+
const CSS_FILES = ["variables", "layout", "preview", "controls"];
|
|
45
|
+
const DECK_CSS = CSS_FILES
|
|
46
|
+
.map((name) => readFileSync(join(FORM_DIR, "css", `${name}.css`), "utf-8"))
|
|
47
|
+
.join("\n");
|
|
48
|
+
|
|
49
|
+
// JS modules - concatenated in order (core first, session last with init())
|
|
50
|
+
const JS_FILES = ["deck-core", "deck-render", "deck-interact", "deck-session"];
|
|
51
|
+
const DECK_JS = JS_FILES
|
|
52
|
+
.map((name) => readFileSync(join(FORM_DIR, "js", `${name}.js`), "utf-8"))
|
|
53
|
+
.join("\n");
|
|
54
|
+
|
|
55
|
+
const MIME_TYPES: Record<string, string> = {
|
|
56
|
+
".png": "image/png",
|
|
57
|
+
".jpg": "image/jpeg",
|
|
58
|
+
".jpeg": "image/jpeg",
|
|
59
|
+
".gif": "image/gif",
|
|
60
|
+
".webp": "image/webp",
|
|
61
|
+
".svg": "image/svg+xml",
|
|
62
|
+
".avif": "image/avif",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const ABANDONED_GRACE_MS = 60000;
|
|
66
|
+
const WATCHDOG_INTERVAL_MS = 5000;
|
|
67
|
+
const GENERATE_TIMEOUT_MS = 90_000;
|
|
68
|
+
|
|
69
|
+
function toStringMap(value: unknown): Record<string, string> | null {
|
|
70
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const out: Record<string, string> = {};
|
|
74
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
75
|
+
if (typeof entry !== "string") return null;
|
|
76
|
+
out[key] = entry;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function registerAsset(filePath: string, assetsDir: string): string {
|
|
82
|
+
if (!existsSync(filePath)) throw new Error(`Image not found: ${filePath}`);
|
|
83
|
+
const ext = extname(filePath);
|
|
84
|
+
const id = randomUUID();
|
|
85
|
+
const dest = join(assetsDir, `${id}${ext}`);
|
|
86
|
+
copyFileSync(filePath, dest);
|
|
87
|
+
return `/assets/${id}${ext}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function processImageBlocks(blocks: PreviewBlock[], assetsDir: string): PreviewBlock[] {
|
|
91
|
+
return blocks.map((block) => {
|
|
92
|
+
if (block.type !== "image") return block;
|
|
93
|
+
const servedSrc = registerAsset(block.src, assetsDir);
|
|
94
|
+
return { ...block, src: servedSrc };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function processOptionAssets(option: DeckOption, assetsDir: string): DeckOption {
|
|
99
|
+
if (!option.previewBlocks) return option;
|
|
100
|
+
return { ...option, previewBlocks: processImageBlocks(option.previewBlocks, assetsDir) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DECK_SNAPSHOTS_DIR = join(homedir(), ".pi", "deck-snapshots");
|
|
104
|
+
|
|
105
|
+
function sanitizeForFilename(value: string): string {
|
|
106
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40).replace(/_+$/, "") || "unknown";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function saveDeckSnapshot(
|
|
110
|
+
config: DeckConfig,
|
|
111
|
+
selections: Record<string, string>,
|
|
112
|
+
assetsDir: string,
|
|
113
|
+
normalizedCwd: string,
|
|
114
|
+
gitBranch: string | null,
|
|
115
|
+
sessionId: string,
|
|
116
|
+
baseDir: string,
|
|
117
|
+
suffix?: string
|
|
118
|
+
): { path: string; relativePath: string } {
|
|
119
|
+
const now = new Date();
|
|
120
|
+
const date = now.toISOString().slice(0, 10);
|
|
121
|
+
const time = now.toTimeString().slice(0, 8).replace(/:/g, "");
|
|
122
|
+
const titleSlug = sanitizeForFilename(config.title || "deck");
|
|
123
|
+
const project = sanitizeForFilename(basename(normalizedCwd) || "unknown");
|
|
124
|
+
const branch = sanitizeForFilename(gitBranch || "nogit");
|
|
125
|
+
const safeSuffix = suffix ? `-${suffix}` : "";
|
|
126
|
+
const folderName = `${titleSlug}-${project}-${branch}-${date}-${time}${safeSuffix}`;
|
|
127
|
+
const snapshotPath = join(baseDir, folderName);
|
|
128
|
+
const imagesPath = join(snapshotPath, "images");
|
|
129
|
+
|
|
130
|
+
mkdirSync(snapshotPath, { recursive: true });
|
|
131
|
+
|
|
132
|
+
const saved = structuredClone(config);
|
|
133
|
+
for (const slide of saved.slides) {
|
|
134
|
+
for (const option of slide.options) {
|
|
135
|
+
if (!option.previewBlocks) continue;
|
|
136
|
+
for (const block of option.previewBlocks) {
|
|
137
|
+
if (block.type !== "image" || !block.src.startsWith("/assets/")) continue;
|
|
138
|
+
const filename = block.src.slice("/assets/".length);
|
|
139
|
+
const srcFile = join(assetsDir, filename);
|
|
140
|
+
if (existsSync(srcFile)) {
|
|
141
|
+
mkdirSync(imagesPath, { recursive: true });
|
|
142
|
+
copyFileSync(srcFile, join(imagesPath, filename));
|
|
143
|
+
(block as { src: string }).src = `images/${filename}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const data = {
|
|
150
|
+
config: saved,
|
|
151
|
+
selections,
|
|
152
|
+
savedAt: now.toISOString(),
|
|
153
|
+
savedFrom: { cwd: normalizedCwd, branch: gitBranch, sessionId },
|
|
154
|
+
};
|
|
155
|
+
writeFileSync(join(snapshotPath, "deck.json"), JSON.stringify(data, null, 2));
|
|
156
|
+
|
|
157
|
+
const home = homedir();
|
|
158
|
+
const relativePath = snapshotPath.startsWith(home) ? "~" + snapshotPath.slice(home.length) : snapshotPath;
|
|
159
|
+
return { path: snapshotPath, relativePath };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface DeckServerOptions {
|
|
163
|
+
config: DeckConfig;
|
|
164
|
+
sessionToken: string;
|
|
165
|
+
sessionId: string;
|
|
166
|
+
cwd: string;
|
|
167
|
+
port?: number;
|
|
168
|
+
theme?: { mode?: string; toggleHotkey?: string };
|
|
169
|
+
savedSelections?: Record<string, string>;
|
|
170
|
+
snapshotDir?: string;
|
|
171
|
+
autoSaveOnSubmit?: boolean;
|
|
172
|
+
models?: ModelsPayload;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface DeckServerCallbacks {
|
|
176
|
+
onSubmit: (selections: Record<string, string>) => void;
|
|
177
|
+
onCancel: (reason?: "user" | "stale" | "aborted") => void;
|
|
178
|
+
onGenerateMore: (slideId: string, prompt?: string, model?: string, thinking?: string) => void;
|
|
179
|
+
onRegenerateSlide: (slideId: string, prompt?: string, model?: string, thinking?: string) => void;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface DeckServerHandle {
|
|
183
|
+
url: string;
|
|
184
|
+
port: number;
|
|
185
|
+
close: () => void;
|
|
186
|
+
pushOption: (slideId: string, option: DeckOption) => void;
|
|
187
|
+
cancelGenerate: () => void;
|
|
188
|
+
replaceSlideOptions: (slideId: string, options: DeckOption[]) => void;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function startDeckServer(
|
|
192
|
+
options: DeckServerOptions,
|
|
193
|
+
callbacks: DeckServerCallbacks
|
|
194
|
+
): Promise<DeckServerHandle> {
|
|
195
|
+
const { config, sessionToken, sessionId, cwd, port, theme, savedSelections, snapshotDir, autoSaveOnSubmit } = options;
|
|
196
|
+
const normalizedCwd = normalizePath(cwd);
|
|
197
|
+
const gitBranch = getGitBranch(cwd);
|
|
198
|
+
|
|
199
|
+
const assetsDir = mkdtempSync(join(tmpdir(), "deck-assets-"));
|
|
200
|
+
|
|
201
|
+
for (const slide of config.slides) {
|
|
202
|
+
slide.options = slide.options.map((opt) => processOptionAssets(opt, assetsDir));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const knownSlideIds = new Set(config.slides.map((s) => s.id));
|
|
206
|
+
|
|
207
|
+
const sseClients = new Set<http.ServerResponse>();
|
|
208
|
+
let pendingGenerate: { slideId: string; isRegen: boolean; timer: NodeJS.Timeout } | null = null;
|
|
209
|
+
|
|
210
|
+
const clearPendingGenerate = () => {
|
|
211
|
+
if (pendingGenerate) {
|
|
212
|
+
clearTimeout(pendingGenerate.timer);
|
|
213
|
+
pendingGenerate = null;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const setPendingGenerate = (slideId: string, isRegen: boolean) => {
|
|
218
|
+
clearPendingGenerate();
|
|
219
|
+
const timer = setTimeout(() => {
|
|
220
|
+
if (!pendingGenerate || completed) return;
|
|
221
|
+
const { slideId: sid, isRegen: regen } = pendingGenerate;
|
|
222
|
+
pendingGenerate = null;
|
|
223
|
+
pushEvent(regen ? "regenerate-failed" : "generate-failed", { slideId: sid, reason: "timeout" });
|
|
224
|
+
}, GENERATE_TIMEOUT_MS);
|
|
225
|
+
pendingGenerate = { slideId, isRegen, timer };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
let completed = false;
|
|
229
|
+
let browserConnected = false;
|
|
230
|
+
let sessionEntry: SessionEntry | null = null;
|
|
231
|
+
let watchdog: NodeJS.Timeout | null = null;
|
|
232
|
+
let lastHeartbeatAt = Date.now();
|
|
233
|
+
|
|
234
|
+
const stopWatchdog = () => {
|
|
235
|
+
if (watchdog) {
|
|
236
|
+
clearInterval(watchdog);
|
|
237
|
+
watchdog = null;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const markCompleted = () => {
|
|
242
|
+
if (completed) return false;
|
|
243
|
+
completed = true;
|
|
244
|
+
stopWatchdog();
|
|
245
|
+
clearPendingGenerate();
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const touchHeartbeat = () => {
|
|
250
|
+
lastHeartbeatAt = Date.now();
|
|
251
|
+
if (!browserConnected) {
|
|
252
|
+
browserConnected = true;
|
|
253
|
+
}
|
|
254
|
+
if (sessionEntry) {
|
|
255
|
+
touchSession(sessionEntry);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const pushEvent = (name: string, payload: unknown) => {
|
|
260
|
+
const encoded = JSON.stringify(payload);
|
|
261
|
+
const chunk = `event: ${name}\ndata: ${encoded}\n\n`;
|
|
262
|
+
for (const client of sseClients) {
|
|
263
|
+
try {
|
|
264
|
+
client.write(chunk);
|
|
265
|
+
} catch {
|
|
266
|
+
sseClients.delete(client);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const closeSSE = () => {
|
|
272
|
+
for (const client of sseClients) {
|
|
273
|
+
try {
|
|
274
|
+
client.end();
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
sseClients.clear();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const server = http.createServer(async (req, res) => {
|
|
281
|
+
try {
|
|
282
|
+
const method = req.method || "GET";
|
|
283
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
284
|
+
|
|
285
|
+
if (method === "GET" && url.pathname === "/") {
|
|
286
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
287
|
+
touchHeartbeat();
|
|
288
|
+
const inlineData = safeInlineJSON({
|
|
289
|
+
config,
|
|
290
|
+
sessionToken,
|
|
291
|
+
sessionId,
|
|
292
|
+
cwd: normalizedCwd,
|
|
293
|
+
gitBranch,
|
|
294
|
+
theme,
|
|
295
|
+
savedSelections,
|
|
296
|
+
});
|
|
297
|
+
const title = config.title ? `${config.title} — Design Deck` : "Design Deck";
|
|
298
|
+
const html = DECK_TEMPLATE
|
|
299
|
+
.replace("/* __DECK_DATA_PLACEHOLDER__ */", inlineData)
|
|
300
|
+
.replace("<title>Design Deck</title>", `<title>${title.replace(/</g, "<")}</title>`);
|
|
301
|
+
res.writeHead(200, {
|
|
302
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
303
|
+
"Cache-Control": "no-store",
|
|
304
|
+
});
|
|
305
|
+
res.end(html);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (method === "GET" && url.pathname === "/deck.css") {
|
|
310
|
+
res.writeHead(200, {
|
|
311
|
+
"Content-Type": "text/css; charset=utf-8",
|
|
312
|
+
"Cache-Control": "no-store",
|
|
313
|
+
});
|
|
314
|
+
res.end(DECK_CSS);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (method === "GET" && url.pathname === "/deck.js") {
|
|
319
|
+
res.writeHead(200, {
|
|
320
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
321
|
+
"Cache-Control": "no-store",
|
|
322
|
+
});
|
|
323
|
+
res.end(DECK_JS);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (method === "GET" && url.pathname.startsWith("/assets/")) {
|
|
328
|
+
const filename = url.pathname.slice("/assets/".length);
|
|
329
|
+
if (!filename || filename.includes("/") || filename.includes("..")) {
|
|
330
|
+
sendText(res, 400, "Invalid asset path");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const filePath = resolve(assetsDir, filename);
|
|
334
|
+
if (!filePath.startsWith(assetsDir)) {
|
|
335
|
+
sendText(res, 403, "Forbidden");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (!existsSync(filePath)) {
|
|
339
|
+
sendText(res, 404, "Asset not found");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const ext = extname(filename).toLowerCase();
|
|
343
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
344
|
+
const data = readFileSync(filePath);
|
|
345
|
+
res.writeHead(200, {
|
|
346
|
+
"Content-Type": contentType,
|
|
347
|
+
"Cache-Control": "public, max-age=86400",
|
|
348
|
+
"Content-Length": data.length,
|
|
349
|
+
});
|
|
350
|
+
res.end(data);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (method === "GET" && url.pathname === "/events") {
|
|
355
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
356
|
+
touchHeartbeat();
|
|
357
|
+
res.writeHead(200, {
|
|
358
|
+
"Content-Type": "text/event-stream",
|
|
359
|
+
"Cache-Control": "no-cache, no-transform",
|
|
360
|
+
Connection: "keep-alive",
|
|
361
|
+
"X-Accel-Buffering": "no",
|
|
362
|
+
});
|
|
363
|
+
res.write(": connected\n\n");
|
|
364
|
+
sseClients.add(res);
|
|
365
|
+
req.on("close", () => {
|
|
366
|
+
sseClients.delete(res);
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (method === "GET" && url.pathname === "/health") {
|
|
372
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
373
|
+
sendJson(res, 200, { ok: true, maxBodySize: MAX_BODY_SIZE });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (method === "GET" && url.pathname === "/models") {
|
|
378
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
379
|
+
sendJson(res, 200, options.models ?? { current: null, available: [], defaultModel: null, currentThinking: "off", currentModelReasoning: false });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (method === "POST" && url.pathname === "/save-model-default") {
|
|
384
|
+
const body = await safeParseBody(req, res);
|
|
385
|
+
if (!body) return;
|
|
386
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
387
|
+
const payload = body as { model?: string };
|
|
388
|
+
const model = typeof payload.model === "string" && payload.model.trim() ? payload.model.trim() : null;
|
|
389
|
+
try {
|
|
390
|
+
saveGenerateModel(model);
|
|
391
|
+
if (options.models) options.models.defaultModel = model;
|
|
392
|
+
sendJson(res, 200, { ok: true });
|
|
393
|
+
} catch {
|
|
394
|
+
sendJson(res, 500, { ok: false, error: "Failed to save setting" });
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (method === "POST" && url.pathname === "/heartbeat") {
|
|
400
|
+
const body = await safeParseBody(req, res);
|
|
401
|
+
if (!body) return;
|
|
402
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
403
|
+
touchHeartbeat();
|
|
404
|
+
sendJson(res, 200, { ok: true });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (method === "POST" && url.pathname === "/submit") {
|
|
409
|
+
const body = await safeParseBody(req, res);
|
|
410
|
+
if (!body) return;
|
|
411
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
412
|
+
if (completed) {
|
|
413
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const payload = body as { selections?: unknown };
|
|
418
|
+
const selections = toStringMap(payload.selections);
|
|
419
|
+
if (!selections) {
|
|
420
|
+
sendJson(res, 400, { ok: false, error: "Invalid selections payload" });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
touchHeartbeat();
|
|
425
|
+
if (autoSaveOnSubmit !== false) {
|
|
426
|
+
try {
|
|
427
|
+
saveDeckSnapshot(config, selections, assetsDir, normalizedCwd, gitBranch, sessionId, snapshotDir || DECK_SNAPSHOTS_DIR, "submitted");
|
|
428
|
+
} catch {}
|
|
429
|
+
}
|
|
430
|
+
markCompleted();
|
|
431
|
+
unregisterSession(sessionId);
|
|
432
|
+
pushEvent("deck-close", { reason: "submitted" });
|
|
433
|
+
sendJson(res, 200, { ok: true });
|
|
434
|
+
setImmediate(() => callbacks.onSubmit(selections));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (method === "POST" && url.pathname === "/save") {
|
|
439
|
+
const body = await safeParseBody(req, res);
|
|
440
|
+
if (!body) return;
|
|
441
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
442
|
+
|
|
443
|
+
const payload = body as { selections?: unknown };
|
|
444
|
+
const selections = toStringMap(payload.selections) ?? {};
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const result = saveDeckSnapshot(config, selections, assetsDir, normalizedCwd, gitBranch, sessionId, snapshotDir || DECK_SNAPSHOTS_DIR);
|
|
448
|
+
sendJson(res, 200, { ok: true, path: result.path, relativePath: result.relativePath });
|
|
449
|
+
} catch (err) {
|
|
450
|
+
const message = err instanceof Error ? err.message : "Save failed";
|
|
451
|
+
sendJson(res, 500, { ok: false, error: message });
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (method === "POST" && url.pathname === "/cancel") {
|
|
457
|
+
const body = await safeParseBody(req, res);
|
|
458
|
+
if (!body) return;
|
|
459
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
460
|
+
if (completed) {
|
|
461
|
+
sendJson(res, 200, { ok: true });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const payload = body as { reason?: string; selections?: unknown };
|
|
466
|
+
const reason =
|
|
467
|
+
payload.reason === "stale" || payload.reason === "aborted" || payload.reason === "user"
|
|
468
|
+
? payload.reason
|
|
469
|
+
: "user";
|
|
470
|
+
|
|
471
|
+
const cancelSelections = toStringMap(payload.selections);
|
|
472
|
+
if (cancelSelections && Object.keys(cancelSelections).length > 0) {
|
|
473
|
+
try {
|
|
474
|
+
saveDeckSnapshot(config, cancelSelections, assetsDir, normalizedCwd, gitBranch, sessionId, snapshotDir || DECK_SNAPSHOTS_DIR, "cancelled");
|
|
475
|
+
} catch {}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
markCompleted();
|
|
479
|
+
unregisterSession(sessionId);
|
|
480
|
+
pushEvent("deck-close", { reason });
|
|
481
|
+
sendJson(res, 200, { ok: true });
|
|
482
|
+
setImmediate(() => callbacks.onCancel(reason));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (method === "POST" && url.pathname === "/generate-more") {
|
|
487
|
+
const body = await safeParseBody(req, res);
|
|
488
|
+
if (!body) return;
|
|
489
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
490
|
+
if (completed) {
|
|
491
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const payload = body as { slideId?: string; prompt?: string; model?: string; thinking?: string };
|
|
496
|
+
if (typeof payload.slideId !== "string" || payload.slideId.trim() === "") {
|
|
497
|
+
sendJson(res, 400, { ok: false, error: "slideId is required" });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (!knownSlideIds.has(payload.slideId)) {
|
|
501
|
+
sendJson(res, 404, { ok: false, error: "Unknown slide" });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (pendingGenerate) {
|
|
505
|
+
sendJson(res, 409, { ok: false, error: "A generation is already in progress" });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const prompt = typeof payload.prompt === "string" ? payload.prompt.trim() || undefined : undefined;
|
|
510
|
+
const model = typeof payload.model === "string" ? (payload.model.trim() || "") : undefined;
|
|
511
|
+
const thinking = typeof payload.thinking === "string" ? payload.thinking.trim() || undefined : undefined;
|
|
512
|
+
|
|
513
|
+
setPendingGenerate(payload.slideId as string, false);
|
|
514
|
+
touchHeartbeat();
|
|
515
|
+
sendJson(res, 200, { ok: true });
|
|
516
|
+
setImmediate(() => {
|
|
517
|
+
callbacks.onGenerateMore(payload.slideId as string, prompt, model, thinking);
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (method === "POST" && url.pathname === "/regenerate-slide") {
|
|
523
|
+
const body = await safeParseBody(req, res);
|
|
524
|
+
if (!body) return;
|
|
525
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
526
|
+
if (completed) {
|
|
527
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const payload = body as { slideId?: string; prompt?: string; model?: string; thinking?: string };
|
|
532
|
+
if (typeof payload.slideId !== "string" || payload.slideId.trim() === "") {
|
|
533
|
+
sendJson(res, 400, { ok: false, error: "slideId is required" });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (!knownSlideIds.has(payload.slideId)) {
|
|
537
|
+
sendJson(res, 404, { ok: false, error: "Unknown slide" });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (pendingGenerate) {
|
|
541
|
+
sendJson(res, 409, { ok: false, error: "A generation is already in progress" });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const prompt = typeof payload.prompt === "string" ? payload.prompt.trim() || undefined : undefined;
|
|
546
|
+
const model = typeof payload.model === "string" ? (payload.model.trim() || "") : undefined;
|
|
547
|
+
const thinking = typeof payload.thinking === "string" ? payload.thinking.trim() || undefined : undefined;
|
|
548
|
+
|
|
549
|
+
setPendingGenerate(payload.slideId as string, true);
|
|
550
|
+
touchHeartbeat();
|
|
551
|
+
sendJson(res, 200, { ok: true });
|
|
552
|
+
setImmediate(() => {
|
|
553
|
+
callbacks.onRegenerateSlide(payload.slideId as string, prompt, model, thinking);
|
|
554
|
+
});
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
sendText(res, 404, "Not found");
|
|
559
|
+
} catch (err) {
|
|
560
|
+
const message = err instanceof Error ? err.message : "Server error";
|
|
561
|
+
sendJson(res, 500, { ok: false, error: message });
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
return new Promise((resolve, reject) => {
|
|
566
|
+
const onError = (err: Error) => {
|
|
567
|
+
reject(new Error(`Failed to start deck server: ${err.message}`));
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
server.once("error", onError);
|
|
571
|
+
server.listen(port ?? 0, "127.0.0.1", () => {
|
|
572
|
+
server.off("error", onError);
|
|
573
|
+
const addr = server.address();
|
|
574
|
+
if (!addr || typeof addr === "string") {
|
|
575
|
+
reject(new Error("Failed to start deck server: invalid address"));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const url = `http://localhost:${addr.port}/?session=${sessionToken}`;
|
|
580
|
+
const now = Date.now();
|
|
581
|
+
sessionEntry = {
|
|
582
|
+
id: sessionId,
|
|
583
|
+
url,
|
|
584
|
+
cwd: normalizedCwd,
|
|
585
|
+
gitBranch,
|
|
586
|
+
title: config.title || "Design Deck",
|
|
587
|
+
startedAt: now,
|
|
588
|
+
lastSeen: now,
|
|
589
|
+
};
|
|
590
|
+
registerSession(sessionEntry);
|
|
591
|
+
|
|
592
|
+
if (!watchdog) {
|
|
593
|
+
watchdog = setInterval(() => {
|
|
594
|
+
if (completed || !browserConnected) return;
|
|
595
|
+
if (Date.now() - lastHeartbeatAt <= ABANDONED_GRACE_MS) return;
|
|
596
|
+
if (!markCompleted()) return;
|
|
597
|
+
unregisterSession(sessionId);
|
|
598
|
+
pushEvent("deck-close", { reason: "stale" });
|
|
599
|
+
setImmediate(() => callbacks.onCancel("stale"));
|
|
600
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
resolve({
|
|
604
|
+
url,
|
|
605
|
+
port: addr.port,
|
|
606
|
+
close: () => {
|
|
607
|
+
if (!completed) {
|
|
608
|
+
markCompleted();
|
|
609
|
+
unregisterSession(sessionId);
|
|
610
|
+
pushEvent("deck-close", { reason: "closed" });
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
server.close();
|
|
614
|
+
} catch {}
|
|
615
|
+
closeSSE();
|
|
616
|
+
try {
|
|
617
|
+
rmSync(assetsDir, { recursive: true, force: true });
|
|
618
|
+
} catch {}
|
|
619
|
+
},
|
|
620
|
+
pushOption: (slideId: string, option: DeckOption) => {
|
|
621
|
+
if (completed) {
|
|
622
|
+
throw new Error("Deck session is closed");
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
if (!isDeckOption(option)) {
|
|
626
|
+
throw new Error("Invalid deck option payload");
|
|
627
|
+
}
|
|
628
|
+
const slide = config.slides.find((s) => s.id === slideId);
|
|
629
|
+
if (!slide) {
|
|
630
|
+
throw new Error(`Unknown slide id: ${slideId}`);
|
|
631
|
+
}
|
|
632
|
+
const processed = processOptionAssets(option, assetsDir);
|
|
633
|
+
slide.options.push(processed);
|
|
634
|
+
clearPendingGenerate();
|
|
635
|
+
pushEvent("new-option", { slideId, option: processed });
|
|
636
|
+
} catch (err) {
|
|
637
|
+
clearPendingGenerate();
|
|
638
|
+
pushEvent("generate-failed", { slideId });
|
|
639
|
+
throw err;
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
cancelGenerate: () => {
|
|
643
|
+
if (!pendingGenerate) return;
|
|
644
|
+
const { slideId, isRegen } = pendingGenerate;
|
|
645
|
+
clearPendingGenerate();
|
|
646
|
+
pushEvent(isRegen ? "regenerate-failed" : "generate-failed", { slideId });
|
|
647
|
+
},
|
|
648
|
+
replaceSlideOptions: (slideId: string, options: DeckOption[]) => {
|
|
649
|
+
if (completed) {
|
|
650
|
+
throw new Error("Deck session is closed");
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
const slide = config.slides.find((s) => s.id === slideId);
|
|
654
|
+
if (!slide) {
|
|
655
|
+
throw new Error(`Unknown slide id: ${slideId}`);
|
|
656
|
+
}
|
|
657
|
+
const processedOptions = options.map((opt) => {
|
|
658
|
+
if (!isDeckOption(opt)) {
|
|
659
|
+
throw new Error("Invalid deck option payload");
|
|
660
|
+
}
|
|
661
|
+
return processOptionAssets(opt, assetsDir);
|
|
662
|
+
});
|
|
663
|
+
slide.options = processedOptions;
|
|
664
|
+
clearPendingGenerate();
|
|
665
|
+
pushEvent("replace-options", { slideId, options: processedOptions });
|
|
666
|
+
} catch (err) {
|
|
667
|
+
clearPendingGenerate();
|
|
668
|
+
pushEvent("regenerate-failed", { slideId });
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
}
|