@mevdragon/vidfarm-devcli 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/editor-capabilities/SKILL.md +166 -0
- package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
- package/.agents/skills/vidfarm-media/SKILL.md +43 -4
- package/SKILL.director.md +190 -18
- package/SKILL.platform.md +8 -4
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +77 -75
- package/demo/dist/favicon.ico +0 -0
- package/dist/src/app.js +3031 -300
- package/dist/src/cli.js +1549 -69
- package/dist/src/config.js +7 -0
- package/dist/src/devcli/clip-store.js +29 -2
- package/dist/src/devcli/clips.js +55 -9
- package/dist/src/devcli/composition-edit.js +658 -8
- package/dist/src/devcli/local-backend.js +123 -0
- package/dist/src/devcli/migrate-local.js +140 -0
- package/dist/src/devcli/sync.js +311 -0
- package/dist/src/devcli/timeline-edit.js +490 -0
- package/dist/src/editor-chat.js +56 -17
- package/dist/src/frontend/discover-client.js +130 -0
- package/dist/src/frontend/discover-store.js +23 -0
- package/dist/src/frontend/file-directory.js +995 -0
- package/dist/src/frontend/homepage-view.js +3 -3
- package/dist/src/frontend/template-editor-chat.js +28 -22
- package/dist/src/landing-page.js +24 -7
- package/dist/src/page-shell.js +26 -2
- package/dist/src/primitive-registry.js +409 -4
- package/dist/src/reskin/agency-page.js +1 -1
- package/dist/src/reskin/calendar-page.js +2 -1
- package/dist/src/reskin/chat-page.js +420 -85
- package/dist/src/reskin/discover-page.js +731 -39
- package/dist/src/reskin/document.js +1311 -387
- package/dist/src/reskin/help-page.js +1 -0
- package/dist/src/reskin/inpaint-clipper-page.js +649 -0
- package/dist/src/reskin/inpaint-page.js +2459 -446
- package/dist/src/reskin/inpaint-video-page.js +1339 -0
- package/dist/src/reskin/library-page.js +1168 -228
- package/dist/src/reskin/login-page.js +6 -6
- package/dist/src/reskin/pricing-page.js +2 -0
- package/dist/src/reskin/settings-page.js +55 -10
- package/dist/src/reskin/theme.js +365 -20
- package/dist/src/services/billing.js +4 -0
- package/dist/src/services/clip-curation/gemini.js +5 -0
- package/dist/src/services/clip-curation/hunt.js +81 -1
- package/dist/src/services/clip-curation/index.js +2 -1
- package/dist/src/services/clip-curation/local-agent.js +4 -3
- package/dist/src/services/clip-curation/media-select.js +85 -0
- package/dist/src/services/clip-curation/query.js +5 -1
- package/dist/src/services/clip-curation/refine.js +50 -20
- package/dist/src/services/clip-curation/scan.js +10 -3
- package/dist/src/services/clip-curation/taxonomy.js +3 -1
- package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
- package/dist/src/services/clip-records.js +42 -1
- package/dist/src/services/clip-search.js +43 -13
- package/dist/src/services/file-directory.js +117 -0
- package/dist/src/services/hyperframes.js +283 -3
- package/dist/src/services/serverless-records.js +43 -0
- package/dist/src/services/storage.js +47 -2
- package/dist/src/services/upstream.js +5 -5
- package/dist/src/template-editor-shell.js +16 -2
- package/package.json +1 -1
- package/public/assets/discover-client-app.js +1 -0
- package/public/assets/file-directory-app.js +2 -0
- package/public/assets/homepage-client-app.js +12 -12
- package/public/assets/page-runtime-client-app.js +24 -24
- package/public/assets/placeholders/scene-placeholder.png +0 -0
- package/src/assets/favicon.ico +0 -0
- package/src/assets/logo-vidfarm.png +0 -0
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
//
|
|
13
13
|
// NOTE: the sidebar uses `.rk-sidebar*` class names — deliberately NOT `.rk-side*`
|
|
14
14
|
// (that belongs to the settings page's own inner tab rail) so the two never clash.
|
|
15
|
-
import { RESKIN_CSS, RESKIN_FONT_LINKS
|
|
15
|
+
import { RESKIN_CSS, RESKIN_FONT_LINKS } from "./theme.js";
|
|
16
16
|
/** Minimal HTML escaper so reskin modules don't need to import app helpers. */
|
|
17
17
|
export function rkEscape(value) {
|
|
18
18
|
return String(value ?? "")
|
|
@@ -22,69 +22,131 @@ export function rkEscape(value) {
|
|
|
22
22
|
.replace(/"/g, """)
|
|
23
23
|
.replace(/'/g, "'");
|
|
24
24
|
}
|
|
25
|
+
/** Canonical brand name + origin, used for <title>, meta tags, and social cards. */
|
|
26
|
+
export const BRAND_NAME = "VidFarm";
|
|
27
|
+
export const BRAND_ORIGIN = "https://vidfarm.cc";
|
|
28
|
+
export const BRAND_OG_IMAGE = `${BRAND_ORIGIN}/assets/logo-vidfarm.png`;
|
|
29
|
+
/** Normalize any page title to the canonical "VidFarm" branding. Older reskin
|
|
30
|
+
* modules pass "vidfarm reskin — X" / "vidfarm — X"; both collapse to "VidFarm — X". */
|
|
31
|
+
export function rkBrandTitle(raw) {
|
|
32
|
+
return String(raw ?? "")
|
|
33
|
+
.replace(/vidfarm reskin/gi, BRAND_NAME)
|
|
34
|
+
.replace(/\bvidfarm\b/gi, BRAND_NAME);
|
|
35
|
+
}
|
|
25
36
|
/** The sidebar shows only the core app pages (not every reskinned page).
|
|
26
37
|
* Other pages stay reachable from the /reskin gallery (brand link). */
|
|
27
38
|
const SIDEBAR_NAV = [
|
|
28
39
|
{ slug: "discover", label: "Discover" },
|
|
29
|
-
{ slug: "chat", label: "
|
|
40
|
+
{ slug: "chat", label: "Create" },
|
|
30
41
|
{ slug: "library", label: "Library" },
|
|
31
42
|
{ slug: "calendar", label: "Calendar" },
|
|
32
43
|
{ slug: "help", label: "Guides" },
|
|
33
44
|
{ slug: "settings", label: "Settings" }
|
|
34
45
|
];
|
|
35
|
-
|
|
46
|
+
/** Title-case the local-part of an email as a display-name fallback. Kept inline
|
|
47
|
+
* so this module stays free of app-side imports (see file header). */
|
|
48
|
+
function rkNameFromEmail(email) {
|
|
49
|
+
const local = email.trim().toLowerCase().split("@", 1)[0] || email.trim().toLowerCase();
|
|
50
|
+
const words = local
|
|
51
|
+
.split(/[^a-z0-9]+/i)
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1));
|
|
54
|
+
return words.join(" ") || email.trim();
|
|
55
|
+
}
|
|
56
|
+
/** The subtle name + email block pinned to the sidebar foot. Renders nothing
|
|
57
|
+
* when signed out (no email), so the sidebar is unchanged for visitors. */
|
|
58
|
+
function renderSidebarAccount(account) {
|
|
59
|
+
const email = account?.email?.trim() || "";
|
|
60
|
+
if (!email)
|
|
61
|
+
return "";
|
|
62
|
+
const name = account?.name?.trim() || rkNameFromEmail(email);
|
|
63
|
+
return `<div class="rk-sidebar-account" title="Signed in as ${rkEscape(email)}">
|
|
64
|
+
<div class="rk-sidebar-account-name">${rkEscape(name)}</div>
|
|
65
|
+
<div class="rk-sidebar-account-email">${rkEscape(email)}</div>
|
|
66
|
+
</div>`;
|
|
67
|
+
}
|
|
68
|
+
function renderSidebar(activeSlug, account) {
|
|
36
69
|
const items = SIDEBAR_NAV.map((p) => {
|
|
37
70
|
const active = p.slug === activeSlug ? " is-active" : "";
|
|
38
71
|
return `<a class="rk-sidebar-item${active}" href="/${p.slug}"${p.slug === activeSlug ? ` aria-current="page"` : ""}>${p.label}</a>`;
|
|
39
72
|
}).join("");
|
|
73
|
+
// Phone nav: a CSS checkbox-hack hamburger (no JS). At ≤640px the horizontal
|
|
74
|
+
// strip hides the nav behind the burger and drops it into a vertical dropdown
|
|
75
|
+
// when the box is checked — the sibling `~` combinator needs the checkbox to
|
|
76
|
+
// precede both the nav and the account block, hence the order below.
|
|
40
77
|
return `<aside class="rk-sidebar">
|
|
41
|
-
<a class="rk-sidebar-brand" href="/discover"><span class="rk-brand-mark">V</span>
|
|
78
|
+
<a class="rk-sidebar-brand" href="/discover"><span class="rk-brand-mark">V</span>VidFarm</a>
|
|
79
|
+
<input type="checkbox" id="rk-navtoggle" class="rk-nav-toggle-cb" hidden>
|
|
80
|
+
<label for="rk-navtoggle" class="rk-nav-burger" aria-label="Toggle navigation" aria-controls="rk-navtoggle">
|
|
81
|
+
<svg viewBox="0 0 20 20" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3 6h14M3 10h14M3 14h14"/></svg>
|
|
82
|
+
</label>
|
|
42
83
|
<nav class="rk-sidebar-nav" aria-label="Reskin pages">
|
|
43
84
|
${items}
|
|
44
85
|
</nav>
|
|
86
|
+
${renderSidebarAccount(account)}
|
|
45
87
|
</aside>`;
|
|
46
88
|
}
|
|
47
89
|
/** Draggable chat FAB (defaults to the old sidebar-foot spot, bottom-left) +
|
|
48
90
|
* the popup AI chat panel it toggles. Sample chat only — this is the design
|
|
49
91
|
* sandbox, so the panel demos the UI rather than hitting a live agent. The
|
|
50
92
|
* old "compare with live page" link survives as the ↗ tool in the panel head. */
|
|
51
|
-
function renderChatDock(activeSlug) {
|
|
52
|
-
|
|
53
|
-
|
|
93
|
+
export function renderChatDock(activeSlug) {
|
|
94
|
+
// In the /editor left-dock, the brand block is replaced by an obvious
|
|
95
|
+
// "Back to Library" button — the editor is a focused workspace, so its
|
|
96
|
+
// primary chrome affordance is getting back out, not re-stating the brand.
|
|
97
|
+
const head = activeSlug === "editor"
|
|
98
|
+
? `<a class="rk-aichat-back" id="rkAichatBack" href="/library" title="Back to your library">
|
|
99
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
|
100
|
+
<span>Back to Library</span>
|
|
101
|
+
</a>`
|
|
102
|
+
: `<span class="rk-brand-mark">V</span>
|
|
103
|
+
<div class="rk-aichat-head-main">
|
|
104
|
+
<div class="rk-aichat-title">VidFarm AI</div>
|
|
105
|
+
<div class="rk-aichat-sub">on your own AI keys</div>
|
|
106
|
+
</div>`;
|
|
54
107
|
return `<button class="rk-fab" id="rkFab" type="button" aria-label="Open AI chat" aria-expanded="false" aria-controls="rkAichat" title="Chat with vidfarm AI — drag to move">
|
|
55
108
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
|
|
56
109
|
</button>
|
|
57
110
|
<aside class="rk-aichat has-files" id="rkAichat" hidden aria-label="AI chat panel">
|
|
58
111
|
<div class="rk-aichat-files" id="rkAichatFiles" aria-label="Files">
|
|
59
|
-
<div class="rk-aichat-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<button type="button" class="rk-aichat-drop-btn" id="rkAichatUploadBtn">Upload files</button>
|
|
112
|
+
<div class="rk-aichat-files-head">
|
|
113
|
+
<span class="rk-aichat-files-title">Your files</span>
|
|
114
|
+
<button class="rk-aichat-tool" id="rkFilesClose" type="button" title="Minimize files" aria-label="Minimize files">
|
|
115
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>
|
|
116
|
+
</button>
|
|
65
117
|
</div>
|
|
118
|
+
<div class="rk-aichat-files-mount" id="rkAichatFilesMount" data-rk-directory data-mode="attach" data-primary-click="preview"></div>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="rk-aichat-history" id="rkAichatHistory" aria-label="Chat history">
|
|
121
|
+
<div class="rk-aichat-hist-head">Chat history</div>
|
|
122
|
+
<div class="rk-aichat-hist-body" id="rkAichatHistBody"></div>
|
|
66
123
|
</div>
|
|
67
124
|
<div class="rk-aichat-main">
|
|
68
125
|
<header class="rk-aichat-head">
|
|
69
|
-
|
|
70
|
-
<
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
126
|
+
${head}
|
|
127
|
+
<button class="rk-aichat-tool" id="rkHistoryToggle" type="button" title="Chat history" aria-label="Toggle chat history" aria-pressed="false">
|
|
128
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/></svg>
|
|
129
|
+
</button>
|
|
130
|
+
<button class="rk-aichat-tool" id="rkCopyThread" type="button" title="Copy chat ID" aria-label="Copy chat ID">
|
|
131
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
132
|
+
</button>
|
|
133
|
+
<button class="rk-aichat-tool" id="rkNewChat" type="button" title="New chat" aria-label="Start a new chat">
|
|
134
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
|
|
76
135
|
</button>
|
|
77
136
|
<button class="rk-aichat-tool" id="rkAichatMin" type="button" title="Minimize" aria-label="Minimize chat">—</button>
|
|
78
137
|
</header>
|
|
79
138
|
<div class="rk-aichat-log" id="rkAichatLog">
|
|
80
|
-
<div class="rk-aichat-msg is-ai">Hi! I'm the vidfarm assistant.
|
|
81
|
-
<div class="rk-aichat-msg is-user">What changed in this reskin?</div>
|
|
82
|
-
<div class="rk-aichat-msg is-ai">This page was ported into the farmville design system — honey-gold accent, soft rounded cards, the shared rk- primitives — while keeping the layout one-to-one with the live page.</div>
|
|
139
|
+
<div class="rk-aichat-msg is-ai">Hi! I'm the vidfarm assistant. I can take real actions on your account — browse your files, hit vidfarm APIs, kick off renders. Ask me anything, or attach a file from the panel on the left.</div>
|
|
83
140
|
</div>
|
|
84
141
|
<div class="rk-aichat-chips" id="rkAichatChips"></div>
|
|
85
142
|
<form class="rk-aichat-composer" id="rkAichatForm">
|
|
86
|
-
<
|
|
87
|
-
<
|
|
143
|
+
<textarea class="rk-aichat-field" id="rkAichatInput" rows="3" placeholder="Ask the vidfarm AI…" autocomplete="off"></textarea>
|
|
144
|
+
<div class="rk-aichat-composer-actions">
|
|
145
|
+
<button class="rk-aichat-attach" id="rkAichatAttach" type="button" title="Attach a file" aria-label="Attach a file">
|
|
146
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
147
|
+
</button>
|
|
148
|
+
<button class="rk-btn rk-btn-gold rk-btn-sm" type="submit">Send</button>
|
|
149
|
+
</div>
|
|
88
150
|
</form>
|
|
89
151
|
</div>
|
|
90
152
|
</aside>`;
|
|
@@ -95,15 +157,26 @@ function renderChatDock(activeSlug) {
|
|
|
95
157
|
* on the left it replaces the sidebar nav (body.rk-chat-left). Open state
|
|
96
158
|
* survives page navigation via sessionStorage. Plain IIFE — no backticks or
|
|
97
159
|
* dollar-brace inside (it is embedded in a template literal). */
|
|
98
|
-
const RESKIN_CHROME_SCRIPT = `(() => {
|
|
160
|
+
export const RESKIN_CHROME_SCRIPT = `(() => {
|
|
99
161
|
var fab = document.getElementById('rkFab');
|
|
100
162
|
var panel = document.getElementById('rkAichat');
|
|
101
163
|
if (!fab || !panel) return;
|
|
164
|
+
// Editor dock mode: the /editor shell mounts this chat as a permanent, always-
|
|
165
|
+
// open LEFT column (no floating FAB) — reusing the exact same agentic dock. On
|
|
166
|
+
// narrow screens it degrades back to the floating FAB.
|
|
167
|
+
var isEditorDock = !!document.querySelector('[data-rk-editor-dock]');
|
|
168
|
+
var editorDockWide = isEditorDock && (typeof window.matchMedia !== 'function' || window.matchMedia('(min-width: 1025px)').matches);
|
|
169
|
+
if (isEditorDock) fab.hidden = true;
|
|
102
170
|
var minBtn = document.getElementById('rkAichatMin');
|
|
103
171
|
var form = document.getElementById('rkAichatForm');
|
|
104
172
|
var input = document.getElementById('rkAichatInput');
|
|
105
173
|
var log = document.getElementById('rkAichatLog');
|
|
106
174
|
var MARGIN = 10;
|
|
175
|
+
// On the dedicated /chat page the panel's chat column is redundant (the page
|
|
176
|
+
// IS the chat), so we hide the floating FAB and instead let the composer's
|
|
177
|
+
// attach button open the panel in FILES-ONLY mode (just the file explorer).
|
|
178
|
+
var isChatPage = !!document.querySelector('.rk-chat-page');
|
|
179
|
+
if (isChatPage) fab.hidden = true;
|
|
107
180
|
|
|
108
181
|
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
|
109
182
|
|
|
@@ -130,18 +203,37 @@ const RESKIN_CHROME_SCRIPT = `(() => {
|
|
|
130
203
|
document.body.classList.toggle('rk-chat-left', !panel.hidden && !onRight);
|
|
131
204
|
}
|
|
132
205
|
|
|
133
|
-
function openPanel() {
|
|
206
|
+
function openPanel(filesOnly) {
|
|
134
207
|
panel.hidden = false;
|
|
208
|
+
panel.classList.toggle('is-files-only', !!filesOnly);
|
|
135
209
|
fab.classList.add('is-open');
|
|
136
210
|
fab.setAttribute('aria-expanded', 'true');
|
|
137
|
-
|
|
138
|
-
|
|
211
|
+
if (filesOnly) {
|
|
212
|
+
// slim left-hand drawer that replaces the sidebar (like the normal dock),
|
|
213
|
+
// so it never covers the centered chat column's composer / Send button
|
|
214
|
+
setLeftMode('files');
|
|
215
|
+
panel.classList.remove('is-right');
|
|
216
|
+
document.body.classList.add('rk-chat-left');
|
|
217
|
+
} else {
|
|
218
|
+
anchorPanel();
|
|
219
|
+
// On short / narrow screens the left drawer stacks ABOVE the chat and
|
|
220
|
+
// crushes the log + composer. Start collapsed to the chat column there;
|
|
221
|
+
// the user can still open Files/History from the header toggles.
|
|
222
|
+
try {
|
|
223
|
+
if (window.matchMedia && window.matchMedia('(max-width:700px)').matches) setLeftMode('');
|
|
224
|
+
} catch (e) {}
|
|
225
|
+
}
|
|
226
|
+
try { sessionStorage.setItem('rk-chat-open', filesOnly ? 'files' : '1'); } catch (e) {}
|
|
227
|
+
if (!filesOnly) loadBoot();
|
|
139
228
|
refreshFilesOnOpen();
|
|
140
|
-
if (input) input.focus();
|
|
229
|
+
if (!filesOnly && input) input.focus();
|
|
141
230
|
}
|
|
142
231
|
|
|
143
232
|
function closePanel() {
|
|
233
|
+
// Editor dock (wide) is a permanent column — minimize / Escape can't close it.
|
|
234
|
+
if (editorDockWide) return;
|
|
144
235
|
panel.hidden = true;
|
|
236
|
+
panel.classList.remove('is-files-only');
|
|
145
237
|
fab.classList.remove('is-open');
|
|
146
238
|
fab.setAttribute('aria-expanded', 'false');
|
|
147
239
|
document.body.classList.remove('rk-chat-left');
|
|
@@ -198,371 +290,864 @@ const RESKIN_CHROME_SCRIPT = `(() => {
|
|
|
198
290
|
el.textContent = text;
|
|
199
291
|
log.appendChild(el);
|
|
200
292
|
log.scrollTop = log.scrollHeight;
|
|
293
|
+
return el;
|
|
201
294
|
}
|
|
202
|
-
|
|
203
|
-
e.preventDefault();
|
|
204
|
-
var v = (input.value || '').trim();
|
|
205
|
-
var picked = selected.slice();
|
|
206
|
-
if (!v && !picked.length) return;
|
|
207
|
-
var line = v;
|
|
208
|
-
if (picked.length) {
|
|
209
|
-
var names = picked.map(function (f) { return f.name; }).join(', ');
|
|
210
|
-
line = (v ? v + ' ' : '') + '(attached: ' + names + ')';
|
|
211
|
-
}
|
|
212
|
-
bubble('is-user', line);
|
|
213
|
-
input.value = '';
|
|
214
|
-
clearChips();
|
|
215
|
-
setTimeout(function () {
|
|
216
|
-
bubble('is-ai', picked.length
|
|
217
|
-
? 'Got the ' + picked.length + ' file' + (picked.length > 1 ? 's' : '') + ' from your ' + rootLabel(fstate.root) + ' — on the live app I would use ' + (picked.length > 1 ? 'them' : 'it') + ' in the conversation. This reskin panel is a sample, so replies are canned.'
|
|
218
|
-
: "I'm the sample chat in the reskin sandbox, so replies here are canned — on the live app this panel wires up to the vidfarm agent.");
|
|
219
|
-
}, 450);
|
|
220
|
-
});
|
|
295
|
+
function logBottom() { if (log) log.scrollTop = log.scrollHeight; }
|
|
221
296
|
|
|
222
|
-
//
|
|
223
|
-
var
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
var
|
|
234
|
-
|
|
235
|
-
var
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// The sources are surfaced as top-level FOLDERS of one virtual root (no source
|
|
243
|
-
// tabs) — navigate into them natively like any folder.
|
|
244
|
-
var VROOTS = [{ key: 'my_files', label: 'My Files' }, { key: 'temp', label: 'Temp' }, { key: 'raws', label: 'Raws' }];
|
|
245
|
-
var fstate = {
|
|
246
|
-
root: '', // '' = virtual root (lists My Files + Temp + Raws); else a ROOTS key
|
|
247
|
-
path: { my_files: '', temp: '', raws: '' },
|
|
248
|
-
data: { my_files: null, temp: null, raws: null },
|
|
249
|
-
status: { my_files: 'idle', temp: 'idle', raws: 'idle' } // idle|loading|ready|auth|error
|
|
250
|
-
};
|
|
297
|
+
// Remember the opening greeting so "New chat" can restore a fresh thread.
|
|
298
|
+
var GREETING = (function () {
|
|
299
|
+
var first = log && log.querySelector('.rk-aichat-msg.is-ai');
|
|
300
|
+
return first ? first.textContent : "Hi! I'm the vidfarm assistant — ask me anything about your videos, clips, and posts.";
|
|
301
|
+
})();
|
|
302
|
+
|
|
303
|
+
// ─── real agentic chat: wired to the live editor-chat backend ──────────────
|
|
304
|
+
// Boot (endpoint + api key + brainstorm template) is fetched lazily on first
|
|
305
|
+
// open from /chat-dock/boot (cookie-authed). Tool calls surface as first-class
|
|
306
|
+
// actions: every http_request becomes a clickable card that opens a modal with
|
|
307
|
+
// the exact request + response, exactly like the /editor chat.
|
|
308
|
+
var BOOT = null, BOOT_STATE = 'idle'; // idle|loading|ready|anon|error
|
|
309
|
+
var ENDPOINT = '/api/v1/editor-chat', API_KEY = null, TEMPLATE = null, CACHED = null;
|
|
310
|
+
var THREADS_URL = '/api/v1/editor-chat/threads', TEMPLATE_ID = 'chat-brainstorm';
|
|
311
|
+
var convo = []; // [{role,text}] running conversation
|
|
312
|
+
// Minted up-front so the "Copy chat ID" tool always has something to copy
|
|
313
|
+
// (even before the first message) and so the id we copy is the SAME one the
|
|
314
|
+
// first send persists under. Reset to a fresh id on New chat, replaced by the
|
|
315
|
+
// real id when a saved thread is opened.
|
|
316
|
+
var threadId = genId('thread');
|
|
251
317
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
318
|
+
// Cross-page hand-off: /chat (or a future AI-driven page nav) can pass
|
|
319
|
+
// ?rk_chat=<threadId> to continue that EXACT conversation in this dock. Read it
|
|
320
|
+
// once, strip it from the URL (so reload / back-button doesn't re-trigger), and
|
|
321
|
+
// replay the thread as soon as boot is ready (see consumeHandoff, called from
|
|
322
|
+
// loadBoot's ready path). Also opens the panel below so the jump is seamless.
|
|
323
|
+
var handoffThread = null;
|
|
324
|
+
try {
|
|
325
|
+
var _hq = new URLSearchParams(window.location.search);
|
|
326
|
+
handoffThread = _hq.get('rk_chat') || null;
|
|
327
|
+
if (handoffThread) {
|
|
328
|
+
_hq.delete('rk_chat');
|
|
329
|
+
var _hqs = _hq.toString();
|
|
330
|
+
window.history.replaceState(window.history.state, '', window.location.pathname + (_hqs ? '?' + _hqs : '') + window.location.hash);
|
|
331
|
+
}
|
|
332
|
+
} catch (e) { handoffThread = null; }
|
|
333
|
+
// Replay a handed-off thread once the dock is authed + booted. openThread needs
|
|
334
|
+
// API_KEY (set by loadBoot), so this only fires from the boot-ready path.
|
|
335
|
+
function consumeHandoff() {
|
|
336
|
+
if (!handoffThread || BOOT_STATE !== 'ready') return;
|
|
337
|
+
var id = handoffThread; handoffThread = null;
|
|
338
|
+
setLeftMode(''); // show the conversation, not the Files/History drawer
|
|
339
|
+
openThread(id);
|
|
255
340
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
341
|
+
var busy = false;
|
|
342
|
+
var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
|
|
343
|
+
|
|
344
|
+
// On the /editor dock the chat MUST be scoped to the OPEN composition, not the
|
|
345
|
+
// generic brainstorm boot — otherwise the agent has no fork id and can't run
|
|
346
|
+
// video_context / editor_action (it guesses "chat-brainstorm" and gives up).
|
|
347
|
+
// The editor page already embeds the composition identity in #hf-boot; use it
|
|
348
|
+
// as the template context (exactly like the SPA chat's body.template) so
|
|
349
|
+
// thread_template_id is the real template_… id and create_video is dropped in
|
|
350
|
+
// favor of the edit tools. Set eagerly so the very first send is correct even
|
|
351
|
+
// before /chat-dock/boot resolves (that fetch still supplies the api key).
|
|
352
|
+
var EDITOR_TEMPLATE = null;
|
|
353
|
+
if (isEditorDock) {
|
|
354
|
+
try {
|
|
355
|
+
var hfBootEl = document.getElementById('hf-boot');
|
|
356
|
+
var hfBoot = hfBootEl ? JSON.parse(hfBootEl.textContent || '{}') : null;
|
|
357
|
+
var editorTplId = hfBoot && (hfBoot.templateId || hfBoot.compositionId);
|
|
358
|
+
if (editorTplId) {
|
|
359
|
+
EDITOR_TEMPLATE = {
|
|
360
|
+
page: 'docs', tracerId: null, tracers: [], defaultRequestTracer: null,
|
|
361
|
+
templateId: editorTplId,
|
|
362
|
+
templateSlug: (hfBoot && (hfBoot.slugId || hfBoot.templateId)) || editorTplId,
|
|
363
|
+
templateTitle: (hfBoot && hfBoot.title) || 'Studio',
|
|
364
|
+
templateDescription: '', docsRoutes: []
|
|
365
|
+
};
|
|
366
|
+
TEMPLATE = EDITOR_TEMPLATE;
|
|
367
|
+
TEMPLATE_ID = editorTplId;
|
|
368
|
+
}
|
|
369
|
+
} catch (e) {}
|
|
267
370
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (
|
|
276
|
-
s = Math.round(s); var m = Math.floor(s / 60), r = s % 60;
|
|
277
|
-
return m + ':' + (r < 10 ? '0' + r : r);
|
|
278
|
-
}
|
|
279
|
-
// Raws feed items are clips (clip_id / source_filename / view_url / duration),
|
|
280
|
-
// a different shape than attachments — map them onto the file-row model.
|
|
281
|
-
function normRaw(raw) {
|
|
282
|
-
var id = raw.clip_id || raw.id || '';
|
|
283
|
-
if (!id) return null;
|
|
284
|
-
return {
|
|
285
|
-
id: id,
|
|
286
|
-
name: raw.source_filename || raw.description || 'clip',
|
|
287
|
-
contentType: 'video/mp4',
|
|
288
|
-
viewUrl: raw.view_url || raw.viewUrl || '',
|
|
289
|
-
thumbUrl: raw.thumbnail_url || raw.thumbnailUrl || '',
|
|
290
|
-
metaText: fmtDur(raw.duration_sec),
|
|
291
|
-
sizeBytes: undefined,
|
|
292
|
-
folderPath: ''
|
|
293
|
-
};
|
|
371
|
+
|
|
372
|
+
function genId(p) { return p + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); }
|
|
373
|
+
function setBusy(v) {
|
|
374
|
+
busy = v;
|
|
375
|
+
// While a reply streams, the Send button becomes an interruptive Stop
|
|
376
|
+
// (stays clickable so the user can abort); it flips back to Send when done.
|
|
377
|
+
if (form) { var b = form.querySelector('button[type=submit]'); if (b) { b.textContent = v ? 'Stop' : 'Send'; b.classList.toggle('is-stop', v); } }
|
|
378
|
+
if (newChatBtn) newChatBtn.disabled = v;
|
|
294
379
|
}
|
|
295
|
-
function
|
|
296
|
-
var
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
var n = normFolder(fp);
|
|
301
|
-
if (!n || (cur && n === cur) || n.indexOf(prefix) !== 0) return;
|
|
302
|
-
var child = n.slice(prefix.length).split('/')[0];
|
|
303
|
-
if (child) names[child] = prefix + child;
|
|
304
|
-
}
|
|
305
|
-
(data.folders || []).forEach(consider);
|
|
306
|
-
(data.items || []).forEach(function (it) { consider(it.folderPath); });
|
|
307
|
-
return Object.keys(names).sort().map(function (n) { return { name: n, path: names[n] }; });
|
|
380
|
+
function clientContext() {
|
|
381
|
+
var now = new Date(), local = null, tz = null;
|
|
382
|
+
try { local = new Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(now); } catch (e) { local = now.toString(); }
|
|
383
|
+
try { tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || null; } catch (e) { tz = null; }
|
|
384
|
+
return { currentDateTime: now.toISOString(), localDateTime: local, timeZone: tz };
|
|
308
385
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
386
|
+
// The live composition snapshot (fork id, layers, DNA) the SPA editor bundle
|
|
387
|
+
// exposes on window.__vidfarmEditorAction.getSnapshot(). Injected into the
|
|
388
|
+
// outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
|
|
389
|
+
// knows which fork to read (video_context) and mutate (editor_action). Only the
|
|
390
|
+
// /editor dock has this bridge; elsewhere it returns ''.
|
|
391
|
+
function editorContextBlock() {
|
|
392
|
+
if (!isEditorDock) return '';
|
|
393
|
+
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
394
|
+
if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
|
|
395
|
+
var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
|
|
396
|
+
if (!snap) return '';
|
|
397
|
+
try { return '\\n\\n<editor_context>\\n' + JSON.stringify(snap, null, 2) + '\\n</editor_context>'; }
|
|
398
|
+
catch (e) { return ''; }
|
|
399
|
+
}
|
|
400
|
+
function loadBoot() {
|
|
401
|
+
if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
|
|
402
|
+
BOOT_STATE = 'loading';
|
|
403
|
+
fetch('/chat-dock/boot', { credentials: 'same-origin', headers: { accept: 'application/json' } })
|
|
404
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
321
405
|
.then(function (j) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
406
|
+
var b = j && j.editorChat;
|
|
407
|
+
if (!b) { BOOT_STATE = 'anon'; return; }
|
|
408
|
+
BOOT = b;
|
|
409
|
+
ENDPOINT = b.apiUrl || ENDPOINT;
|
|
410
|
+
API_KEY = b.vidfarmApiKey || null;
|
|
411
|
+
// On the editor dock keep the composition-scoped template from #hf-boot —
|
|
412
|
+
// the generic brainstorm boot would strand the agent without a fork id.
|
|
413
|
+
if (EDITOR_TEMPLATE) { TEMPLATE = EDITOR_TEMPLATE; TEMPLATE_ID = EDITOR_TEMPLATE.templateId; }
|
|
414
|
+
else { TEMPLATE = b.template || null; TEMPLATE_ID = (TEMPLATE && TEMPLATE.templateId) || TEMPLATE_ID; }
|
|
415
|
+
CACHED = b.cachedContext || null;
|
|
416
|
+
THREADS_URL = b.threadsUrl || THREADS_URL;
|
|
417
|
+
BOOT_STATE = 'ready';
|
|
418
|
+
loadThreads();
|
|
419
|
+
consumeHandoff();
|
|
330
420
|
})
|
|
331
|
-
.catch(function () {
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
421
|
+
.catch(function () { BOOT_STATE = 'error'; });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── first-class HTTP action card + request/response modal ──
|
|
425
|
+
function fmtBody(v) {
|
|
426
|
+
if (v === null || v === undefined) return '';
|
|
427
|
+
if (typeof v === 'string') { try { return JSON.stringify(JSON.parse(v), null, 2); } catch (e) { return v; } }
|
|
428
|
+
try { return JSON.stringify(v, null, 2); } catch (e) { return String(v); }
|
|
429
|
+
}
|
|
430
|
+
function copyText(t) {
|
|
431
|
+
try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(t); return; } } catch (e) {}
|
|
432
|
+
try { var ta = document.createElement('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } catch (e) {}
|
|
433
|
+
}
|
|
434
|
+
var COPY_SVG = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
435
|
+
var CARET_SVG = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 4 6 6-6 6"/></svg>';
|
|
436
|
+
|
|
437
|
+
// Expandable/collapsible JSON tree — a recursive walk of the value with a
|
|
438
|
+
// per-path collapsed set (re-rendered on toggle). Mirrors the /editor chat's
|
|
439
|
+
// JsonViewer: indent depth*16+10px, quoted-key + colon, [ … ]/{ … } collapsed
|
|
440
|
+
// previews, per-line hover copy button.
|
|
441
|
+
function jsonViewer(value) {
|
|
442
|
+
var collapsed = {};
|
|
443
|
+
var wrap = document.createElement('div'); wrap.className = 'rk-aichat-json';
|
|
444
|
+
var bar = document.createElement('div'); bar.className = 'rk-aichat-json-bar';
|
|
445
|
+
var expand = document.createElement('button'); expand.type = 'button'; expand.className = 'rk-aichat-json-all'; expand.textContent = 'Collapse all';
|
|
446
|
+
var copyAll = document.createElement('button'); copyAll.type = 'button'; copyAll.className = 'rk-aichat-json-all'; copyAll.textContent = 'Copy all';
|
|
447
|
+
bar.appendChild(expand); bar.appendChild(copyAll);
|
|
448
|
+
var scroll = document.createElement('div'); scroll.className = 'rk-aichat-json-scroll';
|
|
449
|
+
wrap.appendChild(bar); wrap.appendChild(scroll);
|
|
450
|
+
function fmt(v) { if (typeof v === 'string') return JSON.stringify(v); if (typeof v === 'number' || typeof v === 'boolean') return String(v); if (v === null) return 'null'; return JSON.stringify(v); }
|
|
451
|
+
function lineRow(text, depth) {
|
|
452
|
+
var row = document.createElement('div'); row.className = 'rk-aichat-json-line'; row.style.paddingLeft = (depth * 16 + 10) + 'px';
|
|
453
|
+
var cp = document.createElement('button'); cp.type = 'button'; cp.className = 'rk-aichat-json-copy'; cp.setAttribute('aria-label', 'Copy line'); cp.innerHTML = COPY_SVG;
|
|
454
|
+
cp.addEventListener('click', function () { copyText(text); });
|
|
455
|
+
var sp = document.createElement('span'); sp.className = 'rk-aichat-json-text'; sp.textContent = text;
|
|
456
|
+
row.appendChild(cp); row.appendChild(sp); return row;
|
|
457
|
+
}
|
|
458
|
+
function toggleRow(text, depth, path, open) {
|
|
459
|
+
var row = document.createElement('div'); row.className = 'rk-aichat-json-line is-toggle'; row.style.paddingLeft = (depth * 16 + 10) + 'px';
|
|
460
|
+
var t = document.createElement('button'); t.type = 'button'; t.className = 'rk-aichat-json-caret'; t.setAttribute('data-open', open ? 'true' : 'false'); t.innerHTML = CARET_SVG;
|
|
461
|
+
t.addEventListener('click', function () { if (open) collapsed[path] = true; else delete collapsed[path]; render(); });
|
|
462
|
+
var sp = document.createElement('span'); sp.className = 'rk-aichat-json-text'; sp.textContent = text;
|
|
463
|
+
row.appendChild(t); row.appendChild(sp); return row;
|
|
464
|
+
}
|
|
465
|
+
function walk(v, depth, prefix, isLast, path) {
|
|
466
|
+
var comma = isLast ? '' : ',';
|
|
467
|
+
if (Array.isArray(v)) {
|
|
468
|
+
if (!v.length) { scroll.appendChild(lineRow(prefix + '[]' + comma, depth)); return; }
|
|
469
|
+
if (collapsed[path]) { scroll.appendChild(toggleRow(prefix + '[ … ]' + comma, depth, path, false)); return; }
|
|
470
|
+
scroll.appendChild(toggleRow(prefix + '[', depth, path, true));
|
|
471
|
+
for (var i = 0; i < v.length; i++) walk(v[i], depth + 1, '', i === v.length - 1, path + '.' + i);
|
|
472
|
+
scroll.appendChild(lineRow(']' + comma, depth));
|
|
473
|
+
} else if (v && typeof v === 'object') {
|
|
474
|
+
var keys = Object.keys(v);
|
|
475
|
+
if (!keys.length) { scroll.appendChild(lineRow(prefix + '{}' + comma, depth)); return; }
|
|
476
|
+
if (collapsed[path]) { scroll.appendChild(toggleRow(prefix + '{ … }' + comma, depth, path, false)); return; }
|
|
477
|
+
scroll.appendChild(toggleRow(prefix + '{', depth, path, true));
|
|
478
|
+
for (var k = 0; k < keys.length; k++) {
|
|
479
|
+
var key = keys[k], cp = JSON.stringify(key) + ': ', last = k === keys.length - 1, child = v[key];
|
|
480
|
+
if (child && typeof child === 'object') walk(child, depth + 1, cp, last, path + '.' + key);
|
|
481
|
+
else scroll.appendChild(lineRow(cp + fmt(child) + (last ? '' : ','), depth + 1));
|
|
482
|
+
}
|
|
483
|
+
scroll.appendChild(lineRow('}' + comma, depth));
|
|
484
|
+
} else {
|
|
485
|
+
scroll.appendChild(lineRow(prefix + fmt(v) + comma, depth));
|
|
486
|
+
}
|
|
345
487
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
wrap.appendChild(btn);
|
|
488
|
+
function render() { scroll.innerHTML = ''; walk(value, 0, '', true, 'root'); }
|
|
489
|
+
var allCollapsed = false;
|
|
490
|
+
function markAll(v, path) {
|
|
491
|
+
if (Array.isArray(v)) { if (v.length) { collapsed[path] = true; for (var i = 0; i < v.length; i++) markAll(v[i], path + '.' + i); } }
|
|
492
|
+
else if (v && typeof v === 'object') { var ks = Object.keys(v); if (ks.length) { collapsed[path] = true; for (var k = 0; k < ks.length; k++) markAll(v[ks[k]], path + '.' + ks[k]); } }
|
|
352
493
|
}
|
|
494
|
+
expand.addEventListener('click', function () {
|
|
495
|
+
allCollapsed = !allCollapsed;
|
|
496
|
+
if (allCollapsed) { markAll(value, 'root'); delete collapsed['root']; expand.textContent = 'Expand all'; }
|
|
497
|
+
else { collapsed = {}; expand.textContent = 'Collapse all'; }
|
|
498
|
+
render();
|
|
499
|
+
});
|
|
500
|
+
copyAll.addEventListener('click', function () { copyText(fmtBody(value)); });
|
|
501
|
+
render();
|
|
353
502
|
return wrap;
|
|
354
503
|
}
|
|
355
|
-
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
504
|
+
function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
505
|
+
// Render inline markdown in assistant text: bold, italic, markdown links and
|
|
506
|
+
// bare absolute URLs → HTML (mirrors the /editor chat's renderInlineMarkdown).
|
|
507
|
+
// Order matters: **bold** before *italic*; links before bare-URL autolinking
|
|
508
|
+
// (an href="…" is never preceded by whitespace/'(' so it won't double-link).
|
|
509
|
+
function linkify(text) {
|
|
510
|
+
var html = escapeHtml(text);
|
|
511
|
+
html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
512
|
+
html = html.replace(/(^|[^\\w*])\\*([^*\\n]+)\\*/g, '$1<em>$2</em>');
|
|
513
|
+
html = html.replace(/(^|[^\\w_])_([^_\\n]+)_/g, '$1<em>$2</em>');
|
|
514
|
+
html = html.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
515
|
+
html = html.replace(/(^|[\\s(])(https?:\\/\\/[^\\s<]+[^<.,:;"')\\]\\s])/g, '$1<a href="$2" target="_blank" rel="noopener noreferrer">$2</a>');
|
|
516
|
+
return html;
|
|
517
|
+
}
|
|
518
|
+
function openHttpModal(ex) {
|
|
519
|
+
var req = ex.request || {}, res = ex.response || {};
|
|
520
|
+
var status = typeof res.status === 'number' ? res.status : 0;
|
|
521
|
+
var isErr = status >= 400 || res.error;
|
|
522
|
+
var back = document.createElement('div');
|
|
523
|
+
back.className = 'rk-aichat-modal-back';
|
|
524
|
+
var modal = document.createElement('div');
|
|
525
|
+
modal.className = 'rk-aichat-modal';
|
|
526
|
+
var head = document.createElement('div');
|
|
527
|
+
head.className = 'rk-aichat-modal-head';
|
|
528
|
+
head.innerHTML = '<span class="rk-aichat-http-method">' + (req.method || 'GET') + '</span>'
|
|
529
|
+
+ '<span class="rk-aichat-http-url"></span>'
|
|
530
|
+
+ '<span class="rk-aichat-http-status" data-error="' + (isErr ? 'true' : 'false') + '">' + (status || '—') + (res.statusText ? ' ' + res.statusText : '') + '</span>'
|
|
531
|
+
+ '<button type="button" class="rk-aichat-modal-x" aria-label="Close">×</button>';
|
|
532
|
+
head.querySelector('.rk-aichat-http-url').textContent = req.url || req.path || '';
|
|
533
|
+
var body = document.createElement('div');
|
|
534
|
+
body.className = 'rk-aichat-modal-body';
|
|
535
|
+
// A raw string block (URL, error text). Always plain <pre>.
|
|
536
|
+
function textBlock(label, text) {
|
|
537
|
+
if (!text) return;
|
|
538
|
+
var wrap = document.createElement('div'); wrap.className = 'rk-aichat-http-block';
|
|
539
|
+
var l = document.createElement('div'); l.className = 'rk-aichat-http-blabel'; l.textContent = label;
|
|
540
|
+
var pre = document.createElement('pre'); pre.className = 'rk-aichat-http-pre'; pre.textContent = text;
|
|
541
|
+
wrap.appendChild(l); wrap.appendChild(pre); body.appendChild(wrap);
|
|
542
|
+
}
|
|
543
|
+
// A body/headers block: collapsible JSON tree when structured, else <pre>.
|
|
544
|
+
function dataBlock(label, value) {
|
|
545
|
+
if (value === null || value === undefined || value === '') return;
|
|
546
|
+
var data = value;
|
|
547
|
+
if (typeof value === 'string') { try { var p = JSON.parse(value); if (p && typeof p === 'object') data = p; } catch (e) {} }
|
|
548
|
+
var wrap = document.createElement('div'); wrap.className = 'rk-aichat-http-block';
|
|
549
|
+
var l = document.createElement('div'); l.className = 'rk-aichat-http-blabel'; l.textContent = label;
|
|
550
|
+
wrap.appendChild(l);
|
|
551
|
+
if (data && typeof data === 'object') { wrap.appendChild(jsonViewer(data)); }
|
|
552
|
+
else { var pre = document.createElement('pre'); pre.className = 'rk-aichat-http-pre'; pre.textContent = (typeof value === 'string') ? value : fmtBody(value); wrap.appendChild(pre); }
|
|
553
|
+
body.appendChild(wrap);
|
|
554
|
+
}
|
|
555
|
+
function hasKeys(o) { return o && typeof o === 'object' && Object.keys(o).length > 0; }
|
|
556
|
+
if (ex.explanation) { var note = document.createElement('div'); note.className = 'rk-aichat-http-note'; note.textContent = ex.explanation; body.appendChild(note); }
|
|
557
|
+
textBlock('URL', req.url || req.path || '');
|
|
558
|
+
if (req.body !== undefined && req.body !== null && req.body !== '') dataBlock('Request', req.body);
|
|
559
|
+
if (res.error) textBlock('Response', res.error);
|
|
560
|
+
else dataBlock('Response', (res.body === undefined || res.body === null || res.body === '') ? '(empty)' : res.body);
|
|
561
|
+
if (hasKeys(res.headers)) dataBlock('Response headers', res.headers);
|
|
562
|
+
modal.appendChild(head); modal.appendChild(body); back.appendChild(modal);
|
|
563
|
+
function close() { if (back.parentNode) back.parentNode.removeChild(back); document.removeEventListener('keydown', onKey); }
|
|
564
|
+
function onKey(e) { if (e.key === 'Escape') { e.stopPropagation(); close(); } }
|
|
565
|
+
back.addEventListener('click', function (e) { if (e.target === back) close(); });
|
|
566
|
+
head.querySelector('.rk-aichat-modal-x').addEventListener('click', close);
|
|
567
|
+
document.addEventListener('keydown', onKey);
|
|
568
|
+
document.body.appendChild(back);
|
|
569
|
+
}
|
|
570
|
+
function httpCard(ex) {
|
|
571
|
+
var req = ex.request || {}, res = ex.response || {};
|
|
572
|
+
var status = typeof res.status === 'number' ? res.status : 0;
|
|
573
|
+
var isErr = status >= 400 || res.error;
|
|
359
574
|
var b = document.createElement('button');
|
|
360
575
|
b.type = 'button';
|
|
361
|
-
b.className = 'rk-aichat-
|
|
362
|
-
var
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
main.appendChild(name);
|
|
371
|
-
if (sub) {
|
|
372
|
-
var meta = document.createElement('span');
|
|
373
|
-
meta.className = 'rk-aichat-fmeta';
|
|
374
|
-
meta.textContent = sub;
|
|
375
|
-
main.appendChild(meta);
|
|
376
|
-
}
|
|
377
|
-
var chev = document.createElement('span');
|
|
378
|
-
chev.className = 'rk-aichat-fadd';
|
|
379
|
-
chev.textContent = 'Open ›';
|
|
380
|
-
b.appendChild(ic); b.appendChild(main); b.appendChild(chev);
|
|
381
|
-
b.addEventListener('click', onOpen);
|
|
576
|
+
b.className = 'rk-aichat-http';
|
|
577
|
+
var m = document.createElement('span'); m.className = 'rk-aichat-http-method'; m.textContent = req.method || 'GET';
|
|
578
|
+
var u = document.createElement('span'); u.className = 'rk-aichat-http-url';
|
|
579
|
+
var url = req.url || req.path || '';
|
|
580
|
+
u.textContent = url.replace(/^https?:\\/\\/[^/]+/, ''); u.title = url;
|
|
581
|
+
var s = document.createElement('span'); s.className = 'rk-aichat-http-status';
|
|
582
|
+
s.setAttribute('data-error', isErr ? 'true' : 'false'); s.textContent = status || '—';
|
|
583
|
+
b.appendChild(m); b.appendChild(u); b.appendChild(s);
|
|
584
|
+
b.addEventListener('click', function () { openHttpModal(ex); });
|
|
382
585
|
return b;
|
|
383
586
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
var
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
587
|
+
|
|
588
|
+
// ── file preview: classify a file by content-type + extension ──
|
|
589
|
+
function fileKind(name, ct) {
|
|
590
|
+
ct = (ct || '').toLowerCase();
|
|
591
|
+
var ext = ((name || '').split('.').pop() || '').toLowerCase();
|
|
592
|
+
if (ct.indexOf('image/') === 0 || /^(png|jpe?g|gif|webp|svg|avif|bmp|heic|ico)$/.test(ext)) return 'image';
|
|
593
|
+
if (ct.indexOf('video/') === 0 || /^(mp4|mov|webm|m4v|mkv|ogv)$/.test(ext)) return 'video';
|
|
594
|
+
if (ct.indexOf('audio/') === 0 || /^(mp3|wav|m4a|aac|ogg|oga|flac|opus)$/.test(ext)) return 'audio';
|
|
595
|
+
if (ct.indexOf('application/pdf') === 0 || ext === 'pdf') return 'pdf';
|
|
596
|
+
if (ct.indexOf('json') >= 0 || ext === 'json') return 'json';
|
|
597
|
+
if (ext === 'md' || ext === 'markdown' || ct.indexOf('markdown') >= 0) return 'markdown';
|
|
598
|
+
if (ct.indexOf('text/') === 0 || /^(txt|csv|tsv|srt|vtt|log|xml|yaml|yml|ini|conf|html?)$/.test(ext)) return 'text';
|
|
599
|
+
return 'other';
|
|
393
600
|
}
|
|
394
601
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
602
|
+
// Compact markdown → HTML for .md previews (headings, lists, quotes, rules,
|
|
603
|
+
// fenced + inline code, bold/italic/links). Escapes first, then injects tags
|
|
604
|
+
// (same order as linkify). Backticks are built via char codes to avoid
|
|
605
|
+
// escaping issues inside this template literal.
|
|
606
|
+
function renderMarkdown(src) {
|
|
607
|
+
var BT = String.fromCharCode(96), FENCE = BT + BT + BT;
|
|
608
|
+
function inline(s) {
|
|
609
|
+
s = escapeHtml(s);
|
|
610
|
+
s = s.replace(new RegExp(BT + '([^' + BT + ']+)' + BT, 'g'), '<code>$1</code>');
|
|
611
|
+
s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
612
|
+
s = s.replace(/(^|[^\\w*])\\*([^*\\n]+)\\*/g, '$1<em>$2</em>');
|
|
613
|
+
s = s.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
614
|
+
s = s.replace(/(^|[\\s(])(https?:\\/\\/[^\\s<]+[^<.,:;"')\\]\\s])/g, '$1<a href="$2" target="_blank" rel="noopener noreferrer">$2</a>');
|
|
615
|
+
return s;
|
|
409
616
|
}
|
|
410
|
-
var
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
617
|
+
var lines = String(src).replace(/\\r\\n?/g, '\\n').split('\\n');
|
|
618
|
+
var out = [], inCode = false, code = [], listType = null, listItems = [];
|
|
619
|
+
function flushList() { if (!listType) return; out.push('<' + listType + '>' + listItems.join('') + '</' + listType + '>'); listType = null; listItems = []; }
|
|
620
|
+
function flushCode() { out.push('<pre class="rk-aichat-preview-code"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>'); code = []; }
|
|
621
|
+
for (var i = 0; i < lines.length; i++) {
|
|
622
|
+
var ln = lines[i];
|
|
623
|
+
if (ln.trim().indexOf(FENCE) === 0) { if (inCode) { flushCode(); inCode = false; } else { flushList(); inCode = true; } continue; }
|
|
624
|
+
if (inCode) { code.push(ln); continue; }
|
|
625
|
+
var h = ln.match(/^(#{1,6})\\s+(.*)$/);
|
|
626
|
+
if (h) { flushList(); var lvl = h[1].length; out.push('<h' + lvl + '>' + inline(h[2]) + '</h' + lvl + '>'); continue; }
|
|
627
|
+
if (/^\\s*([-*+])\\s+/.test(ln)) { if (listType !== 'ul') { flushList(); listType = 'ul'; } listItems.push('<li>' + inline(ln.replace(/^\\s*[-*+]\\s+/, '')) + '</li>'); continue; }
|
|
628
|
+
var om = ln.match(/^\\s*\\d+\\.\\s+(.*)$/);
|
|
629
|
+
if (om) { if (listType !== 'ol') { flushList(); listType = 'ol'; } listItems.push('<li>' + inline(om[1]) + '</li>'); continue; }
|
|
630
|
+
if (/^\\s*>\\s?/.test(ln)) { flushList(); out.push('<blockquote>' + inline(ln.replace(/^\\s*>\\s?/, '')) + '</blockquote>'); continue; }
|
|
631
|
+
if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(ln)) { flushList(); out.push('<hr>'); continue; }
|
|
632
|
+
if (!ln.trim()) { flushList(); continue; }
|
|
633
|
+
flushList();
|
|
634
|
+
out.push('<p>' + inline(ln) + '</p>');
|
|
635
|
+
}
|
|
636
|
+
if (inCode) flushCode();
|
|
637
|
+
flushList();
|
|
638
|
+
return out.join('');
|
|
425
639
|
}
|
|
426
640
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
641
|
+
// ── file preview modal: images (carousel), video, audio, pdf, and text
|
|
642
|
+
// (json → collapsible tree, md → rendered, txt/csv/srt/vtt → <pre>) ──
|
|
643
|
+
function openFilePreview(items, index) {
|
|
644
|
+
items = (items || []).filter(function (f) { return f && f.viewUrl; });
|
|
645
|
+
if (!items.length) return;
|
|
646
|
+
var idx = Math.max(0, Math.min(index || 0, items.length - 1));
|
|
647
|
+
|
|
648
|
+
var back = document.createElement('div'); back.className = 'rk-aichat-modal-back';
|
|
649
|
+
var modal = document.createElement('div'); modal.className = 'rk-aichat-modal rk-aichat-preview';
|
|
650
|
+
var head = document.createElement('div'); head.className = 'rk-aichat-modal-head';
|
|
651
|
+
var nameEl = document.createElement('span'); nameEl.className = 'rk-aichat-http-url rk-aichat-preview-name';
|
|
652
|
+
var openLink = document.createElement('a'); openLink.className = 'rk-aichat-preview-open'; openLink.target = '_blank'; openLink.rel = 'noopener noreferrer'; openLink.textContent = 'Open ↗';
|
|
653
|
+
var xBtn = document.createElement('button'); xBtn.type = 'button'; xBtn.className = 'rk-aichat-modal-x'; xBtn.setAttribute('aria-label', 'Close'); xBtn.innerHTML = '×';
|
|
654
|
+
head.appendChild(nameEl); head.appendChild(openLink); head.appendChild(xBtn);
|
|
655
|
+
|
|
656
|
+
var body = document.createElement('div'); body.className = 'rk-aichat-modal-body rk-aichat-preview-body';
|
|
657
|
+
|
|
658
|
+
var nav = document.createElement('div'); nav.className = 'rk-aichat-preview-nav';
|
|
659
|
+
var prev = document.createElement('button'); prev.type = 'button'; prev.className = 'rk-aichat-preview-btn'; prev.innerHTML = '‹ Prev';
|
|
660
|
+
var counter = document.createElement('span'); counter.className = 'rk-aichat-preview-count';
|
|
661
|
+
var next = document.createElement('button'); next.type = 'button'; next.className = 'rk-aichat-preview-btn'; next.innerHTML = 'Next ›';
|
|
662
|
+
nav.appendChild(prev); nav.appendChild(counter); nav.appendChild(next);
|
|
663
|
+
|
|
664
|
+
function renderUnknown(container, it, msg) {
|
|
665
|
+
var wrap = document.createElement('div'); wrap.className = 'rk-aichat-preview-empty';
|
|
666
|
+
var p = document.createElement('div'); p.textContent = msg || 'No inline preview for this file type.'; wrap.appendChild(p);
|
|
667
|
+
var a = document.createElement('a'); a.href = it.viewUrl; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.className = 'rk-aichat-preview-dl'; a.textContent = 'Open / download ↗'; wrap.appendChild(a);
|
|
668
|
+
container.appendChild(wrap);
|
|
669
|
+
}
|
|
670
|
+
function renderText(container, it, kind) {
|
|
671
|
+
var loading = document.createElement('div'); loading.className = 'rk-aichat-preview-loading'; loading.textContent = 'Loading…'; container.appendChild(loading);
|
|
672
|
+
fetch(it.viewUrl, { credentials: 'same-origin' })
|
|
673
|
+
.then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.text(); })
|
|
674
|
+
.then(function (txt) {
|
|
675
|
+
container.innerHTML = '';
|
|
676
|
+
if (kind === 'json') {
|
|
677
|
+
var parsed; try { parsed = JSON.parse(txt); } catch (e) { parsed = undefined; }
|
|
678
|
+
if (parsed && typeof parsed === 'object') { container.appendChild(jsonViewer(parsed)); return; }
|
|
679
|
+
var pj = document.createElement('pre'); pj.className = 'rk-aichat-http-pre rk-aichat-preview-text'; pj.textContent = txt; container.appendChild(pj); return;
|
|
680
|
+
}
|
|
681
|
+
if (kind === 'markdown') { var md = document.createElement('div'); md.className = 'rk-aichat-preview-md'; md.innerHTML = renderMarkdown(txt); container.appendChild(md); return; }
|
|
682
|
+
var pre = document.createElement('pre'); pre.className = 'rk-aichat-http-pre rk-aichat-preview-text'; pre.textContent = txt; container.appendChild(pre);
|
|
683
|
+
})
|
|
684
|
+
.catch(function () { container.innerHTML = ''; renderUnknown(container, it, 'Couldn’t load this file.'); });
|
|
685
|
+
}
|
|
686
|
+
function render() {
|
|
687
|
+
var it = items[idx];
|
|
688
|
+
nameEl.textContent = it.name || 'file'; nameEl.title = it.name || '';
|
|
689
|
+
openLink.href = it.viewUrl;
|
|
690
|
+
body.innerHTML = '';
|
|
691
|
+
var kind = fileKind(it.name, it.contentType);
|
|
692
|
+
if (kind === 'image') { var im = document.createElement('img'); im.className = 'rk-aichat-preview-img'; im.src = it.viewUrl; im.alt = it.name || ''; body.appendChild(im); }
|
|
693
|
+
else if (kind === 'video') { var v = document.createElement('video'); v.className = 'rk-aichat-preview-media'; v.src = it.viewUrl; v.controls = true; v.playsInline = true; body.appendChild(v); }
|
|
694
|
+
else if (kind === 'audio') { var wrap = document.createElement('div'); wrap.className = 'rk-aichat-preview-audiowrap'; var a = document.createElement('audio'); a.className = 'rk-aichat-preview-audio'; a.src = it.viewUrl; a.controls = true; wrap.appendChild(a); body.appendChild(wrap); }
|
|
695
|
+
else if (kind === 'pdf') { var f = document.createElement('iframe'); f.className = 'rk-aichat-preview-frame'; f.src = it.viewUrl; body.appendChild(f); }
|
|
696
|
+
else if (kind === 'json' || kind === 'markdown' || kind === 'text') { renderText(body, it, kind); }
|
|
697
|
+
else { renderUnknown(body, it); }
|
|
698
|
+
counter.textContent = (idx + 1) + ' / ' + items.length;
|
|
699
|
+
prev.disabled = idx <= 0; next.disabled = idx >= items.length - 1;
|
|
700
|
+
nav.hidden = items.length < 2;
|
|
701
|
+
}
|
|
702
|
+
prev.addEventListener('click', function () { if (idx > 0) { idx--; render(); } });
|
|
703
|
+
next.addEventListener('click', function () { if (idx < items.length - 1) { idx++; render(); } });
|
|
704
|
+
|
|
705
|
+
modal.appendChild(head); modal.appendChild(body); modal.appendChild(nav); back.appendChild(modal);
|
|
706
|
+
function close() { if (back.parentNode) back.parentNode.removeChild(back); document.removeEventListener('keydown', onKey); }
|
|
707
|
+
function onKey(e) { if (e.key === 'Escape') { e.stopPropagation(); close(); } else if (e.key === 'ArrowLeft') { if (idx > 0) { idx--; render(); } } else if (e.key === 'ArrowRight') { if (idx < items.length - 1) { idx++; render(); } } }
|
|
708
|
+
back.addEventListener('click', function (e) { if (e.target === back) close(); });
|
|
709
|
+
xBtn.addEventListener('click', close);
|
|
710
|
+
document.addEventListener('keydown', onKey);
|
|
711
|
+
document.body.appendChild(back);
|
|
712
|
+
render();
|
|
434
713
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
714
|
+
|
|
715
|
+
// ── assistant turn controller (status → actions → streamed text) ──
|
|
716
|
+
function appendAssistant() {
|
|
717
|
+
var turn = document.createElement('div'); turn.className = 'rk-aichat-turn';
|
|
718
|
+
var actions = document.createElement('div'); actions.className = 'rk-aichat-actions';
|
|
719
|
+
var status = document.createElement('div'); status.className = 'rk-aichat-status';
|
|
720
|
+
status.innerHTML = '<span class="rk-aichat-typing"><span></span><span></span><span></span></span>';
|
|
721
|
+
var textEl = document.createElement('div'); textEl.className = 'rk-aichat-msg is-ai rk-aichat-stream'; textEl.hidden = true;
|
|
722
|
+
turn.appendChild(actions); turn.appendChild(status); turn.appendChild(textEl);
|
|
723
|
+
log.appendChild(turn); logBottom();
|
|
724
|
+
var lastText = '', flowEl = null;
|
|
725
|
+
return {
|
|
726
|
+
hideStatus: function () { status.hidden = true; },
|
|
727
|
+
showText: function () { status.hidden = true; textEl.hidden = false; },
|
|
728
|
+
setText: function (t) { lastText = t; textEl.textContent = t; },
|
|
729
|
+
// After streaming completes, re-render the text as markdown (bold/italic/links).
|
|
730
|
+
renderMd: function () { if (lastText) textEl.innerHTML = linkify(lastText); },
|
|
731
|
+
addNote: function (name) {
|
|
732
|
+
var n = document.createElement('div'); n.className = 'rk-aichat-toolnote';
|
|
733
|
+
n.textContent = 'Running ' + name + '…'; actions.appendChild(n); logBottom();
|
|
734
|
+
return n;
|
|
735
|
+
},
|
|
736
|
+
// Terminal one-line note (kind 'ok' | 'err' | undefined) — e.g. the real
|
|
737
|
+
// browser-side outcome of an editor_action apply.
|
|
738
|
+
note: function (text, kind) {
|
|
739
|
+
var n = document.createElement('div');
|
|
740
|
+
n.className = 'rk-aichat-toolnote' + (kind === 'err' ? ' is-err' : (kind === 'ok' ? ' is-ok' : ''));
|
|
741
|
+
n.textContent = text; actions.appendChild(n); logBottom();
|
|
742
|
+
return n;
|
|
743
|
+
},
|
|
744
|
+
addHttp: function (ex) { actions.appendChild(httpCard(ex)); logBottom(); },
|
|
745
|
+
// create_video pipeline progress (persistent status row, distinct from the
|
|
746
|
+
// thinking dots which hide once agent text streams).
|
|
747
|
+
flowStatus: function (s) {
|
|
748
|
+
if (!flowEl) { flowEl = document.createElement('div'); flowEl.className = 'rk-aichat-flow'; flowEl.innerHTML = '<span class="rk-aichat-typing"><span></span><span></span><span></span></span><span class="rk-aichat-flow-label"></span>'; turn.appendChild(flowEl); }
|
|
749
|
+
flowEl.hidden = false; flowEl.querySelector('.rk-aichat-flow-label').textContent = s || ''; logBottom();
|
|
750
|
+
},
|
|
751
|
+
// Final pipeline message (markdown-linkified, e.g. "Open it in the editor").
|
|
752
|
+
flowResult: function (md) {
|
|
753
|
+
if (flowEl) flowEl.hidden = true;
|
|
754
|
+
var b = document.createElement('div'); b.className = 'rk-aichat-msg is-ai'; b.innerHTML = linkify(md); turn.appendChild(b); logBottom();
|
|
755
|
+
},
|
|
756
|
+
fail: function (msg) {
|
|
757
|
+
status.hidden = true;
|
|
758
|
+
var er = document.createElement('div'); er.className = 'rk-aichat-err';
|
|
759
|
+
er.textContent = msg || 'Something went wrong.'; turn.appendChild(er); logBottom();
|
|
760
|
+
}
|
|
761
|
+
};
|
|
459
762
|
}
|
|
460
763
|
|
|
461
|
-
|
|
462
|
-
function
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
try {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (!
|
|
477
|
-
|
|
478
|
-
|
|
764
|
+
// ── SSE reader (mirrors the live editor-chat client) ──
|
|
765
|
+
function handleEvent(raw, h) {
|
|
766
|
+
var lines = raw.split('\\n'), dataLine = null;
|
|
767
|
+
for (var j = 0; j < lines.length; j++) { var t = lines[j].trim(); if (t.indexOf('data:') === 0) { dataLine = t; break; } }
|
|
768
|
+
if (!dataLine) return;
|
|
769
|
+
var payload = dataLine.slice(5).trim();
|
|
770
|
+
if (payload === '[DONE]' || !payload) return;
|
|
771
|
+
var chunk; try { chunk = JSON.parse(payload); } catch (e) { return; }
|
|
772
|
+
if (!chunk || typeof chunk !== 'object') return;
|
|
773
|
+
if (chunk.type === 'text-delta' && typeof chunk.delta === 'string') h.onDelta(chunk.delta);
|
|
774
|
+
else if (chunk.type === 'tool-call' && typeof chunk.toolName === 'string') h.onToolCall(chunk.toolName, chunk.toolCallId);
|
|
775
|
+
else if (chunk.type === 'tool-result') h.onToolResult(chunk.toolName, chunk.result, chunk.toolCallId);
|
|
776
|
+
else if (chunk.type === 'error') h.onError((typeof chunk.errorText === 'string') ? chunk.errorText : ((chunk.error && chunk.error.message) || 'Unable to read assistant response.'));
|
|
777
|
+
}
|
|
778
|
+
function readStream(response, h) {
|
|
779
|
+
if (!response.body || !response.body.getReader) return response.text().then(function (t) { t.split('\\n\\n').forEach(function (ev) { handleEvent(ev, h); }); });
|
|
780
|
+
var reader = response.body.getReader(), decoder = new TextDecoder(), buffer = '';
|
|
781
|
+
function pump() {
|
|
782
|
+
return reader.read().then(function (res) {
|
|
783
|
+
if (res.done) { if (buffer) handleEvent(buffer, h); return; }
|
|
784
|
+
buffer += decoder.decode(res.value, { stream: true });
|
|
785
|
+
var events = buffer.split('\\n\\n');
|
|
786
|
+
buffer = events.pop() || '';
|
|
787
|
+
for (var i = 0; i < events.length; i++) handleEvent(events[i], h);
|
|
788
|
+
return pump();
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
return pump();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Turn this turn's attachments into a text line the model can quote back as
|
|
795
|
+
// REST payload URLs (prompt_attachments / source_image_url / media_url), in
|
|
796
|
+
// addition to the binary file content parts. Mirrors the /editor React dock
|
|
797
|
+
// (buildAttachmentTransportText). Double-backslash-n = a browser-side newline
|
|
798
|
+
// (we live inside a script template literal, so a bare newline escape breaks it).
|
|
799
|
+
function buildAttachmentUrlText(atts) {
|
|
800
|
+
if (!atts || !atts.length) return '';
|
|
801
|
+
var lines = atts.map(function (a) { return '- ' + (a.fileName || 'file') + ' (' + (a.contentType || '') + '): ' + a.viewUrl; });
|
|
802
|
+
return '\\n\\nAttached file URLs for REST payloads:\\n' + lines.join('\\n') + '\\nUse these exact URLs as prompt_attachments for image generation, source_image_url for image edits, media_url for video slides, or reference attachment URLs when calling template routes.';
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function sendMessage(text) {
|
|
806
|
+
if (busy) return;
|
|
807
|
+
text = (text || '').trim();
|
|
808
|
+
var picked = (typeof selected !== 'undefined' ? selected : []).slice();
|
|
809
|
+
// A pasted file may still be uploading to /temp — wait for it rather than
|
|
810
|
+
// silently dropping the attachment. Flash the in-flight chips as a hint.
|
|
811
|
+
if (picked.some(function (f) { return f && f.pending; })) {
|
|
812
|
+
picked.forEach(function (f) { if (f.pending) flashChip(f.id); });
|
|
479
813
|
return;
|
|
480
814
|
}
|
|
481
|
-
var
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
if (
|
|
485
|
-
if (
|
|
486
|
-
|
|
487
|
-
var
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
815
|
+
var atts = picked.filter(function (f) { return f && f.viewUrl; }).map(function (f) {
|
|
816
|
+
return { id: String(f.id), fileName: f.name || 'file', contentType: f.contentType || 'application/octet-stream', viewUrl: f.viewUrl };
|
|
817
|
+
});
|
|
818
|
+
if (!text && !atts.length) return;
|
|
819
|
+
if (BOOT_STATE === 'idle' || BOOT_STATE === 'error') loadBoot();
|
|
820
|
+
|
|
821
|
+
var line = text;
|
|
822
|
+
if (picked.length) { line = (text ? text + ' ' : '') + '(attached: ' + picked.map(function (f) { return f.name; }).join(', ') + ')'; }
|
|
823
|
+
bubble('is-user', line);
|
|
824
|
+
convo.push({ role: 'user', text: text || line });
|
|
825
|
+
if (input) { input.value = ''; if (typeof autosizeInput === 'function') autosizeInput(); }
|
|
826
|
+
if (typeof clearChips === 'function') clearChips();
|
|
827
|
+
|
|
828
|
+
if (BOOT_STATE === 'anon') {
|
|
829
|
+
var v = appendAssistant(); v.showText();
|
|
830
|
+
v.setText('Sign in to chat with the vidfarm AI on your own provider keys.');
|
|
492
831
|
return;
|
|
493
832
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
var
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
var
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
833
|
+
|
|
834
|
+
var view = appendAssistant();
|
|
835
|
+
setBusy(true);
|
|
836
|
+
if (!threadId) threadId = genId('thread');
|
|
837
|
+
// Attach a fresh <editor_context> to the current (last) user turn only, so the
|
|
838
|
+
// model sees the composition state without bloating persisted history.
|
|
839
|
+
var ctxBlock = editorContextBlock();
|
|
840
|
+
// Attachments (pasted files OR files picked from the directory explorer) must
|
|
841
|
+
// ride in the model messages as file content parts + a URL text line — the
|
|
842
|
+
// backend only feeds the model messages[].content, NOT user_message.attachments
|
|
843
|
+
// (which is persistence-only). Without this the AI never saw attached files.
|
|
844
|
+
var attUrlText = atts.length ? buildAttachmentUrlText(atts) : '';
|
|
845
|
+
var outMessages = convo.map(function (m, i) {
|
|
846
|
+
var isLast = i === convo.length - 1;
|
|
847
|
+
var isLastUser = isLast && m.role === 'user';
|
|
848
|
+
var t = m.text;
|
|
849
|
+
if (isLastUser && attUrlText) t = t + attUrlText;
|
|
850
|
+
if (ctxBlock && isLastUser) t = t + ctxBlock;
|
|
851
|
+
var content = [{ type: 'text', text: t }];
|
|
852
|
+
if (isLastUser && atts.length) {
|
|
853
|
+
atts.forEach(function (a) { content.push({ type: 'file', data: a.viewUrl, mediaType: a.contentType || 'application/octet-stream' }); });
|
|
854
|
+
}
|
|
855
|
+
return { role: m.role, content: content };
|
|
856
|
+
});
|
|
857
|
+
var body = {
|
|
858
|
+
messages: outMessages,
|
|
859
|
+
thread_id: threadId,
|
|
860
|
+
thread_template_id: TEMPLATE_ID,
|
|
861
|
+
thread_title: (text || line).slice(0, 42),
|
|
862
|
+
thread_tracers: (TEMPLATE && TEMPLATE.tracers) || [],
|
|
863
|
+
client_context: clientContext(),
|
|
864
|
+
user_message: { id: genId('user'), role: 'user', text: text || line, attachments: atts },
|
|
865
|
+
assistant_message: { id: genId('assistant') }
|
|
866
|
+
};
|
|
867
|
+
if (TEMPLATE) body.template = TEMPLATE;
|
|
868
|
+
if (CACHED) body.cached_context = CACHED;
|
|
869
|
+
var headers = { 'content-type': 'application/json' };
|
|
870
|
+
if (API_KEY) headers['vidfarm-api-key'] = API_KEY;
|
|
871
|
+
|
|
872
|
+
var acc = '', sawText = false, errored = false;
|
|
873
|
+
var handlers = {
|
|
874
|
+
onDelta: function (d) { if (!sawText) { sawText = true; view.showText(); } acc += d; view.setText(acc); logBottom(); },
|
|
875
|
+
onToolCall: function (name) { if (name !== 'http_request' && name !== 'create_video') view.addNote(name); },
|
|
876
|
+
onToolResult: function (name, result) {
|
|
877
|
+
if (name === 'create_video') { runCreateVideoFlow(view, result); return; }
|
|
878
|
+
// editor_action only mutates the composition browser-side. On /editor the
|
|
879
|
+
// SPA bundle exposes window.__vidfarmEditorAction; apply the tool result
|
|
880
|
+
// through it and surface the REAL outcome. Without this the dock ignored
|
|
881
|
+
// editor_action entirely, so the model's edits were pure no-ops even
|
|
882
|
+
// though its reply claimed success. (Only wired on the editor dock — the
|
|
883
|
+
// bridge is absent on /discover, /library, etc.)
|
|
884
|
+
if (name === 'editor_action') { applyEditorActionFromChat(view, result); return; }
|
|
885
|
+
if (result && result.request && result.response) view.addHttp(result);
|
|
886
|
+
},
|
|
887
|
+
onError: function (m) { errored = true; view.fail(m); }
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
891
|
+
pendingAbort = controller;
|
|
892
|
+
var req;
|
|
893
|
+
try { req = fetch(ENDPOINT, { method: 'POST', headers: headers, credentials: 'same-origin', body: JSON.stringify(body), signal: controller ? controller.signal : undefined }); }
|
|
894
|
+
catch (err) { req = Promise.reject(err); }
|
|
895
|
+
req.then(function (resp) {
|
|
896
|
+
if (!resp.ok) return resp.json().catch(function () { return {}; }).then(function (j) { throw new Error((j && j.error) || ('Chat request failed (' + resp.status + ').')); });
|
|
897
|
+
return readStream(resp, handlers);
|
|
898
|
+
}).then(function () {
|
|
899
|
+
pendingAbort = null;
|
|
900
|
+
view.hideStatus();
|
|
901
|
+
if (sawText) { convo.push({ role: 'assistant', text: acc }); view.renderMd(); }
|
|
902
|
+
else if (!errored) { view.showText(); view.setText('Done.'); }
|
|
903
|
+
setBusy(false);
|
|
904
|
+
if (input) input.focus();
|
|
905
|
+
loadThreads();
|
|
906
|
+
}).catch(function (err) {
|
|
907
|
+
pendingAbort = null;
|
|
908
|
+
// User hit Stop mid-reply — keep whatever streamed so far, no error.
|
|
909
|
+
if (err && (err.name === 'AbortError' || (controller && controller.signal && controller.signal.aborted))) {
|
|
910
|
+
view.hideStatus();
|
|
911
|
+
if (sawText) { convo.push({ role: 'assistant', text: acc }); view.renderMd(); }
|
|
912
|
+
else { view.showText(); view.setText('Stopped.'); }
|
|
913
|
+
setBusy(false); if (input) input.focus();
|
|
542
914
|
return;
|
|
543
915
|
}
|
|
544
|
-
var
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
916
|
+
var msg = (err && err.message) ? err.message : String(err);
|
|
917
|
+
if (!API_KEY) msg = msg + '\\n\\nAdd an AI provider key in Settings to chat on your own keys.';
|
|
918
|
+
view.fail(msg); setBusy(false); if (input) input.focus();
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function resetConversation() {
|
|
923
|
+
convo = []; threadId = genId('thread');
|
|
924
|
+
if (log) { log.innerHTML = ''; bubble('is-ai', GREETING); }
|
|
925
|
+
if (typeof clearChips === 'function') clearChips();
|
|
926
|
+
}
|
|
927
|
+
function newChat() {
|
|
928
|
+
resetConversation();
|
|
929
|
+
setActiveThread(null);
|
|
930
|
+
if (input) { input.value = ''; if (typeof autosizeInput === 'function') autosizeInput(); input.focus(); }
|
|
931
|
+
}
|
|
932
|
+
// NOTE: the New-chat button element (rkNewChat) is resolved later (see the
|
|
933
|
+
// var block below), so bind its click handler there — binding here would run
|
|
934
|
+
// against an undefined newChatBtn (var hoisting) and silently never attach.
|
|
935
|
+
if (form) form.addEventListener('submit', function (e) {
|
|
936
|
+
e.preventDefault();
|
|
937
|
+
if (busy) { if (pendingAbort) pendingAbort.abort(); return; }
|
|
938
|
+
sendMessage(input ? input.value : '');
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
// The composer is now a multi-row textarea: grow with content (capped) and let
|
|
942
|
+
// Enter send (Shift+Enter inserts a newline, like every modern chat box).
|
|
943
|
+
function autosizeInput() {
|
|
944
|
+
if (!input || input.tagName !== 'TEXTAREA') return;
|
|
945
|
+
input.style.height = 'auto';
|
|
946
|
+
input.style.height = Math.min(input.scrollHeight, 210) + 'px';
|
|
947
|
+
}
|
|
948
|
+
if (input) {
|
|
949
|
+
input.addEventListener('input', autosizeInput);
|
|
950
|
+
input.addEventListener('keydown', function (e) {
|
|
951
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
952
|
+
e.preventDefault();
|
|
953
|
+
if (form && form.requestSubmit) form.requestSubmit();
|
|
954
|
+
else { if (busy) { if (pendingAbort) pendingAbort.abort(); } else sendMessage(input.value); }
|
|
955
|
+
}
|
|
562
956
|
});
|
|
563
957
|
}
|
|
564
958
|
|
|
959
|
+
// ─── editor_action: apply a composition mutation through the SPA bridge that
|
|
960
|
+
// the editor bundle exposes on window (__vidfarmEditorAction). Only present on
|
|
961
|
+
// the /editor dock; elsewhere there is no composition to mutate. ───
|
|
962
|
+
function applyEditorActionFromChat(view, result) {
|
|
963
|
+
var label = (result && result.action_type) ? String(result.action_type) : 'editor_action';
|
|
964
|
+
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
965
|
+
if (!bridge || typeof bridge.apply !== 'function') {
|
|
966
|
+
view.note('⚠ Editor not ready — reload the page, then ask again (' + label + ' was not applied).', 'err');
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
var outcome;
|
|
970
|
+
try { outcome = bridge.apply(result); }
|
|
971
|
+
catch (e) { outcome = { ok: false, error: (e && e.message) ? e.message : String(e) }; }
|
|
972
|
+
if (outcome && outcome.ok) {
|
|
973
|
+
view.note('✓ ' + (outcome.summary || ('Applied ' + label + '.')), 'ok');
|
|
974
|
+
} else {
|
|
975
|
+
view.note('⚠ Couldn’t apply ' + label + ((outcome && outcome.error) ? ': ' + outcome.error : '.'), 'err');
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ─── create_video: the browser-side pipeline behind the agent's create_video
|
|
980
|
+
// tool. The backend tool is a passthrough (returns the intent only); the
|
|
981
|
+
// frontend runs ingest → decompose → fork and posts a clickable "Open in the
|
|
982
|
+
// editor" link. Ported 1:1 from the /editor chat's runCreateVideoFlow. ───
|
|
983
|
+
function ivHeaders(json) { var h = { accept: 'application/json' }; if (json) h['content-type'] = 'application/json'; if (API_KEY) h['vidfarm-api-key'] = API_KEY; return h; }
|
|
984
|
+
function ivSleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
|
985
|
+
function ivJson(res) { return res.text().then(function (t) { try { return t ? JSON.parse(t) : null; } catch (e) { return null; } }); }
|
|
986
|
+
async function runCreateVideoFlow(view, intentRaw) {
|
|
987
|
+
var intent = intentRaw || {};
|
|
988
|
+
if (!API_KEY) { view.flowResult('⚠️ You need to be logged in to create a video.'); return; }
|
|
989
|
+
var origin = window.location.origin;
|
|
990
|
+
|
|
991
|
+
function pollInspirationReady(id) {
|
|
992
|
+
var deadline = Date.now() + 5 * 60 * 1000;
|
|
993
|
+
function step() {
|
|
994
|
+
return fetch(origin + '/api/v1/videos/' + encodeURIComponent(id), { headers: ivHeaders(false) }).then(ivJson).then(function (j) {
|
|
995
|
+
var st = String((j && j.status) || '');
|
|
996
|
+
if (st === 'ready') return j;
|
|
997
|
+
if (st === 'failed') throw new Error('source video download failed' + ((j && j.error) ? ': ' + j.error : '') + '.');
|
|
998
|
+
if (Date.now() > deadline) throw new Error('source video download timed out.');
|
|
999
|
+
return ivSleep(5000).then(step);
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
return step();
|
|
1003
|
+
}
|
|
1004
|
+
function jobMediaUrl(job) {
|
|
1005
|
+
function s(v) { return (typeof v === 'string' && v.trim()) ? v.trim() : null; }
|
|
1006
|
+
var result = (job && job.result && typeof job.result === 'object') ? job.result : {};
|
|
1007
|
+
var out = (result.output && typeof result.output === 'object') ? result.output : result;
|
|
1008
|
+
var direct = s(out.primary_file_url) || s(out.video && out.video.file_url) || s(out.image && out.image.file_url) || s(out.render && out.render.output_url);
|
|
1009
|
+
if (direct) return direct;
|
|
1010
|
+
if (out.files && out.files.length) { for (var i = 0; i < out.files.length; i++) { var f = out.files[i]; var u = s(typeof f === 'string' ? f : (f && (f.file_url || f.url))); if (u) return u; } }
|
|
1011
|
+
if (job && job.artifacts && job.artifacts.length) { for (var k = 0; k < job.artifacts.length; k++) { var u2 = s(job.artifacts[k] && job.artifacts[k].public_url); if (u2) return u2; } }
|
|
1012
|
+
return null;
|
|
1013
|
+
}
|
|
1014
|
+
function pollJobMedia(jobId) {
|
|
1015
|
+
var deadline = Date.now() + 8 * 60 * 1000;
|
|
1016
|
+
function step() {
|
|
1017
|
+
return ivSleep(5000).then(function () {
|
|
1018
|
+
return fetch(origin + '/api/v1/user/me/jobs/' + encodeURIComponent(jobId), { headers: ivHeaders(false) }).then(ivJson).then(function (j) {
|
|
1019
|
+
var st = String((j && j.status) || '');
|
|
1020
|
+
var url = jobMediaUrl(j);
|
|
1021
|
+
if (url) return url;
|
|
1022
|
+
if (st === 'failed' || st === 'cancelled') throw new Error('generation ' + st + '.');
|
|
1023
|
+
if (Date.now() > deadline) throw new Error('generation timed out.');
|
|
1024
|
+
return step();
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
return step();
|
|
1029
|
+
}
|
|
1030
|
+
async function ingestGeneratedVideo(mediaUrl, title) {
|
|
1031
|
+
var blob = await (await fetch(mediaUrl)).blob();
|
|
1032
|
+
var presignRes = await fetch(origin + '/discover/templates/upload/presign', { method: 'POST', headers: ivHeaders(true), body: JSON.stringify({ file_name: 'generated.mp4', content_type: 'video/mp4', size_bytes: blob.size }) });
|
|
1033
|
+
var presign = await ivJson(presignRes);
|
|
1034
|
+
if (!presignRes.ok) throw new Error((presign && presign.error) || ('upload presign failed (' + presignRes.status + ')'));
|
|
1035
|
+
var storageKey = presign && presign.storage_key;
|
|
1036
|
+
var uploadedName = (presign && presign.file_name) || 'generated.mp4';
|
|
1037
|
+
if (presign && presign.transport === 'presigned' && presign.upload && presign.upload.url) {
|
|
1038
|
+
var put = await fetch(presign.upload.url, { method: (presign.upload.method || 'PUT'), headers: presign.upload.headers || {}, body: blob });
|
|
1039
|
+
if (!put.ok) throw new Error('upload failed (' + put.status + ')');
|
|
1040
|
+
} else {
|
|
1041
|
+
var form = new FormData(); form.append('file', blob, 'generated.mp4');
|
|
1042
|
+
var up = await fetch(origin + ((presign && presign.upload && presign.upload.url) || '/discover/templates/upload'), { method: 'POST', headers: ivHeaders(false), body: form });
|
|
1043
|
+
var upJson = await ivJson(up);
|
|
1044
|
+
if (!up.ok || !upJson || !upJson.storage_key) throw new Error((upJson && upJson.error) || ('upload failed (' + up.status + ')'));
|
|
1045
|
+
storageKey = upJson.storage_key; uploadedName = upJson.file_name || 'generated.mp4';
|
|
1046
|
+
}
|
|
1047
|
+
if (!storageKey) throw new Error('upload did not return a storage key.');
|
|
1048
|
+
var addRes = await fetch(origin + '/discover/templates', { method: 'POST', headers: ivHeaders(true), body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title: title, origin: 'raw' }) });
|
|
1049
|
+
var add = await ivJson(addRes);
|
|
1050
|
+
if (!addRes.ok) throw new Error((add && add.error) || ('ingest failed (' + addRes.status + ')'));
|
|
1051
|
+
return add;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
var mode = intent.mode === 'replicate' ? 'replicate' : (intent.mode === 'generate' ? 'generate' : null);
|
|
1056
|
+
var inspiration, userPrompt;
|
|
1057
|
+
if (mode === 'replicate') {
|
|
1058
|
+
var sourceUrl = String(intent.source_url || '').trim();
|
|
1059
|
+
if (!/^https?:\\/\\//i.test(sourceUrl)) { view.flowResult('⚠️ I need a video URL (TikTok/YouTube/Instagram/X) to replicate.'); return; }
|
|
1060
|
+
userPrompt = String(intent.instructions || '').trim() || null;
|
|
1061
|
+
view.flowStatus('Fetching the source video…');
|
|
1062
|
+
var addRes = await fetch(origin + '/discover/templates', { method: 'POST', headers: ivHeaders(true), body: JSON.stringify({ source_url: sourceUrl }) });
|
|
1063
|
+
var add = await ivJson(addRes);
|
|
1064
|
+
if (!addRes.ok) throw new Error((add && add.error) || ("couldn't fetch that URL (" + addRes.status + ')'));
|
|
1065
|
+
inspiration = (add && add.status === 'ready') ? add : await pollInspirationReady(add && add.inspiration_id);
|
|
1066
|
+
} else if (mode === 'generate') {
|
|
1067
|
+
var prompt = String(intent.prompt || '').trim();
|
|
1068
|
+
if (!prompt) { view.flowResult("⚠️ Tell me what the video should be about and I'll create it."); return; }
|
|
1069
|
+
userPrompt = prompt;
|
|
1070
|
+
view.flowStatus('Generating your video… (this can take a couple of minutes)');
|
|
1071
|
+
var genRes = await fetch(origin + '/api/v1/primitives/videos/generate', { method: 'POST', headers: ivHeaders(true), body: JSON.stringify({ tracer: 'chat-create-' + Date.now().toString(36), payload: { prompt: prompt } }) });
|
|
1072
|
+
var gen = await ivJson(genRes);
|
|
1073
|
+
if (!genRes.ok) throw new Error((gen && gen.error) || ("couldn't start generation (" + genRes.status + ')'));
|
|
1074
|
+
var mediaUrl = await pollJobMedia(gen && gen.job_id);
|
|
1075
|
+
view.flowStatus('Building an editable template from the generated video…');
|
|
1076
|
+
var add2 = await ingestGeneratedVideo(mediaUrl, prompt.slice(0, 80));
|
|
1077
|
+
inspiration = (add2 && add2.status === 'ready') ? add2 : await pollInspirationReady(add2 && add2.inspiration_id);
|
|
1078
|
+
} else {
|
|
1079
|
+
view.flowResult("⚠️ I couldn't tell whether to generate a new video or replicate a URL — try rephrasing.");
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
var templateId = inspiration && inspiration.template_id;
|
|
1083
|
+
var inspirationId = inspiration && inspiration.inspiration_id;
|
|
1084
|
+
if (!templateId || !inspirationId) throw new Error("the template wasn't created from the video.");
|
|
1085
|
+
view.flowStatus(mode === 'replicate' ? 'Recreating the scenes… (about a minute)' : 'Analyzing into an editable timeline…');
|
|
1086
|
+
var decRes = await fetch(origin + '/api/v1/inspirations/' + encodeURIComponent(inspirationId) + '/decompose', { method: 'POST', headers: ivHeaders(true), body: JSON.stringify({ user_prompt: userPrompt }) });
|
|
1087
|
+
if (!decRes.ok) { var dec = await ivJson(decRes); throw new Error((dec && dec.error) || ('scene analysis failed (' + decRes.status + ')')); }
|
|
1088
|
+
view.flowStatus('Finalizing your editable copy…');
|
|
1089
|
+
var forkRes = await fetch(origin + '/api/v1/compositions', { method: 'POST', headers: ivHeaders(true), body: JSON.stringify({ template_id: templateId }) });
|
|
1090
|
+
var fork = await ivJson(forkRes);
|
|
1091
|
+
if (!forkRes.ok) throw new Error((fork && fork.error) || ("couldn't create an editable copy (" + forkRes.status + ')'));
|
|
1092
|
+
var forkId = fork && fork.fork_id;
|
|
1093
|
+
var editorUrl = origin + '/editor/' + encodeURIComponent(templateId) + (forkId ? '/fork/' + encodeURIComponent(forkId) : '');
|
|
1094
|
+
view.flowResult('✅ Your video is ready — [Open it in the editor](' + editorUrl + ').');
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
view.flowResult('⚠️ I couldn’t finish creating the video: ' + ((error && error.message) ? error.message : String(error)));
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ─── Files drawer ───
|
|
1101
|
+
// Directory browsing lives in the reusable component (src/frontend/file-directory.ts),
|
|
1102
|
+
// auto-mounted into #rkAichatFilesMount by /assets/file-directory-app.js. This
|
|
1103
|
+
// inline script only owns the chat-side glue: the attach chips + preview modal.
|
|
1104
|
+
// We expose those to the component as host hooks BEFORE the deferred module runs
|
|
1105
|
+
// (this classic <script> executes during parse, ahead of the module).
|
|
1106
|
+
var chipsEl = document.getElementById('rkAichatChips');
|
|
1107
|
+
var filesToggle = document.getElementById('rkFilesToggle');
|
|
1108
|
+
var historyToggle = document.getElementById('rkHistoryToggle');
|
|
1109
|
+
var newChatBtn = document.getElementById('rkNewChat');
|
|
1110
|
+
if (newChatBtn) newChatBtn.addEventListener('click', function () { if (!busy) newChat(); });
|
|
1111
|
+
// Copy the current chat thread id (handy for support / linking a conversation).
|
|
1112
|
+
// threadId is minted up-front, so this always has a real id to copy.
|
|
1113
|
+
var copyThreadBtn = document.getElementById('rkCopyThread');
|
|
1114
|
+
if (copyThreadBtn) copyThreadBtn.addEventListener('click', function () {
|
|
1115
|
+
var prevTitle = copyThreadBtn.title;
|
|
1116
|
+
copyText(String(threadId));
|
|
1117
|
+
copyThreadBtn.title = 'Copied chat ID';
|
|
1118
|
+
copyThreadBtn.classList.add('is-on');
|
|
1119
|
+
setTimeout(function () { copyThreadBtn.title = prevTitle; copyThreadBtn.classList.remove('is-on'); }, 1400);
|
|
1120
|
+
});
|
|
1121
|
+
var histBody = document.getElementById('rkAichatHistBody');
|
|
1122
|
+
var selected = [];
|
|
1123
|
+
window.__rkDirectoryHost = {
|
|
1124
|
+
onAttach: function (items) { (items || []).forEach(function (it) { attachFile(it); }); },
|
|
1125
|
+
onPreview: function (items, i) { openFilePreview(items || [], i || 0); }
|
|
1126
|
+
};
|
|
1127
|
+
function refreshFiles() { if (window.__rkDirectoryInstance) window.__rkDirectoryInstance.refresh(); }
|
|
1128
|
+
|
|
1129
|
+
// Editor dock "Back to Library": leave the editor cleanly — clear the persisted
|
|
1130
|
+
// open-state so the destination page loads with the chat panel closed and on a
|
|
1131
|
+
// fresh chat, then navigate. (Real anchor href="/library" is the fallback.)
|
|
1132
|
+
var backBtn = document.getElementById('rkAichatBack');
|
|
1133
|
+
if (backBtn) backBtn.addEventListener('click', function (e) {
|
|
1134
|
+
try { sessionStorage.removeItem('rk-chat-open'); } catch (err) {}
|
|
1135
|
+
e.preventDefault();
|
|
1136
|
+
window.location.assign('/library');
|
|
1137
|
+
});
|
|
1138
|
+
|
|
565
1139
|
function attachFile(it) {
|
|
1140
|
+
// Files-only drawer (opened from the /chat page) has no chat composer of its
|
|
1141
|
+
// own — hand the picked file to the /chat page's composer via a DOM event.
|
|
1142
|
+
if (panel.classList.contains('is-files-only')) {
|
|
1143
|
+
try {
|
|
1144
|
+
document.dispatchEvent(new CustomEvent('rk-chat-attach-file', { detail: {
|
|
1145
|
+
id: String(it.id), name: it.name || 'file',
|
|
1146
|
+
contentType: it.contentType || 'application/octet-stream', viewUrl: it.viewUrl || ''
|
|
1147
|
+
} }));
|
|
1148
|
+
} catch (e) {}
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
566
1151
|
if (selected.some(function (f) { return f.id === it.id; })) { flashChip(it.id); return; }
|
|
567
1152
|
selected.push(it);
|
|
568
1153
|
renderChips();
|
|
@@ -571,12 +1156,17 @@ const RESKIN_CHROME_SCRIPT = `(() => {
|
|
|
571
1156
|
function renderChips() {
|
|
572
1157
|
if (!chipsEl) return;
|
|
573
1158
|
chipsEl.innerHTML = '';
|
|
574
|
-
selected.forEach(function (f) {
|
|
1159
|
+
selected.forEach(function (f, i) {
|
|
575
1160
|
var chip = document.createElement('span');
|
|
576
|
-
chip.className = 'rk-aichat-chip';
|
|
1161
|
+
chip.className = 'rk-aichat-chip' + (f.pending ? ' is-pending' : '');
|
|
577
1162
|
chip.setAttribute('data-id', f.id);
|
|
578
|
-
var
|
|
1163
|
+
if (f.pending) { var sp = document.createElement('span'); sp.className = 'rk-aichat-chip-spin'; sp.setAttribute('aria-hidden', 'true'); chip.appendChild(sp); }
|
|
1164
|
+
var label = document.createElement('button');
|
|
1165
|
+
label.type = 'button';
|
|
1166
|
+
label.className = 'rk-aichat-chip-label';
|
|
1167
|
+
label.title = f.pending ? 'Uploading ' + f.name + '…' : 'Preview ' + f.name;
|
|
579
1168
|
label.textContent = f.name;
|
|
1169
|
+
if (f.viewUrl && !f.pending) label.addEventListener('click', function () { openFilePreview(selected.filter(function (s) { return !s.pending; }), selected.filter(function (s) { return !s.pending; }).indexOf(f)); });
|
|
580
1170
|
var x = document.createElement('button');
|
|
581
1171
|
x.type = 'button';
|
|
582
1172
|
x.setAttribute('aria-label', 'Remove ' + f.name);
|
|
@@ -598,36 +1188,347 @@ const RESKIN_CHROME_SCRIPT = `(() => {
|
|
|
598
1188
|
}
|
|
599
1189
|
function clearChips() { selected = []; renderChips(); }
|
|
600
1190
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1191
|
+
// ─── left drawer mode: 'files' | 'history' | '' (closed) ───────────────────
|
|
1192
|
+
// Files and Chat history share the one left slot, so only one shows at a time.
|
|
1193
|
+
function leftMode() {
|
|
1194
|
+
if (panel.classList.contains('has-history')) return 'history';
|
|
1195
|
+
if (panel.classList.contains('has-files')) return 'files';
|
|
1196
|
+
return '';
|
|
1197
|
+
}
|
|
1198
|
+
function filesOpen() { return leftMode() === 'files'; }
|
|
1199
|
+
function setLeftMode(mode) {
|
|
1200
|
+
panel.classList.toggle('has-files', mode === 'files');
|
|
1201
|
+
panel.classList.toggle('has-history', mode === 'history');
|
|
604
1202
|
if (filesToggle) {
|
|
605
|
-
filesToggle.classList.toggle('is-on',
|
|
606
|
-
filesToggle.setAttribute('aria-pressed',
|
|
1203
|
+
filesToggle.classList.toggle('is-on', mode === 'files');
|
|
1204
|
+
filesToggle.setAttribute('aria-pressed', mode === 'files' ? 'true' : 'false');
|
|
607
1205
|
}
|
|
608
|
-
|
|
609
|
-
|
|
1206
|
+
if (historyToggle) {
|
|
1207
|
+
historyToggle.classList.toggle('is-on', mode === 'history');
|
|
1208
|
+
historyToggle.setAttribute('aria-pressed', mode === 'history' ? 'true' : 'false');
|
|
1209
|
+
}
|
|
1210
|
+
var caBtn = document.getElementById('rkAichatAttach');
|
|
1211
|
+
if (caBtn) {
|
|
1212
|
+
caBtn.classList.toggle('is-on', mode === 'files');
|
|
1213
|
+
caBtn.setAttribute('aria-pressed', mode === 'files' ? 'true' : 'false');
|
|
1214
|
+
}
|
|
1215
|
+
try { localStorage.setItem('rk-left-mode', mode); } catch (e) {}
|
|
1216
|
+
if (mode === 'files') refreshFiles();
|
|
1217
|
+
if (mode === 'history') loadThreads();
|
|
610
1218
|
if (!panel.hidden) anchorPanel();
|
|
611
1219
|
}
|
|
612
1220
|
function refreshFilesOnOpen() {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
1221
|
+
// Re-fetch the current folder so listings are fresh when the drawer opens.
|
|
1222
|
+
if (filesOpen()) refreshFiles();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ─── Chat history — REAL editor-chat threads (past conversations) ──────────
|
|
1226
|
+
var threads = [], threadsState = 'idle', loadingThread = false;
|
|
1227
|
+
function authHeaders() { var h = { accept: 'application/json' }; if (API_KEY) h['vidfarm-api-key'] = API_KEY; return h; }
|
|
1228
|
+
function relTime(iso) {
|
|
1229
|
+
if (!iso) return '';
|
|
1230
|
+
var t = Date.parse(iso); if (isNaN(t)) return '';
|
|
1231
|
+
var s = Math.max(0, (Date.now() - t) / 1000);
|
|
1232
|
+
if (s < 60) return 'just now';
|
|
1233
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
1234
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
1235
|
+
if (s < 604800) return Math.floor(s / 86400) + 'd ago';
|
|
1236
|
+
try { return new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' }); } catch (e) { return ''; }
|
|
1237
|
+
}
|
|
1238
|
+
function histNote(text, opts) {
|
|
1239
|
+
var wrap = document.createElement('div'); wrap.className = 'rk-aichat-fstate';
|
|
1240
|
+
var p = document.createElement('div'); p.textContent = text; wrap.appendChild(p);
|
|
1241
|
+
if (opts && opts.href) { var a = document.createElement('a'); a.href = opts.href; a.textContent = opts.linkText || 'Open'; wrap.appendChild(a); }
|
|
1242
|
+
if (opts && opts.retry) { var b = document.createElement('button'); b.type = 'button'; b.textContent = 'Try again'; b.addEventListener('click', function () { loadThreads(); }); wrap.appendChild(b); }
|
|
1243
|
+
return wrap;
|
|
1244
|
+
}
|
|
1245
|
+
function setActiveThread(id) {
|
|
1246
|
+
threadId = id;
|
|
1247
|
+
if (!histBody) return;
|
|
1248
|
+
var rows = histBody.querySelectorAll('.rk-aichat-frow');
|
|
1249
|
+
for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
|
|
1250
|
+
}
|
|
1251
|
+
function threadRow(t) {
|
|
1252
|
+
var b = document.createElement('button');
|
|
1253
|
+
b.type = 'button';
|
|
1254
|
+
b.className = 'rk-aichat-frow' + (t.id === threadId ? ' is-active' : '');
|
|
1255
|
+
b.setAttribute('data-id', t.id);
|
|
1256
|
+
var ic = document.createElement('span');
|
|
1257
|
+
ic.className = 'rk-aichat-fic rk-aichat-fic-file';
|
|
1258
|
+
ic.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
|
|
1259
|
+
var main = document.createElement('span'); main.className = 'rk-aichat-fmain';
|
|
1260
|
+
var name = document.createElement('span'); name.className = 'rk-aichat-fname'; name.textContent = t.title || 'Untitled chat';
|
|
1261
|
+
var count = (typeof t.messageCount === 'number') ? t.messageCount : 0;
|
|
1262
|
+
var when = relTime(t.lastMessageAt || t.updatedAt);
|
|
1263
|
+
var meta = document.createElement('span'); meta.className = 'rk-aichat-fmeta';
|
|
1264
|
+
meta.textContent = (count ? count + ' message' + (count === 1 ? '' : 's') : '') + ((count && when) ? ' · ' : '') + (when || '');
|
|
1265
|
+
main.appendChild(name); main.appendChild(meta);
|
|
1266
|
+
var del = document.createElement('button');
|
|
1267
|
+
del.type = 'button'; del.className = 'rk-aichat-fadd'; del.setAttribute('aria-label', 'Delete chat'); del.textContent = '×';
|
|
1268
|
+
del.addEventListener('click', function (ev) { ev.stopPropagation(); deleteThread(t.id, b); });
|
|
1269
|
+
b.appendChild(ic); b.appendChild(main); b.appendChild(del);
|
|
1270
|
+
b.addEventListener('click', function () { openThread(t.id); });
|
|
1271
|
+
return b;
|
|
1272
|
+
}
|
|
1273
|
+
function renderHistory() {
|
|
1274
|
+
if (!histBody) return;
|
|
1275
|
+
histBody.innerHTML = '';
|
|
1276
|
+
if (BOOT_STATE === 'anon') { histBody.appendChild(histNote('Sign in to sync your saved chats.', { href: '/login', linkText: 'Sign in' })); return; }
|
|
1277
|
+
if (threadsState === 'loading' && !threads.length) { histBody.appendChild(histNote('Loading your chats…')); return; }
|
|
1278
|
+
if (threadsState === 'auth') { histBody.appendChild(histNote('Sign in to sync your saved chats.', { href: '/login', linkText: 'Sign in' })); return; }
|
|
1279
|
+
if (threadsState === 'error') { histBody.appendChild(histNote('Couldn’t load your chats.', { retry: true })); return; }
|
|
1280
|
+
if (!threads.length) { histBody.appendChild(histNote('No conversations yet. Start one below — it saves automatically.')); return; }
|
|
1281
|
+
for (var i = 0; i < threads.length; i++) histBody.appendChild(threadRow(threads[i]));
|
|
1282
|
+
}
|
|
1283
|
+
function loadThreads() {
|
|
1284
|
+
if (!histBody) return;
|
|
1285
|
+
if (BOOT_STATE === 'anon') { renderHistory(); return; }
|
|
1286
|
+
if (BOOT_STATE !== 'ready') { loadBoot(); return; } // loadBoot() calls back into loadThreads() when ready
|
|
1287
|
+
threadsState = 'loading'; renderHistory();
|
|
1288
|
+
var url = THREADS_URL + '?template_id=' + encodeURIComponent(TEMPLATE_ID) + '&limit=30&include_messages=false';
|
|
1289
|
+
fetch(url, { headers: authHeaders(), credentials: 'same-origin' })
|
|
1290
|
+
.then(function (r) {
|
|
1291
|
+
if (r.status === 401 || r.status === 403) { threadsState = 'auth'; return null; }
|
|
1292
|
+
if (!r.ok) throw new Error('http ' + r.status);
|
|
1293
|
+
return r.json();
|
|
1294
|
+
})
|
|
1295
|
+
.then(function (j) {
|
|
1296
|
+
if (!j) { renderHistory(); return; }
|
|
1297
|
+
threads = (j && j.threads) || []; threadsState = 'ready'; renderHistory();
|
|
1298
|
+
})
|
|
1299
|
+
.catch(function () { threadsState = 'error'; renderHistory(); });
|
|
1300
|
+
}
|
|
1301
|
+
function openThread(id) {
|
|
1302
|
+
if (busy || loadingThread) return;
|
|
1303
|
+
loadingThread = true;
|
|
1304
|
+
fetch(THREADS_URL + '/' + encodeURIComponent(id), { headers: authHeaders(), credentials: 'same-origin' })
|
|
1305
|
+
.then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json(); })
|
|
1306
|
+
.then(function (j) {
|
|
1307
|
+
var t = j && j.thread; if (!t) throw new Error('missing thread');
|
|
1308
|
+
convo = []; if (log) log.innerHTML = '';
|
|
1309
|
+
var msgs = t.messages || [];
|
|
1310
|
+
for (var i = 0; i < msgs.length; i++) {
|
|
1311
|
+
var m = msgs[i], role = (m.role === 'assistant') ? 'assistant' : 'user', txt = m.text || '';
|
|
1312
|
+
convo.push({ role: role, text: txt });
|
|
1313
|
+
if (role === 'user') { bubble('is-user', txt); }
|
|
1314
|
+
else {
|
|
1315
|
+
var v = appendAssistant();
|
|
1316
|
+
var ex = m.httpExchanges || [];
|
|
1317
|
+
for (var k = 0; k < ex.length; k++) { if (ex[k] && ex[k].request && ex[k].response) v.addHttp(ex[k]); }
|
|
1318
|
+
v.showText(); v.setText(txt); v.hideStatus(); v.renderMd();
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
setActiveThread(id);
|
|
1322
|
+
loadingThread = false;
|
|
1323
|
+
logBottom();
|
|
1324
|
+
if (input) input.focus();
|
|
1325
|
+
})
|
|
1326
|
+
.catch(function () { loadingThread = false; });
|
|
1327
|
+
}
|
|
1328
|
+
function deleteThread(id, row) {
|
|
1329
|
+
if (!API_KEY) return;
|
|
1330
|
+
fetch(THREADS_URL + '/' + encodeURIComponent(id), { method: 'DELETE', headers: authHeaders(), credentials: 'same-origin' })
|
|
1331
|
+
.then(function (r) {
|
|
1332
|
+
if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
|
|
1333
|
+
threads = threads.filter(function (t) { return t.id !== id; });
|
|
1334
|
+
if (id === threadId) resetConversation();
|
|
1335
|
+
renderHistory();
|
|
1336
|
+
})
|
|
1337
|
+
.catch(function () {});
|
|
619
1338
|
}
|
|
620
1339
|
|
|
621
1340
|
if (filesToggle) {
|
|
622
|
-
filesToggle.addEventListener('click', function () {
|
|
1341
|
+
filesToggle.addEventListener('click', function () { setLeftMode(leftMode() === 'files' ? '' : 'files'); });
|
|
623
1342
|
}
|
|
624
|
-
//
|
|
1343
|
+
// Composer paperclip — opens the folder directory nav (Files drawer) so the
|
|
1344
|
+
// user can browse + attach a file, mirroring the header Files toggle. Works
|
|
1345
|
+
// in both the editor dock and the floating pop-panel.
|
|
1346
|
+
var composerAttach = document.getElementById('rkAichatAttach');
|
|
1347
|
+
if (composerAttach) {
|
|
1348
|
+
composerAttach.addEventListener('click', function () { setLeftMode(leftMode() === 'files' ? '' : 'files'); });
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// ─── paste-to-attach: drop a copied image/file straight into the composer ───
|
|
1352
|
+
// Clipboard files (screenshot, copied image, "Copy image") get auto-uploaded
|
|
1353
|
+
// to the user's /temp folder, then attached as a chip just like a picked file.
|
|
1354
|
+
// We show a spinner chip immediately and swap it for the real file on success.
|
|
1355
|
+
function extNameFor(blob, idx) {
|
|
1356
|
+
var ct = (blob && blob.type) || '';
|
|
1357
|
+
var base = ct.indexOf('image/') === 0 ? 'pasted-image' : ct.indexOf('video/') === 0 ? 'pasted-video' : ct.indexOf('audio/') === 0 ? 'pasted-audio' : 'pasted-file';
|
|
1358
|
+
var ext = ct && ct.indexOf('/') > -1 ? ct.split('/')[1].split('+')[0].split(';')[0] : 'bin';
|
|
1359
|
+
if (ext === 'jpeg') ext = 'jpg';
|
|
1360
|
+
var stamp = Date.now().toString(36) + (idx ? '-' + idx : '');
|
|
1361
|
+
return base + '-' + stamp + '.' + ext;
|
|
1362
|
+
}
|
|
1363
|
+
function uploadPastedBlob(blob, fileName, pendingId) {
|
|
1364
|
+
var fd = new FormData();
|
|
1365
|
+
fd.append('file', blob, fileName);
|
|
1366
|
+
var headers = {}; if (API_KEY) headers['vidfarm-api-key'] = API_KEY;
|
|
1367
|
+
fetch(window.location.origin + '/api/v1/user/me/temporary-files/upload', {
|
|
1368
|
+
method: 'POST', credentials: 'same-origin', headers: headers, body: fd
|
|
1369
|
+
}).then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { if (!r.ok) throw new Error((j && j.error) || ('upload failed (' + r.status + ')')); return j; }); })
|
|
1370
|
+
.then(function (j) {
|
|
1371
|
+
var f = j && j.file;
|
|
1372
|
+
if (!f || !f.viewUrl) throw new Error('upload returned no file');
|
|
1373
|
+
var idx = selected.map(function (s) { return s.id; }).indexOf(pendingId);
|
|
1374
|
+
var item = { id: String(f.id), name: f.fileName || fileName, contentType: f.contentType || (blob && blob.type) || 'application/octet-stream', viewUrl: f.viewUrl };
|
|
1375
|
+
if (idx > -1) selected[idx] = item; else selected.push(item);
|
|
1376
|
+
renderChips();
|
|
1377
|
+
})
|
|
1378
|
+
.catch(function () {
|
|
1379
|
+
// A failed paste-upload just drops its chip — keep the chat log clean.
|
|
1380
|
+
selected = selected.filter(function (s) { return s.id !== pendingId; });
|
|
1381
|
+
renderChips();
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
function handlePasteFiles(e) {
|
|
1385
|
+
// Files-only drawer (on /chat) has no composer of its own — let that page's
|
|
1386
|
+
// composer own paste; here we only handle the dock's real chat composer.
|
|
1387
|
+
if (panel.classList.contains('is-files-only')) return;
|
|
1388
|
+
var dt = e.clipboardData || (window.clipboardData);
|
|
1389
|
+
if (!dt) return;
|
|
1390
|
+
var blobs = [];
|
|
1391
|
+
var items = dt.items;
|
|
1392
|
+
if (items && items.length) {
|
|
1393
|
+
for (var i = 0; i < items.length; i++) {
|
|
1394
|
+
if (items[i] && items[i].kind === 'file') { var b = items[i].getAsFile(); if (b) blobs.push(b); }
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
if (!blobs.length && dt.files && dt.files.length) { for (var k = 0; k < dt.files.length; k++) blobs.push(dt.files[k]); }
|
|
1398
|
+
if (!blobs.length) return; // plain-text paste → let the textarea handle it
|
|
1399
|
+
e.preventDefault();
|
|
1400
|
+
blobs.forEach(function (blob, idx) {
|
|
1401
|
+
var name = (blob && blob.name) || extNameFor(blob, idx);
|
|
1402
|
+
var pendingId = 'paste-' + Date.now().toString(36) + '-' + idx + '-' + Math.random().toString(36).slice(2, 6);
|
|
1403
|
+
selected.push({ id: pendingId, name: name, contentType: (blob && blob.type) || 'application/octet-stream', pending: true });
|
|
1404
|
+
renderChips();
|
|
1405
|
+
uploadPastedBlob(blob, name, pendingId);
|
|
1406
|
+
});
|
|
1407
|
+
if (input) input.focus();
|
|
1408
|
+
}
|
|
1409
|
+
if (input) input.addEventListener('paste', handlePasteFiles);
|
|
1410
|
+
if (historyToggle) {
|
|
1411
|
+
historyToggle.addEventListener('click', function () { setLeftMode(leftMode() === 'history' ? '' : 'history'); });
|
|
1412
|
+
}
|
|
1413
|
+
// /chat composer's attach button ⇄ files-only drawer (open ↔ close toggle).
|
|
1414
|
+
var chatAttachBtn = document.getElementById('rk-chat-attach');
|
|
1415
|
+
if (chatAttachBtn) {
|
|
1416
|
+
chatAttachBtn.addEventListener('click', function () {
|
|
1417
|
+
if (!panel.hidden && panel.classList.contains('is-files-only')) closePanel();
|
|
1418
|
+
else openPanel(true);
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
var filesCloseBtn = document.getElementById('rkFilesClose');
|
|
1422
|
+
if (filesCloseBtn) filesCloseBtn.addEventListener('click', function () {
|
|
1423
|
+
// In the /chat files-only drawer this is the only close affordance → shut the
|
|
1424
|
+
// panel. In the full pop-panel it just collapses the Files drawer back to chat.
|
|
1425
|
+
if (panel.classList.contains('is-files-only')) closePanel();
|
|
1426
|
+
else setLeftMode('');
|
|
1427
|
+
});
|
|
1428
|
+
// Honor the saved drawer mode (default: files open).
|
|
625
1429
|
try {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
1430
|
+
var savedMode = localStorage.getItem('rk-left-mode');
|
|
1431
|
+
setLeftMode(savedMode === null ? 'files' : savedMode);
|
|
1432
|
+
} catch (e) { setLeftMode('files'); }
|
|
1433
|
+
renderHistory();
|
|
629
1434
|
|
|
630
|
-
try {
|
|
1435
|
+
try {
|
|
1436
|
+
var reopen = sessionStorage.getItem('rk-chat-open');
|
|
1437
|
+
if (isEditorDock) { /* handled below */ }
|
|
1438
|
+
else if (isChatPage) { if (reopen === 'files') openPanel(true); }
|
|
1439
|
+
else if (reopen === '1') openPanel();
|
|
1440
|
+
} catch (e) {}
|
|
1441
|
+
|
|
1442
|
+
// A handed-off thread (?rk_chat=…) always opens the dock so the conversation
|
|
1443
|
+
// reappears immediately (openPanel → loadBoot → consumeHandoff replays it).
|
|
1444
|
+
// The wide editor dock force-opens itself just below, so skip it here.
|
|
1445
|
+
if (handoffThread && !isChatPage && !editorDockWide) openPanel();
|
|
1446
|
+
|
|
1447
|
+
// Editor dock: force the panel open and pinned as a left column. Reuses the
|
|
1448
|
+
// normal openPanel path so the live /chat-dock/boot fetch + threads + files
|
|
1449
|
+
// all wire up exactly as on the reskin pages.
|
|
1450
|
+
if (editorDockWide) {
|
|
1451
|
+
panel.classList.add('rk-aichat--editor');
|
|
1452
|
+
if (minBtn) minBtn.hidden = true;
|
|
1453
|
+
openPanel();
|
|
1454
|
+
panel.classList.remove('is-right');
|
|
1455
|
+
document.body.classList.add('rk-chat-left');
|
|
1456
|
+
// The editor dock force-opens the panel, but that's a property of the editor
|
|
1457
|
+
// shell, NOT a user choice to keep chat open — so don't let it persist. Otherwise
|
|
1458
|
+
// navigating out (e.g. Back to Library) would re-pop the panel on the next page.
|
|
1459
|
+
try { sessionStorage.removeItem('rk-chat-open'); } catch (e) {}
|
|
1460
|
+
// Start as a clean chat column (Files/History collapsed) WITHOUT persisting a
|
|
1461
|
+
// mode — setLeftMode writes localStorage, which the reskin pages share. The
|
|
1462
|
+
// toggle buttons still open the drawers on demand.
|
|
1463
|
+
panel.classList.remove('has-files', 'has-history');
|
|
1464
|
+
var ftog = document.getElementById('rkFilesToggle');
|
|
1465
|
+
if (ftog) { ftog.classList.remove('is-on'); ftog.setAttribute('aria-pressed', 'false'); }
|
|
1466
|
+
var htog = document.getElementById('rkHistoryToggle');
|
|
1467
|
+
if (htog) { htog.classList.remove('is-on'); htog.setAttribute('aria-pressed', 'false'); }
|
|
1468
|
+
var catog = document.getElementById('rkAichatAttach');
|
|
1469
|
+
if (catog) { catog.classList.remove('is-on'); catog.setAttribute('aria-pressed', 'false'); }
|
|
1470
|
+
}
|
|
1471
|
+
})();
|
|
1472
|
+
|
|
1473
|
+
// ── subtle per-video loading indicators ──────────────────────────────────────
|
|
1474
|
+
// A small spinner appears over ANY <video> while it is actively fetching data
|
|
1475
|
+
// (initial buffer OR a mid-playback rebuffer) and fades out the moment the video
|
|
1476
|
+
// has enough data to paint/play. Self-contained so it works on every reskin page
|
|
1477
|
+
// regardless of the chat dock. It only shows while the browser is genuinely
|
|
1478
|
+
// LOADING (networkState === 2), so idle preload="none" videos never spin.
|
|
1479
|
+
(function () {
|
|
1480
|
+
function host(v) { return v.parentElement; }
|
|
1481
|
+
function spinner(v, make) {
|
|
1482
|
+
var p = host(v);
|
|
1483
|
+
if (!p) return null;
|
|
1484
|
+
var s = p.querySelector(':scope > .rk-vspin');
|
|
1485
|
+
if (!s && make) {
|
|
1486
|
+
s = document.createElement('span');
|
|
1487
|
+
s.className = 'rk-vspin';
|
|
1488
|
+
s.setAttribute('aria-hidden', 'true');
|
|
1489
|
+
p.appendChild(s);
|
|
1490
|
+
}
|
|
1491
|
+
return s || null;
|
|
1492
|
+
}
|
|
1493
|
+
// NETWORK_LOADING === 2 && readyState < HAVE_FUTURE_DATA (3)
|
|
1494
|
+
function loading(v) { return v.networkState === 2 && v.readyState < 3; }
|
|
1495
|
+
function show(v) { var s = spinner(v, true); if (s) s.classList.add('is-on'); }
|
|
1496
|
+
function hide(v) { var s = spinner(v, false); if (s) s.classList.remove('is-on'); }
|
|
1497
|
+
function sync(v) { if (loading(v)) show(v); else hide(v); }
|
|
1498
|
+
function wire(v) {
|
|
1499
|
+
if (v.__rkVload) return;
|
|
1500
|
+
v.__rkVload = true;
|
|
1501
|
+
// ready / has-data events → definitely stop spinning
|
|
1502
|
+
['loadeddata', 'canplay', 'canplaythrough', 'playing', 'error', 'abort', 'suspend', 'emptied']
|
|
1503
|
+
.forEach(function (ev) { v.addEventListener(ev, function () { hide(v); }); });
|
|
1504
|
+
// fetch / rebuffer events → spin only if actually loading
|
|
1505
|
+
['loadstart', 'progress', 'waiting', 'stalled', 'seeking']
|
|
1506
|
+
.forEach(function (ev) { v.addEventListener(ev, function () { sync(v); }); });
|
|
1507
|
+
sync(v);
|
|
1508
|
+
}
|
|
1509
|
+
function scan(root) { (root || document).querySelectorAll('video').forEach(wire); }
|
|
1510
|
+
if (document.readyState === 'loading') {
|
|
1511
|
+
document.addEventListener('DOMContentLoaded', function () { scan(); });
|
|
1512
|
+
} else { scan(); }
|
|
1513
|
+
// catch lazily-inserted (TikTok viewer) and lazily-hydrated (data-vsrc → src) videos
|
|
1514
|
+
if ('MutationObserver' in window) {
|
|
1515
|
+
new MutationObserver(function (muts) {
|
|
1516
|
+
for (var i = 0; i < muts.length; i++) {
|
|
1517
|
+
var m = muts[i];
|
|
1518
|
+
if (m.type === 'attributes') {
|
|
1519
|
+
if (m.target && m.target.tagName === 'VIDEO') { wire(m.target); sync(m.target); }
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
if (!m.addedNodes) continue;
|
|
1523
|
+
for (var j = 0; j < m.addedNodes.length; j++) {
|
|
1524
|
+
var n = m.addedNodes[j];
|
|
1525
|
+
if (!n || n.nodeType !== 1) continue;
|
|
1526
|
+
if (n.tagName === 'VIDEO') wire(n);
|
|
1527
|
+
else if (n.querySelectorAll) scan(n);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
|
|
1531
|
+
}
|
|
631
1532
|
})();`;
|
|
632
1533
|
/** Wrap page content in a complete standalone reskin HTML document. */
|
|
633
1534
|
export function reskinDocument(input) {
|
|
@@ -635,27 +1536,50 @@ export function reskinDocument(input) {
|
|
|
635
1536
|
const active = input.activeSlug ?? null;
|
|
636
1537
|
const pageCss = input.pageCss ? `\n/* page-scoped */\n${input.pageCss}\n` : "";
|
|
637
1538
|
const chromeScript = chrome === "full" ? `\n<script>${RESKIN_CHROME_SCRIPT}</script>` : "";
|
|
638
|
-
|
|
1539
|
+
// Full-chrome pages carry the chat dock, whose Files drawer is the reusable
|
|
1540
|
+
// directory-explorer component — so its bundle loads on every such page.
|
|
1541
|
+
const baseModules = chrome === "full" ? ["/assets/file-directory-app.js"] : [];
|
|
1542
|
+
const moduleScripts = [...baseModules, ...(input.moduleScripts ?? [])]
|
|
1543
|
+
.map((src) => `\n<script type="module" src="${rkEscape(src)}"></script>`)
|
|
1544
|
+
.join("");
|
|
1545
|
+
const script = (input.script ? `\n<script>${input.script}</script>` : "") + chromeScript + moduleScripts;
|
|
639
1546
|
const shell = chrome === "full"
|
|
640
1547
|
? `<div class="rk-app">
|
|
641
|
-
${renderSidebar(active)}
|
|
1548
|
+
${renderSidebar(active, input.account)}
|
|
642
1549
|
<div class="rk-content">
|
|
643
1550
|
${input.body}
|
|
644
1551
|
</div>
|
|
645
1552
|
${renderChatDock(active)}
|
|
646
1553
|
</div>`
|
|
647
1554
|
: input.body;
|
|
1555
|
+
const pageTitle = rkBrandTitle(input.title);
|
|
1556
|
+
const pageDesc = rkBrandTitle(input.description ?? input.title);
|
|
648
1557
|
return `<!doctype html>
|
|
649
1558
|
<html lang="en">
|
|
650
1559
|
<head>
|
|
651
1560
|
<meta charset="utf-8">
|
|
652
1561
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
653
|
-
<
|
|
654
|
-
<
|
|
1562
|
+
<link rel="icon" href="/assets/favicon.ico" sizes="any">
|
|
1563
|
+
<link rel="apple-touch-icon" href="/assets/logo-vidfarm.png">
|
|
1564
|
+
<title>${rkEscape(pageTitle)}</title>
|
|
1565
|
+
<meta name="description" content="${rkEscape(pageDesc)}">
|
|
1566
|
+
<meta name="theme-color" content="#f8f4eb">
|
|
1567
|
+
<meta name="robots" content="index, follow">
|
|
1568
|
+
<meta name="application-name" content="${BRAND_NAME}">
|
|
1569
|
+
<meta property="og:type" content="website">
|
|
1570
|
+
<meta property="og:site_name" content="${BRAND_NAME}">
|
|
1571
|
+
<meta property="og:title" content="${rkEscape(pageTitle)}">
|
|
1572
|
+
<meta property="og:description" content="${rkEscape(pageDesc)}">
|
|
1573
|
+
<meta property="og:url" content="${BRAND_ORIGIN}">
|
|
1574
|
+
<meta property="og:image" content="${BRAND_OG_IMAGE}">
|
|
1575
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
1576
|
+
<meta name="twitter:title" content="${rkEscape(pageTitle)}">
|
|
1577
|
+
<meta name="twitter:description" content="${rkEscape(pageDesc)}">
|
|
1578
|
+
<meta name="twitter:image" content="${BRAND_OG_IMAGE}">
|
|
655
1579
|
${RESKIN_FONT_LINKS}
|
|
656
1580
|
<style>${RESKIN_CSS}${pageCss}</style>
|
|
657
1581
|
</head>
|
|
658
|
-
<body class="rk-root${chrome === "full" ? " rk-has-sidebar" : ""}">
|
|
1582
|
+
<body class="rk-root${chrome === "full" ? " rk-has-sidebar" : ""}${input.account?.email?.trim() ? " rk-signed-in" : ""}">
|
|
659
1583
|
${shell}${script}
|
|
660
1584
|
</body>
|
|
661
1585
|
</html>`;
|