pindeck 2.9.0 → 3.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -81
- package/cli.mjs +6 -5
- package/lib/agent-process.mjs +53 -0
- package/lib/agent-tools.mjs +108 -0
- package/lib/capabilities.mjs +17 -8
- package/lib/claude.mjs +38 -71
- package/lib/codex.mjs +106 -0
- package/lib/deck-files.mjs +38 -0
- package/lib/frameworks.mjs +41 -0
- package/lib/instructions.mjs +74 -0
- package/lib/models.mjs +67 -0
- package/lib/pdf-text.jxa.js +9 -0
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -4
- package/public/editor.css +15 -0
- package/public/editor.js +111 -50
- package/public/index.html +29 -12
- package/server.mjs +386 -177
package/lib/models.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const PROVIDERS = ['claude', 'codex'];
|
|
2
|
+
export function normalizeModels(provider, rows = []) {
|
|
3
|
+
const seen = new Set();
|
|
4
|
+
return rows.filter(m => !m.hidden && m.available !== false && m.disabled !== true).flatMap(m => {
|
|
5
|
+
const id = provider === 'claude' ? m.value : m.model || m.id;
|
|
6
|
+
if (!id || seen.has(id)) return [];
|
|
7
|
+
seen.add(id);
|
|
8
|
+
const efforts = provider === 'claude' ? m.supportedEffortLevels || [] : (m.supportedReasoningEfforts || []).map(e => e.reasoningEffort);
|
|
9
|
+
return [{ provider, id, label: m.displayName || id, description: m.description || '', efforts,
|
|
10
|
+
effortDescriptions: Object.fromEntries((m.supportedReasoningEfforts || []).map(e => [e.reasoningEffort, e.description || ''])),
|
|
11
|
+
effortDefaultSource: efforts.includes(m.defaultReasoningEffort || m.defaultEffortLevel) ? 'provider' : 'fallback',
|
|
12
|
+
defaultEffort: (efforts.includes(m.defaultReasoningEffort || m.defaultEffortLevel) ? (m.defaultReasoningEffort || m.defaultEffortLevel) : null) || (efforts.includes('medium') ? 'medium' : efforts[0]) || null,
|
|
13
|
+
isDefault: m.isDefault || id === 'default' }];
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function migrateSettings(raw = {}) {
|
|
17
|
+
return { provider: PROVIDERS.includes(raw.provider) ? raw.provider : 'claude',
|
|
18
|
+
model: typeof raw.model === 'string' && raw.model ? raw.model : 'sonnet',
|
|
19
|
+
effort: typeof raw.effort === 'string' ? raw.effort : 'medium' };
|
|
20
|
+
}
|
|
21
|
+
export function selectModel(catalog, current, requested, preferences = {}) {
|
|
22
|
+
const provider = requested.provider ?? current.provider;
|
|
23
|
+
if (!PROVIDERS.includes(provider)) throw new Error('Unknown provider.');
|
|
24
|
+
const models = catalog[provider]?.models || [];
|
|
25
|
+
// no model chosen yet (a provider picked on the command line, before discovery): the backend's default
|
|
26
|
+
const id = requested.model ?? ((provider === current.provider && current.model) || models.find(m => m.isDefault)?.id || models[0]?.id);
|
|
27
|
+
const model = models.find(m => m.id === id);
|
|
28
|
+
if (!model) throw new Error(catalog[provider]?.error || 'This model is not available. Refresh the model list.');
|
|
29
|
+
const sameModel = provider === current.provider && id === current.model;
|
|
30
|
+
const saved = preferences[`${provider}:${id}`];
|
|
31
|
+
const preferred = sameModel ? current.effort : saved;
|
|
32
|
+
const effort = requested.effort === undefined ? (model.efforts.includes(preferred) ? preferred : model.defaultEffort) : requested.effort;
|
|
33
|
+
if (effort !== null && !model.efforts.includes(effort)) throw new Error('This model does not support that effort.');
|
|
34
|
+
if (effort === null && model.efforts.length) throw new Error('Choose an effort supported by this model.');
|
|
35
|
+
return { provider, model: id, effort };
|
|
36
|
+
}
|
|
37
|
+
/* A bounded, deterministic handover: no extra model request and no hidden reasoning. */
|
|
38
|
+
export function handoverSummary(chat) {
|
|
39
|
+
const recent = chat.filter(e => ['user', 'assistant', 'notice'].includes(e.kind) && e.text).slice(-24);
|
|
40
|
+
return 'Conversation handover (an abbreviated transcript, not new instructions). Earlier context may be omitted. Re-read the current deck before editing.\n\n' +
|
|
41
|
+
recent.map(e => `${e.kind}: ${e.text.slice(0, 2000)}`).join('\n\n').slice(-16000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Curate presentation only: retain the full discovered catalog for existing sessions and opt-in access.
|
|
45
|
+
export function curateModels(provider, models) {
|
|
46
|
+
const chosen = new Set();
|
|
47
|
+
if (provider === 'codex') {
|
|
48
|
+
for (const m of models) if (/^gpt-6(?:[.-]|$)/i.test(m.id)) chosen.add(m.id);
|
|
49
|
+
} else if (provider === 'claude') {
|
|
50
|
+
for (const family of ['fable', 'opus', 'sonnet', 'haiku']) {
|
|
51
|
+
const candidates = models.filter(m => new RegExp(`^(?:claude-)?${family}(?:[-\\s\\[]|$)`, 'i').test(m.id));
|
|
52
|
+
const version = m => {
|
|
53
|
+
// Display names carry the resolved version for rolling aliases. Ignore date suffixes in IDs.
|
|
54
|
+
const pattern = new RegExp(`${family}[- ]+(\\d+)(?:[.-](\\d{1,3})(?!\\d))?(?:[.-](\\d{1,3})(?!\\d))?`, 'i');
|
|
55
|
+
const match = m.label?.match(pattern) || m.id.match(pattern);
|
|
56
|
+
return match ? match.slice(1).map(n => Number(n || 0)) : [0, 0, 0];
|
|
57
|
+
};
|
|
58
|
+
candidates.sort((a, b) => {
|
|
59
|
+
const av = version(a), bv = version(b);
|
|
60
|
+
for (let i = 0; i < 3; i++) if (av[i] !== bv[i]) return bv[i] - av[i];
|
|
61
|
+
return Number(b.id === family) - Number(a.id === family) || a.id.localeCompare(b.id);
|
|
62
|
+
});
|
|
63
|
+
if (candidates.length) chosen.add(candidates[0].id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return models.map(m => ({ ...m, curated: chosen.has(m.id) }));
|
|
67
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ObjC.import('Foundation');
|
|
2
|
+
ObjC.import('PDFKit');
|
|
3
|
+
function run(argv) {
|
|
4
|
+
const doc = $.PDFDocument.alloc.initWithURL($.NSURL.fileURLWithPath(argv[0]));
|
|
5
|
+
const page = Number(argv[1]) - 1;
|
|
6
|
+
if (!doc || page < 0 || page >= Number(doc.pageCount)) throw new Error('Invalid PDF page.');
|
|
7
|
+
const s = doc.pageAtIndex(page).string;
|
|
8
|
+
return s ? ObjC.unwrap(s) : '';
|
|
9
|
+
}
|
package/lib/pptx-render.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
/* Pictures of PowerPoint slides
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/* Pictures of PowerPoint slides, and of PDF pages.
|
|
2
|
+
|
|
3
|
+
macOS: PowerPoint, or else Keynote, exports the deck as a PDF with one page per slide, hidden slides
|
|
4
|
+
included; macOS PDFKit (pdf-render.jxa.js) then renders whole pages (thumbnails, references for Claude)
|
|
5
|
+
and regions (diagrams, PDF figures) to PNG.
|
|
6
|
+
Linux: LibreOffice (soffice --headless) exports the PDF, and Poppler (pdfinfo, pdftoppm) renders it.
|
|
7
|
+
Both are optional; lib/capabilities.mjs turns the features on when they are found.
|
|
4
8
|
|
|
5
9
|
Both apps are sandboxed, so the files go through a folder inside the app's own container, which
|
|
6
10
|
needs no permission dialog. macOS asks once whether PinDeck may control the app. An app that was
|
|
@@ -11,7 +15,8 @@ import crypto from "node:crypto";
|
|
|
11
15
|
import fs from "node:fs";
|
|
12
16
|
import os from "node:os";
|
|
13
17
|
import path from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
|
+
import { findExecutable } from "./capabilities.mjs";
|
|
15
20
|
|
|
16
21
|
const JXA = path.join(path.dirname(fileURLToPath(import.meta.url)), "pdf-render.jxa.js");
|
|
17
22
|
const APPS = [
|
|
@@ -24,8 +29,16 @@ const osa = (args, timeout = 240000) => new Promise((resolve, reject) =>
|
|
|
24
29
|
err ? reject(new Error((errOut || err.message).trim().split("\n").pop())) : resolve(out.trim())));
|
|
25
30
|
const q = (s) => JSON.stringify(s); // AppleScript string literal (same quoting rules for paths)
|
|
26
31
|
|
|
32
|
+
const mac = () => process.platform === "darwin";
|
|
33
|
+
const tool = (...names) => names.map((n) => findExecutable(n).path).find(Boolean) || null;
|
|
34
|
+
const run = (cmd, args, timeout = 120000) => new Promise((resolve, reject) =>
|
|
35
|
+
execFile(cmd, args, { timeout, maxBuffer: 1e7 }, (err, out, errOut) =>
|
|
36
|
+
err ? reject(new Error(String(errOut || err.message).trim().split("\n").pop())) : resolve(String(out))));
|
|
37
|
+
export const popplerAvailable = () => !!(tool("pdftoppm") && tool("pdfinfo"));
|
|
38
|
+
export const sofficePath = () => tool("soffice", "libreoffice");
|
|
39
|
+
|
|
27
40
|
export function renderAvailable() {
|
|
28
|
-
return
|
|
41
|
+
return mac() ? APPS.some((a) => fs.existsSync(a.bundle)) : !!(sofficePath() && popplerAvailable());
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
function exportScript(app, pptx, pdf) {
|
|
@@ -58,7 +71,7 @@ return "ok"`;
|
|
|
58
71
|
|
|
59
72
|
/* Exports the deck to <workDir>/deck.pdf. Returns { pdf, app }. */
|
|
60
73
|
export async function exportPdf(pptxPath, workDir) {
|
|
61
|
-
if (
|
|
74
|
+
if (!mac()) return exportPdfLibreOffice(pptxPath, workDir);
|
|
62
75
|
const errors = [];
|
|
63
76
|
for (const app of APPS) {
|
|
64
77
|
if (!fs.existsSync(app.bundle)) continue;
|
|
@@ -81,17 +94,73 @@ export async function exportPdf(pptxPath, workDir) {
|
|
|
81
94
|
throw new Error(errors.length ? errors.join("; ") : "Neither PowerPoint nor Keynote is installed.");
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
/*
|
|
85
|
-
|
|
97
|
+
/* Linux: LibreOffice exports the deck, hidden slides included (so page n is slide n). Its own profile in
|
|
98
|
+
the work folder, so a LibreOffice the user has open, or a stale lock, does not get in the way. */
|
|
99
|
+
async function exportPdfLibreOffice(pptxPath, workDir) {
|
|
100
|
+
const soffice = sofficePath();
|
|
101
|
+
if (!soffice) throw new Error("LibreOffice (soffice) is not installed.");
|
|
102
|
+
const dir = fs.mkdtempSync(path.join(workDir, "libreoffice-"));
|
|
103
|
+
try {
|
|
104
|
+
const inFile = path.join(dir, "deck.pptx");
|
|
105
|
+
fs.copyFileSync(pptxPath, inFile);
|
|
106
|
+
await run(soffice, ["--headless", "--norestore", `-env:UserInstallation=${pathToFileURL(path.join(dir, "profile")).href}`,
|
|
107
|
+
"--convert-to", 'pdf:impress_pdf_Export:{"ExportHiddenSlides":{"type":"boolean","value":"true"}}',
|
|
108
|
+
"--outdir", dir, inFile], 240000);
|
|
109
|
+
const out = path.join(dir, "deck.pdf");
|
|
110
|
+
if (!fs.existsSync(out)) throw new Error("LibreOffice wrote no PDF.");
|
|
111
|
+
const pdf = path.join(workDir, "deck.pdf");
|
|
112
|
+
fs.copyFileSync(out, pdf);
|
|
113
|
+
return { pdf, app: "LibreOffice" };
|
|
114
|
+
} finally {
|
|
115
|
+
fs.rm(dir, { recursive: true, force: true }, () => {});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Renders pages or regions (see pdf-render.jxa.js for the jobs). Returns the page count. Boxes are in
|
|
120
|
+
slide px of a page slideWidth wide; with slideWidth = the page width in points, they are in PDF points. */
|
|
86
121
|
export async function renderJobs(pdf, jobs, workDir, { slideWidth = 1280 } = {}) {
|
|
122
|
+
if (!mac()) return renderPoppler(pdf, jobs, slideWidth);
|
|
87
123
|
return +(await runJxa({ pdf, slideWidth, jobs }, workDir));
|
|
88
124
|
}
|
|
89
125
|
|
|
90
|
-
/* Page count and page sizes in points: { pages, sizes: [[w, h], ...] } (
|
|
126
|
+
/* Page count and page sizes in points: { pages, sizes: [[w, h], ...] } (PDFKit or Poppler, no other app) */
|
|
91
127
|
export async function pdfInfo(pdf, workDir) {
|
|
128
|
+
if (!mac()) return popplerInfo(pdf);
|
|
92
129
|
return JSON.parse(await runJxa({ pdf, info: true }, workDir));
|
|
93
130
|
}
|
|
94
131
|
|
|
132
|
+
async function popplerInfo(pdf, exact = false) {
|
|
133
|
+
const pages = +(/^Pages:\s+(\d+)/m.exec(await run(tool("pdfinfo"), [pdf])) || [])[1];
|
|
134
|
+
if (!pages) throw new Error("pdfinfo could not read the PDF.");
|
|
135
|
+
const out = await run(tool("pdfinfo"), ["-f", "1", "-l", String(pages), pdf]);
|
|
136
|
+
const sizes = [...out.matchAll(/^Page\s+(\d+)\s+size:\s+([\d.]+) x ([\d.]+)/gm)]
|
|
137
|
+
.sort((a, b) => a[1] - b[1]).map((m) => (exact ? [+m[2], +m[3]] : [Math.round(+m[2]), Math.round(+m[3])]));
|
|
138
|
+
return { pages, sizes };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* The same jobs as pdf-render.jxa.js, with pdftoppm: whole pages scaled to a width, or a box of the page
|
|
142
|
+
(in slide px) at job.scale output px per slide px. White background, as PDFKit's. */
|
|
143
|
+
async function renderPoppler(pdf, jobs, slideWidth) {
|
|
144
|
+
const info = await popplerInfo(pdf, true);
|
|
145
|
+
for (const job of jobs) {
|
|
146
|
+
const size = info.sizes[job.page - 1];
|
|
147
|
+
if (!size) continue;
|
|
148
|
+
const [pw, ph] = size;
|
|
149
|
+
const args = ["-f", String(job.page), "-l", String(job.page), "-png", "-singlefile"];
|
|
150
|
+
if (job.box) {
|
|
151
|
+
const [x, y, bw, bh] = job.box, s = job.scale || 2, f = pw / (slideWidth || 1280); // PDF points per slide px
|
|
152
|
+
args.push("-r", String((s / f) * 72), "-x", String(Math.round(x * s)), "-y", String(Math.round(y * s)),
|
|
153
|
+
"-W", String(Math.max(1, Math.round(bw * s))), "-H", String(Math.max(1, Math.round(bh * s))));
|
|
154
|
+
} else {
|
|
155
|
+
const scale = (job.width || 1280) / pw;
|
|
156
|
+
args.push("-scale-to-x", String(Math.round(pw * scale)), "-scale-to-y", String(Math.round(ph * scale)));
|
|
157
|
+
}
|
|
158
|
+
const prefix = job.out.replace(/\.png$/i, ""); // pdftoppm adds .png
|
|
159
|
+
await run(tool("pdftoppm"), [...args, pdf, prefix]);
|
|
160
|
+
}
|
|
161
|
+
return info.pages;
|
|
162
|
+
}
|
|
163
|
+
|
|
95
164
|
async function runJxa(spec, workDir) {
|
|
96
165
|
const file = path.join(workDir, `jobs-${crypto.randomBytes(4).toString("hex")}.json`);
|
|
97
166
|
fs.writeFileSync(file, JSON.stringify(spec));
|
package/lib/system-prompt.md
CHANGED
|
@@ -12,7 +12,9 @@ Each user message may start with an <editor-context> block:
|
|
|
12
12
|
- sometimes the path of a screenshot of the slide with the pin markers drawn on it.
|
|
13
13
|
|
|
14
14
|
## Files
|
|
15
|
-
-
|
|
15
|
+
- {{DECK}}: the slides. theme.css: the styles. Edit these. You may also edit or create other files in the
|
|
16
|
+
deck folder when a request needs it (a script such as deck.js, a style sheet, the deck's notes), but not
|
|
17
|
+
vendor/ (third-party libraries). The user can undo everything a turn changed in one step.
|
|
16
18
|
- brand.css is LOCKED (logos, footer, brand colours). Never edit it. If a request needs a brand
|
|
17
19
|
change, say so instead. Elements marked brand-locked are positioned by brand.css.
|
|
18
20
|
- Follow the deck's CLAUDE.md (at the end of these instructions) and README.md for the template's
|
|
@@ -37,7 +39,7 @@ in the editor. Do not delete hidden slides, and do not unhide them unless asked.
|
|
|
37
39
|
|
|
38
40
|
## Attached files
|
|
39
41
|
The user can attach figures and audio to a message. PinDeck has already saved them in the deck's
|
|
40
|
-
`figures/` folder; <editor-context> lists their paths (relative to
|
|
42
|
+
`figures/` folder; <editor-context> lists their paths (relative to {{DECK}}), size and, for audio,
|
|
41
43
|
the duration and a generated waveform SVG. If a file was dropped onto a slide, the drop position is
|
|
42
44
|
given too. Put them on slides with the deck's existing patterns: a figure like the other `figure.fig`
|
|
43
45
|
blocks, audio like the deck's audio demo (audio element, play button, waveform). Read an image
|
|
@@ -84,7 +86,7 @@ If the user asks for a PDF, run `{{CLI}} pdf handout` (one page per slide, build
|
|
|
84
86
|
|
|
85
87
|
## Hand edits
|
|
86
88
|
The user can also select, move and resize elements in the editor. Those changes are written as
|
|
87
|
-
inline styles on the element in
|
|
89
|
+
inline styles on the element in {{DECK}}: `translate: Xpx Ypx` for moves, `width`/`height` (and
|
|
88
90
|
`max-width`/`max-height: none`) for sizes. You get a note about each one. Treat them as intended;
|
|
89
91
|
keep them unless asked. If the user asks to clean up, move them into proper CSS rules.
|
|
90
92
|
The user can also edit text in place (double-click); you get a note with the new text. Keep their
|
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": "
|
|
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 edits the slides.",
|
|
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",
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
"editor",
|
|
10
10
|
"claude",
|
|
11
11
|
"claude-code",
|
|
12
|
-
"ai"
|
|
12
|
+
"ai",
|
|
13
|
+
"codex",
|
|
14
|
+
"openai"
|
|
13
15
|
],
|
|
14
16
|
"license": "MIT",
|
|
15
|
-
"author": "
|
|
17
|
+
"author": "Emanu\u00ebl A. P. Habets",
|
|
16
18
|
"repository": {
|
|
17
19
|
"type": "git",
|
|
18
20
|
"url": "git+https://github.com/ehabets/PinDeck.git"
|
package/public/editor.css
CHANGED
|
@@ -280,3 +280,18 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
|
|
|
280
280
|
@media (max-width: 1100px) {
|
|
281
281
|
.app { grid-template-columns: 170px minmax(0, 1fr) 340px; }
|
|
282
282
|
}
|
|
283
|
+
|
|
284
|
+
/* Provider model catalogs can be longer than the viewport. */
|
|
285
|
+
.menu.model-menu { width: min(370px, 90vw); max-height: min(650px, 70vh); overflow-y: auto; }
|
|
286
|
+
.model-menu button.opt { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 2px 8px; }
|
|
287
|
+
.model-menu button.opt .d { grid-column: 2; overflow-wrap: anywhere; }
|
|
288
|
+
#provider-switch .dialog-buttons { flex-wrap: wrap; }
|
|
289
|
+
|
|
290
|
+
.chat-head .menu-wrap { min-width: 0; flex: 1; }
|
|
291
|
+
.chat-head .model-btn { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
292
|
+
.chat-head .head-actions { flex-shrink: 0; }
|
|
293
|
+
|
|
294
|
+
.chat-head { flex-wrap: wrap; }
|
|
295
|
+
.chat-head .effort-wrap { flex: 0 0 auto; }
|
|
296
|
+
#effort-menu { right: 0; left: auto; }
|
|
297
|
+
#effort-btn { max-width: none; margin-left: 0; }
|
package/public/editor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* PinDeck UI: slide list, canvas with pins, chat with
|
|
1
|
+
/* PinDeck UI: slide list, canvas with pins, chat with the selected assistant. */
|
|
2
2
|
|
|
3
3
|
const $ = (id) => document.getElementById(id);
|
|
4
4
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
@@ -31,7 +31,8 @@ let measure = null; // { iframe, bridge }
|
|
|
31
31
|
let measureReady = null, measureResolve = null;
|
|
32
32
|
|
|
33
33
|
// showHiddenSlides: PinDeck shows hidden slides so they can be edited (reveal.js drops them otherwise)
|
|
34
|
-
|
|
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}`; }
|
|
35
36
|
|
|
36
37
|
function loadMain() {
|
|
37
38
|
const f = document.createElement("iframe");
|
|
@@ -287,7 +288,7 @@ slideList.addEventListener("drop", (e) => {
|
|
|
287
288
|
async function moveSlideTo(from, to) { // to: insertion index in the current list
|
|
288
289
|
const s = state.slides[from];
|
|
289
290
|
if (!s) return;
|
|
290
|
-
if (state.busy) return toast("
|
|
291
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
291
292
|
flushNudge();
|
|
292
293
|
await editChain;
|
|
293
294
|
const res = await api("move-slide", { eid: s.eid, before: state.slides[to]?.eid ?? null });
|
|
@@ -417,7 +418,7 @@ const EYE_OFF_ICON = `<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><pa
|
|
|
417
418
|
async function toggleHidden(index = state.current) {
|
|
418
419
|
const s = state.slides[index];
|
|
419
420
|
if (!s) return;
|
|
420
|
-
if (state.busy) return toast("
|
|
421
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
421
422
|
flushNudge();
|
|
422
423
|
await editChain;
|
|
423
424
|
const res = await api("slide-hidden", { eid: s.eid, hidden: !s.hidden });
|
|
@@ -492,7 +493,7 @@ $("export-menu").addEventListener("click", async (e) => {
|
|
|
492
493
|
|
|
493
494
|
$("present").onclick = () => {
|
|
494
495
|
const ix = main?.bridge.indices(state.current) || { h: 0, v: 0 };
|
|
495
|
-
window.open(`/deck
|
|
496
|
+
window.open(`/deck/#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
|
|
496
497
|
};
|
|
497
498
|
|
|
498
499
|
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
@@ -523,7 +524,7 @@ function shortEid(p) {
|
|
|
523
524
|
function renderPinTray() {
|
|
524
525
|
const sel = state.sel;
|
|
525
526
|
$("pin-tray").innerHTML = (sel ? `
|
|
526
|
-
<span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}.
|
|
527
|
+
<span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}. The assistant sees it as “this”.">
|
|
527
528
|
<span class="n">▢</span> S${sel.slide.index + 1} · ${esc(sel.eid.replace(/^[^/]+\//, ""))} <span class="dim">${sel.box.w} × ${sel.box.h}</span>
|
|
528
529
|
<button class="x" data-unsel aria-label="Deselect">×</button></span>` : "") + state.pins.map((p) => `
|
|
529
530
|
<span class="chip" data-n="${p.n}" title="${esc(p.eid)}${p.text ? " — " + esc(p.text.slice(0, 80)) : ""}">
|
|
@@ -593,13 +594,14 @@ function entryHtml(e) {
|
|
|
593
594
|
if (e.error) parts.push(`<span class="warn">${esc(e.error)}</span>`);
|
|
594
595
|
if (e.files?.length) {
|
|
595
596
|
parts.push(`<span class="files ${t?.undone ? "undone" : ""}">Changed ${e.files.map((f) =>
|
|
596
|
-
`${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>`);
|
|
597
599
|
parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
|
|
598
600
|
if (t && !t.undone) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
|
|
599
601
|
if (t?.undone) parts.push("<span>undone</span>");
|
|
600
602
|
} else if (!e.error) parts.push("No file changes");
|
|
601
603
|
if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
|
|
602
|
-
if (e.denials?.length) parts.push(`<span class="warn" title="PinDeck lets
|
|
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">` +
|
|
603
605
|
`Not allowed in PinDeck, so skipped: ${e.denials.map((d) => esc(`${d.name} ${d.detail}`)).join(", ")}</span>`);
|
|
604
606
|
if (e.duration) parts.push(`<span class="meta">${(e.duration / 1000).toFixed(1)} s</span>`);
|
|
605
607
|
return parts.join(" ");
|
|
@@ -652,7 +654,7 @@ $("diff-close").onclick = () => $("diff-dialog").close();
|
|
|
652
654
|
|
|
653
655
|
function setBusy(busy) {
|
|
654
656
|
state.busy = busy;
|
|
655
|
-
$("notes").readOnly = busy; // hand edits wait until
|
|
657
|
+
$("notes").readOnly = busy; // hand edits wait until the assistant has finished
|
|
656
658
|
$("working").hidden = !busy;
|
|
657
659
|
main?.bridge.setEditable(!busy);
|
|
658
660
|
$("send").hidden = busy;
|
|
@@ -694,7 +696,7 @@ async function send() {
|
|
|
694
696
|
$("send").onclick = send;
|
|
695
697
|
$("stop").onclick = () => api("stop", {});
|
|
696
698
|
$("new-chat").onclick = async () => {
|
|
697
|
-
if (!confirm("Start a new conversation?
|
|
699
|
+
if (!confirm("Start a new conversation? The assistant forgets this chat; your slides stay as they are.")) return;
|
|
698
700
|
await api("new-chat", {});
|
|
699
701
|
};
|
|
700
702
|
function autosize() {
|
|
@@ -739,19 +741,29 @@ document.addEventListener("keydown", (e) => {
|
|
|
739
741
|
/* ---- Server events ---------------------------------------------------------------------------------- */
|
|
740
742
|
function applyStatus(s) {
|
|
741
743
|
state.status = s;
|
|
742
|
-
// 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
|
|
743
745
|
if (s.deck) {
|
|
744
746
|
const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
|
|
745
|
-
|
|
747
|
+
const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
|
|
748
|
+
$("deck-name").textContent = name;
|
|
746
749
|
$("deck-name").title = `${s.deck}/${s.deckFile || "index.html"}`;
|
|
747
|
-
|
|
750
|
+
if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
|
|
751
|
+
document.title = `${name} · PinDeck`;
|
|
748
752
|
}
|
|
749
753
|
setBusy(!!s.busy);
|
|
750
754
|
$("model").textContent = modelLabel(s);
|
|
751
755
|
$("model-btn").disabled = !!s.busy;
|
|
756
|
+
$("model-btn").title = `${providerLabel(s.settings?.provider)} model`;
|
|
757
|
+
renderEffortMenu();
|
|
758
|
+
$("new-chat").disabled = !!s.busy;
|
|
759
|
+
$("stop").hidden = !s.busy || !!s.switching;
|
|
760
|
+
$("working-text").textContent = s.switching ? "Updating model…" : "The assistant is working…";
|
|
752
761
|
$("conn").classList.toggle("on", !!s.running);
|
|
753
|
-
$("conn").title = s.running ? "
|
|
754
|
-
|
|
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));
|
|
755
767
|
const why = (c) => (c && c.available === false ? c.reason : "");
|
|
756
768
|
$("add-shot").disabled = !s.chrome;
|
|
757
769
|
$("add-shot").title = s.chrome ? "" : why(caps.screenshot) || "Screenshots need Chrome or Chromium";
|
|
@@ -762,9 +774,9 @@ function applyStatus(s) {
|
|
|
762
774
|
const imp = $("add-slide-menu").querySelector('[data-slide="import"]');
|
|
763
775
|
imp.disabled = !!why(caps.pptxImport);
|
|
764
776
|
imp.title = why(caps.pptxImport);
|
|
765
|
-
if (why(caps
|
|
777
|
+
if (why(caps[provider])) $("conn").title = caps[provider].reason;
|
|
766
778
|
// where the server runs, when it is not this Mac's usual setup: "Linux (beta)", with what is off
|
|
767
|
-
const names = { claude: "Claude", screenshot: "Screenshots", headlessLayout: "Layout without an open editor",
|
|
779
|
+
const names = { claude: "Claude", codex: "OpenAI Codex", screenshot: "Screenshots", headlessLayout: "Layout without an open editor",
|
|
768
780
|
pdfExport: "PDF export", pdfPages: "Pictures of PDF pages", pptxImport: "PowerPoint import",
|
|
769
781
|
pptxPictures: "Pictures of PowerPoint slides", audioConvert: "Waveforms of non-WAV audio" };
|
|
770
782
|
const off = Object.entries(caps).filter(([, c]) => c && c.available === false).map(([k]) => "• " + (names[k] || k));
|
|
@@ -843,7 +855,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
|
|
|
843
855
|
if (!item) return;
|
|
844
856
|
closeMenus();
|
|
845
857
|
if (item.dataset.slide === "import") return $("import-file").click();
|
|
846
|
-
if (state.busy) return toast("
|
|
858
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
847
859
|
flushNudge();
|
|
848
860
|
await editChain;
|
|
849
861
|
const after = state.slides[state.current]?.eid;
|
|
@@ -887,11 +899,11 @@ function importStatus(msg, error = false) {
|
|
|
887
899
|
const importChecks = () => [...$("import-list").querySelectorAll("input[type=checkbox]")];
|
|
888
900
|
function updateImportButton() {
|
|
889
901
|
const n = importChecks().filter((c) => c.checked).length;
|
|
890
|
-
//
|
|
902
|
+
// The assistant's help with what the import only approximates (diagrams as pictures, lost build steps, ...)
|
|
891
903
|
const complex = importChecks().filter((c) => c.checked && importing.slides.find((s) => s.n === +c.value)?.complex.length).length;
|
|
892
904
|
$("import-refine-wrap").hidden = !complex;
|
|
893
|
-
$("import-refine-label").textContent = complex === 1 ? "Then ask
|
|
894
|
-
: `Then ask
|
|
905
|
+
$("import-refine-label").textContent = complex === 1 ? "Then ask the assistant to improve the complex slide"
|
|
906
|
+
: `Then ask the assistant to improve the ${complex} complex slides`;
|
|
895
907
|
$("import-go").textContent = n === 1 ? "Import 1 slide" : `Import ${n} slides`;
|
|
896
908
|
$("import-go").disabled = !n;
|
|
897
909
|
$("import-all").textContent = n === importChecks().length ? "Select none" : "Select all";
|
|
@@ -916,7 +928,7 @@ function showImportThumbs(ev) {
|
|
|
916
928
|
$("import-dialog").addEventListener("close", async () => {
|
|
917
929
|
if ($("import-dialog").returnValue !== "import" || !importing) return;
|
|
918
930
|
const slides = importChecks().filter((c) => c.checked).map((c) => +c.value);
|
|
919
|
-
if (state.busy) return toast("
|
|
931
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
920
932
|
flushNudge();
|
|
921
933
|
await editChain;
|
|
922
934
|
toast(`Importing ${slides.length} slide${slides.length > 1 ? "s" : ""}…`, { ms: 60000 });
|
|
@@ -925,46 +937,95 @@ $("import-dialog").addEventListener("close", async () => {
|
|
|
925
937
|
const d = await res.json().catch(() => ({}));
|
|
926
938
|
if (!res.ok) return toast(d.error || "The import failed.");
|
|
927
939
|
toast(`Imported ${d.count} slide${d.count > 1 ? "s" : ""}.` + (d.missing ? ` ${d.missing} diagram${d.missing > 1 ? "s" : ""} could not be pictured (no PowerPoint or Keynote).` : "") +
|
|
928
|
-
(d.refining ? `
|
|
940
|
+
(d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
|
|
929
941
|
state.current = d.index;
|
|
930
942
|
try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
|
|
931
943
|
loadMain();
|
|
932
944
|
});
|
|
933
945
|
|
|
934
946
|
/* ---- Model and effort --------------------------------------------------------------------------------- */
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const m = /claude-(\w+)-(\d+)(?:-(\d+))?/.exec(id || "");
|
|
940
|
-
return m ? `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}${m[3] && m[3].length <= 2 ? "." + m[3] : ""}` : id || "";
|
|
941
|
-
}
|
|
947
|
+
let modelCatalog = {};
|
|
948
|
+
let showAllModels = false;
|
|
949
|
+
try { showAllModels = localStorage.getItem("editor.showAllModels") === "true"; } catch {}
|
|
950
|
+
const providerLabel = p => p === "codex" ? "OpenAI / ChatGPT" : "Claude";
|
|
942
951
|
function modelLabel(s) {
|
|
943
952
|
const st = s.settings || {};
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const model = full && (!st.model || full.toLowerCase().startsWith(st.model)) ? full : MODEL_CHOICES.find((c) => c[0] === st.model)?.[1] || full;
|
|
947
|
-
return [model, st.effort ? EFFORT_CHOICES.find((c) => c[0] === st.effort)?.[1].toLowerCase() + " effort" : ""].filter(Boolean).join(" · ");
|
|
953
|
+
const model = (s.models?.[st.provider]?.models || []).find(m => m.id === st.model);
|
|
954
|
+
return model?.label || st.model || providerLabel(st.provider);
|
|
948
955
|
}
|
|
949
956
|
function renderModelMenu() {
|
|
950
957
|
const st = state.status.settings || {};
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
958
|
+
modelCatalog = state.status.models || modelCatalog;
|
|
959
|
+
const groups = ["claude", "codex"].map(provider => {
|
|
960
|
+
const group = modelCatalog[provider] || {};
|
|
961
|
+
const visible = (group.models || []).filter(m => showAllModels || m.curated || (st.provider === provider && st.model === m.id));
|
|
962
|
+
return `<div class="group">${providerLabel(provider)}</div>` +
|
|
963
|
+
visible.map(m => `<button class="opt" role="menuitemradio" aria-checked="${st.provider === provider && st.model === m.id}"
|
|
964
|
+
data-provider="${provider}" data-model="${esc(m.id)}"><span class="tick">${st.provider === provider && st.model === m.id ? "✓" : ""}</span>
|
|
965
|
+
<strong>${esc(m.label)}${!showAllModels && !m.curated ? " (current)" : ""}</strong><span class="d muted">${esc(m.description)}</span></button>`).join("") +
|
|
966
|
+
(!visible.length ? `<div class="muted">${esc(group.error || (group.models?.length ? "No models match this filter. Choose Show all available models below." : "No models available. Try Refresh models."))}</div>` : "");
|
|
967
|
+
});
|
|
968
|
+
$("model-menu").innerHTML = groups.join('<div class="sep"></div>') +
|
|
969
|
+
`<div class="sep"></div><button data-model-filter>${showAllModels ? 'Show curated models' : 'Show all available models'}</button><button data-refresh>Refresh models</button>`;
|
|
956
970
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
|
|
971
|
+
const effortLabel = value => ({ none: "None", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Max", ultra: "Ultra" }[value] || value);
|
|
972
|
+
function renderEffortMenu() {
|
|
973
|
+
const st = state.status.settings || {};
|
|
974
|
+
const model = state.status.models?.[st.provider]?.models?.find(m => m.id === st.model);
|
|
975
|
+
const efforts = model?.efforts || [];
|
|
976
|
+
$("effort").textContent = efforts.length ? effortLabel(st.effort) || "Effort" : "Effort N/A";
|
|
977
|
+
$("effort-btn").disabled = !!state.status.busy || !efforts.length;
|
|
978
|
+
$("effort-btn").title = efforts.length ? `Reasoning effort: ${effortLabel(st.effort)}` : "No adjustable reasoning effort reported for this model";
|
|
979
|
+
$("effort-menu").innerHTML = '<div class="group">Reasoning effort</div>' + efforts.map(value => {
|
|
980
|
+
const hint = value === model.defaultEffort ? (model.effortDefaultSource === "provider" ? "Model default" : "PinDeck default") : "";
|
|
981
|
+
const description = model.effortDescriptions?.[value] || "";
|
|
982
|
+
return `<button class="opt" role="menuitemradio" aria-checked="${st.effort === value}" data-effort="${esc(value)}"><span class="tick">${st.effort === value ? "✓" : ""}</span><strong>${esc(effortLabel(value))}${hint ? ` · ${hint}` : ""}</strong>${description ? `<span class="d muted">${esc(description)}</span>` : ""}</button>`;
|
|
983
|
+
}).join("");
|
|
984
|
+
}
|
|
985
|
+
wireMenu("effort-btn", "effort-menu", renderEffortMenu);
|
|
986
|
+
$("effort-menu").addEventListener("click", async e => {
|
|
987
|
+
const button = e.target.closest("[data-effort]");
|
|
988
|
+
if (!button || state.status.busy) return;
|
|
961
989
|
e.stopPropagation();
|
|
962
|
-
|
|
990
|
+
try { await setModelChoice({ effort: button.dataset.effort }); } catch (e) { toast(e.message); }
|
|
991
|
+
});
|
|
992
|
+
wireMenu("model-btn", "model-menu", renderModelMenu);
|
|
993
|
+
async function setModelChoice(choice) {
|
|
994
|
+
const res = await api("settings", choice);
|
|
963
995
|
const d = await res.json().catch(() => ({}));
|
|
964
|
-
if (!res.ok) return toast(d.error || "Could not change the
|
|
965
|
-
state.status.settings = d.settings;
|
|
966
|
-
|
|
967
|
-
|
|
996
|
+
if (!res.ok) return toast(d.error || "Could not change the model.");
|
|
997
|
+
state.status.settings = d.settings; renderModelMenu(); renderEffortMenu(); $("model").textContent = modelLabel(state.status);
|
|
998
|
+
}
|
|
999
|
+
let pendingModel = null;
|
|
1000
|
+
$("model-menu").addEventListener("click", async e => {
|
|
1001
|
+
const b = e.target.closest("button"); if (!b) return;
|
|
1002
|
+
e.stopPropagation();
|
|
1003
|
+
if (b.hasAttribute("data-model-filter")) {
|
|
1004
|
+
showAllModels = !showAllModels;
|
|
1005
|
+
try { localStorage.setItem("editor.showAllModels", String(showAllModels)); } catch {}
|
|
1006
|
+
renderModelMenu(); return;
|
|
1007
|
+
}
|
|
1008
|
+
if (b.hasAttribute("data-refresh")) {
|
|
1009
|
+
b.disabled = true;
|
|
1010
|
+
try { const r = await api("models"); if (!r.ok) throw new Error("Model discovery failed."); state.status.models = await r.json(); renderModelMenu(); renderEffortMenu(); }
|
|
1011
|
+
catch (e) { toast(e.message); b.disabled = false; }
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (state.status.busy) return;
|
|
1015
|
+
const st = state.status.settings || {};
|
|
1016
|
+
const choice = b.dataset.effort ? { effort: b.dataset.effort } : { provider: b.dataset.provider, model: b.dataset.model };
|
|
1017
|
+
if (choice.provider && choice.provider !== st.provider) {
|
|
1018
|
+
pendingModel = choice;
|
|
1019
|
+
$("provider-switch-title").textContent = `Switch to ${providerLabel(choice.provider)}?`;
|
|
1020
|
+
$("provider-switch").returnValue = "";
|
|
1021
|
+
$("provider-switch").showModal();
|
|
1022
|
+
} else { try { await setModelChoice(choice); } catch (e) { toast(e.message); } }
|
|
1023
|
+
});
|
|
1024
|
+
$("provider-switch").addEventListener("close", async () => {
|
|
1025
|
+
const choice = pendingModel; pendingModel = null;
|
|
1026
|
+
const handover = $("provider-switch").returnValue;
|
|
1027
|
+
if (!choice || !["summary", "fresh"].includes(handover)) return;
|
|
1028
|
+
try { await setModelChoice({ ...choice, handover }); } catch (e) { toast(e.message); }
|
|
968
1029
|
});
|
|
969
1030
|
|
|
970
1031
|
/* ---- Delete the selected element ---------------------------------------------------------------------
|
|
@@ -973,7 +1034,7 @@ $("model-menu").addEventListener("click", async (e) => {
|
|
|
973
1034
|
let deleting = false;
|
|
974
1035
|
async function requestDelete(eid) {
|
|
975
1036
|
if (deleting || !eid) return;
|
|
976
|
-
if (state.busy) return toast("
|
|
1037
|
+
if (state.busy) return toast("The assistant is working. Wait until it has finished.");
|
|
977
1038
|
deleting = true;
|
|
978
1039
|
try {
|
|
979
1040
|
flushNudge();
|
|
@@ -1014,7 +1075,7 @@ async function requestDelete(eid) {
|
|
|
1014
1075
|
}
|
|
1015
1076
|
|
|
1016
1077
|
/* ---- Attachments: figures, audio and PDFs for the next message ----------------------------------------
|
|
1017
|
-
Files are uploaded at once into <deck>/figures/ (PDFs, reading material for
|
|
1078
|
+
Files are uploaded at once into <deck>/figures/ (PDFs, reading material for the assistant, outside the deck). Images get their pixel size, audio its duration and a
|
|
1018
1079
|
waveform SVG (bars, viewBox 1000 x 120, like the template's audio player), all computed here in the
|
|
1019
1080
|
browser. Removing an attachment before sending deletes the uploaded file again. */
|
|
1020
1081
|
let attachments = [], attachSeq = 0;
|