@yeaft/webchat-agent 1.0.522 → 1.0.524
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/connection/buffer.js +5 -4
- package/connection/message-router.js +9 -1
- package/index.js +1 -1
- package/local-runtime/server/handlers/agent-file-terminal.js +23 -5
- package/local-runtime/server/handlers/client-workbench.js +19 -4
- package/local-runtime/server/routes/upload-routes.js +96 -1
- package/local-runtime/server/workbench-preview.js +94 -3
- package/local-runtime/server/ws-agent.js +1 -1
- package/local-runtime/server/ws-client.js +1 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +125 -105
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/workbench/file-ops.js +97 -2
- package/workbench/utils.js +5 -0
- package/workbench.js +1 -1
package/connection/buffer.js
CHANGED
|
@@ -59,7 +59,7 @@ function bufferMessage(msg, reason) {
|
|
|
59
59
|
|
|
60
60
|
const FILE_CHUNK_SEND_TIMEOUT_MS = 10_000;
|
|
61
61
|
|
|
62
|
-
function
|
|
62
|
+
function sendTransferChunk(socket, data) {
|
|
63
63
|
// Production uses Node ws; retain compatibility with synchronous, one-argument
|
|
64
64
|
// transport stubs without treating a real ws.send() return as a flushed write.
|
|
65
65
|
if (!(socket instanceof WebSocket) && socket.send.length < 2) {
|
|
@@ -104,14 +104,14 @@ async function sendNow(msg, queuedSocket) {
|
|
|
104
104
|
const socket = ctx.ws;
|
|
105
105
|
if (!socket || socket.readyState !== WebSocket.OPEN) return bufferMessage(msg, 'Disconnected');
|
|
106
106
|
// File transfers are request/connection scoped, unlike replayable chat output.
|
|
107
|
-
if (msg.type
|
|
107
|
+
if (['file_content_chunk', 'video_chunk'].includes(msg.type) && queuedSocket !== socket) return 'dropped';
|
|
108
108
|
const payload = ctx.serverEncryptionRequired && ctx.sessionKey
|
|
109
109
|
? await encrypt(msg, ctx.sessionKey) : msg;
|
|
110
110
|
if (socket !== ctx.ws || socket.readyState !== WebSocket.OPEN) {
|
|
111
111
|
return bufferMessage(msg, 'Connection changed');
|
|
112
112
|
}
|
|
113
113
|
const data = JSON.stringify(payload);
|
|
114
|
-
if (msg.type
|
|
114
|
+
if (['file_content_chunk', 'video_chunk'].includes(msg.type)) return sendTransferChunk(socket, data);
|
|
115
115
|
socket.send(data);
|
|
116
116
|
return 'sent';
|
|
117
117
|
}
|
|
@@ -161,7 +161,8 @@ export async function sendToServer(msg) {
|
|
|
161
161
|
return 'dropped';
|
|
162
162
|
}
|
|
163
163
|
const promise = new Promise((resolve, reject) => {
|
|
164
|
-
|
|
164
|
+
const connectionScoped = ['file_content_chunk', 'video_chunk'].includes(msg.type);
|
|
165
|
+
ctx.outboundSendQueue.push({ msg, bytes, resolve, reject, socket: connectionScoped ? ctx.ws : null });
|
|
165
166
|
ctx.outboundSendQueueBytes = Number(ctx.outboundSendQueueBytes || 0) + bytes;
|
|
166
167
|
});
|
|
167
168
|
scheduleOutboundDrain();
|
|
@@ -10,7 +10,7 @@ import { decodeKey } from '../encryption.js';
|
|
|
10
10
|
import { handleTerminalCreate, handleTerminalInput, handleTerminalResize, handleTerminalClose } from '../terminal.js';
|
|
11
11
|
import { handleProxyHttpRequest, handleProxyWsOpen, handleProxyWsMessage, handleProxyWsClose } from '../proxy.js';
|
|
12
12
|
import {
|
|
13
|
-
handleReadFile, handleWriteFile, handleListDirectory,
|
|
13
|
+
handleReadFile, handleVideoMetadata, handleVideoChunk, handleWriteFile, handleListDirectory,
|
|
14
14
|
handleGitStatus, handleGitDiff, handleGitAdd, handleGitReset, handleGitRestore, handleGitCommit, handleGitPush,
|
|
15
15
|
handleFileSearch, handleResolveFileReferences, handleCreateFile, handleDeleteFiles, handleMoveFiles, handleCopyFiles, handleUploadToDir, handleTransferFiles
|
|
16
16
|
} from '../workbench.js';
|
|
@@ -251,6 +251,14 @@ export async function handleMessage(msg) {
|
|
|
251
251
|
await handleReadFile(msg);
|
|
252
252
|
break;
|
|
253
253
|
|
|
254
|
+
case 'video_metadata':
|
|
255
|
+
await handleVideoMetadata(msg);
|
|
256
|
+
break;
|
|
257
|
+
|
|
258
|
+
case 'video_chunk':
|
|
259
|
+
await handleVideoChunk(msg);
|
|
260
|
+
break;
|
|
261
|
+
|
|
254
262
|
case 'write_file':
|
|
255
263
|
await handleWriteFile(msg);
|
|
256
264
|
break;
|
package/index.js
CHANGED
|
@@ -177,7 +177,7 @@ async function detectCapabilities() {
|
|
|
177
177
|
// agent build can speak plaintext WS frames. New servers see this and
|
|
178
178
|
// flip `agent.encryptOutbound = false`, stopping outbound encryption
|
|
179
179
|
// to this peer. Old servers ignore the unknown capability token.
|
|
180
|
-
const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'workbench_request_correlation', 'workbench_terminal_cleanup_fence', 'workbench_file_content_chunks', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', 'response_image_preview', 'yeaft_plugins', 'yeaft_managed_skills', 'settings_request_correlation'];
|
|
180
|
+
const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'workbench_request_correlation', 'workbench_terminal_cleanup_fence', 'workbench_file_content_chunks', 'workbench_video_stream', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', 'response_image_preview', 'yeaft_plugins', 'yeaft_managed_skills', 'settings_request_correlation'];
|
|
181
181
|
capabilities.push(getAgentUpgradeCapability());
|
|
182
182
|
if (isWorkCenterEnabled()) {
|
|
183
183
|
capabilities.push('work_center', 'work_center_message_v2');
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from '../workbench-correlation.js';
|
|
27
27
|
import { appendFileContentChunk, discardFileContentAssembly } from '../file-content-assembly.js';
|
|
28
28
|
import { cachePreviewFile, MAX_PREVIEW_FILE_BYTES } from '../preview-files.js';
|
|
29
|
-
import { createWorkbenchPreview } from '../workbench-preview.js';
|
|
29
|
+
import { createWorkbenchPreview, createWorkbenchVideo } from '../workbench-preview.js';
|
|
30
30
|
|
|
31
31
|
function stripAgentRouting(msg) {
|
|
32
32
|
const {
|
|
@@ -286,9 +286,27 @@ async function handleOneShotResponse(agentId, agent, msg, routeKey) {
|
|
|
286
286
|
await pending.onResponse?.({ error: 'Preview workspace is no longer available' });
|
|
287
287
|
return;
|
|
288
288
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
let projected = msg;
|
|
290
|
+
if (msg.type === 'file_content' && msg.binary && !pending.onResponse) {
|
|
291
|
+
projected = cacheBinaryPreview(msg, null, pending);
|
|
292
|
+
} else if (msg.type === 'video_metadata' && !msg.error && !pending.onResponse) {
|
|
293
|
+
const fileId = randomUUID();
|
|
294
|
+
const token = createWorkbenchVideo(fileId, pending, msg);
|
|
295
|
+
const { mtimeMs: _mtimeMs, ...publicMetadata } = msg;
|
|
296
|
+
projected = token ? {
|
|
297
|
+
...publicMetadata,
|
|
298
|
+
filePath: msg.requestedFilePath || msg.filePath,
|
|
299
|
+
videoStream: true,
|
|
300
|
+
fileId,
|
|
301
|
+
previewToken: token,
|
|
302
|
+
previewUrl: `/api/preview/${fileId}?token=${encodeURIComponent(token)}`,
|
|
303
|
+
} : {
|
|
304
|
+
...publicMetadata,
|
|
305
|
+
filePath: msg.requestedFilePath || msg.filePath,
|
|
306
|
+
error: 'Video stream is unavailable for this Session.',
|
|
307
|
+
errorCode: 'VIDEO_STREAM_UNAVAILABLE',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
292
310
|
await sendToPendingClient(agentId, projected, pending);
|
|
293
311
|
}
|
|
294
312
|
|
|
@@ -304,7 +322,7 @@ export async function handleAgentFileTerminal(agentId, agent, rawMsg) {
|
|
|
304
322
|
'terminal_created', 'terminal_output', 'terminal_closed', 'terminal_error',
|
|
305
323
|
]);
|
|
306
324
|
const oneShotTypes = new Set([
|
|
307
|
-
'file_content', 'file_content_chunk', 'file_references_resolved', 'file_saved', 'directory_listing', 'file_op_result',
|
|
325
|
+
'file_content', 'file_content_chunk', 'video_metadata', 'video_chunk', 'file_references_resolved', 'file_saved', 'directory_listing', 'file_op_result',
|
|
308
326
|
'git_status_result', 'git_diff_result', 'git_op_result', 'file_search_result',
|
|
309
327
|
]);
|
|
310
328
|
if (!terminalTypes.has(msg.type) && !oneShotTypes.has(msg.type)) return false;
|
|
@@ -62,6 +62,7 @@ const AGENT_DIRECTORY_PICKER_CONVERSATION = '_workdir_picker';
|
|
|
62
62
|
const RESPONSE_TYPES = Object.freeze({
|
|
63
63
|
terminal_create: ['terminal_created', 'terminal_error'],
|
|
64
64
|
read_file: ['file_content'],
|
|
65
|
+
video_metadata: ['video_metadata'],
|
|
65
66
|
resolve_file_references: ['file_references_resolved'],
|
|
66
67
|
write_file: ['file_saved'],
|
|
67
68
|
list_directory: ['directory_listing'],
|
|
@@ -83,6 +84,7 @@ const RESPONSE_TYPES = Object.freeze({
|
|
|
83
84
|
const TIMEOUT_RESPONSE_TYPES = Object.freeze({
|
|
84
85
|
terminal_create: 'terminal_error',
|
|
85
86
|
read_file: 'file_content',
|
|
87
|
+
video_metadata: 'video_metadata',
|
|
86
88
|
resolve_file_references: 'file_references_resolved',
|
|
87
89
|
write_file: 'file_saved',
|
|
88
90
|
list_directory: 'directory_listing',
|
|
@@ -124,8 +126,8 @@ function workbenchFailureResponse({ agentId, msg, resolved, error }) {
|
|
|
124
126
|
if (msg.type === 'terminal_create') {
|
|
125
127
|
return { ...response, terminalId: msg.terminalId || null, message: error };
|
|
126
128
|
}
|
|
127
|
-
if (msg.type === 'read_file') {
|
|
128
|
-
return { ...response, filePath: msg.filePath };
|
|
129
|
+
if (msg.type === 'read_file' || msg.type === 'video_metadata') {
|
|
130
|
+
return { ...response, filePath: msg.filePath, requestedFilePath: msg.filePath };
|
|
129
131
|
}
|
|
130
132
|
if (msg.type === 'resolve_file_references') {
|
|
131
133
|
return { ...response, references: [] };
|
|
@@ -308,7 +310,8 @@ export async function handleClientWorkbench(clientId, client, msg, checkAgentAcc
|
|
|
308
310
|
}
|
|
309
311
|
|
|
310
312
|
case 'resolve_file_references':
|
|
311
|
-
case 'read_file':
|
|
313
|
+
case 'read_file':
|
|
314
|
+
case 'video_metadata': {
|
|
312
315
|
const fileAgentId = msg.agentId || client.currentAgent;
|
|
313
316
|
if (!fileAgentId) { console.warn('[Server] read_file: no agentId'); return; }
|
|
314
317
|
if (!await checkAgentAccess(fileAgentId)) return;
|
|
@@ -318,6 +321,17 @@ export async function handleClientWorkbench(clientId, client, msg, checkAgentAcc
|
|
|
318
321
|
return;
|
|
319
322
|
}
|
|
320
323
|
const fileConvId = resolved.conversationId || msg.conversationId || client.currentConversation || '_explorer';
|
|
324
|
+
if (msg.type === 'video_metadata'
|
|
325
|
+
&& (!agents.get(fileAgentId)?.capabilities?.includes?.('workbench_video_stream')
|
|
326
|
+
|| !agentSupportsWorkbenchRequestCorrelation(agents.get(fileAgentId)))) {
|
|
327
|
+
await sendToWebClient(client, workbenchFailureResponse({
|
|
328
|
+
agentId: fileAgentId,
|
|
329
|
+
msg,
|
|
330
|
+
resolved: { ...resolved, conversationId: fileConvId },
|
|
331
|
+
error: 'Video streaming is not supported by this Agent',
|
|
332
|
+
}));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
321
335
|
if (msg.responseImagePreview
|
|
322
336
|
&& !agents.get(fileAgentId)?.capabilities?.includes?.('response_image_preview')) {
|
|
323
337
|
await sendToWebClient(client, workbenchFailureResponse({
|
|
@@ -336,7 +350,8 @@ export async function handleClientWorkbench(clientId, client, msg, checkAgentAcc
|
|
|
336
350
|
msg,
|
|
337
351
|
resolved,
|
|
338
352
|
canonical: canonicalWorkbenchMessage(msg, { ...resolved, conversationId: fileConvId }, {
|
|
339
|
-
canonicalWorkDir: !resolved.legacy
|
|
353
|
+
canonicalWorkDir: !resolved.legacy
|
|
354
|
+
&& (msg.type === 'resolve_file_references' || msg.type === 'video_metadata'),
|
|
340
355
|
}),
|
|
341
356
|
});
|
|
342
357
|
break;
|
|
@@ -5,7 +5,7 @@ import { userDb } from '../database.js';
|
|
|
5
5
|
import { pendingFiles, previewFiles } from '../context.js';
|
|
6
6
|
import { PREVIEW_FILE_TTL_MS, prunePreviewFiles } from '../preview-files.js';
|
|
7
7
|
import { yeaftAssetStore } from '../yeaft-asset-store.js';
|
|
8
|
-
import { readWorkbenchPreview } from '../workbench-preview.js';
|
|
8
|
+
import { openWorkbenchVideo, readWorkbenchPreview, WORKBENCH_VIDEO_CHUNK_BYTES } from '../workbench-preview.js';
|
|
9
9
|
|
|
10
10
|
// 文件上传配置 (存储在内存中)
|
|
11
11
|
const upload = multer({
|
|
@@ -13,6 +13,93 @@ const upload = multer({
|
|
|
13
13
|
limits: { fileSize: CONFIG.maxFileSize }
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
export function parseByteRange(header, size) {
|
|
17
|
+
if (!header) return null;
|
|
18
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim());
|
|
19
|
+
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(size) || size <= 0) return false;
|
|
20
|
+
let start;
|
|
21
|
+
let end;
|
|
22
|
+
if (!match[1]) {
|
|
23
|
+
const suffix = Number(match[2]);
|
|
24
|
+
if (!/^\d+$/.test(match[2]) || !Number.isSafeInteger(suffix) || suffix <= 0) return false;
|
|
25
|
+
start = Math.max(0, size - suffix);
|
|
26
|
+
end = size - 1;
|
|
27
|
+
} else {
|
|
28
|
+
if (!/^\d+$/.test(match[1]) || (match[2] && !/^\d+$/.test(match[2]))) return false;
|
|
29
|
+
start = Number(match[1]);
|
|
30
|
+
end = match[2] ? Number(match[2]) : size - 1;
|
|
31
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= size || end < start) return false;
|
|
32
|
+
end = Math.min(end, size - 1);
|
|
33
|
+
}
|
|
34
|
+
return { start, end };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function writeResponseChunk(res, chunk) {
|
|
38
|
+
if (res.destroyed || res.writableEnded) return false;
|
|
39
|
+
if (res.write(chunk)) return true;
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
const cleanup = () => {
|
|
42
|
+
res.off?.('drain', onDrain);
|
|
43
|
+
res.off?.('close', onClose);
|
|
44
|
+
res.off?.('error', onError);
|
|
45
|
+
};
|
|
46
|
+
const onDrain = () => { cleanup(); resolve(); };
|
|
47
|
+
const onClose = () => { cleanup(); resolve(); };
|
|
48
|
+
const onError = error => { cleanup(); reject(error); };
|
|
49
|
+
res.once('drain', onDrain);
|
|
50
|
+
res.once('close', onClose);
|
|
51
|
+
res.once('error', onError);
|
|
52
|
+
});
|
|
53
|
+
return !res.destroyed && !res.writableEnded;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function streamWorkbenchVideo(req, res, video) {
|
|
57
|
+
const abortController = new AbortController();
|
|
58
|
+
const abort = () => abortController.abort();
|
|
59
|
+
req.once?.('aborted', abort);
|
|
60
|
+
res.once?.('close', abort);
|
|
61
|
+
try {
|
|
62
|
+
const requested = parseByteRange(req.headers?.range, video.size);
|
|
63
|
+
if (requested === false) {
|
|
64
|
+
res.setHeader('Content-Range', `bytes */${video.size}`);
|
|
65
|
+
return res.status(416).end();
|
|
66
|
+
}
|
|
67
|
+
if (video.size === 0 && requested) {
|
|
68
|
+
res.setHeader('Content-Range', 'bytes */0');
|
|
69
|
+
return res.status(416).end();
|
|
70
|
+
}
|
|
71
|
+
const range = requested || { start: 0, end: video.size - 1 };
|
|
72
|
+
const partial = requested !== null;
|
|
73
|
+
const isHead = req.method === 'HEAD';
|
|
74
|
+
const firstEnd = video.size === 0 ? -1 : Math.min(range.end, range.start + WORKBENCH_VIDEO_CHUNK_BYTES - 1);
|
|
75
|
+
const firstChunk = isHead || firstEnd < range.start
|
|
76
|
+
? null
|
|
77
|
+
: await video.read(range.start, firstEnd, abortController.signal);
|
|
78
|
+
res.status(partial ? 206 : 200);
|
|
79
|
+
res.setHeader('Content-Type', video.mimeType);
|
|
80
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
81
|
+
res.setHeader('Content-Length', String(video.size === 0 ? 0 : range.end - range.start + 1));
|
|
82
|
+
if (partial) res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${video.size}`);
|
|
83
|
+
const disposition = req.query.download === '1' ? 'attachment' : 'inline';
|
|
84
|
+
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(video.filename)}"`);
|
|
85
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
86
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
87
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
88
|
+
if (isHead || video.size === 0) return res.end();
|
|
89
|
+
if (!await writeResponseChunk(res, firstChunk)) return;
|
|
90
|
+
for (let start = firstEnd + 1; start <= range.end;) {
|
|
91
|
+
const end = Math.min(range.end, start + WORKBENCH_VIDEO_CHUNK_BYTES - 1);
|
|
92
|
+
const chunk = await video.read(start, end, abortController.signal);
|
|
93
|
+
if (!await writeResponseChunk(res, chunk)) return;
|
|
94
|
+
start = end + 1;
|
|
95
|
+
}
|
|
96
|
+
res.end();
|
|
97
|
+
} finally {
|
|
98
|
+
req.off?.('aborted', abort);
|
|
99
|
+
res.off?.('close', abort);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
16
103
|
export function extensionForMimeType(mimeType) {
|
|
17
104
|
const type = String(mimeType || '').toLowerCase();
|
|
18
105
|
if (type === 'image/png') return '.png';
|
|
@@ -82,10 +169,18 @@ export function registerUploadRoutes(app, { requireAuth }) {
|
|
|
82
169
|
app.get('/api/preview/:fileId', async (req, res) => {
|
|
83
170
|
let file;
|
|
84
171
|
try {
|
|
172
|
+
if (typeof req.query.token === 'string' && req.query.token.startsWith('wbv1.')) {
|
|
173
|
+
const video = openWorkbenchVideo(req.params.fileId, req.query.token);
|
|
174
|
+
return await streamWorkbenchVideo(req, res, video);
|
|
175
|
+
}
|
|
85
176
|
file = typeof req.query.token === 'string' && req.query.token.startsWith('wb1.')
|
|
86
177
|
? await readWorkbenchPreview(req.params.fileId, req.query.token)
|
|
87
178
|
: previewFiles.get(req.params.fileId);
|
|
88
179
|
} catch (error) {
|
|
180
|
+
if (res.headersSent) {
|
|
181
|
+
res.destroy(error);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
89
184
|
res.setHeader('Cache-Control', 'no-store');
|
|
90
185
|
return res.status(error.status || 502).send(error.status ? error.message : 'Preview read failed');
|
|
91
186
|
}
|
|
@@ -9,6 +9,9 @@ import { cachePreviewFile, MAX_PREVIEW_FILE_BYTES, prunePreviewFiles } from './p
|
|
|
9
9
|
|
|
10
10
|
const inflight = new Map();
|
|
11
11
|
const MAX_REFILLS = 16;
|
|
12
|
+
const MAX_VIDEO_READS = 32;
|
|
13
|
+
let activeVideoReads = 0;
|
|
14
|
+
export const WORKBENCH_VIDEO_CHUNK_BYTES = 1024 * 1024;
|
|
12
15
|
const key = () => createHash('sha256').update(`workbench-preview:${CONFIG.jwtSecret}`).digest();
|
|
13
16
|
const failure = (status, message) => Object.assign(new Error(message), { status });
|
|
14
17
|
|
|
@@ -28,10 +31,25 @@ export function createWorkbenchPreview(fileId, pending, filePath) {
|
|
|
28
31
|
return `wb1.${Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url')}`;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
function
|
|
34
|
+
export function createWorkbenchVideo(fileId, pending, metadata) {
|
|
35
|
+
if (!pending?.route || !metadata?.filePath || !Number.isSafeInteger(metadata.size)
|
|
36
|
+
|| metadata.size < 0 || !Number.isFinite(metadata.mtimeMs) || !metadata.mimeType?.startsWith('video/')) return null;
|
|
37
|
+
const source = {
|
|
38
|
+
fileId, filePath: metadata.filePath, size: metadata.size, mtimeMs: metadata.mtimeMs,
|
|
39
|
+
mimeType: metadata.mimeType, filename: metadata.filePath.split(/[\\/]/).pop(),
|
|
40
|
+
agentId: pending.agentId, userId: pending.userId,
|
|
41
|
+
route: pending.route, workspaceGeneration: pending.workspaceGeneration,
|
|
42
|
+
};
|
|
43
|
+
const iv = randomBytes(12);
|
|
44
|
+
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
|
45
|
+
const encrypted = Buffer.concat([cipher.update(JSON.stringify(source)), cipher.final()]);
|
|
46
|
+
return `wbv1.${Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function decodeSource(fileId, token, prefix = 'wb1.') {
|
|
32
50
|
try {
|
|
33
|
-
if (typeof token !== 'string' || !token.startsWith(
|
|
34
|
-
const bytes = Buffer.from(token.slice(
|
|
51
|
+
if (typeof token !== 'string' || !token.startsWith(prefix) || token.length > 24000) throw new Error();
|
|
52
|
+
const bytes = Buffer.from(token.slice(prefix.length), 'base64url');
|
|
35
53
|
const cipher = createDecipheriv('aes-256-gcm', key(), bytes.subarray(0, 12));
|
|
36
54
|
cipher.setAuthTag(bytes.subarray(12, 28));
|
|
37
55
|
const source = JSON.parse(Buffer.concat([cipher.update(bytes.subarray(28)), cipher.final()]).toString());
|
|
@@ -102,6 +120,79 @@ async function refill(source, token, { client, resolved }) {
|
|
|
102
120
|
}
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
async function requestVideoChunk(source, start, end, signal) {
|
|
124
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start
|
|
125
|
+
|| end >= source.size || (end - start + 1) > WORKBENCH_VIDEO_CHUNK_BYTES) {
|
|
126
|
+
throw failure(416, 'Invalid video byte range');
|
|
127
|
+
}
|
|
128
|
+
if (signal?.aborted) throw failure(499, 'Video request cancelled');
|
|
129
|
+
if (activeVideoReads >= MAX_VIDEO_READS) throw failure(503, 'Video stream capacity exceeded; retry shortly');
|
|
130
|
+
activeVideoReads += 1;
|
|
131
|
+
const clientId = `video:${randomUUID()}`;
|
|
132
|
+
const publicRequestId = randomUUID();
|
|
133
|
+
let requestId;
|
|
134
|
+
let onAbort;
|
|
135
|
+
try {
|
|
136
|
+
const { client, resolved } = authorize(source);
|
|
137
|
+
const result = await new Promise((resolve, reject) => {
|
|
138
|
+
onAbort = () => reject(failure(499, 'Video request cancelled'));
|
|
139
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
140
|
+
requestId = registerWorkbenchRequest({
|
|
141
|
+
agentId: source.agentId, clientId, publicRequestId, userId: source.userId,
|
|
142
|
+
allowLegacyCorrelation: false, route: source.route, routeKey: resolved.routeKey,
|
|
143
|
+
conversationId: resolved.conversationId, workspaceGeneration: source.workspaceGeneration,
|
|
144
|
+
role: client.role, requestType: 'video_chunk', expectedResponseTypes: ['video_chunk'],
|
|
145
|
+
onTimeout: (_pending, reason) => reject(failure(reason === 'timeout' ? 504 : 503, 'Video read timed out or disconnected')),
|
|
146
|
+
onResponse: msg => {
|
|
147
|
+
if (msg.error) return reject(failure(msg.errorCode === 'ENOENT' ? 404 : msg.errorCode === 'VIDEO_FILE_CHANGED' ? 409 : 502, msg.error));
|
|
148
|
+
const buffer = Buffer.from(msg.content || '', 'base64');
|
|
149
|
+
if (msg.filePath !== source.filePath || msg.start !== start || msg.end !== end
|
|
150
|
+
|| msg.size !== source.size || msg.mtimeMs !== source.mtimeMs
|
|
151
|
+
|| msg.mimeType !== source.mimeType || buffer.length !== end - start + 1) {
|
|
152
|
+
return reject(failure(502, 'Invalid video stream response'));
|
|
153
|
+
}
|
|
154
|
+
resolve(buffer);
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
if (!requestId) return reject(failure(503, 'Video stream unavailable'));
|
|
158
|
+
Promise.resolve(forwardToAgent(source.agentId, {
|
|
159
|
+
type: 'video_chunk', filePath: source.filePath, workDir: resolved.workDir,
|
|
160
|
+
conversationId: resolved.conversationId, workbenchRoute: source.route,
|
|
161
|
+
workbenchRouteKey: resolved.routeKey, workbenchWorkspaceGeneration: source.workspaceGeneration,
|
|
162
|
+
_workbenchRequestId: requestId, _requestUserId: source.userId,
|
|
163
|
+
_requestClientId: clientId, requestId: publicRequestId,
|
|
164
|
+
start, end, expectedSize: source.size, expectedMtimeMs: source.mtimeMs,
|
|
165
|
+
})).then(sent => {
|
|
166
|
+
if (sent === false) reject(failure(503, 'Agent is offline'));
|
|
167
|
+
}, () => reject(failure(503, 'Video read failed')));
|
|
168
|
+
});
|
|
169
|
+
authorize(source);
|
|
170
|
+
return result;
|
|
171
|
+
} finally {
|
|
172
|
+
signal?.removeEventListener('abort', onAbort);
|
|
173
|
+
if (requestId) deleteWorkbenchRequest({ agentId: source.agentId, requestId });
|
|
174
|
+
activeVideoReads -= 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function openWorkbenchVideo(fileId, token) {
|
|
179
|
+
const source = decodeSource(fileId, token, 'wbv1.');
|
|
180
|
+
if (!Number.isSafeInteger(source.size) || source.size < 0 || !Number.isFinite(source.mtimeMs)
|
|
181
|
+
|| !source.mimeType?.startsWith('video/')) throw failure(403, 'Forbidden');
|
|
182
|
+
const agent = agents.get(source.agentId);
|
|
183
|
+
if (!agent?.capabilities?.includes?.('workbench_video_stream')
|
|
184
|
+
|| !agentSupportsWorkbenchRequestCorrelation(agent)) {
|
|
185
|
+
throw failure(410, 'Video streaming is no longer available');
|
|
186
|
+
}
|
|
187
|
+
authorize(source);
|
|
188
|
+
return {
|
|
189
|
+
size: source.size,
|
|
190
|
+
mimeType: source.mimeType,
|
|
191
|
+
filename: source.filename || source.filePath.split(/[\\/]/).pop() || 'video',
|
|
192
|
+
read: (start, end, signal) => requestVideoChunk(source, start, end, signal),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
105
196
|
/** Resolve an HTTP capability independently of the browser's active Session or socket. */
|
|
106
197
|
export async function readWorkbenchPreview(fileId, token) {
|
|
107
198
|
const source = decodeSource(fileId, token);
|
|
@@ -454,7 +454,7 @@ async function handleAgentMessage(agentId, msg, ws) {
|
|
|
454
454
|
'proxy_ws_closed', 'proxy_ws_error', 'restart_agent_ack', 'upgrade_agent_ack',
|
|
455
455
|
'directory_listing', 'folders_list', 'models_list', 'yeaft_output', 'yeaft_session_output', 'session_output', 'yeaft_asset_put',
|
|
456
456
|
'yeaft_history_chunk', 'yeaft_history_outline', 'yeaft_history_search_result', 'yeaft_history_window', 'slash_commands_update', 'agent_metrics',
|
|
457
|
-
'file_content', 'file_content_chunk', 'file_saved', 'file_op_result', 'file_search_result',
|
|
457
|
+
'file_content', 'file_content_chunk', 'video_metadata', 'video_chunk', 'file_saved', 'file_op_result', 'file_search_result',
|
|
458
458
|
'git_status_result', 'git_diff_result', 'git_op_result',
|
|
459
459
|
'terminal_created', 'terminal_output', 'terminal_closed', 'terminal_error',
|
|
460
460
|
'agent_capabilities_updated', 'browser_runtime_status_result', 'browser_runtime_install_progress', 'browser_runtime_error',
|
|
@@ -232,7 +232,7 @@ export function handleWebConnection(ws, url, req = {}) {
|
|
|
232
232
|
const WORKBENCH_TYPES = new Set([
|
|
233
233
|
...CLIENT_BROWSER_TYPES,
|
|
234
234
|
'terminal_create', 'terminal_input', 'terminal_resize', 'terminal_close',
|
|
235
|
-
'read_file', 'resolve_file_references', 'write_file', 'create_file', 'delete_files', 'move_files', 'copy_files', 'upload_to_dir', 'file_search',
|
|
235
|
+
'read_file', 'video_metadata', 'resolve_file_references', 'write_file', 'create_file', 'delete_files', 'move_files', 'copy_files', 'upload_to_dir', 'file_search',
|
|
236
236
|
'git_status', 'git_diff', 'git_add', 'git_reset', 'git_restore', 'git_commit', 'git_push',
|
|
237
237
|
'proxy_update_ports', 'update_file_tabs', 'restore_file_tabs'
|
|
238
238
|
]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.524"}
|