@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.
- package/.env.example +9 -0
- package/GETTING_STARTED.developers.md +1 -30
- package/README.md +22 -5
- package/SKILL.developer.md +464 -12
- package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +475 -0
- package/auto-create-templates/extractor-system-prompt.md +113 -0
- package/auto-create-templates/production-graph.schema.json +276 -0
- package/auto-create-templates/runbook.md +140 -0
- package/auto-create-templates/template-plan.schema.json +230 -0
- package/auto-create-templates/template-planner-prompt.md +75 -0
- package/dist/src/account-pages-legacy.js +9064 -0
- package/dist/src/account-pages.js +41 -620
- package/dist/src/app.js +7173 -494
- package/dist/src/cli.js +525 -29
- package/dist/src/config.js +14 -7
- package/dist/src/context.js +51 -35
- package/dist/src/db.js +1049 -55
- package/dist/src/dev-app-legacy.js +693 -0
- package/dist/src/dev-app.js +4 -904
- package/dist/src/domain.js +1 -1
- package/dist/src/editor-chat-history.js +72 -0
- package/dist/src/editor-chat.js +202 -0
- package/dist/src/frontend/flockposter-cache-store.js +116 -0
- package/dist/src/frontend/homepage-client.js +114 -0
- package/dist/src/frontend/homepage-shared.js +3 -0
- package/dist/src/frontend/homepage-store.js +27 -0
- package/dist/src/frontend/homepage-view.js +147 -0
- package/dist/src/frontend/page-runtime-client.js +100 -0
- package/dist/src/frontend/page-runtime-store.js +8 -0
- package/dist/src/frontend/template-editor-chat.js +2261 -0
- package/dist/src/homepage.js +695 -339
- package/dist/src/lib/template-paths.js +10 -4
- package/dist/src/lib/template-style-options.js +95 -15
- package/dist/src/page-runtime.js +1 -0
- package/dist/src/page-shell.js +941 -0
- package/dist/src/primitive-context.js +163 -0
- package/dist/src/primitive-registry.js +340 -0
- package/dist/src/primitive-sdk.js +3 -0
- package/dist/src/primitives/remotion/html-image.js +28 -0
- package/dist/src/react-page-shell.js +34 -0
- package/dist/src/ready-post-schedule-component.js +1514 -0
- package/dist/src/registry.js +44 -18
- package/dist/src/runtime.js +6 -1
- package/dist/src/services/api-call-history.js +245 -0
- package/dist/src/services/auth.js +27 -9
- package/dist/src/services/billing.js +248 -9
- package/dist/src/services/chat-threads.js +88 -0
- package/dist/src/services/job-logs.js +10 -3
- package/dist/src/services/jobs.js +13 -2
- package/dist/src/services/providers.js +944 -55
- package/dist/src/services/rate-limits.js +236 -0
- package/dist/src/services/remotion.js +143 -16
- package/dist/src/services/storage.js +23 -8
- package/dist/src/services/template-certification.js +26 -3
- package/dist/src/services/template-loader.js +19 -1
- package/dist/src/services/template-sources.js +316 -7
- package/dist/src/template-editor-pages.js +2309 -0
- package/dist/src/template-editor-shell.js +1507 -0
- package/dist/src/worker.js +120 -22
- package/package.json +30 -6
- package/public/assets/homepage-app.js +54 -0
- package/public/assets/homepage-client-app.js +54 -0
- package/public/assets/page-runtime-client-app.js +68 -0
- package/dist/templates/template_0000/src/lib/images.js +0 -202
- package/dist/templates/template_0000/src/remotion/Root.js +0 -33
- package/dist/templates/template_0000/src/remotion/index.js +0 -3
- package/dist/templates/template_0000/src/sdk.js +0 -3
- package/dist/templates/template_0000/src/style-options.js +0 -51
- package/dist/templates/template_0000/src/template-dna.js +0 -9
- package/dist/templates/template_0000/src/template.js +0 -1441
- package/templates/template_0000/README.md +0 -77
- package/templates/template_0000/SKILL.md +0 -225
- package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
- package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
- package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
- package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
- package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
- package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
- package/templates/template_0000/composition.json +0 -11
- package/templates/template_0000/package.json +0 -28
- package/templates/template_0000/research/preview/.gitkeep +0 -1
- package/templates/template_0000/research/source_notes.md +0 -7
- package/templates/template_0000/src/lib/images.js +0 -202
- package/templates/template_0000/src/lib/images.ts +0 -241
- package/templates/template_0000/src/remotion/Root.js +0 -33
- package/templates/template_0000/src/remotion/Root.tsx +0 -75
- package/templates/template_0000/src/remotion/index.js +0 -3
- package/templates/template_0000/src/remotion/index.tsx +0 -4
- package/templates/template_0000/src/sdk.js +0 -3
- package/templates/template_0000/src/sdk.ts +0 -122
- package/templates/template_0000/src/style-options.js +0 -51
- package/templates/template_0000/src/style-options.ts +0 -60
- package/templates/template_0000/src/template-dna.js +0 -9
- package/templates/template_0000/src/template-dna.ts +0 -15
- package/templates/template_0000/src/template.js +0 -1441
- package/templates/template_0000/src/template.ts +0 -2042
- package/templates/template_0000/template.config.json +0 -21
- 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("<", "<")
|
|
157
|
+
.replaceAll(">", ">")
|
|
158
|
+
.replaceAll("\"", """)
|
|
159
|
+
.replaceAll("'", "'");
|
|
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
|
+
}
|