@mevdragon/vidfarm-devcli 0.2.7 → 0.2.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 (98) hide show
  1. package/.env.example +9 -0
  2. package/GETTING_STARTED.developers.md +1 -30
  3. package/README.md +22 -5
  4. package/SKILL.developer.md +464 -12
  5. package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +475 -0
  6. package/auto-create-templates/extractor-system-prompt.md +113 -0
  7. package/auto-create-templates/production-graph.schema.json +276 -0
  8. package/auto-create-templates/runbook.md +140 -0
  9. package/auto-create-templates/template-plan.schema.json +230 -0
  10. package/auto-create-templates/template-planner-prompt.md +75 -0
  11. package/dist/src/account-pages-legacy.js +9064 -0
  12. package/dist/src/account-pages.js +41 -620
  13. package/dist/src/app.js +7173 -494
  14. package/dist/src/cli.js +525 -29
  15. package/dist/src/config.js +14 -7
  16. package/dist/src/context.js +51 -35
  17. package/dist/src/db.js +1049 -55
  18. package/dist/src/dev-app-legacy.js +693 -0
  19. package/dist/src/dev-app.js +4 -904
  20. package/dist/src/domain.js +1 -1
  21. package/dist/src/editor-chat-history.js +72 -0
  22. package/dist/src/editor-chat.js +202 -0
  23. package/dist/src/frontend/flockposter-cache-store.js +116 -0
  24. package/dist/src/frontend/homepage-client.js +114 -0
  25. package/dist/src/frontend/homepage-shared.js +3 -0
  26. package/dist/src/frontend/homepage-store.js +27 -0
  27. package/dist/src/frontend/homepage-view.js +147 -0
  28. package/dist/src/frontend/page-runtime-client.js +100 -0
  29. package/dist/src/frontend/page-runtime-store.js +8 -0
  30. package/dist/src/frontend/template-editor-chat.js +2261 -0
  31. package/dist/src/homepage.js +695 -339
  32. package/dist/src/lib/template-paths.js +10 -4
  33. package/dist/src/lib/template-style-options.js +95 -15
  34. package/dist/src/page-runtime.js +1 -0
  35. package/dist/src/page-shell.js +941 -0
  36. package/dist/src/primitive-context.js +163 -0
  37. package/dist/src/primitive-registry.js +340 -0
  38. package/dist/src/primitive-sdk.js +3 -0
  39. package/dist/src/primitives/remotion/html-image.js +28 -0
  40. package/dist/src/react-page-shell.js +34 -0
  41. package/dist/src/ready-post-schedule-component.js +1514 -0
  42. package/dist/src/registry.js +44 -18
  43. package/dist/src/runtime.js +6 -1
  44. package/dist/src/services/api-call-history.js +245 -0
  45. package/dist/src/services/auth.js +27 -9
  46. package/dist/src/services/billing.js +248 -9
  47. package/dist/src/services/chat-threads.js +88 -0
  48. package/dist/src/services/job-logs.js +10 -3
  49. package/dist/src/services/jobs.js +13 -2
  50. package/dist/src/services/providers.js +944 -55
  51. package/dist/src/services/rate-limits.js +236 -0
  52. package/dist/src/services/remotion.js +143 -16
  53. package/dist/src/services/storage.js +23 -8
  54. package/dist/src/services/template-certification.js +26 -3
  55. package/dist/src/services/template-loader.js +19 -1
  56. package/dist/src/services/template-sources.js +316 -7
  57. package/dist/src/template-editor-pages.js +2309 -0
  58. package/dist/src/template-editor-shell.js +1507 -0
  59. package/dist/src/worker.js +120 -22
  60. package/package.json +30 -6
  61. package/public/assets/homepage-app.js +54 -0
  62. package/public/assets/homepage-client-app.js +54 -0
  63. package/public/assets/page-runtime-client-app.js +68 -0
  64. package/dist/templates/template_0000/src/lib/images.js +0 -202
  65. package/dist/templates/template_0000/src/remotion/Root.js +0 -33
  66. package/dist/templates/template_0000/src/remotion/index.js +0 -3
  67. package/dist/templates/template_0000/src/sdk.js +0 -3
  68. package/dist/templates/template_0000/src/style-options.js +0 -51
  69. package/dist/templates/template_0000/src/template-dna.js +0 -9
  70. package/dist/templates/template_0000/src/template.js +0 -1441
  71. package/templates/template_0000/README.md +0 -77
  72. package/templates/template_0000/SKILL.md +0 -225
  73. package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
  74. package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  75. package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
  76. package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
  77. package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  78. package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
  79. package/templates/template_0000/composition.json +0 -11
  80. package/templates/template_0000/package.json +0 -28
  81. package/templates/template_0000/research/preview/.gitkeep +0 -1
  82. package/templates/template_0000/research/source_notes.md +0 -7
  83. package/templates/template_0000/src/lib/images.js +0 -202
  84. package/templates/template_0000/src/lib/images.ts +0 -241
  85. package/templates/template_0000/src/remotion/Root.js +0 -33
  86. package/templates/template_0000/src/remotion/Root.tsx +0 -75
  87. package/templates/template_0000/src/remotion/index.js +0 -3
  88. package/templates/template_0000/src/remotion/index.tsx +0 -4
  89. package/templates/template_0000/src/sdk.js +0 -3
  90. package/templates/template_0000/src/sdk.ts +0 -122
  91. package/templates/template_0000/src/style-options.js +0 -51
  92. package/templates/template_0000/src/style-options.ts +0 -60
  93. package/templates/template_0000/src/template-dna.js +0 -9
  94. package/templates/template_0000/src/template-dna.ts +0 -15
  95. package/templates/template_0000/src/template.js +0 -1441
  96. package/templates/template_0000/src/template.ts +0 -2042
  97. package/templates/template_0000/template.config.json +0 -21
  98. package/templates/template_0000/tsconfig.json +0 -19
