@contenthero/mcp 0.4.11 → 0.4.12
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/dist/format.d.ts +175 -26
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +277 -35
- package/dist/format.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +41 -11
- package/dist/server.js.map +1 -1
- package/dist/widget/generation.d.ts +2 -2
- package/dist/widget/generation.d.ts.map +1 -1
- package/dist/widget/generation.js +2 -2
- package/dist/widget/generation.js.map +1 -1
- package/package.json +3 -3
package/dist/format.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
// The spec's own spelling of the key that binds a result to its widget. See `server.ts` for why only the
|
|
7
7
|
// constants come from this package and not its server helpers.
|
|
8
|
+
import { aspectLabel } from '@contenthero-ai/brand-ui';
|
|
8
9
|
import { RESOURCE_URI_META_KEY } from '@modelcontextprotocol/ext-apps';
|
|
9
10
|
import { GENERATION_WIDGET_URI } from './widget-uri.js';
|
|
10
11
|
import { ContentHeroError, InsufficientCreditsError, RateLimitError } from '@contenthero/sdk';
|
|
@@ -72,42 +73,82 @@ export function text(body, isError = false) {
|
|
|
72
73
|
export const DEFAULT_APP_URL = 'https://app.contenthero.ai';
|
|
73
74
|
export function studioUrlFor(baseUrl, outputId, index, total) {
|
|
74
75
|
const root = baseUrl.replace(/\/+$/, '');
|
|
75
|
-
|
|
76
|
+
/**
|
|
77
|
+
* ⭐⭐⭐ **ONE-BASED, BECAUSE EVERY OTHER THING A PERSON SEES IS.**
|
|
78
|
+
*
|
|
79
|
+
* The reference they copy is `<id>-1`. The detail view says "Variation 1 of 4". A url saying
|
|
80
|
+
* `variation=0` for that same picture made three surfaces disagree, and the one that disagreed was the
|
|
81
|
+
* only one anybody would ever paste into a message or a bug report.
|
|
82
|
+
*
|
|
83
|
+
* ⛔ The studio's `imageIndex` is a ZERO-BASED slot and stays that way. Slot space is an internal fact
|
|
84
|
+
* about `task_outcomes` and `item_statuses`, not a number to show anyone. The app subtracts one when it
|
|
85
|
+
* reads this parameter, so the conversion sits at the boundary where the two vocabularies meet rather
|
|
86
|
+
* than leaking slot space into a shareable link.
|
|
87
|
+
*/
|
|
88
|
+
const variation = total > 1 ? `&variation=${index + 1}` : '';
|
|
76
89
|
return `${root}/studio?output=${encodeURIComponent(outputId)}${variation}`;
|
|
77
90
|
}
|
|
91
|
+
export function mediaWidgetData(input) {
|
|
92
|
+
return {
|
|
93
|
+
outputId: input.outputId ?? null,
|
|
94
|
+
contentType: input.contentType ?? null,
|
|
95
|
+
modelId: input.modelId ?? '',
|
|
96
|
+
/**
|
|
97
|
+
* ⛔⛔ **NULL WHEN THERE IS NOTHING TO NAME, AND THE WIDGET MUST RENDER NO CHIP.**
|
|
98
|
+
*
|
|
99
|
+
* This used to be `displayName ?? modelId`, fed by a catalog fetch with a `catch` that returned the id.
|
|
100
|
+
* The id reads like a label, so a failed fetch showed as a chip flickering between kebab case and title
|
|
101
|
+
* case rather than as a failure. The name now arrives on the row.
|
|
102
|
+
*/
|
|
103
|
+
modelName: input.modelDisplayName ?? null,
|
|
104
|
+
modelBrandColor: input.modelBrandColor ?? null,
|
|
105
|
+
modelIconKey: input.modelIconKey ?? null,
|
|
106
|
+
displayAspect: input.displayAspect ?? null,
|
|
107
|
+
/** Verbatim. Some prompts are JSON-shaped because the person authored one; that object IS the prompt. */
|
|
108
|
+
prompt: input.prompt ?? null,
|
|
109
|
+
items: input.items.map((it) => ({
|
|
110
|
+
url: it.url,
|
|
111
|
+
posterUrl: it.posterUrl ?? null,
|
|
112
|
+
name: it.name,
|
|
113
|
+
contentType: it.contentType,
|
|
114
|
+
displayAspect: it.displayAspect ?? null,
|
|
115
|
+
openUrl: it.openUrl ?? null,
|
|
116
|
+
reference: it.reference ?? null,
|
|
117
|
+
modelName: it.modelName ?? null,
|
|
118
|
+
modelBrandColor: it.modelBrandColor ?? null,
|
|
119
|
+
modelIconKey: it.modelIconKey ?? null,
|
|
120
|
+
})),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** A generation's widget payload. A thin adapter over {@link mediaWidgetData}, not a second builder. */
|
|
78
124
|
export function generationWidgetData(gen, posterUrls = [], baseUrl = DEFAULT_APP_URL) {
|
|
79
125
|
const urls = gen.outputUrls ?? [];
|
|
80
|
-
return {
|
|
126
|
+
return mediaWidgetData({
|
|
81
127
|
outputId: gen.outputId,
|
|
82
128
|
contentType: gen.contentType,
|
|
83
129
|
modelId: gen.modelId,
|
|
130
|
+
modelDisplayName: gen.modelDisplayName,
|
|
131
|
+
modelBrandColor: gen.modelBrandColor,
|
|
132
|
+
modelIconKey: gen.modelIconKey,
|
|
84
133
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* that returned the id. The id reads like a label, so a failed fetch showed as a chip flickering
|
|
89
|
-
* between kebab case and title case rather than as a failure. The name now arrives on the row.
|
|
90
|
-
*
|
|
91
|
-
* ⚠️ It is not always this generation's own model: an upload names itself, and a look names the model
|
|
92
|
-
* that produced the image it was assembled from.
|
|
134
|
+
* ⭐ SHARED, which is what makes a generation render as a ROW. Every variation of one generation has
|
|
135
|
+
* the same shape by construction, so the widget lays them out side by side for comparison rather than
|
|
136
|
+
* as a mixed grid.
|
|
93
137
|
*/
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/** Brand family key (e.g. `"openai"`) for choosing a glyph. NOT the model id. */
|
|
98
|
-
modelIconKey: gen.modelIconKey ?? null,
|
|
99
|
-
/** `"W:H"`, or null for audio. Drives the column count and the tile's own shape. */
|
|
100
|
-
displayAspect: gen.displayAspect ?? null,
|
|
101
|
-
/** Verbatim. Some prompts are JSON-shaped because the person authored one; that object IS the prompt. */
|
|
102
|
-
prompt: gen.prompt ?? null,
|
|
103
|
-
outputs: urls.map((url, i) => ({
|
|
138
|
+
displayAspect: gen.displayAspect,
|
|
139
|
+
prompt: gen.prompt,
|
|
140
|
+
items: urls.map((url, i) => ({
|
|
104
141
|
url,
|
|
105
142
|
posterUrl: posterUrls[i] ?? null,
|
|
106
143
|
name: `${gen.outputId}${urls.length > 1 ? `-${i + 1}` : ''}`,
|
|
107
|
-
|
|
108
|
-
|
|
144
|
+
contentType: gen.contentType,
|
|
145
|
+
displayAspect: gen.displayAspect ?? null,
|
|
146
|
+
// A generation lives in the studio. Another producer supplies its own destination.
|
|
147
|
+
openUrl: studioUrlFor(baseUrl, gen.outputId, i, urls.length),
|
|
148
|
+
// Every generation output is referenceable by id, which is what makes Animate and Edit meaningful.
|
|
149
|
+
reference: `${gen.outputId}${urls.length > 1 ? `-${i + 1}` : ''}`,
|
|
109
150
|
})),
|
|
110
|
-
};
|
|
151
|
+
});
|
|
111
152
|
}
|
|
112
153
|
export function completedResult(gen, attachments = [], posterUrls = [],
|
|
113
154
|
/** The server this client talks to, so Open deep-links to it rather than always to production. */
|
|
@@ -215,7 +256,13 @@ export function getStatusCall(outputIds) {
|
|
|
215
256
|
* case. The widget polls `get_generation_status`, and the name arrives with the first response.
|
|
216
257
|
*/
|
|
217
258
|
export function pendingResult(outputId, pollAfterSeconds = 15, shape) {
|
|
218
|
-
|
|
259
|
+
/**
|
|
260
|
+
* ⚠️ THE REASSURANCE MUST MATCH THE MEDIUM. This said "This is normal for video" on every pending result,
|
|
261
|
+
* including image jobs, where it reads as the server describing something other than what was asked for.
|
|
262
|
+
* `shape` is present precisely when we know which medium it is.
|
|
263
|
+
*/
|
|
264
|
+
const normal = shape?.contentType === 'image' ? '' : ' This is normal for video.';
|
|
265
|
+
const prose = `Still rendering (outputId ${outputId}).${normal} Call ${getStatusCall([outputId])} in ~${pollAfterSeconds}s [poll_after_seconds: ${pollAfterSeconds}] to get the final URLs.`;
|
|
219
266
|
if (!shape)
|
|
220
267
|
return text(prose);
|
|
221
268
|
return {
|
|
@@ -233,16 +280,48 @@ export function pendingResult(outputId, pollAfterSeconds = 15, shape) {
|
|
|
233
280
|
/** At least one, or the widget renders a grid with nothing in it and looks broken rather than busy. */
|
|
234
281
|
expected: Math.max(1, shape.expected ?? 1),
|
|
235
282
|
pollAfterSeconds,
|
|
236
|
-
|
|
283
|
+
items: [],
|
|
237
284
|
},
|
|
238
285
|
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
239
286
|
};
|
|
240
287
|
}
|
|
241
288
|
/** Synchronous audio result (already complete on submit). */
|
|
242
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Synchronous audio: already complete when the call returns.
|
|
291
|
+
*
|
|
292
|
+
* ## ⛔⛔⛔ THIS RENDERED NOTHING FOR THE ENTIRE LIFE OF THE WIDGET
|
|
293
|
+
*
|
|
294
|
+
* Audio has a first-class MCP block AND the widget plays it, and neither reached anyone, because this
|
|
295
|
+
* builder returned plain text and `generate_audio` was never added to the hand-maintained list of tools
|
|
296
|
+
* that declare a widget. Nobody decided audio should be invisible; it is what an allowlist does to
|
|
297
|
+
* anything nobody remembered to add.
|
|
298
|
+
*
|
|
299
|
+
* ⚠️ No model name, brand or aspect here: a synchronous result carries none of them, and inventing them
|
|
300
|
+
* would be worse than a chip that renders nothing. Audio has no shape, so `displayAspect` is genuinely
|
|
301
|
+
* null rather than unknown.
|
|
302
|
+
*/
|
|
303
|
+
export function audioResult(result, baseUrl = DEFAULT_APP_URL) {
|
|
243
304
|
const urls = result.outputUrls ?? [];
|
|
244
305
|
const header = `Done. Audio generated (outputId ${result.outputId}):`;
|
|
245
|
-
|
|
306
|
+
const prose = [header, ...urls.map((u, i) => `${i + 1}. ${u}`)].join('\n');
|
|
307
|
+
if (!urls.length)
|
|
308
|
+
return text(prose);
|
|
309
|
+
return {
|
|
310
|
+
content: [{ type: 'text', text: prose }],
|
|
311
|
+
structuredContent: mediaWidgetData({
|
|
312
|
+
outputId: result.outputId,
|
|
313
|
+
contentType: 'audio',
|
|
314
|
+
items: urls.map((url, i) => ({
|
|
315
|
+
url,
|
|
316
|
+
name: `${result.outputId}${urls.length > 1 ? `-${i + 1}` : ''}`,
|
|
317
|
+
contentType: 'audio',
|
|
318
|
+
// Audio has no shape, so there is nothing for a tile to take.
|
|
319
|
+
displayAspect: null,
|
|
320
|
+
openUrl: studioUrlFor(baseUrl, result.outputId, i, urls.length),
|
|
321
|
+
})),
|
|
322
|
+
}),
|
|
323
|
+
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
324
|
+
};
|
|
246
325
|
}
|
|
247
326
|
/**
|
|
248
327
|
* In-place clip enhancement: ONE JOB PER SOURCE, so the agent gets every outputId.
|
|
@@ -299,8 +378,21 @@ export function generationStatusResult(gen, attachments = [], baseUrl) {
|
|
|
299
378
|
* ⭐ Found by the `verify:inline` harness in its first run, minutes after it existed. The unit tests could
|
|
300
379
|
* not see it: they call `completedResult` directly and never go through here.
|
|
301
380
|
*/
|
|
302
|
-
if (gen.status === 'completed')
|
|
303
|
-
|
|
381
|
+
if (gen.status === 'completed') {
|
|
382
|
+
const res = completedResult(gen, attachments, [], baseUrl);
|
|
383
|
+
/**
|
|
384
|
+
* ⛔⛔ **A REPORT CARRIES NO DISPLAY PAYLOAD, AND STRIPPING IT IS NOT COSMETIC.**
|
|
385
|
+
*
|
|
386
|
+
* This tool does not declare the widget, because `generate_*` already returns one that polls itself to
|
|
387
|
+
* completion and a second card for the same generation is the duplicate we removed. A result that still
|
|
388
|
+
* carried `structuredContent` and `_meta` would be ignored by the host, but it would also make
|
|
389
|
+
* "emits widget data" and "declares the widget" disagree, and that equivalence is exactly what the
|
|
390
|
+
* completeness guard checks in both directions. An invariant with an exemption is a list again.
|
|
391
|
+
*/
|
|
392
|
+
delete res.structuredContent;
|
|
393
|
+
delete res._meta;
|
|
394
|
+
return res;
|
|
395
|
+
}
|
|
304
396
|
if (gen.status === 'failed') {
|
|
305
397
|
return text(`Generation ${gen.outputId} failed: ${gen.error ?? 'unknown error'}`, true);
|
|
306
398
|
}
|
|
@@ -632,7 +724,54 @@ function batchItemLine(it, index, hasImage) {
|
|
|
632
724
|
* array (fetched + base64-encoded by the caller, image items only; null for
|
|
633
725
|
* video/audio/errors). This just assembles the result. See get-context §9.5.
|
|
634
726
|
*/
|
|
635
|
-
|
|
727
|
+
/**
|
|
728
|
+
* Turn resolved media into tiles.
|
|
729
|
+
*
|
|
730
|
+
* ## ⭐⭐⭐ THE BYTES ARE FOR THE AGENT, THE URLS ARE FOR THE PERSON, AND ONE CALL DOES BOTH
|
|
731
|
+
*
|
|
732
|
+
* The image blocks above are the agent's vision and they cost context, which is why they run through one
|
|
733
|
+
* shared budget. The widget renders from URLS, which cost nothing. So a call can attach as many pixels as
|
|
734
|
+
* the budget allows AND display every item, and the two limits do not fight: more items means fewer
|
|
735
|
+
* inlined images, never a card that shows less than was asked for.
|
|
736
|
+
*
|
|
737
|
+
* ⛔ **NO CHIP, BECAUSE `model` HERE IS A RAW ID.** `gpt-image-2` reads like a label, and substituting one
|
|
738
|
+
* is the exact defect that made the generation chip flicker between kebab case and title case. Resolving
|
|
739
|
+
* it needs the registry, the same way the generation path got its name, so until this payload carries a
|
|
740
|
+
* display name the tiles render their media and no label.
|
|
741
|
+
*
|
|
742
|
+
* ⚠️ Transcripts are skipped: the widget has no element for text, and a tile that renders nothing is worse
|
|
743
|
+
* than an item the summary already describes in words.
|
|
744
|
+
*/
|
|
745
|
+
function mediaBatchItems(result, baseUrl) {
|
|
746
|
+
const items = [];
|
|
747
|
+
for (const it of result.items) {
|
|
748
|
+
if (!it.ok || !it.url)
|
|
749
|
+
continue;
|
|
750
|
+
if (it.type !== 'image' && it.type !== 'video' && it.type !== 'audio')
|
|
751
|
+
continue;
|
|
752
|
+
const g = it.geometry;
|
|
753
|
+
items.push({
|
|
754
|
+
url: it.url,
|
|
755
|
+
// A video's still, so a tile shows something before anyone presses play.
|
|
756
|
+
posterUrl: it.type === 'video' ? it.imageUrl : null,
|
|
757
|
+
/** The reference the API takes: `<id>` or `<id>-<n>`, one-based, matching what a person reads. */
|
|
758
|
+
name: it.mediaId ? `${it.mediaId}${it.variation && it.variation > 1 ? `-${it.variation}` : ''}` : it.url,
|
|
759
|
+
// ⚠️ Only a mediaId is referenceable. A raw url resolved here is not a library item the API can name.
|
|
760
|
+
reference: it.mediaId
|
|
761
|
+
? `${it.mediaId}${it.variation && it.variation > 1 ? `-${it.variation}` : ''}`
|
|
762
|
+
: undefined,
|
|
763
|
+
contentType: it.type,
|
|
764
|
+
// MEASURED, from the storage spine. Null when nothing measured it, which the tile handles.
|
|
765
|
+
displayAspect: g ? aspectLabel(g.width, g.height) : null,
|
|
766
|
+
openUrl: it.mediaId
|
|
767
|
+
? `${baseUrl.replace(/\/+$/, '')}/studio?output=${encodeURIComponent(it.mediaId)}` +
|
|
768
|
+
(it.variation && it.variation > 1 ? `&variation=${it.variation}` : '')
|
|
769
|
+
: undefined,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
return items;
|
|
773
|
+
}
|
|
774
|
+
export function mediaBatchResult(result, images, baseUrl = DEFAULT_APP_URL) {
|
|
636
775
|
const { items } = result;
|
|
637
776
|
const okCount = items.filter((i) => i.ok).length;
|
|
638
777
|
const keyframeCount = items.reduce((n, it) => n + (it.keyframes?.length ?? 0), 0);
|
|
@@ -658,7 +797,18 @@ export function mediaBatchResult(result, images) {
|
|
|
658
797
|
}
|
|
659
798
|
}
|
|
660
799
|
});
|
|
661
|
-
|
|
800
|
+
/**
|
|
801
|
+
* ⭐ DISPLAY COSTS NOTHING EXTRA. The blocks above are the agent's vision and are budget-bounded; this is
|
|
802
|
+
* urls, so every resolved item renders whether or not its pixels fit that budget.
|
|
803
|
+
*/
|
|
804
|
+
const tiles = mediaBatchItems(result, baseUrl);
|
|
805
|
+
if (tiles.length === 0)
|
|
806
|
+
return { content };
|
|
807
|
+
return {
|
|
808
|
+
content,
|
|
809
|
+
structuredContent: mediaWidgetData({ items: tiles }),
|
|
810
|
+
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
811
|
+
};
|
|
662
812
|
}
|
|
663
813
|
/** Phase 1 of an upload: the signed URL + the PUT-then-complete instructions. */
|
|
664
814
|
export function mediaUploadResult(r) {
|
|
@@ -679,8 +829,53 @@ export function mediaUploadResult(r) {
|
|
|
679
829
|
]));
|
|
680
830
|
}
|
|
681
831
|
/** A finalized upload or import: a first-class media output. */
|
|
682
|
-
|
|
683
|
-
|
|
832
|
+
/**
|
|
833
|
+
* Media the person's own bytes just became.
|
|
834
|
+
*
|
|
835
|
+
* ## ⭐ AN UPLOAD IS NEW MEDIA IN THEIR LIBRARY, SO IT DISPLAYS
|
|
836
|
+
*
|
|
837
|
+
* The rule is that a tool returning newly created or newly acquired media shows it, and an upload is the
|
|
838
|
+
* second. Confirmation is the value: a thumbnail says the right file landed, where a line of text says
|
|
839
|
+
* only that something did.
|
|
840
|
+
*
|
|
841
|
+
* ⛔ This could not render at all until the API started returning `contentType`. Guessing image from a
|
|
842
|
+
* url's extension is what renders a video as a broken image, so text was the honest answer while the type
|
|
843
|
+
* was unknown, and it remains the answer for a `document`, which has no element.
|
|
844
|
+
*
|
|
845
|
+
* ⚠️ Referenceable, unlike an export: `outputId` is exactly what `generate_*` accepts, which is what the
|
|
846
|
+
* prose has always told the caller.
|
|
847
|
+
*/
|
|
848
|
+
export function uploadedMediaResult(r, baseUrl = DEFAULT_APP_URL) {
|
|
849
|
+
const prose = `Media ready (id ${r.outputId}): ${r.url}. Reference it by outputId in generate_* or add_post_asset, or find it via list_media / get_media.`;
|
|
850
|
+
return renderableMedia(prose, r.outputId, r.url, r.contentType, baseUrl);
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* The shared tail of every "here is one new library item" result.
|
|
854
|
+
*
|
|
855
|
+
* ⚠️ ONE PLACE, because an upload and an import differ in their prose and in nothing else that matters
|
|
856
|
+
* here. Written twice they would drift the first time one of them learned something the other did not.
|
|
857
|
+
*/
|
|
858
|
+
function renderableMedia(prose, outputId, url, contentType, baseUrl) {
|
|
859
|
+
const medium = contentType === 'image' || contentType === 'video' || contentType === 'audio' ? contentType : undefined;
|
|
860
|
+
if (!outputId || !medium)
|
|
861
|
+
return text(prose);
|
|
862
|
+
return {
|
|
863
|
+
content: [{ type: 'text', text: prose }],
|
|
864
|
+
structuredContent: mediaWidgetData({
|
|
865
|
+
outputId,
|
|
866
|
+
contentType: medium,
|
|
867
|
+
items: [
|
|
868
|
+
{
|
|
869
|
+
url,
|
|
870
|
+
name: outputId,
|
|
871
|
+
contentType: medium,
|
|
872
|
+
reference: outputId,
|
|
873
|
+
openUrl: studioUrlFor(baseUrl, outputId, 0, 1),
|
|
874
|
+
},
|
|
875
|
+
],
|
|
876
|
+
}),
|
|
877
|
+
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
878
|
+
};
|
|
684
879
|
}
|
|
685
880
|
/**
|
|
686
881
|
* The result of an import, which may have created nothing.
|
|
@@ -699,9 +894,9 @@ export function uploadedMediaResult(r) {
|
|
|
699
894
|
* Saying "already imported" without naming what it is would send someone hunting for a library item that
|
|
700
895
|
* does not exist. That is the exact confusion this whole fix came from.
|
|
701
896
|
*/
|
|
702
|
-
export function importedMediaResult(r) {
|
|
897
|
+
export function importedMediaResult(r, baseUrl = DEFAULT_APP_URL) {
|
|
703
898
|
if (!r.alreadyExisted) {
|
|
704
|
-
return
|
|
899
|
+
return renderableMedia(`Media ready (id ${r.outputId}): ${r.url}. Reference it by outputId in generate_* or add_post_asset, or find it via list_media / get_media.`, r.outputId, r.url, r.contentType, baseUrl);
|
|
705
900
|
}
|
|
706
901
|
if (r.outputId) {
|
|
707
902
|
return text(`Already in your library (id ${r.outputId}): ${r.url}. Nothing was imported: these exact bytes are already there. Reference it by outputId as usual.`);
|
|
@@ -1465,6 +1660,53 @@ export function layerTypesResult(cat) {
|
|
|
1465
1660
|
JSON.stringify(cat, null, 2));
|
|
1466
1661
|
}
|
|
1467
1662
|
/** A completed export -> the download URL; an in-flight one -> the exportId to poll. */
|
|
1663
|
+
/**
|
|
1664
|
+
* Which formats the widget can actually draw.
|
|
1665
|
+
*
|
|
1666
|
+
* ⚠️ `pdf` and `pptx` have no element, and a multi-slide `png`/`jpg` export comes back as a ZIP rather than
|
|
1667
|
+
* an image. Rendering a tile for any of them would show a broken picture where the text already gives a
|
|
1668
|
+
* working download link, so they stay text.
|
|
1669
|
+
*/
|
|
1670
|
+
const EXPORT_MEDIUM = { mp4: 'video', png: 'image', jpg: 'image' };
|
|
1671
|
+
/**
|
|
1672
|
+
* An export, DISPLAYED. Separate from {@link exportJobResult} by name, not by a flag.
|
|
1673
|
+
*
|
|
1674
|
+
* ## ⛔⛔ ONLY THE TOOL THAT STARTED THE EXPORT KNOWS ITS FORMAT
|
|
1675
|
+
*
|
|
1676
|
+
* `get_export` polls by exportId alone, so it cannot know whether the file is an mp4 or a pptx and can
|
|
1677
|
+
* never render one. Leaving both paths inside one builder made the completeness guard read `get_export` as
|
|
1678
|
+
* a tool that emits a widget, which it does not, and an invariant that has to be argued with is not one.
|
|
1679
|
+
* Two names, each true on its own.
|
|
1680
|
+
*/
|
|
1681
|
+
export function completedExportResult(job, format, baseUrl = DEFAULT_APP_URL) {
|
|
1682
|
+
const prose = `Export ${job.exportId} completed.\nDownload: ${job.outputUrl}`;
|
|
1683
|
+
const medium = EXPORT_MEDIUM[format];
|
|
1684
|
+
if (job.status !== 'completed' || !job.outputUrl || !medium)
|
|
1685
|
+
return exportJobResult(job);
|
|
1686
|
+
{
|
|
1687
|
+
/**
|
|
1688
|
+
* ## ⛔⛔ AN EXPORT RENDERS, AND IT IS NOT REFERENCEABLE
|
|
1689
|
+
*
|
|
1690
|
+
* Someone waited for a render, so they should see it. But an `exportId` is not an `outputId`: no
|
|
1691
|
+
* generate tool resolves one, so Animate, Edit and Recreate would emit messages the agent cannot act
|
|
1692
|
+
* on. Omitting `reference` is what hides them, and Download, the verb that actually applies to a
|
|
1693
|
+
* rendered file, stays.
|
|
1694
|
+
*
|
|
1695
|
+
* ⚠️ NO `openUrl` EITHER, and not because it is hard to compute. An export's home is a download; the
|
|
1696
|
+
* project it came from is a different destination with a different meaning, and `/editor/{id}` versus
|
|
1697
|
+
* `/canvas/{id}` is not knowable from here anyway.
|
|
1698
|
+
*/
|
|
1699
|
+
return {
|
|
1700
|
+
content: [{ type: 'text', text: prose }],
|
|
1701
|
+
structuredContent: mediaWidgetData({
|
|
1702
|
+
contentType: medium,
|
|
1703
|
+
items: [{ url: job.outputUrl, name: job.exportId, contentType: medium }],
|
|
1704
|
+
}),
|
|
1705
|
+
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
/** An export, REPORTED. No widget: a poll does not know the format, so it cannot draw the file. */
|
|
1468
1710
|
export function exportJobResult(job) {
|
|
1469
1711
|
if (job.status === 'completed') {
|
|
1470
1712
|
return text(`Export ${job.exportId} completed.\nDownload: ${job.outputUrl}`);
|