@officexapp/vidfarm-devcli 0.21.11 → 0.21.14
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/hyperframes-core/SKILL.md +2 -0
- package/.agents/skills/hyperframes-creative/SKILL.md +1 -0
- package/.agents/skills/hyperframes-creative/references/beat-direction.md +17 -0
- package/.agents/skills/vidfarm-director/SKILL.md +4 -0
- package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +1 -1
- package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +23 -6
- package/.agents/skills/vidfarm-director/references/core-workflows.md +1 -1
- package/.agents/skills/vidfarm-director/references/editor-workflows.md +15 -2
- package/.agents/skills/vidfarm-director/references/primitives.md +1 -1
- package/.agents/skills/vidfarm-director/references/rest-api.md +1 -1
- package/.agents/skills/vidfarm-media/SKILL.md +16 -6
- package/README.md +2 -2
- package/SKILL.director.md +45 -11
- package/demo/dist/app.js +247 -226
- package/dist/src/cli.js +198 -30
- package/dist/src/devcli/composition-edit.js +99 -23
- package/dist/src/devcli/cost-mode.js +13 -4
- package/dist/src/devcli/doctor.js +65 -9
- package/dist/src/devcli/local-frontend-server.js +342 -50
- package/dist/src/devcli/port-utils.js +43 -0
- package/dist/src/devcli/process-scan.js +173 -0
- package/package.json +3 -1
- package/public/serve-shells/editor.html +75 -13
- package/public/serve-shells/library-files.html +75 -13
- package/public/serve-shells/library-raws.html +75 -13
- package/public/serve-shells/tools-clipper.html +75 -13
- package/public/serve-shells/tools-image.html +75 -13
- package/public/serve-shells/tools-video.html +75 -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@officexapp/vidfarm-devcli",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.14",
|
|
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",
|
|
@@ -1266,12 +1266,25 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1266
1266
|
// Desktop-agents nudge banner: a full-width alert pinned to the top of the
|
|
1267
1267
|
// /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
|
|
1268
1268
|
// this chrome script, so gate strictly by pathname. Dismissible per session.
|
|
1269
|
+
// The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
|
|
1270
|
+
// a desktop AI agent). A client reviewing a shared video in the editor should
|
|
1271
|
+
// NOT be told to go install Claude Code / Codex — so on the editor we suppress
|
|
1272
|
+
// it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
|
|
1269
1273
|
(function mountDesktopAgentsBanner() {
|
|
1270
1274
|
try {
|
|
1271
1275
|
var path = location.pathname;
|
|
1272
1276
|
var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
|
|
1273
1277
|
var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
|
|
1274
1278
|
if (!onEditor && !onChat) return;
|
|
1279
|
+
if (onEditor) {
|
|
1280
|
+
var bootEl = document.getElementById('hf-boot');
|
|
1281
|
+
if (bootEl) {
|
|
1282
|
+
try {
|
|
1283
|
+
var boot = JSON.parse(bootEl.textContent || '{}');
|
|
1284
|
+
if (boot && boot.freeTier) return;
|
|
1285
|
+
} catch (e) {}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1275
1288
|
if (document.querySelector('.vf-topbanner')) return;
|
|
1276
1289
|
if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
|
|
1277
1290
|
var GUIDE = '/blog/desktop-ai-agents';
|
|
@@ -1477,6 +1490,23 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1477
1490
|
setLeftMode(''); // show the conversation, not the Files/History drawer
|
|
1478
1491
|
openThread(id);
|
|
1479
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
|
+
}
|
|
1480
1510
|
var busy = false;
|
|
1481
1511
|
var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
|
|
1482
1512
|
|
|
@@ -1527,14 +1557,20 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1527
1557
|
// outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
|
|
1528
1558
|
// knows which fork to read (video_context) and mutate (editor_action). Only the
|
|
1529
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.
|
|
1530
1564
|
function editorContextBlock() {
|
|
1531
|
-
if (!isEditorDock) return '';
|
|
1565
|
+
if (!isEditorDock) return Promise.resolve('');
|
|
1532
1566
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
1533
|
-
if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
|
|
1567
|
+
if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
|
|
1534
1568
|
var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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 ''; });
|
|
1538
1574
|
}
|
|
1539
1575
|
function loadBoot() {
|
|
1540
1576
|
if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
|
|
@@ -1557,6 +1593,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1557
1593
|
loadThreads();
|
|
1558
1594
|
if (leftMode() === 'cloud') loadTasks();
|
|
1559
1595
|
consumeHandoff();
|
|
1596
|
+
restoreActiveThread();
|
|
1560
1597
|
})
|
|
1561
1598
|
.catch(function () { BOOT_STATE = 'error'; });
|
|
1562
1599
|
}
|
|
@@ -2110,8 +2147,10 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2110
2147
|
setBusy(true);
|
|
2111
2148
|
if (!threadId) threadId = genId('thread');
|
|
2112
2149
|
// Attach a fresh <editor_context> to the current (last) user turn only, so the
|
|
2113
|
-
// model sees the composition state without bloating persisted history.
|
|
2114
|
-
|
|
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) {
|
|
2115
2154
|
// Attachments (pasted files OR files picked from the directory explorer) must
|
|
2116
2155
|
// ride in the model messages as file content parts + a URL text line — the
|
|
2117
2156
|
// backend only feeds the model messages[].content, NOT user_message.attachments
|
|
@@ -2129,6 +2168,9 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2129
2168
|
}
|
|
2130
2169
|
return { role: m.role, content: content };
|
|
2131
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);
|
|
2132
2174
|
var body = {
|
|
2133
2175
|
messages: outMessages,
|
|
2134
2176
|
thread_id: threadId,
|
|
@@ -2192,6 +2234,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2192
2234
|
if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
|
|
2193
2235
|
view.fail(msg); setBusy(false); if (input) input.focus();
|
|
2194
2236
|
});
|
|
2237
|
+
}); // end editorContextBlock().then
|
|
2195
2238
|
}
|
|
2196
2239
|
|
|
2197
2240
|
function resetConversation() {
|
|
@@ -2445,10 +2488,25 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2445
2488
|
// fall through to chat-attach for folders / non-placeable files.
|
|
2446
2489
|
if (isEditorDock && it && it.viewUrl) {
|
|
2447
2490
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
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;
|
|
2452
2510
|
}
|
|
2453
2511
|
}
|
|
2454
2512
|
// Files-only drawer (opened from the /chat page) has no chat composer of its
|
|
@@ -2565,7 +2623,11 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2565
2623
|
return wrap;
|
|
2566
2624
|
}
|
|
2567
2625
|
function setActiveThread(id) {
|
|
2568
|
-
|
|
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); }
|
|
2569
2631
|
if (!histBody) return;
|
|
2570
2632
|
var rows = histBody.querySelectorAll('.rk-aichat-frow');
|
|
2571
2633
|
for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
|
|
@@ -2658,7 +2720,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
2658
2720
|
.then(function (r) {
|
|
2659
2721
|
if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
|
|
2660
2722
|
threads = threads.filter(function (t) { return t.id !== id; });
|
|
2661
|
-
if (id === threadId) resetConversation();
|
|
2723
|
+
if (id === threadId) { resetConversation(); saveActiveThread(null); }
|
|
2662
2724
|
renderHistory();
|
|
2663
2725
|
})
|
|
2664
2726
|
.catch(function () {});
|
|
@@ -1575,12 +1575,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1575
1575
|
// Desktop-agents nudge banner: a full-width alert pinned to the top of the
|
|
1576
1576
|
// /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
|
|
1577
1577
|
// this chrome script, so gate strictly by pathname. Dismissible per session.
|
|
1578
|
+
// The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
|
|
1579
|
+
// a desktop AI agent). A client reviewing a shared video in the editor should
|
|
1580
|
+
// NOT be told to go install Claude Code / Codex — so on the editor we suppress
|
|
1581
|
+
// it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
|
|
1578
1582
|
(function mountDesktopAgentsBanner() {
|
|
1579
1583
|
try {
|
|
1580
1584
|
var path = location.pathname;
|
|
1581
1585
|
var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
|
|
1582
1586
|
var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
|
|
1583
1587
|
if (!onEditor && !onChat) return;
|
|
1588
|
+
if (onEditor) {
|
|
1589
|
+
var bootEl = document.getElementById('hf-boot');
|
|
1590
|
+
if (bootEl) {
|
|
1591
|
+
try {
|
|
1592
|
+
var boot = JSON.parse(bootEl.textContent || '{}');
|
|
1593
|
+
if (boot && boot.freeTier) return;
|
|
1594
|
+
} catch (e) {}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1584
1597
|
if (document.querySelector('.vf-topbanner')) return;
|
|
1585
1598
|
if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
|
|
1586
1599
|
var GUIDE = '/blog/desktop-ai-agents';
|
|
@@ -1786,6 +1799,23 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1786
1799
|
setLeftMode(''); // show the conversation, not the Files/History drawer
|
|
1787
1800
|
openThread(id);
|
|
1788
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
|
+
}
|
|
1789
1819
|
var busy = false;
|
|
1790
1820
|
var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
|
|
1791
1821
|
|
|
@@ -1836,14 +1866,20 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1836
1866
|
// outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
|
|
1837
1867
|
// knows which fork to read (video_context) and mutate (editor_action). Only the
|
|
1838
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.
|
|
1839
1873
|
function editorContextBlock() {
|
|
1840
|
-
if (!isEditorDock) return '';
|
|
1874
|
+
if (!isEditorDock) return Promise.resolve('');
|
|
1841
1875
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
1842
|
-
if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
|
|
1876
|
+
if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
|
|
1843
1877
|
var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
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 ''; });
|
|
1847
1883
|
}
|
|
1848
1884
|
function loadBoot() {
|
|
1849
1885
|
if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
|
|
@@ -1866,6 +1902,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1866
1902
|
loadThreads();
|
|
1867
1903
|
if (leftMode() === 'cloud') loadTasks();
|
|
1868
1904
|
consumeHandoff();
|
|
1905
|
+
restoreActiveThread();
|
|
1869
1906
|
})
|
|
1870
1907
|
.catch(function () { BOOT_STATE = 'error'; });
|
|
1871
1908
|
}
|
|
@@ -2419,8 +2456,10 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2419
2456
|
setBusy(true);
|
|
2420
2457
|
if (!threadId) threadId = genId('thread');
|
|
2421
2458
|
// Attach a fresh <editor_context> to the current (last) user turn only, so the
|
|
2422
|
-
// model sees the composition state without bloating persisted history.
|
|
2423
|
-
|
|
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) {
|
|
2424
2463
|
// Attachments (pasted files OR files picked from the directory explorer) must
|
|
2425
2464
|
// ride in the model messages as file content parts + a URL text line — the
|
|
2426
2465
|
// backend only feeds the model messages[].content, NOT user_message.attachments
|
|
@@ -2438,6 +2477,9 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2438
2477
|
}
|
|
2439
2478
|
return { role: m.role, content: content };
|
|
2440
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);
|
|
2441
2483
|
var body = {
|
|
2442
2484
|
messages: outMessages,
|
|
2443
2485
|
thread_id: threadId,
|
|
@@ -2501,6 +2543,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2501
2543
|
if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
|
|
2502
2544
|
view.fail(msg); setBusy(false); if (input) input.focus();
|
|
2503
2545
|
});
|
|
2546
|
+
}); // end editorContextBlock().then
|
|
2504
2547
|
}
|
|
2505
2548
|
|
|
2506
2549
|
function resetConversation() {
|
|
@@ -2754,10 +2797,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2754
2797
|
// fall through to chat-attach for folders / non-placeable files.
|
|
2755
2798
|
if (isEditorDock && it && it.viewUrl) {
|
|
2756
2799
|
var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
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;
|
|
2761
2819
|
}
|
|
2762
2820
|
}
|
|
2763
2821
|
// Files-only drawer (opened from the /chat page) has no chat composer of its
|
|
@@ -2874,7 +2932,11 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2874
2932
|
return wrap;
|
|
2875
2933
|
}
|
|
2876
2934
|
function setActiveThread(id) {
|
|
2877
|
-
|
|
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); }
|
|
2878
2940
|
if (!histBody) return;
|
|
2879
2941
|
var rows = histBody.querySelectorAll('.rk-aichat-frow');
|
|
2880
2942
|
for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
|
|
@@ -2967,7 +3029,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
2967
3029
|
.then(function (r) {
|
|
2968
3030
|
if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
|
|
2969
3031
|
threads = threads.filter(function (t) { return t.id !== id; });
|
|
2970
|
-
if (id === threadId) resetConversation();
|
|
3032
|
+
if (id === threadId) { resetConversation(); saveActiveThread(null); }
|
|
2971
3033
|
renderHistory();
|
|
2972
3034
|
})
|
|
2973
3035
|
.catch(function () {});
|