@productmaker/mcp 3.1.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/http.js +46 -13
- package/dist/stdio.js +46 -13
- package/dist/ui/meta-upload-app.html +328 -223
- package/package.json +1 -1
package/dist/http.js
CHANGED
|
@@ -8906,7 +8906,7 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
8906
8906
|
], [
|
|
8907
8907
|
"creative_variant"
|
|
8908
8908
|
]),
|
|
8909
|
-
defineCapability("create_product_task", "
|
|
8909
|
+
defineCapability("create_product_task", "a6a2d3c4d462e43496d440f0bea37292894a2b818c5ccfa364fcdaac7982544e", "credit_consuming", "creative", [
|
|
8910
8910
|
"credit_consuming",
|
|
8911
8911
|
"state_change"
|
|
8912
8912
|
], [
|
|
@@ -8947,6 +8947,18 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
8947
8947
|
"productContext"
|
|
8948
8948
|
]),
|
|
8949
8949
|
slot("video_options", false, [
|
|
8950
|
+
"videoPreset",
|
|
8951
|
+
"durationSeconds",
|
|
8952
|
+
"pauseForAngleReview",
|
|
8953
|
+
// MCP-only: WhatsApp runs its own confirmation policy and does not
|
|
8954
|
+
// offer a "decide everything" mode, so the field is not mirrored there.
|
|
8955
|
+
"aiDecidesAll",
|
|
8956
|
+
"videoTier",
|
|
8957
|
+
"videoResolution",
|
|
8958
|
+
"multiAngleVideos",
|
|
8959
|
+
"angleNarratives",
|
|
8960
|
+
"competitorScanEnabled"
|
|
8961
|
+
], [
|
|
8950
8962
|
"videoPreset",
|
|
8951
8963
|
"durationSeconds",
|
|
8952
8964
|
"pauseForAngleReview",
|
|
@@ -9172,7 +9184,7 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9172
9184
|
], [
|
|
9173
9185
|
"creative"
|
|
9174
9186
|
]),
|
|
9175
|
-
defineCapability("generate_landing", "
|
|
9187
|
+
defineCapability("generate_landing", "75947c5a70d26305ca07ab8d7bc0be710e862eb9319010ee66dd43e64fab2050", "credit_consuming", "creative", [
|
|
9176
9188
|
"credit_consuming",
|
|
9177
9189
|
"state_change"
|
|
9178
9190
|
], [
|
|
@@ -9214,6 +9226,12 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9214
9226
|
"subheadline"
|
|
9215
9227
|
]),
|
|
9216
9228
|
slot("landing_options", false, [
|
|
9229
|
+
"competitorScanEnabled",
|
|
9230
|
+
"landingStrategy",
|
|
9231
|
+
"landingImageFormat",
|
|
9232
|
+
// MCP-only, like create_product_task's aiDecidesAll.
|
|
9233
|
+
"aiDecidesAll"
|
|
9234
|
+
], [
|
|
9217
9235
|
"competitorScanEnabled",
|
|
9218
9236
|
"landingStrategy",
|
|
9219
9237
|
"landingImageFormat"
|
|
@@ -9242,7 +9260,7 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9242
9260
|
fieldCompletion: "all"
|
|
9243
9261
|
}
|
|
9244
9262
|
]),
|
|
9245
|
-
defineCapability("generate_video_creative", "
|
|
9263
|
+
defineCapability("generate_video_creative", "417f185d4bf8bbcdf044f98f4b8366473178aca8e7a99cc6f8d58cb0221442dc", "credit_consuming", "creative", [
|
|
9246
9264
|
"credit_consuming",
|
|
9247
9265
|
"state_change"
|
|
9248
9266
|
], [
|
|
@@ -9264,6 +9282,20 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9264
9282
|
"country"
|
|
9265
9283
|
]),
|
|
9266
9284
|
slot("render_options", true, [
|
|
9285
|
+
"aspectRatio",
|
|
9286
|
+
"durationSeconds",
|
|
9287
|
+
"videoPreset",
|
|
9288
|
+
"videoTier",
|
|
9289
|
+
"videoResolution",
|
|
9290
|
+
"features",
|
|
9291
|
+
"cta",
|
|
9292
|
+
"scriptNotes",
|
|
9293
|
+
"pauseForVideoScriptReview",
|
|
9294
|
+
"pauseForAngleReview",
|
|
9295
|
+
// MCP-only, like create_product_task's aiDecidesAll.
|
|
9296
|
+
"aiDecidesAll",
|
|
9297
|
+
"competitorScanEnabled"
|
|
9298
|
+
], [
|
|
9267
9299
|
"aspectRatio",
|
|
9268
9300
|
"durationSeconds",
|
|
9269
9301
|
"videoPreset",
|
|
@@ -11042,7 +11074,7 @@ var START_DROPSHIPPING_PROMPT = [
|
|
|
11042
11074
|
"",
|
|
11043
11075
|
"After I choose a product, call start_dropshipping_project. Ask me for the real supplier cost and required costing inputs before cost_dropshipping_project; never treat a marketplace sale price as supplier cost. When nextAction.kind is cost_product, call cost_product first as a free read-only estimate with those real supplier inputs, carry its price and compareAtPrice into the creative task, then call cost_dropshipping_project with the canonical inputs to persist costing. After every guided mutation, call guide_dropshipping_launch and follow ProductMaker's returned nextAction. Do not maintain a parallel workflow state in chat.",
|
|
11044
11076
|
"",
|
|
11045
|
-
"Use the existing creative/task tools for generation, preserve their estimate and confirmation gates,
|
|
11077
|
+
"Use the existing creative/task tools for generation, preserve their estimate and confirmation gates, and keep polling get_task_status until the task is done, errored, or paused \u2014 a timed-out poll means it is still running, so call again. When it pauses, show me the draft and ask me; only answer it yourself through resolve_pending_action if I delegated that decision to you or the task runs with aiDecidesAll / reviewMode automatic. Verify actual outputs before calling the creative stage complete.",
|
|
11046
11078
|
"",
|
|
11047
11079
|
"Before Shopify publication, call list_shopify_shops, check the real task readiness, ask whether the product has variants, show the intended publication, and wait for explicit approval before calling publish_to_shopify. Report the exact blocker when publication is not ready.",
|
|
11048
11080
|
"",
|
|
@@ -11368,13 +11400,12 @@ var ApiClient = class {
|
|
|
11368
11400
|
};
|
|
11369
11401
|
|
|
11370
11402
|
// src/index.ts
|
|
11371
|
-
var MCP_VERSION = "3.
|
|
11403
|
+
var MCP_VERSION = "3.2.0";
|
|
11372
11404
|
|
|
11373
11405
|
// src/meta-upload-app.ts
|
|
11374
11406
|
import { readFileSync } from "fs";
|
|
11375
11407
|
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
11376
11408
|
var MEDIA_UPLOAD_APP_RESOURCE_URI = "ui://media-upload/v1.html";
|
|
11377
|
-
var MEDIA_UPLOAD_APP_DOMAIN = "https://productmaker.app";
|
|
11378
11409
|
var MEDIA_UPLOAD_APP_NAME = "ProductMaker media upload";
|
|
11379
11410
|
var MEDIA_UPLOAD_APP_HTML_URL = new URL("./ui/meta-upload-app.html", import.meta.url);
|
|
11380
11411
|
var MEDIA_UPLOAD_APP_CSP = {
|
|
@@ -11420,7 +11451,6 @@ function registerMediaUploadAppResource(server, resolveEndpoint) {
|
|
|
11420
11451
|
mimeType: RESOURCE_MIME_TYPE,
|
|
11421
11452
|
_meta: {
|
|
11422
11453
|
ui: {
|
|
11423
|
-
domain: MEDIA_UPLOAD_APP_DOMAIN,
|
|
11424
11454
|
csp
|
|
11425
11455
|
}
|
|
11426
11456
|
}
|
|
@@ -11432,7 +11462,6 @@ function registerMediaUploadAppResource(server, resolveEndpoint) {
|
|
|
11432
11462
|
text: html,
|
|
11433
11463
|
_meta: {
|
|
11434
11464
|
ui: {
|
|
11435
|
-
domain: MEDIA_UPLOAD_APP_DOMAIN,
|
|
11436
11465
|
csp
|
|
11437
11466
|
}
|
|
11438
11467
|
}
|
|
@@ -12909,6 +12938,7 @@ var CreateProductTaskInput = z49.object({
|
|
|
12909
12938
|
z49.literal(30)
|
|
12910
12939
|
]).default(15).describe("Opcional. Duraci\xF3n objetivo del video: 15s (single_call, default) o 30s (Multiescena, aproximadamente 30s y facturado por escena)."),
|
|
12911
12940
|
pauseForAngleReview: z49.boolean().optional().describe("Opcional. Si true, pausa despu\xE9s de generar el \xE1ngulo para que el usuario lo confirme o edite antes del render. \xDAsalo si quiere decidir 15/30s cuando aparezca un winner. Default false."),
|
|
12941
|
+
aiDecidesAll: z49.boolean().optional().describe("Opcional. Si true, ProductMaker decide TODO y no vuelve a interrumpir al usuario: resuelve solo las confirmaciones de referencias del producto y no abre gates de aprobaci\xF3n, as\xED que `pendingUserAction` no deber\xEDa aparecer. Act\xEDvalo \xFAnicamente cuando el usuario pida expl\xEDcitamente que la IA decida todo o que no le pregunten m\xE1s. Default false."),
|
|
12912
12942
|
videoTier: VideoTierEnum.optional().describe('Opcional. Estilo del video. "basic" = "Realista" para 15s; al pedir durationSeconds=30 el sistema selecciona internamente Multiescena. No se expone el tier interno directamente.'),
|
|
12913
12943
|
videoResolution: VideoResolutionEnum.optional().describe('Opcional. Resoluci\xF3n del render. En la web se pregunta "\xBFResoluci\xF3n del video?". "480p" = excelente calidad y m\xE1s econ\xF3mico (RECOMENDADO, default si se omite); "720p" = m\xE1xima nitidez, entre 3% y 15% m\xE1s caro seg\xFAn formato y tier. Si el usuario no menciona resoluci\xF3n, OMITE este campo (el backend usa 480p).'),
|
|
12914
12944
|
multiAngleVideos: z49.boolean().optional().describe("Opcional. Si true, genera m\xFAltiples \xE1ngulos de venta en paralelo (3-5 videos). Si false/omitido, genera 1 video del \xE1ngulo principal. Default false."),
|
|
@@ -13236,6 +13266,7 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
|
|
|
13236
13266
|
"options.subheadline": input.subheadline,
|
|
13237
13267
|
"options.landingStrategy": input.landingStrategy,
|
|
13238
13268
|
"options.landingImageFormat": input.landingImageFormat,
|
|
13269
|
+
"options.aiDecidesAll": input.aiDecidesAll,
|
|
13239
13270
|
pauseForAngleReview: input.pauseForAngleReview,
|
|
13240
13271
|
url: input.productUrl
|
|
13241
13272
|
}),
|
|
@@ -13307,7 +13338,7 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
|
|
|
13307
13338
|
throw translateError(e);
|
|
13308
13339
|
}
|
|
13309
13340
|
});
|
|
13310
|
-
server.tool("get_task_status", "Devuelve el estado y URLs de outputs de una tarea. Si waitSeconds>0, hace polling hasta que cambie la fase, aparezca `pendingUserAction`, o llegue al timeout (max 120s). Cuando la tarea queda en `done`, incluye enlaces a sus outputs.\n\nSi aparece `pendingUserAction`, el pipeline est\xE1 PARADO: muestra el draft y
|
|
13341
|
+
server.tool("get_task_status", "Devuelve el estado y URLs de outputs de una tarea. Si waitSeconds>0, hace polling hasta que cambie la fase, aparezca `pendingUserAction`, o llegue al timeout (max 120s). Cuando la tarea queda en `done`, incluye enlaces a sus outputs.\n\nSIGUE LLAM\xC1NDOLA hasta que el estado sea terminal (`done` o `error`) o aparezca `pendingUserAction`. Un retorno sin ninguna de esas dos cosas significa que la tarea sigue corriendo y que alcanzaste el timeout, NO que se colg\xF3 ni que termin\xF3: vuelve a llamarla con waitSeconds alto. Si dejas de llamarla, la tarea sigue avanzando sin que nadie la mire y el usuario se queda esperando en silencio.\n\nSi aparece `pendingUserAction`, el pipeline est\xE1 PARADO esperando una decisi\xF3n del USUARIO: muestra el draft, PREG\xDANTALE y espera su respuesta. Resp\xF3ndela t\xFA mismo con `resolve_pending_action` \xFAnicamente si el usuario ya te deleg\xF3 esa decisi\xF3n \u2014 porque te lo dijo en la conversaci\xF3n, o porque la tarea corre con `aiDecidesAll` / `reviewMode: automatic`. Nunca deduzcas la delegaci\xF3n del contenido del draft: que la propuesta te parezca obvia o correcta no es un permiso. Al responder, repite el mismo `kind`. Kinds: `product_reference_set_approval`, `product_reference_confirmation`, `angle_review_approval`, `service_angle_approval` y `service_script_approval`.", GetTaskStatusInput.shape, async (input, extra) => {
|
|
13311
13342
|
try {
|
|
13312
13343
|
const signal = extra?.signal;
|
|
13313
13344
|
const fetcher = /* @__PURE__ */ __name(() => api.getJson(`/v1/status/${encodeURIComponent(input.taskId)}`, {
|
|
@@ -13390,7 +13421,7 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
|
|
|
13390
13421
|
throw translateError(e);
|
|
13391
13422
|
}
|
|
13392
13423
|
});
|
|
13393
|
-
server.tool("resolve_pending_action", "Resuelve la acci\xF3n pendiente devuelta por `get_task_status`; `action.kind` debe coincidir exactamente. Puede aprobar el conjunto exacto de referencias del producto, confirmar agrupaci\xF3n, o resolver revisiones de \xE1ngulos/servicios. Nunca env\xEDes una lista de referencias: el servidor conserva el conjunto mostrado y rechaza snapshots obsoletos. La recuperaci\xF3n que necesita fotos nuevas se completa en ProductMaker Web porque MCP no acepta URLs arbitrarias sin ticket de upload ligado al usuario.", ResolvePendingActionInput.shape, async (input) => {
|
|
13424
|
+
server.tool("resolve_pending_action", "Resuelve la acci\xF3n pendiente devuelta por `get_task_status`; `action.kind` debe coincidir exactamente. Esta llamada habla EN NOMBRE DEL USUARIO: \xFAsala solo con una respuesta que \xE9l te haya dado, o cuando te haya delegado la decisi\xF3n de forma expl\xEDcita o v\xEDa `aiDecidesAll` / `reviewMode: automatic`. Si nadie te deleg\xF3 nada, pregunta y espera en vez de llamarla. Puede aprobar el conjunto exacto de referencias del producto, confirmar agrupaci\xF3n, o resolver revisiones de \xE1ngulos/servicios. Nunca env\xEDes una lista de referencias: el servidor conserva el conjunto mostrado y rechaza snapshots obsoletos. La recuperaci\xF3n que necesita fotos nuevas se completa en ProductMaker Web porque MCP no acepta URLs arbitrarias sin ticket de upload ligado al usuario.", ResolvePendingActionInput.shape, async (input) => {
|
|
13394
13425
|
try {
|
|
13395
13426
|
const raw = await api.getJson(`/v1/status/${encodeURIComponent(input.taskId)}`);
|
|
13396
13427
|
const status = StatusForResolveSchema.parse(raw);
|
|
@@ -13693,6 +13724,7 @@ var VideoInput = z50.object({
|
|
|
13693
13724
|
scriptNotes: z50.string().optional().describe('Opcional. Notas/restricciones para el guion (la IA las usa al redactar). Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico", "Mencionar 2x1". NO es el guion literal \u2014 para editar el guion palabra-por-palabra, pasa `pauseForVideoScriptReview: true` y usa get_video_script / approve_video_script tras la pausa.'),
|
|
13694
13725
|
pauseForVideoScriptReview: z50.boolean().optional().describe("Opcional. Si true, la tarea pausa despu\xE9s de generar el guion y antes de renderizar. \xDAsalo cuando el usuario quiera editar el guion antes de gastar cr\xE9ditos en el video. Tras la pausa: get_video_script para leerlo, approve_video_script para aprobar (con o sin ediciones). Default false."),
|
|
13695
13726
|
pauseForAngleReview: z50.boolean().optional().describe("Opcional. Si true, pausa despu\xE9s de generar el \xE1ngulo para que el usuario lo confirme o edite antes del render. Default false."),
|
|
13727
|
+
aiDecidesAll: z50.boolean().optional().describe("Opcional. Si true, ProductMaker decide TODO y no vuelve a interrumpir al usuario: resuelve solo las confirmaciones de referencias del producto y no abre gates de aprobaci\xF3n. Act\xEDvalo \xFAnicamente cuando el usuario pida expl\xEDcitamente que la IA decida todo o que no le pregunten m\xE1s. Default false."),
|
|
13696
13728
|
competitorScanEnabled: CreativeRefs.competitorScanEnabled,
|
|
13697
13729
|
brandId: CreativeRefs.brandId,
|
|
13698
13730
|
projectId: CreativeRefs.projectId
|
|
@@ -13770,6 +13802,7 @@ var EditImageCreativeInput = z50.object({
|
|
|
13770
13802
|
instruction: z50.string().min(1).max(1e3).describe('Qu\xE9 cambiar en esa imagen. Ej. "quita el badge de env\xEDo gratis", "cambia el fondo a blanco".')
|
|
13771
13803
|
});
|
|
13772
13804
|
var LandingInput = z50.object({
|
|
13805
|
+
aiDecidesAll: z50.boolean().optional().describe("Opcional. Si true, ProductMaker decide TODO y no vuelve a interrumpir al usuario: resuelve solo las confirmaciones de referencias del producto y no abre gates de aprobaci\xF3n. Act\xEDvalo \xFAnicamente cuando el usuario pida expl\xEDcitamente que la IA decida todo o que no le pregunten m\xE1s. Default false."),
|
|
13773
13806
|
images: CreativeRefs.images,
|
|
13774
13807
|
language: CreativeRefs.language,
|
|
13775
13808
|
country: CreativeRefs.country,
|
|
@@ -14976,7 +15009,7 @@ function uploadSessionText(result) {
|
|
|
14976
15009
|
text: [
|
|
14977
15010
|
`### Prepara tu ${mediaLabel} en ProductMaker`,
|
|
14978
15011
|
`Usa primero el App inline de este chat. Si tu host no soporta el bridge de archivos, usa este fallback: [Abrir carga segura](${result.uploadPageUrl})`,
|
|
14979
|
-
`Puedes seleccionar hasta ${result.maxFiles} ${result.mediaKind === "mixed" ? "archivos" : "archivo"}.
|
|
15012
|
+
`Puedes seleccionar hasta ${result.maxFiles} ${result.mediaKind === "mixed" ? "archivos" : "archivo"}. El App sube cada archivo solo; cuando quieras seguir, escr\xEDbemelo en el chat.`,
|
|
14980
15013
|
`Este enlace expira: ${result.expiresAt}`
|
|
14981
15014
|
].join("\n\n")
|
|
14982
15015
|
}
|
|
@@ -14991,7 +15024,7 @@ __name(resolveEndpointForSession, "resolveEndpointForSession");
|
|
|
14991
15024
|
function registerMediaUploadTools(server, api) {
|
|
14992
15025
|
registerAppTool(server, "request_media_upload", {
|
|
14993
15026
|
title: "Subir archivos",
|
|
14994
|
-
description: "Abre el selector universal de ProductMaker para im\xE1genes o videos del usuario. Ll\xE1malo autom\xE1ticamente y antes de preguntar por campos faltantes cuando una petici\xF3n natural adjunte o mencione un archivo local, por ejemplo \u2018crea una landing con este producto\u2019, \u2018haz creativos con esta foto\u2019 o \u2018sube este video a Meta\u2019; hazlo aunque el usuario diga que todav\xEDa no generes ni publiques, porque esta herramienta solo prepara el archivo y no consume cr\xE9ditos, salvo que proh\xEDba expl\xEDcitamente subir, compartir o almacenar el archivo; no pidas una ruta local ni URL. Es la \xFAnica ruta de carga para create_product_task, generate_image_creatives, generate_video_creative, generate_free_video, generate_landing y publish_creatives_to_meta. Pasa las referencias opcionales de ChatGPT en files; si el host no puede transferirlas, el mismo App muestra el selector inline. Usa uploadPurpose=product_reference para fotos de producto y landings, creative para creativos o anuncios, y generic solo cuando ninguno aplique. Usa mediaKind=image para referencias de producto y landings; mixed solo cuando el consumidor acepte ambos tipos.
|
|
15027
|
+
description: "Abre el selector universal de ProductMaker para im\xE1genes o videos del usuario. Ll\xE1malo autom\xE1ticamente y antes de preguntar por campos faltantes cuando una petici\xF3n natural adjunte o mencione un archivo local, por ejemplo \u2018crea una landing con este producto\u2019, \u2018haz creativos con esta foto\u2019 o \u2018sube este video a Meta\u2019; hazlo aunque el usuario diga que todav\xEDa no generes ni publiques, porque esta herramienta solo prepara el archivo y no consume cr\xE9ditos, salvo que proh\xEDba expl\xEDcitamente subir, compartir o almacenar el archivo; no pidas una ruta local ni URL. Es la \xFAnica ruta de carga para create_product_task, generate_image_creatives, generate_video_creative, generate_free_video, generate_landing y publish_creatives_to_meta. Pasa las referencias opcionales de ChatGPT en files; si el host no puede transferirlas, el mismo App muestra el selector inline. Usa uploadPurpose=product_reference para fotos de producto y landings, creative para creativos o anuncios, y generic solo cuando ninguno aplique. Usa mediaKind=image para referencias de producto y landings; mixed solo cuando el consumidor acepte ambos tipos. El App NO te avisa cuando termina: en cuanto el usuario diga que ya subi\xF3 el archivo o que sigas, llama `check_media_upload` con este sessionId para obtener los assetId, y reanuda la intenci\xF3n original usando \xFAnicamente assetId y mediaKind, sin pedir que vuelva a subir ni mostrar datos t\xE9cnicos. El enlace externo es fallback solo si el App inline falla. Esta herramienta no genera, publica ni consume cr\xE9ditos.",
|
|
14995
15028
|
inputSchema: UploadSessionToolInputShape,
|
|
14996
15029
|
outputSchema: UniversalUploadStructuredResponse.shape,
|
|
14997
15030
|
_meta: {
|
|
@@ -15031,7 +15064,7 @@ function registerMediaUploadTools(server, api) {
|
|
|
15031
15064
|
}
|
|
15032
15065
|
});
|
|
15033
15066
|
server.registerTool("check_media_upload", {
|
|
15034
|
-
description: "Comprueba la carga universal. Si status=uploaded, conserva assets[] para la petici\xF3n anterior y pasa cada assetId \xFAnicamente a un consumidor compatible. Un asset de video solo sirve donde se admite video; las referencias de producto, imagen y landing requieren mediaKind=image. No muestres IDs t\xE9cnicos como JSON al usuario.",
|
|
15067
|
+
description: "Comprueba la carga universal. Ll\xE1mala cuando el usuario diga que ya subi\xF3 sus archivos: es la \xFAnica forma de enterarte, porque el App no manda mensajes al chat. Si status=pending, el usuario todav\xEDa no ha terminado: d\xEDselo y espera, no inventes un assetId ni abras otra sesi\xF3n de carga. Si status=uploaded, conserva assets[] para la petici\xF3n anterior y pasa cada assetId \xFAnicamente a un consumidor compatible. Un asset de video solo sirve donde se admite video; las referencias de producto, imagen y landing requieren mediaKind=image. No muestres IDs t\xE9cnicos como JSON al usuario.",
|
|
15035
15068
|
inputSchema: UploadStatusInput.shape,
|
|
15036
15069
|
_meta: {
|
|
15037
15070
|
"openai/widgetAccessible": true
|
package/dist/stdio.js
CHANGED
|
@@ -264,13 +264,12 @@ var ApiClient = class {
|
|
|
264
264
|
};
|
|
265
265
|
|
|
266
266
|
// src/index.ts
|
|
267
|
-
var MCP_VERSION = "3.
|
|
267
|
+
var MCP_VERSION = "3.2.0";
|
|
268
268
|
|
|
269
269
|
// src/meta-upload-app.ts
|
|
270
270
|
import { readFileSync } from "fs";
|
|
271
271
|
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
272
272
|
var MEDIA_UPLOAD_APP_RESOURCE_URI = "ui://media-upload/v1.html";
|
|
273
|
-
var MEDIA_UPLOAD_APP_DOMAIN = "https://productmaker.app";
|
|
274
273
|
var MEDIA_UPLOAD_APP_NAME = "ProductMaker media upload";
|
|
275
274
|
var MEDIA_UPLOAD_APP_HTML_URL = new URL("./ui/meta-upload-app.html", import.meta.url);
|
|
276
275
|
var MEDIA_UPLOAD_APP_CSP = {
|
|
@@ -316,7 +315,6 @@ function registerMediaUploadAppResource(server, resolveEndpoint) {
|
|
|
316
315
|
mimeType: RESOURCE_MIME_TYPE,
|
|
317
316
|
_meta: {
|
|
318
317
|
ui: {
|
|
319
|
-
domain: MEDIA_UPLOAD_APP_DOMAIN,
|
|
320
318
|
csp
|
|
321
319
|
}
|
|
322
320
|
}
|
|
@@ -328,7 +326,6 @@ function registerMediaUploadAppResource(server, resolveEndpoint) {
|
|
|
328
326
|
text: html,
|
|
329
327
|
_meta: {
|
|
330
328
|
ui: {
|
|
331
|
-
domain: MEDIA_UPLOAD_APP_DOMAIN,
|
|
332
329
|
csp
|
|
333
330
|
}
|
|
334
331
|
}
|
|
@@ -9530,7 +9527,7 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9530
9527
|
], [
|
|
9531
9528
|
"creative_variant"
|
|
9532
9529
|
]),
|
|
9533
|
-
defineCapability("create_product_task", "
|
|
9530
|
+
defineCapability("create_product_task", "a6a2d3c4d462e43496d440f0bea37292894a2b818c5ccfa364fcdaac7982544e", "credit_consuming", "creative", [
|
|
9534
9531
|
"credit_consuming",
|
|
9535
9532
|
"state_change"
|
|
9536
9533
|
], [
|
|
@@ -9571,6 +9568,18 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9571
9568
|
"productContext"
|
|
9572
9569
|
]),
|
|
9573
9570
|
slot("video_options", false, [
|
|
9571
|
+
"videoPreset",
|
|
9572
|
+
"durationSeconds",
|
|
9573
|
+
"pauseForAngleReview",
|
|
9574
|
+
// MCP-only: WhatsApp runs its own confirmation policy and does not
|
|
9575
|
+
// offer a "decide everything" mode, so the field is not mirrored there.
|
|
9576
|
+
"aiDecidesAll",
|
|
9577
|
+
"videoTier",
|
|
9578
|
+
"videoResolution",
|
|
9579
|
+
"multiAngleVideos",
|
|
9580
|
+
"angleNarratives",
|
|
9581
|
+
"competitorScanEnabled"
|
|
9582
|
+
], [
|
|
9574
9583
|
"videoPreset",
|
|
9575
9584
|
"durationSeconds",
|
|
9576
9585
|
"pauseForAngleReview",
|
|
@@ -9796,7 +9805,7 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9796
9805
|
], [
|
|
9797
9806
|
"creative"
|
|
9798
9807
|
]),
|
|
9799
|
-
defineCapability("generate_landing", "
|
|
9808
|
+
defineCapability("generate_landing", "75947c5a70d26305ca07ab8d7bc0be710e862eb9319010ee66dd43e64fab2050", "credit_consuming", "creative", [
|
|
9800
9809
|
"credit_consuming",
|
|
9801
9810
|
"state_change"
|
|
9802
9811
|
], [
|
|
@@ -9838,6 +9847,12 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9838
9847
|
"subheadline"
|
|
9839
9848
|
]),
|
|
9840
9849
|
slot("landing_options", false, [
|
|
9850
|
+
"competitorScanEnabled",
|
|
9851
|
+
"landingStrategy",
|
|
9852
|
+
"landingImageFormat",
|
|
9853
|
+
// MCP-only, like create_product_task's aiDecidesAll.
|
|
9854
|
+
"aiDecidesAll"
|
|
9855
|
+
], [
|
|
9841
9856
|
"competitorScanEnabled",
|
|
9842
9857
|
"landingStrategy",
|
|
9843
9858
|
"landingImageFormat"
|
|
@@ -9866,7 +9881,7 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9866
9881
|
fieldCompletion: "all"
|
|
9867
9882
|
}
|
|
9868
9883
|
]),
|
|
9869
|
-
defineCapability("generate_video_creative", "
|
|
9884
|
+
defineCapability("generate_video_creative", "417f185d4bf8bbcdf044f98f4b8366473178aca8e7a99cc6f8d58cb0221442dc", "credit_consuming", "creative", [
|
|
9870
9885
|
"credit_consuming",
|
|
9871
9886
|
"state_change"
|
|
9872
9887
|
], [
|
|
@@ -9888,6 +9903,20 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
|
|
|
9888
9903
|
"country"
|
|
9889
9904
|
]),
|
|
9890
9905
|
slot("render_options", true, [
|
|
9906
|
+
"aspectRatio",
|
|
9907
|
+
"durationSeconds",
|
|
9908
|
+
"videoPreset",
|
|
9909
|
+
"videoTier",
|
|
9910
|
+
"videoResolution",
|
|
9911
|
+
"features",
|
|
9912
|
+
"cta",
|
|
9913
|
+
"scriptNotes",
|
|
9914
|
+
"pauseForVideoScriptReview",
|
|
9915
|
+
"pauseForAngleReview",
|
|
9916
|
+
// MCP-only, like create_product_task's aiDecidesAll.
|
|
9917
|
+
"aiDecidesAll",
|
|
9918
|
+
"competitorScanEnabled"
|
|
9919
|
+
], [
|
|
9891
9920
|
"aspectRatio",
|
|
9892
9921
|
"durationSeconds",
|
|
9893
9922
|
"videoPreset",
|
|
@@ -11711,6 +11740,7 @@ var CreateProductTaskInput = z48.object({
|
|
|
11711
11740
|
z48.literal(30)
|
|
11712
11741
|
]).default(15).describe("Opcional. Duraci\xF3n objetivo del video: 15s (single_call, default) o 30s (Multiescena, aproximadamente 30s y facturado por escena)."),
|
|
11713
11742
|
pauseForAngleReview: z48.boolean().optional().describe("Opcional. Si true, pausa despu\xE9s de generar el \xE1ngulo para que el usuario lo confirme o edite antes del render. \xDAsalo si quiere decidir 15/30s cuando aparezca un winner. Default false."),
|
|
11743
|
+
aiDecidesAll: z48.boolean().optional().describe("Opcional. Si true, ProductMaker decide TODO y no vuelve a interrumpir al usuario: resuelve solo las confirmaciones de referencias del producto y no abre gates de aprobaci\xF3n, as\xED que `pendingUserAction` no deber\xEDa aparecer. Act\xEDvalo \xFAnicamente cuando el usuario pida expl\xEDcitamente que la IA decida todo o que no le pregunten m\xE1s. Default false."),
|
|
11714
11744
|
videoTier: VideoTierEnum.optional().describe('Opcional. Estilo del video. "basic" = "Realista" para 15s; al pedir durationSeconds=30 el sistema selecciona internamente Multiescena. No se expone el tier interno directamente.'),
|
|
11715
11745
|
videoResolution: VideoResolutionEnum.optional().describe('Opcional. Resoluci\xF3n del render. En la web se pregunta "\xBFResoluci\xF3n del video?". "480p" = excelente calidad y m\xE1s econ\xF3mico (RECOMENDADO, default si se omite); "720p" = m\xE1xima nitidez, entre 3% y 15% m\xE1s caro seg\xFAn formato y tier. Si el usuario no menciona resoluci\xF3n, OMITE este campo (el backend usa 480p).'),
|
|
11716
11746
|
multiAngleVideos: z48.boolean().optional().describe("Opcional. Si true, genera m\xFAltiples \xE1ngulos de venta en paralelo (3-5 videos). Si false/omitido, genera 1 video del \xE1ngulo principal. Default false."),
|
|
@@ -12038,6 +12068,7 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
|
|
|
12038
12068
|
"options.subheadline": input.subheadline,
|
|
12039
12069
|
"options.landingStrategy": input.landingStrategy,
|
|
12040
12070
|
"options.landingImageFormat": input.landingImageFormat,
|
|
12071
|
+
"options.aiDecidesAll": input.aiDecidesAll,
|
|
12041
12072
|
pauseForAngleReview: input.pauseForAngleReview,
|
|
12042
12073
|
url: input.productUrl
|
|
12043
12074
|
}),
|
|
@@ -12109,7 +12140,7 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
|
|
|
12109
12140
|
throw translateError(e);
|
|
12110
12141
|
}
|
|
12111
12142
|
});
|
|
12112
|
-
server.tool("get_task_status", "Devuelve el estado y URLs de outputs de una tarea. Si waitSeconds>0, hace polling hasta que cambie la fase, aparezca `pendingUserAction`, o llegue al timeout (max 120s). Cuando la tarea queda en `done`, incluye enlaces a sus outputs.\n\nSi aparece `pendingUserAction`, el pipeline est\xE1 PARADO: muestra el draft y
|
|
12143
|
+
server.tool("get_task_status", "Devuelve el estado y URLs de outputs de una tarea. Si waitSeconds>0, hace polling hasta que cambie la fase, aparezca `pendingUserAction`, o llegue al timeout (max 120s). Cuando la tarea queda en `done`, incluye enlaces a sus outputs.\n\nSIGUE LLAM\xC1NDOLA hasta que el estado sea terminal (`done` o `error`) o aparezca `pendingUserAction`. Un retorno sin ninguna de esas dos cosas significa que la tarea sigue corriendo y que alcanzaste el timeout, NO que se colg\xF3 ni que termin\xF3: vuelve a llamarla con waitSeconds alto. Si dejas de llamarla, la tarea sigue avanzando sin que nadie la mire y el usuario se queda esperando en silencio.\n\nSi aparece `pendingUserAction`, el pipeline est\xE1 PARADO esperando una decisi\xF3n del USUARIO: muestra el draft, PREG\xDANTALE y espera su respuesta. Resp\xF3ndela t\xFA mismo con `resolve_pending_action` \xFAnicamente si el usuario ya te deleg\xF3 esa decisi\xF3n \u2014 porque te lo dijo en la conversaci\xF3n, o porque la tarea corre con `aiDecidesAll` / `reviewMode: automatic`. Nunca deduzcas la delegaci\xF3n del contenido del draft: que la propuesta te parezca obvia o correcta no es un permiso. Al responder, repite el mismo `kind`. Kinds: `product_reference_set_approval`, `product_reference_confirmation`, `angle_review_approval`, `service_angle_approval` y `service_script_approval`.", GetTaskStatusInput.shape, async (input, extra) => {
|
|
12113
12144
|
try {
|
|
12114
12145
|
const signal = extra?.signal;
|
|
12115
12146
|
const fetcher = /* @__PURE__ */ __name(() => api.getJson(`/v1/status/${encodeURIComponent(input.taskId)}`, {
|
|
@@ -12192,7 +12223,7 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
|
|
|
12192
12223
|
throw translateError(e);
|
|
12193
12224
|
}
|
|
12194
12225
|
});
|
|
12195
|
-
server.tool("resolve_pending_action", "Resuelve la acci\xF3n pendiente devuelta por `get_task_status`; `action.kind` debe coincidir exactamente. Puede aprobar el conjunto exacto de referencias del producto, confirmar agrupaci\xF3n, o resolver revisiones de \xE1ngulos/servicios. Nunca env\xEDes una lista de referencias: el servidor conserva el conjunto mostrado y rechaza snapshots obsoletos. La recuperaci\xF3n que necesita fotos nuevas se completa en ProductMaker Web porque MCP no acepta URLs arbitrarias sin ticket de upload ligado al usuario.", ResolvePendingActionInput.shape, async (input) => {
|
|
12226
|
+
server.tool("resolve_pending_action", "Resuelve la acci\xF3n pendiente devuelta por `get_task_status`; `action.kind` debe coincidir exactamente. Esta llamada habla EN NOMBRE DEL USUARIO: \xFAsala solo con una respuesta que \xE9l te haya dado, o cuando te haya delegado la decisi\xF3n de forma expl\xEDcita o v\xEDa `aiDecidesAll` / `reviewMode: automatic`. Si nadie te deleg\xF3 nada, pregunta y espera en vez de llamarla. Puede aprobar el conjunto exacto de referencias del producto, confirmar agrupaci\xF3n, o resolver revisiones de \xE1ngulos/servicios. Nunca env\xEDes una lista de referencias: el servidor conserva el conjunto mostrado y rechaza snapshots obsoletos. La recuperaci\xF3n que necesita fotos nuevas se completa en ProductMaker Web porque MCP no acepta URLs arbitrarias sin ticket de upload ligado al usuario.", ResolvePendingActionInput.shape, async (input) => {
|
|
12196
12227
|
try {
|
|
12197
12228
|
const raw = await api.getJson(`/v1/status/${encodeURIComponent(input.taskId)}`);
|
|
12198
12229
|
const status = StatusForResolveSchema.parse(raw);
|
|
@@ -12495,6 +12526,7 @@ var VideoInput = z49.object({
|
|
|
12495
12526
|
scriptNotes: z49.string().optional().describe('Opcional. Notas/restricciones para el guion (la IA las usa al redactar). Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico", "Mencionar 2x1". NO es el guion literal \u2014 para editar el guion palabra-por-palabra, pasa `pauseForVideoScriptReview: true` y usa get_video_script / approve_video_script tras la pausa.'),
|
|
12496
12527
|
pauseForVideoScriptReview: z49.boolean().optional().describe("Opcional. Si true, la tarea pausa despu\xE9s de generar el guion y antes de renderizar. \xDAsalo cuando el usuario quiera editar el guion antes de gastar cr\xE9ditos en el video. Tras la pausa: get_video_script para leerlo, approve_video_script para aprobar (con o sin ediciones). Default false."),
|
|
12497
12528
|
pauseForAngleReview: z49.boolean().optional().describe("Opcional. Si true, pausa despu\xE9s de generar el \xE1ngulo para que el usuario lo confirme o edite antes del render. Default false."),
|
|
12529
|
+
aiDecidesAll: z49.boolean().optional().describe("Opcional. Si true, ProductMaker decide TODO y no vuelve a interrumpir al usuario: resuelve solo las confirmaciones de referencias del producto y no abre gates de aprobaci\xF3n. Act\xEDvalo \xFAnicamente cuando el usuario pida expl\xEDcitamente que la IA decida todo o que no le pregunten m\xE1s. Default false."),
|
|
12498
12530
|
competitorScanEnabled: CreativeRefs.competitorScanEnabled,
|
|
12499
12531
|
brandId: CreativeRefs.brandId,
|
|
12500
12532
|
projectId: CreativeRefs.projectId
|
|
@@ -12572,6 +12604,7 @@ var EditImageCreativeInput = z49.object({
|
|
|
12572
12604
|
instruction: z49.string().min(1).max(1e3).describe('Qu\xE9 cambiar en esa imagen. Ej. "quita el badge de env\xEDo gratis", "cambia el fondo a blanco".')
|
|
12573
12605
|
});
|
|
12574
12606
|
var LandingInput = z49.object({
|
|
12607
|
+
aiDecidesAll: z49.boolean().optional().describe("Opcional. Si true, ProductMaker decide TODO y no vuelve a interrumpir al usuario: resuelve solo las confirmaciones de referencias del producto y no abre gates de aprobaci\xF3n. Act\xEDvalo \xFAnicamente cuando el usuario pida expl\xEDcitamente que la IA decida todo o que no le pregunten m\xE1s. Default false."),
|
|
12575
12608
|
images: CreativeRefs.images,
|
|
12576
12609
|
language: CreativeRefs.language,
|
|
12577
12610
|
country: CreativeRefs.country,
|
|
@@ -13778,7 +13811,7 @@ function uploadSessionText(result) {
|
|
|
13778
13811
|
text: [
|
|
13779
13812
|
`### Prepara tu ${mediaLabel} en ProductMaker`,
|
|
13780
13813
|
`Usa primero el App inline de este chat. Si tu host no soporta el bridge de archivos, usa este fallback: [Abrir carga segura](${result.uploadPageUrl})`,
|
|
13781
|
-
`Puedes seleccionar hasta ${result.maxFiles} ${result.mediaKind === "mixed" ? "archivos" : "archivo"}.
|
|
13814
|
+
`Puedes seleccionar hasta ${result.maxFiles} ${result.mediaKind === "mixed" ? "archivos" : "archivo"}. El App sube cada archivo solo; cuando quieras seguir, escr\xEDbemelo en el chat.`,
|
|
13782
13815
|
`Este enlace expira: ${result.expiresAt}`
|
|
13783
13816
|
].join("\n\n")
|
|
13784
13817
|
}
|
|
@@ -13793,7 +13826,7 @@ __name(resolveEndpointForSession, "resolveEndpointForSession");
|
|
|
13793
13826
|
function registerMediaUploadTools(server, api) {
|
|
13794
13827
|
registerAppTool(server, "request_media_upload", {
|
|
13795
13828
|
title: "Subir archivos",
|
|
13796
|
-
description: "Abre el selector universal de ProductMaker para im\xE1genes o videos del usuario. Ll\xE1malo autom\xE1ticamente y antes de preguntar por campos faltantes cuando una petici\xF3n natural adjunte o mencione un archivo local, por ejemplo \u2018crea una landing con este producto\u2019, \u2018haz creativos con esta foto\u2019 o \u2018sube este video a Meta\u2019; hazlo aunque el usuario diga que todav\xEDa no generes ni publiques, porque esta herramienta solo prepara el archivo y no consume cr\xE9ditos, salvo que proh\xEDba expl\xEDcitamente subir, compartir o almacenar el archivo; no pidas una ruta local ni URL. Es la \xFAnica ruta de carga para create_product_task, generate_image_creatives, generate_video_creative, generate_free_video, generate_landing y publish_creatives_to_meta. Pasa las referencias opcionales de ChatGPT en files; si el host no puede transferirlas, el mismo App muestra el selector inline. Usa uploadPurpose=product_reference para fotos de producto y landings, creative para creativos o anuncios, y generic solo cuando ninguno aplique. Usa mediaKind=image para referencias de producto y landings; mixed solo cuando el consumidor acepte ambos tipos.
|
|
13829
|
+
description: "Abre el selector universal de ProductMaker para im\xE1genes o videos del usuario. Ll\xE1malo autom\xE1ticamente y antes de preguntar por campos faltantes cuando una petici\xF3n natural adjunte o mencione un archivo local, por ejemplo \u2018crea una landing con este producto\u2019, \u2018haz creativos con esta foto\u2019 o \u2018sube este video a Meta\u2019; hazlo aunque el usuario diga que todav\xEDa no generes ni publiques, porque esta herramienta solo prepara el archivo y no consume cr\xE9ditos, salvo que proh\xEDba expl\xEDcitamente subir, compartir o almacenar el archivo; no pidas una ruta local ni URL. Es la \xFAnica ruta de carga para create_product_task, generate_image_creatives, generate_video_creative, generate_free_video, generate_landing y publish_creatives_to_meta. Pasa las referencias opcionales de ChatGPT en files; si el host no puede transferirlas, el mismo App muestra el selector inline. Usa uploadPurpose=product_reference para fotos de producto y landings, creative para creativos o anuncios, y generic solo cuando ninguno aplique. Usa mediaKind=image para referencias de producto y landings; mixed solo cuando el consumidor acepte ambos tipos. El App NO te avisa cuando termina: en cuanto el usuario diga que ya subi\xF3 el archivo o que sigas, llama `check_media_upload` con este sessionId para obtener los assetId, y reanuda la intenci\xF3n original usando \xFAnicamente assetId y mediaKind, sin pedir que vuelva a subir ni mostrar datos t\xE9cnicos. El enlace externo es fallback solo si el App inline falla. Esta herramienta no genera, publica ni consume cr\xE9ditos.",
|
|
13797
13830
|
inputSchema: UploadSessionToolInputShape,
|
|
13798
13831
|
outputSchema: UniversalUploadStructuredResponse.shape,
|
|
13799
13832
|
_meta: {
|
|
@@ -13833,7 +13866,7 @@ function registerMediaUploadTools(server, api) {
|
|
|
13833
13866
|
}
|
|
13834
13867
|
});
|
|
13835
13868
|
server.registerTool("check_media_upload", {
|
|
13836
|
-
description: "Comprueba la carga universal. Si status=uploaded, conserva assets[] para la petici\xF3n anterior y pasa cada assetId \xFAnicamente a un consumidor compatible. Un asset de video solo sirve donde se admite video; las referencias de producto, imagen y landing requieren mediaKind=image. No muestres IDs t\xE9cnicos como JSON al usuario.",
|
|
13869
|
+
description: "Comprueba la carga universal. Ll\xE1mala cuando el usuario diga que ya subi\xF3 sus archivos: es la \xFAnica forma de enterarte, porque el App no manda mensajes al chat. Si status=pending, el usuario todav\xEDa no ha terminado: d\xEDselo y espera, no inventes un assetId ni abras otra sesi\xF3n de carga. Si status=uploaded, conserva assets[] para la petici\xF3n anterior y pasa cada assetId \xFAnicamente a un consumidor compatible. Un asset de video solo sirve donde se admite video; las referencias de producto, imagen y landing requieren mediaKind=image. No muestres IDs t\xE9cnicos como JSON al usuario.",
|
|
13837
13870
|
inputSchema: UploadStatusInput.shape,
|
|
13838
13871
|
_meta: {
|
|
13839
13872
|
"openai/widgetAccessible": true
|
|
@@ -14935,7 +14968,7 @@ var START_DROPSHIPPING_PROMPT = [
|
|
|
14935
14968
|
"",
|
|
14936
14969
|
"After I choose a product, call start_dropshipping_project. Ask me for the real supplier cost and required costing inputs before cost_dropshipping_project; never treat a marketplace sale price as supplier cost. When nextAction.kind is cost_product, call cost_product first as a free read-only estimate with those real supplier inputs, carry its price and compareAtPrice into the creative task, then call cost_dropshipping_project with the canonical inputs to persist costing. After every guided mutation, call guide_dropshipping_launch and follow ProductMaker's returned nextAction. Do not maintain a parallel workflow state in chat.",
|
|
14937
14970
|
"",
|
|
14938
|
-
"Use the existing creative/task tools for generation, preserve their estimate and confirmation gates,
|
|
14971
|
+
"Use the existing creative/task tools for generation, preserve their estimate and confirmation gates, and keep polling get_task_status until the task is done, errored, or paused \u2014 a timed-out poll means it is still running, so call again. When it pauses, show me the draft and ask me; only answer it yourself through resolve_pending_action if I delegated that decision to you or the task runs with aiDecidesAll / reviewMode automatic. Verify actual outputs before calling the creative stage complete.",
|
|
14939
14972
|
"",
|
|
14940
14973
|
"Before Shopify publication, call list_shopify_shops, check the real task readiness, ask whether the product has variants, show the intended publication, and wait for explicit approval before calling publish_to_shopify. Report the exact blocker when publication is not ready.",
|
|
14941
14974
|
"",
|
|
@@ -214,24 +214,6 @@
|
|
|
214
214
|
line-height: 1;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
.complete-button {
|
|
218
|
-
justify-self: start;
|
|
219
|
-
min-width: 76px;
|
|
220
|
-
margin-top: 10px;
|
|
221
|
-
padding: 10px 20px;
|
|
222
|
-
border: 0;
|
|
223
|
-
border-radius: 12px;
|
|
224
|
-
background: var(--pm-primary);
|
|
225
|
-
color: #ffffff;
|
|
226
|
-
cursor: pointer;
|
|
227
|
-
font-weight: 780;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
.complete-button:disabled {
|
|
231
|
-
cursor: not-allowed;
|
|
232
|
-
opacity: 0.5;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
217
|
.drop-help {
|
|
236
218
|
position: absolute;
|
|
237
219
|
width: 1px;
|
|
@@ -511,15 +493,6 @@
|
|
|
511
493
|
aria-live="polite"
|
|
512
494
|
></p>
|
|
513
495
|
</div>
|
|
514
|
-
<button
|
|
515
|
-
class="complete-button"
|
|
516
|
-
type="button"
|
|
517
|
-
data-testid="meta-upload-complete-btn"
|
|
518
|
-
hidden
|
|
519
|
-
disabled
|
|
520
|
-
>
|
|
521
|
-
OK
|
|
522
|
-
</button>
|
|
523
496
|
</section>
|
|
524
497
|
</article>
|
|
525
498
|
</main>
|
|
@@ -548,6 +521,7 @@
|
|
|
548
521
|
const IMAGE_COMPRESSION_THRESHOLD_BYTES = 1.8 * 1024 * 1024;
|
|
549
522
|
const IMAGE_COMPRESSION_MAX_BYTES = 1.8 * 1024 * 1024;
|
|
550
523
|
const IMAGE_MAX_DIMENSION = 2048;
|
|
524
|
+
const PREVIEW_MAX_DIMENSION = 256;
|
|
551
525
|
const MAX_UPLOAD_ATTEMPTS = 3;
|
|
552
526
|
const TOOL_RESULT_TEXT_LIMIT = 100000;
|
|
553
527
|
const RPC_TIMEOUT_MS = 15000;
|
|
@@ -575,9 +549,6 @@
|
|
|
575
549
|
batchError: document.querySelector(
|
|
576
550
|
'[data-testid="meta-upload-batch-error"]',
|
|
577
551
|
),
|
|
578
|
-
completeButton: document.querySelector(
|
|
579
|
-
'[data-testid="meta-upload-complete-btn"]',
|
|
580
|
-
),
|
|
581
552
|
uploadPrompt: document.querySelector(
|
|
582
553
|
'[data-testid="meta-upload-prompt"]',
|
|
583
554
|
),
|
|
@@ -590,7 +561,6 @@
|
|
|
590
561
|
disposed: false,
|
|
591
562
|
initialized: false,
|
|
592
563
|
standardHostReady: false,
|
|
593
|
-
hostCanMessage: false,
|
|
594
564
|
hostCanServerTools: false,
|
|
595
565
|
hostContainerDimensions: null,
|
|
596
566
|
rpcId: 0,
|
|
@@ -601,9 +571,6 @@
|
|
|
601
571
|
session: null,
|
|
602
572
|
toolInput: {},
|
|
603
573
|
items: [],
|
|
604
|
-
assets: [],
|
|
605
|
-
completionNotified: false,
|
|
606
|
-
completionNotificationInFlight: false,
|
|
607
574
|
consumedBridgeIds: new Set(),
|
|
608
575
|
consumingBridgeFiles: false,
|
|
609
576
|
bridgeConsumptionQueued: false,
|
|
@@ -676,38 +643,6 @@
|
|
|
676
643
|
scheduleIntrinsicSizeReport(true);
|
|
677
644
|
}
|
|
678
645
|
|
|
679
|
-
function canMessageHost() {
|
|
680
|
-
return (
|
|
681
|
-
hasOpenAiFunction("sendFollowUpMessage") || state.hostCanMessage
|
|
682
|
-
);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function canCompleteUpload() {
|
|
686
|
-
return (
|
|
687
|
-
!state.disposed &&
|
|
688
|
-
Boolean(state.session) &&
|
|
689
|
-
!sessionExpired() &&
|
|
690
|
-
!state.busyBatch &&
|
|
691
|
-
!state.completionNotified &&
|
|
692
|
-
!state.completionNotificationInFlight &&
|
|
693
|
-
state.assets.length > 0 &&
|
|
694
|
-
state.items.length > 0 &&
|
|
695
|
-
state.assets.length === state.items.length &&
|
|
696
|
-
state.items.every((item) => item.status === "uploaded") &&
|
|
697
|
-
canMessageHost()
|
|
698
|
-
);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function renderCompletionAction() {
|
|
702
|
-
const hasVerifiedAssets = state.assets.length > 0;
|
|
703
|
-
elements.completeButton.hidden =
|
|
704
|
-
state.completionNotified || !hasVerifiedAssets || !canMessageHost();
|
|
705
|
-
elements.completeButton.disabled = !canCompleteUpload();
|
|
706
|
-
elements.completeButton.textContent =
|
|
707
|
-
state.completionNotificationInFlight ? "…" : "OK";
|
|
708
|
-
scheduleIntrinsicSizeReport(true);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
646
|
function parseFinitePositiveNumber(value) {
|
|
712
647
|
return typeof value === "number" &&
|
|
713
648
|
Number.isFinite(value) &&
|
|
@@ -926,8 +861,6 @@
|
|
|
926
861
|
!state.session ||
|
|
927
862
|
sessionExpired() ||
|
|
928
863
|
state.uploadBlocked ||
|
|
929
|
-
state.completionNotified ||
|
|
930
|
-
state.completionNotificationInFlight ||
|
|
931
864
|
state.busyBatch ||
|
|
932
865
|
state.items.length >= safeSessionLimit();
|
|
933
866
|
elements.input.disabled = disabled;
|
|
@@ -1043,7 +976,6 @@
|
|
|
1043
976
|
hostContext.containerDimensions,
|
|
1044
977
|
);
|
|
1045
978
|
applyHostContainerDimensions();
|
|
1046
|
-
state.hostCanMessage = capabilityEnabled(capabilities.message);
|
|
1047
979
|
state.hostCanServerTools = capabilityEnabled(
|
|
1048
980
|
capabilities.serverTools,
|
|
1049
981
|
);
|
|
@@ -1151,7 +1083,6 @@
|
|
|
1151
1083
|
abortActiveWork();
|
|
1152
1084
|
for (const item of state.items) revokePreviewUrl(item);
|
|
1153
1085
|
state.items = [];
|
|
1154
|
-
state.assets = [];
|
|
1155
1086
|
state.consumedBridgeIds.clear();
|
|
1156
1087
|
state.consumingBridgeFiles = false;
|
|
1157
1088
|
state.bridgeConsumptionQueued = false;
|
|
@@ -1161,8 +1092,6 @@
|
|
|
1161
1092
|
renderItems();
|
|
1162
1093
|
state.uploadBlocked = false;
|
|
1163
1094
|
state.fallbackRequired = false;
|
|
1164
|
-
state.completionNotified = false;
|
|
1165
|
-
state.completionNotificationInFlight = false;
|
|
1166
1095
|
state.workCancelled = false;
|
|
1167
1096
|
if (state.abortController?.signal.aborted) {
|
|
1168
1097
|
state.abortController = null;
|
|
@@ -1180,9 +1109,9 @@
|
|
|
1180
1109
|
} else {
|
|
1181
1110
|
setFallbackVisibility(state.fallbackRequired);
|
|
1182
1111
|
setSessionStatus("Sesión lista para recibir archivos.", "neutral");
|
|
1112
|
+
if (sessionChanged) void refreshSessionStatus({ onMount: true });
|
|
1183
1113
|
}
|
|
1184
1114
|
setPickerAvailability();
|
|
1185
|
-
renderCompletionAction();
|
|
1186
1115
|
void consumePendingBridgeFiles();
|
|
1187
1116
|
scheduleIntrinsicSizeReport(true);
|
|
1188
1117
|
}
|
|
@@ -1333,9 +1262,7 @@
|
|
|
1333
1262
|
state.disposed ||
|
|
1334
1263
|
!state.session ||
|
|
1335
1264
|
sessionExpired() ||
|
|
1336
|
-
state.uploadBlocked
|
|
1337
|
-
state.completionNotified ||
|
|
1338
|
-
state.completionNotificationInFlight
|
|
1265
|
+
state.uploadBlocked
|
|
1339
1266
|
)
|
|
1340
1267
|
return;
|
|
1341
1268
|
state.workCancelled = false;
|
|
@@ -1354,9 +1281,7 @@
|
|
|
1354
1281
|
state.disposed ||
|
|
1355
1282
|
!state.session ||
|
|
1356
1283
|
sessionExpired() ||
|
|
1357
|
-
state.uploadBlocked
|
|
1358
|
-
state.completionNotified ||
|
|
1359
|
-
state.completionNotificationInFlight
|
|
1284
|
+
state.uploadBlocked
|
|
1360
1285
|
)
|
|
1361
1286
|
return;
|
|
1362
1287
|
const bridge = openAiBridge();
|
|
@@ -1530,8 +1455,6 @@
|
|
|
1530
1455
|
!state.session ||
|
|
1531
1456
|
sessionExpired() ||
|
|
1532
1457
|
state.uploadBlocked ||
|
|
1533
|
-
state.completionNotified ||
|
|
1534
|
-
state.completionNotificationInFlight ||
|
|
1535
1458
|
state.busyBatch
|
|
1536
1459
|
)
|
|
1537
1460
|
return;
|
|
@@ -1551,20 +1474,18 @@
|
|
|
1551
1474
|
if (!selected.length) return;
|
|
1552
1475
|
|
|
1553
1476
|
state.busyBatch = true;
|
|
1554
|
-
state.assets = [];
|
|
1555
|
-
state.completionNotified = false;
|
|
1556
1477
|
setPickerAvailability();
|
|
1557
|
-
renderCompletionAction();
|
|
1558
1478
|
for (const file of selected) {
|
|
1559
1479
|
const mediaKind = mediaKindForType(file.type) || "image";
|
|
1560
1480
|
const item = createItem(file, mediaKind);
|
|
1561
1481
|
state.items.push(item);
|
|
1562
1482
|
renderItems();
|
|
1483
|
+
// Thumbnail is cosmetic and must never delay or block the upload.
|
|
1484
|
+
void upgradePreviewToDataUrl(item);
|
|
1563
1485
|
await prepareAndUpload(item);
|
|
1564
1486
|
}
|
|
1565
1487
|
state.busyBatch = false;
|
|
1566
1488
|
setPickerAvailability();
|
|
1567
|
-
renderCompletionAction();
|
|
1568
1489
|
if (state.items.some((item) => item.status === "uploaded")) {
|
|
1569
1490
|
await refreshSessionStatus();
|
|
1570
1491
|
}
|
|
@@ -1585,6 +1506,7 @@
|
|
|
1585
1506
|
failureStage: "",
|
|
1586
1507
|
assetId: "",
|
|
1587
1508
|
previewUrl: "",
|
|
1509
|
+
slotReserved: false,
|
|
1588
1510
|
};
|
|
1589
1511
|
}
|
|
1590
1512
|
|
|
@@ -1607,12 +1529,15 @@
|
|
|
1607
1529
|
item.status = "uploaded";
|
|
1608
1530
|
item.progress = 100;
|
|
1609
1531
|
item.error = "";
|
|
1532
|
+
item.slotReserved = false;
|
|
1610
1533
|
emitAnalytics("mcp_media_upload_completed", item.mediaKind);
|
|
1611
1534
|
} catch (error) {
|
|
1612
1535
|
const failure = safeFailure(error);
|
|
1613
1536
|
item.status = "failed";
|
|
1614
1537
|
item.error = failure.message;
|
|
1615
1538
|
item.failureStage = failure.stage;
|
|
1539
|
+
item.failureCode = failure.code;
|
|
1540
|
+
void releaseUploadSlot(item);
|
|
1616
1541
|
emitAnalytics("mcp_media_upload_error", item.mediaKind, {
|
|
1617
1542
|
failureStage: failure.stage,
|
|
1618
1543
|
errorType: failure.code,
|
|
@@ -1623,6 +1548,10 @@
|
|
|
1623
1548
|
"danger",
|
|
1624
1549
|
true,
|
|
1625
1550
|
);
|
|
1551
|
+
} else if (failure.code === "resolve_rejected") {
|
|
1552
|
+
// Permanent: the server already refused this exact file. Offering a
|
|
1553
|
+
// retry here is what let one rejection look like nine flaky ones.
|
|
1554
|
+
requireFallback(failure.message, "danger");
|
|
1626
1555
|
} else if (failure.stage !== "validation") {
|
|
1627
1556
|
requireFallback(
|
|
1628
1557
|
"La carga no terminó. Puedes reintentarlo o continuar desde el enlace seguro.",
|
|
@@ -1685,39 +1614,77 @@
|
|
|
1685
1614
|
return inspection;
|
|
1686
1615
|
}
|
|
1687
1616
|
|
|
1688
|
-
|
|
1689
|
-
|
|
1617
|
+
/**
|
|
1618
|
+
* Decodes `file` into a canvas-drawable source WITHOUT a `blob:` URL.
|
|
1619
|
+
*
|
|
1620
|
+
* Host sandboxes restrict `img-src`, and ChatGPT's does not allow `blob:`
|
|
1621
|
+
* ("Loading the image 'blob:…' violates the following Content Security Policy
|
|
1622
|
+
* directive: img-src 'self' data: …"), so `<img>.src = URL.createObjectURL(file)`
|
|
1623
|
+
* is blocked, fires onerror, and every image was rejected as unreadable —
|
|
1624
|
+
* regardless of its format. `createImageBitmap` reads the File directly, so no
|
|
1625
|
+
* URL and no CSP is involved. The object-URL path stays only as a fallback for
|
|
1626
|
+
* hosts without it, where nothing blocked us in the first place.
|
|
1627
|
+
*
|
|
1628
|
+
* Callers own the result: call releaseImageSource() when done.
|
|
1629
|
+
*/
|
|
1630
|
+
async function decodeImageSource(file) {
|
|
1631
|
+
if (typeof createImageBitmap === "function") {
|
|
1632
|
+
try {
|
|
1633
|
+
return await createImageBitmap(file);
|
|
1634
|
+
} catch {
|
|
1635
|
+
// fall through to the object-URL path
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
return await new Promise((resolve, reject) => {
|
|
1690
1639
|
const image = new Image();
|
|
1691
1640
|
const objectUrl = URL.createObjectURL(file);
|
|
1692
|
-
const cleanup = () => URL.revokeObjectURL(objectUrl);
|
|
1693
1641
|
image.onload = () => {
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
const height = image.naturalHeight;
|
|
1697
|
-
if (!validDimension(width) || !validDimension(height)) {
|
|
1698
|
-
reject(
|
|
1699
|
-
validationError(
|
|
1700
|
-
"invalid_metadata",
|
|
1701
|
-
"No se pudo leer la imagen.",
|
|
1702
|
-
),
|
|
1703
|
-
);
|
|
1704
|
-
return;
|
|
1705
|
-
}
|
|
1706
|
-
resolve({ mediaKind: "image", width, height, image });
|
|
1642
|
+
URL.revokeObjectURL(objectUrl);
|
|
1643
|
+
resolve(image);
|
|
1707
1644
|
};
|
|
1708
1645
|
image.onerror = () => {
|
|
1709
|
-
|
|
1710
|
-
reject(
|
|
1711
|
-
validationError(
|
|
1712
|
-
"invalid_metadata",
|
|
1713
|
-
"No se pudo leer la imagen.",
|
|
1714
|
-
),
|
|
1715
|
-
);
|
|
1646
|
+
URL.revokeObjectURL(objectUrl);
|
|
1647
|
+
reject(new Error("decode_failed"));
|
|
1716
1648
|
};
|
|
1717
1649
|
image.src = objectUrl;
|
|
1718
1650
|
});
|
|
1719
1651
|
}
|
|
1720
1652
|
|
|
1653
|
+
function releaseImageSource(source) {
|
|
1654
|
+
if (source && typeof source.close === "function") source.close();
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function sourceDimensions(source) {
|
|
1658
|
+
return {
|
|
1659
|
+
width: source.width ?? source.naturalWidth,
|
|
1660
|
+
height: source.height ?? source.naturalHeight,
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
async function inspectImage(file) {
|
|
1665
|
+
let source;
|
|
1666
|
+
try {
|
|
1667
|
+
source = await decodeImageSource(file);
|
|
1668
|
+
} catch {
|
|
1669
|
+
throw validationError(
|
|
1670
|
+
"invalid_metadata",
|
|
1671
|
+
"No se pudo leer la imagen.",
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
try {
|
|
1675
|
+
const { width, height } = sourceDimensions(source);
|
|
1676
|
+
if (!validDimension(width) || !validDimension(height)) {
|
|
1677
|
+
throw validationError(
|
|
1678
|
+
"invalid_metadata",
|
|
1679
|
+
"No se pudo leer la imagen.",
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
return { mediaKind: "image", width, height };
|
|
1683
|
+
} finally {
|
|
1684
|
+
releaseImageSource(source);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1721
1688
|
function inspectVideo(file) {
|
|
1722
1689
|
return new Promise((resolve, reject) => {
|
|
1723
1690
|
const video = document.createElement("video");
|
|
@@ -1772,6 +1739,15 @@
|
|
|
1772
1739
|
|
|
1773
1740
|
async function compressImage(file, inspection) {
|
|
1774
1741
|
if (file.size <= IMAGE_COMPRESSION_THRESHOLD_BYTES) return file;
|
|
1742
|
+
// Decoded here rather than carried over from inspectImage so the source's
|
|
1743
|
+
// lifetime never spans two functions: an ImageBitmap must be closed, and only
|
|
1744
|
+
// oversized files reach this path at all.
|
|
1745
|
+
let source;
|
|
1746
|
+
try {
|
|
1747
|
+
source = await decodeImageSource(file);
|
|
1748
|
+
} catch {
|
|
1749
|
+
return file;
|
|
1750
|
+
}
|
|
1775
1751
|
try {
|
|
1776
1752
|
const scale = Math.min(
|
|
1777
1753
|
1,
|
|
@@ -1791,13 +1767,7 @@
|
|
|
1791
1767
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
1792
1768
|
context.restore();
|
|
1793
1769
|
}
|
|
1794
|
-
context.drawImage(
|
|
1795
|
-
inspection.image,
|
|
1796
|
-
0,
|
|
1797
|
-
0,
|
|
1798
|
-
canvas.width,
|
|
1799
|
-
canvas.height,
|
|
1800
|
-
);
|
|
1770
|
+
context.drawImage(source, 0, 0, canvas.width, canvas.height);
|
|
1801
1771
|
let blob = await canvasBlob(canvas, file.type);
|
|
1802
1772
|
if (
|
|
1803
1773
|
file.type === "image/png" &&
|
|
@@ -1830,6 +1800,8 @@
|
|
|
1830
1800
|
safeError.safeMessage = "No se pudo comprimir la imagen.";
|
|
1831
1801
|
safeError.safeStage = "compression";
|
|
1832
1802
|
throw safeError;
|
|
1803
|
+
} finally {
|
|
1804
|
+
releaseImageSource(source);
|
|
1833
1805
|
}
|
|
1834
1806
|
}
|
|
1835
1807
|
|
|
@@ -1858,6 +1830,9 @@
|
|
|
1858
1830
|
item.uploadFile,
|
|
1859
1831
|
item.uploadId,
|
|
1860
1832
|
);
|
|
1833
|
+
// From here the server holds a slot for this assetId until the
|
|
1834
|
+
// object lands or the TTL expires.
|
|
1835
|
+
item.slotReserved = true;
|
|
1861
1836
|
await putFileWithProgress(
|
|
1862
1837
|
resolved.uploadUrl,
|
|
1863
1838
|
item.uploadFile,
|
|
@@ -1870,6 +1845,7 @@
|
|
|
1870
1845
|
return;
|
|
1871
1846
|
} catch (error) {
|
|
1872
1847
|
if (error && error.message === "session_expired") throw error;
|
|
1848
|
+
if (error && error.permanent) throw error;
|
|
1873
1849
|
if (state.disposed || state.workCancelled) throw error;
|
|
1874
1850
|
lastError = error;
|
|
1875
1851
|
}
|
|
@@ -1887,6 +1863,96 @@
|
|
|
1887
1863
|
throw error;
|
|
1888
1864
|
}
|
|
1889
1865
|
|
|
1866
|
+
/**
|
|
1867
|
+
* Classifies a failed resolve.
|
|
1868
|
+
*
|
|
1869
|
+
* 410 means the upload link is spent and the whole session is dead; any
|
|
1870
|
+
* other 4xx means the API refused this particular file (unsupported type,
|
|
1871
|
+
* session already at its file limit). Both are deterministic, and
|
|
1872
|
+
* retrying them three times only produced three identical failures before
|
|
1873
|
+
* telling the user to "retry" something that could never succeed. Only a
|
|
1874
|
+
* server fault or a network blip earns another attempt, so anything below
|
|
1875
|
+
* 500 stops at once and carries the server's own reason to the card.
|
|
1876
|
+
*/
|
|
1877
|
+
async function resolveFailure(response) {
|
|
1878
|
+
const status = response.status;
|
|
1879
|
+
const expired = status === 401 || status === 410;
|
|
1880
|
+
const error = new Error(
|
|
1881
|
+
expired ? "session_expired" : "resolve_rejected",
|
|
1882
|
+
);
|
|
1883
|
+
error.safeCode = expired ? "session_expired" : "resolve_rejected";
|
|
1884
|
+
error.safeStage = "resolve";
|
|
1885
|
+
error.permanent = status < 500;
|
|
1886
|
+
error.safeMessage = await resolveFailureMessage(response);
|
|
1887
|
+
return error;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
async function resolveFailureMessage(response) {
|
|
1891
|
+
// Expiry means one thing and the App says it better than the API does;
|
|
1892
|
+
// a per-file rejection is the opposite, so there the server's own
|
|
1893
|
+
// reason is the only useful text and the copy below is just a floor.
|
|
1894
|
+
if (response.status === 401 || response.status === 410)
|
|
1895
|
+
return "La sesión expiró. Abre el enlace seguro para crear otra.";
|
|
1896
|
+
const fallback =
|
|
1897
|
+
response.status === 413
|
|
1898
|
+
? "El archivo es demasiado grande para esta carga."
|
|
1899
|
+
: response.status < 500
|
|
1900
|
+
? "ProductMaker no aceptó este archivo."
|
|
1901
|
+
: "No pudimos preparar la carga. Reintenta o abre la carga segura.";
|
|
1902
|
+
try {
|
|
1903
|
+
const body = await response.json();
|
|
1904
|
+
const detail = isRecord(body)
|
|
1905
|
+
? boundedString(
|
|
1906
|
+
typeof body.message === "string"
|
|
1907
|
+
? body.message
|
|
1908
|
+
: Array.isArray(body.message)
|
|
1909
|
+
? body.message.join(", ")
|
|
1910
|
+
: "",
|
|
1911
|
+
200,
|
|
1912
|
+
)
|
|
1913
|
+
: "";
|
|
1914
|
+
return detail || fallback;
|
|
1915
|
+
} catch {
|
|
1916
|
+
return fallback;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Hands the session's slot back when an upload will never finish.
|
|
1922
|
+
*
|
|
1923
|
+
* A resolved reservation is held for its full TTL, so one failed or
|
|
1924
|
+
* removed file used to keep a seat for fifteen minutes and every later
|
|
1925
|
+
* pick in that session was refused as "maximum number of files".
|
|
1926
|
+
*
|
|
1927
|
+
* Best effort: this runs on a path that is already failing, and the TTL
|
|
1928
|
+
* still frees the slot on its own, so no error here may reach the user.
|
|
1929
|
+
*/
|
|
1930
|
+
async function releaseUploadSlot(item) {
|
|
1931
|
+
if (!item || !item.slotReserved || !state.session) return;
|
|
1932
|
+
item.slotReserved = false;
|
|
1933
|
+
try {
|
|
1934
|
+
const uploadPage = new URL(state.session.uploadPageUrl);
|
|
1935
|
+
const releaseUrl = new URL(
|
|
1936
|
+
state.session.resolveEndpoint.replace(/\/resolve$/, "/release"),
|
|
1937
|
+
uploadPage.origin,
|
|
1938
|
+
);
|
|
1939
|
+
if (!safeReleaseEndpointUrl(releaseUrl)) return;
|
|
1940
|
+
const token =
|
|
1941
|
+
uploadPage.searchParams.get("token") ||
|
|
1942
|
+
new URLSearchParams(uploadPage.hash.slice(1)).get("token");
|
|
1943
|
+
if (!token) return;
|
|
1944
|
+
await fetch(releaseUrl.toString(), {
|
|
1945
|
+
method: "POST",
|
|
1946
|
+
headers: { "content-type": "application/json" },
|
|
1947
|
+
body: JSON.stringify({ token, uploadId: item.uploadId }),
|
|
1948
|
+
credentials: "omit",
|
|
1949
|
+
redirect: "error",
|
|
1950
|
+
});
|
|
1951
|
+
} catch {
|
|
1952
|
+
// The reservation expires on its own; never surface this.
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1890
1956
|
async function resolveUpload(file, uploadId) {
|
|
1891
1957
|
const uploadPage = new URL(state.session.uploadPageUrl);
|
|
1892
1958
|
const resolveUrl = new URL(
|
|
@@ -1921,13 +1987,7 @@
|
|
|
1921
1987
|
signal: activeAbortSignal(),
|
|
1922
1988
|
});
|
|
1923
1989
|
if (!response.ok) {
|
|
1924
|
-
|
|
1925
|
-
response.status === 401 || response.status === 410
|
|
1926
|
-
? "session_expired"
|
|
1927
|
-
: "resolve_failed",
|
|
1928
|
-
);
|
|
1929
|
-
error.safeStage = "resolve";
|
|
1930
|
-
throw error;
|
|
1990
|
+
throw await resolveFailure(response);
|
|
1931
1991
|
}
|
|
1932
1992
|
const body = await response.json();
|
|
1933
1993
|
if (!isRecord(body)) throw new Error("invalid_resolve_response");
|
|
@@ -2025,6 +2085,31 @@
|
|
|
2025
2085
|
return officialApi || sameOriginWebProxy || localApi || configuredApi;
|
|
2026
2086
|
}
|
|
2027
2087
|
|
|
2088
|
+
/**
|
|
2089
|
+
* Same origin rules as the resolver, for the sibling /release path.
|
|
2090
|
+
*
|
|
2091
|
+
* The web fallback page proxies through /api/upload-media/resolve and has
|
|
2092
|
+
* no release route, so a session using it simply skips the release and
|
|
2093
|
+
* lets the TTL free the slot. The inline App always resolves against the
|
|
2094
|
+
* API directly, which is where an abandoned reservation actually hurts.
|
|
2095
|
+
*/
|
|
2096
|
+
function safeReleaseEndpointUrl(url) {
|
|
2097
|
+
if (url.username || url.password) return false;
|
|
2098
|
+
if (url.search || url.hash) return false;
|
|
2099
|
+
const releasePath = "/v1/uploaded-assets/sessions/release";
|
|
2100
|
+
if (url.pathname !== releasePath) return false;
|
|
2101
|
+
if (url.origin === "https://api.productmaker.app") return true;
|
|
2102
|
+
if (
|
|
2103
|
+
url.protocol === "http:" &&
|
|
2104
|
+
["localhost", "127.0.0.1", "::1"].includes(url.hostname)
|
|
2105
|
+
)
|
|
2106
|
+
return true;
|
|
2107
|
+
return (
|
|
2108
|
+
Boolean(CONFIGURED_RESOLVE_ENDPOINT) &&
|
|
2109
|
+
url.origin === new URL(CONFIGURED_RESOLVE_ENDPOINT).origin
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2028
2113
|
function putFileWithProgress(url, file, onProgress) {
|
|
2029
2114
|
return new Promise((resolve, reject) => {
|
|
2030
2115
|
const request = new XMLHttpRequest();
|
|
@@ -2094,7 +2179,8 @@
|
|
|
2094
2179
|
!item ||
|
|
2095
2180
|
item.status !== "failed" ||
|
|
2096
2181
|
!item.file ||
|
|
2097
|
-
item.failureStage === "validation"
|
|
2182
|
+
item.failureStage === "validation" ||
|
|
2183
|
+
item.failureCode === "resolve_rejected"
|
|
2098
2184
|
)
|
|
2099
2185
|
return;
|
|
2100
2186
|
item.attempts = 0;
|
|
@@ -2104,29 +2190,38 @@
|
|
|
2104
2190
|
|
|
2105
2191
|
function removeItem(index) {
|
|
2106
2192
|
if (state.busyBatch) return;
|
|
2193
|
+
// A rehydrated item is a receipt for bytes already in staging; dropping
|
|
2194
|
+
// the card here would hide an asset the agent can still see and use.
|
|
2195
|
+
if (state.items[index]?.rehydrated) return;
|
|
2107
2196
|
const [item] = state.items.splice(index, 1);
|
|
2108
|
-
if (item)
|
|
2197
|
+
if (item) {
|
|
2198
|
+
revokePreviewUrl(item);
|
|
2199
|
+
if (item.status !== "uploaded") void releaseUploadSlot(item);
|
|
2200
|
+
}
|
|
2109
2201
|
renderItems();
|
|
2110
2202
|
setPickerAvailability();
|
|
2111
2203
|
}
|
|
2112
2204
|
|
|
2113
2205
|
function revokePreviewUrl(item) {
|
|
2114
2206
|
if (
|
|
2115
|
-
item.previewUrl &&
|
|
2207
|
+
item.previewUrl.startsWith("blob:") &&
|
|
2116
2208
|
typeof window.URL.revokeObjectURL === "function"
|
|
2117
2209
|
) {
|
|
2118
2210
|
window.URL.revokeObjectURL(item.previewUrl);
|
|
2119
|
-
item.previewUrl = "";
|
|
2120
2211
|
}
|
|
2212
|
+
item.previewUrl = "";
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function previewableImage(item) {
|
|
2216
|
+
return Boolean(
|
|
2217
|
+
item.file &&
|
|
2218
|
+
item.mediaKind === "image" &&
|
|
2219
|
+
IMAGE_TYPES.has(item.file.type),
|
|
2220
|
+
);
|
|
2121
2221
|
}
|
|
2122
2222
|
|
|
2123
2223
|
function previewUrlFor(item) {
|
|
2124
|
-
if (
|
|
2125
|
-
!item.file ||
|
|
2126
|
-
item.mediaKind !== "image" ||
|
|
2127
|
-
!IMAGE_TYPES.has(item.file.type)
|
|
2128
|
-
)
|
|
2129
|
-
return "";
|
|
2224
|
+
if (!previewableImage(item)) return "";
|
|
2130
2225
|
if (
|
|
2131
2226
|
!item.previewUrl &&
|
|
2132
2227
|
typeof window.URL.createObjectURL === "function"
|
|
@@ -2136,6 +2231,44 @@
|
|
|
2136
2231
|
return item.previewUrl;
|
|
2137
2232
|
}
|
|
2138
2233
|
|
|
2234
|
+
/**
|
|
2235
|
+
* Upgrades the thumbnail from the `blob:` object URL to a small `data:` URL.
|
|
2236
|
+
*
|
|
2237
|
+
* ChatGPT's sandbox blocks `blob:` in `img-src` (see decodeImageSource), so the
|
|
2238
|
+
* object URL renders as a broken image there; `data:` is allowed. This only ever
|
|
2239
|
+
* replaces a working-or-broken preview with a working one, so a failure here is
|
|
2240
|
+
* cosmetic and leaves the existing behaviour untouched on hosts that allow
|
|
2241
|
+
* `blob:`. Downscaled first so the inline URL stays a few KB, not the whole file.
|
|
2242
|
+
*/
|
|
2243
|
+
async function upgradePreviewToDataUrl(item) {
|
|
2244
|
+
if (!previewableImage(item)) return;
|
|
2245
|
+
let source;
|
|
2246
|
+
try {
|
|
2247
|
+
source = await decodeImageSource(item.file);
|
|
2248
|
+
const { width, height } = sourceDimensions(source);
|
|
2249
|
+
if (!validDimension(width) || !validDimension(height)) return;
|
|
2250
|
+
const scale = Math.min(
|
|
2251
|
+
1,
|
|
2252
|
+
PREVIEW_MAX_DIMENSION / Math.max(width, height),
|
|
2253
|
+
);
|
|
2254
|
+
const canvas = document.createElement("canvas");
|
|
2255
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
2256
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
2257
|
+
const context = canvas.getContext("2d");
|
|
2258
|
+
if (!context) return;
|
|
2259
|
+
context.drawImage(source, 0, 0, canvas.width, canvas.height);
|
|
2260
|
+
const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
|
|
2261
|
+
if (!dataUrl.startsWith("data:image/")) return;
|
|
2262
|
+
revokePreviewUrl(item);
|
|
2263
|
+
item.previewUrl = dataUrl;
|
|
2264
|
+
renderItems();
|
|
2265
|
+
} catch {
|
|
2266
|
+
// Cosmetic only: the object-URL preview and the upload both stand on their own.
|
|
2267
|
+
} finally {
|
|
2268
|
+
releaseImageSource(source);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2139
2272
|
function renderItems() {
|
|
2140
2273
|
const fragment = document.createDocumentFragment();
|
|
2141
2274
|
state.items.forEach((item, index) => {
|
|
@@ -2186,6 +2319,7 @@
|
|
|
2186
2319
|
if (
|
|
2187
2320
|
item.status === "failed" &&
|
|
2188
2321
|
item.failureStage !== "validation" &&
|
|
2322
|
+
item.failureCode !== "resolve_rejected" &&
|
|
2189
2323
|
item.file
|
|
2190
2324
|
) {
|
|
2191
2325
|
const retry = document.createElement("button");
|
|
@@ -2232,7 +2366,6 @@
|
|
|
2232
2366
|
fragment.append(card);
|
|
2233
2367
|
});
|
|
2234
2368
|
elements.fileList.replaceChildren(fragment);
|
|
2235
|
-
renderCompletionAction();
|
|
2236
2369
|
scheduleIntrinsicSizeReport(true);
|
|
2237
2370
|
}
|
|
2238
2371
|
|
|
@@ -2244,10 +2377,54 @@
|
|
|
2244
2377
|
return "Pendiente";
|
|
2245
2378
|
}
|
|
2246
2379
|
|
|
2247
|
-
|
|
2380
|
+
function uploadedStatusMessage(assets) {
|
|
2381
|
+
return assets.length === 1
|
|
2382
|
+
? "Archivo listo. Escribe en el chat para continuar."
|
|
2383
|
+
: `${assets.length} archivos listos. Escribe en el chat para continuar.`;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/**
|
|
2387
|
+
* Rebuilds the file list from assets the session already holds, so a
|
|
2388
|
+
* re-rendered App shows what was uploaded instead of an empty picker.
|
|
2389
|
+
* These items have no local File: they cannot be retried or removed,
|
|
2390
|
+
* because the bytes live in the staging bucket and not in this page.
|
|
2391
|
+
*/
|
|
2392
|
+
function adoptAlreadyUploadedAssets(assets) {
|
|
2393
|
+
for (const item of state.items) revokePreviewUrl(item);
|
|
2394
|
+
state.items = assets.map((asset, index) => ({
|
|
2395
|
+
id: `rehydrated-${asset.assetId}`,
|
|
2396
|
+
uploadId: asset.assetId,
|
|
2397
|
+
file: null,
|
|
2398
|
+
displayName: `Archivo ${index + 1}`,
|
|
2399
|
+
uploadFile: null,
|
|
2400
|
+
mediaKind: asset.mediaKind,
|
|
2401
|
+
status: "uploaded",
|
|
2402
|
+
progress: 100,
|
|
2403
|
+
attempts: 0,
|
|
2404
|
+
error: "",
|
|
2405
|
+
failureStage: "",
|
|
2406
|
+
assetId: asset.assetId,
|
|
2407
|
+
previewUrl: "",
|
|
2408
|
+
rehydrated: true,
|
|
2409
|
+
}));
|
|
2410
|
+
renderItems();
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/**
|
|
2414
|
+
* Reads the staging session's real contents.
|
|
2415
|
+
*
|
|
2416
|
+
* `onMount` marks the call the App makes when a host re-renders a saved
|
|
2417
|
+
* tool result — reopening the chat, switching away and back. That render
|
|
2418
|
+
* starts from an empty widget with no items, so without this the user is
|
|
2419
|
+
* shown a fresh picker for files they already uploaded. On mount an empty
|
|
2420
|
+
* session is the normal case (nothing uploaded yet) and must stay silent;
|
|
2421
|
+
* after an upload the same emptiness means the upload did not land.
|
|
2422
|
+
*/
|
|
2423
|
+
async function refreshSessionStatus({ onMount = false } = {}) {
|
|
2248
2424
|
if (!state.session) return;
|
|
2249
2425
|
const requestedSessionId = state.session.sessionId;
|
|
2250
|
-
|
|
2426
|
+
if (!onMount)
|
|
2427
|
+
setSessionStatus("Comprobando el staging privado…", "neutral");
|
|
2251
2428
|
try {
|
|
2252
2429
|
const result = await callReadOnlyTool(state.session.checkToolName, {
|
|
2253
2430
|
sessionId: requestedSessionId,
|
|
@@ -2259,9 +2436,8 @@
|
|
|
2259
2436
|
)
|
|
2260
2437
|
return;
|
|
2261
2438
|
const status = parseStatusResult(result);
|
|
2262
|
-
state.assets = status.assets;
|
|
2263
|
-
renderCompletionAction();
|
|
2264
2439
|
if (!status.assets.length) {
|
|
2440
|
+
if (onMount) return;
|
|
2265
2441
|
requireFallback(
|
|
2266
2442
|
"La sesión sigue pendiente. Continúa desde el enlace seguro para terminar la carga.",
|
|
2267
2443
|
"warning",
|
|
@@ -2269,33 +2445,38 @@
|
|
|
2269
2445
|
return;
|
|
2270
2446
|
}
|
|
2271
2447
|
|
|
2448
|
+
if (onMount) {
|
|
2449
|
+
// The probe is async: the user can pick a file while it is in
|
|
2450
|
+
// flight. Their selection is the live truth, so never overwrite it
|
|
2451
|
+
// with the snapshot this call started from.
|
|
2452
|
+
if (state.items.length > 0 || state.busyBatch) return;
|
|
2453
|
+
adoptAlreadyUploadedAssets(status.assets);
|
|
2454
|
+
setSessionStatus(uploadedStatusMessage(status.assets), "success");
|
|
2455
|
+
setPickerAvailability();
|
|
2456
|
+
scheduleIntrinsicSizeReport(true);
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2272
2460
|
const partial =
|
|
2273
2461
|
status.status === "pending" ||
|
|
2274
2462
|
(state.items.length > 0 &&
|
|
2275
2463
|
status.assets.length < state.items.length);
|
|
2276
|
-
const canContinueInChat = canMessageHost();
|
|
2277
2464
|
if (partial) {
|
|
2278
2465
|
requireFallback(
|
|
2279
2466
|
"Carga parcial verificada. Puedes continuar desde el enlace seguro si algún archivo no terminó.",
|
|
2280
2467
|
"warning",
|
|
2281
2468
|
);
|
|
2282
|
-
} else if (!canContinueInChat) {
|
|
2283
|
-
requireFallback(
|
|
2284
|
-
"Carga completada. Este chat no puede continuar desde el widget. Abre el enlace seguro para seguir.",
|
|
2285
|
-
"warning",
|
|
2286
|
-
);
|
|
2287
2469
|
} else {
|
|
2288
2470
|
setFallbackVisibility(state.fallbackRequired);
|
|
2289
2471
|
}
|
|
2290
2472
|
setSessionStatus(
|
|
2291
2473
|
partial
|
|
2292
2474
|
? "Carga parcial verificada. Continuaremos solo con los assets listos."
|
|
2293
|
-
:
|
|
2294
|
-
|
|
2295
|
-
: "Carga completada. Abre el enlace seguro para seguir.",
|
|
2296
|
-
partial || !canContinueInChat ? "warning" : "success",
|
|
2475
|
+
: uploadedStatusMessage(status.assets),
|
|
2476
|
+
partial ? "warning" : "success",
|
|
2297
2477
|
);
|
|
2298
2478
|
} catch (error) {
|
|
2479
|
+
if (onMount) return;
|
|
2299
2480
|
const expired =
|
|
2300
2481
|
sessionExpired() ||
|
|
2301
2482
|
(error && /expired|expirad/i.test(String(error.message)));
|
|
@@ -2374,82 +2555,6 @@
|
|
|
2374
2555
|
return { status, assets };
|
|
2375
2556
|
}
|
|
2376
2557
|
|
|
2377
|
-
async function notifyChatUploadComplete() {
|
|
2378
|
-
if (
|
|
2379
|
-
state.completionNotified ||
|
|
2380
|
-
state.completionNotificationInFlight ||
|
|
2381
|
-
!state.session
|
|
2382
|
-
)
|
|
2383
|
-
return;
|
|
2384
|
-
const completionSessionId = state.session.sessionId;
|
|
2385
|
-
state.completionNotificationInFlight = true;
|
|
2386
|
-
setPickerAvailability();
|
|
2387
|
-
renderCompletionAction();
|
|
2388
|
-
const assets = state.assets
|
|
2389
|
-
.map((asset) => `${asset.mediaKind} asset_id ${asset.assetId}`)
|
|
2390
|
-
.join(", ");
|
|
2391
|
-
const message = `The ProductMaker upload is complete. Continue my previous request using these uploaded assets: ${assets}. Do not ask me to upload them again. Ask only for the remaining inputs required by my original request.`;
|
|
2392
|
-
try {
|
|
2393
|
-
const bridge = openAiBridge();
|
|
2394
|
-
if (bridge && typeof bridge.sendFollowUpMessage === "function") {
|
|
2395
|
-
await bridge.sendFollowUpMessage({ prompt: message });
|
|
2396
|
-
if (
|
|
2397
|
-
!state.session ||
|
|
2398
|
-
state.session.sessionId !== completionSessionId
|
|
2399
|
-
)
|
|
2400
|
-
return;
|
|
2401
|
-
state.completionNotified = true;
|
|
2402
|
-
setPickerAvailability();
|
|
2403
|
-
renderCompletionAction();
|
|
2404
|
-
return;
|
|
2405
|
-
}
|
|
2406
|
-
if (state.hostCanMessage) {
|
|
2407
|
-
const messageResult = await rpcRequest("ui/message", {
|
|
2408
|
-
role: "user",
|
|
2409
|
-
content: [{ type: "text", text: message }],
|
|
2410
|
-
});
|
|
2411
|
-
if (isRecord(messageResult) && messageResult.isError === true)
|
|
2412
|
-
throw new Error("host_message_rejected");
|
|
2413
|
-
if (
|
|
2414
|
-
!state.session ||
|
|
2415
|
-
state.session.sessionId !== completionSessionId
|
|
2416
|
-
)
|
|
2417
|
-
return;
|
|
2418
|
-
state.completionNotified = true;
|
|
2419
|
-
setPickerAvailability();
|
|
2420
|
-
renderCompletionAction();
|
|
2421
|
-
return;
|
|
2422
|
-
}
|
|
2423
|
-
setSessionStatus(
|
|
2424
|
-
"La carga terminó, pero este host no puede continuar el chat desde el widget. Vuelve al chat para seguir.",
|
|
2425
|
-
"warning",
|
|
2426
|
-
);
|
|
2427
|
-
} catch {
|
|
2428
|
-
if (
|
|
2429
|
-
!state.session ||
|
|
2430
|
-
state.session.sessionId !== completionSessionId
|
|
2431
|
-
)
|
|
2432
|
-
return;
|
|
2433
|
-
setSessionStatus(
|
|
2434
|
-
"No pudimos continuar el chat. Intenta nuevamente.",
|
|
2435
|
-
"danger",
|
|
2436
|
-
);
|
|
2437
|
-
} finally {
|
|
2438
|
-
if (
|
|
2439
|
-
state.session &&
|
|
2440
|
-
state.session.sessionId === completionSessionId
|
|
2441
|
-
) {
|
|
2442
|
-
state.completionNotificationInFlight = false;
|
|
2443
|
-
setPickerAvailability();
|
|
2444
|
-
renderCompletionAction();
|
|
2445
|
-
}
|
|
2446
|
-
}
|
|
2447
|
-
}
|
|
2448
|
-
|
|
2449
|
-
elements.completeButton.addEventListener("click", () => {
|
|
2450
|
-
if (canCompleteUpload()) void notifyChatUploadComplete();
|
|
2451
|
-
});
|
|
2452
|
-
|
|
2453
2558
|
document.addEventListener("DOMContentLoaded", initialize, {
|
|
2454
2559
|
once: true,
|
|
2455
2560
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@productmaker/mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Product Maker MCP server — cost products, generate dropshipping landing pages, UGC videos, and ad creatives, and publish to Shopify and Meta Ads from Claude, ChatGPT, or Gemini.",
|