@mevdragon/vidfarm-devcli 0.20.6 → 0.20.9

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.
Files changed (157) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +15 -1
  2. package/.agents/skills/vidfarm-director/SKILL.md +106 -0
  3. package/.agents/skills/vidfarm-director/recipes/find-and-fork-template.md +16 -0
  4. package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +13 -0
  5. package/.agents/skills/vidfarm-director/recipes/onboard-a-new-director.md +13 -0
  6. package/.agents/skills/vidfarm-director/recipes/retheme-template.md +17 -0
  7. package/.agents/skills/vidfarm-director/references/assets-and-sourcing.md +117 -0
  8. package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +245 -0
  9. package/.agents/skills/vidfarm-director/references/core-workflows.md +273 -0
  10. package/.agents/skills/vidfarm-director/references/editor-workflows.md +365 -0
  11. package/.agents/skills/vidfarm-director/references/onboarding.md +28 -0
  12. package/.agents/skills/vidfarm-director/references/primitives.md +308 -0
  13. package/SKILL.director.md +517 -250
  14. package/dist/src/cli.js +563 -99
  15. package/dist/src/devcli/clips.js +29 -12
  16. package/dist/src/devcli/doctor.js +2 -2
  17. package/dist/src/devcli/local-backend.js +46 -2
  18. package/dist/src/services/clip-curation/cost.js +5 -1
  19. package/dist/src/services/clip-curation/gemini.js +14 -2
  20. package/dist/src/services/clip-curation/hunt.js +56 -0
  21. package/dist/src/services/clip-curation/index.js +1 -1
  22. package/dist/src/services/clip-curation/scan.js +5 -3
  23. package/package.json +30 -10
  24. package/SKILL.platform.md +0 -432
  25. package/demo/README.md +0 -28
  26. package/demo/dist/app.css +0 -1
  27. package/demo/dist/app.js +0 -1850
  28. package/demo/dist/chunks/chunk-DXB73IDG.js +0 -1
  29. package/demo/dist/chunks/chunk-S7OWAJDS.js +0 -36
  30. package/demo/dist/chunks/chunk-VTIBZ6AN.js +0 -1
  31. package/demo/dist/chunks/dist-ADSJKBVE.js +0 -332
  32. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +0 -1
  33. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +0 -425
  34. package/demo/dist/chunks/lib-XAQ37YOE.js +0 -1
  35. package/demo/dist/chunks/src-TJ2QYA4U.js +0 -207
  36. package/demo/dist/favicon.ico +0 -0
  37. package/demo/dist/icons/timeline/audio.svg +0 -7
  38. package/demo/dist/icons/timeline/captions.svg +0 -5
  39. package/demo/dist/icons/timeline/composition.svg +0 -12
  40. package/demo/dist/icons/timeline/image.svg +0 -18
  41. package/demo/dist/icons/timeline/music.svg +0 -10
  42. package/demo/dist/icons/timeline/text.svg +0 -3
  43. package/demo/dist/index.html +0 -15
  44. package/dist/src/account-pages-legacy.js +0 -9396
  45. package/dist/src/account-pages.js +0 -61
  46. package/dist/src/app.js +0 -21603
  47. package/dist/src/composition-runtime.js +0 -1053
  48. package/dist/src/config.js +0 -217
  49. package/dist/src/context.js +0 -447
  50. package/dist/src/dev-app-legacy.js +0 -739
  51. package/dist/src/dev-app.js +0 -6
  52. package/dist/src/devcli/migrate-local.js +0 -140
  53. package/dist/src/devcli/sync.js +0 -368
  54. package/dist/src/domain.js +0 -5
  55. package/dist/src/editor-chat-history.js +0 -82
  56. package/dist/src/editor-chat.js +0 -828
  57. package/dist/src/editor-dark-theme.js +0 -1128
  58. package/dist/src/frontend/debug.js +0 -71
  59. package/dist/src/frontend/discover-client.js +0 -130
  60. package/dist/src/frontend/discover-store.js +0 -23
  61. package/dist/src/frontend/file-directory.js +0 -1018
  62. package/dist/src/frontend/flockposter-cache-store.js +0 -124
  63. package/dist/src/frontend/homepage-client.js +0 -446
  64. package/dist/src/frontend/homepage-shared.js +0 -201
  65. package/dist/src/frontend/homepage-store.js +0 -66
  66. package/dist/src/frontend/homepage-view.js +0 -705
  67. package/dist/src/frontend/page-runtime-client.js +0 -132
  68. package/dist/src/frontend/page-runtime-store.js +0 -9
  69. package/dist/src/frontend/sentry.js +0 -42
  70. package/dist/src/frontend/template-editor-chat.js +0 -4181
  71. package/dist/src/help-page.js +0 -346
  72. package/dist/src/homepage.js +0 -1458
  73. package/dist/src/index.js +0 -16
  74. package/dist/src/instrument.js +0 -30
  75. package/dist/src/landing-page.js +0 -384
  76. package/dist/src/page-runtime.js +0 -2
  77. package/dist/src/page-shell.js +0 -1452
  78. package/dist/src/primitive-context.js +0 -416
  79. package/dist/src/primitive-registry.js +0 -3940
  80. package/dist/src/primitive-sdk.js +0 -4
  81. package/dist/src/primitives/hyperframes-media.js +0 -108
  82. package/dist/src/react-page-shell.js +0 -35
  83. package/dist/src/ready-post-schedule-component.js +0 -1540
  84. package/dist/src/registry.js +0 -296
  85. package/dist/src/reskin/agency-page.js +0 -299
  86. package/dist/src/reskin/calendar-page.js +0 -568
  87. package/dist/src/reskin/chat-page.js +0 -942
  88. package/dist/src/reskin/discover-page.js +0 -1788
  89. package/dist/src/reskin/document.js +0 -1587
  90. package/dist/src/reskin/help-page.js +0 -357
  91. package/dist/src/reskin/index-page.js +0 -62
  92. package/dist/src/reskin/inpaint-clipper-page.js +0 -890
  93. package/dist/src/reskin/inpaint-page.js +0 -2554
  94. package/dist/src/reskin/inpaint-video-page.js +0 -1339
  95. package/dist/src/reskin/job-runs-page.js +0 -477
  96. package/dist/src/reskin/library-page.js +0 -1634
  97. package/dist/src/reskin/login-page.js +0 -262
  98. package/dist/src/reskin/portfolio-page.js +0 -687
  99. package/dist/src/reskin/pricing-page.js +0 -390
  100. package/dist/src/reskin/settings-page.js +0 -732
  101. package/dist/src/reskin/theme.js +0 -711
  102. package/dist/src/runtime.js +0 -35
  103. package/dist/src/services/api-call-history.js +0 -249
  104. package/dist/src/services/auth.js +0 -152
  105. package/dist/src/services/billing-pricing.js +0 -39
  106. package/dist/src/services/billing.js +0 -241
  107. package/dist/src/services/cast.js +0 -127
  108. package/dist/src/services/chat-threads.js +0 -92
  109. package/dist/src/services/clip-records.js +0 -250
  110. package/dist/src/services/clip-search.js +0 -77
  111. package/dist/src/services/clip-vectors.js +0 -125
  112. package/dist/src/services/composition-sanitize.js +0 -124
  113. package/dist/src/services/composition-watch.js +0 -79
  114. package/dist/src/services/elevenlabs.js +0 -222
  115. package/dist/src/services/file-directory.js +0 -117
  116. package/dist/src/services/fork-access.js +0 -93
  117. package/dist/src/services/fork-manifest.js +0 -42
  118. package/dist/src/services/ghostcut.js +0 -179
  119. package/dist/src/services/hyperframes.js +0 -3654
  120. package/dist/src/services/job-capacity.js +0 -14
  121. package/dist/src/services/job-logs.js +0 -197
  122. package/dist/src/services/jobs.js +0 -136
  123. package/dist/src/services/local-dynamo.js +0 -0
  124. package/dist/src/services/media-processing.js +0 -766
  125. package/dist/src/services/primitive-media-lambda.js +0 -280
  126. package/dist/src/services/providers.js +0 -2748
  127. package/dist/src/services/rate-limits.js +0 -262
  128. package/dist/src/services/scene-annotations.js +0 -32
  129. package/dist/src/services/serverless-auth.js +0 -382
  130. package/dist/src/services/serverless-jobs.js +0 -1084
  131. package/dist/src/services/serverless-provider-keys.js +0 -409
  132. package/dist/src/services/serverless-records.js +0 -1515
  133. package/dist/src/services/serverless-template-configs.js +0 -75
  134. package/dist/src/services/storage.js +0 -461
  135. package/dist/src/services/swipe-customize.js +0 -437
  136. package/dist/src/services/template-certification.js +0 -413
  137. package/dist/src/services/template-loader.js +0 -99
  138. package/dist/src/services/template-runtime-bundles.js +0 -217
  139. package/dist/src/services/template-sources.js +0 -1017
  140. package/dist/src/services/upstream.js +0 -248
  141. package/dist/src/services/video-normalization.js +0 -2
  142. package/dist/src/services/webhooks.js +0 -62
  143. package/dist/src/template-editor-pages.js +0 -2576
  144. package/dist/src/template-editor-shell.js +0 -2893
  145. package/dist/src/template-sdk.js +0 -4
  146. package/dist/src/worker.js +0 -17
  147. package/public/assets/discover-client-app.js +0 -1
  148. package/public/assets/file-directory-app.js +0 -3
  149. package/public/assets/homepage-app.js +0 -54
  150. package/public/assets/homepage-client-app.js +0 -80
  151. package/public/assets/page-runtime-client-app.js +0 -94
  152. package/public/assets/placeholders/scene-placeholder.png +0 -0
  153. package/src/assets/SELLING_AWARENESS_STAGES.md +0 -579
  154. package/src/assets/SELLING_WITH_HOOKS.md +0 -377
  155. package/src/assets/SELLING_WITH_VSLS.md +0 -606
  156. package/src/assets/favicon.ico +0 -0
  157. package/src/assets/logo-vidfarm.png +0 -0
