pindeck 3.1.0-beta.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -58
- package/cli.mjs +19 -9
- package/front.mjs +138 -43
- package/lib/agent-process.mjs +4 -1
- package/lib/agent-tools.mjs +5 -2
- package/lib/asset-roots.mjs +22 -0
- package/lib/capabilities.mjs +2 -2
- package/lib/claude.mjs +10 -2
- package/lib/deck-files.mjs +5 -3
- package/lib/deck-list.mjs +28 -5
- package/lib/deck-target.mjs +21 -3
- package/lib/eids.mjs +15 -6
- package/lib/login.mjs +15 -1
- package/lib/models.mjs +10 -4
- package/lib/pdf.mjs +18 -2
- package/lib/platform-notice.mjs +2 -2
- package/lib/servers.mjs +91 -0
- package/package.json +2 -2
- package/public/bridge.js +56 -7
- package/public/editor.css +8 -2
- package/public/editor.js +191 -83
- package/public/events-worker.js +47 -0
- package/public/index.html +1 -1
- package/server.mjs +452 -155
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,13 +38,16 @@ 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";
|
|
42
|
-
import { deckTarget, homeDir } from "./lib/deck-target.mjs";
|
|
43
|
-
import {
|
|
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";
|
|
44
46
|
import { USAGE } from "./lib/usage.mjs";
|
|
45
47
|
|
|
46
48
|
/* ---- Config ------------------------------------------------------------------ */
|
|
47
|
-
/* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in
|
|
48
|
-
prints the links
|
|
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. */
|
|
49
51
|
const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
|
|
50
52
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
51
53
|
const argv = process.argv.slice(2);
|
|
@@ -65,6 +67,8 @@ const LOGIN = argv[0] === "login";
|
|
|
65
67
|
if (LOGIN) argv.shift();
|
|
66
68
|
const PROVIDER = opt("--provider", "");
|
|
67
69
|
const MODEL = opt("--model", "");
|
|
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}.`);
|
|
68
72
|
/* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
|
|
69
73
|
const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
|
|
70
74
|
const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
|
|
@@ -74,24 +78,15 @@ const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
|
74
78
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
75
79
|
const SHOT_NAME = /^[\w-][\w.-]*\.png$/; // a file name in SHOT_DIR, as the editor may ask for it
|
|
76
80
|
const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
|
|
77
|
-
/* pindeck login: a new one-time login link from the
|
|
78
|
-
|
|
79
|
-
if (LOGIN) {
|
|
80
|
-
const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
|
|
81
|
-
let info = null;
|
|
82
|
-
for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
|
|
83
|
-
if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
|
|
84
|
-
try {
|
|
85
|
-
const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
|
|
86
|
-
if (!res.ok) throw new Error(res.status);
|
|
87
|
-
console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
|
|
88
|
-
process.exit(0);
|
|
89
|
-
} catch {
|
|
90
|
-
console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
|
|
91
|
-
process.exit(1);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
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);
|
|
94
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";
|
|
95
90
|
let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
|
|
96
91
|
const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
|
|
97
92
|
const CLI_CMD = cliCommand(CLI_REL);
|
|
@@ -101,6 +96,8 @@ const CAPS = detectCapabilities();
|
|
|
101
96
|
function checkDeck() {
|
|
102
97
|
const file = path.join(DECK_DIR, DECK_FILE);
|
|
103
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.";
|
|
104
101
|
try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
|
|
105
102
|
catch { return `${file} cannot be read and written (check its permissions).`; }
|
|
106
103
|
// saving replaces the file through a temporary file next to it, so the folder must be writable too
|
|
@@ -108,6 +105,8 @@ function checkDeck() {
|
|
|
108
105
|
catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
|
|
109
106
|
return null;
|
|
110
107
|
}
|
|
108
|
+
const homeNote = !FRONT && homeWarning(HOME_DIR); // behind the front, the front says it
|
|
109
|
+
if (homeNote) console.warn(homeNote);
|
|
111
110
|
const deckProblem = checkDeck();
|
|
112
111
|
if (deckProblem) {
|
|
113
112
|
console.error(`${deckProblem}\n\n${USAGE}`);
|
|
@@ -115,29 +114,44 @@ if (deckProblem) {
|
|
|
115
114
|
}
|
|
116
115
|
// reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
|
|
117
116
|
const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
|
|
117
|
+
const ENDS_HTML = /<\/html>\s*$/i;
|
|
118
118
|
const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
|
|
119
119
|
if (frameworkProblem) {
|
|
120
120
|
console.error(frameworkProblem);
|
|
121
121
|
process.exit(1);
|
|
122
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); }
|
|
123
133
|
/* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
|
|
124
|
-
port is bound (see Start), so a failed start leaves no state behind.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 = () => {};
|
|
128
138
|
function writeServerInfo() {
|
|
129
139
|
for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
134
147
|
}
|
|
148
|
+
unregister = registerServer(HOME_DIR, { url: ORIGIN, token: TOKEN, key: deckKey, deckDir: DECK_DIR, deckFile: DECK_FILE,
|
|
149
|
+
...(FRONT ? { behindFront: true } : {}) });
|
|
135
150
|
}
|
|
136
|
-
/* On exit: only our own
|
|
151
|
+
/* On exit: only our own files, never those of a PinDeck started since for this or another deck. */
|
|
137
152
|
function removeServerInfo() {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
153
|
+
unregister();
|
|
154
|
+
try { if (JSON.parse(fs.readFileSync(LOCK_FILE, "utf8")).pid === process.pid) fs.rmSync(LOCK_FILE, { force: true }); } catch {}
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
/* ---- Small helpers --------------------------------------------------------------- */
|
|
@@ -170,9 +184,23 @@ function writeDeck(f, content, manual = false) {
|
|
|
170
184
|
ourWrites.set(f, { hash: hash(content), manual });
|
|
171
185
|
writeAtomic(path.join(DECK_DIR, f), content);
|
|
172
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 });
|
|
173
200
|
/* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
|
|
174
201
|
function writeDeckFile(rel, data, manual = true) {
|
|
175
|
-
const abs =
|
|
202
|
+
const abs = deckPath(rel);
|
|
203
|
+
state.turn?.touched.add(rel);
|
|
176
204
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
177
205
|
ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
|
|
178
206
|
writeAtomic(abs, data);
|
|
@@ -185,7 +213,7 @@ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
|
|
|
185
213
|
const asText = (v) => (v == null ? "" : String(v));
|
|
186
214
|
const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
|
|
187
215
|
function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
|
|
188
|
-
if (v == null) return
|
|
216
|
+
if (v == null) return removeDeckFile(rel);
|
|
189
217
|
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
190
218
|
writeDeck(rel, v);
|
|
191
219
|
}
|
|
@@ -298,13 +326,13 @@ function sized(url, size) {
|
|
|
298
326
|
u.searchParams.set("height", size.height);
|
|
299
327
|
return u.href;
|
|
300
328
|
}
|
|
301
|
-
function chrome(args, done) {
|
|
329
|
+
function chrome(args, done, signal) {
|
|
302
330
|
if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
|
|
303
331
|
return new Promise((resolve, reject) => {
|
|
304
332
|
// a profile per run: concurrent runs must not share one
|
|
305
333
|
const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
|
|
306
334
|
const { size } = deckSlides();
|
|
307
|
-
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,
|
|
308
336
|
"--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
|
|
309
337
|
`--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
|
|
310
338
|
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
|
|
@@ -322,17 +350,22 @@ function chrome(args, done) {
|
|
|
322
350
|
proc.stderr.on("data", (d) => { err += d; check(); });
|
|
323
351
|
proc.on("error", (e) => finish(reject, e));
|
|
324
352
|
// "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
|
|
325
|
-
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)}` : "."}`))));
|
|
326
355
|
const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
|
|
356
|
+
signal?.addEventListener("abort", () => finish(reject, new Error("Stopped.")), { once: true });
|
|
327
357
|
});
|
|
328
358
|
}
|
|
329
359
|
const deckUrl = (mode, params = {}) =>
|
|
330
|
-
`${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
|
|
331
|
-
|
|
332
|
-
|
|
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 }));
|
|
333
366
|
const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
|
|
334
367
|
await chrome([`--screenshot=${out}`, deckUrl("shot", { slide: slide.eid, pins: JSON.stringify(pins) })],
|
|
335
|
-
(_o, err) => /bytes written to file/.test(err) && fs.existsSync(out));
|
|
368
|
+
(_o, err) => /bytes written to file/.test(err) && fs.existsSync(out), signal);
|
|
336
369
|
if (!fs.existsSync(out)) throw new Error("Chrome did not write the screenshot.");
|
|
337
370
|
// keep the folder small
|
|
338
371
|
const old = fs.readdirSync(SHOT_DIR).map((f) => path.join(SHOT_DIR, f))
|
|
@@ -376,16 +409,17 @@ function exportPdf(kind, includeHidden = false) {
|
|
|
376
409
|
if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
|
|
377
410
|
const split = kind === "presentation";
|
|
378
411
|
const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
|
|
379
|
-
const out = path.join(DECK_DIR, "export", name);
|
|
412
|
+
const out = path.join(DECK_DIR, "export", name); // checked when the export runs (deckPath)
|
|
380
413
|
const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
|
|
381
414
|
const run = async () => {
|
|
415
|
+
deckPath(`export/${name}`);
|
|
382
416
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
383
417
|
const layoutProfile = profile("layout"), printProfile = profile("print");
|
|
384
418
|
const html = await printLayout(CHROME, chromeStart(layoutProfile,
|
|
385
|
-
`${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);
|
|
386
420
|
const id = `${process.pid}-${++snapSeq}`;
|
|
387
421
|
snapshots.set(id, html);
|
|
388
|
-
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); }
|
|
389
423
|
finally { snapshots.delete(id); }
|
|
390
424
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
391
425
|
url: `deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
@@ -399,13 +433,15 @@ function exportPdf(kind, includeHidden = false) {
|
|
|
399
433
|
const CHAT_FILE = path.join(STATE_DIR, "chat.json");
|
|
400
434
|
const state = {
|
|
401
435
|
chat: [], seq: 0, busy: false, model: MODEL || null, switching: false, preparing: null, sessionId: null, provider: "claude", handover: "", sessionArchive: [],
|
|
402
|
-
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
|
|
403
438
|
uploads: new Set(), // files uploaded for the next message; removable until it is sent
|
|
404
439
|
pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
|
|
405
440
|
};
|
|
406
441
|
try {
|
|
407
442
|
const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
|
|
408
|
-
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 });
|
|
409
445
|
} catch {}
|
|
410
446
|
/* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
|
|
411
447
|
let chatTimer = null;
|
|
@@ -414,7 +450,8 @@ function writeChat() {
|
|
|
414
450
|
chatTimer = null;
|
|
415
451
|
try {
|
|
416
452
|
writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
|
|
417
|
-
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 }));
|
|
418
455
|
} catch (e) { console.error(e.message); }
|
|
419
456
|
}
|
|
420
457
|
const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
|
|
@@ -428,38 +465,59 @@ function addEntry(entry) {
|
|
|
428
465
|
}
|
|
429
466
|
const liveTurns = () => state.turns.filter((t) => !t.undone);
|
|
430
467
|
const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") : null);
|
|
431
|
-
/*
|
|
432
|
-
|
|
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. */
|
|
433
472
|
const CONFIG_FILE = path.join(HOME_DIR, "config.json");
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
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)
|
|
441
495
|
if (PROVIDER) {
|
|
442
|
-
if (!
|
|
443
|
-
settings.provider
|
|
444
|
-
|
|
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.` });
|
|
445
507
|
}
|
|
446
|
-
if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
|
|
447
|
-
// A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
|
|
448
|
-
if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
|
|
449
508
|
state.provider = settings.provider;
|
|
450
509
|
/* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
|
|
451
510
|
deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
|
|
452
511
|
it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
|
|
453
512
|
or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
|
|
454
513
|
a symlink that leads anywhere else is refused. */
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
});
|
|
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
|
+
}
|
|
463
521
|
const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
|
|
464
522
|
const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
465
523
|
const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
|
|
@@ -479,14 +537,22 @@ function servable(base, rel) {
|
|
|
479
537
|
return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
|
|
480
538
|
}
|
|
481
539
|
|
|
482
|
-
let catalog = { claude: { models: [] }, codex: { models: [] } }
|
|
540
|
+
let catalog = { claude: { models: [] }, codex: { models: [] } };
|
|
483
541
|
let session = null, starting = null;
|
|
484
|
-
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 },
|
|
485
543
|
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
486
544
|
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
487
545
|
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
488
|
-
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
489
|
-
|
|
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
|
+
};
|
|
490
556
|
|
|
491
557
|
const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
|
|
492
558
|
.replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
|
|
@@ -509,9 +575,50 @@ function slidesInstructions() {
|
|
|
509
575
|
git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
|
|
510
576
|
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
511
577
|
function lockedPaths() {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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;
|
|
515
622
|
}
|
|
516
623
|
/* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
|
|
517
624
|
or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
|
|
@@ -582,9 +689,25 @@ function retire(agent) {
|
|
|
582
689
|
retiring.add(stopped);
|
|
583
690
|
stopped.finally(() => retiring.delete(stopped));
|
|
584
691
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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 () => {
|
|
588
711
|
if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
|
|
589
712
|
const agent = makeSession({ provider, model: "", effort: null });
|
|
590
713
|
discoveryAgents.add(agent);
|
|
@@ -593,8 +716,36 @@ async function discoverModels() {
|
|
|
593
716
|
catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
|
|
594
717
|
} catch (e) { catalog[provider] = { models: [], error: e.message }; }
|
|
595
718
|
finally { discoveryAgents.delete(agent); retire(agent); }
|
|
596
|
-
})
|
|
597
|
-
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
|
+
}
|
|
598
749
|
}
|
|
599
750
|
async function ensureSession() {
|
|
600
751
|
if (starting) return starting;
|
|
@@ -602,17 +753,37 @@ async function ensureSession() {
|
|
|
602
753
|
const available = providerAvailable(settings.provider);
|
|
603
754
|
if (!available.available) throw new Error(available.reason);
|
|
604
755
|
starting = (async () => {
|
|
605
|
-
if (!catalog[settings.provider].models.length) await discoverModels();
|
|
756
|
+
if (!catalog[settings.provider].models.length) await discoverModels({ providers: [settings.provider] });
|
|
757
|
+
settle();
|
|
606
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
|
+
}
|
|
607
765
|
const agent = makeSession({ ...settings });
|
|
608
766
|
session = agent;
|
|
609
|
-
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
|
+
}
|
|
610
773
|
catch (e) { retire(agent); throw e; }
|
|
611
774
|
})().finally(() => { starting = null; });
|
|
612
775
|
return starting;
|
|
613
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
|
+
}
|
|
614
784
|
async function changeSettings(requested) {
|
|
615
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
|
|
616
787
|
const choice = selectModel(catalog, settings, requested, effortPreferences);
|
|
617
788
|
if (Object.keys(choice).every(k => choice[k] === settings[k])) return;
|
|
618
789
|
const changingProvider = choice.provider !== settings.provider;
|
|
@@ -627,21 +798,22 @@ async function changeSettings(requested) {
|
|
|
627
798
|
if (!candidate.running) throw new Error("The new backend stopped before it was ready.");
|
|
628
799
|
const summary = changingProvider && requested.handover === "summary" ? handoverSummary(state.chat) : "";
|
|
629
800
|
if (changingProvider && state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
|
|
630
|
-
const
|
|
631
|
-
if (settings.effort)
|
|
632
|
-
if (choice.effort)
|
|
633
|
-
|
|
634
|
-
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 };
|
|
635
806
|
Object.assign(settings, choice);
|
|
807
|
+
deckSettings = { ...choice };
|
|
636
808
|
state.provider = choice.provider;
|
|
637
|
-
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++; }
|
|
638
810
|
if (reuse) candidate.settings = choice;
|
|
639
811
|
session = candidate;
|
|
640
812
|
if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
|
|
641
813
|
if (!reuse) retire(old);
|
|
642
814
|
saveChat();
|
|
643
|
-
addEntry({ kind: "notice", text: changingProvider
|
|
644
|
-
? `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"}.`
|
|
645
817
|
: `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
|
|
646
818
|
} catch (e) { if (session !== candidate) retire(candidate); throw e; }
|
|
647
819
|
finally { state.busy = false; state.switching = false; pushStatus(); }
|
|
@@ -676,6 +848,15 @@ function toolSummary(name, input = {}) {
|
|
|
676
848
|
}
|
|
677
849
|
}
|
|
678
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
|
+
};
|
|
679
860
|
function handleEvent(ev) {
|
|
680
861
|
if (ev.type === "system" && ev.subtype === "init") {
|
|
681
862
|
state.model = ev.model || state.model;
|
|
@@ -685,7 +866,11 @@ function handleEvent(ev) {
|
|
|
685
866
|
} else if (ev.type === "assistant" && state.turn) {
|
|
686
867
|
for (const b of ev.message?.content || []) {
|
|
687
868
|
if (b.type === "text" && b.text.trim()) addEntry({ kind: "assistant", text: b.text.trim() });
|
|
688
|
-
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
|
+
}
|
|
689
874
|
}
|
|
690
875
|
} else if (ev.type === "user" && state.turn) {
|
|
691
876
|
for (const b of ev.message?.content || []) {
|
|
@@ -707,7 +892,9 @@ function handleEvent(ev) {
|
|
|
707
892
|
function startTurn(promptText, userEntry) {
|
|
708
893
|
// every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
|
|
709
894
|
// takes back all it changed
|
|
710
|
-
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);
|
|
711
898
|
state.busy = true;
|
|
712
899
|
pushStatus();
|
|
713
900
|
const turn = state.turn;
|
|
@@ -734,9 +921,26 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
734
921
|
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that the assistant created
|
|
735
922
|
restored.push(f);
|
|
736
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
|
+
}
|
|
737
933
|
toolEntries.clear();
|
|
738
934
|
const eid = runEidPass();
|
|
739
|
-
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));
|
|
740
944
|
const before = {}, after = {};
|
|
741
945
|
for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
|
|
742
946
|
// the deck file and theme.css first, then the rest by name
|
|
@@ -752,11 +956,14 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
752
956
|
deletedFiles.push({ rel, data: fs.readFileSync(abs) });
|
|
753
957
|
fs.rmSync(abs, { force: true });
|
|
754
958
|
}
|
|
755
|
-
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 };
|
|
756
960
|
if (files.length) { state.turns.push(t); state.redo = []; }
|
|
757
961
|
addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
|
|
758
962
|
denials, restored, newIds: eid.assigned.length });
|
|
759
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"}).` });
|
|
760
967
|
if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
|
|
761
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"}.` });
|
|
762
969
|
pushStatus();
|
|
@@ -802,7 +1009,7 @@ function formatAttachment(a) {
|
|
|
802
1009
|
const formatDrop = (a) => (a.droppedAt?.slide ? ` Dropped onto slide ${a.droppedAt.slide.index + 1} (${a.droppedAt.slide.eid}) at ` +
|
|
803
1010
|
`x=${a.droppedAt.point.x} y=${a.droppedAt.point.y}${a.droppedAt.eid ? `, over ${a.droppedAt.eid}` : ""}: probably where the user wants it.` : "");
|
|
804
1011
|
|
|
805
|
-
function buildPrompt({ text: userText, pins = [], selection, layouts = [], current, screenshot, attachments = [] }) {
|
|
1012
|
+
function buildPrompt({ text: userText, pins = [], selection, layouts = [], current, screenshot, shotFailed, attachments = [] }) {
|
|
806
1013
|
const ctx = [];
|
|
807
1014
|
if (current) ctx.push(`The user is looking at slide ${current.index + 1} (${current.eid}).`);
|
|
808
1015
|
if (state.notes.length) {
|
|
@@ -815,7 +1022,9 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
815
1022
|
`box x=${selection.box.x} y=${selection.box.y} w=${selection.box.w} h=${selection.box.h} on slide ${selection.slide.index + 1}.`);
|
|
816
1023
|
if (pins.length) ctx.push("Pins (numbered clicks; the user refers to them by number):\n" + pins.map(formatPin).join("\n"));
|
|
817
1024
|
for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
|
|
818
|
-
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.`);
|
|
819
1028
|
if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
|
|
820
1029
|
`use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
|
|
821
1030
|
return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
|
|
@@ -827,9 +1036,11 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
827
1036
|
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
|
|
828
1037
|
const bad = (t, status = 400) => ({ status, text: t });
|
|
829
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)
|
|
830
1041
|
const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
|
|
831
1042
|
.map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
|
|
832
|
-
[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)));
|
|
833
1044
|
if (!abs) return bad(`No PDF "${file}": use the path from the attached files, or a .pdf in the deck folder.`, 404);
|
|
834
1045
|
const info = await pdfInfo(abs, STATE_DIR);
|
|
835
1046
|
const n = Number(page);
|
|
@@ -884,13 +1095,19 @@ function scheduleFlush() {
|
|
|
884
1095
|
if (external) {
|
|
885
1096
|
state.notes = [...new Set([...state.notes,
|
|
886
1097
|
"The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
|
|
887
|
-
// 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)
|
|
888
1099
|
clearTimeout(eidTimer);
|
|
889
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)
|
|
890
1103
|
const pass = () => {
|
|
891
1104
|
if (state.busy) return;
|
|
892
1105
|
const now = readDeck(DECK_FILE);
|
|
893
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
|
|
894
1111
|
runEidPass();
|
|
895
1112
|
};
|
|
896
1113
|
eidTimer = setTimeout(pass, 800);
|
|
@@ -976,22 +1193,40 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
|
|
|
976
1193
|
}
|
|
977
1194
|
|
|
978
1195
|
/* What deleting an element would remove, and which of its files nothing else uses. Files outside the
|
|
979
|
-
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. */
|
|
980
1198
|
function deletePlan(eid) {
|
|
981
1199
|
const html = readDeck(DECK_FILE);
|
|
982
1200
|
const target = deleteTarget(html, eid);
|
|
983
1201
|
if (!target) return null;
|
|
984
1202
|
const rest = removeElement(html, target.eid);
|
|
985
1203
|
const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
|
|
1204
|
+
const others = otherDeckRefs();
|
|
986
1205
|
const files = fileRefs(target.outer).flatMap((ref) => {
|
|
987
1206
|
const abs = path.resolve(DECK_DIR, ref);
|
|
988
1207
|
const rel = path.relative(DECK_DIR, abs).split(path.sep).join("/");
|
|
989
1208
|
if (rel.startsWith("..") || rel.startsWith("vendor/") || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) return [];
|
|
990
|
-
|
|
991
|
-
|
|
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 }];
|
|
992
1213
|
});
|
|
993
1214
|
return { ...target, html: rest, files };
|
|
994
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
|
+
}
|
|
995
1230
|
|
|
996
1231
|
/* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
|
|
997
1232
|
Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
|
|
@@ -1006,6 +1241,8 @@ function undoConflicts(t, direction) {
|
|
|
1006
1241
|
// ones (which must be unchanged); redo the other way round
|
|
1007
1242
|
const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
|
|
1008
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); } }
|
|
1009
1246
|
for (const { rel, data } of comeBack) {
|
|
1010
1247
|
const abs = path.join(DECK_DIR, rel);
|
|
1011
1248
|
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
@@ -1021,6 +1258,13 @@ function conflict(res, direction, files) {
|
|
|
1021
1258
|
error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
|
|
1022
1259
|
`it would overwrite those changes. Reload to see the files as they are.` });
|
|
1023
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;
|
|
1024
1268
|
function undo(res) {
|
|
1025
1269
|
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
1026
1270
|
const t = liveTurns().at(-1);
|
|
@@ -1029,12 +1273,15 @@ function undo(res) {
|
|
|
1029
1273
|
if (diverged.length) return conflict(res, "undo", diverged);
|
|
1030
1274
|
for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
|
|
1031
1275
|
for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
|
|
1032
|
-
for (const { rel } of t.addedFiles || [])
|
|
1276
|
+
for (const { rel } of t.addedFiles || []) removeDeckFile(rel); // imported figures go
|
|
1033
1277
|
t.undone = true;
|
|
1278
|
+
markUndone(t);
|
|
1034
1279
|
state.redo.push(t);
|
|
1280
|
+
const names = t.files.map((f) => f.file);
|
|
1035
1281
|
state.notes.push(t.kind === "manual"
|
|
1036
1282
|
? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
|
|
1037
|
-
: `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.`);
|
|
1038
1285
|
broadcast("undone", { turnId: t.id, undone: true });
|
|
1039
1286
|
pushStatus();
|
|
1040
1287
|
return json(res, 200, {});
|
|
@@ -1043,7 +1290,7 @@ function undo(res) {
|
|
|
1043
1290
|
/* ---- PowerPoint import -----------------------------------------------------------------------------
|
|
1044
1291
|
Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
|
|
1045
1292
|
makes a PDF of it, from which come the thumbnails and, on import, the pictures of diagrams.
|
|
1046
|
-
Only the latest upload is kept, in ~/.pindeck/decks/<deck>/imports/. */
|
|
1293
|
+
Only the latest upload is kept, in ~/.pindeck-v3/decks/<deck>/imports/. */
|
|
1047
1294
|
const IMPORT_DIR = path.join(STATE_DIR, "imports");
|
|
1048
1295
|
const IMPORT_MAX = 500 * 1024 * 1024;
|
|
1049
1296
|
let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
|
|
@@ -1155,28 +1402,36 @@ const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
|
|
|
1155
1402
|
there, and the front sends this server's token in a header. */
|
|
1156
1403
|
const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
|
|
1157
1404
|
/* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
|
|
1158
|
-
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();
|
|
1159
1410
|
function chromeStart(profile, target) {
|
|
1160
1411
|
fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
1161
1412
|
const start = path.join(profile, "pindeck-start.html");
|
|
1162
|
-
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, """);
|
|
1163
1418
|
fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
|
|
1164
1419
|
return pathToFileURL(start).href;
|
|
1165
1420
|
}
|
|
1166
1421
|
|
|
1167
1422
|
/* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
|
|
1168
1423
|
Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
|
|
1169
|
-
function serveFile(res, file, transform, req) {
|
|
1424
|
+
function serveFile(res, file, transform, req, extra = {}) {
|
|
1170
1425
|
const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
|
|
1171
1426
|
if (transform) return fs.readFile(file, (err, data) => {
|
|
1172
1427
|
if (err) return text(res, 404, "Not found");
|
|
1173
1428
|
const out = Buffer.from(transform(String(data)));
|
|
1174
|
-
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 });
|
|
1175
1430
|
res.end(out);
|
|
1176
1431
|
});
|
|
1177
1432
|
fs.stat(file, (err, st) => {
|
|
1178
1433
|
if (err || !st.isFile()) return text(res, 404, "Not found");
|
|
1179
|
-
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 };
|
|
1180
1435
|
let start = 0, end = st.size - 1, code = 200;
|
|
1181
1436
|
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
|
|
1182
1437
|
if (range && (range[1] || range[2])) {
|
|
@@ -1200,14 +1455,29 @@ function serveFile(res, file, transform, req) {
|
|
|
1200
1455
|
}
|
|
1201
1456
|
|
|
1202
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": "*" } : {}) });
|
|
1203
1465
|
const server = http.createServer(async (req, res) => {
|
|
1204
1466
|
if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
|
|
1205
1467
|
const host = String(req.headers.host || "").toLowerCase();
|
|
1206
1468
|
if (!goodHost(host)) return text(res, 403, "Bad host");
|
|
1207
|
-
const url = new URL(req.url, ORIGIN);
|
|
1208
|
-
const p = url.pathname;
|
|
1209
1469
|
try {
|
|
1470
|
+
const url = new URL(req.url, ORIGIN);
|
|
1471
|
+
let p = url.pathname;
|
|
1210
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
|
+
}
|
|
1211
1481
|
// UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
|
|
1212
1482
|
const ok = authed(req);
|
|
1213
1483
|
if (req.method === "GET" && (p === "/" || p === "/index.html")) {
|
|
@@ -1218,18 +1488,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
1218
1488
|
const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
|
|
1219
1489
|
return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
1220
1490
|
}
|
|
1221
|
-
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");
|
|
1222
1492
|
let snap;
|
|
1223
1493
|
if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
|
|
1224
1494
|
const html = snapshots.get(snap[1]);
|
|
1225
1495
|
if (!html) return text(res, 404, "Not found");
|
|
1226
|
-
|
|
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'" });
|
|
1227
1499
|
return res.end(html);
|
|
1228
1500
|
}
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
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
|
+
};
|
|
1232
1508
|
if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
|
|
1509
|
+
if (!viewing) return toView(DECK_PAGE);
|
|
1233
1510
|
res.writeHead(302, { location: ".." + DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1234
1511
|
return res.end();
|
|
1235
1512
|
}
|
|
@@ -1238,8 +1515,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1238
1515
|
if (deckPath === null) return text(res, 403, "Forbidden");
|
|
1239
1516
|
if (deckPath) {
|
|
1240
1517
|
const f = deckPath;
|
|
1518
|
+
if (!viewing && /\.html$/i.test(f)) return toView(p);
|
|
1241
1519
|
const mode = url.searchParams.get("editor");
|
|
1242
|
-
if (!mode || !/\.html
|
|
1520
|
+
if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing));
|
|
1243
1521
|
return serveFile(res, f, (s) => {
|
|
1244
1522
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
1245
1523
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
@@ -1249,8 +1527,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1249
1527
|
return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
|
|
1250
1528
|
}
|
|
1251
1529
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
1252
|
-
})
|
|
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)
|
|
1253
1532
|
}
|
|
1533
|
+
if (viewing) return text(res, 404, "Not found");
|
|
1254
1534
|
|
|
1255
1535
|
// /shots/: a screenshot for the chat, a PNG directly in the screenshots folder (no subfolder, no symlink out)
|
|
1256
1536
|
if (req.method === "GET" && p.startsWith("/shots/")) {
|
|
@@ -1278,11 +1558,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1278
1558
|
req.on("close", () => clients.delete(res));
|
|
1279
1559
|
return;
|
|
1280
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
|
+
}
|
|
1281
1567
|
if (p === "/api/ping") return json(res, 200, { ok: true });
|
|
1282
1568
|
if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
|
|
1283
1569
|
if (p === "/api/status") return json(res, 200, status());
|
|
1284
1570
|
if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
|
|
1285
|
-
if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
|
|
1286
1571
|
if (p === "/api/measure-result" && req.method === "POST") {
|
|
1287
1572
|
const b = await body(req);
|
|
1288
1573
|
const w = pending.get(b.id);
|
|
@@ -1304,27 +1589,34 @@ const server = http.createServer(async (req, res) => {
|
|
|
1304
1589
|
if (!placed(b.selection)) b.selection = null;
|
|
1305
1590
|
if (!Array.isArray(b.layouts)) b.layouts = [];
|
|
1306
1591
|
if (b.current && !Number.isInteger(b.current.index)) b.current = null;
|
|
1307
|
-
const preparation = { cancelled: false };
|
|
1592
|
+
const preparation = { cancelled: false, controller: new AbortController() };
|
|
1308
1593
|
state.preparing = preparation;
|
|
1309
1594
|
state.busy = true; pushStatus();
|
|
1310
1595
|
try {
|
|
1311
|
-
let screenshot = null;
|
|
1596
|
+
let screenshot = null, shotFailed = null;
|
|
1312
1597
|
const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
|
|
1313
1598
|
if (shotSlide) {
|
|
1314
|
-
|
|
1315
|
-
|
|
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." });
|
|
1316
1606
|
}
|
|
1317
|
-
if (preparation.cancelled) return json(res, 409, { error: "Stopped before sending the message." });
|
|
1318
1607
|
const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
|
|
1319
1608
|
// only files PinDeck itself stored can be attached
|
|
1320
1609
|
b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
|
|
1321
1610
|
b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
|
|
1611
|
+
state.lastPins = b.pins;
|
|
1612
|
+
state.preparing = null;
|
|
1322
1613
|
const entry = addEntry({ kind: "user", text: b.text,
|
|
1323
1614
|
screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
|
|
1324
1615
|
attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
|
|
1325
1616
|
selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
|
|
1326
1617
|
pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
|
|
1327
|
-
|
|
1618
|
+
if (shotFailed) addEntry({ kind: "notice", text: `Screenshot of slide ${shotFailed.slide} failed: ${shotFailed.error}` });
|
|
1619
|
+
startTurn(buildPrompt({ ...b, screenshot, shotFailed }), entry);
|
|
1328
1620
|
return json(res, 200, { id: entry.id });
|
|
1329
1621
|
} finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
|
|
1330
1622
|
}
|
|
@@ -1351,7 +1643,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1351
1643
|
return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
|
|
1352
1644
|
}
|
|
1353
1645
|
const rel = attachPath(original);
|
|
1354
|
-
writeDeckFile(rel, buf);
|
|
1646
|
+
try { writeDeckFile(rel, buf); } catch (e) { return json(res, e.status || 500, { error: e.message }); }
|
|
1355
1647
|
state.uploads.add(rel);
|
|
1356
1648
|
return json(res, 200, { path: rel, kind, bytes: buf.length, url: `deck/${rel}` });
|
|
1357
1649
|
}
|
|
@@ -1364,7 +1656,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1364
1656
|
}
|
|
1365
1657
|
return json(res, 200, {});
|
|
1366
1658
|
}
|
|
1367
|
-
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
|
|
1368
1660
|
if (p === "/api/settings" && req.method === "POST") {
|
|
1369
1661
|
const b = await body(req);
|
|
1370
1662
|
if (state.busy || starting) return json(res, 409, { error: "Wait until the current request finishes before switching." });
|
|
@@ -1455,7 +1747,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1455
1747
|
catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
|
|
1456
1748
|
}
|
|
1457
1749
|
if (p === "/api/stop" && req.method === "POST") {
|
|
1458
|
-
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, {}); }
|
|
1459
1751
|
if (state.busy && state.turn) {
|
|
1460
1752
|
const interruptedTurn = state.turn;
|
|
1461
1753
|
interruptedTurn.cancelled = true;
|
|
@@ -1470,7 +1762,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1470
1762
|
if (p === "/api/new-chat" && req.method === "POST") {
|
|
1471
1763
|
if (state.busy || starting) return json(res, 409, { error: "Stop the current request before starting a new chat." });
|
|
1472
1764
|
session?.stop();
|
|
1473
|
-
|
|
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 });
|
|
1474
1768
|
saveChat();
|
|
1475
1769
|
broadcast("reset", { chat: [] });
|
|
1476
1770
|
ensureSession().catch(e => addEntry({ kind: "notice", text: e.message }));
|
|
@@ -1497,11 +1791,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1497
1791
|
if (diverged.length) return conflict(res, "redo", diverged);
|
|
1498
1792
|
state.redo.pop();
|
|
1499
1793
|
for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
|
|
1500
|
-
for (const { rel } of t.deletedFiles || [])
|
|
1794
|
+
for (const { rel } of t.deletedFiles || []) removeDeckFile(rel);
|
|
1501
1795
|
for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
|
|
1502
1796
|
t.undone = false;
|
|
1797
|
+
markUndone(t);
|
|
1503
1798
|
state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
|
|
1504
|
-
: `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.`);
|
|
1505
1801
|
broadcast("undone", { turnId: t.id, undone: false });
|
|
1506
1802
|
pushStatus();
|
|
1507
1803
|
return json(res, 200, {});
|
|
@@ -1528,7 +1824,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1528
1824
|
const turnId = recordHandEdit(before, plan.html, label,
|
|
1529
1825
|
`The user deleted ${plan.eid} (${plan.tag}) by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
|
|
1530
1826
|
{ manual: false, deletedFiles });
|
|
1531
|
-
for (const f of deletedFiles)
|
|
1827
|
+
for (const f of deletedFiles) removeDeckFile(f.rel);
|
|
1532
1828
|
return json(res, 200, { turnId, deleted: plan.eid, files: deletedFiles.map((f) => f.rel) });
|
|
1533
1829
|
}
|
|
1534
1830
|
|
|
@@ -1547,7 +1843,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1547
1843
|
return json(res, 200, { turnId });
|
|
1548
1844
|
}
|
|
1549
1845
|
if (p === "/api/source") {
|
|
1550
|
-
|
|
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);
|
|
1551
1849
|
return html == null ? json(res, 404, { error: "Element not found in the file." }) : json(res, 200, { html });
|
|
1552
1850
|
}
|
|
1553
1851
|
// Speaker notes of a slide: read for the notes field, saved as a hand edit
|
|
@@ -1593,9 +1891,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1593
1891
|
const before = snapshot(EDITABLE);
|
|
1594
1892
|
const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
|
|
1595
1893
|
if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
1596
|
-
|
|
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 });
|
|
1597
1897
|
const short = (e) => e.replace(/^[^/]+\//, "/");
|
|
1598
|
-
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)}`,
|
|
1599
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)}".`);
|
|
1600
1900
|
return json(res, 200, { turnId, eid: r2.eid });
|
|
1601
1901
|
}
|
|
@@ -1650,9 +1950,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1650
1950
|
return text(res, 404, "Not found");
|
|
1651
1951
|
} catch (e) {
|
|
1652
1952
|
// malformed JSON or %-encoding in a request is the caller's mistake, not a server error
|
|
1653
|
-
const
|
|
1654
|
-
if (!bad) console.error(e);
|
|
1655
|
-
|
|
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));
|
|
1656
1958
|
}
|
|
1657
1959
|
});
|
|
1658
1960
|
|
|
@@ -1677,7 +1979,7 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1677
1979
|
if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
|
|
1678
1980
|
startWatcher();
|
|
1679
1981
|
} catch (e) {
|
|
1680
|
-
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}`);
|
|
1681
1983
|
removeServerInfo();
|
|
1682
1984
|
process.exit(1);
|
|
1683
1985
|
}
|
|
@@ -1689,19 +1991,14 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1689
1991
|
} else {
|
|
1690
1992
|
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
1691
1993
|
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
1994
|
+
openOnEnter(() => loginUrl(loginCode(3600000)));
|
|
1692
1995
|
console.log(`Deck: ${DECK_DIR}`);
|
|
1693
1996
|
console.log(`Server: ${CAPS.label}`);
|
|
1694
1997
|
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
1695
1998
|
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
1696
1999
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
const models = catalog[settings.provider].models;
|
|
1700
|
-
const first = models.find(m => m.isDefault) || models[0];
|
|
1701
|
-
if (first) { settings.model = first.id; settings.effort = first.defaultEffort; }
|
|
1702
|
-
}
|
|
1703
|
-
pushStatus();
|
|
1704
|
-
});
|
|
2000
|
+
for (const n of startNotices) addEntry(n);
|
|
2001
|
+
discoverModels().then(() => { settle(); pushStatus(); });
|
|
1705
2002
|
});
|
|
1706
2003
|
/* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
|
|
1707
2004
|
SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
|