@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 CHANGED
@@ -8906,7 +8906,7 @@ var PRODUCTMAKER_CAPABILITY_REGISTRY = deepFreeze([
8906
8906
  ], [
8907
8907
  "creative_variant"
8908
8908
  ]),
8909
- defineCapability("create_product_task", "1c2c0153391172e057cbab25c16c79222611f37b9a6ec41a5135aeadec0c574f", "credit_consuming", "creative", [
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", "e35e6373ddb92ba09e4adf1acd79387767387817a7b3ec8d83b464f884508fe1", "credit_consuming", "creative", [
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", "6bcd073df66023f8108a671b3027e1f35e9596cf9481ddf13a0282b4c56f5f34", "credit_consuming", "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, poll real task status with get_task_status, and answer explicit pauses through resolve_pending_action. Verify actual outputs before calling the creative stage complete.",
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.1.1";
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 llama `resolve_pending_action` repitiendo 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) => {
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"}. Cuando termines, pulsa **OK** dentro del App para que ProductMaker contin\xFAe en este chat.`,
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. Tras OK, 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.",
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.1.1";
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", "1c2c0153391172e057cbab25c16c79222611f37b9a6ec41a5135aeadec0c574f", "credit_consuming", "creative", [
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", "e35e6373ddb92ba09e4adf1acd79387767387817a7b3ec8d83b464f884508fe1", "credit_consuming", "creative", [
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", "6bcd073df66023f8108a671b3027e1f35e9596cf9481ddf13a0282b4c56f5f34", "credit_consuming", "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 llama `resolve_pending_action` repitiendo 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) => {
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"}. Cuando termines, pulsa **OK** dentro del App para que ProductMaker contin\xFAe en este chat.`,
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. Tras OK, 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.",
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, poll real task status with get_task_status, and answer explicit pauses through resolve_pending_action. Verify actual outputs before calling the creative stage complete.",
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
- function inspectImage(file) {
1689
- return new Promise((resolve, reject) => {
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
- cleanup();
1695
- const width = image.naturalWidth;
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
- cleanup();
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
- const error = new Error(
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) revokePreviewUrl(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
- async function refreshSessionStatus() {
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
- setSessionStatus("Comprobando el staging privado…", "neutral");
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
- : canContinueInChat
2294
- ? "Carga completada."
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.1.1",
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.",