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