@ohos-ports/slides-grab 1.5.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README-ko.md +292 -0
- package/README.md +301 -0
- package/bin/ohos-postinstall.cjs +118 -0
- package/bin/ppt-agent.js +620 -0
- package/convert.cjs +20 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/glib-2.0/include/glibconfig.h +220 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/index.js +1 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libexpat.so +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libffi.so +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libjpeg.so +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libpcre2-8.so.0 +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips-cpp.so.42.20.3 +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips.so.42.20.3 +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/package.json +13 -0
- package/native/@img/sharp-openharmony-arm64/lib/sharp-openharmony-arm64.node +0 -0
- package/native/@img/sharp-openharmony-arm64/package.json +16 -0
- package/package.json +95 -0
- package/runtimes/claude-code/agents/design-critic-agent.md +23 -0
- package/runtimes/codex/agents/slides-grab-design-critic.md +22 -0
- package/scripts/build-viewer.js +444 -0
- package/scripts/design-gate.js +258 -0
- package/scripts/download-video.js +213 -0
- package/scripts/editor-server.js +1167 -0
- package/scripts/figma-export.js +169 -0
- package/scripts/generate-image.js +116 -0
- package/scripts/generate-images.js +164 -0
- package/scripts/html2pdf.js +822 -0
- package/scripts/html2png.js +246 -0
- package/scripts/html2pptx.js +162 -0
- package/scripts/import-template.js +86 -0
- package/scripts/install-runtime.js +216 -0
- package/scripts/render-tldraw.js +44 -0
- package/scripts/validate-slides.js +221 -0
- package/skills/slides-grab/SKILL.md +65 -0
- package/skills/slides-grab/references/presentation-workflow-reference.md +61 -0
- package/skills/slides-grab-card-news/SKILL.md +35 -0
- package/skills/slides-grab-design/SKILL.md +88 -0
- package/skills/slides-grab-design/references/beautiful-slide-defaults.md +88 -0
- package/skills/slides-grab-design/references/design-gate.md +349 -0
- package/skills/slides-grab-design/references/design-rules.md +76 -0
- package/skills/slides-grab-design/references/design-system-full.md +568 -0
- package/skills/slides-grab-design/references/detailed-design-rules.md +70 -0
- package/skills/slides-grab-export/SKILL.md +55 -0
- package/skills/slides-grab-export/references/export-rules.md +27 -0
- package/skills/slides-grab-export/references/html2pptx.md +627 -0
- package/skills/slides-grab-export/references/ooxml.md +427 -0
- package/skills/slides-grab-export/references/pptx-skill-reference.md +189 -0
- package/skills/slides-grab-html/SKILL.md +67 -0
- package/skills/slides-grab-image/SKILL.md +82 -0
- package/skills/slides-grab-plan/SKILL.md +68 -0
- package/skills/slides-grab-plan/references/design-md-to-slides-conversion.md +135 -0
- package/skills/slides-grab-plan/references/outline-format.md +47 -0
- package/skills/slides-grab-plan/references/plan-workflow-reference.md +140 -0
- package/src/codex-imagen.js +182 -0
- package/src/design-diversity-data.js +6932 -0
- package/src/design-gate-report.js +244 -0
- package/src/design-gate-state.js +329 -0
- package/src/design-import.js +164 -0
- package/src/design-md-parser.js +415 -0
- package/src/design-styles-data.js +1928 -0
- package/src/design-styles.js +209 -0
- package/src/editor/codex-edit.js +584 -0
- package/src/editor/edit-subprocess.js +170 -0
- package/src/editor/editor-codex-prompt.md +50 -0
- package/src/editor/editor.html +1785 -0
- package/src/editor/js/editor-bbox.js +332 -0
- package/src/editor/js/editor-chat.js +56 -0
- package/src/editor/js/editor-direct-edit.js +238 -0
- package/src/editor/js/editor-dom.js +59 -0
- package/src/editor/js/editor-init.js +405 -0
- package/src/editor/js/editor-navigation.js +54 -0
- package/src/editor/js/editor-select.js +558 -0
- package/src/editor/js/editor-send.js +175 -0
- package/src/editor/js/editor-sse.js +163 -0
- package/src/editor/js/editor-state.js +41 -0
- package/src/editor/js/editor-type.js +71 -0
- package/src/editor/js/editor-utils.js +167 -0
- package/src/editor/js/model-registry.js +37 -0
- package/src/editor/screenshot.js +82 -0
- package/src/export-resolution.cjs +68 -0
- package/src/figma.js +71 -0
- package/src/html2pptx-scale.cjs +120 -0
- package/src/html2pptx.cjs +1262 -0
- package/src/image-contract.js +329 -0
- package/src/image-native.js +468 -0
- package/src/nano-banana.js +841 -0
- package/src/ohos-browser-bridge.cjs +272 -0
- package/src/pptx-raster-export.cjs +299 -0
- package/src/resolve.js +110 -0
- package/src/slide-mode.cjs +72 -0
- package/src/template-fidelity.js +267 -0
- package/src/template-import.js +505 -0
- package/src/template-layout.js +261 -0
- package/src/template-pack.js +255 -0
- package/src/tldraw/render.js +473 -0
- package/src/validation/cli.js +120 -0
- package/src/validation/core.js +1023 -0
- package/templates/chart.html +121 -0
- package/templates/closing.html +54 -0
- package/templates/content.html +50 -0
- package/templates/contents.html +60 -0
- package/templates/cover.html +64 -0
- package/templates/custom/.gitkeep +0 -0
- package/templates/custom/README.md +7 -0
- package/templates/design-styles/README.md +20 -0
- package/templates/design-styles/preview.html +4438 -0
- package/templates/diagram-tldraw.html +56 -0
- package/templates/diagram.html +98 -0
- package/templates/quote.html +31 -0
- package/templates/section-divider.html +43 -0
- package/templates/split-layout.html +43 -0
- package/templates/statistics.html +55 -0
- package/templates/team.html +49 -0
- package/templates/timeline.html +59 -0
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readdir, readFile, writeFile, mkdtemp, rm, mkdir } from 'node:fs/promises';
|
|
4
|
+
import { watch as fsWatch } from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { basename, dirname, join, resolve, relative, sep } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
buildCodexEditPrompt,
|
|
13
|
+
buildCodexExecArgs,
|
|
14
|
+
buildClaudeExecArgs,
|
|
15
|
+
normalizeSelection,
|
|
16
|
+
scaleSelectionToScreenshot,
|
|
17
|
+
writeAnnotatedScreenshot,
|
|
18
|
+
} from '../src/editor/codex-edit.js';
|
|
19
|
+
import {
|
|
20
|
+
ALL_MODELS,
|
|
21
|
+
CODEX_MODELS,
|
|
22
|
+
CLAUDE_MODELS,
|
|
23
|
+
DEFAULT_CODEX_MODEL,
|
|
24
|
+
isClaudeModel,
|
|
25
|
+
} from '../src/editor/js/model-registry.js';
|
|
26
|
+
import {
|
|
27
|
+
parseEditTimeoutMs,
|
|
28
|
+
runEditSubprocess,
|
|
29
|
+
} from '../src/editor/edit-subprocess.js';
|
|
30
|
+
import { buildSlideRuntimeHtml } from '../src/image-contract.js';
|
|
31
|
+
import { regenerateImageNativeSlide } from '../src/image-native.js';
|
|
32
|
+
import { CODEX_DEFAULT_MODEL } from '../src/codex-imagen.js';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const {
|
|
36
|
+
DEFAULT_SLIDE_MODE,
|
|
37
|
+
getSlideModeChoices,
|
|
38
|
+
getSlideModeConfig,
|
|
39
|
+
normalizeSlideMode,
|
|
40
|
+
} = require('../src/slide-mode.cjs');
|
|
41
|
+
|
|
42
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
43
|
+
const __dirname = dirname(__filename);
|
|
44
|
+
const PACKAGE_ROOT = process.env.PPT_AGENT_PACKAGE_ROOT || resolve(__dirname, '..');
|
|
45
|
+
|
|
46
|
+
let express;
|
|
47
|
+
let screenshotMod;
|
|
48
|
+
|
|
49
|
+
async function loadDeps() {
|
|
50
|
+
if (!express) {
|
|
51
|
+
express = (await import('express')).default;
|
|
52
|
+
}
|
|
53
|
+
if (!screenshotMod) {
|
|
54
|
+
screenshotMod = await import('../src/editor/screenshot.js');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_PORT = 3456;
|
|
59
|
+
const DEFAULT_SLIDES_DIR = 'slides';
|
|
60
|
+
|
|
61
|
+
const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
|
|
62
|
+
const PORT_PROBE_HOSTS = ['::', '127.0.0.1'];
|
|
63
|
+
const PORT_PROBE_IGNORED_CODES = new Set(['EAFNOSUPPORT', 'EADDRNOTAVAIL']);
|
|
64
|
+
|
|
65
|
+
const MAX_RUNS = 200;
|
|
66
|
+
const MAX_LOG_CHARS = 800_000;
|
|
67
|
+
const EDIT_TIMEOUT_MS = parseEditTimeoutMs();
|
|
68
|
+
const IMAGE_REGEN_DELAY_MS = Number.parseInt(process.env.PPT_AGENT_IMAGE_REGEN_DELAY_MS || '0', 10) || 0;
|
|
69
|
+
const MOCK_IMAGE_PROVIDER = Boolean(process.env.PPT_AGENT_MOCK_IMAGE_PROVIDER);
|
|
70
|
+
const EDITOR_TYPES = ['html', 'image'];
|
|
71
|
+
|
|
72
|
+
function waitForImageRegenerationDelay(signal) {
|
|
73
|
+
if (IMAGE_REGEN_DELAY_MS <= 0) return Promise.resolve();
|
|
74
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
75
|
+
const timer = setTimeout(resolvePromise, IMAGE_REGEN_DELAY_MS);
|
|
76
|
+
signal?.addEventListener('abort', () => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
rejectPromise(new Error('Image regeneration was aborted.'));
|
|
79
|
+
}, { once: true });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const MOCK_PNG = Buffer.from(
|
|
83
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
|
84
|
+
'base64',
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
function mockImageGenerateImpl() {
|
|
88
|
+
return async () => ({ mimeType: 'image/png', bytes: MOCK_PNG });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isImageNativeSlideHtml(html) {
|
|
92
|
+
return /<meta\s+name=["']slides-grab-image-native["']\s+content=["']true["']/i.test(html) &&
|
|
93
|
+
/<meta\s+name=["']slides-grab-image-native-metadata["']\s+content=["'][^"']+["']/i.test(html);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeEditorType(rawEditorType) {
|
|
97
|
+
const editorType = typeof rawEditorType === 'string' ? rawEditorType.trim() : '';
|
|
98
|
+
if (!EDITOR_TYPES.includes(editorType)) {
|
|
99
|
+
throw new Error('`--editor` must be one of: html, image.');
|
|
100
|
+
}
|
|
101
|
+
return editorType;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function printUsage() {
|
|
105
|
+
process.stdout.write(`Usage: slides-grab edit [options]\n\n`);
|
|
106
|
+
process.stdout.write(`Options:\n`);
|
|
107
|
+
process.stdout.write(` --port <number> Server port (default: ${DEFAULT_PORT})\n`);
|
|
108
|
+
process.stdout.write(` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})\n`);
|
|
109
|
+
process.stdout.write(` --mode <mode> Slide mode: ${getSlideModeChoices().join(', ')} (default: ${DEFAULT_SLIDE_MODE})\n`);
|
|
110
|
+
process.stdout.write(` --editor <type> Editor type: html or image (default: html)\n`);
|
|
111
|
+
process.stdout.write(` Model is selected in editor UI dropdown.\n`);
|
|
112
|
+
process.stdout.write(` -h, --help Show this help message\n`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const EDITOR_PAGE_COPY = Object.freeze({
|
|
116
|
+
html: Object.freeze({
|
|
117
|
+
title: 'HTML Editor - slides-grab',
|
|
118
|
+
brand: 'HTML Editor',
|
|
119
|
+
sendLabel: 'Run HTML Edit',
|
|
120
|
+
hint: 'HTML Editor: drag on the slide to add red bboxes. Cmd/Ctrl+Enter to run.',
|
|
121
|
+
}),
|
|
122
|
+
image: Object.freeze({
|
|
123
|
+
title: 'Image Editor - slides-grab',
|
|
124
|
+
brand: 'Image Editor',
|
|
125
|
+
sendLabel: 'Regenerate Image',
|
|
126
|
+
hint: 'Image Editor: drag on the slide to add red bboxes for regeneration.',
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
function renderEditorPage(html, editorType) {
|
|
131
|
+
const type = editorType === 'image' ? 'image' : 'html';
|
|
132
|
+
const copy = EDITOR_PAGE_COPY[type];
|
|
133
|
+
return html
|
|
134
|
+
.replaceAll('__SLIDES_GRAB_EDITOR_TYPE__', type)
|
|
135
|
+
.replaceAll('__SLIDES_GRAB_EDITOR_TITLE__', copy.title)
|
|
136
|
+
.replaceAll('__SLIDES_GRAB_EDITOR_BRAND__', copy.brand)
|
|
137
|
+
.replaceAll('__SLIDES_GRAB_EDITOR_SEND_LABEL__', copy.sendLabel)
|
|
138
|
+
.replaceAll('__SLIDES_GRAB_EDITOR_HINT__', copy.hint);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseArgs(argv) {
|
|
142
|
+
const opts = {
|
|
143
|
+
port: DEFAULT_PORT,
|
|
144
|
+
slidesDir: DEFAULT_SLIDES_DIR,
|
|
145
|
+
mode: DEFAULT_SLIDE_MODE,
|
|
146
|
+
editor: 'html',
|
|
147
|
+
help: false,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
151
|
+
const arg = argv[i];
|
|
152
|
+
if (arg === '-h' || arg === '--help') {
|
|
153
|
+
opts.help = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (arg === '--port') {
|
|
158
|
+
opts.port = Number(argv[i + 1]);
|
|
159
|
+
i += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (arg.startsWith('--port=')) {
|
|
164
|
+
opts.port = Number(arg.slice('--port='.length));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (arg === '--slides-dir') {
|
|
169
|
+
opts.slidesDir = argv[i + 1];
|
|
170
|
+
i += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (arg.startsWith('--slides-dir=')) {
|
|
175
|
+
opts.slidesDir = arg.slice('--slides-dir='.length);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (arg === '--mode') {
|
|
180
|
+
opts.mode = normalizeSlideMode(argv[i + 1]);
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (arg.startsWith('--mode=')) {
|
|
186
|
+
opts.mode = normalizeSlideMode(arg.slice('--mode='.length));
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (arg === '--editor') {
|
|
191
|
+
opts.editor = normalizeEditorType(argv[i + 1]);
|
|
192
|
+
i += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (arg.startsWith('--editor=')) {
|
|
197
|
+
opts.editor = normalizeEditorType(arg.slice('--editor='.length));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (arg === '--codex-model') {
|
|
202
|
+
// Backward compatibility: ignore legacy CLI option.
|
|
203
|
+
i += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!Number.isInteger(opts.port) || opts.port <= 0) {
|
|
211
|
+
throw new Error('`--port` must be a positive integer.');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (typeof opts.slidesDir !== 'string' || opts.slidesDir.trim() === '') {
|
|
215
|
+
throw new Error('`--slides-dir` must be a non-empty path.');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
opts.slidesDir = opts.slidesDir.trim();
|
|
219
|
+
opts.mode = normalizeSlideMode(opts.mode);
|
|
220
|
+
opts.editor = normalizeEditorType(opts.editor);
|
|
221
|
+
|
|
222
|
+
return opts;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function buildPortInUseError(port) {
|
|
226
|
+
return new Error(`Editor port ${port} is already in use. Choose another port with \`--port <number>\` and try again.`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function assertHostPortAvailable(port, host) {
|
|
230
|
+
const probe = net.createServer();
|
|
231
|
+
try {
|
|
232
|
+
await new Promise((resolve, reject) => {
|
|
233
|
+
probe.once('error', reject);
|
|
234
|
+
probe.listen({ port, host, exclusive: true }, resolve);
|
|
235
|
+
});
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error?.code === 'EADDRINUSE') {
|
|
238
|
+
throw buildPortInUseError(port);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (PORT_PROBE_IGNORED_CODES.has(error?.code)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
throw error;
|
|
246
|
+
} finally {
|
|
247
|
+
if (probe.listening) {
|
|
248
|
+
await new Promise((resolve, reject) => {
|
|
249
|
+
probe.close((error) => {
|
|
250
|
+
if (error) {
|
|
251
|
+
reject(error);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
resolve();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function assertPortUsable(port) {
|
|
262
|
+
for (const host of PORT_PROBE_HOSTS) {
|
|
263
|
+
await assertHostPortAvailable(port, host);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function listenOnPort(app, port) {
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const server = app.listen(port, () => resolve(server));
|
|
270
|
+
server.once('error', (error) => {
|
|
271
|
+
if (error?.code === 'EADDRINUSE') {
|
|
272
|
+
reject(buildPortInUseError(port));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
reject(error);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const sseClients = new Set();
|
|
282
|
+
|
|
283
|
+
function broadcastSSE(event, data) {
|
|
284
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
285
|
+
for (const res of sseClients) {
|
|
286
|
+
res.write(payload);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let browserPromise = null;
|
|
291
|
+
|
|
292
|
+
async function getScreenshotBrowser() {
|
|
293
|
+
if (!browserPromise) {
|
|
294
|
+
browserPromise = screenshotMod.createScreenshotBrowser();
|
|
295
|
+
}
|
|
296
|
+
return browserPromise;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function closeBrowser() {
|
|
300
|
+
if (browserPromise) {
|
|
301
|
+
const { browser } = await getScreenshotBrowser();
|
|
302
|
+
browserPromise = null;
|
|
303
|
+
await browser.close();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function withScreenshotPage(callback, screenshotSize) {
|
|
308
|
+
const { browser } = await getScreenshotBrowser();
|
|
309
|
+
const { context, page } = await screenshotMod.createScreenshotPage(browser, screenshotSize);
|
|
310
|
+
try {
|
|
311
|
+
return await callback(page);
|
|
312
|
+
} finally {
|
|
313
|
+
await context.close().catch(() => {});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function toPosixPath(inputPath) {
|
|
318
|
+
return inputPath.split(sep).join('/');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function toSlidePathLabel(slidesDirectory, slideFile) {
|
|
322
|
+
const relativePath = relative(process.cwd(), join(slidesDirectory, slideFile));
|
|
323
|
+
const hasParentTraversal = relativePath.startsWith('..');
|
|
324
|
+
const label = !hasParentTraversal && relativePath !== '' ? relativePath : join(slidesDirectory, slideFile);
|
|
325
|
+
return toPosixPath(label);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function listSlideFiles(slidesDirectory) {
|
|
329
|
+
const entries = await readdir(slidesDirectory, { withFileTypes: true });
|
|
330
|
+
return entries
|
|
331
|
+
.filter((entry) => entry.isFile() && SLIDE_FILE_PATTERN.test(entry.name))
|
|
332
|
+
.map((entry) => entry.name)
|
|
333
|
+
.sort((a, b) => {
|
|
334
|
+
const numA = Number.parseInt(a.match(/\d+/)?.[0] ?? '0', 10);
|
|
335
|
+
const numB = Number.parseInt(b.match(/\d+/)?.[0] ?? '0', 10);
|
|
336
|
+
return numA - numB || a.localeCompare(b);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function normalizeSlideFilename(rawSlide, source = '`slide`') {
|
|
341
|
+
const slide = typeof rawSlide === 'string' ? basename(rawSlide.trim()) : '';
|
|
342
|
+
if (!slide || !SLIDE_FILE_PATTERN.test(slide)) {
|
|
343
|
+
throw new Error(`Missing or invalid ${source}.`);
|
|
344
|
+
}
|
|
345
|
+
return slide;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function normalizeSlideHtml(rawHtml) {
|
|
349
|
+
if (typeof rawHtml !== 'string' || rawHtml.trim() === '') {
|
|
350
|
+
throw new Error('Missing or invalid `html`.');
|
|
351
|
+
}
|
|
352
|
+
return rawHtml;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function sanitizeTargets(rawTargets) {
|
|
356
|
+
if (!Array.isArray(rawTargets)) return [];
|
|
357
|
+
|
|
358
|
+
return rawTargets
|
|
359
|
+
.filter((target) => target && typeof target === 'object')
|
|
360
|
+
.slice(0, 30)
|
|
361
|
+
.map((target) => ({
|
|
362
|
+
xpath: typeof target.xpath === 'string' ? target.xpath.slice(0, 500) : '',
|
|
363
|
+
tag: typeof target.tag === 'string' ? target.tag.slice(0, 40) : '',
|
|
364
|
+
text: typeof target.text === 'string' ? target.text.slice(0, 400) : '',
|
|
365
|
+
}))
|
|
366
|
+
.filter((target) => target.xpath);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function normalizeSelections(rawSelections, slideSize, { allowEmpty = false } = {}) {
|
|
370
|
+
if (!Array.isArray(rawSelections) || rawSelections.length === 0) {
|
|
371
|
+
if (allowEmpty) return [];
|
|
372
|
+
throw new Error('At least one selection is required.');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return rawSelections.slice(0, 24).map((selection) => {
|
|
376
|
+
const selectionSource = selection?.bbox && typeof selection.bbox === 'object'
|
|
377
|
+
? selection.bbox
|
|
378
|
+
: selection;
|
|
379
|
+
|
|
380
|
+
const bbox = normalizeSelection(selectionSource, slideSize);
|
|
381
|
+
const targets = sanitizeTargets(selection?.targets);
|
|
382
|
+
|
|
383
|
+
return { bbox, targets };
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function normalizeModel(rawModel) {
|
|
388
|
+
const model = typeof rawModel === 'string' ? rawModel.trim() : '';
|
|
389
|
+
if (!model) return DEFAULT_CODEX_MODEL;
|
|
390
|
+
if (!ALL_MODELS.includes(model)) {
|
|
391
|
+
throw new Error(`Invalid \`model\`. Allowed models: ${ALL_MODELS.join(', ')}`);
|
|
392
|
+
}
|
|
393
|
+
return model;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function randomRunId() {
|
|
397
|
+
const ts = Date.now();
|
|
398
|
+
const rand = Math.floor(Math.random() * 100000);
|
|
399
|
+
return `run-${ts}-${rand}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function mirrorRunLog(onLog) {
|
|
403
|
+
return (stream, chunk) => {
|
|
404
|
+
onLog(stream, chunk);
|
|
405
|
+
process[stream].write(chunk);
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function spawnCodexEdit({ prompt, imagePath, model, cwd, onLog, onChild, signal }) {
|
|
410
|
+
const codexBin = process.env.PPT_AGENT_CODEX_BIN || 'codex';
|
|
411
|
+
const args = buildCodexExecArgs({ prompt, imagePath, model });
|
|
412
|
+
return runEditSubprocess({
|
|
413
|
+
bin: codexBin,
|
|
414
|
+
args,
|
|
415
|
+
cwd,
|
|
416
|
+
// Close stdin (`'ignore'`) so the Codex CLI does not wait for additional
|
|
417
|
+
// piped instructions. Recent Codex versions (>=0.125) print
|
|
418
|
+
// "Reading additional input from stdin..." and block forever when stdin
|
|
419
|
+
// is left open as a pipe even though the prompt is already passed via
|
|
420
|
+
// the trailing argv. This mirrors `spawnClaudeEdit` below.
|
|
421
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
422
|
+
timeoutMs: EDIT_TIMEOUT_MS,
|
|
423
|
+
engineLabel: 'Codex',
|
|
424
|
+
onLog: mirrorRunLog(onLog),
|
|
425
|
+
onChild,
|
|
426
|
+
signal,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function spawnClaudeEdit({ prompt, imagePath, model, cwd, onLog, onChild, signal }) {
|
|
431
|
+
const claudeBin = process.env.PPT_AGENT_CLAUDE_BIN || 'claude';
|
|
432
|
+
const args = buildClaudeExecArgs({ prompt, imagePath, model });
|
|
433
|
+
|
|
434
|
+
// Remove CLAUDECODE env var to avoid "nested session" detection error
|
|
435
|
+
const env = { ...process.env };
|
|
436
|
+
delete env.CLAUDECODE;
|
|
437
|
+
|
|
438
|
+
return runEditSubprocess({
|
|
439
|
+
bin: claudeBin,
|
|
440
|
+
args,
|
|
441
|
+
cwd,
|
|
442
|
+
env,
|
|
443
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
444
|
+
timeoutMs: EDIT_TIMEOUT_MS,
|
|
445
|
+
engineLabel: 'Claude',
|
|
446
|
+
onLog: mirrorRunLog(onLog),
|
|
447
|
+
onChild,
|
|
448
|
+
signal,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function createRunStore() {
|
|
453
|
+
const activeRunsBySlide = new Map();
|
|
454
|
+
const runStore = new Map();
|
|
455
|
+
const runOrder = [];
|
|
456
|
+
|
|
457
|
+
function toRunSummary(run) {
|
|
458
|
+
return {
|
|
459
|
+
runId: run.runId,
|
|
460
|
+
slide: run.slide,
|
|
461
|
+
model: run.model,
|
|
462
|
+
status: run.status,
|
|
463
|
+
code: run.code,
|
|
464
|
+
message: run.message,
|
|
465
|
+
startedAt: run.startedAt,
|
|
466
|
+
finishedAt: run.finishedAt,
|
|
467
|
+
prompt: run.prompt,
|
|
468
|
+
selectionsCount: run.selectionsCount,
|
|
469
|
+
logSize: run.log.length,
|
|
470
|
+
logPreview: run.log.slice(-2000),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
hasActiveRunForSlide(slide) {
|
|
476
|
+
return activeRunsBySlide.has(slide);
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
getActiveRunId(slide) {
|
|
480
|
+
return activeRunsBySlide.get(slide) ?? null;
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
startRun({ runId, slide, prompt, selectionsCount, model }) {
|
|
484
|
+
activeRunsBySlide.set(slide, runId);
|
|
485
|
+
|
|
486
|
+
const run = {
|
|
487
|
+
runId,
|
|
488
|
+
slide,
|
|
489
|
+
status: 'running',
|
|
490
|
+
code: null,
|
|
491
|
+
message: 'Running',
|
|
492
|
+
prompt,
|
|
493
|
+
model,
|
|
494
|
+
selectionsCount,
|
|
495
|
+
startedAt: new Date().toISOString(),
|
|
496
|
+
finishedAt: null,
|
|
497
|
+
log: '',
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
runStore.set(runId, run);
|
|
501
|
+
runOrder.push(runId);
|
|
502
|
+
|
|
503
|
+
while (runOrder.length > MAX_RUNS) {
|
|
504
|
+
const oldestRunId = runOrder.shift();
|
|
505
|
+
if (!oldestRunId) continue;
|
|
506
|
+
runStore.delete(oldestRunId);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return toRunSummary(run);
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
appendLog(runId, chunk) {
|
|
513
|
+
const run = runStore.get(runId);
|
|
514
|
+
if (!run) return;
|
|
515
|
+
|
|
516
|
+
run.log += chunk;
|
|
517
|
+
if (run.log.length > MAX_LOG_CHARS) {
|
|
518
|
+
run.log = run.log.slice(run.log.length - MAX_LOG_CHARS);
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
finishRun(runId, { status, code, message }) {
|
|
523
|
+
const run = runStore.get(runId);
|
|
524
|
+
if (!run) return null;
|
|
525
|
+
|
|
526
|
+
run.status = status;
|
|
527
|
+
run.code = code;
|
|
528
|
+
run.message = message;
|
|
529
|
+
run.finishedAt = new Date().toISOString();
|
|
530
|
+
|
|
531
|
+
if (activeRunsBySlide.get(run.slide) === runId) {
|
|
532
|
+
activeRunsBySlide.delete(run.slide);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return toRunSummary(run);
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
clearActiveRun(slide, runId) {
|
|
539
|
+
if (activeRunsBySlide.get(slide) === runId) {
|
|
540
|
+
activeRunsBySlide.delete(slide);
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
listRuns(limit = 60) {
|
|
545
|
+
return runOrder
|
|
546
|
+
.slice(Math.max(0, runOrder.length - limit))
|
|
547
|
+
.reverse()
|
|
548
|
+
.map((runId) => runStore.get(runId))
|
|
549
|
+
.filter(Boolean)
|
|
550
|
+
.map((run) => toRunSummary(run));
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
getRunLog(runId) {
|
|
554
|
+
const run = runStore.get(runId);
|
|
555
|
+
if (!run) return null;
|
|
556
|
+
return run.log;
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
listActiveRuns() {
|
|
560
|
+
return Array.from(activeRunsBySlide.entries()).map(([slide, runId]) => ({ slide, runId }));
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function startServer(opts) {
|
|
566
|
+
await assertPortUsable(opts.port);
|
|
567
|
+
await loadDeps();
|
|
568
|
+
const slidesDirectory = resolve(process.cwd(), opts.slidesDir);
|
|
569
|
+
await mkdir(slidesDirectory, { recursive: true });
|
|
570
|
+
|
|
571
|
+
const runStore = createRunStore();
|
|
572
|
+
|
|
573
|
+
const childProcessesByRunId = new Map();
|
|
574
|
+
const abortControllersByRunId = new Map();
|
|
575
|
+
|
|
576
|
+
function registerAbortController(runId, abortController) {
|
|
577
|
+
if (!abortController) return;
|
|
578
|
+
abortControllersByRunId.set(runId, abortController);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function trackChild(runId, child) {
|
|
582
|
+
if (!child || typeof child.kill !== 'function') return;
|
|
583
|
+
childProcessesByRunId.set(runId, child);
|
|
584
|
+
child.once('close', () => {
|
|
585
|
+
if (childProcessesByRunId.get(runId) === child) {
|
|
586
|
+
childProcessesByRunId.delete(runId);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function killTrackedChild(runId, { reason } = {}) {
|
|
592
|
+
const ac = abortControllersByRunId.get(runId);
|
|
593
|
+
if (ac && !ac.signal.aborted) {
|
|
594
|
+
try {
|
|
595
|
+
ac.abort();
|
|
596
|
+
} catch {}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const child = childProcessesByRunId.get(runId);
|
|
600
|
+
if (!child) return Boolean(ac);
|
|
601
|
+
childProcessesByRunId.delete(runId);
|
|
602
|
+
if (child.killed || child.exitCode != null) return true;
|
|
603
|
+
if (reason) {
|
|
604
|
+
process.stderr.write(`[editor] killing run ${runId}: ${reason}\n`);
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
child.kill('SIGTERM');
|
|
608
|
+
} catch {}
|
|
609
|
+
const forceKill = setTimeout(() => {
|
|
610
|
+
if (!child.killed && child.exitCode == null) {
|
|
611
|
+
try {
|
|
612
|
+
child.kill('SIGKILL');
|
|
613
|
+
} catch {}
|
|
614
|
+
}
|
|
615
|
+
}, 5_000);
|
|
616
|
+
forceKill.unref?.();
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function killAllTrackedChildren({ reason, signal = 'SIGTERM' } = {}) {
|
|
621
|
+
for (const ac of abortControllersByRunId.values()) {
|
|
622
|
+
if (!ac.signal.aborted) {
|
|
623
|
+
try {
|
|
624
|
+
ac.abort();
|
|
625
|
+
} catch {}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
abortControllersByRunId.clear();
|
|
629
|
+
|
|
630
|
+
const ids = Array.from(childProcessesByRunId.keys());
|
|
631
|
+
for (const runId of ids) {
|
|
632
|
+
const child = childProcessesByRunId.get(runId);
|
|
633
|
+
if (!child) continue;
|
|
634
|
+
childProcessesByRunId.delete(runId);
|
|
635
|
+
if (child.killed || child.exitCode != null) continue;
|
|
636
|
+
if (reason) {
|
|
637
|
+
process.stderr.write(`[editor] killing run ${runId}: ${reason}\n`);
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
child.kill(signal);
|
|
641
|
+
} catch {}
|
|
642
|
+
}
|
|
643
|
+
return ids.length;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const app = express();
|
|
647
|
+
app.use(express.json({ limit: '5mb' }));
|
|
648
|
+
app.use('/js', express.static(join(PACKAGE_ROOT, 'src', 'editor', 'js')));
|
|
649
|
+
app.use('/slides/assets', express.static(join(slidesDirectory, 'assets')));
|
|
650
|
+
|
|
651
|
+
const editorHtmlPath = join(PACKAGE_ROOT, 'src', 'editor', 'editor.html');
|
|
652
|
+
|
|
653
|
+
function broadcastRunsSnapshot() {
|
|
654
|
+
broadcastSSE('runsSnapshot', {
|
|
655
|
+
runs: runStore.listRuns(),
|
|
656
|
+
activeRuns: runStore.listActiveRuns(),
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
app.get('/', async (_req, res) => {
|
|
661
|
+
try {
|
|
662
|
+
const html = await readFile(editorHtmlPath, 'utf-8');
|
|
663
|
+
res.type('html').send(renderEditorPage(html, opts.editor));
|
|
664
|
+
} catch (err) {
|
|
665
|
+
res.status(500).send(`Failed to load editor: ${err.message}`);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
app.get('/slides/:file', async (req, res) => {
|
|
670
|
+
let file;
|
|
671
|
+
try {
|
|
672
|
+
file = normalizeSlideFilename(req.params.file, 'slide filename');
|
|
673
|
+
} catch {
|
|
674
|
+
return res.status(400).send('Invalid slide filename');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const filePath = join(slidesDirectory, file);
|
|
678
|
+
try {
|
|
679
|
+
const html = await readFile(filePath, 'utf-8');
|
|
680
|
+
const runtimeHtml = buildSlideRuntimeHtml(html, {
|
|
681
|
+
baseHref: '/slides/',
|
|
682
|
+
slideFile: file,
|
|
683
|
+
});
|
|
684
|
+
res.type('html').send(runtimeHtml);
|
|
685
|
+
} catch {
|
|
686
|
+
res.status(404).send(`Slide not found: ${file}`);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
app.get('/api/slides', async (_req, res) => {
|
|
691
|
+
try {
|
|
692
|
+
const files = await listSlideFiles(slidesDirectory);
|
|
693
|
+
res.json(files);
|
|
694
|
+
} catch (err) {
|
|
695
|
+
res.status(500).json({ error: err.message });
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
app.post('/api/slides/:file/save', async (req, res) => {
|
|
700
|
+
if (opts.editor === 'image') {
|
|
701
|
+
return res.status(405).json({
|
|
702
|
+
error: 'Direct slide HTML save is unavailable in Image Editor.',
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
let file;
|
|
707
|
+
try {
|
|
708
|
+
file = normalizeSlideFilename(req.params.file, '`slide`');
|
|
709
|
+
} catch (error) {
|
|
710
|
+
return res.status(400).json({ error: error.message });
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const bodySlide = req.body?.slide;
|
|
714
|
+
if (bodySlide !== undefined) {
|
|
715
|
+
let normalizedBodySlide;
|
|
716
|
+
try {
|
|
717
|
+
normalizedBodySlide = normalizeSlideFilename(bodySlide, '`slide`');
|
|
718
|
+
} catch (error) {
|
|
719
|
+
return res.status(400).json({ error: error.message });
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (normalizedBodySlide !== file) {
|
|
723
|
+
return res.status(400).json({ error: '`slide` does not match the requested file.' });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
let html;
|
|
728
|
+
try {
|
|
729
|
+
html = normalizeSlideHtml(req.body?.html);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
return res.status(400).json({ error: error.message });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const filePath = join(slidesDirectory, file);
|
|
735
|
+
try {
|
|
736
|
+
await readFile(filePath, 'utf-8');
|
|
737
|
+
} catch {
|
|
738
|
+
return res.status(404).json({ error: `Slide not found: ${file}` });
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
await writeFile(filePath, html, 'utf8');
|
|
743
|
+
return res.json({
|
|
744
|
+
success: true,
|
|
745
|
+
slide: file,
|
|
746
|
+
bytes: Buffer.byteLength(html, 'utf8'),
|
|
747
|
+
});
|
|
748
|
+
} catch (error) {
|
|
749
|
+
return res.status(500).json({
|
|
750
|
+
success: false,
|
|
751
|
+
error: `Failed to save ${file}: ${error.message}`,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
app.get('/api/config', (_req, res) => {
|
|
757
|
+
const cfg = getSlideModeConfig(opts.mode);
|
|
758
|
+
res.json({
|
|
759
|
+
editorType: opts.editor,
|
|
760
|
+
slideMode: opts.mode,
|
|
761
|
+
framePx: { width: cfg.framePx.width, height: cfg.framePx.height },
|
|
762
|
+
screenshotPx: { width: cfg.screenshotPx.width, height: cfg.screenshotPx.height },
|
|
763
|
+
sizeLabel: cfg.sizeLabel,
|
|
764
|
+
aspectRatioLabel: cfg.aspectRatioLabel,
|
|
765
|
+
coordinateSpaceLabel: cfg.coordinateSpaceLabel,
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
app.get('/api/models', (_req, res) => {
|
|
770
|
+
res.json({
|
|
771
|
+
models: ALL_MODELS,
|
|
772
|
+
defaultModel: DEFAULT_CODEX_MODEL,
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
app.get('/api/runs', (_req, res) => {
|
|
777
|
+
res.json({
|
|
778
|
+
runs: runStore.listRuns(100),
|
|
779
|
+
activeRuns: runStore.listActiveRuns(),
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
app.get('/api/runs/:runId/log', (req, res) => {
|
|
784
|
+
const log = runStore.getRunLog(req.params.runId);
|
|
785
|
+
if (log === null) {
|
|
786
|
+
return res.status(404).send('Run not found');
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
res.type('text/plain').send(log);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
app.get('/api/events', (req, res) => {
|
|
793
|
+
res.writeHead(200, {
|
|
794
|
+
'Content-Type': 'text/event-stream',
|
|
795
|
+
'Cache-Control': 'no-cache',
|
|
796
|
+
Connection: 'keep-alive',
|
|
797
|
+
});
|
|
798
|
+
res.write('event: connected\ndata: {}\n\n');
|
|
799
|
+
|
|
800
|
+
sseClients.add(res);
|
|
801
|
+
req.on('close', () => sseClients.delete(res));
|
|
802
|
+
|
|
803
|
+
const snapshotPayload = {
|
|
804
|
+
runs: runStore.listRuns(),
|
|
805
|
+
activeRuns: runStore.listActiveRuns(),
|
|
806
|
+
};
|
|
807
|
+
res.write(`event: runsSnapshot\ndata: ${JSON.stringify(snapshotPayload)}\n\n`);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
app.post('/api/apply', async (req, res) => {
|
|
811
|
+
const { slide, prompt, selections, model, mode, provider, baseUrl } = req.body ?? {};
|
|
812
|
+
|
|
813
|
+
if (mode !== undefined && mode !== opts.editor) {
|
|
814
|
+
const command = mode === 'image' ? 'slides-grab edit-image' : 'slides-grab edit';
|
|
815
|
+
return res.status(400).json({
|
|
816
|
+
error: `Configured editor is ${opts.editor}; request body.mode is ${String(mode)}. Use ${command} or omit body.mode.`,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (!slide || typeof slide !== 'string' || !SLIDE_FILE_PATTERN.test(slide)) {
|
|
821
|
+
return res.status(400).json({ error: 'Missing or invalid `slide`.' });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (typeof prompt !== 'string' || prompt.trim() === '') {
|
|
825
|
+
return res.status(400).json({ error: 'Missing or invalid `prompt`.' });
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const applyMode = opts.editor;
|
|
829
|
+
|
|
830
|
+
let selectedModel;
|
|
831
|
+
if (applyMode === 'image') {
|
|
832
|
+
selectedModel = typeof model === 'string' && model.trim() ? model.trim() : CODEX_DEFAULT_MODEL;
|
|
833
|
+
} else {
|
|
834
|
+
try {
|
|
835
|
+
selectedModel = normalizeModel(model);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
return res.status(400).json({ error: error.message });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (runStore.hasActiveRunForSlide(slide)) {
|
|
842
|
+
return res.status(409).json({
|
|
843
|
+
error: `Slide ${slide} already has an active run.`,
|
|
844
|
+
runId: runStore.getActiveRunId(slide),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
let normalizedSelections;
|
|
849
|
+
try {
|
|
850
|
+
normalizedSelections = normalizeSelections(
|
|
851
|
+
selections,
|
|
852
|
+
getSlideModeConfig(opts.mode).framePx,
|
|
853
|
+
{ allowEmpty: opts.editor === 'image' },
|
|
854
|
+
);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
return res.status(400).json({ error: error.message });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (applyMode === 'image') {
|
|
860
|
+
const slideHtml = await readFile(join(slidesDirectory, slide), 'utf8').catch(() => '');
|
|
861
|
+
if (!isImageNativeSlideHtml(slideHtml)) {
|
|
862
|
+
return res.status(400).json({
|
|
863
|
+
error: `${slide} is not an image-native slide. Generate it with slides-grab image --image-native --name slide-XX or open HTML slides with slides-grab edit.`,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const runId = randomRunId();
|
|
869
|
+
|
|
870
|
+
const runSummary = runStore.startRun({
|
|
871
|
+
runId,
|
|
872
|
+
slide,
|
|
873
|
+
prompt: prompt.trim(),
|
|
874
|
+
selectionsCount: normalizedSelections.length,
|
|
875
|
+
model: selectedModel,
|
|
876
|
+
mode: applyMode,
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
broadcastSSE('applyStarted', {
|
|
880
|
+
runId,
|
|
881
|
+
slide,
|
|
882
|
+
model: selectedModel,
|
|
883
|
+
mode: applyMode,
|
|
884
|
+
selectionsCount: normalizedSelections.length,
|
|
885
|
+
selectionBoxes: normalizedSelections.map((selection) => selection.bbox),
|
|
886
|
+
});
|
|
887
|
+
broadcastRunsSnapshot();
|
|
888
|
+
|
|
889
|
+
const tmpPath = await mkdtemp(join(tmpdir(), 'editor-codex-'));
|
|
890
|
+
const screenshotPath = join(tmpPath, 'slide.png');
|
|
891
|
+
const annotatedPath = join(tmpPath, 'slide-annotated.png');
|
|
892
|
+
|
|
893
|
+
const abortController = new AbortController();
|
|
894
|
+
registerAbortController(runId, abortController);
|
|
895
|
+
let clientDisconnected = false;
|
|
896
|
+
const handleClientClose = () => {
|
|
897
|
+
if (clientDisconnected) return;
|
|
898
|
+
if (res.writableEnded) return;
|
|
899
|
+
clientDisconnected = true;
|
|
900
|
+
killTrackedChild(runId, { reason: 'client disconnected before /api/apply finished' });
|
|
901
|
+
};
|
|
902
|
+
req.on('close', handleClientClose);
|
|
903
|
+
res.on('close', handleClientClose);
|
|
904
|
+
|
|
905
|
+
try {
|
|
906
|
+
if (applyMode === 'image') {
|
|
907
|
+
await waitForImageRegenerationDelay(abortController.signal);
|
|
908
|
+
const imageResult = await regenerateImageNativeSlide({
|
|
909
|
+
slidesDir: slidesDirectory,
|
|
910
|
+
slideFile: slide,
|
|
911
|
+
prompt,
|
|
912
|
+
selections: normalizedSelections,
|
|
913
|
+
signal: abortController.signal,
|
|
914
|
+
provider: provider || 'codex',
|
|
915
|
+
model: selectedModel,
|
|
916
|
+
baseUrl: typeof baseUrl === 'string' ? baseUrl : '',
|
|
917
|
+
env: process.env,
|
|
918
|
+
fetchImpl: globalThis.fetch,
|
|
919
|
+
...(MOCK_IMAGE_PROVIDER ? { generateImageImpl: mockImageGenerateImpl() } : {}),
|
|
920
|
+
});
|
|
921
|
+
runStore.finishRun(runId, {
|
|
922
|
+
status: 'success',
|
|
923
|
+
code: 0,
|
|
924
|
+
message: 'Image regeneration completed.',
|
|
925
|
+
});
|
|
926
|
+
broadcastSSE('applyFinished', {
|
|
927
|
+
runId,
|
|
928
|
+
slide,
|
|
929
|
+
model: selectedModel,
|
|
930
|
+
mode: applyMode,
|
|
931
|
+
success: true,
|
|
932
|
+
aborted: false,
|
|
933
|
+
code: 0,
|
|
934
|
+
message: 'Image regeneration completed.',
|
|
935
|
+
});
|
|
936
|
+
broadcastRunsSnapshot();
|
|
937
|
+
if (!clientDisconnected && !res.writableEnded) {
|
|
938
|
+
res.json({
|
|
939
|
+
...runSummary,
|
|
940
|
+
success: true,
|
|
941
|
+
aborted: false,
|
|
942
|
+
runId,
|
|
943
|
+
model: selectedModel,
|
|
944
|
+
mode: applyMode,
|
|
945
|
+
code: 0,
|
|
946
|
+
message: 'Image regeneration completed.',
|
|
947
|
+
image: imageResult,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
await withScreenshotPage(async (page) => {
|
|
953
|
+
await screenshotMod.captureSlideScreenshot(
|
|
954
|
+
page,
|
|
955
|
+
slide,
|
|
956
|
+
screenshotPath,
|
|
957
|
+
`http://localhost:${opts.port}/slides`,
|
|
958
|
+
{ useHttp: true, screenshotSize: getSlideModeConfig(opts.mode).screenshotPx },
|
|
959
|
+
);
|
|
960
|
+
}, getSlideModeConfig(opts.mode).screenshotPx);
|
|
961
|
+
|
|
962
|
+
const scaledBoxes = normalizedSelections.map((selection) =>
|
|
963
|
+
scaleSelectionToScreenshot(
|
|
964
|
+
selection.bbox,
|
|
965
|
+
getSlideModeConfig(opts.mode).framePx,
|
|
966
|
+
getSlideModeConfig(opts.mode).screenshotPx,
|
|
967
|
+
),
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
await writeAnnotatedScreenshot(screenshotPath, annotatedPath, scaledBoxes);
|
|
971
|
+
|
|
972
|
+
const codexPrompt = buildCodexEditPrompt({
|
|
973
|
+
slideFile: slide,
|
|
974
|
+
slidePath: toSlidePathLabel(slidesDirectory, slide),
|
|
975
|
+
userPrompt: prompt,
|
|
976
|
+
slideMode: opts.mode,
|
|
977
|
+
selections: normalizedSelections,
|
|
978
|
+
designBaseDir: slidesDirectory,
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
const usesClaude = isClaudeModel(selectedModel);
|
|
982
|
+
const spawnEdit = usesClaude ? spawnClaudeEdit : spawnCodexEdit;
|
|
983
|
+
const result = await spawnEdit({
|
|
984
|
+
prompt: codexPrompt,
|
|
985
|
+
imagePath: annotatedPath,
|
|
986
|
+
model: selectedModel,
|
|
987
|
+
cwd: process.cwd(),
|
|
988
|
+
onLog: (stream, chunk) => {
|
|
989
|
+
runStore.appendLog(runId, chunk);
|
|
990
|
+
broadcastSSE('applyLog', { runId, slide, stream, chunk });
|
|
991
|
+
},
|
|
992
|
+
onChild: (child) => trackChild(runId, child),
|
|
993
|
+
signal: abortController.signal,
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
const engineLabel = isClaudeModel(selectedModel) ? 'Claude' : 'Codex';
|
|
997
|
+
const aborted = Boolean(result.aborted);
|
|
998
|
+
const success = !aborted && result.code === 0;
|
|
999
|
+
let message;
|
|
1000
|
+
if (aborted) {
|
|
1001
|
+
message = result.abortMessage || `${engineLabel} edit was aborted.`;
|
|
1002
|
+
} else if (success) {
|
|
1003
|
+
message = `${engineLabel} edit completed.`;
|
|
1004
|
+
} else {
|
|
1005
|
+
message = result.timeoutMessage || `${engineLabel} exited with code ${result.code}.`;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const status = aborted ? 'aborted' : success ? 'success' : 'failed';
|
|
1009
|
+
|
|
1010
|
+
runStore.finishRun(runId, {
|
|
1011
|
+
status,
|
|
1012
|
+
code: result.code,
|
|
1013
|
+
message,
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
broadcastSSE('applyFinished', {
|
|
1017
|
+
runId,
|
|
1018
|
+
slide,
|
|
1019
|
+
model: selectedModel,
|
|
1020
|
+
success,
|
|
1021
|
+
aborted,
|
|
1022
|
+
code: result.code,
|
|
1023
|
+
message,
|
|
1024
|
+
});
|
|
1025
|
+
broadcastRunsSnapshot();
|
|
1026
|
+
|
|
1027
|
+
if (clientDisconnected || res.writableEnded) {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
res.json({
|
|
1032
|
+
...runSummary,
|
|
1033
|
+
success,
|
|
1034
|
+
aborted,
|
|
1035
|
+
runId,
|
|
1036
|
+
model: selectedModel,
|
|
1037
|
+
code: result.code,
|
|
1038
|
+
message,
|
|
1039
|
+
});
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1042
|
+
const abortedBySignal = clientDisconnected || abortController.signal.aborted;
|
|
1043
|
+
|
|
1044
|
+
runStore.finishRun(runId, {
|
|
1045
|
+
status: abortedBySignal ? 'aborted' : 'failed',
|
|
1046
|
+
code: -1,
|
|
1047
|
+
message,
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
broadcastSSE('applyFinished', {
|
|
1051
|
+
runId,
|
|
1052
|
+
slide,
|
|
1053
|
+
model: selectedModel,
|
|
1054
|
+
success: false,
|
|
1055
|
+
aborted: abortedBySignal,
|
|
1056
|
+
code: -1,
|
|
1057
|
+
message,
|
|
1058
|
+
});
|
|
1059
|
+
broadcastRunsSnapshot();
|
|
1060
|
+
|
|
1061
|
+
if (clientDisconnected || res.writableEnded) {
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
res.status(500).json({
|
|
1066
|
+
success: false,
|
|
1067
|
+
aborted: abortedBySignal,
|
|
1068
|
+
runId,
|
|
1069
|
+
error: message,
|
|
1070
|
+
});
|
|
1071
|
+
} finally {
|
|
1072
|
+
req.off?.('close', handleClientClose);
|
|
1073
|
+
res.off?.('close', handleClientClose);
|
|
1074
|
+
childProcessesByRunId.delete(runId);
|
|
1075
|
+
abortControllersByRunId.delete(runId);
|
|
1076
|
+
runStore.clearActiveRun(slide, runId);
|
|
1077
|
+
await rm(tmpPath, { recursive: true, force: true }).catch(() => {});
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
app.post('/api/runs/:runId/cancel', (req, res) => {
|
|
1082
|
+
const { runId } = req.params;
|
|
1083
|
+
const killed = killTrackedChild(runId, { reason: 'cancelled via /api/runs/:runId/cancel' });
|
|
1084
|
+
if (!killed) {
|
|
1085
|
+
return res.status(404).json({ error: 'No active run for this runId.' });
|
|
1086
|
+
}
|
|
1087
|
+
res.json({ runId, cancelled: true });
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
let debounceTimer = null;
|
|
1091
|
+
const watcher = fsWatch(slidesDirectory, { persistent: false }, (_eventType, filename) => {
|
|
1092
|
+
if (!filename || !SLIDE_FILE_PATTERN.test(filename)) return;
|
|
1093
|
+
clearTimeout(debounceTimer);
|
|
1094
|
+
debounceTimer = setTimeout(() => {
|
|
1095
|
+
broadcastSSE('fileChanged', { file: filename });
|
|
1096
|
+
}, 300);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
const server = await listenOnPort(app, opts.port);
|
|
1100
|
+
|
|
1101
|
+
process.stdout.write('\n slides-grab editor\n');
|
|
1102
|
+
process.stdout.write(' ─────────────────────────────────────\n');
|
|
1103
|
+
process.stdout.write(` Local: http://localhost:${opts.port}\n`);
|
|
1104
|
+
process.stdout.write(` Models: ${ALL_MODELS.join(', ')}\n`);
|
|
1105
|
+
process.stdout.write(` Editor: ${opts.editor}\n`);
|
|
1106
|
+
process.stdout.write(` Slides: ${slidesDirectory}\n`);
|
|
1107
|
+
process.stdout.write(' ─────────────────────────────────────\n\n');
|
|
1108
|
+
|
|
1109
|
+
let shuttingDown = false;
|
|
1110
|
+
async function shutdown() {
|
|
1111
|
+
if (shuttingDown) return;
|
|
1112
|
+
shuttingDown = true;
|
|
1113
|
+
process.stdout.write('\n[editor] Shutting down...\n');
|
|
1114
|
+
|
|
1115
|
+
const killedCount = killAllTrackedChildren({
|
|
1116
|
+
reason: 'editor server is shutting down',
|
|
1117
|
+
signal: 'SIGTERM',
|
|
1118
|
+
});
|
|
1119
|
+
if (killedCount > 0) {
|
|
1120
|
+
process.stdout.write(`[editor] Sent SIGTERM to ${killedCount} active edit subprocess(es).\n`);
|
|
1121
|
+
await new Promise((resolve) => {
|
|
1122
|
+
const t = setTimeout(resolve, 1_000);
|
|
1123
|
+
t.unref?.();
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
watcher.close();
|
|
1128
|
+
for (const client of sseClients) {
|
|
1129
|
+
client.end();
|
|
1130
|
+
}
|
|
1131
|
+
sseClients.clear();
|
|
1132
|
+
server.close();
|
|
1133
|
+
await closeBrowser();
|
|
1134
|
+
process.exit(0);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
process.on('SIGINT', shutdown);
|
|
1138
|
+
process.on('SIGTERM', shutdown);
|
|
1139
|
+
|
|
1140
|
+
return {
|
|
1141
|
+
server,
|
|
1142
|
+
shutdown,
|
|
1143
|
+
childProcessesByRunId,
|
|
1144
|
+
killTrackedChild,
|
|
1145
|
+
killAllTrackedChildren,
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const args = process.argv.slice(2);
|
|
1150
|
+
|
|
1151
|
+
let opts;
|
|
1152
|
+
try {
|
|
1153
|
+
opts = parseArgs(args);
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
process.stderr.write(`[editor] ${error.message}\n`);
|
|
1156
|
+
process.exit(1);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (opts.help) {
|
|
1160
|
+
printUsage();
|
|
1161
|
+
process.exit(0);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
startServer(opts).catch((err) => {
|
|
1165
|
+
process.stderr.write(`[editor] Fatal: ${err.message}\n`);
|
|
1166
|
+
process.exit(1);
|
|
1167
|
+
});
|