@yeaft/webchat-agent 1.0.522 → 1.0.523

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.
@@ -59,7 +59,7 @@ function bufferMessage(msg, reason) {
59
59
 
60
60
  const FILE_CHUNK_SEND_TIMEOUT_MS = 10_000;
61
61
 
62
- function sendFileChunk(socket, data) {
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 === 'file_content_chunk' && queuedSocket !== socket) return 'dropped';
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 === 'file_content_chunk') return sendFileChunk(socket, data);
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
- ctx.outboundSendQueue.push({ msg, bytes, resolve, reject, socket: msg.type === 'file_content_chunk' ? ctx.ws : null });
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
- const projected = msg.type === 'file_content' && msg.binary && !pending.onResponse
290
- ? cacheBinaryPreview(msg, null, pending)
291
- : msg;
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 && msg.type === 'resolve_file_references',
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 decodeSource(fileId, token) {
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('wb1.') || token.length > 24000) throw new Error();
34
- const bytes = Buffer.from(token.slice(4), 'base64url');
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.522"}
1
+ {"version":"1.0.523"}