pindeck 3.0.0-beta.3 → 3.2.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 +136 -38
- package/cli.mjs +19 -9
- package/front.mjs +413 -0
- package/lib/agent-process.mjs +4 -1
- package/lib/agent-tools.mjs +5 -2
- package/lib/asset-roots.mjs +22 -0
- package/lib/claude.mjs +10 -2
- package/lib/deck-files.mjs +5 -3
- package/lib/deck-list.mjs +72 -0
- package/lib/deck-target.mjs +43 -0
- package/lib/eids.mjs +15 -6
- package/lib/login.mjs +75 -0
- package/lib/models.mjs +10 -4
- package/lib/pdf.mjs +18 -2
- package/lib/servers.mjs +91 -0
- package/lib/usage.mjs +15 -0
- package/package.json +4 -3
- package/public/bridge.js +56 -7
- package/public/editor.css +30 -3
- package/public/editor.js +260 -99
- package/public/events-worker.js +47 -0
- package/public/front.css +14 -0
- package/public/index.html +10 -2
- package/server.mjs +493 -235
package/server.mjs
CHANGED
|
@@ -10,16 +10,15 @@
|
|
|
10
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
12
|
- stores figures and audio attached to chat messages in <deck>/figures/ for the assistant to use, and attached
|
|
13
|
-
PDFs (papers, reports, figures) in ~/.pindeck, outside the deck
|
|
13
|
+
PDFs (papers, reports, figures) in ~/.pindeck-v3, 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
|
|
16
16
|
cookie in the browser, a header for the CLI).
|
|
17
|
-
Runtime files (chat, screenshots, backups) live in ~/.pindeck, never in the deck or the package. */
|
|
17
|
+
Runtime files (chat, screenshots, backups) live in ~/.pindeck-v3, never in the deck or the package. */
|
|
18
18
|
|
|
19
19
|
import http from "node:http";
|
|
20
20
|
import { EventEmitter } from "node:events";
|
|
21
21
|
import fs from "node:fs";
|
|
22
|
-
import os from "node:os";
|
|
23
22
|
import path from "node:path";
|
|
24
23
|
import crypto from "node:crypto";
|
|
25
24
|
import { spawn, execFile } from "node:child_process";
|
|
@@ -29,9 +28,9 @@ import { unifiedDiff } from "./lib/diff.mjs";
|
|
|
29
28
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
30
29
|
import { CodexSession } from "./lib/codex.mjs";
|
|
31
30
|
import { createDeckTools } from "./lib/agent-tools.mjs";
|
|
32
|
-
import { migrateSettings, selectModel, handoverSummary, curateModels } from "./lib/models.mjs";
|
|
31
|
+
import { PROVIDERS, migrateSettings, selectModel, handoverSummary, curateModels } from "./lib/models.mjs";
|
|
33
32
|
import { formatLayout, formatPin } from "./lib/layout.mjs";
|
|
34
|
-
import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs";
|
|
33
|
+
import { printLayout, printPdf, chromeReason, ROOT_ARGS, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs";
|
|
35
34
|
import { readPptx } from "./lib/pptx.mjs";
|
|
36
35
|
import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
|
|
37
36
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
@@ -39,19 +38,17 @@ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capability
|
|
|
39
38
|
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
40
39
|
import { readInstructions } from "./lib/instructions.mjs";
|
|
41
40
|
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
41
|
+
import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
|
|
42
|
+
import { listDecks, linkProblem } from "./lib/deck-list.mjs";
|
|
43
|
+
import { assetRoots } from "./lib/asset-roots.mjs";
|
|
44
|
+
import { registerServer, printLogin, alive } from "./lib/servers.mjs";
|
|
45
|
+
import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
46
|
+
import { USAGE } from "./lib/usage.mjs";
|
|
42
47
|
|
|
43
48
|
/* ---- Config ------------------------------------------------------------------ */
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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. A deck made otherwise names its slides in its <head> with
|
|
49
|
-
<meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
|
|
50
|
-
login prints a new one-time login link for the PinDeck running for that deck
|
|
51
|
-
(without a deck: for the PinDeck started last).
|
|
52
|
-
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
53
|
-
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
54
|
-
"assetRoots" in ~/.pindeck-v3/config.json.`;
|
|
49
|
+
/* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in and
|
|
50
|
+
prints the links; this server tells it its address over IPC. */
|
|
51
|
+
const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
|
|
55
52
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
56
53
|
const argv = process.argv.slice(2);
|
|
57
54
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
@@ -70,42 +67,26 @@ const LOGIN = argv[0] === "login";
|
|
|
70
67
|
if (LOGIN) argv.shift();
|
|
71
68
|
const PROVIDER = opt("--provider", "");
|
|
72
69
|
const MODEL = opt("--model", "");
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
|
|
78
|
-
const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
|
|
79
|
-
const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
|
|
80
|
-
const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
|
|
70
|
+
if (process.env.CLAUDE_MODEL && !LOGIN && !FRONT) // PinDeck 2 read it; behind the front, the front says so
|
|
71
|
+
console.warn(`CLAUDE_MODEL is not used by PinDeck 3: choose the model in the editor's model menu, or start with --model ${process.env.CLAUDE_MODEL}.`);
|
|
72
|
+
/* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
|
|
73
|
+
const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
|
|
81
74
|
const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
|
|
82
75
|
const LOCKED = ["brand.css"]; // never, enforced twice
|
|
83
|
-
const HOME_DIR =
|
|
84
|
-
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
85
|
-
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
86
|
-
const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
87
|
-
crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
|
|
76
|
+
const HOME_DIR = homeDir();
|
|
88
77
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
89
78
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
79
|
+
const SHOT_NAME = /^[\w-][\w.-]*\.png$/; // a file name in SHOT_DIR, as the editor may ask for it
|
|
90
80
|
const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
|
|
91
|
-
/* pindeck login: a new one-time login link from the
|
|
92
|
-
|
|
93
|
-
if (LOGIN) {
|
|
94
|
-
const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
|
|
95
|
-
let info = null;
|
|
96
|
-
for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
|
|
97
|
-
if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
|
|
98
|
-
try {
|
|
99
|
-
const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
|
|
100
|
-
if (!res.ok) throw new Error(res.status);
|
|
101
|
-
console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
|
|
102
|
-
process.exit(0);
|
|
103
|
-
} catch {
|
|
104
|
-
console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
81
|
+
/* pindeck login: a new one-time login link from the PinDeck that serves the deck (see lib/servers.mjs) */
|
|
82
|
+
if (LOGIN) await printLogin(HOME_DIR, argv[0] ? deckTarget(argv[0]) : null);
|
|
108
83
|
const TOKEN = crypto.randomBytes(16).toString("hex");
|
|
84
|
+
/* The deck's pages are untrusted content: a deck (or a template, or a script the assistant wrote) must not
|
|
85
|
+
reach the API, the chat or other decks. They are served with a sandbox policy, which gives them an origin
|
|
86
|
+
of their own (no cookie, no token, no access to the editor page), under /v/<VIEW_TOKEN>/: a path that
|
|
87
|
+
lets them load their own files, and nothing else. */
|
|
88
|
+
const VIEW = `/v/${crypto.randomBytes(16).toString("hex")}`;
|
|
89
|
+
const SANDBOX = "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads allow-presentation";
|
|
109
90
|
let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
|
|
110
91
|
const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
|
|
111
92
|
const CLI_CMD = cliCommand(CLI_REL);
|
|
@@ -115,6 +96,8 @@ const CAPS = detectCapabilities();
|
|
|
115
96
|
function checkDeck() {
|
|
116
97
|
const file = path.join(DECK_DIR, DECK_FILE);
|
|
117
98
|
if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
|
|
99
|
+
const linked = linkProblem(DECK_DIR, DECK_FILE);
|
|
100
|
+
if (linked) return linked + " Nothing was changed.";
|
|
118
101
|
try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
|
|
119
102
|
catch { return `${file} cannot be read and written (check its permissions).`; }
|
|
120
103
|
// saving replaces the file through a temporary file next to it, so the folder must be writable too
|
|
@@ -122,6 +105,8 @@ function checkDeck() {
|
|
|
122
105
|
catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
|
|
123
106
|
return null;
|
|
124
107
|
}
|
|
108
|
+
const homeNote = !FRONT && homeWarning(HOME_DIR); // behind the front, the front says it
|
|
109
|
+
if (homeNote) console.warn(homeNote);
|
|
125
110
|
const deckProblem = checkDeck();
|
|
126
111
|
if (deckProblem) {
|
|
127
112
|
console.error(`${deckProblem}\n\n${USAGE}`);
|
|
@@ -129,27 +114,44 @@ if (deckProblem) {
|
|
|
129
114
|
}
|
|
130
115
|
// reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
|
|
131
116
|
const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
|
|
117
|
+
const ENDS_HTML = /<\/html>\s*$/i;
|
|
132
118
|
const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
|
|
133
119
|
if (frameworkProblem) {
|
|
134
120
|
console.error(frameworkProblem);
|
|
135
121
|
process.exit(1);
|
|
136
122
|
}
|
|
123
|
+
/* One PinDeck per deck: two deck servers on one state folder would overwrite each other's chat and undo. The
|
|
124
|
+
one that has the deck open holds STATE_DIR/open.json; a second one says where the deck is open and stops,
|
|
125
|
+
before anything is written. */
|
|
126
|
+
const LOCK_FILE = path.join(STATE_DIR, "open.json");
|
|
127
|
+
const lockHolder = () => {
|
|
128
|
+
try { const o = JSON.parse(fs.readFileSync(LOCK_FILE, "utf8")); return o.pid !== process.pid && alive(o.pid) ? o : null; } catch { return null; }
|
|
129
|
+
};
|
|
130
|
+
const openElsewhere = (o) => `${path.join(DECK_DIR, DECK_FILE)} is open in another PinDeck already (${o.url}, process ${o.pid}). ` +
|
|
131
|
+
`Use that one, or stop it first: two PinDecks on one deck would overwrite each other's chat and undo.`;
|
|
132
|
+
if (lockHolder()) { console.error(openElsewhere(lockHolder())); process.exit(1); }
|
|
137
133
|
/* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
|
|
138
|
-
port is bound (see Start), so a failed start leaves no state behind.
|
|
139
|
-
|
|
134
|
+
port is bound (see Start), so a failed start leaves no state behind. The server file (lib/servers.mjs) lets
|
|
135
|
+
pindeck login and cli.mjs find this server; behind the front, login goes to the front (a deck server's port
|
|
136
|
+
is no use to a browser behind a tunnel), and the assistant's CLI gets this server's address. */
|
|
137
|
+
let unregister = () => {};
|
|
140
138
|
function writeServerInfo() {
|
|
141
139
|
for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
140
|
+
const lock = JSON.stringify({ pid: process.pid, url: process.env.PINDECK_FRONT_URL || ORIGIN });
|
|
141
|
+
try { fs.writeFileSync(LOCK_FILE, lock, { flag: "wx", mode: 0o600 }); }
|
|
142
|
+
catch (e) {
|
|
143
|
+
if (e.code !== "EEXIST") throw e;
|
|
144
|
+
const other = lockHolder();
|
|
145
|
+
if (other) throw Object.assign(new Error(openElsewhere(other)), { plain: true });
|
|
146
|
+
fs.writeFileSync(LOCK_FILE, lock, { mode: 0o600 }); // left by a PinDeck that is gone
|
|
146
147
|
}
|
|
148
|
+
unregister = registerServer(HOME_DIR, { url: ORIGIN, token: TOKEN, key: deckKey, deckDir: DECK_DIR, deckFile: DECK_FILE,
|
|
149
|
+
...(FRONT ? { behindFront: true } : {}) });
|
|
147
150
|
}
|
|
148
|
-
/* On exit: only our own
|
|
151
|
+
/* On exit: only our own files, never those of a PinDeck started since for this or another deck. */
|
|
149
152
|
function removeServerInfo() {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
+
unregister();
|
|
154
|
+
try { if (JSON.parse(fs.readFileSync(LOCK_FILE, "utf8")).pid === process.pid) fs.rmSync(LOCK_FILE, { force: true }); } catch {}
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
/* ---- Small helpers --------------------------------------------------------------- */
|
|
@@ -182,9 +184,23 @@ function writeDeck(f, content, manual = false) {
|
|
|
182
184
|
ourWrites.set(f, { hash: hash(content), manual });
|
|
183
185
|
writeAtomic(path.join(DECK_DIR, f), content);
|
|
184
186
|
}
|
|
187
|
+
/* A file PinDeck itself writes or deletes in the deck folder (an upload, a picture, an export, a file Undo
|
|
188
|
+
brings back or removes) must be there once symlinks are followed: a figures/ or export/ that links
|
|
189
|
+
elsewhere is not written through, as serving refuses to read through it. */
|
|
190
|
+
function deckPath(rel) {
|
|
191
|
+
const abs = path.join(DECK_DIR, rel);
|
|
192
|
+
let p = abs;
|
|
193
|
+
while (!fs.existsSync(p) && path.dirname(p) !== p) p = path.dirname(p);
|
|
194
|
+
const real = realOr(p), deck = realOr(DECK_DIR);
|
|
195
|
+
if (real !== deck && !real.startsWith(deck + path.sep))
|
|
196
|
+
throw Object.assign(new Error(`${rel} leads out of the deck folder (through a symlink): PinDeck does not write or delete there.`), { status: 409 });
|
|
197
|
+
return abs;
|
|
198
|
+
}
|
|
199
|
+
const removeDeckFile = (rel) => fs.rmSync(deckPath(rel), { force: true });
|
|
185
200
|
/* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
|
|
186
201
|
function writeDeckFile(rel, data, manual = true) {
|
|
187
|
-
const abs =
|
|
202
|
+
const abs = deckPath(rel);
|
|
203
|
+
state.turn?.touched.add(rel);
|
|
188
204
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
189
205
|
ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
|
|
190
206
|
writeAtomic(abs, data);
|
|
@@ -197,7 +213,7 @@ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
|
|
|
197
213
|
const asText = (v) => (v == null ? "" : String(v));
|
|
198
214
|
const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
|
|
199
215
|
function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
|
|
200
|
-
if (v == null) return
|
|
216
|
+
if (v == null) return removeDeckFile(rel);
|
|
201
217
|
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
202
218
|
writeDeck(rel, v);
|
|
203
219
|
}
|
|
@@ -310,13 +326,13 @@ function sized(url, size) {
|
|
|
310
326
|
u.searchParams.set("height", size.height);
|
|
311
327
|
return u.href;
|
|
312
328
|
}
|
|
313
|
-
function chrome(args, done) {
|
|
329
|
+
function chrome(args, done, signal) {
|
|
314
330
|
if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
|
|
315
331
|
return new Promise((resolve, reject) => {
|
|
316
332
|
// a profile per run: concurrent runs must not share one
|
|
317
333
|
const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
|
|
318
334
|
const { size } = deckSlides();
|
|
319
|
-
const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
|
|
335
|
+
const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run", ...ROOT_ARGS,
|
|
320
336
|
"--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
|
|
321
337
|
`--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
|
|
322
338
|
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
|
|
@@ -334,17 +350,22 @@ function chrome(args, done) {
|
|
|
334
350
|
proc.stderr.on("data", (d) => { err += d; check(); });
|
|
335
351
|
proc.on("error", (e) => finish(reject, e));
|
|
336
352
|
// "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
|
|
337
|
-
proc.on("close", () => (done(out, err) ? finish(resolve, out)
|
|
353
|
+
proc.on("close", () => (done(out, err) ? finish(resolve, out)
|
|
354
|
+
: finish(reject, new Error(`Chrome exited without output${chromeReason(err) ? `: ${chromeReason(err)}` : "."}`))));
|
|
338
355
|
const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
|
|
356
|
+
signal?.addEventListener("abort", () => finish(reject, new Error("Stopped.")), { once: true });
|
|
339
357
|
});
|
|
340
358
|
}
|
|
341
359
|
const deckUrl = (mode, params = {}) =>
|
|
342
|
-
`${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
|
|
343
|
-
|
|
344
|
-
|
|
360
|
+
`${ORIGIN}${VIEW}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
|
|
361
|
+
/* A screenshot of a slide with the pins of a message on it: those the message carries when the user asks for
|
|
362
|
+
one, those of the latest message when the assistant does (cli.mjs shot). Never pins the editor no longer
|
|
363
|
+
shows, or another tab's. */
|
|
364
|
+
async function takeShot(slide, pins = state.lastPins, signal) {
|
|
365
|
+
pins = pins.filter((p) => p.slide?.eid === slide.eid).map((p) => ({ n: p.n, eid: p.eid, at: p.at, point: p.point }));
|
|
345
366
|
const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
|
|
346
367
|
await chrome([`--screenshot=${out}`, deckUrl("shot", { slide: slide.eid, pins: JSON.stringify(pins) })],
|
|
347
|
-
(_o, err) => /bytes written to file/.test(err) && fs.existsSync(out));
|
|
368
|
+
(_o, err) => /bytes written to file/.test(err) && fs.existsSync(out), signal);
|
|
348
369
|
if (!fs.existsSync(out)) throw new Error("Chrome did not write the screenshot.");
|
|
349
370
|
// keep the folder small
|
|
350
371
|
const old = fs.readdirSync(SHOT_DIR).map((f) => path.join(SHOT_DIR, f))
|
|
@@ -388,19 +409,20 @@ function exportPdf(kind, includeHidden = false) {
|
|
|
388
409
|
if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
|
|
389
410
|
const split = kind === "presentation";
|
|
390
411
|
const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
|
|
391
|
-
const out = path.join(DECK_DIR, "export", name);
|
|
412
|
+
const out = path.join(DECK_DIR, "export", name); // checked when the export runs (deckPath)
|
|
392
413
|
const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
|
|
393
414
|
const run = async () => {
|
|
415
|
+
deckPath(`export/${name}`);
|
|
394
416
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
395
417
|
const layoutProfile = profile("layout"), printProfile = profile("print");
|
|
396
418
|
const html = await printLayout(CHROME, chromeStart(layoutProfile,
|
|
397
|
-
`${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
|
|
419
|
+
`${ORIGIN}${VIEW}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
|
|
398
420
|
const id = `${process.pid}-${++snapSeq}`;
|
|
399
421
|
snapshots.set(id, html);
|
|
400
|
-
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
|
|
422
|
+
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${VIEW}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
|
|
401
423
|
finally { snapshots.delete(id); }
|
|
402
424
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
403
|
-
url:
|
|
425
|
+
url: `deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
404
426
|
};
|
|
405
427
|
const job = exportQueue.then(run, run);
|
|
406
428
|
exportQueue = job.catch(() => {});
|
|
@@ -411,13 +433,15 @@ function exportPdf(kind, includeHidden = false) {
|
|
|
411
433
|
const CHAT_FILE = path.join(STATE_DIR, "chat.json");
|
|
412
434
|
const state = {
|
|
413
435
|
chat: [], seq: 0, busy: false, model: MODEL || null, switching: false, preparing: null, sessionId: null, provider: "claude", handover: "", sessionArchive: [],
|
|
414
|
-
turns: [], redo: [], turn: null, notes: [],
|
|
436
|
+
turns: [], redo: [], turn: null, notes: [], lastPins: [], current: null,
|
|
437
|
+
conversation: 1, // counts the conversations of this run: a new chat or another provider starts the next
|
|
415
438
|
uploads: new Set(), // files uploaded for the next message; removable until it is sent
|
|
416
439
|
pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
|
|
417
440
|
};
|
|
418
441
|
try {
|
|
419
442
|
const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
|
|
420
|
-
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude",
|
|
443
|
+
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude",
|
|
444
|
+
handover: saved.handover || "", sessionArchive: saved.sessionArchive || [], saved: saved.settings || null, hasChat: true });
|
|
421
445
|
} catch {}
|
|
422
446
|
/* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
|
|
423
447
|
let chatTimer = null;
|
|
@@ -426,7 +450,8 @@ function writeChat() {
|
|
|
426
450
|
chatTimer = null;
|
|
427
451
|
try {
|
|
428
452
|
writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
|
|
429
|
-
provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50)
|
|
453
|
+
provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50),
|
|
454
|
+
settings: deckSettings ?? undefined }));
|
|
430
455
|
} catch (e) { console.error(e.message); }
|
|
431
456
|
}
|
|
432
457
|
const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
|
|
@@ -440,38 +465,59 @@ function addEntry(entry) {
|
|
|
440
465
|
}
|
|
441
466
|
const liveTurns = () => state.turns.filter((t) => !t.undone);
|
|
442
467
|
const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") : null);
|
|
443
|
-
/*
|
|
444
|
-
|
|
468
|
+
/* Provider, model and effort belong to each deck, with its conversation: saved in its chat.json when chosen
|
|
469
|
+
in the menu, so another deck's choice never moves this deck's conversation to another backend. config.json
|
|
470
|
+
holds the last choice, which a new deck starts with, and each model's preferred effort. --provider and
|
|
471
|
+
--model apply to this run only. The v3 preview uses its own state directory so v2 can keep running. */
|
|
445
472
|
const CONFIG_FILE = path.join(HOME_DIR, "config.json");
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (
|
|
473
|
+
const isObject = (o) => !!o && typeof o === "object" && !Array.isArray(o);
|
|
474
|
+
function readConfig(warn = false) {
|
|
475
|
+
try {
|
|
476
|
+
const c = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
477
|
+
if (isObject(c)) return c;
|
|
478
|
+
if (warn) console.warn(`${CONFIG_FILE} does not hold settings ({ ... }); using the defaults.`);
|
|
479
|
+
} catch (e) { if (warn && e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
|
|
480
|
+
return {};
|
|
481
|
+
}
|
|
482
|
+
const preferencesOf = (c) => Object.fromEntries(Object.entries(isObject(c.effortPreferences) ? c.effortPreferences : {}).filter(([, v]) => typeof v === "string"));
|
|
483
|
+
const fileConfig = readConfig(true);
|
|
484
|
+
let effortPreferences = preferencesOf(fileConfig);
|
|
485
|
+
const defaults = migrateSettings(fileConfig);
|
|
486
|
+
// the legacy single effort (v2): the preference of the model it was chosen for
|
|
487
|
+
if (fileConfig.effort && defaults.model) effortPreferences[`${defaults.provider}:${defaults.model}`] ??= defaults.effort;
|
|
488
|
+
let deckSettings = isObject(state.saved) && PROVIDERS.includes(state.saved.provider) ? migrateSettings(state.saved) : null;
|
|
489
|
+
const settings = deckSettings ? { ...deckSettings }
|
|
490
|
+
// a chat saved before decks kept their own settings: its provider, with that provider's default model
|
|
491
|
+
: state.hasChat && state.provider !== defaults.provider ? { provider: state.provider, model: state.provider === "claude" ? "sonnet" : "", effort: null }
|
|
492
|
+
: { ...defaults };
|
|
493
|
+
const ownSettings = { ...settings }; // without --provider and --model, which apply to this run only
|
|
494
|
+
const startNotices = []; // added to the chat once the port is bound (a failed start writes nothing)
|
|
453
495
|
if (PROVIDER) {
|
|
454
|
-
if (!
|
|
455
|
-
settings.provider
|
|
456
|
-
|
|
496
|
+
if (!PROVIDERS.includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
|
|
497
|
+
if (PROVIDER !== settings.provider) Object.assign(settings, { provider: PROVIDER, model: PROVIDER === "claude" ? "sonnet" : "", effort: null });
|
|
498
|
+
}
|
|
499
|
+
if (MODEL && MODEL !== settings.model) Object.assign(settings, { model: MODEL, effort: null }); // this run only, not saved
|
|
500
|
+
if (settings.effort == null) settings.effort = effortPreferences[`${settings.provider}:${settings.model}`] ?? null;
|
|
501
|
+
// A saved conversation belongs to its provider: --provider for another one starts a new conversation, and says so
|
|
502
|
+
if (state.provider !== settings.provider && (state.sessionId || state.chat.length)) {
|
|
503
|
+
if (state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
|
|
504
|
+
state.sessionId = null; state.handover = "";
|
|
505
|
+
const why = PROVIDER === settings.provider ? `for this run (--provider ${PROVIDER})` : "(this deck's own choice)";
|
|
506
|
+
startNotices.push({ kind: "notice", fresh: true, text: `Switched to ${providerName(settings.provider)} ${why} · New conversation.` });
|
|
457
507
|
}
|
|
458
|
-
if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
|
|
459
|
-
// A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
|
|
460
|
-
if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
|
|
461
508
|
state.provider = settings.provider;
|
|
462
509
|
/* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
|
|
463
510
|
deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
|
|
464
511
|
it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
|
|
465
512
|
or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
|
|
466
513
|
a symlink that leads anywhere else is refused. */
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
});
|
|
514
|
+
// on its own a missing one stops the start; behind the front, which lists decks only from roots that exist,
|
|
515
|
+
// one that is gone since the front started is left out with a warning, so the deck still opens
|
|
516
|
+
const { roots: ASSET_ROOTS, missing: missingRoots } = assetRoots(assetArgs, fileConfig);
|
|
517
|
+
for (const r of missingRoots) {
|
|
518
|
+
if (!FRONT) { console.error(`Asset root ${r} is not a folder.\n\n${USAGE}`); process.exit(2); }
|
|
519
|
+
console.warn(`Asset root ${r} is not a folder (any more): left out.`);
|
|
520
|
+
}
|
|
475
521
|
const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
|
|
476
522
|
const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
477
523
|
const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
|
|
@@ -491,14 +537,22 @@ function servable(base, rel) {
|
|
|
491
537
|
return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
|
|
492
538
|
}
|
|
493
539
|
|
|
494
|
-
let catalog = { claude: { models: [] }, codex: { models: [] } }
|
|
540
|
+
let catalog = { claude: { models: [] }, codex: { models: [] } };
|
|
495
541
|
let session = null, starting = null;
|
|
496
|
-
const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
542
|
+
const status = () => ({ busy: state.busy, switching: state.switching, preparing: !!state.preparing, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
497
543
|
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
498
544
|
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
499
545
|
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
500
|
-
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
501
|
-
|
|
546
|
+
undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)) });
|
|
547
|
+
/* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
|
|
548
|
+
changed since the last push: the editor keeps the last ones it got. */
|
|
549
|
+
let catalogSent = "";
|
|
550
|
+
const pushStatus = () => {
|
|
551
|
+
const st = status(), lists = JSON.stringify(catalog);
|
|
552
|
+
if (lists === catalogSent) delete st.models;
|
|
553
|
+
catalogSent = lists;
|
|
554
|
+
broadcast("status", st);
|
|
555
|
+
};
|
|
502
556
|
|
|
503
557
|
const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
|
|
504
558
|
.replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
|
|
@@ -521,9 +575,50 @@ function slidesInstructions() {
|
|
|
521
575
|
git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
|
|
522
576
|
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
523
577
|
function lockedPaths() {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
578
|
+
const { files, folders } = otherDecks();
|
|
579
|
+
return [...LOCKED, "vendor/**", ".git/**", ...files, ...folders.map((f) => `${f}/**`)];
|
|
580
|
+
}
|
|
581
|
+
/* The other decks in this deck's folder: its other .html files, and the subfolders that hold a deck of their
|
|
582
|
+
own (as the deck menu lists them). Neither the assistant nor this deck's undo touches them. */
|
|
583
|
+
function otherDecks() {
|
|
584
|
+
let files = [];
|
|
585
|
+
try { files = fs.readdirSync(DECK_DIR).filter((f) => /\.html$/i.test(f) && f !== DECK_FILE); } catch {}
|
|
586
|
+
const folders = [...new Set(listDecks([DECK_DIR]).filter((d) => !d.problem && d.dir !== DECK_DIR)
|
|
587
|
+
.map((d) => path.relative(DECK_DIR, d.dir).split(path.sep).join("/")))];
|
|
588
|
+
return { files, folders };
|
|
589
|
+
}
|
|
590
|
+
const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders] }); };
|
|
591
|
+
|
|
592
|
+
/* Decks that share a folder share its files (theme.css, figures/): while the assistant of one works, that of
|
|
593
|
+
another may change them too. Each deck server keeps the times of its turns in a file of its own under
|
|
594
|
+
turns/<folder>/; a turn that overlapped another deck's keeps only the files its own assistant said it wrote. */
|
|
595
|
+
const TURNS_DIR = path.join(HOME_DIR, "turns", hash(DECK_DIR).slice(0, 12));
|
|
596
|
+
const TURNS_FILE = path.join(TURNS_DIR, `${deckKey}.json`);
|
|
597
|
+
let turnTimes = []; // [{ start, end }] of this deck's recent turns
|
|
598
|
+
function logTurnTime(t) {
|
|
599
|
+
const now = Date.now();
|
|
600
|
+
turnTimes = turnTimes.filter((x) => x === t || (x.end ?? now) > now - 3600000).slice(-20);
|
|
601
|
+
if (!turnTimes.includes(t)) turnTimes.push(t);
|
|
602
|
+
try {
|
|
603
|
+
fs.mkdirSync(TURNS_DIR, { recursive: true, mode: 0o700 });
|
|
604
|
+
writeAtomic(TURNS_FILE, JSON.stringify({ deck: DECK_FILE === "index.html" ? path.basename(DECK_DIR) : DECK_FILE, pid: process.pid, turns: turnTimes }));
|
|
605
|
+
} catch (e) { console.error(e.message); }
|
|
606
|
+
}
|
|
607
|
+
/* The other decks whose assistant worked in this folder at some time since start: their names */
|
|
608
|
+
function othersWorkedSince(start) {
|
|
609
|
+
const now = Date.now(), names = [];
|
|
610
|
+
let files = [];
|
|
611
|
+
try { files = fs.readdirSync(TURNS_DIR).filter((f) => f.endsWith(".json") && f !== `${deckKey}.json`); } catch {}
|
|
612
|
+
for (const f of files) {
|
|
613
|
+
try {
|
|
614
|
+
const o = JSON.parse(fs.readFileSync(path.join(TURNS_DIR, f), "utf8"));
|
|
615
|
+
let alive = true;
|
|
616
|
+
try { process.kill(o.pid, 0); } catch (e) { alive = e.code === "EPERM"; }
|
|
617
|
+
// a turn still open in a server that is gone ended when that server did, at the latest
|
|
618
|
+
if (o.turns.some((x) => (x.end ?? (alive ? now : x.start)) >= start)) names.push(o.deck);
|
|
619
|
+
} catch {}
|
|
620
|
+
}
|
|
621
|
+
return names;
|
|
527
622
|
}
|
|
528
623
|
/* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
|
|
529
624
|
or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
|
|
@@ -594,9 +689,25 @@ function retire(agent) {
|
|
|
594
689
|
retiring.add(stopped);
|
|
595
690
|
stopped.finally(() => retiring.delete(stopped));
|
|
596
691
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
692
|
+
/* What each backend offers. Asking means starting a backend process, so it is done once per provider at a
|
|
693
|
+
time, and the answer is shared: through models.json in the state folder, for the other deck servers too,
|
|
694
|
+
for 10 minutes (Refresh models asks again). A message waits only for its own provider's list. */
|
|
695
|
+
const MODELS_FILE = path.join(HOME_DIR, "models.json"), MODELS_TTL = 10 * 60000;
|
|
696
|
+
const asking = {}; // provider -> promise of its discovery
|
|
697
|
+
function cachedModels() {
|
|
698
|
+
try {
|
|
699
|
+
const c = JSON.parse(fs.readFileSync(MODELS_FILE, "utf8"));
|
|
700
|
+
return Date.now() - c.time < MODELS_TTL && c.catalog && typeof c.catalog === "object" ? c.catalog : {};
|
|
701
|
+
} catch { return {}; }
|
|
702
|
+
}
|
|
703
|
+
function cacheModels() {
|
|
704
|
+
const ok = Object.fromEntries(Object.entries(catalog).filter(([, c]) => c.models.length && !c.error));
|
|
705
|
+
if (!Object.keys(ok).length) return;
|
|
706
|
+
try { writeAtomic(MODELS_FILE, JSON.stringify({ time: Date.now(), catalog: { ...cachedModels(), ...ok } })); } catch {}
|
|
707
|
+
}
|
|
708
|
+
function discoverProvider(provider) {
|
|
709
|
+
if (asking[provider]) return asking[provider];
|
|
710
|
+
asking[provider] = (async () => {
|
|
600
711
|
if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
|
|
601
712
|
const agent = makeSession({ provider, model: "", effort: null });
|
|
602
713
|
discoveryAgents.add(agent);
|
|
@@ -605,8 +716,36 @@ async function discoverModels() {
|
|
|
605
716
|
catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
|
|
606
717
|
} catch (e) { catalog[provider] = { models: [], error: e.message }; }
|
|
607
718
|
finally { discoveryAgents.delete(agent); retire(agent); }
|
|
608
|
-
})
|
|
609
|
-
return
|
|
719
|
+
})().finally(() => { delete asking[provider]; });
|
|
720
|
+
return asking[provider];
|
|
721
|
+
}
|
|
722
|
+
/* providers: which to ask; fresh: ask the backends even when another deck server asked lately */
|
|
723
|
+
async function discoverModels({ providers = ["claude", "codex"], fresh = false } = {}) {
|
|
724
|
+
const cached = fresh ? {} : cachedModels();
|
|
725
|
+
await Promise.all(providers.map((p) => {
|
|
726
|
+
if (cached[p]?.models?.length && providerAvailable(p).available) { catalog[p] = cached[p]; return null; }
|
|
727
|
+
return discoverProvider(p);
|
|
728
|
+
}));
|
|
729
|
+
cacheModels();
|
|
730
|
+
pushStatus();
|
|
731
|
+
return catalog;
|
|
732
|
+
}
|
|
733
|
+
/* The model and effort as they will be used, once the backend has said what it offers: a saved or given
|
|
734
|
+
model it does not offer gives way to its default model (and the chat says so), an effort the model does
|
|
735
|
+
not offer to the model's preferred or default one. Shown before the first message is sent. */
|
|
736
|
+
function settle() {
|
|
737
|
+
const { models, error } = catalog[settings.provider];
|
|
738
|
+
if (!models.length || error) return; // discovery failed: its error answers the next message
|
|
739
|
+
let model = models.find((m) => m.id === settings.model);
|
|
740
|
+
if (!model) {
|
|
741
|
+
model = models.find((m) => m.isDefault) || models.find((m) => m.curated) || models[0];
|
|
742
|
+
if (settings.model) addEntry({ kind: "notice", text: `The model "${settings.model}" is not available from ${providerName(settings.provider)} now: using ${model.label} instead.` });
|
|
743
|
+
Object.assign(settings, { model: model.id, effort: null });
|
|
744
|
+
}
|
|
745
|
+
if (!model.efforts.includes(settings.effort)) {
|
|
746
|
+
const preferred = effortPreferences[`${settings.provider}:${model.id}`];
|
|
747
|
+
settings.effort = model.efforts.includes(preferred) ? preferred : model.defaultEffort ?? null;
|
|
748
|
+
}
|
|
610
749
|
}
|
|
611
750
|
async function ensureSession() {
|
|
612
751
|
if (starting) return starting;
|
|
@@ -614,17 +753,37 @@ async function ensureSession() {
|
|
|
614
753
|
const available = providerAvailable(settings.provider);
|
|
615
754
|
if (!available.available) throw new Error(available.reason);
|
|
616
755
|
starting = (async () => {
|
|
617
|
-
if (!catalog[settings.provider].models.length) await discoverModels();
|
|
756
|
+
if (!catalog[settings.provider].models.length) await discoverModels({ providers: [settings.provider] });
|
|
757
|
+
settle();
|
|
618
758
|
Object.assign(settings, selectModel(catalog, settings, {}));
|
|
759
|
+
// a deck without a choice of its own keeps the one its conversation starts with, not the next one another
|
|
760
|
+
// deck saves in config.json; a run-only --provider or --model is not kept
|
|
761
|
+
if (!deckSettings) {
|
|
762
|
+
deckSettings = PROVIDER || MODEL ? { ...ownSettings } : { provider: settings.provider, model: settings.model, effort: settings.effort };
|
|
763
|
+
saveChat();
|
|
764
|
+
}
|
|
619
765
|
const agent = makeSession({ ...settings });
|
|
620
766
|
session = agent;
|
|
621
|
-
try {
|
|
767
|
+
try {
|
|
768
|
+
await agent.start(state.sessionId);
|
|
769
|
+
// the session's own start lists the models too: the freshest list there is
|
|
770
|
+
if (agent.models?.length) { catalog[settings.provider] = { models: curateModels(settings.provider, agent.models), error: null }; cacheModels(); }
|
|
771
|
+
if (session === agent) pushStatus();
|
|
772
|
+
}
|
|
622
773
|
catch (e) { retire(agent); throw e; }
|
|
623
774
|
})().finally(() => { starting = null; });
|
|
624
775
|
return starting;
|
|
625
776
|
}
|
|
777
|
+
function providerName(p) { return p === "codex" ? "OpenAI / ChatGPT" : "Claude"; }
|
|
778
|
+
/* config.json as it is now (another deck may have written it since this one started), with this choice and
|
|
779
|
+
its effort preferences merged in, in one atomic write */
|
|
780
|
+
function saveConfig(choice, preferences) {
|
|
781
|
+
const now = readConfig();
|
|
782
|
+
writeAtomic(CONFIG_FILE, JSON.stringify({ ...now, ...choice, effortPreferences: { ...preferencesOf(now), ...preferences } }, null, 2) + "\n");
|
|
783
|
+
}
|
|
626
784
|
async function changeSettings(requested) {
|
|
627
785
|
if (state.busy || starting) throw new Error("Wait until the current request finishes before switching.");
|
|
786
|
+
effortPreferences = { ...effortPreferences, ...preferencesOf(readConfig()) }; // as other decks left them
|
|
628
787
|
const choice = selectModel(catalog, settings, requested, effortPreferences);
|
|
629
788
|
if (Object.keys(choice).every(k => choice[k] === settings[k])) return;
|
|
630
789
|
const changingProvider = choice.provider !== settings.provider;
|
|
@@ -639,21 +798,22 @@ async function changeSettings(requested) {
|
|
|
639
798
|
if (!candidate.running) throw new Error("The new backend stopped before it was ready.");
|
|
640
799
|
const summary = changingProvider && requested.handover === "summary" ? handoverSummary(state.chat) : "";
|
|
641
800
|
if (changingProvider && state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
|
|
642
|
-
const
|
|
643
|
-
if (settings.effort)
|
|
644
|
-
if (choice.effort)
|
|
645
|
-
|
|
646
|
-
effortPreferences =
|
|
801
|
+
const changed = {}; // only the preferences this choice sets: the model left, and the model chosen
|
|
802
|
+
if (settings.effort) changed[`${settings.provider}:${settings.model}`] = settings.effort;
|
|
803
|
+
if (choice.effort) changed[`${choice.provider}:${choice.model}`] = choice.effort;
|
|
804
|
+
try { saveConfig(choice, changed); } catch (e) { console.error(e.message); }
|
|
805
|
+
effortPreferences = { ...effortPreferences, ...changed };
|
|
647
806
|
Object.assign(settings, choice);
|
|
807
|
+
deckSettings = { ...choice };
|
|
648
808
|
state.provider = choice.provider;
|
|
649
|
-
if (changingProvider) { state.sessionId = candidate.sessionId || null; state.handover = summary; state.model = null; }
|
|
809
|
+
if (changingProvider) { state.sessionId = candidate.sessionId || null; state.handover = summary; state.model = null; state.conversation++; }
|
|
650
810
|
if (reuse) candidate.settings = choice;
|
|
651
811
|
session = candidate;
|
|
652
812
|
if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
|
|
653
813
|
if (!reuse) retire(old);
|
|
654
814
|
saveChat();
|
|
655
|
-
addEntry({ kind: "notice", text: changingProvider
|
|
656
|
-
? `Switched to ${choice.provider
|
|
815
|
+
addEntry({ kind: "notice", ...(changingProvider && !summary ? { fresh: true } : {}), text: changingProvider
|
|
816
|
+
? `Switched to ${providerName(choice.provider)} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
|
|
657
817
|
: `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
|
|
658
818
|
} catch (e) { if (session !== candidate) retire(candidate); throw e; }
|
|
659
819
|
finally { state.busy = false; state.switching = false; pushStatus(); }
|
|
@@ -666,11 +826,13 @@ function toolSummary(name, input = {}) {
|
|
|
666
826
|
const r = path.relative(DECK_DIR, p);
|
|
667
827
|
return !r || r.startsWith("..") ? path.basename(p) : r;
|
|
668
828
|
};
|
|
829
|
+
// a picture in the screenshots folder: the chat shows it
|
|
830
|
+
const shotOf = (p) => (p && realOr(path.dirname(path.resolve(p))) === realOr(SHOT_DIR) && SHOT_NAME.test(path.basename(p)) ? { shot: path.basename(p) } : {});
|
|
669
831
|
switch (name) {
|
|
670
|
-
case "pindeck_read": return { name: "Read", detail: base(input.path) };
|
|
832
|
+
case "pindeck_read": return { name: "Read", detail: base(input.path), ...shotOf(input.path) };
|
|
671
833
|
case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
|
|
672
834
|
case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
|
|
673
|
-
case "Read": return { name: "Read", detail: base(input.file_path) };
|
|
835
|
+
case "Read": return { name: "Read", detail: base(input.file_path), ...shotOf(input.file_path) };
|
|
674
836
|
case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
|
|
675
837
|
case "Bash": {
|
|
676
838
|
// a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
|
|
@@ -686,6 +848,15 @@ function toolSummary(name, input = {}) {
|
|
|
686
848
|
}
|
|
687
849
|
}
|
|
688
850
|
const toolEntries = new Map(); // tool_use_id -> entry
|
|
851
|
+
// the tools that write a file, and the argument that names it (Claude's, then those of PinDeck's Codex bridge)
|
|
852
|
+
const WRITES = { Edit: "file_path", MultiEdit: "file_path", Write: "file_path", NotebookEdit: "notebook_path",
|
|
853
|
+
pindeck_edit: "path", pindeck_write: "path" };
|
|
854
|
+
const DECK_REAL = realOr(DECK_DIR);
|
|
855
|
+
const deckRel = (p) => {
|
|
856
|
+
const abs = path.resolve(DECK_DIR, p);
|
|
857
|
+
const rel = path.relative(abs.startsWith(DECK_REAL + path.sep) ? DECK_REAL : DECK_DIR, abs);
|
|
858
|
+
return rel.split(path.sep).join("/");
|
|
859
|
+
};
|
|
689
860
|
function handleEvent(ev) {
|
|
690
861
|
if (ev.type === "system" && ev.subtype === "init") {
|
|
691
862
|
state.model = ev.model || state.model;
|
|
@@ -695,7 +866,11 @@ function handleEvent(ev) {
|
|
|
695
866
|
} else if (ev.type === "assistant" && state.turn) {
|
|
696
867
|
for (const b of ev.message?.content || []) {
|
|
697
868
|
if (b.type === "text" && b.text.trim()) addEntry({ kind: "assistant", text: b.text.trim() });
|
|
698
|
-
if (b.type === "tool_use")
|
|
869
|
+
if (b.type === "tool_use") {
|
|
870
|
+
toolEntries.set(b.id, addEntry({ kind: "tool", ...toolSummary(b.name, b.input) }));
|
|
871
|
+
const file = WRITES[b.name] && b.input?.[WRITES[b.name]];
|
|
872
|
+
if (typeof file === "string") state.turn.touched.add(deckRel(file));
|
|
873
|
+
}
|
|
699
874
|
}
|
|
700
875
|
} else if (ev.type === "user" && state.turn) {
|
|
701
876
|
for (const b of ev.message?.content || []) {
|
|
@@ -717,7 +892,9 @@ function handleEvent(ev) {
|
|
|
717
892
|
function startTurn(promptText, userEntry) {
|
|
718
893
|
// every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
|
|
719
894
|
// takes back all it changed
|
|
720
|
-
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan:
|
|
895
|
+
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), gitConfig: readDeck(".git/config"), scan: scanDeck(), touched: new Set(),
|
|
896
|
+
time: { start: Date.now(), end: null } };
|
|
897
|
+
logTurnTime(state.turn.time);
|
|
721
898
|
state.busy = true;
|
|
722
899
|
pushStatus();
|
|
723
900
|
const turn = state.turn;
|
|
@@ -744,9 +921,26 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
744
921
|
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that the assistant created
|
|
745
922
|
restored.push(f);
|
|
746
923
|
}
|
|
924
|
+
// .git/config can make git run commands (filters, fsmonitor), and PinDeck runs git after the id pass: an
|
|
925
|
+
// assistant must not change it, by whatever path or spelling it got there
|
|
926
|
+
if (readDeck(".git/config") !== turn.gitConfig) {
|
|
927
|
+
try {
|
|
928
|
+
if (turn.gitConfig == null) fs.rmSync(path.join(DECK_DIR, ".git/config"), { force: true });
|
|
929
|
+
else fs.writeFileSync(path.join(DECK_DIR, ".git/config"), turn.gitConfig);
|
|
930
|
+
restored.push(".git/config");
|
|
931
|
+
} catch (e) { console.error(e.message); }
|
|
932
|
+
}
|
|
747
933
|
toolEntries.clear();
|
|
748
934
|
const eid = runEidPass();
|
|
749
|
-
const
|
|
935
|
+
const found = scanChanges(turn.scan, scanDeck());
|
|
936
|
+
// another deck's assistant worked in this folder meanwhile: only what this one wrote (and its deck file)
|
|
937
|
+
const others = othersWorkedSince(turn.time.start);
|
|
938
|
+
turn.time.end = Date.now();
|
|
939
|
+
logTurnTime(turn.time);
|
|
940
|
+
const ours = (rel) => !others.length || rel === DECK_FILE || turn.touched.has(rel);
|
|
941
|
+
const changed = Object.fromEntries(Object.entries(found.changed).filter(([rel]) => ours(rel)));
|
|
942
|
+
const tooLarge = found.tooLarge.filter(ours);
|
|
943
|
+
const notOurs = [...Object.keys(found.changed), ...found.tooLarge].filter((rel) => !ours(rel));
|
|
750
944
|
const before = {}, after = {};
|
|
751
945
|
for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
|
|
752
946
|
// the deck file and theme.css first, then the rest by name
|
|
@@ -762,11 +956,14 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
762
956
|
deletedFiles.push({ rel, data: fs.readFileSync(abs) });
|
|
763
957
|
fs.rmSync(abs, { force: true });
|
|
764
958
|
}
|
|
765
|
-
const t = { id: turn.id, kind: "agent", provider: settings.provider, before, after, files, deletedFiles, undone: false };
|
|
959
|
+
const t = { id: turn.id, kind: "agent", provider: settings.provider, conversation: state.conversation, before, after, files, deletedFiles, undone: false };
|
|
766
960
|
if (files.length) { state.turns.push(t); state.redo = []; }
|
|
767
961
|
addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
|
|
768
962
|
denials, restored, newIds: eid.assigned.length });
|
|
769
963
|
if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
|
|
964
|
+
if (notOurs.length) addEntry({ kind: "notice", text: `${notOurs.join(", ")} changed during this turn while the assistant of ` +
|
|
965
|
+
`${others.join(", ")} was working in this folder too, and the assistant here did not say it wrote ${notOurs.length > 1 ? "them" : "it"}: ` +
|
|
966
|
+
`not part of this change (Undo leaves ${notOurs.length > 1 ? "them" : "it"} as ${notOurs.length > 1 ? "they are" : "it is"}).` });
|
|
770
967
|
if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
|
|
771
968
|
`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"}.` });
|
|
772
969
|
pushStatus();
|
|
@@ -812,7 +1009,7 @@ function formatAttachment(a) {
|
|
|
812
1009
|
const formatDrop = (a) => (a.droppedAt?.slide ? ` Dropped onto slide ${a.droppedAt.slide.index + 1} (${a.droppedAt.slide.eid}) at ` +
|
|
813
1010
|
`x=${a.droppedAt.point.x} y=${a.droppedAt.point.y}${a.droppedAt.eid ? `, over ${a.droppedAt.eid}` : ""}: probably where the user wants it.` : "");
|
|
814
1011
|
|
|
815
|
-
function buildPrompt({ text: userText, pins = [], selection, layouts = [], current, screenshot, attachments = [] }) {
|
|
1012
|
+
function buildPrompt({ text: userText, pins = [], selection, layouts = [], current, screenshot, shotFailed, attachments = [] }) {
|
|
816
1013
|
const ctx = [];
|
|
817
1014
|
if (current) ctx.push(`The user is looking at slide ${current.index + 1} (${current.eid}).`);
|
|
818
1015
|
if (state.notes.length) {
|
|
@@ -825,7 +1022,9 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
825
1022
|
`box x=${selection.box.x} y=${selection.box.y} w=${selection.box.w} h=${selection.box.h} on slide ${selection.slide.index + 1}.`);
|
|
826
1023
|
if (pins.length) ctx.push("Pins (numbered clicks; the user refers to them by number):\n" + pins.map(formatPin).join("\n"));
|
|
827
1024
|
for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
|
|
828
|
-
if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
|
|
1025
|
+
if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide}${screenshot.pins ? " with the pin markers" : ""}: ${screenshot.path} (Read it).`);
|
|
1026
|
+
if (shotFailed) ctx.push(`The user asked for a screenshot of slide ${shotFailed.slide}, but it could not be taken (${shotFailed.error}). ` +
|
|
1027
|
+
`Take one with \`${CLI_CMD} shot ${shotFailed.slide}\` if you need to see the slide.`);
|
|
829
1028
|
if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
|
|
830
1029
|
`use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
|
|
831
1030
|
return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
|
|
@@ -837,9 +1036,11 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
837
1036
|
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
|
|
838
1037
|
const bad = (t, status = 400) => ({ status, text: t });
|
|
839
1038
|
if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
|
|
1039
|
+
// compared by real path: the Codex bridge names files by theirs, and the deck's path may go through a
|
|
1040
|
+
// symlink (~/Dropbox, /tmp on macOS)
|
|
840
1041
|
const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
|
|
841
1042
|
.map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
|
|
842
|
-
[DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
|
|
1043
|
+
[DECK_DIR, PDF_DIR].map(realOr).some((d) => realOr(f).startsWith(d + path.sep)));
|
|
843
1044
|
if (!abs) return bad(`No PDF "${file}": use the path from the attached files, or a .pdf in the deck folder.`, 404);
|
|
844
1045
|
const info = await pdfInfo(abs, STATE_DIR);
|
|
845
1046
|
const n = Number(page);
|
|
@@ -894,13 +1095,19 @@ function scheduleFlush() {
|
|
|
894
1095
|
if (external) {
|
|
895
1096
|
state.notes = [...new Set([...state.notes,
|
|
896
1097
|
"The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
|
|
897
|
-
// new elements from a hand edit get ids once the file is quiet:
|
|
1098
|
+
// new elements from a hand edit get ids once the file is quiet: present, and unchanged since (see pass)
|
|
898
1099
|
clearTimeout(eidTimer);
|
|
899
1100
|
const seen = hash(readDeck(DECK_FILE) ?? "");
|
|
1101
|
+
// a program that writes the file in pieces, with a pause, must not find it replaced halfway (its rest
|
|
1102
|
+
// would go to the old file): unchanged for 2 s, and complete (ending in </html>, if the deck did)
|
|
900
1103
|
const pass = () => {
|
|
901
1104
|
if (state.busy) return;
|
|
902
1105
|
const now = readDeck(DECK_FILE);
|
|
903
1106
|
if (now == null || hash(now) !== seen) return; // still being written; its own event comes
|
|
1107
|
+
let age = 0;
|
|
1108
|
+
try { age = Date.now() - fs.statSync(path.join(DECK_DIR, DECK_FILE)).mtimeMs; } catch { return; }
|
|
1109
|
+
if (age < 2000) { eidTimer = setTimeout(pass, 2000 - age); return; }
|
|
1110
|
+
if (ENDS_HTML.test(deckAtStart) && !ENDS_HTML.test(now)) return; // incomplete: its next save comes
|
|
904
1111
|
runEidPass();
|
|
905
1112
|
};
|
|
906
1113
|
eidTimer = setTimeout(pass, 800);
|
|
@@ -986,22 +1193,40 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
|
|
|
986
1193
|
}
|
|
987
1194
|
|
|
988
1195
|
/* What deleting an element would remove, and which of its files nothing else uses. Files outside the
|
|
989
|
-
deck folder, in vendor/, or still referenced elsewhere (
|
|
1196
|
+
deck folder, in vendor/, or still referenced elsewhere (the rest of the deck, theme.css, brand.css, or
|
|
1197
|
+
another deck in the folder or one folder down, which may share figures/) are never offered. */
|
|
990
1198
|
function deletePlan(eid) {
|
|
991
1199
|
const html = readDeck(DECK_FILE);
|
|
992
1200
|
const target = deleteTarget(html, eid);
|
|
993
1201
|
if (!target) return null;
|
|
994
1202
|
const rest = removeElement(html, target.eid);
|
|
995
1203
|
const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
|
|
1204
|
+
const others = otherDeckRefs();
|
|
996
1205
|
const files = fileRefs(target.outer).flatMap((ref) => {
|
|
997
1206
|
const abs = path.resolve(DECK_DIR, ref);
|
|
998
1207
|
const rel = path.relative(DECK_DIR, abs).split(path.sep).join("/");
|
|
999
1208
|
if (rel.startsWith("..") || rel.startsWith("vendor/") || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) return [];
|
|
1000
|
-
|
|
1001
|
-
|
|
1209
|
+
try { deckPath(rel); } catch { return []; } // through a symlink out of the deck: never offered
|
|
1210
|
+
const usedBy = others.filter((o) => o.uses(abs)).map((o) => o.name);
|
|
1211
|
+
const usedElsewhere = fileRefs(rest).some((r) => path.resolve(DECK_DIR, r) === abs) || css.includes(ref) || css.includes(rel) || usedBy.length > 0;
|
|
1212
|
+
return [{ rel, bytes: fs.statSync(abs).size, usedElsewhere, usedBy }];
|
|
1002
1213
|
});
|
|
1003
1214
|
return { ...target, html: rest, files };
|
|
1004
1215
|
}
|
|
1216
|
+
/* The other decks of this folder and one folder down, each with a test of whether it refers to a file: in its
|
|
1217
|
+
HTML (relative to its own folder), or in the stylesheets beside it */
|
|
1218
|
+
function otherDeckRefs() {
|
|
1219
|
+
const own = path.join(DECK_DIR, DECK_FILE);
|
|
1220
|
+
const decks = [...fs.readdirSync(DECK_DIR).filter((f) => /\.html$/i.test(f)).map((f) => ({ dir: DECK_DIR, file: f })),
|
|
1221
|
+
...listDecks([DECK_DIR]).filter((d) => d.dir !== DECK_DIR)].filter((d) => path.join(d.dir, d.file) !== own);
|
|
1222
|
+
return decks.map(({ dir, file }) => {
|
|
1223
|
+
const read = (f) => { try { return fs.statSync(f).size <= 5e6 ? fs.readFileSync(f, "utf8") : ""; } catch { return ""; } };
|
|
1224
|
+
const refs = new Set(fileRefs(read(path.join(dir, file))).map((r) => { try { return path.resolve(dir, decodeURI(r)); } catch { return path.resolve(dir, r); } }));
|
|
1225
|
+
const css = dir === DECK_DIR ? "" : read(path.join(dir, "theme.css")) + read(path.join(dir, "brand.css"));
|
|
1226
|
+
return { name: path.relative(DECK_DIR, path.join(dir, file)).split(path.sep).join("/"),
|
|
1227
|
+
uses: (abs) => refs.has(abs) || (!!css && css.includes(path.relative(dir, abs).split(path.sep).join("/"))) };
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1005
1230
|
|
|
1006
1231
|
/* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
|
|
1007
1232
|
Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
|
|
@@ -1016,6 +1241,8 @@ function undoConflicts(t, direction) {
|
|
|
1016
1241
|
// ones (which must be unchanged); redo the other way round
|
|
1017
1242
|
const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
|
|
1018
1243
|
const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
|
|
1244
|
+
// a file to bring back or remove through a symlink out of the deck: not done (deckPath), so not begun
|
|
1245
|
+
for (const { rel } of [...comeBack, ...go]) { try { deckPath(rel); } catch { files.push(rel); } }
|
|
1019
1246
|
for (const { rel, data } of comeBack) {
|
|
1020
1247
|
const abs = path.join(DECK_DIR, rel);
|
|
1021
1248
|
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
@@ -1031,6 +1258,13 @@ function conflict(res, direction, files) {
|
|
|
1031
1258
|
error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
|
|
1032
1259
|
`it would overwrite those changes. Reload to see the files as they are.` });
|
|
1033
1260
|
}
|
|
1261
|
+
/* The chat entry of a change keeps whether it is undone: the undo history ends with the run, the chat does not */
|
|
1262
|
+
function markUndone(t) {
|
|
1263
|
+
const e = state.chat.find((x) => x.turnId === t.id && (x.kind === "turn" || x.kind === "edit"));
|
|
1264
|
+
if (e) { e.undone = t.undone; saveChat(); }
|
|
1265
|
+
}
|
|
1266
|
+
// an assistant turn made in the conversation going on now (not before a new chat or a provider switch)
|
|
1267
|
+
const byThisChat = (t) => t.conversation === state.conversation;
|
|
1034
1268
|
function undo(res) {
|
|
1035
1269
|
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
1036
1270
|
const t = liveTurns().at(-1);
|
|
@@ -1039,12 +1273,15 @@ function undo(res) {
|
|
|
1039
1273
|
if (diverged.length) return conflict(res, "undo", diverged);
|
|
1040
1274
|
for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
|
|
1041
1275
|
for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
|
|
1042
|
-
for (const { rel } of t.addedFiles || [])
|
|
1276
|
+
for (const { rel } of t.addedFiles || []) removeDeckFile(rel); // imported figures go
|
|
1043
1277
|
t.undone = true;
|
|
1278
|
+
markUndone(t);
|
|
1044
1279
|
state.redo.push(t);
|
|
1280
|
+
const names = t.files.map((f) => f.file);
|
|
1045
1281
|
state.notes.push(t.kind === "manual"
|
|
1046
1282
|
? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
|
|
1047
|
-
: `The user undid your change from turn
|
|
1283
|
+
: `The user undid ${byThisChat(t) ? "your change from turn" : "a change made before this conversation began, in turn"} ${t.id}; ` +
|
|
1284
|
+
`${names.join(" and ")} ${names.length > 1 ? "are" : "is"} back to the state before it. Re-read before editing.`);
|
|
1048
1285
|
broadcast("undone", { turnId: t.id, undone: true });
|
|
1049
1286
|
pushStatus();
|
|
1050
1287
|
return json(res, 200, {});
|
|
@@ -1053,7 +1290,7 @@ function undo(res) {
|
|
|
1053
1290
|
/* ---- PowerPoint import -----------------------------------------------------------------------------
|
|
1054
1291
|
Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
|
|
1055
1292
|
makes a PDF of it, from which come the thumbnails and, on import, the pictures of diagrams.
|
|
1056
|
-
Only the latest upload is kept, in ~/.pindeck/decks/<deck>/imports/. */
|
|
1293
|
+
Only the latest upload is kept, in ~/.pindeck-v3/decks/<deck>/imports/. */
|
|
1057
1294
|
const IMPORT_DIR = path.join(STATE_DIR, "imports");
|
|
1058
1295
|
const IMPORT_MAX = 500 * 1024 * 1024;
|
|
1059
1296
|
let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
|
|
@@ -1161,80 +1398,40 @@ async function refineImport(imp, pdf, plans, eids, first) {
|
|
|
1161
1398
|
|
|
1162
1399
|
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
1163
1400
|
const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
|
|
1164
|
-
/*
|
|
1165
|
-
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
/* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
|
|
1169
|
-
where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
|
|
1170
|
-
code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
|
|
1171
|
-
and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
|
|
1172
|
-
carries this server's port. */
|
|
1173
|
-
const loginCodes = new Map(); // code -> expiry (ms)
|
|
1174
|
-
function loginCode(ttl) {
|
|
1175
|
-
const now = Date.now();
|
|
1176
|
-
for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
|
|
1177
|
-
const code = crypto.randomBytes(16).toString("hex");
|
|
1178
|
-
loginCodes.set(code, now + ttl);
|
|
1179
|
-
return code;
|
|
1180
|
-
}
|
|
1181
|
-
const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
|
|
1182
|
-
const cookieName = () => `pindeck-${PORT}`;
|
|
1183
|
-
const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
|
|
1184
|
-
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
|
|
1185
|
-
function cookie(req, name) {
|
|
1186
|
-
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
1187
|
-
const i = part.indexOf("=");
|
|
1188
|
-
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
1189
|
-
}
|
|
1190
|
-
return null;
|
|
1191
|
-
}
|
|
1192
|
-
const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
|
|
1193
|
-
const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
|
|
1194
|
-
<body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
|
|
1195
|
-
<h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
|
|
1196
|
-
for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
|
|
1197
|
-
PinDeck checkout).</p>`;
|
|
1198
|
-
/* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
|
|
1199
|
-
start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
|
|
1200
|
-
function login(res, url) {
|
|
1201
|
-
const code = url.searchParams.get("code") || "";
|
|
1202
|
-
const exp = loginCodes.get(code);
|
|
1203
|
-
loginCodes.delete(code);
|
|
1204
|
-
if (!exp || exp < Date.now()) {
|
|
1205
|
-
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1206
|
-
return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
|
|
1207
|
-
}
|
|
1208
|
-
let next = url.searchParams.get("next") || "/";
|
|
1209
|
-
if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
|
|
1210
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
1211
|
-
"set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
|
|
1212
|
-
const to = JSON.stringify(next).replace(/</g, "\\u003c");
|
|
1213
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
|
|
1214
|
-
}
|
|
1401
|
+
/* Who may read the editor, the deck and the API: see lib/login.mjs. Behind the front, browsers log in
|
|
1402
|
+
there, and the front sends this server's token in a header. */
|
|
1403
|
+
const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
|
|
1215
1404
|
/* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
|
|
1216
|
-
its command line:
|
|
1405
|
+
its command line. It never logs in: its targets are the deck's pages under /v/, which need no cookie, so a
|
|
1406
|
+
deck's script there has no way to the API. Chrome's command-line screenshot, DOM dump and print do not
|
|
1407
|
+
work on a page with a sandbox policy, so the page it asks for is served without one, once: the target
|
|
1408
|
+
carries a key that works for one request (headlessKeys). */
|
|
1409
|
+
const headlessKeys = new Set();
|
|
1217
1410
|
function chromeStart(profile, target) {
|
|
1218
1411
|
fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
1219
1412
|
const start = path.join(profile, "pindeck-start.html");
|
|
1220
|
-
const
|
|
1413
|
+
const key = crypto.randomBytes(16).toString("hex"), u = new URL(target);
|
|
1414
|
+
headlessKeys.add(key);
|
|
1415
|
+
setTimeout(() => headlessKeys.delete(key), 120000).unref();
|
|
1416
|
+
u.searchParams.set("headless", key);
|
|
1417
|
+
const to = u.href.replace(/&/g, "&").replace(/"/g, """);
|
|
1221
1418
|
fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
|
|
1222
1419
|
return pathToFileURL(start).href;
|
|
1223
1420
|
}
|
|
1224
1421
|
|
|
1225
1422
|
/* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
|
|
1226
1423
|
Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
|
|
1227
|
-
function serveFile(res, file, transform, req) {
|
|
1424
|
+
function serveFile(res, file, transform, req, extra = {}) {
|
|
1228
1425
|
const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
|
|
1229
1426
|
if (transform) return fs.readFile(file, (err, data) => {
|
|
1230
1427
|
if (err) return text(res, 404, "Not found");
|
|
1231
1428
|
const out = Buffer.from(transform(String(data)));
|
|
1232
|
-
res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
|
|
1429
|
+
res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store", ...extra });
|
|
1233
1430
|
res.end(out);
|
|
1234
1431
|
});
|
|
1235
1432
|
fs.stat(file, (err, st) => {
|
|
1236
1433
|
if (err || !st.isFile()) return text(res, 404, "Not found");
|
|
1237
|
-
const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
|
|
1434
|
+
const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store", ...extra };
|
|
1238
1435
|
let start = 0, end = st.size - 1, code = 200;
|
|
1239
1436
|
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
|
|
1240
1437
|
if (range && (range[1] || range[2])) {
|
|
@@ -1258,14 +1455,29 @@ function serveFile(res, file, transform, req) {
|
|
|
1258
1455
|
}
|
|
1259
1456
|
|
|
1260
1457
|
let ready = false;
|
|
1458
|
+
const NEEDS_DECK = /^\/api\/(message|slides|layout|shot|pdf|pdf-figure|edit|text|split|remove|notes|delete|delete-info|source|slide-hidden|move-slide|add-slide|import-apply|export)$/;
|
|
1459
|
+
const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
|
|
1460
|
+
/* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
|
|
1461
|
+
read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret. */
|
|
1462
|
+
const SCRIPTABLE = /\.(html|svg|xml)$/i;
|
|
1463
|
+
const deckHeaders = (f, viewing) => ({ ...(SCRIPTABLE.test(f) ? { "content-security-policy": SANDBOX } : {}),
|
|
1464
|
+
...(viewing ? { "access-control-allow-origin": "*" } : {}) });
|
|
1261
1465
|
const server = http.createServer(async (req, res) => {
|
|
1262
1466
|
if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
|
|
1263
1467
|
const host = String(req.headers.host || "").toLowerCase();
|
|
1264
1468
|
if (!goodHost(host)) return text(res, 403, "Bad host");
|
|
1265
|
-
const url = new URL(req.url, ORIGIN);
|
|
1266
|
-
const p = url.pathname;
|
|
1267
1469
|
try {
|
|
1470
|
+
const url = new URL(req.url, ORIGIN);
|
|
1471
|
+
let p = url.pathname;
|
|
1268
1472
|
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
1473
|
+
// /v/<token>/deck/... and /v/<token>/files/...: the deck's pages and files, read-only, nothing else
|
|
1474
|
+
let viewing = false;
|
|
1475
|
+
if (p.startsWith("/v/")) {
|
|
1476
|
+
if (!p.startsWith(VIEW + "/") || (req.method !== "GET" && req.method !== "HEAD")) return text(res, 403, "Forbidden");
|
|
1477
|
+
p = p.slice(VIEW.length);
|
|
1478
|
+
if (!p.startsWith("/deck/") && !p.startsWith("/files/")) return text(res, 404, "Not found");
|
|
1479
|
+
viewing = true;
|
|
1480
|
+
}
|
|
1269
1481
|
// UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
|
|
1270
1482
|
const ok = authed(req);
|
|
1271
1483
|
if (req.method === "GET" && (p === "/" || p === "/index.html")) {
|
|
@@ -1276,18 +1488,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
1276
1488
|
const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
|
|
1277
1489
|
return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
1278
1490
|
}
|
|
1279
|
-
if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
|
|
1491
|
+
if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok && !viewing) return text(res, 403, "Not logged in");
|
|
1280
1492
|
let snap;
|
|
1281
1493
|
if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
|
|
1282
1494
|
const html = snapshots.get(snap[1]);
|
|
1283
1495
|
if (!html) return text(res, 404, "Not found");
|
|
1284
|
-
|
|
1496
|
+
// a static print layout (its scripts were removed): no scripts at all, instead of the sandbox Chrome cannot print
|
|
1497
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...deckHeaders(".css", viewing),
|
|
1498
|
+
"content-security-policy": "script-src 'none'" });
|
|
1285
1499
|
return res.end(html);
|
|
1286
1500
|
}
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
1501
|
+
// A deck page asked for outside /v/ (the editor's frames ask for deck/?editor=main, Present for deck/):
|
|
1502
|
+
// sent there, with a relative redirect that stays under the front's /d/<deck>/. /deck/ alone is the deck
|
|
1503
|
+
// page; with an asset root that page lives under /files/, so that ../ paths reach the root.
|
|
1504
|
+
const toView = (target) => {
|
|
1505
|
+
res.writeHead(302, { location: "../".repeat(p.split("/").length - 2) + VIEW.slice(1) + target + url.search, "cache-control": "no-store" });
|
|
1506
|
+
res.end();
|
|
1507
|
+
};
|
|
1289
1508
|
if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
|
|
1290
|
-
|
|
1509
|
+
if (!viewing) return toView(DECK_PAGE);
|
|
1510
|
+
res.writeHead(302, { location: ".." + DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1291
1511
|
return res.end();
|
|
1292
1512
|
}
|
|
1293
1513
|
const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
|
|
@@ -1295,8 +1515,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1295
1515
|
if (deckPath === null) return text(res, 403, "Forbidden");
|
|
1296
1516
|
if (deckPath) {
|
|
1297
1517
|
const f = deckPath;
|
|
1518
|
+
if (!viewing && /\.html$/i.test(f)) return toView(p);
|
|
1298
1519
|
const mode = url.searchParams.get("editor");
|
|
1299
|
-
if (!mode || !/\.html
|
|
1520
|
+
if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing));
|
|
1300
1521
|
return serveFile(res, f, (s) => {
|
|
1301
1522
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
1302
1523
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
@@ -1306,7 +1527,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
1306
1527
|
return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
|
|
1307
1528
|
}
|
|
1308
1529
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
1309
|
-
})
|
|
1530
|
+
}, req, ["shot", "dump", "pdf"].includes(mode) && headlessKeys.delete(url.searchParams.get("headless"))
|
|
1531
|
+
? deckHeaders(".css", viewing) : deckHeaders(f, viewing)); // headless Chrome's page, once (see chromeStart)
|
|
1532
|
+
}
|
|
1533
|
+
if (viewing) return text(res, 404, "Not found");
|
|
1534
|
+
|
|
1535
|
+
// /shots/: a screenshot for the chat, a PNG directly in the screenshots folder (no subfolder, no symlink out)
|
|
1536
|
+
if (req.method === "GET" && p.startsWith("/shots/")) {
|
|
1537
|
+
if (!ok) return text(res, 403, "Not logged in");
|
|
1538
|
+
const name = decodeURIComponent(p.slice(7));
|
|
1539
|
+
if (!SHOT_NAME.test(name)) return text(res, 403, "Forbidden");
|
|
1540
|
+
const f = path.join(SHOT_DIR, name);
|
|
1541
|
+
let real;
|
|
1542
|
+
try { real = fs.realpathSync(f); } catch { return text(res, 404, "Not found"); }
|
|
1543
|
+
return path.dirname(real) === realOr(SHOT_DIR) ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
1310
1544
|
}
|
|
1311
1545
|
|
|
1312
1546
|
// API: token required (header or login cookie); POSTs must come from the editor page itself
|
|
@@ -1324,11 +1558,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1324
1558
|
req.on("close", () => clients.delete(res));
|
|
1325
1559
|
return;
|
|
1326
1560
|
}
|
|
1561
|
+
// the deck file gone (moved, renamed, deleted) while PinDeck runs: say so, instead of failing on it
|
|
1562
|
+
if (NEEDS_DECK.test(p) && readDeck(DECK_FILE) == null) {
|
|
1563
|
+
const why = `${path.join(DECK_DIR, DECK_FILE)} is not there any more (moved, renamed or deleted). Put it back, ` +
|
|
1564
|
+
`or start PinDeck for the deck where it is now.`;
|
|
1565
|
+
return CLI_ROUTES.test(p) ? text(res, 409, why) : json(res, 409, { code: "deck-gone", error: why });
|
|
1566
|
+
}
|
|
1327
1567
|
if (p === "/api/ping") return json(res, 200, { ok: true });
|
|
1328
1568
|
if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
|
|
1329
1569
|
if (p === "/api/status") return json(res, 200, status());
|
|
1330
1570
|
if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
|
|
1331
|
-
if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
|
|
1332
1571
|
if (p === "/api/measure-result" && req.method === "POST") {
|
|
1333
1572
|
const b = await body(req);
|
|
1334
1573
|
const w = pending.get(b.id);
|
|
@@ -1350,26 +1589,34 @@ const server = http.createServer(async (req, res) => {
|
|
|
1350
1589
|
if (!placed(b.selection)) b.selection = null;
|
|
1351
1590
|
if (!Array.isArray(b.layouts)) b.layouts = [];
|
|
1352
1591
|
if (b.current && !Number.isInteger(b.current.index)) b.current = null;
|
|
1353
|
-
const preparation = { cancelled: false };
|
|
1592
|
+
const preparation = { cancelled: false, controller: new AbortController() };
|
|
1354
1593
|
state.preparing = preparation;
|
|
1355
1594
|
state.busy = true; pushStatus();
|
|
1356
1595
|
try {
|
|
1357
|
-
let screenshot = null;
|
|
1596
|
+
let screenshot = null, shotFailed = null;
|
|
1358
1597
|
const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
|
|
1359
1598
|
if (shotSlide) {
|
|
1360
|
-
|
|
1361
|
-
|
|
1599
|
+
const pins = b.pins.filter((p) => p.slide.eid === shotSlide.eid);
|
|
1600
|
+
try { screenshot = { slide: shotSlide.index + 1, pins: pins.length, path: await takeShot(shotSlide, pins, preparation.controller.signal) }; }
|
|
1601
|
+
catch (e) { shotFailed = { slide: shotSlide.index + 1, error: e.message }; }
|
|
1602
|
+
}
|
|
1603
|
+
if (preparation.cancelled) {
|
|
1604
|
+
if (screenshot) fs.rmSync(screenshot.path, { force: true });
|
|
1605
|
+
return json(res, 409, { code: "stopped", error: "Stopped before sending the message." });
|
|
1362
1606
|
}
|
|
1363
|
-
if (preparation.cancelled) return json(res, 409, { error: "Stopped before sending the message." });
|
|
1364
1607
|
const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
|
|
1365
1608
|
// only files PinDeck itself stored can be attached
|
|
1366
1609
|
b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
|
|
1367
1610
|
b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
|
|
1368
|
-
|
|
1611
|
+
state.lastPins = b.pins;
|
|
1612
|
+
state.preparing = null;
|
|
1613
|
+
const entry = addEntry({ kind: "user", text: b.text,
|
|
1614
|
+
screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
|
|
1369
1615
|
attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
|
|
1370
1616
|
selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
|
|
1371
1617
|
pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
|
|
1372
|
-
|
|
1618
|
+
if (shotFailed) addEntry({ kind: "notice", text: `Screenshot of slide ${shotFailed.slide} failed: ${shotFailed.error}` });
|
|
1619
|
+
startTurn(buildPrompt({ ...b, screenshot, shotFailed }), entry);
|
|
1373
1620
|
return json(res, 200, { id: entry.id });
|
|
1374
1621
|
} finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
|
|
1375
1622
|
}
|
|
@@ -1396,9 +1643,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1396
1643
|
return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
|
|
1397
1644
|
}
|
|
1398
1645
|
const rel = attachPath(original);
|
|
1399
|
-
writeDeckFile(rel, buf);
|
|
1646
|
+
try { writeDeckFile(rel, buf); } catch (e) { return json(res, e.status || 500, { error: e.message }); }
|
|
1400
1647
|
state.uploads.add(rel);
|
|
1401
|
-
return json(res, 200, { path: rel, kind, bytes: buf.length, url:
|
|
1648
|
+
return json(res, 200, { path: rel, kind, bytes: buf.length, url: `deck/${rel}` });
|
|
1402
1649
|
}
|
|
1403
1650
|
if (p === "/api/upload-remove" && req.method === "POST") {
|
|
1404
1651
|
const b = await body(req);
|
|
@@ -1409,7 +1656,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1409
1656
|
}
|
|
1410
1657
|
return json(res, 200, {});
|
|
1411
1658
|
}
|
|
1412
|
-
if (p === "/api/models" && req.method === "GET") return json(res, 200, await discoverModels());
|
|
1659
|
+
if (p === "/api/models" && req.method === "GET") return json(res, 200, await discoverModels({ fresh: true })); // Refresh models
|
|
1413
1660
|
if (p === "/api/settings" && req.method === "POST") {
|
|
1414
1661
|
const b = await body(req);
|
|
1415
1662
|
if (state.busy || starting) return json(res, 409, { error: "Wait until the current request finishes before switching." });
|
|
@@ -1500,7 +1747,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1500
1747
|
catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
|
|
1501
1748
|
}
|
|
1502
1749
|
if (p === "/api/stop" && req.method === "POST") {
|
|
1503
|
-
if (state.preparing) { state.preparing.cancelled = true; return json(res, 200, {}); }
|
|
1750
|
+
if (state.preparing) { state.preparing.cancelled = true; state.preparing.controller.abort(); return json(res, 200, {}); }
|
|
1504
1751
|
if (state.busy && state.turn) {
|
|
1505
1752
|
const interruptedTurn = state.turn;
|
|
1506
1753
|
interruptedTurn.cancelled = true;
|
|
@@ -1515,7 +1762,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1515
1762
|
if (p === "/api/new-chat" && req.method === "POST") {
|
|
1516
1763
|
if (state.busy || starting) return json(res, 409, { error: "Stop the current request before starting a new chat." });
|
|
1517
1764
|
session?.stop();
|
|
1518
|
-
|
|
1765
|
+
// the conversation starts anew; the slides, and their undo and redo, stay as they are
|
|
1766
|
+
if (state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
|
|
1767
|
+
Object.assign(state, { chat: [], sessionId: null, handover: "", turn: null, busy: false, notes: [], conversation: state.conversation + 1 });
|
|
1519
1768
|
saveChat();
|
|
1520
1769
|
broadcast("reset", { chat: [] });
|
|
1521
1770
|
ensureSession().catch(e => addEntry({ kind: "notice", text: e.message }));
|
|
@@ -1542,11 +1791,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1542
1791
|
if (diverged.length) return conflict(res, "redo", diverged);
|
|
1543
1792
|
state.redo.pop();
|
|
1544
1793
|
for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
|
|
1545
|
-
for (const { rel } of t.deletedFiles || [])
|
|
1794
|
+
for (const { rel } of t.deletedFiles || []) removeDeckFile(rel);
|
|
1546
1795
|
for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
|
|
1547
1796
|
t.undone = false;
|
|
1797
|
+
markUndone(t);
|
|
1548
1798
|
state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
|
|
1549
|
-
: `The user redid your change from turn ${t.id} after undoing it.`
|
|
1799
|
+
: byThisChat(t) ? `The user redid your change from turn ${t.id} after undoing it.`
|
|
1800
|
+
: `The user redid a change made before this conversation began (turn ${t.id}): ${t.files.map((f) => f.file).join(" and ")} as that change left ${t.files.length > 1 ? "them" : "it"}. Re-read before editing.`);
|
|
1550
1801
|
broadcast("undone", { turnId: t.id, undone: false });
|
|
1551
1802
|
pushStatus();
|
|
1552
1803
|
return json(res, 200, {});
|
|
@@ -1573,7 +1824,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1573
1824
|
const turnId = recordHandEdit(before, plan.html, label,
|
|
1574
1825
|
`The user deleted ${plan.eid} (${plan.tag}) by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
|
|
1575
1826
|
{ manual: false, deletedFiles });
|
|
1576
|
-
for (const f of deletedFiles)
|
|
1827
|
+
for (const f of deletedFiles) removeDeckFile(f.rel);
|
|
1577
1828
|
return json(res, 200, { turnId, deleted: plan.eid, files: deletedFiles.map((f) => f.rel) });
|
|
1578
1829
|
}
|
|
1579
1830
|
|
|
@@ -1592,7 +1843,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1592
1843
|
return json(res, 200, { turnId });
|
|
1593
1844
|
}
|
|
1594
1845
|
if (p === "/api/source") {
|
|
1595
|
-
|
|
1846
|
+
let where = [];
|
|
1847
|
+
try { where = JSON.parse(url.searchParams.get("path") || "[]"); } catch {}
|
|
1848
|
+
const html = getInner(readDeck(DECK_FILE), url.searchParams.get("eid"), where);
|
|
1596
1849
|
return html == null ? json(res, 404, { error: "Element not found in the file." }) : json(res, 200, { html });
|
|
1597
1850
|
}
|
|
1598
1851
|
// Speaker notes of a slide: read for the notes field, saved as a hand edit
|
|
@@ -1638,9 +1891,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1638
1891
|
const before = snapshot(EDITABLE);
|
|
1639
1892
|
const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
|
|
1640
1893
|
if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
1641
|
-
|
|
1894
|
+
// the new bullet's elements get ids of their own: never a copy of one from the bullet it was split from
|
|
1895
|
+
const inner = String(b.newHtml ?? "").replace(/\sdata-eid="[^"]*"/g, "");
|
|
1896
|
+
const r2 = insertAfter(r1.html, b.eid, { tag: "li", cls: b.cls || "", inner, newEid: b.newEid });
|
|
1642
1897
|
const short = (e) => e.replace(/^[^/]+\//, "/");
|
|
1643
|
-
const turnId = recordHandEdit(before, r2.html, `New bullet after ${short(b.eid)}`,
|
|
1898
|
+
const turnId = recordHandEdit(before, assignEids(r2.html).html, `New bullet after ${short(b.eid)}`,
|
|
1644
1899
|
`The user added a bullet (${r2.eid}) after ${b.eid} by pressing Enter while editing; ${b.eid} now reads "${String(b.text || "").slice(0, 200)}".`);
|
|
1645
1900
|
return json(res, 200, { turnId, eid: r2.eid });
|
|
1646
1901
|
}
|
|
@@ -1695,9 +1950,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1695
1950
|
return text(res, 404, "Not found");
|
|
1696
1951
|
} catch (e) {
|
|
1697
1952
|
// malformed JSON or %-encoding in a request is the caller's mistake, not a server error
|
|
1698
|
-
const
|
|
1699
|
-
if (!bad) console.error(e);
|
|
1700
|
-
|
|
1953
|
+
const large = e.message === "body too large", bad = large || e instanceof SyntaxError || e instanceof URIError;
|
|
1954
|
+
if (!bad && !e.status) console.error(e);
|
|
1955
|
+
// the rest of a body too large is not read: the connection is not used again
|
|
1956
|
+
if (large && !res.headersSent) res.setHeader("connection", "close");
|
|
1957
|
+
if (!res.headersSent) text(res, e.status || (large ? 413 : bad ? 400 : 500), String(e.message || e));
|
|
1701
1958
|
}
|
|
1702
1959
|
});
|
|
1703
1960
|
|
|
@@ -1722,25 +1979,26 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1722
1979
|
if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
|
|
1723
1980
|
startWatcher();
|
|
1724
1981
|
} catch (e) {
|
|
1725
|
-
console.error(`PinDeck could not open the deck: ${e.message}`);
|
|
1982
|
+
console.error(e.plain ? e.message : `PinDeck could not open the deck: ${e.message}`);
|
|
1726
1983
|
removeServerInfo();
|
|
1727
1984
|
process.exit(1);
|
|
1728
1985
|
}
|
|
1729
1986
|
ready = true;
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1987
|
+
if (FRONT) {
|
|
1988
|
+
// the front prints the login link and the capabilities; it ends this server when it goes, even killed
|
|
1989
|
+
process.send({ ready: { url: ORIGIN, token: TOKEN } });
|
|
1990
|
+
process.on("disconnect", () => { if (!quitting) quit(); }); // a second quit() would exit at once
|
|
1991
|
+
} else {
|
|
1992
|
+
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
1993
|
+
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
1994
|
+
openOnEnter(() => loginUrl(loginCode(3600000)));
|
|
1995
|
+
console.log(`Deck: ${DECK_DIR}`);
|
|
1996
|
+
console.log(`Server: ${CAPS.label}`);
|
|
1997
|
+
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
1998
|
+
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
1999
|
+
}
|
|
2000
|
+
for (const n of startNotices) addEntry(n);
|
|
2001
|
+
discoverModels().then(() => { settle(); pushStatus(); });
|
|
1744
2002
|
});
|
|
1745
2003
|
/* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
|
|
1746
2004
|
SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
|