@officexapp/vidfarm-devcli 0.21.12 → 0.21.15
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/vidfarm-director/references/automation-and-local-dev.md +19 -2
- package/SKILL.director.md +19 -2
- package/demo/dist/app.js +247 -226
- package/dist/src/cli.js +56 -7
- package/dist/src/devcli/composition-edit.js +99 -23
- package/dist/src/devcli/doctor.js +65 -9
- package/dist/src/devcli/local-frontend-server.js +341 -49
- package/dist/src/devcli/port-utils.js +43 -0
- package/dist/src/devcli/process-scan.js +173 -0
- package/dist/src/hyperframes/composition.js +2 -2
- package/package.json +3 -1
- package/public/serve-shells/editor.html +62 -13
- package/public/serve-shells/library-files.html +62 -13
- package/public/serve-shells/library-raws.html +62 -13
- package/public/serve-shells/tools-clipper.html +62 -13
- package/public/serve-shells/tools-image.html +62 -13
- package/public/serve-shells/tools-video.html +62 -13
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Detect (and optionally reap) local vidfarm / hyperframes server processes.
|
|
2
|
+
//
|
|
3
|
+
// Two failure modes this exists for:
|
|
4
|
+
// 1. Concurrency — a customer runs several local video jobs; `doctor` should
|
|
5
|
+
// show which serve/preview boxes are up and on what ports.
|
|
6
|
+
// 2. Orphans — when the devcli is upgraded, renamed (e.g. @mevdragon →
|
|
7
|
+
// @officexapp), or its package dir is pruned, any still-running serve /
|
|
8
|
+
// hyperframes-preview process keeps holding its port but now executes from
|
|
9
|
+
// a DELETED path. Such a process serves broken assets forever (the classic
|
|
10
|
+
// "Studio bundle missing" / "Waiting for preview server…" hang) yet never
|
|
11
|
+
// exits. We flag those as `orphaned` (their script file no longer exists)
|
|
12
|
+
// so they can be reaped and the port reclaimed.
|
|
13
|
+
//
|
|
14
|
+
// Backend-free: only `node:child_process` (ps) + `node:fs`. POSIX only — on
|
|
15
|
+
// win32 we return an empty list with a note rather than guessing at wmic.
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
// A command line is one of OUR long-running local servers only if it drives a
|
|
19
|
+
// `vidfarm serve` (or its `cli.js serve` runtime) or a `hyperframes preview`
|
|
20
|
+
// server. Short-lived commands (`vidfarm jobs`, `vidfarm render`, …) are NOT
|
|
21
|
+
// servers and must not appear — they hold no port and would only add noise.
|
|
22
|
+
function classify(command) {
|
|
23
|
+
const c = command.toLowerCase();
|
|
24
|
+
const isPreview = /hyperframes\b.*\bpreview\b/.test(c) || /\bpreview\b\s.*--port/.test(c);
|
|
25
|
+
if (isPreview)
|
|
26
|
+
return "preview";
|
|
27
|
+
const isVidfarm = /\bvidfarm\b/.test(c) || /vidfarm-devcli/.test(c) || /\/cli\.js\b/.test(c);
|
|
28
|
+
if (isVidfarm && /\bserve\b/.test(c))
|
|
29
|
+
return "serve";
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function parsePort(command) {
|
|
33
|
+
const m = command.match(/--port(?:[=\s]+)(\d{2,5})\b/);
|
|
34
|
+
if (m)
|
|
35
|
+
return Number(m[1]);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
// Fallback when the command line carries no --port (the default port, or an
|
|
39
|
+
// auto-advanced one): ask lsof for the pid's listening TCP socket. Best-effort
|
|
40
|
+
// and cheap — one lsof per matched SERVER process, not per ps row.
|
|
41
|
+
function detectListeningPort(pid) {
|
|
42
|
+
const lsof = spawnSync("lsof", ["-nP", "-a", "-p", String(pid), "-iTCP", "-sTCP:LISTEN", "-Fn"], {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
timeout: 2_000
|
|
45
|
+
});
|
|
46
|
+
if (lsof.status !== 0 || !lsof.stdout)
|
|
47
|
+
return null;
|
|
48
|
+
// -Fn emits lines like `n*:3001` / `n127.0.0.1:3001` per listening socket.
|
|
49
|
+
const m = lsof.stdout.match(/^n.*:(\d{2,5})$/m);
|
|
50
|
+
return m ? Number(m[1]) : null;
|
|
51
|
+
}
|
|
52
|
+
// Pull the launched script path (the first `.../something.js` token after the
|
|
53
|
+
// node executable) so we can test whether it still exists on disk.
|
|
54
|
+
function parseScriptPath(command) {
|
|
55
|
+
const m = command.match(/\s(\/[^\s]+?\.(?:js|mjs|cjs))\b/);
|
|
56
|
+
return m ? m[1] : null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Enumerate local vidfarm/hyperframes server processes via `ps`. Best-effort:
|
|
60
|
+
* any parsing failure yields an empty (but `supported:true`) list rather than
|
|
61
|
+
* throwing, so callers can treat this as advisory.
|
|
62
|
+
*/
|
|
63
|
+
export function scanLocalServers() {
|
|
64
|
+
if (process.platform === "win32") {
|
|
65
|
+
return { supported: false, note: "process scan is POSIX-only (macOS/Linux)", servers: [] };
|
|
66
|
+
}
|
|
67
|
+
const ps = spawnSync("ps", ["-Ao", "pid=,command="], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
|
68
|
+
if (ps.status !== 0 || !ps.stdout) {
|
|
69
|
+
return { supported: false, note: "could not run `ps`", servers: [] };
|
|
70
|
+
}
|
|
71
|
+
const servers = [];
|
|
72
|
+
for (const line of ps.stdout.split("\n")) {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (!trimmed)
|
|
75
|
+
continue;
|
|
76
|
+
const spaceIdx = trimmed.indexOf(" ");
|
|
77
|
+
if (spaceIdx === -1)
|
|
78
|
+
continue;
|
|
79
|
+
const pid = Number(trimmed.slice(0, spaceIdx));
|
|
80
|
+
if (!Number.isInteger(pid))
|
|
81
|
+
continue;
|
|
82
|
+
const command = trimmed.slice(spaceIdx + 1).trim();
|
|
83
|
+
// Skip our own scanning invocation (the `ps` line itself and this grep-like
|
|
84
|
+
// command) and anything that isn't a vidfarm/hyperframes server.
|
|
85
|
+
const kind = classify(command);
|
|
86
|
+
if (!kind)
|
|
87
|
+
continue;
|
|
88
|
+
const scriptPath = parseScriptPath(command);
|
|
89
|
+
// Orphaned = launched from a script file that no longer exists on disk.
|
|
90
|
+
// Only assert this when we actually resolved a path (else unknown, not orphaned).
|
|
91
|
+
const orphaned = Boolean(scriptPath) && !existsSync(scriptPath);
|
|
92
|
+
servers.push({
|
|
93
|
+
pid,
|
|
94
|
+
command,
|
|
95
|
+
kind,
|
|
96
|
+
port: parsePort(command) ?? detectListeningPort(pid),
|
|
97
|
+
scriptPath,
|
|
98
|
+
orphaned,
|
|
99
|
+
isSelf: pid === process.pid
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return { supported: true, servers };
|
|
103
|
+
}
|
|
104
|
+
/** True when `pid` is still alive (signal 0 probes without delivering). */
|
|
105
|
+
export function isAlive(pid) {
|
|
106
|
+
try {
|
|
107
|
+
process.kill(pid, 0);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// ESRCH = gone; EPERM = alive but not ours to signal.
|
|
112
|
+
return error.code === "EPERM";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Reap the given pids (never self): SIGTERM first, then — after `graceMs` —
|
|
117
|
+
* SIGKILL any that ignored it. Returns per-pid outcomes with the strongest
|
|
118
|
+
* signal delivered and whether the process is confirmed gone. Async so the
|
|
119
|
+
* grace period doesn't block the event loop.
|
|
120
|
+
*/
|
|
121
|
+
export async function reapProcesses(pids, graceMs = 1500) {
|
|
122
|
+
const results = new Map();
|
|
123
|
+
const pending = [];
|
|
124
|
+
for (const pid of pids) {
|
|
125
|
+
if (pid === process.pid) {
|
|
126
|
+
results.set(pid, { pid, signal: null, killed: false, error: "refusing to kill self" });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
process.kill(pid, "SIGTERM");
|
|
131
|
+
results.set(pid, { pid, signal: "SIGTERM", killed: false });
|
|
132
|
+
pending.push(pid);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
const code = error.code;
|
|
136
|
+
// ESRCH → already gone (success); anything else → real failure.
|
|
137
|
+
results.set(pid, {
|
|
138
|
+
pid,
|
|
139
|
+
signal: null,
|
|
140
|
+
killed: code === "ESRCH",
|
|
141
|
+
error: code === "ESRCH" ? undefined : (error instanceof Error ? error.message : String(error))
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (pending.length > 0) {
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
147
|
+
for (const pid of pending) {
|
|
148
|
+
const result = results.get(pid);
|
|
149
|
+
if (!isAlive(pid)) {
|
|
150
|
+
result.killed = true;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Ignored SIGTERM — escalate to SIGKILL, which cannot be caught or
|
|
154
|
+
// ignored. A successful delivery (or ESRCH = already gone) means the
|
|
155
|
+
// process is reaped; we do NOT re-probe immediately because the kernel
|
|
156
|
+
// may not have torn it down yet (and a child pid lingers as a zombie
|
|
157
|
+
// until waited on). Only EPERM/other errors are real failures.
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, "SIGKILL");
|
|
160
|
+
result.signal = "SIGKILL";
|
|
161
|
+
result.killed = true;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const code = error.code;
|
|
165
|
+
result.killed = code === "ESRCH";
|
|
166
|
+
if (!result.killed)
|
|
167
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return pids.map((pid) => results.get(pid));
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=process-scan.js.map
|
|
@@ -705,7 +705,7 @@ function layerStyle(layer) {
|
|
|
705
705
|
}
|
|
706
706
|
if (["caption", "text", "shape", "html"].includes(layer.kind)) {
|
|
707
707
|
const fontFamily = layer.fontFamily || "TikTok Sans";
|
|
708
|
-
styles.push("display:flex", "align-items:center", "justify-content:center", "padding:3px", `font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', Montserrat, Abel, sans-serif`, `font-weight:${positiveInteger(layer.fontWeight, 700)}`, `line-height:${positiveNumber(layer.lineHeight, 1.18)}`, "text-align:center", "text-transform:none", `font-size:${positiveInteger(layer.fontSize, 32)}px`, `color:${layer.color || "#ffffff"}`, layer.kind === "shape" && layer.textBackgroundStyle === "panel"
|
|
708
|
+
styles.push("display:flex", "align-items:center", "justify-content:center", "padding:3px", `font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', Montserrat, Abel, sans-serif, 'Noto Color Emoji'`, `font-weight:${positiveInteger(layer.fontWeight, 700)}`, `line-height:${positiveNumber(layer.lineHeight, 1.18)}`, "text-align:center", "text-transform:none", `font-size:${positiveInteger(layer.fontSize, 32)}px`, `color:${layer.color || "#ffffff"}`, layer.kind === "shape" && layer.textBackgroundStyle === "panel"
|
|
709
709
|
? `background:${layer.background || "transparent"}`
|
|
710
710
|
: "background:transparent");
|
|
711
711
|
}
|
|
@@ -733,7 +733,7 @@ function textInlineCss(style, color, background, fontFamily, fontWeight) {
|
|
|
733
733
|
else if (style === "highlight-translucent") {
|
|
734
734
|
styles.push("padding:0.07em 0.46em 0.09em", "border-radius:0.32em", `background:${rgbaFromColor(background, 0.34)}`, "text-shadow:none", "-webkit-text-stroke:0 transparent");
|
|
735
735
|
}
|
|
736
|
-
styles.push(`font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', sans-serif`);
|
|
736
|
+
styles.push(`font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', sans-serif, 'Noto Color Emoji'`);
|
|
737
737
|
styles.push(`font-weight:${fontWeight}`);
|
|
738
738
|
styles.push(`color:${color}`);
|
|
739
739
|
return styles.join(";");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@officexapp/vidfarm-devcli",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.15",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"dist/src/devcli/local-backend.js",
|
|
22
22
|
"dist/src/devcli/local-frontend-server.js",
|
|
23
23
|
"dist/src/devcli/local-render.js",
|
|
24
|
+
"dist/src/devcli/port-utils.js",
|
|
25
|
+
"dist/src/devcli/process-scan.js",
|
|
24
26
|
"dist/src/devcli/skills.js",
|
|
25
27
|
"dist/src/devcli/speech.js",
|
|
26
28
|
"dist/src/devcli/stills.js",
|
|
@@ -1490,6 +1490,23 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1490
1490
|
setLeftMode(''); // show the conversation, not the Files/History drawer
|
|
1491
1491
|
openThread(id);
|
|
1492
1492
|
}
|
|
1493
|
+
var hadHandoff = !!handoffThread;
|
|
1494
|
+
// Sticky active thread per template so a page refresh reopens the SAME
|
|
1495
|
+
// conversation instead of a blank chat (server routes deliberately never
|
|
1496
|
+
// attach ?thread= to editor URLs — the browser owns "which chat is active").
|
|
1497
|
+
function activeThreadKey() { return 'rk-chat-active:' + TEMPLATE_ID; }
|
|
1498
|
+
function saveActiveThread(id) {
|
|
1499
|
+
try { if (id) localStorage.setItem(activeThreadKey(), id); else localStorage.removeItem(activeThreadKey()); } catch (e) {}
|
|
1500
|
+
}
|
|
1501
|
+
var restoredActive = false;
|
|
1502
|
+
function restoreActiveThread() {
|
|
1503
|
+
if (restoredActive || hadHandoff) return;
|
|
1504
|
+
restoredActive = true;
|
|
1505
|
+
if (convo.length) return; // user already chatting — don't clobber
|
|
1506
|
+
var saved = null;
|
|
1507
|
+
try { saved = localStorage.getItem(activeThreadKey()); } catch (e) {}
|
|
1508
|
+
if (saved) openThread(saved);
|
|
1509
|
+
}
|
|
1493
1510
|
var busy = false;
|
|
1494
1511
|
var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
|
|
1495
1512
|
|
|
@@ -1540,14 +1557,20 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1540
1557
|
// outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
|
|
1541
1558
|
// knows which fork to read (video_context) and mutate (editor_action). Only the
|
|
1542
1559
|
// /editor dock has this bridge; elsewhere it returns ''.
|
|
1560
|
+
// Async: the Option-B bridge's getSnapshot() returns a PROMISE (it re-reads the
|
|
1561
|
+
// composition through the files API). The old sync call JSON.stringify'd the
|
|
1562
|
+
// Promise itself, sending the model a literal "{}" editor_context — no fork id,
|
|
1563
|
+
// no layers, no viral DNA. Always resolve before serializing.
|
|
1543
1564
|
function editorContextBlock() {
|
|
1544
|
-
if (!isEditorDock) return '';
|
|
1565
|
+
if (!isEditorDock) return Promise.resolve('');
|
|
1545
1566
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
1546
|
-
if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
|
|
1567
|
+
if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
|
|
1547
1568
|
var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1569
|
+
return Promise.resolve(snap).then(function (s) {
|
|
1570
|
+
if (!s) return '';
|
|
1571
|
+
try { return '\n\n<editor_context>\n' + JSON.stringify(s, null, 2) + '\n</editor_context>'; }
|
|
1572
|
+
catch (e) { return ''; }
|
|
1573
|
+
}, function () { return ''; });
|
|
1551
1574
|
}
|
|
1552
1575
|
function loadBoot() {
|
|
1553
1576
|
if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
|
|
@@ -1570,6 +1593,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1570
1593
|
loadThreads();
|
|
1571
1594
|
if (leftMode() === 'cloud') loadTasks();
|
|
1572
1595
|
consumeHandoff();
|
|
1596
|
+
restoreActiveThread();
|
|
1573
1597
|
})
|
|
1574
1598
|
.catch(function () { BOOT_STATE = 'error'; });
|
|
1575
1599
|
}
|
|
@@ -2123,8 +2147,10 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2123
2147
|
setBusy(true);
|
|
2124
2148
|
if (!threadId) threadId = genId('thread');
|
|
2125
2149
|
// Attach a fresh <editor_context> to the current (last) user turn only, so the
|
|
2126
|
-
// model sees the composition state without bloating persisted history.
|
|
2127
|
-
|
|
2150
|
+
// model sees the composition state without bloating persisted history. The
|
|
2151
|
+
// block resolves asynchronously (files-API read) — wait for it before building
|
|
2152
|
+
// the outgoing messages so the model actually receives the composition state.
|
|
2153
|
+
editorContextBlock().then(function (ctxBlock) {
|
|
2128
2154
|
// Attachments (pasted files OR files picked from the directory explorer) must
|
|
2129
2155
|
// ride in the model messages as file content parts + a URL text line — the
|
|
2130
2156
|
// backend only feeds the model messages[].content, NOT user_message.attachments
|
|
@@ -2142,6 +2168,9 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2142
2168
|
}
|
|
2143
2169
|
return { role: m.role, content: content };
|
|
2144
2170
|
});
|
|
2171
|
+
// The send is what turns a freshly minted thread id into a real saved
|
|
2172
|
+
// thread — make it the sticky-restore target from this moment on.
|
|
2173
|
+
saveActiveThread(threadId);
|
|
2145
2174
|
var body = {
|
|
2146
2175
|
messages: outMessages,
|
|
2147
2176
|
thread_id: threadId,
|
|
@@ -2205,6 +2234,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2205
2234
|
if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
|
|
2206
2235
|
view.fail(msg); setBusy(false); if (input) input.focus();
|
|
2207
2236
|
});
|
|
2237
|
+
}); // end editorContextBlock().then
|
|
2208
2238
|
}
|
|
2209
2239
|
|
|
2210
2240
|
function resetConversation() {
|
|
@@ -2458,10 +2488,25 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2458
2488
|
// fall through to chat-attach for folders / non-placeable files.
|
|
2459
2489
|
if (isEditorDock && it && it.viewUrl) {
|
|
2460
2490
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2491
|
+
// Placeability must be decided SYNCHRONOUSLY (the Option-B bridge returns a
|
|
2492
|
+
// Promise, so we can't branch on its result to decide chat-attach fallback —
|
|
2493
|
+
// checking ".ok" on the Promise made EVERY click fall through, placing the
|
|
2494
|
+
// media AND attaching it to chat, with no toast). Only image/video/audio go
|
|
2495
|
+
// on the timeline; folders/docs still fall through to chat-attach.
|
|
2496
|
+
var ct = String(it.contentType || '');
|
|
2497
|
+
if (bridge && typeof bridge.placeMediaAtPlayhead === 'function'
|
|
2498
|
+
&& (ct.indexOf('image/') === 0 || ct.indexOf('video/') === 0 || ct.indexOf('audio/') === 0)) {
|
|
2499
|
+
var placedName = it.name || 'media';
|
|
2500
|
+
var settlePlace = function (r) {
|
|
2501
|
+
if (r && r.ok) { editorPlaceToast('Added \u201c' + placedName + '\u201d to the timeline'); }
|
|
2502
|
+
else { editorPlaceToast('Couldn\u2019t add \u201c' + placedName + '\u201d' + ((r && r.error) ? ': ' + r.error : ''), true); }
|
|
2503
|
+
};
|
|
2504
|
+
try {
|
|
2505
|
+
var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
|
|
2506
|
+
if (res && typeof res.then === 'function') { res.then(settlePlace, function (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }); }
|
|
2507
|
+
else { settlePlace(res); }
|
|
2508
|
+
} catch (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }
|
|
2509
|
+
return;
|
|
2465
2510
|
}
|
|
2466
2511
|
}
|
|
2467
2512
|
// Files-only drawer (opened from the /chat page) has no chat composer of its
|
|
@@ -2578,7 +2623,11 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2578
2623
|
return wrap;
|
|
2579
2624
|
}
|
|
2580
2625
|
function setActiveThread(id) {
|
|
2581
|
-
|
|
2626
|
+
// null = "fresh unsaved chat": KEEP the freshly minted threadId (sends must
|
|
2627
|
+
// always carry a real thread_id or the server silently skips persistence)
|
|
2628
|
+
// and clear the sticky restore key; a real id becomes both current + sticky.
|
|
2629
|
+
if (id) { threadId = id; saveActiveThread(id); }
|
|
2630
|
+
else { saveActiveThread(null); }
|
|
2582
2631
|
if (!histBody) return;
|
|
2583
2632
|
var rows = histBody.querySelectorAll('.rk-aichat-frow');
|
|
2584
2633
|
for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
|
|
@@ -2671,7 +2720,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2671
2720
|
.then(function (r) {
|
|
2672
2721
|
if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
|
|
2673
2722
|
threads = threads.filter(function (t) { return t.id !== id; });
|
|
2674
|
-
if (id === threadId) resetConversation();
|
|
2723
|
+
if (id === threadId) { resetConversation(); saveActiveThread(null); }
|
|
2675
2724
|
renderHistory();
|
|
2676
2725
|
})
|
|
2677
2726
|
.catch(function () {});
|
|
@@ -1799,6 +1799,23 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1799
1799
|
setLeftMode(''); // show the conversation, not the Files/History drawer
|
|
1800
1800
|
openThread(id);
|
|
1801
1801
|
}
|
|
1802
|
+
var hadHandoff = !!handoffThread;
|
|
1803
|
+
// Sticky active thread per template so a page refresh reopens the SAME
|
|
1804
|
+
// conversation instead of a blank chat (server routes deliberately never
|
|
1805
|
+
// attach ?thread= to editor URLs — the browser owns "which chat is active").
|
|
1806
|
+
function activeThreadKey() { return 'rk-chat-active:' + TEMPLATE_ID; }
|
|
1807
|
+
function saveActiveThread(id) {
|
|
1808
|
+
try { if (id) localStorage.setItem(activeThreadKey(), id); else localStorage.removeItem(activeThreadKey()); } catch (e) {}
|
|
1809
|
+
}
|
|
1810
|
+
var restoredActive = false;
|
|
1811
|
+
function restoreActiveThread() {
|
|
1812
|
+
if (restoredActive || hadHandoff) return;
|
|
1813
|
+
restoredActive = true;
|
|
1814
|
+
if (convo.length) return; // user already chatting — don't clobber
|
|
1815
|
+
var saved = null;
|
|
1816
|
+
try { saved = localStorage.getItem(activeThreadKey()); } catch (e) {}
|
|
1817
|
+
if (saved) openThread(saved);
|
|
1818
|
+
}
|
|
1802
1819
|
var busy = false;
|
|
1803
1820
|
var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
|
|
1804
1821
|
|
|
@@ -1849,14 +1866,20 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1849
1866
|
// outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
|
|
1850
1867
|
// knows which fork to read (video_context) and mutate (editor_action). Only the
|
|
1851
1868
|
// /editor dock has this bridge; elsewhere it returns ''.
|
|
1869
|
+
// Async: the Option-B bridge's getSnapshot() returns a PROMISE (it re-reads the
|
|
1870
|
+
// composition through the files API). The old sync call JSON.stringify'd the
|
|
1871
|
+
// Promise itself, sending the model a literal "{}" editor_context — no fork id,
|
|
1872
|
+
// no layers, no viral DNA. Always resolve before serializing.
|
|
1852
1873
|
function editorContextBlock() {
|
|
1853
|
-
if (!isEditorDock) return '';
|
|
1874
|
+
if (!isEditorDock) return Promise.resolve('');
|
|
1854
1875
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
1855
|
-
if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
|
|
1876
|
+
if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
|
|
1856
1877
|
var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1878
|
+
return Promise.resolve(snap).then(function (s) {
|
|
1879
|
+
if (!s) return '';
|
|
1880
|
+
try { return '\n\n<editor_context>\n' + JSON.stringify(s, null, 2) + '\n</editor_context>'; }
|
|
1881
|
+
catch (e) { return ''; }
|
|
1882
|
+
}, function () { return ''; });
|
|
1860
1883
|
}
|
|
1861
1884
|
function loadBoot() {
|
|
1862
1885
|
if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
|
|
@@ -1879,6 +1902,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1879
1902
|
loadThreads();
|
|
1880
1903
|
if (leftMode() === 'cloud') loadTasks();
|
|
1881
1904
|
consumeHandoff();
|
|
1905
|
+
restoreActiveThread();
|
|
1882
1906
|
})
|
|
1883
1907
|
.catch(function () { BOOT_STATE = 'error'; });
|
|
1884
1908
|
}
|
|
@@ -2432,8 +2456,10 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2432
2456
|
setBusy(true);
|
|
2433
2457
|
if (!threadId) threadId = genId('thread');
|
|
2434
2458
|
// Attach a fresh <editor_context> to the current (last) user turn only, so the
|
|
2435
|
-
// model sees the composition state without bloating persisted history.
|
|
2436
|
-
|
|
2459
|
+
// model sees the composition state without bloating persisted history. The
|
|
2460
|
+
// block resolves asynchronously (files-API read) — wait for it before building
|
|
2461
|
+
// the outgoing messages so the model actually receives the composition state.
|
|
2462
|
+
editorContextBlock().then(function (ctxBlock) {
|
|
2437
2463
|
// Attachments (pasted files OR files picked from the directory explorer) must
|
|
2438
2464
|
// ride in the model messages as file content parts + a URL text line — the
|
|
2439
2465
|
// backend only feeds the model messages[].content, NOT user_message.attachments
|
|
@@ -2451,6 +2477,9 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2451
2477
|
}
|
|
2452
2478
|
return { role: m.role, content: content };
|
|
2453
2479
|
});
|
|
2480
|
+
// The send is what turns a freshly minted thread id into a real saved
|
|
2481
|
+
// thread — make it the sticky-restore target from this moment on.
|
|
2482
|
+
saveActiveThread(threadId);
|
|
2454
2483
|
var body = {
|
|
2455
2484
|
messages: outMessages,
|
|
2456
2485
|
thread_id: threadId,
|
|
@@ -2514,6 +2543,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2514
2543
|
if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
|
|
2515
2544
|
view.fail(msg); setBusy(false); if (input) input.focus();
|
|
2516
2545
|
});
|
|
2546
|
+
}); // end editorContextBlock().then
|
|
2517
2547
|
}
|
|
2518
2548
|
|
|
2519
2549
|
function resetConversation() {
|
|
@@ -2767,10 +2797,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2767
2797
|
// fall through to chat-attach for folders / non-placeable files.
|
|
2768
2798
|
if (isEditorDock && it && it.viewUrl) {
|
|
2769
2799
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2800
|
+
// Placeability must be decided SYNCHRONOUSLY (the Option-B bridge returns a
|
|
2801
|
+
// Promise, so we can't branch on its result to decide chat-attach fallback —
|
|
2802
|
+
// checking ".ok" on the Promise made EVERY click fall through, placing the
|
|
2803
|
+
// media AND attaching it to chat, with no toast). Only image/video/audio go
|
|
2804
|
+
// on the timeline; folders/docs still fall through to chat-attach.
|
|
2805
|
+
var ct = String(it.contentType || '');
|
|
2806
|
+
if (bridge && typeof bridge.placeMediaAtPlayhead === 'function'
|
|
2807
|
+
&& (ct.indexOf('image/') === 0 || ct.indexOf('video/') === 0 || ct.indexOf('audio/') === 0)) {
|
|
2808
|
+
var placedName = it.name || 'media';
|
|
2809
|
+
var settlePlace = function (r) {
|
|
2810
|
+
if (r && r.ok) { editorPlaceToast('Added \u201c' + placedName + '\u201d to the timeline'); }
|
|
2811
|
+
else { editorPlaceToast('Couldn\u2019t add \u201c' + placedName + '\u201d' + ((r && r.error) ? ': ' + r.error : ''), true); }
|
|
2812
|
+
};
|
|
2813
|
+
try {
|
|
2814
|
+
var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
|
|
2815
|
+
if (res && typeof res.then === 'function') { res.then(settlePlace, function (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }); }
|
|
2816
|
+
else { settlePlace(res); }
|
|
2817
|
+
} catch (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }
|
|
2818
|
+
return;
|
|
2774
2819
|
}
|
|
2775
2820
|
}
|
|
2776
2821
|
// Files-only drawer (opened from the /chat page) has no chat composer of its
|
|
@@ -2887,7 +2932,11 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2887
2932
|
return wrap;
|
|
2888
2933
|
}
|
|
2889
2934
|
function setActiveThread(id) {
|
|
2890
|
-
|
|
2935
|
+
// null = "fresh unsaved chat": KEEP the freshly minted threadId (sends must
|
|
2936
|
+
// always carry a real thread_id or the server silently skips persistence)
|
|
2937
|
+
// and clear the sticky restore key; a real id becomes both current + sticky.
|
|
2938
|
+
if (id) { threadId = id; saveActiveThread(id); }
|
|
2939
|
+
else { saveActiveThread(null); }
|
|
2891
2940
|
if (!histBody) return;
|
|
2892
2941
|
var rows = histBody.querySelectorAll('.rk-aichat-frow');
|
|
2893
2942
|
for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
|
|
@@ -2980,7 +3029,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2980
3029
|
.then(function (r) {
|
|
2981
3030
|
if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
|
|
2982
3031
|
threads = threads.filter(function (t) { return t.id !== id; });
|
|
2983
|
-
if (id === threadId) resetConversation();
|
|
3032
|
+
if (id === threadId) { resetConversation(); saveActiveThread(null); }
|
|
2984
3033
|
renderHistory();
|
|
2985
3034
|
})
|
|
2986
3035
|
.catch(function () {});
|
|
@@ -2753,6 +2753,23 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2753
2753
|
setLeftMode(''); // show the conversation, not the Files/History drawer
|
|
2754
2754
|
openThread(id);
|
|
2755
2755
|
}
|
|
2756
|
+
var hadHandoff = !!handoffThread;
|
|
2757
|
+
// Sticky active thread per template so a page refresh reopens the SAME
|
|
2758
|
+
// conversation instead of a blank chat (server routes deliberately never
|
|
2759
|
+
// attach ?thread= to editor URLs — the browser owns "which chat is active").
|
|
2760
|
+
function activeThreadKey() { return 'rk-chat-active:' + TEMPLATE_ID; }
|
|
2761
|
+
function saveActiveThread(id) {
|
|
2762
|
+
try { if (id) localStorage.setItem(activeThreadKey(), id); else localStorage.removeItem(activeThreadKey()); } catch (e) {}
|
|
2763
|
+
}
|
|
2764
|
+
var restoredActive = false;
|
|
2765
|
+
function restoreActiveThread() {
|
|
2766
|
+
if (restoredActive || hadHandoff) return;
|
|
2767
|
+
restoredActive = true;
|
|
2768
|
+
if (convo.length) return; // user already chatting — don't clobber
|
|
2769
|
+
var saved = null;
|
|
2770
|
+
try { saved = localStorage.getItem(activeThreadKey()); } catch (e) {}
|
|
2771
|
+
if (saved) openThread(saved);
|
|
2772
|
+
}
|
|
2756
2773
|
var busy = false;
|
|
2757
2774
|
var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
|
|
2758
2775
|
|
|
@@ -2803,14 +2820,20 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2803
2820
|
// outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
|
|
2804
2821
|
// knows which fork to read (video_context) and mutate (editor_action). Only the
|
|
2805
2822
|
// /editor dock has this bridge; elsewhere it returns ''.
|
|
2823
|
+
// Async: the Option-B bridge's getSnapshot() returns a PROMISE (it re-reads the
|
|
2824
|
+
// composition through the files API). The old sync call JSON.stringify'd the
|
|
2825
|
+
// Promise itself, sending the model a literal "{}" editor_context — no fork id,
|
|
2826
|
+
// no layers, no viral DNA. Always resolve before serializing.
|
|
2806
2827
|
function editorContextBlock() {
|
|
2807
|
-
if (!isEditorDock) return '';
|
|
2828
|
+
if (!isEditorDock) return Promise.resolve('');
|
|
2808
2829
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
2809
|
-
if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
|
|
2830
|
+
if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
|
|
2810
2831
|
var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2832
|
+
return Promise.resolve(snap).then(function (s) {
|
|
2833
|
+
if (!s) return '';
|
|
2834
|
+
try { return '\n\n<editor_context>\n' + JSON.stringify(s, null, 2) + '\n</editor_context>'; }
|
|
2835
|
+
catch (e) { return ''; }
|
|
2836
|
+
}, function () { return ''; });
|
|
2814
2837
|
}
|
|
2815
2838
|
function loadBoot() {
|
|
2816
2839
|
if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
|
|
@@ -2833,6 +2856,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2833
2856
|
loadThreads();
|
|
2834
2857
|
if (leftMode() === 'cloud') loadTasks();
|
|
2835
2858
|
consumeHandoff();
|
|
2859
|
+
restoreActiveThread();
|
|
2836
2860
|
})
|
|
2837
2861
|
.catch(function () { BOOT_STATE = 'error'; });
|
|
2838
2862
|
}
|
|
@@ -3386,8 +3410,10 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
3386
3410
|
setBusy(true);
|
|
3387
3411
|
if (!threadId) threadId = genId('thread');
|
|
3388
3412
|
// Attach a fresh <editor_context> to the current (last) user turn only, so the
|
|
3389
|
-
// model sees the composition state without bloating persisted history.
|
|
3390
|
-
|
|
3413
|
+
// model sees the composition state without bloating persisted history. The
|
|
3414
|
+
// block resolves asynchronously (files-API read) — wait for it before building
|
|
3415
|
+
// the outgoing messages so the model actually receives the composition state.
|
|
3416
|
+
editorContextBlock().then(function (ctxBlock) {
|
|
3391
3417
|
// Attachments (pasted files OR files picked from the directory explorer) must
|
|
3392
3418
|
// ride in the model messages as file content parts + a URL text line — the
|
|
3393
3419
|
// backend only feeds the model messages[].content, NOT user_message.attachments
|
|
@@ -3405,6 +3431,9 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
3405
3431
|
}
|
|
3406
3432
|
return { role: m.role, content: content };
|
|
3407
3433
|
});
|
|
3434
|
+
// The send is what turns a freshly minted thread id into a real saved
|
|
3435
|
+
// thread — make it the sticky-restore target from this moment on.
|
|
3436
|
+
saveActiveThread(threadId);
|
|
3408
3437
|
var body = {
|
|
3409
3438
|
messages: outMessages,
|
|
3410
3439
|
thread_id: threadId,
|
|
@@ -3468,6 +3497,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
3468
3497
|
if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
|
|
3469
3498
|
view.fail(msg); setBusy(false); if (input) input.focus();
|
|
3470
3499
|
});
|
|
3500
|
+
}); // end editorContextBlock().then
|
|
3471
3501
|
}
|
|
3472
3502
|
|
|
3473
3503
|
function resetConversation() {
|
|
@@ -3721,10 +3751,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
3721
3751
|
// fall through to chat-attach for folders / non-placeable files.
|
|
3722
3752
|
if (isEditorDock && it && it.viewUrl) {
|
|
3723
3753
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3754
|
+
// Placeability must be decided SYNCHRONOUSLY (the Option-B bridge returns a
|
|
3755
|
+
// Promise, so we can't branch on its result to decide chat-attach fallback —
|
|
3756
|
+
// checking ".ok" on the Promise made EVERY click fall through, placing the
|
|
3757
|
+
// media AND attaching it to chat, with no toast). Only image/video/audio go
|
|
3758
|
+
// on the timeline; folders/docs still fall through to chat-attach.
|
|
3759
|
+
var ct = String(it.contentType || '');
|
|
3760
|
+
if (bridge && typeof bridge.placeMediaAtPlayhead === 'function'
|
|
3761
|
+
&& (ct.indexOf('image/') === 0 || ct.indexOf('video/') === 0 || ct.indexOf('audio/') === 0)) {
|
|
3762
|
+
var placedName = it.name || 'media';
|
|
3763
|
+
var settlePlace = function (r) {
|
|
3764
|
+
if (r && r.ok) { editorPlaceToast('Added \u201c' + placedName + '\u201d to the timeline'); }
|
|
3765
|
+
else { editorPlaceToast('Couldn\u2019t add \u201c' + placedName + '\u201d' + ((r && r.error) ? ': ' + r.error : ''), true); }
|
|
3766
|
+
};
|
|
3767
|
+
try {
|
|
3768
|
+
var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
|
|
3769
|
+
if (res && typeof res.then === 'function') { res.then(settlePlace, function (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }); }
|
|
3770
|
+
else { settlePlace(res); }
|
|
3771
|
+
} catch (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }
|
|
3772
|
+
return;
|
|
3728
3773
|
}
|
|
3729
3774
|
}
|
|
3730
3775
|
// Files-only drawer (opened from the /chat page) has no chat composer of its
|
|
@@ -3841,7 +3886,11 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
3841
3886
|
return wrap;
|
|
3842
3887
|
}
|
|
3843
3888
|
function setActiveThread(id) {
|
|
3844
|
-
|
|
3889
|
+
// null = "fresh unsaved chat": KEEP the freshly minted threadId (sends must
|
|
3890
|
+
// always carry a real thread_id or the server silently skips persistence)
|
|
3891
|
+
// and clear the sticky restore key; a real id becomes both current + sticky.
|
|
3892
|
+
if (id) { threadId = id; saveActiveThread(id); }
|
|
3893
|
+
else { saveActiveThread(null); }
|
|
3845
3894
|
if (!histBody) return;
|
|
3846
3895
|
var rows = histBody.querySelectorAll('.rk-aichat-frow');
|
|
3847
3896
|
for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
|
|
@@ -3934,7 +3983,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
3934
3983
|
.then(function (r) {
|
|
3935
3984
|
if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
|
|
3936
3985
|
threads = threads.filter(function (t) { return t.id !== id; });
|
|
3937
|
-
if (id === threadId) resetConversation();
|
|
3986
|
+
if (id === threadId) { resetConversation(); saveActiveThread(null); }
|
|
3938
3987
|
renderHistory();
|
|
3939
3988
|
})
|
|
3940
3989
|
.catch(function () {});
|