slidemind 0.1.2 → 0.1.4
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 +31 -19
- package/README.md +31 -6
- package/bin/slidemind.mjs +437 -18
- package/package.json +2 -6
- package/skills/create-slides/SKILL.md +14 -8
- package/skills/search/SKILL.md +75 -0
- package/src/bridge/build-and-push.mjs +178 -34
- package/src/bridge/bundle.mjs +325 -99
- package/src/bridge/capture.mjs +187 -0
- package/src/bridge/client.mjs +628 -47
- package/src/bridge/config.mjs +8 -2
- package/src/builders/build.mjs +75 -26
- package/src/builders/lint.mjs +112 -0
- package/src/builders/resolve-skill.mjs +16 -21
- package/src/builders/review-overlay.js +821 -0
- package/src/builders/review.mjs +388 -0
- package/src/lib/reused-slides.mjs +67 -0
- package/src/lib/slide-order.mjs +4 -1
- package/src/mcp/server.mjs +368 -27
- package/src/builders/lint-slides.mjs +0 -796
- package/src/builders/primitives.json +0 -19
package/src/bridge/client.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// backend runs render/bundle in-process and relays insert/replace/engine to the
|
|
6
6
|
// open PowerPoint pane. No localhost, no shared loopback token, no cert.
|
|
7
7
|
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
import http from "node:http";
|
|
9
10
|
import https from "node:https";
|
|
10
11
|
|
|
@@ -338,19 +339,44 @@ export async function requireHost() {
|
|
|
338
339
|
);
|
|
339
340
|
}
|
|
340
341
|
|
|
342
|
+
// Tie-break a multi-backend presId collision without bothering the user when
|
|
343
|
+
// only one candidate can actually serve the deck:
|
|
344
|
+
// (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
|
|
345
|
+
// ref (`preferBrandId` — the deck's presFP, which equals the pane's
|
|
346
|
+
// brandId by the bundle-cache contract);
|
|
347
|
+
// (b) else exactly one connected pane with ANY active CD (brandId set) —
|
|
348
|
+
// e.g. a local dev backend connected without a CD next to prod with one.
|
|
349
|
+
// Returns the winning entry or null when genuinely ambiguous.
|
|
350
|
+
function tieBreakMatches(matches, preferBrandId) {
|
|
351
|
+
const live = matches.filter((m) => m.connected !== false && !m.suspendedAt);
|
|
352
|
+
if (preferBrandId) {
|
|
353
|
+
const want = live.filter((m) => m.brandId === preferBrandId);
|
|
354
|
+
if (want.length === 1) return want[0];
|
|
355
|
+
}
|
|
356
|
+
const withCd = live.filter((m) => m.brandId);
|
|
357
|
+
if (withCd.length === 1) return withCd[0];
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
341
361
|
// Resolve which backend a presId lives on via a LIVE fan-out list (never a
|
|
342
362
|
// cached map — a presId can move backends on re-pair/redeploy). Throws an
|
|
343
363
|
// actionable message on a miss, or an ambiguity error when the same presId is
|
|
344
|
-
// open on more than one reachable backend.
|
|
345
|
-
export async function resolveBackendForPres(
|
|
364
|
+
// open on more than one reachable backend and no tie-break rule settles it.
|
|
365
|
+
export async function resolveBackendForPres(
|
|
366
|
+
presId,
|
|
367
|
+
{ preferBrandId = null } = {},
|
|
368
|
+
) {
|
|
346
369
|
if (!presId) throw new Error("resolveBackendForPres: presId is required");
|
|
347
370
|
const { presentations } = await fanOutPresentations();
|
|
348
371
|
const matches = presentations.filter((p) => p.presId === presId);
|
|
349
372
|
if (matches.length === 1) return matches[0].backendURL;
|
|
350
373
|
if (matches.length > 1) {
|
|
374
|
+
const winner = tieBreakMatches(matches, preferBrandId);
|
|
375
|
+
if (winner) return winner.backendURL;
|
|
351
376
|
const envs = matches.map((m) => m.env).join(", ");
|
|
352
377
|
throw new Error(
|
|
353
|
-
`Presentation '${presId}' is open on multiple environments: ${envs}
|
|
378
|
+
`Presentation '${presId}' is open on multiple environments: ${envs}, ` +
|
|
379
|
+
"and no single connected one has the deck's corporate design active. " +
|
|
354
380
|
"Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
|
|
355
381
|
);
|
|
356
382
|
}
|
|
@@ -360,11 +386,79 @@ export async function resolveBackendForPres(presId) {
|
|
|
360
386
|
);
|
|
361
387
|
}
|
|
362
388
|
|
|
389
|
+
// Wait for a presentation's task pane to come back after a `pane_suspended`
|
|
390
|
+
// (PowerPoint backgrounded → webview suspended → socket dropped). Polls the
|
|
391
|
+
// live registry; succeeds on the same presId reconnecting, OR on a CONNECTED
|
|
392
|
+
// entry matching `fileName` + `brandId` — a pane reconnect can mint a NEW
|
|
393
|
+
// presId for the same window, so identity-by-content covers the churn. Returns
|
|
394
|
+
// the live registry entry (caller re-targets from it), or null on timeout.
|
|
395
|
+
// `onWait` fires once with the wait budget in seconds — the caller's single
|
|
396
|
+
// actionable "click the pane" line.
|
|
397
|
+
export async function waitForPaneReady(
|
|
398
|
+
presId,
|
|
399
|
+
{
|
|
400
|
+
backendURL = null,
|
|
401
|
+
fileName = null,
|
|
402
|
+
brandId = null,
|
|
403
|
+
timeoutMs = null,
|
|
404
|
+
pollMs = null,
|
|
405
|
+
onWait = null,
|
|
406
|
+
} = {},
|
|
407
|
+
) {
|
|
408
|
+
const waitMs =
|
|
409
|
+
timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_TIMEOUT_MS", 120000);
|
|
410
|
+
const stepMs = pollMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_POLL_MS", 3000);
|
|
411
|
+
const deadline = Date.now() + waitMs;
|
|
412
|
+
let notified = false;
|
|
413
|
+
for (;;) {
|
|
414
|
+
let list = [];
|
|
415
|
+
try {
|
|
416
|
+
list = backendURL
|
|
417
|
+
? await listPresentationsForBackend(backendURL)
|
|
418
|
+
: (await fanOutPresentations()).presentations;
|
|
419
|
+
} catch {
|
|
420
|
+
/* transient list failure — poll again until the deadline */
|
|
421
|
+
}
|
|
422
|
+
const connected = list.filter(
|
|
423
|
+
(p) => p.connected !== false && !p.suspendedAt,
|
|
424
|
+
);
|
|
425
|
+
const same = connected.find((p) => p.presId === presId);
|
|
426
|
+
if (same) return same;
|
|
427
|
+
if (fileName || brandId) {
|
|
428
|
+
const rematch = connected.find(
|
|
429
|
+
(p) =>
|
|
430
|
+
(!fileName || p.fileName === fileName || p.title === fileName) &&
|
|
431
|
+
(!brandId || p.brandId === brandId),
|
|
432
|
+
);
|
|
433
|
+
if (rematch) return rematch;
|
|
434
|
+
}
|
|
435
|
+
if (Date.now() >= deadline) return null;
|
|
436
|
+
if (!notified) {
|
|
437
|
+
notified = true;
|
|
438
|
+
onWait?.(Math.round(waitMs / 1000));
|
|
439
|
+
}
|
|
440
|
+
await sleep(stepMs);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// The one actionable line a human needs when a pane op is waiting. stderr so
|
|
445
|
+
// it reaches the terminal from the CLI and the MCP server log alike.
|
|
446
|
+
function paneWaitNotice(seconds) {
|
|
447
|
+
process.stderr.write(
|
|
448
|
+
"task pane suspended — click the slidemind pane in PowerPoint to wake it " +
|
|
449
|
+
`(waiting up to ${seconds}s)…\n`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
363
453
|
// GET the full design bundle (tokens, css, layouts, designRules, signed asset
|
|
364
454
|
// URLs, engines) for the presentation's active corporate design.
|
|
365
|
-
export async function getDesignBundle(
|
|
455
|
+
export async function getDesignBundle(
|
|
456
|
+
presId,
|
|
457
|
+
{ backendURL = null, preferBrandId = null } = {},
|
|
458
|
+
) {
|
|
366
459
|
if (!presId) throw new Error("getDesignBundle: presId is required");
|
|
367
|
-
const be =
|
|
460
|
+
const be =
|
|
461
|
+
backendURL || (await resolveBackendForPres(presId, { preferBrandId }));
|
|
368
462
|
return withTransientRetry(async () => {
|
|
369
463
|
const res = await bridgeFetch(
|
|
370
464
|
`/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
|
|
@@ -375,35 +469,333 @@ export async function getDesignBundle(presId, { backendURL = null } = {}) {
|
|
|
375
469
|
});
|
|
376
470
|
}
|
|
377
471
|
|
|
472
|
+
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
473
|
+
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
474
|
+
// when several environments are paired this fans out and tags each engine with
|
|
475
|
+
// its short `env` label (never the backendURL — the no-URL rule). Filter by
|
|
476
|
+
// engine `type` (collection|search) and/or `subtype` (image|slide|template|
|
|
477
|
+
// content). Returns a flat `{ engines: [...] }` like the single-backend route.
|
|
478
|
+
async function listEnginesForBackend(backendURL, { type = null, subtype = null } = {}) {
|
|
479
|
+
return withTransientRetry(async () => {
|
|
480
|
+
const params = new URLSearchParams();
|
|
481
|
+
if (type) params.set("type", type);
|
|
482
|
+
if (subtype) params.set("subtype", subtype);
|
|
483
|
+
const qs = params.toString();
|
|
484
|
+
const res = await bridgeFetch(
|
|
485
|
+
`/api/cli/engines${qs ? `?${qs}` : ""}`,
|
|
486
|
+
{ timeoutMs: listTimeoutMs() },
|
|
487
|
+
backendURL,
|
|
488
|
+
);
|
|
489
|
+
const data = await res.json();
|
|
490
|
+
const arr = Array.isArray(data?.engines) ? data.engines : [];
|
|
491
|
+
const env = deriveEnvLabel(backendURL);
|
|
492
|
+
return arr.map((e) => ({ ...e, env }));
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export async function listEngines({ type = null, subtype = null } = {}) {
|
|
497
|
+
const sessions = sessionsInScope();
|
|
498
|
+
const settled = await Promise.allSettled(
|
|
499
|
+
sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
|
|
500
|
+
);
|
|
501
|
+
const engines = [];
|
|
502
|
+
const failures = [];
|
|
503
|
+
let fulfilledCount = 0;
|
|
504
|
+
settled.forEach((r, i) => {
|
|
505
|
+
if (r.status === "fulfilled") {
|
|
506
|
+
fulfilledCount += 1;
|
|
507
|
+
engines.push(...r.value);
|
|
508
|
+
} else {
|
|
509
|
+
failures.push({ env: deriveEnvLabel(sessions[i].backendURL), err: r.reason });
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
// Mirror listOpenPresentations: only surface an error when nothing answered.
|
|
513
|
+
if (engines.length === 0 && fulfilledCount === 0 && failures.length > 0) {
|
|
514
|
+
if (failures.length === 1) throw failures[0].err;
|
|
515
|
+
throw new Error(
|
|
516
|
+
"slidemind: every paired backend failed to list engines:\n" +
|
|
517
|
+
failures.map((f) => ` ${f.env}: ${f.err.message}`).join("\n"),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
return { engines };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// Server-side OOXML render
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
//
|
|
527
|
+
// Two protocols, newest first:
|
|
528
|
+
// 1. Render JOBS (preferred): POST creates a job, the CLI polls cheap status
|
|
529
|
+
// GETs and fetches the result when done. A full deck render routinely
|
|
530
|
+
// outlives the cloud ingress's ~240s request cap — only short polling
|
|
531
|
+
// requests survive that reliably, and they carry per-slide progress.
|
|
532
|
+
// 2. Sync render (fallback): one POST held open for the whole render. Kept
|
|
533
|
+
// for older backends that don't serve /api/cli/capabilities yet.
|
|
534
|
+
|
|
535
|
+
function envInt(name, fallback) {
|
|
536
|
+
const n = parseInt(process.env[name] || "", 10);
|
|
537
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function sleep(ms) {
|
|
541
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// One capability probe per backend per process. Keyed by the RESOLVED backend
|
|
545
|
+
// URL (callers always resolve before probing), so multiple paired backends on
|
|
546
|
+
// mixed versions each get their own answer.
|
|
547
|
+
const renderJobsCapability = new Map();
|
|
548
|
+
|
|
549
|
+
function backendSupportsRenderJobs(backendURL) {
|
|
550
|
+
let probe = renderJobsCapability.get(backendURL);
|
|
551
|
+
if (!probe) {
|
|
552
|
+
probe = (async () => {
|
|
553
|
+
try {
|
|
554
|
+
const res = await bridgeFetch(
|
|
555
|
+
"/api/cli/capabilities",
|
|
556
|
+
{ timeoutMs: listTimeoutMs() },
|
|
557
|
+
backendURL,
|
|
558
|
+
);
|
|
559
|
+
const data = await res.json();
|
|
560
|
+
return data?.renderJobs === true;
|
|
561
|
+
} catch {
|
|
562
|
+
// Older backends 404 this path (with a non-JSON body) — sync render.
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
renderJobsCapability.set(backendURL, probe);
|
|
567
|
+
}
|
|
568
|
+
return probe;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Rebuild a job failure into the exact error shape bridgeFetch throws for the
|
|
572
|
+
// sync route, so callers (etag self-heal, MCP error texts) can't tell the
|
|
573
|
+
// protocols apart.
|
|
574
|
+
function jobFailureError(jobError, presId) {
|
|
575
|
+
const status = jobError?.status ?? 500;
|
|
576
|
+
const detail = jobError?.error ?? "render_failed";
|
|
577
|
+
const err = new Error(
|
|
578
|
+
`slidemind bridge ${status} render job failed for ${presId}: ${detail}`,
|
|
579
|
+
);
|
|
580
|
+
err.status = status;
|
|
581
|
+
err.code = detail;
|
|
582
|
+
err.body = { error: detail, ...(jobError?.extra ?? {}) };
|
|
583
|
+
return err;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function renderViaSync({ presId, body, backendURL }) {
|
|
587
|
+
const res = await bridgeFetch(
|
|
588
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
|
|
589
|
+
{
|
|
590
|
+
method: "POST",
|
|
591
|
+
headers: { "content-type": "application/json" },
|
|
592
|
+
body: JSON.stringify(body),
|
|
593
|
+
// Server-side OOXML render is a heavy operation — a full deck of richly
|
|
594
|
+
// composed slides routinely runs well past the 35s default. Treat it like
|
|
595
|
+
// a mutation: give it a generous default and let env override it.
|
|
596
|
+
timeoutMs:
|
|
597
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
|
|
598
|
+
300000,
|
|
599
|
+
},
|
|
600
|
+
backendURL,
|
|
601
|
+
);
|
|
602
|
+
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
606
|
+
const pollMs = envInt("SLIDEMIND_BRIDGE_RENDER_POLL_MS", 2500);
|
|
607
|
+
const deadlineMs = envInt("SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS", 900000);
|
|
608
|
+
const resultTimeoutMs = envInt(
|
|
609
|
+
"SLIDEMIND_BRIDGE_RENDER_RESULT_TIMEOUT_MS",
|
|
610
|
+
120000,
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
const createJob = async () => {
|
|
614
|
+
// Deliberately NOT wrapped in withTransientRetry: its blanket 404 retry
|
|
615
|
+
// would re-upload a multi-MB slide payload, and creation errors (stale
|
|
616
|
+
// etag, no CD, unknown presId) are definitive.
|
|
617
|
+
const res = await bridgeFetch(
|
|
618
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/render-jobs`,
|
|
619
|
+
{
|
|
620
|
+
method: "POST",
|
|
621
|
+
headers: { "content-type": "application/json" },
|
|
622
|
+
body: JSON.stringify(body),
|
|
623
|
+
timeoutMs:
|
|
624
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) ||
|
|
625
|
+
35000,
|
|
626
|
+
},
|
|
627
|
+
backendURL,
|
|
628
|
+
);
|
|
629
|
+
const data = await res.json();
|
|
630
|
+
if (!data?.jobId) throw new Error("render-jobs returned no jobId");
|
|
631
|
+
return data.jobId;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
let jobId = await createJob();
|
|
635
|
+
let resubmitted = false;
|
|
636
|
+
let lastReported = "";
|
|
637
|
+
const deadline = Date.now() + deadlineMs;
|
|
638
|
+
|
|
639
|
+
for (;;) {
|
|
640
|
+
if (Date.now() > deadline) {
|
|
641
|
+
const err = new Error(
|
|
642
|
+
`slidemind render did not finish within ${deadlineMs}ms ` +
|
|
643
|
+
"(raise SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS for very large decks). " +
|
|
644
|
+
"The render keeps running server-side — retrying may pick up a warm cache.",
|
|
645
|
+
);
|
|
646
|
+
err.code = "bridge_timeout";
|
|
647
|
+
throw err;
|
|
648
|
+
}
|
|
649
|
+
await sleep(pollMs);
|
|
650
|
+
|
|
651
|
+
let status;
|
|
652
|
+
try {
|
|
653
|
+
const res = await bridgeFetch(
|
|
654
|
+
`/api/cli/render-jobs/${encodeURIComponent(jobId)}`,
|
|
655
|
+
{ timeoutMs: 15000 },
|
|
656
|
+
backendURL,
|
|
657
|
+
);
|
|
658
|
+
status = await res.json();
|
|
659
|
+
} catch (err) {
|
|
660
|
+
// The job record vanished (backend Redis flush/restart) — same recovery
|
|
661
|
+
// as a lost replica: resubmit once.
|
|
662
|
+
if (err?.code === "job_not_found") {
|
|
663
|
+
if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, presId);
|
|
664
|
+
resubmitted = true;
|
|
665
|
+
onProgress?.({ state: "restarted" });
|
|
666
|
+
jobId = await createJob();
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
// Rate-limited poll: honor the window, then resume.
|
|
670
|
+
if (err?.status === 429) {
|
|
671
|
+
const retryAfterSec = err?.body?.retryAfterSec;
|
|
672
|
+
await sleep(Math.min((retryAfterSec || 5) * 1000, 30000));
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
// Anything else (network blip, 5xx, replica restart) — polling is
|
|
676
|
+
// idempotent; just poll again until the deadline.
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (onProgress) {
|
|
681
|
+
const snapshot = JSON.stringify([status.state, status.progress]);
|
|
682
|
+
if (snapshot !== lastReported) {
|
|
683
|
+
lastReported = snapshot;
|
|
684
|
+
onProgress({ state: status.state, ...(status.progress ?? {}) });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (status.state === "failed") {
|
|
689
|
+
const jobError = status.error ?? { status: 500, error: "render_failed" };
|
|
690
|
+
// The rendering replica died (deploy, crash, scale-in). Renders are
|
|
691
|
+
// idempotent — resubmit once before giving up.
|
|
692
|
+
if (jobError.error === "job_lost" && !resubmitted) {
|
|
693
|
+
resubmitted = true;
|
|
694
|
+
onProgress?.({ state: "restarted" });
|
|
695
|
+
jobId = await createJob();
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
throw jobFailureError(jobError, presId);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (status.state === "done") {
|
|
702
|
+
// The result may be tens of MB — generous timeout, and a couple of
|
|
703
|
+
// retries for transient blips (the stored result is re-fetchable).
|
|
704
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
705
|
+
try {
|
|
706
|
+
const res = await bridgeFetch(
|
|
707
|
+
`/api/cli/render-jobs/${encodeURIComponent(jobId)}/result`,
|
|
708
|
+
{ timeoutMs: resultTimeoutMs },
|
|
709
|
+
backendURL,
|
|
710
|
+
);
|
|
711
|
+
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
712
|
+
} catch (err) {
|
|
713
|
+
if (
|
|
714
|
+
attempt >= 2 ||
|
|
715
|
+
err?.code === "result_expired" ||
|
|
716
|
+
err?.code === "job_not_found"
|
|
717
|
+
) {
|
|
718
|
+
throw err;
|
|
719
|
+
}
|
|
720
|
+
await sleep(pollMs);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
378
727
|
// Server-side OOXML render: ship authored slide JSX (+ deck-local assets) and
|
|
379
728
|
// get back a native .pptx (base64) rendered against the presentation's own CD.
|
|
380
729
|
// `slides`: { "<file>.jsx": "<jsx source>" }. `deckAssets`: { "assets/<f>": "<base64>" }.
|
|
730
|
+
// `onProgress` (optional) receives `{ state, done?, total?, stage? }` ticks on
|
|
731
|
+
// the job protocol; the sync fallback emits none.
|
|
381
732
|
export async function renderPptx({
|
|
382
733
|
presId,
|
|
383
734
|
slides,
|
|
384
735
|
deckAssets = null,
|
|
736
|
+
nativeSlides = null,
|
|
385
737
|
etag = null,
|
|
386
738
|
backendURL = null,
|
|
739
|
+
onProgress = null,
|
|
387
740
|
}) {
|
|
388
741
|
if (!presId) throw new Error("renderPptx: presId is required");
|
|
389
|
-
|
|
390
|
-
|
|
742
|
+
const hasSlides = slides && Object.keys(slides).length > 0;
|
|
743
|
+
const hasNative = nativeSlides && Object.keys(nativeSlides).length > 0;
|
|
744
|
+
if (!hasSlides && !hasNative) {
|
|
745
|
+
throw new Error("renderPptx: slides or nativeSlides is required");
|
|
391
746
|
}
|
|
392
747
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
393
|
-
const body = { slides };
|
|
748
|
+
const body = { slides: slides ?? {} };
|
|
394
749
|
if (deckAssets && Object.keys(deckAssets).length > 0) {
|
|
395
750
|
body.deckAssets = deckAssets;
|
|
396
751
|
}
|
|
752
|
+
// Reused native one-slide .pptx (base64), keyed by play-order filename — the
|
|
753
|
+
// server splices them in by numeric-aware sorted filename (contract #7).
|
|
754
|
+
if (hasNative) body.nativeSlides = nativeSlides;
|
|
755
|
+
if (etag) body.etag = etag;
|
|
756
|
+
|
|
757
|
+
if (await backendSupportsRenderJobs(be)) {
|
|
758
|
+
try {
|
|
759
|
+
return await renderViaJob({ presId, body, backendURL: be, onProgress });
|
|
760
|
+
} catch (err) {
|
|
761
|
+
// The backend speaks the protocol but its job store is down — the sync
|
|
762
|
+
// route renders in-process without Redis, so fall back to it.
|
|
763
|
+
if (err?.code !== "jobs_unavailable") throw err;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return renderViaSync({ presId, body, backendURL: be });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Translate a deck the CLI captured LOCALLY (the shipped capture engine) into a
|
|
770
|
+
// native .pptx. The slow Chrome work already ran client-side, so this is a fast
|
|
771
|
+
// single synchronous request — the server runs only the IP-sensitive boxes→OOXML
|
|
772
|
+
// half. Returns the same shape as renderPptx ({ ok, base64, slideCount, … }).
|
|
773
|
+
export async function translateDeck({
|
|
774
|
+
presId,
|
|
775
|
+
captured,
|
|
776
|
+
nativeSlides = null,
|
|
777
|
+
etag = null,
|
|
778
|
+
backendURL = null,
|
|
779
|
+
}) {
|
|
780
|
+
if (!presId) throw new Error("translateDeck: presId is required");
|
|
781
|
+
if (!captured) throw new Error("translateDeck: captured is required");
|
|
782
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
783
|
+
const body = { captured };
|
|
784
|
+
// Reused native one-slide .pptx (base64), keyed by play-order filename — the
|
|
785
|
+
// server splices them in alongside the locally-captured JSX slides (contract
|
|
786
|
+
// #7). The slow capture already ran client-side; native slides are pass-through.
|
|
787
|
+
if (nativeSlides && Object.keys(nativeSlides).length > 0) {
|
|
788
|
+
body.nativeSlides = nativeSlides;
|
|
789
|
+
}
|
|
397
790
|
if (etag) body.etag = etag;
|
|
398
791
|
const res = await bridgeFetch(
|
|
399
|
-
`/api/cli/presentations/${encodeURIComponent(presId)}/
|
|
792
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/translate`,
|
|
400
793
|
{
|
|
401
794
|
method: "POST",
|
|
402
795
|
headers: { "content-type": "application/json" },
|
|
403
796
|
body: JSON.stringify(body),
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// a mutation: give it a generous default and let env override it.
|
|
797
|
+
// The body carries inlined image bytes and the response a base64 .pptx —
|
|
798
|
+
// bandwidth, not server compute. Give it the render-grade timeout.
|
|
407
799
|
timeoutMs:
|
|
408
800
|
parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
|
|
409
801
|
300000,
|
|
@@ -413,30 +805,65 @@ export async function renderPptx({
|
|
|
413
805
|
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
414
806
|
}
|
|
415
807
|
|
|
808
|
+
// Run a pane-relayed engine op; on `pane_suspended` (the server already waited
|
|
809
|
+
// ~25s for a reconnect) wait CLIENT-side for the pane to wake — one actionable
|
|
810
|
+
// line, then a single retry against the live entry (covers presId churn too).
|
|
811
|
+
// Engine ops are idempotent reads, so the retry is safe.
|
|
812
|
+
async function engineOpWithPaneWait(presId, backendURL, attempt) {
|
|
813
|
+
try {
|
|
814
|
+
return await attempt(presId, backendURL);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
if (err?.code !== "pane_suspended") throw err;
|
|
817
|
+
const live = await waitForPaneReady(presId, {
|
|
818
|
+
backendURL,
|
|
819
|
+
onWait: paneWaitNotice,
|
|
820
|
+
});
|
|
821
|
+
if (!live) throw err;
|
|
822
|
+
return attempt(live.presId, live.backendURL ?? backendURL);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
416
826
|
// Brand-engine search through the local bridge. New add-ins run this
|
|
417
827
|
// server-side with the relayed user token; older add-ins may fall back to a
|
|
418
|
-
// task-pane proxy. Returns a searchId + numbered
|
|
419
|
-
|
|
828
|
+
// task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
|
|
829
|
+
// slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
|
|
830
|
+
// are passed through for slide engines (image engines ignore intent); omit them
|
|
831
|
+
// and the server uses its defaults (back-compat with older add-ins).
|
|
832
|
+
export async function engineSearch({
|
|
833
|
+
presId,
|
|
834
|
+
engineId,
|
|
835
|
+
query,
|
|
836
|
+
subtype = null,
|
|
837
|
+
intent = null,
|
|
838
|
+
top = null,
|
|
839
|
+
backendURL = null,
|
|
840
|
+
}) {
|
|
420
841
|
if (!presId) throw new Error("engineSearch: presId is required");
|
|
421
842
|
if (!engineId) throw new Error("engineSearch: engineId is required");
|
|
422
843
|
if (!query) throw new Error("engineSearch: query is required");
|
|
423
844
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
845
|
+
const body = { engineId, query };
|
|
846
|
+
if (subtype) body.subtype = subtype;
|
|
847
|
+
if (intent) body.intent = intent;
|
|
848
|
+
if (typeof top === "number") body.top = top;
|
|
849
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
850
|
+
const res = await bridgeFetch(
|
|
851
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
|
|
852
|
+
{
|
|
853
|
+
method: "POST",
|
|
854
|
+
headers: { "content-type": "application/json" },
|
|
855
|
+
body: JSON.stringify(body),
|
|
856
|
+
// Engine ops relay through the pane: the server may wait up to ~25s for
|
|
857
|
+
// a suspended pane to reconnect, then up to 60s for the pane's ack —
|
|
858
|
+
// stay above that sum so typed errors win over client timeouts.
|
|
859
|
+
timeoutMs:
|
|
860
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
861
|
+
90000,
|
|
862
|
+
},
|
|
863
|
+
b,
|
|
864
|
+
);
|
|
865
|
+
return res.json(); // { ok, searchId, engineId, subtype, intent, resultCount, gridDataUri?, results }
|
|
866
|
+
});
|
|
440
867
|
}
|
|
441
868
|
|
|
442
869
|
// Fetch a chosen engine result's bytes through the local bridge. For image
|
|
@@ -459,21 +886,134 @@ export async function engineDownload({
|
|
|
459
886
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
460
887
|
const body = { searchId, selection };
|
|
461
888
|
if (iconColor) body.iconColor = iconColor;
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
889
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
890
|
+
const res = await bridgeFetch(
|
|
891
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download`,
|
|
892
|
+
{
|
|
893
|
+
method: "POST",
|
|
894
|
+
headers: { "content-type": "application/json" },
|
|
895
|
+
body: JSON.stringify(body),
|
|
896
|
+
// Same relay path as engine/search — outlast the server's reconnect
|
|
897
|
+
// wait + pane ack timeout.
|
|
898
|
+
timeoutMs:
|
|
899
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
900
|
+
90000,
|
|
901
|
+
},
|
|
902
|
+
b,
|
|
903
|
+
);
|
|
904
|
+
return res.json(); // { ok, base64, mimeType, svgMarkup }
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
|
|
909
|
+
// WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
|
|
910
|
+
// the pane like search/download.
|
|
911
|
+
export async function extractSlide({
|
|
912
|
+
presId,
|
|
913
|
+
searchId,
|
|
914
|
+
selection,
|
|
915
|
+
backendURL = null,
|
|
916
|
+
}) {
|
|
917
|
+
if (!presId) throw new Error("extractSlide: presId is required");
|
|
918
|
+
if (typeof searchId !== "number") {
|
|
919
|
+
throw new Error("extractSlide: searchId (number) is required");
|
|
920
|
+
}
|
|
921
|
+
if (typeof selection !== "number") {
|
|
922
|
+
throw new Error("extractSlide: selection (number) is required");
|
|
923
|
+
}
|
|
924
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
925
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
926
|
+
const res = await bridgeFetch(
|
|
927
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract`,
|
|
928
|
+
{
|
|
929
|
+
method: "POST",
|
|
930
|
+
headers: { "content-type": "application/json" },
|
|
931
|
+
body: JSON.stringify({ searchId, selection }),
|
|
932
|
+
timeoutMs:
|
|
933
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
934
|
+
90000,
|
|
935
|
+
},
|
|
936
|
+
b,
|
|
937
|
+
);
|
|
938
|
+
return res.json(); // { ok, base64, slideId?, presentationTitle? }
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Insert one or more chosen slide-search results live into the open
|
|
943
|
+
// presentation (the "reuse → insert live" path). Relayed through the pane.
|
|
944
|
+
export async function insertSlideLive({
|
|
945
|
+
presId,
|
|
946
|
+
searchId,
|
|
947
|
+
selections,
|
|
948
|
+
at = null,
|
|
949
|
+
backendURL = null,
|
|
950
|
+
}) {
|
|
951
|
+
if (!presId) throw new Error("insertSlideLive: presId is required");
|
|
952
|
+
if (typeof searchId !== "number") {
|
|
953
|
+
throw new Error("insertSlideLive: searchId (number) is required");
|
|
954
|
+
}
|
|
955
|
+
if (!Array.isArray(selections) || selections.length === 0) {
|
|
956
|
+
throw new Error("insertSlideLive: selections (number[]) is required");
|
|
957
|
+
}
|
|
958
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
959
|
+
// Live insert is a MUTATION, so it carries an operationId for the add-in's
|
|
960
|
+
// runMutationOnce dedup (the slide-insert handler keys an LRU on it). Generate
|
|
961
|
+
// it ONCE here, before engineOpWithPaneWait — the body is built once and
|
|
962
|
+
// reused across the wrapper's single pane_suspended retry, so the SAME id
|
|
963
|
+
// rides both attempts and a retry can never double-insert. Mirrors how
|
|
964
|
+
// buildAndPush makes one push operationId reused across attempts.
|
|
965
|
+
const operationId = `insert-${randomUUID()}`;
|
|
966
|
+
const body = { searchId, selections, operationId };
|
|
967
|
+
if (typeof at === "number") body.at = at;
|
|
968
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
969
|
+
const res = await bridgeFetch(
|
|
970
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-insert`,
|
|
971
|
+
{
|
|
972
|
+
method: "POST",
|
|
973
|
+
headers: { "content-type": "application/json" },
|
|
974
|
+
body: JSON.stringify(body),
|
|
975
|
+
timeoutMs:
|
|
976
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
977
|
+
90000,
|
|
978
|
+
},
|
|
979
|
+
b,
|
|
980
|
+
);
|
|
981
|
+
return res.json(); // { ok, insertedCount, insertedSlideIds }
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Fetch a chosen slide-search result's thumbnail/preview as a data URI for
|
|
986
|
+
// on-demand viewing (the agent doesn't have to extract the whole .pptx just to
|
|
987
|
+
// look at a slide). Relayed through the pane.
|
|
988
|
+
export async function viewSlide({
|
|
989
|
+
presId,
|
|
990
|
+
searchId,
|
|
991
|
+
selection,
|
|
992
|
+
backendURL = null,
|
|
993
|
+
}) {
|
|
994
|
+
if (!presId) throw new Error("viewSlide: presId is required");
|
|
995
|
+
if (typeof searchId !== "number") {
|
|
996
|
+
throw new Error("viewSlide: searchId (number) is required");
|
|
997
|
+
}
|
|
998
|
+
if (typeof selection !== "number") {
|
|
999
|
+
throw new Error("viewSlide: selection (number) is required");
|
|
1000
|
+
}
|
|
1001
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1002
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1003
|
+
const res = await bridgeFetch(
|
|
1004
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
|
|
1005
|
+
{
|
|
1006
|
+
method: "POST",
|
|
1007
|
+
headers: { "content-type": "application/json" },
|
|
1008
|
+
body: JSON.stringify({ searchId, selection }),
|
|
1009
|
+
timeoutMs:
|
|
1010
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
1011
|
+
90000,
|
|
1012
|
+
},
|
|
1013
|
+
b,
|
|
1014
|
+
);
|
|
1015
|
+
return res.json(); // { ok, dataUri, mimeType }
|
|
1016
|
+
});
|
|
477
1017
|
}
|
|
478
1018
|
|
|
479
1019
|
export async function insertSlides({
|
|
@@ -532,7 +1072,13 @@ export async function replaceSlides({
|
|
|
532
1072
|
// `backendURL`/`env` after a fan-out list), or throws with a helpful message if
|
|
533
1073
|
// the choice is ambiguous. Callers that already hold the entry can pass its
|
|
534
1074
|
// `backendURL` straight to the targeted ops, skipping a second fan-out.
|
|
535
|
-
|
|
1075
|
+
// `preferBrandId` (the deck's bundle-ref presFP) tie-breaks a presId open on
|
|
1076
|
+
// several backends — see tieBreakMatches.
|
|
1077
|
+
export function pickPresentationEntry(
|
|
1078
|
+
registry,
|
|
1079
|
+
explicitPresId,
|
|
1080
|
+
{ preferBrandId = null } = {},
|
|
1081
|
+
) {
|
|
536
1082
|
if (explicitPresId) {
|
|
537
1083
|
const matches = registry.filter((p) => p.presId === explicitPresId);
|
|
538
1084
|
if (matches.length === 0) {
|
|
@@ -542,9 +1088,12 @@ export function pickPresentationEntry(registry, explicitPresId) {
|
|
|
542
1088
|
);
|
|
543
1089
|
}
|
|
544
1090
|
if (matches.length > 1) {
|
|
1091
|
+
const winner = tieBreakMatches(matches, preferBrandId);
|
|
1092
|
+
if (winner) return winner;
|
|
545
1093
|
const envs = matches.map((m) => m.env || "?").join(", ");
|
|
546
1094
|
throw new Error(
|
|
547
|
-
`Presentation '${explicitPresId}' is open on multiple environments: ${envs}
|
|
1095
|
+
`Presentation '${explicitPresId}' is open on multiple environments: ${envs}, ` +
|
|
1096
|
+
"and no single connected one has the deck's corporate design active. " +
|
|
548
1097
|
"Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
|
|
549
1098
|
);
|
|
550
1099
|
}
|
|
@@ -557,6 +1106,13 @@ export function pickPresentationEntry(registry, explicitPresId) {
|
|
|
557
1106
|
);
|
|
558
1107
|
}
|
|
559
1108
|
if (registry.length === 1) return registry[0];
|
|
1109
|
+
// ONE window mirrored across several paired backends is not a user choice —
|
|
1110
|
+
// apply the same tie-break as the explicit-presId path. Several DIFFERENT
|
|
1111
|
+
// windows stay a user decision.
|
|
1112
|
+
if (new Set(registry.map((p) => p.presId)).size === 1) {
|
|
1113
|
+
const winner = tieBreakMatches(registry, preferBrandId);
|
|
1114
|
+
if (winner) return winner;
|
|
1115
|
+
}
|
|
560
1116
|
const list = registry
|
|
561
1117
|
.map(
|
|
562
1118
|
(p) =>
|
|
@@ -569,6 +1125,31 @@ export function pickPresentationEntry(registry, explicitPresId) {
|
|
|
569
1125
|
);
|
|
570
1126
|
}
|
|
571
1127
|
|
|
1128
|
+
// Window-AGNOSTIC picker for engine ops (discovery / search / result view). The
|
|
1129
|
+
// engine catalog is per-user and NOT bound to any window, so any connected pane
|
|
1130
|
+
// can run the search — there is nothing for the caller to disambiguate. When no
|
|
1131
|
+
// explicit presId is given we attach to one automatically (preferring a live,
|
|
1132
|
+
// non-suspended pane so we don't have to wake a backgrounded one) instead of
|
|
1133
|
+
// throwing the "multiple windows, pick --to" error pickPresentationEntry raises.
|
|
1134
|
+
// An explicit presId still resolves exactly as pickPresentationEntry.
|
|
1135
|
+
//
|
|
1136
|
+
// CAVEAT: the searchId the add-in returns is bound to the pane that ran the
|
|
1137
|
+
// search (each window keeps its own search-context store). So follow-up
|
|
1138
|
+
// view/extract/insert must target the SAME entry this returns — surface its
|
|
1139
|
+
// presId to the caller so it can thread it back.
|
|
1140
|
+
export function pickAnyPresentationEntry(registry, explicitPresId, opts = {}) {
|
|
1141
|
+
if (explicitPresId) return pickPresentationEntry(registry, explicitPresId, opts);
|
|
1142
|
+
if (!Array.isArray(registry) || registry.length === 0) {
|
|
1143
|
+
throw new Error(
|
|
1144
|
+
"No PowerPoint windows registered with the slidemind add-in. " +
|
|
1145
|
+
"Open a presentation, ensure the add-in is loaded, and try again.",
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
return (
|
|
1149
|
+
registry.find((p) => p.connected !== false && !p.suspendedAt) ?? registry[0]
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
572
1153
|
// Back-compat shim: returns just the presId string. Prefer
|
|
573
1154
|
// pickPresentationEntry when you need the origin backend.
|
|
574
1155
|
export function pickPresentation(registry, explicitPresId) {
|