slidemind 0.3.13 → 0.3.14
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-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +86 -21
- package/README.md +14 -17
- package/bin/slidemind.mjs +65 -4969
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +113 -74
- package/skills/search/SKILL.md +25 -7
- package/src/bridge/build-and-push.mjs +108 -15
- package/src/bridge/bundle.mjs +69 -0
- package/src/bridge/capture.mjs +19 -0
- package/src/bridge/client/brands.mjs +622 -0
- package/src/bridge/client/engine-routing.mjs +237 -0
- package/src/bridge/client/engine-search.mjs +310 -0
- package/src/bridge/client/engine-slides.mjs +435 -0
- package/src/bridge/client/pane-ops.mjs +335 -0
- package/src/bridge/client/presentations.mjs +587 -0
- package/src/bridge/client/render.mjs +317 -0
- package/src/bridge/client/transport.mjs +695 -0
- package/src/bridge/client.mjs +80 -3452
- package/src/bridge/config.mjs +1 -1
- package/src/bridge/engine-download.mjs +15 -2
- package/src/builders/build.mjs +11 -804
- package/src/builders/draft-check.mjs +376 -0
- package/src/builders/lint.mjs +38 -2
- package/src/builders/preview.html +799 -0
- package/src/builders/review.mjs +23 -4
- package/src/builders/visual-families.mjs +6 -8
- package/src/cli/common.mjs +179 -0
- package/src/cli/concepts.mjs +310 -0
- package/src/cli/connection.mjs +147 -0
- package/src/cli/deck.mjs +608 -0
- package/src/cli/designs.mjs +113 -0
- package/src/cli/diagnose.mjs +141 -0
- package/src/cli/download-bundle.mjs +144 -0
- package/src/cli/engine-helpers.mjs +429 -0
- package/src/cli/engine-results.mjs +320 -0
- package/src/cli/help.mjs +201 -0
- package/src/cli/install.mjs +257 -0
- package/src/cli/read.mjs +120 -0
- package/src/cli/ready.mjs +805 -0
- package/src/cli/search.mjs +608 -0
- package/src/cli/session.mjs +177 -0
- package/src/cli/setup.mjs +282 -0
- package/src/cli/slide-reuse.mjs +334 -0
- package/src/cli/status.mjs +118 -0
- package/src/cli/workspace.mjs +302 -0
- package/src/installers/codex.mjs +1 -1
- package/src/update-check.mjs +231 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Pane-free engine routes (the DIRECT path)
|
|
2
|
+
//
|
|
3
|
+
// Newer add-ins serve the whole engine surface off the CLI session:
|
|
4
|
+
// GET /api/cli/engines catalog (same path,
|
|
5
|
+
// answered pane-free)
|
|
6
|
+
// POST /api/cli/searches → { searchId, results }
|
|
7
|
+
// GET /api/cli/searches/:id/results/:sel/image stream
|
|
8
|
+
// GET /api/cli/searches/:id/results/:sel/thumbnail stream
|
|
9
|
+
// GET /api/cli/searches/:id/results/:sel/slide stream (one-slide .pptx)
|
|
10
|
+
// GET /api/cli/searches/:id/results/:sel/source-presentation
|
|
11
|
+
//
|
|
12
|
+
// The searchId is SERVER-owned (per user, short TTL) rather than bound to a
|
|
13
|
+
// window's pane, and a request carries only that searchId plus the selection
|
|
14
|
+
// index — the server resolves the source config it stored at search time, so no
|
|
15
|
+
// client ever names a file, site or drive.
|
|
16
|
+
//
|
|
17
|
+
// How a function picks its path: a `presId` means "relay through THAT window"
|
|
18
|
+
// (the compat path old add-ins need and live insert always needs); NO presId
|
|
19
|
+
// plus an explicit `backendURL`/`brandId` means "run this pane-free". Callers
|
|
20
|
+
// resolve the backend once with resolveEngineBackend(), which returns null when
|
|
21
|
+
// nothing paired advertises the routes — that null is the signal to fall back
|
|
22
|
+
// to the pane relay, never a failure.
|
|
23
|
+
|
|
24
|
+
import { deriveEnvLabel } from "../config.mjs";
|
|
25
|
+
import { getDesignBundleForBrand, getDesignBundleForBrandAnyBackend } from "./brands.mjs";
|
|
26
|
+
import { isSessionInvalidError, sessionsInScope, bridgeFetch, backendCapabilitiesResult, backendSupportsEngineRoutes } from "./transport.mjs";
|
|
27
|
+
|
|
28
|
+
export function searchRoute(searchId, suffix = "") {
|
|
29
|
+
return `/api/cli/searches/${encodeURIComponent(String(searchId))}${suffix}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A searchId is a number on the pane relay (the pane mints it per window) and an
|
|
33
|
+
// opaque server-minted id on the direct routes — accept either, reject anything
|
|
34
|
+
// else before a call goes near the network.
|
|
35
|
+
export function assertSearchId(fn, searchId) {
|
|
36
|
+
const ok =
|
|
37
|
+
(typeof searchId === "number" && Number.isFinite(searchId)) ||
|
|
38
|
+
(typeof searchId === "string" && searchId.trim().length > 0);
|
|
39
|
+
if (!ok) throw new Error(`${fn}: searchId is required`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A large asset legitimately takes longer than an engine op: the server is
|
|
43
|
+
// fetching from SharePoint/blob AND piping. Separate budget so raising it
|
|
44
|
+
// doesn't loosen every other bridge call.
|
|
45
|
+
export function downloadTimeoutMs() {
|
|
46
|
+
return (
|
|
47
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000", 10) ||
|
|
48
|
+
600000
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resultRoute(searchId, selection, leaf, params = null) {
|
|
53
|
+
const qs = params && [...params.keys()].length > 0 ? `?${params}` : "";
|
|
54
|
+
return `${searchRoute(searchId, `/results/${encodeURIComponent(String(selection))}/${leaf}`)}${qs}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Per-process memo of which backend serves a brand's engine routes, so a
|
|
58
|
+
// multi-environment session pays the brand probe at most once per design.
|
|
59
|
+
export const engineBackendForBrand = new Map();
|
|
60
|
+
|
|
61
|
+
// A searchId is opaque (server-minted, hex) on the direct routes and numeric on
|
|
62
|
+
// the pane relay. The distinction routes: an opaque id names a record on the
|
|
63
|
+
// backend that minted it, and the pane can NEVER serve it — its handler only
|
|
64
|
+
// understands its own per-window numeric ids.
|
|
65
|
+
export function isOpaqueSearchId(searchId) {
|
|
66
|
+
return (
|
|
67
|
+
typeof searchId === "string" &&
|
|
68
|
+
searchId.trim() !== "" &&
|
|
69
|
+
!/^\d+$/.test(searchId.trim())
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Which paired backend minted this server-owned search record. Search records
|
|
74
|
+
// are oid-scoped, so a foreign environment answers the same 404 as an expired
|
|
75
|
+
// id — probing every engine-routes candidate with the record's own GET is both
|
|
76
|
+
// exact and cheap (2h TTL, at most a couple of environments). Returns null when
|
|
77
|
+
// no candidate holds it: expired, or minted by an environment no longer in
|
|
78
|
+
// scope. Never falls back to the pane — see isOpaqueSearchId.
|
|
79
|
+
//
|
|
80
|
+
// One failure class is NOT routing information: a 401 means the session itself
|
|
81
|
+
// is dead (revoked in the add-in's Connect CLI panel, or aged past its hard
|
|
82
|
+
// lifetime). Swallowing it reports a revoked session as "that search expired",
|
|
83
|
+
// and the user then re-runs a search that 401s the same way. So per-candidate
|
|
84
|
+
// failures are collected and a session-invalid one is rethrown instead.
|
|
85
|
+
export async function resolveSearchBackend(searchId) {
|
|
86
|
+
if (!isOpaqueSearchId(searchId)) return null;
|
|
87
|
+
const failures = [];
|
|
88
|
+
for (const s of sessionsInScope()) {
|
|
89
|
+
// A revoked session 401s the capability probe too, and that answer is
|
|
90
|
+
// indistinguishable from "old add-in" unless the failure is kept.
|
|
91
|
+
const probe = await backendCapabilitiesResult(s.backendURL);
|
|
92
|
+
if (probe.error && isSessionInvalidError(probe.error)) {
|
|
93
|
+
failures.push(probe.error);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (probe.caps?.engineRoutes !== true) continue;
|
|
97
|
+
try {
|
|
98
|
+
await bridgeFetch(searchRoute(searchId), {}, s.backendURL);
|
|
99
|
+
return s.backendURL;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// 404 (not this environment's record / expired) or unreachable — try the
|
|
102
|
+
// next candidate; the distinction doesn't matter to routing.
|
|
103
|
+
failures.push(err);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// The two failures that are NOT the record talking: a dead session, and a
|
|
107
|
+
// missing Microsoft 365 sign-in (the record re-read is an engine route, so
|
|
108
|
+
// it refuses with the rest of corpus search). Reported as "that search
|
|
109
|
+
// expired", both send the user to re-run a search that fails the same way —
|
|
110
|
+
// so they outrank the expired-record answer.
|
|
111
|
+
const blocking = failures.find(
|
|
112
|
+
(e) => isSessionInvalidError(e) || e?.code === "login_required",
|
|
113
|
+
);
|
|
114
|
+
if (blocking) throw blocking;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Which paired backend runs the pane-free engine routes for this call. An
|
|
119
|
+
// explicit backendURL is honoured as-is (the caller already resolved a window or
|
|
120
|
+
// a deck). Otherwise: every session in scope that advertises the routes is a
|
|
121
|
+
// candidate — with ONE connected environment (the normal case) that IS the
|
|
122
|
+
// answer, and no probe is worth paying for it. With several connected, the
|
|
123
|
+
// DECK's bound design decides, never an open window's origin. presFP/brandId is
|
|
124
|
+
// content-derived, so the SAME design onboarded in two environments carries the
|
|
125
|
+
// SAME id — candidates are therefore probed FIRST, and only when none serves
|
|
126
|
+
// the design does the caller fall back to the pane relay (the design lives on
|
|
127
|
+
// an older add-in, and searching a different corpus would fold ITS slides into
|
|
128
|
+
// this deck). Returns null for that fallback and when nothing paired serves the
|
|
129
|
+
// routes at all.
|
|
130
|
+
export async function resolveEngineBackend({ backendURL = null, brandId = null } = {}) {
|
|
131
|
+
if (backendURL) {
|
|
132
|
+
return (await backendSupportsEngineRoutes(backendURL)) ? backendURL : null;
|
|
133
|
+
}
|
|
134
|
+
const sessions = sessionsInScope();
|
|
135
|
+
const candidates = [];
|
|
136
|
+
for (const s of sessions) {
|
|
137
|
+
if (await backendSupportsEngineRoutes(s.backendURL)) candidates.push(s.backendURL);
|
|
138
|
+
}
|
|
139
|
+
if (candidates.length === 0) return null;
|
|
140
|
+
// Exactly one CONNECTED environment: there is nothing else the deck could
|
|
141
|
+
// belong to. Note the test is on the sessions, not on the candidates — during
|
|
142
|
+
// a rollout only one of several environments serves the routes, and taking it
|
|
143
|
+
// regardless would have searched the wrong corpus.
|
|
144
|
+
if (sessions.length === 1) return candidates[0];
|
|
145
|
+
if (brandId) {
|
|
146
|
+
// The memo only stands while that environment is still a candidate.
|
|
147
|
+
const memo = engineBackendForBrand.get(brandId);
|
|
148
|
+
if (memo) return candidates.includes(memo) ? memo : null;
|
|
149
|
+
// Candidates first: an environment that serves both the design AND the
|
|
150
|
+
// engine routes wins outright, even when an older environment also has the
|
|
151
|
+
// (content-identical) design.
|
|
152
|
+
for (const be of candidates) {
|
|
153
|
+
try {
|
|
154
|
+
await getDesignBundleForBrand(brandId, { backendURL: be });
|
|
155
|
+
engineBackendForBrand.set(brandId, be);
|
|
156
|
+
return be;
|
|
157
|
+
} catch {
|
|
158
|
+
// This candidate doesn't serve the design — try the next.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const { backendURL: be } = await getDesignBundleForBrandAnyBackend(brandId);
|
|
163
|
+
engineBackendForBrand.set(brandId, be);
|
|
164
|
+
// Only a non-candidate can answer here: the design's environment is an
|
|
165
|
+
// older add-in with no engine routes. null sends the caller to that
|
|
166
|
+
// environment's pane relay instead of a different corpus.
|
|
167
|
+
return null;
|
|
168
|
+
} catch {
|
|
169
|
+
// No backend owns the design (unknown brand, old add-in) — fall through
|
|
170
|
+
// rather than guessing an environment.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (candidates.length === 1) return candidates[0];
|
|
174
|
+
const err = new Error(
|
|
175
|
+
`Several connected environments run engine search (${candidates
|
|
176
|
+
.map((u) => deriveEnvLabel(u))
|
|
177
|
+
.join(", ")}). Scope the command with --backend <env>, point it at a ` +
|
|
178
|
+
"bound deck (--deck) so the deck's design picks one, or name an open " +
|
|
179
|
+
"window with --to <presId> to relay through it.",
|
|
180
|
+
);
|
|
181
|
+
err.code = "engine_backend_ambiguous";
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Which connected environments answer the pane-free engine routes — the
|
|
186
|
+
// "backend authoring ready" half of the readiness picture (`doctor`, `ready`),
|
|
187
|
+
// reported as ENV LABELS so no URL can reach a printed line. Pass
|
|
188
|
+
// connectionStatus()'s `backends` to skip environments that are already known
|
|
189
|
+
// unreachable; without it every session in scope is probed.
|
|
190
|
+
export async function engineReachability({ backends = null } = {}) {
|
|
191
|
+
const urls = Array.isArray(backends)
|
|
192
|
+
? backends.filter((b) => b?.ok && b.backendURL).map((b) => b.backendURL)
|
|
193
|
+
: sessionsInScope().map((s) => s.backendURL);
|
|
194
|
+
const envs = [];
|
|
195
|
+
for (const url of urls) {
|
|
196
|
+
if (await backendSupportsEngineRoutes(url)) envs.push(deriveEnvLabel(url));
|
|
197
|
+
}
|
|
198
|
+
return { available: envs.length > 0, envs };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Resolve + verify the backend for a direct engine call. Throws a typed
|
|
202
|
+
// `engine_routes_unavailable` when the caller asked for the pane-free path on a
|
|
203
|
+
// backend that doesn't serve it — a programming error by then, since callers go
|
|
204
|
+
// through resolveEngineBackend first, but it must never surface as a bare 404.
|
|
205
|
+
export async function directEngineBackend(fn, { backendURL = null, brandId = null }) {
|
|
206
|
+
const be = backendURL ?? (await resolveEngineBackend({ brandId }));
|
|
207
|
+
if (be && (await backendSupportsEngineRoutes(be))) return be;
|
|
208
|
+
const err = new Error(
|
|
209
|
+
`${fn}: no connected slidemind add-in serves pane-free engine routes` +
|
|
210
|
+
`${be ? ` ('${deriveEnvLabel(be)}' is too old)` : ""}. ` +
|
|
211
|
+
"Open PowerPoint with the slidemind pane so the search can be relayed, or update the add-in.",
|
|
212
|
+
);
|
|
213
|
+
err.code = "engine_routes_unavailable";
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Corpus search runs as the USER: the pane-free engine routes resolve the
|
|
218
|
+
// caller's group membership live from the delegated tokens `slidemind login`
|
|
219
|
+
// minted, and answer `401 { code: "login_required", action: "…" }` without
|
|
220
|
+
// them. Shape it like the connection ladder's rungs — `{ code, actor, message }`
|
|
221
|
+
// — so the CLI can print ONE actionable line and an agent knows it cannot clear
|
|
222
|
+
// this one itself (the sign-in needs a browser and a human). Returns null for
|
|
223
|
+
// any other error.
|
|
224
|
+
export function loginRequiredBlocker(err) {
|
|
225
|
+
if (err?.code !== "login_required") return null;
|
|
226
|
+
const action =
|
|
227
|
+
typeof err?.body?.action === "string" && err.body.action.trim()
|
|
228
|
+
? err.body.action.trim()
|
|
229
|
+
: "run `slidemind login` to sign in to Microsoft 365";
|
|
230
|
+
return {
|
|
231
|
+
code: "login_required",
|
|
232
|
+
actor: "user",
|
|
233
|
+
message:
|
|
234
|
+
`slidemind needs your Microsoft 365 sign-in before it can search the corpus — ${action}. ` +
|
|
235
|
+
"Everything else (render, bundle, imagery already downloaded) keeps working.",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Engine search + result download (pane-free direct path, pane relay fallback).
|
|
2
|
+
|
|
3
|
+
import { deriveEnvLabel } from "../config.mjs";
|
|
4
|
+
import { assertSearchId, downloadTimeoutMs, resultRoute, directEngineBackend } from "./engine-routing.mjs";
|
|
5
|
+
import { pickAnyPresentationEntry } from "./pane-ops.mjs";
|
|
6
|
+
import { resolveBackendForPres, engineOpWithPaneWait } from "./presentations.mjs";
|
|
7
|
+
import { engineTimeoutMs, bridgeFetch, bridgeStreamToFile, envInt, delegatedBodyFields, graphHeaders } from "./transport.mjs";
|
|
8
|
+
|
|
9
|
+
// Brand-engine search through the local bridge. New add-ins run this
|
|
10
|
+
// server-side with the relayed user token; older add-ins may fall back to a
|
|
11
|
+
// task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
|
|
12
|
+
// slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
|
|
13
|
+
// are passed through for slide engines (image engines ignore intent); omit them
|
|
14
|
+
// and the server uses its defaults (back-compat with older add-ins).
|
|
15
|
+
//
|
|
16
|
+
// `engineId` may also be a SCOPE sentinel (`scope:all` / `scope:sharepoint`) —
|
|
17
|
+
// an unfiltered search over the whole corpus rather than one engine's saved
|
|
18
|
+
// folder filter. A scope has no subtype to derive its index from, so it needs an
|
|
19
|
+
// explicit `resultType`; the add-in rejects a scope search without one.
|
|
20
|
+
//
|
|
21
|
+
// With NO presId and a resolved `backendURL`/`brandId` this runs pane-free
|
|
22
|
+
// against `POST /api/cli/searches` (same body) and tags the answer with the
|
|
23
|
+
// backend that owns the searchId.
|
|
24
|
+
export async function engineSearch({
|
|
25
|
+
presId = null,
|
|
26
|
+
engineId,
|
|
27
|
+
query,
|
|
28
|
+
subtype = null,
|
|
29
|
+
resultType = null,
|
|
30
|
+
intent = null,
|
|
31
|
+
top = null,
|
|
32
|
+
backendURL = null,
|
|
33
|
+
brandId = null,
|
|
34
|
+
// When the chosen pane is suspended, wait CLIENT-side for THIS window to wake
|
|
35
|
+
// (the single-window default). engineSearchAnyPane sets this false for all but
|
|
36
|
+
// the last candidate so it can fail over to another live window instead.
|
|
37
|
+
waitOnSuspend = true,
|
|
38
|
+
timeoutMs = null,
|
|
39
|
+
}) {
|
|
40
|
+
if (!engineId) throw new Error("engineSearch: engineId is required");
|
|
41
|
+
if (!query) throw new Error("engineSearch: query is required");
|
|
42
|
+
const direct = !presId && Boolean(backendURL || brandId);
|
|
43
|
+
if (!presId && !direct) throw new Error("engineSearch: presId is required");
|
|
44
|
+
const body = { engineId, query };
|
|
45
|
+
if (subtype) body.subtype = subtype;
|
|
46
|
+
// Additive: omitted unless set, so the body an older add-in sees is unchanged.
|
|
47
|
+
if (resultType) body.resultType = resultType;
|
|
48
|
+
if (intent) body.intent = intent;
|
|
49
|
+
if (typeof top === "number") body.top = top;
|
|
50
|
+
if (direct) {
|
|
51
|
+
const be = await directEngineBackend("engineSearch", { backendURL, brandId });
|
|
52
|
+
// The user's own identity: the backend resolves the caller's group
|
|
53
|
+
// membership from these tokens per request and refuses without them
|
|
54
|
+
// (login_required). Search also seeds the speculative insert cache
|
|
55
|
+
// server-side, which is why it carries the Graph token as well as the
|
|
56
|
+
// byte GETs.
|
|
57
|
+
const delegated = await delegatedBodyFields(be);
|
|
58
|
+
const res = await bridgeFetch(
|
|
59
|
+
"/api/cli/searches",
|
|
60
|
+
{
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "content-type": "application/json" },
|
|
63
|
+
body: JSON.stringify({ ...body, ...delegated }),
|
|
64
|
+
timeoutMs: timeoutMs ?? engineTimeoutMs(),
|
|
65
|
+
},
|
|
66
|
+
be,
|
|
67
|
+
);
|
|
68
|
+
const data = (await res.json()) ?? {};
|
|
69
|
+
// Tag the routing state (like the brand routes do) so every follow-up pick
|
|
70
|
+
// hits the backend that owns the searchId. `env` is the printable half —
|
|
71
|
+
// callers must never surface backendURL.
|
|
72
|
+
return { ...data, backendURL: be, env: deriveEnvLabel(be) };
|
|
73
|
+
}
|
|
74
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
75
|
+
const run = async (pid, b) => {
|
|
76
|
+
const res = await bridgeFetch(
|
|
77
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
|
|
78
|
+
{
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json" },
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
timeoutMs: timeoutMs ?? engineTimeoutMs(),
|
|
83
|
+
},
|
|
84
|
+
b,
|
|
85
|
+
);
|
|
86
|
+
return res.json(); // { ok, searchId, engineId, subtype, intent, resultCount, gridDataUri?, results }
|
|
87
|
+
};
|
|
88
|
+
return waitOnSuspend ? engineOpWithPaneWait(presId, be, run) : run(presId, be);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A suspended/throttled background webview must not strand a window-AGNOSTIC
|
|
92
|
+
// engine search: the catalog is per-user, so any connected pane can answer it.
|
|
93
|
+
// Try the picked pane first, then every other connected pane on the same
|
|
94
|
+
// backend (non-suspended first), failing fast past each unresponsive one; the
|
|
95
|
+
// LAST candidate keeps the normal "click the pane to wake it" wait so a genuine
|
|
96
|
+
// single-window/all-asleep case still surfaces the actionable prompt. Returns
|
|
97
|
+
// the search result tagged with the presId/backendURL that actually answered —
|
|
98
|
+
// the searchId is bound to THAT window, so callers must thread it into
|
|
99
|
+
// follow-up view/insert/add-slide. An explicit presId pins the window (no
|
|
100
|
+
// failover — the caller asked for that specific one).
|
|
101
|
+
export async function engineSearchAnyPane({
|
|
102
|
+
registry,
|
|
103
|
+
explicitPresId = null,
|
|
104
|
+
engineId,
|
|
105
|
+
query,
|
|
106
|
+
subtype = null,
|
|
107
|
+
resultType = null,
|
|
108
|
+
intent = null,
|
|
109
|
+
top = null,
|
|
110
|
+
}) {
|
|
111
|
+
const primary = pickAnyPresentationEntry(registry, explicitPresId);
|
|
112
|
+
const primaryBackend = primary.backendURL ?? null;
|
|
113
|
+
let candidates;
|
|
114
|
+
if (explicitPresId) {
|
|
115
|
+
candidates = [primary];
|
|
116
|
+
} else {
|
|
117
|
+
const others = (Array.isArray(registry) ? registry : [])
|
|
118
|
+
.filter(
|
|
119
|
+
(p) =>
|
|
120
|
+
p.presId !== primary.presId &&
|
|
121
|
+
p.connected !== false &&
|
|
122
|
+
(p.backendURL ?? null) === primaryBackend,
|
|
123
|
+
)
|
|
124
|
+
.sort((a, b) => (a.suspendedAt ? 1 : 0) - (b.suspendedAt ? 1 : 0));
|
|
125
|
+
candidates = [primary, ...others];
|
|
126
|
+
}
|
|
127
|
+
const failoverTimeout = envInt(
|
|
128
|
+
"SLIDEMIND_BRIDGE_ENGINE_FAILOVER_TIMEOUT_MS",
|
|
129
|
+
25000,
|
|
130
|
+
);
|
|
131
|
+
let lastErr = null;
|
|
132
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
133
|
+
const cand = candidates[i];
|
|
134
|
+
const isLast = i === candidates.length - 1;
|
|
135
|
+
try {
|
|
136
|
+
const res = await engineSearch({
|
|
137
|
+
presId: cand.presId,
|
|
138
|
+
engineId,
|
|
139
|
+
query,
|
|
140
|
+
subtype,
|
|
141
|
+
resultType,
|
|
142
|
+
intent,
|
|
143
|
+
top,
|
|
144
|
+
backendURL: cand.backendURL ?? primaryBackend,
|
|
145
|
+
waitOnSuspend: isLast,
|
|
146
|
+
timeoutMs: isLast ? null : failoverTimeout,
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
...res,
|
|
150
|
+
presId: cand.presId,
|
|
151
|
+
backendURL: cand.backendURL ?? primaryBackend,
|
|
152
|
+
};
|
|
153
|
+
} catch (err) {
|
|
154
|
+
lastErr = err;
|
|
155
|
+
// A suspended/frozen/slow pane is recoverable by trying the next window;
|
|
156
|
+
// anything else (bad engineId, auth, 4xx) is real — surface it now.
|
|
157
|
+
const recoverable =
|
|
158
|
+
err?.code === "pane_suspended" ||
|
|
159
|
+
err?.code === "pane_timeout" ||
|
|
160
|
+
err?.code === "bridge_timeout" ||
|
|
161
|
+
err?.status === 503 ||
|
|
162
|
+
err?.status === 504;
|
|
163
|
+
if (isLast || !recoverable) throw err;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
throw lastErr ?? new Error("engineSearchAnyPane: no candidate panes");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fetch a chosen engine result's bytes through the local bridge. For image
|
|
170
|
+
// engines this returns SVG/PNG bytes (+ optional recoloured svgMarkup) WITHOUT
|
|
171
|
+
// inserting onto a slide.
|
|
172
|
+
//
|
|
173
|
+
// Pane-only by design: the pane-free route table streams (see
|
|
174
|
+
// engineDownloadToFile), and this base64-through-the-ack path only exists for
|
|
175
|
+
// add-ins that predate streaming — which by definition predate the direct
|
|
176
|
+
// routes too.
|
|
177
|
+
export async function engineDownload({
|
|
178
|
+
presId,
|
|
179
|
+
searchId,
|
|
180
|
+
selection,
|
|
181
|
+
iconColor = null,
|
|
182
|
+
backendURL = null,
|
|
183
|
+
}) {
|
|
184
|
+
if (!presId) throw new Error("engineDownload: presId is required");
|
|
185
|
+
assertSearchId("engineDownload", searchId);
|
|
186
|
+
if (typeof selection !== "number") {
|
|
187
|
+
throw new Error("engineDownload: selection (number) is required");
|
|
188
|
+
}
|
|
189
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
190
|
+
const body = { searchId, selection };
|
|
191
|
+
if (iconColor) body.iconColor = iconColor;
|
|
192
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
193
|
+
const res = await bridgeFetch(
|
|
194
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download`,
|
|
195
|
+
{
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: { "content-type": "application/json" },
|
|
198
|
+
body: JSON.stringify(body),
|
|
199
|
+
// Same relay path as engine/search — outlast the server's reconnect
|
|
200
|
+
// wait + pane ack timeout.
|
|
201
|
+
timeoutMs:
|
|
202
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
203
|
+
90000,
|
|
204
|
+
},
|
|
205
|
+
b,
|
|
206
|
+
);
|
|
207
|
+
return res.json(); // { ok, base64, mimeType, svgMarkup }
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Stream a chosen engine result's bytes to `destPath`. The size-unbounded
|
|
212
|
+
// download: the pane's Socket.IO ack carries only a signed source reference, and
|
|
213
|
+
// the add-in server fetches it and pipes the body here — so nothing buffers the
|
|
214
|
+
// whole image and there is no base64 4/3 inflation. `engineDownload` above sends
|
|
215
|
+
// the bytes through the pane ack instead and is therefore capped by the relay's
|
|
216
|
+
// 10 MB frame limit; prefer this for anything that might be large.
|
|
217
|
+
//
|
|
218
|
+
// An SVG result comes back as JSON (`{ ok, mimeType, svgMarkup }`) rather than a
|
|
219
|
+
// byte stream: it is kilobytes and its recolour happens pane-side. Callers get
|
|
220
|
+
// `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
|
|
221
|
+
// can persist whichever arrived.
|
|
222
|
+
//
|
|
223
|
+
// With NO presId and a resolved `backendURL`/`brandId` this streams pane-free
|
|
224
|
+
// from `GET /api/cli/searches/:searchId/results/:selection/image` — same answer
|
|
225
|
+
// shape, no window involved.
|
|
226
|
+
export async function engineDownloadToFile({
|
|
227
|
+
presId = null,
|
|
228
|
+
searchId,
|
|
229
|
+
selection,
|
|
230
|
+
destPath,
|
|
231
|
+
iconColor = null,
|
|
232
|
+
backendURL = null,
|
|
233
|
+
brandId = null,
|
|
234
|
+
onProgress = null,
|
|
235
|
+
}) {
|
|
236
|
+
const direct = !presId && Boolean(backendURL || brandId);
|
|
237
|
+
if (!presId && !direct) {
|
|
238
|
+
throw new Error("engineDownloadToFile: presId is required");
|
|
239
|
+
}
|
|
240
|
+
assertSearchId("engineDownloadToFile", searchId);
|
|
241
|
+
if (typeof selection !== "number") {
|
|
242
|
+
throw new Error("engineDownloadToFile: selection (number) is required");
|
|
243
|
+
}
|
|
244
|
+
if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
|
|
245
|
+
// Both paths answer with the same two shapes (streamed bytes, or the inline
|
|
246
|
+
// SVG as JSON), so the response shaping is authored once.
|
|
247
|
+
const shape = (res) => {
|
|
248
|
+
if (!res.streamed) {
|
|
249
|
+
// The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
|
|
250
|
+
const parsed = res.body ? JSON.parse(res.body) : {};
|
|
251
|
+
return { ok: true, streamed: false, ...parsed };
|
|
252
|
+
}
|
|
253
|
+
const warningsHeader = res.headers?.["x-slidemind-warnings"];
|
|
254
|
+
let warnings;
|
|
255
|
+
if (warningsHeader) {
|
|
256
|
+
try {
|
|
257
|
+
warnings = JSON.parse(warningsHeader);
|
|
258
|
+
} catch {
|
|
259
|
+
// A malformed header must not fail a download that otherwise succeeded.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
streamed: true,
|
|
265
|
+
mimeType: String(res.headers?.["content-type"] ?? "") || null,
|
|
266
|
+
bytesWritten: res.bytesWritten,
|
|
267
|
+
...(warnings ? { warnings } : {}),
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
if (direct) {
|
|
271
|
+
const b = await directEngineBackend("engineDownloadToFile", {
|
|
272
|
+
backendURL,
|
|
273
|
+
brandId,
|
|
274
|
+
});
|
|
275
|
+
// Only the recolour rides the query string — the RESOURCE is never
|
|
276
|
+
// client-supplied on this path; the server resolves it from searchId+selection.
|
|
277
|
+
const params = new URLSearchParams();
|
|
278
|
+
if (iconColor) params.set("iconColor", iconColor);
|
|
279
|
+
const res = await bridgeStreamToFile(
|
|
280
|
+
resultRoute(searchId, selection, "image", params),
|
|
281
|
+
{
|
|
282
|
+
method: "GET",
|
|
283
|
+
headers: await graphHeaders(b),
|
|
284
|
+
timeoutMs: downloadTimeoutMs(),
|
|
285
|
+
onProgress,
|
|
286
|
+
},
|
|
287
|
+
destPath,
|
|
288
|
+
b,
|
|
289
|
+
);
|
|
290
|
+
return shape(res);
|
|
291
|
+
}
|
|
292
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
293
|
+
const body = { searchId, selection };
|
|
294
|
+
if (iconColor) body.iconColor = iconColor;
|
|
295
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
296
|
+
const res = await bridgeStreamToFile(
|
|
297
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
|
|
298
|
+
{
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers: { "content-type": "application/json" },
|
|
301
|
+
body: JSON.stringify(body),
|
|
302
|
+
timeoutMs: downloadTimeoutMs(),
|
|
303
|
+
onProgress,
|
|
304
|
+
},
|
|
305
|
+
destPath,
|
|
306
|
+
b,
|
|
307
|
+
);
|
|
308
|
+
return shape(res);
|
|
309
|
+
});
|
|
310
|
+
}
|