@@ -1,4181 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
- import { createPortal } from "react-dom";
4
- import { debugError, debugLog, debugWarn } from "./debug.js";
5
- // Parse the fork id out of the canonical editor path
6
- // /editor/<template_id>/fork/<fork_id> → "<fork_id>" (else null). The editor
7
- // URL scheme moved from ?fork= to a path segment; this reads the new form.
8
- function forkIdFromLocation() {
9
- if (typeof window === "undefined")
10
- return null;
11
- try {
12
- const match = new URL(window.location.href).pathname.match(/^\/editor\/[^/]+\/fork\/([^/]+)\/?$/);
13
- if (!match)
14
- return null;
15
- const forkId = decodeURIComponent(match[1]);
16
- // /editor/original/fork/new (blank new project) has no real fork yet.
17
- return forkId === "new" ? null : forkId;
18
- }
19
- catch {
20
- return null;
21
- }
22
- }
23
- const MB = 1024 * 1024;
24
- const MY_FILES_UPLOAD_LIMIT_BYTES = 50 * MB;
25
- const CHAT_INLINE_IMAGE_TARGET_BYTES = 2 * MB;
26
- const CHAT_INLINE_IMAGE_HARD_LIMIT_BYTES = 3 * MB;
27
- const CHAT_INLINE_TOTAL_BUDGET_BYTES = Math.floor(3.5 * MB);
28
- const CHAT_INLINE_IMAGE_MAX_EDGE = 2048;
29
- const CHAT_TEMPORARY_UPLOAD_FOLDER = "chat";
30
- function normalizeUploadedAttachment(raw) {
31
- const id = raw.id || raw.attachment_id || raw.file_id || "";
32
- const fileName = raw.fileName || raw.file_name;
33
- const contentType = raw.contentType || raw.content_type;
34
- const viewUrl = raw.viewUrl || raw.view_url || "";
35
- if (!id || !fileName || !contentType || !viewUrl) {
36
- return null;
37
- }
38
- return {
39
- id,
40
- fileName,
41
- contentType,
42
- viewUrl,
43
- sizeBytes: typeof raw.sizeBytes === "number" ? raw.sizeBytes : raw.size_bytes,
44
- folderPath: raw.folderPath ?? raw.folder_path ?? ""
45
- };
46
- }
47
- function isImageAttachment(attachment) {
48
- return attachment.contentType.startsWith("image/");
49
- }
50
- function isVideoAttachment(attachment) {
51
- return attachment.contentType.startsWith("video/");
52
- }
53
- const INLINE_MEDIA_IMAGE_EXTENSIONS = new Set([
54
- "apng",
55
- "avif",
56
- "gif",
57
- "jpeg",
58
- "jpg",
59
- "png",
60
- "svg",
61
- "webp"
62
- ]);
63
- const INLINE_MEDIA_VIDEO_EXTENSIONS = new Set([
64
- "m4v",
65
- "mov",
66
- "mp4",
67
- "mpeg",
68
- "mpg",
69
- "ogv",
70
- "webm"
71
- ]);
72
- const MAX_CHAT_PAYLOAD_STRING_LENGTH = 1200;
73
- const MAX_CHAT_PAYLOAD_ARRAY_ITEMS = 24;
74
- const MAX_CHAT_PAYLOAD_OBJECT_KEYS = 80;
75
- const DATA_URL_PREFIX_REGEX = /^data:([^;,]+)[^,]*,/i;
76
- const SECRET_HEADER_REGEX = /authorization|api[-_]?key|secret|token|vidfarm-api-key/i;
77
- const CHAT_TRANSPORT_COMPACT_CHAR_THRESHOLD = 48_000;
78
- const CHAT_TRANSPORT_RECENT_MESSAGES = 14;
79
- const CHAT_TRANSPORT_MAX_SUMMARY_CHARS = 12_000;
80
- const CHAT_TRANSPORT_MAX_MESSAGE_CHARS = 1_200;
81
- const LOCAL_THREAD_MESSAGE_CACHE_LIMIT = 30;
82
- function formatJsonBody(value) {
83
- if (value === undefined || value === null || value === "") {
84
- return "";
85
- }
86
- if (typeof value === "string") {
87
- try {
88
- return JSON.stringify(JSON.parse(value), null, 2);
89
- }
90
- catch {
91
- return value;
92
- }
93
- }
94
- return JSON.stringify(value, null, 2);
95
- }
96
- function summarizeLargeString(value) {
97
- const dataUrlMatch = DATA_URL_PREFIX_REGEX.exec(value);
98
- if (dataUrlMatch) {
99
- return `[${dataUrlMatch[1]} data URL omitted, ${value.length.toLocaleString()} chars]`;
100
- }
101
- if (value.length > MAX_CHAT_PAYLOAD_STRING_LENGTH) {
102
- return `${value.slice(0, MAX_CHAT_PAYLOAD_STRING_LENGTH)}… [truncated ${value.length.toLocaleString()} chars]`;
103
- }
104
- return value;
105
- }
106
- function sanitizeChatPayload(value, depth = 0) {
107
- if (value === null || value === undefined) {
108
- return value;
109
- }
110
- if (typeof value === "string") {
111
- return summarizeLargeString(value);
112
- }
113
- if (typeof value === "number" || typeof value === "boolean") {
114
- return value;
115
- }
116
- if (typeof value === "bigint") {
117
- return value.toString();
118
- }
119
- if (typeof value === "function" || typeof value === "symbol") {
120
- return undefined;
121
- }
122
- if (depth >= 8) {
123
- return "[nested payload omitted]";
124
- }
125
- if (Array.isArray(value)) {
126
- const items = value
127
- .slice(0, MAX_CHAT_PAYLOAD_ARRAY_ITEMS)
128
- .map((entry) => sanitizeChatPayload(entry, depth + 1));
129
- if (value.length > MAX_CHAT_PAYLOAD_ARRAY_ITEMS) {
130
- items.push(`[${value.length - MAX_CHAT_PAYLOAD_ARRAY_ITEMS} more items omitted]`);
131
- }
132
- return items;
133
- }
134
- if (typeof value === "object") {
135
- const output = {};
136
- const entries = Object.entries(value);
137
- for (const [key, entry] of entries.slice(0, MAX_CHAT_PAYLOAD_OBJECT_KEYS)) {
138
- const sanitized = sanitizeChatPayload(entry, depth + 1);
139
- if (sanitized !== undefined) {
140
- output[key] = sanitized;
141
- }
142
- }
143
- if (entries.length > MAX_CHAT_PAYLOAD_OBJECT_KEYS) {
144
- output.__omitted_keys = entries.length - MAX_CHAT_PAYLOAD_OBJECT_KEYS;
145
- }
146
- return output;
147
- }
148
- return String(value);
149
- }
150
- function sanitizeToolResult(result) {
151
- return sanitizeChatPayload(result);
152
- }
153
- function sanitizeHttpExchange(exchange) {
154
- return {
155
- ...exchange,
156
- request: {
157
- ...exchange.request,
158
- headers: Object.fromEntries(Object.entries(exchange.request.headers).map(([key, value]) => [
159
- key,
160
- SECRET_HEADER_REGEX.test(key) ? "[redacted]" : summarizeLargeString(value)
161
- ])),
162
- body: sanitizeChatPayload(exchange.request.body)
163
- },
164
- result: {
165
- ...exchange.result,
166
- headers: Object.fromEntries(Object.entries(exchange.result.headers).map(([key, value]) => [
167
- key,
168
- SECRET_HEADER_REGEX.test(key) ? "[redacted]" : summarizeLargeString(value)
169
- ])),
170
- body: sanitizeChatPayload(exchange.result.body),
171
- error: exchange.result.error ? summarizeLargeString(exchange.result.error) : exchange.result.error
172
- }
173
- };
174
- }
175
- function buildBrowserDateContext() {
176
- const now = new Date();
177
- let localDateTime = null;
178
- let timeZone = null;
179
- try {
180
- localDateTime = new Intl.DateTimeFormat(undefined, {
181
- dateStyle: "full",
182
- timeStyle: "long"
183
- }).format(now);
184
- }
185
- catch {
186
- localDateTime = now.toString();
187
- }
188
- try {
189
- timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
190
- }
191
- catch {
192
- timeZone = null;
193
- }
194
- return {
195
- currentDateTime: now.toISOString(),
196
- localDateTime,
197
- timeZone
198
- };
199
- }
200
- function escapeHtml(value) {
201
- return value
202
- .replaceAll("&", "&amp;")
203
- .replaceAll("<", "&lt;")
204
- .replaceAll(">", "&gt;")
205
- .replaceAll("\"", "&quot;")
206
- .replaceAll("'", "&#39;");
207
- }
208
- function renderInlineMarkdown(value) {
209
- let html = escapeHtml(value);
210
- html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
211
- html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
212
- html = html.replace(/(^|[^\*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>");
213
- html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
214
- html = html.replace(/(^|[\s(])(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g, '$1<a href="$2" target="_blank" rel="noopener noreferrer">$2</a>');
215
- return html;
216
- }
217
- function renderMarkdownToHtml(value) {
218
- const normalized = value.replace(/\r\n/g, "\n").trim();
219
- if (!normalized) {
220
- return "";
221
- }
222
- const blocks = normalized.split(/\n{2,}/);
223
- return blocks.map((block) => {
224
- const trimmed = block.trim();
225
- if (!trimmed) {
226
- return "";
227
- }
228
- if (trimmed.startsWith("```") && trimmed.endsWith("```")) {
229
- const code = trimmed.replace(/^```[^\n]*\n?/, "").replace(/\n?```$/, "");
230
- return `<pre><code>${escapeHtml(code)}</code></pre>`;
231
- }
232
- const lines = trimmed.split("\n");
233
- const isBulletList = lines.every((line) => /^[-*]\s+/.test(line.trim()));
234
- if (isBulletList) {
235
- const items = lines
236
- .map((line) => line.trim().replace(/^[-*]\s+/, ""))
237
- .map((line) => `<li>${renderInlineMarkdown(line)}</li>`)
238
- .join("");
239
- return `<ul>${items}</ul>`;
240
- }
241
- const isOrderedList = lines.every((line) => /^\d+\.\s+/.test(line.trim()));
242
- if (isOrderedList) {
243
- const items = lines
244
- .map((line) => line.trim().replace(/^\d+\.\s+/, ""))
245
- .map((line) => `<li>${renderInlineMarkdown(line)}</li>`)
246
- .join("");
247
- return `<ol>${items}</ol>`;
248
- }
249
- return `<p>${lines.map((line) => renderInlineMarkdown(line.trim())).join("<br />")}</p>`;
250
- }).filter(Boolean).join("");
251
- }
252
- function titleCaseLabel(value) {
253
- return value
254
- .replace(/[_-]+/g, " ")
255
- .replace(/\s+/g, " ")
256
- .trim()
257
- .replace(/\b\w/g, (char) => char.toUpperCase());
258
- }
259
- function isGenericPathLabel(value) {
260
- return /^item \d+$/i.test(value.trim()) || /^output$/i.test(value.trim());
261
- }
262
- function extractStorageKeyFromUrl(url) {
263
- try {
264
- const parsed = new URL(url);
265
- const queryKey = parsed.searchParams.get("key");
266
- if (queryKey?.trim()) {
267
- return queryKey.trim().replace(/^\/+/, "");
268
- }
269
- return null;
270
- }
271
- catch {
272
- return null;
273
- }
274
- }
275
- function safeDecodeURIComponent(value) {
276
- try {
277
- return decodeURIComponent(value);
278
- }
279
- catch {
280
- return value;
281
- }
282
- }
283
- function extractFilenameFromUrl(url) {
284
- const storageKey = extractStorageKeyFromUrl(url);
285
- if (storageKey) {
286
- const fileName = safeDecodeURIComponent(storageKey.split("/").filter(Boolean).pop() || "");
287
- if (fileName) {
288
- return fileName;
289
- }
290
- }
291
- try {
292
- const pathname = new URL(url).pathname;
293
- return safeDecodeURIComponent(pathname.split("/").filter(Boolean).pop() || "");
294
- }
295
- catch {
296
- return "";
297
- }
298
- }
299
- function labelFromUrl(url) {
300
- const filename = extractFilenameFromUrl(url);
301
- return filename || "Output";
302
- }
303
- function downloadNameFromUrl(url, fallback) {
304
- const filename = extractFilenameFromUrl(url);
305
- if (filename) {
306
- return filename;
307
- }
308
- const normalizedFallback = fallback.trim().replace(/[\\/:*?"<>|]+/g, "-");
309
- return normalizedFallback || "download";
310
- }
311
- function getMediaKindFromUrl(url) {
312
- const fileName = extractFilenameFromUrl(url);
313
- const extension = fileName.split(".").pop()?.trim().toLowerCase() || "";
314
- if (INLINE_MEDIA_IMAGE_EXTENSIONS.has(extension)) {
315
- return "image";
316
- }
317
- if (INLINE_MEDIA_VIDEO_EXTENSIONS.has(extension)) {
318
- return "video";
319
- }
320
- return null;
321
- }
322
- function extractCandidateUrls(value) {
323
- const urls = new Set();
324
- const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
325
- const bareUrlPattern = /(^|[\s(])(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/gm;
326
- let match = null;
327
- while ((match = markdownLinkPattern.exec(value)) !== null) {
328
- urls.add(match[2]);
329
- }
330
- while ((match = bareUrlPattern.exec(value)) !== null) {
331
- urls.add(match[2]);
332
- }
333
- return [...urls];
334
- }
335
- function extractInlineMediaPreviews(text, excludedUrls = []) {
336
- const excluded = new Set(excludedUrls);
337
- const seen = new Set();
338
- const previews = [];
339
- for (const url of extractCandidateUrls(text)) {
340
- if (excluded.has(url) || seen.has(url)) {
341
- continue;
342
- }
343
- const kind = getMediaKindFromUrl(url);
344
- if (!kind) {
345
- continue;
346
- }
347
- seen.add(url);
348
- previews.push({
349
- kind,
350
- url,
351
- label: labelFromUrl(url)
352
- });
353
- }
354
- return previews;
355
- }
356
- function collectLabeledUrls(value, output, seen = new Set(), path = []) {
357
- if (output.length >= 12 || value == null) {
358
- return;
359
- }
360
- if (typeof value === "string") {
361
- const trimmed = value.trim();
362
- if (/^https?:\/\//i.test(trimmed) && !seen.has(trimmed)) {
363
- seen.add(trimmed);
364
- const rawPathLabel = path.length ? path[path.length - 1] || "Output" : "";
365
- const pathLabel = titleCaseLabel(rawPathLabel);
366
- const filenameLabel = labelFromUrl(trimmed);
367
- output.push({
368
- label: filenameLabel !== "Output" ? filenameLabel : pathLabel && !isGenericPathLabel(rawPathLabel) ? pathLabel : filenameLabel,
369
- url: trimmed
370
- });
371
- }
372
- return;
373
- }
374
- if (Array.isArray(value)) {
375
- for (const [index, entry] of value.entries()) {
376
- collectLabeledUrls(entry, output, seen, [...path, `item ${index + 1}`]);
377
- if (output.length >= 12) {
378
- return;
379
- }
380
- }
381
- return;
382
- }
383
- if (typeof value === "object") {
384
- for (const [key, entry] of Object.entries(value)) {
385
- collectLabeledUrls(entry, output, seen, [...path, key]);
386
- if (output.length >= 12) {
387
- return;
388
- }
389
- }
390
- }
391
- }
392
- function buildJobQueuedMessage(job) {
393
- // MUST stay string-identical to buildAsyncJobHandoffText in src/editor-chat.ts —
394
- // the server streams that text and this client copy dedupes it by substring.
395
- // Job-KIND-agnostic ("job", not "render"): fires for any queued async job.
396
- return [
397
- `Started job \`${job.job_id}\`${job.tracer ? ` with tracer \`${job.tracer}\`` : ""}.`,
398
- "If it takes a while, ask for an update later and I can check it for you."
399
- ].join("\n\n");
400
- }
401
- function isBrainstormPrimitiveId(value) {
402
- return typeof value === "string" && value.startsWith("primitive:brainstorm_");
403
- }
404
- function normalizeJobArtifacts(value) {
405
- if (!Array.isArray(value)) {
406
- return [];
407
- }
408
- const artifacts = [];
409
- for (const entry of value) {
410
- if (!entry || typeof entry !== "object") {
411
- continue;
412
- }
413
- const artifact = entry;
414
- const normalized = {
415
- artifact_id: typeof artifact.artifact_id === "string" ? artifact.artifact_id : undefined,
416
- kind: typeof artifact.kind === "string" ? artifact.kind : undefined,
417
- storage_key: typeof artifact.storage_key === "string" ? artifact.storage_key : undefined,
418
- public_url: typeof artifact.public_url === "string" ? artifact.public_url : undefined,
419
- metadata: artifact.metadata && typeof artifact.metadata === "object"
420
- ? artifact.metadata
421
- : null,
422
- created_at: typeof artifact.created_at === "string" ? artifact.created_at : undefined
423
- };
424
- if (normalized.public_url || normalized.storage_key) {
425
- artifacts.push(normalized);
426
- }
427
- }
428
- return artifacts;
429
- }
430
- function artifactLabelFromStorageKey(storageKey) {
431
- if (!storageKey) {
432
- return "JSON result";
433
- }
434
- const name = safeDecodeURIComponent(storageKey.split("/").filter(Boolean).pop() || "result.json");
435
- return name;
436
- }
437
- function artifactFileName(storageKey) {
438
- return safeDecodeURIComponent(storageKey?.split("/").filter(Boolean).pop() || "");
439
- }
440
- function brainstormLabelFromPrimitiveId(primitiveId) {
441
- switch (primitiveId) {
442
- case "primitive:brainstorm_hooks":
443
- return "Hooks JSON";
444
- case "primitive:brainstorm_angles":
445
- return "Angles JSON";
446
- case "primitive:brainstorm_awareness_stages":
447
- return "Awareness JSON";
448
- case "primitive:brainstorm_coldstart":
449
- return "Coldstart JSON";
450
- default:
451
- return "Brainstorm JSON";
452
- }
453
- }
454
- function extractBrainstormJsonArtifactsFromJob(job) {
455
- if (job.status !== "succeeded" || !isBrainstormPrimitiveId(job.primitive_id)) {
456
- return [];
457
- }
458
- const previews = [];
459
- for (const [index, artifact] of normalizeJobArtifacts(job.artifacts).entries()) {
460
- const publicUrl = artifact.public_url?.trim();
461
- const storageKey = artifact.storage_key?.trim();
462
- const isJsonArtifact = artifact.kind === "json"
463
- || Boolean(publicUrl && /\.json(?:[?#]|$)/i.test(publicUrl))
464
- || Boolean(storageKey && /\.json$/i.test(storageKey));
465
- if (!isJsonArtifact || !publicUrl) {
466
- continue;
467
- }
468
- const fileLabel = artifactLabelFromStorageKey(artifact.storage_key);
469
- const prefixLabel = brainstormLabelFromPrimitiveId(job.primitive_id);
470
- previews.push({
471
- key: artifact.artifact_id || `${job.job_id}:${publicUrl}:${index}`,
472
- url: publicUrl,
473
- label: fileLabel === "result.json" ? prefixLabel : `${prefixLabel} • ${fileLabel}`,
474
- primitiveId: job.primitive_id,
475
- primitiveType: job.primitive_type,
476
- jobId: job.job_id
477
- });
478
- }
479
- return previews;
480
- }
481
- function brainstormDocumentLabelFromPrimitiveId(primitiveId) {
482
- switch (primitiveId) {
483
- case "primitive:brainstorm_awareness_stages":
484
- return "Awareness response";
485
- default:
486
- return "Brainstorm response";
487
- }
488
- }
489
- function extractBrainstormDocumentArtifactsFromJob(job) {
490
- if (job.status !== "succeeded" || !isBrainstormPrimitiveId(job.primitive_id)) {
491
- return [];
492
- }
493
- const previews = [];
494
- for (const [index, artifact] of normalizeJobArtifacts(job.artifacts).entries()) {
495
- const publicUrl = artifact.public_url?.trim();
496
- const storageKey = artifact.storage_key?.trim();
497
- const fileName = artifactFileName(storageKey);
498
- const isPrompt = /^prompt\./i.test(fileName);
499
- const isMarkdown = artifact.kind === "text"
500
- ? Boolean(publicUrl && /\.md(?:[?#]|$)/i.test(publicUrl) || storageKey && /\.md$/i.test(storageKey))
501
- : Boolean(publicUrl && /\.md(?:[?#]|$)/i.test(publicUrl) || storageKey && /\.md$/i.test(storageKey));
502
- const isText = Boolean(publicUrl && /\.txt(?:[?#]|$)/i.test(publicUrl) || storageKey && /\.txt$/i.test(storageKey));
503
- if (!publicUrl || isPrompt || (!isMarkdown && !isText)) {
504
- continue;
505
- }
506
- const defaultLabel = brainstormDocumentLabelFromPrimitiveId(job.primitive_id);
507
- previews.push({
508
- key: artifact.artifact_id || `${job.job_id}:${publicUrl}:${index}`,
509
- url: publicUrl,
510
- label: fileName === "response.md" || fileName === "response.txt" ? defaultLabel : artifactLabelFromStorageKey(storageKey),
511
- jobId: job.job_id,
512
- format: isMarkdown ? "markdown" : "text"
513
- });
514
- }
515
- return previews;
516
- }
517
- function buildJobCompletionMessage(job) {
518
- if (job.status === "succeeded") {
519
- const urls = [];
520
- collectLabeledUrls(job.result, urls);
521
- const lines = [`Render \`${job.job_id}\` succeeded.`];
522
- if (isBrainstormPrimitiveId(job.primitive_id)
523
- && (extractBrainstormJsonArtifactsFromJob(job).length || extractBrainstormDocumentArtifactsFromJob(job).length)) {
524
- lines.push("");
525
- lines.push("Open the result below. It contains the brainstorm output you can review, copy, and reuse.");
526
- }
527
- if (urls.length) {
528
- lines.push("");
529
- lines.push("Outputs:");
530
- lines.push(...urls.map(({ url, label }) => `- [${label}](${url})`));
531
- }
532
- return lines.join("\n");
533
- }
534
- if (job.status === "failed") {
535
- return `Render \`${job.job_id}\` failed.${job.error?.message ? ` ${job.error.message}` : ""}`;
536
- }
537
- if (job.status === "cancelled") {
538
- return `Render \`${job.job_id}\` was cancelled.`;
539
- }
540
- return `Render \`${job.job_id}\` is ${job.status}.`;
541
- }
542
- function appendDistinctMessageText(current, addition) {
543
- const normalizedCurrent = current.trim();
544
- const normalizedAddition = addition.trim();
545
- if (!normalizedAddition) {
546
- return current;
547
- }
548
- if (normalizedCurrent.includes(normalizedAddition)) {
549
- return current;
550
- }
551
- return normalizedCurrent ? `${normalizedCurrent}\n\n${normalizedAddition}` : normalizedAddition;
552
- }
553
- const CHAT_BOTTOM_STICKY_THRESHOLD_PX = 48;
554
- function isScrolledNearBottom(element) {
555
- return element.scrollHeight - element.scrollTop - element.clientHeight <= CHAT_BOTTOM_STICKY_THRESHOLD_PX;
556
- }
557
- function upsertAutomaticPollExchange(exchanges, nextExchange) {
558
- const current = [...(exchanges ?? [])];
559
- const matchIndex = current.findIndex((exchange) => (exchange.pollMode === "automatic" && exchange.pollJobId === nextExchange.pollJobId));
560
- if (matchIndex === -1) {
561
- current.push(nextExchange);
562
- return current;
563
- }
564
- current[matchIndex] = nextExchange;
565
- return current;
566
- }
567
- const JOB_POLL_INTERVAL_MS = 30_000;
568
- const JOB_POLL_BUSY_INTERVAL_MS = 180_000;
569
- const JOB_POLL_BUSY_THRESHOLD = 4;
570
- const JOB_POLL_MAX_ATTEMPTS = 40;
571
- const JOB_POLL_GLOBAL_MIN_GAP_MS = 1_500;
572
- const JOB_POLL_MIN_TIMER_MS = 250;
573
- function tryReadJobStatusPayload(value) {
574
- if (!value || typeof value !== "object") {
575
- return null;
576
- }
577
- const record = value;
578
- if (typeof record.job_id !== "string" || typeof record.status !== "string") {
579
- return null;
580
- }
581
- return {
582
- job_id: record.job_id,
583
- tracer: typeof record.tracer === "string" ? record.tracer : undefined,
584
- status: record.status,
585
- primitive_id: typeof record.primitive_id === "string" ? record.primitive_id : undefined,
586
- primitive_type: typeof record.primitive_type === "string" ? record.primitive_type : undefined,
587
- operation_name: typeof record.operation_name === "string" ? record.operation_name : undefined,
588
- progress: typeof record.progress === "number" ? record.progress : null,
589
- result: record.result,
590
- artifacts: normalizeJobArtifacts(record.artifacts),
591
- error: record.error && typeof record.error === "object"
592
- ? {
593
- message: typeof record.error.message === "string"
594
- ? record.error.message
595
- : undefined,
596
- type: typeof record.error.type === "string"
597
- ? record.error.type
598
- : undefined
599
- }
600
- : null
601
- };
602
- }
603
- function isPrimitiveRestUrl(value) {
604
- if (!value) {
605
- return false;
606
- }
607
- try {
608
- const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
609
- return new URL(value, base).pathname.startsWith("/api/v1/primitives");
610
- }
611
- catch {
612
- return false;
613
- }
614
- }
615
- function isTerminalJobStatus(status) {
616
- return status === "succeeded" || status === "failed" || status === "cancelled";
617
- }
618
- function makeJobPollerKey(threadId, assistantMessageId, jobId) {
619
- return `${threadId}::${assistantMessageId}::${jobId}`;
620
- }
621
- function buildAuthHeaders(apiKey, contentType) {
622
- const headers = new Headers();
623
- if (apiKey) {
624
- headers.set("vidfarm-api-key", apiKey);
625
- }
626
- if (contentType) {
627
- headers.set("content-type", contentType);
628
- }
629
- return headers;
630
- }
631
- async function parseJson(response) {
632
- return await response.json();
633
- }
634
- async function fileToDataUrl(file) {
635
- return await new Promise((resolve, reject) => {
636
- const reader = new FileReader();
637
- reader.onload = () => {
638
- if (typeof reader.result === "string") {
639
- resolve(reader.result);
640
- return;
641
- }
642
- reject(new Error("Unable to read attachment contents."));
643
- };
644
- reader.onerror = () => reject(reader.error ?? new Error("Unable to read attachment contents."));
645
- reader.readAsDataURL(file);
646
- });
647
- }
648
- async function blobToDataUrl(blob) {
649
- return await new Promise((resolve, reject) => {
650
- const reader = new FileReader();
651
- reader.onload = () => {
652
- if (typeof reader.result === "string") {
653
- resolve(reader.result);
654
- return;
655
- }
656
- reject(new Error("Unable to read compressed attachment contents."));
657
- };
658
- reader.onerror = () => reject(reader.error ?? new Error("Unable to read compressed attachment contents."));
659
- reader.readAsDataURL(blob);
660
- });
661
- }
662
- function estimateDataUrlBytes(value) {
663
- if (!value) {
664
- return 0;
665
- }
666
- const marker = ";base64,";
667
- const markerIndex = value.indexOf(marker);
668
- if (markerIndex < 0) {
669
- return value.length;
670
- }
671
- const base64 = value.slice(markerIndex + marker.length).replace(/\s/g, "");
672
- const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
673
- return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
674
- }
675
- function estimateInlineAttachmentBytes(attachments) {
676
- return attachments.reduce((total, attachment) => total + estimateDataUrlBytes(attachment.transportData), 0);
677
- }
678
- async function canvasToBlob(canvas, contentType, quality) {
679
- return await new Promise((resolve) => {
680
- canvas.toBlob((blob) => resolve(blob), contentType, quality);
681
- });
682
- }
683
- async function decodeImageForCanvas(file) {
684
- if (typeof createImageBitmap === "function") {
685
- try {
686
- const bitmap = await createImageBitmap(file);
687
- return {
688
- source: bitmap,
689
- width: bitmap.width,
690
- height: bitmap.height,
691
- close: () => bitmap.close()
692
- };
693
- }
694
- catch {
695
- // Fall back to HTMLImageElement decoding below.
696
- }
697
- }
698
- return await new Promise((resolve) => {
699
- const image = new Image();
700
- const objectUrl = URL.createObjectURL(file);
701
- image.onload = () => {
702
- URL.revokeObjectURL(objectUrl);
703
- resolve({
704
- source: image,
705
- width: image.naturalWidth || image.width,
706
- height: image.naturalHeight || image.height
707
- });
708
- };
709
- image.onerror = () => {
710
- URL.revokeObjectURL(objectUrl);
711
- resolve(null);
712
- };
713
- image.src = objectUrl;
714
- });
715
- }
716
- async function createCompressedImageDataUrl(file, remainingBudgetBytes) {
717
- if (!file.type.startsWith("image/") || remainingBudgetBytes <= 0) {
718
- return undefined;
719
- }
720
- const hardLimitBytes = Math.min(CHAT_INLINE_IMAGE_HARD_LIMIT_BYTES, remainingBudgetBytes);
721
- const targetBytes = Math.min(CHAT_INLINE_IMAGE_TARGET_BYTES, hardLimitBytes);
722
- if (file.size <= targetBytes) {
723
- return fileToDataUrl(file);
724
- }
725
- const decoded = await decodeImageForCanvas(file);
726
- if (!decoded || decoded.width <= 0 || decoded.height <= 0) {
727
- return file.size <= hardLimitBytes ? fileToDataUrl(file) : undefined;
728
- }
729
- try {
730
- let bestBlob = null;
731
- const maxEdges = [CHAT_INLINE_IMAGE_MAX_EDGE, 1600, 1280, 1024];
732
- const qualities = [0.86, 0.78, 0.7, 0.62, 0.54];
733
- for (const maxEdge of maxEdges) {
734
- const scale = Math.min(1, maxEdge / Math.max(decoded.width, decoded.height));
735
- const width = Math.max(1, Math.round(decoded.width * scale));
736
- const height = Math.max(1, Math.round(decoded.height * scale));
737
- const canvas = document.createElement("canvas");
738
- canvas.width = width;
739
- canvas.height = height;
740
- const context = canvas.getContext("2d");
741
- if (!context) {
742
- continue;
743
- }
744
- context.fillStyle = "#fff";
745
- context.fillRect(0, 0, width, height);
746
- context.drawImage(decoded.source, 0, 0, width, height);
747
- for (const quality of qualities) {
748
- const blob = await canvasToBlob(canvas, "image/jpeg", quality);
749
- if (!blob) {
750
- continue;
751
- }
752
- if (blob.size <= hardLimitBytes && (!bestBlob || blob.size < bestBlob.size)) {
753
- bestBlob = blob;
754
- }
755
- if (blob.size <= targetBytes) {
756
- return blobToDataUrl(blob);
757
- }
758
- }
759
- }
760
- if (bestBlob && (file.size > hardLimitBytes || bestBlob.size < file.size)) {
761
- return blobToDataUrl(bestBlob);
762
- }
763
- return file.size <= hardLimitBytes ? fileToDataUrl(file) : undefined;
764
- }
765
- finally {
766
- decoded.close?.();
767
- }
768
- }
769
- async function addInlinePreviewForChatUpload(input) {
770
- const remainingBudgetBytes = CHAT_INLINE_TOTAL_BUDGET_BYTES - input.currentInlineBytes;
771
- const transportData = await createCompressedImageDataUrl(input.file, remainingBudgetBytes).catch(() => undefined);
772
- return transportData ? { ...input.attachment, transportData } : input.attachment;
773
- }
774
- async function uploadAttachment(input) {
775
- const maxBytes = input.maxBytes ?? MY_FILES_UPLOAD_LIMIT_BYTES;
776
- const limitLabel = input.limitLabel ?? "50 MB";
777
- if (input.file.size > maxBytes) {
778
- throw new Error(`File uploads can be at most ${limitLabel}.`);
779
- }
780
- const uploadFormData = new FormData();
781
- uploadFormData.append("file", input.file);
782
- uploadFormData.append("folder_path", input.folderPath || "");
783
- debugLog("chat.upload.attempt", {
784
- file_name: input.file.name,
785
- size_bytes: input.file.size,
786
- folder_path: input.folderPath
787
- });
788
- const uploadResponse = await fetch("/api/v1/user/me/attachments/upload", {
789
- method: "POST",
790
- headers: buildAuthHeaders(input.apiKey),
791
- body: uploadFormData
792
- });
793
- const uploadPayload = await parseJson(uploadResponse);
794
- if (!uploadResponse.ok || !uploadPayload.attachment) {
795
- debugError("chat.upload.failed", {
796
- file_name: input.file.name,
797
- status: uploadResponse.status,
798
- error: uploadPayload.error
799
- });
800
- throw new Error(uploadPayload.error || "Unable to upload attachment.");
801
- }
802
- debugLog("chat.upload.succeeded", {
803
- file_name: input.file.name,
804
- attachment_id: uploadPayload.attachment.id
805
- });
806
- return normalizeUploadedAttachment(uploadPayload.attachment) ?? (() => {
807
- throw new Error("Attachment response was incomplete.");
808
- })();
809
- }
810
- async function listMyFiles(apiKey) {
811
- const response = await fetch("/api/v1/user/me/attachments", {
812
- headers: buildAuthHeaders(apiKey)
813
- });
814
- const payload = await parseJson(response);
815
- if (!response.ok) {
816
- throw new Error(payload.error || "Unable to load My Files.");
817
- }
818
- const attachments = [];
819
- for (const entry of payload.attachments ?? []) {
820
- const normalized = normalizeUploadedAttachment(entry);
821
- if (normalized) {
822
- attachments.push({ ...normalized, createdAt: entry.createdAt ?? entry.created_at });
823
- }
824
- }
825
- return {
826
- attachments,
827
- folders: payload.folders ?? []
828
- };
829
- }
830
- async function uploadTemporaryFile(input) {
831
- if (input.file.size > 100 * 1024 * 1024) {
832
- throw new Error("Temporary files can be at most 100 MB.");
833
- }
834
- debugLog("chat.temp_upload.attempt", {
835
- file_name: input.file.name,
836
- size_bytes: input.file.size,
837
- content_type: input.file.type
838
- });
839
- const presignResponse = await fetch("/api/v1/user/me/temporary-files/presign", {
840
- method: "POST",
841
- headers: buildAuthHeaders(input.apiKey, "application/json"),
842
- body: JSON.stringify({
843
- file_name: input.file.name,
844
- content_type: input.file.type || undefined,
845
- size_bytes: input.file.size,
846
- folder_path: input.folderPath || undefined
847
- })
848
- });
849
- const presignPayload = await parseJson(presignResponse);
850
- if (!presignResponse.ok || !("transport" in presignPayload)) {
851
- throw new Error(("error" in presignPayload && presignPayload.error) || "Unable to prepare temporary upload.");
852
- }
853
- if (presignPayload.transport === "server") {
854
- const uploadFormData = new FormData();
855
- uploadFormData.append(presignPayload.upload.form_field || "file", input.file);
856
- uploadFormData.append("folder_path", input.folderPath || "");
857
- const uploadResponse = await fetch(presignPayload.upload.url, {
858
- method: presignPayload.upload.method || "POST",
859
- headers: buildAuthHeaders(input.apiKey),
860
- body: uploadFormData
861
- });
862
- const uploadPayload = await parseJson(uploadResponse);
863
- if (!uploadResponse.ok || !uploadPayload.file) {
864
- throw new Error(uploadPayload.error || "Unable to upload temporary file.");
865
- }
866
- const normalized = normalizeUploadedAttachment(uploadPayload.file);
867
- if (!normalized) {
868
- throw new Error("Temporary file response was incomplete.");
869
- }
870
- return {
871
- ...normalized,
872
- createdAt: uploadPayload.file.createdAt ?? uploadPayload.file.created_at,
873
- expiresAt: uploadPayload.file.expiresAt ?? uploadPayload.file.expires_at,
874
- s3Url: uploadPayload.file.s3Url ?? uploadPayload.file.s3_url
875
- };
876
- }
877
- const uploadResponse = await fetch(presignPayload.upload.url, {
878
- method: presignPayload.upload.method,
879
- headers: presignPayload.upload.headers,
880
- body: input.file
881
- });
882
- if (!uploadResponse.ok) {
883
- throw new Error(`Upload failed with status ${uploadResponse.status}.`);
884
- }
885
- const finalizeResponse = await fetch("/api/v1/user/me/temporary-files", {
886
- method: "POST",
887
- headers: buildAuthHeaders(input.apiKey, "application/json"),
888
- body: JSON.stringify({
889
- file_id: presignPayload.file_id,
890
- file_name: presignPayload.file_name,
891
- content_type: presignPayload.content_type,
892
- size_bytes: input.file.size,
893
- storage_key: presignPayload.storage_key,
894
- folder_path: presignPayload.folder_path || input.folderPath || ""
895
- })
896
- });
897
- const finalizePayload = await parseJson(finalizeResponse);
898
- if (!finalizeResponse.ok || !finalizePayload.file) {
899
- throw new Error(finalizePayload.error || "Unable to save temporary file.");
900
- }
901
- const normalized = normalizeUploadedAttachment(finalizePayload.file);
902
- if (!normalized) {
903
- throw new Error("Temporary file response was incomplete.");
904
- }
905
- return {
906
- ...normalized,
907
- createdAt: finalizePayload.file.createdAt ?? finalizePayload.file.created_at,
908
- expiresAt: finalizePayload.file.expiresAt ?? finalizePayload.file.expires_at,
909
- s3Url: finalizePayload.file.s3Url ?? finalizePayload.file.s3_url
910
- };
911
- }
912
- async function listTemporaryFiles(apiKey) {
913
- const response = await fetch("/api/v1/user/me/temporary-files", {
914
- headers: buildAuthHeaders(apiKey)
915
- });
916
- const payload = await parseJson(response);
917
- if (!response.ok) {
918
- throw new Error(payload.error || "Unable to load temporary files.");
919
- }
920
- const files = [];
921
- for (const entry of payload.files ?? []) {
922
- const normalized = normalizeUploadedAttachment(entry);
923
- if (normalized) {
924
- files.push({
925
- ...normalized,
926
- createdAt: entry.createdAt ?? entry.created_at,
927
- expiresAt: entry.expiresAt ?? entry.expires_at,
928
- s3Url: entry.s3Url ?? entry.s3_url
929
- });
930
- }
931
- }
932
- return {
933
- files,
934
- folders: payload.folders ?? []
935
- };
936
- }
937
- async function listGeneratedMedia(apiKey, cursor) {
938
- const url = new URL("/api/v1/user/me/generated-media", window.location.origin);
939
- url.searchParams.set("limit", "24");
940
- if (cursor) {
941
- url.searchParams.set("cursor", cursor);
942
- }
943
- const response = await fetch(`${url.pathname}${url.search}`, {
944
- headers: buildAuthHeaders(apiKey)
945
- });
946
- const payload = await parseJson(response);
947
- if (!response.ok) {
948
- throw new Error(payload.error || "Unable to load recent media.");
949
- }
950
- const media = [];
951
- for (const entry of payload.media ?? []) {
952
- const normalized = normalizeUploadedAttachment(entry);
953
- if (normalized) {
954
- media.push({
955
- ...normalized,
956
- createdAt: entry.createdAt ?? entry.created_at,
957
- jobId: typeof entry.job_id === "string" ? entry.job_id : undefined,
958
- tracer: typeof entry.tracer === "string" ? entry.tracer : undefined,
959
- templateId: typeof entry.template_id === "string" ? entry.template_id : undefined
960
- });
961
- }
962
- }
963
- return {
964
- media,
965
- nextCursor: payload.next_cursor ?? null
966
- };
967
- }
968
- function estimateChatMessageChars(messages) {
969
- return messages.reduce((total, message) => {
970
- const attachmentChars = message.attachments.reduce((sum, attachment) => (sum + attachment.fileName.length + attachment.contentType.length + 32), 0);
971
- return total + message.text.length + attachmentChars;
972
- }, 0);
973
- }
974
- function truncateTransportSummaryText(value, maxChars) {
975
- const normalized = value.trim().replace(/\s+/g, " ");
976
- if (normalized.length <= maxChars) {
977
- return normalized;
978
- }
979
- return `${normalized.slice(0, Math.max(0, maxChars - 32)).trimEnd()}… [truncated]`;
980
- }
981
- function compactTransportHistory(messages) {
982
- if (messages.length <= CHAT_TRANSPORT_RECENT_MESSAGES
983
- || estimateChatMessageChars(messages) <= CHAT_TRANSPORT_COMPACT_CHAR_THRESHOLD) {
984
- return messages;
985
- }
986
- const recentMessages = messages.slice(-CHAT_TRANSPORT_RECENT_MESSAGES);
987
- const oldMessages = messages.slice(0, -CHAT_TRANSPORT_RECENT_MESSAGES);
988
- const summaryLines = [];
989
- let summaryChars = 0;
990
- for (const message of oldMessages) {
991
- const attachmentsText = message.attachments.length
992
- ? ` Attachments: ${message.attachments.map((attachment) => `${attachment.fileName} (${attachment.contentType})`).join(", ")}.`
993
- : "";
994
- const line = `[${message.role}]: ${truncateTransportSummaryText(`${message.text}${attachmentsText}`, CHAT_TRANSPORT_MAX_MESSAGE_CHARS)}`;
995
- if (summaryChars + line.length > CHAT_TRANSPORT_MAX_SUMMARY_CHARS) {
996
- summaryLines.push(`[${oldMessages.length - summaryLines.length} earlier messages omitted]`);
997
- break;
998
- }
999
- summaryLines.push(line);
1000
- summaryChars += line.length;
1001
- }
1002
- return [
1003
- {
1004
- id: makeMessageId("assistant"),
1005
- role: "assistant",
1006
- text: [
1007
- "[Earlier in this conversation - compacted summary]",
1008
- "Older turns were summarized before sending this request so the chat can continue efficiently.",
1009
- "",
1010
- summaryLines.join("\n\n")
1011
- ].join("\n"),
1012
- attachments: []
1013
- },
1014
- ...recentMessages
1015
- ];
1016
- }
1017
- function buildAttachmentTransportText(attachments) {
1018
- if (!attachments.length) {
1019
- return null;
1020
- }
1021
- const lines = attachments.map((attachment) => (`- ${attachment.fileName} (${attachment.contentType}): ${attachment.viewUrl}`));
1022
- return [
1023
- "Attached file URLs for REST payloads:",
1024
- ...lines,
1025
- "Use these exact URLs as prompt_attachments for primitive image generation, source_image_url for primitive image edits, media_url for primitive video slides, or reference attachment URLs when calling template routes."
1026
- ].join("\n");
1027
- }
1028
- function toTransportMessages(messages) {
1029
- const latestIndex = messages.length - 1;
1030
- return messages.map((message, messageIndex) => {
1031
- const attachmentText = buildAttachmentTransportText(message.attachments);
1032
- return {
1033
- role: message.role,
1034
- content: [
1035
- ...(message.text ? [{ type: "text", text: message.text }] : []),
1036
- ...(attachmentText ? [{ type: "text", text: attachmentText }] : []),
1037
- ...message.attachments.map((attachment) => ({
1038
- type: "file",
1039
- data: messageIndex === latestIndex ? attachment.transportData || attachment.viewUrl : attachment.viewUrl,
1040
- mediaType: attachment.contentType
1041
- }))
1042
- ]
1043
- };
1044
- });
1045
- }
1046
- function readCachedScheduleChannels() {
1047
- const cache = window.__vidfarmFlockPosterCache;
1048
- const snapshot = cache?.readSnapshot?.();
1049
- if (!snapshot) {
1050
- return {};
1051
- }
1052
- const channels = Array.isArray(snapshot?.channels)
1053
- ? snapshot.channels
1054
- .map((channel) => channel && typeof channel === "object" ? channel : null)
1055
- .filter((channel) => typeof channel?.id === "string" && Boolean(channel.id.trim()))
1056
- .map((channel) => ({
1057
- id: String(channel?.id || ""),
1058
- name: typeof channel?.name === "string" ? channel.name : null,
1059
- title: typeof channel?.title === "string" ? channel.title : null,
1060
- handle: typeof channel?.handle === "string" ? channel.handle : null,
1061
- platform: typeof channel?.platform === "string" ? channel.platform : null,
1062
- status: typeof channel?.status === "string" ? channel.status : null
1063
- }))
1064
- : [];
1065
- return channels.length || snapshot?.channelsUpdatedAt
1066
- ? {
1067
- scheduleChannels: channels,
1068
- scheduleChannelsUpdatedAt: snapshot?.channelsUpdatedAt ?? null
1069
- }
1070
- : {};
1071
- }
1072
- function readSelectedLibraryPosts() {
1073
- const snapshot = window.__vidfarmLibrarySelection?.readSelectedPosts?.();
1074
- const posts = Array.isArray(snapshot?.posts)
1075
- ? snapshot.posts
1076
- .map((post) => post && typeof post === "object" ? post : null)
1077
- .filter((post) => typeof post?.post_id === "string" && Boolean(post.post_id.trim()))
1078
- .map((post) => ({
1079
- post_id: String(post?.post_id || ""),
1080
- title: typeof post?.title === "string" ? post.title : null,
1081
- caption: typeof post?.caption === "string" ? post.caption : null,
1082
- tracer: typeof post?.tracer === "string" ? post.tracer : null,
1083
- status: typeof post?.status === "string" ? post.status : null,
1084
- share_url: typeof post?.share_url === "string" ? post.share_url : null,
1085
- media: Array.isArray(post?.media)
1086
- ? post.media.slice(0, 8).map((asset) => asset && typeof asset === "object" ? asset : null).filter(Boolean).map((asset) => ({
1087
- url: typeof asset?.url === "string" ? asset.url : null,
1088
- kind: typeof asset?.kind === "string" ? asset.kind : null,
1089
- role: typeof asset?.role === "string" ? asset.role : null,
1090
- content_type: typeof asset?.content_type === "string" ? asset.content_type : null
1091
- }))
1092
- : []
1093
- }))
1094
- : [];
1095
- return {
1096
- selectedPosts: posts,
1097
- selectedPostsUpdatedAt: snapshot?.updatedAt ?? null
1098
- };
1099
- }
1100
- async function readStreamText(response, handlers) {
1101
- if (!response.body) {
1102
- throw new Error("Chat response did not include a body.");
1103
- }
1104
- const reader = response.body.getReader();
1105
- const decoder = new TextDecoder();
1106
- let buffer = "";
1107
- while (true) {
1108
- const { done, value } = await reader.read();
1109
- if (done) {
1110
- break;
1111
- }
1112
- buffer += decoder.decode(value, { stream: true });
1113
- const events = buffer.split("\n\n");
1114
- buffer = events.pop() ?? "";
1115
- for (const event of events) {
1116
- const line = event
1117
- .split("\n")
1118
- .map((entry) => entry.trim())
1119
- .find((entry) => entry.startsWith("data:"));
1120
- if (!line) {
1121
- continue;
1122
- }
1123
- const payload = line.slice(5).trim();
1124
- if (payload === "[DONE]") {
1125
- continue;
1126
- }
1127
- let parsed;
1128
- try {
1129
- parsed = JSON.parse(payload);
1130
- }
1131
- catch {
1132
- continue;
1133
- }
1134
- if (!parsed || typeof parsed !== "object") {
1135
- continue;
1136
- }
1137
- const chunk = parsed;
1138
- if (chunk.type === "text-delta" && typeof chunk.delta === "string") {
1139
- handlers.onDelta(chunk.delta);
1140
- }
1141
- else if (chunk.type === "status") {
1142
- const statusMessage = typeof chunk.status === "string"
1143
- ? chunk.status
1144
- : typeof chunk.message === "string"
1145
- ? chunk.message
1146
- : null;
1147
- if (statusMessage && handlers.onStatus) {
1148
- handlers.onStatus(statusMessage);
1149
- }
1150
- }
1151
- else if (chunk.type === "tool-call"
1152
- && typeof chunk.toolCallId === "string"
1153
- && typeof chunk.toolName === "string"
1154
- && handlers.onToolCall) {
1155
- handlers.onToolCall({
1156
- toolCallId: chunk.toolCallId,
1157
- toolName: chunk.toolName,
1158
- args: chunk.args && typeof chunk.args === "object" ? chunk.args : undefined
1159
- });
1160
- }
1161
- else if (chunk.type === "tool-result"
1162
- && typeof chunk.toolCallId === "string"
1163
- && typeof chunk.toolName === "string"
1164
- && handlers.onToolResult) {
1165
- handlers.onToolResult({
1166
- toolCallId: chunk.toolCallId,
1167
- toolName: chunk.toolName,
1168
- result: chunk.result
1169
- });
1170
- }
1171
- else if (chunk.type === "error" && handlers.onError) {
1172
- handlers.onError?.(typeof chunk.errorText === "string"
1173
- ? chunk.errorText
1174
- : typeof chunk.error?.message === "string"
1175
- ? chunk.error.message
1176
- : "Unable to read assistant response.");
1177
- }
1178
- }
1179
- }
1180
- }
1181
- function makeStorageKeys(templateId) {
1182
- return {
1183
- threads: `vidfarm-editor-chat-threads:${templateId}`,
1184
- activeThread: `vidfarm-editor-chat-active-thread:${templateId}`
1185
- };
1186
- }
1187
- function getChatStorage() {
1188
- if (typeof window === "undefined") {
1189
- return null;
1190
- }
1191
- try {
1192
- return window.sessionStorage;
1193
- }
1194
- catch {
1195
- try {
1196
- return window.localStorage;
1197
- }
1198
- catch {
1199
- return null;
1200
- }
1201
- }
1202
- }
1203
- function makeThreadId() {
1204
- return `thread-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1205
- }
1206
- function makeMessageId(prefix) {
1207
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1208
- return `${prefix}-${crypto.randomUUID()}`;
1209
- }
1210
- return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1211
- }
1212
- function normalizeTracerList(values) {
1213
- const seen = new Set();
1214
- const tracers = [];
1215
- for (const value of values) {
1216
- const tracer = value.trim();
1217
- if (!tracer || seen.has(tracer)) {
1218
- continue;
1219
- }
1220
- seen.add(tracer);
1221
- tracers.push(tracer);
1222
- }
1223
- return tracers;
1224
- }
1225
- function mergeTracerLists(...groups) {
1226
- return normalizeTracerList(groups.flat());
1227
- }
1228
- function archiveTracerValue(tracer) {
1229
- const normalized = tracer.trim();
1230
- if (!normalized || normalized.startsWith("archived_")) {
1231
- return normalized;
1232
- }
1233
- return `archived_${normalized}`;
1234
- }
1235
- function makeTracerSuffix() {
1236
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1237
- return crypto.randomUUID().replace(/-/g, "").slice(-8);
1238
- }
1239
- return Math.random().toString(16).slice(2, 10).padEnd(8, "0").slice(0, 8);
1240
- }
1241
- function slugifyTracerPrefix(value) {
1242
- const normalized = value
1243
- .trim()
1244
- .toLowerCase()
1245
- .replace(/[^a-z0-9]+/g, "-")
1246
- .replace(/^-+|-+$/g, "");
1247
- return (normalized || "tracer").slice(0, 28);
1248
- }
1249
- function makeTracerId(templateSlug, existing) {
1250
- const prefix = slugifyTracerPrefix(templateSlug);
1251
- let candidate = "";
1252
- do {
1253
- candidate = `${prefix}-${makeTracerSuffix()}`;
1254
- } while (existing.includes(candidate));
1255
- return candidate;
1256
- }
1257
- function ensureTracerHasRandomSuffix(tracer, existing) {
1258
- const normalized = tracer.trim();
1259
- if (!normalized) {
1260
- return "";
1261
- }
1262
- if (/-[a-z0-9]{8}$/i.test(normalized)) {
1263
- return normalized;
1264
- }
1265
- let candidate = `${normalized}-${makeTracerSuffix()}`;
1266
- while (existing.includes(candidate)) {
1267
- candidate = `${normalized}-${makeTracerSuffix()}`;
1268
- }
1269
- return candidate;
1270
- }
1271
- function readTracersFromLocation() {
1272
- const params = new URLSearchParams(window.location.search);
1273
- const values = [...params.getAll("tracers"), ...params.getAll("tracer")];
1274
- const output = [];
1275
- for (const rawValue of values) {
1276
- const normalized = rawValue.trim();
1277
- if (!normalized) {
1278
- continue;
1279
- }
1280
- const inner = normalized.startsWith("[") && normalized.endsWith("]")
1281
- ? normalized.slice(1, -1)
1282
- : normalized;
1283
- output.push(...inner.split(",").map((value) => value.trim().replace(/^["']|["']$/g, "")));
1284
- }
1285
- return normalizeTracerList(output);
1286
- }
1287
- function readThreadIdFromLocation() {
1288
- const params = new URLSearchParams(window.location.search);
1289
- return (params.get("thread_id") ||
1290
- params.get("threadId") ||
1291
- params.get("chat_id") ||
1292
- params.get("chatId") ||
1293
- "").trim();
1294
- }
1295
- function formatThreadTitle(text) {
1296
- const normalized = text.trim().replace(/\s+/g, " ");
1297
- if (!normalized) {
1298
- return "New chat";
1299
- }
1300
- return normalized.length > 42 ? `${normalized.slice(0, 42).trimEnd()}…` : normalized;
1301
- }
1302
- function serializeThreads(threads, activeThreadId) {
1303
- return threads.map((thread) => ({
1304
- id: thread.id,
1305
- templateId: thread.templateId,
1306
- title: thread.title,
1307
- createdAt: thread.createdAt,
1308
- updatedAt: thread.updatedAt,
1309
- lastMessageAt: thread.lastMessageAt ?? null,
1310
- messageCount: Math.max(thread.messageCount ?? 0, thread.messages.length),
1311
- tracers: thread.tracers,
1312
- hiddenFromView: thread.hiddenFromView,
1313
- messages: (thread.id === activeThreadId ? thread.messages.slice(-LOCAL_THREAD_MESSAGE_CACHE_LIMIT) : []).map((message) => ({
1314
- id: message.id,
1315
- role: message.role,
1316
- text: message.text,
1317
- attachments: message.attachments.map((attachment) => ({
1318
- id: attachment.id,
1319
- fileName: attachment.fileName,
1320
- contentType: attachment.contentType,
1321
- viewUrl: attachment.viewUrl
1322
- })),
1323
- toolExchanges: message.toolExchanges?.map((exchange) => ({
1324
- ...exchange,
1325
- args: sanitizeChatPayload(exchange.args),
1326
- result: sanitizeToolResult(exchange.result)
1327
- })),
1328
- httpExchanges: message.httpExchanges?.map(sanitizeHttpExchange)
1329
- }))
1330
- }));
1331
- }
1332
- function hydrateThreads(raw, fallbackTemplateId) {
1333
- if (!raw) {
1334
- return [];
1335
- }
1336
- try {
1337
- const parsed = JSON.parse(raw);
1338
- if (!Array.isArray(parsed)) {
1339
- return [];
1340
- }
1341
- return parsed
1342
- .filter((thread) => thread && typeof thread.id === "string")
1343
- .map((thread) => ({
1344
- id: thread.id,
1345
- templateId: typeof thread.templateId === "string" && thread.templateId.trim() ? thread.templateId : fallbackTemplateId,
1346
- title: typeof thread.title === "string" && thread.title.trim() ? thread.title : "New chat",
1347
- createdAt: typeof thread.createdAt === "string" ? thread.createdAt : new Date().toISOString(),
1348
- updatedAt: typeof thread.updatedAt === "string" ? thread.updatedAt : new Date().toISOString(),
1349
- lastMessageAt: typeof thread.lastMessageAt === "string" ? thread.lastMessageAt : null,
1350
- messageCount: Math.max(typeof thread.messageCount === "number" ? thread.messageCount : 0, Array.isArray(thread.messages) ? thread.messages.length : 0),
1351
- tracers: normalizeTracerList(Array.isArray(thread.tracers) ? thread.tracers.filter((value) => typeof value === "string") : []),
1352
- hiddenFromView: thread.hiddenFromView === true,
1353
- messages: Array.isArray(thread.messages)
1354
- ? thread.messages.map((message) => ({
1355
- id: message.id,
1356
- role: message.role,
1357
- text: message.text,
1358
- attachments: Array.isArray(message.attachments) ? message.attachments : [],
1359
- toolExchanges: Array.isArray(message.toolExchanges) ? message.toolExchanges : [],
1360
- httpExchanges: Array.isArray(message.httpExchanges) ? message.httpExchanges : []
1361
- }))
1362
- : []
1363
- }));
1364
- }
1365
- catch {
1366
- return [];
1367
- }
1368
- }
1369
- function hydrateThreadRecord(thread, fallbackTemplateId) {
1370
- if (!thread || typeof thread.id !== "string") {
1371
- return null;
1372
- }
1373
- return {
1374
- id: thread.id,
1375
- templateId: typeof thread.templateId === "string" && thread.templateId.trim() ? thread.templateId : fallbackTemplateId,
1376
- title: typeof thread.title === "string" && thread.title.trim() ? thread.title : "New chat",
1377
- createdAt: typeof thread.createdAt === "string" ? thread.createdAt : new Date().toISOString(),
1378
- updatedAt: typeof thread.updatedAt === "string" ? thread.updatedAt : new Date().toISOString(),
1379
- lastMessageAt: typeof thread.lastMessageAt === "string" ? thread.lastMessageAt : null,
1380
- messageCount: Math.max(typeof thread.messageCount === "number" ? thread.messageCount : 0, Array.isArray(thread.messages) ? thread.messages.length : 0),
1381
- tracers: normalizeTracerList(Array.isArray(thread.tracers) ? thread.tracers.filter((value) => typeof value === "string") : []),
1382
- hiddenFromView: thread.hiddenFromView === true,
1383
- messages: Array.isArray(thread.messages)
1384
- ? thread.messages.map((message) => ({
1385
- id: message.id,
1386
- role: message.role,
1387
- text: message.text,
1388
- attachments: Array.isArray(message.attachments) ? message.attachments : [],
1389
- toolExchanges: Array.isArray(message.toolExchanges) ? message.toolExchanges : [],
1390
- httpExchanges: Array.isArray(message.httpExchanges) ? message.httpExchanges : []
1391
- }))
1392
- : []
1393
- };
1394
- }
1395
- function mergeThreadSets(localThreads, remoteThreads) {
1396
- const byId = new Map();
1397
- for (const thread of remoteThreads) {
1398
- const localThread = localThreads.find((entry) => entry.id === thread.id);
1399
- byId.set(thread.id, {
1400
- ...thread,
1401
- lastMessageAt: thread.lastMessageAt ?? localThread?.lastMessageAt ?? null,
1402
- messageCount: Math.max(thread.messageCount ?? 0, localThread?.messageCount ?? 0, thread.messages.length, localThread?.messages.length ?? 0),
1403
- messages: thread.messages.length ? thread.messages : (localThread?.messages ?? [])
1404
- });
1405
- }
1406
- for (const thread of localThreads) {
1407
- if (!byId.has(thread.id)) {
1408
- byId.set(thread.id, thread);
1409
- }
1410
- }
1411
- return [...byId.values()].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
1412
- }
1413
- function sortThreadsByUpdatedAt(threads) {
1414
- return [...threads].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
1415
- }
1416
- function threadMatchesTracers(thread, selectedTracers) {
1417
- if (!selectedTracers.length) {
1418
- return true;
1419
- }
1420
- return thread.tracers.some((threadTracer) => selectedTracers.some((selectedTracer) => doesTracerMatchSelection(threadTracer, selectedTracer)));
1421
- }
1422
- function doesTracerMatchSelection(threadTracer, selectedTracer) {
1423
- const normalizedThreadTracer = threadTracer.trim();
1424
- const normalizedSelectedTracer = selectedTracer.trim();
1425
- if (!normalizedThreadTracer || !normalizedSelectedTracer) {
1426
- return false;
1427
- }
1428
- if (normalizedThreadTracer === normalizedSelectedTracer) {
1429
- return true;
1430
- }
1431
- return normalizedThreadTracer.startsWith(`${normalizedSelectedTracer}-`) && /-[a-z0-9]{8}$/i.test(normalizedThreadTracer);
1432
- }
1433
- function summarizeThread(thread) {
1434
- const lastMessage = [...thread.messages].reverse().find((message) => message.text.trim());
1435
- return lastMessage?.text.trim() || "No messages yet";
1436
- }
1437
- function summarizeThreadPreview(thread) {
1438
- const preview = summarizeThread(thread);
1439
- if (!thread.messages.length) {
1440
- return "No messages yet";
1441
- }
1442
- const normalizedTitle = thread.title.trim().toLowerCase();
1443
- const normalizedPreview = preview.trim().toLowerCase();
1444
- if (!preview || normalizedPreview === normalizedTitle) {
1445
- return `${thread.messages.length} message${thread.messages.length === 1 ? "" : "s"}`;
1446
- }
1447
- return preview;
1448
- }
1449
- function AttachmentChip(props) {
1450
- return (_jsxs("div", { className: "vf-editor-chat-attachment", children: [_jsx("span", { className: "vf-editor-chat-attachment-name", children: props.attachment.fileName }), props.onRemove ? (_jsx("button", { type: "button", className: "vf-editor-chat-attachment-remove", "aria-label": "Remove attachment", onClick: props.onRemove, children: "\u00D7" })) : null] }));
1451
- }
1452
- function DownloadIcon() {
1453
- return (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M10 3v9" }), _jsx("path", { d: "m6.5 8.5 3.5 3.5 3.5-3.5" }), _jsx("path", { d: "M4 16h12" })] }));
1454
- }
1455
- function PaintBrushIcon() {
1456
- return (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M4.2 15.8c1.6.5 3.1-.1 3.7-1.7.6-1.4 1.8-2.1 3.5-2.1" }), _jsx("path", { d: "m10.7 11.6 5.2-5.2a1.9 1.9 0 0 0-2.7-2.7L8 8.9" }), _jsx("path", { d: "m7.9 9 3.1 3.1" }), _jsx("path", { d: "M3.5 16.5c1.4.5 3.6.5 4.9-.8" })] }));
1457
- }
1458
- function LinkIcon() {
1459
- return (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M8.4 11.6a3.2 3.2 0 0 0 4.5 0l2.2-2.2a3.2 3.2 0 0 0-4.5-4.5L9.4 6.1" }), _jsx("path", { d: "M11.6 8.4a3.2 3.2 0 0 0-4.5 0L4.9 10.6a3.2 3.2 0 0 0 4.5 4.5l1.2-1.2" })] }));
1460
- }
1461
- function CopyIcon() {
1462
- return (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("rect", { x: "7", y: "7", width: "9", height: "9", rx: "2" }), _jsx("path", { d: "M5 12H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1" })] }));
1463
- }
1464
- function ChevronIcon(props) {
1465
- return (_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", style: { transform: props.open ? "rotate(90deg)" : "rotate(0deg)" }, children: _jsx("path", { d: "m7 4 6 6-6 6" }) }));
1466
- }
1467
- function mediaDownloadName(preview) {
1468
- const cleanLabel = preview.label.trim().replace(/[\\/:*?"<>|]+/g, "-");
1469
- if (cleanLabel) {
1470
- return cleanLabel;
1471
- }
1472
- try {
1473
- const pathname = new URL(preview.url, window.location.href).pathname;
1474
- const name = pathname.split("/").filter(Boolean).pop();
1475
- return name || "media";
1476
- }
1477
- catch {
1478
- return "media";
1479
- }
1480
- }
1481
- function mediaInpaintHref(preview, ensureTracer = false) {
1482
- if (typeof window === "undefined" || preview.kind !== "image" || !preview.url) {
1483
- return "";
1484
- }
1485
- const pageUrl = new URL("/tools/image", window.location.origin);
1486
- const bridge = window.__vidfarmEditorTracers;
1487
- const existingTracers = bridge?.get() ?? readTracersFromLocation();
1488
- const tracer = existingTracers[0] || (ensureTracer ? bridge?.add() ?? "" : "");
1489
- if (tracer) {
1490
- pageUrl.searchParams.set("tracer", tracer);
1491
- }
1492
- pageUrl.searchParams.set("source_image_url", preview.url);
1493
- pageUrl.searchParams.set("source_image_name", preview.label || mediaDownloadName(preview));
1494
- return pageUrl.toString();
1495
- }
1496
- function MediaPreviewModal(props) {
1497
- const [isMounted, setIsMounted] = useState(false);
1498
- const [copyStatus, setCopyStatus] = useState("");
1499
- const { onClose } = props;
1500
- const gallery = props.gallery?.length ? props.gallery : [props.preview];
1501
- const initialIndex = Math.max(0, gallery.findIndex((preview) => preview.url === props.preview.url));
1502
- const [activeIndex, setActiveIndex] = useState(initialIndex);
1503
- const activePreview = gallery[activeIndex] || props.preview;
1504
- const canNavigate = gallery.length > 1;
1505
- const canInpaint = activePreview.kind === "image";
1506
- const initialInpaintHref = canInpaint ? mediaInpaintHref(activePreview, false) || "/tools/image" : "/tools/image";
1507
- useEffect(() => {
1508
- setIsMounted(true);
1509
- }, []);
1510
- useEffect(() => {
1511
- setCopyStatus("");
1512
- }, [activePreview.url]);
1513
- const handleCopyLink = async () => {
1514
- try {
1515
- if (navigator.clipboard?.writeText) {
1516
- await navigator.clipboard.writeText(activePreview.url);
1517
- }
1518
- else {
1519
- const field = document.createElement("textarea");
1520
- field.value = activePreview.url;
1521
- field.style.position = "fixed";
1522
- field.style.opacity = "0";
1523
- document.body.appendChild(field);
1524
- field.focus();
1525
- field.select();
1526
- document.execCommand("copy");
1527
- document.body.removeChild(field);
1528
- }
1529
- setCopyStatus("done");
1530
- }
1531
- catch {
1532
- setCopyStatus("error");
1533
- }
1534
- window.setTimeout(() => setCopyStatus(""), 1600);
1535
- };
1536
- const handleOpenInpaint = (event) => {
1537
- const href = mediaInpaintHref(activePreview, true);
1538
- if (!href) {
1539
- event.preventDefault();
1540
- return;
1541
- }
1542
- event.preventDefault();
1543
- window.open(href, "_blank", "noopener,noreferrer");
1544
- };
1545
- useEffect(() => {
1546
- const handleKeyDown = (event) => {
1547
- if (event.key === "Escape") {
1548
- onClose();
1549
- return;
1550
- }
1551
- if (!canNavigate) {
1552
- return;
1553
- }
1554
- if (event.key === "ArrowLeft") {
1555
- event.preventDefault();
1556
- setActiveIndex((current) => (current - 1 + gallery.length) % gallery.length);
1557
- }
1558
- else if (event.key === "ArrowRight") {
1559
- event.preventDefault();
1560
- setActiveIndex((current) => (current + 1) % gallery.length);
1561
- }
1562
- };
1563
- window.addEventListener("keydown", handleKeyDown);
1564
- return () => window.removeEventListener("keydown", handleKeyDown);
1565
- }, [canNavigate, gallery.length, onClose]);
1566
- if (!isMounted) {
1567
- return null;
1568
- }
1569
- return createPortal(_jsx("div", { className: "vf-editor-chat-media-modal-backdrop", onClick: props.onClose, children: _jsxs("div", { className: "vf-editor-chat-media-modal", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "vf-editor-chat-media-modal-header", children: [_jsx("a", { className: "vf-editor-chat-media-modal-link", href: activePreview.url, target: "_blank", rel: "noopener noreferrer", children: canNavigate ? `${activePreview.label} (${activeIndex + 1} of ${gallery.length})` : activePreview.label }), _jsxs("div", { className: "vf-editor-chat-media-modal-actions", children: [canNavigate ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: () => setActiveIndex((current) => (current - 1 + gallery.length) % gallery.length), "aria-label": "Previous image", title: "Previous image", children: "\u2039" }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: () => setActiveIndex((current) => (current + 1) % gallery.length), "aria-label": "Next image", title: "Next image", children: "\u203A" })] })) : null, canInpaint ? (_jsx("a", { className: "vf-editor-chat-media-modal-control", href: initialInpaintHref, target: "_blank", rel: "noopener noreferrer", onClick: handleOpenInpaint, "aria-label": "Open image editor", title: "Image edit", children: _jsx(PaintBrushIcon, {}) })) : null, _jsx("a", { className: "vf-editor-chat-media-modal-control", href: activePreview.url, download: mediaDownloadName(activePreview), "aria-label": "Download media", title: "Download", children: _jsx(DownloadIcon, {}) }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", "data-state": copyStatus, onClick: () => void handleCopyLink(), "aria-label": copyStatus === "done" ? "Media URL copied" : copyStatus === "error" ? "Unable to copy media URL" : "Copy media URL", title: copyStatus === "done" ? "Copied" : copyStatus === "error" ? "Copy failed" : "Copy link", children: _jsx(LinkIcon, {}) }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: props.onClose, "aria-label": "Close media preview", title: "Close", children: "\u00D7" })] })] }), _jsx("div", { className: "vf-editor-chat-media-modal-body", children: activePreview.kind === "image" ? (_jsx("img", { src: activePreview.url, alt: activePreview.label, className: "vf-editor-chat-media-modal-image" })) : (_jsx("video", { src: activePreview.url, className: "vf-editor-chat-media-modal-video", controls: true, autoPlay: true, playsInline: true })) })] }) }), document.body);
1570
- }
1571
- function ImagePreviewButton(props) {
1572
- const [hasError, setHasError] = useState(false);
1573
- return (_jsx("button", { type: "button", className: "vf-editor-chat-message-thumb-button is-image", onClick: () => props.onOpenModal(props.preview, props.previews), "aria-label": `Preview ${props.preview.label}`, title: props.preview.label, "data-error": hasError ? "true" : "false", children: hasError ? (_jsx("span", { className: "vf-editor-chat-message-thumb-fallback", children: "Preview unavailable" })) : (_jsx("img", { src: props.preview.url, alt: props.preview.label, className: "vf-editor-chat-message-attachment-thumb", loading: "lazy", onError: () => setHasError(true) })) }));
1574
- }
1575
- function VideoPreviewCard(props) {
1576
- return (_jsx("div", { className: "vf-editor-chat-message-attachment-row", children: _jsxs("button", { type: "button", className: "vf-editor-chat-message-thumb-button", onClick: () => props.onOpenModal(props.preview), "aria-label": `Preview ${props.preview.label}`, children: [_jsx("video", { src: props.preview.url, className: "vf-editor-chat-message-attachment-thumb vf-editor-chat-message-video-thumb", preload: "metadata", muted: true, playsInline: true }), _jsx("span", { className: "vf-editor-chat-message-video-play", "aria-hidden": "true" })] }) }));
1577
- }
1578
- function ImagePreviewRail(props) {
1579
- if (!props.previews.length) {
1580
- return null;
1581
- }
1582
- return (_jsx("div", { className: "vf-editor-chat-message-image-rail", "aria-label": "Image previews", children: props.previews.map((preview) => (_jsx(ImagePreviewButton, { preview: preview, previews: props.previews, onOpenModal: props.onOpenModal }, preview.url))) }));
1583
- }
1584
- function ComposerAttachmentItem(props) {
1585
- return (_jsxs("div", { className: "vf-editor-chat-composer-attachment-item", children: [isImageAttachment(props.attachment) ? (_jsx("img", { src: props.attachment.viewUrl, alt: props.attachment.fileName, className: "vf-editor-chat-composer-attachment-thumb" })) : (_jsx("div", { className: "vf-editor-chat-composer-attachment-file", "aria-hidden": "true", children: props.attachment.fileName.slice(0, 1).toUpperCase() })), _jsx("div", { className: "vf-editor-chat-composer-attachment-meta", children: _jsx("span", { className: "vf-editor-chat-composer-attachment-name", children: props.attachment.fileName }) }), _jsx("button", { type: "button", className: "vf-editor-chat-composer-attachment-remove", "aria-label": `Remove ${props.attachment.fileName}`, onClick: props.onRemove, children: "\u00D7" })] }));
1586
- }
1587
- function HttpExchangeCard(props) {
1588
- const [isOpen, setIsOpen] = useState(false);
1589
- const [isMounted, setIsMounted] = useState(false);
1590
- const requestBody = formatJsonBody(props.exchange.request.body);
1591
- const responseBody = formatJsonBody(props.exchange.result.body);
1592
- const requestUrl = props.exchange.request.url;
1593
- const displayUrl = requestUrl.length > 52
1594
- ? `${requestUrl.slice(0, 52).trimEnd()}...`
1595
- : requestUrl;
1596
- const isError = props.exchange.result.status >= 400 || Boolean(props.exchange.result.error);
1597
- const isSuccess = props.exchange.result.status >= 200 && props.exchange.result.status < 300;
1598
- useEffect(() => {
1599
- setIsMounted(true);
1600
- }, []);
1601
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "vf-editor-chat-http-card", children: _jsxs("button", { type: "button", className: "vf-editor-chat-http-card-header", onClick: () => setIsOpen(true), "aria-label": `Open HTTP details for ${props.exchange.request.method} ${requestUrl}`, children: [_jsx("span", { className: "vf-editor-chat-http-pill", children: props.exchange.request.method }), _jsx("span", { className: "vf-editor-chat-http-url", title: requestUrl, children: displayUrl }), _jsxs("span", { className: "vf-editor-chat-http-status", "data-error": isError ? "true" : "false", "data-success": isSuccess ? "true" : "false", children: [props.exchange.result.status, props.exchange.result.statusText ? ` ${props.exchange.result.statusText}` : ""] }), _jsx("span", { className: "vf-editor-chat-http-expand", "aria-hidden": "true", children: "+" })] }) }), isMounted && isOpen ? createPortal(_jsx("div", { className: "vf-editor-chat-http-modal-backdrop", onClick: () => setIsOpen(false), children: _jsxs("div", { className: "vf-editor-chat-http-modal", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "vf-editor-chat-http-modal-header", children: [_jsxs("div", { className: "vf-editor-chat-http-modal-header-main", children: [_jsx("span", { className: "vf-editor-chat-http-pill", children: props.exchange.request.method }), _jsx("span", { className: "vf-editor-chat-http-modal-url", children: requestUrl }), _jsxs("span", { className: "vf-editor-chat-http-status", "data-error": isError ? "true" : "false", "data-success": isSuccess ? "true" : "false", children: [props.exchange.result.status, props.exchange.result.statusText ? ` ${props.exchange.result.statusText}` : ""] })] }), _jsx("button", { type: "button", className: "vf-editor-chat-http-modal-close", onClick: () => setIsOpen(false), "aria-label": "Close HTTP details", children: "\u00D7" })] }), _jsxs("div", { className: "vf-editor-chat-http-card-body is-modal", children: [props.exchange.explanation ? (_jsx("div", { className: "vf-editor-chat-http-note", children: props.exchange.explanation })) : null, _jsxs("div", { className: "vf-editor-chat-http-block", children: [_jsx("div", { className: "vf-editor-chat-http-label", children: "URL" }), _jsx("pre", { className: "vf-editor-chat-http-pre", children: requestUrl })] }), requestBody ? (_jsxs("div", { className: "vf-editor-chat-http-block", children: [_jsx("div", { className: "vf-editor-chat-http-label", children: "Request" }), _jsx("pre", { className: "vf-editor-chat-http-pre", children: requestBody })] })) : null, _jsxs("div", { className: "vf-editor-chat-http-block", children: [_jsx("div", { className: "vf-editor-chat-http-label", children: "Response" }), _jsx("pre", { className: "vf-editor-chat-http-pre", children: props.exchange.result.error || responseBody || "(empty)" })] }), Object.keys(props.exchange.result.headers).length ? (_jsxs("div", { className: "vf-editor-chat-http-block", children: [_jsx("div", { className: "vf-editor-chat-http-label", children: "Headers" }), _jsx("pre", { className: "vf-editor-chat-http-pre", children: formatJsonBody(props.exchange.result.headers) })] })) : null] })] }) }), document.body) : null] }));
1602
- }
1603
- function formatJsonInlineValue(value) {
1604
- if (typeof value === "string") {
1605
- return JSON.stringify(value);
1606
- }
1607
- if (typeof value === "number" || typeof value === "boolean") {
1608
- return String(value);
1609
- }
1610
- if (value === null) {
1611
- return "null";
1612
- }
1613
- return JSON.stringify(value);
1614
- }
1615
- function copyPlainText(text) {
1616
- if (navigator.clipboard?.writeText) {
1617
- return navigator.clipboard.writeText(text);
1618
- }
1619
- const field = document.createElement("textarea");
1620
- field.value = text;
1621
- field.style.position = "fixed";
1622
- field.style.opacity = "0";
1623
- document.body.appendChild(field);
1624
- field.focus();
1625
- field.select();
1626
- document.execCommand("copy");
1627
- document.body.removeChild(field);
1628
- return Promise.resolve();
1629
- }
1630
- function JsonViewerLine(props) {
1631
- return (_jsxs("div", { className: "vf-editor-chat-json-line", style: { paddingLeft: `${props.depth * 18 + 12}px` }, children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-copy", "aria-label": "Copy line", title: "Copy line", onClick: () => props.onCopy(props.text), children: _jsx(CopyIcon, {}) }), _jsx("span", { className: "vf-editor-chat-json-line-text", children: props.text })] }));
1632
- }
1633
- function JsonViewerNode(props) {
1634
- const value = props.value;
1635
- const suffix = props.isLast ? "" : ",";
1636
- if (Array.isArray(value)) {
1637
- const path = props.path;
1638
- const isCollapsed = props.collapsedPaths.has(path);
1639
- const headerText = `${props.linePrefix ?? ""}[`;
1640
- if (!value.length) {
1641
- return _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}[]${suffix}`, depth: props.depth, onCopy: props.onCopy });
1642
- }
1643
- if (isCollapsed) {
1644
- return (_jsxs("div", { className: "vf-editor-chat-json-toggle-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-toggle-icon", onClick: () => props.togglePath(path), "aria-label": "Expand JSON block", children: _jsx(ChevronIcon, { open: false }) }), _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}[ … ]${suffix}`, depth: props.depth, onCopy: props.onCopy })] }));
1645
- }
1646
- return (_jsxs("div", { children: [_jsxs("div", { className: "vf-editor-chat-json-toggle-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-toggle-icon", onClick: () => props.togglePath(path), "aria-label": "Collapse JSON block", children: _jsx(ChevronIcon, { open: true }) }), _jsx(JsonViewerLine, { text: headerText, depth: props.depth, onCopy: props.onCopy })] }), value.map((entry, index) => (_jsx(JsonViewerNode, { value: entry, depth: props.depth + 1, path: `${path}.${index}`, isLast: index === value.length - 1, collapsedPaths: props.collapsedPaths, togglePath: props.togglePath, onCopy: props.onCopy }, `${path}:${index}`))), _jsx(JsonViewerLine, { text: `]${suffix}`, depth: props.depth, onCopy: props.onCopy })] }));
1647
- }
1648
- if (value && typeof value === "object") {
1649
- const entries = Object.entries(value);
1650
- const path = props.path;
1651
- const isCollapsed = props.collapsedPaths.has(path);
1652
- const headerText = `${props.linePrefix ?? ""}{`;
1653
- if (!entries.length) {
1654
- return _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}{}${suffix}`, depth: props.depth, onCopy: props.onCopy });
1655
- }
1656
- if (isCollapsed) {
1657
- return (_jsxs("div", { className: "vf-editor-chat-json-toggle-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-toggle-icon", onClick: () => props.togglePath(path), "aria-label": "Expand JSON block", children: _jsx(ChevronIcon, { open: false }) }), _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}{ … }${suffix}`, depth: props.depth, onCopy: props.onCopy })] }));
1658
- }
1659
- return (_jsxs("div", { children: [_jsxs("div", { className: "vf-editor-chat-json-toggle-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-toggle-icon", onClick: () => props.togglePath(path), "aria-label": "Collapse JSON block", children: _jsx(ChevronIcon, { open: true }) }), _jsx(JsonViewerLine, { text: headerText, depth: props.depth, onCopy: props.onCopy })] }), entries.map(([key, entry], index) => {
1660
- const childPrefix = `${JSON.stringify(key)}: `;
1661
- if (entry && typeof entry === "object") {
1662
- return (_jsx(JsonViewerNode, { value: entry, depth: props.depth + 1, linePrefix: childPrefix, path: `${path}.${key}`, isLast: index === entries.length - 1, collapsedPaths: props.collapsedPaths, togglePath: props.togglePath, onCopy: props.onCopy }, `${path}:${key}`));
1663
- }
1664
- return (_jsx(JsonViewerLine, { text: `${childPrefix}${formatJsonInlineValue(entry)}${index === entries.length - 1 ? "" : ","}`, depth: props.depth + 1, onCopy: props.onCopy }, `${path}:${key}`));
1665
- }), _jsx(JsonViewerLine, { text: `}${suffix}`, depth: props.depth, onCopy: props.onCopy })] }));
1666
- }
1667
- return (_jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}${formatJsonInlineValue(value)}${suffix}`, depth: props.depth, onCopy: props.onCopy }));
1668
- }
1669
- function JsonViewer(props) {
1670
- const [collapsedPaths, setCollapsedPaths] = useState(new Set());
1671
- const [copyState, setCopyState] = useState("");
1672
- const togglePath = (path) => {
1673
- setCollapsedPaths((current) => {
1674
- const next = new Set(current);
1675
- if (next.has(path)) {
1676
- next.delete(path);
1677
- }
1678
- else {
1679
- next.add(path);
1680
- }
1681
- return next;
1682
- });
1683
- };
1684
- const handleCopy = async (text) => {
1685
- try {
1686
- await copyPlainText(text);
1687
- setCopyState("done");
1688
- }
1689
- catch {
1690
- setCopyState("error");
1691
- }
1692
- window.setTimeout(() => setCopyState(""), 1400);
1693
- };
1694
- return (_jsxs("div", { className: "vf-editor-chat-json-viewer", children: [_jsxs("div", { className: "vf-editor-chat-json-toolbar", children: [_jsx("span", { className: "vf-editor-chat-json-file", children: props.storageKey }), _jsx("span", { className: "vf-editor-chat-json-copy-state", "data-state": copyState, children: copyState === "done" ? "Copied line" : copyState === "error" ? "Copy failed" : "Hover a line to copy" })] }), _jsx("div", { className: "vf-editor-chat-json-scroll", children: _jsx(JsonViewerNode, { value: props.value, depth: 0, path: "root", collapsedPaths: collapsedPaths, togglePath: togglePath, onCopy: handleCopy }) })] }));
1695
- }
1696
- function JsonArtifactModal(props) {
1697
- const [isMounted, setIsMounted] = useState(false);
1698
- const [copyStatus, setCopyStatus] = useState("");
1699
- const downloadName = downloadNameFromUrl(props.artifact.url, `${props.artifact.label}.json`);
1700
- const formattedData = formatJsonBody(props.data);
1701
- useEffect(() => {
1702
- setIsMounted(true);
1703
- }, []);
1704
- const handleCopy = async () => {
1705
- try {
1706
- await copyPlainText(formattedData);
1707
- setCopyStatus("done");
1708
- }
1709
- catch {
1710
- setCopyStatus("error");
1711
- }
1712
- window.setTimeout(() => setCopyStatus(""), 1600);
1713
- };
1714
- useEffect(() => {
1715
- const handleKeyDown = (event) => {
1716
- if (event.key === "Escape") {
1717
- props.onClose();
1718
- }
1719
- };
1720
- window.addEventListener("keydown", handleKeyDown);
1721
- return () => window.removeEventListener("keydown", handleKeyDown);
1722
- }, [props]);
1723
- if (!isMounted) {
1724
- return null;
1725
- }
1726
- return createPortal(_jsx("div", { className: "vf-editor-chat-media-modal-backdrop", onClick: props.onClose, children: _jsxs("div", { className: "vf-editor-chat-json-modal", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "vf-editor-chat-media-modal-header", children: [_jsx("a", { className: "vf-editor-chat-media-modal-link", href: props.artifact.url, target: "_blank", rel: "noopener noreferrer", children: props.artifact.label }), _jsxs("div", { className: "vf-editor-chat-media-modal-actions", children: [_jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", "data-state": copyStatus, onClick: () => void handleCopy(), title: copyStatus === "done" ? "Copied" : copyStatus === "error" ? "Copy failed" : "Copy JSON", "aria-label": copyStatus === "done" ? "JSON copied" : copyStatus === "error" ? "Unable to copy JSON" : "Copy JSON", children: _jsx(CopyIcon, {}) }), _jsx("a", { className: "vf-editor-chat-media-modal-control", href: props.artifact.url, download: downloadName, title: "Download JSON", "aria-label": "Download JSON", children: _jsx(DownloadIcon, {}) }), _jsx("a", { className: "vf-editor-chat-media-modal-control", href: props.artifact.url, target: "_blank", rel: "noopener noreferrer", title: "Open raw JSON", "aria-label": "Open raw JSON", children: _jsx(LinkIcon, {}) }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: props.onClose, "aria-label": "Close JSON preview", title: "Close", children: "\u00D7" })] })] }), _jsx("div", { className: "vf-editor-chat-json-modal-body", children: _jsx(JsonViewer, { value: props.data, storageKey: props.artifact.label }) })] }) }), document.body);
1727
- }
1728
- function JsonArtifactCard(props) {
1729
- const [isExpanded, setIsExpanded] = useState(false);
1730
- const [isModalOpen, setIsModalOpen] = useState(false);
1731
- const [data, setData] = useState(null);
1732
- const [error, setError] = useState(null);
1733
- const [loading, setLoading] = useState(false);
1734
- const ensureLoaded = async () => {
1735
- if (data !== null || loading) {
1736
- return;
1737
- }
1738
- setLoading(true);
1739
- setError(null);
1740
- try {
1741
- const response = await fetch(props.artifact.url, { credentials: "omit" });
1742
- const payload = await response.json();
1743
- setData(payload);
1744
- }
1745
- catch (nextError) {
1746
- setError(nextError instanceof Error ? nextError.message : "Unable to load JSON artifact.");
1747
- }
1748
- finally {
1749
- setLoading(false);
1750
- }
1751
- };
1752
- const handleToggleExpand = () => {
1753
- const next = !isExpanded;
1754
- setIsExpanded(next);
1755
- if (next) {
1756
- void ensureLoaded();
1757
- }
1758
- };
1759
- const handleOpenModal = () => {
1760
- void ensureLoaded();
1761
- setIsModalOpen(true);
1762
- };
1763
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "vf-editor-chat-json-card", children: [_jsxs("div", { className: "vf-editor-chat-json-card-head", children: [_jsxs("div", { children: [_jsx("div", { className: "vf-editor-chat-json-card-kicker", children: "JSON result" }), _jsx("div", { className: "vf-editor-chat-json-card-title", children: props.artifact.label })] }), _jsxs("div", { className: "vf-editor-chat-json-card-actions", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-card-action", onClick: handleToggleExpand, children: isExpanded ? "Hide preview" : "Preview JSON" }), _jsx("button", { type: "button", className: "vf-editor-chat-json-card-action is-strong", onClick: handleOpenModal, children: "Open" })] })] }), isExpanded ? (_jsx("div", { className: "vf-editor-chat-json-card-body", children: loading ? (_jsx("div", { className: "vf-editor-chat-json-state", children: "Loading JSON\u2026" })) : error ? (_jsx("div", { className: "vf-editor-chat-json-state is-error", children: error })) : data !== null ? (_jsx(JsonViewer, { value: data, storageKey: props.artifact.label })) : null })) : null] }), isModalOpen && data !== null ? (_jsx(JsonArtifactModal, { artifact: props.artifact, data: data, onClose: () => setIsModalOpen(false) })) : null] }));
1764
- }
1765
- function TextArtifactViewer(props) {
1766
- if (props.format === "markdown") {
1767
- return (_jsx("div", { className: "vf-editor-chat-doc-markdown", dangerouslySetInnerHTML: { __html: renderMarkdownToHtml(props.text) } }));
1768
- }
1769
- return _jsx("pre", { className: "vf-editor-chat-doc-pre", children: props.text });
1770
- }
1771
- function DocumentArtifactModal(props) {
1772
- const [isMounted, setIsMounted] = useState(false);
1773
- const [copyStatus, setCopyStatus] = useState("");
1774
- const fallbackExtension = props.artifact.format === "markdown" ? ".md" : ".txt";
1775
- const downloadName = downloadNameFromUrl(props.artifact.url, `${props.artifact.label}${fallbackExtension}`);
1776
- useEffect(() => {
1777
- setIsMounted(true);
1778
- }, []);
1779
- const handleCopy = async () => {
1780
- try {
1781
- await copyPlainText(props.text);
1782
- setCopyStatus("done");
1783
- }
1784
- catch {
1785
- setCopyStatus("error");
1786
- }
1787
- window.setTimeout(() => setCopyStatus(""), 1600);
1788
- };
1789
- useEffect(() => {
1790
- const handleKeyDown = (event) => {
1791
- if (event.key === "Escape") {
1792
- props.onClose();
1793
- }
1794
- };
1795
- window.addEventListener("keydown", handleKeyDown);
1796
- return () => window.removeEventListener("keydown", handleKeyDown);
1797
- }, [props]);
1798
- if (!isMounted) {
1799
- return null;
1800
- }
1801
- return createPortal(_jsx("div", { className: "vf-editor-chat-media-modal-backdrop", onClick: props.onClose, children: _jsxs("div", { className: "vf-editor-chat-json-modal", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "vf-editor-chat-media-modal-header", children: [_jsx("a", { className: "vf-editor-chat-media-modal-link", href: props.artifact.url, target: "_blank", rel: "noopener noreferrer", children: props.artifact.label }), _jsxs("div", { className: "vf-editor-chat-media-modal-actions", children: [_jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", "data-state": copyStatus, onClick: () => void handleCopy(), title: copyStatus === "done" ? "Copied" : copyStatus === "error" ? "Copy failed" : props.artifact.format === "markdown" ? "Copy markdown" : "Copy text", "aria-label": copyStatus === "done" ? "Document copied" : copyStatus === "error" ? "Unable to copy document" : props.artifact.format === "markdown" ? "Copy markdown" : "Copy text", children: _jsx(CopyIcon, {}) }), _jsx("a", { className: "vf-editor-chat-media-modal-control", href: props.artifact.url, download: downloadName, title: props.artifact.format === "markdown" ? "Download markdown" : "Download text", "aria-label": props.artifact.format === "markdown" ? "Download markdown" : "Download text", children: _jsx(DownloadIcon, {}) }), _jsx("a", { className: "vf-editor-chat-media-modal-control", href: props.artifact.url, target: "_blank", rel: "noopener noreferrer", title: "Open raw file", "aria-label": "Open raw file", children: _jsx(LinkIcon, {}) }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: props.onClose, "aria-label": "Close document preview", title: "Close", children: "\u00D7" })] })] }), _jsx("div", { className: "vf-editor-chat-json-modal-body", children: _jsx("div", { className: "vf-editor-chat-doc-viewer", children: _jsx(TextArtifactViewer, { text: props.text, format: props.artifact.format }) }) })] }) }), document.body);
1802
- }
1803
- function DocumentArtifactCard(props) {
1804
- const [isExpanded, setIsExpanded] = useState(false);
1805
- const [isModalOpen, setIsModalOpen] = useState(false);
1806
- const [text, setText] = useState(null);
1807
- const [error, setError] = useState(null);
1808
- const [loading, setLoading] = useState(false);
1809
- const ensureLoaded = async () => {
1810
- if (text !== null || loading) {
1811
- return;
1812
- }
1813
- setLoading(true);
1814
- setError(null);
1815
- try {
1816
- const response = await fetch(props.artifact.url, { credentials: "omit" });
1817
- const payload = await response.text();
1818
- setText(payload);
1819
- }
1820
- catch (nextError) {
1821
- setError(nextError instanceof Error ? nextError.message : "Unable to load document artifact.");
1822
- }
1823
- finally {
1824
- setLoading(false);
1825
- }
1826
- };
1827
- const handleToggleExpand = () => {
1828
- const next = !isExpanded;
1829
- setIsExpanded(next);
1830
- if (next) {
1831
- void ensureLoaded();
1832
- }
1833
- };
1834
- const handleOpenModal = () => {
1835
- void ensureLoaded();
1836
- setIsModalOpen(true);
1837
- };
1838
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "vf-editor-chat-json-card", children: [_jsxs("div", { className: "vf-editor-chat-json-card-head", children: [_jsxs("div", { children: [_jsx("div", { className: "vf-editor-chat-json-card-kicker", children: props.artifact.format === "markdown" ? "Markdown result" : "Text result" }), _jsx("div", { className: "vf-editor-chat-json-card-title", children: props.artifact.label })] }), _jsxs("div", { className: "vf-editor-chat-json-card-actions", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-card-action", onClick: handleToggleExpand, children: isExpanded ? "Hide preview" : "Preview" }), _jsx("button", { type: "button", className: "vf-editor-chat-json-card-action is-strong", onClick: handleOpenModal, children: "Open" })] })] }), isExpanded ? (_jsx("div", { className: "vf-editor-chat-json-card-body", children: loading ? (_jsx("div", { className: "vf-editor-chat-json-state", children: "Loading document\u2026" })) : error ? (_jsx("div", { className: "vf-editor-chat-json-state is-error", children: error })) : text !== null ? (_jsx("div", { className: "vf-editor-chat-doc-viewer is-inline", children: _jsx(TextArtifactViewer, { text: text, format: props.artifact.format }) })) : null })) : null] }), isModalOpen && text !== null ? (_jsx(DocumentArtifactModal, { artifact: props.artifact, text: text, onClose: () => setIsModalOpen(false) })) : null] }));
1839
- }
1840
- const ChatBubble = memo(function ChatBubble(props) {
1841
- const isAssistant = props.message.role === "assistant";
1842
- const hasVisibleText = props.message.text.trim().length > 0;
1843
- const renderedText = hasVisibleText ? renderMarkdownToHtml(props.message.text) : "";
1844
- const [mediaModalState, setMediaModalState] = useState(null);
1845
- const imageAttachments = props.message.attachments.filter(isImageAttachment);
1846
- const videoAttachments = props.message.attachments.filter(isVideoAttachment);
1847
- const fileAttachments = props.message.attachments.filter((attachment) => !isImageAttachment(attachment) && !isVideoAttachment(attachment));
1848
- const inlineMediaPreviews = hasVisibleText
1849
- ? extractInlineMediaPreviews(props.message.text, props.message.attachments.map((attachment) => attachment.viewUrl))
1850
- : [];
1851
- const inlineImagePreviews = inlineMediaPreviews.filter((preview) => preview.kind === "image");
1852
- const inlineVideoPreviews = inlineMediaPreviews.filter((preview) => preview.kind === "video");
1853
- const brainstormJsonArtifacts = (props.message.httpExchanges ?? []).flatMap((exchange) => {
1854
- const job = tryReadJobStatusPayload(exchange.result.body);
1855
- return job ? extractBrainstormJsonArtifactsFromJob(job) : [];
1856
- }).filter((artifact, index, items) => items.findIndex((entry) => entry.url === artifact.url) === index);
1857
- const brainstormDocumentArtifacts = (props.message.httpExchanges ?? []).flatMap((exchange) => {
1858
- const job = tryReadJobStatusPayload(exchange.result.body);
1859
- return job ? extractBrainstormDocumentArtifactsFromJob(job) : [];
1860
- }).filter((artifact, index, items) => items.findIndex((entry) => entry.url === artifact.url) === index);
1861
- return (_jsxs("div", { className: `vf-editor-chat-message ${isAssistant ? "is-assistant" : "is-user"}`, children: [_jsx("div", { className: "vf-editor-chat-message-meta", children: isAssistant ? "Copilot" : "You" }), _jsxs("div", { className: "vf-editor-chat-bubble", "data-pending": props.message.isPending ? "true" : "false", children: [props.message.attachments.length ? (_jsxs("div", { className: "vf-editor-chat-message-attachments", children: [fileAttachments.map((attachment) => (_jsx("div", { className: "vf-editor-chat-message-attachment-row", children: _jsx("a", { className: "vf-editor-chat-message-preview-link", href: attachment.viewUrl, target: "_blank", rel: "noopener noreferrer", children: _jsx(AttachmentChip, { attachment: attachment }) }) }, attachment.id))), _jsx(ImagePreviewRail, { previews: imageAttachments.map((attachment) => ({
1862
- kind: "image",
1863
- url: attachment.viewUrl,
1864
- label: attachment.fileName
1865
- })), onOpenModal: (preview, gallery) => setMediaModalState({ preview, gallery }) }), videoAttachments.map((attachment) => (_jsx(VideoPreviewCard, { preview: {
1866
- kind: "video",
1867
- url: attachment.viewUrl,
1868
- label: attachment.fileName
1869
- }, onOpenModal: (preview) => setMediaModalState({ preview, gallery: [preview] }) }, attachment.id)))] })) : null, props.message.statusText ? (_jsxs("div", { className: "vf-editor-chat-message-status", "aria-live": "polite", children: [_jsx("span", { className: "vf-editor-chat-status-dot" }), props.message.statusText] })) : null, props.message.httpExchanges?.length ? (_jsx("div", { className: "vf-editor-chat-http-list", children: props.message.httpExchanges.map((exchange, index) => (_jsx(HttpExchangeCard, { exchange: exchange }, `${exchange.request.method}:${exchange.request.url}:${index}`))) })) : null, hasVisibleText ? (_jsx("div", { className: "vf-editor-chat-message-body", dangerouslySetInnerHTML: { __html: renderedText } })) : props.message.isPending ? (_jsxs("div", { className: "vf-editor-chat-message-loading", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] })) : null, inlineMediaPreviews.length ? (_jsxs("div", { className: "vf-editor-chat-message-attachments", children: [_jsx(ImagePreviewRail, { previews: inlineImagePreviews, onOpenModal: (preview, gallery) => setMediaModalState({ preview, gallery }) }), inlineVideoPreviews.map((preview) => (_jsx(VideoPreviewCard, { preview: preview, onOpenModal: (activePreview) => setMediaModalState({ preview: activePreview, gallery: [activePreview] }) }, preview.url)))] })) : null, brainstormJsonArtifacts.length ? (_jsx("div", { className: "vf-editor-chat-json-list", children: brainstormJsonArtifacts.map((artifact) => (_jsx(JsonArtifactCard, { artifact: artifact }, artifact.key))) })) : null, brainstormDocumentArtifacts.length ? (_jsx("div", { className: "vf-editor-chat-json-list", children: brainstormDocumentArtifacts.map((artifact) => (_jsx(DocumentArtifactCard, { artifact: artifact }, artifact.key))) })) : null] }), mediaModalState ? (_jsx(MediaPreviewModal, { preview: mediaModalState.preview, gallery: mediaModalState.gallery, onClose: () => setMediaModalState(null) })) : null] }));
1870
- });
1871
- function formatBytes(bytes) {
1872
- if (!bytes || bytes <= 0) {
1873
- return "Unknown size";
1874
- }
1875
- const units = ["B", "KB", "MB", "GB"];
1876
- let value = bytes;
1877
- let unitIndex = 0;
1878
- while (value >= 1024 && unitIndex < units.length - 1) {
1879
- value /= 1024;
1880
- unitIndex += 1;
1881
- }
1882
- return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
1883
- }
1884
- function FilePreview(props) {
1885
- if (isImageAttachment(props.file)) {
1886
- return _jsx("img", { className: "vf-editor-upload-file-thumb", src: props.file.viewUrl, alt: props.file.fileName });
1887
- }
1888
- if (isVideoAttachment(props.file)) {
1889
- return _jsx("video", { className: "vf-editor-upload-file-thumb", src: props.file.viewUrl, muted: true, playsInline: true });
1890
- }
1891
- return _jsx("div", { className: "vf-editor-upload-file-icon", "aria-hidden": "true", children: props.file.fileName.slice(0, 1).toUpperCase() });
1892
- }
1893
- function normalizeFolderPath(value) {
1894
- return value
1895
- .split("/")
1896
- .map((segment) => segment.trim())
1897
- .filter(Boolean)
1898
- .join("/");
1899
- }
1900
- function joinFolderPath(parent, name) {
1901
- return normalizeFolderPath([parent, name].filter(Boolean).join("/"));
1902
- }
1903
- function parentFolderPath(value) {
1904
- const parts = normalizeFolderPath(value).split("/").filter(Boolean);
1905
- parts.pop();
1906
- return parts.join("/");
1907
- }
1908
- function folderDisplayName(value) {
1909
- const parts = normalizeFolderPath(value).split("/").filter(Boolean);
1910
- return parts.at(-1) || "All files";
1911
- }
1912
- function getImmediateFolders(input) {
1913
- const current = normalizeFolderPath(input.currentFolder);
1914
- const prefix = current ? `${current}/` : "";
1915
- const names = new Map();
1916
- for (const folder of input.folders) {
1917
- const normalized = normalizeFolderPath(folder);
1918
- if (!normalized || (current && normalized === current) || !normalized.startsWith(prefix)) {
1919
- continue;
1920
- }
1921
- const rest = normalized.slice(prefix.length);
1922
- const childName = rest.split("/")[0];
1923
- if (childName) {
1924
- names.set(childName, joinFolderPath(current, childName));
1925
- }
1926
- }
1927
- for (const file of input.files) {
1928
- const normalized = normalizeFolderPath(file.folderPath ?? "");
1929
- if (!normalized || (current && normalized === current) || !normalized.startsWith(prefix)) {
1930
- continue;
1931
- }
1932
- const rest = normalized.slice(prefix.length);
1933
- const childName = rest.split("/")[0];
1934
- if (childName) {
1935
- names.set(childName, joinFolderPath(current, childName));
1936
- }
1937
- }
1938
- return Array.from(names.entries())
1939
- .map(([name, path]) => ({ name, path }))
1940
- .sort((a, b) => a.name.localeCompare(b.name));
1941
- }
1942
- function UploadActionIcon(props) {
1943
- if (props.name === "share") {
1944
- return (_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M14 4h6v6" }), _jsx("path", { d: "M10 14 20 4" }), _jsx("path", { d: "M20 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4" })] }));
1945
- }
1946
- if (props.name === "link") {
1947
- return (_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M10 13a5 5 0 0 0 7.07 0l2.12-2.12a5 5 0 0 0-7.07-7.07L11 4.93" }), _jsx("path", { d: "M14 11a5 5 0 0 0-7.07 0L4.81 13.12a5 5 0 0 0 7.07 7.07L13 19.07" })] }));
1948
- }
1949
- return (_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M3 6h18" }), _jsx("path", { d: "M8 6V4h8v2" }), _jsx("path", { d: "M19 6 18 20H6L5 6" }), _jsx("path", { d: "M10 11v5" }), _jsx("path", { d: "M14 11v5" })] }));
1950
- }
1951
- function UploadDrawer(props) {
1952
- const [tab, setTab] = useState("my-files");
1953
- const [uploadSelected, setUploadSelected] = useState([]);
1954
- const [myFiles, setMyFiles] = useState([]);
1955
- const [myFolders, setMyFolders] = useState([]);
1956
- const [mySelectedIds, setMySelectedIds] = useState(() => new Set());
1957
- const [recentMedia, setRecentMedia] = useState([]);
1958
- const [recentTemporaryFiles, setRecentTemporaryFiles] = useState([]);
1959
- const [recentSelectedIds, setRecentSelectedIds] = useState(() => new Set());
1960
- const [recentNextCursor, setRecentNextCursor] = useState(null);
1961
- const [recentLoading, setRecentLoading] = useState(false);
1962
- const [myFolderPath, setMyFolderPath] = useState("");
1963
- const [openFolderMenu, setOpenFolderMenu] = useState(null);
1964
- const [folderDialog, setFolderDialog] = useState(null);
1965
- const [status, setStatus] = useState(null);
1966
- const [error, setError] = useState(null);
1967
- const [confirmDelete, setConfirmDelete] = useState(null);
1968
- const uploadInputRef = useRef(null);
1969
- const myInputRef = useRef(null);
1970
- const recentFiles = useMemo(() => {
1971
- const generated = recentMedia.map((file) => ({ ...file, recentSource: "generated" }));
1972
- const temporary = recentTemporaryFiles.map((file) => ({ ...file, recentSource: "temporary" }));
1973
- return [...temporary, ...generated].sort((a, b) => {
1974
- const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
1975
- const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
1976
- return bTime - aTime;
1977
- });
1978
- }, [recentMedia, recentTemporaryFiles]);
1979
- const confirmAttachments = tab === "upload"
1980
- ? uploadSelected
1981
- : tab === "my-files"
1982
- ? myFiles.filter((file) => mySelectedIds.has(file.id))
1983
- : tab === "recents"
1984
- ? recentFiles.filter((file) => recentSelectedIds.has(file.id))
1985
- : [];
1986
- async function refreshMyFiles() {
1987
- const next = await listMyFiles(props.apiKey);
1988
- setMyFiles(next.attachments);
1989
- setMyFolders(next.folders);
1990
- }
1991
- async function refreshRecentMedia(cursor) {
1992
- setRecentLoading(true);
1993
- try {
1994
- const [next, temporary] = cursor
1995
- ? [await listGeneratedMedia(props.apiKey, cursor), null]
1996
- : await Promise.all([
1997
- listGeneratedMedia(props.apiKey),
1998
- listTemporaryFiles(props.apiKey)
1999
- ]);
2000
- setRecentMedia((current) => cursor ? [...current, ...next.media] : next.media);
2001
- if (temporary) {
2002
- setRecentTemporaryFiles(temporary.files);
2003
- }
2004
- setRecentNextCursor(next.nextCursor);
2005
- if (!cursor) {
2006
- setRecentSelectedIds(new Set());
2007
- }
2008
- }
2009
- finally {
2010
- setRecentLoading(false);
2011
- }
2012
- }
2013
- useEffect(() => {
2014
- if (!props.open) {
2015
- return;
2016
- }
2017
- setError(null);
2018
- setStatus(null);
2019
- void refreshMyFiles().catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to load My Files."));
2020
- void refreshRecentMedia().catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to load recent media."));
2021
- }, [props.open]);
2022
- function resetAndClose() {
2023
- setUploadSelected([]);
2024
- setMySelectedIds(new Set());
2025
- setRecentSelectedIds(new Set());
2026
- setStatus(null);
2027
- setError(null);
2028
- setOpenFolderMenu(null);
2029
- setFolderDialog(null);
2030
- props.onClose();
2031
- }
2032
- async function copyText(value) {
2033
- try {
2034
- if (navigator.clipboard?.writeText) {
2035
- await navigator.clipboard.writeText(value);
2036
- }
2037
- else {
2038
- const textarea = document.createElement("textarea");
2039
- textarea.value = value;
2040
- textarea.style.position = "fixed";
2041
- textarea.style.opacity = "0";
2042
- document.body.appendChild(textarea);
2043
- textarea.focus();
2044
- textarea.select();
2045
- document.execCommand("copy");
2046
- document.body.removeChild(textarea);
2047
- }
2048
- setStatus("File URL copied.");
2049
- }
2050
- catch {
2051
- setError("Unable to copy file URL.");
2052
- }
2053
- }
2054
- async function uploadFiles(files, scope) {
2055
- if (!files.length) {
2056
- return;
2057
- }
2058
- setError(null);
2059
- const uploaded = [];
2060
- for (const file of files) {
2061
- try {
2062
- setStatus(`Uploading ${file.name}...`);
2063
- if (scope === "upload") {
2064
- const temporary = await uploadTemporaryFile({
2065
- apiKey: props.apiKey,
2066
- file,
2067
- folderPath: CHAT_TEMPORARY_UPLOAD_FOLDER
2068
- });
2069
- const attachment = await addInlinePreviewForChatUpload({
2070
- attachment: temporary,
2071
- file,
2072
- currentInlineBytes: estimateInlineAttachmentBytes([...uploadSelected, ...uploaded])
2073
- });
2074
- uploaded.push(attachment);
2075
- continue;
2076
- }
2077
- const attachment = await uploadAttachment({
2078
- apiKey: props.apiKey,
2079
- file,
2080
- folderPath: myFolderPath,
2081
- maxBytes: MY_FILES_UPLOAD_LIMIT_BYTES,
2082
- limitLabel: "50 MB"
2083
- });
2084
- uploaded.push(attachment);
2085
- }
2086
- catch (nextError) {
2087
- setError(nextError instanceof Error ? nextError.message : "Upload failed.");
2088
- }
2089
- }
2090
- if (scope === "upload") {
2091
- setUploadSelected((current) => [...current, ...uploaded]);
2092
- await refreshRecentMedia().catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to refresh recent files."));
2093
- }
2094
- else if (scope === "my-files") {
2095
- await refreshMyFiles();
2096
- setMySelectedIds((current) => new Set([...current, ...uploaded.map((file) => file.id)]));
2097
- }
2098
- if (uploaded.length) {
2099
- setStatus(`${uploaded.length} file${uploaded.length === 1 ? "" : "s"} uploaded.`);
2100
- }
2101
- }
2102
- async function deleteFile(file, scope) {
2103
- const url = scope === "my-files"
2104
- ? `/api/v1/user/me/attachments/${encodeURIComponent(file.id)}`
2105
- : `/api/v1/user/me/temporary-files/${encodeURIComponent(file.id)}`;
2106
- const response = await fetch(url, {
2107
- method: "DELETE",
2108
- headers: buildAuthHeaders(props.apiKey, "application/json")
2109
- });
2110
- const payload = await response.json().catch(() => ({}));
2111
- if (!response.ok) {
2112
- throw new Error(payload.error || "Unable to delete file.");
2113
- }
2114
- if (scope === "my-files") {
2115
- setMyFiles((current) => current.filter((entry) => entry.id !== file.id));
2116
- setMySelectedIds((current) => {
2117
- const next = new Set(current);
2118
- next.delete(file.id);
2119
- return next;
2120
- });
2121
- }
2122
- setConfirmDelete(null);
2123
- setStatus("File deleted.");
2124
- }
2125
- async function createFolder(scope) {
2126
- if (scope !== "my-files" || !folderDialog || folderDialog.mode !== "create") {
2127
- return;
2128
- }
2129
- const folderPath = joinFolderPath(folderDialog.baseFolder, folderDialog.value);
2130
- if (!folderPath) {
2131
- setError("Choose a folder name.");
2132
- return;
2133
- }
2134
- const response = await fetch("/api/v1/user/me/attachments/folders", {
2135
- method: "POST",
2136
- headers: buildAuthHeaders(props.apiKey, "application/json"),
2137
- body: JSON.stringify({ folder_path: folderPath })
2138
- });
2139
- const payload = await response.json().catch(() => ({}));
2140
- if (!response.ok) {
2141
- setError(payload.error || "Unable to create folder.");
2142
- return;
2143
- }
2144
- setMyFolders((current) => Array.from(new Set([...current, folderPath])).sort());
2145
- setFolderDialog(null);
2146
- setStatus("Folder created.");
2147
- }
2148
- async function renameFolder(scope, fromFolder) {
2149
- if (scope !== "my-files" || !folderDialog || folderDialog.mode !== "rename") {
2150
- return;
2151
- }
2152
- const renamedLeaf = folderDialog.value.trim();
2153
- const toFolder = joinFolderPath(parentFolderPath(fromFolder), renamedLeaf);
2154
- if (!toFolder || toFolder === fromFolder) {
2155
- return;
2156
- }
2157
- const response = await fetch("/api/v1/user/me/attachments/folders", {
2158
- method: "PATCH",
2159
- headers: buildAuthHeaders(props.apiKey, "application/json"),
2160
- body: JSON.stringify({ from_folder_path: fromFolder, to_folder_path: toFolder })
2161
- });
2162
- const payload = await response.json().catch(() => ({}));
2163
- if (!response.ok) {
2164
- setError(payload.error || "Unable to rename folder.");
2165
- return;
2166
- }
2167
- setMyFolderPath((current) => current === fromFolder || current.startsWith(`${fromFolder}/`)
2168
- ? `${toFolder}${current.slice(fromFolder.length)}`
2169
- : current);
2170
- await refreshMyFiles();
2171
- setOpenFolderMenu(null);
2172
- setFolderDialog(null);
2173
- setStatus("Folder renamed.");
2174
- }
2175
- async function deleteFolder(scope, folderPath) {
2176
- if (scope !== "my-files") {
2177
- return;
2178
- }
2179
- const response = await fetch("/api/v1/user/me/attachments/folders", {
2180
- method: "DELETE",
2181
- headers: buildAuthHeaders(props.apiKey, "application/json"),
2182
- body: JSON.stringify({ folder_path: folderPath })
2183
- });
2184
- const payload = await response.json().catch(() => ({}));
2185
- if (!response.ok) {
2186
- setError(payload.error || "Unable to delete folder.");
2187
- return;
2188
- }
2189
- setMyFolderPath((current) => current === folderPath || current.startsWith(`${folderPath}/`) ? parentFolderPath(folderPath) : current);
2190
- await refreshMyFiles();
2191
- setOpenFolderMenu(null);
2192
- setFolderDialog(null);
2193
- setStatus("Folder deleted.");
2194
- }
2195
- function renderFolderBrowser(scope) {
2196
- const folders = myFolders;
2197
- const files = myFiles;
2198
- const currentFolder = myFolderPath;
2199
- const setCurrentFolder = setMyFolderPath;
2200
- const childFolders = getImmediateFolders({ folders, files, currentFolder });
2201
- const parts = normalizeFolderPath(currentFolder).split("/").filter(Boolean);
2202
- return (_jsxs("div", { className: "vf-editor-upload-browser", children: [_jsxs("div", { className: "vf-editor-upload-browser-toolbar", children: [_jsxs("div", { className: "vf-editor-upload-breadcrumb", "aria-label": "Current folder", children: [_jsx("button", { type: "button", "data-current": !currentFolder, onClick: () => setCurrentFolder(""), children: "files" }), parts.map((part, index) => {
2203
- const path = parts.slice(0, index + 1).join("/");
2204
- return (_jsxs("span", { className: "vf-editor-upload-breadcrumb-segment", children: [_jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("button", { type: "button", "data-current": path === currentFolder, onClick: () => setCurrentFolder(path), children: part })] }, path));
2205
- })] }), _jsx("button", { type: "button", onClick: () => setFolderDialog({ mode: "create", scope: "my-files", baseFolder: currentFolder, value: "" }), children: "New folder" })] }), childFolders.length ? (_jsx("div", { className: "vf-editor-upload-folder-grid", children: childFolders.map((folder) => (_jsxs("article", { className: "vf-editor-upload-folder-row", children: [_jsxs("button", { type: "button", className: "vf-editor-upload-folder-open", onClick: () => setCurrentFolder(folder.path), children: [_jsx("span", { className: "vf-editor-upload-folder-icon", "aria-hidden": "true" }), _jsx("span", { children: folder.name })] }), _jsx("button", { type: "button", className: "vf-editor-upload-folder-menu-button", "aria-label": `Folder actions for ${folder.name}`, onClick: () => setOpenFolderMenu(openFolderMenu === folder.path ? null : folder.path), children: "..." }), openFolderMenu === folder.path ? (_jsxs("div", { className: "vf-editor-upload-folder-menu", children: [_jsx("button", { type: "button", onClick: () => setFolderDialog({ mode: "rename", scope: "my-files", folderPath: folder.path, value: folderDisplayName(folder.path) }), children: "Rename" }), _jsx("button", { type: "button", "data-danger": "true", onClick: () => setFolderDialog({ mode: "delete", scope: "my-files", folderPath: folder.path, value: "" }), children: "Delete" })] })) : null] }, folder.path))) })) : null] }));
2206
- }
2207
- function renderFileCard(file, scope) {
2208
- const selected = scope === "my-files"
2209
- ? mySelectedIds.has(file.id)
2210
- : scope === "recents"
2211
- ? recentSelectedIds.has(file.id)
2212
- : false;
2213
- const copyUrl = "s3Url" in file && file.s3Url ? file.s3Url : file.viewUrl;
2214
- return (_jsxs("article", { className: "vf-editor-upload-file-card", "data-selected": selected ? "true" : "false", children: [_jsxs("button", { type: "button", className: "vf-editor-upload-file-select", onClick: () => {
2215
- if (scope === "temporary") {
2216
- void copyText(copyUrl);
2217
- return;
2218
- }
2219
- const setSelectedIds = scope === "recents" ? setRecentSelectedIds : setMySelectedIds;
2220
- setSelectedIds((current) => {
2221
- const next = new Set(current);
2222
- if (next.has(file.id)) {
2223
- next.delete(file.id);
2224
- }
2225
- else {
2226
- next.add(file.id);
2227
- }
2228
- return next;
2229
- });
2230
- }, children: [_jsx(FilePreview, { file: file }), _jsxs("span", { className: "vf-editor-upload-file-main", children: [_jsx("span", { className: "vf-editor-upload-file-name", children: file.fileName }), _jsxs("span", { className: "vf-editor-upload-file-meta", children: [formatBytes(file.sizeBytes), file.folderPath ? ` · ${file.folderPath}` : ""] })] })] }), _jsxs("div", { className: "vf-editor-upload-file-actions", children: [scope === "my-files" || scope === "recents" ? (_jsx("span", { className: "vf-editor-upload-selection-indicator", "data-selected": selected ? "true" : "false", "aria-hidden": "true", children: selected ? "✓" : "" })) : null, _jsx("a", { href: file.viewUrl, target: "_blank", rel: "noreferrer", "aria-label": `View ${file.fileName}`, children: _jsx(UploadActionIcon, { name: "share" }) }), _jsx("button", { type: "button", "aria-label": `Copy URL for ${file.fileName}`, onClick: () => void copyText(copyUrl), children: _jsx(UploadActionIcon, { name: "link" }) }), scope === "recents" ? null : (_jsx("button", { type: "button", "aria-label": `Delete ${file.fileName}`, onClick: () => setConfirmDelete(confirmDelete === file.id ? null : file.id), children: _jsx(UploadActionIcon, { name: "trash" }) }))] }), scope !== "recents" && confirmDelete === file.id ? (_jsxs("div", { className: "vf-editor-upload-popconfirm", children: [_jsx("span", { children: "Delete this file?" }), _jsx("button", { type: "button", onClick: () => setConfirmDelete(null), children: "Cancel" }), _jsx("button", { type: "button", "data-danger": "true", onClick: () => void deleteFile(file, scope), children: "Delete" })] })) : null] }, file.id));
2231
- }
2232
- function renderFolderDialog() {
2233
- if (!folderDialog) {
2234
- return null;
2235
- }
2236
- const title = folderDialog.mode === "create"
2237
- ? "New folder"
2238
- : folderDialog.mode === "rename"
2239
- ? "Rename folder"
2240
- : "Delete folder";
2241
- const targetPath = folderDialog.mode === "delete" ? folderDialog.folderPath : "";
2242
- return (_jsxs("div", { className: "vf-editor-upload-folder-dialog", role: "dialog", "aria-label": title, children: [_jsx("div", { className: "vf-editor-upload-folder-dialog-title", children: title }), folderDialog.mode === "delete" ? (_jsxs("div", { className: "vf-editor-upload-folder-dialog-copy", children: ["Delete ", _jsx("strong", { children: targetPath }), " and all files inside it?"] })) : (_jsx("input", { value: folderDialog.value, autoFocus: true, placeholder: "folder-name", onChange: (event) => setFolderDialog({ ...folderDialog, value: event.target.value }), onKeyDown: (event) => {
2243
- if (event.key === "Enter") {
2244
- event.preventDefault();
2245
- if (folderDialog.mode === "create") {
2246
- void createFolder(folderDialog.scope);
2247
- }
2248
- else {
2249
- void renameFolder(folderDialog.scope, folderDialog.folderPath);
2250
- }
2251
- }
2252
- } })), _jsxs("div", { className: "vf-editor-upload-folder-dialog-actions", children: [_jsx("button", { type: "button", onClick: () => setFolderDialog(null), children: "Cancel" }), folderDialog.mode === "create" ? (_jsx("button", { type: "button", className: "vf-editor-upload-confirm", onClick: () => void createFolder(folderDialog.scope), children: "Create" })) : folderDialog.mode === "rename" ? (_jsx("button", { type: "button", className: "vf-editor-upload-confirm", onClick: () => void renameFolder(folderDialog.scope, folderDialog.folderPath), children: "Rename" })) : (_jsx("button", { type: "button", "data-danger": "true", onClick: () => void deleteFolder(folderDialog.scope, folderDialog.folderPath), children: "Delete" }))] })] }));
2253
- }
2254
- if (!props.open) {
2255
- return null;
2256
- }
2257
- const visibleMyFiles = myFiles.filter((file) => normalizeFolderPath(file.folderPath ?? "") === normalizeFolderPath(myFolderPath));
2258
- return createPortal(_jsx("div", { className: "vf-editor-upload-drawer-backdrop", children: _jsxs("aside", { className: "vf-editor-upload-drawer", "aria-label": "Upload drawer", children: [_jsxs("div", { className: "vf-editor-upload-header", children: [_jsxs("div", { children: [_jsx("div", { className: "vf-editor-upload-title", children: "Upload" }), _jsx("div", { className: "vf-editor-upload-subtitle", children: "Upload, attach, and copy file URLs." })] }), _jsx("button", { type: "button", className: "vf-editor-upload-close", "aria-label": "Close upload drawer", onClick: resetAndClose, children: "\u00D7" })] }), _jsxs("div", { className: "vf-editor-upload-tabs", role: "tablist", children: [_jsx("button", { type: "button", "data-active": tab === "upload", onClick: () => setTab("upload"), children: "Upload file" }), _jsx("button", { type: "button", "data-active": tab === "my-files", onClick: () => setTab("my-files"), children: "My Files" }), _jsx("button", { type: "button", "data-active": tab === "recents", onClick: () => setTab("recents"), children: "Recents" })] }), _jsxs("div", { className: "vf-editor-upload-body", children: [error ? _jsx("div", { className: "vf-editor-upload-status", "data-tone": "error", children: error }) : null, status ? _jsx("div", { className: "vf-editor-upload-status", children: status }) : null, renderFolderDialog(), tab === "upload" ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "vf-editor-upload-drop", onClick: () => uploadInputRef.current?.click(), children: ["Upload files to chat", _jsx("span", { children: "Stored in Recents and attached after Confirm. Images get compressed inline previews when possible; other files attach by URL. Max 100 MB each." })] }), _jsx("input", { ref: uploadInputRef, type: "file", multiple: true, hidden: true, onChange: (event) => {
2259
- void uploadFiles(Array.from(event.target.files ?? []), "upload");
2260
- event.target.value = "";
2261
- } }), _jsx("div", { className: "vf-editor-upload-list", children: uploadSelected.map((file) => (_jsxs("article", { className: "vf-editor-upload-file-card", "data-selected": "true", children: [_jsx(FilePreview, { file: file }), _jsxs("span", { className: "vf-editor-upload-file-main", children: [_jsx("span", { className: "vf-editor-upload-file-name", children: file.fileName }), _jsx("span", { className: "vf-editor-upload-file-meta", children: formatBytes(file.sizeBytes) })] }), _jsx("button", { type: "button", onClick: () => setUploadSelected((current) => current.filter((entry) => entry.id !== file.id)), children: "\u00D7" })] }, file.id))) })] })) : tab === "my-files" ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "vf-editor-upload-drop", onClick: () => myInputRef.current?.click(), children: ["Add to My Files", _jsxs("span", { children: ["Uploads into ", folderDisplayName(myFolderPath), " and auto-selects. Max 50 MB each."] })] }), _jsx("input", { ref: myInputRef, type: "file", multiple: true, hidden: true, onChange: (event) => {
2262
- void uploadFiles(Array.from(event.target.files ?? []), "my-files");
2263
- event.target.value = "";
2264
- } }), renderFolderBrowser("my-files"), _jsx("div", { className: "vf-editor-upload-list", children: visibleMyFiles.length ? visibleMyFiles.map((file) => renderFileCard(file, "my-files")) : _jsx("div", { className: "vf-editor-upload-empty", children: "No files in this folder." }) })] })) : tab === "recents" ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "vf-editor-upload-list", children: recentFiles.length ? recentFiles.map((file) => renderFileCard(file, "recents")) : (_jsx("div", { className: "vf-editor-upload-empty", children: recentLoading ? "Loading recent files..." : "No recent files yet." })) }), recentNextCursor ? (_jsx("button", { type: "button", className: "vf-editor-upload-more", disabled: recentLoading, onClick: () => void refreshRecentMedia(recentNextCursor).catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to load more recent media.")), children: recentLoading ? "Loading..." : "Load more generated media" })) : null] })) : null] }), _jsxs("div", { className: "vf-editor-upload-footer", children: [_jsx("button", { type: "button", onClick: resetAndClose, children: "Cancel" }), _jsxs("div", { className: "vf-editor-upload-footer-confirm", children: [_jsxs("span", { children: [confirmAttachments.length, " file", confirmAttachments.length === 1 ? "" : "s", " selected"] }), _jsx("button", { type: "button", className: "vf-editor-upload-confirm", disabled: !confirmAttachments.length, onClick: () => {
2265
- props.onConfirm(confirmAttachments);
2266
- resetAndClose();
2267
- }, children: "Confirm" })] })] })] }) }), document.body);
2268
- }
2269
- export function TemplateEditorChat({ boot }) {
2270
- const isBrainstormSurface = boot.template.templateId === "chat-brainstorm";
2271
- const showPersistentSidebar = isBrainstormSurface && boot.template.persistentHistorySidebar === true;
2272
- const storageKeys = useMemo(() => makeStorageKeys(boot.template.templateId), [boot.template.templateId]);
2273
- const chatStorage = useMemo(() => getChatStorage(), []);
2274
- const fileInputRef = useRef(null);
2275
- const viewportRef = useRef(null);
2276
- const shouldStickToChatBottomRef = useRef(true);
2277
- const forceChatBottomOnNextRenderRef = useRef(false);
2278
- const previousActiveThreadIdRef = useRef(null);
2279
- const activeSendRef = useRef(null);
2280
- const activeJobPollersRef = useRef(new Map());
2281
- const jobPollGateRef = useRef({ nextAvailableAt: 0 });
2282
- const threadsRef = useRef([]);
2283
- const loadedThreadDetailsRef = useRef(new Set());
2284
- const [threads, setThreads] = useState([]);
2285
- const [activeThreadId, setActiveThreadId] = useState(null);
2286
- const [loadingThreadId, setLoadingThreadId] = useState(null);
2287
- const [openThreadMenu, setOpenThreadMenu] = useState(null);
2288
- const [draft, setDraft] = useState("");
2289
- const [pendingAttachments, setPendingAttachments] = useState([]);
2290
- const [lastError, setLastError] = useState(null);
2291
- const [sendingThreadId, setSendingThreadId] = useState(null);
2292
- const [loadingStatus, setLoadingStatus] = useState(null);
2293
- const [hasHydrated, setHasHydrated] = useState(false);
2294
- const [isDropTarget, setIsDropTarget] = useState(false);
2295
- const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
2296
- const [isMobileChatExpanded, setIsMobileChatExpanded] = useState(false);
2297
- const [showHistoryPanel, setShowHistoryPanel] = useState(false);
2298
- const [showBranchesPanel, setShowBranchesPanel] = useState(false);
2299
- const [branches, setBranches] = useState(null);
2300
- const [branchesLoading, setBranchesLoading] = useState(false);
2301
- const [branchesError, setBranchesError] = useState(null);
2302
- const [isUploadDrawerOpen, setIsUploadDrawerOpen] = useState(false);
2303
- const [tracers, setTracers] = useState(() => {
2304
- const bridge = typeof window !== "undefined" ? window.__vidfarmEditorTracers : null;
2305
- if (bridge) {
2306
- return bridge.get();
2307
- }
2308
- if (typeof window !== "undefined") {
2309
- const fromUrl = readTracersFromLocation();
2310
- if (fromUrl.length) {
2311
- return fromUrl;
2312
- }
2313
- }
2314
- return normalizeTracerList(boot.template.tracers || []);
2315
- });
2316
- const threadListUrl = useMemo(() => {
2317
- if (!boot.threadsUrl) {
2318
- return null;
2319
- }
2320
- const url = new URL(boot.threadsUrl, window.location.origin);
2321
- if (!isBrainstormSurface) {
2322
- url.searchParams.set("template_id", boot.template.templateId);
2323
- }
2324
- url.searchParams.set("include_messages", "false");
2325
- url.searchParams.set("limit", isBrainstormSurface ? "200" : "50");
2326
- return url.toString();
2327
- }, [boot.template.templateId, boot.threadsUrl, isBrainstormSurface]);
2328
- const threadDetailUrl = (threadId) => (boot.threadsUrl
2329
- ? new URL(`${boot.threadsUrl.replace(/\/$/, "")}/${encodeURIComponent(threadId)}`, window.location.origin).toString()
2330
- : null);
2331
- useEffect(() => {
2332
- const hydratedThreads = hydrateThreads(chatStorage?.getItem(storageKeys.threads) ?? null, boot.template.templateId);
2333
- const savedActiveThread = chatStorage?.getItem(storageKeys.activeThread) ?? null;
2334
- const requestedThreadId = readThreadIdFromLocation();
2335
- const currentTracers = window.__vidfarmEditorTracers?.get() ?? readTracersFromLocation();
2336
- const nextHydratedThreads = requestedThreadId && !hydratedThreads.some((thread) => thread.id === requestedThreadId)
2337
- ? [
2338
- {
2339
- id: requestedThreadId,
2340
- templateId: boot.template.templateId,
2341
- title: "Loading chat",
2342
- createdAt: new Date().toISOString(),
2343
- updatedAt: new Date().toISOString(),
2344
- lastMessageAt: null,
2345
- messageCount: 0,
2346
- tracers: currentTracers,
2347
- hiddenFromView: false,
2348
- messages: []
2349
- },
2350
- ...hydratedThreads
2351
- ]
2352
- : hydratedThreads;
2353
- const visibleHydratedThreads = nextHydratedThreads.filter((thread) => !thread.hiddenFromView);
2354
- const newestMatchingThread = visibleHydratedThreads.find((thread) => threadMatchesTracers(thread, currentTracers));
2355
- threadsRef.current = nextHydratedThreads;
2356
- setThreads(nextHydratedThreads);
2357
- if (requestedThreadId
2358
- && visibleHydratedThreads.some((thread) => thread.id === requestedThreadId)) {
2359
- setActiveThreadId(requestedThreadId);
2360
- }
2361
- else if (savedActiveThread
2362
- && visibleHydratedThreads.some((thread) => thread.id === savedActiveThread)
2363
- && (!currentTracers.length || visibleHydratedThreads.some((thread) => thread.id === savedActiveThread && threadMatchesTracers(thread, currentTracers)))) {
2364
- setActiveThreadId(savedActiveThread);
2365
- }
2366
- else {
2367
- setActiveThreadId(newestMatchingThread?.id ?? visibleHydratedThreads[0]?.id ?? null);
2368
- }
2369
- setHasHydrated(true);
2370
- }, [boot.template.templateId, chatStorage, storageKeys]);
2371
- useEffect(() => {
2372
- if (!boot.vidfarmApiKey || !threadListUrl) {
2373
- return;
2374
- }
2375
- const abortController = new AbortController();
2376
- void (async () => {
2377
- try {
2378
- const remoteThreads = [];
2379
- let nextCursor = null;
2380
- const seenCursors = new Set();
2381
- do {
2382
- const pageUrl = new URL(threadListUrl);
2383
- if (nextCursor) {
2384
- pageUrl.searchParams.set("cursor", nextCursor);
2385
- }
2386
- const response = await fetch(pageUrl.toString(), {
2387
- method: "GET",
2388
- headers: buildAuthHeaders(boot.vidfarmApiKey),
2389
- signal: abortController.signal
2390
- });
2391
- if (!response.ok) {
2392
- return;
2393
- }
2394
- const payload = await response.json().catch(() => null);
2395
- if (Array.isArray(payload?.threads)) {
2396
- remoteThreads.push(...payload.threads
2397
- .map((thread) => hydrateThreadRecord(thread, boot.template.templateId))
2398
- .filter((thread) => Boolean(thread)));
2399
- }
2400
- const candidateCursor = typeof payload?.next_cursor === "string" && payload.next_cursor ? payload.next_cursor : null;
2401
- if (!candidateCursor || seenCursors.has(candidateCursor)) {
2402
- nextCursor = null;
2403
- }
2404
- else {
2405
- seenCursors.add(candidateCursor);
2406
- nextCursor = candidateCursor;
2407
- }
2408
- } while (nextCursor && !abortController.signal.aborted);
2409
- if (!remoteThreads.length) {
2410
- return;
2411
- }
2412
- setThreads((current) => {
2413
- const nextThreads = mergeThreadSets(current, remoteThreads);
2414
- threadsRef.current = nextThreads;
2415
- return nextThreads;
2416
- });
2417
- setActiveThreadId((current) => {
2418
- // Keep whatever the client is showing as long as it still exists
2419
- // somewhere in the merged set. Otherwise a freshly-created local
2420
- // "New chat" (not yet persisted to the server) gets swapped out for
2421
- // the server's most-recent thread on every remote fetch.
2422
- if (current && threadsRef.current.some((thread) => thread.id === current)) {
2423
- return current;
2424
- }
2425
- return remoteThreads[0]?.id ?? current;
2426
- });
2427
- }
2428
- catch (error) {
2429
- if (error instanceof DOMException && error.name === "AbortError") {
2430
- return;
2431
- }
2432
- }
2433
- })();
2434
- return () => {
2435
- abortController.abort();
2436
- };
2437
- }, [boot.template.templateId, boot.vidfarmApiKey, threadListUrl]);
2438
- useEffect(() => {
2439
- threadsRef.current = threads;
2440
- }, [threads]);
2441
- const [currentForkId, setCurrentForkId] = useState(() => forkIdFromLocation());
2442
- useEffect(() => {
2443
- const syncForkFromLocation = () => {
2444
- setCurrentForkId(forkIdFromLocation());
2445
- };
2446
- window.addEventListener("popstate", syncForkFromLocation);
2447
- return () => {
2448
- window.removeEventListener("popstate", syncForkFromLocation);
2449
- };
2450
- }, []);
2451
- async function loadBranches(options) {
2452
- if (!boot.vidfarmApiKey) {
2453
- setBranchesError("Sign in to see forks.");
2454
- return;
2455
- }
2456
- if (branchesLoading) {
2457
- return;
2458
- }
2459
- if (!options?.force && branches !== null) {
2460
- return;
2461
- }
2462
- setBranchesLoading(true);
2463
- setBranchesError(null);
2464
- try {
2465
- const response = await fetch("/api/v1/compositions", {
2466
- method: "GET",
2467
- headers: buildAuthHeaders(boot.vidfarmApiKey)
2468
- });
2469
- if (!response.ok) {
2470
- setBranchesError(response.status === 401 ? "Sign in to see forks." : `Failed to load forks (${response.status}).`);
2471
- setBranches([]);
2472
- return;
2473
- }
2474
- const payload = await response.json().catch(() => null);
2475
- const raw = Array.isArray(payload?.forks) ? payload.forks : [];
2476
- const summaries = raw
2477
- .map((entry) => {
2478
- if (!entry || typeof entry !== "object") {
2479
- return null;
2480
- }
2481
- const record = entry;
2482
- const forkId = typeof record.fork_id === "string" ? record.fork_id : null;
2483
- if (!forkId) {
2484
- return null;
2485
- }
2486
- return {
2487
- forkId,
2488
- templateId: typeof record.template_id === "string" ? record.template_id : null,
2489
- parentForkId: typeof record.parent_fork_id === "string" ? record.parent_fork_id : null,
2490
- parentVersion: typeof record.parent_version === "number" ? record.parent_version : null,
2491
- title: typeof record.title === "string" ? record.title : null,
2492
- visibility: typeof record.visibility === "string" ? record.visibility : null,
2493
- latestVersion: typeof record.latest_version === "number" ? record.latest_version : null,
2494
- ownerCustomerId: typeof record.owner_customer_id === "string" ? record.owner_customer_id : null,
2495
- createdAt: typeof record.created_at === "string" ? record.created_at : null,
2496
- updatedAt: typeof record.updated_at === "string" ? record.updated_at : null
2497
- };
2498
- })
2499
- .filter((entry) => Boolean(entry))
2500
- .filter((entry) => entry.templateId === boot.template.templateId)
2501
- .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
2502
- setBranches(summaries);
2503
- }
2504
- catch (error) {
2505
- setBranchesError("Could not load forks. Please try again.");
2506
- setBranches([]);
2507
- }
2508
- finally {
2509
- setBranchesLoading(false);
2510
- }
2511
- }
2512
- useEffect(() => {
2513
- if (showBranchesPanel && branches === null && !branchesLoading) {
2514
- void loadBranches();
2515
- }
2516
- }, [showBranchesPanel]);
2517
- useEffect(() => {
2518
- const bridge = window.__vidfarmEditorTracers;
2519
- if (bridge) {
2520
- setTracers(bridge.get());
2521
- return bridge.subscribe((nextTracers) => setTracers(nextTracers));
2522
- }
2523
- const syncFromLocation = () => {
2524
- const fromUrl = readTracersFromLocation();
2525
- setTracers(fromUrl.length ? fromUrl : normalizeTracerList(boot.template.tracers || []));
2526
- };
2527
- syncFromLocation();
2528
- window.addEventListener("popstate", syncFromLocation);
2529
- return () => {
2530
- window.removeEventListener("popstate", syncFromLocation);
2531
- };
2532
- }, [boot.template.tracers]);
2533
- // Debounce localStorage persistence so streamed deltas (fires on every
2534
- // token) don't JSON.stringify the whole thread bag per keystroke. Reads
2535
- // the latest state via refs so the closure never captures stale data.
2536
- const persistThreadsRef = useRef(threads);
2537
- const persistActiveThreadIdRef = useRef(activeThreadId);
2538
- useEffect(() => {
2539
- persistThreadsRef.current = threads;
2540
- persistActiveThreadIdRef.current = activeThreadId;
2541
- }, [threads, activeThreadId]);
2542
- useEffect(() => {
2543
- if (!hasHydrated) {
2544
- return;
2545
- }
2546
- const timer = window.setTimeout(() => {
2547
- try {
2548
- chatStorage?.setItem(storageKeys.threads, JSON.stringify(serializeThreads(persistThreadsRef.current, persistActiveThreadIdRef.current)));
2549
- }
2550
- catch (error) {
2551
- console.warn("Unable to persist editor chat threads.", error);
2552
- }
2553
- }, 300);
2554
- return () => window.clearTimeout(timer);
2555
- }, [activeThreadId, chatStorage, hasHydrated, storageKeys, threads]);
2556
- useEffect(() => {
2557
- if (!hasHydrated) {
2558
- return;
2559
- }
2560
- try {
2561
- if (activeThreadId) {
2562
- chatStorage?.setItem(storageKeys.activeThread, activeThreadId);
2563
- }
2564
- else {
2565
- chatStorage?.removeItem(storageKeys.activeThread);
2566
- }
2567
- }
2568
- catch (error) {
2569
- console.warn("Unable to persist active editor chat thread.", error);
2570
- }
2571
- }, [activeThreadId, chatStorage, hasHydrated, storageKeys]);
2572
- // Keep the URL's thread_id in sync with the client's active thread so a
2573
- // reload, back-nav, or shared link always resolves to the same conversation
2574
- // — even for a "New chat" the user hasn't sent a message in yet.
2575
- useEffect(() => {
2576
- if (!hasHydrated || typeof window === "undefined") {
2577
- return;
2578
- }
2579
- const url = new URL(window.location.href);
2580
- const currentThreadParam = url.searchParams.get("thread_id");
2581
- if (activeThreadId) {
2582
- if (currentThreadParam === activeThreadId)
2583
- return;
2584
- url.searchParams.set("thread_id", activeThreadId);
2585
- }
2586
- else {
2587
- if (!currentThreadParam)
2588
- return;
2589
- url.searchParams.delete("thread_id");
2590
- }
2591
- window.history.replaceState({}, "", url.toString());
2592
- }, [activeThreadId, hasHydrated]);
2593
- const activeThread = useMemo(() => threads.find((thread) => thread.id === activeThreadId) ?? null, [activeThreadId, threads]);
2594
- const openThreadMenuThread = useMemo(() => threads.find((thread) => thread.id === openThreadMenu?.threadId) ?? null, [openThreadMenu?.threadId, threads]);
2595
- useEffect(() => {
2596
- if (!hasHydrated || !boot.vidfarmApiKey || !activeThreadId) {
2597
- return;
2598
- }
2599
- const existingThread = threadsRef.current.find((thread) => thread.id === activeThreadId);
2600
- if (loadedThreadDetailsRef.current.has(activeThreadId)) {
2601
- return;
2602
- }
2603
- const requestedThreadId = readThreadIdFromLocation();
2604
- const isUnsentLocalThread = Boolean(existingThread
2605
- && (existingThread.messageCount ?? 0) === 0
2606
- && existingThread.messages.length === 0);
2607
- if (isUnsentLocalThread) {
2608
- loadedThreadDetailsRef.current.add(activeThreadId);
2609
- return;
2610
- }
2611
- const url = threadDetailUrl(activeThreadId);
2612
- if (!url) {
2613
- return;
2614
- }
2615
- const abortController = new AbortController();
2616
- const shouldShowLoadingState = !existingThread?.messages.length;
2617
- if (shouldShowLoadingState) {
2618
- setLoadingThreadId(activeThreadId);
2619
- }
2620
- void (async () => {
2621
- try {
2622
- const response = await fetch(url, {
2623
- method: "GET",
2624
- headers: buildAuthHeaders(boot.vidfarmApiKey),
2625
- signal: abortController.signal
2626
- });
2627
- if (!response.ok) {
2628
- const isLocalOnlyThread = Boolean(existingThread && activeThreadId !== requestedThreadId);
2629
- const isLikelyUnsentThread = Boolean(existingThread && (existingThread.messageCount ?? 0) === 0 && existingThread.messages.length === 0);
2630
- if (response.status === 404 && !isLocalOnlyThread && !isLikelyUnsentThread) {
2631
- setLastError(`Chat ID ${activeThreadId} was not found for this account.`);
2632
- }
2633
- loadedThreadDetailsRef.current.add(activeThreadId);
2634
- return;
2635
- }
2636
- const payload = await response.json().catch(() => null);
2637
- const remoteThread = payload?.thread ? hydrateThreadRecord(payload.thread, boot.template.templateId) : null;
2638
- if (!remoteThread) {
2639
- return;
2640
- }
2641
- loadedThreadDetailsRef.current.add(remoteThread.id);
2642
- setLastError(null);
2643
- setThreads((current) => {
2644
- const nextThreads = mergeThreadSets(current, [remoteThread]);
2645
- threadsRef.current = nextThreads;
2646
- return nextThreads;
2647
- });
2648
- }
2649
- catch (error) {
2650
- if (error instanceof DOMException && error.name === "AbortError") {
2651
- return;
2652
- }
2653
- }
2654
- finally {
2655
- if (!abortController.signal.aborted) {
2656
- setLoadingThreadId((current) => (current === activeThreadId ? null : current));
2657
- }
2658
- }
2659
- })();
2660
- return () => {
2661
- abortController.abort();
2662
- setLoadingThreadId((current) => (current === activeThreadId ? null : current));
2663
- };
2664
- }, [activeThreadId, boot.vidfarmApiKey, boot.threadsUrl, hasHydrated]);
2665
- useEffect(() => {
2666
- if (!hasHydrated || activeThread && !activeThread.hiddenFromView) {
2667
- return;
2668
- }
2669
- const newestMatchingThread = threads.find((thread) => !thread.hiddenFromView && threadMatchesTracers(thread, tracers));
2670
- setActiveThreadId(newestMatchingThread?.id ?? threads.find((thread) => !thread.hiddenFromView)?.id ?? null);
2671
- }, [activeThread, hasHydrated, threads, tracers]);
2672
- const requestTracers = useMemo(() => activeThread?.tracers.length ? activeThread.tracers : tracers, [activeThread?.tracers, tracers]);
2673
- const visibleThreads = useMemo(() => (threads.filter((thread) => !thread.hiddenFromView).sort((a, b) => {
2674
- const aMatches = threadMatchesTracers(a, tracers);
2675
- const bMatches = threadMatchesTracers(b, tracers);
2676
- if (aMatches !== bMatches) {
2677
- return aMatches ? -1 : 1;
2678
- }
2679
- return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
2680
- })), [threads, tracers]);
2681
- const defaultRequestTracer = requestTracers[0] ?? null;
2682
- const messages = activeThread?.messages ?? [];
2683
- const isAnyThreadSending = sendingThreadId !== null;
2684
- const isActiveThreadSending = Boolean(activeThreadId && sendingThreadId === activeThreadId);
2685
- const canSend = useMemo(() => (draft.trim() || pendingAttachments.length) && !isAnyThreadSending, [draft, pendingAttachments.length, isAnyThreadSending]);
2686
- useEffect(() => {
2687
- if (!hasHydrated || !boot.vidfarmApiKey || !activeThread || !activeThread.messages.length) {
2688
- return;
2689
- }
2690
- resumePersistedJobPolling(activeThread);
2691
- }, [activeThread, boot.vidfarmApiKey, hasHydrated]);
2692
- async function updateRemoteThreadState(thread, overrides) {
2693
- if (!boot.vidfarmApiKey || !boot.threadsUrl || !thread.messages.length) {
2694
- return;
2695
- }
2696
- const url = threadDetailUrl(thread.id);
2697
- if (!url) {
2698
- return;
2699
- }
2700
- await fetch(url, {
2701
- method: "PATCH",
2702
- headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
2703
- body: JSON.stringify({
2704
- template_id: boot.template.templateId,
2705
- title: overrides?.title ?? thread.title,
2706
- tracers: overrides?.tracers ?? thread.tracers,
2707
- hidden_from_view: overrides?.hiddenFromView ?? (thread.hiddenFromView === true),
2708
- archived: overrides?.archived
2709
- })
2710
- }).catch(() => { });
2711
- }
2712
- async function appendRemoteThreadExchange(input) {
2713
- if (!boot.vidfarmApiKey || !boot.threadsUrl || !input.messages.length) {
2714
- return;
2715
- }
2716
- const url = threadDetailUrl(input.threadId);
2717
- if (!url) {
2718
- return;
2719
- }
2720
- await fetch(`${url}/exchanges`, {
2721
- method: "POST",
2722
- headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
2723
- body: JSON.stringify({
2724
- template_id: input.templateId ?? boot.template.templateId,
2725
- title: input.title || "New chat",
2726
- tracers: input.tracers,
2727
- messages: input.messages.map((message) => ({
2728
- id: message.id,
2729
- role: message.role,
2730
- text: message.text,
2731
- attachments: message.attachments.map((attachment) => ({
2732
- id: attachment.id,
2733
- fileName: attachment.fileName,
2734
- contentType: attachment.contentType,
2735
- viewUrl: attachment.viewUrl
2736
- })),
2737
- toolExchanges: message.toolExchanges,
2738
- httpExchanges: message.httpExchanges?.map(sanitizeHttpExchange)
2739
- }))
2740
- })
2741
- }).catch(() => { });
2742
- }
2743
- async function deleteRemoteThreadState(threadId) {
2744
- if (!boot.vidfarmApiKey || !boot.threadsUrl) {
2745
- return;
2746
- }
2747
- const url = threadDetailUrl(threadId);
2748
- if (!url) {
2749
- return;
2750
- }
2751
- await fetch(url, {
2752
- method: "DELETE",
2753
- headers: buildAuthHeaders(boot.vidfarmApiKey)
2754
- }).catch(() => { });
2755
- }
2756
- function syncTracerUrl(nextTracers) {
2757
- const url = new URL(window.location.href);
2758
- if (nextTracers.length) {
2759
- url.searchParams.set("tracers", nextTracers.join(","));
2760
- }
2761
- else {
2762
- url.searchParams.delete("tracers");
2763
- }
2764
- window.history.replaceState({}, "", url.toString());
2765
- window.dispatchEvent(new CustomEvent("vidfarm:editor-tracers-changed", {
2766
- detail: {
2767
- tracers: nextTracers
2768
- }
2769
- }));
2770
- }
2771
- function applyTracerList(nextTracers) {
2772
- const normalized = normalizeTracerList(nextTracers);
2773
- const bridge = window.__vidfarmEditorTracers;
2774
- if (bridge) {
2775
- bridge.set(normalized);
2776
- return;
2777
- }
2778
- setTracers(normalized);
2779
- syncTracerUrl(normalized);
2780
- }
2781
- function createTracer() {
2782
- const bridge = window.__vidfarmEditorTracers;
2783
- if (bridge) {
2784
- return bridge.add();
2785
- }
2786
- const tracer = makeTracerId(boot.template.templateSlug, tracers);
2787
- applyTracerList([tracer, ...tracers]);
2788
- return tracer;
2789
- }
2790
- function addAiTracer(tracer) {
2791
- const existing = window.__vidfarmEditorTracers?.get() ?? tracers;
2792
- const normalized = tracer ? ensureTracerHasRandomSuffix(tracer, existing) : null;
2793
- const bridge = window.__vidfarmEditorTracers;
2794
- if (bridge) {
2795
- return bridge.add(normalized ?? undefined);
2796
- }
2797
- if (normalized) {
2798
- applyTracerList([normalized, ...existing.filter((entry) => entry !== normalized)]);
2799
- return normalized;
2800
- }
2801
- return createTracer();
2802
- }
2803
- function setDefaultRequestTracerSelection(tracer) {
2804
- const normalized = tracer.trim();
2805
- if (!normalized) {
2806
- return;
2807
- }
2808
- const bridge = window.__vidfarmEditorTracers;
2809
- if (bridge) {
2810
- bridge.add(normalized);
2811
- }
2812
- else if (!tracers.includes(normalized)) {
2813
- applyTracerList([normalized, ...tracers]);
2814
- }
2815
- if (activeThread) {
2816
- updateThread(activeThread.id, (thread) => ({
2817
- ...thread,
2818
- tracers: [normalized, ...thread.tracers.filter((entry) => entry !== normalized)],
2819
- updatedAt: new Date().toISOString()
2820
- }));
2821
- }
2822
- }
2823
- function removeTracer(tracer) {
2824
- const bridge = window.__vidfarmEditorTracers;
2825
- if (bridge) {
2826
- bridge.remove(tracer);
2827
- return;
2828
- }
2829
- const remaining = tracers.filter((entry) => entry !== tracer);
2830
- applyTracerList(remaining);
2831
- }
2832
- useEffect(() => {
2833
- syncTracerUrl(tracers);
2834
- }, [tracers]);
2835
- useEffect(() => {
2836
- const viewport = viewportRef.current;
2837
- if (!viewport) {
2838
- return;
2839
- }
2840
- const handleScroll = () => {
2841
- shouldStickToChatBottomRef.current = isScrolledNearBottom(viewport);
2842
- if (!shouldStickToChatBottomRef.current) {
2843
- forceChatBottomOnNextRenderRef.current = false;
2844
- }
2845
- };
2846
- handleScroll();
2847
- viewport.addEventListener("scroll", handleScroll, { passive: true });
2848
- return () => {
2849
- viewport.removeEventListener("scroll", handleScroll);
2850
- };
2851
- }, []);
2852
- useLayoutEffect(() => {
2853
- const viewport = viewportRef.current;
2854
- if (!viewport) {
2855
- return;
2856
- }
2857
- const activeThreadChanged = previousActiveThreadIdRef.current !== activeThreadId;
2858
- previousActiveThreadIdRef.current = activeThreadId;
2859
- if (activeThreadChanged || forceChatBottomOnNextRenderRef.current || shouldStickToChatBottomRef.current) {
2860
- viewport.scrollTo({ top: viewport.scrollHeight, behavior: "auto" });
2861
- shouldStickToChatBottomRef.current = true;
2862
- forceChatBottomOnNextRenderRef.current = false;
2863
- }
2864
- }, [messages, activeThreadId]);
2865
- useEffect(() => {
2866
- return () => {
2867
- activeSendRef.current?.abortController.abort();
2868
- activeSendRef.current = null;
2869
- for (const poller of activeJobPollersRef.current.values()) {
2870
- if (poller.timeoutId !== null) {
2871
- window.clearTimeout(poller.timeoutId);
2872
- }
2873
- poller.abortController?.abort();
2874
- }
2875
- activeJobPollersRef.current.clear();
2876
- };
2877
- }, []);
2878
- useEffect(() => {
2879
- if (!openThreadMenu) {
2880
- return;
2881
- }
2882
- const closeMenu = () => setOpenThreadMenu(null);
2883
- document.addEventListener("click", closeMenu);
2884
- window.addEventListener("resize", closeMenu);
2885
- window.addEventListener("scroll", closeMenu, true);
2886
- return () => {
2887
- document.removeEventListener("click", closeMenu);
2888
- window.removeEventListener("resize", closeMenu);
2889
- window.removeEventListener("scroll", closeMenu, true);
2890
- };
2891
- }, [openThreadMenu]);
2892
- useEffect(() => {
2893
- const resumePolling = () => {
2894
- if (document.visibilityState !== "visible" || !navigator.onLine) {
2895
- return;
2896
- }
2897
- const now = Date.now();
2898
- for (const poller of activeJobPollersRef.current.values()) {
2899
- if (poller.inFlight) {
2900
- continue;
2901
- }
2902
- if (poller.nextPollAt !== null && poller.nextPollAt <= now && poller.runPoll) {
2903
- scheduleNextJobPollForPoller(poller, poller.runPoll, poller.nextPollAt - now);
2904
- }
2905
- }
2906
- };
2907
- window.addEventListener("online", resumePolling);
2908
- document.addEventListener("visibilitychange", resumePolling);
2909
- return () => {
2910
- window.removeEventListener("online", resumePolling);
2911
- document.removeEventListener("visibilitychange", resumePolling);
2912
- };
2913
- }, []);
2914
- function ensureActiveThread(seedText) {
2915
- if (activeThread) {
2916
- return activeThread.id;
2917
- }
2918
- const now = new Date().toISOString();
2919
- const threadId = makeThreadId();
2920
- const nextThread = {
2921
- id: threadId,
2922
- templateId: boot.template.templateId,
2923
- title: formatThreadTitle(seedText || ""),
2924
- createdAt: now,
2925
- updatedAt: now,
2926
- lastMessageAt: null,
2927
- messageCount: 0,
2928
- tracers: normalizeTracerList(tracers),
2929
- messages: []
2930
- };
2931
- threadsRef.current = [nextThread, ...threadsRef.current];
2932
- setThreads((current) => [nextThread, ...current]);
2933
- setActiveThreadId(threadId);
2934
- return threadId;
2935
- }
2936
- function createNewThread(options) {
2937
- const now = new Date().toISOString();
2938
- const threadTracers = normalizeTracerList(options?.tracers ?? tracers);
2939
- const threadId = makeThreadId();
2940
- const nextThread = {
2941
- id: threadId,
2942
- templateId: boot.template.templateId,
2943
- title: options?.title || "New chat",
2944
- createdAt: now,
2945
- updatedAt: now,
2946
- lastMessageAt: null,
2947
- messageCount: 0,
2948
- tracers: threadTracers,
2949
- messages: []
2950
- };
2951
- threadsRef.current = [nextThread, ...threadsRef.current];
2952
- setThreads((current) => [nextThread, ...current]);
2953
- if (options?.activate !== false) {
2954
- setActiveThreadId(threadId);
2955
- setDraft("");
2956
- setPendingAttachments([]);
2957
- setLastError(null);
2958
- }
2959
- setOpenThreadMenu(null);
2960
- return threadId;
2961
- }
2962
- function createNewThreadFromClick() {
2963
- createNewThread();
2964
- setShowHistoryPanel(false);
2965
- }
2966
- function deleteThread(threadId) {
2967
- const thread = threads.find((entry) => entry.id === threadId);
2968
- if (!thread) {
2969
- return;
2970
- }
2971
- if (!window.confirm(`Delete conversation "${thread.title}"?`)) {
2972
- return;
2973
- }
2974
- const remainingThreads = threads.filter((entry) => entry.id !== threadId);
2975
- threadsRef.current = threadsRef.current.filter((entry) => entry.id !== threadId);
2976
- for (const [pollerKey, poller] of activeJobPollersRef.current.entries()) {
2977
- if (poller.threadId === threadId) {
2978
- stopJobPoller(pollerKey);
2979
- }
2980
- }
2981
- setThreads(remainingThreads);
2982
- if (activeThreadId === threadId) {
2983
- setActiveThreadId(remainingThreads[0]?.id ?? null);
2984
- }
2985
- setLastError(null);
2986
- setOpenThreadMenu(null);
2987
- void deleteRemoteThreadState(threadId);
2988
- }
2989
- function updateThread(threadId, updater) {
2990
- const nextThreads = sortThreadsByUpdatedAt(threadsRef.current.map((thread) => (thread.id === threadId ? updater(thread) : thread)));
2991
- threadsRef.current = nextThreads;
2992
- setThreads(nextThreads);
2993
- }
2994
- function attachTracersToThread(threadId, nextTracers) {
2995
- const normalized = normalizeTracerList(nextTracers);
2996
- updateThread(threadId, (thread) => ({
2997
- ...thread,
2998
- tracers: mergeTracerLists(thread.tracers, normalized),
2999
- updatedAt: new Date().toISOString()
3000
- }));
3001
- const thread = threadsRef.current.find((entry) => entry.id === threadId);
3002
- if (thread) {
3003
- void updateRemoteThreadState({
3004
- ...thread,
3005
- tracers: mergeTracerLists(thread.tracers, normalized),
3006
- updatedAt: new Date().toISOString()
3007
- }, {
3008
- tracers: mergeTracerLists(thread.tracers, normalized)
3009
- });
3010
- }
3011
- }
3012
- function findThreadIdForTracer(tracer, exceptThreadId) {
3013
- const normalized = tracer.trim();
3014
- if (!normalized) {
3015
- return null;
3016
- }
3017
- return threadsRef.current.find((thread) => (!thread.hiddenFromView && thread.id !== exceptThreadId && thread.tracers.includes(normalized)))?.id ?? null;
3018
- }
3019
- function ensureThreadForTracer(tracer, options) {
3020
- const existing = findThreadIdForTracer(tracer);
3021
- if (existing) {
3022
- return existing;
3023
- }
3024
- return createNewThread({
3025
- tracers: [tracer],
3026
- title: options?.title || `Tracer ${tracer}`,
3027
- activate: options?.activate ?? false
3028
- });
3029
- }
3030
- function userRequestedSeparateTracerThreads(text) {
3031
- const normalized = text.toLowerCase();
3032
- return /(?:own|separate|different|new)\s+(?:tracer\s+and\s+)?chat\s+threads?/.test(normalized)
3033
- || /chat\s+threads?.{0,80}(?:own|separate|different|new)\s+tracers?/.test(normalized)
3034
- || /each.{0,80}(?:own|separate|different|new).{0,80}chat\s+threads?/.test(normalized)
3035
- || /(?:own|separate|different|new)\s+tracers?/.test(normalized)
3036
- || /(?:each|every|per|one\s+per).{0,80}(?:own|separate|different|new).{0,80}tracers?/.test(normalized)
3037
- || /(?:own|separate|different|new)\s+tracers?.{0,80}(?:each|every|per|outputs?|pieces?|posts?|jobs?|generations?)/.test(normalized)
3038
- || /(?:outputs?|pieces?|posts?|jobs?|generations?).{0,80}(?:own|separate|different|new)\s+tracers?/.test(normalized);
3039
- }
3040
- function mirrorJobToTracerThread(input) {
3041
- const existingThreadId = findThreadIdForTracer(input.tracer, input.sourceThreadId);
3042
- if (!existingThreadId && !input.shouldCreateThread) {
3043
- return null;
3044
- }
3045
- const targetThreadId = existingThreadId ?? ensureThreadForTracer(input.tracer, {
3046
- title: `Tracer ${input.tracer}`,
3047
- activate: false
3048
- });
3049
- if (!targetThreadId || targetThreadId === input.sourceThreadId) {
3050
- return null;
3051
- }
3052
- const messageId = makeMessageId("assistant");
3053
- const now = new Date().toISOString();
3054
- const messageText = input.job.status === "succeeded" || input.job.status === "failed" || input.job.status === "cancelled"
3055
- ? buildJobCompletionMessage(input.job)
3056
- : buildJobQueuedMessage(input.job);
3057
- const statusText = input.job.status === "succeeded" || input.job.status === "failed" || input.job.status === "cancelled"
3058
- ? null
3059
- : `Job ${input.job.job_id} is being tracked. ${getJobPollCadenceText()}`;
3060
- const mirroredMessage = {
3061
- id: messageId,
3062
- role: "assistant",
3063
- text: messageText,
3064
- attachments: [],
3065
- httpExchanges: input.httpExchange ? [input.httpExchange] : [],
3066
- statusText,
3067
- isPending: false
3068
- };
3069
- updateThread(targetThreadId, (thread) => ({
3070
- ...thread,
3071
- updatedAt: now,
3072
- messages: [
3073
- ...thread.messages,
3074
- mirroredMessage
3075
- ]
3076
- }));
3077
- const targetThread = threadsRef.current.find((thread) => thread.id === targetThreadId);
3078
- if (targetThread) {
3079
- void appendRemoteThreadExchange({
3080
- threadId: targetThreadId,
3081
- templateId: targetThread.templateId,
3082
- title: targetThread.title,
3083
- tracers: targetThread.tracers,
3084
- messages: [mirroredMessage]
3085
- });
3086
- }
3087
- return { threadId: targetThreadId, messageId };
3088
- }
3089
- function editThreadTracers(threadId) {
3090
- const thread = threads.find((entry) => entry.id === threadId);
3091
- if (!thread) {
3092
- return;
3093
- }
3094
- const value = window.prompt("Thread tracers, comma separated", thread.tracers.join(", "));
3095
- if (value === null) {
3096
- return;
3097
- }
3098
- const nextTracers = normalizeTracerList(value.split(","));
3099
- updateThread(threadId, (current) => ({
3100
- ...current,
3101
- tracers: nextTracers,
3102
- updatedAt: new Date().toISOString()
3103
- }));
3104
- void updateRemoteThreadState(thread, { tracers: nextTracers });
3105
- setOpenThreadMenu(null);
3106
- }
3107
- function archiveThread(threadId) {
3108
- const thread = threads.find((entry) => entry.id === threadId);
3109
- if (!thread) {
3110
- return;
3111
- }
3112
- const confirmed = window.confirm(`Archive conversation "${thread.title}"? Its tracer links will be prefixed with archived_.`);
3113
- if (!confirmed) {
3114
- return;
3115
- }
3116
- const nextTracers = normalizeTracerList(thread.tracers.map(archiveTracerValue));
3117
- updateThread(threadId, (current) => ({
3118
- ...current,
3119
- tracers: nextTracers,
3120
- updatedAt: new Date().toISOString()
3121
- }));
3122
- void updateRemoteThreadState(thread, { tracers: nextTracers, archived: true });
3123
- setOpenThreadMenu(null);
3124
- }
3125
- function removeThreadFromView(threadId) {
3126
- const thread = threads.find((entry) => entry.id === threadId);
3127
- if (!thread) {
3128
- return;
3129
- }
3130
- const confirmed = window.confirm(`Remove conversation "${thread.title}" from this frontend view? The thread and tracer links stay available; Delete removes the thread completely.`);
3131
- if (!confirmed) {
3132
- return;
3133
- }
3134
- for (const [pollerKey, poller] of activeJobPollersRef.current.entries()) {
3135
- if (poller.threadId === threadId) {
3136
- stopJobPoller(pollerKey);
3137
- }
3138
- }
3139
- updateThread(threadId, (current) => ({
3140
- ...current,
3141
- hiddenFromView: true,
3142
- updatedAt: new Date().toISOString()
3143
- }));
3144
- if (activeThreadId === threadId) {
3145
- const nextThread = visibleThreads.find((entry) => entry.id !== threadId);
3146
- setActiveThreadId(nextThread?.id ?? null);
3147
- }
3148
- setOpenThreadMenu(null);
3149
- setLastError(null);
3150
- void updateRemoteThreadState(thread, { hiddenFromView: true });
3151
- }
3152
- async function copyThreadId(threadId) {
3153
- try {
3154
- if (navigator.clipboard?.writeText) {
3155
- await navigator.clipboard.writeText(threadId);
3156
- }
3157
- else {
3158
- const textarea = document.createElement("textarea");
3159
- textarea.value = threadId;
3160
- textarea.setAttribute("readonly", "");
3161
- textarea.style.position = "fixed";
3162
- textarea.style.opacity = "0";
3163
- document.body.appendChild(textarea);
3164
- textarea.select();
3165
- document.execCommand("copy");
3166
- document.body.removeChild(textarea);
3167
- }
3168
- setLastError(null);
3169
- }
3170
- catch {
3171
- setLastError("Unable to copy chat ID.");
3172
- }
3173
- finally {
3174
- setOpenThreadMenu(null);
3175
- }
3176
- }
3177
- function toggleThreadMenu(threadId, button) {
3178
- if (openThreadMenu?.threadId === threadId) {
3179
- setOpenThreadMenu(null);
3180
- return;
3181
- }
3182
- const rect = button.getBoundingClientRect();
3183
- const menuWidth = 148;
3184
- setOpenThreadMenu({
3185
- threadId,
3186
- top: rect.bottom + 6,
3187
- left: Math.max(8, Math.min(window.innerWidth - menuWidth - 8, rect.right - menuWidth))
3188
- });
3189
- }
3190
- function stopJobPoller(pollerKey) {
3191
- const poller = activeJobPollersRef.current.get(pollerKey);
3192
- if (!poller) {
3193
- return;
3194
- }
3195
- if (poller.timeoutId !== null) {
3196
- window.clearTimeout(poller.timeoutId);
3197
- }
3198
- poller.abortController?.abort();
3199
- activeJobPollersRef.current.delete(pollerKey);
3200
- }
3201
- function scheduleNextJobPoll(pollerKey, runPoll, delayMs) {
3202
- const poller = activeJobPollersRef.current.get(pollerKey);
3203
- if (!poller) {
3204
- return;
3205
- }
3206
- scheduleNextJobPollForPoller(poller, runPoll, delayMs);
3207
- }
3208
- function scheduleNextJobPollForPoller(poller, runPoll, delayMs) {
3209
- if (poller.timeoutId !== null) {
3210
- window.clearTimeout(poller.timeoutId);
3211
- }
3212
- const safeDelayMs = Math.max(JOB_POLL_MIN_TIMER_MS, delayMs);
3213
- poller.nextPollAt = Date.now() + safeDelayMs;
3214
- poller.timeoutId = window.setTimeout(() => {
3215
- poller.timeoutId = null;
3216
- void runPoll();
3217
- }, safeDelayMs);
3218
- }
3219
- function getJobPollIntervalMs() {
3220
- return activeJobPollersRef.current.size >= JOB_POLL_BUSY_THRESHOLD
3221
- ? JOB_POLL_BUSY_INTERVAL_MS
3222
- : JOB_POLL_INTERVAL_MS;
3223
- }
3224
- function formatJobPollInterval(ms = getJobPollIntervalMs()) {
3225
- if (ms >= 60_000) {
3226
- const minutes = Math.round(ms / 60_000);
3227
- return `${minutes} min${minutes === 1 ? "" : "s"}`;
3228
- }
3229
- return `${Math.round(ms / 1_000)}s`;
3230
- }
3231
- function getJobPollCadenceText(ms = getJobPollIntervalMs()) {
3232
- return activeJobPollersRef.current.size >= JOB_POLL_BUSY_THRESHOLD
3233
- ? `Many renders are active, so the next check is in ${formatJobPollInterval(ms)}.`
3234
- : `Checking again gently every ${formatJobPollInterval(ms)}.`;
3235
- }
3236
- function rescheduleActiveJobPollersForLoad() {
3237
- const intervalMs = getJobPollIntervalMs();
3238
- if (intervalMs === JOB_POLL_INTERVAL_MS) {
3239
- return;
3240
- }
3241
- const now = Date.now();
3242
- for (const poller of activeJobPollersRef.current.values()) {
3243
- if (poller.inFlight || !poller.runPoll) {
3244
- continue;
3245
- }
3246
- const remainingMs = poller.nextPollAt === null ? 0 : poller.nextPollAt - now;
3247
- if (remainingMs < intervalMs) {
3248
- scheduleNextJobPollForPoller(poller, poller.runPoll, intervalMs);
3249
- }
3250
- }
3251
- }
3252
- function reserveJobPollSlot(now) {
3253
- const slotAt = Math.max(now, jobPollGateRef.current.nextAvailableAt);
3254
- jobPollGateRef.current.nextAvailableAt = slotAt + JOB_POLL_GLOBAL_MIN_GAP_MS;
3255
- return slotAt - now;
3256
- }
3257
- function scheduleJobPolling(input) {
3258
- const pollerKey = makeJobPollerKey(input.threadId, input.assistantMessageId, input.jobId);
3259
- if (activeJobPollersRef.current.has(pollerKey)) {
3260
- return;
3261
- }
3262
- activeJobPollersRef.current.set(pollerKey, {
3263
- timeoutId: null,
3264
- abortController: null,
3265
- runPoll: null,
3266
- inFlight: false,
3267
- nextPollAt: null,
3268
- lastPollAt: null,
3269
- attempts: 0,
3270
- threadId: input.threadId
3271
- });
3272
- const runPoll = async () => {
3273
- const poller = activeJobPollersRef.current.get(pollerKey);
3274
- if (!poller) {
3275
- return;
3276
- }
3277
- if (poller.inFlight) {
3278
- return;
3279
- }
3280
- const now = Date.now();
3281
- const pollIntervalMs = getJobPollIntervalMs();
3282
- if (poller.lastPollAt !== null && now - poller.lastPollAt < pollIntervalMs) {
3283
- scheduleNextJobPoll(pollerKey, runPoll, pollIntervalMs - (now - poller.lastPollAt));
3284
- return;
3285
- }
3286
- if (poller.attempts >= JOB_POLL_MAX_ATTEMPTS) {
3287
- updateThread(input.threadId, (thread) => ({
3288
- ...thread,
3289
- updatedAt: new Date().toISOString(),
3290
- messages: thread.messages.map((message) => (message.id === input.assistantMessageId
3291
- ? {
3292
- ...message,
3293
- statusText: "Background polling stopped after multiple checks. Open the job route when you want to refresh again."
3294
- }
3295
- : message))
3296
- }));
3297
- stopJobPoller(pollerKey);
3298
- return;
3299
- }
3300
- if (document.visibilityState !== "visible" || !navigator.onLine) {
3301
- scheduleNextJobPoll(pollerKey, runPoll, pollIntervalMs);
3302
- return;
3303
- }
3304
- const globalDelayMs = reserveJobPollSlot(Date.now());
3305
- if (globalDelayMs > 0) {
3306
- scheduleNextJobPoll(pollerKey, runPoll, globalDelayMs);
3307
- return;
3308
- }
3309
- poller.inFlight = true;
3310
- poller.lastPollAt = now;
3311
- poller.nextPollAt = null;
3312
- poller.attempts += 1;
3313
- poller.abortController = new AbortController();
3314
- try {
3315
- updateThread(input.threadId, (thread) => ({
3316
- ...thread,
3317
- updatedAt: new Date().toISOString(),
3318
- messages: thread.messages.map((message) => (message.id === input.assistantMessageId
3319
- ? {
3320
- ...message,
3321
- statusText: `Job ${input.jobId} is being tracked. ${getJobPollCadenceText(pollIntervalMs)}`
3322
- }
3323
- : message))
3324
- }));
3325
- const url = input.primitiveId || isPrimitiveRestUrl(input.sourceRequestUrl)
3326
- ? `/api/v1/primitives/jobs/${encodeURIComponent(input.jobId)}`
3327
- : `/api/v1/templates/${encodeURIComponent(boot.template.templateId)}/jobs/${encodeURIComponent(input.jobId)}`;
3328
- const response = await fetch(url, {
3329
- method: "GET",
3330
- headers: buildAuthHeaders(boot.vidfarmApiKey),
3331
- signal: poller.abortController.signal
3332
- });
3333
- const responseBody = await response.json().catch(() => null);
3334
- const responseHeaders = Object.fromEntries(response.headers.entries());
3335
- const payload = tryReadJobStatusPayload(responseBody);
3336
- updateThread(input.threadId, (thread) => ({
3337
- ...thread,
3338
- updatedAt: new Date().toISOString(),
3339
- messages: thread.messages.map((message) => {
3340
- if (message.id !== input.assistantMessageId) {
3341
- return message;
3342
- }
3343
- const nextHttpExchanges = upsertAutomaticPollExchange(message.httpExchanges, sanitizeHttpExchange({
3344
- explanation: `Automatic ${formatJobPollInterval(pollIntervalMs)} job status check for ${input.jobId}`,
3345
- pollJobId: input.jobId,
3346
- pollMode: "automatic",
3347
- request: {
3348
- method: "GET",
3349
- url,
3350
- headers: Object.fromEntries(buildAuthHeaders(boot.vidfarmApiKey).entries())
3351
- },
3352
- result: {
3353
- status: response.status,
3354
- statusText: response.statusText,
3355
- headers: responseHeaders,
3356
- body: responseBody
3357
- }
3358
- }));
3359
- const nextStatusText = payload
3360
- ? payload.status === "succeeded"
3361
- ? `Render ${payload.job_id} succeeded.`
3362
- : payload.status === "failed" || payload.status === "cancelled"
3363
- ? `Render ${payload.job_id} ${payload.status}.${payload.error?.message ? ` ${payload.error.message}` : ""}`
3364
- : `Render ${payload.job_id} is ${payload.status}. Next check in ${formatJobPollInterval(pollIntervalMs)}.`
3365
- : `Render ${input.jobId} checked. Next check in ${formatJobPollInterval(pollIntervalMs)}.`;
3366
- return {
3367
- ...message,
3368
- httpExchanges: nextHttpExchanges,
3369
- statusText: nextStatusText,
3370
- text: payload && (payload.status === "succeeded" || payload.status === "failed" || payload.status === "cancelled")
3371
- ? appendDistinctMessageText(message.text, buildJobCompletionMessage(payload))
3372
- : message.text
3373
- };
3374
- })
3375
- }));
3376
- if (payload && (payload.status === "succeeded" || payload.status === "failed" || payload.status === "cancelled")) {
3377
- stopJobPoller(pollerKey);
3378
- return;
3379
- }
3380
- }
3381
- catch (error) {
3382
- if (error instanceof DOMException && error.name === "AbortError") {
3383
- return;
3384
- }
3385
- updateThread(input.threadId, (thread) => ({
3386
- ...thread,
3387
- updatedAt: new Date().toISOString(),
3388
- messages: thread.messages.map((message) => (message.id === input.assistantMessageId
3389
- ? {
3390
- ...message,
3391
- statusText: `Automatic polling paused after an error. It will retry in ${formatJobPollInterval()}.`
3392
- }
3393
- : message))
3394
- }));
3395
- }
3396
- finally {
3397
- const latestPoller = activeJobPollersRef.current.get(pollerKey);
3398
- if (!latestPoller) {
3399
- return;
3400
- }
3401
- latestPoller.inFlight = false;
3402
- latestPoller.abortController = null;
3403
- if (latestPoller.attempts < JOB_POLL_MAX_ATTEMPTS
3404
- && latestPoller.timeoutId === null
3405
- && activeJobPollersRef.current.has(pollerKey)) {
3406
- scheduleNextJobPoll(pollerKey, runPoll, getJobPollIntervalMs());
3407
- }
3408
- }
3409
- };
3410
- const initialPoller = activeJobPollersRef.current.get(pollerKey);
3411
- if (initialPoller) {
3412
- initialPoller.runPoll = runPoll;
3413
- }
3414
- const poller = activeJobPollersRef.current.get(pollerKey);
3415
- if (poller) {
3416
- scheduleNextJobPoll(pollerKey, runPoll, input.initialDelayMs ?? getJobPollIntervalMs());
3417
- rescheduleActiveJobPollersForLoad();
3418
- }
3419
- }
3420
- function resumePersistedJobPolling(thread) {
3421
- for (const message of thread.messages) {
3422
- if (message.role !== "assistant" || !message.httpExchanges?.length) {
3423
- continue;
3424
- }
3425
- for (const exchange of message.httpExchanges) {
3426
- if (exchange.request.method !== "POST") {
3427
- continue;
3428
- }
3429
- const payload = tryReadJobStatusPayload(exchange.result.body);
3430
- const pollerKey = makeJobPollerKey(thread.id, message.id, payload?.job_id ?? "");
3431
- if (!payload || isTerminalJobStatus(payload.status) || activeJobPollersRef.current.has(pollerKey)) {
3432
- continue;
3433
- }
3434
- const hasTerminalFollowup = message.httpExchanges.some((candidate) => {
3435
- const candidatePayload = tryReadJobStatusPayload(candidate.result.body);
3436
- return candidatePayload?.job_id === payload.job_id && isTerminalJobStatus(candidatePayload.status);
3437
- });
3438
- if (hasTerminalFollowup) {
3439
- continue;
3440
- }
3441
- scheduleJobPolling({
3442
- threadId: thread.id,
3443
- assistantMessageId: message.id,
3444
- jobId: payload.job_id,
3445
- tracer: payload.tracer,
3446
- initialStatus: payload.status,
3447
- primitiveId: payload.primitive_id,
3448
- sourceRequestUrl: exchange.request.url,
3449
- initialDelayMs: JOB_POLL_MIN_TIMER_MS
3450
- });
3451
- }
3452
- }
3453
- }
3454
- async function appendFiles(files) {
3455
- if (!files.length) {
3456
- return;
3457
- }
3458
- setLastError(null);
3459
- const uploadedAttachments = [];
3460
- let lastUploadError = null;
3461
- for (const file of files) {
3462
- try {
3463
- const uploaded = await uploadTemporaryFile({
3464
- apiKey: boot.vidfarmApiKey,
3465
- file,
3466
- folderPath: CHAT_TEMPORARY_UPLOAD_FOLDER
3467
- });
3468
- const attachment = await addInlinePreviewForChatUpload({
3469
- attachment: uploaded,
3470
- file,
3471
- currentInlineBytes: estimateInlineAttachmentBytes([...pendingAttachments, ...uploadedAttachments])
3472
- });
3473
- uploadedAttachments.push(attachment);
3474
- }
3475
- catch (error) {
3476
- lastUploadError = error instanceof Error ? error.message : "Unable to upload attachment.";
3477
- }
3478
- }
3479
- if (lastUploadError && uploadedAttachments.length === 0) {
3480
- setLastError(lastUploadError);
3481
- }
3482
- if (uploadedAttachments.length) {
3483
- setPendingAttachments((current) => [...current, ...uploadedAttachments]);
3484
- if (lastUploadError) {
3485
- setLastError(lastUploadError);
3486
- }
3487
- }
3488
- }
3489
- async function handlePickFiles(event) {
3490
- const files = Array.from(event.target.files ?? []);
3491
- await appendFiles(files);
3492
- event.target.value = "";
3493
- }
3494
- async function handleComposerPaste(event) {
3495
- const files = Array.from(event.clipboardData.files ?? []).filter((file) => file.size > 0);
3496
- if (!files.length) {
3497
- return;
3498
- }
3499
- event.preventDefault();
3500
- await appendFiles(files);
3501
- }
3502
- async function handleComposerDrop(event) {
3503
- event.preventDefault();
3504
- setIsDropTarget(false);
3505
- const files = Array.from(event.dataTransfer.files ?? []).filter((file) => file.size > 0);
3506
- if (!files.length) {
3507
- return;
3508
- }
3509
- await appendFiles(files);
3510
- }
3511
- // "create me a video of…" / "replicate this URL…" — the discover-chat-only
3512
- // create_video tool returns the user's intent; we run the SAME pipeline the
3513
- // devcli `create`/`replicate` commands run, here in the browser, updating this
3514
- // assistant message as it progresses and appending an "Open in editor" link
3515
- // when the new video is ready. Long-running but visible; all same-origin API
3516
- // calls (the source download happens server-side in the ingest primitive).
3517
- async function runCreateVideoFlow(threadId, messageId, intentRaw) {
3518
- const intent = intentRaw ?? {};
3519
- const apiKey = boot.vidfarmApiKey;
3520
- const origin = window.location.origin;
3521
- const jsonHeaders = buildAuthHeaders(apiKey, "application/json");
3522
- const getHeaders = buildAuthHeaders(apiKey);
3523
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3524
- const patch = (status, appendText) => {
3525
- updateThread(threadId, (thread) => ({
3526
- ...thread,
3527
- updatedAt: new Date().toISOString(),
3528
- messages: thread.messages.map((message) => (message.id === messageId
3529
- ? {
3530
- ...message,
3531
- isPending: false,
3532
- statusText: status ?? message.statusText,
3533
- text: appendText ? appendDistinctMessageText(message.text, appendText) : message.text
3534
- }
3535
- : message))
3536
- }));
3537
- };
3538
- const readJson = async (res) => {
3539
- const text = await res.text();
3540
- try {
3541
- return text ? JSON.parse(text) : null;
3542
- }
3543
- catch {
3544
- return null;
3545
- }
3546
- };
3547
- const pollInspirationReady = async (inspirationId) => {
3548
- const deadline = Date.now() + 5 * 60 * 1000;
3549
- for (;;) {
3550
- const res = await fetch(new URL(`/api/v1/videos/${encodeURIComponent(inspirationId)}`, origin), { headers: getHeaders });
3551
- const json = await readJson(res);
3552
- const status = String(json?.status ?? "");
3553
- if (status === "ready")
3554
- return json;
3555
- if (status === "failed")
3556
- throw new Error(`source video download failed${json?.error ? `: ${json.error}` : ""}.`);
3557
- if (Date.now() > deadline)
3558
- throw new Error("source video download timed out.");
3559
- await sleep(5000);
3560
- }
3561
- };
3562
- const resolveJobMediaUrl = (job) => {
3563
- const readStr = (value) => (typeof value === "string" && value.trim() ? value.trim() : null);
3564
- const result = (job?.result && typeof job.result === "object") ? job.result : {};
3565
- const output = (result.output && typeof result.output === "object") ? result.output : result;
3566
- const direct = readStr(output?.primary_file_url) ?? readStr(output?.video?.file_url) ?? readStr(output?.image?.file_url) ?? readStr(output?.render?.output_url);
3567
- if (direct)
3568
- return direct;
3569
- if (Array.isArray(output?.files)) {
3570
- for (const file of output.files) {
3571
- const url = readStr(typeof file === "string" ? file : (file?.file_url ?? file?.url));
3572
- if (url)
3573
- return url;
3574
- }
3575
- }
3576
- if (Array.isArray(job?.artifacts)) {
3577
- for (const artifact of job.artifacts) {
3578
- const url = readStr(artifact?.public_url);
3579
- if (url)
3580
- return url;
3581
- }
3582
- }
3583
- return null;
3584
- };
3585
- const pollJobMedia = async (jobId) => {
3586
- const deadline = Date.now() + 8 * 60 * 1000;
3587
- for (;;) {
3588
- await sleep(5000);
3589
- const res = await fetch(new URL(`/api/v1/user/me/jobs/${encodeURIComponent(jobId)}`, origin), { headers: getHeaders });
3590
- const json = await readJson(res);
3591
- const status = String(json?.status ?? "");
3592
- const url = resolveJobMediaUrl(json);
3593
- if (url)
3594
- return url;
3595
- if (status === "failed" || status === "cancelled")
3596
- throw new Error(`generation ${status}.`);
3597
- if (Date.now() > deadline)
3598
- throw new Error("generation timed out.");
3599
- }
3600
- };
3601
- // Wrap a generated MP4 into a new template via the upload-ingest path (the
3602
- // same route `inspiration-add <file>` uses). The generated media lives on
3603
- // public-read storage (bucket CORS allows a browser GET), so we can pull the
3604
- // blob and re-upload it.
3605
- const ingestGeneratedVideo = async (mediaUrl, title) => {
3606
- const blob = await (await fetch(mediaUrl)).blob();
3607
- const presignRes = await fetch(new URL("/discover/templates/upload/presign", origin), {
3608
- method: "POST",
3609
- headers: jsonHeaders,
3610
- body: JSON.stringify({ file_name: "generated.mp4", content_type: "video/mp4", size_bytes: blob.size })
3611
- });
3612
- const presign = await readJson(presignRes);
3613
- if (!presignRes.ok)
3614
- throw new Error(presign?.error || `upload presign failed (${presignRes.status})`);
3615
- let storageKey = presign?.storage_key;
3616
- let uploadedName = presign?.file_name || "generated.mp4";
3617
- if (presign?.transport === "presigned" && presign?.upload?.url) {
3618
- const put = await fetch(presign.upload.url, { method: presign.upload.method || "PUT", headers: presign.upload.headers || {}, body: blob });
3619
- if (!put.ok)
3620
- throw new Error(`upload failed (${put.status})`);
3621
- }
3622
- else {
3623
- const form = new FormData();
3624
- form.append("file", blob, "generated.mp4");
3625
- const up = await fetch(new URL(presign?.upload?.url || "/discover/templates/upload", origin), { method: "POST", headers: getHeaders, body: form });
3626
- const upJson = await readJson(up);
3627
- if (!up.ok || !upJson?.storage_key)
3628
- throw new Error(upJson?.error || `upload failed (${up.status})`);
3629
- storageKey = upJson.storage_key;
3630
- uploadedName = upJson.file_name || "generated.mp4";
3631
- }
3632
- if (!storageKey)
3633
- throw new Error("upload did not return a storage key.");
3634
- // origin:"raw" — a prompt-generated video is the user's own new project,
3635
- // not an imported inspiration; the editor must not auto-prompt Decompose.
3636
- const addRes = await fetch(new URL("/discover/templates", origin), {
3637
- method: "POST",
3638
- headers: jsonHeaders,
3639
- body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title, origin: "raw" })
3640
- });
3641
- const add = await readJson(addRes);
3642
- if (!addRes.ok)
3643
- throw new Error(add?.error || `ingest failed (${addRes.status})`);
3644
- return add;
3645
- };
3646
- try {
3647
- if (!apiKey) {
3648
- patch(null, "⚠️ You need to be logged in to create a video.");
3649
- return;
3650
- }
3651
- const mode = intent.mode === "replicate" ? "replicate" : intent.mode === "generate" ? "generate" : null;
3652
- let inspiration;
3653
- let userPrompt;
3654
- if (mode === "replicate") {
3655
- const sourceUrl = (intent.source_url ?? "").trim();
3656
- if (!/^https?:\/\//i.test(sourceUrl)) {
3657
- patch(null, "⚠️ I need a video URL (TikTok/YouTube/Instagram/X) to replicate.");
3658
- return;
3659
- }
3660
- userPrompt = (intent.instructions ?? "").trim() || null;
3661
- patch("Fetching the source video…");
3662
- const addRes = await fetch(new URL("/discover/templates", origin), { method: "POST", headers: jsonHeaders, body: JSON.stringify({ source_url: sourceUrl }) });
3663
- const add = await readJson(addRes);
3664
- if (!addRes.ok)
3665
- throw new Error(add?.error || `couldn't fetch that URL (${addRes.status})`);
3666
- inspiration = add?.status === "ready" ? add : await pollInspirationReady(add?.inspiration_id);
3667
- }
3668
- else if (mode === "generate") {
3669
- const prompt = (intent.prompt ?? "").trim();
3670
- if (!prompt) {
3671
- patch(null, "⚠️ Tell me what the video should be about and I'll create it.");
3672
- return;
3673
- }
3674
- userPrompt = prompt;
3675
- patch("Generating your video… (this can take a couple of minutes)");
3676
- const genRes = await fetch(new URL("/api/v1/primitives/videos/generate", origin), {
3677
- method: "POST",
3678
- headers: jsonHeaders,
3679
- body: JSON.stringify({ tracer: `chat-create-${Date.now().toString(36)}`, payload: { prompt } })
3680
- });
3681
- const gen = await readJson(genRes);
3682
- if (!genRes.ok)
3683
- throw new Error(gen?.error || `couldn't start generation (${genRes.status})`);
3684
- const mediaUrl = await pollJobMedia(gen?.job_id);
3685
- patch("Building an editable template from the generated video…");
3686
- const add = await ingestGeneratedVideo(mediaUrl, prompt.slice(0, 80));
3687
- inspiration = add?.status === "ready" ? add : await pollInspirationReady(add?.inspiration_id);
3688
- }
3689
- else {
3690
- patch(null, "⚠️ I couldn't tell whether to generate a new video or replicate a URL — try rephrasing.");
3691
- return;
3692
- }
3693
- const templateId = inspiration?.template_id;
3694
- const inspirationId = inspiration?.inspiration_id;
3695
- if (!templateId || !inspirationId)
3696
- throw new Error("the template wasn't created from the video.");
3697
- patch(mode === "replicate" ? "Recreating the scenes… (about a minute)" : "Analyzing into an editable timeline…");
3698
- const decRes = await fetch(new URL(`/api/v1/inspirations/${encodeURIComponent(inspirationId)}/decompose`, origin), {
3699
- method: "POST",
3700
- headers: jsonHeaders,
3701
- body: JSON.stringify({ user_prompt: userPrompt })
3702
- });
3703
- if (!decRes.ok) {
3704
- const dec = await readJson(decRes);
3705
- throw new Error(dec?.error || `scene analysis failed (${decRes.status})`);
3706
- }
3707
- patch("Finalizing your editable copy…");
3708
- const forkRes = await fetch(new URL("/api/v1/compositions", origin), { method: "POST", headers: jsonHeaders, body: JSON.stringify({ template_id: templateId }) });
3709
- const fork = await readJson(forkRes);
3710
- if (!forkRes.ok)
3711
- throw new Error(fork?.error || `couldn't create an editable copy (${forkRes.status})`);
3712
- const forkId = fork?.fork_id;
3713
- const editorUrl = `${origin}/editor/${encodeURIComponent(templateId)}${forkId ? `/fork/${encodeURIComponent(forkId)}` : ""}`;
3714
- patch("Your video is ready.", `✅ Your video is ready — [Open it in the editor](${editorUrl}).`);
3715
- }
3716
- catch (error) {
3717
- patch(null, `⚠️ I couldn't finish creating the video: ${error instanceof Error ? error.message : String(error)}`);
3718
- }
3719
- }
3720
- async function handleSend() {
3721
- if (!canSend) {
3722
- return;
3723
- }
3724
- forceChatBottomOnNextRenderRef.current = true;
3725
- const threadId = ensureActiveThread(draft);
3726
- const now = new Date().toISOString();
3727
- const threadForSend = threadsRef.current.find((thread) => thread.id === threadId);
3728
- const userMessage = {
3729
- id: makeMessageId("user"),
3730
- role: "user",
3731
- text: draft.trim(),
3732
- attachments: pendingAttachments
3733
- };
3734
- const assistantMessage = {
3735
- id: makeMessageId("assistant"),
3736
- role: "assistant",
3737
- text: "",
3738
- attachments: [],
3739
- statusText: "Waiting for AI response…",
3740
- isPending: true
3741
- };
3742
- const existingMessages = threadForSend?.messages ?? [];
3743
- const historyForTransport = compactTransportHistory([...existingMessages, userMessage]);
3744
- updateThread(threadId, (thread) => ({
3745
- ...thread,
3746
- title: thread.messages.length === 0 ? formatThreadTitle(userMessage.text) : thread.title,
3747
- updatedAt: now,
3748
- messages: [...thread.messages, userMessage, assistantMessage]
3749
- }));
3750
- setDraft("");
3751
- setPendingAttachments([]);
3752
- setLastError(null);
3753
- setSendingThreadId(threadId);
3754
- setLoadingStatus("Waiting for AI response…");
3755
- const abortController = new AbortController();
3756
- activeSendRef.current = {
3757
- abortController,
3758
- threadId,
3759
- assistantMessageId: assistantMessage.id
3760
- };
3761
- try {
3762
- const requestBody = JSON.stringify({
3763
- messages: toTransportMessages(historyForTransport),
3764
- thread_id: threadId,
3765
- thread_template_id: threadForSend?.templateId ?? boot.template.templateId,
3766
- thread_title: existingMessages.length === 0 ? formatThreadTitle(userMessage.text) : (threadForSend?.title ?? formatThreadTitle(userMessage.text)),
3767
- thread_tracers: requestTracers,
3768
- client_context: buildBrowserDateContext(),
3769
- user_message: {
3770
- id: userMessage.id,
3771
- role: userMessage.role,
3772
- text: userMessage.text,
3773
- attachments: userMessage.attachments.map((attachment) => ({
3774
- id: attachment.id,
3775
- fileName: attachment.fileName,
3776
- contentType: attachment.contentType,
3777
- viewUrl: attachment.viewUrl
3778
- }))
3779
- },
3780
- assistant_message: {
3781
- id: assistantMessage.id
3782
- },
3783
- template: {
3784
- ...boot.template,
3785
- tracers: requestTracers,
3786
- defaultRequestTracer,
3787
- activeTracer: defaultRequestTracer
3788
- },
3789
- cached_context: {
3790
- ...boot.cachedContext,
3791
- ...readCachedScheduleChannels(),
3792
- ...readSelectedLibraryPosts()
3793
- }
3794
- });
3795
- debugLog("chat.send.attempt", {
3796
- thread_id: threadId,
3797
- template_id: boot.template.templateId,
3798
- message_count: historyForTransport.length,
3799
- attachment_count: userMessage.attachments.length,
3800
- text_length: userMessage.text.length
3801
- });
3802
- const sendStartedAt = Date.now();
3803
- const response = await fetch(boot.apiUrl || "/api/v1/editor-chat", {
3804
- method: "POST",
3805
- headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
3806
- signal: abortController.signal,
3807
- body: requestBody
3808
- });
3809
- if (!response.ok) {
3810
- const payload = await response.json().catch(() => ({}));
3811
- debugError("chat.send.rejected", {
3812
- thread_id: threadId,
3813
- status: response.status,
3814
- error: typeof payload?.error === "string" ? payload.error : null
3815
- });
3816
- throw new Error(typeof payload?.error === "string" ? payload.error : `Chat failed with status ${response.status}.`);
3817
- }
3818
- debugLog("chat.send.stream_open", {
3819
- thread_id: threadId,
3820
- latency_ms: Date.now() - sendStartedAt
3821
- });
3822
- const pendingToolCalls = new Map();
3823
- const shouldSplitJobsIntoThreads = userRequestedSeparateTracerThreads(userMessage.text);
3824
- await readStreamText(response, {
3825
- onDelta: (chunk) => {
3826
- setLoadingStatus("AI is responding…");
3827
- updateThread(threadId, (thread) => ({
3828
- ...thread,
3829
- updatedAt: new Date().toISOString(),
3830
- messages: thread.messages.map((message) => (message.id === assistantMessage.id
3831
- ? {
3832
- ...message,
3833
- text: `${message.text}${chunk}`,
3834
- statusText: "AI is responding…",
3835
- isPending: false
3836
- }
3837
- : message))
3838
- }));
3839
- },
3840
- onStatus: (status) => {
3841
- setLoadingStatus(status);
3842
- updateThread(threadId, (thread) => ({
3843
- ...thread,
3844
- messages: thread.messages.map((message) => (message.id === assistantMessage.id
3845
- ? {
3846
- ...message,
3847
- statusText: status,
3848
- isPending: !message.text
3849
- }
3850
- : message))
3851
- }));
3852
- },
3853
- onToolCall: (toolCall) => {
3854
- pendingToolCalls.set(toolCall.toolCallId, {
3855
- toolName: toolCall.toolName,
3856
- args: toolCall.args
3857
- });
3858
- updateThread(threadId, (thread) => ({
3859
- ...thread,
3860
- updatedAt: new Date().toISOString(),
3861
- messages: thread.messages.map((message) => (message.id === assistantMessage.id
3862
- ? {
3863
- ...message,
3864
- toolExchanges: [
3865
- ...(message.toolExchanges ?? []),
3866
- {
3867
- toolCallId: toolCall.toolCallId,
3868
- toolName: toolCall.toolName,
3869
- args: sanitizeChatPayload(toolCall.args),
3870
- status: "pending"
3871
- }
3872
- ]
3873
- }
3874
- : message))
3875
- }));
3876
- },
3877
- onToolResult: (toolResult) => {
3878
- const sanitizedToolResult = sanitizeToolResult(toolResult.result);
3879
- const pendingCall = pendingToolCalls.get(toolResult.toolCallId);
3880
- if (toolResult.toolName === "create_video") {
3881
- // Fire-and-forget: run the ingest→decompose→fork pipeline in the
3882
- // browser, streaming progress into this assistant message.
3883
- void runCreateVideoFlow(threadId, assistantMessage.id, toolResult.result);
3884
- }
3885
- if (toolResult.toolName === "frontend_action") {
3886
- const actionResult = toolResult.result;
3887
- if (actionResult?.action === "add_tracer") {
3888
- addAiTracer(actionResult.tracer ?? undefined);
3889
- }
3890
- else if (actionResult?.action === "remove_tracer" && actionResult.tracer) {
3891
- removeTracer(actionResult.tracer);
3892
- }
3893
- else if (actionResult?.action === "activate_tracer" && actionResult.tracer) {
3894
- setDefaultRequestTracerSelection(actionResult.tracer);
3895
- }
3896
- else if (actionResult?.action === "create_thread") {
3897
- const threadTracer = actionResult.tracer ? addAiTracer(actionResult.tracer) : createTracer();
3898
- createNewThread({
3899
- tracers: threadTracer ? [threadTracer] : [],
3900
- title: threadTracer ? `Tracer ${threadTracer}` : "New chat",
3901
- activate: false
3902
- });
3903
- }
3904
- }
3905
- const toolResultRecordForMirror = toolResult.result;
3906
- const jobStatusPayloadForMirror = tryReadJobStatusPayload(toolResultRecordForMirror?.response?.body);
3907
- const mirrorHttpExchange = toolResultRecordForMirror?.request && toolResultRecordForMirror?.response
3908
- ? sanitizeHttpExchange({
3909
- explanation: toolResultRecordForMirror.explanation ?? null,
3910
- request: toolResultRecordForMirror.request,
3911
- result: toolResultRecordForMirror.response
3912
- })
3913
- : undefined;
3914
- const mirroredJobTarget = toolResultRecordForMirror?.request?.method === "POST" && jobStatusPayloadForMirror?.tracer
3915
- ? mirrorJobToTracerThread({
3916
- sourceThreadId: threadId,
3917
- tracer: jobStatusPayloadForMirror.tracer,
3918
- job: jobStatusPayloadForMirror,
3919
- httpExchange: mirrorHttpExchange,
3920
- shouldCreateThread: shouldSplitJobsIntoThreads
3921
- })
3922
- : null;
3923
- if (mirroredJobTarget
3924
- && jobStatusPayloadForMirror
3925
- && !isTerminalJobStatus(jobStatusPayloadForMirror.status)) {
3926
- scheduleJobPolling({
3927
- threadId: mirroredJobTarget.threadId,
3928
- assistantMessageId: mirroredJobTarget.messageId,
3929
- jobId: jobStatusPayloadForMirror.job_id,
3930
- tracer: jobStatusPayloadForMirror.tracer,
3931
- initialStatus: jobStatusPayloadForMirror.status,
3932
- primitiveId: jobStatusPayloadForMirror.primitive_id,
3933
- sourceRequestUrl: toolResultRecordForMirror?.request?.url
3934
- });
3935
- }
3936
- updateThread(threadId, (thread) => ({
3937
- ...thread,
3938
- updatedAt: new Date().toISOString(),
3939
- messages: thread.messages.map((message) => {
3940
- if (message.id !== assistantMessage.id) {
3941
- return message;
3942
- }
3943
- const nextToolExchanges = (message.toolExchanges ?? []).map((exchange) => (exchange.toolCallId === toolResult.toolCallId
3944
- ? {
3945
- ...exchange,
3946
- result: sanitizedToolResult,
3947
- status: (typeof toolResult.result?.error === "string" ? "error" : "complete")
3948
- }
3949
- : exchange));
3950
- const toolResultRecord = toolResult.result;
3951
- const jobStatusPayload = tryReadJobStatusPayload(toolResultRecord?.response?.body);
3952
- const nextHttpExchanges = toolResultRecord?.request && toolResultRecord?.response
3953
- ? [
3954
- ...(message.httpExchanges ?? []),
3955
- sanitizeHttpExchange({
3956
- explanation: toolResultRecord.explanation ?? null,
3957
- request: toolResultRecord.request,
3958
- result: toolResultRecord.response
3959
- })
3960
- ]
3961
- : message.httpExchanges;
3962
- if (pendingCall) {
3963
- pendingToolCalls.delete(toolResult.toolCallId);
3964
- }
3965
- if (toolResultRecord?.request?.method === "POST" && jobStatusPayload?.tracer) {
3966
- const bridge = window.__vidfarmEditorTracers;
3967
- if (bridge) {
3968
- bridge.add(jobStatusPayload.tracer);
3969
- }
3970
- else {
3971
- applyTracerList([jobStatusPayload.tracer, ...tracers.filter((entry) => entry !== jobStatusPayload.tracer)]);
3972
- }
3973
- attachTracersToThread(threadId, [jobStatusPayload.tracer]);
3974
- }
3975
- if (jobStatusPayload
3976
- && !mirroredJobTarget
3977
- && !isTerminalJobStatus(jobStatusPayload.status)) {
3978
- scheduleJobPolling({
3979
- threadId,
3980
- assistantMessageId: assistantMessage.id,
3981
- jobId: jobStatusPayload.job_id,
3982
- tracer: jobStatusPayload.tracer,
3983
- initialStatus: jobStatusPayload.status,
3984
- primitiveId: jobStatusPayload.primitive_id,
3985
- sourceRequestUrl: toolResultRecord?.request?.url
3986
- });
3987
- }
3988
- return {
3989
- ...message,
3990
- toolExchanges: nextToolExchanges,
3991
- httpExchanges: nextHttpExchanges,
3992
- statusText: jobStatusPayload
3993
- && !isTerminalJobStatus(jobStatusPayload.status)
3994
- ? `Job ${jobStatusPayload.job_id} is being tracked. ${getJobPollCadenceText()}`
3995
- : message.statusText,
3996
- isPending: false,
3997
- text: jobStatusPayload && toolResultRecord?.request?.method === "POST" && toolResultRecord?.response?.status === 202
3998
- ? appendDistinctMessageText(message.text, buildJobQueuedMessage(jobStatusPayload))
3999
- : jobStatusPayload && isTerminalJobStatus(jobStatusPayload.status)
4000
- ? appendDistinctMessageText(message.text, buildJobCompletionMessage(jobStatusPayload))
4001
- : jobStatusPayload
4002
- ? appendDistinctMessageText(message.text, buildJobCompletionMessage(jobStatusPayload))
4003
- : message.text
4004
- };
4005
- })
4006
- }));
4007
- },
4008
- onError: (errorMessage) => {
4009
- debugError("chat.stream.error", { thread_id: threadId, message: errorMessage });
4010
- setLastError(errorMessage);
4011
- setLoadingStatus(null);
4012
- updateThread(threadId, (thread) => ({
4013
- ...thread,
4014
- messages: thread.messages.filter((message) => message.id !== assistantMessage.id)
4015
- }));
4016
- }
4017
- });
4018
- debugLog("chat.stream.completed", {
4019
- thread_id: threadId,
4020
- duration_ms: Date.now() - sendStartedAt
4021
- });
4022
- updateThread(threadId, (thread) => ({
4023
- ...thread,
4024
- updatedAt: new Date().toISOString(),
4025
- messages: thread.messages.map((message) => (message.id === assistantMessage.id
4026
- ? {
4027
- ...message,
4028
- statusText: message.statusText?.includes("being tracked") ? message.statusText : null,
4029
- isPending: false
4030
- }
4031
- : message))
4032
- }));
4033
- }
4034
- catch (error) {
4035
- if (error instanceof DOMException && error.name === "AbortError") {
4036
- debugWarn("chat.send.aborted", { thread_id: threadId });
4037
- setLastError(null);
4038
- setLoadingStatus(null);
4039
- updateThread(threadId, (thread) => ({
4040
- ...thread,
4041
- updatedAt: new Date().toISOString(),
4042
- messages: thread.messages.map((message) => {
4043
- if (message.id !== assistantMessage.id) {
4044
- return message;
4045
- }
4046
- const hasContent = Boolean(message.text.trim()
4047
- || message.toolExchanges?.length
4048
- || message.httpExchanges?.length);
4049
- return hasContent
4050
- ? {
4051
- ...message,
4052
- statusText: "Stopped.",
4053
- isPending: false
4054
- }
4055
- : {
4056
- ...message,
4057
- text: "Stopped.",
4058
- statusText: null,
4059
- isPending: false
4060
- };
4061
- })
4062
- }));
4063
- return;
4064
- }
4065
- debugError("chat.send.failed", {
4066
- thread_id: threadId,
4067
- message: error instanceof Error ? error.message : String(error)
4068
- });
4069
- setLastError(error instanceof Error ? error.message : "Unable to send chat message.");
4070
- setLoadingStatus(null);
4071
- updateThread(threadId, (thread) => ({
4072
- ...thread,
4073
- messages: thread.messages.map((message) => (message.id === assistantMessage.id
4074
- ? {
4075
- ...message,
4076
- text: error instanceof Error ? `Error: ${error.message}` : "Error: Unable to send chat message.",
4077
- statusText: null,
4078
- isPending: false
4079
- }
4080
- : message))
4081
- }));
4082
- }
4083
- finally {
4084
- if (activeSendRef.current?.assistantMessageId === assistantMessage.id) {
4085
- activeSendRef.current = null;
4086
- }
4087
- setSendingThreadId((current) => (current === threadId ? null : current));
4088
- setLoadingStatus(null);
4089
- }
4090
- }
4091
- function stopActiveSend() {
4092
- setLoadingStatus("Stopping…");
4093
- const activeSend = activeSendRef.current;
4094
- if (activeSend) {
4095
- updateThread(activeSend.threadId, (thread) => ({
4096
- ...thread,
4097
- updatedAt: new Date().toISOString(),
4098
- messages: thread.messages.map((message) => (message.id === activeSend.assistantMessageId
4099
- ? {
4100
- ...message,
4101
- statusText: "Stopping…"
4102
- }
4103
- : message))
4104
- }));
4105
- }
4106
- activeSendRef.current?.abortController.abort();
4107
- }
4108
- useEffect(() => {
4109
- return () => {
4110
- activeSendRef.current?.abortController.abort();
4111
- for (const pollerKey of activeJobPollersRef.current.keys()) {
4112
- stopJobPoller(pollerKey);
4113
- }
4114
- // Flush the debounced persistence so a mid-stream unmount does not
4115
- // lose the last few deltas before the 300ms timer fires.
4116
- try {
4117
- chatStorage?.setItem(storageKeys.threads, JSON.stringify(serializeThreads(persistThreadsRef.current, persistActiveThreadIdRef.current)));
4118
- }
4119
- catch { }
4120
- };
4121
- }, [chatStorage, storageKeys]);
4122
- function handleComposerKeyDown(event) {
4123
- if (event.key === "Enter" && !event.shiftKey) {
4124
- event.preventDefault();
4125
- void handleSend();
4126
- }
4127
- }
4128
- const threadListContent = (_jsx("div", { className: `vf-editor-chat-thread-list${isBrainstormSurface ? " vf-editor-chat-thread-list-sidebar" : ""}`, role: "tablist", "aria-label": "Conversation threads", children: visibleThreads.length ? visibleThreads.map((thread) => (_jsxs("div", { role: "tab", "aria-selected": thread.id === activeThreadId, className: "vf-editor-chat-thread-chip", "data-active": thread.id === activeThreadId ? "true" : "false", children: [_jsxs("button", { type: "button", className: "vf-editor-chat-thread-chip-main", onClick: () => {
4129
- setActiveThreadId(thread.id);
4130
- setLastError(null);
4131
- setOpenThreadMenu(null);
4132
- setShowHistoryPanel(false);
4133
- }, children: [_jsx("span", { className: "vf-editor-chat-thread-chip-title", children: thread.title }), thread.tracers.length ? (_jsxs("span", { className: "vf-editor-chat-thread-chip-tracers", children: [thread.tracers.slice(0, 2).join(", "), thread.tracers.length > 2 ? ` +${thread.tracers.length - 2}` : ""] })) : null] }), _jsx("div", { className: "vf-editor-chat-thread-menu-wrap", children: _jsx("button", { type: "button", className: "vf-editor-chat-thread-chip-menu", "aria-label": `Actions for ${thread.title}`, onClick: (event) => {
4134
- event.stopPropagation();
4135
- toggleThreadMenu(thread.id, event.currentTarget);
4136
- }, children: "..." }) })] }, thread.id))) : (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "Start a chat to create your first conversation thread." })) }));
4137
- return (_jsxs("div", { className: "vf-editor-chat-shell", "data-mobile-open": isMobileChatOpen ? "true" : "false", "data-mobile-expanded": isMobileChatExpanded ? "true" : "false", "data-layout": isBrainstormSurface ? "brainstorm" : "default", children: [_jsx("button", { type: "button", className: "vf-editor-chat-mobile-fab", "aria-label": "Open chat", "aria-expanded": isMobileChatOpen, onClick: () => {
4138
- setIsMobileChatExpanded(false);
4139
- setIsMobileChatOpen(true);
4140
- }, children: _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M20 11.5a7.5 7.5 0 0 1-9.8 7.1L4 20l1.5-5.3A7.5 7.5 0 1 1 20 11.5Z" }) }) }), lastError ? (_jsx("div", { className: "vf-editor-chat-error", role: "status", children: lastError })) : null, _jsxs("div", { className: `${isBrainstormSurface ? "vf-editor-chat-body vf-editor-chat-brainstorm-layout" : "vf-editor-chat-body"}${showPersistentSidebar ? " has-sidebar" : ""}`, "data-history-open": showHistoryPanel || showBranchesPanel ? "true" : "false", children: [showPersistentSidebar ? (_jsxs("aside", { className: "vf-editor-chat-history-pane", children: [_jsx("div", { className: "vf-editor-chat-history-header", children: _jsxs("div", { className: "vf-editor-chat-history-header-main", children: [_jsxs("a", { className: "vf-editor-chat-back-button", href: "/discover", "aria-label": "Back to Discover", children: [_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) }), "Discover"] }), _jsx("div", { className: "vf-editor-chat-history-kicker", children: "Chat history" })] }) }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread vf-editor-chat-new-thread-sidebar", onClick: createNewThreadFromClick, children: "New chat" }), threadListContent] })) : null, _jsxs("div", { className: "vf-editor-chat-thread", children: [_jsxs("div", { className: "vf-editor-chat-header", children: [_jsxs("div", { className: "vf-editor-chat-header-row", children: [_jsx("a", { className: "vf-editor-chat-back-button vf-editor-chat-back-button--header", href: "/discover", "aria-label": "Back to Discover", children: _jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) }) }), _jsx("button", { type: "button", className: "vf-editor-chat-history-icon", "aria-label": showHistoryPanel ? "Back to chat" : "Open chat history", "aria-pressed": showHistoryPanel, onClick: () => {
4141
- setShowHistoryPanel((prev) => !prev);
4142
- setShowBranchesPanel(false);
4143
- }, children: showHistoryPanel ? (_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) })) : (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "10", cy: "10", r: "7.2" }), _jsx("path", { d: "M10 6v4l2.6 1.6" })] })) }), _jsx("button", { type: "button", className: "vf-editor-chat-history-icon vf-editor-chat-branches-icon", "aria-label": showBranchesPanel ? "Back to chat" : "Open template forks", "aria-pressed": showBranchesPanel, onClick: () => {
4144
- setShowBranchesPanel((prev) => !prev);
4145
- setShowHistoryPanel(false);
4146
- }, children: showBranchesPanel ? (_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) })) : (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "5.5", cy: "4.5", r: "1.9" }), _jsx("circle", { cx: "5.5", cy: "15.5", r: "1.9" }), _jsx("circle", { cx: "14.5", cy: "8", r: "1.9" }), _jsx("path", { d: "M5.5 6.4v7.2" }), _jsx("path", { d: "M5.5 13.6c0-3.2 2.4-3.6 4.4-3.9 2-.3 4.6-.5 4.6-3.7" })] })) }), _jsxs("div", { className: "vf-editor-chat-header-titles", children: [_jsx("div", { className: "vf-editor-chat-header-title", children: "Vidfarm" }), _jsx("div", { className: "vf-editor-chat-header-subtitle", children: showHistoryPanel ? "Chat history" : showBranchesPanel ? "Template forks" : boot.template.templateTitle })] }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread", onClick: createNewThreadFromClick, children: "New chat" }), _jsx("button", { type: "button", className: "vf-editor-chat-expand", "aria-label": isMobileChatExpanded ? "Shrink chat" : "Expand chat", "aria-pressed": isMobileChatExpanded, onClick: () => setIsMobileChatExpanded((prev) => !prev), children: isMobileChatExpanded ? (_jsxs("svg", { viewBox: "0 0 20 20", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M16.5 8.5h-5v-5" }), _jsx("path", { d: "M11.5 8.5 17 3" }), _jsx("path", { d: "M3.5 11.5h5v5" }), _jsx("path", { d: "M8.5 11.5 3 17" })] })) : (_jsxs("svg", { viewBox: "0 0 20 20", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M11.5 3.5h5v5" }), _jsx("path", { d: "M16.5 3.5 11 9" }), _jsx("path", { d: "M8.5 16.5h-5v-5" }), _jsx("path", { d: "M3.5 16.5 9 11" })] })) }), _jsx("button", { type: "button", className: "vf-editor-chat-minimize", "aria-label": "Minimize chat", onClick: () => setIsMobileChatOpen(false), children: "\u00D7" })] }), openThreadMenu && openThreadMenuThread ? createPortal(_jsxs("div", { className: "vf-editor-chat-thread-menu", role: "menu", style: {
4147
- top: openThreadMenu.top,
4148
- left: openThreadMenu.left
4149
- }, onClick: (event) => event.stopPropagation(), children: [_jsx("button", { type: "button", onClick: () => copyThreadId(openThreadMenuThread.id), children: "Copy Chat ID" }), _jsx("button", { type: "button", onClick: () => editThreadTracers(openThreadMenuThread.id), children: "Edit tracers" }), _jsx("button", { type: "button", onClick: () => removeThreadFromView(openThreadMenuThread.id), children: "Remove from view" }), _jsx("button", { type: "button", onClick: () => archiveThread(openThreadMenuThread.id), children: "Archive thread" }), _jsx("button", { type: "button", "data-danger": "true", onClick: () => deleteThread(openThreadMenuThread.id), children: "Delete" })] }), document.body) : null] }), _jsx("div", { className: "vf-editor-chat-viewport", ref: viewportRef, children: showHistoryPanel ? (_jsx("div", { className: "vf-editor-chat-history-panel", children: threadListContent })) : showBranchesPanel ? (_jsxs("div", { className: "vf-editor-chat-history-panel vf-editor-chat-branches-panel", children: [_jsxs("div", { className: "vf-editor-chat-branches-header", children: [_jsxs("div", { className: "vf-editor-chat-branches-header-main", children: [_jsx("div", { className: "vf-editor-chat-branches-kicker", children: "Forks" }), _jsxs("div", { className: "vf-editor-chat-branches-title", children: [branches?.length ?? 0, " fork", (branches?.length ?? 0) === 1 ? "" : "s", " of this template"] })] }), _jsx("button", { type: "button", className: "vf-editor-chat-branches-refresh", onClick: () => { void loadBranches({ force: true }); }, disabled: branchesLoading, "aria-label": "Refresh forks", children: branchesLoading ? "Loading…" : "Refresh" })] }), branchesError ? (_jsx("div", { className: "vf-editor-chat-thread-empty", role: "status", children: branchesError })) : branchesLoading && !branches ? (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "Loading forks\u2026" })) : branches && branches.length ? (_jsx("div", { className: "vf-editor-chat-thread-list", children: branches.map((fork) => {
4150
- const isCurrent = currentForkId === fork.forkId;
4151
- const forkUrl = `/editor/${encodeURIComponent(boot.template.templateId)}/fork/${encodeURIComponent(fork.forkId)}`;
4152
- const label = fork.title && fork.title.trim() ? fork.title : fork.forkId;
4153
- const parentSuffix = fork.parentForkId ? ` · forked from ${fork.parentForkId.slice(0, 12)}${fork.parentVersion ? `#v${fork.parentVersion}` : ""}` : "";
4154
- const versionSuffix = fork.latestVersion && fork.latestVersion > 0 ? ` · v${fork.latestVersion}` : "";
4155
- return (_jsx("div", { role: "listitem", className: "vf-editor-chat-thread-chip", "data-active": isCurrent ? "true" : "false", children: _jsxs("a", { className: "vf-editor-chat-thread-chip-main vf-editor-chat-branch-chip-link", href: forkUrl, target: "_blank", rel: "noopener noreferrer", children: [_jsxs("span", { className: "vf-editor-chat-thread-chip-title", children: [label, isCurrent ? _jsx("span", { className: "vf-editor-chat-branch-current-badge", children: "current" }) : null] }), _jsxs("span", { className: "vf-editor-chat-thread-chip-tracers", children: [fork.visibility ?? "private", versionSuffix, parentSuffix] })] }) }, fork.forkId));
4156
- }) })) : (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "No forks yet for this template." }))] })) : messages.length ? messages.map((message) => (_jsx(ChatBubble, { message: message }, message.id))) : activeThreadId && loadingThreadId === activeThreadId ? (_jsxs("div", { className: "vf-editor-chat-empty", children: [_jsx("div", { className: "vf-editor-chat-kicker", children: "Loading chat" }), _jsxs("h2", { children: ["Opening ", activeThreadId] })] })) : (_jsxs("div", { className: "vf-editor-chat-empty", children: [_jsx("div", { className: "vf-editor-chat-kicker", children: "AI Copilot" }), _jsx("h2", { children: isBrainstormSurface ? "Start a new chat." : "Repurpose this video." }), _jsx("p", { children: isBrainstormSurface
4157
- ? "Use the brainstorm tools to go from zero context to angles, hooks, and a strategy you can actually test."
4158
- : "Use the brainstorm tools to turn this video into a native ad for your product, spot product-placement opportunities, and shape the timeline you can actually test. You can also connect from your own AI coding agent, including Claude Code or OpenAI Codex, and ask it to work against this composition." }), _jsx("div", { className: "vf-editor-chat-empty-prompts", "aria-label": "Example prompts", children: isBrainstormSurface ? (_jsxs(_Fragment, { children: [_jsx("div", { children: "I don't know where to start" }), _jsx("div", { children: "Help me think of persuasive angles" }), _jsx("div", { children: "Help me think of viral hooks" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { children: "I don't know where to start" }), _jsx("div", { children: "Repurpose this video into a social media native ad for my product" }), _jsx("div", { children: "Help me identify product placement opportunities in this video for my product" })] })) })] })) }), _jsx("div", { className: "vf-editor-chat-footer", hidden: showHistoryPanel || showBranchesPanel, children: _jsxs("div", { className: "vf-editor-chat-composer", "data-drop-target": isDropTarget ? "true" : "false", onDragEnter: (event) => {
4159
- event.preventDefault();
4160
- setIsDropTarget(true);
4161
- }, onDragOver: (event) => {
4162
- event.preventDefault();
4163
- if (!isDropTarget) {
4164
- setIsDropTarget(true);
4165
- }
4166
- }, onDragLeave: (event) => {
4167
- event.preventDefault();
4168
- const nextTarget = event.relatedTarget;
4169
- if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
4170
- return;
4171
- }
4172
- setIsDropTarget(false);
4173
- }, onDrop: (event) => void handleComposerDrop(event), children: [isDropTarget ? (_jsx("div", { className: "vf-editor-chat-dropzone-hint", children: "Drop files to attach" })) : null, pendingAttachments.length ? (_jsx("div", { className: "vf-editor-chat-composer-attachments-strip", children: pendingAttachments.map((attachment) => (_jsx(ComposerAttachmentItem, { attachment: attachment, onRemove: () => setPendingAttachments((current) => current.filter((entry) => entry.id !== attachment.id)) }, attachment.id))) })) : null, _jsxs("div", { className: "vf-editor-chat-composer-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-attach-button", "aria-label": "Open upload drawer", onClick: () => setIsUploadDrawerOpen(true), children: "\uD83D\uDCCE" }), _jsx("textarea", { rows: 1, placeholder: isBrainstormSurface ? "Describe the offer, audience, or strategy problem you want help with..." : "Ask it to call a route, inspect jobs, save config, or continue this thread...", className: "vf-editor-chat-input", value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: handleComposerKeyDown, onPaste: (event) => void handleComposerPaste(event) }), _jsx("button", { type: "button", className: "vf-editor-chat-send-button", onClick: () => {
4174
- if (isActiveThreadSending) {
4175
- stopActiveSend();
4176
- return;
4177
- }
4178
- void handleSend();
4179
- }, disabled: isActiveThreadSending ? false : !canSend, children: isActiveThreadSending ? "Stop" : "Send" })] }), _jsx("input", { ref: fileInputRef, type: "file", accept: "image/*,video/*,audio/*,application/pdf,text/plain,text/markdown", multiple: true, hidden: true, onChange: (event) => void handlePickFiles(event) })] }) })] })] }), _jsx(UploadDrawer, { apiKey: boot.vidfarmApiKey, open: isUploadDrawerOpen, onClose: () => setIsUploadDrawerOpen(false), onConfirm: (attachments) => setPendingAttachments((current) => [...current, ...attachments]) })] }));
4180
- }
4181
- //# sourceMappingURL=template-editor-chat.js.map