pindeck 2.8.1 → 3.0.0-beta.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 +162 -59
- package/cli.mjs +4 -4
- package/lib/agent-process.mjs +49 -0
- package/lib/agent-tools.mjs +85 -0
- package/lib/claude.mjs +34 -66
- package/lib/codex.mjs +104 -0
- package/lib/models.mjs +66 -0
- package/lib/pdf-text.jxa.js +9 -0
- package/lib/platform-notice.mjs +34 -0
- package/package.json +7 -4
- package/public/editor.css +15 -0
- package/public/editor.js +95 -41
- package/public/index.html +29 -12
- package/server.mjs +232 -122
package/server.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/* PinDeck: local server.
|
|
3
3
|
|
|
4
|
-
pindeck [deck-dir] [--port 5180] [--model <name>] (or: node server.mjs ...)
|
|
4
|
+
pindeck [deck-dir] [--port 5180] [--provider claude|codex] [--model <name>] (or: node server.mjs ...)
|
|
5
5
|
|
|
6
6
|
- serves the editor UI and the deck (with a bridge script injected in edit mode)
|
|
7
7
|
- pushes file changes to the browser (server-sent events) for live reload
|
|
8
|
-
- runs one long-lived
|
|
9
|
-
- snapshots files around every
|
|
10
|
-
- answers `cli.mjs layout|shot|slides|pdf|pdf-figure` for
|
|
8
|
+
- runs one long-lived backend process for the chat sidebar
|
|
9
|
+
- snapshots files around every assistant turn for diffs and undo
|
|
10
|
+
- answers `cli.mjs layout|shot|slides|pdf|pdf-figure` for the assistant's self-checks, exports and PDF figures
|
|
11
11
|
- exports the deck as a PDF handout or a PDF presentation (build steps on separate pages)
|
|
12
|
-
- stores figures and audio attached to chat messages in <deck>/figures/ for
|
|
12
|
+
- stores figures and audio attached to chat messages in <deck>/figures/ for the assistant to use, and attached
|
|
13
13
|
PDFs (papers, reports, figures) in ~/.pindeck, outside the deck
|
|
14
14
|
- imports slides from PowerPoint files in the deck's own layouts
|
|
15
15
|
Listens on 127.0.0.1 only; every API call needs the per-run token.
|
|
@@ -25,6 +25,9 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
26
26
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
27
27
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
28
|
+
import { CodexSession } from "./lib/codex.mjs";
|
|
29
|
+
import { createDeckTools } from "./lib/agent-tools.mjs";
|
|
30
|
+
import { migrateSettings, selectModel, handoverSummary, curateModels } from "./lib/models.mjs";
|
|
28
31
|
import { formatLayout, formatPin } from "./lib/layout.mjs";
|
|
29
32
|
import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs";
|
|
30
33
|
import { readPptx } from "./lib/pptx.mjs";
|
|
@@ -32,7 +35,7 @@ import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./
|
|
|
32
35
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
33
36
|
|
|
34
37
|
/* ---- Config ------------------------------------------------------------------ */
|
|
35
|
-
const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
|
|
38
|
+
const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--provider claude|codex] [--model <name>]
|
|
36
39
|
|
|
37
40
|
Opens the reveal.js deck in deck-folder (default: the current folder) in PinDeck.
|
|
38
41
|
The folder must contain index.html.`;
|
|
@@ -41,12 +44,19 @@ const argv = process.argv.slice(2);
|
|
|
41
44
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
42
45
|
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
43
46
|
const PORT = +opt("--port", process.env.PORT || 5180);
|
|
44
|
-
|
|
45
|
-
const
|
|
47
|
+
if (process.platform !== "darwin") {
|
|
48
|
+
const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
|
|
49
|
+
await showPlatformNotice(PORT);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
const PROVIDER = opt("--provider", "");
|
|
53
|
+
const MODEL = opt("--model", "");
|
|
54
|
+
const deckPath = path.resolve(argv[0] || process.cwd());
|
|
55
|
+
const DECK_DIR = fs.existsSync(deckPath) ? fs.realpathSync(deckPath) : deckPath;
|
|
46
56
|
const DECK_FILE = "index.html";
|
|
47
|
-
const EDITABLE = ["index.html", "theme.css"]; //
|
|
57
|
+
const EDITABLE = ["index.html", "theme.css"]; // Both backends may edit these
|
|
48
58
|
const LOCKED = ["brand.css"]; // never, enforced twice
|
|
49
|
-
const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
|
|
59
|
+
const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
|
|
50
60
|
const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
|
|
51
61
|
crypto.createHash("sha1").update(DECK_DIR).digest("hex").slice(0, 8);
|
|
52
62
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
@@ -62,6 +72,7 @@ if (!fs.existsSync(path.join(DECK_DIR, DECK_FILE))) {
|
|
|
62
72
|
process.exit(1);
|
|
63
73
|
}
|
|
64
74
|
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
|
75
|
+
fs.mkdirSync(PDF_DIR, { recursive: true });
|
|
65
76
|
const serverInfo = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
|
|
66
77
|
fs.writeFileSync(path.join(STATE_DIR, "server.json"), serverInfo);
|
|
67
78
|
fs.writeFileSync(path.join(HOME_DIR, "server.json"), serverInfo); // the CLI's fallback: most recent server
|
|
@@ -250,20 +261,21 @@ function exportPdf(kind, includeHidden = false) {
|
|
|
250
261
|
return job;
|
|
251
262
|
}
|
|
252
263
|
|
|
253
|
-
/* ---- Chat state and the
|
|
264
|
+
/* ---- Chat state and the selected backend session --------------------------------------------- */
|
|
254
265
|
const CHAT_FILE = path.join(STATE_DIR, "chat.json");
|
|
255
266
|
const state = {
|
|
256
|
-
chat: [], seq: 0, busy: false, model: MODEL || null, sessionId: null,
|
|
267
|
+
chat: [], seq: 0, busy: false, model: MODEL || null, switching: false, preparing: null, sessionId: null, provider: "claude", handover: "", sessionArchive: [],
|
|
257
268
|
turns: [], redo: [], turn: null, notes: [], pins: [], current: null,
|
|
258
269
|
uploads: new Set(), // files uploaded for the next message; removable until it is sent
|
|
259
270
|
pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
|
|
260
271
|
};
|
|
261
272
|
try {
|
|
262
273
|
const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
|
|
263
|
-
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
|
|
274
|
+
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude", handover: saved.handover || "", sessionArchive: saved.sessionArchive || [] });
|
|
264
275
|
} catch {}
|
|
265
|
-
const saveChat = () => fs.
|
|
266
|
-
JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId
|
|
276
|
+
const saveChat = () => fs.writeFileSync(CHAT_FILE,
|
|
277
|
+
JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
|
|
278
|
+
provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50) }));
|
|
267
279
|
function addEntry(entry) {
|
|
268
280
|
entry.id = ++state.seq;
|
|
269
281
|
entry.time = Date.now();
|
|
@@ -273,28 +285,30 @@ function addEntry(entry) {
|
|
|
273
285
|
return entry;
|
|
274
286
|
}
|
|
275
287
|
const liveTurns = () => state.turns.filter((t) => !t.undone);
|
|
276
|
-
const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "
|
|
277
|
-
/*
|
|
278
|
-
|
|
279
|
-
so the file always shows what PinDeck uses. --model on the command line applies to one run only. */
|
|
280
|
-
const MODELS = ["opus", "sonnet"];
|
|
281
|
-
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
282
|
-
const DEFAULTS = { model: "sonnet", effort: "medium" };
|
|
288
|
+
const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") : null);
|
|
289
|
+
/* The menu persists provider, model and effort. CLI overrides apply to this run only.
|
|
290
|
+
The v3 preview uses its own state directory so v2 can keep running independently. */
|
|
283
291
|
const CONFIG_FILE = path.join(HOME_DIR, "config.json");
|
|
284
|
-
const settings = { ...DEFAULTS };
|
|
285
292
|
let fileConfig = {};
|
|
286
293
|
try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
|
|
287
|
-
catch (e) { if (e.code !== "ENOENT") console.warn(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
294
|
+
catch (e) { if (e.code !== "ENOENT") console.warn("Invalid PinDeck configuration; using defaults."); }
|
|
295
|
+
const settings = migrateSettings(fileConfig);
|
|
296
|
+
let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
|
|
297
|
+
// Preserve the legacy selection as the first model-specific preference.
|
|
298
|
+
if (settings.effort) effortPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
|
|
299
|
+
if (PROVIDER) {
|
|
300
|
+
if (!["claude", "codex"].includes(PROVIDER)) throw new Error("Use --provider claude or codex.");
|
|
301
|
+
settings.provider = PROVIDER;
|
|
302
|
+
if (!MODEL && PROVIDER !== fileConfig.provider) settings.model = PROVIDER === "claude" ? "sonnet" : "";
|
|
303
|
+
}
|
|
304
|
+
if (MODEL) settings.model = MODEL;
|
|
305
|
+
// A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
|
|
306
|
+
if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
|
|
307
|
+
state.provider = settings.provider;
|
|
308
|
+
let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
|
|
309
|
+
let session = null, starting = null;
|
|
310
|
+
const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
311
|
+
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE, chrome: !!CHROME,
|
|
298
312
|
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
299
313
|
const pushStatus = () => broadcast("status", status());
|
|
300
314
|
|
|
@@ -308,32 +322,106 @@ function deckInstructions() {
|
|
|
308
322
|
try { return `\n\n## The deck's CLAUDE.md\n${fs.readFileSync(path.join(DECK_DIR, "CLAUDE.md"), "utf8").trim()}\n`; }
|
|
309
323
|
catch { return ""; }
|
|
310
324
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
325
|
+
const agentTools = createDeckTools({ deckDir: DECK_DIR, readDirs: [SHOT_DIR, PDF_DIR],
|
|
326
|
+
cliFile: path.join(EDITOR_DIR, "cli.mjs"), pdfTextFile: path.join(EDITOR_DIR, "lib/pdf-text.jxa.js"),
|
|
327
|
+
env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN } });
|
|
328
|
+
const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?## Hand edits/, `## Tools and attachments
|
|
329
|
+
Figures and audio are already saved under figures/. Keep their paths and use the deck's existing figure/audio patterns. Do not rename or move attachments.
|
|
330
|
+
Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
|
|
331
|
+
Use pindeck_edit for exact replacements in index.html and theme.css. Never change brand.css.
|
|
332
|
+
Use pindeck_command with separate command and args for layout, shot, slides, pdf or pdf-figure.
|
|
333
|
+
Do not use shell commands, native file edits, or other tools to bypass these restrictions.
|
|
334
|
+
When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
|
|
335
|
+
Read README.md and the deck instructions below for conventions. Use layout after every visual change.
|
|
336
|
+
PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
|
|
337
|
+
|
|
338
|
+
## Hand edits`) + deckInstructions();
|
|
339
|
+
function makeSession(choice) {
|
|
340
|
+
let initial = null;
|
|
341
|
+
const options = {
|
|
342
|
+
cwd: DECK_DIR,
|
|
343
|
+
env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
|
|
344
|
+
onEvent: ev => {
|
|
345
|
+
if (ev.type === "system" && ev.subtype === "init") initial = ev;
|
|
346
|
+
if (session === agent) handleEvent(ev);
|
|
347
|
+
},
|
|
348
|
+
onExit: error => {
|
|
349
|
+
if (session !== agent) return;
|
|
350
|
+
if (state.turn) finishTurn({ error: error.message });
|
|
351
|
+
pushStatus();
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
|
|
355
|
+
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
356
|
+
args: ["--append-system-prompt", systemPrompt + deckInstructions(),
|
|
357
|
+
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands",
|
|
358
|
+
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Bash",
|
|
359
|
+
"--allowedTools", "Read", "Glob", "Grep", ...EDITABLE.map(f => `Edit(./${f})`), ...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
360
|
+
"--disallowedTools", ...LOCKED.flatMap(f => [`Edit(./${f})`, `Write(./${f})`]),
|
|
361
|
+
...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
|
|
362
|
+
});
|
|
363
|
+
agent.initialEvent = () => initial;
|
|
364
|
+
return agent;
|
|
365
|
+
}
|
|
366
|
+
async function discoverModels() {
|
|
367
|
+
if (discovering) return discovering;
|
|
368
|
+
discovering = Promise.all(["claude", "codex"].map(async provider => {
|
|
369
|
+
const agent = makeSession({ provider, model: "", effort: null });
|
|
370
|
+
try {
|
|
371
|
+
if (provider === "codex") await agent.connect(); else await agent.start();
|
|
372
|
+
catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
|
|
373
|
+
} catch (e) { catalog[provider] = { models: [], error: e.message }; }
|
|
374
|
+
finally { agent.stop(); }
|
|
375
|
+
})).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
|
|
376
|
+
return discovering;
|
|
377
|
+
}
|
|
378
|
+
async function ensureSession() {
|
|
379
|
+
if (starting) return starting;
|
|
380
|
+
if (session?.running) return;
|
|
381
|
+
starting = (async () => {
|
|
382
|
+
if (!catalog[settings.provider].models.length) await discoverModels();
|
|
383
|
+
Object.assign(settings, selectModel(catalog, settings, {}));
|
|
384
|
+
const agent = makeSession({ ...settings });
|
|
385
|
+
session = agent;
|
|
386
|
+
try { await agent.start(state.sessionId); if (session === agent) pushStatus(); }
|
|
387
|
+
catch (e) { agent.stop(); throw e; }
|
|
388
|
+
})().finally(() => { starting = null; });
|
|
389
|
+
return starting;
|
|
390
|
+
}
|
|
391
|
+
async function changeSettings(requested) {
|
|
392
|
+
if (state.busy || starting) throw new Error("Wait until the current request finishes before switching.");
|
|
393
|
+
const choice = selectModel(catalog, settings, requested, effortPreferences);
|
|
394
|
+
if (Object.keys(choice).every(k => choice[k] === settings[k])) return;
|
|
395
|
+
const changingProvider = choice.provider !== settings.provider;
|
|
396
|
+
if (changingProvider && !["summary", "fresh"].includes(requested.handover)) throw new Error("Choose whether to pass a summary or start fresh.");
|
|
397
|
+
const old = session;
|
|
398
|
+
// Codex owns a single writer per thread. Model/effort are turn parameters, so keep its writer.
|
|
399
|
+
const reuse = !changingProvider && choice.provider === "codex" && old?.running;
|
|
400
|
+
const candidate = reuse ? old : makeSession(choice);
|
|
401
|
+
state.busy = true; state.switching = true; pushStatus();
|
|
402
|
+
try {
|
|
403
|
+
if (!reuse) await candidate.start(changingProvider ? null : state.sessionId);
|
|
404
|
+
if (!candidate.running) throw new Error("The new backend stopped before it was ready.");
|
|
405
|
+
const summary = changingProvider && requested.handover === "summary" ? handoverSummary(state.chat) : "";
|
|
406
|
+
if (changingProvider && state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
|
|
407
|
+
const nextPreferences = { ...effortPreferences };
|
|
408
|
+
if (settings.effort) nextPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
|
|
409
|
+
if (choice.effort) nextPreferences[`${choice.provider}:${choice.model}`] = choice.effort;
|
|
410
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...fileConfig, ...choice, effortPreferences: nextPreferences }, null, 2) + "\n");
|
|
411
|
+
effortPreferences = nextPreferences;
|
|
412
|
+
Object.assign(settings, choice);
|
|
413
|
+
state.provider = choice.provider;
|
|
414
|
+
if (changingProvider) { state.sessionId = candidate.sessionId || null; state.handover = summary; state.model = null; }
|
|
415
|
+
if (reuse) candidate.settings = choice;
|
|
416
|
+
session = candidate;
|
|
417
|
+
if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
|
|
418
|
+
if (!reuse) old?.stop();
|
|
419
|
+
saveChat();
|
|
420
|
+
addEntry({ kind: "notice", text: changingProvider
|
|
421
|
+
? `Switched to ${choice.provider === "codex" ? "OpenAI / ChatGPT" : "Claude"} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
|
|
422
|
+
: `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
|
|
423
|
+
} catch (e) { if (session !== candidate) candidate.stop(); throw e; }
|
|
424
|
+
finally { state.busy = false; state.switching = false; pushStatus(); }
|
|
337
425
|
}
|
|
338
426
|
|
|
339
427
|
function toolSummary(name, input = {}) {
|
|
@@ -344,6 +432,9 @@ function toolSummary(name, input = {}) {
|
|
|
344
432
|
return !r || r.startsWith("..") ? path.basename(p) : r;
|
|
345
433
|
};
|
|
346
434
|
switch (name) {
|
|
435
|
+
case "pindeck_read": return { name: "Read", detail: base(input.path) };
|
|
436
|
+
case "pindeck_edit": return { name: "Edit", detail: base(input.path) };
|
|
437
|
+
case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
|
|
347
438
|
case "Read": return { name: "Read", detail: base(input.file_path) };
|
|
348
439
|
case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
|
|
349
440
|
case "Bash": {
|
|
@@ -392,8 +483,14 @@ function startTurn(promptText, userEntry) {
|
|
|
392
483
|
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
|
|
393
484
|
state.busy = true;
|
|
394
485
|
pushStatus();
|
|
395
|
-
|
|
396
|
-
|
|
486
|
+
const turn = state.turn;
|
|
487
|
+
const context = state.handover ? state.handover + "\n\n" + promptText : promptText;
|
|
488
|
+
ensureSession().then(async () => {
|
|
489
|
+
if (state.turn !== turn || turn.cancelled) return;
|
|
490
|
+
const sendingSession = session;
|
|
491
|
+
await session.send([{ type: "text", text: context }]);
|
|
492
|
+
if (session === sendingSession) { state.handover = ""; saveChat(); }
|
|
493
|
+
}).catch(e => { if (state.turn === turn) finishTurn({ error: e.message }); });
|
|
397
494
|
}
|
|
398
495
|
|
|
399
496
|
function finishTurn({ error, cost, duration, denials = [] }) {
|
|
@@ -407,7 +504,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
407
504
|
const now = readDeck(f);
|
|
408
505
|
if (now === turn.before[f]) continue;
|
|
409
506
|
if (turn.before[f] != null) writeDeck(f, turn.before[f]);
|
|
410
|
-
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that
|
|
507
|
+
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that the assistant created
|
|
411
508
|
restored.push(f);
|
|
412
509
|
}
|
|
413
510
|
toolEntries.clear();
|
|
@@ -417,7 +514,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
417
514
|
const d = unifiedDiff(turn.before[f] ?? "", after[f] ?? "", f);
|
|
418
515
|
return { file: f, added: d.added, removed: d.removed };
|
|
419
516
|
}).filter((f) => f.added || f.removed);
|
|
420
|
-
// after a hand-off: imported pictures that
|
|
517
|
+
// after a hand-off: imported pictures that the assistant replaced (nothing refers to them any more) are
|
|
421
518
|
// deleted; undo brings them back with the turn
|
|
422
519
|
const deletedFiles = [];
|
|
423
520
|
for (const rel of turn.importedFiles || []) {
|
|
@@ -426,7 +523,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
426
523
|
deletedFiles.push({ rel, data: fs.readFileSync(abs) });
|
|
427
524
|
fs.rmSync(abs, { force: true });
|
|
428
525
|
}
|
|
429
|
-
const t = { id: turn.id, kind: "
|
|
526
|
+
const t = { id: turn.id, kind: "agent", provider: settings.provider, before: turn.before, after, files, deletedFiles, undone: false };
|
|
430
527
|
if (files.length) { state.turns.push(t); state.redo = []; }
|
|
431
528
|
addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
|
|
432
529
|
denials, restored, newIds: eid.assigned.length });
|
|
@@ -436,7 +533,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
436
533
|
|
|
437
534
|
/* ---- Attachments: figures, audio and PDFs added to a chat message ------------------------------------
|
|
438
535
|
Figures and audio go into <deck>/figures/ (paths relative to the deck). PDFs are reading material for
|
|
439
|
-
|
|
536
|
+
the assistant (a paper, a report, a figure or table to take over) and stay outside the deck, in PDF_DIR
|
|
440
537
|
(absolute paths); `cli.mjs pdf-figure` renders a page or part of one into figures/ when it is needed. */
|
|
441
538
|
const ATTACH_TYPES = {
|
|
442
539
|
image: ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif"],
|
|
@@ -495,8 +592,8 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
495
592
|
|
|
496
593
|
/* `cli.mjs pdf-figure <pdf> <page> [x,y,w,h] [figures/name.png] [--width px]`: renders a PDF page, or a box
|
|
497
594
|
of it given as fractions of the page from the top left, to PNG. Without a figures/ name it goes to the
|
|
498
|
-
screenshots folder, for
|
|
499
|
-
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
|
|
595
|
+
screenshots folder, for the assistant to look at before choosing; with one, into the deck. */
|
|
596
|
+
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
|
|
500
597
|
const bad = (t, status = 400) => ({ status, text: t });
|
|
501
598
|
if (process.platform !== "darwin") return bad("Rendering PDF pages needs macOS (PDFKit).", 501);
|
|
502
599
|
const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
|
|
@@ -518,6 +615,7 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
518
615
|
await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
|
|
519
616
|
if (!fs.existsSync(tmp)) return bad("PDFKit did not write the picture.", 500);
|
|
520
617
|
const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
|
|
618
|
+
if (cancelled()) return bad("The request was cancelled before the figure could be saved.", 409);
|
|
521
619
|
if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
|
|
522
620
|
const buf = fs.readFileSync(tmp);
|
|
523
621
|
fs.rmSync(tmp, { force: true });
|
|
@@ -566,7 +664,7 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
|
|
|
566
664
|
files: [{ file: DECK_FILE, added: d.added, removed: d.removed }], undone: false };
|
|
567
665
|
state.turns.push(t);
|
|
568
666
|
state.redo = [];
|
|
569
|
-
// chat: false keeps the edit out of the chat (it stays in the undo history and
|
|
667
|
+
// chat: false keeps the edit out of the chat (it stays in the undo history and the assistant is still told)
|
|
570
668
|
if (chat) addEntry({ kind: "edit", turnId: t.id, label, files: t.files });
|
|
571
669
|
else state.seq++; // keep turn ids unique: they share the counter with chat entries
|
|
572
670
|
state.notes.push(note);
|
|
@@ -592,9 +690,9 @@ function deletePlan(eid) {
|
|
|
592
690
|
return { ...target, html: rest, files };
|
|
593
691
|
}
|
|
594
692
|
|
|
595
|
-
/* ---- Undo (
|
|
693
|
+
/* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------- */
|
|
596
694
|
function undo(res) {
|
|
597
|
-
if (state.busy) return json(res, 409, { error: "Wait until
|
|
695
|
+
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
598
696
|
const t = liveTurns().at(-1);
|
|
599
697
|
if (!t) return json(res, 409, { error: "Nothing to undo." });
|
|
600
698
|
const edited = EDITABLE.some((f) => readDeck(f) !== t.after[f]);
|
|
@@ -697,7 +795,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
|
|
|
697
795
|
missing: r.plans.flatMap((p) => p.figures.filter((f) => f.missing)).length };
|
|
698
796
|
}
|
|
699
797
|
|
|
700
|
-
/* The hand-off:
|
|
798
|
+
/* The hand-off: the assistant gets the slides the import could only approximate, each with a picture of the
|
|
701
799
|
original slide, and improves them in the deck's style. Returns the number of slides handed over. */
|
|
702
800
|
async function refineImport(imp, pdf, plans, eids, first) {
|
|
703
801
|
const todo = plans.map((p, i) => ({ p, index: first + i, eid: eids[i] })).filter((x) => x.p.complex.length);
|
|
@@ -808,8 +906,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
808
906
|
}
|
|
809
907
|
|
|
810
908
|
if (p === "/api/message" && req.method === "POST") {
|
|
811
|
-
if (state.busy) return json(res, 409, { error: "
|
|
909
|
+
if (state.busy) return json(res, 409, { error: "The assistant is still working." });
|
|
812
910
|
const b = await body(req);
|
|
911
|
+
if (state.busy) return json(res, 409, { error: "The assistant is still working." });
|
|
813
912
|
if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
|
|
814
913
|
// what the prompt reads from pins and the selection must be there
|
|
815
914
|
const placed = (x) => x && typeof x.eid === "string" && x.slide && Number.isInteger(x.slide.index) && x.box;
|
|
@@ -817,22 +916,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
817
916
|
if (!placed(b.selection)) b.selection = null;
|
|
818
917
|
if (!Array.isArray(b.layouts)) b.layouts = [];
|
|
819
918
|
if (b.current && !Number.isInteger(b.current.index)) b.current = null;
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
919
|
+
const preparation = { cancelled: false };
|
|
920
|
+
state.preparing = preparation;
|
|
921
|
+
state.busy = true; pushStatus();
|
|
922
|
+
try {
|
|
923
|
+
let screenshot = null;
|
|
924
|
+
const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
|
|
925
|
+
if (shotSlide) {
|
|
926
|
+
try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
|
|
927
|
+
catch (e) { addEntry({ kind: "notice", text: "Screenshot failed: " + e.message }); }
|
|
928
|
+
}
|
|
929
|
+
if (preparation.cancelled) return json(res, 409, { error: "Stopped before sending the message." });
|
|
930
|
+
const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
|
|
931
|
+
// only files PinDeck itself stored can be attached
|
|
932
|
+
b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
|
|
933
|
+
b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
|
|
934
|
+
const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
|
|
935
|
+
attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
|
|
936
|
+
selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
|
|
937
|
+
pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
|
|
938
|
+
startTurn(buildPrompt({ ...b, screenshot }), entry);
|
|
939
|
+
return json(res, 200, { id: entry.id });
|
|
940
|
+
} finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
|
|
836
941
|
}
|
|
837
942
|
if (p === "/api/upload" && req.method === "POST") {
|
|
838
943
|
const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
|
|
@@ -872,22 +977,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
872
977
|
}
|
|
873
978
|
return json(res, 200, {});
|
|
874
979
|
}
|
|
980
|
+
if (p === "/api/models" && req.method === "GET") return json(res, 200, await discoverModels());
|
|
875
981
|
if (p === "/api/settings" && req.method === "POST") {
|
|
876
982
|
const b = await body(req);
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
if (state.busy) return json(res, 409, { error: "Wait until Claude has finished, then change the model or effort." });
|
|
881
|
-
Object.assign(settings, { model, effort });
|
|
882
|
-
saveSettings();
|
|
883
|
-
// restart the session with the new flags; --resume keeps the conversation
|
|
884
|
-
if (session.running) { session.stop(); setTimeout(ensureSession, 300); }
|
|
885
|
-
addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
|
|
886
|
-
pushStatus();
|
|
887
|
-
return json(res, 200, { settings });
|
|
983
|
+
if (state.busy || starting) return json(res, 409, { error: "Wait until the current request finishes before switching." });
|
|
984
|
+
try { await changeSettings(b); return json(res, 200, { settings }); }
|
|
985
|
+
catch (e) { return json(res, 400, { error: e.message }); }
|
|
888
986
|
}
|
|
889
987
|
if (p === "/api/slide-hidden" && req.method === "POST") {
|
|
890
|
-
if (state.busy) return json(res, 409, { error: "
|
|
988
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
891
989
|
const b = await body(req);
|
|
892
990
|
const before = snapshot(EDITABLE);
|
|
893
991
|
const slides = findSlides(parse(before[DECK_FILE]));
|
|
@@ -902,7 +1000,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
902
1000
|
return json(res, 200, { turnId, index });
|
|
903
1001
|
}
|
|
904
1002
|
if (p === "/api/move-slide" && req.method === "POST") {
|
|
905
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1003
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
906
1004
|
const b = await body(req);
|
|
907
1005
|
const before = snapshot(EDITABLE);
|
|
908
1006
|
const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
|
|
@@ -914,7 +1012,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
914
1012
|
return json(res, 200, { turnId, index: r.index });
|
|
915
1013
|
}
|
|
916
1014
|
if (p === "/api/add-slide" && req.method === "POST") {
|
|
917
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1015
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
918
1016
|
const b = await body(req);
|
|
919
1017
|
if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
|
|
920
1018
|
const before = snapshot(EDITABLE);
|
|
@@ -942,7 +1040,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
942
1040
|
let imp;
|
|
943
1041
|
try { imp = startImport(name, Buffer.concat(chunks)); }
|
|
944
1042
|
catch (e) { return json(res, 400, { error: `${name} could not be read: ${e.message}` }); }
|
|
945
|
-
// what the import can only approximate, so the dialog can offer
|
|
1043
|
+
// what the import can only approximate, so the dialog can offer the assistant's help with it
|
|
946
1044
|
const complex = new Map(planSlides(imp.model, imp.model.slides.map((s) => s.n), { drawings: renderAvailable() }).map((p) => [p.n, p.complex]));
|
|
947
1045
|
return json(res, 200, { id: imp.id, name, render: renderAvailable(),
|
|
948
1046
|
slides: imp.model.slides.map((s) => ({ n: s.n, title: s.title, subtitle: s.subtitle, hidden: s.hidden, kind: s.kind, complex: complex.get(s.n) || [] })) });
|
|
@@ -953,7 +1051,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
953
1051
|
return serveFile(res, path.join(imp.dir, `thumb-${n}.png`));
|
|
954
1052
|
}
|
|
955
1053
|
if (p === "/api/import-apply" && req.method === "POST") {
|
|
956
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1054
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
957
1055
|
const b = await body(req);
|
|
958
1056
|
const imp = lastImport;
|
|
959
1057
|
if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
|
|
@@ -963,21 +1061,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
963
1061
|
catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
|
|
964
1062
|
}
|
|
965
1063
|
if (p === "/api/stop" && req.method === "POST") {
|
|
966
|
-
if (state.
|
|
1064
|
+
if (state.preparing) { state.preparing.cancelled = true; return json(res, 200, {}); }
|
|
1065
|
+
if (state.busy && state.turn) {
|
|
967
1066
|
const interruptedTurn = state.turn;
|
|
968
|
-
|
|
1067
|
+
interruptedTurn.cancelled = true;
|
|
1068
|
+
if (starting || !session?.running) { session?.stop(); finishTurn({ error: "Stopped." }); return json(res, 200, {}); }
|
|
1069
|
+
Promise.resolve(session.interrupt()).catch(() => {});
|
|
969
1070
|
setTimeout(() => {
|
|
970
|
-
if (state.busy && state.turn === interruptedTurn) session.stop();
|
|
1071
|
+
if (state.busy && state.turn === interruptedTurn) { session.stop(); finishTurn({ error: "Stopped." }); }
|
|
971
1072
|
}, 5000); // force only if the interrupted turn does not answer
|
|
972
1073
|
}
|
|
973
1074
|
return json(res, 200, {});
|
|
974
1075
|
}
|
|
975
1076
|
if (p === "/api/new-chat" && req.method === "POST") {
|
|
976
|
-
|
|
977
|
-
|
|
1077
|
+
if (state.busy || starting) return json(res, 409, { error: "Stop the current request before starting a new chat." });
|
|
1078
|
+
session?.stop();
|
|
1079
|
+
Object.assign(state, { chat: [], sessionId: null, handover: "", turns: [], redo: [], turn: null, busy: false, notes: [] });
|
|
978
1080
|
saveChat();
|
|
979
1081
|
broadcast("reset", { chat: [] });
|
|
980
|
-
ensureSession();
|
|
1082
|
+
ensureSession().catch(e => addEntry({ kind: "notice", text: e.message }));
|
|
981
1083
|
pushStatus();
|
|
982
1084
|
return json(res, 200, {});
|
|
983
1085
|
}
|
|
@@ -994,7 +1096,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
994
1096
|
}
|
|
995
1097
|
if (p === "/api/undo" && req.method === "POST") return undo(res);
|
|
996
1098
|
if (p === "/api/redo" && req.method === "POST") {
|
|
997
|
-
if (state.busy) return json(res, 409, { error: "Wait until
|
|
1099
|
+
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
998
1100
|
const t = state.redo.at(-1);
|
|
999
1101
|
if (!t) return json(res, 409, { error: "Nothing to redo." });
|
|
1000
1102
|
if (EDITABLE.some((f) => (readDeck(f) ?? null) !== (t.before[f] ?? null))) {
|
|
@@ -1024,7 +1126,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1024
1126
|
return json(res, 200, { eid: plan.eid, tag: plan.tag, files: plan.files });
|
|
1025
1127
|
}
|
|
1026
1128
|
if (p === "/api/delete" && req.method === "POST") {
|
|
1027
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1129
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1028
1130
|
const b = await body(req);
|
|
1029
1131
|
const plan = deletePlan(b.eid);
|
|
1030
1132
|
if (!plan) return json(res, 404, { error: "Element not found in the file." });
|
|
@@ -1043,7 +1145,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1043
1145
|
|
|
1044
1146
|
// Hand edits from the editor: drag/resize (inline styles) and text
|
|
1045
1147
|
if (p === "/api/edit" && req.method === "POST") {
|
|
1046
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1148
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1047
1149
|
const b = await body(req);
|
|
1048
1150
|
const before = snapshot(EDITABLE);
|
|
1049
1151
|
const r = setStyles(before[DECK_FILE], b.ops || []);
|
|
@@ -1064,7 +1166,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1064
1166
|
return n ? json(res, 200, n) : json(res, 404, { error: "Slide not found in the file." });
|
|
1065
1167
|
}
|
|
1066
1168
|
if (p === "/api/notes" && req.method === "POST") {
|
|
1067
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1169
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1068
1170
|
const b = await body(req);
|
|
1069
1171
|
const before = snapshot(EDITABLE);
|
|
1070
1172
|
const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
|
|
@@ -1078,7 +1180,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1078
1180
|
return json(res, 200, { turnId });
|
|
1079
1181
|
}
|
|
1080
1182
|
if (p === "/api/text" && req.method === "POST") {
|
|
1081
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1183
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1082
1184
|
const b = await body(req);
|
|
1083
1185
|
const before = snapshot(EDITABLE);
|
|
1084
1186
|
const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
|
|
@@ -1093,7 +1195,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1093
1195
|
|
|
1094
1196
|
// Enter in a bullet: the bullet keeps the text before the caret, a new bullet after it gets the rest
|
|
1095
1197
|
if (p === "/api/split" && req.method === "POST") {
|
|
1096
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1198
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1097
1199
|
const b = await body(req);
|
|
1098
1200
|
const before = snapshot(EDITABLE);
|
|
1099
1201
|
const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
|
|
@@ -1105,7 +1207,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1105
1207
|
return json(res, 200, { turnId, eid: r2.eid });
|
|
1106
1208
|
}
|
|
1107
1209
|
if (p === "/api/remove" && req.method === "POST") {
|
|
1108
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1210
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1109
1211
|
const b = await body(req);
|
|
1110
1212
|
const before = snapshot(EDITABLE);
|
|
1111
1213
|
const html = removeElement(before[DECK_FILE], b.eid);
|
|
@@ -1121,7 +1223,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1121
1223
|
catch (e) { return json(res, 500, { error: String(e.message || e) }); }
|
|
1122
1224
|
}
|
|
1123
1225
|
|
|
1124
|
-
// CLI endpoints (used by
|
|
1226
|
+
// CLI endpoints (used by both backends via cli.mjs)
|
|
1125
1227
|
if (p === "/api/pdf") {
|
|
1126
1228
|
const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
|
|
1127
1229
|
if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
|
|
@@ -1129,7 +1231,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1129
1231
|
return text(res, 200, r.path);
|
|
1130
1232
|
}
|
|
1131
1233
|
if (p === "/api/pdf-figure") {
|
|
1132
|
-
const
|
|
1234
|
+
const turn = state.turn;
|
|
1235
|
+
const r = await pdfFigure({ ...Object.fromEntries(url.searchParams), cancelled: () => !!turn && (turn.cancelled || state.turn !== turn) });
|
|
1133
1236
|
return text(res, r.status || 200, r.text);
|
|
1134
1237
|
}
|
|
1135
1238
|
if (p === "/api/slides") {
|
|
@@ -1159,8 +1262,15 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1159
1262
|
console.log(`PinDeck: ${ORIGIN}/`);
|
|
1160
1263
|
console.log(`Deck: ${DECK_DIR}`);
|
|
1161
1264
|
if (!CHROME) console.log("No Chrome found: screenshots are disabled (set CHROME_BIN).");
|
|
1162
|
-
|
|
1265
|
+
discoverModels().then(() => {
|
|
1266
|
+
if (!settings.model) {
|
|
1267
|
+
const models = catalog[settings.provider].models;
|
|
1268
|
+
const first = models.find(m => m.isDefault) || models[0];
|
|
1269
|
+
if (first) { settings.model = first.id; settings.effort = first.defaultEffort; }
|
|
1270
|
+
}
|
|
1271
|
+
pushStatus();
|
|
1272
|
+
});
|
|
1163
1273
|
});
|
|
1164
|
-
const quit = () => { session
|
|
1274
|
+
const quit = () => { session?.stop(); setTimeout(() => process.exit(0), 300); };
|
|
1165
1275
|
process.on("SIGINT", quit);
|
|
1166
1276
|
process.on("SIGTERM", quit);
|