pindeck 2.9.0 → 3.0.0-beta.2
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 +262 -81
- package/cli.mjs +6 -5
- package/lib/agent-process.mjs +53 -0
- package/lib/agent-tools.mjs +108 -0
- package/lib/capabilities.mjs +17 -8
- package/lib/claude.mjs +38 -71
- package/lib/codex.mjs +106 -0
- package/lib/deck-files.mjs +38 -0
- package/lib/frameworks.mjs +41 -0
- package/lib/instructions.mjs +74 -0
- package/lib/models.mjs +67 -0
- package/lib/pdf-text.jxa.js +9 -0
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -4
- package/public/editor.css +15 -0
- package/public/editor.js +111 -50
- package/public/index.html +29 -12
- package/server.mjs +386 -177
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>]
|
|
4
|
+
pindeck [deck-dir | deck.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <dir>] (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; the editor, the deck and every API call need the per-run token (a login
|
|
@@ -27,27 +27,37 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
27
27
|
import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
28
28
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
29
29
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
30
|
+
import { CodexSession } from "./lib/codex.mjs";
|
|
31
|
+
import { createDeckTools } from "./lib/agent-tools.mjs";
|
|
32
|
+
import { migrateSettings, selectModel, handoverSummary, curateModels } from "./lib/models.mjs";
|
|
30
33
|
import { formatLayout, formatPin } from "./lib/layout.mjs";
|
|
31
34
|
import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs";
|
|
32
35
|
import { readPptx } from "./lib/pptx.mjs";
|
|
33
36
|
import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
|
|
34
37
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
35
38
|
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
|
|
36
|
-
import {
|
|
39
|
+
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
40
|
+
import { readInstructions } from "./lib/instructions.mjs";
|
|
41
|
+
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
37
42
|
|
|
38
43
|
/* ---- Config ------------------------------------------------------------------ */
|
|
39
|
-
const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
|
|
40
|
-
pindeck login [deck-folder]
|
|
44
|
+
const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
|
|
45
|
+
pindeck login [deck-folder | deck-file.html]
|
|
41
46
|
|
|
42
|
-
Opens
|
|
43
|
-
|
|
47
|
+
Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
|
|
48
|
+
folder), which then opens its index.html.
|
|
44
49
|
login prints a new one-time login link for the PinDeck running for that deck
|
|
45
|
-
(without a
|
|
50
|
+
(without a deck: for the PinDeck started last).
|
|
51
|
+
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
52
|
+
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
53
|
+
"assetRoots" in ~/.pindeck-v3/config.json.`;
|
|
46
54
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
47
55
|
const argv = process.argv.slice(2);
|
|
48
56
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
49
57
|
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
50
58
|
const portArg = opt("--port", process.env.PORT || 5180);
|
|
59
|
+
const assetArgs = [];
|
|
60
|
+
for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
|
|
51
61
|
let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
|
|
52
62
|
if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
|
|
53
63
|
if (!isSupportedPlatform(process.platform)) {
|
|
@@ -57,14 +67,23 @@ if (!isSupportedPlatform(process.platform)) {
|
|
|
57
67
|
}
|
|
58
68
|
const LOGIN = argv[0] === "login";
|
|
59
69
|
if (LOGIN) argv.shift();
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
const PROVIDER = opt("--provider", "");
|
|
71
|
+
const MODEL = opt("--model", "");
|
|
72
|
+
/* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
|
|
73
|
+
that is not a folder is a file, so a missing file gets a clear message. */
|
|
74
|
+
const TARGET = path.resolve(argv[0] || process.cwd());
|
|
75
|
+
const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
|
|
76
|
+
const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
|
|
77
|
+
const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
|
|
78
|
+
const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
|
|
79
|
+
const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
|
|
80
|
+
const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
|
|
64
81
|
const LOCKED = ["brand.css"]; // never, enforced twice
|
|
65
|
-
const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
|
|
83
|
+
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
84
|
+
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
85
|
+
const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
86
|
+
crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
|
|
68
87
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
69
88
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
70
89
|
const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
|
|
@@ -107,6 +126,12 @@ if (deckProblem) {
|
|
|
107
126
|
console.error(`${deckProblem}\n\n${USAGE}`);
|
|
108
127
|
process.exit(1);
|
|
109
128
|
}
|
|
129
|
+
// only reveal.js for now: another framework's deck is left exactly as it is (no ids, no state)
|
|
130
|
+
const frameworkProblem = unsupportedMessage(fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8"), DECK_FILE);
|
|
131
|
+
if (frameworkProblem) {
|
|
132
|
+
console.error(frameworkProblem);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
110
135
|
/* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
|
|
111
136
|
port is bound (see Start), so a failed start leaves no state behind. */
|
|
112
137
|
const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
|
|
@@ -147,6 +172,25 @@ function writeDeckFile(rel, data, manual = true) {
|
|
|
147
172
|
writeAtomic(abs, data);
|
|
148
173
|
}
|
|
149
174
|
const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
|
|
175
|
+
/* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
|
|
176
|
+
a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
|
|
177
|
+
const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
|
|
178
|
+
const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
|
|
179
|
+
const asText = (v) => (v == null ? "" : String(v));
|
|
180
|
+
const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
|
|
181
|
+
function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
|
|
182
|
+
if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
183
|
+
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
184
|
+
writeDeck(rel, v);
|
|
185
|
+
}
|
|
186
|
+
function fileChange(rel, before, after) {
|
|
187
|
+
if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
|
|
188
|
+
const d = unifiedDiff(asText(before), asText(after), rel);
|
|
189
|
+
return { file: rel, added: d.added, removed: d.removed };
|
|
190
|
+
}
|
|
191
|
+
const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
|
|
192
|
+
? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
|
|
193
|
+
: unifiedDiff(asText(before), asText(after), rel).text);
|
|
150
194
|
/* The revision of the editable files: sent with every API answer, checked on the editor's changes */
|
|
151
195
|
const deckRev = () => revision(DECK_DIR, EDITABLE);
|
|
152
196
|
const json = (res, code, obj) => {
|
|
@@ -267,7 +311,7 @@ function chrome(args, done) {
|
|
|
267
311
|
});
|
|
268
312
|
}
|
|
269
313
|
const deckUrl = (mode, params = {}) =>
|
|
270
|
-
`${ORIGIN}
|
|
314
|
+
`${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
|
|
271
315
|
async function takeShot(slide) {
|
|
272
316
|
const pins = state.pins.filter((p) => p.slide.eid === slide.eid).map((p) => ({ n: p.n, eid: p.eid, at: p.at, point: p.point }));
|
|
273
317
|
const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
|
|
@@ -311,21 +355,21 @@ const measure = (slide) => measureInEditor(slide).catch((e) => {
|
|
|
311
355
|
presentation: one page per build step (a slide with two clicks gives three pages).
|
|
312
356
|
Files go to <deck>/export/. One export at a time. */
|
|
313
357
|
let exportQueue = Promise.resolve(), snapSeq = 0;
|
|
314
|
-
const snapshots = new Map(); // id -> static print layout, served
|
|
358
|
+
const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
|
|
315
359
|
function exportPdf(kind, includeHidden = false) {
|
|
316
360
|
if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
|
|
317
361
|
const split = kind === "presentation";
|
|
318
|
-
const name = `${
|
|
362
|
+
const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
|
|
319
363
|
const out = path.join(DECK_DIR, "export", name);
|
|
320
364
|
const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
|
|
321
365
|
const run = async () => {
|
|
322
366
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
323
367
|
const layoutProfile = profile("layout"), printProfile = profile("print");
|
|
324
368
|
const html = await printLayout(CHROME, chromeStart(layoutProfile,
|
|
325
|
-
`${ORIGIN}
|
|
369
|
+
`${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
|
|
326
370
|
const id = `${process.pid}-${++snapSeq}`;
|
|
327
371
|
snapshots.set(id, html);
|
|
328
|
-
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}
|
|
372
|
+
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
|
|
329
373
|
finally { snapshots.delete(id); }
|
|
330
374
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
331
375
|
url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
@@ -335,25 +379,27 @@ function exportPdf(kind, includeHidden = false) {
|
|
|
335
379
|
return job;
|
|
336
380
|
}
|
|
337
381
|
|
|
338
|
-
/* ---- Chat state and the
|
|
382
|
+
/* ---- Chat state and the selected backend session --------------------------------------------- */
|
|
339
383
|
const CHAT_FILE = path.join(STATE_DIR, "chat.json");
|
|
340
384
|
const state = {
|
|
341
|
-
chat: [], seq: 0, busy: false, model: MODEL || null, sessionId: null,
|
|
385
|
+
chat: [], seq: 0, busy: false, model: MODEL || null, switching: false, preparing: null, sessionId: null, provider: "claude", handover: "", sessionArchive: [],
|
|
342
386
|
turns: [], redo: [], turn: null, notes: [], pins: [], current: null,
|
|
343
387
|
uploads: new Set(), // files uploaded for the next message; removable until it is sent
|
|
344
388
|
pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
|
|
345
389
|
};
|
|
346
390
|
try {
|
|
347
391
|
const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
|
|
348
|
-
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
|
|
392
|
+
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude", handover: saved.handover || "", sessionArchive: saved.sessionArchive || [] });
|
|
349
393
|
} catch {}
|
|
350
394
|
/* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
|
|
351
395
|
let chatTimer = null;
|
|
352
396
|
function writeChat() {
|
|
353
397
|
clearTimeout(chatTimer);
|
|
354
398
|
chatTimer = null;
|
|
355
|
-
try {
|
|
356
|
-
|
|
399
|
+
try {
|
|
400
|
+
writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
|
|
401
|
+
provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50) }));
|
|
402
|
+
} catch (e) { console.error(e.message); }
|
|
357
403
|
}
|
|
358
404
|
const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
|
|
359
405
|
function addEntry(entry) {
|
|
@@ -365,78 +411,214 @@ function addEntry(entry) {
|
|
|
365
411
|
return entry;
|
|
366
412
|
}
|
|
367
413
|
const liveTurns = () => state.turns.filter((t) => !t.undone);
|
|
368
|
-
const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "
|
|
369
|
-
/*
|
|
370
|
-
|
|
371
|
-
so the file always shows what PinDeck uses. --model on the command line applies to one run only. */
|
|
372
|
-
const MODELS = ["opus", "sonnet"];
|
|
373
|
-
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
374
|
-
const DEFAULTS = { model: "sonnet", effort: "medium" };
|
|
414
|
+
const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") : null);
|
|
415
|
+
/* The menu persists provider, model and effort. CLI overrides apply to this run only.
|
|
416
|
+
The v3 preview uses its own state directory so v2 can keep running independently. */
|
|
375
417
|
const CONFIG_FILE = path.join(HOME_DIR, "config.json");
|
|
376
|
-
const settings = { ...DEFAULTS };
|
|
377
418
|
let fileConfig = {};
|
|
378
419
|
try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
|
|
379
420
|
catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
421
|
+
const settings = migrateSettings(fileConfig);
|
|
422
|
+
let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
|
|
423
|
+
// Preserve the legacy selection as the first model-specific preference.
|
|
424
|
+
if (settings.effort) effortPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
|
|
425
|
+
if (PROVIDER) {
|
|
426
|
+
if (!["claude", "codex"].includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
|
|
427
|
+
settings.provider = PROVIDER;
|
|
428
|
+
if (!MODEL && PROVIDER !== fileConfig.provider) settings.model = PROVIDER === "claude" ? "sonnet" : "";
|
|
429
|
+
}
|
|
388
430
|
if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
|
|
431
|
+
// A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
|
|
432
|
+
if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
|
|
433
|
+
state.provider = settings.provider;
|
|
434
|
+
/* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
|
|
435
|
+
deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
|
|
436
|
+
it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
|
|
437
|
+
or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
|
|
438
|
+
a symlink that leads anywhere else is refused. */
|
|
439
|
+
const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
440
|
+
const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
|
|
441
|
+
const abs = path.resolve(expandHome(String(r)));
|
|
442
|
+
let st = null;
|
|
443
|
+
try { st = fs.statSync(abs); } catch {}
|
|
444
|
+
if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
|
|
445
|
+
return abs;
|
|
446
|
+
});
|
|
447
|
+
const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
|
|
448
|
+
const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
449
|
+
const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
|
|
450
|
+
const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
|
|
451
|
+
const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
|
|
452
|
+
/* The deck page's URL, and the folder URL its relative paths resolve against */
|
|
453
|
+
const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
|
|
454
|
+
: `/deck/${encodeURIComponent(DECK_FILE)}`;
|
|
455
|
+
const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
|
|
456
|
+
/* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
|
|
457
|
+
followed. null: refuse. A missing file passes, and is answered 404. */
|
|
458
|
+
function servable(base, rel) {
|
|
459
|
+
const f = safeJoin(base, rel);
|
|
460
|
+
if (!f) return null;
|
|
461
|
+
let real;
|
|
462
|
+
try { real = fs.realpathSync(f); } catch { return f; }
|
|
463
|
+
return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
|
|
464
|
+
}
|
|
389
465
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
466
|
+
let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
|
|
467
|
+
let session = null, starting = null;
|
|
468
|
+
const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
469
|
+
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
470
|
+
chrome: CAPS.screenshot.available, capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
393
471
|
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
394
472
|
const pushStatus = () => broadcast("status", status());
|
|
395
473
|
|
|
396
474
|
const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
|
|
397
|
-
.replaceAll("{{CLI}}", CLI_CMD) + capabilityPrompt(CAPS, CLI_CMD)
|
|
475
|
+
.replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
|
|
476
|
+
// a template's own notes usually say index.html; other decks may share this folder
|
|
477
|
+
(NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
|
|
478
|
+
`${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
|
|
479
|
+
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
480
|
+
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
481
|
+
`other decks may use them too.\n` : "");
|
|
482
|
+
/* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
|
|
483
|
+
git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
|
|
484
|
+
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
485
|
+
function lockedPaths() {
|
|
486
|
+
let others = [];
|
|
487
|
+
try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
|
|
488
|
+
return [...LOCKED, "vendor/**", ".git/**", ...others];
|
|
489
|
+
}
|
|
398
490
|
/* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
|
|
399
491
|
or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
|
|
400
|
-
MCP servers have no place in a slide editor). That also skips
|
|
401
|
-
the system prompt instead, read afresh at each session start
|
|
402
|
-
|
|
403
|
-
|
|
492
|
+
MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
|
|
493
|
+
into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
|
|
494
|
+
other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
|
|
495
|
+
not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
|
|
496
|
+
`node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
|
|
404
497
|
function deckInstructions() {
|
|
405
498
|
return ["CLAUDE.md", "AGENTS.md"].map((name) => {
|
|
406
|
-
|
|
407
|
-
try { s = fs.readFileSync(path.join(DECK_DIR, name), "utf8").trim(); } catch { return ""; }
|
|
499
|
+
const s = readInstructions(path.join(DECK_DIR, name));
|
|
408
500
|
return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
|
|
409
501
|
}).join("");
|
|
410
502
|
}
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
503
|
+
// a function: ORIGIN is known only once the port is bound
|
|
504
|
+
const agentEnv = () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN });
|
|
505
|
+
const agentTools = createDeckTools({ deckDir: DECK_DIR, deckFile: DECK_FILE, readDirs: [SHOT_DIR, PDF_DIR, ...ASSET_ROOTS],
|
|
506
|
+
locked: lockedPaths, cliFile: path.join(EDITOR_DIR, "cli.mjs"), pdfTextFile: path.join(EDITOR_DIR, "lib/pdf-text.jxa.js"),
|
|
507
|
+
env: agentEnv });
|
|
508
|
+
const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?## Hand edits/, `## Tools and attachments
|
|
509
|
+
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.
|
|
510
|
+
Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
|
|
511
|
+
Use pindeck_edit for exact replacements in ${DECK_FILE}, theme.css and the deck's other files, and pindeck_write to create a new file in the deck folder. Never change brand.css.
|
|
512
|
+
Use pindeck_command with separate command and args for layout, shot, slides, pdf or pdf-figure.
|
|
513
|
+
Do not use shell commands, native file edits, or other tools to bypass these restrictions.
|
|
514
|
+
When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
|
|
515
|
+
Read README.md and the deck instructions below for conventions. Use layout after every visual change.
|
|
516
|
+
PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
|
|
517
|
+
|
|
518
|
+
## Hand edits`) + deckInstructions();
|
|
519
|
+
function makeSession(choice) {
|
|
520
|
+
let initial = null;
|
|
521
|
+
const options = {
|
|
522
|
+
cwd: DECK_DIR,
|
|
523
|
+
env: agentEnv,
|
|
524
|
+
onEvent: ev => {
|
|
525
|
+
if (ev.type === "system" && ev.subtype === "init") initial = ev;
|
|
526
|
+
if (session === agent) handleEvent(ev);
|
|
527
|
+
},
|
|
528
|
+
onExit: error => {
|
|
529
|
+
if (session !== agent) return;
|
|
530
|
+
if (state.turn) finishTurn({ error: error.message });
|
|
531
|
+
pushStatus();
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
/* Claude Code: any file in the deck folder (Edit rules cover Write too; Write is named as well, to be
|
|
535
|
+
explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
|
|
536
|
+
const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
|
|
537
|
+
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
538
|
+
args: () => ["--append-system-prompt", systemPrompt + deckInstructions(),
|
|
539
|
+
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
540
|
+
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
541
|
+
"--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
|
|
542
|
+
...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
543
|
+
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
544
|
+
...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
|
|
545
|
+
});
|
|
546
|
+
agent.initialEvent = () => initial;
|
|
547
|
+
return agent;
|
|
548
|
+
}
|
|
549
|
+
const providerAvailable = (provider) => detectCapabilities()[provider];
|
|
550
|
+
/* Every backend process that is stopped is waited for on exit: the discovery helpers, the session a switch
|
|
551
|
+
replaced, the current one. */
|
|
552
|
+
const retiring = new Set(), discoveryAgents = new Set();
|
|
553
|
+
function retire(agent) {
|
|
554
|
+
if (!agent) return;
|
|
555
|
+
const stopped = agent.stop();
|
|
556
|
+
retiring.add(stopped);
|
|
557
|
+
stopped.finally(() => retiring.delete(stopped));
|
|
558
|
+
}
|
|
559
|
+
async function discoverModels() {
|
|
560
|
+
if (discovering) return discovering;
|
|
561
|
+
discovering = Promise.all(["claude", "codex"].map(async provider => {
|
|
562
|
+
if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
|
|
563
|
+
const agent = makeSession({ provider, model: "", effort: null });
|
|
564
|
+
discoveryAgents.add(agent);
|
|
565
|
+
try {
|
|
566
|
+
if (provider === "codex") await agent.connect(); else await agent.start();
|
|
567
|
+
catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
|
|
568
|
+
} catch (e) { catalog[provider] = { models: [], error: e.message }; }
|
|
569
|
+
finally { discoveryAgents.delete(agent); retire(agent); }
|
|
570
|
+
})).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
|
|
571
|
+
return discovering;
|
|
572
|
+
}
|
|
573
|
+
async function ensureSession() {
|
|
574
|
+
if (starting) return starting;
|
|
575
|
+
if (session?.running || quitting) return;
|
|
576
|
+
const available = providerAvailable(settings.provider);
|
|
577
|
+
if (!available.available) throw new Error(available.reason);
|
|
578
|
+
starting = (async () => {
|
|
579
|
+
if (!catalog[settings.provider].models.length) await discoverModels();
|
|
580
|
+
Object.assign(settings, selectModel(catalog, settings, {}));
|
|
581
|
+
const agent = makeSession({ ...settings });
|
|
582
|
+
session = agent;
|
|
583
|
+
try { await agent.start(state.sessionId); if (session === agent) pushStatus(); }
|
|
584
|
+
catch (e) { retire(agent); throw e; }
|
|
585
|
+
})().finally(() => { starting = null; });
|
|
586
|
+
return starting;
|
|
587
|
+
}
|
|
588
|
+
async function changeSettings(requested) {
|
|
589
|
+
if (state.busy || starting) throw new Error("Wait until the current request finishes before switching.");
|
|
590
|
+
const choice = selectModel(catalog, settings, requested, effortPreferences);
|
|
591
|
+
if (Object.keys(choice).every(k => choice[k] === settings[k])) return;
|
|
592
|
+
const changingProvider = choice.provider !== settings.provider;
|
|
593
|
+
if (changingProvider && !["summary", "fresh"].includes(requested.handover)) throw new Error("Choose whether to pass a summary or start fresh.");
|
|
594
|
+
const old = session;
|
|
595
|
+
// Codex owns a single writer per thread. Model/effort are turn parameters, so keep its writer.
|
|
596
|
+
const reuse = !changingProvider && choice.provider === "codex" && old?.running;
|
|
597
|
+
const candidate = reuse ? old : makeSession(choice);
|
|
598
|
+
state.busy = true; state.switching = true; pushStatus();
|
|
599
|
+
try {
|
|
600
|
+
if (!reuse) await candidate.start(changingProvider ? null : state.sessionId);
|
|
601
|
+
if (!candidate.running) throw new Error("The new backend stopped before it was ready.");
|
|
602
|
+
const summary = changingProvider && requested.handover === "summary" ? handoverSummary(state.chat) : "";
|
|
603
|
+
if (changingProvider && state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
|
|
604
|
+
const nextPreferences = { ...effortPreferences };
|
|
605
|
+
if (settings.effort) nextPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
|
|
606
|
+
if (choice.effort) nextPreferences[`${choice.provider}:${choice.model}`] = choice.effort;
|
|
607
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...fileConfig, ...choice, effortPreferences: nextPreferences }, null, 2) + "\n");
|
|
608
|
+
effortPreferences = nextPreferences;
|
|
609
|
+
Object.assign(settings, choice);
|
|
610
|
+
state.provider = choice.provider;
|
|
611
|
+
if (changingProvider) { state.sessionId = candidate.sessionId || null; state.handover = summary; state.model = null; }
|
|
612
|
+
if (reuse) candidate.settings = choice;
|
|
613
|
+
session = candidate;
|
|
614
|
+
if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
|
|
615
|
+
if (!reuse) retire(old);
|
|
616
|
+
saveChat();
|
|
617
|
+
addEntry({ kind: "notice", text: changingProvider
|
|
618
|
+
? `Switched to ${choice.provider === "codex" ? "OpenAI / ChatGPT" : "Claude"} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
|
|
619
|
+
: `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
|
|
620
|
+
} catch (e) { if (session !== candidate) retire(candidate); throw e; }
|
|
621
|
+
finally { state.busy = false; state.switching = false; pushStatus(); }
|
|
440
622
|
}
|
|
441
623
|
|
|
442
624
|
function toolSummary(name, input = {}) {
|
|
@@ -447,6 +629,9 @@ function toolSummary(name, input = {}) {
|
|
|
447
629
|
return !r || r.startsWith("..") ? path.basename(p) : r;
|
|
448
630
|
};
|
|
449
631
|
switch (name) {
|
|
632
|
+
case "pindeck_read": return { name: "Read", detail: base(input.path) };
|
|
633
|
+
case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
|
|
634
|
+
case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
|
|
450
635
|
case "Read": return { name: "Read", detail: base(input.file_path) };
|
|
451
636
|
case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
|
|
452
637
|
case "Bash": {
|
|
@@ -492,12 +677,19 @@ function handleEvent(ev) {
|
|
|
492
677
|
}
|
|
493
678
|
|
|
494
679
|
function startTurn(promptText, userEntry) {
|
|
495
|
-
|
|
680
|
+
// every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
|
|
681
|
+
// takes back all it changed
|
|
682
|
+
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
|
|
496
683
|
state.busy = true;
|
|
497
684
|
pushStatus();
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
685
|
+
const turn = state.turn;
|
|
686
|
+
const context = state.handover ? state.handover + "\n\n" + promptText : promptText;
|
|
687
|
+
ensureSession().then(async () => {
|
|
688
|
+
if (state.turn !== turn || turn.cancelled) return;
|
|
689
|
+
const sendingSession = session;
|
|
690
|
+
await session.send([{ type: "text", text: context }]);
|
|
691
|
+
if (session === sendingSession) { state.handover = ""; saveChat(); }
|
|
692
|
+
}).catch(e => { if (state.turn === turn) finishTurn({ error: e.message }); });
|
|
501
693
|
}
|
|
502
694
|
|
|
503
695
|
function finishTurn({ error, cost, duration, denials = [] }) {
|
|
@@ -511,17 +703,19 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
511
703
|
const now = readDeck(f);
|
|
512
704
|
if (now === turn.before[f]) continue;
|
|
513
705
|
if (turn.before[f] != null) writeDeck(f, turn.before[f]);
|
|
514
|
-
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that
|
|
706
|
+
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that the assistant created
|
|
515
707
|
restored.push(f);
|
|
516
708
|
}
|
|
517
709
|
toolEntries.clear();
|
|
518
710
|
const eid = runEidPass();
|
|
519
|
-
const
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
711
|
+
const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
|
|
712
|
+
const before = {}, after = {};
|
|
713
|
+
for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
|
|
714
|
+
// the deck file and theme.css first, then the rest by name
|
|
715
|
+
const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
|
|
716
|
+
const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
|
|
717
|
+
.map((rel) => fileChange(rel, before[rel], after[rel]));
|
|
718
|
+
// after a hand-off: imported pictures that the assistant replaced (nothing refers to them any more) are
|
|
525
719
|
// deleted; undo brings them back with the turn
|
|
526
720
|
const deletedFiles = [];
|
|
527
721
|
for (const rel of turn.importedFiles || []) {
|
|
@@ -530,17 +724,19 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
530
724
|
deletedFiles.push({ rel, data: fs.readFileSync(abs) });
|
|
531
725
|
fs.rmSync(abs, { force: true });
|
|
532
726
|
}
|
|
533
|
-
const t = { id: turn.id, kind: "
|
|
727
|
+
const t = { id: turn.id, kind: "agent", provider: settings.provider, before, after, files, deletedFiles, undone: false };
|
|
534
728
|
if (files.length) { state.turns.push(t); state.redo = []; }
|
|
535
729
|
addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
|
|
536
730
|
denials, restored, newIds: eid.assigned.length });
|
|
537
731
|
if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
|
|
732
|
+
if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
|
|
733
|
+
`too large to keep a copy of (over 2 MB): Undo leaves ${tooLarge.length > 1 ? "them" : "it"} as ${tooLarge.length > 1 ? "they are" : "it is"}.` });
|
|
538
734
|
pushStatus();
|
|
539
735
|
}
|
|
540
736
|
|
|
541
737
|
/* ---- Attachments: figures, audio and PDFs added to a chat message ------------------------------------
|
|
542
738
|
Figures and audio go into <deck>/figures/ (paths relative to the deck). PDFs are reading material for
|
|
543
|
-
|
|
739
|
+
the assistant (a paper, a report, a figure or table to take over) and stay outside the deck, in PDF_DIR
|
|
544
740
|
(absolute paths); `cli.mjs pdf-figure` renders a page or part of one into figures/ when it is needed. */
|
|
545
741
|
const ATTACH_TYPES = {
|
|
546
742
|
image: ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif"],
|
|
@@ -593,14 +789,14 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
593
789
|
for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
|
|
594
790
|
if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
|
|
595
791
|
if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
|
|
596
|
-
|
|
792
|
+
`use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
|
|
597
793
|
return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
|
|
598
794
|
}
|
|
599
795
|
|
|
600
796
|
/* `cli.mjs pdf-figure <pdf> <page> [x,y,w,h] [figures/name.png] [--width px]`: renders a PDF page, or a box
|
|
601
797
|
of it given as fractions of the page from the top left, to PNG. Without a figures/ name it goes to the
|
|
602
|
-
screenshots folder, for
|
|
603
|
-
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
|
|
798
|
+
screenshots folder, for the assistant to look at before choosing; with one, into the deck. */
|
|
799
|
+
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
|
|
604
800
|
const bad = (t, status = 400) => ({ status, text: t });
|
|
605
801
|
if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
|
|
606
802
|
const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
|
|
@@ -620,8 +816,9 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
620
816
|
if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
|
|
621
817
|
const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
|
|
622
818
|
await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
|
|
623
|
-
if (!fs.existsSync(tmp)) return bad("
|
|
819
|
+
if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
|
|
624
820
|
const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
|
|
821
|
+
if (cancelled()) return bad("The request was cancelled before the figure could be saved.", 409);
|
|
625
822
|
if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
|
|
626
823
|
const buf = fs.readFileSync(tmp);
|
|
627
824
|
fs.rmSync(tmp, { force: true });
|
|
@@ -734,7 +931,7 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
|
|
|
734
931
|
files: [{ file: DECK_FILE, added: d.added, removed: d.removed }], undone: false };
|
|
735
932
|
state.turns.push(t);
|
|
736
933
|
state.redo = [];
|
|
737
|
-
// chat: false keeps the edit out of the chat (it stays in the undo history and
|
|
934
|
+
// chat: false keeps the edit out of the chat (it stays in the undo history and the assistant is still told)
|
|
738
935
|
if (chat) addEntry({ kind: "edit", turnId: t.id, label, files: t.files });
|
|
739
936
|
else state.seq++; // keep turn ids unique: they share the counter with chat entries
|
|
740
937
|
state.notes.push(note);
|
|
@@ -760,7 +957,7 @@ function deletePlan(eid) {
|
|
|
760
957
|
return { ...target, html: rest, files };
|
|
761
958
|
}
|
|
762
959
|
|
|
763
|
-
/* ---- Undo (
|
|
960
|
+
/* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
|
|
764
961
|
Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
|
|
765
962
|
every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
|
|
766
963
|
files, and the pictures or audio files they would delete or bring back. Anything else means the files
|
|
@@ -768,7 +965,7 @@ function deletePlan(eid) {
|
|
|
768
965
|
Then nothing is written and the answer is 409 with the files that differ. */
|
|
769
966
|
function undoConflicts(t, direction) {
|
|
770
967
|
const expect = direction === "undo" ? t.after : t.before;
|
|
771
|
-
const files =
|
|
968
|
+
const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
|
|
772
969
|
// undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
|
|
773
970
|
// ones (which must be unchanged); redo the other way round
|
|
774
971
|
const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
|
|
@@ -789,16 +986,12 @@ function conflict(res, direction, files) {
|
|
|
789
986
|
`it would overwrite those changes. Reload to see the files as they are.` });
|
|
790
987
|
}
|
|
791
988
|
function undo(res) {
|
|
792
|
-
if (state.busy) return json(res, 409, { error: "Wait until
|
|
989
|
+
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
793
990
|
const t = liveTurns().at(-1);
|
|
794
991
|
if (!t) return json(res, 409, { error: "Nothing to undo." });
|
|
795
992
|
const diverged = undoConflicts(t, "undo");
|
|
796
993
|
if (diverged.length) return conflict(res, "undo", diverged);
|
|
797
|
-
for (const
|
|
798
|
-
if (readDeck(f) === t.before[f]) continue;
|
|
799
|
-
if (t.before[f] != null) writeDeck(f, t.before[f]);
|
|
800
|
-
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
|
|
801
|
-
}
|
|
994
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
|
|
802
995
|
for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
|
|
803
996
|
for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
|
|
804
997
|
t.undone = true;
|
|
@@ -883,7 +1076,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
|
|
|
883
1076
|
missing: r.plans.flatMap((p) => p.figures.filter((f) => f.missing)).length };
|
|
884
1077
|
}
|
|
885
1078
|
|
|
886
|
-
/* The hand-off:
|
|
1079
|
+
/* The hand-off: the assistant gets the slides the import could only approximate, each with a picture of the
|
|
887
1080
|
original slide, and improves them in the deck's style. Returns the number of slides handed over. */
|
|
888
1081
|
async function refineImport(imp, pdf, plans, eids, first) {
|
|
889
1082
|
const todo = plans.map((p, i) => ({ p, index: first + i, eid: eids[i] })).filter((x) => x.p.complex.length);
|
|
@@ -1037,19 +1230,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
1037
1230
|
const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
|
|
1038
1231
|
return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
1039
1232
|
}
|
|
1040
|
-
if (p.startsWith("/deck/") && !ok) return text(res, 403, "Not logged in");
|
|
1233
|
+
if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
|
|
1041
1234
|
let snap;
|
|
1042
|
-
if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
|
|
1235
|
+
if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
|
|
1043
1236
|
const html = snapshots.get(snap[1]);
|
|
1044
1237
|
if (!html) return text(res, 404, "Not found");
|
|
1045
1238
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1046
1239
|
return res.end(html);
|
|
1047
1240
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1241
|
+
// /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
|
|
1242
|
+
// /files/, so that ../ paths reach the root: /deck/ alone redirects there.
|
|
1243
|
+
if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
|
|
1244
|
+
res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1245
|
+
return res.end();
|
|
1246
|
+
}
|
|
1247
|
+
const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
|
|
1248
|
+
: p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
|
|
1249
|
+
if (deckPath === null) return text(res, 403, "Forbidden");
|
|
1250
|
+
if (deckPath) {
|
|
1251
|
+
const f = deckPath;
|
|
1051
1252
|
const mode = url.searchParams.get("editor");
|
|
1052
|
-
if (!mode ||
|
|
1253
|
+
if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
|
|
1053
1254
|
return serveFile(res, f, (s) => {
|
|
1054
1255
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
1055
1256
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
@@ -1086,11 +1287,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1086
1287
|
}
|
|
1087
1288
|
|
|
1088
1289
|
if (p === "/api/message" && req.method === "POST") {
|
|
1089
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1090
|
-
// checked again here: the claude command may have been installed (or removed) since the start
|
|
1091
|
-
const
|
|
1092
|
-
if (!
|
|
1290
|
+
if (state.busy) return json(res, 409, { error: "The assistant is still working." });
|
|
1291
|
+
// checked again here: the claude or codex command may have been installed (or removed) since the start
|
|
1292
|
+
const backend = providerAvailable(settings.provider);
|
|
1293
|
+
if (!backend.available) return json(res, 503, { code: `${settings.provider}-unavailable`, error: backend.reason });
|
|
1093
1294
|
const b = await body(req);
|
|
1295
|
+
if (state.busy) return json(res, 409, { error: "The assistant is still working." });
|
|
1094
1296
|
if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
|
|
1095
1297
|
// what the prompt reads from pins and the selection must be there
|
|
1096
1298
|
const placed = (x) => x && typeof x.eid === "string" && x.slide && Number.isInteger(x.slide.index) && x.box;
|
|
@@ -1098,22 +1300,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
1098
1300
|
if (!placed(b.selection)) b.selection = null;
|
|
1099
1301
|
if (!Array.isArray(b.layouts)) b.layouts = [];
|
|
1100
1302
|
if (b.current && !Number.isInteger(b.current.index)) b.current = null;
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1303
|
+
const preparation = { cancelled: false };
|
|
1304
|
+
state.preparing = preparation;
|
|
1305
|
+
state.busy = true; pushStatus();
|
|
1306
|
+
try {
|
|
1307
|
+
let screenshot = null;
|
|
1308
|
+
const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
|
|
1309
|
+
if (shotSlide) {
|
|
1310
|
+
try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
|
|
1311
|
+
catch (e) { addEntry({ kind: "notice", text: "Screenshot failed: " + e.message }); }
|
|
1312
|
+
}
|
|
1313
|
+
if (preparation.cancelled) return json(res, 409, { error: "Stopped before sending the message." });
|
|
1314
|
+
const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
|
|
1315
|
+
// only files PinDeck itself stored can be attached
|
|
1316
|
+
b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
|
|
1317
|
+
b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
|
|
1318
|
+
const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
|
|
1319
|
+
attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
|
|
1320
|
+
selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
|
|
1321
|
+
pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
|
|
1322
|
+
startTurn(buildPrompt({ ...b, screenshot }), entry);
|
|
1323
|
+
return json(res, 200, { id: entry.id });
|
|
1324
|
+
} finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
|
|
1117
1325
|
}
|
|
1118
1326
|
if (p === "/api/upload" && req.method === "POST") {
|
|
1119
1327
|
const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
|
|
@@ -1151,22 +1359,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1151
1359
|
}
|
|
1152
1360
|
return json(res, 200, {});
|
|
1153
1361
|
}
|
|
1362
|
+
if (p === "/api/models" && req.method === "GET") return json(res, 200, await discoverModels());
|
|
1154
1363
|
if (p === "/api/settings" && req.method === "POST") {
|
|
1155
1364
|
const b = await body(req);
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
if (state.busy) return json(res, 409, { error: "Wait until Claude has finished, then change the model or effort." });
|
|
1160
|
-
Object.assign(settings, { model, effort });
|
|
1161
|
-
saveSettings();
|
|
1162
|
-
// restart the session with the new flags; --resume keeps the conversation
|
|
1163
|
-
if (session.running) session.stop().then(() => { if (!quitting) ensureSession(); });
|
|
1164
|
-
addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
|
|
1165
|
-
pushStatus();
|
|
1166
|
-
return json(res, 200, { settings });
|
|
1365
|
+
if (state.busy || starting) return json(res, 409, { error: "Wait until the current request finishes before switching." });
|
|
1366
|
+
try { await changeSettings(b); return json(res, 200, { settings }); }
|
|
1367
|
+
catch (e) { return json(res, 400, { error: e.message }); }
|
|
1167
1368
|
}
|
|
1168
1369
|
if (p === "/api/slide-hidden" && req.method === "POST") {
|
|
1169
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1370
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1170
1371
|
const b = await body(req);
|
|
1171
1372
|
if (staleEdit(res, b)) return;
|
|
1172
1373
|
const before = snapshot(EDITABLE);
|
|
@@ -1182,7 +1383,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1182
1383
|
return json(res, 200, { turnId, index });
|
|
1183
1384
|
}
|
|
1184
1385
|
if (p === "/api/move-slide" && req.method === "POST") {
|
|
1185
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1386
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1186
1387
|
const b = await body(req);
|
|
1187
1388
|
if (staleEdit(res, b)) return;
|
|
1188
1389
|
const before = snapshot(EDITABLE);
|
|
@@ -1195,7 +1396,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1195
1396
|
return json(res, 200, { turnId, index: r.index });
|
|
1196
1397
|
}
|
|
1197
1398
|
if (p === "/api/add-slide" && req.method === "POST") {
|
|
1198
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1399
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1199
1400
|
const b = await body(req);
|
|
1200
1401
|
if (staleEdit(res, b)) return;
|
|
1201
1402
|
if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
|
|
@@ -1225,7 +1426,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1225
1426
|
let imp;
|
|
1226
1427
|
try { imp = startImport(name, Buffer.concat(chunks)); }
|
|
1227
1428
|
catch (e) { return json(res, 400, { error: `${name} could not be read: ${e.message}` }); }
|
|
1228
|
-
// what the import can only approximate, so the dialog can offer
|
|
1429
|
+
// what the import can only approximate, so the dialog can offer the assistant's help with it
|
|
1229
1430
|
const complex = new Map(planSlides(imp.model, imp.model.slides.map((s) => s.n), { drawings: renderAvailable() }).map((p) => [p.n, p.complex]));
|
|
1230
1431
|
return json(res, 200, { id: imp.id, name, render: renderAvailable(),
|
|
1231
1432
|
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) || [] })) });
|
|
@@ -1236,7 +1437,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1236
1437
|
return serveFile(res, path.join(imp.dir, `thumb-${n}.png`));
|
|
1237
1438
|
}
|
|
1238
1439
|
if (p === "/api/import-apply" && req.method === "POST") {
|
|
1239
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1440
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1240
1441
|
const b = await body(req);
|
|
1241
1442
|
if (staleEdit(res, b)) return;
|
|
1242
1443
|
const imp = lastImport;
|
|
@@ -1247,21 +1448,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
1247
1448
|
catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
|
|
1248
1449
|
}
|
|
1249
1450
|
if (p === "/api/stop" && req.method === "POST") {
|
|
1250
|
-
if (state.
|
|
1451
|
+
if (state.preparing) { state.preparing.cancelled = true; return json(res, 200, {}); }
|
|
1452
|
+
if (state.busy && state.turn) {
|
|
1251
1453
|
const interruptedTurn = state.turn;
|
|
1252
|
-
|
|
1454
|
+
interruptedTurn.cancelled = true;
|
|
1455
|
+
if (starting || !session?.running) { session?.stop(); finishTurn({ error: "Stopped." }); return json(res, 200, {}); }
|
|
1456
|
+
Promise.resolve(session.interrupt()).catch(() => {});
|
|
1253
1457
|
setTimeout(() => {
|
|
1254
|
-
if (state.busy && state.turn === interruptedTurn) session
|
|
1458
|
+
if (state.busy && state.turn === interruptedTurn) { session?.stop(); finishTurn({ error: "Stopped." }); }
|
|
1255
1459
|
}, 5000); // force only if the interrupted turn does not answer
|
|
1256
1460
|
}
|
|
1257
1461
|
return json(res, 200, {});
|
|
1258
1462
|
}
|
|
1259
1463
|
if (p === "/api/new-chat" && req.method === "POST") {
|
|
1260
|
-
|
|
1261
|
-
|
|
1464
|
+
if (state.busy || starting) return json(res, 409, { error: "Stop the current request before starting a new chat." });
|
|
1465
|
+
session?.stop();
|
|
1466
|
+
Object.assign(state, { chat: [], sessionId: null, handover: "", turns: [], redo: [], turn: null, busy: false, notes: [] });
|
|
1262
1467
|
saveChat();
|
|
1263
1468
|
broadcast("reset", { chat: [] });
|
|
1264
|
-
ensureSession();
|
|
1469
|
+
ensureSession().catch(e => addEntry({ kind: "notice", text: e.message }));
|
|
1265
1470
|
pushStatus();
|
|
1266
1471
|
return json(res, 200, {});
|
|
1267
1472
|
}
|
|
@@ -1269,7 +1474,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1269
1474
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
|
|
1270
1475
|
const t = state.turns.find((t) => t.id === +m[1]);
|
|
1271
1476
|
if (!t) return text(res, 404, "No such change");
|
|
1272
|
-
return text(res, 200,
|
|
1477
|
+
return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
|
|
1273
1478
|
}
|
|
1274
1479
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
|
|
1275
1480
|
const t = liveTurns().at(-1);
|
|
@@ -1278,17 +1483,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1278
1483
|
}
|
|
1279
1484
|
if (p === "/api/undo" && req.method === "POST") return undo(res);
|
|
1280
1485
|
if (p === "/api/redo" && req.method === "POST") {
|
|
1281
|
-
if (state.busy) return json(res, 409, { error: "Wait until
|
|
1486
|
+
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
1282
1487
|
const t = state.redo.at(-1);
|
|
1283
1488
|
if (!t) return json(res, 409, { error: "Nothing to redo." });
|
|
1284
1489
|
const diverged = undoConflicts(t, "redo");
|
|
1285
1490
|
if (diverged.length) return conflict(res, "redo", diverged);
|
|
1286
1491
|
state.redo.pop();
|
|
1287
|
-
for (const
|
|
1288
|
-
if (readDeck(f) === t.after[f]) continue;
|
|
1289
|
-
if (t.after[f] != null) writeDeck(f, t.after[f]);
|
|
1290
|
-
else fs.rmSync(path.join(DECK_DIR, f), { force: true });
|
|
1291
|
-
}
|
|
1492
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
|
|
1292
1493
|
for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
1293
1494
|
for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
|
|
1294
1495
|
t.undone = false;
|
|
@@ -1306,7 +1507,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1306
1507
|
return json(res, 200, { eid: plan.eid, tag: plan.tag, files: plan.files });
|
|
1307
1508
|
}
|
|
1308
1509
|
if (p === "/api/delete" && req.method === "POST") {
|
|
1309
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1510
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1310
1511
|
const b = await body(req);
|
|
1311
1512
|
if (staleEdit(res, b)) return;
|
|
1312
1513
|
const plan = deletePlan(b.eid);
|
|
@@ -1326,7 +1527,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1326
1527
|
|
|
1327
1528
|
// Hand edits from the editor: drag/resize (inline styles) and text
|
|
1328
1529
|
if (p === "/api/edit" && req.method === "POST") {
|
|
1329
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1530
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1330
1531
|
const b = await body(req);
|
|
1331
1532
|
if (staleEdit(res, b)) return;
|
|
1332
1533
|
const before = snapshot(EDITABLE);
|
|
@@ -1348,7 +1549,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1348
1549
|
return n ? json(res, 200, n) : json(res, 404, { error: "Slide not found in the file." });
|
|
1349
1550
|
}
|
|
1350
1551
|
if (p === "/api/notes" && req.method === "POST") {
|
|
1351
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1552
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1352
1553
|
const b = await body(req);
|
|
1353
1554
|
if (staleEdit(res, b)) return;
|
|
1354
1555
|
const before = snapshot(EDITABLE);
|
|
@@ -1363,7 +1564,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1363
1564
|
return json(res, 200, { turnId });
|
|
1364
1565
|
}
|
|
1365
1566
|
if (p === "/api/text" && req.method === "POST") {
|
|
1366
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1567
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1367
1568
|
const b = await body(req);
|
|
1368
1569
|
if (staleEdit(res, b)) return;
|
|
1369
1570
|
const before = snapshot(EDITABLE);
|
|
@@ -1379,7 +1580,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1379
1580
|
|
|
1380
1581
|
// Enter in a bullet: the bullet keeps the text before the caret, a new bullet after it gets the rest
|
|
1381
1582
|
if (p === "/api/split" && req.method === "POST") {
|
|
1382
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1583
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1383
1584
|
const b = await body(req);
|
|
1384
1585
|
if (staleEdit(res, b)) return;
|
|
1385
1586
|
const before = snapshot(EDITABLE);
|
|
@@ -1392,7 +1593,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1392
1593
|
return json(res, 200, { turnId, eid: r2.eid });
|
|
1393
1594
|
}
|
|
1394
1595
|
if (p === "/api/remove" && req.method === "POST") {
|
|
1395
|
-
if (state.busy) return json(res, 409, { error: "
|
|
1596
|
+
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1396
1597
|
const b = await body(req);
|
|
1397
1598
|
if (staleEdit(res, b)) return;
|
|
1398
1599
|
const before = snapshot(EDITABLE);
|
|
@@ -1409,7 +1610,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1409
1610
|
catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
|
|
1410
1611
|
}
|
|
1411
1612
|
|
|
1412
|
-
// CLI endpoints (used by
|
|
1613
|
+
// CLI endpoints (used by both backends via cli.mjs)
|
|
1413
1614
|
if (p === "/api/pdf") {
|
|
1414
1615
|
const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
|
|
1415
1616
|
if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
|
|
@@ -1418,7 +1619,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1418
1619
|
return text(res, 200, r.path);
|
|
1419
1620
|
}
|
|
1420
1621
|
if (p === "/api/pdf-figure") {
|
|
1421
|
-
const
|
|
1622
|
+
const turn = state.turn;
|
|
1623
|
+
const r = await pdfFigure({ ...Object.fromEntries(url.searchParams), cancelled: () => !!turn && (turn.cancelled || state.turn !== turn) });
|
|
1422
1624
|
return text(res, r.status || 200, r.text);
|
|
1423
1625
|
}
|
|
1424
1626
|
if (p === "/api/slides") {
|
|
@@ -1461,7 +1663,6 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1461
1663
|
ORIGIN = `http://127.0.0.1:${PORT}`;
|
|
1462
1664
|
try {
|
|
1463
1665
|
writeServerInfo();
|
|
1464
|
-
completeSettings();
|
|
1465
1666
|
fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
|
|
1466
1667
|
const first = runEidPass();
|
|
1467
1668
|
if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
|
|
@@ -1478,9 +1679,16 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1478
1679
|
console.log(`Server: ${CAPS.label}`);
|
|
1479
1680
|
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
1480
1681
|
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
1481
|
-
|
|
1682
|
+
discoverModels().then(() => {
|
|
1683
|
+
if (!settings.model) {
|
|
1684
|
+
const models = catalog[settings.provider].models;
|
|
1685
|
+
const first = models.find(m => m.isDefault) || models[0];
|
|
1686
|
+
if (first) { settings.model = first.id; settings.effort = first.defaultEffort; }
|
|
1687
|
+
}
|
|
1688
|
+
pushStatus();
|
|
1689
|
+
});
|
|
1482
1690
|
});
|
|
1483
|
-
/* Ctrl+C or SIGTERM: stop taking requests, let
|
|
1691
|
+
/* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
|
|
1484
1692
|
SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
|
|
1485
1693
|
async function quit() {
|
|
1486
1694
|
if (quitting) process.exit(1);
|
|
@@ -1492,7 +1700,8 @@ async function quit() {
|
|
|
1492
1700
|
server.close();
|
|
1493
1701
|
server.closeAllConnections?.();
|
|
1494
1702
|
removeServerInfo();
|
|
1495
|
-
|
|
1703
|
+
for (const agent of [...discoveryAgents, session]) retire(agent);
|
|
1704
|
+
await Promise.race([Promise.all(retiring), new Promise((r) => setTimeout(r, 6000))]);
|
|
1496
1705
|
if (chatTimer) writeChat();
|
|
1497
1706
|
process.exit(0);
|
|
1498
1707
|
}
|