pindeck 3.0.0-beta.1 → 3.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +217 -44
- package/cli.mjs +6 -3
- package/lib/agent-process.mjs +7 -3
- package/lib/agent-tools.mjs +32 -9
- package/lib/capabilities.mjs +121 -0
- package/lib/claude.mjs +7 -3
- package/lib/codex.mjs +4 -2
- package/lib/deck-files.mjs +94 -0
- package/lib/frameworks.mjs +41 -0
- package/lib/instructions.mjs +74 -0
- package/lib/models.mjs +2 -1
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -3
- package/public/editor.css +1 -0
- package/public/editor.js +79 -22
- package/public/index.html +2 -2
- package/server.mjs +599 -166
package/lib/waveform.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/* Waveform SVG for an audio file, as the editor makes for attached audio (bars, viewBox 1000 x 120):
|
|
2
|
-
WAV is read directly; other formats are converted to WAV first with macOS
|
|
2
|
+
WAV is read directly; other formats are converted to WAV first, with afconvert on macOS or ffmpeg on
|
|
3
|
+
Linux, when available. */
|
|
3
4
|
|
|
4
5
|
import { execFile } from "node:child_process";
|
|
6
|
+
import { findExecutable } from "./capabilities.mjs";
|
|
5
7
|
import fs from "node:fs";
|
|
6
8
|
import os from "node:os";
|
|
7
9
|
import path from "node:path";
|
|
@@ -52,12 +54,16 @@ function svgOf(wav) {
|
|
|
52
54
|
/* Returns the SVG text, or null if the audio cannot be read */
|
|
53
55
|
export async function waveformSvg(data, ext) {
|
|
54
56
|
let wav = /^wav(e)?$/i.test(ext) ? readWav(data) : null;
|
|
55
|
-
|
|
57
|
+
// other formats: decoded to 16-bit WAV by afconvert (macOS) or ffmpeg (Linux, when installed)
|
|
58
|
+
const ffmpeg = process.platform !== "darwin" && findExecutable("ffmpeg").path;
|
|
59
|
+
if (!wav && (process.platform === "darwin" || ffmpeg)) {
|
|
56
60
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pindeck-wave-"));
|
|
57
61
|
try {
|
|
58
62
|
const src = path.join(dir, `in.${ext || "audio"}`), out = path.join(dir, "out.wav");
|
|
59
63
|
fs.writeFileSync(src, data);
|
|
60
|
-
|
|
64
|
+
const [cmd, args] = ffmpeg ? [ffmpeg, ["-v", "error", "-y", "-i", src, "-f", "wav", "-acodec", "pcm_s16le", out]]
|
|
65
|
+
: ["afconvert", ["-f", "WAVE", "-d", "LEI16", src, out]];
|
|
66
|
+
await new Promise((resolve, reject) => execFile(cmd, args, { timeout: 60000 },
|
|
61
67
|
(err) => (err ? reject(err) : resolve())));
|
|
62
68
|
wav = readWav(fs.readFileSync(out));
|
|
63
69
|
} catch { wav = null; }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
4
|
-
"description": "PinDeck:
|
|
3
|
+
"version": "3.0.0-beta.2",
|
|
4
|
+
"description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
|
7
7
|
"presentation",
|
|
@@ -37,7 +37,10 @@
|
|
|
37
37
|
"start": "node server.mjs",
|
|
38
38
|
"test": "node --test"
|
|
39
39
|
},
|
|
40
|
-
"os": [
|
|
40
|
+
"os": [
|
|
41
|
+
"darwin",
|
|
42
|
+
"linux"
|
|
43
|
+
],
|
|
41
44
|
"engines": {
|
|
42
45
|
"node": ">=20"
|
|
43
46
|
}
|
package/public/editor.css
CHANGED
|
@@ -65,6 +65,7 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
|
|
|
65
65
|
}
|
|
66
66
|
.pos { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 52px; text-align: center; }
|
|
67
67
|
.deck-name { min-width: 0; margin-left: 8px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
68
|
+
.server-note { flex: none; margin-left: 8px; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: #6b4a12; background: #fbf0dc; cursor: help; }
|
|
68
69
|
.toggle { display: inline-flex; align-items: center; gap: 6px; }
|
|
69
70
|
.toggle .dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; background: var(--orange); color: #fff;
|
|
70
71
|
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; }
|
package/public/editor.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/* PinDeck UI: slide list, canvas with pins, chat with the selected assistant. */
|
|
2
2
|
|
|
3
|
-
const TOKEN = document.querySelector('meta[name="editor-token"]').content;
|
|
4
3
|
const $ = (id) => document.getElementById(id);
|
|
5
4
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
/* Changes to the deck carry the revision of the files this tab shows (state.rev); the server refuses them
|
|
6
|
+
(409, code "stale") if the files have changed since. A change that went through gives the new revision. */
|
|
7
|
+
const CHANGES = new Set(["edit", "text", "split", "remove", "notes", "slide-hidden", "move-slide", "add-slide",
|
|
8
|
+
"delete", "import-apply", "undo", "redo"]);
|
|
9
|
+
const api = async (p, body) => {
|
|
10
|
+
const change = body !== undefined && CHANGES.has(p.split("?")[0]);
|
|
11
|
+
if (change && state.rev) body = { ...body, rev: state.rev };
|
|
12
|
+
const res = await fetch("/api/" + p, {
|
|
13
|
+
method: body === undefined ? "GET" : "POST",
|
|
14
|
+
headers: { "content-type": "application/json" },
|
|
15
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
16
|
+
});
|
|
17
|
+
if (change && res.ok && res.headers.get("x-deck-rev")) state.rev = res.headers.get("x-deck-rev");
|
|
18
|
+
return res;
|
|
19
|
+
};
|
|
11
20
|
|
|
12
21
|
const savedSlide = (() => { try { return +localStorage.getItem("editor.slide") || 0; } catch { return 0; } })();
|
|
13
22
|
const state = {
|
|
@@ -22,7 +31,8 @@ let measure = null; // { iframe, bridge }
|
|
|
22
31
|
let measureReady = null, measureResolve = null;
|
|
23
32
|
|
|
24
33
|
// showHiddenSlides: PinDeck shows hidden slides so they can be edited (reveal.js drops them otherwise)
|
|
25
|
-
|
|
34
|
+
// /deck/ is the deck file the server was started with (index.html, or a named file such as talk.html)
|
|
35
|
+
function deckSrc(mode, extra = "") { return `/deck/?editor=${mode}&showHiddenSlides=true${extra}`; }
|
|
26
36
|
|
|
27
37
|
function loadMain() {
|
|
28
38
|
const f = document.createElement("iframe");
|
|
@@ -102,7 +112,11 @@ window.__editorHost = {
|
|
|
102
112
|
commitText(edit) {
|
|
103
113
|
queueEdit(async () => {
|
|
104
114
|
const res = await api("text", edit);
|
|
105
|
-
if (!res.ok)
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
const d = await res.json().catch(() => ({}));
|
|
117
|
+
if (d.code === "stale") keepRejectedText(edit.text, d);
|
|
118
|
+
else toast(d.error || "Could not save the text.");
|
|
119
|
+
}
|
|
106
120
|
loadMain(); // re-render: math, footer numbers, and the file as saved
|
|
107
121
|
}, "Could not save the text.");
|
|
108
122
|
},
|
|
@@ -171,6 +185,19 @@ async function undoRedo(which) {
|
|
|
171
185
|
const res = await api(which, {});
|
|
172
186
|
if (!res.ok) toast((await res.json().catch(() => ({}))).error || `Nothing to ${which}.`);
|
|
173
187
|
}
|
|
188
|
+
/* A text edit refused because the file changed meanwhile: the slide reloads with the file as it is now,
|
|
189
|
+
and the user's text stays available to copy and type in again. Nothing is merged automatically. */
|
|
190
|
+
let rejectedText = "";
|
|
191
|
+
function keepRejectedText(text, d) {
|
|
192
|
+
if (d.rev) state.rev = d.rev;
|
|
193
|
+
rejectedText = String(text ?? "");
|
|
194
|
+
toast(`${esc(d.error)}<a href="#" data-copy-rejected>Copy your text</a>`, { html: true, ms: 20000 });
|
|
195
|
+
}
|
|
196
|
+
document.addEventListener("click", (e) => {
|
|
197
|
+
if (!e.target.closest?.("[data-copy-rejected]")) return;
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
navigator.clipboard?.writeText(rejectedText).then(() => toast("Your text is on the clipboard."), () => toast(rejectedText, { ms: 20000 }));
|
|
200
|
+
});
|
|
174
201
|
let toastTimer = null;
|
|
175
202
|
function toast(msg, { html = false, ms = 3200 } = {}) {
|
|
176
203
|
$("toast")[html ? "innerHTML" : "textContent"] = msg;
|
|
@@ -338,7 +365,7 @@ notesBox.addEventListener("keydown", (e) => {
|
|
|
338
365
|
// closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
|
|
339
366
|
addEventListener("pagehide", () => {
|
|
340
367
|
if (notesDirty() && !state.busy)
|
|
341
|
-
navigator.sendBeacon(
|
|
368
|
+
navigator.sendBeacon("/api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
|
|
342
369
|
});
|
|
343
370
|
function setNotesOpen(open) {
|
|
344
371
|
$("notes-panel").classList.toggle("collapsed", !open);
|
|
@@ -466,7 +493,7 @@ $("export-menu").addEventListener("click", async (e) => {
|
|
|
466
493
|
|
|
467
494
|
$("present").onclick = () => {
|
|
468
495
|
const ix = main?.bridge.indices(state.current) || { h: 0, v: 0 };
|
|
469
|
-
window.open(`/deck
|
|
496
|
+
window.open(`/deck/#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
|
|
470
497
|
};
|
|
471
498
|
|
|
472
499
|
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
@@ -567,13 +594,14 @@ function entryHtml(e) {
|
|
|
567
594
|
if (e.error) parts.push(`<span class="warn">${esc(e.error)}</span>`);
|
|
568
595
|
if (e.files?.length) {
|
|
569
596
|
parts.push(`<span class="files ${t?.undone ? "undone" : ""}">Changed ${e.files.map((f) =>
|
|
570
|
-
`${esc(f.file)}
|
|
597
|
+
`${esc(f.file)} ` + (f.binary ? `<span class="add">(binary)</span>`
|
|
598
|
+
: `<span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`)).join(", ")}</span>`);
|
|
571
599
|
parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
|
|
572
600
|
if (t && !t.undone) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
|
|
573
601
|
if (t?.undone) parts.push("<span>undone</span>");
|
|
574
602
|
} else if (!e.error) parts.push("No file changes");
|
|
575
603
|
if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
|
|
576
|
-
if (e.denials?.length) parts.push(`<span class="warn" title="PinDeck lets the assistant run only its own commands (layout, shot, slides, pdf) and edit index.html and
|
|
604
|
+
if (e.denials?.length) parts.push(`<span class="warn" title="PinDeck lets the assistant run only its own commands (layout, shot, slides, pdf) and edit ${esc(state.status?.deckFile || "index.html")}, theme.css and the deck's other files, never brand.css">` +
|
|
577
605
|
`Not allowed in PinDeck, so skipped: ${e.denials.map((d) => esc(`${d.name} ${d.detail}`)).join(", ")}</span>`);
|
|
578
606
|
if (e.duration) parts.push(`<span class="meta">${(e.duration / 1000).toFixed(1)} s</span>`);
|
|
579
607
|
return parts.join(" ");
|
|
@@ -713,12 +741,14 @@ document.addEventListener("keydown", (e) => {
|
|
|
713
741
|
/* ---- Server events ---------------------------------------------------------------------------------- */
|
|
714
742
|
function applyStatus(s) {
|
|
715
743
|
state.status = s;
|
|
716
|
-
// the deck's name in the header ("ocean-example"), its full path on hover
|
|
744
|
+
// the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
|
|
717
745
|
if (s.deck) {
|
|
718
746
|
const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
|
|
719
|
-
|
|
747
|
+
const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
|
|
748
|
+
$("deck-name").textContent = name;
|
|
720
749
|
$("deck-name").title = `${s.deck}/${s.deckFile || "index.html"}`;
|
|
721
|
-
|
|
750
|
+
if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
|
|
751
|
+
document.title = `${name} · PinDeck`;
|
|
722
752
|
}
|
|
723
753
|
setBusy(!!s.busy);
|
|
724
754
|
$("model").textContent = modelLabel(s);
|
|
@@ -730,15 +760,40 @@ function applyStatus(s) {
|
|
|
730
760
|
$("working-text").textContent = s.switching ? "Updating model…" : "The assistant is working…";
|
|
731
761
|
$("conn").classList.toggle("on", !!s.running);
|
|
732
762
|
$("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
|
|
763
|
+
// only the selected backend's availability matters: Codex missing is no news to someone using Claude
|
|
764
|
+
const provider = s.settings?.provider || "claude";
|
|
765
|
+
const caps = Object.fromEntries(Object.entries(s.capabilities || {})
|
|
766
|
+
.filter(([k]) => !["claude", "codex"].includes(k) || k === provider));
|
|
767
|
+
const why = (c) => (c && c.available === false ? c.reason : "");
|
|
733
768
|
$("add-shot").disabled = !s.chrome;
|
|
734
|
-
$("add-shot").title = s.chrome ? "" : "Screenshots need Chrome or Chromium";
|
|
769
|
+
$("add-shot").title = s.chrome ? "" : why(caps.screenshot) || "Screenshots need Chrome or Chromium";
|
|
770
|
+
for (const item of $("export-menu").querySelectorAll("[data-export]")) {
|
|
771
|
+
item.disabled = !!why(caps.pdfExport);
|
|
772
|
+
item.title = why(caps.pdfExport);
|
|
773
|
+
}
|
|
774
|
+
const imp = $("add-slide-menu").querySelector('[data-slide="import"]');
|
|
775
|
+
imp.disabled = !!why(caps.pptxImport);
|
|
776
|
+
imp.title = why(caps.pptxImport);
|
|
777
|
+
if (why(caps[provider])) $("conn").title = caps[provider].reason;
|
|
778
|
+
// where the server runs, when it is not this Mac's usual setup: "Linux (beta)", with what is off
|
|
779
|
+
const names = { claude: "Claude", codex: "OpenAI Codex", screenshot: "Screenshots", headlessLayout: "Layout without an open editor",
|
|
780
|
+
pdfExport: "PDF export", pdfPages: "Pictures of PDF pages", pptxImport: "PowerPoint import",
|
|
781
|
+
pptxPictures: "Pictures of PowerPoint slides", audioConvert: "Waveforms of non-WAV audio" };
|
|
782
|
+
const off = Object.entries(caps).filter(([, c]) => c && c.available === false).map(([k]) => "• " + (names[k] || k));
|
|
783
|
+
const reasons = [...new Set(Object.values(caps).filter((c) => c && c.available === false).map((c) => c.reason))];
|
|
784
|
+
$("server-note").hidden = !caps.platform || caps.platform === "darwin";
|
|
785
|
+
$("server-note").textContent = caps.label ? `Server: ${caps.label}` : "";
|
|
786
|
+
$("server-note").title = off.length ? `Not available on this server:\n${off.join("\n")}\n\nWhy:\n${reasons.join("\n")}` : "";
|
|
735
787
|
$("add-slide-btn").disabled = !!s.busy;
|
|
736
788
|
renderToolbar();
|
|
737
789
|
}
|
|
738
790
|
function connect() {
|
|
739
|
-
const es = new EventSource(
|
|
791
|
+
const es = new EventSource("/api/events");
|
|
740
792
|
es.addEventListener("hello", (ev) => {
|
|
741
793
|
const d = JSON.parse(ev.data);
|
|
794
|
+
// reconnected after missing file events (tunnel dropped, laptop asleep): show the files as they are now
|
|
795
|
+
if (state.rev && d.rev && d.rev !== state.rev && main) { loadMain(); loadMeasure(); }
|
|
796
|
+
if (d.rev) state.rev = d.rev;
|
|
742
797
|
clearChat();
|
|
743
798
|
state.turns = new Map(d.turns.map((t) => [t.id, t]));
|
|
744
799
|
d.chat.forEach(renderEntry);
|
|
@@ -762,7 +817,8 @@ function connect() {
|
|
|
762
817
|
rerenderTurns();
|
|
763
818
|
});
|
|
764
819
|
es.addEventListener("files", (ev) => {
|
|
765
|
-
const { files, manual } = JSON.parse(ev.data);
|
|
820
|
+
const { files, manual, rev } = JSON.parse(ev.data);
|
|
821
|
+
if (rev) state.rev = rev;
|
|
766
822
|
const cssOnly = files.every((f) => f.endsWith(".css"));
|
|
767
823
|
if (manual && main) {
|
|
768
824
|
loadMeasure(); // the visible slide already shows the hand edit
|
|
@@ -779,7 +835,8 @@ function connect() {
|
|
|
779
835
|
try { api("measure-result", { id, result: await queueMeasure(index) }); }
|
|
780
836
|
catch (err) { api("measure-result", { id, error: String(err) }); }
|
|
781
837
|
});
|
|
782
|
-
// EventSource reconnects by itself; if the server restarted,
|
|
838
|
+
// EventSource reconnects by itself; if the server restarted, the login cookie is stale: reload the page,
|
|
839
|
+
// which asks for a new login link
|
|
783
840
|
es.onerror = async () => {
|
|
784
841
|
try { if ((await api("ping")).status === 403) location.reload(); } catch {}
|
|
785
842
|
};
|
|
@@ -821,7 +878,7 @@ $("import-file").addEventListener("change", async () => {
|
|
|
821
878
|
if (!file) return;
|
|
822
879
|
toast(`Reading ${file.name}…`);
|
|
823
880
|
const res = await fetch("/api/import", { method: "POST", body: file,
|
|
824
|
-
headers: { "x-
|
|
881
|
+
headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
|
|
825
882
|
const d = await res.json().catch(() => ({}));
|
|
826
883
|
if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
|
|
827
884
|
importing = d;
|
|
@@ -864,7 +921,7 @@ function showImportThumbs(ev) {
|
|
|
864
921
|
for (const c of importChecks()) {
|
|
865
922
|
const img = new Image();
|
|
866
923
|
img.alt = "";
|
|
867
|
-
img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}
|
|
924
|
+
img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}`;
|
|
868
925
|
c.closest(".thumb").append(img);
|
|
869
926
|
}
|
|
870
927
|
}
|
|
@@ -1030,7 +1087,7 @@ const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Ma
|
|
|
1030
1087
|
|
|
1031
1088
|
async function uploadBlob(blob, name) {
|
|
1032
1089
|
const res = await fetch("/api/upload", { method: "POST", body: blob,
|
|
1033
|
-
headers: { "x-
|
|
1090
|
+
headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
|
|
1034
1091
|
const d = await res.json().catch(() => ({}));
|
|
1035
1092
|
if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
|
|
1036
1093
|
return d;
|
package/public/index.html
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<meta name="editor-token" content="%%TOKEN%%">
|
|
7
6
|
<title>PinDeck</title>
|
|
8
7
|
<link rel="icon" href="/editor/favicon.svg" type="image/svg+xml">
|
|
9
8
|
<link rel="icon" href="/editor/favicon.png" type="image/png" sizes="64x64">
|
|
@@ -37,6 +36,7 @@
|
|
|
37
36
|
<span id="slide-pos" class="pos">– / –</span>
|
|
38
37
|
<button id="next" class="icon" title="Next slide (→)" aria-label="Next slide">›</button>
|
|
39
38
|
<span id="deck-name" class="deck-name"></span>
|
|
39
|
+
<span id="server-note" class="server-note" hidden></span>
|
|
40
40
|
</div>
|
|
41
41
|
<div class="group">
|
|
42
42
|
<button id="undo" class="icon" aria-label="Undo" title="Undo (⌘Z)" disabled>↶</button>
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
double-click to change the text. A selected element is what the assistant takes “this” to mean.</p>
|
|
100
100
|
<p>Add figures, audio, a PDF or a screenshot with <strong>+</strong>, or drag and drop or paste a file; the assistant can put it on a slide
|
|
101
101
|
or, for a paper or report, take the details from it.</p>
|
|
102
|
-
<p>The assistant edits <code>index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
|
|
102
|
+
<p>The assistant edits <code id="deck-file">index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
|
|
103
103
|
<code>brand.css</code> is locked.</p>
|
|
104
104
|
</div>
|
|
105
105
|
</div>
|