@@ -0,0 +1,2261 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ function normalizeUploadedAttachment(raw) {
5
+ const id = raw.id || raw.attachment_id || "";
6
+ const fileName = raw.fileName || raw.file_name;
7
+ const contentType = raw.contentType || raw.content_type;
8
+ const viewUrl = raw.viewUrl || raw.view_url || "";
9
+ if (!id || !fileName || !contentType || !viewUrl) {
10
+ return null;
11
+ }
12
+ return { id, fileName, contentType, viewUrl };
13
+ }
14
+ function isImageAttachment(attachment) {
15
+ return attachment.contentType.startsWith("image/");
16
+ }
17
+ function isVideoAttachment(attachment) {
18
+ return attachment.contentType.startsWith("video/");
19
+ }
20
+ const INLINE_MEDIA_IMAGE_EXTENSIONS = new Set([
21
+ "apng",
22
+ "avif",
23
+ "gif",
24
+ "jpeg",
25
+ "jpg",
26
+ "png",
27
+ "svg",
28
+ "webp"
29
+ ]);
30
+ const INLINE_MEDIA_VIDEO_EXTENSIONS = new Set([
31
+ "m4v",
32
+ "mov",
33
+ "mp4",
34
+ "mpeg",
35
+ "mpg",
36
+ "ogv",
37
+ "webm"
38
+ ]);
39
+ const MAX_CHAT_PAYLOAD_STRING_LENGTH = 1200;
40
+ const MAX_CHAT_PAYLOAD_ARRAY_ITEMS = 24;
41
+ const MAX_CHAT_PAYLOAD_OBJECT_KEYS = 80;
42
+ const DATA_URL_PREFIX_REGEX = /^data:([^;,]+)[^,]*,/i;
43
+ function formatJsonBody(value) {
44
+ if (value === undefined || value === null || value === "") {
45
+ return "";
46
+ }
47
+ if (typeof value === "string") {
48
+ try {
49
+ return JSON.stringify(JSON.parse(value), null, 2);
50
+ }
51
+ catch {
52
+ return value;
53
+ }
54
+ }
55
+ return JSON.stringify(value, null, 2);
56
+ }
57
+ function summarizeLargeString(value) {
58
+ const dataUrlMatch = DATA_URL_PREFIX_REGEX.exec(value);
59
+ if (dataUrlMatch) {
60
+ return `[${dataUrlMatch[1]} data URL omitted, ${value.length.toLocaleString()} chars]`;
61
+ }
62
+ if (value.length > MAX_CHAT_PAYLOAD_STRING_LENGTH) {
63
+ return `${value.slice(0, MAX_CHAT_PAYLOAD_STRING_LENGTH)}… [truncated ${value.length.toLocaleString()} chars]`;
64
+ }
65
+ return value;
66
+ }
67
+ function sanitizeChatPayload(value, depth = 0) {
68
+ if (value === null || value === undefined) {
69
+ return value;
70
+ }
71
+ if (typeof value === "string") {
72
+ return summarizeLargeString(value);
73
+ }
74
+ if (typeof value === "number" || typeof value === "boolean") {
75
+ return value;
76
+ }
77
+ if (typeof value === "bigint") {
78
+ return value.toString();
79
+ }
80
+ if (typeof value === "function" || typeof value === "symbol") {
81
+ return undefined;
82
+ }
83
+ if (depth >= 8) {
84
+ return "[nested payload omitted]";
85
+ }
86
+ if (Array.isArray(value)) {
87
+ const items = value
88
+ .slice(0, MAX_CHAT_PAYLOAD_ARRAY_ITEMS)
89
+ .map((entry) => sanitizeChatPayload(entry, depth + 1));
90
+ if (value.length > MAX_CHAT_PAYLOAD_ARRAY_ITEMS) {
91
+ items.push(`[${value.length - MAX_CHAT_PAYLOAD_ARRAY_ITEMS} more items omitted]`);
92
+ }
93
+ return items;
94
+ }
95
+ if (typeof value === "object") {
96
+ const output = {};
97
+ const entries = Object.entries(value);
98
+ for (const [key, entry] of entries.slice(0, MAX_CHAT_PAYLOAD_OBJECT_KEYS)) {
99
+ const sanitized = sanitizeChatPayload(entry, depth + 1);
100
+ if (sanitized !== undefined) {
101
+ output[key] = sanitized;
102
+ }
103
+ }
104
+ if (entries.length > MAX_CHAT_PAYLOAD_OBJECT_KEYS) {
105
+ output.__omitted_keys = entries.length - MAX_CHAT_PAYLOAD_OBJECT_KEYS;
106
+ }
107
+ return output;
108
+ }
109
+ return String(value);
110
+ }
111
+ function sanitizeToolResult(result) {
112
+ return sanitizeChatPayload(result);
113
+ }
114
+ function sanitizeHttpExchange(exchange) {
115
+ return {
116
+ ...exchange,
117
+ request: {
118
+ ...exchange.request,
119
+ body: sanitizeChatPayload(exchange.request.body)
120
+ },
121
+ result: {
122
+ ...exchange.result,
123
+ body: sanitizeChatPayload(exchange.result.body),
124
+ error: exchange.result.error ? summarizeLargeString(exchange.result.error) : exchange.result.error
125
+ }
126
+ };
127
+ }
128
+ function buildBrowserDateContext() {
129
+ const now = new Date();
130
+ let localDateTime = null;
131
+ let timeZone = null;
132
+ try {
133
+ localDateTime = new Intl.DateTimeFormat(undefined, {
134
+ dateStyle: "full",
135
+ timeStyle: "long"
136
+ }).format(now);
137
+ }
138
+ catch {
139
+ localDateTime = now.toString();
140
+ }
141
+ try {
142
+ timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
143
+ }
144
+ catch {
145
+ timeZone = null;
146
+ }
147
+ return {
148
+ currentDateTime: now.toISOString(),
149
+ localDateTime,
150
+ timeZone
151
+ };
152
+ }
153
+ function escapeHtml(value) {
154
+ return value
155
+ .replaceAll("&", "&")
156
+ .replaceAll("<", "&lt;")
157
+ .replaceAll(">", "&gt;")
158
+ .replaceAll("\"", "&quot;")
159
+ .replaceAll("'", "&#39;");
160
+ }
161
+ function renderInlineMarkdown(value) {
162
+ let html = escapeHtml(value);
163
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
164
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
165
+ html = html.replace(/(^|[^\*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>");
166
+ html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
167
+ html = html.replace(/(^|[\s(])(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g, '$1<a href="$2" target="_blank" rel="noopener noreferrer">$2</a>');
168
+ return html;
169
+ }
170
+ function renderMarkdownToHtml(value) {
171
+ const normalized = value.replace(/\r\n/g, "\n").trim();
172
+ if (!normalized) {
173
+ return "";
174
+ }
175
+ const blocks = normalized.split(/\n{2,}/);
176
+ return blocks.map((block) => {
177
+ const trimmed = block.trim();
178
+ if (!trimmed) {
179
+ return "";
180
+ }
181
+ if (trimmed.startsWith("```") && trimmed.endsWith("```")) {
182
+ const code = trimmed.replace(/^```[^\n]*\n?/, "").replace(/\n?```$/, "");
183
+ return `<pre><code>${escapeHtml(code)}</code></pre>`;
184
+ }
185
+ const lines = trimmed.split("\n");
186
+ const isBulletList = lines.every((line) => /^[-*]\s+/.test(line.trim()));
187
+ if (isBulletList) {
188
+ const items = lines
189
+ .map((line) => line.trim().replace(/^[-*]\s+/, ""))
190
+ .map((line) => `<li>${renderInlineMarkdown(line)}</li>`)
191
+ .join("");
192
+ return `<ul>${items}</ul>`;
193
+ }
194
+ const isOrderedList = lines.every((line) => /^\d+\.\s+/.test(line.trim()));
195
+ if (isOrderedList) {
196
+ const items = lines
197
+ .map((line) => line.trim().replace(/^\d+\.\s+/, ""))
198
+ .map((line) => `<li>${renderInlineMarkdown(line)}</li>`)
199
+ .join("");
200
+ return `<ol>${items}</ol>`;
201
+ }
202
+ return `<p>${lines.map((line) => renderInlineMarkdown(line.trim())).join("<br />")}</p>`;
203
+ }).filter(Boolean).join("");
204
+ }
205
+ function titleCaseLabel(value) {
206
+ return value
207
+ .replace(/[_-]+/g, " ")
208
+ .replace(/\s+/g, " ")
209
+ .trim()
210
+ .replace(/\b\w/g, (char) => char.toUpperCase());
211
+ }
212
+ function isGenericPathLabel(value) {
213
+ return /^item \d+$/i.test(value.trim()) || /^output$/i.test(value.trim());
214
+ }
215
+ function extractStorageKeyFromUrl(url) {
216
+ try {
217
+ const parsed = new URL(url);
218
+ const queryKey = parsed.searchParams.get("key");
219
+ if (queryKey?.trim()) {
220
+ return queryKey.trim().replace(/^\/+/, "");
221
+ }
222
+ return null;
223
+ }
224
+ catch {
225
+ return null;
226
+ }
227
+ }
228
+ function extractFilenameFromUrl(url) {
229
+ const storageKey = extractStorageKeyFromUrl(url);
230
+ if (storageKey) {
231
+ const fileName = decodeURIComponent(storageKey.split("/").filter(Boolean).pop() || "");
232
+ if (fileName) {
233
+ return fileName;
234
+ }
235
+ }
236
+ try {
237
+ const pathname = new URL(url).pathname;
238
+ return decodeURIComponent(pathname.split("/").filter(Boolean).pop() || "");
239
+ }
240
+ catch {
241
+ return "";
242
+ }
243
+ }
244
+ function labelFromUrl(url) {
245
+ const filename = extractFilenameFromUrl(url);
246
+ return filename || "Output";
247
+ }
248
+ function getMediaKindFromUrl(url) {
249
+ const fileName = extractFilenameFromUrl(url);
250
+ const extension = fileName.split(".").pop()?.trim().toLowerCase() || "";
251
+ if (INLINE_MEDIA_IMAGE_EXTENSIONS.has(extension)) {
252
+ return "image";
253
+ }
254
+ if (INLINE_MEDIA_VIDEO_EXTENSIONS.has(extension)) {
255
+ return "video";
256
+ }
257
+ return null;
258
+ }
259
+ function extractCandidateUrls(value) {
260
+ const urls = new Set();
261
+ const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
262
+ const bareUrlPattern = /(^|[\s(])(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/gm;
263
+ let match = null;
264
+ while ((match = markdownLinkPattern.exec(value)) !== null) {
265
+ urls.add(match[2]);
266
+ }
267
+ while ((match = bareUrlPattern.exec(value)) !== null) {
268
+ urls.add(match[2]);
269
+ }
270
+ return [...urls];
271
+ }
272
+ function extractInlineMediaPreviews(text, excludedUrls = []) {
273
+ const excluded = new Set(excludedUrls);
274
+ const seen = new Set();
275
+ const previews = [];
276
+ for (const url of extractCandidateUrls(text)) {
277
+ if (excluded.has(url) || seen.has(url)) {
278
+ continue;
279
+ }
280
+ const kind = getMediaKindFromUrl(url);
281
+ if (!kind) {
282
+ continue;
283
+ }
284
+ seen.add(url);
285
+ previews.push({
286
+ kind,
287
+ url,
288
+ label: labelFromUrl(url)
289
+ });
290
+ }
291
+ return previews;
292
+ }
293
+ function collectLabeledUrls(value, output, seen = new Set(), path = []) {
294
+ if (output.length >= 12 || value == null) {
295
+ return;
296
+ }
297
+ if (typeof value === "string") {
298
+ const trimmed = value.trim();
299
+ if (/^https?:\/\//i.test(trimmed) && !seen.has(trimmed)) {
300
+ seen.add(trimmed);
301
+ const rawPathLabel = path.length ? path[path.length - 1] || "Output" : "";
302
+ const pathLabel = titleCaseLabel(rawPathLabel);
303
+ const filenameLabel = labelFromUrl(trimmed);
304
+ output.push({
305
+ label: filenameLabel !== "Output" ? filenameLabel : pathLabel && !isGenericPathLabel(rawPathLabel) ? pathLabel : filenameLabel,
306
+ url: trimmed
307
+ });
308
+ }
309
+ return;
310
+ }
311
+ if (Array.isArray(value)) {
312
+ for (const [index, entry] of value.entries()) {
313
+ collectLabeledUrls(entry, output, seen, [...path, `item ${index + 1}`]);
314
+ if (output.length >= 12) {
315
+ return;
316
+ }
317
+ }
318
+ return;
319
+ }
320
+ if (typeof value === "object") {
321
+ for (const [key, entry] of Object.entries(value)) {
322
+ collectLabeledUrls(entry, output, seen, [...path, key]);
323
+ if (output.length >= 12) {
324
+ return;
325
+ }
326
+ }
327
+ }
328
+ }
329
+ function buildJobQueuedMessage(job) {
330
+ return [
331
+ `Started job \`${job.job_id}\`${job.tracer ? ` with tracer \`${job.tracer}\`` : ""}.`,
332
+ "If it takes a while, ask for an update later and I can check it for you."
333
+ ].join("\n\n");
334
+ }
335
+ function buildJobCompletionMessage(job) {
336
+ if (job.status === "succeeded") {
337
+ const urls = [];
338
+ collectLabeledUrls(job.result, urls);
339
+ const lines = [`Job \`${job.job_id}\` succeeded.`];
340
+ if (urls.length) {
341
+ lines.push("");
342
+ lines.push("Outputs:");
343
+ lines.push(...urls.map(({ url, label }) => `- [${label}](${url})`));
344
+ }
345
+ return lines.join("\n");
346
+ }
347
+ if (job.status === "failed") {
348
+ return `Job \`${job.job_id}\` failed.${job.error?.message ? ` ${job.error.message}` : ""}`;
349
+ }
350
+ if (job.status === "cancelled") {
351
+ return `Job \`${job.job_id}\` was cancelled.`;
352
+ }
353
+ return `Job \`${job.job_id}\` is ${job.status}.`;
354
+ }
355
+ function appendDistinctMessageText(current, addition) {
356
+ const normalizedCurrent = current.trim();
357
+ const normalizedAddition = addition.trim();
358
+ if (!normalizedAddition) {
359
+ return current;
360
+ }
361
+ if (normalizedCurrent.includes(normalizedAddition)) {
362
+ return current;
363
+ }
364
+ return normalizedCurrent ? `${normalizedCurrent}\n\n${normalizedAddition}` : normalizedAddition;
365
+ }
366
+ const CHAT_BOTTOM_STICKY_THRESHOLD_PX = 48;
367
+ function isScrolledNearBottom(element) {
368
+ return element.scrollHeight - element.scrollTop - element.clientHeight <= CHAT_BOTTOM_STICKY_THRESHOLD_PX;
369
+ }
370
+ function upsertAutomaticPollExchange(exchanges, nextExchange) {
371
+ const current = [...(exchanges ?? [])];
372
+ const matchIndex = current.findIndex((exchange) => (exchange.pollMode === "automatic" && exchange.pollJobId === nextExchange.pollJobId));
373
+ if (matchIndex === -1) {
374
+ current.push(nextExchange);
375
+ return current;
376
+ }
377
+ current[matchIndex] = nextExchange;
378
+ return current;
379
+ }
380
+ const JOB_POLL_INTERVAL_MS = 30_000;
381
+ const JOB_POLL_BUSY_INTERVAL_MS = 180_000;
382
+ const JOB_POLL_BUSY_THRESHOLD = 4;
383
+ const JOB_POLL_MAX_ATTEMPTS = 40;
384
+ const JOB_POLL_GLOBAL_MIN_GAP_MS = 1_500;
385
+ const JOB_POLL_MIN_TIMER_MS = 250;
386
+ function tryReadJobStatusPayload(value) {
387
+ if (!value || typeof value !== "object") {
388
+ return null;
389
+ }
390
+ const record = value;
391
+ if (typeof record.job_id !== "string" || typeof record.status !== "string") {
392
+ return null;
393
+ }
394
+ return {
395
+ job_id: record.job_id,
396
+ tracer: typeof record.tracer === "string" ? record.tracer : undefined,
397
+ status: record.status,
398
+ progress: typeof record.progress === "number" ? record.progress : null,
399
+ result: record.result,
400
+ error: record.error && typeof record.error === "object"
401
+ ? {
402
+ message: typeof record.error.message === "string"
403
+ ? record.error.message
404
+ : undefined,
405
+ type: typeof record.error.type === "string"
406
+ ? record.error.type
407
+ : undefined
408
+ }
409
+ : null
410
+ };
411
+ }
412
+ function buildAuthHeaders(apiKey, contentType) {
413
+ const headers = new Headers();
414
+ if (apiKey) {
415
+ headers.set("vidfarm-api-key", apiKey);
416
+ }
417
+ if (contentType) {
418
+ headers.set("content-type", contentType);
419
+ }
420
+ return headers;
421
+ }
422
+ async function parseJson(response) {
423
+ return await response.json();
424
+ }
425
+ async function fileToDataUrl(file) {
426
+ return await new Promise((resolve, reject) => {
427
+ const reader = new FileReader();
428
+ reader.onload = () => {
429
+ if (typeof reader.result === "string") {
430
+ resolve(reader.result);
431
+ return;
432
+ }
433
+ reject(new Error("Unable to read attachment contents."));
434
+ };
435
+ reader.onerror = () => reject(reader.error ?? new Error("Unable to read attachment contents."));
436
+ reader.readAsDataURL(file);
437
+ });
438
+ }
439
+ async function uploadAttachment(input) {
440
+ const presignResponse = await fetch("/api/v1/user/me/attachments/presign", {
441
+ method: "POST",
442
+ headers: buildAuthHeaders(input.apiKey, "application/json"),
443
+ body: JSON.stringify({
444
+ file_name: input.file.name,
445
+ content_type: input.file.type || undefined,
446
+ size_bytes: input.file.size
447
+ })
448
+ });
449
+ const presignPayload = await parseJson(presignResponse);
450
+ if (!presignResponse.ok || !("transport" in presignPayload)) {
451
+ throw new Error(("error" in presignPayload && presignPayload.error) || "Unable to prepare upload.");
452
+ }
453
+ if (presignPayload.transport === "server") {
454
+ const uploadFormData = new FormData();
455
+ uploadFormData.append(presignPayload.upload.form_field || "file", input.file);
456
+ const uploadResponse = await fetch(presignPayload.upload.url, {
457
+ method: presignPayload.upload.method || "POST",
458
+ headers: buildAuthHeaders(input.apiKey),
459
+ body: uploadFormData
460
+ });
461
+ const uploadPayload = await parseJson(uploadResponse);
462
+ if (!uploadResponse.ok || !uploadPayload.attachment) {
463
+ throw new Error(uploadPayload.error || "Unable to upload attachment.");
464
+ }
465
+ return normalizeUploadedAttachment(uploadPayload.attachment) ?? (() => {
466
+ throw new Error("Attachment response was incomplete.");
467
+ })();
468
+ }
469
+ const uploadResponse = await fetch(presignPayload.upload.url, {
470
+ method: presignPayload.upload.method,
471
+ headers: presignPayload.upload.headers,
472
+ body: input.file
473
+ });
474
+ if (!uploadResponse.ok) {
475
+ throw new Error(`Upload failed with status ${uploadResponse.status}.`);
476
+ }
477
+ const finalizeResponse = await fetch("/api/v1/user/me/attachments", {
478
+ method: "POST",
479
+ headers: buildAuthHeaders(input.apiKey, "application/json"),
480
+ body: JSON.stringify({
481
+ attachment_id: presignPayload.attachment_id,
482
+ file_name: presignPayload.file_name,
483
+ content_type: presignPayload.content_type,
484
+ size_bytes: input.file.size,
485
+ storage_key: presignPayload.storage_key
486
+ })
487
+ });
488
+ const finalizePayload = await parseJson(finalizeResponse);
489
+ if (!finalizeResponse.ok || !finalizePayload.attachment) {
490
+ throw new Error(finalizePayload.error || "Unable to save uploaded attachment.");
491
+ }
492
+ return normalizeUploadedAttachment(finalizePayload.attachment) ?? (() => {
493
+ throw new Error("Attachment response was incomplete.");
494
+ })();
495
+ }
496
+ function toTransportMessages(messages, systemMessages) {
497
+ return [
498
+ ...systemMessages
499
+ .filter((entry) => entry.trim().length > 0)
500
+ .map((entry) => ({
501
+ role: "system",
502
+ content: entry
503
+ })),
504
+ ...messages.map((message) => ({
505
+ role: message.role,
506
+ content: [
507
+ ...(message.text ? [{ type: "text", text: message.text }] : []),
508
+ ...message.attachments.map((attachment) => ({
509
+ type: "file",
510
+ data: attachment.transportData || attachment.viewUrl,
511
+ mediaType: attachment.contentType
512
+ }))
513
+ ]
514
+ }))
515
+ ];
516
+ }
517
+ function readCachedScheduleChannels() {
518
+ const cache = window.__vidfarmFlockPosterCache;
519
+ const snapshot = cache?.readSnapshot?.();
520
+ if (!snapshot) {
521
+ return {};
522
+ }
523
+ const channels = Array.isArray(snapshot?.channels)
524
+ ? snapshot.channels
525
+ .map((channel) => channel && typeof channel === "object" ? channel : null)
526
+ .filter((channel) => typeof channel?.id === "string" && Boolean(channel.id.trim()))
527
+ .map((channel) => ({
528
+ id: String(channel?.id || ""),
529
+ name: typeof channel?.name === "string" ? channel.name : null,
530
+ title: typeof channel?.title === "string" ? channel.title : null,
531
+ handle: typeof channel?.handle === "string" ? channel.handle : null,
532
+ platform: typeof channel?.platform === "string" ? channel.platform : null,
533
+ status: typeof channel?.status === "string" ? channel.status : null
534
+ }))
535
+ : [];
536
+ return channels.length || snapshot?.channelsUpdatedAt
537
+ ? {
538
+ scheduleChannels: channels,
539
+ scheduleChannelsUpdatedAt: snapshot?.channelsUpdatedAt ?? null
540
+ }
541
+ : {};
542
+ }
543
+ function readSelectedLibraryPosts() {
544
+ const snapshot = window.__vidfarmLibrarySelection?.readSelectedPosts?.();
545
+ const posts = Array.isArray(snapshot?.posts)
546
+ ? snapshot.posts
547
+ .map((post) => post && typeof post === "object" ? post : null)
548
+ .filter((post) => typeof post?.post_id === "string" && Boolean(post.post_id.trim()))
549
+ .map((post) => ({
550
+ post_id: String(post?.post_id || ""),
551
+ title: typeof post?.title === "string" ? post.title : null,
552
+ caption: typeof post?.caption === "string" ? post.caption : null,
553
+ tracer: typeof post?.tracer === "string" ? post.tracer : null,
554
+ status: typeof post?.status === "string" ? post.status : null,
555
+ share_url: typeof post?.share_url === "string" ? post.share_url : null,
556
+ media: Array.isArray(post?.media)
557
+ ? post.media.slice(0, 8).map((asset) => asset && typeof asset === "object" ? asset : null).filter(Boolean).map((asset) => ({
558
+ url: typeof asset?.url === "string" ? asset.url : null,
559
+ kind: typeof asset?.kind === "string" ? asset.kind : null,
560
+ role: typeof asset?.role === "string" ? asset.role : null,
561
+ content_type: typeof asset?.content_type === "string" ? asset.content_type : null
562
+ }))
563
+ : []
564
+ }))
565
+ : [];
566
+ return {
567
+ selectedPosts: posts,
568
+ selectedPostsUpdatedAt: snapshot?.updatedAt ?? null
569
+ };
570
+ }
571
+ async function readStreamText(response, handlers) {
572
+ if (!response.body) {
573
+ throw new Error("Chat response did not include a body.");
574
+ }
575
+ const reader = response.body.getReader();
576
+ const decoder = new TextDecoder();
577
+ let buffer = "";
578
+ while (true) {
579
+ const { done, value } = await reader.read();
580
+ if (done) {
581
+ break;
582
+ }
583
+ buffer += decoder.decode(value, { stream: true });
584
+ const events = buffer.split("\n\n");
585
+ buffer = events.pop() ?? "";
586
+ for (const event of events) {
587
+ const line = event
588
+ .split("\n")
589
+ .map((entry) => entry.trim())
590
+ .find((entry) => entry.startsWith("data:"));
591
+ if (!line) {
592
+ continue;
593
+ }
594
+ const payload = line.slice(5).trim();
595
+ if (payload === "[DONE]") {
596
+ continue;
597
+ }
598
+ let parsed;
599
+ try {
600
+ parsed = JSON.parse(payload);
601
+ }
602
+ catch {
603
+ continue;
604
+ }
605
+ if (!parsed || typeof parsed !== "object") {
606
+ continue;
607
+ }
608
+ const chunk = parsed;
609
+ if (chunk.type === "text-delta" && typeof chunk.delta === "string") {
610
+ handlers.onDelta(chunk.delta);
611
+ }
612
+ else if (chunk.type === "status") {
613
+ const statusMessage = typeof chunk.status === "string"
614
+ ? chunk.status
615
+ : typeof chunk.message === "string"
616
+ ? chunk.message
617
+ : null;
618
+ if (statusMessage && handlers.onStatus) {
619
+ handlers.onStatus(statusMessage);
620
+ }
621
+ }
622
+ else if (chunk.type === "tool-call"
623
+ && typeof chunk.toolCallId === "string"
624
+ && typeof chunk.toolName === "string"
625
+ && handlers.onToolCall) {
626
+ handlers.onToolCall({
627
+ toolCallId: chunk.toolCallId,
628
+ toolName: chunk.toolName,
629
+ args: chunk.args && typeof chunk.args === "object" ? chunk.args : undefined
630
+ });
631
+ }
632
+ else if (chunk.type === "tool-result"
633
+ && typeof chunk.toolCallId === "string"
634
+ && typeof chunk.toolName === "string"
635
+ && handlers.onToolResult) {
636
+ handlers.onToolResult({
637
+ toolCallId: chunk.toolCallId,
638
+ toolName: chunk.toolName,
639
+ result: chunk.result
640
+ });
641
+ }
642
+ else if (chunk.type === "error" && handlers.onError) {
643
+ handlers.onError?.(typeof chunk.errorText === "string"
644
+ ? chunk.errorText
645
+ : typeof chunk.error?.message === "string"
646
+ ? chunk.error.message
647
+ : "Unable to read assistant response.");
648
+ }
649
+ }
650
+ }
651
+ }
652
+ function makeStorageKeys(templateId) {
653
+ return {
654
+ threads: `vidfarm-editor-chat-threads:${templateId}`,
655
+ activeThread: `vidfarm-editor-chat-active-thread:${templateId}`
656
+ };
657
+ }
658
+ function getChatStorage() {
659
+ if (typeof window === "undefined") {
660
+ return null;
661
+ }
662
+ try {
663
+ return window.sessionStorage;
664
+ }
665
+ catch {
666
+ try {
667
+ return window.localStorage;
668
+ }
669
+ catch {
670
+ return null;
671
+ }
672
+ }
673
+ }
674
+ function makeThreadId() {
675
+ return `thread-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
676
+ }
677
+ function makeMessageId(prefix) {
678
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
679
+ return `${prefix}-${crypto.randomUUID()}`;
680
+ }
681
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
682
+ }
683
+ function normalizeTracerList(values) {
684
+ const seen = new Set();
685
+ const tracers = [];
686
+ for (const value of values) {
687
+ const tracer = value.trim();
688
+ if (!tracer || seen.has(tracer)) {
689
+ continue;
690
+ }
691
+ seen.add(tracer);
692
+ tracers.push(tracer);
693
+ }
694
+ return tracers;
695
+ }
696
+ function mergeTracerLists(...groups) {
697
+ return normalizeTracerList(groups.flat());
698
+ }
699
+ function archiveTracerValue(tracer) {
700
+ const normalized = tracer.trim();
701
+ if (!normalized || normalized.startsWith("archived_")) {
702
+ return normalized;
703
+ }
704
+ return `archived_${normalized}`;
705
+ }
706
+ function makeTracerSuffix() {
707
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
708
+ return crypto.randomUUID().replace(/-/g, "").slice(-8);
709
+ }
710
+ return Math.random().toString(16).slice(2, 10).padEnd(8, "0").slice(0, 8);
711
+ }
712
+ function slugifyTracerPrefix(value) {
713
+ const normalized = value
714
+ .trim()
715
+ .toLowerCase()
716
+ .replace(/[^a-z0-9]+/g, "-")
717
+ .replace(/^-+|-+$/g, "");
718
+ return (normalized || "tracer").slice(0, 28);
719
+ }
720
+ function makeTracerId(templateSlug, existing) {
721
+ const prefix = slugifyTracerPrefix(templateSlug);
722
+ let candidate = "";
723
+ do {
724
+ candidate = `${prefix}-${makeTracerSuffix()}`;
725
+ } while (existing.includes(candidate));
726
+ return candidate;
727
+ }
728
+ function ensureTracerHasRandomSuffix(tracer, existing) {
729
+ const normalized = tracer.trim();
730
+ if (!normalized) {
731
+ return "";
732
+ }
733
+ if (/-[a-z0-9]{8}$/i.test(normalized)) {
734
+ return normalized;
735
+ }
736
+ let candidate = `${normalized}-${makeTracerSuffix()}`;
737
+ while (existing.includes(candidate)) {
738
+ candidate = `${normalized}-${makeTracerSuffix()}`;
739
+ }
740
+ return candidate;
741
+ }
742
+ function readTracersFromLocation() {
743
+ const params = new URLSearchParams(window.location.search);
744
+ const values = [...params.getAll("tracers"), ...params.getAll("tracer")];
745
+ const output = [];
746
+ for (const rawValue of values) {
747
+ const normalized = rawValue.trim();
748
+ if (!normalized) {
749
+ continue;
750
+ }
751
+ const inner = normalized.startsWith("[") && normalized.endsWith("]")
752
+ ? normalized.slice(1, -1)
753
+ : normalized;
754
+ output.push(...inner.split(",").map((value) => value.trim().replace(/^["']|["']$/g, "")));
755
+ }
756
+ return normalizeTracerList(output);
757
+ }
758
+ function formatThreadTitle(text) {
759
+ const normalized = text.trim().replace(/\s+/g, " ");
760
+ if (!normalized) {
761
+ return "New chat";
762
+ }
763
+ return normalized.length > 42 ? `${normalized.slice(0, 42).trimEnd()}…` : normalized;
764
+ }
765
+ function serializeThreads(threads) {
766
+ return threads.map((thread) => ({
767
+ id: thread.id,
768
+ title: thread.title,
769
+ createdAt: thread.createdAt,
770
+ updatedAt: thread.updatedAt,
771
+ tracers: thread.tracers,
772
+ hiddenFromView: thread.hiddenFromView,
773
+ messages: thread.messages.map((message) => ({
774
+ id: message.id,
775
+ role: message.role,
776
+ text: message.text,
777
+ attachments: message.attachments.map((attachment) => ({
778
+ id: attachment.id,
779
+ fileName: attachment.fileName,
780
+ contentType: attachment.contentType,
781
+ viewUrl: attachment.viewUrl
782
+ })),
783
+ toolExchanges: message.toolExchanges?.map((exchange) => ({
784
+ ...exchange,
785
+ args: sanitizeChatPayload(exchange.args),
786
+ result: sanitizeToolResult(exchange.result)
787
+ })),
788
+ httpExchanges: message.httpExchanges?.map(sanitizeHttpExchange)
789
+ }))
790
+ }));
791
+ }
792
+ function hydrateThreads(raw) {
793
+ if (!raw) {
794
+ return [];
795
+ }
796
+ try {
797
+ const parsed = JSON.parse(raw);
798
+ if (!Array.isArray(parsed)) {
799
+ return [];
800
+ }
801
+ return parsed
802
+ .filter((thread) => thread && typeof thread.id === "string")
803
+ .map((thread) => ({
804
+ id: thread.id,
805
+ title: typeof thread.title === "string" && thread.title.trim() ? thread.title : "New chat",
806
+ createdAt: typeof thread.createdAt === "string" ? thread.createdAt : new Date().toISOString(),
807
+ updatedAt: typeof thread.updatedAt === "string" ? thread.updatedAt : new Date().toISOString(),
808
+ tracers: normalizeTracerList(Array.isArray(thread.tracers) ? thread.tracers.filter((value) => typeof value === "string") : []),
809
+ hiddenFromView: thread.hiddenFromView === true,
810
+ messages: Array.isArray(thread.messages)
811
+ ? thread.messages.map((message) => ({
812
+ id: message.id,
813
+ role: message.role,
814
+ text: message.text,
815
+ attachments: Array.isArray(message.attachments) ? message.attachments : [],
816
+ toolExchanges: Array.isArray(message.toolExchanges) ? message.toolExchanges : [],
817
+ httpExchanges: Array.isArray(message.httpExchanges) ? message.httpExchanges : []
818
+ }))
819
+ : []
820
+ }));
821
+ }
822
+ catch {
823
+ return [];
824
+ }
825
+ }
826
+ function hydrateThreadRecord(thread) {
827
+ if (!thread || typeof thread.id !== "string") {
828
+ return null;
829
+ }
830
+ return {
831
+ id: thread.id,
832
+ title: typeof thread.title === "string" && thread.title.trim() ? thread.title : "New chat",
833
+ createdAt: typeof thread.createdAt === "string" ? thread.createdAt : new Date().toISOString(),
834
+ updatedAt: typeof thread.updatedAt === "string" ? thread.updatedAt : new Date().toISOString(),
835
+ tracers: normalizeTracerList(Array.isArray(thread.tracers) ? thread.tracers.filter((value) => typeof value === "string") : []),
836
+ hiddenFromView: thread.hiddenFromView === true,
837
+ messages: Array.isArray(thread.messages)
838
+ ? thread.messages.map((message) => ({
839
+ id: message.id,
840
+ role: message.role,
841
+ text: message.text,
842
+ attachments: Array.isArray(message.attachments) ? message.attachments : [],
843
+ toolExchanges: Array.isArray(message.toolExchanges) ? message.toolExchanges : [],
844
+ httpExchanges: Array.isArray(message.httpExchanges) ? message.httpExchanges : []
845
+ }))
846
+ : []
847
+ };
848
+ }
849
+ function mergeThreadSets(localThreads, remoteThreads) {
850
+ const byId = new Map();
851
+ for (const thread of remoteThreads) {
852
+ byId.set(thread.id, thread);
853
+ }
854
+ for (const thread of localThreads) {
855
+ if (!byId.has(thread.id)) {
856
+ byId.set(thread.id, thread);
857
+ }
858
+ }
859
+ return [...byId.values()].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
860
+ }
861
+ function threadMatchesTracers(thread, selectedTracers) {
862
+ if (!selectedTracers.length) {
863
+ return true;
864
+ }
865
+ return thread.tracers.some((threadTracer) => selectedTracers.some((selectedTracer) => doesTracerMatchSelection(threadTracer, selectedTracer)));
866
+ }
867
+ function doesTracerMatchSelection(threadTracer, selectedTracer) {
868
+ const normalizedThreadTracer = threadTracer.trim();
869
+ const normalizedSelectedTracer = selectedTracer.trim();
870
+ if (!normalizedThreadTracer || !normalizedSelectedTracer) {
871
+ return false;
872
+ }
873
+ if (normalizedThreadTracer === normalizedSelectedTracer) {
874
+ return true;
875
+ }
876
+ return normalizedThreadTracer.startsWith(`${normalizedSelectedTracer}-`) && /-[a-z0-9]{8}$/i.test(normalizedThreadTracer);
877
+ }
878
+ function summarizeThread(thread) {
879
+ const lastMessage = [...thread.messages].reverse().find((message) => message.text.trim());
880
+ return lastMessage?.text.trim() || "No messages yet";
881
+ }
882
+ function summarizeThreadPreview(thread) {
883
+ const preview = summarizeThread(thread);
884
+ if (!thread.messages.length) {
885
+ return "No messages yet";
886
+ }
887
+ const normalizedTitle = thread.title.trim().toLowerCase();
888
+ const normalizedPreview = preview.trim().toLowerCase();
889
+ if (!preview || normalizedPreview === normalizedTitle) {
890
+ return `${thread.messages.length} message${thread.messages.length === 1 ? "" : "s"}`;
891
+ }
892
+ return preview;
893
+ }
894
+ function AttachmentChip(props) {
895
+ 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] }));
896
+ }
897
+ function MediaPreviewModal(props) {
898
+ const [isMounted, setIsMounted] = useState(false);
899
+ useEffect(() => {
900
+ setIsMounted(true);
901
+ }, []);
902
+ if (!isMounted) {
903
+ return null;
904
+ }
905
+ 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: props.preview.url, target: "_blank", rel: "noopener noreferrer", children: props.preview.label }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-close", onClick: props.onClose, "aria-label": "Close media preview", children: "\u00D7" })] }), _jsx("div", { className: "vf-editor-chat-media-modal-body", children: props.preview.kind === "image" ? (_jsx("img", { src: props.preview.url, alt: props.preview.label, className: "vf-editor-chat-media-modal-image" })) : (_jsx("video", { src: props.preview.url, className: "vf-editor-chat-media-modal-video", controls: true, autoPlay: true, playsInline: true })) })] }) }), document.body);
906
+ }
907
+ function ImagePreviewButton(props) {
908
+ const [hasError, setHasError] = useState(false);
909
+ return (_jsx("button", { type: "button", className: "vf-editor-chat-message-thumb-button is-image", onClick: () => props.onOpenModal(props.preview), "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) })) }));
910
+ }
911
+ function VideoPreviewCard(props) {
912
+ 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" })] }) }));
913
+ }
914
+ function ImagePreviewRail(props) {
915
+ if (!props.previews.length) {
916
+ return null;
917
+ }
918
+ return (_jsx("div", { className: "vf-editor-chat-message-image-rail", "aria-label": "Image previews", children: props.previews.map((preview) => (_jsx(ImagePreviewButton, { preview: preview, onOpenModal: props.onOpenModal }, preview.url))) }));
919
+ }
920
+ function ComposerAttachmentItem(props) {
921
+ 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" })] }));
922
+ }
923
+ function HttpExchangeCard(props) {
924
+ const [isOpen, setIsOpen] = useState(false);
925
+ const [isMounted, setIsMounted] = useState(false);
926
+ const requestBody = formatJsonBody(props.exchange.request.body);
927
+ const responseBody = formatJsonBody(props.exchange.result.body);
928
+ const requestUrl = props.exchange.request.url;
929
+ const displayUrl = requestUrl.length > 52
930
+ ? `${requestUrl.slice(0, 52).trimEnd()}...`
931
+ : requestUrl;
932
+ const isError = props.exchange.result.status >= 400 || Boolean(props.exchange.result.error);
933
+ const isSuccess = props.exchange.result.status >= 200 && props.exchange.result.status < 300;
934
+ useEffect(() => {
935
+ setIsMounted(true);
936
+ }, []);
937
+ 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] }));
938
+ }
939
+ function ChatBubble(props) {
940
+ const isAssistant = props.message.role === "assistant";
941
+ const hasVisibleText = props.message.text.trim().length > 0;
942
+ const renderedText = hasVisibleText ? renderMarkdownToHtml(props.message.text) : "";
943
+ const [mediaModalPreview, setMediaModalPreview] = useState(null);
944
+ const imageAttachments = props.message.attachments.filter(isImageAttachment);
945
+ const videoAttachments = props.message.attachments.filter(isVideoAttachment);
946
+ const fileAttachments = props.message.attachments.filter((attachment) => !isImageAttachment(attachment) && !isVideoAttachment(attachment));
947
+ const inlineMediaPreviews = hasVisibleText
948
+ ? extractInlineMediaPreviews(props.message.text, props.message.attachments.map((attachment) => attachment.viewUrl))
949
+ : [];
950
+ const inlineImagePreviews = inlineMediaPreviews.filter((preview) => preview.kind === "image");
951
+ const inlineVideoPreviews = inlineMediaPreviews.filter((preview) => preview.kind === "video");
952
+ 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) => ({
953
+ kind: "image",
954
+ url: attachment.viewUrl,
955
+ label: attachment.fileName
956
+ })), onOpenModal: setMediaModalPreview }), videoAttachments.map((attachment) => (_jsx(VideoPreviewCard, { preview: {
957
+ kind: "video",
958
+ url: attachment.viewUrl,
959
+ label: attachment.fileName
960
+ }, onOpenModal: setMediaModalPreview }, 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: setMediaModalPreview }), inlineVideoPreviews.map((preview) => (_jsx(VideoPreviewCard, { preview: preview, onOpenModal: setMediaModalPreview }, preview.url)))] })) : null] }), mediaModalPreview ? (_jsx(MediaPreviewModal, { preview: mediaModalPreview, onClose: () => setMediaModalPreview(null) })) : null] }));
961
+ }
962
+ export function TemplateEditorChat({ boot }) {
963
+ const storageKeys = useMemo(() => makeStorageKeys(boot.template.templateId), [boot.template.templateId]);
964
+ const chatStorage = useMemo(() => getChatStorage(), []);
965
+ const systemMessages = useMemo(() => {
966
+ const messages = [];
967
+ if (boot.cachedContext.directorSkill?.trim()) {
968
+ messages.push(`Director skill context:\n\n${boot.cachedContext.directorSkill.trim()}`);
969
+ }
970
+ if (boot.cachedContext.templateSkill?.trim()) {
971
+ messages.push(`Template skill context:\n\n${boot.cachedContext.templateSkill.trim()}`);
972
+ }
973
+ return messages;
974
+ }, [boot.cachedContext.directorSkill, boot.cachedContext.templateSkill]);
975
+ const fileInputRef = useRef(null);
976
+ const viewportRef = useRef(null);
977
+ const shouldStickToChatBottomRef = useRef(true);
978
+ const forceChatBottomOnNextRenderRef = useRef(false);
979
+ const previousActiveThreadIdRef = useRef(null);
980
+ const activeSendRef = useRef(null);
981
+ const activeJobPollersRef = useRef(new Map());
982
+ const jobPollGateRef = useRef({ nextAvailableAt: 0 });
983
+ const threadsRef = useRef([]);
984
+ const [threads, setThreads] = useState([]);
985
+ const [activeThreadId, setActiveThreadId] = useState(null);
986
+ const [openThreadMenu, setOpenThreadMenu] = useState(null);
987
+ const [draft, setDraft] = useState("");
988
+ const [pendingAttachments, setPendingAttachments] = useState([]);
989
+ const [lastError, setLastError] = useState(null);
990
+ const [sendingThreadId, setSendingThreadId] = useState(null);
991
+ const [loadingStatus, setLoadingStatus] = useState(null);
992
+ const [hasHydrated, setHasHydrated] = useState(false);
993
+ const [isDropTarget, setIsDropTarget] = useState(false);
994
+ const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
995
+ const [tracers, setTracers] = useState(() => {
996
+ const bridge = typeof window !== "undefined" ? window.__vidfarmEditorTracers : null;
997
+ if (bridge) {
998
+ return bridge.get();
999
+ }
1000
+ if (typeof window !== "undefined") {
1001
+ const fromUrl = readTracersFromLocation();
1002
+ if (fromUrl.length) {
1003
+ return fromUrl;
1004
+ }
1005
+ }
1006
+ return normalizeTracerList(boot.template.tracers || []);
1007
+ });
1008
+ const threadListUrl = useMemo(() => {
1009
+ if (!boot.threadsUrl) {
1010
+ return null;
1011
+ }
1012
+ const url = new URL(boot.threadsUrl, window.location.origin);
1013
+ url.searchParams.set("template_id", boot.template.templateId);
1014
+ return url.toString();
1015
+ }, [boot.template.templateId, boot.threadsUrl]);
1016
+ const threadDetailUrl = (threadId) => (boot.threadsUrl
1017
+ ? new URL(`${boot.threadsUrl.replace(/\/$/, "")}/${encodeURIComponent(threadId)}`, window.location.origin).toString()
1018
+ : null);
1019
+ useEffect(() => {
1020
+ const hydratedThreads = hydrateThreads(chatStorage?.getItem(storageKeys.threads) ?? null);
1021
+ const savedActiveThread = chatStorage?.getItem(storageKeys.activeThread) ?? null;
1022
+ const currentTracers = window.__vidfarmEditorTracers?.get() ?? readTracersFromLocation();
1023
+ const visibleHydratedThreads = hydratedThreads.filter((thread) => !thread.hiddenFromView);
1024
+ const newestMatchingThread = visibleHydratedThreads.find((thread) => threadMatchesTracers(thread, currentTracers));
1025
+ threadsRef.current = hydratedThreads;
1026
+ setThreads(hydratedThreads);
1027
+ if (savedActiveThread
1028
+ && visibleHydratedThreads.some((thread) => thread.id === savedActiveThread)
1029
+ && (!currentTracers.length || visibleHydratedThreads.some((thread) => thread.id === savedActiveThread && threadMatchesTracers(thread, currentTracers)))) {
1030
+ setActiveThreadId(savedActiveThread);
1031
+ }
1032
+ else {
1033
+ setActiveThreadId(newestMatchingThread?.id ?? visibleHydratedThreads[0]?.id ?? null);
1034
+ }
1035
+ setHasHydrated(true);
1036
+ }, [chatStorage, storageKeys]);
1037
+ useEffect(() => {
1038
+ if (!boot.vidfarmApiKey || !threadListUrl) {
1039
+ return;
1040
+ }
1041
+ const abortController = new AbortController();
1042
+ void (async () => {
1043
+ try {
1044
+ const response = await fetch(threadListUrl, {
1045
+ method: "GET",
1046
+ headers: buildAuthHeaders(boot.vidfarmApiKey),
1047
+ signal: abortController.signal
1048
+ });
1049
+ if (!response.ok) {
1050
+ return;
1051
+ }
1052
+ const payload = await response.json().catch(() => null);
1053
+ const remoteThreads = Array.isArray(payload?.threads)
1054
+ ? payload.threads.map(hydrateThreadRecord).filter((thread) => Boolean(thread))
1055
+ : [];
1056
+ if (!remoteThreads.length) {
1057
+ return;
1058
+ }
1059
+ setThreads((current) => mergeThreadSets(current, remoteThreads));
1060
+ setActiveThreadId((current) => {
1061
+ if (current && remoteThreads.some((thread) => thread.id === current)) {
1062
+ return current;
1063
+ }
1064
+ return remoteThreads[0]?.id ?? current;
1065
+ });
1066
+ }
1067
+ catch (error) {
1068
+ if (error instanceof DOMException && error.name === "AbortError") {
1069
+ return;
1070
+ }
1071
+ }
1072
+ })();
1073
+ return () => {
1074
+ abortController.abort();
1075
+ };
1076
+ }, [boot.vidfarmApiKey, threadListUrl]);
1077
+ useEffect(() => {
1078
+ threadsRef.current = threads;
1079
+ }, [threads]);
1080
+ useEffect(() => {
1081
+ const bridge = window.__vidfarmEditorTracers;
1082
+ if (bridge) {
1083
+ setTracers(bridge.get());
1084
+ return bridge.subscribe((nextTracers) => setTracers(nextTracers));
1085
+ }
1086
+ const syncFromLocation = () => {
1087
+ const fromUrl = readTracersFromLocation();
1088
+ setTracers(fromUrl.length ? fromUrl : normalizeTracerList(boot.template.tracers || []));
1089
+ };
1090
+ syncFromLocation();
1091
+ window.addEventListener("popstate", syncFromLocation);
1092
+ return () => {
1093
+ window.removeEventListener("popstate", syncFromLocation);
1094
+ };
1095
+ }, [boot.template.tracers]);
1096
+ useEffect(() => {
1097
+ if (!hasHydrated) {
1098
+ return;
1099
+ }
1100
+ try {
1101
+ chatStorage?.setItem(storageKeys.threads, JSON.stringify(serializeThreads(threads)));
1102
+ }
1103
+ catch (error) {
1104
+ console.warn("Unable to persist editor chat threads.", error);
1105
+ }
1106
+ }, [chatStorage, hasHydrated, storageKeys, threads]);
1107
+ useEffect(() => {
1108
+ if (!hasHydrated) {
1109
+ return;
1110
+ }
1111
+ try {
1112
+ if (activeThreadId) {
1113
+ chatStorage?.setItem(storageKeys.activeThread, activeThreadId);
1114
+ }
1115
+ else {
1116
+ chatStorage?.removeItem(storageKeys.activeThread);
1117
+ }
1118
+ }
1119
+ catch (error) {
1120
+ console.warn("Unable to persist active editor chat thread.", error);
1121
+ }
1122
+ }, [activeThreadId, chatStorage, hasHydrated, storageKeys]);
1123
+ const activeThread = useMemo(() => threads.find((thread) => thread.id === activeThreadId) ?? null, [activeThreadId, threads]);
1124
+ const openThreadMenuThread = useMemo(() => threads.find((thread) => thread.id === openThreadMenu?.threadId) ?? null, [openThreadMenu?.threadId, threads]);
1125
+ useEffect(() => {
1126
+ if (!hasHydrated || activeThread && !activeThread.hiddenFromView) {
1127
+ return;
1128
+ }
1129
+ const newestMatchingThread = threads.find((thread) => !thread.hiddenFromView && threadMatchesTracers(thread, tracers));
1130
+ setActiveThreadId(newestMatchingThread?.id ?? threads.find((thread) => !thread.hiddenFromView)?.id ?? null);
1131
+ }, [activeThread, hasHydrated, threads, tracers]);
1132
+ const requestTracers = useMemo(() => activeThread?.tracers.length ? activeThread.tracers : tracers, [activeThread?.tracers, tracers]);
1133
+ const visibleThreads = useMemo(() => (threads.filter((thread) => !thread.hiddenFromView).sort((a, b) => {
1134
+ const aMatches = threadMatchesTracers(a, tracers);
1135
+ const bMatches = threadMatchesTracers(b, tracers);
1136
+ if (aMatches !== bMatches) {
1137
+ return aMatches ? -1 : 1;
1138
+ }
1139
+ return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
1140
+ })), [threads, tracers]);
1141
+ const defaultRequestTracer = requestTracers[0] ?? null;
1142
+ const messages = activeThread?.messages ?? [];
1143
+ const isAnyThreadSending = sendingThreadId !== null;
1144
+ const isActiveThreadSending = Boolean(activeThreadId && sendingThreadId === activeThreadId);
1145
+ const canSend = useMemo(() => (draft.trim() || pendingAttachments.length) && !isAnyThreadSending, [draft, pendingAttachments.length, isAnyThreadSending]);
1146
+ async function updateRemoteThreadState(thread, overrides) {
1147
+ if (!boot.vidfarmApiKey || !boot.threadsUrl || !thread.messages.length) {
1148
+ return;
1149
+ }
1150
+ const url = threadDetailUrl(thread.id);
1151
+ if (!url) {
1152
+ return;
1153
+ }
1154
+ await fetch(url, {
1155
+ method: "PATCH",
1156
+ headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
1157
+ body: JSON.stringify({
1158
+ template_id: boot.template.templateId,
1159
+ title: overrides?.title ?? thread.title,
1160
+ tracers: overrides?.tracers ?? thread.tracers,
1161
+ hidden_from_view: overrides?.hiddenFromView ?? (thread.hiddenFromView === true),
1162
+ archived: overrides?.archived
1163
+ })
1164
+ }).catch(() => { });
1165
+ }
1166
+ async function deleteRemoteThreadState(threadId) {
1167
+ if (!boot.vidfarmApiKey || !boot.threadsUrl) {
1168
+ return;
1169
+ }
1170
+ const url = threadDetailUrl(threadId);
1171
+ if (!url) {
1172
+ return;
1173
+ }
1174
+ await fetch(url, {
1175
+ method: "DELETE",
1176
+ headers: buildAuthHeaders(boot.vidfarmApiKey)
1177
+ }).catch(() => { });
1178
+ }
1179
+ function syncTracerUrl(nextTracers) {
1180
+ const url = new URL(window.location.href);
1181
+ if (nextTracers.length) {
1182
+ url.searchParams.set("tracers", nextTracers.join(","));
1183
+ }
1184
+ else {
1185
+ url.searchParams.delete("tracers");
1186
+ }
1187
+ window.history.replaceState({}, "", url.toString());
1188
+ window.dispatchEvent(new CustomEvent("vidfarm:editor-tracers-changed", {
1189
+ detail: {
1190
+ tracers: nextTracers
1191
+ }
1192
+ }));
1193
+ }
1194
+ function applyTracerList(nextTracers) {
1195
+ const normalized = normalizeTracerList(nextTracers);
1196
+ const bridge = window.__vidfarmEditorTracers;
1197
+ if (bridge) {
1198
+ bridge.set(normalized);
1199
+ return;
1200
+ }
1201
+ setTracers(normalized);
1202
+ syncTracerUrl(normalized);
1203
+ }
1204
+ function createTracer() {
1205
+ const bridge = window.__vidfarmEditorTracers;
1206
+ if (bridge) {
1207
+ return bridge.add();
1208
+ }
1209
+ const tracer = makeTracerId(boot.template.templateSlug, tracers);
1210
+ applyTracerList([tracer, ...tracers]);
1211
+ return tracer;
1212
+ }
1213
+ function addAiTracer(tracer) {
1214
+ const existing = window.__vidfarmEditorTracers?.get() ?? tracers;
1215
+ const normalized = tracer ? ensureTracerHasRandomSuffix(tracer, existing) : null;
1216
+ const bridge = window.__vidfarmEditorTracers;
1217
+ if (bridge) {
1218
+ return bridge.add(normalized ?? undefined);
1219
+ }
1220
+ if (normalized) {
1221
+ applyTracerList([normalized, ...existing.filter((entry) => entry !== normalized)]);
1222
+ return normalized;
1223
+ }
1224
+ return createTracer();
1225
+ }
1226
+ function setDefaultRequestTracerSelection(tracer) {
1227
+ const normalized = tracer.trim();
1228
+ if (!normalized) {
1229
+ return;
1230
+ }
1231
+ const bridge = window.__vidfarmEditorTracers;
1232
+ if (bridge) {
1233
+ bridge.add(normalized);
1234
+ }
1235
+ else if (!tracers.includes(normalized)) {
1236
+ applyTracerList([normalized, ...tracers]);
1237
+ }
1238
+ if (activeThread) {
1239
+ updateThread(activeThread.id, (thread) => ({
1240
+ ...thread,
1241
+ tracers: [normalized, ...thread.tracers.filter((entry) => entry !== normalized)],
1242
+ updatedAt: new Date().toISOString()
1243
+ }));
1244
+ }
1245
+ }
1246
+ function removeTracer(tracer) {
1247
+ const bridge = window.__vidfarmEditorTracers;
1248
+ if (bridge) {
1249
+ bridge.remove(tracer);
1250
+ return;
1251
+ }
1252
+ const remaining = tracers.filter((entry) => entry !== tracer);
1253
+ applyTracerList(remaining);
1254
+ }
1255
+ useEffect(() => {
1256
+ syncTracerUrl(tracers);
1257
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1258
+ }, []);
1259
+ useEffect(() => {
1260
+ const viewport = viewportRef.current;
1261
+ if (!viewport) {
1262
+ return;
1263
+ }
1264
+ const handleScroll = () => {
1265
+ shouldStickToChatBottomRef.current = isScrolledNearBottom(viewport);
1266
+ if (!shouldStickToChatBottomRef.current) {
1267
+ forceChatBottomOnNextRenderRef.current = false;
1268
+ }
1269
+ };
1270
+ handleScroll();
1271
+ viewport.addEventListener("scroll", handleScroll, { passive: true });
1272
+ return () => {
1273
+ viewport.removeEventListener("scroll", handleScroll);
1274
+ };
1275
+ }, []);
1276
+ useLayoutEffect(() => {
1277
+ const viewport = viewportRef.current;
1278
+ if (!viewport) {
1279
+ return;
1280
+ }
1281
+ const activeThreadChanged = previousActiveThreadIdRef.current !== activeThreadId;
1282
+ previousActiveThreadIdRef.current = activeThreadId;
1283
+ if (activeThreadChanged || forceChatBottomOnNextRenderRef.current || shouldStickToChatBottomRef.current) {
1284
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior: "auto" });
1285
+ shouldStickToChatBottomRef.current = true;
1286
+ forceChatBottomOnNextRenderRef.current = false;
1287
+ }
1288
+ }, [messages, activeThreadId]);
1289
+ useEffect(() => {
1290
+ return () => {
1291
+ activeSendRef.current?.abortController.abort();
1292
+ activeSendRef.current = null;
1293
+ for (const poller of activeJobPollersRef.current.values()) {
1294
+ if (poller.timeoutId !== null) {
1295
+ window.clearTimeout(poller.timeoutId);
1296
+ }
1297
+ poller.abortController?.abort();
1298
+ }
1299
+ activeJobPollersRef.current.clear();
1300
+ };
1301
+ }, []);
1302
+ useEffect(() => {
1303
+ if (!openThreadMenu) {
1304
+ return;
1305
+ }
1306
+ const closeMenu = () => setOpenThreadMenu(null);
1307
+ document.addEventListener("click", closeMenu);
1308
+ window.addEventListener("resize", closeMenu);
1309
+ window.addEventListener("scroll", closeMenu, true);
1310
+ return () => {
1311
+ document.removeEventListener("click", closeMenu);
1312
+ window.removeEventListener("resize", closeMenu);
1313
+ window.removeEventListener("scroll", closeMenu, true);
1314
+ };
1315
+ }, [openThreadMenu]);
1316
+ useEffect(() => {
1317
+ const resumePolling = () => {
1318
+ if (document.visibilityState !== "visible" || !navigator.onLine) {
1319
+ return;
1320
+ }
1321
+ const now = Date.now();
1322
+ for (const poller of activeJobPollersRef.current.values()) {
1323
+ if (poller.inFlight) {
1324
+ continue;
1325
+ }
1326
+ if (poller.nextPollAt !== null && poller.nextPollAt <= now && poller.runPoll) {
1327
+ scheduleNextJobPollForPoller(poller, poller.runPoll, poller.nextPollAt - now);
1328
+ }
1329
+ }
1330
+ };
1331
+ window.addEventListener("online", resumePolling);
1332
+ document.addEventListener("visibilitychange", resumePolling);
1333
+ return () => {
1334
+ window.removeEventListener("online", resumePolling);
1335
+ document.removeEventListener("visibilitychange", resumePolling);
1336
+ };
1337
+ }, []);
1338
+ function ensureActiveThread(seedText) {
1339
+ if (activeThread) {
1340
+ return activeThread.id;
1341
+ }
1342
+ const now = new Date().toISOString();
1343
+ const threadId = makeThreadId();
1344
+ const nextThread = {
1345
+ id: threadId,
1346
+ title: formatThreadTitle(seedText || ""),
1347
+ createdAt: now,
1348
+ updatedAt: now,
1349
+ tracers: normalizeTracerList(tracers),
1350
+ messages: []
1351
+ };
1352
+ setThreads((current) => [nextThread, ...current]);
1353
+ setActiveThreadId(threadId);
1354
+ return threadId;
1355
+ }
1356
+ function createNewThread(options) {
1357
+ const now = new Date().toISOString();
1358
+ const threadTracers = normalizeTracerList(options?.tracers ?? tracers);
1359
+ const threadId = makeThreadId();
1360
+ const nextThread = {
1361
+ id: threadId,
1362
+ title: options?.title || "New chat",
1363
+ createdAt: now,
1364
+ updatedAt: now,
1365
+ tracers: threadTracers,
1366
+ messages: []
1367
+ };
1368
+ threadsRef.current = [nextThread, ...threadsRef.current];
1369
+ setThreads((current) => [nextThread, ...current]);
1370
+ if (options?.activate !== false) {
1371
+ setActiveThreadId(threadId);
1372
+ setDraft("");
1373
+ setPendingAttachments([]);
1374
+ setLastError(null);
1375
+ }
1376
+ setOpenThreadMenu(null);
1377
+ return threadId;
1378
+ }
1379
+ function createNewThreadFromClick() {
1380
+ createNewThread();
1381
+ }
1382
+ function deleteThread(threadId) {
1383
+ const thread = threads.find((entry) => entry.id === threadId);
1384
+ if (!thread) {
1385
+ return;
1386
+ }
1387
+ if (!window.confirm(`Delete conversation "${thread.title}"?`)) {
1388
+ return;
1389
+ }
1390
+ const remainingThreads = threads.filter((entry) => entry.id !== threadId);
1391
+ for (const [jobId, poller] of activeJobPollersRef.current.entries()) {
1392
+ if (poller.threadId === threadId) {
1393
+ stopJobPoller(jobId);
1394
+ }
1395
+ }
1396
+ setThreads(remainingThreads);
1397
+ if (activeThreadId === threadId) {
1398
+ setActiveThreadId(remainingThreads[0]?.id ?? null);
1399
+ }
1400
+ setLastError(null);
1401
+ setOpenThreadMenu(null);
1402
+ void deleteRemoteThreadState(threadId);
1403
+ }
1404
+ function updateThread(threadId, updater) {
1405
+ setThreads((current) => {
1406
+ const next = current.map((thread) => (thread.id === threadId ? updater(thread) : thread));
1407
+ return [...next].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
1408
+ });
1409
+ }
1410
+ function attachTracersToThread(threadId, nextTracers) {
1411
+ const normalized = normalizeTracerList(nextTracers);
1412
+ updateThread(threadId, (thread) => ({
1413
+ ...thread,
1414
+ tracers: mergeTracerLists(thread.tracers, normalized),
1415
+ updatedAt: new Date().toISOString()
1416
+ }));
1417
+ const thread = threadsRef.current.find((entry) => entry.id === threadId);
1418
+ if (thread) {
1419
+ void updateRemoteThreadState({
1420
+ ...thread,
1421
+ tracers: mergeTracerLists(thread.tracers, normalized),
1422
+ updatedAt: new Date().toISOString()
1423
+ }, {
1424
+ tracers: mergeTracerLists(thread.tracers, normalized)
1425
+ });
1426
+ }
1427
+ }
1428
+ function findThreadIdForTracer(tracer, exceptThreadId) {
1429
+ const normalized = tracer.trim();
1430
+ if (!normalized) {
1431
+ return null;
1432
+ }
1433
+ return threadsRef.current.find((thread) => (!thread.hiddenFromView && thread.id !== exceptThreadId && thread.tracers.includes(normalized)))?.id ?? null;
1434
+ }
1435
+ function ensureThreadForTracer(tracer, options) {
1436
+ const existing = findThreadIdForTracer(tracer);
1437
+ if (existing) {
1438
+ return existing;
1439
+ }
1440
+ return createNewThread({
1441
+ tracers: [tracer],
1442
+ title: options?.title || `Tracer ${tracer}`,
1443
+ activate: options?.activate ?? false
1444
+ });
1445
+ }
1446
+ function userRequestedSeparateTracerThreads(text) {
1447
+ const normalized = text.toLowerCase();
1448
+ return /(?:own|separate|different|new)\s+(?:tracer\s+and\s+)?chat\s+threads?/.test(normalized)
1449
+ || /chat\s+threads?.{0,80}(?:own|separate|different|new)\s+tracers?/.test(normalized)
1450
+ || /each.{0,80}(?:own|separate|different|new).{0,80}chat\s+threads?/.test(normalized);
1451
+ }
1452
+ function mirrorJobToTracerThread(input) {
1453
+ const existingThreadId = findThreadIdForTracer(input.tracer, input.sourceThreadId);
1454
+ if (!existingThreadId && !input.shouldCreateThread) {
1455
+ return null;
1456
+ }
1457
+ const targetThreadId = existingThreadId ?? ensureThreadForTracer(input.tracer, {
1458
+ title: `Tracer ${input.tracer}`,
1459
+ activate: false
1460
+ });
1461
+ if (!targetThreadId || targetThreadId === input.sourceThreadId) {
1462
+ return null;
1463
+ }
1464
+ const messageId = makeMessageId("assistant");
1465
+ const now = new Date().toISOString();
1466
+ const messageText = input.job.status === "succeeded" || input.job.status === "failed" || input.job.status === "cancelled"
1467
+ ? buildJobCompletionMessage(input.job)
1468
+ : buildJobQueuedMessage(input.job);
1469
+ const statusText = input.job.status === "succeeded" || input.job.status === "failed" || input.job.status === "cancelled"
1470
+ ? null
1471
+ : `Job ${input.job.job_id} is being tracked. ${getJobPollCadenceText()}`;
1472
+ updateThread(targetThreadId, (thread) => ({
1473
+ ...thread,
1474
+ updatedAt: now,
1475
+ messages: [
1476
+ ...thread.messages,
1477
+ {
1478
+ id: messageId,
1479
+ role: "assistant",
1480
+ text: messageText,
1481
+ attachments: [],
1482
+ httpExchanges: input.httpExchange ? [input.httpExchange] : [],
1483
+ statusText,
1484
+ isPending: false
1485
+ }
1486
+ ]
1487
+ }));
1488
+ return { threadId: targetThreadId, messageId };
1489
+ }
1490
+ function editThreadTracers(threadId) {
1491
+ const thread = threads.find((entry) => entry.id === threadId);
1492
+ if (!thread) {
1493
+ return;
1494
+ }
1495
+ const value = window.prompt("Thread tracers, comma separated", thread.tracers.join(", "));
1496
+ if (value === null) {
1497
+ return;
1498
+ }
1499
+ const nextTracers = normalizeTracerList(value.split(","));
1500
+ updateThread(threadId, (current) => ({
1501
+ ...current,
1502
+ tracers: nextTracers,
1503
+ updatedAt: new Date().toISOString()
1504
+ }));
1505
+ void updateRemoteThreadState(thread, { tracers: nextTracers });
1506
+ setOpenThreadMenu(null);
1507
+ }
1508
+ function archiveThread(threadId) {
1509
+ const thread = threads.find((entry) => entry.id === threadId);
1510
+ if (!thread) {
1511
+ return;
1512
+ }
1513
+ const confirmed = window.confirm(`Archive conversation "${thread.title}"? Its tracer links will be prefixed with archived_.`);
1514
+ if (!confirmed) {
1515
+ return;
1516
+ }
1517
+ const nextTracers = normalizeTracerList(thread.tracers.map(archiveTracerValue));
1518
+ updateThread(threadId, (current) => ({
1519
+ ...current,
1520
+ tracers: nextTracers,
1521
+ updatedAt: new Date().toISOString()
1522
+ }));
1523
+ void updateRemoteThreadState(thread, { tracers: nextTracers, archived: true });
1524
+ setOpenThreadMenu(null);
1525
+ }
1526
+ function removeThreadFromView(threadId) {
1527
+ const thread = threads.find((entry) => entry.id === threadId);
1528
+ if (!thread) {
1529
+ return;
1530
+ }
1531
+ const confirmed = window.confirm(`Remove conversation "${thread.title}" from this frontend view? The thread and tracer links stay available; Delete removes the thread completely.`);
1532
+ if (!confirmed) {
1533
+ return;
1534
+ }
1535
+ for (const [jobId, poller] of activeJobPollersRef.current.entries()) {
1536
+ if (poller.threadId === threadId) {
1537
+ stopJobPoller(jobId);
1538
+ }
1539
+ }
1540
+ updateThread(threadId, (current) => ({
1541
+ ...current,
1542
+ hiddenFromView: true,
1543
+ updatedAt: new Date().toISOString()
1544
+ }));
1545
+ if (activeThreadId === threadId) {
1546
+ const nextThread = visibleThreads.find((entry) => entry.id !== threadId);
1547
+ setActiveThreadId(nextThread?.id ?? null);
1548
+ }
1549
+ setOpenThreadMenu(null);
1550
+ setLastError(null);
1551
+ void updateRemoteThreadState(thread, { hiddenFromView: true });
1552
+ }
1553
+ async function copyThreadId(threadId) {
1554
+ try {
1555
+ if (navigator.clipboard?.writeText) {
1556
+ await navigator.clipboard.writeText(threadId);
1557
+ }
1558
+ else {
1559
+ const textarea = document.createElement("textarea");
1560
+ textarea.value = threadId;
1561
+ textarea.setAttribute("readonly", "");
1562
+ textarea.style.position = "fixed";
1563
+ textarea.style.opacity = "0";
1564
+ document.body.appendChild(textarea);
1565
+ textarea.select();
1566
+ document.execCommand("copy");
1567
+ document.body.removeChild(textarea);
1568
+ }
1569
+ setLastError(null);
1570
+ }
1571
+ catch {
1572
+ setLastError("Unable to copy chat ID.");
1573
+ }
1574
+ finally {
1575
+ setOpenThreadMenu(null);
1576
+ }
1577
+ }
1578
+ function toggleThreadMenu(threadId, button) {
1579
+ if (openThreadMenu?.threadId === threadId) {
1580
+ setOpenThreadMenu(null);
1581
+ return;
1582
+ }
1583
+ const rect = button.getBoundingClientRect();
1584
+ const menuWidth = 148;
1585
+ setOpenThreadMenu({
1586
+ threadId,
1587
+ top: rect.bottom + 6,
1588
+ left: Math.max(8, Math.min(window.innerWidth - menuWidth - 8, rect.right - menuWidth))
1589
+ });
1590
+ }
1591
+ function stopJobPoller(jobId) {
1592
+ const poller = activeJobPollersRef.current.get(jobId);
1593
+ if (!poller) {
1594
+ return;
1595
+ }
1596
+ if (poller.timeoutId !== null) {
1597
+ window.clearTimeout(poller.timeoutId);
1598
+ }
1599
+ poller.abortController?.abort();
1600
+ activeJobPollersRef.current.delete(jobId);
1601
+ }
1602
+ function scheduleNextJobPoll(jobId, runPoll, delayMs) {
1603
+ const poller = activeJobPollersRef.current.get(jobId);
1604
+ if (!poller) {
1605
+ return;
1606
+ }
1607
+ scheduleNextJobPollForPoller(poller, runPoll, delayMs);
1608
+ }
1609
+ function scheduleNextJobPollForPoller(poller, runPoll, delayMs) {
1610
+ if (poller.timeoutId !== null) {
1611
+ window.clearTimeout(poller.timeoutId);
1612
+ }
1613
+ const safeDelayMs = Math.max(JOB_POLL_MIN_TIMER_MS, delayMs);
1614
+ poller.nextPollAt = Date.now() + safeDelayMs;
1615
+ poller.timeoutId = window.setTimeout(() => {
1616
+ poller.timeoutId = null;
1617
+ void runPoll();
1618
+ }, safeDelayMs);
1619
+ }
1620
+ function getJobPollIntervalMs() {
1621
+ return activeJobPollersRef.current.size >= JOB_POLL_BUSY_THRESHOLD
1622
+ ? JOB_POLL_BUSY_INTERVAL_MS
1623
+ : JOB_POLL_INTERVAL_MS;
1624
+ }
1625
+ function formatJobPollInterval(ms = getJobPollIntervalMs()) {
1626
+ if (ms >= 60_000) {
1627
+ const minutes = Math.round(ms / 60_000);
1628
+ return `${minutes} min${minutes === 1 ? "" : "s"}`;
1629
+ }
1630
+ return `${Math.round(ms / 1_000)}s`;
1631
+ }
1632
+ function getJobPollCadenceText(ms = getJobPollIntervalMs()) {
1633
+ return activeJobPollersRef.current.size >= JOB_POLL_BUSY_THRESHOLD
1634
+ ? `Many jobs are active, so the next check is in ${formatJobPollInterval(ms)}.`
1635
+ : `Checking again gently every ${formatJobPollInterval(ms)}.`;
1636
+ }
1637
+ function rescheduleActiveJobPollersForLoad() {
1638
+ const intervalMs = getJobPollIntervalMs();
1639
+ if (intervalMs === JOB_POLL_INTERVAL_MS) {
1640
+ return;
1641
+ }
1642
+ const now = Date.now();
1643
+ for (const poller of activeJobPollersRef.current.values()) {
1644
+ if (poller.inFlight || !poller.runPoll) {
1645
+ continue;
1646
+ }
1647
+ const remainingMs = poller.nextPollAt === null ? 0 : poller.nextPollAt - now;
1648
+ if (remainingMs < intervalMs) {
1649
+ scheduleNextJobPollForPoller(poller, poller.runPoll, intervalMs);
1650
+ }
1651
+ }
1652
+ }
1653
+ function reserveJobPollSlot(now) {
1654
+ const slotAt = Math.max(now, jobPollGateRef.current.nextAvailableAt);
1655
+ jobPollGateRef.current.nextAvailableAt = slotAt + JOB_POLL_GLOBAL_MIN_GAP_MS;
1656
+ return slotAt - now;
1657
+ }
1658
+ function scheduleJobPolling(input) {
1659
+ if (activeJobPollersRef.current.has(input.jobId)) {
1660
+ return;
1661
+ }
1662
+ activeJobPollersRef.current.set(input.jobId, {
1663
+ timeoutId: null,
1664
+ abortController: null,
1665
+ runPoll: null,
1666
+ inFlight: false,
1667
+ nextPollAt: null,
1668
+ lastPollAt: null,
1669
+ attempts: 0,
1670
+ threadId: input.threadId
1671
+ });
1672
+ const runPoll = async () => {
1673
+ const poller = activeJobPollersRef.current.get(input.jobId);
1674
+ if (!poller) {
1675
+ return;
1676
+ }
1677
+ if (poller.inFlight) {
1678
+ return;
1679
+ }
1680
+ const now = Date.now();
1681
+ const pollIntervalMs = getJobPollIntervalMs();
1682
+ if (poller.lastPollAt !== null && now - poller.lastPollAt < pollIntervalMs) {
1683
+ scheduleNextJobPoll(input.jobId, runPoll, pollIntervalMs - (now - poller.lastPollAt));
1684
+ return;
1685
+ }
1686
+ if (poller.attempts >= JOB_POLL_MAX_ATTEMPTS) {
1687
+ updateThread(input.threadId, (thread) => ({
1688
+ ...thread,
1689
+ updatedAt: new Date().toISOString(),
1690
+ messages: thread.messages.map((message) => (message.id === input.assistantMessageId
1691
+ ? {
1692
+ ...message,
1693
+ statusText: "Background polling stopped after multiple checks. Open the job route when you want to refresh again."
1694
+ }
1695
+ : message))
1696
+ }));
1697
+ stopJobPoller(input.jobId);
1698
+ return;
1699
+ }
1700
+ if (document.visibilityState !== "visible" || !navigator.onLine) {
1701
+ scheduleNextJobPoll(input.jobId, runPoll, pollIntervalMs);
1702
+ return;
1703
+ }
1704
+ const globalDelayMs = reserveJobPollSlot(Date.now());
1705
+ if (globalDelayMs > 0) {
1706
+ scheduleNextJobPoll(input.jobId, runPoll, globalDelayMs);
1707
+ return;
1708
+ }
1709
+ poller.inFlight = true;
1710
+ poller.lastPollAt = now;
1711
+ poller.nextPollAt = null;
1712
+ poller.attempts += 1;
1713
+ poller.abortController = new AbortController();
1714
+ try {
1715
+ updateThread(input.threadId, (thread) => ({
1716
+ ...thread,
1717
+ updatedAt: new Date().toISOString(),
1718
+ messages: thread.messages.map((message) => (message.id === input.assistantMessageId
1719
+ ? {
1720
+ ...message,
1721
+ statusText: `Job ${input.jobId} is being tracked. ${getJobPollCadenceText(pollIntervalMs)}`
1722
+ }
1723
+ : message))
1724
+ }));
1725
+ const url = `/api/v1/templates/${encodeURIComponent(boot.template.templateId)}/jobs/${encodeURIComponent(input.jobId)}`;
1726
+ const response = await fetch(url, {
1727
+ method: "GET",
1728
+ headers: buildAuthHeaders(boot.vidfarmApiKey),
1729
+ signal: poller.abortController.signal
1730
+ });
1731
+ const responseBody = await response.json().catch(() => null);
1732
+ const responseHeaders = Object.fromEntries(response.headers.entries());
1733
+ const payload = tryReadJobStatusPayload(responseBody);
1734
+ updateThread(input.threadId, (thread) => ({
1735
+ ...thread,
1736
+ updatedAt: new Date().toISOString(),
1737
+ messages: thread.messages.map((message) => {
1738
+ if (message.id !== input.assistantMessageId) {
1739
+ return message;
1740
+ }
1741
+ const nextHttpExchanges = upsertAutomaticPollExchange(message.httpExchanges, sanitizeHttpExchange({
1742
+ explanation: `Automatic ${formatJobPollInterval(pollIntervalMs)} job status check for ${input.jobId}`,
1743
+ pollJobId: input.jobId,
1744
+ pollMode: "automatic",
1745
+ request: {
1746
+ method: "GET",
1747
+ url,
1748
+ headers: Object.fromEntries(buildAuthHeaders(boot.vidfarmApiKey).entries())
1749
+ },
1750
+ result: {
1751
+ status: response.status,
1752
+ statusText: response.statusText,
1753
+ headers: responseHeaders,
1754
+ body: responseBody
1755
+ }
1756
+ }));
1757
+ const nextStatusText = payload
1758
+ ? payload.status === "succeeded"
1759
+ ? `Job ${payload.job_id} succeeded.`
1760
+ : payload.status === "failed" || payload.status === "cancelled"
1761
+ ? `Job ${payload.job_id} ${payload.status}.${payload.error?.message ? ` ${payload.error.message}` : ""}`
1762
+ : `Job ${payload.job_id} is ${payload.status}. Next check in ${formatJobPollInterval(pollIntervalMs)}.`
1763
+ : `Job ${input.jobId} checked. Next check in ${formatJobPollInterval(pollIntervalMs)}.`;
1764
+ return {
1765
+ ...message,
1766
+ httpExchanges: nextHttpExchanges,
1767
+ statusText: nextStatusText,
1768
+ text: payload && (payload.status === "succeeded" || payload.status === "failed" || payload.status === "cancelled")
1769
+ ? appendDistinctMessageText(message.text, buildJobCompletionMessage(payload))
1770
+ : message.text
1771
+ };
1772
+ })
1773
+ }));
1774
+ if (payload && (payload.status === "succeeded" || payload.status === "failed" || payload.status === "cancelled")) {
1775
+ stopJobPoller(input.jobId);
1776
+ return;
1777
+ }
1778
+ }
1779
+ catch (error) {
1780
+ if (error instanceof DOMException && error.name === "AbortError") {
1781
+ return;
1782
+ }
1783
+ updateThread(input.threadId, (thread) => ({
1784
+ ...thread,
1785
+ updatedAt: new Date().toISOString(),
1786
+ messages: thread.messages.map((message) => (message.id === input.assistantMessageId
1787
+ ? {
1788
+ ...message,
1789
+ statusText: `Automatic polling paused after an error. It will retry in ${formatJobPollInterval()}.`
1790
+ }
1791
+ : message))
1792
+ }));
1793
+ }
1794
+ finally {
1795
+ const latestPoller = activeJobPollersRef.current.get(input.jobId);
1796
+ if (!latestPoller) {
1797
+ return;
1798
+ }
1799
+ latestPoller.inFlight = false;
1800
+ latestPoller.abortController = null;
1801
+ if (latestPoller.attempts < JOB_POLL_MAX_ATTEMPTS
1802
+ && latestPoller.timeoutId === null
1803
+ && activeJobPollersRef.current.has(input.jobId)) {
1804
+ scheduleNextJobPoll(input.jobId, runPoll, getJobPollIntervalMs());
1805
+ }
1806
+ }
1807
+ };
1808
+ const initialPoller = activeJobPollersRef.current.get(input.jobId);
1809
+ if (initialPoller) {
1810
+ initialPoller.runPoll = runPoll;
1811
+ }
1812
+ const poller = activeJobPollersRef.current.get(input.jobId);
1813
+ if (poller) {
1814
+ scheduleNextJobPoll(input.jobId, runPoll, getJobPollIntervalMs());
1815
+ rescheduleActiveJobPollersForLoad();
1816
+ }
1817
+ }
1818
+ async function appendFiles(files) {
1819
+ if (!files.length) {
1820
+ return;
1821
+ }
1822
+ setLastError(null);
1823
+ const uploadedAttachments = [];
1824
+ let lastUploadError = null;
1825
+ for (const file of files) {
1826
+ try {
1827
+ const transportData = await fileToDataUrl(file);
1828
+ const uploaded = await uploadAttachment({
1829
+ apiKey: boot.vidfarmApiKey,
1830
+ file
1831
+ });
1832
+ uploadedAttachments.push({
1833
+ ...uploaded,
1834
+ transportData
1835
+ });
1836
+ }
1837
+ catch (error) {
1838
+ lastUploadError = error instanceof Error ? error.message : "Unable to upload attachment.";
1839
+ }
1840
+ }
1841
+ if (lastUploadError && uploadedAttachments.length === 0) {
1842
+ setLastError(lastUploadError);
1843
+ }
1844
+ if (uploadedAttachments.length) {
1845
+ setPendingAttachments((current) => [...current, ...uploadedAttachments]);
1846
+ if (lastUploadError) {
1847
+ setLastError(lastUploadError);
1848
+ }
1849
+ }
1850
+ }
1851
+ async function handlePickFiles(event) {
1852
+ const files = Array.from(event.target.files ?? []);
1853
+ await appendFiles(files);
1854
+ event.target.value = "";
1855
+ }
1856
+ async function handleComposerPaste(event) {
1857
+ const files = Array.from(event.clipboardData.files ?? []).filter((file) => file.size > 0);
1858
+ if (!files.length) {
1859
+ return;
1860
+ }
1861
+ event.preventDefault();
1862
+ await appendFiles(files);
1863
+ }
1864
+ async function handleComposerDrop(event) {
1865
+ event.preventDefault();
1866
+ setIsDropTarget(false);
1867
+ const files = Array.from(event.dataTransfer.files ?? []).filter((file) => file.size > 0);
1868
+ if (!files.length) {
1869
+ return;
1870
+ }
1871
+ await appendFiles(files);
1872
+ }
1873
+ async function handleSend() {
1874
+ if (!canSend) {
1875
+ return;
1876
+ }
1877
+ forceChatBottomOnNextRenderRef.current = true;
1878
+ const threadId = ensureActiveThread(draft);
1879
+ const now = new Date().toISOString();
1880
+ const userMessage = {
1881
+ id: makeMessageId("user"),
1882
+ role: "user",
1883
+ text: draft.trim(),
1884
+ attachments: pendingAttachments
1885
+ };
1886
+ const assistantMessage = {
1887
+ id: makeMessageId("assistant"),
1888
+ role: "assistant",
1889
+ text: "",
1890
+ attachments: [],
1891
+ statusText: "Waiting for AI response…",
1892
+ isPending: true
1893
+ };
1894
+ const existingMessages = threads.find((thread) => thread.id === threadId)?.messages ?? [];
1895
+ const historyForTransport = [...existingMessages, userMessage];
1896
+ updateThread(threadId, (thread) => ({
1897
+ ...thread,
1898
+ title: thread.messages.length === 0 ? formatThreadTitle(userMessage.text) : thread.title,
1899
+ updatedAt: now,
1900
+ messages: [...thread.messages, userMessage, assistantMessage]
1901
+ }));
1902
+ setDraft("");
1903
+ setPendingAttachments([]);
1904
+ setLastError(null);
1905
+ setSendingThreadId(threadId);
1906
+ setLoadingStatus("Waiting for AI response…");
1907
+ const abortController = new AbortController();
1908
+ activeSendRef.current = {
1909
+ abortController,
1910
+ threadId,
1911
+ assistantMessageId: assistantMessage.id
1912
+ };
1913
+ try {
1914
+ const response = await fetch(boot.apiUrl || "/api/v1/editor-chat", {
1915
+ method: "POST",
1916
+ headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
1917
+ signal: abortController.signal,
1918
+ body: JSON.stringify({
1919
+ messages: toTransportMessages(historyForTransport, systemMessages),
1920
+ thread_id: threadId,
1921
+ thread_title: existingMessages.length === 0 ? formatThreadTitle(userMessage.text) : (threads.find((entry) => entry.id === threadId)?.title ?? formatThreadTitle(userMessage.text)),
1922
+ thread_tracers: requestTracers,
1923
+ client_context: buildBrowserDateContext(),
1924
+ user_message: {
1925
+ id: userMessage.id,
1926
+ role: userMessage.role,
1927
+ text: userMessage.text,
1928
+ attachments: userMessage.attachments.map((attachment) => ({
1929
+ id: attachment.id,
1930
+ fileName: attachment.fileName,
1931
+ contentType: attachment.contentType,
1932
+ viewUrl: attachment.viewUrl
1933
+ }))
1934
+ },
1935
+ assistant_message: {
1936
+ id: assistantMessage.id
1937
+ },
1938
+ template: {
1939
+ ...boot.template,
1940
+ tracers: requestTracers,
1941
+ defaultRequestTracer,
1942
+ activeTracer: defaultRequestTracer
1943
+ },
1944
+ cached_context: {
1945
+ ...boot.cachedContext,
1946
+ ...readCachedScheduleChannels(),
1947
+ ...readSelectedLibraryPosts()
1948
+ }
1949
+ })
1950
+ });
1951
+ if (!response.ok) {
1952
+ const payload = await response.json().catch(() => ({}));
1953
+ throw new Error(typeof payload?.error === "string" ? payload.error : `Chat failed with status ${response.status}.`);
1954
+ }
1955
+ const pendingToolCalls = new Map();
1956
+ const shouldSplitJobsIntoThreads = userRequestedSeparateTracerThreads(userMessage.text);
1957
+ await readStreamText(response, {
1958
+ onDelta: (chunk) => {
1959
+ setLoadingStatus("AI is responding…");
1960
+ updateThread(threadId, (thread) => ({
1961
+ ...thread,
1962
+ updatedAt: new Date().toISOString(),
1963
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
1964
+ ? {
1965
+ ...message,
1966
+ text: `${message.text}${chunk}`,
1967
+ statusText: "AI is responding…",
1968
+ isPending: false
1969
+ }
1970
+ : message))
1971
+ }));
1972
+ },
1973
+ onStatus: (status) => {
1974
+ setLoadingStatus(status);
1975
+ updateThread(threadId, (thread) => ({
1976
+ ...thread,
1977
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
1978
+ ? {
1979
+ ...message,
1980
+ statusText: status,
1981
+ isPending: !message.text
1982
+ }
1983
+ : message))
1984
+ }));
1985
+ },
1986
+ onToolCall: (toolCall) => {
1987
+ pendingToolCalls.set(toolCall.toolCallId, {
1988
+ toolName: toolCall.toolName,
1989
+ args: toolCall.args
1990
+ });
1991
+ updateThread(threadId, (thread) => ({
1992
+ ...thread,
1993
+ updatedAt: new Date().toISOString(),
1994
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
1995
+ ? {
1996
+ ...message,
1997
+ toolExchanges: [
1998
+ ...(message.toolExchanges ?? []),
1999
+ {
2000
+ toolCallId: toolCall.toolCallId,
2001
+ toolName: toolCall.toolName,
2002
+ args: sanitizeChatPayload(toolCall.args),
2003
+ status: "pending"
2004
+ }
2005
+ ]
2006
+ }
2007
+ : message))
2008
+ }));
2009
+ },
2010
+ onToolResult: (toolResult) => {
2011
+ const sanitizedToolResult = sanitizeToolResult(toolResult.result);
2012
+ const pendingCall = pendingToolCalls.get(toolResult.toolCallId);
2013
+ if (toolResult.toolName === "frontend_action") {
2014
+ const actionResult = toolResult.result;
2015
+ if (actionResult?.action === "add_tracer") {
2016
+ addAiTracer(actionResult.tracer ?? undefined);
2017
+ }
2018
+ else if (actionResult?.action === "remove_tracer" && actionResult.tracer) {
2019
+ removeTracer(actionResult.tracer);
2020
+ }
2021
+ else if (actionResult?.action === "activate_tracer" && actionResult.tracer) {
2022
+ setDefaultRequestTracerSelection(actionResult.tracer);
2023
+ }
2024
+ else if (actionResult?.action === "create_thread") {
2025
+ const threadTracer = actionResult.tracer ? addAiTracer(actionResult.tracer) : createTracer();
2026
+ createNewThread({
2027
+ tracers: threadTracer ? [threadTracer] : [],
2028
+ title: threadTracer ? `Tracer ${threadTracer}` : "New chat",
2029
+ activate: false
2030
+ });
2031
+ }
2032
+ }
2033
+ const toolResultRecordForMirror = toolResult.result;
2034
+ const jobStatusPayloadForMirror = tryReadJobStatusPayload(toolResultRecordForMirror?.response?.body);
2035
+ const mirrorHttpExchange = toolResultRecordForMirror?.request && toolResultRecordForMirror?.response
2036
+ ? sanitizeHttpExchange({
2037
+ explanation: toolResultRecordForMirror.explanation ?? null,
2038
+ request: toolResultRecordForMirror.request,
2039
+ result: toolResultRecordForMirror.response
2040
+ })
2041
+ : undefined;
2042
+ const mirroredJobTarget = toolResultRecordForMirror?.request?.method === "POST" && jobStatusPayloadForMirror?.tracer
2043
+ ? mirrorJobToTracerThread({
2044
+ sourceThreadId: threadId,
2045
+ tracer: jobStatusPayloadForMirror.tracer,
2046
+ job: jobStatusPayloadForMirror,
2047
+ httpExchange: mirrorHttpExchange,
2048
+ shouldCreateThread: shouldSplitJobsIntoThreads
2049
+ })
2050
+ : null;
2051
+ if (mirroredJobTarget
2052
+ && toolResultRecordForMirror?.request?.method === "POST"
2053
+ && toolResultRecordForMirror?.response?.status === 202
2054
+ && jobStatusPayloadForMirror
2055
+ && jobStatusPayloadForMirror.status !== "succeeded"
2056
+ && jobStatusPayloadForMirror.status !== "failed"
2057
+ && jobStatusPayloadForMirror.status !== "cancelled") {
2058
+ scheduleJobPolling({
2059
+ threadId: mirroredJobTarget.threadId,
2060
+ assistantMessageId: mirroredJobTarget.messageId,
2061
+ jobId: jobStatusPayloadForMirror.job_id,
2062
+ tracer: jobStatusPayloadForMirror.tracer,
2063
+ initialStatus: jobStatusPayloadForMirror.status
2064
+ });
2065
+ }
2066
+ updateThread(threadId, (thread) => ({
2067
+ ...thread,
2068
+ updatedAt: new Date().toISOString(),
2069
+ messages: thread.messages.map((message) => {
2070
+ if (message.id !== assistantMessage.id) {
2071
+ return message;
2072
+ }
2073
+ const nextToolExchanges = (message.toolExchanges ?? []).map((exchange) => (exchange.toolCallId === toolResult.toolCallId
2074
+ ? {
2075
+ ...exchange,
2076
+ result: sanitizedToolResult,
2077
+ status: (typeof toolResult.result?.error === "string" ? "error" : "complete")
2078
+ }
2079
+ : exchange));
2080
+ const toolResultRecord = toolResult.result;
2081
+ const jobStatusPayload = tryReadJobStatusPayload(toolResultRecord?.response?.body);
2082
+ const nextHttpExchanges = toolResultRecord?.request && toolResultRecord?.response
2083
+ ? [
2084
+ ...(message.httpExchanges ?? []),
2085
+ sanitizeHttpExchange({
2086
+ explanation: toolResultRecord.explanation ?? null,
2087
+ request: toolResultRecord.request,
2088
+ result: toolResultRecord.response
2089
+ })
2090
+ ]
2091
+ : message.httpExchanges;
2092
+ if (pendingCall) {
2093
+ pendingToolCalls.delete(toolResult.toolCallId);
2094
+ }
2095
+ if (toolResultRecord?.request?.method === "POST" && jobStatusPayload?.tracer) {
2096
+ const bridge = window.__vidfarmEditorTracers;
2097
+ if (bridge) {
2098
+ bridge.add(jobStatusPayload.tracer);
2099
+ }
2100
+ else {
2101
+ applyTracerList([jobStatusPayload.tracer, ...tracers.filter((entry) => entry !== jobStatusPayload.tracer)]);
2102
+ }
2103
+ attachTracersToThread(threadId, [jobStatusPayload.tracer]);
2104
+ }
2105
+ if (toolResultRecord?.request?.method === "POST"
2106
+ && toolResultRecord?.response?.status === 202
2107
+ && jobStatusPayload
2108
+ && !mirroredJobTarget
2109
+ && jobStatusPayload.status !== "succeeded"
2110
+ && jobStatusPayload.status !== "failed"
2111
+ && jobStatusPayload.status !== "cancelled") {
2112
+ scheduleJobPolling({
2113
+ threadId,
2114
+ assistantMessageId: assistantMessage.id,
2115
+ jobId: jobStatusPayload.job_id,
2116
+ tracer: jobStatusPayload.tracer,
2117
+ initialStatus: jobStatusPayload.status
2118
+ });
2119
+ }
2120
+ return {
2121
+ ...message,
2122
+ toolExchanges: nextToolExchanges,
2123
+ httpExchanges: nextHttpExchanges,
2124
+ text: jobStatusPayload && toolResultRecord?.request?.method === "POST" && toolResultRecord?.response?.status === 202
2125
+ ? appendDistinctMessageText(message.text, buildJobQueuedMessage(jobStatusPayload))
2126
+ : jobStatusPayload && (jobStatusPayload.status === "succeeded" || jobStatusPayload.status === "failed" || jobStatusPayload.status === "cancelled")
2127
+ ? appendDistinctMessageText(message.text, buildJobCompletionMessage(jobStatusPayload))
2128
+ : message.text
2129
+ };
2130
+ })
2131
+ }));
2132
+ },
2133
+ onError: (errorMessage) => {
2134
+ setLastError(errorMessage);
2135
+ setLoadingStatus(null);
2136
+ updateThread(threadId, (thread) => ({
2137
+ ...thread,
2138
+ messages: thread.messages.filter((message) => message.id !== assistantMessage.id)
2139
+ }));
2140
+ }
2141
+ });
2142
+ updateThread(threadId, (thread) => ({
2143
+ ...thread,
2144
+ updatedAt: new Date().toISOString(),
2145
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
2146
+ ? {
2147
+ ...message,
2148
+ statusText: null,
2149
+ isPending: false
2150
+ }
2151
+ : message))
2152
+ }));
2153
+ }
2154
+ catch (error) {
2155
+ if (error instanceof DOMException && error.name === "AbortError") {
2156
+ setLastError(null);
2157
+ setLoadingStatus(null);
2158
+ updateThread(threadId, (thread) => ({
2159
+ ...thread,
2160
+ updatedAt: new Date().toISOString(),
2161
+ messages: thread.messages.map((message) => {
2162
+ if (message.id !== assistantMessage.id) {
2163
+ return message;
2164
+ }
2165
+ const hasContent = Boolean(message.text.trim()
2166
+ || message.toolExchanges?.length
2167
+ || message.httpExchanges?.length);
2168
+ return hasContent
2169
+ ? {
2170
+ ...message,
2171
+ statusText: "Stopped.",
2172
+ isPending: false
2173
+ }
2174
+ : {
2175
+ ...message,
2176
+ text: "Stopped.",
2177
+ statusText: null,
2178
+ isPending: false
2179
+ };
2180
+ })
2181
+ }));
2182
+ return;
2183
+ }
2184
+ setLastError(error instanceof Error ? error.message : "Unable to send chat message.");
2185
+ setLoadingStatus(null);
2186
+ updateThread(threadId, (thread) => ({
2187
+ ...thread,
2188
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
2189
+ ? {
2190
+ ...message,
2191
+ text: error instanceof Error ? `Error: ${error.message}` : "Error: Unable to send chat message.",
2192
+ statusText: null,
2193
+ isPending: false
2194
+ }
2195
+ : message))
2196
+ }));
2197
+ }
2198
+ finally {
2199
+ if (activeSendRef.current?.assistantMessageId === assistantMessage.id) {
2200
+ activeSendRef.current = null;
2201
+ }
2202
+ setSendingThreadId((current) => (current === threadId ? null : current));
2203
+ setLoadingStatus(null);
2204
+ }
2205
+ }
2206
+ function stopActiveSend() {
2207
+ setLoadingStatus("Stopping…");
2208
+ const activeSend = activeSendRef.current;
2209
+ if (activeSend) {
2210
+ updateThread(activeSend.threadId, (thread) => ({
2211
+ ...thread,
2212
+ updatedAt: new Date().toISOString(),
2213
+ messages: thread.messages.map((message) => (message.id === activeSend.assistantMessageId
2214
+ ? {
2215
+ ...message,
2216
+ statusText: "Stopping…"
2217
+ }
2218
+ : message))
2219
+ }));
2220
+ }
2221
+ activeSendRef.current?.abortController.abort();
2222
+ }
2223
+ function handleComposerKeyDown(event) {
2224
+ if (event.key === "Enter" && !event.shiftKey) {
2225
+ event.preventDefault();
2226
+ void handleSend();
2227
+ }
2228
+ }
2229
+ return (_jsxs("div", { className: "vf-editor-chat-shell", "data-mobile-open": isMobileChatOpen ? "true" : "false", children: [_jsx("button", { type: "button", className: "vf-editor-chat-mobile-fab", "aria-label": "Open chat", "aria-expanded": isMobileChatOpen, onClick: () => setIsMobileChatOpen(true), 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: "vf-editor-chat-thread", children: [_jsxs("div", { className: "vf-editor-chat-header", children: [_jsxs("div", { className: "vf-editor-chat-header-row", children: [_jsxs("div", { children: [_jsx("div", { className: "vf-editor-chat-header-title", children: "Copilot" }), _jsx("div", { className: "vf-editor-chat-header-subtitle", children: 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-minimize", "aria-label": "Minimize chat", onClick: () => setIsMobileChatOpen(false), children: "\u00D7" })] }), _jsx("div", { className: "vf-editor-chat-thread-list", 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: () => {
2230
+ setActiveThreadId(thread.id);
2231
+ setLastError(null);
2232
+ setOpenThreadMenu(null);
2233
+ }, 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) => {
2234
+ event.stopPropagation();
2235
+ toggleThreadMenu(thread.id, event.currentTarget);
2236
+ }, children: "..." }) })] }, thread.id))) : (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "Start a chat to create your first conversation thread." })) }), openThreadMenu && openThreadMenuThread ? createPortal(_jsxs("div", { className: "vf-editor-chat-thread-menu", role: "menu", style: {
2237
+ top: openThreadMenu.top,
2238
+ left: openThreadMenu.left
2239
+ }, 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: messages.length ? messages.map((message) => (_jsx(ChatBubble, { message: message }, message.id))) : (_jsxs("div", { className: "vf-editor-chat-empty", children: [_jsx("div", { className: "vf-editor-chat-kicker", children: "Template Copilot" }), _jsx("h2", { children: "Ask it to inspect or call the template REST API." }), _jsx("p", { children: "The sidebar can read skill context, execute template routes, attach POST runs to this thread's tracers, and show each HTTP request and response inline per thread." })] })) }), _jsx("div", { className: "vf-editor-chat-footer", children: _jsxs("div", { className: "vf-editor-chat-composer", "data-drop-target": isDropTarget ? "true" : "false", onDragEnter: (event) => {
2240
+ event.preventDefault();
2241
+ setIsDropTarget(true);
2242
+ }, onDragOver: (event) => {
2243
+ event.preventDefault();
2244
+ if (!isDropTarget) {
2245
+ setIsDropTarget(true);
2246
+ }
2247
+ }, onDragLeave: (event) => {
2248
+ event.preventDefault();
2249
+ const nextTarget = event.relatedTarget;
2250
+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
2251
+ return;
2252
+ }
2253
+ setIsDropTarget(false);
2254
+ }, 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": "Add attachment", onClick: () => fileInputRef.current?.click(), children: "+" }), _jsx("textarea", { rows: 1, placeholder: "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: () => {
2255
+ if (isActiveThreadSending) {
2256
+ stopActiveSend();
2257
+ return;
2258
+ }
2259
+ void handleSend();
2260
+ }, 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) })] }) })] })] }));
2261
+ }