@productmaker/mcp 2.3.2 → 2.8.3
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/README.md +32 -2
- package/dist/http.js +1283 -477
- package/dist/stdio.js +1283 -477
- package/dist/ui/meta-upload-app.html +2268 -0
- package/package.json +5 -4
|
@@ -0,0 +1,2268 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="es">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>Carga segura para Meta · ProductMaker</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: light dark;
|
|
11
|
+
font-family:
|
|
12
|
+
Inter,
|
|
13
|
+
ui-sans-serif,
|
|
14
|
+
system-ui,
|
|
15
|
+
-apple-system,
|
|
16
|
+
BlinkMacSystemFont,
|
|
17
|
+
"Segoe UI",
|
|
18
|
+
sans-serif;
|
|
19
|
+
--pm-bg: #f6f8fb;
|
|
20
|
+
--pm-card: #ffffff;
|
|
21
|
+
--pm-card-muted: #f0f4f8;
|
|
22
|
+
--pm-text: #172033;
|
|
23
|
+
--pm-muted: #667085;
|
|
24
|
+
--pm-line: #d7deea;
|
|
25
|
+
--pm-primary: #5b4ef5;
|
|
26
|
+
--pm-primary-strong: #4638dc;
|
|
27
|
+
--pm-primary-soft: #eeecff;
|
|
28
|
+
--pm-success: #087a55;
|
|
29
|
+
--pm-success-soft: #e9f9f2;
|
|
30
|
+
--pm-danger: #b42318;
|
|
31
|
+
--pm-danger-soft: #fff0ee;
|
|
32
|
+
--pm-warning: #8a4b08;
|
|
33
|
+
--pm-warning-soft: #fff6e5;
|
|
34
|
+
--pm-shadow: 0 18px 45px rgb(27 36 66 / 10%);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@media (prefers-color-scheme: dark) {
|
|
38
|
+
:root {
|
|
39
|
+
--pm-bg: #0f1320;
|
|
40
|
+
--pm-card: #171c2b;
|
|
41
|
+
--pm-card-muted: #202638;
|
|
42
|
+
--pm-text: #f3f5fb;
|
|
43
|
+
--pm-muted: #a8b0c2;
|
|
44
|
+
--pm-line: #343c50;
|
|
45
|
+
--pm-primary: #8c83ff;
|
|
46
|
+
--pm-primary-strong: #a59eff;
|
|
47
|
+
--pm-primary-soft: #292552;
|
|
48
|
+
--pm-success: #5ee1ae;
|
|
49
|
+
--pm-success-soft: #153c32;
|
|
50
|
+
--pm-danger: #ff8b83;
|
|
51
|
+
--pm-danger-soft: #482624;
|
|
52
|
+
--pm-warning: #ffc46b;
|
|
53
|
+
--pm-warning-soft: #49361d;
|
|
54
|
+
--pm-shadow: 0 18px 45px rgb(0 0 0 / 28%);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
* {
|
|
59
|
+
box-sizing: border-box;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
body {
|
|
63
|
+
margin: 0;
|
|
64
|
+
background: var(--pm-bg);
|
|
65
|
+
color: var(--pm-text);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
button,
|
|
69
|
+
input,
|
|
70
|
+
select,
|
|
71
|
+
textarea {
|
|
72
|
+
font: inherit;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
button,
|
|
76
|
+
select,
|
|
77
|
+
a {
|
|
78
|
+
-webkit-tap-highlight-color: transparent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
button:focus-visible,
|
|
82
|
+
select:focus-visible,
|
|
83
|
+
textarea:focus-visible,
|
|
84
|
+
a:focus-visible,
|
|
85
|
+
[tabindex]:focus-visible {
|
|
86
|
+
outline: 3px solid
|
|
87
|
+
color-mix(in srgb, var(--pm-primary) 35%, transparent);
|
|
88
|
+
outline-offset: 2px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.app {
|
|
92
|
+
width: min(100%, 760px);
|
|
93
|
+
margin: 0 auto;
|
|
94
|
+
padding: 18px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.card {
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
border: 1px solid var(--pm-line);
|
|
100
|
+
border-radius: 24px;
|
|
101
|
+
background: var(--pm-card);
|
|
102
|
+
box-shadow: var(--pm-shadow);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.hero,
|
|
106
|
+
.section {
|
|
107
|
+
padding: 22px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.hero {
|
|
111
|
+
border-bottom: 1px solid var(--pm-line);
|
|
112
|
+
background:
|
|
113
|
+
radial-gradient(
|
|
114
|
+
circle at 100% 0%,
|
|
115
|
+
var(--pm-primary-soft),
|
|
116
|
+
transparent 42%
|
|
117
|
+
),
|
|
118
|
+
var(--pm-card);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.eyebrow {
|
|
122
|
+
display: inline-flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
gap: 7px;
|
|
125
|
+
margin: 0 0 10px;
|
|
126
|
+
color: var(--pm-primary-strong);
|
|
127
|
+
font-size: 12px;
|
|
128
|
+
font-weight: 800;
|
|
129
|
+
letter-spacing: 0.08em;
|
|
130
|
+
text-transform: uppercase;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.eyebrow-dot {
|
|
134
|
+
width: 8px;
|
|
135
|
+
height: 8px;
|
|
136
|
+
border-radius: 999px;
|
|
137
|
+
background: var(--pm-primary);
|
|
138
|
+
box-shadow: 0 0 0 5px var(--pm-primary-soft);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
h1,
|
|
142
|
+
h2,
|
|
143
|
+
p {
|
|
144
|
+
margin-top: 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
h1 {
|
|
148
|
+
margin-bottom: 8px;
|
|
149
|
+
font-size: clamp(23px, 5vw, 34px);
|
|
150
|
+
line-height: 1.12;
|
|
151
|
+
letter-spacing: -0.03em;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
h2 {
|
|
155
|
+
margin-bottom: 6px;
|
|
156
|
+
font-size: 18px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.muted {
|
|
160
|
+
color: var(--pm-muted);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.hero-copy,
|
|
164
|
+
.section-copy {
|
|
165
|
+
margin-bottom: 0;
|
|
166
|
+
font-size: 14px;
|
|
167
|
+
line-height: 1.55;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.session-row {
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-wrap: wrap;
|
|
173
|
+
align-items: center;
|
|
174
|
+
gap: 8px 12px;
|
|
175
|
+
margin-top: 16px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.session-id {
|
|
179
|
+
overflow: hidden;
|
|
180
|
+
max-width: 100%;
|
|
181
|
+
padding: 7px 10px;
|
|
182
|
+
border-radius: 9px;
|
|
183
|
+
background: var(--pm-card-muted);
|
|
184
|
+
color: var(--pm-muted);
|
|
185
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
186
|
+
font-size: 11px;
|
|
187
|
+
text-overflow: ellipsis;
|
|
188
|
+
white-space: nowrap;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.fallback-link {
|
|
192
|
+
color: var(--pm-primary-strong);
|
|
193
|
+
font-size: 13px;
|
|
194
|
+
font-weight: 750;
|
|
195
|
+
text-decoration-thickness: 1px;
|
|
196
|
+
text-underline-offset: 3px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.status {
|
|
200
|
+
display: none;
|
|
201
|
+
margin: 14px 0 0;
|
|
202
|
+
padding: 10px 12px;
|
|
203
|
+
border-radius: 12px;
|
|
204
|
+
background: var(--pm-card-muted);
|
|
205
|
+
color: var(--pm-muted);
|
|
206
|
+
font-size: 13px;
|
|
207
|
+
line-height: 1.45;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.status[data-tone="success"] {
|
|
211
|
+
display: block;
|
|
212
|
+
background: var(--pm-success-soft);
|
|
213
|
+
color: var(--pm-success);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.status[data-tone="warning"] {
|
|
217
|
+
display: block;
|
|
218
|
+
background: var(--pm-warning-soft);
|
|
219
|
+
color: var(--pm-warning);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.status[data-tone="danger"] {
|
|
223
|
+
display: block;
|
|
224
|
+
background: var(--pm-danger-soft);
|
|
225
|
+
color: var(--pm-danger);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.status[data-tone="neutral"] {
|
|
229
|
+
display: block;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.section + .section {
|
|
233
|
+
border-top: 1px solid var(--pm-line);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.dropzone {
|
|
237
|
+
display: grid;
|
|
238
|
+
min-height: 190px;
|
|
239
|
+
place-items: center;
|
|
240
|
+
padding: 24px;
|
|
241
|
+
border: 2px dashed var(--pm-line);
|
|
242
|
+
border-radius: 18px;
|
|
243
|
+
background: var(--pm-card-muted);
|
|
244
|
+
text-align: center;
|
|
245
|
+
transition:
|
|
246
|
+
border-color 160ms ease,
|
|
247
|
+
background 160ms ease,
|
|
248
|
+
transform 160ms ease;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.dropzone[data-dragging="true"] {
|
|
252
|
+
border-color: var(--pm-primary);
|
|
253
|
+
background: var(--pm-primary-soft);
|
|
254
|
+
transform: translateY(-1px);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.dropzone[aria-disabled="true"] {
|
|
258
|
+
cursor: not-allowed;
|
|
259
|
+
opacity: 0.65;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.drop-icon {
|
|
263
|
+
display: grid;
|
|
264
|
+
width: 48px;
|
|
265
|
+
height: 48px;
|
|
266
|
+
margin: 0 auto 12px;
|
|
267
|
+
place-items: center;
|
|
268
|
+
border-radius: 15px;
|
|
269
|
+
background: var(--pm-primary-soft);
|
|
270
|
+
color: var(--pm-primary-strong);
|
|
271
|
+
font-size: 24px;
|
|
272
|
+
font-weight: 800;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.drop-title {
|
|
276
|
+
margin-bottom: 6px;
|
|
277
|
+
font-size: 16px;
|
|
278
|
+
font-weight: 780;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.drop-help {
|
|
282
|
+
max-width: 490px;
|
|
283
|
+
margin: 0 auto 15px;
|
|
284
|
+
color: var(--pm-muted);
|
|
285
|
+
font-size: 12px;
|
|
286
|
+
line-height: 1.5;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.native-input {
|
|
290
|
+
position: absolute;
|
|
291
|
+
width: 1px;
|
|
292
|
+
height: 1px;
|
|
293
|
+
overflow: hidden;
|
|
294
|
+
clip: rect(0 0 0 0);
|
|
295
|
+
white-space: nowrap;
|
|
296
|
+
clip-path: inset(50%);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.button {
|
|
300
|
+
display: inline-flex;
|
|
301
|
+
min-height: 42px;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: center;
|
|
304
|
+
gap: 8px;
|
|
305
|
+
padding: 10px 16px;
|
|
306
|
+
border: 1px solid transparent;
|
|
307
|
+
border-radius: 12px;
|
|
308
|
+
background: var(--pm-primary);
|
|
309
|
+
color: #ffffff;
|
|
310
|
+
cursor: pointer;
|
|
311
|
+
font-size: 13px;
|
|
312
|
+
font-weight: 780;
|
|
313
|
+
line-height: 1;
|
|
314
|
+
transition:
|
|
315
|
+
background 140ms ease,
|
|
316
|
+
opacity 140ms ease,
|
|
317
|
+
transform 140ms ease;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.button:hover:not(:disabled) {
|
|
321
|
+
background: var(--pm-primary-strong);
|
|
322
|
+
transform: translateY(-1px);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.button:disabled {
|
|
326
|
+
cursor: not-allowed;
|
|
327
|
+
opacity: 0.5;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.button.secondary {
|
|
331
|
+
border-color: var(--pm-line);
|
|
332
|
+
background: var(--pm-card);
|
|
333
|
+
color: var(--pm-text);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.button.secondary:hover:not(:disabled) {
|
|
337
|
+
background: var(--pm-card-muted);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.button.danger {
|
|
341
|
+
border-color: color-mix(in srgb, var(--pm-danger) 25%, var(--pm-line));
|
|
342
|
+
background: transparent;
|
|
343
|
+
color: var(--pm-danger);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.batch-error {
|
|
347
|
+
display: none;
|
|
348
|
+
margin: 12px 0 0;
|
|
349
|
+
color: var(--pm-danger);
|
|
350
|
+
font-size: 13px;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.batch-error:not(:empty) {
|
|
354
|
+
display: block;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.file-list {
|
|
358
|
+
display: grid;
|
|
359
|
+
gap: 10px;
|
|
360
|
+
margin-top: 16px;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.file-card {
|
|
364
|
+
padding: 13px;
|
|
365
|
+
border: 1px solid var(--pm-line);
|
|
366
|
+
border-radius: 15px;
|
|
367
|
+
background: var(--pm-card);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.file-head,
|
|
371
|
+
.file-actions,
|
|
372
|
+
.file-meta {
|
|
373
|
+
display: flex;
|
|
374
|
+
align-items: center;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.file-head {
|
|
378
|
+
justify-content: space-between;
|
|
379
|
+
gap: 12px;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.file-meta {
|
|
383
|
+
min-width: 0;
|
|
384
|
+
gap: 10px;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.file-kind {
|
|
388
|
+
display: grid;
|
|
389
|
+
width: 36px;
|
|
390
|
+
height: 36px;
|
|
391
|
+
flex: 0 0 auto;
|
|
392
|
+
place-items: center;
|
|
393
|
+
border-radius: 11px;
|
|
394
|
+
background: var(--pm-primary-soft);
|
|
395
|
+
color: var(--pm-primary-strong);
|
|
396
|
+
font-size: 17px;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.file-label {
|
|
400
|
+
margin: 0;
|
|
401
|
+
font-size: 13px;
|
|
402
|
+
font-weight: 760;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.file-state {
|
|
406
|
+
margin: 3px 0 0;
|
|
407
|
+
color: var(--pm-muted);
|
|
408
|
+
font-size: 11px;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.file-state.error {
|
|
412
|
+
color: var(--pm-danger);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.file-actions {
|
|
416
|
+
flex: 0 0 auto;
|
|
417
|
+
gap: 7px;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.icon-button {
|
|
421
|
+
min-height: 34px;
|
|
422
|
+
padding: 7px 9px;
|
|
423
|
+
border: 1px solid var(--pm-line);
|
|
424
|
+
border-radius: 9px;
|
|
425
|
+
background: transparent;
|
|
426
|
+
color: var(--pm-text);
|
|
427
|
+
cursor: pointer;
|
|
428
|
+
font-size: 11px;
|
|
429
|
+
font-weight: 730;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.progress {
|
|
433
|
+
height: 6px;
|
|
434
|
+
margin-top: 11px;
|
|
435
|
+
overflow: hidden;
|
|
436
|
+
border-radius: 999px;
|
|
437
|
+
background: var(--pm-card-muted);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.progress-bar {
|
|
441
|
+
width: 0;
|
|
442
|
+
height: 100%;
|
|
443
|
+
border-radius: inherit;
|
|
444
|
+
background: var(--pm-primary);
|
|
445
|
+
transition: width 120ms linear;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.destination-grid {
|
|
449
|
+
display: grid;
|
|
450
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
451
|
+
gap: 12px;
|
|
452
|
+
margin-top: 16px;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.field {
|
|
456
|
+
display: grid;
|
|
457
|
+
gap: 6px;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.field label {
|
|
461
|
+
color: var(--pm-muted);
|
|
462
|
+
font-size: 11px;
|
|
463
|
+
font-weight: 760;
|
|
464
|
+
letter-spacing: 0.02em;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
select,
|
|
468
|
+
textarea {
|
|
469
|
+
width: 100%;
|
|
470
|
+
border: 1px solid var(--pm-line);
|
|
471
|
+
border-radius: 11px;
|
|
472
|
+
background: var(--pm-card);
|
|
473
|
+
color: var(--pm-text);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
select {
|
|
477
|
+
min-height: 44px;
|
|
478
|
+
padding: 9px 32px 9px 10px;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
textarea {
|
|
482
|
+
min-height: 135px;
|
|
483
|
+
padding: 12px;
|
|
484
|
+
resize: vertical;
|
|
485
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
486
|
+
font-size: 11px;
|
|
487
|
+
line-height: 1.5;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.destination-actions,
|
|
491
|
+
.handoff-actions {
|
|
492
|
+
display: flex;
|
|
493
|
+
flex-wrap: wrap;
|
|
494
|
+
gap: 10px;
|
|
495
|
+
margin-top: 16px;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.privacy-note {
|
|
499
|
+
display: flex;
|
|
500
|
+
gap: 9px;
|
|
501
|
+
margin: 16px 0 0;
|
|
502
|
+
padding: 11px 12px;
|
|
503
|
+
border-radius: 12px;
|
|
504
|
+
background: var(--pm-success-soft);
|
|
505
|
+
color: var(--pm-success);
|
|
506
|
+
font-size: 11px;
|
|
507
|
+
line-height: 1.5;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
[hidden] {
|
|
511
|
+
display: none !important;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
@media (max-width: 640px) {
|
|
515
|
+
.app {
|
|
516
|
+
padding: 10px;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.card {
|
|
520
|
+
border-radius: 18px;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.hero,
|
|
524
|
+
.section {
|
|
525
|
+
padding: 17px;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.destination-grid {
|
|
529
|
+
grid-template-columns: 1fr;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.button {
|
|
533
|
+
width: 100%;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
@media (prefers-reduced-motion: reduce) {
|
|
538
|
+
*,
|
|
539
|
+
*::before,
|
|
540
|
+
*::after {
|
|
541
|
+
scroll-behavior: auto !important;
|
|
542
|
+
transition: none !important;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
</style>
|
|
546
|
+
</head>
|
|
547
|
+
<body>
|
|
548
|
+
<main class="app" data-testid="meta-upload-root">
|
|
549
|
+
<article class="card">
|
|
550
|
+
<header class="hero">
|
|
551
|
+
<p class="eyebrow">
|
|
552
|
+
<span class="eyebrow-dot"></span>ProductMaker · Meta Ads
|
|
553
|
+
</p>
|
|
554
|
+
<h1>Sube tus creativos de forma segura</h1>
|
|
555
|
+
<p class="hero-copy muted">
|
|
556
|
+
Los archivos van directo al staging privado de ProductMaker. El chat
|
|
557
|
+
solo recibe identificadores seguros para continuar.
|
|
558
|
+
</p>
|
|
559
|
+
<div class="session-row">
|
|
560
|
+
<code class="session-id" data-testid="meta-upload-session-id"
|
|
561
|
+
>Esperando sesión…</code
|
|
562
|
+
>
|
|
563
|
+
<a
|
|
564
|
+
class="fallback-link"
|
|
565
|
+
data-testid="meta-upload-fallback-link"
|
|
566
|
+
href="#"
|
|
567
|
+
target="_blank"
|
|
568
|
+
rel="noopener noreferrer"
|
|
569
|
+
hidden
|
|
570
|
+
>Abrir carga segura en otra pestaña</a
|
|
571
|
+
>
|
|
572
|
+
</div>
|
|
573
|
+
<p
|
|
574
|
+
class="status"
|
|
575
|
+
data-testid="meta-upload-session-status"
|
|
576
|
+
aria-live="polite"
|
|
577
|
+
></p>
|
|
578
|
+
</header>
|
|
579
|
+
|
|
580
|
+
<section class="section" aria-labelledby="upload-heading">
|
|
581
|
+
<h2 id="upload-heading">1. Selecciona tus archivos</h2>
|
|
582
|
+
<p class="section-copy muted">
|
|
583
|
+
Hasta 10 archivos · 350 MB por archivo · videos de máximo 180
|
|
584
|
+
segundos · mínimo 600 × 600 px.
|
|
585
|
+
</p>
|
|
586
|
+
<div
|
|
587
|
+
class="dropzone"
|
|
588
|
+
data-testid="meta-upload-dropzone"
|
|
589
|
+
data-dragging="false"
|
|
590
|
+
aria-disabled="true"
|
|
591
|
+
>
|
|
592
|
+
<div>
|
|
593
|
+
<div class="drop-icon" aria-hidden="true">↑</div>
|
|
594
|
+
<p class="drop-title">Arrastra aquí o usa el selector</p>
|
|
595
|
+
<p class="drop-help">
|
|
596
|
+
JPEG, PNG, WebP, MP4, MOV o WebM. Las imágenes grandes se
|
|
597
|
+
comprimen; los videos nunca se transcodifican.
|
|
598
|
+
</p>
|
|
599
|
+
<button
|
|
600
|
+
class="button"
|
|
601
|
+
type="button"
|
|
602
|
+
data-testid="meta-upload-picker-btn"
|
|
603
|
+
disabled
|
|
604
|
+
>
|
|
605
|
+
Elegir archivos
|
|
606
|
+
</button>
|
|
607
|
+
<button
|
|
608
|
+
class="button secondary"
|
|
609
|
+
type="button"
|
|
610
|
+
data-testid="meta-upload-library-btn"
|
|
611
|
+
hidden
|
|
612
|
+
disabled
|
|
613
|
+
>
|
|
614
|
+
Elegir desde la biblioteca de ChatGPT
|
|
615
|
+
</button>
|
|
616
|
+
<input
|
|
617
|
+
class="native-input"
|
|
618
|
+
data-testid="meta-upload-input"
|
|
619
|
+
type="file"
|
|
620
|
+
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm"
|
|
621
|
+
multiple
|
|
622
|
+
disabled
|
|
623
|
+
/>
|
|
624
|
+
</div>
|
|
625
|
+
</div>
|
|
626
|
+
<p
|
|
627
|
+
class="batch-error"
|
|
628
|
+
data-testid="meta-upload-batch-error"
|
|
629
|
+
role="alert"
|
|
630
|
+
aria-live="assertive"
|
|
631
|
+
></p>
|
|
632
|
+
<div
|
|
633
|
+
class="file-list"
|
|
634
|
+
data-testid="meta-upload-file-list"
|
|
635
|
+
aria-live="polite"
|
|
636
|
+
></div>
|
|
637
|
+
<button
|
|
638
|
+
class="button secondary"
|
|
639
|
+
type="button"
|
|
640
|
+
data-testid="meta-upload-pending-refresh"
|
|
641
|
+
hidden
|
|
642
|
+
>
|
|
643
|
+
Actualizar estado del staging
|
|
644
|
+
</button>
|
|
645
|
+
<p class="privacy-note">
|
|
646
|
+
<span aria-hidden="true">✓</span>
|
|
647
|
+
<span
|
|
648
|
+
>No enviamos bytes, enlaces temporales, nombres de archivo ni
|
|
649
|
+
tokens al modelo.</span
|
|
650
|
+
>
|
|
651
|
+
</p>
|
|
652
|
+
</section>
|
|
653
|
+
|
|
654
|
+
<section
|
|
655
|
+
class="section"
|
|
656
|
+
data-testid="meta-upload-destination-section"
|
|
657
|
+
aria-labelledby="destination-heading"
|
|
658
|
+
hidden
|
|
659
|
+
>
|
|
660
|
+
<h2 id="destination-heading">2. Elige el destino de Meta</h2>
|
|
661
|
+
<p class="section-copy muted">
|
|
662
|
+
La selección es obligatoria. Las cuentas se filtran por el portfolio
|
|
663
|
+
elegido.
|
|
664
|
+
</p>
|
|
665
|
+
<div class="destination-grid">
|
|
666
|
+
<div class="field">
|
|
667
|
+
<label for="portfolio-select">Portfolio</label>
|
|
668
|
+
<select
|
|
669
|
+
id="portfolio-select"
|
|
670
|
+
data-testid="meta-upload-portfolio-select"
|
|
671
|
+
>
|
|
672
|
+
<option value="">Selecciona un portfolio</option>
|
|
673
|
+
</select>
|
|
674
|
+
</div>
|
|
675
|
+
<div class="field">
|
|
676
|
+
<label for="ad-account-select">Cuenta publicitaria</label>
|
|
677
|
+
<select
|
|
678
|
+
id="ad-account-select"
|
|
679
|
+
data-testid="meta-upload-ad-account-select"
|
|
680
|
+
disabled
|
|
681
|
+
>
|
|
682
|
+
<option value="">Selecciona una cuenta</option>
|
|
683
|
+
</select>
|
|
684
|
+
</div>
|
|
685
|
+
<div class="field">
|
|
686
|
+
<label for="page-select">Página</label>
|
|
687
|
+
<select id="page-select" data-testid="meta-upload-page-select">
|
|
688
|
+
<option value="">Selecciona una página</option>
|
|
689
|
+
</select>
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
<div class="destination-actions">
|
|
693
|
+
<button
|
|
694
|
+
class="button"
|
|
695
|
+
type="button"
|
|
696
|
+
data-testid="meta-upload-submit-destination"
|
|
697
|
+
disabled
|
|
698
|
+
>
|
|
699
|
+
Enviar selección al chat
|
|
700
|
+
</button>
|
|
701
|
+
<button
|
|
702
|
+
class="button secondary"
|
|
703
|
+
type="button"
|
|
704
|
+
data-testid="meta-upload-check-status"
|
|
705
|
+
>
|
|
706
|
+
Actualizar estado
|
|
707
|
+
</button>
|
|
708
|
+
</div>
|
|
709
|
+
</section>
|
|
710
|
+
|
|
711
|
+
<section
|
|
712
|
+
class="section"
|
|
713
|
+
data-testid="meta-upload-handoff-section"
|
|
714
|
+
aria-labelledby="handoff-heading"
|
|
715
|
+
hidden
|
|
716
|
+
>
|
|
717
|
+
<h2 id="handoff-heading">3. Copia el handoff seguro</h2>
|
|
718
|
+
<p class="section-copy muted">
|
|
719
|
+
Este host no puede devolver el mensaje automáticamente. Copia este
|
|
720
|
+
JSON exacto en el chat.
|
|
721
|
+
</p>
|
|
722
|
+
<textarea data-testid="meta-upload-handoff-copy" readonly></textarea>
|
|
723
|
+
<div class="handoff-actions">
|
|
724
|
+
<button
|
|
725
|
+
class="button secondary"
|
|
726
|
+
type="button"
|
|
727
|
+
data-testid="meta-upload-copy-btn"
|
|
728
|
+
>
|
|
729
|
+
Copiar handoff
|
|
730
|
+
</button>
|
|
731
|
+
</div>
|
|
732
|
+
</section>
|
|
733
|
+
</article>
|
|
734
|
+
</main>
|
|
735
|
+
|
|
736
|
+
<script>
|
|
737
|
+
(() => {
|
|
738
|
+
"use strict";
|
|
739
|
+
|
|
740
|
+
const ACCEPTED_TYPES = new Set([
|
|
741
|
+
"image/jpeg",
|
|
742
|
+
"image/png",
|
|
743
|
+
"image/webp",
|
|
744
|
+
"video/mp4",
|
|
745
|
+
"video/quicktime",
|
|
746
|
+
"video/webm",
|
|
747
|
+
]);
|
|
748
|
+
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
749
|
+
const VIDEO_TYPES = new Set([
|
|
750
|
+
"video/mp4",
|
|
751
|
+
"video/quicktime",
|
|
752
|
+
"video/webm",
|
|
753
|
+
]);
|
|
754
|
+
const MAX_FILES = 10;
|
|
755
|
+
const MAX_FILE_SIZE_BYTES = 350 * 1024 * 1024;
|
|
756
|
+
const MAX_VIDEO_DURATION_SECONDS = 180;
|
|
757
|
+
const MIN_DIMENSION = 600;
|
|
758
|
+
const IMAGE_COMPRESSION_THRESHOLD_BYTES = 1.8 * 1024 * 1024;
|
|
759
|
+
const IMAGE_COMPRESSION_MAX_BYTES = 1.8 * 1024 * 1024;
|
|
760
|
+
const IMAGE_MAX_DIMENSION = 2048;
|
|
761
|
+
const MAX_UPLOAD_ATTEMPTS = 3;
|
|
762
|
+
const TOOL_RESULT_TEXT_LIMIT = 100000;
|
|
763
|
+
const RPC_TIMEOUT_MS = 15000;
|
|
764
|
+
const APP_PROTOCOL_VERSION = "2026-01-26";
|
|
765
|
+
|
|
766
|
+
const elements = {
|
|
767
|
+
root: document.querySelector('[data-testid="meta-upload-root"]'),
|
|
768
|
+
sessionId: document.querySelector(
|
|
769
|
+
'[data-testid="meta-upload-session-id"]',
|
|
770
|
+
),
|
|
771
|
+
fallbackLink: document.querySelector(
|
|
772
|
+
'[data-testid="meta-upload-fallback-link"]',
|
|
773
|
+
),
|
|
774
|
+
sessionStatus: document.querySelector(
|
|
775
|
+
'[data-testid="meta-upload-session-status"]',
|
|
776
|
+
),
|
|
777
|
+
dropzone: document.querySelector(
|
|
778
|
+
'[data-testid="meta-upload-dropzone"]',
|
|
779
|
+
),
|
|
780
|
+
pickerButton: document.querySelector(
|
|
781
|
+
'[data-testid="meta-upload-picker-btn"]',
|
|
782
|
+
),
|
|
783
|
+
libraryPickerButton: document.querySelector(
|
|
784
|
+
'[data-testid="meta-upload-library-btn"]',
|
|
785
|
+
),
|
|
786
|
+
input: document.querySelector('[data-testid="meta-upload-input"]'),
|
|
787
|
+
batchError: document.querySelector(
|
|
788
|
+
'[data-testid="meta-upload-batch-error"]',
|
|
789
|
+
),
|
|
790
|
+
fileList: document.querySelector(
|
|
791
|
+
'[data-testid="meta-upload-file-list"]',
|
|
792
|
+
),
|
|
793
|
+
destinationSection: document.querySelector(
|
|
794
|
+
'[data-testid="meta-upload-destination-section"]',
|
|
795
|
+
),
|
|
796
|
+
portfolio: document.querySelector(
|
|
797
|
+
'[data-testid="meta-upload-portfolio-select"]',
|
|
798
|
+
),
|
|
799
|
+
adAccount: document.querySelector(
|
|
800
|
+
'[data-testid="meta-upload-ad-account-select"]',
|
|
801
|
+
),
|
|
802
|
+
page: document.querySelector(
|
|
803
|
+
'[data-testid="meta-upload-page-select"]',
|
|
804
|
+
),
|
|
805
|
+
submitDestination: document.querySelector(
|
|
806
|
+
'[data-testid="meta-upload-submit-destination"]',
|
|
807
|
+
),
|
|
808
|
+
checkStatus: document.querySelector(
|
|
809
|
+
'[data-testid="meta-upload-check-status"]',
|
|
810
|
+
),
|
|
811
|
+
pendingRefresh: document.querySelector(
|
|
812
|
+
'[data-testid="meta-upload-pending-refresh"]',
|
|
813
|
+
),
|
|
814
|
+
handoffSection: document.querySelector(
|
|
815
|
+
'[data-testid="meta-upload-handoff-section"]',
|
|
816
|
+
),
|
|
817
|
+
handoffCopy: document.querySelector(
|
|
818
|
+
'[data-testid="meta-upload-handoff-copy"]',
|
|
819
|
+
),
|
|
820
|
+
copyButton: document.querySelector(
|
|
821
|
+
'[data-testid="meta-upload-copy-btn"]',
|
|
822
|
+
),
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const state = {
|
|
826
|
+
initialized: false,
|
|
827
|
+
standardHostReady: false,
|
|
828
|
+
hostCanMessage: false,
|
|
829
|
+
hostCanServerTools: false,
|
|
830
|
+
rpcId: 0,
|
|
831
|
+
pendingRpc: new Map(),
|
|
832
|
+
session: null,
|
|
833
|
+
toolInput: {},
|
|
834
|
+
items: [],
|
|
835
|
+
assets: [],
|
|
836
|
+
destinations: { portfolios: [], adAccounts: [], pages: [] },
|
|
837
|
+
consumedBridgeIds: new Set(),
|
|
838
|
+
consumingBridgeFiles: false,
|
|
839
|
+
busyBatch: false,
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
function isRecord(value) {
|
|
843
|
+
return (
|
|
844
|
+
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function capabilityEnabled(value) {
|
|
849
|
+
return value === true || isRecord(value);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function boundedString(value, maxLength = 300) {
|
|
853
|
+
return typeof value === "string" && value.length > 0
|
|
854
|
+
? value.slice(0, maxLength)
|
|
855
|
+
: "";
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function openAiBridge() {
|
|
859
|
+
return isRecord(window.openai) ? window.openai : null;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function hasOpenAiFunction(name) {
|
|
863
|
+
const bridge = openAiBridge();
|
|
864
|
+
return Boolean(bridge && typeof bridge[name] === "function");
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function mediaKindForType(type) {
|
|
868
|
+
if (IMAGE_TYPES.has(type)) return "image";
|
|
869
|
+
if (VIDEO_TYPES.has(type)) return "video";
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function safeSessionLimit() {
|
|
874
|
+
const proposed =
|
|
875
|
+
state.session && Number.isInteger(state.session.maxFiles)
|
|
876
|
+
? state.session.maxFiles
|
|
877
|
+
: MAX_FILES;
|
|
878
|
+
return Math.max(1, Math.min(MAX_FILES, proposed));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function sessionExpired() {
|
|
882
|
+
if (!state.session) return false;
|
|
883
|
+
const expiresAt = Date.parse(state.session.expiresAt);
|
|
884
|
+
return !Number.isFinite(expiresAt) || expiresAt <= Date.now();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function setSessionStatus(message, tone = "neutral") {
|
|
888
|
+
elements.sessionStatus.textContent = message;
|
|
889
|
+
elements.sessionStatus.dataset.tone = tone;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function setBatchError(message) {
|
|
893
|
+
elements.batchError.textContent = message;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function setPickerAvailability() {
|
|
897
|
+
const disabled =
|
|
898
|
+
!state.session ||
|
|
899
|
+
sessionExpired() ||
|
|
900
|
+
state.busyBatch ||
|
|
901
|
+
state.items.length >= safeSessionLimit();
|
|
902
|
+
elements.input.disabled = disabled;
|
|
903
|
+
elements.pickerButton.disabled = disabled;
|
|
904
|
+
elements.libraryPickerButton.disabled = disabled;
|
|
905
|
+
elements.dropzone.setAttribute("aria-disabled", String(disabled));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function emitAnalytics(eventName, mediaKind, extra = {}) {
|
|
909
|
+
const safeExtra = {};
|
|
910
|
+
if (typeof extra.failureStage === "string")
|
|
911
|
+
safeExtra.failureStage = extra.failureStage.slice(0, 40);
|
|
912
|
+
if (typeof extra.errorType === "string")
|
|
913
|
+
safeExtra.errorType = extra.errorType.slice(0, 60);
|
|
914
|
+
window.dispatchEvent(
|
|
915
|
+
new CustomEvent("productmaker:analytics", {
|
|
916
|
+
detail: {
|
|
917
|
+
eventName,
|
|
918
|
+
mediaKind,
|
|
919
|
+
surface: "mcp_remote_upload",
|
|
920
|
+
transport: "browser_to_cdn",
|
|
921
|
+
...safeExtra,
|
|
922
|
+
},
|
|
923
|
+
}),
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function initialize() {
|
|
928
|
+
if (state.initialized) return;
|
|
929
|
+
state.initialized = true;
|
|
930
|
+
bindUi();
|
|
931
|
+
setPickerAvailability();
|
|
932
|
+
void initializeStandardHost();
|
|
933
|
+
const bridge = openAiBridge();
|
|
934
|
+
elements.libraryPickerButton.hidden = !(
|
|
935
|
+
bridge &&
|
|
936
|
+
typeof bridge.selectFiles === "function" &&
|
|
937
|
+
typeof bridge.getFileDownloadUrl === "function"
|
|
938
|
+
);
|
|
939
|
+
if (bridge && isRecord(bridge.toolInput)) {
|
|
940
|
+
mergeToolInput(bridge.toolInput);
|
|
941
|
+
}
|
|
942
|
+
if (bridge && isRecord(bridge.toolOutput)) {
|
|
943
|
+
applyToolResult({ structuredContent: bridge.toolOutput });
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function bindUi() {
|
|
948
|
+
elements.pickerButton.addEventListener("click", () =>
|
|
949
|
+
elements.input.click(),
|
|
950
|
+
);
|
|
951
|
+
elements.libraryPickerButton.addEventListener(
|
|
952
|
+
"click",
|
|
953
|
+
() => void chooseLibraryFiles(),
|
|
954
|
+
);
|
|
955
|
+
elements.input.addEventListener("change", () => {
|
|
956
|
+
const files = elements.input.files
|
|
957
|
+
? Array.from(elements.input.files)
|
|
958
|
+
: [];
|
|
959
|
+
elements.input.value = "";
|
|
960
|
+
void processLocalFiles(files);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
for (const eventName of ["dragenter", "dragover"]) {
|
|
964
|
+
elements.dropzone.addEventListener(eventName, (event) => {
|
|
965
|
+
event.preventDefault();
|
|
966
|
+
if (elements.dropzone.getAttribute("aria-disabled") === "false") {
|
|
967
|
+
elements.dropzone.dataset.dragging = "true";
|
|
968
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
for (const eventName of ["dragleave", "drop"]) {
|
|
973
|
+
elements.dropzone.addEventListener(eventName, (event) => {
|
|
974
|
+
event.preventDefault();
|
|
975
|
+
elements.dropzone.dataset.dragging = "false";
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
elements.dropzone.addEventListener("drop", (event) => {
|
|
979
|
+
if (elements.dropzone.getAttribute("aria-disabled") === "true")
|
|
980
|
+
return;
|
|
981
|
+
const files = event.dataTransfer
|
|
982
|
+
? Array.from(event.dataTransfer.files)
|
|
983
|
+
: [];
|
|
984
|
+
void processLocalFiles(files);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
elements.portfolio.addEventListener("change", () => {
|
|
988
|
+
renderAdAccounts();
|
|
989
|
+
updateDestinationSubmit();
|
|
990
|
+
});
|
|
991
|
+
elements.adAccount.addEventListener(
|
|
992
|
+
"change",
|
|
993
|
+
updateDestinationSubmit,
|
|
994
|
+
);
|
|
995
|
+
elements.page.addEventListener("change", updateDestinationSubmit);
|
|
996
|
+
elements.submitDestination.addEventListener(
|
|
997
|
+
"click",
|
|
998
|
+
() => void submitDestination(),
|
|
999
|
+
);
|
|
1000
|
+
elements.checkStatus.addEventListener(
|
|
1001
|
+
"click",
|
|
1002
|
+
() => void refreshSessionStatus(),
|
|
1003
|
+
);
|
|
1004
|
+
elements.pendingRefresh.addEventListener(
|
|
1005
|
+
"click",
|
|
1006
|
+
() => void refreshSessionStatus(),
|
|
1007
|
+
);
|
|
1008
|
+
elements.copyButton.addEventListener(
|
|
1009
|
+
"click",
|
|
1010
|
+
() => void copyHandoff(),
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
window.addEventListener("message", handleHostMessage);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function initializeStandardHost() {
|
|
1017
|
+
try {
|
|
1018
|
+
const result = await rpcRequest("ui/initialize", {
|
|
1019
|
+
appInfo: { name: "ProductMaker Meta upload", version: "1.0.0" },
|
|
1020
|
+
appCapabilities: {},
|
|
1021
|
+
protocolVersion: APP_PROTOCOL_VERSION,
|
|
1022
|
+
});
|
|
1023
|
+
state.standardHostReady = true;
|
|
1024
|
+
const capabilities =
|
|
1025
|
+
isRecord(result) && isRecord(result.hostCapabilities)
|
|
1026
|
+
? result.hostCapabilities
|
|
1027
|
+
: {};
|
|
1028
|
+
state.hostCanMessage = capabilityEnabled(capabilities.message);
|
|
1029
|
+
state.hostCanServerTools = capabilityEnabled(
|
|
1030
|
+
capabilities.serverTools,
|
|
1031
|
+
);
|
|
1032
|
+
postNotification("ui/notifications/initialized", {});
|
|
1033
|
+
} catch {
|
|
1034
|
+
state.standardHostReady = false;
|
|
1035
|
+
state.hostCanServerTools = false;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function rpcRequest(method, params) {
|
|
1040
|
+
return new Promise((resolve, reject) => {
|
|
1041
|
+
state.rpcId += 1;
|
|
1042
|
+
const id = state.rpcId;
|
|
1043
|
+
const timeout = window.setTimeout(() => {
|
|
1044
|
+
state.pendingRpc.delete(id);
|
|
1045
|
+
reject(new Error("host_timeout"));
|
|
1046
|
+
}, RPC_TIMEOUT_MS);
|
|
1047
|
+
state.pendingRpc.set(id, { resolve, reject, timeout });
|
|
1048
|
+
window.parent.postMessage(
|
|
1049
|
+
{ jsonrpc: "2.0", id, method, params },
|
|
1050
|
+
"*",
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function postNotification(method, params) {
|
|
1056
|
+
window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*");
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function handleHostMessage(event) {
|
|
1060
|
+
if (event.source !== window.parent || !isRecord(event.data)) return;
|
|
1061
|
+
const message = event.data;
|
|
1062
|
+
if (Object.prototype.hasOwnProperty.call(message, "id")) {
|
|
1063
|
+
const pending = state.pendingRpc.get(message.id);
|
|
1064
|
+
if (!pending) return;
|
|
1065
|
+
window.clearTimeout(pending.timeout);
|
|
1066
|
+
state.pendingRpc.delete(message.id);
|
|
1067
|
+
if (isRecord(message.error))
|
|
1068
|
+
pending.reject(new Error("host_error"));
|
|
1069
|
+
else pending.resolve(message.result);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (message.method === "ui/notifications/tool-result") {
|
|
1074
|
+
applyToolResult(isRecord(message.params) ? message.params : {});
|
|
1075
|
+
}
|
|
1076
|
+
if (message.method === "ui/notifications/tool-input") {
|
|
1077
|
+
const params = isRecord(message.params) ? message.params : {};
|
|
1078
|
+
mergeToolInput(
|
|
1079
|
+
isRecord(params.arguments) ? params.arguments : params,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function applyToolResult(result) {
|
|
1085
|
+
const candidate = isRecord(result.structuredContent)
|
|
1086
|
+
? result.structuredContent
|
|
1087
|
+
: isRecord(result)
|
|
1088
|
+
? result
|
|
1089
|
+
: null;
|
|
1090
|
+
if (!candidate) return;
|
|
1091
|
+
const session = parseSession(candidate);
|
|
1092
|
+
if (!session) return;
|
|
1093
|
+
state.session = session;
|
|
1094
|
+
elements.sessionId.textContent = session.sessionId;
|
|
1095
|
+
elements.fallbackLink.href = session.uploadPageUrl;
|
|
1096
|
+
elements.fallbackLink.hidden = false;
|
|
1097
|
+
if (sessionExpired()) {
|
|
1098
|
+
setSessionStatus(
|
|
1099
|
+
"La sesión está expirada. Abre el enlace seguro para solicitar una nueva.",
|
|
1100
|
+
"danger",
|
|
1101
|
+
);
|
|
1102
|
+
elements.pendingRefresh.hidden = true;
|
|
1103
|
+
} else {
|
|
1104
|
+
setSessionStatus("Sesión lista para recibir archivos.", "neutral");
|
|
1105
|
+
elements.pendingRefresh.hidden = false;
|
|
1106
|
+
}
|
|
1107
|
+
setPickerAvailability();
|
|
1108
|
+
void consumePendingBridgeFiles();
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function parseSession(candidate) {
|
|
1112
|
+
const sessionId = boundedString(candidate.sessionId, 100);
|
|
1113
|
+
const uploadPageUrl = boundedString(candidate.uploadPageUrl, 2000);
|
|
1114
|
+
const mediaKind = candidate.mediaKind;
|
|
1115
|
+
const assetRole = candidate.assetRole;
|
|
1116
|
+
const expiresAt = boundedString(candidate.expiresAt, 100);
|
|
1117
|
+
if (!sessionId || !["image", "video", "mixed"].includes(mediaKind))
|
|
1118
|
+
return null;
|
|
1119
|
+
if (!["media", "thumbnail"].includes(assetRole)) return null;
|
|
1120
|
+
if (!Number.isInteger(candidate.maxFiles) || !expiresAt) return null;
|
|
1121
|
+
try {
|
|
1122
|
+
const parsedUrl = new URL(uploadPageUrl);
|
|
1123
|
+
if (!safeUploadPageUrl(parsedUrl)) return null;
|
|
1124
|
+
} catch {
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
1127
|
+
return {
|
|
1128
|
+
sessionId,
|
|
1129
|
+
uploadPageUrl,
|
|
1130
|
+
mediaKind,
|
|
1131
|
+
assetRole,
|
|
1132
|
+
maxFiles: candidate.maxFiles,
|
|
1133
|
+
expiresAt,
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function mergeToolInput(input) {
|
|
1138
|
+
state.toolInput = { ...state.toolInput, ...input };
|
|
1139
|
+
void consumePendingBridgeFiles();
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function consumePendingBridgeFiles() {
|
|
1143
|
+
if (
|
|
1144
|
+
!state.session ||
|
|
1145
|
+
!Array.isArray(state.toolInput.files) ||
|
|
1146
|
+
state.consumingBridgeFiles
|
|
1147
|
+
)
|
|
1148
|
+
return;
|
|
1149
|
+
state.consumingBridgeFiles = true;
|
|
1150
|
+
try {
|
|
1151
|
+
const pending = state.toolInput.files.filter((reference) => {
|
|
1152
|
+
const id = isRecord(reference)
|
|
1153
|
+
? boundedString(reference.file_id, 300)
|
|
1154
|
+
: "";
|
|
1155
|
+
return id && !state.consumedBridgeIds.has(id);
|
|
1156
|
+
});
|
|
1157
|
+
if (!pending.length) return;
|
|
1158
|
+
if (!hasOpenAiFunction("getFileDownloadUrl")) {
|
|
1159
|
+
setSessionStatus(
|
|
1160
|
+
"Este host no expone el puente de archivos. Usa el selector o el enlace seguro.",
|
|
1161
|
+
"warning",
|
|
1162
|
+
);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const availableSlots = Math.max(
|
|
1166
|
+
0,
|
|
1167
|
+
safeSessionLimit() - state.items.length,
|
|
1168
|
+
);
|
|
1169
|
+
const files = [];
|
|
1170
|
+
for (const reference of pending.slice(0, availableSlots)) {
|
|
1171
|
+
const id = boundedString(reference.file_id, 300);
|
|
1172
|
+
state.consumedBridgeIds.add(id);
|
|
1173
|
+
try {
|
|
1174
|
+
files.push(await downloadBridgeReference(reference));
|
|
1175
|
+
} catch {
|
|
1176
|
+
addBridgeFailure(reference);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (files.length) await processFiles(files);
|
|
1180
|
+
} finally {
|
|
1181
|
+
state.consumingBridgeFiles = false;
|
|
1182
|
+
const hasUnconsumed =
|
|
1183
|
+
Array.isArray(state.toolInput.files) &&
|
|
1184
|
+
state.toolInput.files.some((reference) => {
|
|
1185
|
+
const id = isRecord(reference)
|
|
1186
|
+
? boundedString(reference.file_id, 300)
|
|
1187
|
+
: "";
|
|
1188
|
+
return id && !state.consumedBridgeIds.has(id);
|
|
1189
|
+
});
|
|
1190
|
+
if (
|
|
1191
|
+
hasUnconsumed &&
|
|
1192
|
+
hasOpenAiFunction("getFileDownloadUrl") &&
|
|
1193
|
+
state.items.length < safeSessionLimit()
|
|
1194
|
+
) {
|
|
1195
|
+
queueMicrotask(() => void consumePendingBridgeFiles());
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function processLocalFiles(files) {
|
|
1201
|
+
if (!state.session || sessionExpired() || state.busyBatch) return;
|
|
1202
|
+
const bridge = openAiBridge();
|
|
1203
|
+
if (
|
|
1204
|
+
!bridge ||
|
|
1205
|
+
typeof bridge.uploadFile !== "function" ||
|
|
1206
|
+
typeof bridge.getFileDownloadUrl !== "function"
|
|
1207
|
+
) {
|
|
1208
|
+
await processFiles(files);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
const availableSlots = Math.max(
|
|
1212
|
+
0,
|
|
1213
|
+
safeSessionLimit() - state.items.length,
|
|
1214
|
+
);
|
|
1215
|
+
const normalized = [];
|
|
1216
|
+
for (const file of files.slice(0, availableSlots)) {
|
|
1217
|
+
try {
|
|
1218
|
+
const fileId = boundedString(
|
|
1219
|
+
await bridge.uploadFile(file, { library: false }),
|
|
1220
|
+
300,
|
|
1221
|
+
);
|
|
1222
|
+
if (!fileId) throw new Error("missing_bridge_file_id");
|
|
1223
|
+
normalized.push(
|
|
1224
|
+
await downloadBridgeReference({
|
|
1225
|
+
fileId,
|
|
1226
|
+
fileName: file.name,
|
|
1227
|
+
mimeType: file.type,
|
|
1228
|
+
}),
|
|
1229
|
+
);
|
|
1230
|
+
} catch {
|
|
1231
|
+
addBridgeFailure({ fileName: file.name, mimeType: file.type });
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (files.length > availableSlots) {
|
|
1235
|
+
setBatchError(
|
|
1236
|
+
`Puedes subir hasta ${safeSessionLimit()} archivos por sesión.`,
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
await processFiles(normalized);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
async function chooseLibraryFiles() {
|
|
1243
|
+
if (!state.session || sessionExpired() || state.busyBatch) return;
|
|
1244
|
+
const bridge = openAiBridge();
|
|
1245
|
+
if (
|
|
1246
|
+
!bridge ||
|
|
1247
|
+
typeof bridge.selectFiles !== "function" ||
|
|
1248
|
+
typeof bridge.getFileDownloadUrl !== "function"
|
|
1249
|
+
)
|
|
1250
|
+
return;
|
|
1251
|
+
try {
|
|
1252
|
+
const result = await bridge.selectFiles();
|
|
1253
|
+
const selected = Array.isArray(result) ? result : [];
|
|
1254
|
+
const availableSlots = Math.max(
|
|
1255
|
+
0,
|
|
1256
|
+
safeSessionLimit() - state.items.length,
|
|
1257
|
+
);
|
|
1258
|
+
const files = [];
|
|
1259
|
+
for (const candidate of selected.slice(0, availableSlots)) {
|
|
1260
|
+
if (!isRecord(candidate)) continue;
|
|
1261
|
+
const fileId = boundedString(candidate.fileId, 300);
|
|
1262
|
+
const fileName = boundedString(candidate.fileName, 240);
|
|
1263
|
+
const mimeType = boundedString(candidate.mimeType, 100);
|
|
1264
|
+
if (!fileId || !fileName || !mimeType) continue;
|
|
1265
|
+
files.push(
|
|
1266
|
+
await downloadBridgeReference({ fileId, fileName, mimeType }),
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
await processFiles(files);
|
|
1270
|
+
} catch {
|
|
1271
|
+
setSessionStatus(
|
|
1272
|
+
"No se pudo abrir el selector del host. Usa el selector del navegador.",
|
|
1273
|
+
"warning",
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function isBrowserFile(value) {
|
|
1279
|
+
return (
|
|
1280
|
+
isRecord(value) &&
|
|
1281
|
+
typeof value.name === "string" &&
|
|
1282
|
+
typeof value.type === "string" &&
|
|
1283
|
+
typeof value.size === "number" &&
|
|
1284
|
+
typeof value.slice === "function"
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function downloadBridgeReference(reference) {
|
|
1289
|
+
const bridge = openAiBridge();
|
|
1290
|
+
if (!bridge || typeof bridge.getFileDownloadUrl !== "function") {
|
|
1291
|
+
throw new Error("bridge_download_unavailable");
|
|
1292
|
+
}
|
|
1293
|
+
const fileId = boundedString(
|
|
1294
|
+
reference.file_id || reference.fileId || reference.id,
|
|
1295
|
+
300,
|
|
1296
|
+
);
|
|
1297
|
+
if (!fileId) throw new Error("invalid_bridge_file");
|
|
1298
|
+
const resolved = await bridge.getFileDownloadUrl({ fileId });
|
|
1299
|
+
const temporaryUrl =
|
|
1300
|
+
typeof resolved === "string"
|
|
1301
|
+
? resolved
|
|
1302
|
+
: isRecord(resolved)
|
|
1303
|
+
? boundedString(
|
|
1304
|
+
resolved.download_url ||
|
|
1305
|
+
resolved.downloadUrl ||
|
|
1306
|
+
resolved.url,
|
|
1307
|
+
3000,
|
|
1308
|
+
)
|
|
1309
|
+
: "";
|
|
1310
|
+
const parsedUrl = new URL(temporaryUrl);
|
|
1311
|
+
if (!safeWebProtocol(parsedUrl)) throw new Error("unsafe_bridge_url");
|
|
1312
|
+
const response = await fetch(parsedUrl.toString(), {
|
|
1313
|
+
method: "GET",
|
|
1314
|
+
credentials: "omit",
|
|
1315
|
+
});
|
|
1316
|
+
if (!response.ok) throw new Error("bridge_download_failed");
|
|
1317
|
+
const blob = await response.blob();
|
|
1318
|
+
const preferredType = boundedString(
|
|
1319
|
+
reference.mime_type || reference.mimeType || reference.type,
|
|
1320
|
+
100,
|
|
1321
|
+
);
|
|
1322
|
+
const contentType = ACCEPTED_TYPES.has(preferredType)
|
|
1323
|
+
? preferredType
|
|
1324
|
+
: ACCEPTED_TYPES.has(blob.type)
|
|
1325
|
+
? blob.type
|
|
1326
|
+
: "application/octet-stream";
|
|
1327
|
+
const fallbackName = contentType.startsWith("video/")
|
|
1328
|
+
? "creative-video"
|
|
1329
|
+
: "creative-image";
|
|
1330
|
+
const name =
|
|
1331
|
+
boundedString(
|
|
1332
|
+
reference.file_name || reference.fileName || reference.name,
|
|
1333
|
+
240,
|
|
1334
|
+
) || fallbackName;
|
|
1335
|
+
return new File([blob], name, {
|
|
1336
|
+
type: contentType,
|
|
1337
|
+
lastModified: Date.now(),
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function addBridgeFailure(reference) {
|
|
1342
|
+
const type = boundedString(
|
|
1343
|
+
reference.mime_type || reference.mimeType || reference.type,
|
|
1344
|
+
100,
|
|
1345
|
+
);
|
|
1346
|
+
const mediaKind = mediaKindForType(type) || "image";
|
|
1347
|
+
const item = createItem(null, mediaKind);
|
|
1348
|
+
item.status = "failed";
|
|
1349
|
+
item.error =
|
|
1350
|
+
"No se pudo leer el archivo temporal del host. Usa el selector o el enlace seguro.";
|
|
1351
|
+
item.failureStage = "bridge";
|
|
1352
|
+
state.items.push(item);
|
|
1353
|
+
renderItems();
|
|
1354
|
+
emitAnalytics("ads_meta_remote_upload_error", mediaKind, {
|
|
1355
|
+
failureStage: "bridge",
|
|
1356
|
+
errorType: "bridge_download_failed",
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
async function processFiles(fileCandidates) {
|
|
1361
|
+
if (!state.session || sessionExpired() || state.busyBatch) return;
|
|
1362
|
+
const files = fileCandidates.filter(isBrowserFile);
|
|
1363
|
+
if (!files.length) return;
|
|
1364
|
+
setBatchError("");
|
|
1365
|
+
const availableSlots = Math.max(
|
|
1366
|
+
0,
|
|
1367
|
+
safeSessionLimit() - state.items.length,
|
|
1368
|
+
);
|
|
1369
|
+
const selected = files.slice(0, availableSlots);
|
|
1370
|
+
if (files.length > availableSlots) {
|
|
1371
|
+
setBatchError(
|
|
1372
|
+
`Puedes subir hasta ${safeSessionLimit()} archivos por sesión.`,
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
if (!selected.length) return;
|
|
1376
|
+
|
|
1377
|
+
state.busyBatch = true;
|
|
1378
|
+
setPickerAvailability();
|
|
1379
|
+
for (const file of selected) {
|
|
1380
|
+
const mediaKind = mediaKindForType(file.type) || "image";
|
|
1381
|
+
const item = createItem(file, mediaKind);
|
|
1382
|
+
state.items.push(item);
|
|
1383
|
+
renderItems();
|
|
1384
|
+
await prepareAndUpload(item);
|
|
1385
|
+
}
|
|
1386
|
+
state.busyBatch = false;
|
|
1387
|
+
setPickerAvailability();
|
|
1388
|
+
if (state.items.some((item) => item.status === "uploaded")) {
|
|
1389
|
+
await refreshSessionStatus();
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function createItem(file, mediaKind) {
|
|
1394
|
+
return {
|
|
1395
|
+
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`,
|
|
1396
|
+
uploadId: createClientUuid(),
|
|
1397
|
+
file,
|
|
1398
|
+
uploadFile: file,
|
|
1399
|
+
mediaKind,
|
|
1400
|
+
status: "pending",
|
|
1401
|
+
progress: 0,
|
|
1402
|
+
attempts: 0,
|
|
1403
|
+
error: "",
|
|
1404
|
+
failureStage: "",
|
|
1405
|
+
stagingAssetId: "",
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
async function prepareAndUpload(item) {
|
|
1410
|
+
if (!item.file) return;
|
|
1411
|
+
item.status = "validating";
|
|
1412
|
+
item.progress = 0;
|
|
1413
|
+
item.error = "";
|
|
1414
|
+
item.failureStage = "";
|
|
1415
|
+
renderItems();
|
|
1416
|
+
try {
|
|
1417
|
+
const inspection = await inspectFile(item.file);
|
|
1418
|
+
item.mediaKind = inspection.mediaKind;
|
|
1419
|
+
item.uploadFile =
|
|
1420
|
+
inspection.mediaKind === "image"
|
|
1421
|
+
? await compressImage(item.file, inspection)
|
|
1422
|
+
: item.file;
|
|
1423
|
+
emitAnalytics("ads_meta_remote_upload_started", item.mediaKind);
|
|
1424
|
+
await uploadWithRetries(item);
|
|
1425
|
+
item.status = "uploaded";
|
|
1426
|
+
item.progress = 100;
|
|
1427
|
+
item.error = "";
|
|
1428
|
+
emitAnalytics("ads_meta_remote_upload_completed", item.mediaKind);
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
const failure = safeFailure(error);
|
|
1431
|
+
item.status = "failed";
|
|
1432
|
+
item.error = failure.message;
|
|
1433
|
+
item.failureStage = failure.stage;
|
|
1434
|
+
emitAnalytics("ads_meta_remote_upload_error", item.mediaKind, {
|
|
1435
|
+
failureStage: failure.stage,
|
|
1436
|
+
errorType: failure.code,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
renderItems();
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function inspectFile(file) {
|
|
1443
|
+
const mediaKind = mediaKindForType(file.type);
|
|
1444
|
+
if (!ACCEPTED_TYPES.has(file.type) || !mediaKind) {
|
|
1445
|
+
throw validationError(
|
|
1446
|
+
"unsupported_mime",
|
|
1447
|
+
"Este tipo de archivo no es compatible.",
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
if (
|
|
1451
|
+
state.session.mediaKind !== "mixed" &&
|
|
1452
|
+
state.session.mediaKind !== mediaKind
|
|
1453
|
+
) {
|
|
1454
|
+
throw validationError(
|
|
1455
|
+
"wrong_media_kind",
|
|
1456
|
+
"El archivo no coincide con el tipo solicitado.",
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
if (
|
|
1460
|
+
state.session.assetRole === "thumbnail" &&
|
|
1461
|
+
mediaKind !== "image"
|
|
1462
|
+
) {
|
|
1463
|
+
throw validationError(
|
|
1464
|
+
"thumbnail_requires_image",
|
|
1465
|
+
"La miniatura debe ser una imagen.",
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
if (file.size <= 0) {
|
|
1469
|
+
throw validationError("empty_file", "El archivo está vacío.");
|
|
1470
|
+
}
|
|
1471
|
+
if (file.size > MAX_FILE_SIZE_BYTES) {
|
|
1472
|
+
throw validationError(
|
|
1473
|
+
"file_too_large",
|
|
1474
|
+
"El archivo supera el límite de 350 MB.",
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const inspection =
|
|
1479
|
+
mediaKind === "image"
|
|
1480
|
+
? await inspectImage(file)
|
|
1481
|
+
: await inspectVideo(file);
|
|
1482
|
+
if (
|
|
1483
|
+
inspection.width < MIN_DIMENSION ||
|
|
1484
|
+
inspection.height < MIN_DIMENSION
|
|
1485
|
+
) {
|
|
1486
|
+
throw validationError(
|
|
1487
|
+
"low_resolution",
|
|
1488
|
+
"La resolución mínima es 600 × 600 px.",
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
if (
|
|
1492
|
+
mediaKind === "video" &&
|
|
1493
|
+
inspection.durationSeconds > MAX_VIDEO_DURATION_SECONDS
|
|
1494
|
+
) {
|
|
1495
|
+
throw validationError(
|
|
1496
|
+
"video_too_long",
|
|
1497
|
+
"El video supera el máximo de 180 segundos.",
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
return inspection;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function inspectImage(file) {
|
|
1504
|
+
return new Promise((resolve, reject) => {
|
|
1505
|
+
const image = new Image();
|
|
1506
|
+
const objectUrl = URL.createObjectURL(file);
|
|
1507
|
+
const cleanup = () => URL.revokeObjectURL(objectUrl);
|
|
1508
|
+
image.onload = () => {
|
|
1509
|
+
cleanup();
|
|
1510
|
+
const width = image.naturalWidth;
|
|
1511
|
+
const height = image.naturalHeight;
|
|
1512
|
+
if (!validDimension(width) || !validDimension(height)) {
|
|
1513
|
+
reject(
|
|
1514
|
+
validationError(
|
|
1515
|
+
"invalid_metadata",
|
|
1516
|
+
"No se pudo leer la imagen.",
|
|
1517
|
+
),
|
|
1518
|
+
);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
resolve({ mediaKind: "image", width, height, image });
|
|
1522
|
+
};
|
|
1523
|
+
image.onerror = () => {
|
|
1524
|
+
cleanup();
|
|
1525
|
+
reject(
|
|
1526
|
+
validationError(
|
|
1527
|
+
"invalid_metadata",
|
|
1528
|
+
"No se pudo leer la imagen.",
|
|
1529
|
+
),
|
|
1530
|
+
);
|
|
1531
|
+
};
|
|
1532
|
+
image.src = objectUrl;
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function inspectVideo(file) {
|
|
1537
|
+
return new Promise((resolve, reject) => {
|
|
1538
|
+
const video = document.createElement("video");
|
|
1539
|
+
const objectUrl = URL.createObjectURL(file);
|
|
1540
|
+
const cleanup = () => URL.revokeObjectURL(objectUrl);
|
|
1541
|
+
video.preload = "metadata";
|
|
1542
|
+
video.onloadedmetadata = () => {
|
|
1543
|
+
cleanup();
|
|
1544
|
+
const width = video.videoWidth;
|
|
1545
|
+
const height = video.videoHeight;
|
|
1546
|
+
const durationSeconds = video.duration;
|
|
1547
|
+
if (
|
|
1548
|
+
!validDimension(width) ||
|
|
1549
|
+
!validDimension(height) ||
|
|
1550
|
+
!Number.isFinite(durationSeconds) ||
|
|
1551
|
+
durationSeconds < 0
|
|
1552
|
+
) {
|
|
1553
|
+
reject(
|
|
1554
|
+
validationError(
|
|
1555
|
+
"invalid_metadata",
|
|
1556
|
+
"No se pudo leer el video.",
|
|
1557
|
+
),
|
|
1558
|
+
);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
resolve({ mediaKind: "video", width, height, durationSeconds });
|
|
1562
|
+
};
|
|
1563
|
+
video.onerror = () => {
|
|
1564
|
+
cleanup();
|
|
1565
|
+
reject(
|
|
1566
|
+
validationError(
|
|
1567
|
+
"invalid_metadata",
|
|
1568
|
+
"No se pudo leer el video.",
|
|
1569
|
+
),
|
|
1570
|
+
);
|
|
1571
|
+
};
|
|
1572
|
+
video.src = objectUrl;
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function validDimension(value) {
|
|
1577
|
+
return Number.isFinite(value) && value > 0;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function validationError(code, message) {
|
|
1581
|
+
const error = new Error(code);
|
|
1582
|
+
error.safeCode = code;
|
|
1583
|
+
error.safeMessage = message;
|
|
1584
|
+
error.safeStage = "validation";
|
|
1585
|
+
return error;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
async function compressImage(file, inspection) {
|
|
1589
|
+
if (file.size <= IMAGE_COMPRESSION_THRESHOLD_BYTES) return file;
|
|
1590
|
+
try {
|
|
1591
|
+
const scale = Math.min(
|
|
1592
|
+
1,
|
|
1593
|
+
IMAGE_MAX_DIMENSION /
|
|
1594
|
+
Math.max(inspection.width, inspection.height),
|
|
1595
|
+
);
|
|
1596
|
+
const canvas = document.createElement("canvas");
|
|
1597
|
+
canvas.width = Math.max(1, Math.round(inspection.width * scale));
|
|
1598
|
+
canvas.height = Math.max(1, Math.round(inspection.height * scale));
|
|
1599
|
+
if (canvas.width < MIN_DIMENSION || canvas.height < MIN_DIMENSION) {
|
|
1600
|
+
throw validationError(
|
|
1601
|
+
"low_resolution",
|
|
1602
|
+
"La resolución mínima final es 600 × 600 px.",
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
const context = canvas.getContext("2d", {
|
|
1606
|
+
alpha: file.type !== "image/jpeg",
|
|
1607
|
+
});
|
|
1608
|
+
if (!context) throw new Error("canvas_unavailable");
|
|
1609
|
+
context.drawImage(
|
|
1610
|
+
inspection.image,
|
|
1611
|
+
0,
|
|
1612
|
+
0,
|
|
1613
|
+
canvas.width,
|
|
1614
|
+
canvas.height,
|
|
1615
|
+
);
|
|
1616
|
+
const blob = await new Promise((resolve, reject) => {
|
|
1617
|
+
canvas.toBlob(
|
|
1618
|
+
(result) =>
|
|
1619
|
+
result
|
|
1620
|
+
? resolve(result)
|
|
1621
|
+
: reject(new Error("compression_failed")),
|
|
1622
|
+
file.type,
|
|
1623
|
+
0.85,
|
|
1624
|
+
);
|
|
1625
|
+
});
|
|
1626
|
+
const compressed = new File([blob], file.name, {
|
|
1627
|
+
type: blob.type || file.type,
|
|
1628
|
+
lastModified: file.lastModified,
|
|
1629
|
+
});
|
|
1630
|
+
const output = compressed.size < file.size ? compressed : file;
|
|
1631
|
+
if (output.size > IMAGE_COMPRESSION_MAX_BYTES) {
|
|
1632
|
+
const error = new Error("compression_too_large");
|
|
1633
|
+
error.safeCode = "compression_too_large";
|
|
1634
|
+
error.safeMessage =
|
|
1635
|
+
"La imagen no pudo reducirse a un tamaño seguro.";
|
|
1636
|
+
error.safeStage = "compression";
|
|
1637
|
+
throw error;
|
|
1638
|
+
}
|
|
1639
|
+
return output;
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
if (
|
|
1642
|
+
error &&
|
|
1643
|
+
["compression", "validation"].includes(error.safeStage)
|
|
1644
|
+
)
|
|
1645
|
+
throw error;
|
|
1646
|
+
const safeError = new Error("compression_failed");
|
|
1647
|
+
safeError.safeCode = "compression_failed";
|
|
1648
|
+
safeError.safeMessage = "No se pudo comprimir la imagen.";
|
|
1649
|
+
safeError.safeStage = "compression";
|
|
1650
|
+
throw safeError;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
async function uploadWithRetries(item) {
|
|
1655
|
+
let lastError = null;
|
|
1656
|
+
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) {
|
|
1657
|
+
item.attempts = attempt;
|
|
1658
|
+
item.status = "uploading";
|
|
1659
|
+
item.progress = 0;
|
|
1660
|
+
renderItems();
|
|
1661
|
+
try {
|
|
1662
|
+
const resolved = await resolveUpload(
|
|
1663
|
+
item.uploadFile,
|
|
1664
|
+
item.uploadId,
|
|
1665
|
+
);
|
|
1666
|
+
await putFileWithProgress(
|
|
1667
|
+
resolved.uploadUrl,
|
|
1668
|
+
item.uploadFile,
|
|
1669
|
+
(progress) => {
|
|
1670
|
+
item.progress = progress;
|
|
1671
|
+
renderItems();
|
|
1672
|
+
},
|
|
1673
|
+
);
|
|
1674
|
+
item.stagingAssetId = resolved.stagingAssetId;
|
|
1675
|
+
return;
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
if (error && error.message === "session_expired") throw error;
|
|
1678
|
+
lastError = error;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
const error = new Error("upload_failed");
|
|
1682
|
+
error.safeCode = "upload_failed";
|
|
1683
|
+
error.safeMessage =
|
|
1684
|
+
"La carga falló tras 3 intentos. Puedes reintentarla.";
|
|
1685
|
+
error.safeStage =
|
|
1686
|
+
lastError && lastError.safeStage ? lastError.safeStage : "upload";
|
|
1687
|
+
throw error;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
async function resolveUpload(file, uploadId) {
|
|
1691
|
+
const uploadPage = new URL(state.session.uploadPageUrl);
|
|
1692
|
+
if (!safeUploadPageUrl(uploadPage))
|
|
1693
|
+
throw new Error("unsafe_upload_page");
|
|
1694
|
+
const token = uploadPage.searchParams.get("token");
|
|
1695
|
+
if (!token) {
|
|
1696
|
+
const error = new Error("missing_token");
|
|
1697
|
+
error.safeStage = "resolve";
|
|
1698
|
+
throw error;
|
|
1699
|
+
}
|
|
1700
|
+
const resolveUrl = new URL(
|
|
1701
|
+
"/api/meta-upload/resolve",
|
|
1702
|
+
uploadPage.origin,
|
|
1703
|
+
);
|
|
1704
|
+
const response = await fetch(resolveUrl.toString(), {
|
|
1705
|
+
method: "POST",
|
|
1706
|
+
headers: { "content-type": "application/json" },
|
|
1707
|
+
body: JSON.stringify({
|
|
1708
|
+
token,
|
|
1709
|
+
uploadId,
|
|
1710
|
+
contentType: file.type,
|
|
1711
|
+
fileName: file.name,
|
|
1712
|
+
fileSize: file.size,
|
|
1713
|
+
}),
|
|
1714
|
+
credentials: "omit",
|
|
1715
|
+
});
|
|
1716
|
+
if (!response.ok) {
|
|
1717
|
+
const error = new Error(
|
|
1718
|
+
response.status === 401 || response.status === 410
|
|
1719
|
+
? "session_expired"
|
|
1720
|
+
: "resolve_failed",
|
|
1721
|
+
);
|
|
1722
|
+
error.safeStage = "resolve";
|
|
1723
|
+
throw error;
|
|
1724
|
+
}
|
|
1725
|
+
const body = await response.json();
|
|
1726
|
+
if (!isRecord(body)) throw new Error("invalid_resolve_response");
|
|
1727
|
+
const uploadUrl = boundedString(body.uploadUrl, 3000);
|
|
1728
|
+
const stagingAssetId = boundedString(body.stagingAssetId, 300);
|
|
1729
|
+
const parsedUploadUrl = new URL(uploadUrl);
|
|
1730
|
+
if (!safeWebProtocol(parsedUploadUrl) || !stagingAssetId) {
|
|
1731
|
+
const error = new Error("invalid_resolve_response");
|
|
1732
|
+
error.safeStage = "resolve";
|
|
1733
|
+
throw error;
|
|
1734
|
+
}
|
|
1735
|
+
return { uploadUrl: parsedUploadUrl.toString(), stagingAssetId };
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function createClientUuid() {
|
|
1739
|
+
if (typeof crypto !== "undefined") {
|
|
1740
|
+
if (typeof crypto.randomUUID === "function")
|
|
1741
|
+
return crypto.randomUUID();
|
|
1742
|
+
if (typeof crypto.getRandomValues === "function") {
|
|
1743
|
+
const bytes = new Uint8Array(16);
|
|
1744
|
+
crypto.getRandomValues(bytes);
|
|
1745
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
1746
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
1747
|
+
const hex = Array.from(bytes, (byte) =>
|
|
1748
|
+
byte.toString(16).padStart(2, "0"),
|
|
1749
|
+
).join("");
|
|
1750
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, "0").slice(-12)}`;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function safeWebProtocol(url) {
|
|
1757
|
+
if (url.protocol === "https:") return true;
|
|
1758
|
+
return (
|
|
1759
|
+
url.protocol === "http:" &&
|
|
1760
|
+
["localhost", "127.0.0.1", "::1"].includes(url.hostname)
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function safeUploadPageUrl(url) {
|
|
1765
|
+
if (url.username || url.password) return false;
|
|
1766
|
+
if (
|
|
1767
|
+
[
|
|
1768
|
+
"https://productmaker.app",
|
|
1769
|
+
"https://www.productmaker.app",
|
|
1770
|
+
].includes(url.origin)
|
|
1771
|
+
)
|
|
1772
|
+
return true;
|
|
1773
|
+
return (
|
|
1774
|
+
url.protocol === "http:" &&
|
|
1775
|
+
["localhost", "127.0.0.1", "::1"].includes(url.hostname)
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function putFileWithProgress(url, file, onProgress) {
|
|
1780
|
+
return new Promise((resolve, reject) => {
|
|
1781
|
+
const request = new XMLHttpRequest();
|
|
1782
|
+
request.open("PUT", url);
|
|
1783
|
+
request.timeout = 5 * 60 * 1000;
|
|
1784
|
+
request.setRequestHeader("Content-Type", file.type);
|
|
1785
|
+
request.upload.addEventListener("progress", (event) => {
|
|
1786
|
+
if (event.lengthComputable && event.total > 0) {
|
|
1787
|
+
onProgress(Math.round((event.loaded / event.total) * 100));
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
request.addEventListener("load", () => {
|
|
1791
|
+
if (request.status >= 200 && request.status < 300) resolve();
|
|
1792
|
+
else reject(uploadTransportError("put_failed"));
|
|
1793
|
+
});
|
|
1794
|
+
request.addEventListener("error", () =>
|
|
1795
|
+
reject(uploadTransportError("network_error")),
|
|
1796
|
+
);
|
|
1797
|
+
request.addEventListener("timeout", () =>
|
|
1798
|
+
reject(uploadTransportError("upload_timeout")),
|
|
1799
|
+
);
|
|
1800
|
+
request.send(file);
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function uploadTransportError(code) {
|
|
1805
|
+
const error = new Error(code);
|
|
1806
|
+
error.safeStage = "put";
|
|
1807
|
+
return error;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function safeFailure(error) {
|
|
1811
|
+
if (error && typeof error.safeMessage === "string") {
|
|
1812
|
+
return {
|
|
1813
|
+
code: boundedString(error.safeCode, 60) || "upload_failed",
|
|
1814
|
+
message: boundedString(error.safeMessage, 300),
|
|
1815
|
+
stage: boundedString(error.safeStage, 40) || "upload",
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
if (error && error.message === "session_expired") {
|
|
1819
|
+
return {
|
|
1820
|
+
code: "session_expired",
|
|
1821
|
+
message:
|
|
1822
|
+
"La sesión expiró. Abre el enlace seguro para crear otra.",
|
|
1823
|
+
stage: "resolve",
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
return {
|
|
1827
|
+
code: "upload_failed",
|
|
1828
|
+
message: "No se pudo completar la carga. Puedes reintentarla.",
|
|
1829
|
+
stage: (error && boundedString(error.safeStage, 40)) || "upload",
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
async function retryItem(index) {
|
|
1834
|
+
const item = state.items[index];
|
|
1835
|
+
if (
|
|
1836
|
+
!item ||
|
|
1837
|
+
item.status !== "failed" ||
|
|
1838
|
+
!item.file ||
|
|
1839
|
+
item.failureStage === "validation"
|
|
1840
|
+
)
|
|
1841
|
+
return;
|
|
1842
|
+
item.attempts = 0;
|
|
1843
|
+
await prepareAndUpload(item);
|
|
1844
|
+
if (item.status === "uploaded") await refreshSessionStatus();
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function removeItem(index) {
|
|
1848
|
+
if (state.busyBatch) return;
|
|
1849
|
+
state.items.splice(index, 1);
|
|
1850
|
+
renderItems();
|
|
1851
|
+
setPickerAvailability();
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function renderItems() {
|
|
1855
|
+
const fragment = document.createDocumentFragment();
|
|
1856
|
+
state.items.forEach((item, index) => {
|
|
1857
|
+
const card = document.createElement("article");
|
|
1858
|
+
card.className = "file-card";
|
|
1859
|
+
card.dataset.testid = `meta-upload-file-${index}`;
|
|
1860
|
+
|
|
1861
|
+
const head = document.createElement("div");
|
|
1862
|
+
head.className = "file-head";
|
|
1863
|
+
const meta = document.createElement("div");
|
|
1864
|
+
meta.className = "file-meta";
|
|
1865
|
+
const icon = document.createElement("span");
|
|
1866
|
+
icon.className = "file-kind";
|
|
1867
|
+
icon.setAttribute("aria-hidden", "true");
|
|
1868
|
+
icon.textContent = item.mediaKind === "video" ? "▶" : "▧";
|
|
1869
|
+
const text = document.createElement("div");
|
|
1870
|
+
const label = document.createElement("p");
|
|
1871
|
+
label.className = "file-label";
|
|
1872
|
+
label.textContent =
|
|
1873
|
+
item.mediaKind === "video"
|
|
1874
|
+
? `Video ${index + 1}`
|
|
1875
|
+
: `Imagen ${index + 1}`;
|
|
1876
|
+
const status = document.createElement("p");
|
|
1877
|
+
status.className = `file-state${item.status === "failed" ? " error" : ""}`;
|
|
1878
|
+
status.dataset.testid =
|
|
1879
|
+
item.status === "failed"
|
|
1880
|
+
? `meta-upload-file-error-${index}`
|
|
1881
|
+
: `meta-upload-file-status-${index}`;
|
|
1882
|
+
status.textContent = itemStatusText(item);
|
|
1883
|
+
text.append(label, status);
|
|
1884
|
+
meta.append(icon, text);
|
|
1885
|
+
|
|
1886
|
+
const actions = document.createElement("div");
|
|
1887
|
+
actions.className = "file-actions";
|
|
1888
|
+
if (
|
|
1889
|
+
item.status === "failed" &&
|
|
1890
|
+
item.failureStage !== "validation" &&
|
|
1891
|
+
item.file
|
|
1892
|
+
) {
|
|
1893
|
+
const retry = document.createElement("button");
|
|
1894
|
+
retry.type = "button";
|
|
1895
|
+
retry.className = "icon-button";
|
|
1896
|
+
retry.dataset.testid = `meta-upload-retry-${index}`;
|
|
1897
|
+
retry.textContent = "Reintentar";
|
|
1898
|
+
retry.addEventListener("click", () => void retryItem(index));
|
|
1899
|
+
actions.append(retry);
|
|
1900
|
+
}
|
|
1901
|
+
const remove = document.createElement("button");
|
|
1902
|
+
remove.type = "button";
|
|
1903
|
+
remove.className = "icon-button";
|
|
1904
|
+
remove.dataset.testid = `meta-upload-remove-${index}`;
|
|
1905
|
+
remove.textContent = "Quitar";
|
|
1906
|
+
remove.disabled = ["uploading", "validating", "uploaded"].includes(
|
|
1907
|
+
item.status,
|
|
1908
|
+
);
|
|
1909
|
+
remove.addEventListener("click", () => removeItem(index));
|
|
1910
|
+
actions.append(remove);
|
|
1911
|
+
head.append(meta, actions);
|
|
1912
|
+
card.append(head);
|
|
1913
|
+
|
|
1914
|
+
if (item.status === "uploading" || item.status === "uploaded") {
|
|
1915
|
+
const progress = document.createElement("div");
|
|
1916
|
+
progress.className = "progress";
|
|
1917
|
+
progress.setAttribute("role", "progressbar");
|
|
1918
|
+
progress.setAttribute("aria-valuemin", "0");
|
|
1919
|
+
progress.setAttribute("aria-valuemax", "100");
|
|
1920
|
+
progress.setAttribute("aria-valuenow", String(item.progress));
|
|
1921
|
+
progress.dataset.testid = `meta-upload-progress-${index}`;
|
|
1922
|
+
const bar = document.createElement("div");
|
|
1923
|
+
bar.className = "progress-bar";
|
|
1924
|
+
bar.style.width = `${item.progress}%`;
|
|
1925
|
+
progress.append(bar);
|
|
1926
|
+
card.append(progress);
|
|
1927
|
+
}
|
|
1928
|
+
fragment.append(card);
|
|
1929
|
+
});
|
|
1930
|
+
elements.fileList.replaceChildren(fragment);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function itemStatusText(item) {
|
|
1934
|
+
if (item.status === "validating") return "Validando…";
|
|
1935
|
+
if (item.status === "uploading") return `Subiendo… ${item.progress}%`;
|
|
1936
|
+
if (item.status === "uploaded") return "Carga completada";
|
|
1937
|
+
if (item.status === "failed") return item.error || "Carga fallida";
|
|
1938
|
+
return "Pendiente";
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
async function refreshSessionStatus() {
|
|
1942
|
+
if (!state.session) return;
|
|
1943
|
+
elements.checkStatus.disabled = true;
|
|
1944
|
+
elements.pendingRefresh.disabled = true;
|
|
1945
|
+
setSessionStatus("Comprobando el staging privado…", "neutral");
|
|
1946
|
+
try {
|
|
1947
|
+
const result = await callReadOnlyTool("check_meta_media_upload", {
|
|
1948
|
+
sessionId: state.session.sessionId,
|
|
1949
|
+
});
|
|
1950
|
+
const status = parseStatusResult(result);
|
|
1951
|
+
state.assets = status.assets;
|
|
1952
|
+
if (!status.assets.length) {
|
|
1953
|
+
setSessionStatus(
|
|
1954
|
+
status.legacyKindAmbiguous
|
|
1955
|
+
? "El tipo del asset legacy es ambiguo. Vuelve a comprobar o usa el enlace seguro."
|
|
1956
|
+
: "La sesión sigue pendiente. Termina la carga o vuelve a comprobar.",
|
|
1957
|
+
"warning",
|
|
1958
|
+
);
|
|
1959
|
+
elements.destinationSection.hidden = true;
|
|
1960
|
+
elements.pendingRefresh.hidden = false;
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
elements.pendingRefresh.hidden = true;
|
|
1964
|
+
|
|
1965
|
+
const locallyUploaded = state.items.filter(
|
|
1966
|
+
(item) => item.status === "uploaded",
|
|
1967
|
+
).length;
|
|
1968
|
+
const partial =
|
|
1969
|
+
status.status === "pending" ||
|
|
1970
|
+
status.assets.length < locallyUploaded;
|
|
1971
|
+
setSessionStatus(
|
|
1972
|
+
partial
|
|
1973
|
+
? "Carga parcial verificada. Continuaremos solo con los assets listos."
|
|
1974
|
+
: "Carga verificada. Elige ahora el destino de Meta.",
|
|
1975
|
+
partial ? "warning" : "success",
|
|
1976
|
+
);
|
|
1977
|
+
const destinationsResult = await callReadOnlyTool(
|
|
1978
|
+
"list_meta_destinations",
|
|
1979
|
+
{},
|
|
1980
|
+
);
|
|
1981
|
+
state.destinations = parseDestinationsResult(destinationsResult);
|
|
1982
|
+
renderDestinations();
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
const expired =
|
|
1985
|
+
sessionExpired() ||
|
|
1986
|
+
(error && /expired|expirad/i.test(String(error.message)));
|
|
1987
|
+
const toolsUnavailable =
|
|
1988
|
+
error && error.message === "host_tools_unavailable";
|
|
1989
|
+
setSessionStatus(
|
|
1990
|
+
expired
|
|
1991
|
+
? "La sesión está expirada. Abre el enlace seguro para solicitar una nueva."
|
|
1992
|
+
: toolsUnavailable
|
|
1993
|
+
? "host_tools_unavailable: este host no permite comprobar el staging desde la App. Usa el enlace seguro."
|
|
1994
|
+
: "No se pudo comprobar la sesión. Conserva el enlace y vuelve a intentarlo.",
|
|
1995
|
+
"danger",
|
|
1996
|
+
);
|
|
1997
|
+
elements.pendingRefresh.hidden = expired;
|
|
1998
|
+
} finally {
|
|
1999
|
+
elements.checkStatus.disabled = false;
|
|
2000
|
+
elements.pendingRefresh.disabled = false;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
async function callReadOnlyTool(name, args) {
|
|
2005
|
+
if (
|
|
2006
|
+
!["check_meta_media_upload", "list_meta_destinations"].includes(
|
|
2007
|
+
name,
|
|
2008
|
+
)
|
|
2009
|
+
) {
|
|
2010
|
+
throw new Error("tool_not_allowed");
|
|
2011
|
+
}
|
|
2012
|
+
const bridge = openAiBridge();
|
|
2013
|
+
let result;
|
|
2014
|
+
if (bridge && typeof bridge.callTool === "function") {
|
|
2015
|
+
result = await bridge.callTool(name, args);
|
|
2016
|
+
} else if (state.hostCanServerTools) {
|
|
2017
|
+
result = await rpcRequest("tools/call", { name, arguments: args });
|
|
2018
|
+
} else {
|
|
2019
|
+
throw new Error("host_tools_unavailable");
|
|
2020
|
+
}
|
|
2021
|
+
if (!isRecord(result) || result.isError === true)
|
|
2022
|
+
throw new Error("tool_call_failed");
|
|
2023
|
+
return result;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function resultPayload(result) {
|
|
2027
|
+
if (isRecord(result.structuredContent))
|
|
2028
|
+
return result.structuredContent;
|
|
2029
|
+
if (!Array.isArray(result.content))
|
|
2030
|
+
throw new Error("missing_tool_result");
|
|
2031
|
+
const text = result.content
|
|
2032
|
+
.filter(
|
|
2033
|
+
(part) =>
|
|
2034
|
+
isRecord(part) &&
|
|
2035
|
+
part.type === "text" &&
|
|
2036
|
+
typeof part.text === "string",
|
|
2037
|
+
)
|
|
2038
|
+
.map((part) => part.text)
|
|
2039
|
+
.join("\n")
|
|
2040
|
+
.slice(0, TOOL_RESULT_TEXT_LIMIT);
|
|
2041
|
+
const parsed = JSON.parse(text);
|
|
2042
|
+
if (!isRecord(parsed)) throw new Error("invalid_tool_result");
|
|
2043
|
+
return parsed;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
function parseStatusResult(result) {
|
|
2047
|
+
const payload = resultPayload(result);
|
|
2048
|
+
const status = payload.status;
|
|
2049
|
+
if (!["pending", "uploaded"].includes(status))
|
|
2050
|
+
throw new Error("invalid_status");
|
|
2051
|
+
const assets = Array.isArray(payload.assets)
|
|
2052
|
+
? payload.assets.slice(0, MAX_FILES).flatMap((asset) => {
|
|
2053
|
+
if (!isRecord(asset)) return [];
|
|
2054
|
+
const stagingAssetId = boundedString(asset.stagingAssetId, 300);
|
|
2055
|
+
const mediaKind = asset.mediaKind;
|
|
2056
|
+
return stagingAssetId && ["image", "video"].includes(mediaKind)
|
|
2057
|
+
? [{ stagingAssetId, mediaKind }]
|
|
2058
|
+
: [];
|
|
2059
|
+
})
|
|
2060
|
+
: [];
|
|
2061
|
+
let legacyKindAmbiguous = false;
|
|
2062
|
+
const legacyStagingAssetId = boundedString(
|
|
2063
|
+
payload.stagingAssetId,
|
|
2064
|
+
300,
|
|
2065
|
+
);
|
|
2066
|
+
if (!assets.length && legacyStagingAssetId) {
|
|
2067
|
+
const localKinds = new Set(
|
|
2068
|
+
state.items
|
|
2069
|
+
.filter((item) => item.status === "uploaded")
|
|
2070
|
+
.map((item) => item.mediaKind)
|
|
2071
|
+
.filter((mediaKind) => ["image", "video"].includes(mediaKind)),
|
|
2072
|
+
);
|
|
2073
|
+
const inferredMediaKind =
|
|
2074
|
+
localKinds.size === 1
|
|
2075
|
+
? Array.from(localKinds)[0]
|
|
2076
|
+
: localKinds.size === 0 &&
|
|
2077
|
+
["image", "video"].includes(state.session.mediaKind)
|
|
2078
|
+
? state.session.mediaKind
|
|
2079
|
+
: null;
|
|
2080
|
+
if (inferredMediaKind) {
|
|
2081
|
+
assets.push({
|
|
2082
|
+
stagingAssetId: legacyStagingAssetId,
|
|
2083
|
+
mediaKind: inferredMediaKind,
|
|
2084
|
+
});
|
|
2085
|
+
} else {
|
|
2086
|
+
legacyKindAmbiguous = true;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
return { status, assets, legacyKindAmbiguous };
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
function parseDestinationsResult(result) {
|
|
2093
|
+
const payload = resultPayload(result);
|
|
2094
|
+
return {
|
|
2095
|
+
portfolios: parseDestinationList(payload.portfolios, (entry) => ({
|
|
2096
|
+
id: boundedString(entry.id, 300),
|
|
2097
|
+
name: boundedString(entry.name, 300) || "Portfolio sin nombre",
|
|
2098
|
+
})),
|
|
2099
|
+
adAccounts: parseDestinationList(payload.adAccounts, (entry) => ({
|
|
2100
|
+
id: boundedString(entry.id, 300),
|
|
2101
|
+
name: boundedString(entry.name, 300) || "Cuenta sin nombre",
|
|
2102
|
+
currency: boundedString(entry.currency, 20),
|
|
2103
|
+
portfolioId: boundedString(entry.portfolioId, 300),
|
|
2104
|
+
accountStatus: Number(entry.accountStatus),
|
|
2105
|
+
})).filter(
|
|
2106
|
+
(entry) => entry.portfolioId && entry.accountStatus === 1,
|
|
2107
|
+
),
|
|
2108
|
+
pages: parseDestinationList(payload.pages, (entry) => ({
|
|
2109
|
+
id: boundedString(entry.id, 300),
|
|
2110
|
+
name: boundedString(entry.name, 300) || "Página sin nombre",
|
|
2111
|
+
})),
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
function parseDestinationList(value, mapper) {
|
|
2116
|
+
if (!Array.isArray(value)) return [];
|
|
2117
|
+
return value.slice(0, 1000).flatMap((entry) => {
|
|
2118
|
+
if (!isRecord(entry)) return [];
|
|
2119
|
+
const mapped = mapper(entry);
|
|
2120
|
+
return mapped.id ? [mapped] : [];
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function renderDestinations() {
|
|
2125
|
+
fillSelect(
|
|
2126
|
+
elements.portfolio,
|
|
2127
|
+
state.destinations.portfolios,
|
|
2128
|
+
"Selecciona un portfolio",
|
|
2129
|
+
(entry) => entry.name,
|
|
2130
|
+
);
|
|
2131
|
+
fillSelect(
|
|
2132
|
+
elements.page,
|
|
2133
|
+
state.destinations.pages,
|
|
2134
|
+
"Selecciona una página",
|
|
2135
|
+
(entry) => entry.name,
|
|
2136
|
+
);
|
|
2137
|
+
renderAdAccounts();
|
|
2138
|
+
elements.destinationSection.hidden = false;
|
|
2139
|
+
updateDestinationSubmit();
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function renderAdAccounts() {
|
|
2143
|
+
const portfolioId = elements.portfolio.value;
|
|
2144
|
+
const accounts = portfolioId
|
|
2145
|
+
? state.destinations.adAccounts.filter(
|
|
2146
|
+
(account) => account.portfolioId === portfolioId,
|
|
2147
|
+
)
|
|
2148
|
+
: [];
|
|
2149
|
+
fillSelect(
|
|
2150
|
+
elements.adAccount,
|
|
2151
|
+
accounts,
|
|
2152
|
+
portfolioId
|
|
2153
|
+
? "Selecciona una cuenta"
|
|
2154
|
+
: "Elige primero un portfolio",
|
|
2155
|
+
(entry) =>
|
|
2156
|
+
`${entry.name}${entry.currency ? ` · ${entry.currency}` : ""}`,
|
|
2157
|
+
);
|
|
2158
|
+
elements.adAccount.disabled = !portfolioId || !accounts.length;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function fillSelect(select, entries, placeholder, labelFor) {
|
|
2162
|
+
const fragment = document.createDocumentFragment();
|
|
2163
|
+
const empty = document.createElement("option");
|
|
2164
|
+
empty.value = "";
|
|
2165
|
+
empty.textContent = placeholder;
|
|
2166
|
+
fragment.append(empty);
|
|
2167
|
+
for (const entry of entries) {
|
|
2168
|
+
const option = document.createElement("option");
|
|
2169
|
+
option.value = entry.id;
|
|
2170
|
+
option.textContent = labelFor(entry);
|
|
2171
|
+
fragment.append(option);
|
|
2172
|
+
}
|
|
2173
|
+
select.replaceChildren(fragment);
|
|
2174
|
+
select.value = "";
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function updateDestinationSubmit() {
|
|
2178
|
+
elements.submitDestination.disabled = !(
|
|
2179
|
+
elements.portfolio.value &&
|
|
2180
|
+
elements.adAccount.value &&
|
|
2181
|
+
elements.page.value &&
|
|
2182
|
+
state.assets.length
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function exactHandoff() {
|
|
2187
|
+
return JSON.stringify({
|
|
2188
|
+
sessionId: state.session.sessionId,
|
|
2189
|
+
portfolioId: elements.portfolio.value,
|
|
2190
|
+
adAccountId: elements.adAccount.value,
|
|
2191
|
+
pageId: elements.page.value,
|
|
2192
|
+
assets: state.assets.slice(0, MAX_FILES).map((asset) => ({
|
|
2193
|
+
stagingAssetId: asset.stagingAssetId,
|
|
2194
|
+
mediaKind: asset.mediaKind,
|
|
2195
|
+
})),
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
async function submitDestination() {
|
|
2200
|
+
if (elements.submitDestination.disabled || !state.session) return;
|
|
2201
|
+
const handoff = exactHandoff();
|
|
2202
|
+
elements.submitDestination.disabled = true;
|
|
2203
|
+
try {
|
|
2204
|
+
const bridge = openAiBridge();
|
|
2205
|
+
if (bridge && typeof bridge.sendFollowUpMessage === "function") {
|
|
2206
|
+
await bridge.sendFollowUpMessage({ prompt: handoff });
|
|
2207
|
+
setSessionStatus(
|
|
2208
|
+
"Selección enviada al chat. Espera la confirmación antes de continuar.",
|
|
2209
|
+
"success",
|
|
2210
|
+
);
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
if (state.hostCanMessage) {
|
|
2214
|
+
await rpcRequest("ui/message", {
|
|
2215
|
+
role: "user",
|
|
2216
|
+
content: [{ type: "text", text: handoff }],
|
|
2217
|
+
});
|
|
2218
|
+
setSessionStatus(
|
|
2219
|
+
"Selección enviada al chat. Espera la confirmación antes de continuar.",
|
|
2220
|
+
"success",
|
|
2221
|
+
);
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
showCopyableHandoff(handoff);
|
|
2225
|
+
} catch {
|
|
2226
|
+
showCopyableHandoff(handoff);
|
|
2227
|
+
} finally {
|
|
2228
|
+
updateDestinationSubmit();
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function showCopyableHandoff(handoff) {
|
|
2233
|
+
elements.handoffCopy.value = handoff;
|
|
2234
|
+
elements.handoffSection.hidden = false;
|
|
2235
|
+
setSessionStatus(
|
|
2236
|
+
"Copia el handoff exacto y pégalo en el chat.",
|
|
2237
|
+
"warning",
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
async function copyHandoff() {
|
|
2242
|
+
const handoff = elements.handoffCopy.value;
|
|
2243
|
+
if (!handoff) return;
|
|
2244
|
+
if (
|
|
2245
|
+
navigator.clipboard &&
|
|
2246
|
+
typeof navigator.clipboard.writeText === "function"
|
|
2247
|
+
) {
|
|
2248
|
+
try {
|
|
2249
|
+
await navigator.clipboard.writeText(handoff);
|
|
2250
|
+
elements.copyButton.textContent = "Copiado";
|
|
2251
|
+
return;
|
|
2252
|
+
} catch {
|
|
2253
|
+
// Selection fallback below.
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
elements.handoffCopy.focus();
|
|
2257
|
+
elements.handoffCopy.select();
|
|
2258
|
+
elements.copyButton.textContent = "Seleccionado para copiar";
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
document.addEventListener("DOMContentLoaded", initialize, {
|
|
2262
|
+
once: true,
|
|
2263
|
+
});
|
|
2264
|
+
if (document.readyState !== "loading") initialize();
|
|
2265
|
+
})();
|
|
2266
|
+
</script>
|
|
2267
|
+
</body>
|
|
2268
|
+
</html>
|