slidemind 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +10 -6
- package/README.md +19 -7
- package/bin/slidemind.mjs +819 -86
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +70 -18
- package/skills/search/SKILL.md +25 -6
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +36 -7
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +513 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/build.mjs +53 -23
- package/src/builders/gate-server.mjs +163 -22
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-overlay.js +1261 -230
- package/src/builders/plan.mjs +146 -25
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review-overlay.js +44 -7
- package/src/builders/review.mjs +76 -11
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +176 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
package/src/bridge/client.mjs
CHANGED
|
@@ -51,6 +51,42 @@ export function compareCliVersions(a, b) {
|
|
|
51
51
|
return 0;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// The one error every version-floor check raises, so the probe path
|
|
55
|
+
// (assertCliVersionSupported) and the per-response header path are
|
|
56
|
+
// indistinguishable to callers and to the skills that relay the text.
|
|
57
|
+
function cliUpdateRequiredError(min) {
|
|
58
|
+
const err = new Error(
|
|
59
|
+
`slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
|
|
60
|
+
`supports (${min}). Run \`slidemind update\` (or ` +
|
|
61
|
+
"`npm install -g slidemind@latest`), then retry.",
|
|
62
|
+
);
|
|
63
|
+
err.code = "cli_update_required";
|
|
64
|
+
return err;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `doctor` and `ready` exist to EXPLAIN a broken connection, version skew
|
|
68
|
+
// included — they render it as a structured blocker with an `actor` and a
|
|
69
|
+
// nextAction. Throwing the raw error out of bridgeFetch would break the one
|
|
70
|
+
// command that diagnoses the problem, so they opt out of the header check
|
|
71
|
+
// (their own ladder still reports cli_update_required, terminally).
|
|
72
|
+
let diagnosticMode = false;
|
|
73
|
+
export function setDiagnosticMode(on) {
|
|
74
|
+
diagnosticMode = on !== false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Every add-in response carries the version floor in `x-slidemind-min-cli`.
|
|
78
|
+
// Checking it here covers routes no capability probe guards — the pane ops
|
|
79
|
+
// (search/read/insert) and pairing itself — for zero extra round-trips, and it
|
|
80
|
+
// fires BEFORE the status handling below so a stale CLI hitting a route that
|
|
81
|
+
// does not exist yet reads "run slidemind update" instead of a bare 404.
|
|
82
|
+
function enforceVersionFloor(response) {
|
|
83
|
+
if (diagnosticMode) return;
|
|
84
|
+
const min = response?.headers?.["x-slidemind-min-cli"];
|
|
85
|
+
if (typeof min !== "string" || !min) return;
|
|
86
|
+
if (compareCliVersions(cliVersion(), min) >= 0) return;
|
|
87
|
+
throw cliUpdateRequiredError(min);
|
|
88
|
+
}
|
|
89
|
+
|
|
54
90
|
function isLoopbackHostname(hostname) {
|
|
55
91
|
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
56
92
|
}
|
|
@@ -199,6 +235,7 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
|
|
|
199
235
|
}
|
|
200
236
|
throw err;
|
|
201
237
|
}
|
|
238
|
+
enforceVersionFloor(response);
|
|
202
239
|
if (response.status === 401) {
|
|
203
240
|
throw new Error(
|
|
204
241
|
"slidemind session is invalid or expired. Re-pair: open the add-in, " +
|
|
@@ -267,6 +304,7 @@ function nodeRequest(urlString, init = {}) {
|
|
|
267
304
|
resolve({
|
|
268
305
|
status: res.statusCode ?? 0,
|
|
269
306
|
statusText: res.statusMessage ?? "",
|
|
307
|
+
headers: res.headers,
|
|
270
308
|
body: Buffer.concat(chunks).toString("utf8"),
|
|
271
309
|
});
|
|
272
310
|
});
|
|
@@ -290,6 +328,163 @@ function nodeRequest(urlString, init = {}) {
|
|
|
290
328
|
});
|
|
291
329
|
}
|
|
292
330
|
|
|
331
|
+
// A streaming sibling of `nodeRequest`, for responses too large to hold in
|
|
332
|
+
// memory. `nodeRequest` concatenates the whole body and decodes it as UTF-8 —
|
|
333
|
+
// fine for JSON, but it would both balloon memory and CORRUPT binary. This pipes
|
|
334
|
+
// a success response straight to `destPath` and never materializes it.
|
|
335
|
+
//
|
|
336
|
+
// Error and JSON bodies are still buffered: both are small by construction (an
|
|
337
|
+
// error message, or the inline SVG the download route answers with), and the
|
|
338
|
+
// caller needs them whole to act on.
|
|
339
|
+
function nodeRequestToFile(urlString, init = {}, destPath) {
|
|
340
|
+
const url = new URL(urlString);
|
|
341
|
+
const isHttps = url.protocol === "https:";
|
|
342
|
+
const transport = isHttps ? https : http;
|
|
343
|
+
const body = init.body ?? null;
|
|
344
|
+
const headers = { ...(init.headers ?? {}) };
|
|
345
|
+
if (
|
|
346
|
+
body &&
|
|
347
|
+
headers["content-length"] === undefined &&
|
|
348
|
+
headers["Content-Length"] === undefined
|
|
349
|
+
) {
|
|
350
|
+
headers["content-length"] = Buffer.byteLength(body);
|
|
351
|
+
}
|
|
352
|
+
const timeoutMs =
|
|
353
|
+
init.timeoutMs ??
|
|
354
|
+
(parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000);
|
|
355
|
+
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
const req = transport.request(
|
|
358
|
+
url,
|
|
359
|
+
{
|
|
360
|
+
method: init.method ?? "GET",
|
|
361
|
+
headers,
|
|
362
|
+
rejectUnauthorized: isHttps
|
|
363
|
+
? !isLoopbackHostname(url.hostname)
|
|
364
|
+
: undefined,
|
|
365
|
+
},
|
|
366
|
+
(res) => {
|
|
367
|
+
const status = res.statusCode ?? 0;
|
|
368
|
+
const contentType = String(res.headers["content-type"] ?? "");
|
|
369
|
+
const buffered = status < 200 || status >= 300 || contentType.includes("application/json");
|
|
370
|
+
if (buffered) {
|
|
371
|
+
const chunks = [];
|
|
372
|
+
res.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
373
|
+
res.on("end", () =>
|
|
374
|
+
resolve({
|
|
375
|
+
status,
|
|
376
|
+
statusText: res.statusMessage ?? "",
|
|
377
|
+
headers: res.headers,
|
|
378
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
379
|
+
streamed: false,
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
res.on("error", reject);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const out = fs.createWriteStream(destPath);
|
|
386
|
+
let bytesWritten = 0;
|
|
387
|
+
const total = Number.parseInt(
|
|
388
|
+
String(res.headers["content-length"] ?? ""),
|
|
389
|
+
10,
|
|
390
|
+
);
|
|
391
|
+
res.on("data", (c) => {
|
|
392
|
+
bytesWritten += c.length;
|
|
393
|
+
init.onProgress?.(bytesWritten, Number.isFinite(total) ? total : null);
|
|
394
|
+
});
|
|
395
|
+
res.pipe(out);
|
|
396
|
+
// A stream that dies mid-flight must reject, not resolve with a truncated
|
|
397
|
+
// file — the caller deletes the partial and surfaces the failure.
|
|
398
|
+
res.on("error", (err) => {
|
|
399
|
+
out.destroy();
|
|
400
|
+
reject(err);
|
|
401
|
+
});
|
|
402
|
+
out.on("error", reject);
|
|
403
|
+
out.on("finish", () =>
|
|
404
|
+
resolve({
|
|
405
|
+
status,
|
|
406
|
+
statusText: res.statusMessage ?? "",
|
|
407
|
+
headers: res.headers,
|
|
408
|
+
bytesWritten,
|
|
409
|
+
streamed: true,
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
},
|
|
413
|
+
);
|
|
414
|
+
req.setTimeout(timeoutMs, () => {
|
|
415
|
+
const err = new Error(
|
|
416
|
+
`slidemind bridge request timed out after ${timeoutMs}ms`,
|
|
417
|
+
);
|
|
418
|
+
err.code = "bridge_timeout";
|
|
419
|
+
req.destroy(err);
|
|
420
|
+
});
|
|
421
|
+
req.on("error", reject);
|
|
422
|
+
if (body) req.write(body);
|
|
423
|
+
req.end();
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// `bridgeFetch` for a response streamed to disk. Shares the session lookup,
|
|
428
|
+
// auth header and non-2xx error mapping so a streaming call fails exactly like
|
|
429
|
+
// every other bridge call.
|
|
430
|
+
async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
|
|
431
|
+
const session = sessionForBackend(backendURL);
|
|
432
|
+
const url = `${session.backendURL}${pathPart}`;
|
|
433
|
+
const headers = {
|
|
434
|
+
accept: "application/octet-stream, application/json",
|
|
435
|
+
...init.headers,
|
|
436
|
+
authorization: `Bearer ${session.sessionToken}`,
|
|
437
|
+
};
|
|
438
|
+
let response;
|
|
439
|
+
try {
|
|
440
|
+
response = await nodeRequestToFile(url, { ...init, headers }, destPath);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (
|
|
443
|
+
err &&
|
|
444
|
+
/ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(
|
|
445
|
+
String(err.message || err),
|
|
446
|
+
)
|
|
447
|
+
) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
|
|
450
|
+
"if it persists, re-pair with `slidemind pair`.",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
enforceVersionFloor(response);
|
|
456
|
+
if (response.status === 401) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
"slidemind session is invalid or expired. Re-pair: open the add-in, " +
|
|
459
|
+
'click "Connect CLI", and run the shown `slidemind pair …`.',
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (response.status < 200 || response.status >= 300) {
|
|
463
|
+
let parsed = null;
|
|
464
|
+
try {
|
|
465
|
+
parsed = response.body ? JSON.parse(response.body) : null;
|
|
466
|
+
} catch {
|
|
467
|
+
/* non-JSON error body */
|
|
468
|
+
}
|
|
469
|
+
const machineCode =
|
|
470
|
+
parsed && typeof parsed.code === "string" ? parsed.code : null;
|
|
471
|
+
const humanError =
|
|
472
|
+
parsed && typeof parsed.error === "string" ? parsed.error : null;
|
|
473
|
+
const detail =
|
|
474
|
+
humanError ?? (response.body ? response.body.slice(0, 400) : "");
|
|
475
|
+
const err = new Error(
|
|
476
|
+
`slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
|
|
477
|
+
(detail ? `: ${detail}` : ""),
|
|
478
|
+
);
|
|
479
|
+
err.status = response.status;
|
|
480
|
+
const code = machineCode ?? humanError;
|
|
481
|
+
if (code) err.code = code;
|
|
482
|
+
if (parsed) err.body = parsed;
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
return response;
|
|
486
|
+
}
|
|
487
|
+
|
|
293
488
|
// List one backend's open presentations, tagging each entry with its origin
|
|
294
489
|
// (backendURL + a short env label) so the merged registry can be routed and
|
|
295
490
|
// displayed per environment. Transient blips retry per-backend.
|
|
@@ -789,6 +984,172 @@ export async function getDesignBundleForBrandAnyBackend(brandId) {
|
|
|
789
984
|
throw err;
|
|
790
985
|
}
|
|
791
986
|
|
|
987
|
+
// Concept-image generation is a heavy provider call (per-image cap ~180s
|
|
988
|
+
// server-side). Budget just over that so a typed server error wins over a
|
|
989
|
+
// client timeout, but keep it below the relay's ~240s ingress cap — the CLI
|
|
990
|
+
// batches per-slide so no single request approaches that ceiling.
|
|
991
|
+
function conceptTimeoutMs() {
|
|
992
|
+
return (
|
|
993
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_CONCEPT_TIMEOUT_MS || "200000", 10) ||
|
|
994
|
+
200000
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function postConceptImages(
|
|
999
|
+
brandId,
|
|
1000
|
+
{ planMarkdown, slideNumbers },
|
|
1001
|
+
backendURL,
|
|
1002
|
+
) {
|
|
1003
|
+
const body = { planMarkdown };
|
|
1004
|
+
if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
|
|
1005
|
+
body.slideNumbers = slideNumbers;
|
|
1006
|
+
}
|
|
1007
|
+
const res = await bridgeFetch(
|
|
1008
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
|
|
1009
|
+
{
|
|
1010
|
+
method: "POST",
|
|
1011
|
+
headers: { "content-type": "application/json" },
|
|
1012
|
+
body: JSON.stringify(body),
|
|
1013
|
+
timeoutMs: conceptTimeoutMs(),
|
|
1014
|
+
},
|
|
1015
|
+
backendURL,
|
|
1016
|
+
);
|
|
1017
|
+
const data = (await res.json()) ?? {};
|
|
1018
|
+
// Tag the response with the backend that answered so a per-slide batch can
|
|
1019
|
+
// pin subsequent calls to the same one (routing state only — never printed).
|
|
1020
|
+
return { ...data, backendURL };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Generate concept mockup images for a brand over the pane-free brand route.
|
|
1024
|
+
// Body `{ planMarkdown, slideNumbers? }`; the optional `slideNumbers` filters
|
|
1025
|
+
// which storyboard beats are rendered (the CLI batches per-slide). Returns the
|
|
1026
|
+
// server's `{ ok, images, slideCount }` augmented with the resolved
|
|
1027
|
+
// `backendURL`. When `backendURL` is given it posts directly; otherwise it
|
|
1028
|
+
// fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
|
|
1029
|
+
// unique so at most one paired backend answers 2xx. Throws a typed
|
|
1030
|
+
// `concept_route_unavailable` when no backend serves the brand (old add-ins,
|
|
1031
|
+
// unknown brand, missing grant) so callers can degrade to "continue without".
|
|
1032
|
+
export async function generateConceptImagesForBrand(
|
|
1033
|
+
brandId,
|
|
1034
|
+
{ planMarkdown, slideNumbers = null, backendURL = null } = {},
|
|
1035
|
+
) {
|
|
1036
|
+
if (!brandId) {
|
|
1037
|
+
throw new Error("generateConceptImagesForBrand: brandId is required");
|
|
1038
|
+
}
|
|
1039
|
+
if (typeof planMarkdown !== "string" || !planMarkdown.trim()) {
|
|
1040
|
+
throw new Error("generateConceptImagesForBrand: planMarkdown is required");
|
|
1041
|
+
}
|
|
1042
|
+
if (backendURL) {
|
|
1043
|
+
return postConceptImages(brandId, { planMarkdown, slideNumbers }, backendURL);
|
|
1044
|
+
}
|
|
1045
|
+
const sessions = sessionsInScope();
|
|
1046
|
+
const failures = [];
|
|
1047
|
+
for (const s of sessions) {
|
|
1048
|
+
try {
|
|
1049
|
+
if (!(await backendSupportsConceptImages(s.backendURL))) {
|
|
1050
|
+
failures.push({
|
|
1051
|
+
env: deriveEnvLabel(s.backendURL),
|
|
1052
|
+
reason: "no concept-images route",
|
|
1053
|
+
});
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
return await postConceptImages(
|
|
1057
|
+
brandId,
|
|
1058
|
+
{ planMarkdown, slideNumbers },
|
|
1059
|
+
s.backendURL,
|
|
1060
|
+
);
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
failures.push({
|
|
1063
|
+
env: deriveEnvLabel(s.backendURL),
|
|
1064
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
1069
|
+
const err = new Error(
|
|
1070
|
+
`no paired backend generates concept mockups for design '${brandId}'` +
|
|
1071
|
+
`${detail ? ` (${detail})` : ""}.`,
|
|
1072
|
+
);
|
|
1073
|
+
err.code = "concept_route_unavailable";
|
|
1074
|
+
throw err;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// gpt-image calls routinely take 60-120s; budget like the concept relay so a
|
|
1078
|
+
// typed server error wins over a client timeout.
|
|
1079
|
+
function imagenTimeoutMs() {
|
|
1080
|
+
return (
|
|
1081
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||
|
|
1082
|
+
200000
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function postGenerateImage(brandId, body, backendURL) {
|
|
1087
|
+
const res = await bridgeFetch(
|
|
1088
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/images`,
|
|
1089
|
+
{
|
|
1090
|
+
method: "POST",
|
|
1091
|
+
headers: { "content-type": "application/json" },
|
|
1092
|
+
body: JSON.stringify(body),
|
|
1093
|
+
timeoutMs: imagenTimeoutMs(),
|
|
1094
|
+
},
|
|
1095
|
+
backendURL,
|
|
1096
|
+
);
|
|
1097
|
+
const data = (await res.json()) ?? {};
|
|
1098
|
+
return { ...data, backendURL };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Generate ONE regular image over the pane-free brand route
|
|
1102
|
+
// (`slidemind imagen`). Body `{ prompt, size?, quality?, brandStyle? }`;
|
|
1103
|
+
// returns the server's `{ ok, image, styleReferences }` augmented with the
|
|
1104
|
+
// resolved `backendURL`. Fan-out and degradation mirror
|
|
1105
|
+
// generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
|
|
1106
|
+
// when no paired backend serves the route (older add-ins, unknown brand,
|
|
1107
|
+
// missing grant) so callers can print the one actionable fix.
|
|
1108
|
+
export async function generateImageForBrand(
|
|
1109
|
+
brandId,
|
|
1110
|
+
{ prompt, size = null, quality = null, brandStyle = false, backendURL = null } = {},
|
|
1111
|
+
) {
|
|
1112
|
+
if (!brandId) {
|
|
1113
|
+
throw new Error("generateImageForBrand: brandId is required");
|
|
1114
|
+
}
|
|
1115
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
1116
|
+
throw new Error("generateImageForBrand: prompt is required");
|
|
1117
|
+
}
|
|
1118
|
+
const body = { prompt };
|
|
1119
|
+
if (size) body.size = size;
|
|
1120
|
+
if (quality) body.quality = quality;
|
|
1121
|
+
if (brandStyle) body.brandStyle = true;
|
|
1122
|
+
if (backendURL) {
|
|
1123
|
+
return postGenerateImage(brandId, body, backendURL);
|
|
1124
|
+
}
|
|
1125
|
+
const sessions = sessionsInScope();
|
|
1126
|
+
const failures = [];
|
|
1127
|
+
for (const s of sessions) {
|
|
1128
|
+
try {
|
|
1129
|
+
if (!(await backendSupportsImageGeneration(s.backendURL))) {
|
|
1130
|
+
failures.push({
|
|
1131
|
+
env: deriveEnvLabel(s.backendURL),
|
|
1132
|
+
reason: "no image-generation route (update the add-in)",
|
|
1133
|
+
});
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
return await postGenerateImage(brandId, body, s.backendURL);
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
failures.push({
|
|
1139
|
+
env: deriveEnvLabel(s.backendURL),
|
|
1140
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
1145
|
+
const err = new Error(
|
|
1146
|
+
`no paired backend generates images for design '${brandId}'` +
|
|
1147
|
+
`${detail ? ` (${detail})` : ""}.`,
|
|
1148
|
+
);
|
|
1149
|
+
err.code = "imagen_route_unavailable";
|
|
1150
|
+
throw err;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
792
1153
|
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
793
1154
|
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
794
1155
|
// when several environments are paired this fans out and tags each engine with
|
|
@@ -905,6 +1266,23 @@ export async function backendSupportsBrandRoutes(backendURL) {
|
|
|
905
1266
|
return caps?.brandRoutes === true;
|
|
906
1267
|
}
|
|
907
1268
|
|
|
1269
|
+
// Whether a backend generates concept mockup images for a brand over the
|
|
1270
|
+
// pane-free `/api/cli/brands/:brandId/concept-images` route (see
|
|
1271
|
+
// generateConceptImagesForBrand). Advertised by newer add-ins only; older ones
|
|
1272
|
+
// omit the flag and the caller degrades to "continue without".
|
|
1273
|
+
export async function backendSupportsConceptImages(backendURL) {
|
|
1274
|
+
const caps = await backendCapabilities(backendURL);
|
|
1275
|
+
return caps?.conceptImages === true;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Whether a backend generates regular images over the pane-free
|
|
1279
|
+
// `/api/cli/brands/:brandId/images` route (see generateImageForBrand).
|
|
1280
|
+
// Advertised by newer add-ins only.
|
|
1281
|
+
export async function backendSupportsImageGeneration(backendURL) {
|
|
1282
|
+
const caps = await backendCapabilities(backendURL);
|
|
1283
|
+
return caps?.imageGeneration === true;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
908
1286
|
// The add-in's hard version floor: /api/cli/capabilities advertises the oldest
|
|
909
1287
|
// CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
|
|
910
1288
|
// mid-rollout skew otherwise surfaces as confusing route/contract errors deep
|
|
@@ -914,13 +1292,7 @@ export async function assertCliVersionSupported(backendURL) {
|
|
|
914
1292
|
const caps = await backendCapabilities(backendURL);
|
|
915
1293
|
const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
|
|
916
1294
|
if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
|
|
917
|
-
|
|
918
|
-
`slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
|
|
919
|
-
`supports (${min}). Run \`slidemind update\` (or ` +
|
|
920
|
-
"`npm install -g slidemind@latest`), then retry.",
|
|
921
|
-
);
|
|
922
|
-
err.code = "cli_update_required";
|
|
923
|
-
throw err;
|
|
1295
|
+
throw cliUpdateRequiredError(min);
|
|
924
1296
|
}
|
|
925
1297
|
|
|
926
1298
|
// Rebuild a job failure into the exact error shape bridgeFetch throws for the
|
|
@@ -1231,11 +1603,17 @@ async function engineOpWithPaneWait(presId, backendURL, attempt) {
|
|
|
1231
1603
|
// slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
|
|
1232
1604
|
// are passed through for slide engines (image engines ignore intent); omit them
|
|
1233
1605
|
// and the server uses its defaults (back-compat with older add-ins).
|
|
1606
|
+
//
|
|
1607
|
+
// `engineId` may also be a SCOPE sentinel (`scope:all` / `scope:sharepoint`) —
|
|
1608
|
+
// an unfiltered search over the whole corpus rather than one engine's saved
|
|
1609
|
+
// folder filter. A scope has no subtype to derive its index from, so it needs an
|
|
1610
|
+
// explicit `resultType`; the add-in rejects a scope search without one.
|
|
1234
1611
|
export async function engineSearch({
|
|
1235
1612
|
presId,
|
|
1236
1613
|
engineId,
|
|
1237
1614
|
query,
|
|
1238
1615
|
subtype = null,
|
|
1616
|
+
resultType = null,
|
|
1239
1617
|
intent = null,
|
|
1240
1618
|
top = null,
|
|
1241
1619
|
backendURL = null,
|
|
@@ -1251,6 +1629,8 @@ export async function engineSearch({
|
|
|
1251
1629
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1252
1630
|
const body = { engineId, query };
|
|
1253
1631
|
if (subtype) body.subtype = subtype;
|
|
1632
|
+
// Additive: omitted unless set, so the body an older add-in sees is unchanged.
|
|
1633
|
+
if (resultType) body.resultType = resultType;
|
|
1254
1634
|
if (intent) body.intent = intent;
|
|
1255
1635
|
if (typeof top === "number") body.top = top;
|
|
1256
1636
|
const run = async (pid, b) => {
|
|
@@ -1285,6 +1665,7 @@ export async function engineSearchAnyPane({
|
|
|
1285
1665
|
engineId,
|
|
1286
1666
|
query,
|
|
1287
1667
|
subtype = null,
|
|
1668
|
+
resultType = null,
|
|
1288
1669
|
intent = null,
|
|
1289
1670
|
top = null,
|
|
1290
1671
|
}) {
|
|
@@ -1318,6 +1699,7 @@ export async function engineSearchAnyPane({
|
|
|
1318
1699
|
engineId,
|
|
1319
1700
|
query,
|
|
1320
1701
|
subtype,
|
|
1702
|
+
resultType,
|
|
1321
1703
|
intent,
|
|
1322
1704
|
top,
|
|
1323
1705
|
backendURL: cand.backendURL ?? primaryBackend,
|
|
@@ -1384,6 +1766,130 @@ export async function engineDownload({
|
|
|
1384
1766
|
});
|
|
1385
1767
|
}
|
|
1386
1768
|
|
|
1769
|
+
// Stream a chosen engine result's bytes to `destPath`. The size-unbounded
|
|
1770
|
+
// download: the pane's Socket.IO ack carries only a signed source reference, and
|
|
1771
|
+
// the add-in server fetches it and pipes the body here — so nothing buffers the
|
|
1772
|
+
// whole image and there is no base64 4/3 inflation. `engineDownload` above sends
|
|
1773
|
+
// the bytes through the pane ack instead and is therefore capped by the relay's
|
|
1774
|
+
// 10 MB frame limit; prefer this for anything that might be large.
|
|
1775
|
+
//
|
|
1776
|
+
// An SVG result comes back as JSON (`{ ok, mimeType, svgMarkup }`) rather than a
|
|
1777
|
+
// byte stream: it is kilobytes and its recolour happens pane-side. Callers get
|
|
1778
|
+
// `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
|
|
1779
|
+
// can persist whichever arrived.
|
|
1780
|
+
export async function engineDownloadToFile({
|
|
1781
|
+
presId,
|
|
1782
|
+
searchId,
|
|
1783
|
+
selection,
|
|
1784
|
+
destPath,
|
|
1785
|
+
iconColor = null,
|
|
1786
|
+
backendURL = null,
|
|
1787
|
+
onProgress = null,
|
|
1788
|
+
}) {
|
|
1789
|
+
if (!presId) throw new Error("engineDownloadToFile: presId is required");
|
|
1790
|
+
if (typeof searchId !== "number") {
|
|
1791
|
+
throw new Error("engineDownloadToFile: searchId (number) is required");
|
|
1792
|
+
}
|
|
1793
|
+
if (typeof selection !== "number") {
|
|
1794
|
+
throw new Error("engineDownloadToFile: selection (number) is required");
|
|
1795
|
+
}
|
|
1796
|
+
if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
|
|
1797
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1798
|
+
const body = { searchId, selection };
|
|
1799
|
+
if (iconColor) body.iconColor = iconColor;
|
|
1800
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1801
|
+
const res = await bridgeStreamToFile(
|
|
1802
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
|
|
1803
|
+
{
|
|
1804
|
+
method: "POST",
|
|
1805
|
+
headers: { "content-type": "application/json" },
|
|
1806
|
+
body: JSON.stringify(body),
|
|
1807
|
+
// A large image legitimately takes longer than an engine op: the server
|
|
1808
|
+
// is fetching from SharePoint/blob AND piping. Separate budget so raising
|
|
1809
|
+
// it doesn't loosen every other relay call.
|
|
1810
|
+
timeoutMs:
|
|
1811
|
+
parseInt(
|
|
1812
|
+
process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
|
|
1813
|
+
10,
|
|
1814
|
+
) || 600000,
|
|
1815
|
+
onProgress,
|
|
1816
|
+
},
|
|
1817
|
+
destPath,
|
|
1818
|
+
b,
|
|
1819
|
+
);
|
|
1820
|
+
if (!res.streamed) {
|
|
1821
|
+
// The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
|
|
1822
|
+
const parsed = res.body ? JSON.parse(res.body) : {};
|
|
1823
|
+
return { ok: true, streamed: false, ...parsed };
|
|
1824
|
+
}
|
|
1825
|
+
const warningsHeader = res.headers?.["x-slidemind-warnings"];
|
|
1826
|
+
let warnings;
|
|
1827
|
+
if (warningsHeader) {
|
|
1828
|
+
try {
|
|
1829
|
+
warnings = JSON.parse(warningsHeader);
|
|
1830
|
+
} catch {
|
|
1831
|
+
// A malformed header must not fail a download that otherwise succeeded.
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
return {
|
|
1835
|
+
ok: true,
|
|
1836
|
+
streamed: true,
|
|
1837
|
+
mimeType: String(res.headers?.["content-type"] ?? "") || null,
|
|
1838
|
+
bytesWritten: res.bytesWritten,
|
|
1839
|
+
...(warnings ? { warnings } : {}),
|
|
1840
|
+
};
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Stream a chosen slide-search result to `destPath` as a native one-slide .pptx.
|
|
1845
|
+
// The size-unbounded sibling of `extractSlide`: the pane ack carries only the
|
|
1846
|
+
// request, and the add-in server performs it and pipes the (base64-decoded) bytes
|
|
1847
|
+
// here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
|
|
1848
|
+
// base64, so it is capped at ~7 MB of real bytes — which real reused slides reach.
|
|
1849
|
+
export async function extractSlideToFile({
|
|
1850
|
+
presId,
|
|
1851
|
+
searchId,
|
|
1852
|
+
selection,
|
|
1853
|
+
destPath,
|
|
1854
|
+
backendURL = null,
|
|
1855
|
+
onProgress = null,
|
|
1856
|
+
}) {
|
|
1857
|
+
if (!presId) throw new Error("extractSlideToFile: presId is required");
|
|
1858
|
+
if (typeof searchId !== "number") {
|
|
1859
|
+
throw new Error("extractSlideToFile: searchId (number) is required");
|
|
1860
|
+
}
|
|
1861
|
+
if (typeof selection !== "number") {
|
|
1862
|
+
throw new Error("extractSlideToFile: selection (number) is required");
|
|
1863
|
+
}
|
|
1864
|
+
if (!destPath) throw new Error("extractSlideToFile: destPath is required");
|
|
1865
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1866
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1867
|
+
const res = await bridgeStreamToFile(
|
|
1868
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract-stream`,
|
|
1869
|
+
{
|
|
1870
|
+
method: "POST",
|
|
1871
|
+
headers: { "content-type": "application/json" },
|
|
1872
|
+
body: JSON.stringify({ searchId, selection }),
|
|
1873
|
+
timeoutMs:
|
|
1874
|
+
parseInt(
|
|
1875
|
+
process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
|
|
1876
|
+
10,
|
|
1877
|
+
) || 600000,
|
|
1878
|
+
onProgress,
|
|
1879
|
+
},
|
|
1880
|
+
destPath,
|
|
1881
|
+
b,
|
|
1882
|
+
);
|
|
1883
|
+
const title = res.headers?.["x-slidemind-presentation-title"];
|
|
1884
|
+
return {
|
|
1885
|
+
ok: true,
|
|
1886
|
+
bytesWritten: res.bytesWritten,
|
|
1887
|
+
slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
|
|
1888
|
+
presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
|
|
1889
|
+
};
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1387
1893
|
// Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
|
|
1388
1894
|
// WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
|
|
1389
1895
|
// the pane like search/download.
|